作者:Andy Lok
原文首发知乎:https://zhuanlan.zhihu.com/p/52855259
这篇文章我们将会来讲讲嵌入式系统中非常重要的概念 —— 寄存器。因为单片机对于外界响应和自身功能的控制基本上全部都要通过寄存器进行交互,所以寄存器的使用将会贯穿整个单片机的学习过程。这篇文章将通过手把手重写我们的 Blinky 程序来介绍寄存器的概念和操作方法。
文章前半部分会先讲寄存器的基本原理,然后后半部分再通过代码示范寄存器的操作方法。
这里使用的嵌入式平台是 STM32F103,它的的寄存器手册可以在 这里 下载。
寄存器操作
在之前我们说过:
寄存器指代的是一段特殊的内存地址区域,但是它没有实际对应的 SRAM (Static Random-Access Memor, 静态随机存取存储器) 存储,对寄存器的操作与对内存的操作完全一致,可以将寄存器当作内存来读写,而对寄存器内存段的读写将会被转化为总线上与外设的数据交换。
所以对寄存器的操作实际上就是对特殊地址的内存进行读写操作。在手册中我们可以找到各寄存器的起始地址 (28页):
我们拿 GPIOA 外设的寄存器来做个例子,我们跳到手册中 GPIO 的章节 (115页),这里有一张表格列出了 GPIO_BSRR 寄存器的结构。
这个寄存器到底有什么用并不重要,我们这里只需掌握如何读懂寄存器表格:
第一行是偏移地址。偏移地址指明了这个寄存器相对于外设寄存器区段的位置,从起始地址表中我们可以知道 GPIOA 寄存器区段的起始地址是 0x4001_0800,而 GPIO_BSRR 的偏移地址为 0x10,因此 GPIOA 的 GPIOA_BSRR 寄存器的真正地址即为 0x4001_0800 + 0x10 = 0x4001_0810。
下面的两行格子是寄存器位的说明。格子上的数字是位偏移地址,格子中间的是位的名称,格子下面的是可读写性,这里格子下方都是 w,也就是说这些位都是只写位。
根据下方说明,如果我们要对 ODR3(另一个寄存器的位) 清0,我们就要对 BR3 写1。这个操作实际上就是对 0x4001_0810 内存地址写 0x1 << 19 (除第19位以外都是0的32位无符号整数)。
使用 Rust 来操作就是这样:
core::ptr::write_volatile(0x4001_0810 as *mut u32, 1 << 19);
GPIO(通用接口)
Blinky 的原理很简单,只需定时改变连接 LED 的引脚的电平,就可以让 LED 闪烁起来了。我们查看核心板的电路原理图可以发现 LED 被连接在了 PC13 引脚上,而且从原理图中可以看出 LED 采用了共阳极接法,当引脚输出低电平时 LED 才会点亮:
STM32F103C8T6 引脚图
注意:有的 STM32F103 核心板 LED 会连接在 PB12 引脚上,需要查看原理图来确定。
STM32 中的引脚被分为了 GPIOA,GPIOB,GPIOC,GPIOD ... 等等多个组,每组中各控制有 16 个引脚,每个组都是一个独立的外设。
在这里,我们需要学习 GPIO 两个关键寄存器:配置寄存器 (GPIOx_CRL,GPIOx_CRH) 和置位/复位寄存器 (GPIOx_BSRR)。 (寄存器名中的 x 即为 GPIO 分组中的 A, B, C .. 等等)
GPIO 配置寄存器
单片机的引脚往往兼有多种功能,比如输入或输出,因此在使用引脚之前要通过配置寄存器配置它的功能。
我们注意到这里出现了两个配置寄存器 GPIOx_CRL 和 GPIOx_CRH,这其实是配置寄存器的高/低部分,低寄存器 (GPIOx_CRL) 负责配置 0..7 号引脚,高寄存器 (GPIOx_CRH) 负责配置 8..15 号引脚。
GPIO 拥有以下几种模式:
- 输入浮空
- 输入上拉
- 输入下拉
- 模拟输入
- 开漏输出
- 推挽式输出
- 推挽式复用功能 ─ 开漏复用功能
输入可以理解为读取引脚上的电平,相反,输出就是控制引脚电平。因为我们想要通过控制引脚电平来点亮 LED,所以我们这里选择输出模式。
输出模式有 推挽式输出 和 开漏输出 两种。推挽输出模式下引脚可以自行输出高低两种电平,但是电流驱动力较弱,适合于和数字元件通讯或驱动 LED;开漏输出只有低电平和截止两种状态,所以需要在电路上加上 上拉电阻 (一端电源一端接引脚的电阻) 才能在截止状态下输出高电平,开漏输出的电流驱动能力更强, 适合于做电流型的驱动。
这里我们选择最简单的推挽式输出模式就可以了。
查阅手册我们可以找到配置寄存器的结构 (114页):
PC13 引脚对应了 MODE13 和 CNF13 两段寄存器位,我们将 MODE13 设置为输出模式即 0x11 (最大速度指的是最大电平翻转频率,这里任选一个都行),然后将 CNF13 设为 0x00 就可以推挽输出了。
GPIO 置位/复位寄存器
置位/复位寄存器专门用于操作引脚输出电平,对 BR (R意为Reset) 写1会让对应引脚输出低电平,对 BS (S意为Set) 写1会让对应引脚输出高电平。操作十分简单,这里就不赘述了。
RCC 总线开关
总线就是之前提到过的时间总线 APB1 和 APB2。单片机中的任何外设都需要从总线上获取时间信号,然而在单片机启动复位后,所有外设都是默认关闭来节省能源,因此在使用外设前需要手动打开总线开关。
RCC (Reset and Clock Control,复位和时钟控制器) 负责单片机时间总线相关的配置,它的 APB2ENR 寄存器用于开关 APB2 总线上的外设。而 GPIO 外设位于 APB2 总线上,我们查找 RCC_APB2ENR 寄存器 (95页):
从图中可知,对 APB2ENR 的 IOPCEN 写 1 就可以启动 GPIOC 外设。
Blinky 示例
我们打开之前文章建立的工程项目,修改 src/main.rs 恢复为最小可编译版本:
#![no_std]
#![no_main]
extern crate panic_halt;
use core::ptr;
use cortex_m::asm;
use cortex_m_rt::entry;
use stm32f103xx;
#[entry]
fn main() -> ! {
asm::nop();
loop { }
}
修改 Cargo.toml 中的依赖。在这里我们暂时没有使用 stm32f103xx 的寄存器功能,只是让编译器自动链接它提供的中断向量表,否则会无法编译:
[denpendencies]
cortex-m = "0.5.8"
cortex-m-rt = "0.6.5"
panic-halt = "0.2.0"
stm32f103xx = "0.11"
我们根据手册的信息定义寄存器的地址:
const RCC_APB2ENR: *mut u32 = (0x4002_1000 + 0x18) as *mut u32;
const GPIOC_CRH: *mut u32 = (0x4001_1000 + 0x04) as *mut u32;
const GPIOC_BSRR: *mut u32 = (0x4001_1000 + 0x10) as *mut u32;
再定义要用到的寄存器位偏移量:
const APB2ENR_IOPCEN: usize = 4;
const CRH_MODE13: usize = 20;
const BSRR_BS13: usize = 13;
const BSRR_BR13: usize = 13 + 16;
修改 main 函数。
#[entry]
fn main() -> ! {
unsafe {
// 启用 GPIOC
ptr::write_volatile(RCC_APB2ENR, 1 << APB2ENR_IOPCEN);
// 配置 GPIOC - PC13 为推挽输出
ptr::write_volatile(GPIOC_CRH, 0b0011 << CRH_MODE13);
// 重置 PC13 以输出低电平
ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13);
}
loop { }
}
注意这里使用了 ptr::write_volatile() 进行内存写入操作,这是因为如果使用 ptr::write() 函数,编译器有可能会把内存的写入操作优化掉或者调换执行顺序,这在内存操作上可以提高效率,但在寄存器上会完全改变我们程序的意图,导致不可预测的后果。对寄存器的读操作也同样不能使用 ptr::read() 而要使用 ptr::read_volatile()。
此时编译运行就能看到点亮的 LED 了。
接下来我们制造一个简单的延迟函数:
fn delay() {
for _ in 0..2_000 {
asm::nop();
}
}
这里使用了一个汇编函数 nop,即为 No Operation。它会空转耗费 CPU 一个时钟周期,然后我们再对它循环来得到一个肉眼可见的延迟。
其实按照 Cortex-M3 72MHz 的时钟速率来计算,2000 周期级别的延迟也应该在毫秒级以下,然而这里的延迟竟然可以达到半秒左右。这是因为在单片机刚启动的时候,芯片默认采用了启动较快但是频率较低的内部时钟,频率大概在 40kHz 左右,一般情况下我们在复位后要设置 RCC 的寄存器将时钟源转为外部高速时钟,这部分我们留到之后再细讲。
修改 loop 循环:
loop {
delay();
// Reset:输出低电平,点亮 LED
unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13); }
delay();
// Set:输出高电平,LED 熄灭
unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BS13); }
}
至此我们的寄存器版本的 Blinky 就完成了!下面是完整代码:
#![no_std]
#![no_main]
extern crate panic_halt;
use core::ptr;
use stm32f103xx;
use cortex_m::asm;
use cortex_m_rt::entry;
const RCC_APB2ENR: *mut u32 = (0x4002_1000 + 0x18) as *mut u32;
const GPIOC_CRH: *mut u32 = (0x4001_1000 + 0x04) as *mut u32;
const GPIOC_BSRR: *mut u32 = (0x4001_1000 + 0x10) as *mut u32;
const APB2ENR_IOPCEN: usize = 4;
const CRH_MODE13: usize = 20;
const BSRR_BS13: usize = 13;
const BSRR_BR13: usize = 13 + 16;
#[entry]
fn main() -> ! {
unsafe {
// 启用 GPIOC
ptr::write_volatile(RCC_APB2ENR, 1 << APB2ENR_IOPCEN);
// 配置 GPIOC - PC13 为推挽输出
ptr::write_volatile(GPIOC_CRH, 0b0011 << CRH_MODE13);
// 重置 PC13 以输出低电平
ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13);
}
loop {
delay();
// Reset:输出低电平,点亮 LED
unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BR13); }
delay();
// Set:输出高电平,LED 熄灭
unsafe { ptr::write_volatile(GPIOC_BSRR, 1 << BSRR_BS13); }
}
}
fn delay() {
for _ in 0..2_000 {
asm::nop();
}
}
Blinky: 抽象
上面代码中使用的就是 C 语言中操作寄存器的方法,简单直接。虽然这样可用,但是可以看出这样操作的语义非常模糊,常常需要反复翻查手册,而且这样会大量使用 unsafe 内存操作,很容易发生人为错误。幸好,Rust 为我们提供了更安全的抽象,可以极大地改善以上两个问题。
stm32f103xx 库安全地封装了寄存器的操作接口,而且它是由 svd2rust 自动生成的,所以可以杜绝人工错误。在 这里 可以找到它的文档。
我们来看看怎样使用这个库:
// 获取 Peripherals
let dp = stm32f103xx::Peripherals::take().unwrap();
// 启用 GPIOC
dp.RCC.apb2enr.write(|w| w.iopben().enabled());
第一行的 stm32f103xx::Peripherals::take() 只会在第一次调用时返回 Some(dp),这样避免了存在多个寄存器实例而的导致数据竞争。
Peripherals 是一个结构体,它拥有所有外设的接口定义,比如说这里的 RCC。可以对 RCC 的 apb2enr 寄存器进行写操作,这个库对寄存器的读写操作都被包含在了闭包中,这样库可以在读写前后执行一些保险操作(重置寄存器值或关闭中断)。w 是 apb2enr 的写入器,我们对其调用 w.iopben().enabled() 和之前使用 unsafe 写入内存完全等价,而且 zero-cost,编译后的指令一般不会有差别。
同理我们对 GPIOC 的操作可以改写为:
// 配置 PC13
dp.GPIOC.crh.write(|w| w.mode13().output().cnf13().push());
// Set
dp.GPIOC.bsrr.write(|w| w.bs13().set());
// Reset
dp.GPIOC.bsrr.write(|w| w.br13().reset());
完整代码:
#![no_std]
#![no_main]
extern crate panic_halt;
use core::ptr;
use stm32f103xx;
use cortex_m::asm;
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
// 获取 Peripherals
let dp = stm32f103xx::Peripherals::take().unwrap();
// 启用 GPIOC
dp.RCC.apb2enr.write(|w| w.iopben().enabled());
// 配置 PC13
dp.GPIOC.crh.write(|w| w.mode13().output().cnf13().push());
loop {
delay();
// Reset:输出低电平,点亮 LED
dp.GPIOC.bsrr.write(|w| w.br13().reset());
delay();
// Set:输出高电平,LED 熄灭
dp.GPIOC.bsrr.write(|w| w.bs13().set());
}
}
fn delay() {
for _ in 0..2_000 {
asm::nop();
}
}
相比于 C style 的寄存器操作,svd2rust 封装了所有寄存器地址信息,而且不需要使用任何 unsafe 代码,这在 Rust 中保证了不会出现任何内存错误。
Blinky: 再抽象
stm32f103xx 的表现非常惊艳,但是这还没能完全发掘 Rust 的潜力。嵌入式工作组为我们提供了 embedded-hal 抽象库,stm32f103xx-hal 就是 embedded-hal 在 stm32f103 上的具体实现。stm32f103xx-hal 库在 stm32f103xx 的基础上再次抽象封装了寄存器的逻辑细节。比如说,stm32f103xx-hal 可以在我们使用 GPIOC 前自动启用 apb2enr 总线开关。同样,这个库也是 zero-cost 的。
修改 Cargo.toml,添加依赖:
[dependencies.stm32f103xx-hal]
features = ["rt"]
git = "https://github.com/japaric/stm32f103xx-hal"
在 src/main.rs 里引入 hal:
extern crate stm32f103xx_hal as hal;
use hal::prelude::*;
hal::prelude 中定义了许多 trait,这些 trait 默认实现于外设结构体(比如说 RCC)上来提供 constrain() 转换函数。constrain() 会将 stm32f103xx 的外设实例转化为 stm32f103xx-hal 中的外设类型。
let dp = stm32f103xx::Peripherals::take().unwrap();
// 将 RCC 寄存器结构体转换为进一步抽象的 hal 结构体
let mut rcc = dp.RCC.constrain();
// 获取 GPIOC 实例,这里会自动打开总线开关
let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);
// 获取 PC13 实例,并进行引脚配置
let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);
// 输出高电平
led.set_high();
// 输出低电平
led.set_low();
完整代码:
#![no_std]
#![no_main]
extern crate panic_halt;
extern crate stm32f103xx_hal as hal;
use core::ptr;
use stm32f103xx;
use cortex_m::asm;
use cortex_m_rt::entry;
use hal::prelude::*;
#[entry]
fn main() -> ! {
// 获取 Peripherals
let dp = stm32f103xx::Peripherals::take().unwrap();
// 将 RCC 寄存器结构体转换为进一步抽象的 hal 结构体
let mut rcc = dp.RCC.constrain();
// 获取 GPIOC 实例,这里会自动打开总线开关
let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);
// 获取 PC13 实例,并进行引脚配置
let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);
loop {
delay();
// 输出低电平
led.set_low();
delay();
// 输出高电平
led.set_high();
}
}
fn delay() {
for _ in 0..2_000 {
asm::nop();
}
}
Conclusion
这篇文章篇幅较长,从寄存器原理一直讲到了内存操作方法,然后展示了如何通过 Rust 强大的抽象能力将零散的内存操作隐藏在安全的操作接口后面,并且还基于 embedded-hal 对寄存器操作的逻辑再一次抽象,得到了安全且容易使用的 API,还可以根据需要灵活选择抽象级别。相信读者已经能感受到Rust 在嵌入式领域相对于 C 的巨大的优势了。