树莓派Pico(RP2040)SRAM与闪存编程

以8位单片机开发者的视角对Pico(RP2040)编程,着重关注对SRAM和闪存编程。

--by Captdam @ Feb 14, 2026 Aug 27, 2025

Index

我和8051与AVR这样的8-bit的单片机打交道已经有一段时间了,但是未尝试过使用32-bit单片机,比如树莓派Pico上面的RP2040.在这一篇博客中,我将以8位单片机开发者的视角来记录我的第一次Pico编程经历。我将着重于对片上运行内存(run-time memory)和片外闪存(off-chip flash)的编程。

我将会编写汇编与C语言应用,直接对单片机寄存器进行读写。这有助于我们探索单片机的底层控制原理。


这篇文章的针对读者为32位RP2040与ARM Cortex-M0+新手,但是熟悉使用汇编与C语言开发8位单片裸金属应用的机开发者。

因为我们将开发裸金属应用,我们将会直接读写单片机的控制寄存器,并不使用任何库。

我们将大量依靠文档。文档中包括了我们所需要知道的所有单片机控制寄存器的信息。

因为RP2040文档更新的缘故,而且他们还决定把老文档的链接直接重定向到新文档,我决定在本地保存当前版本的副本(2025-02-20)。你可以通过原始链接(取样于2026-02-10)获取该文档。

RP2040文档版权页
RP2040文档版权页

8-bit与32-bit单片机的不同

除了信号宽度以外,它们的内存结构也不相同。

哈佛架构8位机

哈佛架构
ATtiny24使用哈佛架构

8051与AVR这样的8-bit的单片机使用哈佛架构。指令(程序)总线与程序内存相连,数据总线与数据内存相连,互不交叉。

程序被保存在片上程序内存中,大多数情况下使用闪存结构。当我们下载二进制数据到单片机,我们实际上就是在烧录程序到片上闪存(flash)。当单片机上电启动,CPU执行单元直接读取片上闪存。

除了闪存外,我们还需要还有数据内存。这可以是片上SRAM(静态随机内存),也可以是外部的内存芯片,或是两者都有。该内存只用于保存数据,CPU无法从其执行。

馮·諾伊曼32位机

馮·諾伊曼架构
RP2040使用馮·諾伊曼架构 ©RP2040

像是RP2040这样的32位机使用馮·諾伊曼架构,指令总线与程序数据总线都连接到同一块内存上。这里只有一个通用的内存接口,对于ARM架构来说,这就是AHB-Lite Crossbar。

更详细地说应该叫做:一个内存空间。从CPU的角度来讲,这里只有一个内存空间。空间中的区块可以是特定任务的,一些区块可以读写,一些区块只读;一些区块只能存放数据,一些区块只能存放程序代码,一些区块既能存放数据也能存放程序代码;还有的区块被映射到单片机控制寄存器。内存总线将不同的物理内存映射到同一个内存空间。

CPU与主内存相连,但是不和可以断电保存程序的闪存连接。要执行一个程序,先需要引导程序(bootloader)将程序载入内存中的可执行区块,然后CPU再从该内存区块执行。

RP2040支持直接执行闪存(XIP)。逻辑上看起来CPU可以直接执行片外闪存(或任何连接在QSPI接口上的设备)。但实际上,XIP的硬件会将程序缓存在片上内存。所以,CPU实际上还是在从片上内存执行。

HelloWorld.asm

我们来写一个简单的驱动GPIO25上的LED的小程序。

这篇例子使用的工程文档可以在这里找到

汇编程序

下面是main.s中的程序汇编源码:

架构

RP2040架构
RP2040架构 ©RP2040

.cpu cortex-m0plus
.thumb
.align 2
.thumb_func
			

在一开头,我们告诉汇编器我们将使用Cortex M0+ CPU(也就是RP2040的CPU)。

该CPU只支持Thumb指令集,它的指令长2字节。因此,我们让汇编器使用Thumb指令集,并且将指令按照2字节对齐。

进入点

RP2040 SRAM地址
RP2040 SRAM地址 ©RP2040

.global reset
reset:
	ldr	r0, =0x20041000		@ Stack Pointer @ SRAM bank 4
	mov	sp, r0
			

我们的程序进入点为reset。我们还需要将标签reset设置为全局(global)。只有这样,reset程序才能在链接(link)时被识别。

我们将使用SRAM板块4(地址0x20004000 - 0x200040FFF,4KiB大小)作为栈。ARM Cortex-M0+ CPU的栈指针(Stack Pointer,简称SP)指向栈内最后入栈的项目且向下生长,因此,我们设置SP初始位置为区块顶部+1,即0x20041000。将此值写入栈指针SP。

