学习 USART 时,容易记住一堆寄存器和初始化函数,却不知道怎样把它们组合成一个完整功能。这篇笔记以STM32F407、USART1、HAL 库为例,把串口基础和程序设计连接起来。
目标功能很简单:电脑发送字符1,开发板亮红灯,再向电脑回复一条消息。
这项功能可以拆成两条数据通路:
接收: 电脑 → USB转串口芯片 → USART RX → 接收中断 → 接收缓冲区 ↓ fgetc() → getchar() ↓ 主循环解析命令 ↓ 控制LED 发送: 主循环 → printf() → fputc() → HAL_UART_Transmit() → USART TX → 电脑串口硬件负责收发,中断负责及时保存数据,缓冲区暂存数据,主循环决定收到数据后做什么。
本文中的寄存器和引脚说明针对 STM32F407。代码用于说明构建思路,采用正常收发路径的骨架形式;接收队列、LED 驱动、系统时钟和错误恢复需要结合工程实现。标准输入输出重定向以 Keil/Arm C 库的 MicroLIB 方案为例。
1. 先分清串行、同步、异步和 USART
1.1 串行与并行:一位位传,还是多位同时传
假设要传送 8 位数据10100101:
| 方式 | 传输过程 |
|---|---|
| 串行通信 | 在一条数据通道上,按顺序发送各个位 |
| 8 位并行通信 | 使用 8 条数据线,在一次传输中同时送出 8 位 |
这里说的是数据线,实际连接还可能需要时钟、控制和地线。串行、并行本身不能直接决定哪一种实际速度更快。
1.2 同步与异步:接收方怎样确定读取时刻
对常见的单片机接口,可以先这样理解:
- 同步通信:例如 SPI,接收方根据时钟信号,在约定的边沿采样。
- 异步串口通信:收发双方没有单独连接的共享时钟线,先约定波特率和帧格式;接收方检测起始位后,用自己的时钟安排采样。
**异步串口内部仍然需要时钟。**它只是没有把时钟通过一根独立的线传给对方。这也是双方的波特率误差不能过大的原因。
以上是常见接口的入门理解;同步通信还存在将时钟信息编码到信号中的形式。
1.3 UART 与 USART
| 名称 | 基本含义 |
|---|---|
| UART | 通用异步收发器 |
| USART | 通用同步/异步收发器 |
STM32 的 USART1 可以工作在普通异步串口模式。使用 HAL 时,这种模式对应的是UART_HandleTypeDef和HAL_UART_Init()。
所以,下面两个名称同时出现完全正常:
UART_HandleTypeDef huart1; huart1.Instance = USART1;USART1指硬件外设;HAL_UART_*表示使用 HAL 的异步串口驱动接口。ST 官方 HAL UART 驱动说明
2. TTL、RS-232、USB 转串口:分别解决什么问题
2.1 数据格式与电压表示是两件事
同一个字符A,可以使用不同的电平标准传输。
| 对比项 | 常说的 TTL/CMOS 电平串口 | RS-232 数据线 |
|---|---|---|
| 逻辑 1 | 高电平,例如约 3.3V | 负电压,例如 −9V |
| 逻辑 0 | 低电平,例如约 0V | 正电压,例如 +9V |
| 常见用途 | 板内、模块间的短距离连接 | 设备之间的串口电缆连接 |
| 与 STM32 的连接 | 确认电平兼容后连接 | 需要 RS-232 收发器转换电平 |
“TTL 串口”是常见叫法,也经常包含 3.3V CMOS 电平串口。表中的电压是示例,RS-232 不要求一定使用 ±9V 或 ±15V;其数据逻辑极性与普通控制器电平不同。TI 对 RS-232 电压极性的说明
RS-232 较大的电压摆幅有助于提高噪声容限,但并不代表它适合所有距离和干扰环境。具体还受线缆、速率和接地等因素影响。
RS-232 信号不能直接接到普通 STM32 UART 引脚上。
2.2 CH340 为什么能直接和开发板通信
常见的 USB 转控制器电平串口连接是:
电脑 USB口 ←→ CH340 等 USB转串口芯片 ←→ STM32 的 TX、RXRS-232 连接则是:
STM32 UART ←→ 电平转换芯片 ←→ RS-232电缆 ←→ 对端RS-232接口前一种连接不需要经过 RS-232。具体模块输出什么电平,要看模块电路和配置,不能仅凭“USB 转串口”这几个字判断。
2.3 TX、RX、GND 怎样接
在两端都按自身收发方向标注引脚的情况下:
设备A TX ──→ 设备B RX 设备A RX ←── 设备B TX 设备A GND ── 设备B GNDTX 是发送,RX 是接收。GND 为普通非隔离连接提供共同的电压参考。
DB9 是连接器形式,不是电平转换芯片。常见电脑 DTE 接口的 2 脚是接收、3 脚是发送、5 脚是信号地。DTE 接 DCE 时,同编号直通可以实现一端输出接另一端输入;两端都是 DTE 时,通常需要交叉连接数据线。接线以设备引脚定义为准,不能只看公头、母头。NI 关于直通线与交叉线的说明
3. 波特率与数据帧:线上究竟在传什么
3.1 波特率决定每一位持续多久
波特率表示每秒传送的码元数量。在这里的普通二进制 UART 中,一个码元携带一位,所以数值上等于每秒传输的位数。
以 9600 波特为例:
每位时间 = 1 / 9600 秒 ≈ 104.17 微秒它不是每秒传送 9600 个字节,也不是每秒传送 9600 个可见字符。
3.2 8N1 是什么意思
8:8 个数据位 N:无奇偶校验 1:1 个停止位一帧的组成是:
起始位0 → 数据位 → 可选校验位 → 停止位1对普通、未反相的控制器电平 UART:空闲状态为高电平,起始位为低电平,停止位为高电平。
发送字符A时,ASCII 编码为0x41,二进制写作01000001。UART 通常低位先发:
起始位 │ D0 D1 D2 D3 D4 D5 D6 D7 │ 停止位 0 │ 1 0 0 0 0 0 1 0 │ 18N1 每发送一个数据字节,需要1 + 8 + 1 = 10个位时间。因此,9600 波特时一个字节约耗时1.04 ms;连续发送时理论有效吞吐量约为960 字节/秒,还未考虑帧间空闲。
3.3 奇偶校验检查什么
- 偶校验:数据位与校验位中,
1的总数为偶数。 - 奇校验:数据位与校验位中,
1的总数为奇数。 - 起始位、停止位不参与这个计数。
A的数据位中有两个1,所以偶校验位填0,奇校验位填1。
奇偶校验能发现部分错误,不能自动纠正错误,也不能检测所有多位错误。
**STM32F407 的字长设置有一个细节:启用校验后,校验位占用配置字长中的最高一位。**因此,8 个有效数据位加 1 个校验位,通常需要配置 9 位字长;8N1 则使用 8 位字长、无校验。其他 STM32 型号应查各自手册。STM32F407 参考手册 RM0090
4. USART 内部怎样发送、接收一个字节
4.1 发送与接收路径
发送: CPU/DMA → 发送数据寄存器TDR → 发送移位寄存器 → TX引脚 接收: RX引脚 → 接收移位寄存器 → 接收数据寄存器RDR → CPU/DMA数据寄存器暂存一个数据字,移位寄存器负责把它与“一位一位传输”的形式互相转换。
在 STM32F407 上,软件通过USART_DR访问数据:
- 写
DR:提交待发送的数据。 - 读
DR:取出收到的数据。
**读 DR 不会读回自己刚写入的发送内容。**发送和接收内部有不同的存储位置。
4.2 必须分清 TXE、TC、RXNE
| 名称 | 含义 | 程序据此做什么 |
|---|---|---|
| UE | USART 使能 | 启用 USART |
| TE | 发送使能 | 允许发送 |
| RE | 接收使能 | 允许接收 |
| TXE | 发送数据寄存器空 | 可以提交下一个数据字 |
| TC | 发送完成 | 最后一帧已完整发出,且没有数据在等待发送 |
| RXNE | 接收数据寄存器非空 | 有尚未取走的数据 |
| TXEIE | TXE 中断使能 | 允许“发送数据寄存器空”请求中断 |
| TCIE | TC 中断使能 | 允许“发送完成”请求中断 |
| RXNEIE | RXNE 相关中断使能 | 允许接收数据就绪等对应事件请求中断 |
例如连续发送A、B:
A 进入移位寄存器 → TDR 空了,TXE=1,但 A 可能还在发送 CPU 把 B 放进 TDR → B 等待 A 发完 B 进入移位寄存器 → TDR 再次空了,TXE=1,但 B 可能还在发送 B 连停止位都发完,TDR 也空 → TC=1**TXE 关心“还能不能放入数据”,TC 关心“是否已经完全发出”。**它们与发送一个字符还是多个字符没有直接对应关系。
收到一个数据字后,硬件置位 RXNE;在本芯片上,读取 DR 会清除 RXNE。接收不及时可能产生溢出错误,实际工程应处理 ORE 等错误状态。
4.3 为什么有 8 倍和 16 倍过采样
接收方用更快的内部节奏观察 RX 电平,帮助判断每一位的值。在三点多数判决模式下,使用靠近位中心的三个采样点决定该位是 0 还是 1。
| 配置 | 特点 |
|---|---|
OVER8=0,16 倍过采样 | 对时钟偏差的容忍度更大,最高波特率受fPCLK/16限制 |
OVER8=1,8 倍过采样 | 可达到更高的最高波特率,上限为fPCLK/8 |
这些是硬件时钟给出的上限关系,实际还受器件工作条件等约束。**两种模式都配置成 115200 时,线上通信速率相同。**采样过程由硬件完成,不需要 CPU 每位执行 8 次或 16 次读引脚操作。RM0090:USART 接收与过采样
5. 功能设计的重点:把硬件接收、数据保存和命令处理分开
这里选择一种适合入门命令控制的方案:
阻塞发送 + 中断接收 + 软件接收队列 + 主循环处理命令。
阻塞发送方便接入printf();中断接收能在主循环忙于其他事情时及时保存输入。
| 模块 | 职责 |
|---|---|
| GPIO 和 USART 初始化 | 确定引脚、时钟和通信格式 |
| 接收中断 | 及时读取硬件接收到的数据 |
| 接收队列 | 保存已收到、尚未处理的数据 |
fgetc() | 从接收队列取出一个字符 |
fputc() | 把一个字符交给串口发送 |
| 主循环 | 解析命令、控制 LED、输出回复 |
5.1 为什么需要接收队列
如果电脑快速发送123,主循环还在处理1,中断已经可能收到2、3。
中断收到:1 → 2 → 3 队列保存:[1][2][3] 主循环按顺序取出并处理单个全局变量可能被后来的字符覆盖。环形队列通过数组和读写位置保存多条待处理数据,但容量仍然有限。
本文用以下自定义接口表示队列,它们不是 HAL 库函数:
void RxQueue_Init(void); bool RxQueue_PushFromISR(uint8_t data); // 成功返回true,队列满返回false bool RxQueue_Pop(uint8_t *data); // 成功返回true,队列空返回false队列设计要明确:
- 中断负责写,主循环负责读,保持先进先出。
- 队列满时采用明确策略,例如丢弃新字节并累计溢出计数;中断中不能等待队列腾出空间。
- 读写共享状态要正确同步,必要时使用很短的临界区。仅加
volatile不等于解决全部并发问题。 - 队列长度根据输入突发量和主循环的处理速度确定。
5.2 文件怎样安排
bsp_debug_usart.h 引脚定义、句柄声明、对外接口 bsp_debug_usart.c 串口初始化、接收队列、接收完成回调 stm32f4xx_it.c USART1中断入口 retarget.c fputc、fgetc重定向 bsp_led.c / .h RGB灯驱动 main.c 初始化、菜单、命令处理同一个串口句柄在.c文件中定义一次,在头文件中用extern声明:
// bsp_debug_usart.c UART_HandleTypeDef huart1 = {0}; // bsp_debug_usart.h extern UART_HandleTypeDef huart1;6. 第一步:配置 GPIO、USART 和 NVIC
6.1 先确定硬件和通信参数
本例使用:
USART1_TX:PA9,AF7 USART1_RX:PA10,AF7 波特率:115200 数据位:8 奇偶校验:无 停止位:1 硬件流控:无 过采样:16倍PA9/PA10 的 AF7 对应 USART1;同一串口可能还有其他可选引脚,要结合芯片封装和开发板电路选择。STM32F405/407 数据手册引脚复用表
STM32F407 的 USART1、USART6 使用 APB2 时钟,USART2、USART3、UART4、UART5 使用 APB1 时钟。在常见的 168MHz 系统配置下,APB2 为 84MHz、APB1 为 42MHz。CPU 频率不等于串口外设时钟,也不等于波特率。
普通无流控异步通信不需要连接 CK、RTS、CTS。RTS/CTS 是控制收发节奏的硬件流控信号;是否支持这些引脚,要看具体外设型号。
6.2 配置底层硬件
采用 HAL MSP 回调方式时,在HAL_UART_MspInit()中准备时钟、引脚和 NVIC:
void HAL_UART_MspInit(UART_HandleTypeDef *huart) { if (huart->Instance != USART1) { return; } __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_9 | GPIO_PIN_10; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate = GPIO_AF7_USART1; HAL_GPIO_Init(GPIOA, &gpio); HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); // 优先级按整个工程安排 HAL_NVIC_EnableIRQ(USART1_IRQn); }GPIO_MODE_AF_PP配合 AF7,把引脚交给 USART 外设控制;RX 仍执行接收功能。gpio.Speed配置引脚输出特性,不是设置串口波特率。
HAL 中的默认 MSP 函数通常是弱定义。工程提供同名的普通定义后,初始化会使用工程中的实现。同名函数只保留一个有效实现,已有 CubeMX 生成代码时应在原有函数中整合。
6.3 填写参数并初始化 USART
void DebugUart_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); } }**填写结构体是在准备配置;调用初始化函数才会把配置应用到硬件。**初次初始化时,HAL_UART_Init()会调用 MSP 初始化路径,配置寄存器并启用 UART。
这一阶段 NVIC 已允许 USART1 中断,但还需要启动具体的接收操作。ST HAL 初始化流程
7. 第二步:连接中断入口、HAL 处理函数和接收回调
7.1 启动第一次接收
接收变量要在异步接收期间一直有效,因此可以放在文件作用域:
static uint8_t rx_byte; static volatile uint32_t rx_queue_overflow; static volatile bool rx_restart_failed; void DebugUart_StartReceive(void) { if (HAL_UART_Receive_IT(&huart1, &rx_byte, 1) != HAL_OK) { Error_Handler(); } }HAL_UART_Receive_IT()的意思是:启动一次中断接收,目标为 1 个字节,收到后放入rx_byte。它启动后返回,不会一直等数据到来。
要区分两层开关:
| 配置 | 作用 |
|---|---|
| NVIC 使能 USART1 通道 | 允许 CPU 响应 USART1 中断 |
| USART 内部接收中断使能 | 允许相应接收事件提出中断请求 |
使用这套 HAL 方案时,接收操作所需的外设中断由HAL_UART_Receive_IT()管理,不再额外手动开启 RXNE,也不在另一条路径直接读取 DR。ST HAL 中断收发接口
7.2 中断入口交给 HAL 处理
在stm32f4xx_it.c中:
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); }HAL 处理接收标志、读取数据并更新内部状态,接收数量达到本次要求后,再调用完成回调。
7.3 回调保存数据,然后继续接收
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance != USART1) { return; } if (!RxQueue_PushFromISR(rx_byte)) { rx_queue_overflow++; } if (HAL_UART_Receive_IT(&huart1, &rx_byte, 1) != HAL_OK) { rx_restart_failed = true; } }每次接收 1 字节的操作完成后,要再次启动接收,才能按此方式继续收到下一个字节。
收到字符 ↓ USART1_IRQHandler() ↓ HAL_UART_IRQHandler() ↓ HAL_UART_RxCpltCallback() ↓ 保存到队列 → 重新启动接收回调仍在中断上下文执行,所以应保持简短。打印菜单、阻塞发送、等待输入和复杂命令解析放到主循环。
上面只演示正常路径。完整工程还应在HAL_UART_ErrorCallback()记录并处理串口错误,并安排接收恢复;主循环或专门的服务逻辑也要处理rx_restart_failed。只记录故障标志不会自动恢复接收。需要自动恢复时,也不能让主循环永远阻塞在不检查恢复条件的取字符函数里。
8. 第三步:构建 printf、fputc、getchar、fgetc
8.1 重定向到底改变了什么
printf()负责格式化文字,底层接口负责把文字送到具体设备。
在本文使用的 MicroLIB 方案中:
printf("value=%d\r\n", 10) ↓ 格式化成字符序列 value=10\r\n ↓ fputc() 输出字符 ↓ USART 发送到电脑这些是 C 标准输入输出接口;HAL UART 提供其底层收发能力。Keil 中按本文方案配置 MicroLIB,并包含<stdio.h>。其他 C 库可能需要实现_write、_read等接口,不能把同一份重定向代码不加区分地套到所有工具链中。Keil 标准输入输出与 MicroLIB 说明
8.2 fputc:发送一个字符
#include <stdio.h> #include "bsp_debug_usart.h" int fputc(int ch, FILE *stream) { uint8_t data = (uint8_t)ch; (void)stream; if (HAL_UART_Transmit(&huart1, &data, 1, 1000) != HAL_OK) { return EOF; } return data; }最后一个参数是超时限制;在通常的 HAL 毫秒时基下,1000表示 1000ms。它不是固定延时,正常发送完成就会返回。
这是一种阻塞发送方式,适合当前入门实验。发送量较大或存在多个任务时,可进一步设计发送队列、互斥和中断/DMA 发送机制。
8.3 fgetc:从软件队列取一个字符
int fgetc(FILE *stream) { uint8_t data; (void)stream; while (!RxQueue_Pop(&data)) { // 等待队列出现数据;中断仍可接收并写入队列 } return data; }主循环调用getchar()时,可以通过这条路径得到一个字符。使用int接收返回值,便于保留标准输入接口的EOF表示能力。
**接收中断已经负责硬件接收,fgetc 只从软件队列取数据。**这里不能再同时调用阻塞版HAL_UART_Receive()或直接读取 DR,否则会与中断路径争抢数据。
fgetc()并不是使用串口的必需部分。如果不需要getchar()、scanf(),主循环直接调用RxQueue_Pop()也可以。
本文的fgetc()在无数据时会阻塞主循环。需要同时处理按键、周期任务和接收恢复时,应使用非阻塞取队列方式、状态机,或者在 RTOS 下采用合适的任务等待机制。
9. 第四步:在主循环中处理命令
下面用两个命令演示功能组合。LED_SetRed()、LED_SetGreen()表示由 LED 驱动提供的接口。
int main(void) { HAL_Init(); SystemClock_Config(); LED_GPIO_Config(); RxQueue_Init(); DebugUart_Init(); DebugUart_StartReceive(); printf("发送1亮红灯,发送2亮绿灯\r\n"); while (1) { int ch = getchar(); if (ch == EOF) { continue; } switch (ch) { case '1': LED_SetRed(); printf("红灯已亮\r\n"); break; case '2': LED_SetGreen(); printf("绿灯已亮\r\n"); break; case '\r': case '\n': break; // 忽略串口助手附加的回车换行 default: printf("未知指令:0x%02X\r\n", (unsigned int)ch); break; } } }这里的getchar()示例只演示正常输入路径,完整工程的队列和故障恢复仍按前文要求补齐。
一次完整交互
- 开发板上电,初始化 GPIO、USART、队列和接收中断。
printf()向电脑输出菜单。- 主循环进入
getchar(),等待命令。 - 电脑以文本方式发送
1。 - 接收中断将字符
'1'放入队列,并启动下一次接收。 getchar()返回'1',主循环执行LED_SetRed()。printf()发送回复,电脑显示“红灯已亮”。
**字符'1'的编码为0x31,数值1的编码为0x01。**本例匹配case '1',应在串口助手使用文本发送1;若采用十六进制发送,则发送31。
10. 怎样一步步搭建,避免出错后无从定位
按下面的顺序建立功能,每一步确认现象后再增加下一部分:
| 阶段 | 本阶段完成什么 | 预期现象 |
|---|---|---|
| 1 | GPIO、时钟、UART 初始化,直接调用HAL_UART_Transmit() | 电脑收到Hello |
| 2 | 添加fputc()重定向 | printf()的内容能显示 |
| 3 | 添加接收中断和队列,主循环直接取队列 | 电脑输入能被主循环读取 |
| 4 | 添加fgetc() | getchar()能取到输入字符 |
| 5 | 添加switch和 LED 驱动 | 字符命令控制灯并得到回复 |
| 6 | 补齐队列满处理、错误恢复和突发输入处理 | 异常情况下行为明确 |
调试时可以先使用英文和数字,确认字节收发正确,再处理中文编码与显示问题。