
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

这篇文章是一篇给编程新手的入门教程。我们以一个非常经典的例子出发:让开发板通过串口把电脑上发过来的数据原样"回传"回去(也就是"回声"功能),同时用一颗绿色 LED 指示数据正在传输。
整个项目使用 UART 阻塞模式(也就是轮询模式),这是最简单、最直观的串口使用方式,非常适合作为串口学习的第一站。
目录
- [1. 目标](#1. 目标)
- [2. 原理图分析:为什么要用 USB 转串口](#2. 原理图分析:为什么要用 USB 转串口)
- [3. 需求分析](#3. 需求分析)
- [4. 学习目标](#4. 学习目标)
- [5. 工程配置(CubeMX 图形化配置)](#5. 工程配置(CubeMX 图形化配置))
- [6. 代码编写](#6. 代码编写)
- [7. 串口上位机工具使用](#7. 串口上位机工具使用)
- [8. 下载验证](#8. 下载验证)
- [9. 源码分析(深入 HAL 库底层)](#9. 源码分析(深入 HAL 库底层))
- [10. 轮询模式使用方法总结](#10. 轮询模式使用方法总结)
- [11. 接口清单](#11. 接口清单)
- [12. 附录](#12. 附录)
1. 目标
本示例将演示 UART 阻塞模式数据发送、接收接口 的使用。这类接口是 STM32 HAL 库中最简单、最直观的串口接口,非常适合作为第一个串口入门示例。
由于是阻塞模式 ,CPU 必须通过轮询的方式去等待数据收发完成,所以它也叫"轮询模式"。
本示例将实现以下串口数据收发逻辑:
- 串口配置为:波特率 = 115200bps,8 位数据,奇校验,停止位 = 1,无流控。
- 使用串口阻塞模式接口接收串口上位机发送的测试数据。
- 使用串口阻塞模式接口将接收到的数据回传到串口上位机。
- 使用绿色 LED 指示灯闪烁状态表示串口正在进行数据传输。
🧠 提前科普:什么是"阻塞"?
"阻塞"这个词听起来很抽象,我们打个比方:
你打电话给客服,然后一直拿着电话等对方接听,什么别的事都不做,这就是"阻塞"。反过来,你一边做别的事、一边等电话,等电话来了再去接,这叫"非阻塞"(一般是中断或 DMA 方式)。
在程序里,阻塞模式就是:CPU 调用这个函数后,会死等在这里,直到任务完成(或超时)才返回,期间 CPU 什么都干不了。
📌 知识点总结
什么是串口轮询(阻塞)模式?
串口轮询模式就是 CPU 主动地、反复地去检查串口外设的状态标志位(比如接收数据寄存器非空标志
RXNE、发送数据寄存器空标志TXE),一旦发现条件满足就立刻处理数据,处理完成才从函数返回。在这个等待和处理的过程中,CPU 一直"霸占"着,不会去干别的事,因此也被称为阻塞模式。它的优点是逻辑简单、代码直观、非常容易理解和调试;缺点是 CPU 利用率很低,在等待期间浪费了大量时间,而且如果接收方处理太慢,发送方又持续发数据,就很容易造成数据丢失。
2. 原理图分析:为什么要用 USB 转串口
2.1 一个核心问题:电脑和单片机"语言不通"
我们先问一个问题:电脑(尤其是现在的笔记本电脑)上已经没有原生串口接口 (九针的 DB9 接口)了,只剩 USB 口。但是 STM32 主控芯片只认识 TTL 串口电平信号。
这两者之间根本无法直接对话,就像:
- 电脑说的是"USB 语言";
- STM32 说的是"TTL 串口语言"。
所以要有一个"翻译官"在中间做转换。

2.2 CH340K:那个可靠的"翻译官"
我们的开发板电路上已经内置了这个"翻译官"------一个叫 CH340K 的 USB 转 USART 芯片。
电脑 USB 口 <------USB协议------> CH340K 芯片 <------TTL串口电平------> STM32 的 USART1
这个转换电路的效果,等效于一个"USB 转 UART TTL"模块 ,因为已经焊接在电路板上了,所以我们在做实验时不需要再外接任何转换模块,直接用一根 USB 线把开发板和电脑连起来就可以了。
2.3 一根 USB 线,电脑"看起来"却是串口

通过电脑 USB 与开发板的连接示意图可以看出:
虽然电脑硬件上用的是 USB 接口,但对于软件 来讲,它就是一个串口!
这是因为 USB 转串口芯片会在电脑端虚拟出一个"COM 串口"(比如 COM3),上位机软件操作这个 COM 口,就好像在操作一个传统的物理串口一样,完全不用关心底层其实是 USB。
这正是为了解决"现在多数电脑都没有标配串口"的问题而普遍采用的方案。
📌 知识点总结
为什么 STM32 开发板和电脑连接要使用 USB 转串口芯片?
因为电脑(尤其笔记本)已经很少配备传统的九针串口,而 STM32 主控只识别 TTL 电平的串口信号,两者信号协议不一致,无法直接通信。USB 转串口芯片(如 CH340)充当"翻译官":它一头通过 USB 协议与电脑通信,另一头通过 TTL 串口电平与单片机通信,从而实现了两者之间的数据交互。对电脑软件而言,这个 USB 口被虚拟成了一个串口(COM 口),所以上位机软件操作它和操作真实串口没有区别,这也是现代开发板普遍采用的标准方案。
3. 需求分析
在写代码之前,先把"我们要什么"列清楚:
- 需要一个绿色 LED 指示灯,用于指示串口数据交互状态(收到/发出数据时闪烁)。
- 需要使用 USART 阻塞模式接收串口上位机发送的数据。
- 需要使用 USART 阻塞模式发送数据到串口上位机工具。
简单说就是三件事:一个灯、一个接收、一个发送。
4. 学习目标
学完本章,你应该掌握:
- 掌握串口基本参数的配置方法(CubeMX 相关配置以及代码实现)。
- 掌握串口阻塞模式发送接口的使用方法。
- 掌握串口阻塞模式接收接口的使用方法。
- 掌握串口上位机工具软件的使用方法。
5. 工程配置(CubeMX 图形化配置)
我们使用 STM32CubeMX 这款图形化配置工具来生成工程骨架,它最大的好处是:寄存器都不用自己配,鼠标点一点,代码自动生成。
5.1 设置调试接口为 SWD(非常重要!)
开发板预留的仿真器调试接口是 SWD(Serial Wire Debug,串行线调试) ,因此我们需要在 CubeMX 的 SYS 配置里,把 Debug 选项选择为 "Serial Wire"。
🚧 注意!这一步非常重要!
如果没有修改这里,保持默认的
No Debug,那么:
- 第一次烧录程序可以成功;
- 但后续烧录程序就会失败!
原因是:程序烧进去之后,如果把调试引脚(JTAG/SWD 占用的引脚)当成了普通 GPIO 或者被禁用,烧录器就再也"连不上"芯片了,俗称"锁死了"。

🕶️ DAP vs SWD 到底是什么?
这一对概念经常被混淆,我们仔细讲清楚:
| 概念 | 全称 | 通俗理解 |
|---|---|---|
| DAP | Debug Access Port(调试访问端口) | 是一个物理仿真器/硬件工具(比如我们用的 DAP-Link 调试器) |
| SWD | Serial Wire Debug(串行线调试) | 是一种调试通信协议(约定好的通信规则) |
两者的关系可以这样打比方:
- DAP = 考试的时候坐在座位上答题的同学本人;
- SWD = 用来传递纸条的通道 + 约定好的手势或暗号。
也就是说:DAP-Link 是一个调试器硬件,而 SWD 是它用来和目标芯片通信的协议。DAP-Link 正是通过 SWD 协议(两根线:SWDIO 数据线 + SWCLK 时钟线)与目标 STM32 芯片通信的。
如果我们在 CubeMX 中错误配置或禁止了 debug 接口(如 JTAG/SWD),那么烧录完程序后,就会导致无法再通过调试器连接目标 STM32 设备,这就是前面说的"锁死"。
📌 知识点总结
STM32 开发板为什么必须配置 SWD 调试接口?它和 DAP 有什么关系?
开发板使用 SWD 接口进行程序烧录和调试,SWD 是串行线调试协议,仅需 SWDIO(数据线)和 SWCLK(时钟线)两根线即可与芯片通信。如果在 CubeMX 中没有将调试模式配置为 Serial Wire,而使用默认的 No Debug,那么烧录程序后芯片的调试引脚可能会被复用为普通功能或被禁用,导致烧录器无法再连接芯片,后续烧录会失败。而 DAP(Debug Access Port)是指调试器硬件本身,比如 DAP-Link 就是一款仿真器,它通过 SWD 协议与目标芯片通信。简而言之,DAP 是"硬件工具",SWD 是"通信协议",两者配合才能完成烧录和在线调试。
5.2 开启外部时钟源(HSE)
为了让系统跑得更准、更快,我们使用开发板上的外部晶振作为高速时钟源(HSE,High Speed External)。
在 CubeMX 的 RCC 配置中,选择 "Crystal / Ceramic Resonator"(晶体/陶瓷谐振器),表示选择外部晶振作为 HSE。
为什么不直接用芯片内部自带的 RC 振荡器?
因为内部 RC 振荡器精度较低、受温度影响大;而外部晶振精度高、更稳定,还能让系统跑满最高主频。

5.3 配置时钟树
开发板上焊接的是一个 8MHz 的 HSE 晶振。在时钟树(Clock Configuration)页面,我们需要:
-
选通 8MHz 晶振作为时钟源;
-
通过 PLL(锁相环)把系统主频倍频到 72MHz(这是这款芯片能稳定运行的最大频率)。
8MHz HSE 晶振 ------> PLL 锁相环(×9)------> 系统主时钟 SYSCLK = 72MHz
时钟就像单片机的"心跳",所有外设(包括串口)的工作频率都源自于它。串口波特率的计算也是基于外设时钟(APB 总线时钟)来的。

5.4 配置 USART1
在"通信部分"(Connectivity)选择 USART1 ,通信模式选择 异步通信模式(Asynchronous)。
💡 一个有趣的问题:为什么选择了 USART1,CubeMX 就自动选好了对应的引脚(PA9/PA10)?
答案: 因为使能了串口,CubeMX 就会自动根据芯片手册里的 "引脚复用表"(见附录 12.1《针脚复用清单》)帮你把该外设默认对应的引脚配置好。这就是图形化配置工具"所见即所得"的便利之处。
GPIO 配置使用默认的即可(现场确认参数模式即可)。

USART1 相关参数配置详解
下面我们逐个讲解串口的各项核心参数:
| 图中标识 | 配置项 | 值 | 说明 |
|---|---|---|---|
| ① | Mode(模式) | Asynchronous(异步) | STM32 的 USART 还支持同步、单线半双工等很多模式,最核心的 UART 通信模式就选异步模式 |
| ② | Hardware Flow Control(硬件流控) | Disable(禁用) | 不使用 RTS/CTS 流控信号,只使用 Rx/Tx 两根线 |
| ③ | BaudRate(波特率) | 115200 | 一个非常常见的标准波特率,指每秒传输多少个"位" |
| ④ | Word Length(字长/数据位长度) | 9 Bits(含校验位) | 其中 8Bits 是数据,1Bits 是校验位 |
| ⑤ | Parity(校验) | Odd(奇校验) | 用来检测传输错误 |
| ⑥ | Stop Bits(停止位) | 1 | 数据帧末尾的停止位占 1 位时间长度 |
| ⑦ | 收发方向 | Receive and Transmit(收发模式) | STM32 的 USART 还支持配置成"只接收"或"只发送",通常直接配置为收发模式即可 |
| ⑧ | OverSampling(过采样) | 默认值(16 倍) | 接收器用来检测和识别输入信号的技术 |
进行配置:

🧠 补充知识一:什么是"数据帧"?
串口传输的数据不是"裸奔"的,它是按照固定的帧结构一帧一帧地传的。一帧数据长这样:
| 起始位(1位,低电平) | 数据位(8位) | 校验位(1位,可选) | 停止位(1位,高电平) |
- 起始位:一个低电平,告诉接收方"注意!数据要来了";
- 数据位:真正要传的数据,从低位开始传;
- 校验位:用来检查数据有没有传错;
- 停止位:一个高电平,表示"这一帧结束了"。
所以一帧完整的 9 位字长 + 奇校验 + 1 停止位的数据,实际上线上传了 1(起始) + 9(数据+校验) + 1(停止) = 11 位。
🧠 补充知识二:奇校验/偶校验怎么理解?
校验位的值是根据数据位里"1"的个数来算出来的:
- 奇校验(Odd) :让整帧(数据位 + 校验位)里"1"的总个数为奇数;
- 偶校验(Even) :让整帧里"1"的总个数为偶数。
打个比方:就像发快递时,寄件人数一下箱子里有 8 件物品,然后告诉快递员"我寄的是偶数件";收货时如果数出来是 7 件,就知道路上少了一件。校验位就是这么个"数 1 的个数"的约定,用来发现传输过程中的误码(注意:只能发现,不能纠错)。
🧠 补充知识三:波特率是什么?
波特率(Baud Rate) 表示每秒传输多少个"位"(bit),单位是 bps(bits per second)。
- 115200 bps 意思就是每秒传 115200 个二进制位;
- 换算成字节:去掉起始位、校验位、停止位这些"额外开销",实际有效数据大约每秒 11520 字节左右。
波特率是串口通信中收发双方必须约定一致的参数,就像两个人打电话必须说同一种语言一样。如果两边波特率不一致,收到的就是一串乱码。
🧠 补充知识四:过采样(OverSampling)是什么?
过采样是 USART 接收器用来检测和识别输入信号的技术。它决定了 USART 在一个位周期内对接收信号进行多少次采样来判断该位的逻辑状态。
- 16 倍过采样:一个位周期采样 16 次,精度高、抗干扰能力强;
- 8 倍过采样:一个位周期采样 8 次,最高波特率可以翻倍,但抗干扰能力稍弱。
为什么需要多次采样? 因为线路上的信号有噪声、有毛刺,只采样一次很容易"看走眼"。多采几次,取多数投票的结果,就能大大提高判断的准确性。判断数据"多取几个样,少数服从多数",这就是过采样的意义。
5.5 配置指示 LED 灯
我们需要一个绿色 LED 作为数据传输指示灯,这里选择 LED4(对应 GPIOF 的 PIN8,稍后代码里会用到),用于指示串口工作状态。

5.6 项目配置并生成
在 CubeMX 里设置好工程名、路径、以及使用的 IDE(比如 Keil MDK),点击 GENERATE CODE 生成工程骨架。CubeMX 会自动生成:
- 外设初始化代码(
MX_USART1_UART_Init等); - 主函数框架;
- 时钟配置代码等。
💡 小提示: CubeMX 生成的代码里会有
/* USER CODE BEGIN ... */和/* USER CODE END ... */这样的注释标记,它们之间的区域是专门留给用户写代码的。只要写在标记之内,即使以后重新生成代码,你的代码也不会被覆盖。

📌 知识点总结
串口通信中的关键参数有哪些?各代表什么含义?
串口通信的关键参数主要有六个:波特率、数据位长度、校验位、停止位、硬件流控和收发模式。波特率表示每秒传输的位数(如 115200bps),收发双方必须一致;数据位长度指一帧中实际数据的位数(常见 8 位);校验位用于检测传输误码,分为无校验、奇校验、偶校验,其中奇校验要求数据位与校验位中"1"的总数为奇数;停止位表示一帧结束的标志,常见 1 位或 2 位;硬件流控使用 RTS/CTS 信号协调收发节奏,多数简单应用不使用;收发模式决定外设是只发、只收还是收发一体。这些参数组成一帧数据:起始位 + 数据位 + 校验位(可选)+ 停止位。此外,过采样倍数(8 或 16)决定了接收器在一个位周期内的采样次数,采样次数越多抗干扰能力越强,但最高波特率越低。
6. 代码编写
CubeMX 生成的工程已经帮我们做好了"硬件配置",接下来只需要在 main 函数里补上我们的业务逻辑。
6.1.1 测试代码(主循环)
我们来看 main 函数里的核心逻辑。重点:所有代码都写在 USER CODE 标记区域内。
c
/* USER CODE BEGIN PV */
/* 用户私有变量定义区 */
/* 用于接收上位机发来的单个字节数据 */
uint8_t recv_byte = 0;
/* 计数器:记录接收到的字节个数 */
uint32_t cnt = 0;
/* USER CODE END PV */
/* Infinite loop(主循环) */
/* USER CODE BEGIN WHILE */
while (1)
{
/*
* HAL_UART_Receive:阻塞式接收 1 个字节。
* 参数说明:
* &huart1 ------ 串口1 的句柄(一个"管家",管着串口的所有信息和状态)
* &recv_byte ------ 接收缓冲区指针,收到的数据放到这个变量里
* 1 ------ 要接收的数据个数(这里只收 1 个字节)
* HAL_MAX_DELAY------ 无限等待,一直等到收到数据为止
*
* 返回值 HAL_OK 表示接收成功,否则接收失败(如超时)。
*/
if (HAL_OK == HAL_UART_Receive(&huart1, &recv_byte, 1, HAL_MAX_DELAY))
{
// HAL_Delay(100); // bug测试:如果在这里加延时,就会发现接收变慢、甚至丢数据
/*
* 注意!这里有个坑(重要):
* 发送时建议不要把超时时间设为 HAL_MAX_DELAY,
* 因为 HAL_UART_Transmit 发送完成后要等待 TC(发送完成)标志置位,
* 如果一直等,就会拖慢下一次 HAL_UART_Receive 的接收节奏。
* 而发送端(上位机)是持续不断发数据的,这样就可能丢失数据。
*
* 所以这里把超时时间设为 0:
* 意思是"把数据交给硬件,立即返回,不等待发送完成"。
* 让数据由硬件慢慢发,CPU 马上回来继续接收。
*/
HAL_UART_Transmit(&huart1, &recv_byte, 1, 0);
// 每收到一个字节,计数器加 1
cnt++;
// 每收到 10 个字节,就翻转一次 LED(实现闪烁效果)
if (cnt >= 10)
{
cnt = 0; // 计数器清零,重新计数
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8); // 翻转 LED4(GPIOF 的 PIN8)
}
}
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
这段代码做了什么?
- 程序进入死循环
while(1); - 调用
HAL_UART_Receive阻塞等待上位机发来的 1 个字节; - 收到之后,立即用
HAL_UART_Transmit把这一字节原样发回去(这就是"回声"); - 计数器 +1,每累计 10 个字节就翻转一次 LED,形成闪烁,表示"正在干活"。
🔍 为什么发送超时时间设为 0 而不设 HAL_MAX_DELAY?
因为
HAL_UART_Transmit在把数据写进寄存器之后,还要等待 TC(Transmit Complete,发送完成) 标志。如果发送数据一直发不完(或硬件较慢),就会一直卡在这里,导致后面的接收来不及响应上位机的持续发送,最终丢数据 。所以这里用一个技巧:发送超时设为 0,写完寄存器立刻返回,让 CPU 马上回来继续接收。这就是"能收到 10 个字节就闪灯"这个功能能稳定工作的关键细节。
6.1.2 HAL_UART_Receive 函数详解
函数原型:
c
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart,
uint8_t *pData,
uint16_t Size,
uint32_t Timeout)
核心逻辑(一句话概括):
在接收数据个数还未达到期望值(
Size)时,通过轮询等待接收缓冲非空标志 RXNE 置位(1) ;一旦置位(1),就从 DR(数据寄存器)取数 ,并按字长/校验掩码提取有效位,写入用户缓冲区、移动指针并递减计数,直到接收完成。
参数说明:
| 参数 | 含义 |
|---|---|
huart |
UART 句柄,需已完成初始化并处于就绪状态(就是我们说的那个"管家") |
pData |
接收缓冲区指针。9 位无校验时按 uint16_t* 使用,否则按 uint8_t* 使用 |
Size |
要接收的数据元素个数(注意不是字节数!)。9 位无校验时是 u16 元素个数,其它情形是 u8 元素个数 |
Timeout |
超时时间(毫秒)。与 HAL_GetTick() 配合判定;HAL_MAX_DELAY 表示无限等待 |
返回值:
| 返回值 | 含义 |
|---|---|
HAL_OK |
接收成功(读满 Size 个元素) |
HAL_TIMEOUT |
在等待 RXNE 标志期间超时 |
HAL_BUSY |
接收状态非就绪(已有接收过程正在进行) |
HAL_ERROR |
入参非法(pData == NULL 或 Size == 0) |
💡 补充:什么是掩码(Mask)?
掩码就是一个"过滤器"。因为当字长是 9 位时,DR 寄存器里最多存 9 位有效数据,超出有效位数的多余位需要"屏蔽"掉。
- 9 位无校验用
0x01FF;- 9 位或 8 位有校验用
0x00FF;- 8 位有校验用
0x007F。和
&(按位与)运算配合,就能把 DR 里我们不需要的高位全部清零,只保留有效数据位。
6.1.3 HAL_UART_Transmit 函数详解
函数原型:
c
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart,
const uint8_t *pData,
uint16_t Size,
uint32_t Timeout)
核心逻辑(一句话概括):
根据配置选择按
uint16_t(9 位无校验,掩码0x01FF)或uint8_t(其余情况,掩码0xFF)发送;每次发送循环先等待 TXE(发送数据寄存器空)标志置位 ,再将当前数据写入 DR 寄存器并递减计数;全部写完后再等待 TC(发送完成)标志表示发送完成。
参数说明:
| 参数 | 含义 |
|---|---|
huart |
UART 句柄,需已初始化且处于就绪状态 |
pData |
待发送数据缓冲区指针。9 位无校验时按 const uint16_t* 解析,否则按 const uint8_t* |
Size |
要发送的数据元素个数(非字节数)。9 位无校验时是 u16 元素数,其余是 u8 元素数 |
Timeout |
超时时间(毫秒)。与 HAL_GetTick() 配合判定;HAL_MAX_DELAY 表示无限等待 |
返回值:
| 返回值 | 含义 |
|---|---|
HAL_OK |
成功发送完 Size 个元素并等待 TC 完成 |
HAL_TIMEOUT |
等待 TXE 或 TC 期间超时 |
HAL_BUSY |
发送状态非就绪(已有发送正在进行) |
HAL_ERROR |
入参非法(pData == NULL 或 Size == 0) |
🧠 补充知识:TXE 和 TC 到底有什么区别?
这两个标志是新手最容易混淆的:
| 标志 | 全称 | 含义 | 什么时候置位 |
|---|---|---|---|
| TXE | Transmit data register Empty | 发送数据寄存器空 | CPU 可以把下一个字节写进 DR 了 |
| TC | Transmission Complete | 传输完成 | 整个数据帧(包括停止位)已经从引脚上全部发出去了 |
打个比方:
- TXE = "寄件箱已经清空,你可以放下一封信了";
- TC = "这封信已经真正送到邮局发出去了"。
所以发送流程是:等 TXE → 写 DR → 等 TXE → 写 DR → ... → 全部写完 → 等 TC。
📌 知识点总结
HAL_UART_Receive 和 HAL_UART_Transmit 这两个阻塞接口的工作流程和原理是什么?
这两个函数是 STM32 HAL 库提供的串口阻塞式(轮询式)收发接口。接收函数
HAL_UART_Receive的核心流程是:在接收个数未达到期望值前,循环轮询等待接收数据寄存器非空标志 RXNE 置位,一旦置位就从数据寄存器 DR 读取数据,并按字长与校验配置使用对应的掩码提取有效位,写入用户缓冲区,同时移动指针并递减剩余计数,直到收满指定数量或超时返回。发送函数HAL_UART_Transmit的流程相反:每次发送前先轮询等待发送数据寄存器空标志 TXE 置位,确认寄存器可写后把数据写入 DR 并递减计数,全部数据写完后,再等待发送完成标志 TC 置位,确认数据帧真正从引脚发出后才返回。两者的参数都是(句柄、数据缓冲区指针、数据元素个数、超时时间),返回值为 HAL_OK 表示成功、HAL_TIMEOUT 表示超时、HAL_BUSY 表示外设忙、HAL_ERROR 表示参数非法。阻塞模式的特点是调用后 CPU 会一直等待直到完成或超时,逻辑简单但会占用 CPU。
7. 串口上位机工具使用
光有单片机这边还不够,我们还需要电脑端的一个软件 来充当"对方",这个软件叫串口上位机(或串口调试助手)。
7.1.1 软件安装
我们使用的是 COMTool 这款串口调试工具,免安装、免费开源。
- 下载链接:
https://sourceforge.net/projects/comtool/ - 该软件是免安装的,将软件包解压缩即可。
- 解压后找到可执行文件:
comtool_windows_vxx / comtool / comtool.exe,双击即可打开。
7.1.2 第一次启动配置
第一步:把语言改成中文。
在设置里选择"中文简体",然后重启软件,界面就变成中文了。
第二步:把不用的页面关掉。
软件默认会打开"协议"、"终端"、"图标"等多个页面,我们只做最基础的收发,可以把它们关掉,只留下"收发"页面,界面更清爽。
7.1.3 工作界面介绍

COMTool 的主界面主要分为几个区域:
| 标识 | 区域 | 作用 |
|---|---|---|
| ① | 串口参数配置区域 | 选择串口号(COM 口)、波特率、数据位、校验位、停止位等。学习过串口基础知识后,这些参数一目了然 |
| ② | 串口接收设置区域 | 设置接收数据显示格式(二进制/字符串)、自动换行、显示时间戳等 |
| ③ | 串口发送设置区域 | 设置发送数据格式(二进制/字符串)、转义字符自动解析、循环发送等 |
| ④ | 历史发送数据缓存区域 | 记录之前发送过的数据,方便重复发送 |
| ⑤⑦ | 数据发送区域 | 输入要发送的数据 + "发送"按钮 |
| ⑥⑧ | 数据接收区域 | 显示接收到的数据 + 清除显示按钮 |
💡 小贴士: 上位机的串口参数(波特率、数据位、校验位、停止位)必须和单片机那边配置的一模一样,否则通信就会乱码或失败。就像打电话,两边必须用同一个频道。
📌 知识点总结
串口上位机工具的作用是什么?使用时需要注意什么?
串口上位机(串口调试助手)是运行在电脑上的通信调试软件,用来代替另一个"串口设备"和单片机进行数据收发测试。它通过 USB 转串口芯片虚拟出的 COM 口与单片机通信。使用时需要注意两点:一是必须先选择正确的串口号(COM 口);二是串口的各项参数------波特率、数据位、校验位、停止位------必须和单片机端配置完全一致,否则无法正常通信,会收到乱码或收不到数据。上位机通常还提供十六进制/字符串两种数据显示格式、循环发送、时间戳等功能,方便我们调试和分析数据。
8. 下载验证
- 把编译好的程序下载到开发板并复位。
- 上位机按照前面介绍的配置进行设置(串口号请根据电脑识别结果选择,一般插上 USB 后设备管理器里会出现一个 COM 口)。
- 在发送区输入任何测试字符串,点击"发送"按钮,开始发送测试数据。
- 可以看到接收显示区域每次发送都有一条相同的数据回传------这就是单片机把我们发的内容原封不动地"回声"了回来!
💡 验证要点:发"Hello"回传"Hello",发"A"回传"A",并且每收到 10 个字节绿色 LED 会闪一下。
9. 源码分析(深入 HAL 库底层)
前面我们用得很爽,但很多同学会好奇:这些代码到底是怎么操作的硬件寄存器? 这一节我们就走进 HAL 库底层,把串口的老底掀开看看。
9.1 USART1 的物理地址
STM32 是一个"内存映射"的处理器,所有外设寄存器都被映射到了固定的地址空间。USART1 也不例外。
c
/* 外设基地址:所有外设寄存器的"老家"都在 0x40000000 附近 */
#define PERIPH_BASE 0x40000000UL
/* APB2 总线外设的基地址 = 外设基地址 + 0x00010000 */
#define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL)
/* USART1 的基地址 = APB2 总线基地址 + 0x00003800 */
#define USART1_BASE (APB2PERIPH_BASE + 0x00003800UL)
/* 把 USART1 基地址强制转换为 USART_TypeDef 结构体指针 */
#define USART1 ((USART_TypeDef *)USART1_BASE)
🧠 这里用到了 C 语言的经典技巧: 用一个结构体指针指向一段寄存器地址空间,结构体里的每个成员就"对应"一个寄存器。我们给某个成员赋值,实际上就是往对应的寄存器地址写值!
USART 寄存器结构体
c
typedef struct
{
__IO uint32_t SR; /*!< USART 状态寄存器 (Status register), 地址偏移:0x00 */
__IO uint32_t DR; /*!< USART 数据寄存器 (Data register), 地址偏移:0x04 */
__IO uint32_t BRR; /*!< USART 波特率寄存器 (Baud rate register),地址偏移:0x08 */
__IO uint32_t CR1; /*!< USART 控制寄存器 1 (Control register 1),地址偏移:0x0C */
__IO uint32_t CR2; /*!< USART 控制寄存器 2 (Control register 2),地址偏移:0x10 */
__IO uint32_t CR3; /*!< USART 控制寄存器 3 (Control register 3),地址偏移:0x14 */
__IO uint32_t GTPR; /*!< USART 保护时间与预分频寄存器,地址偏移:0x18 */
} USART_TypeDef;
每个寄存器干什么的?
| 寄存器 | 作用 |
|---|---|
SR(Status Register) |
状态寄存器,里面就是各种标志位,比如我们前面说的 RXNE、TXE、TC 都在这 |
DR(Data Register) |
数据寄存器,接收时从它"拿"数据,发送时往它"放"数据 |
BRR(Baud Rate Register) |
波特率寄存器,存放波特率分频值 |
CR1/CR2/CR3 |
控制寄存器,配置使能、字长、校验、停止位等各项功能 |
GTPR |
保护时间和预分频寄存器 |
🔍
__IO是什么?它其实是volatile的宏定义。加上volatile是为了防止编译器"自作聪明"地优化------因为寄存器的值随时可能被硬件修改,编译器必须每次都去内存重新读取,不能把它缓存在寄存器里。
📌 知识点总结
STM32 外设寄存器是如何通过内存映射和结构体访问的?
STM32 采用统一的内存映射结构,所有外设的寄存器都被映射到固定的物理地址。每个外设有一个基地址(如 USART1 基地址 = 0x40000000 + 0x10000 + 0x3800),定义芯片头文件会把外设基地址强制转换为对应的寄存器结构体指针(如
USART_TypeDef*)。结构体的每个成员使用__IO(即 volatile)修饰,按顺序依次对应外设的各个寄存器(如 SR、DR、BRR、CR1/CR2/CR3)。因此,通过结构体指针访问成员,本质上就是读写对应的物理地址寄存器。使用 volatile 修饰是为了防止编译器优化,确保每次访问都真正读取内存,因为寄存器值会被硬件实时修改。这也是 HAL 库能够用一句huart1->DR就能读写串口数据的原因。
9.2 初始化代码分析
9.2.1 结构体初始化(填写配置参数)
CubeMX 生成的初始化函数 MX_USART1_UART_Init 长这样:
c
static void MX_USART1_UART_Init(void)
{
/* USER CODE BEGIN USART1_Init 0 */
/* USER CODE END USART1_Init 0 */
/* USER CODE BEGIN USART1_Init 1 */
/* USER CODE END USART1_Init 1 */
/* 指定串口号:句柄的 Instance 指向 USART1 外设 */
huart1.Instance = USART1; /* UART 寄存器基地址 = USART1 */
/* 下面依次填写串口各项参数(结构体赋值) */
huart1.Init.BaudRate = 115200; /* 波特率 115200 */
huart1.Init.WordLength = UART_WORDLENGTH_9B; /* 字长 9 位(含校验),有效数据 8 位 */
huart1.Init.StopBits = UART_STOPBITS_1; /* 停止位 1 位 */
huart1.Init.Parity = UART_PARITY_ODD; /* 奇校验 */
huart1.Init.Mode = UART_MODE_TX_RX; /* 收发模式使能 */
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; /* 无硬件流控 */
huart1.Init.OverSampling = UART_OVERSAMPLING_16; /* 16 倍过采样 */
/* 调用 HAL 库初始化函数,把上面的参数"灌"进寄存器,失败则进入错误处理 */
if (HAL_UART_Init(&huart1) != HAL_OK)
{
Error_Handler();
}
/* USER CODE BEGIN USART1_Init 2 */
/* USER CODE END USART1_Init 2 */
}
通俗理解: 我们先把所有想配置的参数写进 huart1 这个"管家"的名片夹里,然后调用 HAL_UART_Init,让管家拿着名片夹去把每个参数"翻译"成寄存器里的具体位值,写进硬件。
UART 句柄结构体 UART_HandleTypeDef
这是 HAL 库的核心数据结构,它"管家"着串口的所有信息和状态:
c
/**
* @brief UART 句柄结构体定义
* HAL 库使用该结构体管理 UART 外设的所有通信状态和参数
*/
typedef struct __UART_HandleTypeDef
{
USART_TypeDef *Instance; /*!< UART 寄存器基地址指针,指向 USART1/USART2/... 外设寄存器 */
UART_InitTypeDef Init; /*!< UART 通信参数配置:波特率、数据位、校验位、停止位等 */
const uint8_t *pTxBuffPtr; /*!< 指向 UART 发送缓冲区的指针(要发送的数据源) */
uint16_t TxXferSize; /*!< UART 本次发送的总字节数 */
__IO uint16_t TxXferCount; /*!< UART 当前剩余待发送字节数(递减计数器) */
uint8_t *pRxBuffPtr; /*!< 指向 UART 接收缓冲区的指针(存放接收到的数据) */
uint16_t RxXferSize; /*!< UART 本次接收的总字节数 */
__IO uint16_t RxXferCount; /*!< UART 当前剩余待接收字节数(递减计数器) */
__IO HAL_UART_RxTypeTypeDef ReceptionType; /*!< 当前正在进行的接收类型(单次接收/连续接收) */
__IO HAL_UART_RxEventTypeTypeDef RxEventType; /*!< 接收事件类型(用于 DMA 传输完成事件等) */
DMA_HandleTypeDef *hdmatx; /*!< UART 发送 DMA 句柄指针,使用 DMA 发送时有效 */
DMA_HandleTypeDef *hdmarx; /*!< UART 接收 DMA 句柄指针,使用 DMA 接收时有效 */
HAL_LockTypeDef Lock; /*!< 资源锁定对象,防止 HAL 函数被重入(中断嵌套冲突) */
__IO HAL_UART_StateTypeDef gState; /*!< UART 全局状态(包括发送状态):例如 HAL_UART_STATE_READY / BUSY / TIMEOUT 等 */
__IO HAL_UART_StateTypeDef RxState; /*!< UART 接收状态:例如 HAL_UART_STATE_READY / BUSY_RX / TIMEOUT 等 */
__IO uint32_t ErrorCode; /*!< UART 错误码,记录发生过的错误类型(如溢出、帧错误、噪声等) */
...
} UART_HandleTypeDef;
🧠 补充理解:这个结构体里的
gState/RxState有什么用?它们就是前面讲过的
HAL_BUSY返回值的来源!函数一进来,先检查这些状态是不是"就绪(READY)",如果是 BUSY 就说明上一次传输还没结束,直接返回HAL_BUSY,防止并发冲突。Lock则是防止函数被中断重入的"门锁"。
UART 初始化结构体 UART_InitTypeDef
c
/**
* @brief UART 初始化结构体,用于配置 UART 通信参数
*/
typedef struct
{
uint32_t BaudRate; /*!< 波特率,计算公式:IntegerDivider = PCLK / (16 * BaudRate) */
uint32_t WordLength; /*!< 数据位数,可选 @ref UART_Word_Length */
uint32_t StopBits; /*!< 停止位数量,可选 @ref UART_Stop_Bits */
uint32_t Parity; /*!< 校验模式(无/奇/偶),可选 @ref UART_Parity
@note 使能校验时,校验位插入 MSB 位置(最高位) */
uint32_t Mode; /*!< 收发模式使能,可选 @ref UART_Mode */
uint32_t HwFlowCtl; /*!< 硬件流控使能,可选 @ref UART_Hardware_Flow_Control */
uint32_t OverSampling; /*!< 过采样倍数(8 或 16),可选 @ref UART_Over_Sampling */
} UART_InitTypeDef;
9.2.2 标志位选项设置
HAL_UART_Init 内部会调用 UART_SetConfig(huart),在这个函数 里才是真正"逐条翻译"参数、设置寄存器的过程。比如根据 WordLength 和 Parity 的组合去设置 CR1 寄存器中的 M 位、PCE 位、PS 位,根据波特率计算 BRR 的值等。你可以在源码里随便挑一两个标志位的设置过程跟进去看,就会彻底明白"结构体参数 → 寄存器位"这条链路。
9.2.3 USART1 时钟使能(MspInit)
原则: 我必须看到 USART1 被使能、开始工作,并且必须保证配套的 GPIO 也被使能。
HAL 库把"硬件层"的初始化(时钟、引脚)单独抽到了 HAL_UART_MspInit 里,由 CubeMX 自动生成:
c
void HAL_UART_MspInit(UART_HandleTypeDef* huart)
{
GPIO_InitTypeDef GPIO_InitStruct = {0}; /* 定义并清零一个 GPIO 配置结构体 */
if(huart->Instance==USART1) /* 只对 USART1 做硬件初始化 */
{
/* Peripheral clock enable */
__HAL_RCC_USART1_CLK_ENABLE(); /* 使能串口 USART1 的时钟 */
__HAL_RCC_GPIOA_CLK_ENABLE(); /* 使能 GPIOA 的时钟(串口引脚在 PA 组上) */
/* USART1 GPIO 引脚复用配置:
PA9 ------> USART1_TX(发送引脚)
PA10 ------> USART1_RX(接收引脚) */
/* 配置 PA9 为发送引脚:复用推挽输出模式 */
GPIO_InitStruct.Pin = GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; /* 复用推挽模式(引脚被串口外设接管) */
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;/* 高速输出 */
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
/* 配置 PA10 为接收引脚:普通输入模式 */
GPIO_InitStruct.Pin = GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; /* 输入模式 */
GPIO_InitStruct.Pull = GPIO_NOPULL; /* 不带上拉/下拉 */
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}
}
🧠 为什么要单独抽一个 MspInit?
MSP = MCU Support Package(微控制器支持包),意思是"和具体芯片相关的那部分初始化"。HAL 库把"和芯片绑定"的时钟、引脚配置单独放一起,这样同一份驱动代码(比如 UART 驱动)可以在不同芯片、不同引脚上复用------换芯片时只需要改 MspInit 就行,驱动逻辑不用动。这就是分层设计的好处。
📌 知识点总结
STM32 HAL 库的初始化过程是怎样的?MspInit 的作用是什么?
HAL 库的初始化采用分层设计。以串口为例,整个过程分两步:第一步,先把用户配置的通信参数(波特率、字长、校验、停止位等)填写进句柄结构体的
Init成员,然后调用HAL_UART_Init,该函数内部通过UART_SetConfig把这些参数逐条翻译成对应寄存器位(如 CR1 的 M 位、PCE 位、PS 位)写入外设寄存器;第二步,HAL 库会调用HAL_UART_MspInit,MspInit 负责 MCU 支持包层(与具体芯片硬件绑定的部分)的初始化,包括使能外设时钟(如__HAL_RCC_USART1_CLK_ENABLE)、使能相关 GPIO 时钟、把引脚配置为复用模式等。这种分层的好处是:驱动逻辑与具体硬件绑定部分分离,代码可移植性更强,换芯片或换引脚时只需修改 MspInit 部分。此外,句柄结构体(UART_HandleTypeDef)中保存了发送/接收缓冲区指针、剩余传输计数、收发状态、错误码等信息,HAL 函数正是通过读写这些成员来完成一次完整的数据收发管理。
9.3 阻塞接收的底层核心
阻塞接收底层最核心的代码其实就一行:
c
/*
* UART_WaitOnFlagUntilTimeout:轮询等待某个标志位,直到标志满足或超时。
* 参数含义:
* huart ------ 串口句柄
* UART_FLAG_RXNE ------ 要等待的标志:接收数据寄存器非空(RXNE)
* RESET ------ 等待的目标状态(RESET 表示"标志位置位时认为满足",注意这里约定)
* tickstart ------ 起始时间戳,用于计算是否超时
* Timeout ------ 超时时间
*
* 返回值 HAL_OK 表示标志已置位(即:有数据来了!)
*/
if (UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_RXNE, RESET, tickstart, Timeout) != HAL_OK)
{
/* 超时,处理超时逻辑并返回 */
}
这个函数做了什么?
c
/* 简化的内部逻辑 */
while (__HAL_UART_GET_FLAG(huart, UART_FLAG_RXNE) == RESET) /* 只要 RXNE 还没置位(没数据来) */
{
/* 就一直在循环里"死等",同时检查有没有超时 */
if (超时了)
return HAL_TIMEOUT;
}
/* 走出循环说明 RXNE 置位了:DR 里已经有新数据 */
return HAL_OK;
关于字长与校验的两个细节规则(面试高频):
- 字长 = 9B(含校验):帧结构为 8 位数据 + 1 位校验 ,有效数据在
DR[7:0]; - 字长 = 8B 且无校验:帧结构为 8 位数据,有效数据在
DR[7:0]。
c
/* HAL 库里的判断条件:什么时候数据在低 8 位直接用 uint8_t 读? */
if ((huart->Init.WordLength == UART_WORDLENGTH_9B) || /* 字长9位(含校验) */
((huart->Init.WordLength == UART_WORDLENGTH_8B) &&
(huart->Init.Parity == UART_PARITY_NONE))) /* 或:8位且无校验 */
{
/* 这两种情况,数据都在低8位,直接按 uint8_t 读取即可 */
}
通俗解释:不管字长配置是 9 位还是 8 位,只要有效数据都落在 DR 的低 8 位,我们按 8 位去读就行。校验位要么在最高位、要么根本不存在,反正不影响我们取有效数据。
9.4 阻塞发送的底层核心
阻塞发送的底层逻辑和接收"镜像对称":
c
/* 发送核心流程(简化) */
for (待发送数量 > 0)
{
/* ① 轮询等待 TXE 标志:发送数据寄存器空了才允许写入下一个字节 */
if (UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_TXE, RESET, tickstart, Timeout) != HAL_OK)
return HAL_TIMEOUT;
/* ② 把当前数据写入 DR 寄存器(根据字长选择 8 位或 16 位写入) */
huart->Instance->DR = (uint8_t)(*pData++ & 掩码);
/* ③ 剩余待发送数量减一 */
size--;
}
/* ④ 全部写完后再等待 TC(发送完成)标志:确保数据真的从引脚发出去了 */
if (UART_WaitOnFlagUntilTimeout(huart, UART_FLAG_TC, RESET, tickstart, Timeout) != HAL_OK)
return HAL_TIMEOUT;
一句话总结发送底层:等 TXE → 写 DR → ...循环... → 等 TC。
📌 知识点总结
阻塞接收/发送底层是如何通过标志位轮询实现的?RXNE、TXE、TC 各是什么?
阻塞模式的核心是一个轮询等待函数
UART_WaitOnFlagUntilTimeout,它会持续读取状态寄存器 SR 中的对应标志位,直到标志置位(或超时)才返回。接收时等待的是 RXNE(Receive data register Not Empty,接收数据寄存器非空)标志,该标志置位表示硬件已经收到一个完整的数据并放入数据寄存器 DR,此时 CPU 读取 DR 即可;发送时分为两步,先等待 TXE(Transmit data register Empty,发送数据寄存器空)标志,表示寄存器可写,此时把数据写入 DR,全部数据写完后还要等待 TC(Transmission Complete,传输完成)标志,表示整个数据帧(含停止位)已经从引脚完整发出。此外,接收数据时还会根据字长和校验配置判断有效数据的位置:字长 9 位含校验、或 8 位无校验时,有效数据都在 DR 的低 8 位,可直接按 uint8_t 读取。这套"读标志---取/写数据"的循环,就是阻塞式串口收发的本质。
10. 轮询模式使用方法总结
轮询模式(阻塞模式)串口使用的三步曲:
- 配置:在 CubeMX 中配置好串口参数(波特率、字长、校验、停止位等),生成工程;
- 初始化 :HAL 库自动完成参数写入寄存器(
HAL_UART_Init)和时钟/引脚配置(HAL_UART_MspInit); - 使用 :在主循环里调用
HAL_UART_Receive(阻塞接收)和HAL_UART_Transmit(阻塞发送)。
记住两个口诀:
- 接收:等 RXNE → 读 DR;
- 发送:等 TXE → 写 DR → ... → 等 TC。
什么时候适合用轮询模式?
| 场景 | 是否推荐 |
|---|---|
| 简单数据回传、调试输出 | ✅ 非常合适 |
| 主循环里低速收发、逻辑简单 | ✅ 合适 |
| 大量数据收发、需要高效 | ❌ 用 DMA |
| 要求实时响应、CPU 要干别的活 | ❌ 用中断/DMA |
11. 接口清单
| 接口 | 功能 | 适用模式 |
|---|---|---|
HAL_UART_Receive() |
阻塞式接收 | 轮询 |
HAL_UART_Transmit() |
阻塞式发送 | 轮询 |
HAL_UART_Receive_IT() |
中断式接收 | 中断(后续学习) |
HAL_UART_Transmit_IT() |
中断式发送 | 中断(后续学习) |
HAL_UART_Receive_DMA() |
DMA 接收 | DMA(后续学习) |
HAL_UART_Transmit_DMA() |
DMA 发送 | DMA(后续学习) |
阻塞发送和接收目前就两个,中断和 DMA 的接口我们后面章节慢慢介绍。
12. 附录
12.1 针脚复用清单
STM32 的很多引脚都有"复用功能"(Alternate Function,AF)。同一个引脚既可以当普通 GPIO,也可以被某些外设接管(比如 USART、SPI、I2C 等),具体哪个引脚能复用成哪个外设,是由芯片手册里的引脚复用表(Alternate Function Mapping) 决定的。
以 USART1 为例(常用板级引脚):
| 引脚 | 复用功能 |
|---|---|
| PA9 | USART1_TX(发送) |
| PA10 | USART1_RX(接收) |
| PA2 | USART2_TX(发送,USART2 默认) |
| PA3 | USART2_RX(接收,USART2 默认) |
这就是为什么在 CubeMX 里使能 USART1 后,会自动帮我们配置 PA9/PA10 的原因------它查阅了这张"复用清单"。
12.2 一个经典问题:全双工与数据丢失
问题: USART 虽然硬件上自己支持全双工 (即发送和接收可以同时进行),但用户的代码是串行执行的------同一时刻只能做一件事。这样就会存在一个问题:
发送方发送太快,而接收方(用户程序)又没有及时去读取,导致数据丢失。
怎么解决?
我们目前可以按行读取 :因为发送方是按行(一条一条)发送的,每发完一行就会停一下。这样就人为控制住了发送的时间间隔,保证接收方来得及处理,从而正确读取,不丢数据。
c
#define INBUFFER_MAX 128 // 定义接收缓冲区的最大容量为 128 字节
uint8_t recv_byte = 0; // 预留的单字节接收变量(此段主逻辑中暂未直接使用)
uint8_t inbuffer[INBUFFER_MAX] = {0}; // 全局字符接收缓冲区数组,并初始化清零
/**
* @brief 从串口读取一行字符串
* @param huart: 指向 UART_HandleTypeDef 结构体的指针(串口句柄)
* @retval total: 成功读取的字符长度(不包含结尾的 '\0');若缓冲区溢出则返回 0
*/
uint8_t ReadLine(UART_HandleTypeDef *huart)
{
uint8_t ch = 0; // 用于暂存每次从串口接收到的单字符
uint8_t total = 0; // 记录当前已存入缓冲区的字符数量(索引计数器)
while (1)
{
/* 从串口阻塞式接收 1 个字节的数据 */
/* 注意:huart 本身已是指针,直接传递,不需要再加 & 取地址符 */
HAL_UART_Receive(huart, &ch, 1, HAL_MAX_DELAY);
/* 情况 1:收到 Unix/Linux 风格的换行符 '\n' */
if (ch == '\n')
{
inbuffer[total] = '\0'; // 在字符串末尾添加空字符,表示字符串结束
return total; // 返回读取到的有效字符长度
}
/* 情况 2:收到 Windows/旧 Mac 风格的回车符 '\r' */
if (ch == '\r')
{
/* 如果紧接着还有 '\n'(即 Windows 标准的 \r\n 结尾),再读取一次吃掉 '\n' */
HAL_UART_Receive(huart, &ch, 1, HAL_MAX_DELAY);
inbuffer[total] = '\0'; // 在字符串末尾添加空字符,表示字符串结束
return total; // 返回读取到的有效字符长度
}
/* 情况 3:检查缓冲区溢出隐患 */
/* 留出 1 个字节空间给字符串结束符 '\0',防止数组越界 */
if (total >= INBUFFER_MAX - 1)
{
return 0; /* 缓冲区满,读取失败,返回 0 */
}
/* 情况 4:普通有效字符,存入缓冲区并更新计数 */
inbuffer[total++] = ch; // 等价于:inbuffer[total] = ch; total++;
}
}
int main()
{
/* 1. 调用 ReadLine 函数阻塞读取串口 1 发送过来的一行字符串 */
/* 返回值 num 表示实际接收到的有效字符个数(不含 '\0') */
uint8_t num = ReadLine(&huart1);
/* 2. 将接收到的字符串通过串口 1 原样发送回去(实现串口回显/Echo) */
/* 参数 3 传入 num,即只发送接收到的有效字符,不发送多余的 '\0' 或空白字符 */
HAL_UART_Transmit(&huart1, inbuffer, num, HAL_MAX_DELAY);
/* 3. 翻转 GPIOF 的 Pin 8 引脚电平(例如:控制 LED 指示灯闪烁,代表处理了一次命令) */
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
}
这也引出了串口学习的"下一课":真正工业级的可靠做法是中断接收 (每来一个字节就进一次中断把数据拿走)或 DMA 接收(数据由 DMA 硬件自动搬运,搬完再通知 CPU)。这些我们会在后面的章节深入学习。轮询模式是我们打基础的第一步,非常重要!
写在最后
恭喜你,到这里已经完整走完了一个 STM32 串口轮询模式的"回声"项目!你不仅会用了 HAL_UART_Receive 和 HAL_UART_Transmit,还搞清楚了它们背后的标志位(RXNE/TXE/TC)、寄存器(SR/DR/CR)、初始化分层(HAL/MSP)等底层原理。
串口是嵌入式世界里最重要、最常用的通信方式之一,把轮询模式吃透,接下来学中断模式、DMA 模式就会轻松很多。万丈高楼平地起,基础扎实了,往上盖才稳。
Happy Coding!🎉
