宣城市网站建设_网站建设公司_AJAX_seo优化
2026/1/15 4:24:56 网站建设 项目流程

从寄存器到通信:在STM32上手写一个可靠的串口驱动

你有没有遇到过这样的情况——项目紧急,板子焊好了,代码跑不起来,串口没输出?用CubeMX生成的代码太大,Bootloader里塞不下;HAL库又太“重”,还藏着你看不见的坑。这时候,如果能自己动手、从零配置一个干净利落的串口通信,那感觉,就像在荒野中点亮了一盏灯。

今天我们就来干这件事:不用任何库函数,不依赖CubeMX,直接操作寄存器,在STM32F103上实现一套完整的中断式串口收发系统。这不是理论课,是实打实的“裸机编程”实战,适合想真正搞懂外设机制的工程师,也适合需要精简固件或调试底层问题的开发者。


为什么还要学寄存器级串口配置?

你说现在都有HAL和LL库了,动动鼠标就能出代码,为啥还要啃寄存器?

因为——当你面对的是Bootloader、安全启动、资源极度受限的MCU,或者某个莫名其妙丢数据的问题时,那些封装好的API反而成了黑盒

而当你亲手写过一遍USART->BRR = ...,调过一次NVIC优先级,清过一次溢出标志位,你就知道:

  • 哪些步骤不能少
  • 哪些顺序不能乱
  • 哪些错误会悄悄吞噬你的数据

这才是嵌入式开发的底气。


先看一眼硬件:USART到底是怎么工作的?

我们常说“串口”,其实在STM32里叫USART(通用同步/异步收发器)。它支持同步和异步两种模式,但我们最常用的,就是异步串行通信(UART模式)

它的基本帧结构很简单:

[起始位] [数据位(8)] [校验位(可选)] [停止位(1)]

发送方和接收方没有共用时钟,全靠事先约定好的波特率来对齐采样点。比如115200bps,意味着每秒传115200个比特。

STM32内部是怎么做到精准控制这个节奏的呢?核心靠三个部分:

  1. 波特率发生器:基于PCLK分频,算出一个叫USARTDIV的值,填进BRR寄存器;
  2. 移位寄存器:把并行字节变成一位一位往外推;
  3. 状态机与中断逻辑:告诉你“可以发了”、“有数据来了”。

整个过程不需要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位)
-PCEPS控制是否开启奇偶校验

目前我们用最常见组合: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支持,也没有现成例程时,希望你能想起今天这一课:
从时钟开始,一步步点亮串口,让两个世界第一次对话。

如果你实现了自己的串口驱动,欢迎在评论区分享你的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询