STM32 零基础可移植教程 08:串口接收一个字节,先把 RX 中断跑通
上一篇我们把 USART 串口打印跑通了。
板子已经能通过 printf() 往电脑发信息,这一步非常重要。以后 ADC 采样值、I2C 错误码、CAN 状态、程序运行位置,都可以通过串口打印出来。
但串口不应该只会"说话"。
很多时候,我们还希望电脑能给 STM32 发命令。
比如:
-
发一个字符,让 LED 翻转;
-
发一条命令,让蜂鸣器响一下;
-
发一个参数,修改 PWM 占空比;
-
发一行文本,做一个简单的调试控制台。
这些功能的第一步,不是直接写命令解析,而是先把最小接收链路跑通。
这一篇只做一个明确目标:
bash
电脑通过串口发送 1 个字节,STM32 用 RX 中断接收,然后把这个字节打印回来
先别急着收一整行,也先别上 DMA。先让一个字节稳定进来。
本篇目标
最终现象:
bash
串口助手发送:A
STM32 返回:
RX: A, hex: 0x41
如果发送:
bash
1
STM32 返回:
bash
RX: 1, hex: 0x31
本篇用到的外设:
bash
USART
USART interrupt
GPIO Alternate Function
NVIC
本篇跑通标准:
-
Keil 编译通过;
-
程序能下载到开发板;
-
串口助手能看到启动打印;
-
串口助手发送 1 个字符后,STM32 能回显这个字符;
-
能说清楚为什么要先调用
HAL_UART_Receive_IT(); -
能说清楚为什么回调函数里要重新开启下一次接收;
-
能说清楚中断里为什么不建议直接写一堆业务逻辑。
本篇只讲单字节中断接收。
不讲一行命令,不讲环形缓冲,不讲 DMA,不讲 IDLE。这些都很重要,但放到后面一篇一篇拆。
准备工作
你需要准备:
|
项目
|
说明
|
| --- | --- |
|
STM32 开发板
|
任意 STM32 开发板都可以
|
|
下载器
|
ST-LINK/V2 或板载 ST-LINK
|
|
USB 转 TTL 模块
|
如果开发板没有板载 USB 串口,需要外接
|
|
串口助手
|
支持发送单个字符即可
|
|
原理图
|
确认 USART TX/RX 接到哪个引脚
|
|
上一篇 USART 工程
|
建议直接从第 07 篇复制
|
建议从上一篇工程复制一份,改名为:
bash
08_usart_rx_interrupt
因为上一篇已经完成了:
-
USART 基本配置;
-
串口接线;
-
printf()重定向; -
串口助手参数设置。
这一篇在它的基础上加 RX 中断,思路最清楚。
硬件连接
串口接收必须把 RX 也接上。
连接关系仍然是交叉连接:
|
STM32
|
USB 转 TTL
|
| --- | --- |
|
USART_TX
|
RXD
|
|
USART_RX
|
TXD
|
|
GND
|
GND
|
这三个点一个都别省。
上一篇只做打印时,理论上只接 STM32 TX 到 USB-TTL RX 也能看到输出。
但这一篇要让电脑发数据给 STM32,所以必须接:
bash
USB-TTL TXD -> STM32 USART_RX
也就是电脑这边发出来的数据,要进 STM32 的 RX 引脚。
再强调一次:
bash
GND 必须共地
如果 GND 没接,串口可能完全没反应,也可能偶尔能收到一些奇怪字符。这种问题最容易把新手带偏。

先理解 RX 中断流程
很多新手第一次写串口接收中断,会以为只要在 CubeMX 里勾选中断就行。
结果程序下载后,电脑发什么都没反应。
这里有一个关键点:
bash
打开 USART 中断,不等于已经开始接收
你还要在代码里主动调用一次:
bash
HAL_UART_Receive_IT(&huart1, &rx_byte,
1
);
它的意思是:
bash
从现在开始,帮我用中断方式接收 1 个字节
等 1 个字节真的收到了,HAL 会走到回调函数:
bash
HAL_UART_RxCpltCallback()
注意,这里还有第二个坑:
bash
HAL_UART_Receive_IT() 接收一次,就只管这一次
如果你只调用一次,STM32 收到第一个字节后,后面的字节就不一定继续收了。
所以我们要在接收完成回调里重新开启下一次接收:
bash
HAL_UART_Receive_IT(&huart1, &rx_byte,
1
);
本篇的流程可以理解成:
bash
初始化 USART
-> 开启一次中断接收
-> 电脑发送 1 个字节
-> USART 触发中断
-> HAL_UART_IRQHandler()
-> HAL_UART_RxCpltCallback()
-> 记录收到的数据
-> 重新开启下一次接收
-> main while 里读取数据并打印
为什么不直接在中断回调里 printf()?
因为中断里应该尽量少做事。
这一篇我们采用一个更稳的习惯:
bash
中断里只记录事件
主循环里处理业务
这个习惯后面写按键中断、定时器中断、DMA 回调都会用到。

