文章

内核寄存器:CPU 手边的十六个格子(以 Cortex-M4 为例)

你大概见过调试器里那个寄存器面板——单步时它就挂在旁边,一排 R0、R1……R15,跟着一个小小的黄色箭头哗哗地变。大多数人从不点开它。真正逼你点开它的,往往是这两件事之一:看 FreeRTOS 的任务切换代码,通篇是 STMDB SP!, {R4-R11} 这样的天书;或者给一段奇怪的崩溃查因,反汇编出来的东西,一个 C 变量都看不见。

先给你一个 5 秒的实验垫底。随便写一个函数:

1
int add(int a, int b) { return a + b; }

打个断点,在返回处看寄存器面板——R0 恰好等于返回值。不是巧合,是约定:返回值就放在 R0 里。这篇文章就把这张面板上每一个格子讲明白,讲完你会发现,从函数调用到任务切换,从 volatile 到中断 12 周期延迟,全是这十几个格子上演的戏。

先把一个词拧清楚。本系列 《GPIO》 篇也满篇”寄存器”——GPIOD_MODER、GPIOD_ODR 那些是外设寄存器:它们住在内存地图上,有门牌地址,CPU 用普通的读写下令(《Cortex-M4 与 MCU 开发》 里那张全芯片通用的门牌表)。本篇讲的是内核寄存器:R0–R15 这一排。它们没有地址、不能取指针、& 摸不到,访问它们的是另一拨指令。同名不同物,这是嵌入式词典里最大的一个同名异物。

L1:先让它跑起来——看懂那张面板

你在第一层。目标:拿到一张能对照着看调试器的地图,知道每个格子归谁用。

Cortex-M4 的内核里一共 16 个通用格子,编号 R0–R15,外加几个不参与编号的”机关格”。分工如下:

寄存器别名分工谁负责保存它
R0–R3—传参数(前 4 个)、放返回值调用者(caller-saved)
R4–R11—函数里的局部变量、长期占着的中间结果被调用者(callee-saved)
R12IP临时草稿纸,谁都能蹭调用者
R13SP栈顶指针(有两套:MSP / PSP)—
R14LR链接寄存器:函数的”返回地址”书签调用者
R15PC程序计数器:下一条指令的地址—

对 C 程序员来说,这张表里最要紧的一条分界线在 R3 和 R4 之间:R0–R3 是”公共台面”,R4–R11 是”各人自带的工具箱”。这就是为什么编译器总爱用 R4–R11 装你的局部变量——放那儿,调函数不用搬走;而 R0–R3 上面的东西,每次函数调用前都必须找地方存一下,因为被调的家伙随时会来蹭。

看个真实例子。下面这个函数,编译器会这样安排格子:

1
2
3
4
5
int clamp(int v, int lo, int hi)
{
    int t = v < lo ? lo : v;
    return t > hi ? hi : t;
}
clamp:
        CMP     R0, R1          ; v 和 lo 比
        IT      LT
        MOVLT   R0, R1          ; v < lo 就把 R0 换成 lo
        CMP     R0, R2          ; 再和 hi 比
        IT      GT
        MOVGT   R0, R2          ; 超过 hi 就换成 hi
        BX      LR              ; 返回,结果已在 R0

三个参数依次落在 R0、R1、R2,比较、替换全在格子里原地完成,中间变量 t 全程住在 R0 里没进过内存。那个 C 变量只是给”R0 里这团东西”起的别名。

这就解释了开头的另一个现象:开 -O2 编译后,调试器的 watch 窗口里局部变量常显示”已被优化掉”。它不是被删了——它从头到尾就住在一个寄存器里,出了作用域格子被别人占用,”值”无处可寻。想让它可看,得取一次地址,把编译器逼到内存里去。

到这里你已经能对着面板认格子了。但面板上有一堆解释不了的东西:为什么 LR 在你从中断里单步时显示成 0xFFFFFFFD 这种鬼数?为什么 SP 明明只有一个,说法里却有 MSP、PSP 两个名字?面板底下还有几个没编号的:xPSR、PRIMASK、BASEPRI……它们是干嘛的?

