Everything Is Memory

Learning Tock from the ground up · Chapter 2

‹ Contents

Registers Are Not Variables

Chapter 1 got a number to a wire. That store looked like assigning to a variable, and almost none of it was. Here is where the difference shows: what a read gives back, what a compiler does to a loop that polls, and what the second processor does to your value.

What you'll be able to do at the end

  1. Meet an unfamiliar line in a datasheet and predict what reading that location gives back — or say honestly that nothing promises you anything.
  2. Say what an optimizing compiler does to a loop that reads a register, and name the one word that stops it.
  3. Explain why x |= 1 is a real bug on a chip with two processors, and what the hardware offers instead.

Plan on forty minutes. Chapter 1 is the prerequisite and the only one; there is still nothing to install and no hardware needed.

In the book: nothing on this either. read-modify-write appears on none of its pages, and outside two lines of development/code_size the word volatile there is only ever about whether storage survives a reset.

Where chapter 1 left off

One instruction, one address, and a light came on. Nothing about that store was difficult once the three questions were answered.

It also looked exactly like assigning to a variable, and that is the trouble. A variable holds what you put in it, hands it back when you ask, and stays where you left it while nobody else is writing. A register does none of those three reliably.

This chapter is those three, in the order you are most likely to meet them. The first starts with a line almost anyone would write without thinking twice.

Why bother with four registers?

Here is a fair objection. gpio_out is ordinary storage that holds the output state. So turning on pin 25 should just be:

gpio_out |= 1 << 25;   // "or bit 25 into whatever is already there"

That works. It is the obvious thing to write, it is what most code on most chips does, and it needs no extra registers at all. So why did the designers spend silicon on three more?

Because that line is not one operation. It is three.

  1. Read the current 32 bits out of gpio_out.
  2. Change the copy you now hold, turning on bit 25.
  3. Write all 32 bits back.

Between step 1 and step 3 there is a gap. In that gap, the value you are holding is a photograph of the past.

Two things on this chip can act inside that gap. One is an interrupt: hardware pausing your code mid-flow to run something else, then resuming you. The other is the second core. This chip has two complete processors running at the same time, and while each keeps its own scratch slots, they share the memory and every peripheral.

Do not take my word for the consequence. Run it.

Figure 1 Two cores, one register

Step the obvious way to the end, then try the hardware's way.

Both cores run gpio_out |= 1 << n. Each reads the register, changes its own copy, and stores all 32 bits back. Watch what the last store does.

Both cores store to gpio_out_set instead. The OR happens inside the hardware, so neither core ever holds a copy of anything.

Core 0 — wants pin 25 on idleholds 0x00000000pin 25 set in its copystored
Core 1 — wants pin 10 on idleholds 0x00000000pin 10 set in its copystored
The real gpio_out 0x00000000 no pins high
Core 0read gpio_out
Core 1read gpio_out
Core 0or 0x02000000
Core 0write gpio_out = 0x02000000
Core 1or 0x00000400
Core 1write gpio_out = 0x00000400
Core 0write gpio_out_set = 0x02000000
Core 1write gpio_out_set = 0x00000400

Press Step to advance one instruction at a time.

Pin 25 never turned on. Core 1's store carried a copy of the register taken before core 0 touched it, so it put pin 25 back to off on its way past. Nothing crashed and nothing reported an error. The light just does not come on, sometimes, under timing you cannot reproduce on demand.

Both pins are on. Two steps instead of six, no copies held anywhere, and no gap for anything to happen in. No lock, no disabled interrupts, no agreement between the cores — the hardware did the OR itself.

Notice that nothing here is exotic. Both cores are running correct, ordinary code. Then run the second tab and count the steps: the fix is not a smarter algorithm, it is a shorter one.

Nobody wrote a bug. Both cores ran reasonable code, and a pin that was asked to turn on stayed off.

The chip designers did think about two cores hitting one register at once, and the datasheet makes a firm promise:

