目录
- 一、前言
- [二、前置准备:将 LCD 驱动加入工程](#二、前置准备:将 LCD 驱动加入工程)
- [三、STM32H5 USART 硬件框图](#三、STM32H5 USART 硬件框图)
- 四、波特率发生器
- [五、FIFO 模式](#五、FIFO 模式)
- 六、三种编程方式概览
- [七、UART_Device 抽象层预览](#七、UART_Device 抽象层预览)
- 八、总结
- 九、结尾
一、前言
大家好,这里是 Hello_Embed。
本篇文章是"AI编程"系列的第七篇。前几篇我们理解了 UART 协议帧格式(第五篇),学会了用 LCD 打印调试信息(第六篇)。但从理论到真正让串口收发数据,中间还差一步------理解 STM32H5 内部的 USART 硬件结构,以及 HAL 库提供的三种编程方式。
本文借助 Claude Code 辅助生成,结合项目源码和 STM32H5 参考手册进行梳理。
USART 的硬件框图回答了三个关键问题:数据怎么从寄存器到引脚、波特率怎么产生、CPU 怎么知道数据到了。这三种编程方式(查询/中断/DMA)本质上是在 CPU 介入程度和实现复杂度之间做权衡。理解了这一点,后面四篇实战笔记(3-4 到 3-7)你就能看清每一行的"为什么"。
二、前置准备:将 LCD 驱动加入工程
在开始串口实验之前,先把 LCD 驱动移植到你的工程中------后续所有 UART 收发数据都用 LCD 来显示,比 LED 闪烁直观得多。
2.1 需要复制的文件
从 3_程序源码/01_视频配套的源码/ 中找一个已有工程(如第9章的中控程序),将以下目录和文件复制到你的 CubeIDE 工程的对应位置:
你的工程/
├── Drivers/
│ └── Module_driver/ ← 整个目录复制过来
│ ├── spi_lcd.c / .h # LCD 底层驱动(SPI 操作)
│ ├── draw.c / .h # 绘制 API(画字符、图形)
│ ├── font_8x16.c / .h # ASCII 8×16 点阵字库
│ ├── font_chinese.c / .h # 汉字 32×29 点阵字库
│ └── lcd_colors.h # 颜色宏定义
└── Core/
└── Src/
└── main.c ← 需要添加 include 和调用
2.2 在 CubeIDE 中配置头文件路径
- 右键工程 → Properties → C/C++ General → Paths and Symbols
- 在 Includes 选项卡中添加
Drivers/Module_driver - 这一步让编译器能找到
draw.h、spi_lcd.h等头文件
2.3 检查 SPI 和 GPIO 配置
LCD 通过 SPI2 通信,确保 .ioc 中已启用:
- SPI2:Mode 选择 Full-Duplex Master,NSS 选择 Software(由 GPIO 模拟片选)
- GPIO:确认 D/C 引脚(项目中使用 PB13)和 CS 引脚(PB12)已配置为输出
在 main.c 的 USER CODE 区域添加初始化调用:
c
/* USER CODE BEGIN 2 */
LCD_Init(1); // 1 = 横屏(rotation 90°),0 = 竖屏
Draw_Init(); // 获取分辨率,初始化绘制参数
Draw_Clear(0); // 黑屏(0 = 0x00000000)
/* USER CODE END 2 */
2.4 验证
编译下载后屏幕全黑 → 初始化成功。再添加一行测试代码:
c
Draw_String(0, 0, "LCD OK!", 0x00FF0000, 0); // 左上角红色文字
屏幕上看到红色 "LCD OK!" 即验证通过。LCD 现在就是你的调试控制台了。
三、STM32H5 USART 硬件框图
3.1 一句话概括
USART = U niversal S ynchronous/A synchronous R eceiver/Transmitter。STM32H5 上带 "S"(同步模式可选),但本项目只用异步模式(UART)。
3.2 核心硬件模块
┌─── 发送数据寄存器(TDR) ──► 发送移位寄存器(TSR) ──► TX 引脚
CPU 写入 ──► │
│ ┌── 波特率发生器
│ │ (BRR 寄存器)
│ ▼
│ RX 引脚 ──► 接收移位寄存器(RSR) ──► 接收数据寄存器(RDR) ──► CPU 读取
│
└─── 硬件 FIFO(STM32H5 特有)
TX FIFO (最多 8 字节深度)
RX FIFO (最多 8 字节深度)
| 模块 | 作用 | 关键点 |
|---|---|---|
| 发送数据寄存器 (TDR) | CPU 把要发的数据写到这里 | 写入后硬件自动搬移到 TSR |
| 发送移位寄存器 (TSR) | 把并行数据逐 bit 移出到 TX 引脚 | 移位过程无需 CPU 参与 |
| 接收移位寄存器 (RSR) | 从 RX 引脚逐 bit 移入 | 收齐一个字节后自动搬到 RDR |
| 接收数据寄存器 (RDR) | CPU 从这里读收到的数据 | 如果不及时读取,下一字节会覆盖 |
| 波特率发生器 (BRR) | 从 PCLK 分频产生采样时钟 | 收发双方必须一致 |
| FIFO | TX/RX 各 8 字节深度的硬件缓冲区 | STM32H5 特有,减少 CPU 干预频率 |
3.3 发送过程(以发送 0x41 = 'A' 为例)
- CPU 把
0x41写入 TDR - 硬件自动把
0x41搬到 TSR(如果 TSR 空闲) - TSR 在波特率时钟驱动下,从 LSB 开始逐 bit 移到 TX 引脚:
- 先发 1 bit 起始位(低电平)
- 再发
1 0 0 0 0 0 1 0(LSB 先:bit0=1, bit1=0, bit2=0, bit3=0, bit4=0, bit5=0, bit6=1, bit7=0) - 最后发 1 bit 停止位(高电平)
- 发送完毕,状态寄存器置 "发送完成" 标志位
3.4 接收过程
- RX 引脚检测到起始位的下降沿 → 启动采样时钟
- 在每个 bit 的中间位置采样(16 倍过采样时在第 8/9/10 次采样中取多数值)
- 收齐 8 个数据位 + 停止位后,数据写入 RDR
- 状态寄存器置 "接收就绪" 标志位(RXNE),通知 CPU 来读
四、波特率发生器
4.1 计算公式
波特率由 BRR 寄存器控制,本质是 PCLK 的一个分频值:
波特率 = PCLK / (USARTDIV × OVER8)
其中:
- PCLK = 250MHz(本项目 H5 的 APB1 总线时钟)
- USARTDIV = BRR 寄存器的值(整数 + 小数部分)
- OVER8 = 过采样倍数(16 或 8)
4.2 实际例子
目标波特率 = 115200,PCLK = 250MHz,过采样 = 16:
USARTDIV = 250,000,000 / 115200 = 2170.1389...
整数部分 = 2170 (0x87A)
小数部分 ≈ 2/16 = 0.125 → 最接近 0.1389
HAL 库自动完成这个计算,你只需在 .ioc 中填 "115200"。
4.3 为什么不能随便选波特率
不是所有波特率都能精确生成。实际误差 = |实际值 - 目标值| / 目标值。UART 的容错能力大约 ±2%~3%。如果误差超出这个范围,接收方在帧尾附近就会出现采样错位。
以本项目为例:PC↔中控用 115200(USB 直连,信号干净),中控↔传感器用 9600(RS-485 远距离,低速容错更强)。
五、FIFO 模式
5.1 什么是 FIFO
FIFO(First In, First Out)是 STM32H5 USART 的硬件缓冲区。传统 STM32 的 USART 只有一个字节的 TDR 和 RDR,每次收发一个字节就要打断 CPU。FIFO 模式下可以连续收发最多 8 个字节再触发一次中断或 DMA 请求。
5.2 本项目的 FIFO 配置
在 usart.c 中(MX_USART2_UART_Init() 内部):
c
HAL_UARTEx_SetTxFifoThreshold(&huart2, UART_TXFIFO_THRESHOLD_1_8);
HAL_UARTEx_SetRxFifoThreshold(&huart2, UART_RXFIFO_THRESHOLD_1_8);
HAL_UARTEx_EnableFifoMode(&huart2);
| 参数 | 含义 |
|---|---|
UART_TXFIFO_THRESHOLD_1_8 |
TX FIFO 深度只剩 1/8 时触发"空中断",通知 CPU 继续填数据 |
UART_RXFIFO_THRESHOLD_1_8 |
RX FIFO 深度达到 1/8 时触发"满中断",通知 CPU 来取数据 |
解析:阈值设得低(1/8 = 1 字节即触发),行为接近传统 STM32 的单字节模式。设得高(如 7/8 = 7 字节才触发)可以减少中断频率,但需要配合 DMA 使用------本项目正是这样做的。
六、三种编程方式概览
HAL 库提供三种收发方式,核心区别在于 CPU 如何得知数据到了、谁来搬运数据:
| 方式 | 函数名(发送) | 函数名(接收) | CPU 介入 | 适用场景 |
|---|---|---|---|---|
| 查询 | HAL_UART_Transmit() |
HAL_UART_Receive() |
全程占用 | 简单测试、调试 |
| 中断 | HAL_UART_Transmit_IT() |
HAL_UART_Receive_IT() |
每字节中断一次 | 低速、不定长接收 |
| DMA | HAL_UART_Transmit_DMA() |
HAL_UART_Receive_DMA() |
仅传输完成时通知 | 大批量、高速传输 |
6.1 查询方式(Polling)
CPU: 发一个字节 → 死等标志位 → 发下一个字节 → 死等标志位 ...
├─ CPU 100% 被占用 ─┤
c
// 发送 10 个字节,timeout=1000ms
HAL_UART_Transmit(&huart2, data, 10, 1000);
// 这 10 个字节发完之前,CPU 哪都去不了(除非超时)
优点 :代码最简单,没有中断优先级问题。缺点:CPU 效率极低,发 10 个字节可能浪费 1ms,期间不能做任何其他事。
结论:只在调试时用。项目代码中没有使用查询方式。
6.2 中断方式(Interrupt)
CPU: 启动发送 → 去干别的事
硬件: 发完一个字节 → 产生中断 → CPU 回来填下一个字节 → 又去干别的事
├─ CPU 间歇参与 ─┤
c
// 启动中断发送,函数立刻返回
HAL_UART_Transmit_IT(&huart2, data, 10);
// CPU 可以去执行其他代码,每发完一个字节才回来处理
每个字节发完都触发一次中断。115200 bps 下,每字节 86.8μs,也就是大约每 87μs 来一次中断------看起来很快,但累积起来中断开销可观。
优点 :CPU 不用死等。缺点:频繁中断,批量传输时效率不高。
6.3 DMA 方式(Direct Memory Access)
CPU: 告诉 DMA "把 data 里的 10 个字节搬到 USART 发送寄存器" → 去干别的事
DMA: 自动搬运 → 全部搬完 → 通知 CPU "搞定"
├─ CPU 完全不参与 ─┤
c
// 启动 DMA 发送,函数立刻返回
HAL_UART_Transmit_DMA(&huart2, data, 10);
// CPU 全程解放,DMA 搬完后通过回调函数通知
DMA 是一个独立的硬件控制器,能绕过 CPU 直接做数据搬运。本项目使用 GPDMA1 的 4 个通道:
| 通道 | 用途 | 方向 |
|---|---|---|
| Channel 0 | USART2 TX | 内存 → 串口 |
| Channel 1 | UART4 RX | 串口 → 内存 |
| Channel 2 | USART2 RX | 串口 → 内存 |
| Channel 3 | UART4 TX | 内存 → 串口 |
优点 :CPU 几乎零开销,适合大批量高速传输。缺点:需要配置 DMA 通道,代码稍复杂。
6.4 第四种方式:DMA + IDLE 空闲中断
本项目的最终方案是 DMA + IDLE 线检测 ------HAL 库函数为 HAL_UARTEx_ReceiveToIdle_DMA():
DMA 持续往 buffer 里填收到的数据(CPU 完全不参与)
↓
UART RX 线路空闲超过 1 个字符时间 → 触发 IDLE 中断
↓
中断回调中:把已收到的有效数据推入 FreeRTOS 队列
↓
应用任务从队列取数据处理
这解决了 DMA 接收的一个核心问题:DMA 不知道帧什么时候结束。不用 IDLE 的话,你只能设置一个固定的 DMA 传输长度------但串口数据是不可预知的(可能 4 字节的 Modbus 请求,也可能 256 字节的固件文件)。
IDLE 中断配合 DMA 的做法:DMA 只管往 buffer 灌,IDLE 中断告诉你"这一帧结束了"。下一篇(UART 编程实战)会详解具体实现。
七、UART_Device 抽象层预览
在实际项目中,你不会直接调用 HAL_UART_Transmit_DMA(),而是通过 UART_Device 这一层。这是一种面向对象风格的封装,核心是一个结构体接口:
c
typedef struct UART_Device {
char *name; // 设备名:"uart2"、"uart4"、"usbserial"
int (*Init)(struct UART_Device *pDev, int baud, char parity, int data_bit, int stop_bit);
int (*Send)(struct UART_Device *pDev, uint8_t *datas, uint32_t len, int timeout);
int (*RecvByte)(struct UART_Device *pDev, uint8_t *data, int timeout);
int (*Flush)(struct UART_Device *pDev);
void *priv_data; // 指向各实例的私有数据(如 UART_Data)
} UART_Device;
项目中注册了三个设备实例:
| 设备名 | 底层硬件 | 用途 |
|---|---|---|
"uart2" |
USART2 | 板载串口,连接 RS-485 CH1 |
"uart4" |
UART4 | 板载串口,连接 RS-485 CH2 |
"usbserial" |
USB CDC | USB 虚拟串口,连接 PC |
通过 GetUARTDevice("uart4") 获取设备指针后,不管是 USART2 还是 USB 虚拟串口,Send() 和 RecvByte() 的调用方式完全一样。libmodbus 正是基于这套接口移植的。
八、总结
| 知识点 | 一句话 |
|---|---|
| USART 硬件框图 | TDR→TSR→TX 引脚发送,RX 引脚→RSR→RDR 接收,BRR 控制波特率 |
| FIFO | STM32H5 特有,TX/RX 各 8 字节硬件缓冲区,配合 DMA 减少中断 |
| 查询方式 | CPU 死等标志位,效率最低,仅调试用 |
| 中断方式 | 每字节中断一次,CPU 间歇参与 |
| DMA 方式 | CPU 设定后全程不参与,DMA 控制器自动搬运 |
| DMA+IDLE | 项目最终方案,DMA 连续收 + IDLE 中断标定帧尾 |
| UART_Device | OOP 风格封装,统一 USART2/UART4/USB CDC 的接口 |
三种编程方式选哪种,本质上是用代码复杂度换 CPU 效率。查询最简单但效率最低,DMA+IDLE 最复杂但效率最高。
九、结尾
本篇是阶段2到阶段3的过渡------连接了前面的 UART 协议理论和接下来的四种编程实战。理解硬件框图能帮你读懂 HAL 库源码,理解三种方式的差异能帮你下一次决定"用中断还是 DMA"时不再犹豫。
从下一篇开始,我们将逐篇实操四种编程方式。每一篇都会在你自己的工程里跑出实际效果------用 LCD 显示收发结果。
实战提示:下一篇开始前,确保 LCD 驱动已按本文第二节移植好。后续每篇实验的核心就是:配置 UART → 收发数据 → LCD 显示。没有 LCD,你只能靠 LED 闪烁来判断程序是否在跑------回到石器时代。
Hello_Embed 在 AI 辅助嵌入式的路上与你同行。