L2:懂原理——每个格子为什么长这样

到这一层,问题变了:不再问”哪个格子干什么”,而是问”为什么非要这样分工”。

通用寄存器:没有类型的格子

物理上,一个通用寄存器就是一组 32 个触发器,能存一个 32 位的数,没了。它没有类型:同一个 R0,上一条指令里是 int,下一条可以按 4 个 char 拆开用,再下一条可以当地址。”变量有类型”是编译器维护的幻觉,硬件只认 32 个比特。

Cortex-M4 是 load-store 架构:只有寄存器里的数能进 ALU 做运算。内存里的数据必须先”装载”(LDR)到格子,算完再”存”回去(STR)。a + b 在硬件层永远是三步:把 a 装进格子,把 b 装进格子,ALU 把两格相加。这就是为什么局部变量要抢着住寄存器——住内存里的变量,每用一次都要搬进搬出。

PC 和 LR:一条”读哪行”,一张”回去的书签”

R15(PC)指向正在取的指令。《Cortex-M4 与 MCU 开发》 里讲过 CPU 就是”取指—译码—执行”的循环,PC 就是这个循环的游标。所有跳转的本质,都是改写 PC:if 翻面是改 PC,函数调用是改 PC,中断响应也是硬件改 PC——改法不同,目标一致。

R14(LR)解决的是”跳过去之后怎么回来”。函数调用(BL)做两件事:把返回地址写进 LR,把目标地址写进 PC。函数结尾的 BX LR 则照着书签跳回去。书签类比在 《中断:CPU 的分心与回头》 里用过——那是硬件替你把书签(LR 的值)压进栈里。为什么中断要压而普通调用不用?因为普通调用是”你打完这仗就回来”,链是直的,一张书签够用;中断是”不知道什么时候、打到哪一行突然插进来”,主程序自己书签正夹着呢,硬件必须先把整页存起来。

你单步进 SysTick_Handler 时看到的 LR = 0xFFFFFFFD,就是中断篇讲过的魔数 EXC_RETURN:硬件把 LR 这个格子征用了,塞进一个全 F 开头的假地址——它不是跳回去的路标,而是”怎么回去”的指令(回哪个栈、回线程模式还是处理模式)。CPU 一看 LR 长这样,就知道该走”异常返回”流程而不是普通返回。

SP:一根手指,两根指法

R13(SP)指向栈顶。栈本身在内存里(《Cortex-M4 与 MCU 开发》 里主栈通常在 RAM 顶端),SP 是指着它的手指:压栈它自己减,弹栈它自己加。

但 Cortex-M4 的 SP 有个机关:它是”存了两份”的(banked)——MSP(主栈)和 PSP(进程栈),同一根手指的两副指法:

  • 处理模式(跑中断时)永远用 MSP,没得选;
  • 线程模式(跑 main、跑任务时)用哪个,由 CONTROL 寄存器的一位决定。

裸机上你只会在 MSP 里转,感觉不到第二根的存在。装了 FreeRTOS 就不一样了:《FreeRTOS 核心原理》 里每个任务都有自己的栈,那些栈就是走 PSP 的——任务在 PSP 上活,内核和中断在 MSP 上活。一个任务把栈撑爆了,烧的是它自己那块,中断照样能跑、能救场。这份隔离,就是两根 SP 存在的理由。

xPSR:一条状态,三种读法

xPSR 是程序状态寄存器,面板上通常拆成 APSR、IPSR、EPSR 三行显示——其实是同一份数据的三种读法,好比同一间房间开三扇门,每扇门只能看到一部分:

  • APSR(运算标志):上一条指令留下的痕迹——N(负)、Z(零)、C(进位/借位)、V(溢出)。if (a > b) 编译出的 CMP + 条件跳转,靠的就是 CMP 算完把标志写在这,跳转指令再来看;
  • IPSR(中断号):此刻在哪个异常里。0 = 线程模式好好跑着,非 0 = 正在处理第几号中断——ISR 里问”我是谁”就看它;
  • EPSR(执行状态):Thumb 位(T,M4 上永远为 1,写 0 直接 HardFault)和中断可继续的 IT 位。