If core 0 and core 1 both write to GPIO_OUT simultaneously (or to a SET/CLR/XOR alias), the result is as though the write from core 0 took place first, and the write from core 1 was then applied to that intermediate result.

Read that carefully, because it sounds like a rescue and is not one.

It is a promise about writes. Two simultaneous writes are cleanly ordered, with a defined result. But the failure you just watched was not caused by the write. It was caused by the read, several steps earlier, returning a value that went stale while it sat in a processor register.

Which is what gpio_out_set is for. Storing to it is a single write, and the OR happens inside the hardware where no gap exists. That property has a name: the operation is atomic, meaning nothing can observe or interrupt it halfway.

No lock. No disabled interrupts. No agreement between the cores. One store.

The trap: reading back is a separate decision

Everything so far has been about writing. The three questions are answered and the light is on. Now try the obvious next thing and read that address back.

Commit to a guess before you look. Guessing wrong here is the point — this is the one place where ordinary programming instincts actively mislead.

You stored 0x02000000 to 0xD0000018 and the light came on. You now read that same address. What comes back?

That is the answer for ordinary memory, and the instinct this chapter exists to correct. gpio_out_set never held your value in the first place.

Reasonable, and closer. But “it returns zero” would still be a promise, and no promise was made.

Right. The datasheet types this register WO — write-only. Reading it is simply outside what the hardware agreed to do, so the honest answer is that nothing is guaranteed. Worth noting: Tock's crate declares it ReadWrite, which is looser than the hardware contract. Nothing breaks today because the kernel only ever writes it.

gpio_out_set was never storage. Look at what its description says — not what it is called, but what it does:

Perform an atomic bit-set on GPIO_OUT, i.e. GPIO_OUT |= wdata

Three pieces of notation in that one line.

GPIO_OUT
the register's name, in the datasheet's capitals
gpio_out
the same register, in the Rust code's lower case
wdata
the data you wrote

So the line reads: OR the value you wrote into gpio_out.

That is an instruction to the hardware, written as an operation on a different register. The number you store is not kept anywhere. It is used as an argument and thrown away.

Which explains a detail that would otherwise be baffling: storing a value with no bits set does nothing whatsoever. No real storage location on earth silently discards a write like that.

All three of that group are written the same way — an operation on gpio_out, with your value as the argument.

gpio_out_set
GPIO_OUT |= wdata — turn these bits on
gpio_out_clr
GPIO_OUT &= ~wdata — turn these bits off
gpio_out_xor
GPIO_OUT ^= wdata — flip these bits

Meanwhile gpio_out itself, eight bytes earlier, is genuine storage, and the datasheet says so in a sentence that also warns you off the obvious misreading:

Set output level (1/0 → high/low) for GPIO0…31. Reading back gives the last value written, NOT the input value from the pins.
Figure 2 Four addresses, four different bargains

Click each of the four and compare the two panels. They are not the same shape.

Storing there does

Keeps your value. Nothing else happens.

Keeps your value, and moves every pin in the bank to match it.

Turns on the bits you set and leaves the rest alone. The hardware performs gpio_out |= value, then discards your value.

Nothing. It is not yours to set.

Reading it gives

Exactly what you stored.

The last value stored — not what the pins are actually doing.

Not promised. The datasheet types it write-only.

The voltage actually present on the pins, right now.

The only one of the four that behaves like a variable. Store it, come back an hour later, read the same thing.

Genuine storage that also acts. The datasheet is careful here: Reading back gives the last value written, NOT the input value from the pins. So a read tells you your own intent, not the world.

Its whole description is an operation on a different register: GPIO_OUT |= wdata. Store a value with no bits set and nothing happens at all — which no storage location would ever do.

Typed read-only. Read it twice with no code in between and you can get two different answers, because a button was pressed. Figure 4 is about what a compiler does to code that reads an address like this one.

