裸金属切换RP2040时钟源:ROSC,XOSC与PLL
对比RP2040时钟源,在裸金属应用中切换时钟。
树莓派Pico, RP2040, ARM, Cortex M0+, 汇编, 裸金属, 时钟源, 时钟树, ROSC, 环形振荡器, XOSC, 晶体振荡器, PLL, 锁相环, 单片机
--by Captdam @ Mar 8, 2026Index
这篇文章的针对读者为32位RP2040与ARM Cortex-M0+新手,但是熟悉使用汇编与C语言开发8位单片裸金属应用的机开发者。
因为我们将开发裸金属应用,我们将会直接读写单片机和外部闪存设备的控制寄存器,并不使用任何库。
我们将大量依靠文档。文档中包括了我们所需要知道的所有单片机与外部闪存设备控制寄存器的信息。
因为RP2040文档更新的缘故,而且他们还决定把老文档的链接直接重定向到新文档,我决定在本地保存当前版本的副本(2025-02-20)。你可以通过原始链接(取样于2026-02-10)获取该文档。
树莓派Pico开发板带有一块W25Q系列的闪存芯片用于储存用户程序。注意,W25Q系列下有多个型号。所有型号都是用相同的SPI通讯协议,但是使用不同的继续码。所谓继续码是跟在数据地址后的8位代码,其用处为在之后的交互中省略读取指令,以减少发送指令带来的额外带宽消耗。
虽然Pico文档(复制于官方链接(取样于2026-02-10))指出该板载闪存为Winbond W25Q16JV,该闪存使用0b1111xxxx(x为任意电平)作为继续码。但是我通过反汇编发现SDK中二级引导程序发现使用的继续码是0b10101010。在浏览了其它一些型号的文档后,我发现W25Q80EW使用的继续码0bxx01xxxx可以和SDK中使用的继续码匹配。因此,本文章我将使用这一款闪存的文档作为参考。
这篇文章基于我的上一篇文章:RP2040裸金属双核应用与核心1启动协议,你应该先看看那一篇文章。
RP2040有多快
检测上电后CPU时钟的程序在这里找到。
RP2040文档指出,RP2040拥有Dual ARM Cortex-M0+ @ 133MHz,也就是说总计266百万次(2.66亿次)32比特运算。另外,You can achieve 200MHz by running at an elevated core supply (DVDD) and setting VREG VSEL to 1.15V(你还可以提高核心电压DVDD并设置VREG VSEL到1.15V来超频到200MHz),也就是400百万次(4亿次)32比特运算.这比2000年代初的一些个人电脑都快。
不过,我们的RP2040实际速度是多少呢?
使用SIO来测量CPU速度
要找到这个问题的答案,我们可以写个简单的汇编程序,命名为main.s:
.cpu cortex-m0plus
.thumb
.align 2
.thumb_func
.section .vector
.global vector
vector:
.word 0x20041000
.word reset + 1
.section .text
.global reset
reset:
ldr r7, =0x4000f000 @ RESETS_RESET + 0x3000
mov r0, #(1<<5) @ IO_BANK0
str r0, [r7, #0x00]
ldr r7, =0x400140cc @ IO_BANK0_GPIO25_CTRL
mov r0, #5 @ SIO
str r0, [r7, #0x00]
ldr r7, =0xd0000000 @ SIO_GPIO_BASE
ldr r0, =(1<<25) @ GPIO25
str r0, [r7, #0x20] @ SIO_GPIO_BASE_OE
1: str r0, [r7, #0x1C] @ SIO_GPIO_BASE_XOR
b 1b
这个程序包含了一个异或输出的死循环。
根据指令集文档:
str r0, [r7, #0x1C]- 写入GPIO 25(SIO)消耗1个CPU时钟周期。b 1b- 跳转并修改了PC消耗2个CPU时钟周期。
算下来,GPIO 25没3个CPU时钟周期翻转一次,波形周期为6个CPU时钟周期(3高3低)。
为USB启动与闪存启动链接
将程序写入SRAM并从SRAM执行(USB下载完成后启动),创建链接脚本sram.ld:
MEMORY {
SRAM(rwx) : ORIGIN = 0x20000000, LENGTH = 264k
SRAM_4(rwx) : ORIGIN = 0x20040000, LENGTH = 4k
SRAM_5(rwx) : ORIGIN = 0x20041000, LENGTH = 4k
SRAM_0(rwx) : ORIGIN = 0x21000000, LENGTH = 64k
SRAM_1(rwx) : ORIGIN = 0x21010000, LENGTH = 64k
SRAM_2(rwx) : ORIGIN = 0x21020000, LENGTH = 64k
SRAM_3(rwx) : ORIGIN = 0x21030000, LENGTH = 64k
}
ENTRY(reset)
SECTIONS {
.text : {
*(reset)
} > SRAM
}
只需要将reset程序装入SRAM,不需要vector。
进入点为reset程序。
将程序写入闪存并从闪存执行(USB下载完成后读取闪存后启动),以便在断电后保存程序,创建链接脚本sram.ld:
MEMORY {
FLASH(rwx) : ORIGIN = 0x10000000, LENGTH = 2048k
}
SECTIONS {
.text : {
*(.boot2)
*(.vector)
*(reset)
} > FLASH
}
根据片上引导程序的要求,先将之前文章中生成的二级引导程序.boot放在闪存的开头。再根据二级引导程序的要求,将向量表vector放在紧接着二级引导程序的地方。最后,装入reset程序。
闪存编程不需要指定进入点。
编译
arm-none-eabi-as --warn --fatal-warnings -g main.s -o main.o
arm-none-eabi-objdump --disassembler-options=force-thumb -Dxs main.o > main.list
# SRAM
arm-none-eabi-ld -nostdlib -nostartfiles -T sram.ld main.o -o sram.elf
arm-none-eabi-objdump --disassembler-options=force-thumb -dxs sram.elf > sram.list
pico-elf2uf2 sram.elf sram.uf2
# Flash
arm-none-eabi-ld -nostdlib -nostartfiles -T flash.ld main.o boot2.o -o flash.elf
arm-none-eabi-objdump --disassembler-options=force-thumb -dxs flash.elf > flash.list
pico-elf2uf2 flash.elf flash.uf2
编译这个项目,将生成两个uf2文件:
sram.uf2- 下载到并从SRAM执行。flash.uf2- 烧录入并从闪存执行,允许在掉电后保留程序。
测量频率
现在,我们可以使用示波器来检测输出信号。我们将在这里。也就是LED电阻的高侧测量信号。见上图所示测试点。
如示波器展示,该信号频率为966kHz。因为波形周期为6个CPU时钟周期,CPU的频率为5.8MHz。
说好的133MHz呢?
让我们也试一下其它情形:
- 下载到并从SRAM执行。
- 烧录入并从闪存执行。
- 烧录入闪存,断点并重新上电,再从闪存中执行。
| 情况 | 测量频率 | CPU速度 |
|---|---|---|
| 下载到并从SRAM执行 | 966kHz | 5.8MHz |
| 烧录入并从闪存执行 | 966kHz | 5.8MHz |
| 重新上电后从闪存中执行 | 966kHz | 5.8MHz |
所有情形都给出5.8MHz的CPU(系统)时钟频率。
时钟树
PR2040提供了一些列的时钟源。如图左侧所展示。注意有的时钟源由另外的时钟源驱动。
时钟生成器选择一个时钟源,使用分频器降低时钟频率(省电或满足使用者的最高速度)。如图中间所展示。
时钟信号输出到使用者,包括系统(CPU核心)、外置设备、时钟输出。
上电后,参考时钟clk_ref由ROSC驱动,系统时钟(CPU)clk_sys由clk_ref驱动(而不是直接由ROSC驱动)。
时钟选项
一个时钟输出可以被用作另一个时钟的输入。细节参考时钟生成器各自的控制寄存器。下面是我整理的结果:
CLK_GPIO_0 / CLK_GPIO_1
- CLKSRC_PLL_SYS
- CLKSRC_GPIN0
- CLKSRC_GPIN1
- CLKSRC_PLL_USB
- ROSC_CLKSRC
- XOSC_CLKSRC
- CLK_SYS
- CLK_USB
- CLK_ADC
- CLK_RTC
- CLK_REF
CLKSRC_GPIN0与CLKSRC_GPIN1可以作为所有生成器的输入。
CLKSRC_PLL_USB可以作为所有生成器的输入,CLKSRC_PLL_SYS可以作为除CLK_REF以外所有生成器的输入。
CLK_GPIO_2 / CLK_GPIO_3
- CLKSRC_PLL_SYS
- CLKSRC_GPIN0
- CLKSRC_GPIN1
- CLKSRC_PLL_USB
- ROSC_CLKSRC_PH(移相)
- XOSC_CLKSRC
- CLK_SYS
- CLK_USB
- CLK_ADC
- CLK_RTC
- CLK_REF
CLK_GPIO_2和CLK_GPIO_3与CLK_GPIO_0和CLK_GPIO_1完全相同,除了使用移相的ROSC。
CLK_GPIO_N可以使用除CLK_PERI以外的生成器的输出作为输入。
CLK_REF
- ROSC_CLKSRC_PH
- CLKSRC_CLK_REF_AUX
- CLKSRC_PLL_USB
- CLKSRC_GPIN0
- CLKSRC_GPIN1
- XOSC_CLKSRC
CLK_SYS
- CLK_REF
- CLKSRC_CLK_SYS_AUX
- CLKSRC_PLL_SYS
- CLKSRC_PLL_USB
- ROSC_CLKSRC
- XOSC_CLKSRC
- CLKSRC_GPIN0
- CLKSRC_GPIN1
CLK_PERI
- CLK_SYS
- CLKSRC_PLL_SYS
- CLKSRC_PLL_USB
- ROSC_CLKSRC_PH
- XOSC_CLKSRC
- CLKSRC_GPIN0
- CLKSRC_GPIN1
CLK_USB / CLK_ADC / CLK_RTC
- CLKSRC_PLL_USB
- CLKSRC_PLL_SYS
- ROSC_CLKSRC_PH
- XOSC_CLKSRC
- CLKSRC_GPIN0
- CLKSRC_GPIN1
切换时钟源
时钟生成器的输入端为选择器,以选择输入信号。大部分的生成器只有辅助(aux)选择器,参考时钟clk_ref和系统时钟clk_sys有辅助选择器和连续(glitchless)选择器。
要切换辅助选择器的时钟源,使用该时钟的设备都需要停止,以防止时钟信号毛刺带来的问题。另外,切换时钟源需要2个(旧时钟源)周期来停止,外加2个(新时钟源)周期来启动。
切换连续选择器可以随时执行。这允许CPU继续工作(以完成时钟切换)。
如果连续选择器使用了辅助选择器作为时钟源,且需要切换辅助选择器的时钟源,我们必须将连续选择器切换到另一个时钟源以防止切换辅助选择器时输出的毛刺(连续选择器的选择过程是“连续”的,但是它并不能修复不连续,也就是毛刺的信号)。接下来,再切换辅助选择器。最后,将连续选择器切换回已选择新输入信号的辅助选择器。
默认时钟源 - 环形振荡器(ROSC)
环形振荡器是一个永远可用的、永远作为启动时默认选项的、片上的时钟源。该时钟源集成在RP2040芯片内部,不需要外接任何电路,也不占用任何引脚。
上电后将使用这个时钟源。这是最保险的策略,这保证芯片在没有外部时钟电路,或是外部时钟电路失效时也能运行。(此外,RP2040上也可以在运行在一个时钟时检测另一个时钟)
对于一个低成本系统来说,使用ROSC可以节约外部时钟电路,降低系统总成本。
但是,该时钟并不精确。其频率受电压与温度影响。
通过修改相应的寄存器可以修改频率。
| 对比 | RP2040 | AVR(经典) |
|---|---|---|
| 可用性 | 内置,永远可用,不需要任何外部电路 | |
| 精确度 | 频率受电压与温度影响 | |
| 修改频率 | 运行时修改控制寄存器 | |
| 默认时钟源 | 上电时默认 | 出厂默认fuse(非易失性) |
| 切换时钟源 | 运行时修改控制寄存器 片上启动ROM不可修改。 |
编程时修改fuse |
| 运行时切换时钟源 | 可以 | 不行,fuse只能在编程时修改 只能是串行或并行编程,不支持启动程序内编程。 |
| 作用于 | CPU(系统)和外置设备可以独立选择时钟源 | 整个系统,除了(异步)计时器/计数器 |
更精确的时钟源 - 晶体振荡器(XOSC)
使用晶体振荡器可以得到比ROSC更准确与稳定的时钟,因为晶体的物理特性可以被精确地加工,且晶体物理特性决定了其共振频率。该频率几乎不受电压和温度影响。
当对时钟精确度要求高时应该使用晶体。例如,UART,即串口通讯。
但是,使用该时钟需要外部电路(晶体)。也就是说,将提高系统成本。Pico电路板提供了板载12MHz晶体。
另外,晶体振荡器需要更长的启动时间。在上电时这不是什么大的问题,不过一次性的延迟罢了。但是对于需要金厂启停(睡眠)的应用来说,这或许是个问题。
编程使用XOSC
我们只要按照如下修改本文中上一个例子中使用的程序文件main.s就可以使用XOSC了:
.cpu cortex-m0plus
.thumb
.align 2
.thumb_func
.section .vector
.global vector
vector:
.word 0x20041000
.word reset + 1
.section .text
.global reset
reset:
@ Switch to XOSC
ldr r7, =0x40008000
mov r0, #((3<<5) | (0<<0))
str r0, [r7, #0x3C]
nop
nop
nop
nop
mov r0, #((3<<5) | (1<<0))
str r0, [r7, #0x3C]
@ GPIO
ldr r7, =0x4000f000 @ RESETS_RESET + 0x3000
mov r0, #(1<<5) @ IO_BANK0
str r0, [r7, #0x00]
ldr r7, =0x400140cc @ IO_BANK0_GPIO25_CTRL
mov r0, #5 @ SIO
str r0, [r7, #0x00]
ldr r7, =0xd0000000 @ SIO_GPIO_BASE
ldr r0, =(1<<25) @ GPIO25
str r0, [r7, #0x20] @ SIO_GPIO_BASE_OE
1: str r0, [r7, #0x1C] @ SIO_GPIO_BASE_XOR
b 1b
首先,同时修改系统时钟控制器的辅助选择器和连续选择器:
- 连续选择器切换到参考时钟源。这个切换操作可以随时执行。如果连续选择器当前已经选择了参考时钟源,该切换操作将没有效果。如果连续选择器当前选择了辅助选择器,该切换操作将会隔离即将毛刺的辅助选择器。
- 辅助选择器切换到新的时钟源。本例子中为晶体振荡器。
等待4个(ROSC)周期。因为ROSC的频率为5.8MHz但XOSC为12MHz,4个ROSC周期肯定比2个ROSC周期加2个XOSC周期要长。另外,写r0和写控制寄存器也会提供额外的时间。
现在,辅助选择器应该稳定了。我们可以将连续选择器切换回辅助选择器上。
使用默认分频(1)。
The glitchless multiplexer does not switch instantaneously (to avoid glitches)(连续选择器为了防止毛刺并不会立刻切换),要等待几个周期后才会真的切换到新的时钟上。可以通过CLOCKS_CLK_SYS_SELECTED寄存器来确认时钟源已切换。在这个例子中我们并不担心这个问题。
测量XOSC频率
使用相同的链接脚本与命令行指令来编译。下载后,测量输出频率:
| 情况 | 测量频率 | CPU速度 |
|---|---|---|
| 下载到并从SRAM执行 | 1.999MHz | 12MHz |
| 烧录入并从闪存执行 | 1.999MHz | 12MHz |
| 重新上电后从闪存中执行 | 0 | 0 |
启动XOSC
要启动晶体振荡器(XOSC),我们需要再次修改程序main.s如下:
.cpu cortex-m0plus
.thumb
.align 2
.thumb_func
.section .vector
.global vector
vector:
.word 0x20041000
.word reset + 1
.section .text
.global reset
reset:
@ Start XOSC
ldr r7, =0x40024000
ldr r0, =0x00FABAA0
str r0, [r7, #0x00]
1: ldr r0, [r7, #0x04]
lsr r0, #32
bcc 1b
@ Switch to XOSC
ldr r7, =0x40008000
mov r0, #((3<<5) | (0<<0))
str r0, [r7, #0x3C]
nop
nop
mov r0, #((3<<5) | (1<<0))
str r0, [r7, #0x3C]
@ GPIO
ldr r7, =0x4000f000 @ RESETS_RESET + 0x3000
mov r0, #(1<<5) @ IO_BANK0
str r0, [r7, #0x00]
ldr r7, =0x400140cc @ IO_BANK0_GPIO25_CTRL
mov r0, #5 @ SIO
str r0, [r7, #0x00]
ldr r7, =0xd0000000 @ SIO_GPIO_BASE
ldr r0, =(1<<25) @ GPIO25
str r0, [r7, #0x20] @ SIO_GPIO_BASE_OE
1: str r0, [r7, #0x1C] @ SIO_GPIO_BASE_XOR
b 1b
要启动XOSC,设置XOSC_CTRL寄存器中ENABLE密码与FREQ_RANGE。ENABLE必须是0xFAB,FREQ_RANGE必须是0xAA0。
接下来,轮询XOSC_STATUS中STABLE值直到XOSC稳定。
现在就可以使用XOSC了。
链接与编译后,就算重新上电后XOSC也正常工作了。
启动并切换到XOSC的程序在这里找到。
再快一点 - 锁相环(PLL)
要达到更高的频率,我们将需要使用PLL。
RP2040有两套PLL:系统PLL与USB PLL。正如其名,系统PLL用来驱动系统(CPU、SRAM、IO、外置设备),USB PLL用来驱动USB。这允许系统和USB以不同的速度运行。例如,系统运行在最高133MHz,USB运行在48MHz。
如果系统和USB的运行频率相同,只要用一个PLL就可以,这样能省电。
要使用PLL:
- PLL在硬件层面与XOSC相连,使用XOSC为参考时钟。所以,XOSC必去启用。ROSC不够稳定因此不可以作为参考时钟,锁不上。
- 设置反馈分频器,使VCO的频率在750MHz到1.6GHz区间。高的VCO频率将带来更少抖动,低的VCO频率将带来更低能耗。
- 设置两级后段分频器以达到需求的系统/USB频率。系统与USB最高可支持频率分别为133MHz和48MHz。
下面展示了一些PLL设置:
| 目标频率 | 反馈分频器 | VCO频率 | 后段分频器1 | 后段分频器2 | 实际频率 | 注释 |
|---|---|---|---|---|---|---|
| 133MHz | 133 | 1596MHz | 6 | 2 | 133MHz | 完美! |
| 133MHz | 133 | 1596MHz | 2 | 6 | 133MHz | 应该对调后段分频器的值以降低两段中间的频率,进而省电。 |
| 133MHz | 66 | 792MHz | 6 | 1 | 132MHz | 稍微有点偏差,但是VCO级大省电。 |
编程使用系统PLL
我们只要按照如下修改本文中上一个例子中使用的程序文件main.s就可以使用系统PLL了:
启用XOSC
.cpu cortex-m0plus
.thumb
.align 2
.thumb_func
.section .vector
.global vector
vector:
.word 0x20041000
.word reset + 1
.section .text
.global reset
reset:
@ Start XOSC
ldr r7, =0x40024000
ldr r0, =0x00FABAA0
str r0, [r7, #0x00]
1: ldr r0, [r7, #0x04]
lsr r0, #32
bcc 1b
系统PLL和USB PLL都需要使用XOSC为参考时钟。因此,我们需要启用先XOSC。细节参考上一个例子。
允许PLL
@ Start PLL
ldr r7, =0x4000f000
ldr r0, =(1<<12)
str r0, [r7, #0x00]
上电后,系统PLL出于重置状态。我们需要写1来解除该状态才能使用PLL。为了保持其它设备的重置状态,我们可以添加0x3000的地址偏移来原子性地写1清零比特。
配置VCO
ldr r7, =0x40028000
ldr r6, =0x40028000 + 0x3000
mov r0, #(1<<0)
str r0, [r7, #0x00]
mov r0, #66
str r0, [r7, #0x08]
RP2040有两套PLL,一个是最高133MHz的系统的,另一个时48MHz的USB的。系统PLL的控制寄存器基地址为0x40028000,USB PLL的控制寄存器基地址为0x4002C000。
使用r7作为系统PLL的控制寄存器的指针,使用r6系统PLL的控制寄存器的写1清零指针。
首先,设置参考时钟分频器为1。因为板载晶体为12MHz,这将会把12MHz / 1 = 12MHz的参考信号输入PLL。该设置有助于在芯片驱动时钟太高时降频参考信号。
接下来,设置反馈分频器为66。这会使VCO的频率达到12MHz * 66 = 792MHz。
Start PLL
mov r0, #((1<<5) | (1<<0))
str r0, [r6, #0x04]
1: ldr r0, [r7, #0x00]
lsr r0, r0, #32
bcc 1b
默认情况下,PLL的各个部分都是断电的。给VCO与PLL上电以启动PLL,但是不要启动后段分频器。
需要等待一阵频率才会稳定,PLL才能锁住。我们可以通过轮询PLL_SYS_CS寄存器中LOCK比特来确认PLL准备好了。
Configurate post divider
ldr r0, =((6<<16) | (1<<12))
str r0, [r7, #0x0C]
mov r0, #(1<<3)
str r0, [r6, #0x04]
设置两级后段分频器。我们使用6为第一段分频器,1为第二段分频器。这将会将频率降低到792MHz / 6 / 1 = 132MHz。
给后段分频器上电。
切换到PLL
@ Switch to PLL
ldr r7, =0x40008000
mov r0, #((0<<5) | (0<<0))
str r0, [r7, #0x3C]
nop
nop
mov r0, #((0<<5) | (1<<0))
str r0, [r7, #0x3C]
切换辅助选择器与连续选择器为系统PLL。细节参考上一个例子。
GPIO
@ GPIO
ldr r7, =0x4000f000 @ RESETS_RESET + 0x3000
mov r0, #(1<<5) @ IO_BANK0
str r0, [r7, #0x00]
ldr r7, =0x400140cc @ IO_BANK0_GPIO25_CTRL
mov r0, #5 @ SIO
str r0, [r7, #0x00]
ldr r7, =0xd0000000 @ SIO_GPIO_BASE
ldr r0, =(1<<25) @ GPIO25
str r0, [r7, #0x20] @ SIO_GPIO_BASE_OE
1: str r0, [r7, #0x1C] @ SIO_GPIO_BASE_XOR
b 1b
设置GPIO并翻转输出。
测量系统PLL频率
使用相同的链接脚本与命令行指令来编译。下载后,测量输出频率:
| 情况 | 测量频率 | CPU速度 |
|---|---|---|
| 下载到并从SRAM执行 | 22MHz | 132MHz |
| 烧录入并从闪存执行 | 22MHz | 132MHz |
| 重新上电后从闪存中执行 | 22MHz | 132MHz |
启动并切换到PLL的程序在这里找到。
W25Q闪存的最快速度
现在,我们已经成功讲RP2040以133MHz的时钟驱动了。但是在我们结束这一篇文章之前,我们还需要确保外接设备(特别是板载的W25Q闪存)能吃得消。
W25Q闪存支持“标准”读取与“快速”读取两种模式,其中:
“标准”读取最高支持50MHz的SPI频率,“快速”读取最高支持133MHz(3.0V)或104MHz(2.7V)的SPI频率。
之前说到过,在SDK二级引导程序中,SSI BAUD为2。也就是说,SSI以133MHz / 2 = 67.5MHz的频率执行写或读操作。另外,写和读分别在SPI时钟的相对上升/下降沿执行,也就是说,SPI时钟的频率还要减半,为33.25MHz。这比W25Q支持的最高频率要慢。所以,不会出问题。