启用输出

RP2040 RESETS_RESET
Reset control. If a bit is set it means the peripheral is in reset. 0 means the peripheral's reset is deasserted. @ 0x4000C000 ©RP2040

	ldr	r3, =0x4000f000		@RESETS_BASE + RESET  + 0x3000
	mov	r2, #32			@ 1 << 5 (IO_BANK0)
	str	r2, [r3, #0]
			

上电后,用户IO(O区块0,GPIO 0 - 29)仍在重置状态,我们将需要解除该状态才能使用。为此,我们将需要对清空RESET寄存器(地址0x4000C000)的第5个比特(IO_BANK0)。

在这里,我们可以添加0x3000的地址偏移来原子性地写1清零比特。综上,我们对地址0x4000F000写(1<<5)。

RP2040 原子访问寄存器
原子访问寄存器 ©RP2040

IO模式

RP2040 IO_BANK0_GPIO25_CTRL
GPIO 25 control including function select and overrides. @ 0x400140CC ©RP2040
RP2040 SIO_GPIO_OE
GPIO output enable @ 0x400140CC ©RP2040

	ldr	r3, =0x400140cc		@ IO_BANK0_BASE + GPIO25_CTRL
	mov	r2, #5			@ Function 5 (SIO)
	str	r2, [r3, #0]

	ldr	r3, =0xd0000020		@ SIO_BASE + GPIO_OE
	ldr	r2, =(1<<25)
	str	r2, [r3, #0]
			

为了设置GPIO25的功能,我们需要写入功能到地址0x400140CC(GPIO25_CTRL)。要将其作为普通IO,写入5(SIO)。

RP2040 Function Select
RP2040 GPIO Function Select ©RP2040

要启用GPIO25作为输出,向寄存器地址0xD0000020的第25比特(GPIO_EN)写1。

Blink

GPIO output value XOR
GPIO output value XOR @ 0xd000001c ©RP2040

blink:
	ldr	r3, =0xd000001c		@ SIO_BASE + GPIO_XOR
	ldr	r2, =(1<<25)	@ GPIO25
	str	r2, [r3, #0]
	
	ldr	r0, =650000
	bl	delay
	b	blink

delay:
	mov	r4,r0
loop:
	sub	r4,r4,#1
	cmp	r4, #0
	bne	loop
	bx	lr

.align 4
			

程序blink用于闪烁LED。要闪烁GPIO25上面的这个LED,我们可以异或其值。可以对地址0xD000001C的寄存器的第25比特(GPIO_OUT_XOR)写1来原子性地异或。

程序delay用于在每次异或操作之间使用循环loop来制造忙延时。

这些程序和标签只在这个文件中使用,不需要在链接时被看到,所以不需要全局化。

对于32-bit的CPU,恢复对齐为4字节是个好主意。虽然这一点对只使用2字节的Thumb指令的Cortex-M0+无用,但是对别的ARM也许时必要的。让我们保守地加上这点。

使用指令arm-none-eabi-as --warn --fatal-warnings -g main.s -o main.o来汇编这段代码,生成文件main.o

链接脚本

汇编后,我们需要针对目标平台进行链接。汇编只能生成可执行的代码,链接才能让代码能被加载。

下面是链接脚本文件main.ld


MEMORY {
	SRAM(rwx) : ORIGIN = 0x20000000, LENGTH = 264k
}

ENTRY(reset)

SECTIONS {
	. = ORIGIN(SRAM);
	.text : {
		*(.text)
	} >SRAM
}
	

在链接脚本中,我们告诉链接器:

  1. 有一块可读写的内存叫做SRAM,起始于地址0x20000000,长度264KiB。
  2. 进入点为程序reset。从这里开始执行。
  3. 将文本(程序)放入SRAM中,从SRAM的起点开始。

使用指令arm-none-eabi-ld -nostdlib -nostartfiles -T main.ld main.o -o main.elf来链接这段代码,生成文件main.elf

UF2文件

现在,我们的程序就就绪了。但是要将其下载入Pico中,我们还要将其打包为UF2文件,才能通过USB下载。

RP2040提供了一个非常好用的下载接口。与传统单片机相比,我们不再需要通过物理上的编程器(或是其它现在电脑上不再用的接口,如UART),RP2040支持USB下载。上电后,RP2040会声明自己为USB大容量储存设备。这允许开发者像是拷贝文件到U盘一样的方式来下载程序。

使用pico-elf2uf2 main.elf main.uf2来生成UF2文件——main.uf2

你可以下载SDK源代码,但是你需要自己在电脑上编译它。

在(第一版的SDK)pico-sdk/elf2uf2下你可以找到elf2uf2,详情见GitHub上/tools/elf2uf2的SDK1.5.1的源码

我把elf2uf2这个程序复制到了我Linux环境下的/bin中以便直接使用。

我们可以以Hex模式打开uf2文件来看看它的葫芦里卖的什么药,使用od -t x4 main.uf2


Addr    X0       X4       X8       XC	
0000000 0a324655 9e5d5157 00002000 20000000
0000020 00000100 00000000 00000001 e48bff56
0000040 4685480b 22204b0b 4b0b601a 601a2205
0000060 4a0b4b0a 4b0b601a 601a4a09 f000480a
0000100 e7f8f801 3c011c04 d1fc2c00 46c04770
0000120 20041000 4000f000 400140cc d0000020
0000140 02000000 d000001c 0009eb10 00000000
0000160 00000000 00000000 00000000 00000000
*
0000760 00000000 00000000 00000000 0ab16f30
0001000
	

参考UF2格式,我们就能看明白这个文件了:

UF2文件内容
便宜,长度 名称 内容 注释
0,8 魔术字:0x0A324655, 0x9E5D5157 0a324655 9e5d5157
8,4 Flag 00002000 包含家族ID - 当设置时,“文件大小或家族ID或0”一项的值为家族ID(通常指单片机型号)。
12,4 闪存中(对于RP2040这里指代内存)本段数据写入的地址 20000000 同链接脚本中ORIGIN(SRAM)SECTIONS的开头
16,4 数据长度(通常为256) 00000100 256字节长
20,4 区块顺序 00000000 0,第一个区块
24,4 文件共几个区块 00000001 只有1个区块
28,4 文件大小或家族ID或0 e48bff56 RP2040的家族ID,参考该表
32,476 数据,填充0 4685480b... 程序本身,详见arm-none-eabi-objdump -d main.elf
508,4 结尾魔术字:0x0AB16F30 0ab16f30

下载到Pico

Pico上电后,CPU会首先执行片上引导程序(亦称一级引导)。该引导程序位于片上ROM且不可修改。这个引导程序将允许Pico作为USB大容量储存设备(U盘)的方式连接电脑以便编程。

RP2040只有在BOOTSEL按键按下(连接片外闪存芯片的CSn引脚,RP2040引脚56)或闪存中没有合法程序时才会进入USB大容量储存设备模式。

通过复制uf2程序文件到USB大容量储存设备的方式即可对芯片编程。

当收到uf2文件后,引导程序就会将该文件的内容写道特定的位置。

Pico上的LED
Pico上的LED

W我们可以看到Pico上LED闪烁。

对比下载到SRAM与Flash

断电后重新上电,程序消失了,Pico重新变成了大容量储存设备。我程序呢?

下载到SRAM

在链接脚本中,我们写到:


MEMORY {
	SRAM(rwx) : ORIGIN = 0x20000000, LENGTH = 264k
}
	

这使uf2文件中使用0x20000000作为目标地址。于是,引导程序将uf2文件内容(也就是我们的程序)写入地址0x20000000。对于RP2040, this address is mapped to SRAM.

SRAM是易失性的。当断电后,SRAM中的内容就没了。(实际上,就算内容还在也不会执行,引导程序已经卡在等待接收uf2的那一步。)

实际上,就算内容还在SRAM中,CPU也会卡在片上引导程序那一步。要么闪存不合法等待新的uf2文件,或是闪存合法从闪存中重载程序。

下载到闪存

根据文档,XIP的地址起始于0x10000000。该地址映射为外部2MiB板载闪存芯片。

RP2040的闪存地址空间最大支持16MiB(0x01000000)。闪存被镜像4次,使用不同的缓存策略,覆盖0x10000000到0x13FFFFFF。0x10000000 - 0x10FFFFFF为最基础的“cacheable, allocating - Normal cache operation”。对于片上引导来说,如果向0x10000000 - 0x10FFFFFF写入数据就会触发向物理闪存写入,0x11000000 - 0x13FFFFFF不可写。

要下载到XIP(闪存),我们需要链接程序到对应地址,才能让片上引导程序把我们的程序复制入物理闪存。

让我们修改链接脚本来写到0x10000000,如下:


MEMORY {
	Flash(rx) : ORIGIN = 0x10000000, LENGTH = 2048k
}

ENTRY(reset)

SECTIONS {
	. = ORIGIN(Flash);
	.text : {
		*(.text)
	} >Flash
}
	

不需要修改汇编源代码文件。重新链接、打包uf2文件,使用arm-none-eabi-ld -nostdlib -nostartfiles -T main.ld main.o -o main.elfpico-elf2uf2 main.elf main.uf2

如果我们检查新生成的uf2文件的内容,可以看到偏移12处(闪存中本段数据写入的地址)变成了0x10000000。这告诉片上引导程序将程序写到映射到板载闪存的0x10000000。

将uf2程序文件拷入大容量储存设备以对芯片编程。结果哦豁,失败了。LED没有闪,芯片卡在了片上引导程序中。过了一会当uf2接收完成后,芯片重新变回了大容量储存设备。

如果现在闪存中有合法的程序,也会被覆盖。

校验和

RP2040启动顺序
RP2040启动顺序 ©RP2040

对于CPU来说,并没闪存是否编程过这个概念。读取闪存总会读到数据,要么0,要么1,没有”未设置“这个概念。读取未编程的闪存将得到一串随机、或空的数据(但是总会有数据)。

上电后,片上引导程序会从板载闪存中读取256字节,并进行校验:

原来是我们的程序确实写入了闪存,但是没有正确的校验和。因此,片上引导程序无法加载我们的程序。

下载到闪存

现在,我们可以着手对闪存编程了,这样就算掉电我们的程序也能被保存。

这篇例子使用的工程文档可以在这里找到

源代码

在添加校验和之前,我们要对汇编代码进行一点小修改:


.cpu cortex-m0plus
.thumb
.align 2
.thumb_func

.section .boot2, "ax"
.global reset
reset:
	ldr	r0, =0x20041000		@ Stack Pointer @ SRAM bank 4
	mov	sp, r0

	ldr	r3, =0x4000f000		@RESETS_BASE + RESET  + 0x3000
	mov	r2, #32			@ 1 << 5 (IO_BANK0)
	str	r2, [r3, #0]

	ldr	r3, =0x400140cc		@ IO_BANK0_BASE + GPIO25_CTRL
	mov	r2, #5			@ Function 5 (SIO)
	str	r2, [r3, #0]

	ldr	r3, =0xd0000020		@ SIO_BASE + GPIO_OE
	ldr	r2, =(1<<25)
	str	r2, [r3, #0]

blink:
	ldr	r3, =0xd000001c		@ SIO_BASE + GPIO_XOR
	ldr	r2, =(1<<25)	@ GPIO25
	str	r2, [r3, #0]
	
	ldr	r0, =650000
	bl	delay
	b	blink

delay:
	mov	r4,r0
loop:
	sub	r4,r4,#1
	cmp	r4, #0
	bne	loop
	bx	lr

.align 4
	

我们添加了.section .boot2, “ax”。这是为了把下面的程序取个名字boot2,并声明其可分配、可执行。

将该源码保存为boot2_src.s

使用arm-none-eabi-as --warn --fatal-warnings -g boot2_src.s -o boot2_src.o进行汇编,生成文件boot2_src.o


我们还要修改一下链接脚本:


MEMORY {
	FLASH(rx) : ORIGIN = 0x10000000, LENGTH = 2048k
	SRAM(rwx) : ORIGIN = 0x20000000, LENGTH = 264k
}

SECTIONS {
	. = ORIGIN(FLASH);
	.text : {
		KEEP(*(.boot2))
	} >FLASH
}
	

boot2程序放在闪存的最前面。

将该链接脚本保存为boot2_src.ld

使用arm-none-eabi-ld -nostdlib -nostartfiles -T boot2_src.ld boot2_src.o -o boot2_src.elf进行链接,生成文件boot2_src.elf


使用arm-none-eabi-objcopy -O binary boot2_src.elf boot2_src.bin生成包含该程序的二进制文件boot2_src.bin。我们可以通过od -t x4 boot2_src.bin查看其内容:


0000000 4685480b 22204b0b 4b0b601a 601a2205
0000020 4a0b4b0a 4b0b601a 601a4a09 f000480a
0000040 e7f8f801 3c011c04 d1fc2c00 46c04770
0000060 20041000 4000f000 400140cc d0000020
0000100 02000000 d000001c 0009eb10
0000114
	

实际上,我们并不需要给这一段程序取名boot2,或是声明内存空间。该二进制文件中不包含从保护了程序代码外任何信息。

不过,正确命名和声明有助于我们梳理这个过程。

添加校验和

RP2040文档说到:片上引导程序会从板载闪存中读取256字节到SRAM区块5并进行校验:

我们将使用SDK提供的工具来添加校验和。使用pico-pad_checksum -s 0xFFFFFFFF boot2_src.bin boot2.s,生成文件boot2.s。我们可以打开这个汇编源码文件看看:


// Padded and checksummed version of: boot2_src.bin

.cpu cortex-m0plus
.thumb

.section .boot2, "ax"

.byte 0x0b, 0x48, 0x85, 0x46, 0x0b, 0x4b, 0x20, 0x22, 0x1a, 0x60, 0x0b, 0x4b, 0x05, 0x22, 0x1a, 0x60
.byte 0x0a, 0x4b, 0x0b, 0x4a, 0x1a, 0x60, 0x0b, 0x4b, 0x09, 0x4a, 0x1a, 0x60, 0x0a, 0x48, 0x00, 0xf0
.byte 0x01, 0xf8, 0xf8, 0xe7, 0x04, 0x1c, 0x01, 0x3c, 0x00, 0x2c, 0xfc, 0xd1, 0x70, 0x47, 0xc0, 0x46
.byte 0x00, 0x10, 0x04, 0x20, 0x00, 0xf0, 0x00, 0x40, 0xcc, 0x40, 0x01, 0x40, 0x20, 0x00, 0x00, 0xd0
.byte 0x00, 0x00, 0x00, 0x02, 0x1c, 0x00, 0x00, 0xd0, 0x10, 0xeb, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
.byte 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xda, 0x37, 0x23, 0x75
	

可以看到,该文件包含了程序代码(即文件boot2_src.bin),外加结尾4字节的校验和。总长度256字节。

注意在文件最顶端,这一段代码被命名为boot2。这是因为,在大部分情况下,这段代码(片上引导程序读取的256字节)将作为二级引导程序,用于读取主程序。

在(第一版的SDK)pico-sdk/src/rp2_common/boot_stage2下你可以找到pad_checksum,详情见GitHub上/src/rp2_common/boot_stage2的SDK1.5.1的源码

我把pad_checksum这个程序复制到了我Linux环境下的/bin中以便直接使用。

该工具使用Python编写。默认使用256为填充长度,0为种子(seed,初始值)。这里填充长度没问题,但是我们需要指定初始值(种子)为0xFFFFFFFF,使用-s 0xFFFFFFFF

该工具中,第42行写到((binascii.crc32(bytes(bitrev(b, 8) for b in idata_padded), args.seed ^ 0xffffffff) ^ 0xffffffff) & 0xffffffff, 32)。而在Python文档中,其写到:binascii.crc32: Compute CRC-32, the unsigned 32-bit checksum of data, starting with an initial CRC of value. … The algorithm is consistent with the ZIP file checksum。根据GZIP File Format Specification第12页,ZIP算法使用0xedb88320作为多项式,也就是RP2040片上引导程序要求的0x04c11db7倒过来的值。

UF2文件添加校验和

终于,我们可以下载带有正确校验和的程序了。

使用arm-none-eabi-as --warn --fatal-warnings -g boot2.s -o boot2.o将填充过且校验过的源码代码进行汇编,生成文件boot2.o

创建链接脚本:


MEMORY {
	FLASH(rx) : ORIGIN = 0x10000000, LENGTH = 2048k
	SRAM(rwx) : ORIGIN = 0x20000000, LENGTH = 264k
}

SECTIONS {
	. = ORIGIN(FLASH);
	.text : {
		KEEP(*(.boot2))
	} >FLASH
}
	

这将把boot2(也就是我们带有校验的程序)放在闪存起始的位置(映射到地址0x10000000的XIP空间)。这将使片上引导程序将使用uf2打包发过去的程序烧录进闪存。

使用arm-none-eabi-ld -nostdlib -nostartfiles -T main.ld boot2.o -o main.elf链接,生成文件main.elf

使用pico-elf2uf2 main.elf boot2.uf2打包,生成文件boot2.uf2

编程,断电,再上电。

我们的程序被保存了下来。

下一步

可见,片上引导程序只会从闪存读取256字节的数据(包括4字节校验和),也就是说,126条Thumb指令(每条指令2字节)。显然,这完全不够存放一个正常的应用。

因此,我们将引入二级引导程序(boot2),一个保存在闪存中的256字节以内的程序。该程序的目标只有一个:为执行主程序做准备。

在我的下一篇文章中,我们将讨论SDK二级引导程序