一个反直觉时刻:这”一条状态”无法用普通的 LDR/STR 访问,得用专门指令 MRS/MSR。CMSIS 给你包好了:__get_IPSR()、__get_APSR(),底下就是这两条指令。上一节那些没编号的”机关格”(PRIMASK、BASEPRI、CONTROL……)同理——它们统称特殊寄存器,全走 MRS/MSR 这扇窄门,因为它们不是给你存数据的,是控制 CPU 行为的开关。

PRIMASK / FAULTMASK / BASEPRI:三挡”勿扰”牌

这三个是中断的音量旋钮,《中断》 篇讲了 NVIC 的优先级裁决,这里是裁决前的”总闸”:

  • PRIMASK:一挡,”全别来”——置 1 后所有可配置优先级的中断一律不理(NMI 和 HardFault 照样进)。想写一小段不许打断的代码,PRIMASK = 1 …… 干完活 PRIMASK = 0;
  • FAULTMASK:二挡,”连 HardFault 都别来”——比一挡更绝,只有 NMI 能进门。它有个狠设定:异常返回时自动清零,即”保护只管这一程”;
  • BASEPRI:三挡,”低于我的别来”——写一个优先级数字进去,只有优先级更急(数字更小,注意 NVIC 数字越小越急)的中断能进。写 0 等于不用。

为什么需要三挡?因为”别打扰我”在嵌入式里有粗细之分:给共享变量上锁,挡住一切就行(PRIMASK);而 《FreeRTOS 核心原理》 里那个 configMAX_SYSCALL_INTERRUPT_PRIORITY——内核临界区只挡得住普通中断,得给”更急的事”留门——用的正是 BASEPRI:FreeRTOS 进临界区时把 BASEPRI 设成那条线,比这条线急的中断照常服务,内核的账本不会被急事撕烂,急事也不用陪内核等。

CONTROL:三个开关挤一格

CONTROL 一共三位:

  • 位 0:线程模式跑在特权级还是非特权级(上电默认特权);
  • 位 1:线程模式用 MSP 还是 PSP——上面 SP 一节的”由一位决定”就是它;
  • 位 2:FPCA——本线程用过浮点没有(见下节)。

FPU:另外 32 张桌,加上一次”偷懒”

带 FPU 的 M4F(STM32F4 系列都是)另有 32 个浮点寄存器 S0–S31,每个也是 32 位(算双精度时两两拼成 D0–D15)。C 里的 float 变量、sin() 之类的运算就住在这里,和整数世界井水不犯河水。

用它们有个前奏:复位后 FPU 是关的,得先写 CPACR 寄存器打开(启动文件里那段你从没看过的 CPACR |= 0xF << 20 就是干这个)。

这里藏着 《中断》 篇那”12 周期中断延迟”的一笔没算完的账:硬件自动压栈的是 R0–R3、R12、LR、PC、xPSR 这 8 个整数格——不含 32 个浮点格。全压的话延迟得翻几倍,可多数中断根本不碰浮点。于是 M4F 设计了惰性压栈(lazy stacking):进中断时只在栈里预留浮点区的位置,不搬数;真有中断里用了浮点指令,才在那一刻把 32 个格子补进去。不用的,一个周期都不多花——12 周期的广告词,就是这么保住的。

到这里,面板上每个格子你都能说出个所以然了。但退一步看,还有些更根本的问题没回答:为什么偏偏是 16 个格子,不多不少?”谁保存谁”的规矩凭什么这样定?

L3:想得透——为什么非得是这副骨架

到这一层,问题变成:换个设计行不行?代价是什么?

本质:寄存器到底是什么

去掉所有术语,内核寄存器是两样东西:

CPU 的桌面——运算时手边必须摊开的那一小块工作区。内存是仓库,仓库再大,干活时你也得把零件摆到桌上来拧。load-store 架构说的就是这件事:ALU 这双手只够得着桌面。

外加三格”当前状态”——PC 记”干到哪一行了”,xPSR 记”干得怎么样”,CONTROL 记”我现在什么身份”。任务切换(FreeRTOS)为什么保存的是 R4–R11 加上那 8 个自动压栈格?因为把这 16 个桌面格加三格状态存起来、再换一套进去,”一个人”就完整地换掉了。人不在格子里,人在格子的内容里。

为什么是 16 个,不是 8 个或 64 个

桌面大小是一笔精明的账,两头都有代价:

  • 太小,编译器只能把变量来回搬进搬出内存——每搬一次好几条指令、好几个周期。x86 早年只有 8 个通用寄存器(AX、BX……),编译器写出了名的憋屈;
  • 太大,麻烦更隐蔽:指令里每个寄存器操作数都要用二进制位编号,16 个格子正好 4 位。格子翻倍到 32 个(RISC-V 那样),每条指令就多出几位编码,同样闪存里装下的代码变少;更要命的是函数调用约定变宽—— callee-saved 格子从 8 个变 16 个,每次函数调用潜在要保存恢复的东西翻倍,小函数调用密集的嵌入式代码净吃亏。

Cortex-M4 取 16,是在”编译器够用”和”调用够便宜”之间划的线。行业后来也没收敛出唯一答案(RISC-V 选了 32,x86-64 涨到了 16),说明这条线画在哪,取决于你赌代码更像”算得多”还是”跳得多”。嵌入式跳得多、函数小,16 是稳的。

caller-saved / callee-saved:合租厨房的公约

“谁保存谁”的规矩,本质是稀缺共享资源的租约。16 张桌面要给所有函数共用,调用瞬间两拨代码都要桌面,必须先讲好哪些格子”用完必须恢复原样”(callee-saved,R4–R11:你借我的工具箱,还的时候得原样)、哪些是”公共台面,不保证”(caller-saved,R0–R3、R12:台上东西自己收好再让位)。

这份租约的名字叫 AAPCS(ARM 架构过程调用标准)。没有它,你调库函数、库函数调你,各自编译、互不知情,桌面立刻一团糟;有了它,任何两段独立编译的 ARM 代码都能对接。这是”C 语言能在没有源码的情况下链接闭源库”的底层前提——大家守的不是源码约定,是格子约定。

R12 的存在最能看出这份租约的精打细算:它是 Thumb 模式下 BLX 跳转指令需要的草稿格——跳转目标如果太远、又是 Thumb/ARM 状态切换,需要一格既不是参数(不然调用前还得先存参数)、又不是 LR/PC 的格子来中转。于是规格里明确划出 R12 当”公共草稿,人人可蹭”。一个”浪费的格子”,换来一条跳转指令的畅通。

两个 SP:被否决过的另外两条路

线程/处理两套栈、以 banked 方式实现,这个设计也筛掉过两个候选:

  • 只用一套 SP:省了机制,但任务的栈和中断的栈必须搅在一起。任务栈崩了,中断跟着崩,FreeRTOS 那种”给每个任务发小栈、系统栈另算”的玩法直接不成立;
  • 切换 SP 纯靠软件(中断进门后先跑一段代码换指针):每次中断都多付几条指令的”过路费”,而中断延迟是嵌入式里寸土寸金的硬指标——12 周期的卖点直接破产。

banked SP 是用一点晶体管换确定性:进了处理模式,硬件自动切到 MSP,一个周期不花,一个分支不用。嵌入式设计里最值钱的往往不是快,是”保证不慢”。

动手环节:亲手摸到格子

光看不算数,来两件事。

实验一(PC 上就能做):验证调用约定。 先别编译,猜一下——下面这个 5 参数函数,第 5 个参数 e 去哪了?

1
2
3
4
int sum5(int a, int b, int c, int d, int e)
{
    return a + b + c + d + e;
}