Notice that only the first behaves the way a variable does. One address in four. The other three are ordinary programming instincts quietly failing.

So the useful conclusion is not a slogan about doorbells. It is this: what an address does when you write it, and what it does when you read it, are two independent decisions that a human being made for that one register. The address itself carries no hint. You cannot deduce it. You look it up.

The "addresses are house numbers on a street" picture, checked line by line. Press any verdict to run that row.
On a streetIn this chipHolds?
Each house has one numberEach location has one address
Numbers run in order down the streetAddresses run in order, one per byte
The number tells you nothing about the houseMostly true — except the first digit, which tells you the district
Whatever you put in a house stays theregpio_out_set keeps nothing at all
Looking inside a house changes nothingOn some peripherals a read is itself an action
What you find is what you leftgpio_in reports the outside world, which you did not put there
Every number on the street is a houseMost addresses are nothing, and touching them faults

run itstore 7 at 0x20000100, then read 0x20000100what comes back7. One address named one location, and it was the same location both times. Checked against the segment table, which puts SRAM at 0x2 — RP2350 datasheet §2.2, Table 8.

run itread the offsets of gpio_out, gpio_hi_out and gpio_out_setwhat comes back0x010, 0x014, 0x018 — four apart, because a register is four bytes and addresses count bytes. Checked against chips/rp2350/src/gpio.rs:88–108.

run itput 0x20000100 and 0xD0000018 side by sidewhat comes backThe first digit has already decided who answers: 2 is memory, D is SIO. The other seven tell you nothing until that one has been read, which is why this row is only mostly true. Checked against RP2350 datasheet §2.2, Table 8.

run itstore 0x02000000 to 0xD0000018, then read 0xD0000018what comes backNo promise of anything. The datasheet types that register write-only: your value was used as an argument to an OR and discarded, and nothing was kept for you to come back to. Checked against RP2350 datasheet §3.1 register tables.

run itread UART0's data register at 0x40070000what comes backA byte — and the read is what removes it. Arriving data waits in the port's receive queue, the datasheet says, until read out by the CPU, so the next read hands you the next byte rather than that one again. Looking did not observe the house; it took something out of it. Checked against RP2350 datasheet §12.1.2.5.

run itread gpio_in at 0xD0000004what comes backThe voltage on the pins at this instant, put there by the outside world. Nothing you wrote is in it, and the datasheet types the register read-only. Checked against RP2350 datasheet, Table 19.

run itstore 1 to 0x60000000what comes backA fault. Nothing is wired to that range, so no block claims the store, and the bus that carries it raises an error rather than quietly accepting it. Checked against RP2350 datasheet §2.2: Unmapped address ranges raise a bus error when accessed.

Four of seven rows fail. That is why the street picture gets you to the end of question 1 and then quietly starts lying.

That serial port register the table just used is worth looking at properly, because the shape matters more than the example. Its data register sits at offset 0x000, and here is its entire description of the low eight bits:

DATA: Receive (read) data character. Transmit (write) data character.

One address. Read it and you get a byte that arrived from outside the chip. Write it and a byte leaves the chip. Not one operation with two directions — two unrelated operations that happen to share a number.

Figure 3 Same layout, two different bases

Change the port, then change the register. Each control moves one half of the address and leaves the other exactly where it was — which is the whole reason one driver serves both.

port
register
  0x40070000 the port's base — the half the port picks + 0x000 the register's offset — the half the register picks = 0x40070000 uartdr

Change one of the two and watch which line moves. Only the base moved. The offset is a fact about the register, and both ports have the same registers in the same places, because they are one block of hardware built twice. Only the offset moved. The base is a fact about which port, and it does not care which register you asked for.

Why does a driver not have to be written twice?
The offsets. 0x000 is the data register on both, 0x030 the control register on both, because both are the same block of hardware built twice. Only the base differs. That is why a driver is written once, handed a base address, and then works for either — and it is why register listings are written as offsets rather than finished addresses.
This is the pattern behind every peripheral on the chip, and behind the way Tock's chip crates are organised. Learn the shape once and every new peripheral is just a new base and a new table of offsets.