CubeMX 配置步骤
1. 保留上一篇 USART 基本配置
先确认 USART 仍然配置为:
bash
Mode: Asynchronous
Baud Rate: 115200
Word Length: 8 Bits
Parity: None
Stop Bits: 1
Hardware Flow Control: None
也就是常说的:
bash
115200, 8N1
串口助手也要保持一样。
如果你用的是 USART1,常见引脚是:
bash
PA9 -> USART1_TX
PA10 -> USART1_RX
如果你用的是 USART2,常见引脚可能是:
bash
PA2 -> USART2_TX
PA3 -> USART2_RX
不要死记这个引脚。
真正要看的还是开发板原理图和 CubeMX 里的 Pinout。

2. 打开 USART 中断
进入:
bash
Connectivity -> USARTx -> NVIC Settings
勾选:
bash
USARTx global interrupt
如果是 USART1,就是:
bash
USART1 global interrupt
如果是 USART2,就是:
bash
USART2 global interrupt

为什么要勾这个?
因为串口收到数据后,需要通过中断进入 HAL 的串口中断处理函数。
如果这里没打开,HAL_UART_Receive_IT() 虽然调用了,但收到字节后不会正常进回调。
3. 生成 Keil 工程
点击:
bash
GENERATE CODE
然后打开 Keil。
如果 CubeMX 提示要覆盖生成代码,确认你自己的代码都写在 USER CODE 区域里,再生成。

Keil 工程生成和编译
打开 Keil 后,先编译一次:
bash
Build / F7
确认输出:
bash
0 Error(s)
然后打开 CubeMX 生成的中断文件。
STM32F1 常见是:
bash
Core/Src/stm32f1xx_it.c
其他系列可能是:
bash
Core/Src/stm32f4xx_it.c
Core/Src/stm32g0xx_it.c
Core/Src/stm32h7xx_it.c
找到 USART 的中断函数,例如 USART1:
bash
void USART1_IRQHandler(void)
{
/* USER CODE BEGIN USART1_IRQn 0 */
/* USER CODE END USART1_IRQn 0 */
HAL_UART_IRQHandler(&huart1);
/* USER CODE BEGIN USART1_IRQn 1 */
/* USER CODE END USART1_IRQn 1 */
}
这里最关键的是:
bash
HAL_UART_IRQHandler(&huart1);
它会帮你处理 HAL 内部状态,最后在接收完成时调用:
bash
HAL_UART_RxCpltCallback()
如果你的中断函数里没有 HAL_UART_IRQHandler(),那后面的回调就不会正常发生。
一般 CubeMX 会自动生成,不需要你手写。

