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时按这个顺序来:

  1. 使能UART时钟和GPIO时钟
  2. 配置TX/RX引脚为复用功能
  3. 配置波特率(注意:不同芯片的BRR计算公式不一样)
  4. 配置数据格式(8N1是最常用的)
  5. 使能UART模块
  6. 使能发送和接收

发送一个字节的轮询方式:

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 知识结构总览

下面这张图把本章的核心知识点串起来了。你可以看到,所有外设的操作都遵循一个模式:时钟使能 → 寄存器配置 → 状态查询 → 数据交互。

MCU外设寄存器操作知识结构 MCU外设寄存器操作知识结构 MCU外设寄存器操作 GPIO UART SPI I2C 定时器 NVIC MODER OTYPER OSPEEDR BSRR / ODR DR BRR CR1/CR2/CR3 SR CR1/CR2 SR DR (CPOL/CPHA) CR1/CR2 SR1/SR2 DR CCR CNT / PSC ARR CCR1~CCR4 SR / DIER ISER/ICER ISPR/ICPR IPR (优先级分组) 通用操作模式:时钟使能 → 寄存器配置 → 状态查询 → 数据交互 设计抽象层时的关键思路 1. 找出各外设的共性操作模式(初始化、发送、接收、状态查询) 2. 将寄存器操作封装成与硬件无关的接口 3. 用结构体指针访问寄存器,提高代码可移植性