The compiler does not know any of this

One thing still stands between your Rust and the silicon, and it is on your side normally.

A compiler turns your source into machine instructions, and a good one does not translate line by line. It works out what your code means, then emits the cheapest instructions with that meaning. The part doing that is the optimizer.

Suppose you want to wait until the serial port has room for another byte, so you read its flag register in a loop until the "transmit buffer full" bit clears. It is one ordinary-looking line, and what the chip runs is not what you wrote.

Not invented for the example. uartfr is a real register on this chip, TXFF is its bit 5, and Tock's own serial driver asks exactly this question at chips/rp2350/src/uart.rs:438–440.

The right-hand column of each pair adds one word, volatile, which tells the compiler this address is not a variable. Read the left column first, because that is the one you would have written.

Figure 4 What the optimizer does to your code

Pick a case. Compare the two columns line for line.

As you would write it
// wait for room in the transmit buffer
while (*uartfr & TXFF) != 0 {}
what the chip runs
movs  r0, #24
movt  r0, #16391      ; r0 = 0x40070018
ldrb  r0, [r0]        ; read once, out here
lsls  r0, r0, #26     ; bit 5 -> sign bit
it    pl
poppl {r7, pc}        ; clear? return
.LBB8_1:
b     .LBB8_1         ; set? spin for ever

The read happens once, before the loop. If the port was busy at that instant, this spins on that last line for ever and never looks again.

With volatile
// the same loop, with the promise
while (read_volatile(uartfr) & TXFF) != 0 {}
what the chip runs
movs  r0, #24
movt  r0, #16391
.LBB9_1:
ldr   r1, [r0]        ; read it again
lsls  r1, r1, #26
it    pl
poppl {r7, pc}        ; clear? return
...                   ; unrolled; each reads
bmi   .LBB9_1         ; still set? go round

The read is inside the loop, so the loop sees the port change and ends.

What the optimizer was thinking. Nothing in the program writes to that address, so the optimizer concluded the value could not change and lifted the read out. Every step of that reasoning is correct for a variable.

As you would write it
*gpio_out_xor = 1 << 25;
*gpio_out_xor = 1 << 25;
what the chip runs
movs  r0, #40
mov.w r1, #33554432   ; 0x02000000, bit 25
movt  r0, #53248      ; r0 = 0xD0000028
str   r1, [r0]        ; one store

Two flips became one. The pin ends up in the opposite state to the one you asked for.

With volatile
write_volatile(gpio_out_xor, 1 << 25);
write_volatile(gpio_out_xor, 1 << 25);
what the chip runs
movs  r0, #40
mov.w r1, #33554432
movt  r0, #53248
str   r1, [r0]        ; one
str   r1, [r0]        ; two

Both stores survive, so the pin flips twice and ends where it started.

What the optimizer was thinking. Storing the same value twice to a variable is the same as storing it once, so the first was dropped. On this register a store is a flip, and two flips are not one.

