硬件原理
串口通信基础
串口(UART,Universal Asynchronous Receiver/Transmitter)是嵌入式领域最常用的通信方式之一。它的原理其实很简单——用两根数据线就能实现两个设备之间的双向通信。
1 | 设备A 设备B |
串口通信的几个关键点:
| 概念 | 说明 |
|---|---|
| TX / RX | 发送引脚(Transmit)和接收引脚(Receive),交叉连接——A的TX接B的RX,A的RX接B的TX |
| 共地(GND) | 两设备的参考地要连在一起,否则电平基准不同,无法正常通信 |
| 波特率 | 每秒传输的比特数,两设备必须设置相同的波特率才能通信,常用值有 9600、115200 |
| 数据帧 | 通常由 1位起始位 + 8位数据位 + 1位停止位 组成,所以每传1字节实际需要10个比特 |
开发板硬件连接
我的 keysking 开发板上,串口转USB芯片(CH340)已经连接到了 USART2,并引出一个 Type-C 口。只需一根 Type-C 数据线(就是手机充电线)插到电脑上,就能同时给板子供电和进行串口通信。
三个 LED 的连接和之前一样:
| LED | 引脚 | 控制信号 |
|---|---|---|
| 🔴 红色 | PB0 | GPIO 高电平亮 |
| 🟢 绿色 | PA7 | GPIO 高电平亮 |
| 🔵 蓝色 | PA6 | GPIO 高电平亮 |
CubeMX 配置
新建项目
芯片选择 STM32F103C8T6,步骤和前几篇一样,略。
起手式:配置调试接口
在 System Core → SYS 中,将 Debug 设为 Serial Wire。
配置 USART2
前几篇的工程主要和 GPIO 打交道,这次接触了一个新外设——USART(Universal Synchronous/Asynchronous Receiver Transmitter,通用同步/异步收发器)。
在 Connectivity → USART2 中,将模式设为 Asynchronous(异步通信):
| 参数 | 值 |
|---|---|
| Mode | Asynchronous |
| Baud Rate | 115200 |
| Word Length | 8 Bits(含 Parity) |
| Parity | None |
| Stop Bits | 1 |
波特率(Baud Rate) 是串口通信最关键的参数,它定义了每秒钟传输多少个比特(bit)。
波特率必须一致,否则无法通信
串口通信是异步的——TX 和 RX 之间没有独立的时钟线来同步节奏。那两边怎么对齐”一个比特”的时间长度呢?
答案就是波特率。
发送端和接收端各自按事先约定好的波特率来”打拍子”:
1 | 发送端(115200波特率):| 8.68μs | 8.68μs | 8.68μs | ... |
两边都以 115200 的速率采样,就能把每个比特正确解码。
那如果波特率不一致呢? 比如发送端是 115200,接收端误设成了 9600:
1 | 发送端(115200):比特0 比特1 比特2 比特3 比特4 比特5 比特6 比特7 |
接收端一个比特的时间窗口(104μs)比发送端实际发送一个比特的时间(8.7μs)长得多,结果就是——一个波特率采样周期里吞进了多个比特,解码出来的数据全是乱的。
实际调试中也很容易遇到这个问题。第一次打开串口助手时,如果收到的不是可读的文字,而是一堆乱七八糟的符号,先检查波特率是不是设对了——这是最常犯的错误。
数据帧结构与实际传输速率
串口传输 1 个字节(8 位数据),实际发出去的并不只是 8 个比特,而是这样的一个数据帧:
1 | 起始位 8位数据(从低位到高位) 停止位 |
| 组成 | 长度 | 说明 |
|---|---|---|
| 起始位 | 1 bit | 拉低电平,告诉接收端”要开始发了” |
| 数据位 | 8 bit | 真正的数据(从最低位开始传) |
| 停止位 | 1 bit | 拉高电平,告诉接收端”这一帧结束了” |
传 1 字节实际需要发 10 个比特(1起始 + 8数据 + 1停止)。
所以 115200 波特率下:
1 | 115200 ÷ 10 = 11520 字节/秒 |
一秒大约能传 1 万多个字节,对于发送几个字节的指令来说绰绰有余。
常见的波特率
| 波特率 | 每秒理论传输字节数 | 适用场景 |
|---|---|---|
| 9600 | 960 字节 | 长距离通信、低速设备(GPS模块等) |
| 38400 | 3840 字节 | 一般传感器通信 |
| 115200 | 11520 字节 | 最常用,STM32 调试默认值 |
| 921600 | 92160 字节 | 高速通信(需短线、低干扰环境) |
115200 是 STM32 学习中最常用的波特率,它足够快,又不会因为线太长或干扰而出错。
配置完成后,可以看到 PA2(TX)和 PA3(RX)被自动设置为 USART2 的收发引脚。
配置 LED 引脚(GPIO 输出)
三个 LED 的 GPIO 配置和之前完全一样:
| 引脚 | 功能 | User Label | 其他参数 |
|---|---|---|---|
| PB0 | GPIO_Output | LED_RED |
推挽输出、无上下拉、低速 |
| PA7 | GPIO_Output | LED_GREEN |
推挽输出、无上下拉、低速 |
| PA6 | GPIO_Output | LED_BLUE |
推挽输出、无上下拉、低速 |
生成代码
Project Manager 设置:
| 设置项 | 值 |
|---|---|
| Project Name | Serial |
| Toolchain / IDE | CMake |
点击 Generate Code,用 VS Code 打开。
VS Code 中编写核心代码
main.h 中的宏定义
CubeMX 根据 User Label 生成了以下宏:
1 | // LED 引脚定义 |
通信协议设计
电脑和 STM32 要”聊天”,得先约定好聊天的格式——这就是通信协议。
打个比方:你和朋友约好,每次发消息都用 “人名 + 动作” 的格式。
张三 去开门→ 张三去开门李四 坐下→ 李四坐下
谁要是发 今天天气真好,对方就听不懂了——因为不符合约定的格式。
串口通信也是一样的道理。串口只负责把字节从 A 传到 B,它不管这些字节是什么意思。意思是我们自己约定的。
这个项目中,我规定电脑发给 STM32 的每条指令都是 2个字节:
| 字节顺序 | 含义 | 取值 |
|---|---|---|
| 第1个字节 | 选择哪个小灯 | R(红色)、G(绿色)、B(蓝色) |
| 第2个字节 | 开还是关 | 0(关闭)、1(打开) |
例如:
- 发
R1→ STM32 拆开看:第1字节是R(红色灯),第2字节是1(打开)→ 红色灯亮 - 发
G0→ 第1字节是G(绿色灯),第2字节是0(关闭)→ 绿色灯灭 - 发
B1→ 蓝色灯亮
同时,STM32 收到指令后,会把收到的 2 个字节原样发回电脑,作为”我收到了”的确认。
receiveData数组设成uint8_t receiveData[2],就是因为协议规定一条指令刚好 2 个字节,多一个少一个都不行。
main.c 核心代码
打开 main.c,在 USER CODE BEGIN 2 中定义接收缓冲区:
1 | /* USER CODE BEGIN 2 */ |
然后在 while(1) 中编写主逻辑:
1 | /* USER CODE BEGIN WHILE */ |
代码讲解
用到的HAL函数:
| 函数 | 作用 |
|---|---|
HAL_UART_Receive(&huart2, buffer, size, timeout) |
STM32从串口2接收指定字节数的数据 |
HAL_UART_Transmit(&huart2, buffer, size, timeout) |
STM32从串口2发送指定字节数的数据 |
HAL_GPIO_WritePin(port, pin, state) |
老朋友了,设置GPIO输出电平 |
四个参数的详解(以 HAL_UART_Receive 为例):
| 参数 | 含义 | 本项目中填的值 |
|---|---|---|
| 串口句柄指针 | 操作哪个串口 | &huart2(USART2的地址,取地址符不能忘) |
| 数据缓冲区指针 | 接收到的数据存到哪里 | receiveData(数组名本身就是指针) |
| 接收长度 | 要接收多少个字节 | 2(我们规定一条指令就是2字节,所以只收2个) |
| 超时时间 | 等多久没收到就放弃 | HAL_MAX_DELAY(无限等待,直到收到数据) |
核心逻辑:
阻塞接收——
HAL_UART_Receive会一直等待,直到收到2字节数据才往下执行。这是串口的轮询模式(也叫阻塞模式),代码简单直接,但缺点是一旦收不到数据,程序就卡在这了。数据回显——收到后立刻用
HAL_UART_Transmit发回电脑。这在调试时非常有用——看到回声,就说明通信链路是通的。指令解析——判断第二字节
receiveData[1]是'0'还是'1',来决定state是GPIO_PIN_RESET还是GPIO_PIN_SET。开关控制——用
switch根据第一字节决定控制哪个引脚,把state写进去。
解读 MX_USART2_UART_Init —— USART 的配置代码
回到 CubeMX 生成的代码,看看 USART2 到底是怎么被初始化的。这个函数在 main.c 中,main() 里调用 MX_USART2_UART_Init() 完成串口配置。
来看它的实现:
1 | static void MX_USART2_UART_Init(void) |
先认识一个重要的结构体:huart2
1 | UART_HandleTypeDef huart2; |
huart2 是一个串口句柄(Handle),可以理解为 USART2 这个外设的”身份证”。
它身上挂了一堆信息:
- 是哪个串口(
Instance指向 USART2 的寄存器基地址) - 配置参数(波特率、数据格式等都在
Init结构体里) - 运行状态(发送/接收过程中用到的缓存、标志位等)
后面所有操作 USART2 的 HAL 函数,第一个参数都要传 &huart2——就是要这张”身份证”来告诉函数”你操作的是 USART2”。
逐行解析:
| 行 | 代码 | 对应 CubeMX 配置 | 说明 |
|---|---|---|---|
| (1) | .Instance = USART2 |
(自动) | 告诉 HAL 库要操作的是 USART2 这个外设,不是 USART1 或 USART3。Instance 指向 USART2 的寄存器基地址(0x40004400) |
| (2) | .BaudRate = 115200 |
Baud Rate 下拉框 | 设置波特率。CubeMX 会根据你选的数值算出分频系数,写入串口的波特率寄存器(USART_BRR)。我们在前面花了大篇幅讲波特率,这里就是把这个数值落实到代码里 |
| (3) | .WordLength = UART_WORDLENGTH_8B |
Word Length 下拉框 | 一帧数据中数据位的长度。UART_WORDLENGTH_8B 表示 8 位数据。还有 UART_WORDLENGTH_9B(9位),但 8 位最常用 |
| (4) | .StopBits = UART_STOPBITS_1 |
Stop Bits 下拉框 | 停止位长度。UART_STOPBITS_1 = 1 位停止位。还有 0.5 位、2 位等选项,但标准 UART 通信通常就是 1 位 |
| (5) | .Parity = UART_PARITY_NONE |
Parity 下拉框 | 校验位——用于检测数据传输是否出错。NONE 表示不启用校验。还有 UART_PARITY_EVEN(偶校验)和 UART_PARITY_ODD(奇校验) |
| (6) | .Mode = UART_MODE_TX_RX |
(自动) | 开启收发双工。UART_MODE_TX_RX = 同时支持发送和接收。如果只发不收,可以设 UART_MODE_TX |
| (7) | .HwFlowCtl = UART_HWCONTROL_NONE |
(默认) | 硬件流控制——是否额外用 RTS/CTS 引脚来控制数据流。简单的 TTL 串口不需要,设为 NONE |
| (8) | .OverSampling = UART_OVERSAMPLING_16 |
(默认) | 过采样率——接收端用多快的频率去采样 RX 引脚上的电平。16 倍过采样意味着每个比特采 16 次,抗干扰能力强 |
(3)(4)(5)合起来构成了数据帧格式:
1 | 起始位(1) + 数据位(8) + 校验位(无) + 停止位(1) = 一帧共10比特 |
这就是我们之前在”数据帧结构”部分讲到的 UART 帧格式——CubeMX 里的取值,最终通过这里写进了代码。
HAL_UART_Init 内部做了什么?
HAL_UART_Init 拿到 huart2 结构体后,会做三件核心的事:
- 复位 USART2——把 USART2 的寄存器恢复到默认状态
- 配置 USART2 的寄存器——根据
huart2.Init里的参数,计算对应的分频系数、帧格式等,写入 USART2 的寄存器(USART_BRR、USART_CR1、USART_CR2、USART_CR3) - 开启 USART2——使能 USART2 外设,让它开始工作
如果以后想脱离 CubeMX 手写串口配置,本质上就是把上面那 8 行初始化代码,直接翻译成寄存器的读写操作。
解读 MX_GPIO_Init —— 引脚配置与复用功能
MX_GPIO_Init() 不只配置了三个 LED 引脚,还配置了 USART2 的 TX/RX 引脚(PA2、PA3)。不过 TX/RX 的配置不是在 GPIO_Init 里显式写的,而是由 HAL_UART_Init 内部自动完成的——这是因为 USART2 的 TX/RX 引脚是复用功能(Alternate Function),不是普通的 GPIO。
先看 LED 引脚的配置
1 | static void MX_GPIO_Init(void) |
这部分和之前流水灯博客里的 GPIO 配置完全一样,不再重复解释。值得注意的新东西只有两个:
为什么 GPIOA 上的两个 LED 可以一次配完?
HAL_GPIO_Init 的 Pin 成员支持按位或组合多个引脚:
1 | GPIO_InitStruct.Pin = LED_BLUE_Pin | LED_GREEN_Pin; // = GPIO_PIN_6 | GPIO_PIN_7 |
因为 GPIO_PIN_6 是 0x0040,GPIO_PIN_7 是 0x0080,按位或后得到 0x00C0。HAL_GPIO_Init 内部会逐个检查这些位,把所有对应的引脚都配成同样的参数。
但如果引脚分布在不同的 GPIO 端口(比如 PA6 在 GPIOA,PB0 在 GPIOB),就必须分开配置,因为每次调用 HAL_GPIO_Init 的第二个参数是指定端口的。
USART2 的 TX/RX 引脚是怎么配置的?
观察芯片原理图会发现,USART2 的 TX 和 RX 分别占用 PA2 和 PA3。
但在 MX_GPIO_Init 里,并没有看到配置 PA2 和 PA3 的代码。那它们是怎么变成串口引脚的呢?
关键在 HAL_UART_Init(&huart2) 内部——它调用了一个更底层的函数 HAL_UART_MspInit()(MSP = MCU Support Package),这个函数在 stm32f1xx_hal_msp.c 中:
1 | void HAL_UART_MspInit(UART_HandleTypeDef* huart) |
这段代码会在 HAL_UART_Init 中被自动调用。这里出现了前几篇没见过的新模式——**GPIO_MODE_AF_PP(复用推挽输出)**。
| 模式 | 含义 | 什么时候用 |
|---|---|---|
GPIO_MODE_OUTPUT_PP |
普通推挽输出 | 直接控制 LED 这类外设 |
GPIO_MODE_AF_PP |
复用功能推挽输出 | 引脚的控制权交给片上外设(如 USART 的 TX 引脚) |
GPIO_MODE_INPUT |
普通浮空输入 | 读取外部信号(如按键、USART 的 RX 引脚) |
普通输出和复用输出的区别:
1 | 普通输出(OUTPUT_PP): |
对于 USART2 的 TX 引脚(PA2),它发送数据的时机和数据内容都是由 USART 外设硬件决定的,不是由 CPU 用 HAL_GPIO_WritePin 控制的。所以必须配成 AF_PP,把引脚的控制权”让渡”给 USART 外设。
RX 引脚(PA3)同理——它负责接收外部信号到 USART 接收寄存器,所以配成普通的输入模式,让 USART 可以读取引脚电平。
这就是 GPIO 的复用功能(Alternate Function)——同一个引脚,在不同配置下可以属于不同的片上外设。配成普通 GPIO 时,你可以用 WritePin/ReadPin 控制它;配成 AF 时,它归某个外设管。这也是为什么从这篇开始,CubeMX 需要配置 Debug 模式为 Serial Wire——因为一旦开启了 USART 的复用功能,就会影响到 SWD 调试引脚的相关配置。
配置参数的对应关系总结
把 CubeMX 里的配置和生成的代码对应起来:
| CubeMX 设置 | 出现在哪个函数 | 底层操作 |
|---|---|---|
| USART2 Mode = Asynchronous | MX_USART2_UART_Init |
USART_CR1 寄存器的 UE、TE、RE 位 |
| Baud Rate = 115200 | MX_USART2_UART_Init |
计算分频系数 → 写入 USART_BRR 寄存器 |
| PA2 (TX) | HAL_UART_MspInit |
GPIOA_CRL 寄存器,配置为 AF_PP |
| PA3 (RX) | HAL_UART_MspInit |
GPIOA_CRL 寄存器,配置为 输入模式 |
| LED_RED (PB0) | MX_GPIO_Init |
GPIOB_CRL 寄存器,配置为 推挽输出 50MHz |
常见编译警告及处理
如果你直接编译,可能会遇到几个警告,这是 CubeMX 生成代码时常见的情况:
警告1:strlen 未声明
如果你在测试发送功能时写了类似 strlen(message) 的代码,编译器会提示 strlen 未声明。
1 |
警告2:类型不匹配
HAL_UART_Transmit 的第二个参数要求 uint8_t* 类型,但如果你传了一个 char* 数组,会有类型不匹配的警告:
1 | // 错误写法 |
实际上 char 和 uint8_t 在底层都是 8 位数据,类型不同只是因为语义不同——不转换也能运行,但编译器会警告。养成强转的习惯,对代码严谨性有好处。
硬件连接
代码写完后,还需要把 STM32 和电脑连接起来。
keysking 学习板已经集成了串口转 USB 芯片(CH340),连接到了 USART2,并引出了一个 Type-C 接口。所以只需要:
1 | 开发板 Type-C 口 ←→ 电脑 USB 口(用手机数据线) |
如果是使用其他开发板(比如最小系统板),则需要额外的 USB转TTL模块:
1 | STM32 TX (PA2) ←→ 模块 RX |
串口助手的使用
要让电脑和 STM32 通信,还需要一个串口调试助手。这里使用的是 keysking 开发的波特律动串口助手,它的好处是在网页上就能进行调试,无需下载。
连接步骤:
选择串口——在串口助手中点击”选择串口”,找到对应的 COM 口
- 学习板用户选择
USB Serial(CH340) - 不确定是哪个的话,可以插拔数据线,看哪个选项随之出现/消失
- 学习板用户选择
设置波特率——设为 115200(与代码中的设置一致)
设置显示模式——将接收显示模式设为 ASCII码模式,否则默认的 Hex 模式会显示一堆十六进制数
如果找不到串口,说明需要安装相关的串口驱动。
可以在波特律动串口助手界面左下角,点击三横线的按钮,找到常见驱动下载
编译 & 烧录
- VS Code 底部工具栏点击齿轮图标 → 生成
- 连接 ST-Link,点击运行和调试 → 选择 STM32Cube:STM32 Launch STLink GDB Server
- 点击继续按钮,程序开始运行
如果遇到烧录问题,检查 Debug 模式是否设为了 Serial Wire。
效果展示
烧录成功后,在串口助手的发送区输入:
| 发送内容 | 效果 |
|---|---|
R1 |
🔴 红色小灯亮起 |
R0 |
🔴 红色小灯熄灭 |
G1 |
🟢 绿色小灯亮起 |
G0 |
🟢 绿色小灯熄灭 |
B1 |
🔵 蓝色小灯亮起 |
B0 |
🔵 蓝色小灯熄灭 |
同时,串口助手的接收区会显示 STM32 返回的指令内容(回显)。
效果展示视频:
https://www.bilibili.com/video/BV1NV3y6YEYv/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa
轮询模式的局限性
当前代码使用的是串口的轮询模式(阻塞模式),这种模式有明显的缺点:
HAL_UART_Receive会卡住程序——在收到数据之前,main loop 里后面的代码全都不执行- 接收固定长度——必须收到恰好 2 字节才能继续,如果电脑发了 3 个字节,第三个字节就留在缓冲区里了
- 无法处理多任务——如果还想同时做其他事(比如检测按键),就做不到了
解决方法——中断模式和 DMA 模式,会在后面的博客中学习。
总结
这次项目是 STM32 学习路上的一个重要里程碑——从”板子自己玩”进步到”板子和电脑对话”。
| 新知识点 | 说明 |
|---|---|
| USART 外设配置 | CubeMX 中配置串口的波特率、数据格式等参数 |
HAL_UART_Receive / HAL_UART_Transmit |
串口收发的 HAL 函数,阻塞模式 |
| 通信协议设计 | 定义 PC 和 STM32 之间的指令格式 |
| 串口助手 | 调试串口通信的必备工具 |
| 回显机制 | 收到数据立即发回,确认通信正常 |