完整代码
这一篇继续使用应用层封装。
新建或覆盖这两个文件:
bash
Core/Inc/app_uart.h
Core/Src/app_uart.c
如果你从上一篇复制工程,可以直接在上一篇 app_uart 的基础上增加接收相关函数。
1. Core/Inc/app_uart.h
在 Core/Inc 目录下新建:
bash
app_uart.h
写入:
bash
#ifndef APP_UART_H
#define APP_UART_H
#include "main.h"
#include <stdint.h>
void App_UART_Init(void)
;
void App_UART_Print(const char *text)
;
void App_UART_PrintBytes(const uint8_t *data, uint16_t len)
;
HAL_StatusTypeDef App_UART_StartReceiveIT(void)
;
void App_UART_OnRxCplt(UART_HandleTypeDef *huart)
;
uint8_t App_UART_ReadByte(uint8_t *data)
;
#endif
这里新增了 3 个函数:
|
函数
|
作用
|
| --- | --- |
| App_UART_StartReceiveIT() |
开启第一次中断接收
|
| App_UART_OnRxCplt() |
给 HAL 回调调用,记录收到的数据
|
| App_UART_ReadByte() |
主循环读取是否收到新字节
|
2. Core/Src/app_uart.c
在 Core/Src 目录下新建:
bash
app_uart.c
写入:
bash
#include "app_uart.h"
#include <stdio.h>
#include <string.h>
/* * Default USART handle is huart1. * If your project uses USART2, define APP_UART_HANDLE as huart2. */
#ifndef APP_UART_HANDLE
#define APP_UART_HANDLE huart1
#endif
extern
UART_HandleTypeDef APP_UART_HANDLE;
static
uint8_t
s_rx_it_byte =
0
;
static
uint8_t
s_rx_data =
0
;
static
volatile
uint8_t
s_rx_ready =
0
;
void App_UART_Init(void)
{
s_rx_it_byte =
0
;
s_rx_data =
0
;
s_rx_ready =
0
;
}
void App_UART_Print(const char *text)
{
if
(text ==
NULL
)
{
return
;
}
HAL_UART_Transmit(&APP_UART_HANDLE,
(
uint8_t
*)text,
(
uint16_t
)
strlen
(text),
HAL_MAX_DELAY);
}
void App_UART_PrintBytes(const uint8_t *data, uint16_t len)
{
if
((data ==
NULL
) || (len ==
0u
))
{
return
;
}
HAL_UART_Transmit(&APP_UART_HANDLE,
(
uint8_t
*)data,
len,
HAL_MAX_DELAY);
}
HAL_StatusTypeDef App_UART_StartReceiveIT(void)
{
return
HAL_UART_Receive_IT(&APP_UART_HANDLE, &s_rx_it_byte,
1u
);
}
void App_UART_OnRxCplt(UART_HandleTypeDef *huart)
{
if
(huart ==
NULL
)
{
return
;
}
if
(huart->Instance != APP_UART_HANDLE.Instance)
{
return
;
}
s_rx_data = s_rx_it_byte;
s_rx_ready =
1u
;
(
void
)HAL_UART_Receive_IT(&APP_UART_HANDLE, &s_rx_it_byte,
1u
);
}
uint8_t App_UART_ReadByte(uint8_t *data)
{
if
(data ==
NULL
)
{
return
0u
;
}
if
(s_rx_ready ==
0u
)
{
return
0u
;
}
__disable_irq();
*data = s_rx_data;
s_rx_ready =
0u
;
__enable_irq();
return
1u
;
}
int fputc(int ch, FILE *f)
{
uint8_t
data = (
uint8_t
)ch;
HAL_UART_Transmit(&APP_UART_HANDLE, &data,
1u
, HAL_MAX_DELAY);
return
ch;
}
这里有几个点要看懂。
第一,默认串口句柄是:
bash
#define APP_UART_HANDLE huart1
如果你的工程用的是 USART2,就改成:
bash
#define APP_UART_HANDLE huart2
第二,真正交给 HAL 接收的是这个变量:
bash
static
uint8_t
s_rx_it_byte =
0
;
收到一个字节后,我们把它复制到:
bash
static
uint8_t
s_rx_data =
0
;
然后把标志位置 1:
bash
s_rx_ready =
1u
;
主循环通过 App_UART_ReadByte() 读取这个标志。
第三,回调里重新开启下一次接收:
bash
(
void
)HAL_UART_Receive_IT(&APP_UART_HANDLE, &s_rx_it_byte,
1u
);
这行非常重要。
少了它,常见现象是:
bash
只收到第一个字节,后面再发就没反应
3. 把 app_uart.c 加入 Keil 工程
如果你手动新建 .c 文件,Keil 不一定会自动编译它。
在 Keil 工程树里右键:
bash
Application/User/Core
选择:
bash
Add Existing Files to Group 'Application/User/Core'
然后添加:
bash
Core/Src/app_uart.c
如果忘了这一步,可能会报:
bash
undefined symbol App_UART_StartReceiveIT
undefined symbol App_UART_ReadByte
这不是函数写错了,而是 .c 文件没参与编译。
main.c 调用方式
下面开始改 main.c。
老规矩:尽量写在 USER CODE 区域里。
1. Includes 区域
找到:
bash
/* USER CODE BEGIN Includes */
/* USER CODE END Includes */
改成:
bash
/* USER CODE BEGIN Includes */
#include "app_uart.h"
#include <stdio.h>
/* USER CODE END Includes */
<stdio.h> 是给 printf() 用的。
2. 初始化区域
在 main() 里先确认 CubeMX 生成的初始化顺序。
你应该能看到类似:
bash
MX_GPIO_Init();
MX_USART1_UART_Init();
如果你用的是 USART2,就是:
bash
MX_USART2_UART_Init();
然后在:
bash
/* USER CODE BEGIN 2 */
/* USER CODE END 2 */
里面添加:
bash
/* USER CODE BEGIN 2 */
App_UART_Init();
App_UART_StartReceiveIT();
printf
(
"USART RX interrupt test start\r\n"
);
/* USER CODE END 2 */
顺序不要反。
必须先执行:
bash
MX_USART1_UART_Init();
再执行:
bash
App_UART_StartReceiveIT();
因为 USART 外设和句柄要先初始化好,后面才能开启中断接收。
3. while 循环区域
找到:
bash
while
(
1
)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
改成:
bash
while
(
1
)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
uint8_t
rx_data =
0
;
if
(App_UART_ReadByte(&rx_data) !=
0u
)
{
printf
(
"RX: %c, hex: 0x%02X\r\n"
, rx_data, rx_data);
}
HAL_Delay(
10
);
/* USER CODE END 3 */
}
这段代码的意思是:
bash
主循环不断检查有没有收到新字节
如果收到了,就打印字符和十六进制值
比如收到字符 A:
bash
字符显示:A
十六进制:0x41
为什么还要打印十六进制?
因为有些字符看不见,比如回车、换行、空格。
只打印 %c 时,你可能看不出来到底收到的是啥;打印 0x%02X 更适合排查。
4. 添加 HAL 接收完成回调
找到 main.c 下面的:
bash
/* USER CODE BEGIN 4 */
/* USER CODE END 4 */
在里面添加:
bash
/* USER CODE BEGIN 4 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
App_UART_OnRxCplt(huart);
}
/* USER CODE END 4 */
注意:如果你的工程里已经有 HAL_UART_RxCpltCallback(),不要再写第二个同名函数。
你要把这句合并进去:
bash
App_UART_OnRxCplt(huart);
C 语言里同一个函数不能定义两次。
编译、下载和验证
代码加完后,先编译:
bash
Build / F7
确认:
bash
0 Error(s)
然后下载程序。
打开串口助手,参数保持:
bash
115200, 8N1
复位开发板后,先看启动信息:
bash
USART RX interrupt test start
然后在串口助手发送区输入:
bash
A
点击发送。
正常情况下,接收区会显示:
bash
RX: A, hex: 0x41
再发:
bash
B
应该显示:
bash
RX: B, hex: 0x42

