串口硬件结构与三种编程方式

目录


一、前言

大家好,这里是 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 中配置头文件路径

  1. 右键工程 → PropertiesC/C++ GeneralPaths and Symbols
  2. Includes 选项卡中添加 Drivers/Module_driver
  3. 这一步让编译器能找到 draw.hspi_lcd.h 等头文件

2.3 检查 SPI 和 GPIO 配置

LCD 通过 SPI2 通信,确保 .ioc 中已启用:

  • SPI2:Mode 选择 Full-Duplex Master,NSS 选择 Software(由 GPIO 模拟片选)
  • GPIO:确认 D/C 引脚(项目中使用 PB13)和 CS 引脚(PB12)已配置为输出

main.cUSER 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' 为例)

  1. CPU 把 0x41 写入 TDR
  2. 硬件自动把 0x41 搬到 TSR(如果 TSR 空闲)
  3. 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 停止位(高电平)
  4. 发送完毕,状态寄存器置 "发送完成" 标志位

3.4 接收过程

  1. RX 引脚检测到起始位的下降沿 → 启动采样时钟
  2. 在每个 bit 的中间位置采样(16 倍过采样时在第 8/9/10 次采样中取多数值)
  3. 收齐 8 个数据位 + 停止位后,数据写入 RDR
  4. 状态寄存器置 "接收就绪" 标志位(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 辅助嵌入式的路上与你同行。

相关推荐
经济元宇宙1 小时前
2026 工厂搬运自动化:主流 AMR 品牌技术与应用深度测评
数码相机·学习
Ting.~1 小时前
软件设计师备考笔记【day5】-程序设计语言与语言处理程序
笔记
LCG元2 小时前
STM32实战:基于STM32F103的智能停车场车位引导系统
stm32·单片机·嵌入式硬件
WYH2872 小时前
【STM32 串口完全指南】从轮询到中断再到 DMA,一步步教你搞定串口收发!
stm32·单片机·嵌入式硬件
hrw_embedded2 小时前
STM32单片机增加全局内存增大导致ADC数据丢失,明明两个不相干的两个部分,为什么会相互干扰?
stm32·单片机·嵌入式硬件
HalvmånEver2 小时前
MySQL事务(一)
linux·数据库·学习·mysql
miaowmiaow2 小时前
一行命令把 PSD 还原成 HTML / React / Vue:psd2code 实战干货
前端·ai编程
余生皆假期-2 小时前
YuanHub 源码分析【六】MIT 模式
笔记·单片机·嵌入式硬件
van久2 小时前
Day22:JWT 完整学习笔记 + 原理 + 面试题 + 帮助类封装
笔记·学习