从配置到翻转:每个引脚四位,与原子置位
时钟合闸了(第三篇),地址的门道咱们也摸清了(第二篇 MMIO),今天咱们手里的活儿,就剩配置引脚(写四位模式)和翻转引脚(写输出位)两样了。两样都做完了,裸寄存器点灯的全程也就打通了。往后您再看任何外设库的 GPIO 代码,眼底都是地址和位。
配置寄存器:每个引脚四位
十六条脚的配置,寄存器却只留了一对:CRL 管 pin0-7、CRH 管 pin8-15。每个脚都分到了四位:低两位 MODE[1:0]、高两位 CNF[1:0]。PC13 在 CRH 管的高半段里,咱们拿一道算术把它的偏移求出来:(13 - 8) × 4 = 20,所以它的四位就是 CRH 的 bit23-20。设备头文件里的宏就是这么写的(stm32f103xb.h L1576-L1632):
#define GPIO_CRH_MODE13_Pos (20U)
#define GPIO_CRH_MODE13_Msk (0x3UL << GPIO_CRH_MODE13_Pos) // 0x00300000
#define GPIO_CRH_CNF13_Pos (22U)
#define GPIO_CRH_CNF13_Msk (0x3UL << GPIO_CRH_CNF13_Pos) // 0x00C000002
3
4
四位能凑出的组合一共有十六种,咱们把常用的八种列成表(完整表在 RM0008 第 9 章,RM0008 就是 ST 的 STM32F1 参考手册,01 篇咱们翻过的,下面每一行都是照着手册翻译的):
| MODE | CNF | 意思 | 典型用途 |
|---|---|---|---|
| 00 | 00 | 模拟输入 | ADC 采样 |
| 00 | 01 | 浮空输入 | 复位默认,外部信号电平稳定时 |
| 00 | 10 | 上拉/下拉输入 | 按键(F1 里上拉还是下拉由 ODR 决定,第九篇见) |
| 01 | — | 10MHz 输出 | 中速驱动 |
| 10 | 00 | 2MHz 推挽输出 | 咱们点灯的选择 |
| 10 | 01 | 2MHz 开漏输出 | I2C 这类总线(第五站见) |
| 11 | 00 | 50MHz 推挽输出 | 高速驱动 |
| 1x | 1x | 复用输出 | 片上外设接管引脚时 |
咱们要让 PC13 当一个普通 2MHz 推挽输出脚:CNF=00, MODE=10,四位合起来的 0010,落到位上就成了 0x2 << 20 = 0x00200000。对着第二篇 Renode 读回的 CRH = 0x00200000,咱们逐位对上,结果一个位都不差。
咱们把 PC13 在 CRH、BSRR 与 ODR 里的位置画成了下面一张对照图,您扫一眼就能对上号:
复位之后的默认值,也值得咱们看上一眼。手册里写的 CRL/CRH 复位值是 0x44444444(好多4哇),拆开之后每个脚分到的都是 0x4,也就是 CNF=01, MODE=00 的浮空输入。所以上电之后所有脚默认都是输入,等您来配。
Renode 的模型又和手册岔了一道:它给的复位值是
0x00000000,咱们在模拟器里读到的复位值就是 0。上一篇的门控偏差是"模型少算了约束",这一回岔的是"模型没按手册设默认值",原则不变:逻辑看的是模型,默认值与时序看的是手册。
这么一来,配置这两行在干什么,咱们已经完全看得懂了:
GPIOC->CRH &= ~(0xFu << 20); // 清掉 PC13 的四位旧配置
GPIOC->CRH |= (0x2u << 20); // 写入新配置: 0010 = 2MHz 推挽输出2
咱们在第二篇的反汇编里已经给这两行验过身:bic.w 清位、orr.w 置位,前后两次读改写都落进了 0x40011004。
翻车实录:写错半段的代价
例程的第一版,笔者亲手翻过一次车,当初还留下了一个注释。多亏了 git,笔者把它又翻了出来:
// 2) PC13 模式:pin ≥ 8 的配置段在 CRH(每脚 4 位:pin13 → 偏移
// (13-8)*4=20,CNF 占 bit23:22,MODE 占 bit21:20)。第一版把这段写进
// CRL(配成了 pin5),PC13 保持复位默认的浮空输入,BSRR/BRR 写了
// 也不驱动引脚 —— Renode 里 ODR 读数纹丝不动,当场抓包。2
3
4
错在哪?笔者把配置写进了 CRL,也就是管 pin0-7 的低半段。0x2 << 20 写进了 CRL,配到的是 (20-0)/4 = 5 号脚:PC5 莫名其妙成了 2MHz 推挽输出,PC13 的四位却原封不动,还是复位默认的浮空输入。
咱们把翻车现场在 Renode 里复现一遍:时钟合闸、CRH 不配、直接写 BSRR:
--- CRH before (model reset):
0x00000000
--- write 0x00002000 to BSRR, then ODR:
0x00000000 ← ODR 纹丝不动2
3
4
这毛病阴在哪?其实编译零警告、运行零报错。BSRR 的写入"成功"返回,程序照样跑它的循环,而引脚纹丝不动。真翻车的时候,咱们排障的第一反应就是把 CRH/ODR 读上一眼,配置要是没落地,后面写 BSRR 的每一步都在空转。至于"为什么浮空输入模式下输出寄存器不驱动引脚"?引脚内部那两片 MOS 管和四位配置的电路关系,正是下一篇的正题,这里就不展开了。
翻转的三条路
配置写好了,PC13 已经成了输出脚。现在咱们要它高低电平翻转,手册给了三条路。
最顺手的路子是直接改 ODR:GPIOC->ODR ^= 0x2000,或者您用 |= 置一位、&= ~ 清一位。干活的正是咱们第二篇见过的读改写三件套:ldr 读回、orr/bic/eor 修改、str 写回。第一条路的问题在于:读和写是两次分开的访问,而中间隔着一个窗口。
窗口里能出什么事?咱们把最坏的顺序摆一遍:主循环对 CRH 的读改写刚做完"读"和"改","写"的那一步还没轮到,中断进来了,ISR(中断服务函数)也动了同一个寄存器。中断到来时 CPU 转去执行的就是它。等中断返回的时候,主循环接着走它没走完的"写",把手里的旧值加上自己的修改写回 CRH,ISR 刚写进去的东西,就这么被无声覆盖了。单线程裸机里没人跟您抢,这个窗口也就无害了。不过一旦中断和主循环共享外设,它就是实打实的竞争条件。咱们今天把这个隐患记下,真碰上了再解决,那得等下一站的按钮中断。
第二条路的主角是 BSRR 和 BRR:手册专门设了这对寄存器,让咱们一次写就能改好一个位。BSRR 咱们刚在翻车实录里见过,配置没落地时,怎么写都不驱动引脚的就是它。这回配置是对的,咱们直接看指令:
08000178: 6125 str r5, [r4, #16] ; BSRR ← 0x2000,置位 PC13
08000182: 6165 str r5, [r4, #20] ; BRR ← 0x2000,复位 PC132
您数一数,一条 str 就把活干完了。
原子性从哪来?在 BSRR 的语义里:写 1 生效、写 0 无视。这个 32 位寄存器被分成了两半来用。bit0-15 的低半字是置位区:写哪一位,哪个脚就被顶上了高电平。bit16-31 的高半字是复位区:写哪一位,哪个脚就被拉低了。整个动作里没有"读回旧值"的一步:硬件按位独立处理这次写入,每一位只看您写了什么。上一条路留下的那个窗口,在这里压根就不存在了。
BRR 干的事更简单:把 BSRR 的复位区单独抽出来,做成一个专管拉低的寄存器,咱们用起来更直白。那万一一次写里同一位的置位和复位都写了 1 呢?赢的是置位区,这是 RM0008 原文写明的。
咱们例程 01_register_led 走的就是第二条路。灭灯靠的是对 BSRR 写 0x2000(1<<13)、把 BS13 写成 1,PC13 就被顶高了。点灯的时候咱们对 BRR 写 0x2000、把 BR13 写成 1,PC13 就被拉低了。这里有个初看别扭的细节:Blue Pill 的 LED 接在 PC13 上,偏偏低电平才点亮,所以拉低是亮、顶高是灭,"复位"反而对应点灯。刚上手的时候容易记反,写两回就顺了。
最后一条路是位带:给一个位配上专属的地址,Cortex-M3 把这个机制做进了硬件。外设区的头一兆(0x40000000-0x400FFFFF,GPIOC 的 0x40011000 在内),还有 SRAM 的头一兆。这两段里的每一个位,都在别名区里有一个自己的 32 位地址。往别名地址写值的时候,硬件只看写值的 bit[0]:写 1 原位置 1、写 0 原位清零。这是硬件保证的,单次访问天然就是原子的。地址的算术,咱们现场算一遍:
拿出一个小本本,跟我一起开写OwO:
别名地址 = 0x42000000 + (字节偏移 × 32) + (位序号 × 4)
GPIOC_ODR 的 bit13:
0x42000000 + (0x4001100C - 0x40000000) × 32 + 13 × 4
= 0x42000000 + 0x1100C × 0x20 + 0x34
= 0x42000000 + 0x220180 + 0x34
= 0x422201B42
3
4
5
6
7
咱们口说无凭,您看 Renode 的实测(时钟合闸后):
--- bit-band write 0x1 to 0x422201B4, then ODR:
0x00002000 ← bit13 应声而起
--- bit-band write 0x0 to 0x422201B4, then ODR:
0x00000000 ← 应声而落2
3
4
位带的好处是,一个位就是一个专属地址,一条指令就写完了,连 BSRR 那步"算掩码"都省了,读单个位也一样是原子的。代价是那个魔数地址可读性稀烂,所以库代码里少见裸写,咱们用的时候通常拿宏包一层。咱们把三条路摆一起:
| 路 | 指令数 | 原子性 | 读回来 | 适用 |
|---|---|---|---|---|
| ODR 读改写 | 3 | 无窗口保障 | 顺路能读 | 单线程、不在乎干扰 |
| BSRR/BRR | 1 | 单写原子 | 不行 | 中断在场时的正选 |
| 位带别名 | 1 | 读写都原子 | 单位可读 | 高频单位操作、ISR 标志 |
咱们走到这一步,裸寄存器点灯的活儿就全干完了,开钟是第三篇的事,配置和翻转都落在了本篇。不过有几个问题,咱们一路用着,一直没解释:CNF=00 凭什么叫"推挽"?浮空输入到底"浮"在哪?四位配置拧过去,引脚里面的电路发生了什么?下一篇咱们下到电路层把这几个问题讲清。
您来动手
- 您算一遍 PC2 的四位:它在 CRL 还是 CRH?四位是哪几位?2MHz 推挽输出时这两条配置代码怎么写?
- 您在 Renode 里合闸时钟后,手工把 CRH 配成
0x00300000(MODE=11,50MHz),再跑固件,观察翻转行为有没有可见差别,想想为什么。 - 用位带公式手算 IDR(
0x40011008)bit0 的别名地址,您到 Renode 里读它,和直接读 IDR 的结果比一比。 - 最后,把
register_led的main里 BSRR 那行改成GPIOC->ODR ^= 0x2000,您反汇编对比一下,前后main的指令数差几条。
欸欸!自查一下再走
- PC13 的四位为什么在 CRH 的 bit23-20?您拿
(13-8)×4现场推一遍。 - 复位默认
0x44444444,您拆开数一数,每个脚的默认配置是什么?这种默认为什么对上电安全很重要? - BSRR 的原子性,您觉得靠的是"单条指令",还是"写 1 生效、写 0 无视"的语义?两者缺一行不行?
- 位带地址
0x422201B4里的0x34是怎么来的?您代回上面的公式算一算。