如果你发送的是中文,这篇先不建议这么测。
中文通常是多个字节,而且还涉及编码问题。我们现在只验证单字节接收,先用英文、数字、符号。
移植到其他板子的修改点
这篇的移植点主要有 8 个。
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
USART 实例
|
板载 USB 串口可能接 USART1/2/3
|
CubeMX Connectivity
|
|
TX/RX 引脚
|
不同芯片复用引脚不同
|
CubeMX Pinout 和原理图
|
|
USART global interrupt
|
中断接收必须打开 NVIC
|
CubeMX USART NVIC Settings
|
|
串口句柄
| huart1
、huart2、huart3 不同
| app_uart.c
的 APP_UART_HANDLE
|
|
IRQHandler
|
不同 USART 对应不同中断函数
| stm32xx_it.c |
|
波特率
|
两端必须一致
|
CubeMX 和串口助手
|
|
接线
|
RX 必须接 USB-TTL TX
|
硬件连接
|
|
回调合并
|
工程里可能已有 HAL 回调
| main.c
的 USER CODE BEGIN 4
|
换板子的推荐顺序:
-
看原理图,确认电脑串口接到哪个 USART;
-
CubeMX 里配置对应 USART 为
Asynchronous; -
设置 115200、8N1;
-
打开对应 USART 的 global interrupt;
-
生成代码后确认
stm32xx_it.c里有HAL_UART_IRQHandler(&huartx); -
在
app_uart.c里把APP_UART_HANDLE改成实际句柄; -
main.c初始化后调用App_UART_StartReceiveIT(); -
在
HAL_UART_RxCpltCallback()里调用App_UART_OnRxCplt(huart); -
串口助手发单个英文字符验证。
常见问题排查
在这里我先来回答一个大家问得比较多的问题,为什么一遇到串口相关的项目,Keil中就必须要勾选 use MicroLIB呢?
这是 Keil MDK 中 MicroLIB 与标准 C 库的 printf 底层实现路径不同 导致的。 MicroLIB 是 ARM 专门为嵌入式 MCU 裁剪的轻量库。它的 printf 实现非常精简:
bash
printf → 格式化字符串 → 逐字符调用 fputc → UART 发送
你只需要实现 fputc,printf 就直接输出到了串口。
而标准 C 库 的 printf 走的是完整 POSIX 风格的 I/O 层:
bash
printf → fprintf(stdout) → _write(fd=1) → 需要实现更多 syscall
你在 app_uart.c 里写的 fputc 虽然也被编译进去,但标准库的 printf 并不直接依赖它 -- 它走的是 _write 系统调用。
所以你的 fputc 压根没被 printf 调用到,数据没有经过 HAL_UART_Transmit,自然串口上看不到任何东西。
所以如果有的小伙伴在写完代码烧录之后发现串口没有任何反应,可以查一下这个问题。
1. 串口能打印启动信息,但发字符没反应
这种情况说明 TX 打印链路是通的,但 RX 接收链路有问题。
优先检查:
|
优先检查
|
具体方法
|
| --- | --- |
|
RX 线有没有接
|
USB-TTL TXD 要接 STM32 RX
|
|
GND 有没有共地
|
STM32 GND 和 USB-TTL GND 必须相连
|
|
CubeMX 是否打开 USART global interrupt
|
USART 的 NVIC Settings 里勾选
|
|
是否调用 App_UART_StartReceiveIT()
|
必须在 USART 初始化后调用
|
|
回调函数是否添加
| HAL_UART_RxCpltCallback()
里调用 App_UART_OnRxCplt()
|
| APP_UART_HANDLE
是否正确
|
USART2 工程不能还写 huart1
|
先不要怀疑电脑,也不要一上来改一堆代码。按这个顺序查,通常很快能定位。
2. 只能收到第一个字符,后面没反应
这是串口中断接收最经典的坑。
原因通常是:
bash
收到一个字节后,没有重新开启下一次接收
检查 App_UART_OnRxCplt() 里有没有:
bash
(
void
)HAL_UART_Receive_IT(&APP_UART_HANDLE, &s_rx_it_byte,
1u
);
没有这行,就只能收一次。
3. 编译报 huart1 未定义
说明你的工程里没有 huart1。
可能你配置的是 USART2,CubeMX 生成的是:
bash
UART_HandleTypeDef huart2;
解决方法:
把 app_uart.c 里的:
bash
#define APP_UART_HANDLE huart1
改成:
bash
#define APP_UART_HANDLE huart2
如果用 USART3,就改成 huart3。
4. 编译报 undefined symbol App_UART_StartReceiveIT
通常是 app_uart.c 没加入 Keil 工程。
解决方法:
-
右键
Application/User/Core; -
选择
Add Existing Files to Group; -
添加
Core/Src/app_uart.c; -
重新编译。
5. 进不了 HAL_UART_RxCpltCallback()
按顺序查这几个地方:
-
CubeMX 是否打开 USART global interrupt;
-
stm32xx_it.c里是否有对应的USARTx_IRQHandler(); -
USARTx_IRQHandler()里是否调用了HAL_UART_IRQHandler(&huartx); -
App_UART_StartReceiveIT()是否真的被执行; -
串口助手发送方向是否正确,USB-TTL TX 是否接到了 STM32 RX。
如果你会打断点,可以在这些位置打断点:
bash
HAL_UART_RxCpltCallback()
App_UART_OnRxCplt()
App_UART_ReadByte()
一个一个看数据有没有走到。
6. 收到的字符和发送的不一样
优先检查:
-
波特率是否一致;
-
数据位、停止位、校验位是否一致;
-
系统时钟是否配置错误;
-
USB 转 TTL 电平是否匹配;
-
GND 是否接触不良;
-
串口助手是否发送了换行或其他附加字符。
建议先用单个字符测试:
bash
A
B
1
2
不要一开始就发中文或一大串命令。
7. 快速连续发送会丢字符
本篇代码只做最小单字节接收演示。
它只有一个简单的接收标志:
bash
s_rx_ready
如果电脑连续高速发送很多字节,而主循环处理不及时,后来的字节可能覆盖前面的字节。
这不是 HAL 的错,也不是串口线坏了。
解决这个问题需要:
-
接收缓冲区;
-
环形队列;
-
或者 USART DMA + IDLE。
这些我们后面单独讲。
这篇先把 RX 中断链路跑通。
本篇小结
这一篇我们完成了 USART 单字节中断接收。
你现在至少应该知道:
-
串口接收必须接好 STM32 RX;
-
USART 中断接收要在 CubeMX 里打开 global interrupt;
-
打开 NVIC 不代表自动开始接收;
-
必须先调用
HAL_UART_Receive_IT(); -
收到 1 个字节后会进入
HAL_UART_RxCpltCallback(); -
回调里要重新开启下一次接收;
-
中断里尽量只记录事件,主循环里处理打印和业务;
-
换 USART1/2/3 时,要同步修改
APP_UART_HANDLE和检查 IRQHandler。
下一篇我们继续扩展串口接收:
STM32 串口收一行命令:从单字节中断到简单命令解析。
到那一篇,我们会把现在的"收一个字节"扩展成"收一行字符串",让电脑输入:
bash
led on
led off
beep
然后 STM32 根据命令去控制 LED 和蜂鸣器。