Raspberry Pi Pico W Assembly: RP2040, Cortex-M0+, and Thumb
The Raspberry Pi Pico W is an approachable board for studying a real microcontroller at instruction level, provided the boundary is kept clear: an RP2040 runs the application, peripherals, and two Arm Cortex-M0+ cores; the board's CYW43439 is a separate wireless chip. This article develops small, owned-code assembly routines that are called by the Pico SDK. It is not a recipe for replacing boot firmware, extracting firmware, running untrusted images, or controlling wireless hardware through guessed registers.
Identify the board before selecting an architecture
Pico and Pico W are RP2040 boards. Pico W adds the CYW43439 wireless device and associated board support, but it does not replace the RP2040 or turn its CPU into a newer Cortex core. Pico 2 and Pico 2 W instead use RP2350. They may look similar and expose a familiar header, yet their CPU choices, SDK target, memory map, and generated code assumptions differ. An object assembled for cortex-m0plus is a sensible RP2040 artifact; it is not proof that it is the right image for a Pico 2 W project.
| Board | Main MCU | Assembly starting point | Wireless hardware |
|---|---|---|---|
| Pico | RP2040 | Dual Cortex-M0+, Armv6-M Thumb | None on board |
| Pico W | RP2040 | Dual Cortex-M0+, Armv6-M Thumb | CYW43439, separate device |
| Pico 2 | RP2350 | Choose the documented RP2350 target | None on board |
| Pico 2 W | RP2350 | Choose the documented RP2350 target | CYW43439, separate device |
RP2040 contains two Cortex-M0+ processors, shared SRAM, bus fabric, DMA, PIO, timers, UART/SPI/I2C, GPIO and other peripherals. “Dual core” does not mean that an arbitrary register sequence is automatically safe on both cores. It means two independent execution contexts can contend for memory, FIFO resources, peripherals, and ownership decisions. Begin with one SDK-managed core and one deterministic function. Add the second only with a documented protocol, synchronization, and a clearly assigned peripheral owner.
What “Thumb” means on this processor
A Cortex-M0+ implements the Armv6-M architecture and executes Thumb instructions only. It has many 16-bit Thumb instructions and a constrained set of supported 32-bit encodings, including bl; source commonly uses .thumb and .cpu cortex-m0plus. This is much smaller than the “Thumb-2” feature set people may have seen on Cortex-M3/M4/M7 or Cortex-A documentation. Do not assume that Thumb-2 wide forms are available: examples include movw/movt, hardware divide, bit-field instructions, general IT-block use, exclusive load/store atomics, or the rich conditional forms available on larger cores. The assembler should reject unsupported instructions when the CPU is specified; that rejection is helpful.
Prefer a compiler-generated reference for a known C expression, then read the Arm architecture manual and RP2040 datasheet rather than copying a mnemonic from an unrelated core. Division may require a helper supplied by the compiler runtime. Floating point is software work unless a library performs it; RP2040 has no M0+ FPU. The PIO blocks are valuable programmable I/O engines, but PIO programs are their own instruction set, not Cortex-M Thumb assembly.
Registers, flags, bytes, and addresses
| State | Practical meaning on a C call |
|---|---|
r0-r3 | Argument and result registers; volatile scratch values. |
r4-r7 | Callee-saved: restore their incoming values if the function changes them. |
r8-r12 | Additional general registers; higher-register Thumb encodings can be restricted. r8-r11 are callee-saved under AAPCS, r12 is scratch. |
r13 / SP | Stack pointer. Keep its required alignment at public call boundaries. |
r14 / LR | Link register, containing a return address after a call. |
r15 / PC | Program counter. Branches change it; treating it as ordinary storage is a mistake. |
| APSR | Condition flags N, Z, C, V used by arithmetic and conditional branches. |
The RP2040 is little-endian: a 32-bit value's least significant byte occupies the lowest address. A byte load is not interchangeable with a word load merely because both use the same address. Use the access width specified for a peripheral register. Naturally align normal halfwords to two-byte and words to four-byte boundaries. Misalignment assumptions make code nonportable and can fault or behave differently from the experiment imagined.
Thumb source often reaches constants through a literal pool. For example, ldr r1, =0xd0000018 is assembler shorthand which may place the constant nearby and emit a PC-relative load. It is convenient, but code size and placement matter because the short encoding has a limited reach. The assembler's .ltorg can deliberately emit a pool in a long routine. Never let literal data fall through as though it were instructions: branch around it or place it after a return. Inspect the disassembly of your own object when learning this relationship.
AAPCS: the contract with C
The Arm Architecture Procedure Call Standard (AAPCS) is the agreement that makes an assembly function callable from C. In the usual base procedure-call variant, the first four word-sized arguments arrive in r0-r3, further arguments are on the stack, and an integer result returns in r0. A called function may overwrite r0-r3 and r12 and flags. It must preserve r4-r11 and SP. At a public interface, SP must be aligned to 8 bytes. This matters even for a leaf function: future edits may add a C call, a 64-bit value, or debugger/unwinder expectations.
bl symbol branches to a callable symbol and writes the return state into LR. bx lr returns using LR and respects the low-bit Thumb-state convention. Cortex-M is Thumb-only, but use the conventional form rather than inventing a PC write. A non-leaf routine must protect LR before making another call, commonly by pushing an even number of registers so stack alignment remains valid. A minimal leaf routine that neither changes callee-saved registers nor calls out needs no stack frame.
/* add_bias.S — GNU assembler source, built only for an owned Pico project */
.syntax unified
.cpu cortex-m0plus
.thumb
.text
.align 2
.global add_bias
.type add_bias, %function
.thumb_func
add_bias:
adds r0, r0, #7 /* r0 argument becomes the r0 result */
bx lr /* leaf return; LR was not disturbed */
.size add_bias, . - add_bias
The matching declaration is int32_t add_bias(int32_t value);. The immediate form is intentionally simple and relies on no peripheral state. A host-side unit test of the equivalent arithmetic can validate the intended result, while target debugging validates the ABI and build integration. Do not declare a function with one type and implement a different width, signedness, or argument count in assembly; ABI errors can look like random hardware failures.
Let the SDK own reset and startup
After reset, Cortex-M uses a vector table. Its first word supplies the initial stack pointer; its second word supplies the reset handler address. The table also contains exception entries such as NMI, HardFault, SysTick, and external IRQ handlers. Startup code establishes essential runtime state before calling the application's C entry point: it initializes writable .data from its flash image, zeros .bss, sets up vector-table and runtime conventions, and ultimately reaches your program. The exact sequence is supplied by the selected SDK, board, and linker setup.
A linker script maps sections and symbols onto the RP2040's flash and SRAM regions and describes where initialized data is loaded and where it runs. It is not a casual list of addresses. It cooperates with boot-stage code, vector placement, library sections, stack/heap bounds, and the selected image format. For a first assembly study, do not replace the SDK reset path or write a homemade vector table. Provide a normal function from your own source and allow the Pico SDK and its CMake helpers to produce the image. This sharply reduces the chance of a board that cannot boot your test artifact.
Exceptions are asynchronous control transfers, not ordinary function calls. Hardware stacks an exception frame, and handler requirements include acknowledging the documented source, protecting shared data, and returning correctly. Keep handlers short; arrange a C or SDK-level event mechanism for substantial work. A shared variable changed in an interrupt needs an appropriate volatile/synchronization design, not merely an assembly-looking load/store. Enabling or prioritizing an interrupt requires the exact SDK and peripheral documentation for the selected source.
Build boundaries: CMake, SDK, compiler, assembler
The Pico SDK supplies headers, board configuration, startup components, libraries, linker integration, and CMake functions. The GNU Arm Embedded toolchain supplies programs such as arm-none-eabi-gcc, its assembler front end, linker, and binary utilities. CMake configures the dependency graph and chooses the toolchain through the SDK's documented import/setup flow. These layers are complementary: the SDK does not make every Arm instruction legal, and the toolchain does not know a board's wiring without the SDK configuration and your code.
An assembly file with an uppercase .S suffix is normally passed through the C preprocessor before assembly; lowercase .s normally is not. Either can be appropriate. Use .S only when you deliberately need C preprocessor definitions supplied by the build; otherwise a plain source file makes dependencies visible. Pin the CPU explicitly in the source or compiler options, e.g. -mcpu=cortex-m0plus -mthumb, so a host/default target cannot silently accept instructions that RP2040 cannot execute.
# CMakeLists.txt fragment after the standard Pico SDK setup
add_executable(pico_asm_study
main.c
add_bias.S
sio_set_mask.S
)
target_link_libraries(pico_asm_study pico_stdlib hardware_gpio)
pico_add_extra_outputs(pico_asm_study)
This deliberately contains no custom startup or raw flash procedure. Use the SDK's documented USB/UF2 workflow for an image built from source you own. Keep generated build directories separate from source, and inspect arm-none-eabi-objdump -d and arm-none-eabi-readelf -S output as learning evidence. A successful host assembler invocation alone is insufficient: check that the selected target is arm-none-eabi and the core is Cortex-M0+.
A carefully bounded SIO GPIO example
The following routine demonstrates a single documented atomic write, not a complete GPIO driver. On RP2040, SIO base is 0xd0000000; GPIO_OUT_SET is at offset 0x14, therefore 0xd0000014. Writing one bits to that alias sets corresponding output-latch bits atomically; writing zero bits has no effect. The matching GPIO_OUT_XOR is 0xd000001c, and GPIO_OUT_CLR is 0xd0000018. These addresses and semantics are RP2040 datasheet-qualified, not values to extrapolate to RP2350, a radio chip, or an arbitrary peripheral.
/* sio_set_mask.S: void sio_set_mask(uint32_t mask);
Precondition: each selected GPIO was already configured as an SIO output
by trusted SDK code, and the caller owns those output bits. */
.syntax unified
.cpu cortex-m0plus
.thumb
.text
.align 2
.global sio_set_mask
.type sio_set_mask, %function
.thumb_func
sio_set_mask:
ldr r1, =0xd0000014 /* RP2040 SIO GPIO_OUT_SET, exact documented address */
str r0, [r1] /* atomic SET alias: 1 bits set; no read-modify-write */
bx lr
.size sio_set_mask, . - sio_set_mask
For a deliberately user-wired demonstration, connect an external LED anode to GP15 through a suitable current-limiting resistor (for example, 330 Ω to 1 kΩ) and connect its cathode to GND. Use only the board's 3.3 V logic domain; never connect the GPIO to 5 V, and adjust the pin, resistor, polarity, and circuit for your own LED and wiring. A safe C caller establishes this external pin's pad/function/direction through SDK APIs first:
#include "pico/stdlib.h"
#include "hardware/gpio.h"
extern void sio_set_mask(uint32_t mask);
int main(void) {
const uint external_led_pin = 15; /* GP15: chosen external, user-wired LED */
gpio_init(external_led_pin);
gpio_set_dir(external_led_pin, GPIO_OUT);
sio_set_mask(1u << external_led_pin); /* only this configured GPIO */
for (;;) tight_loop_contents();
}
This example is intentionally one-directional. It uses an external LED, not a board-default LED, does not configure a GPIO through guessed IO_BANK0 registers, and must not be used for pins owned by another subsystem. On Pico W the onboard LED is CYW43 GPIO 0; control it with the supported CYW43 SDK API, not raw RP2040 SIO. A mask shift is valid only for a documented pin range; validate a runtime-supplied pin before shifting. Atomic aliases prevent a read-modify-write race on the output latch, but they do not solve policy races: two cores writing contradictory set/clear operations still need a locking or ownership protocol. Consult the current datasheet before using any address, including aliases, because “nearby” is not a specification.
Multicore and wireless boundaries
RP2040's cores can coordinate through SDK multicore facilities, FIFO, spin locks, memory barriers, and carefully designed shared data. A single writer per peripheral is often easier to prove correct than a shared driver. Do not start core 1 in assembly while core 0, the SDK, or a library has an incompatible startup expectation. Do not hold a spin lock across slow I/O or an interrupt-dependent operation. Debug one core first, state who owns a GPIO/PIO/DMA channel, and establish release/acquire ordering where data is handed over.
On Pico W, CYW43439 is connected as a separate wireless component. The Wi-Fi/Bluetooth stack, firmware blob, bus setup, and synchronization are managed by the supported Pico W software stack, not generic SIO GPIO assembly. Toggling a random GPIO cannot configure Wi-Fi, and an RP2040 assembly routine must not attempt firmware extraction, undocumented transport control, or replacement firmware loading. Obtain wireless firmware and SDK components only from Raspberry Pi's official SDK/release channels or other authorized upstream distributions, retain notices and licences, and use their documented update mechanisms.
Debug only code you built and understand
SWD exposes a hardware debug interface; a compatible probe plus OpenOCD can provide a GDB remote target. A typical careful workflow is: build a source-controlled test with debug information; connect to the authorized board according to the Pico/OpenOCD documentation; attach GDB; set a breakpoint in add_bias; inspect r0 and the disassembly; then disconnect. Compare the source, symbol table, and instruction bytes you just produced. Never attach a debugger to equipment you do not administer, disable protections, or load an unfamiliar binary “for analysis.”
Useful questions are modest and concrete: Did r0 enter with 5 and leave with 12? Did bl reach the expected Thumb function? Is SP still 8-byte aligned at a C call? Did the final image contain the literal constant where expected? These checks reveal more than a long register dump. If a fault occurs, preserve the build command, map file, PC, stacked registers, and exact board/SDK revision before changing code.
Safe exercises
- Assemble
add_bias.Sas part of an SDK project and use disassembly to identifyaddsandbx lr. Explain which register carries the result. - Change the bias to a representable small immediate, update a C assertion in an owned test, and compare the two disassemblies. Do not change the CPU option.
- Write a leaf function that returns
a & bfor twouint32_targuments. Document r0/r1 inputs and r0 output, then check its ABI declaration. - Find the SIO GPIO OUT SET/CLR/XOR definitions in the current RP2040 datasheet and SDK headers. Explain why a SET alias is safer than a read-modify-write sequence when two writers are possible, while also explaining why ownership remains necessary.
- Draw a reset-to-main path using vector table, reset handler, section initialization, and C entry. Keep it conceptual; do not replace SDK startup.
Continue reading
Start with the site’s Assembly Language, Machine Code, and the CPU and Instructions, Flags, Branches, Calls, and Returns foundations. For board context and careful wiring, see Raspberry Pi Pico with Waveshare LoRa HAT and ARM Cortex Cores in This Project.
Primary references
- Raspberry Pi RP2040 Datasheet — memory map, SIO registers, boot and peripherals.
- Raspberry Pi Pico SDK and Getting Started with Raspberry Pi Pico.
- Official Pico examples and Pico-series documentation.
- Arm ABI releases: AAPCS and Armv6-M Architecture Reference Manual.
- GNU Arm Embedded Toolchain, GNU assembler manual, and OpenOCD documentation.
dispelled