As you would write it
*gpio_out_set = 1;    // first
*gpio_out_xor = 2;    // second
what the chip runs
movs  r0, #24
movs  r1, #2
movt  r0, #53248      ; r0 = 0xD0000018
str   r1, [r0, #16]   ; the xor - your 2nd
movs  r1, #1
str   r1, [r0]        ; the set - your 1st

The two stores came out in the opposite order to the one you wrote.

With volatile
write_volatile(gpio_out_set, 1);
write_volatile(gpio_out_xor, 2);
what the chip runs
movs  r0, #24
movs  r1, #1
movt  r0, #53248
str   r1, [r0]        ; the set, first
movs  r1, #2
str   r1, [r0, #16]   ; the xor, second

Your order is kept.

What the optimizer was thinking. The two addresses are unrelated as far as the optimizer can tell, so it was free to pick an order. Hardware often is not free.

As you would write it
*gpio_out_set = 1 << 25;
what the chip runs
movs  r0, #24
mov.w r1, #33554432   ; 0x02000000, bit 25
movt  r0, #53248      ; r0 = 0xD0000018
str   r1, [r0]        ; the store

Nothing was removed. The compiler cannot prove that no one else is watching this address, so it keeps the store.

With volatile
write_volatile(gpio_out_set, 1 << 25);
what the chip runs
movs  r0, #24
mov.w r1, #33554432   ; 0x02000000, bit 25
movt  r0, #53248      ; r0 = 0xD0000018
str   r1, [r0]        ; the store

Identical, instruction for instruction.

What the optimizer was thinking. This is the case an earlier draft of this chapter got wrong. It said a store you never read back is dead code and gets deleted. It is not. Repeated stores, reordered stores and ignored reads go missing; a single store stays.

As you would write it
let _ = *uartfr;
what the chip runs
; nothing at all - the body is empty

The read vanished completely. Reading the serial port's data register is how a byte gets received at all, so a read the compiler deletes is a byte that never arrives.

With volatile
let _ = read_volatile(uartfr);
what the chip runs
movs  r0, #24
movt  r0, #16391      ; r0 = 0x40070018
ldr   r0, [r0]        ; the read happens

The read happens.

What the optimizer was thinking. A load whose result you ignore changes nothing about a variable, so it was deleted outright. The street-analogy table earlier said it: on some peripherals a read is itself an action.

Every instruction here is real output rather than a description of what a compiler might do. It came from rustc --target thumbv8m.main-none-eabi -O --emit asm on the nightly this tree pins, against the addresses this chapter has been using. Function entry and exit are cut from each listing, and the volatile loop is shortened: the jump into it, and the copies the compiler made of its body, are left out. Everything shown is otherwise exactly what came back, in the order it came back. The source is optimizer-demo.rs, beside this page. The Rust in each pair is the code that file compiles, with a short comment added here to say what to compare.

Every step of the optimizer's reasoning is valid for a variable. Applied to a register it produces a program that either returns immediately or hangs for ever, depending on what the flag happened to be the first time.

Which one you get is decided before the loop starts. The program fails with no warning, only at higher optimization levels, and usually not on the machine where you tested.

Not all four cases are the same, and the difference is worth holding onto. Repeated stores, reordered stores and ignored reads go missing; a single store you never read back stays. That last one is what the fourth case compiles, and it is the case an earlier draft of this chapter had backwards.

So the fix is not to outsmart the optimizer. It is to tell it the truth, and volatile is how you say it. Every access happens, as many times as written, in the order written. None may be invented, removed, or moved past another.

In Tock you do not have to remember this, because it is welded into the register types:

// impl<T, R> Writeable for ReadWrite<T, R>
fn set(&self, value: T) {
    unsafe { ::core::ptr::write_volatile(self.value.get(), value) }
}
tock-registers 0.10.0, src/registers.rs:63. This is the bottom of every write to a ReadWrite register. Three near-identical siblings at lines 119, 164 and 208 cover the WriteOnly, Aliased and InMemoryRegister types.

What that unsafe is doing there

If you have worked through the Rust Book but not reached its Advanced Features chapter, this is the thing to know. Here unsafe is not a confession that the code is risky.

It is a claim: I have checked the thing the compiler cannot check.

Following a raw pointer would be catastrophic if the address were wrong, and nothing in Rust's type system can verify that 0xD0000018 is a real register. So a human asserts it, inside a block small enough for a reviewer to check by eye.

That move — a tiny audited core wrapped in an API nobody can misuse — is the most characteristic thing in the whole Tock kernel. You will see it everywhere. Here it means every pin.set() in the codebase is safe in Rust's sense, and the trust for all of them sits in four three-line functions.

Receipts

Everything above is a claim about what the machine really does, so here is the machine.

A disassembler reads a compiled program and prints the instructions back out in readable form. Point one at a Tock kernel built from this repository, find the function that sets a pin, and this is what comes out.

You are meant to ignore most of it. Three lines matter, and by now you can read all three.

Figure 5 The three lines that do the work

Click a numbered line. The numbers are the order these three run in, which is not the order they are printed in.

; <RPGpioPin as kernel::hil::gpio::Output>::set push {r4, r6, r7, lr} add r7, sp, #0x8 mov r4, r0 bl <RPGpioPin::get_mode> ; check it was configured as an output uxtb r0, r0 cmp r0, #0x1 bls 0x1000b680 ; not an output? skip the store bl <OUTLINED_FUNCTION_8> ; works out r0 and r1, below pop {r4, r6, r7, pc}   ; <OUTLINED_FUNCTION_8> ldrd r0, r1, [r4, #8] ; r0 = SIO's base, r1 = the pin number movs r2, #0x1 bx lr
1and r1, r1, #0x1f — keeping the pin in range

A mask is an operation that keeps some bits of a number and forces the rest to zero. This one keeps the bottom five bits, which can only express 0 to 31.

Why mask at all? Because self.pin is an ordinary number, and nothing in its type stops it being 40. A 32-bit value has only 32 shift positions, so the compiler has to decide what an impossible shift means rather than leave it to chance.

Rust answers it twice. A kernel ships with overflow checks off, and that setting says the shift amount is masked to its bottom five bits — this and is that masking. Turn the checks on and the same source line panics instead. So the instruction is a build setting made visible, not a fact about the chip.

Which question does it answer? None of them. It is a safety net, and belongs to neither the address nor the value.

2lsl.w r1, r2, r1 — the shift

r2 holds 1 and r1 holds the pin number, so the result is 1 << pin. The whole of question 3, as one instruction.

Nothing here knows which pin it is. The number was put in that register by one of the lines this listing hides, and the shift treats 25 and 3 exactly alike.

Which question does it answer? Question 3 — which pin, of the thirty.

3str r1, [r0, #0x18] — the store

Look at the shape of the address. It is not one number. It is a base held in a processor register plus an offset built into the instruction: r0 holds 0xD0000000 and 0x18 is the offset of gpio_out_set.

That is why register blocks are written as a base plus a table of offsets, the way chapter 1's Figure 7 showed them. It is the shape the processor was built to address, so it costs nothing extra.

This is the instruction. Everything else on this page is arranging for it.

Which question does it answer? Questions 1 and 2, in the two halves of one addressing mode.

— they are function bookkeeping, plus the check that you configured the pin as an output. That check is not free, incidentally: get_mode reads two more peripheral registers to answer it. Everything in this chapter applies to those reads too.

Reproduce it yourself, if you have the toolchain:

  1. build boards/raspberry_pi_pico_2
  2. run llvm-objdump -d --demangle on the resulting binary
  3. search for Output>::set

Ask for demangling rather than guessing at the raw symbol name: how Rust encodes names into symbols depends on compiler settings this board does not pin down. Addresses shift between builds. If you have not set up a cross-compiler yet, skip this — nothing later depends on it.

Where that store sits in Tock

That one instruction is the bottom of a stack. Each layer above it exists for a reason, and none of them is how the pin turns on.

Now that the bottom rung means something, the rest is worth seeing.

Figure 6 Strip the layers off digitalWrite

Peel one layer at a time and read what each one is for.

1 of 5 shown
Arduino
digitalWrite(25, HIGH);
the friendly layer — hides everything below it
Tock's interface
pin.set();
kernel/src/hil/gpio.rs:154 — the same call on every chip Tock supports
Chip driver
self.sio_registers.gpio_out_set.set(1 << self.pin);
chips/rp2350/src/gpio.rs:1519 — the first layer that knows this is an RP2350
Register types
write_volatile(self.value.get(), value)
tock-registers 0.10.0, src/registers.rs:63 — where volatile is enforced for everyone
The machine
and r1, r1, #0x1f lsl.w r1, r2, r1 str r1, [r0, #0x18]
the real instructions from your own build — Figure 5
Notice that the layers add safety and portability, not capability. The bottom rung is the only one that changes anything physical; everything above it exists so that thousands of lines of driver code can share one audited way of doing it.

So here is the problem

Add up what is now true.

Peripherals are addresses. Storing numbers at them moves physical things in the world. And on a bare microcontroller, any instruction anywhere on the chip can store to any address at any moment.

There is nothing to ask. No permission is checked. A store to 0xD0000018 is exactly as allowed as a store to a variable of your own, and costs the same.

A pointer is a number a variable happens to be holding. Give it the wrong number and the same instruction stores somewhere else, with nothing else about the program changed.

Figure 7 The same store, sent somewhere else

Send it somewhere it was never meant to go. Watch instructions run and permission checked while you do.

let mut reading: u32 = 0;         // a sensor value, in SRAM
let p = &mut reading as *mut u32;  // p holds 0x20000100
unsafe { *p = 1; }                // one store
what moved your own variable, and nothing else
instructions run 1 permission checked none

Where the code meant to go. The number is kept, nothing else in the machine notices, and this is the only one of the five anybody intended.

gpio_out_set, the address this whole chapter has been building. A pin goes high. Whatever is wired to that pin moves — a light, a motor, the relay holding a door shut.

UART0's data register. A byte leaves the chip. Tock's console is on this port, so it arrives in the middle of a sentence the kernel was printing.

RESETS, the block that holds other blocks switched off. Most of the chip stays dead until its bit here is cleared, so a wrong value here can switch off hardware that was working.

Nobody owns this range, so the chip raises a fault. This is the one of the five that stops, and it is worth being exact about why: it stopped because the address is unowned, not because you lacked permission to use it.

Notice that instructions run and permission checked never move. Every one of these is one instruction, and not one of them asks permission. The machine has no opinion about which of the five you meant.

That is not a flaw in somebody's program. It is the default condition of the machine.

Every single thing Tock does is an answer to one sentence: any code can write any address.

Its drivers run without the privilege to touch hardware they were not given. Its applications run behind a hardware barrier the kernel programs before letting them start. Its memory for those applications is handed out in bounded pieces rather than shared.

Those three mechanisms have names — capsules, the memory protection unit, and grants — and each one gets its own chapter. You do not need them yet. You need the sentence above, because it is the question all three of them are answering.

Check yourself

Reading this again will not make it stick. Neither will highlighting it — that is one of the few study habits the research is actually rude about.

What does work is producing the answer before you look at one. Three questions, three choices each: commit to one and the reasoning opens underneath it. Getting one wrong is useful information, not a problem — it is the whole reason to answer before reading.

1. You store 0x00000000 to gpio_out_set. What happens?

Right. Not quite. Nothing at all. Its description is GPIO_OUT |= wdata, and OR-ing in a value with no bits set changes nothing. This is the clearest single sign that the address is not storage: no real storage location silently discards a write.

2. Both cores run gpio_out |= 1 << n with different n, and a pin stays off. Where exactly did it go wrong?

Right. Not quite. At the read, not the write. Each core read all 32 bits, changed its own copy, and wrote all 32 back — so the second write carried a value that predated the first. The datasheet's promise that simultaneous writes are cleanly ordered is true and does not help, because the stale data was already in hand.

3. Why can't the compiler be trusted with these addresses?

Right. Not quite. Because its reasoning is correct for variables and wrong here. It will collapse two stores into one, reorder independent stores, cache a value nothing appears to modify, and delete a read whose result you ignore. A lone store it keeps, as Figure 4 shows. On a peripheral, the store is the effect and the value can change with no code involved. volatile is how you say so.

Every word, collected

atomic
An operation nothing can observe or interrupt halfway. A single store to gpio_out_set is atomic; read-change-write is not.
compiler
The program that turns your source into machine instructions, deciding for itself which ones to emit.
core
One complete processor. This chip has two, running at the same time on the same memory.
disassembler
A tool that reads a compiled program and prints its instructions back in readable form.
interrupt
Hardware pausing your code to run something else, then resuming you.
mask
An operation keeping some bits of a number and forcing the rest to zero.
optimizer
The part of a compiler that rewrites your code into cheaper instructions with the same meaning — which is why volatile has to exist.
pointer
A number, held like any other, that is meant to be used as an address. Following it means storing to or reading from wherever it points.
volatile
A promise extracted from the compiler: perform every access, as written, in order, and invent none.

Everything above, checked against source

Every claim on this page comes from one of two documents: the Tock tree at commit 83bad9388, and the RP2350 and Pico 2 datasheets. Nothing here was written from memory.

  • A store to SIO costing no more than a store to a variable — RP2350 datasheet §2.2.6: SIO "contains registers which need single-cycle access from both cores concurrently, such as the GPIO registers. Access is always zero-wait-state", against §1.1 on the memory a variable would live in: "All SRAM supports single-cycle access"
  • gpio_out_set.set(1 << self.pin)chips/rp2350/src/gpio.rs:1519, "self.sio_registers.gpio_out_set.set(1 << self.pin)"
  • GPIO_OUT read-back wording, the |= / &= ~ / ^= semantics, the WO type of GPIO_OUT_SET, and how simultaneous writes from both cores resolve — RP2350 datasheet §3.1 and its register tables at offsets 0x0100x028
  • GPIO_IN typed RO — RP2350 datasheet, Table 19
  • UART0 base 0x40070000 and UART1 base 0x40078000, sharing one register layout — chips/rp2350/src/uart.rs:343–347 and 20–60
  • uartfr's TXFF bit, and Tock asking exactly the question in the volatile example — chips/rp2350/src/uart.rs:103–104 and 438–440, "If the FIFO is enabled, the TXFF bit is set when the transmit FIFO is full"
  • UARTDR bits 7:0, "Receive (read) data character. Transmit (write) data character.", typed RWF — RP2350 datasheet, Table 1029
  • A read of that register removing the byte rather than copying it, which is the street table's looking inside a house changes nothing row — RP2350 datasheet §12.1.2.5: received data is stored in the receive FIFO by the receive logic until read out by the CPU
  • Output::set as the portable interface every chip implements — kernel/src/hil/gpio.rs:151–154, "If the pin is not an output or input/output, this call is ignored"
  • write_volatile at the bottom of every register write — tock-registers 0.10.0, src/registers.rs:63
  • Figure 4's listings — built from learning/ch02-registers-are-not-variables/optimizer-demo.rs in this repository with rustc --target thumbv8m.main-none-eabi --crate-type lib -O --emit asm, on the nightly this tree pins in rust-toolchain.toml, against 0x40070018 (uartfr), 0xD0000018 (gpio_out_set) and 0xD0000028 (gpio_out_xor). A lone plain store compiles to the same instruction as a volatile one, which is why this chapter no longer claims a store you never read back is deleted
  • Figure 5's disassembly — llvm-objdump -d on raspberry_pi_pico_2.elf, built from this tree

The datasheets are linked from Raspberry Pi's silicon documentation page. They are licensed no-derivatives, so this page quotes them briefly with attribution rather than reproducing them.

Text, diagrams and interactive figures © Jon Hillesheim 2026, licensed CC BY-SA 4.0 — share and adapt freely with credit, under the same license. Tock source excerpts quoted above remain under their own Apache-2.0 OR MIT license and are not relicensed here, and so does optimizer-demo.rs, the file Figure 4 is built from — it is code, so you may reuse it without the ShareAlike condition.

Every line reference below links to that commit on the fork it was read from, at the lines it names.