猜完再看答案:前 4 个进 R0–R3,第 5 个上栈。打开 godbolt.org(Compiler Explorer),选 ARM gcc、-mcpu=cortex-m4 -O1,贴进去,你会看到类似:

sum5:
        ADD     R0, R0, R1      ; a+b
        ADD     R2, R2, R3      ; c+d
        ADD     R0, R0, R2      ; 合并
        LDR     R3, [SP]        ; ← e 从栈上取回来
        ADD     R0, R0, R3      ; 加上 e,结果仍在 R0
        BX      LR

LDR R3, [SP] 那行就是”第 5 个参数”现身的地方。顺手再试两件事:把 int 全换成 float,看参数搬去 S0–S3(前提是带 FPU 的编译目标);开 -O2 对比 -O0,看格子被安排得多么抠门。

实验二(板子上做,配 UART 打印):读出特殊寄存器。 CMSIS 把 MRS 包成了函数,在 《UART》 调通的串口上直接打:

1
2
3
4
5
#include "cmsis_compiler.h"          /* 或整包 cmsis_gcc.h */

printf("CONTROL  = %08lx\n", __get_CONTROL());   /* 位1=0:main 跑在 MSP 上 */
printf("IPSR     = %08lx\n", __get_IPSR());      /* 0:线程模式,没在中断里 */
printf("PRIMASK  = %08lx\n", __get_PRIMASK());   /* 0:中断没被总关 */

预期输出三行,CONTROL 和 IPSR 都是 0(或 CONTROL=4,如果你前面用过浮点——位 2 的 FPCA 被置了)。然后把打印挪进 SysTick_Handler,再看 IPSR——变成了 15(SysTick 的异常号)。同一个函数、同一个 CPU,仅凭 IPSR 一格,就能回答”我现在是不是在中断里”——这段代码在不少库里真实存在,专门防你把 printf 之类”不许在中断里调”的东西调进去。

自测两题,能答出来说明格子真的进了脑子:一是 FreeRTOS 任务切换时软件保存 R4–R11,为什么恰好是这 8 个,前 8 个去哪了?(答案在 L1 的分界线和 《中断》 的 8 格自动压栈里。)二是 printf 为什么危险到能压死中断篇开头那块板子——用”寄存器是共享桌面”的视角想想,一个在任务上下文里摊开的桌面,被中断半路借走再还回来,中间发生了什么?

收尾地图

把整篇文章收进一张速查表:

你想看/想干什么用哪个格子一句话理由
找函数返回值R0AAPCS:返回值固定住 R0
数函数参数R0–R3,第 5 个上栈只有 4 格公共台面
找”住得久”的局部变量R4–R11callee-saved,调用不用搬
弄清怎么回去的LR(EXC_RETURN 是魔数)书签格,中断时被征用
弄清跑到哪了PC所有跳转的共同底座
分清线程/中断栈SP + CONTROL 位 1任务 PSP、内核 MSP
在 ISR 里认出自己在 ISRIPSR0 是线程,非 0 是异常号
关中断护住一小段PRIMASK / BASEPRI全关 or 留个急事通道
查浮点上下文CONTROL 位 2(FPCA)惰性压栈的账本

下一站,三条路任选。往汇编里走:打开你项目的启动文件 startup_stm32f4xx.s,之前是注释般的存在,现在 LDR R0, =main、BX R0 这些行会突然全部 readable——那正是上电后人类往格子里放的第一批东西。往内核里走:翻 FreeRTOS portable/GCC/ARM_CM4F/ 里的 PendSV 处理函数,几十行汇编就是”把 16 个桌面格拍照、换人、还原”的标准作业,本文每一节都在里面点名。往诊断里走:下次板子死机,别急着重烧——停下来看一眼寄存器面板的 PC 停在哪、LR 是不是 EXC_RETURN、IPSR 是不是卡在某个 HardFault,很多”玄学崩溃”在格子里留有完整的遗言。格子看懂了,你就有了和 CPU 对质的语言。

本文由作者按照 CC BY 4.0 进行授权