STM32 串口通信基础(发送与接收)
基于江协科技 STM32 入门教程 9-1 串口发送、9-2 串口发送+接收 两个项目整理
一、串口通信基本概念
1.1 什么是串口
串口(Serial Port)是一种逐位按顺序传输数据的通信接口。STM32 中的串口外设叫 USART(Universal Synchronous/Asynchronous Receiver/Transmitter,通用同步/异步收发器),最常用的是异步通信模式(UART),即不需要时钟线,双方约定好波特率即可通信。
1.2 串口通信的接线
最简单的全双工串口通信只需要 3 根线:
| 线 | 方向 | 说明 |
|---|---|---|
| TXD | 发送 | 发送数据,接对方的 RXD |
| RXD | 接收 | 接收数据,接对方的 TXD |
| GND | 地线 | 共地,保证电平参考一致 |
注意:TX 接 RX、RX 接 TX,交叉连接。
1.3 数据帧格式
串口异步通信中,一个字节的数据被打包成一帧,格式如下:
空闲位 | 起始位 | 数据位(8位) | 校验位(可选) | 停止位 | 空闲位
1 | 0 | D0~D7 | 0/1 | 1 | 1
- 起始位:1 位低电平,标志一帧的开始
- 数据位:8 位(最常用),低位在前(LSB First)
- 校验位:可选,用于简单的错误检测(无校验/奇校验/偶校验)
- 停止位:1 位高电平,标志一帧结束
- 空闲位:高电平,表示总线空闲
通信双方必须约定相同的参数才能正确通信:波特率、数据位、校验位、停止位。
1.4 波特率
波特率(Baud Rate)表示每秒传输的码元数,单位 bps。常用波特率:9600、115200 等。
- 波特率 9600 表示每秒传输 9600 位
- 一帧 10 位(1起始+8数据+1停止),则每秒最多传 960 个字节
- 通信双方波特率必须一致,否则数据会乱码
1.5 STM32F103 的 USART 资源
| 外设 | 总线 | TX 引脚 | RX 引脚 |
|---|---|---|---|
| USART1 | APB2 | PA9 | PA10 |
| USART2 | APB1 | PA2 | PA3 |
| USART3 | APB1 | PB10 | PB11 |
本教程使用 USART1(PA9=TX,PA10=RX)。
二、项目一:9-1 串口发送
2.1 项目目标
实现 STM32 通过串口向电脑发送数据,包括发送单个字节、数组、字符串、数字以及格式化输出。
2.2 硬件连接
| STM32 引脚 | 连接 | 说明 |
|---|---|---|
| PA9 (TX) | USB转TTL模块的 RXD | 发送数据到电脑 |
| PA10 (RX) | USB转TTL模块的 TXD | 本项目未使用接收 |
| GND | USB转TTL模块的 GND | 共地 |
电脑端使用串口助手查看接收到的数据,波特率设为 9600。
2.3 代码结构
9-1 串口发送/
├── Hardware/
│ ├── Serial.c ← 串口驱动(初始化+发送函数)
│ └── Serial.h ← 头文件声明
├── User/
│ └── main.c ← 主函数,演示各种发送
└── ...
2.4 Serial_Init 初始化函数逐行解析
void Serial_Init(void)
{
/* 1. 开启时钟 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // USART1 在 APB2 总线
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // GPIOA 时钟
/* 2. GPIO 初始化 —— TX 引脚 */
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; // PA9 = TX
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
/* 3. USART 初始化 */
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600; // 波特率 9600
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 无硬件流控
USART_InitStructure.USART_Mode = USART_Mode_Tx; // 仅发送模式
USART_InitStructure.USART_Parity = USART_Parity_No; // 无校验
USART_InitStructure.USART_StopBits = USART_StopBits_1; // 1位停止位
USART_InitStructure.USART_WordLength = USART_WordLength_8b; // 8位数据位
USART_Init(USART1, &USART_InitStructure);
/* 4. 使能 USART */
USART_Cmd(USART1, ENABLE);
}
关键配置说明
(1)TX 引脚为什么用复用推挽输出(AF_PP)?
PA9 是 USART1 的 TX 引脚,串口外设需要直接控制引脚电平,所以要用复用功能模式。推挽输出能主动输出高低电平,适合发送信号。
(2)USART 初始化结构体参数
| 参数 | 本项目配置 | 说明 |
|---|---|---|
| BaudRate | 9600 | 波特率,需与串口助手一致 |
| WordLength | 8b | 8位数据位 |
| StopBits | 1 | 1位停止位 |
| Parity | No | 无校验位 |
| Mode | Tx | 仅使能发送 |
| HardwareFlowControl | None | 不使用硬件流控 |
这组参数就是串口助手中常见的 "9600, 8N1"(9600波特率、8数据位、无校验、1停止位)。
2.5 发送函数族
发送一个字节 Serial_SendByte
void Serial_SendByte(uint8_t Byte)
{
USART_SendData(USART1, Byte); // 写入数据寄存器 DR
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); // 等待发送完成
}
原理:
USART_SendData把数据写入 TDR(发送数据寄存器)- 硬件自动把数据移入移位寄存器,按位从 TX 引脚发出
- 当 TDR 的数据转移到移位寄存器后,TXE 标志位置 1(TXE = Transmit Data Register Empty)
while循环等待 TXE=1,确保上一个字节已经发完,再写下一个字节
注意:TXE=1 表示数据寄存器空(数据已进入移位寄存器),并不代表整帧完全发完。如果需要确认完全发完,应等待 TC(Transmission Complete)标志。但连续发送时等 TXE 就够了。
发送数组 Serial_SendArray
void Serial_SendArray(uint8_t *Array, uint16_t Length)
{
uint16_t i;
for (i = 0; i < Length; i++)
{
Serial_SendByte(Array[i]); // 逐个字节发送
}
}
发送字符串 Serial_SendString
void Serial_SendString(char *String)
{
uint8_t i;
for (i = 0; String[i] != '\0'; i++) // 遇到 '\0' 停止
{
Serial_SendByte(String[i]);
}
}
字符串在 C 语言中以 '\0'(数值 0)结尾,函数遍历到 '\0' 就停止,不需要额外传长度。
发送数字 Serial_SendNumber
uint32_t Serial_Pow(uint32_t X, uint32_t Y) // 辅助:X的Y次方
{
uint32_t Result = 1;
while (Y--) Result *= X;
return Result;
}
void Serial_SendNumber(uint32_t Number, uint8_t Length)
{
uint8_t i;
for (i = 0; i < Length; i++)
{
Serial_SendByte(Number / Serial_Pow(10, Length - i - 1) % 10 + '0');
}
}
逐位拆解原理: 以 Number=111, Length=3 为例:
- 第 0 位:
111 / 100 % 10 = 1,加'0'得到字符'1' - 第 1 位:
111 / 10 % 10 = 1,得到'1' - 第 2 位:
111 / 1 % 10 = 1,得到'1'
数字加 '0'(ASCII 码 48)是把数字字符转换为对应的 ASCII 字符,这是 C 语言的常用技巧。
2.6 printf 格式化输出的三种方法
方法 1:重定向 fputc(直接用 printf)
int fputc(int ch, FILE *f)
{
Serial_SendByte(ch);
return ch;
}
C 标准库的 printf 底层调用 fputc 输出字符。重写 fputc 把字符导向串口后,就可以直接用 printf:
printf("\r\nNum2=%d", 222);
注意事项:
- 需要在 Keil 工程选项中勾选 Use MicroLIB(魔术棒 → Target → Code Generation)
printf只有一个,整个工程只能重定向到一个串口\r\n是回车换行,串口助手中换行需要这两个字符
方法 2:sprintf + SendString
char String[100];
sprintf(String, "\r\nNum3=%d", 333);
Serial_SendString(String);
sprintf 把格式化结果打印到字符数组,再用串口发送。优点是灵活,数组可以任意处理。
方法 3:封装 Serial_Printf(推荐)
void Serial_Printf(char *format, ...)
{
char String[100];
va_list arg;
va_start(arg, format);
vsprintf(String, format, arg);
va_end(arg);
Serial_SendString(String);
}
使用方法:
Serial_Printf("\r\nNum4=%d", 444);
这是对方法 2 的封装,使用可变参数(va_list、va_start、vsprintf、va_end),调用方式和 printf 一样,但不占用 printf 的重定向,可以在多处使用。
2.7 main.c 演示逻辑
int main(void)
{
OLED_Init();
Serial_Init();
Serial_SendByte(0x41); // 发送字节 'A'
uint8_t MyArray[] = {0x42, 0x43, 0x44, 0x45};
Serial_SendArray(MyArray, 4); // 发送数组 "BCDE"
Serial_SendString("\r\nNum1="); // 发送字符串
Serial_SendNumber(111, 3); // 发送数字 "111"
printf("\r\nNum2=%d", 222); // 方法1:printf重定向
char String[100];
sprintf(String, "\r\nNum3=%d", 333);
Serial_SendString(String); // 方法2:sprintf
Serial_Printf("\r\nNum4=%d", 444); // 方法3:封装的Printf
Serial_Printf("\r\n");
while (1) {}
}
串口助手接收结果:
ABCDE
Num1=111
Num2=222
Num3=333
Num4=444
三、项目二:9-2 串口发送+接收
3.1 项目目标
在 9-1 的基础上增加串口接收功能:电脑通过串口助手发送一个字节,STM32 收到后在 OLED 上显示并回传给电脑(回显测试)。
3.2 与 9-1 的差异
9-2 在 9-1 的基础上增加了三部分:
- RX 引脚初始化(PA10 上拉输入)
- 接收中断配置(RXNE 中断 + NVIC)
- 中断服务函数 + 接收标志位/数据变量
3.3 新增代码逐行解析
(1)RX 引脚初始化
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; // PA10 = RX
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
RX 引脚配置为上拉输入:
- 输入模式:STM32 读取外部电平
- 上拉:空闲时引脚被拉到高电平,符合串口空闲状态为高电平的要求,防止干扰
(2)USART 模式改为收发都使能
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; // 发送+接收
9-1 只有 USART_Mode_Tx,9-2 加上了 USART_Mode_Rx。
(3)接收中断配置
/* 开启 RXNE 中断 */
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
// RXNE = Read Data Register Not Empty(读数据寄存器非空)
// 当串口收到一个字节时,RXNE 标志位置1,触发中断
/* NVIC 中断分组 */
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
/* NVIC 配置 */
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; // USART1中断通道
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; // 使能
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级1
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; // 响应优先级1
NVIC_Init(&NVIC_InitStructure);
为什么用中断而不是轮询?
| 方式 | 特点 |
|---|---|
| 轮询 | CPU 不断查询 RXNE 标志,浪费 CPU 资源,可能漏数据 |
| 中断 | 收到数据时自动跳转到中断函数处理,CPU 平时可以做其他事 |
(4)接收变量与标志位
uint8_t Serial_RxData; // 存放接收到的数据
uint8_t Serial_RxFlag; // 接收标志位:收到数据后置1,读取后清0
(5)中断服务函数
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) // 判断是否为接收中断
{
Serial_RxData = USART_ReceiveData(USART1); // 读取DR寄存器,获取收到的字节
Serial_RxFlag = 1; // 置标志位
USART_ClearITPendingBit(USART1, USART_IT_RXNE); // 清除中断标志
}
}
中断函数要点:
- 函数名
USART1_IRQHandler是固定的,不能写错(从启动文件的中断向量表来) - 先判断中断源(虽然本项目只开了 RXNE 中断,但这是好习惯)
USART_ReceiveData读取 DR 寄存器后,RXNE 标志会硬件自动清除,所以最后的USART_ClearITPendingBit其实可以不写- 中断函数应尽量短,这里只做"读数据+置标志",处理逻辑放在主循环
(6)标志位读取函数
uint8_t Serial_GetRxFlag(void)
{
if (Serial_RxFlag == 1)
{
Serial_RxFlag = 0; // 读取后自动清零
return 1;
}
return 0;
}
uint8_t Serial_GetRxData(void)
{
return Serial_RxData;
}
Serial_GetRxFlag 采用"读后清零"机制,确保每个数据包只被处理一次。
3.4 main.c 接收逻辑
uint8_t RxData;
int main(void)
{
OLED_Init();
OLED_ShowString(1, 1, "RxData:");
Serial_Init();
while (1)
{
if (Serial_GetRxFlag() == 1) // 查询是否收到数据
{
RxData = Serial_GetRxData(); // 获取收到的字节
Serial_SendByte(RxData); // 回传给电脑(回显)
OLED_ShowHexNum(1, 8, RxData, 2); // OLED显示十六进制值
}
}
}
程序流程:
- 主循环不断查询
Serial_GetRxFlag() - 电脑发送一个字节 → 触发中断 → 中断中保存数据、置标志位
- 主循环检测到标志位 → 读取数据 → 回传 + 显示
- 标志位自动清零,等待下一个字节
实验现象:
- 串口助手发送任意字节(如
0x41) - OLED 显示
RxData:41 - 串口助手收到回传的
41(即'A')
四、串口初始化配置对比(9-1 vs 9-2)
| 配置项 | 9-1 仅发送 | 9-2 发送+接收 |
|---|---|---|
| PA9 (TX) | 复用推挽输出 | 复用推挽输出 |
| PA10 (RX) | 不初始化 | 上拉输入 |
| USART_Mode | Tx | Tx | Rx |
| 接收中断 | 不开启 | RXNE 中断使能 |
| NVIC | 不配置 | 配置 USART1 通道 |
| 接收变量/标志 | 无 | Serial_RxData / Serial_RxFlag |
| 中断函数 | 无 | USART1_IRQHandler |
五、串口常用库函数速查
5.1 初始化与使能
void USART_Init(USART_TypeDef* USARTx, USART_InitTypeDef* USART_InitStruct);
void USART_Cmd(USART_TypeDef* USARTx, FunctionalState NewState);
void USART_ITConfig(USART_TypeDef* USARTx, uint16_t USART_IT, FunctionalState NewState);
void USART_DMACmd(USART_TypeDef* USARTx, uint16_t USART_DMAReq, FunctionalState NewState);
5.2 数据收发
void USART_SendData(USART_TypeDef* USARTx, uint16_t Data);
uint16_t USART_ReceiveData(USART_TypeDef* USARTx);
5.3 标志位查询
FlagStatus USART_GetFlagStatus(USART_TypeDef* USARTx, uint16_t USART_FLAG);
void USART_ClearFlag(USART_TypeDef* USARTx, uint16_t USART_FLAG);
ITStatus USART_GetITStatus(USART_TypeDef* USARTx, uint16_t USART_IT);
void USART_ClearITPendingBit(USART_TypeDef* USARTx, uint16_t USART_IT);
5.4 常用标志位
| 标志位 | 含义 | 置位时机 |
|---|---|---|
| USART_FLAG_TXE | 发送数据寄存器空 | TDR 数据移入移位寄存器 |
| USART_FLAG_TC | 发送完成 | 一帧数据完全发送完毕 |
| USART_FLAG_RXNE | 读数据寄存器非空 | 收到一个字节,可读取 |
| USART_FLAG_ORE | 溢出错误 | 上一个数据未读,新数据又到了 |
六、常见问题与注意事项
6.1 串口收到乱码?
检查清单:
- 波特率是否一致(最常见原因):STM32 和串口助手必须相同
- 时钟配置是否正确:SystemInit 中 PLL 配置为 72MHz,波特率计算依赖系统时钟
- 数据位/停止位/校验位是否匹配:通常用 8N1
- 接线是否正确:TX 接 RX、RX 接 TX、GND 共地
- 电平是否匹配:STM32 是 TTL 电平(3.3V),不要直接连 RS232 电平(±12V)
6.2 printf 不工作?
- 是否重写了
fputc函数? - Keil 中是否勾选了 Use MicroLIB?
- 是否调用了
Serial_Init()? - 波特率是否正确?
6.3 进不了接收中断?
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE)是否调用?- NVIC 是否配置并使能了
USART1_IRQn? NVIC_PriorityGroupConfig是否调用?- 中断函数名是否正确?必须是
USART1_IRQHandler,无参数无返回值 - USART_Mode 是否包含
USART_Mode_Rx? - PA10 是否初始化为输入?
6.4 发送函数为什么要等待 TXE?
如果不等待 TXE,连续写入数据时,上一个数据还没从 TDR 转移到移位寄存器,新数据就会覆盖 TDR,导致丢字节。等待 TXE 确保 TDR 已空,可以安全写入下一个字节。
6.5 中断函数中为什么不直接处理数据?
中断函数应尽量短(快进快出),避免在中断中做耗时操作(如 OLED 显示、延时等),否则会影响其他中断响应和主程序运行。最佳实践是:中断中只保存数据和置标志,主循环中处理。
6.6 \r\n 是什么?
\r(Carriage Return,0x0D):光标回到行首\n(Line Feed,0x0A):光标下移一行
Windows 风格的换行需要 \r\n 两个字符。Linux 只用 \n,Mac 旧版只用 \r。串口助手一般需要 \r\n 才能正确换行显示。
七、代码模板
7.1 串口初始化+发送(模板)
void Serial_Init(void)
{
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// PA9 TX 复用推挽输出
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// PA10 RX 上拉输入
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// USART 配置
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = 9600;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_Init(USART1, &USART_InitStructure);
// 接收中断
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&NVIC_InitStructure);
USART_Cmd(USART1, ENABLE);
}
7.2 单字节接收中断(模板)
uint8_t Serial_RxData;
uint8_t Serial_RxFlag;
uint8_t Serial_GetRxFlag(void)
{
if (Serial_RxFlag == 1) { Serial_RxFlag = 0; return 1; }
return 0;
}
uint8_t Serial_GetRxData(void)
{
return Serial_RxData;
}
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
{
Serial_RxData = USART_ReceiveData(USART1);
Serial_RxFlag = 1;
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
本篇覆盖了串口的基础发送与单字节中断接收。下一篇将讲解如何把多个字节组织成"数据包"进行可靠通信。