从寄存器到通信:在STM32上手写一个可靠的串口驱动
你有没有遇到过这样的情况——项目紧急,板子焊好了,代码跑不起来,串口没输出?用CubeMX生成的代码太大,Bootloader里塞不下;HAL库又太“重”,还藏着你看不见的坑。这时候,如果能自己动手、从零配置一个干净利落的串口通信,那感觉,就像在荒野中点亮了一盏灯。
今天我们就来干这件事:不用任何库函数,不依赖CubeMX,直接操作寄存器,在STM32F103上实现一套完整的中断式串口收发系统。这不是理论课,是实打实的“裸机编程”实战,适合想真正搞懂外设机制的工程师,也适合需要精简固件或调试底层问题的开发者。
为什么还要学寄存器级串口配置?
你说现在都有HAL和LL库了,动动鼠标就能出代码,为啥还要啃寄存器?
因为——当你面对的是Bootloader、安全启动、资源极度受限的MCU,或者某个莫名其妙丢数据的问题时,那些封装好的API反而成了黑盒。
而当你亲手写过一遍USART->BRR = ...,调过一次NVIC优先级,清过一次溢出标志位,你就知道:
- 哪些步骤不能少
- 哪些顺序不能乱
- 哪些错误会悄悄吞噬你的数据
这才是嵌入式开发的底气。
先看一眼硬件:USART到底是怎么工作的?
我们常说“串口”,其实在STM32里叫USART(通用同步/异步收发器)。它支持同步和异步两种模式,但我们最常用的,就是异步串行通信(UART模式)。
它的基本帧结构很简单:
[起始位] [数据位(8)] [校验位(可选)] [停止位(1)]发送方和接收方没有共用时钟,全靠事先约定好的波特率来对齐采样点。比如115200bps,意味着每秒传115200个比特。
STM32内部是怎么做到精准控制这个节奏的呢?核心靠三个部分:
- 波特率发生器:基于PCLK分频,算出一个叫
USARTDIV的值,填进BRR寄存器; - 移位寄存器:把并行字节变成一位一位往外推;
- 状态机与中断逻辑:告诉你“可以发了”、“有数据来了”。
整个过程不需要CPU一直盯着,只要设置好中断,剩下的交给硬件自动完成。
第一步:打开时钟——所有初始化的前提
在STM32的世界里,一切外设操作都始于时钟使能。没电的东西,再聪明也没用。
我们要用的是 USART1,它是挂在 APB2 总线上的(比APB1快),对应的IO口是PA9(Tx)、PA10(Rx),属于GPIOA。
所以第一步,必须打开这三个时钟:
- GPIOA 时钟
- AFIO 时钟(即使不用重映射,F1系列也要开)
- USART1 时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN // 使能GPIOA | RCC_APB2ENR_AFIOEN // 使能AFIO(F1必需) | RCC_APB2ENR_USART1EN; // 使能USART1⚠️ 注意顺序:一定要先开时钟,再配置引脚!否则你写的寄存器可能根本不起作用。
第二步:配置GPIO复用——让PA9和PA10“改行”
默认情况下,PA9和PA10就是普通IO口。要让它变成串口Tx/Rx,就得启用“复用功能”(Alternate Function)。
对于STM32F1系列:
- PA9 → USART1_Tx → 复用推挽输出
- PA10 → USART1_Rx → 浮空输入(也可以上拉)
我们通过GPIOA->CRH寄存器来配置这两个引脚(因为它们属于高8位端口PIN8~15):
// 清除PA9和PA10原有配置 GPIOA->CRH &= ~(0xFF << 4); // 清CRH中CNF9和MODE9 GPIOA->CRH &= ~(0xF << 8); // 清CNF10和MODE10 // 配置PA9为复用推挽输出,最大速度50MHz GPIOA->CRH |= (0xB << 4); // 1011: MODE=11, CNF=11 → AF PP // 配置PA10为浮空输入 GPIOA->CRH |= (0x4 << 8); // 0100: MODE=00, CNF=01 → Floating Input🔍 小知识:为什么Tx要用推挽?为了驱动能力强,信号边沿陡峭;Rx设为浮空是因为通常由外部设备拉低,内部无需主动驱动。
第三步:计算并设置波特率
这是最容易出错的地方之一。很多人以为随便设个数就行,结果通信不稳定、乱码频发。
STM32使用公式:
$$
\text{Baud Rate} = \frac{f_{PCLK}}{16 \times \text{USARTDIV}}
$$
其中USARTDIV是一个带小数的数,存储在BRR寄存器中,格式为:
- 高12位:整数部分(DIV_Mantissa)
- 低4位:小数部分(DIV_Fraction)
假设系统时钟72MHz,PCLK2也是72MHz(USART1挂APB2),目标波特率115200:
$$
\text{USARTDIV} = \frac{72000000}{16 \times 115200} ≈ 39.0625
$$
于是:
- 整数部分 = 39
- 小数部分 = 0.0625 × 16 ≈ 1
所以BRR = (39 << 4) | 1
USART1->BRR = (39 << 4) | 1;✅ 推荐做法:写一个宏来自动生成BRR值,避免手动计算错误。
#define UART_BRR(pclk, baud) ((pclk) / (baud * 16))但注意,这只是一个近似值,实际应用中建议测试误差是否小于3%。
第四步:配置USART控制寄存器
接下来是关键一步:告诉USART我要干什么。
我们需要设置:
- 使能发送(TE)和接收(RE)
- 使能接收中断(RXNEIE)
- 使能USART本身(UE)
这些都在CR1寄存器里搞定:
USART1->CR1 = 0; // 先清零,避免残留位影响 USART1->CR1 |= USART_CR1_TE // 使能发送 | USART_CR1_RE // 使能接收 | USART_CR1_RXNEIE // 使能接收中断 | USART_CR1_UE; // 启动USART其他参数如数据位(8/9)、校验位等也可以在这里设置:
-M位控制字长(0=8位,1=9位)
-PCE和PS控制是否开启奇偶校验
目前我们用最常见组合:8N1(8数据位,无校验,1停止位),保持默认即可。
第五步:开启NVIC中断,让CPU响应事件
光开了外设中断还不行,还得让CPU的中断控制器(NVIC)允许这个中断进来。
USART1 的中断向量号是 37,在CMSIS中定义为USART1_IRQn。
NVIC_EnableIRQ(USART1_IRQn);如果你的系统中有多个中断,还可以设置优先级:
NVIC_SetPriority(USART1_IRQn, 5); // 设置优先级为5这样当数据到来时,CPU就会暂停当前任务,跳转到中断服务程序去处理。
第六步:编写中断服务函数——真正干活的地方
中断来了之后做什么?两件事:
1. 判断是不是接收中断(RXNE置位)
2. 读取DR寄存器拿数据,并存入缓冲区
这里我们引入一个环形缓冲区,防止高速输入时数据被覆盖。
#define RX_BUFFER_SIZE 64 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_head = 0; // 写指针 void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { // 接收到新数据? uint8_t data = USART1->DR; // 读DR自动清除标志 rx_buffer[rx_head % RX_BUFFER_SIZE] = data; rx_head++; } }🧠 关键点解释:
-volatile是必须的,告诉编译器别优化掉这个变量。
-% RX_BUFFER_SIZE实现循环索引,空间满了就从头覆盖(可根据需求改为防溢出策略)。
- 读DR寄存器的同时会清除RXNE标志,这是硬件设计决定的。
补充:实现非阻塞发送
虽然接收用了中断,但发送我们可以选择更灵活的方式。
方式一:轮询发送(简单实用)
适用于偶尔发几个字节,比如打印调试信息。
void usart_send_byte(uint8_t byte) { while (!(USART1->SR & USART_SR_TXE)); // 等待发送寄存器空 USART1->DR = byte; } void usart_send_string(const char* str) { while (*str) { usart_send_byte(*str++); } }方式二:中断发送(适合连续大数据)
如果你想发一包数据而不卡主线程,可以用TXE中断。
思路是:
- 第一个字节手动写入DR触发发送;
- 每次TXE中断检查是否有更多数据要发;
- 发完最后一个字节后关闭TXEIE。
这种方式稍复杂,但在实时系统中很有价值。
常见“踩坑”与调试秘籍
别以为写了代码就万事大吉,下面这些坑我全都踩过:
❌ 数据乱码?
- 检查PCLK频率是否正确(尤其是倍频后);
- 波特率计算有没有四舍五入偏差;
- 双方是否都是8N1?Windows串口助手常默认有校验位!
❌ 收不到中断?
- NVIC有没有使能?
- 是否忘了开全局中断
__enable_irq()? - 引脚接反了?Tx连Tx?
❌ 缓冲区溢出?
- 中断处理太慢,主循环来不及消费数据;
- 解决方案:加DMA,或者提高中断优先级。
❌ ORE(溢出错误)频繁出现?
- CPU来不及处理上一条数据,新数据又到了;
- 检查中断是否被长时间屏蔽(比如关总中断太久);
- 考虑增加硬件流控或降低波特率。
实际应用场景举例
这套轻量级串口驱动特别适合以下场景:
✅ Bootloader通信协议
- 接收PC下发的固件包
- 使用简单的帧格式(如
SOH + LEN + DATA + CRC + EOT) - 主循环解析命令,中断负责收数据
✅ 传感器数据透传
- STM32采集ADC、温湿度,通过串口转发给网关
- 不用手动轮询,节省功耗
✅ 多机协同控制
- 多块STM32之间用串口组网
- 每台分配地址,支持广播/点对点
进阶方向:你可以继续做什么?
你现在掌握的只是一个起点。下一步可以尝试:
- 加入DMA:实现零CPU干预的大批量收发;
- 实现软件流控:用XON/XOFF应对缓冲压力;
- 添加超时机制:识别不定长帧结束(如JSON字符串);
- 封装成标准接口:提供
read()/write()类POSIX API; - 移植到其他型号:F4/F7/H7系列寄存器略有不同,但原理一致。
甚至可以用这套思想去理解Linux下的TTY子系统——底层逻辑永远相通。
写在最后:回归本质的力量
在这个动辄调用.init()函数的时代,愿意静下心来看一眼BRR寄存器的人越来越少。
但正是这些看似“过时”的技能,让你在芯片启动的第一毫秒就能掌控全局,在别人还在查HAL库bug的时候,你已经把第一行日志打出来了。
真正的自由,不是依赖多少工具,而是知道工具背后的真相。
下次当你面对一块新的MCU,没有CubeMX支持,也没有现成例程时,希望你能想起今天这一课:
从时钟开始,一步步点亮串口,让两个世界第一次对话。
如果你实现了自己的串口驱动,欢迎在评论区分享你的经验。