2. MCU硬件基础回顾:GPIO、UART、SPI、I2C、定时器、中断控制器核心寄存器操作
做嵌入式底层开发,说白了就是跟寄存器打交道。你写的每一行代码,最终都要落到某个寄存器的某一位上。我见过不少工程师,框架设计得天花乱坠,结果连GPIO输出模式都配错,一上电就冒烟。嗯,这章咱们就把MCU这几个最常用的外设寄存器扒开看看。
本章核心:掌握GPIO、UART、SPI、I2C、定时器、中断控制器这六大外设的寄存器操作模式,理解它们之间的共性,为后续抽象层设计打下基础。
2.1 GPIO:最基础也最容易翻车的外设
GPIO看似简单,但坑不少。我刚开始做项目时,有一次把推挽输出配成了开漏,结果驱动LED死活不亮,查了半天才发现是寄存器配错了。
GPIO的核心寄存器就那么几个:
| 寄存器类型 | 作用 | 常见位宽 |
|---|---|---|
| MODER | 模式选择(输入/输出/复用/模拟) | 2位/引脚 |
| OTYPER | 输出类型(推挽/开漏) | 1位/引脚 |
| OSPEEDR | 输出速度(低速/中速/高速/极速) | 2位/引脚 |
| PUPDR | 上下拉配置(无/上拉/下拉) | 2位/引脚 |
| IDR | 输入数据寄存器 | 只读 |
| ODR | 输出数据寄存器 | 读写 |
| BSRR | 位设置/复位寄存器(原子操作) | 32位 |
我的习惯:操作GPIO输出时,尽量用BSRR而不是ODR。BSRR支持原子操作,不会因为读-改-写被中断打断。尤其是在多任务环境下,这个细节能省掉你很多调试时间。
举个例子,配置PA5为推挽输出、高速、无上下拉:
// 假设基地址为 GPIOA_BASE
#define GPIOA_MODER (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_OTYPER (*(volatile uint32_t *)(GPIOA_BASE + 0x04))
#define GPIOA_OSPEEDR (*(volatile uint32_t *)(GPIOA_BASE + 0x08))
#define GPIOA_PUPDR (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
// PA5: MODER[11:10] = 01 (输出模式)
GPIOA_MODER &= ~(0x3 << 10); // 先清零
GPIOA_MODER |= (0x1 << 10); // 设为输出
// OTYPE: 推挽 (0)
GPIOA_OTYPER &= ~(0x1 << 5);
// OSPEED: 高速 (10)
GPIOA_OSPEEDR &= ~(0x3 << 10);
GPIOA_OSPEEDR |= (0x2 << 10);
// PUPD: 无上下拉 (00)
GPIOA_PUPDR &= ~(0x3 << 10);
我曾经踩过的坑:配置复用功能时,很多人只配了MODER就以为完事了。实际上,像UART的TX/RX引脚,除了MODER要设为复用模式,还得在AFR(复用功能选择寄存器)里选对具体的复用编号。否则数据死活发不出去。
2.2 UART:异步通信的寄存器细节
UART的寄存器设计,不同厂商差异很大。但核心功能寄存器是共通的:
- 数据寄存器(DR):发送和共用一个地址,写就是发,读就是收
- 波特率寄存器(BRR):决定通信速率
- 控制寄存器(CR1/CR2/CR3):使能、数据位、停止位、校验位
- 状态寄存器(SR):TXE(发送空)、RXNE(接收非空)、TC(发送完成)
我个人习惯,初始化UART时按这个顺序来:
- 使能UART时钟和GPIO时钟
- 配置TX/RX引脚为复用功能
- 配置波特率(注意:不同芯片的BRR计算公式不一样)
- 配置数据格式(8N1是最常用的)
- 使能UART模块
- 使能发送和接收
发送一个字节的轮询方式:
void uart_send_byte(uint32_t base, uint8_t data) {
// 等待发送缓冲区空
while (!(*(volatile uint32_t *)(base + SR) & (1 << 7)));
// 写入数据
*(volatile uint32_t *)(base + DR) = data;
}
注意:有些芯片的DR寄存器是16位的,高8位可能用于其他功能。写数据时最好只操作低8位,避免误改其他位。
2.3 SPI:同步通信的寄存器操作
SPI比UART多一根时钟线,寄存器设计也更复杂。核心寄存器包括:
| 寄存器 | 关键位 | 说明 |
|---|---|---|
| CR1 | CPOL, CPHA, MSTR, SPE, BR[2:0] | 时钟极性、相位、主从模式、使能、波特率 |
| CR2 | SSOE, TXEIE, RXNEIE | 片选控制、中断使能 |
| SR | TXE, RXNE, BSY | 状态标志 |
| DR | 数据寄存器 | 读写共用 |
SPI通信有个特点:发送和接收是同时进行的。你写一个字节进DR,同时就会收到一个字节。所以收发流程通常是:
uint8_t spi_transfer_byte(uint32_t base, uint8_t data) {
// 等待发送缓冲区空
while (!(*(volatile uint32_t *)(base + SR) & (1 << 1)));
// 发送数据
*(volatile uint32_t *)(base + DR) = data;
// 等待接收缓冲区非空
while (!(*(volatile uint32_t *)(base + SR) & (1 << 0)));
// 读取接收数据
return (uint8_t)(*(volatile uint32_t *)(base + DR));
}
我曾经遇到的问题:SPI的时钟极性(CPOL)和相位(CPHA)一定要和从设备匹配。有一次我调试一个LCD屏,画面一直花屏,查了两天才发现是CPOL配反了。建议先把这两个位的组合画个时序图,再动手配寄存器。
2.4 I2C:地址与时钟同步
I2C的寄存器操作比SPI更复杂,因为它涉及地址、ACK/NACK、时钟拉伸等机制。核心寄存器:
- CR1/CR2:控制寄存器,配置使能、中断、DMA等
- OAR1/OAR2:自身地址寄存器(从机模式用)
- DR:数据寄存器
- SR1/SR2:状态寄存器,包含SB、ADDR、TXE、RXNE、AF(应答失败)等标志
- CCR:时钟控制寄存器,决定SCL频率
I2C的主机发送流程:
// 发送起始条件 + 从机地址 + 写位
// 等待SB标志置位
// 发送从机地址(左移1位,最低位为0表示写)
// 等待ADDR标志置位,然后清除
// 发送数据字节,每次等待TXE
// 发送停止条件
我的经验:I2C调试时,用逻辑分析仪抓波形是最快的。别光盯着寄存器看,波形能告诉你地址对不对、ACK有没有、时钟有没有被拉低。我曾经靠这个半小时解决了一个困扰团队两天的I2C通信问题。
2.5 定时器:不只是计时
定时器是MCU里最灵活的外设之一。基本定时器只有简单的计数功能,而通用/高级定时器还支持PWM、输入捕获、输出比较、编码器接口等。
核心寄存器:
| 寄存器 | 作用 |
|---|---|
| CNT | 计数器当前值 |
| PSC | 预分频器,决定计数时钟频率 |
| ARR | 自动重装载值,决定计数周期 |
| CCR1~CCR4 | 捕获/比较值,用于PWM占空比或捕获时刻 |
| SR | 状态寄存器,包含UIF(更新中断标志)、CC1IF等 |
| DIER | 中断使能寄存器 |
配置一个1ms中断的定时器(假设时钟72MHz):
// 预分频 7200-1,计数频率 = 72MHz / 7200 = 10kHz
TIM->PSC = 7200 - 1;
// 自动重装载 10-1,周期 = 10 * 0.1ms = 1ms
TIM->ARR = 10 - 1;
// 清除更新中断标志
TIM->SR &= ~(1 << 0);
// 使能更新中断
TIM->DIER |= (1 << 0);
// 使能计数器
TIM->CR1 |= (1 << 0);
注意:预分频器PSC是16位的,最大值65535。如果你的时钟频率很高,又想得到很慢的定时,可能需要级联两个定时器。我做过一个项目需要1秒中断一次,72MHz时钟直接配PSC和ARR会溢出,最后用了定时器级联才搞定。
2.6 中断控制器:NVIC的核心寄存器
ARM Cortex-M系列的中断控制器叫NVIC(嵌套向量中断控制器)。它管理着所有外设中断的使能、挂起、优先级等。
核心寄存器组:
- ISER[0..7]:中断使能设置寄存器,写1使能
- ICER[0..7]:中断清除寄存器,写1禁止
- ISPR[0..7]:中断挂起设置寄存器,用于软件触发中断
- ICPR[0..7]:中断挂起清除寄存器
- IPR[0..59]:中断优先级寄存器,每个中断占8位(实际只用高4位)
使能一个中断的典型操作:
// 使能USART1中断(假设中断号37)
// 37 / 32 = 1,所以用ISER[1]
// 37 % 32 = 5,所以位5
NVIC->ISER[1] = (1 << 5);
// 设置优先级为2(0为最高,数值越大优先级越低)
// 每个IPR寄存器包含4个中断的优先级
// 37 / 4 = 9,所以用IPR[9]
// 37 % 4 = 1,所以偏移8位(每个优先级占8位)
NVIC->IPR[9] = (NVIC->IPR[9] & ~(0xFF << 8)) | (2 << 4 << 8);
我的习惯:中断优先级分组(PRIGROUP)一定要在系统初始化时就设好,而且只设一次。我见过有人在中断服务函数里改优先级分组,结果导致系统崩溃。另外,中断服务函数要尽量短,把耗时操作放到任务级去处理。
2.7 知识结构总览
下面这张图把本章的核心知识点串起来了。你可以看到,所有外设的操作都遵循一个模式:时钟使能 → 寄存器配置 → 状态查询 → 数据交互。