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); // 等待发送完成
}

原理:

  1. USART_SendData 把数据写入 TDR(发送数据寄存器)
  2. 硬件自动把数据移入移位寄存器,按位从 TX 引脚发出
  3. 当 TDR 的数据转移到移位寄存器后,TXE 标志位置 1(TXE = Transmit Data Register Empty)
  4. 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_listva_startvsprintfva_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 的基础上增加了三部分:

  1. RX 引脚初始化(PA10 上拉输入)
  2. 接收中断配置(RXNE 中断 + NVIC)
  3. 中断服务函数 + 接收标志位/数据变量

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显示十六进制值
        }
    }
}

程序流程:

  1. 主循环不断查询 Serial_GetRxFlag()
  2. 电脑发送一个字节 → 触发中断 → 中断中保存数据、置标志位
  3. 主循环检测到标志位 → 读取数据 → 回传 + 显示
  4. 标志位自动清零,等待下一个字节

实验现象:

  • 串口助手发送任意字节(如 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 串口收到乱码?

检查清单:

  1. 波特率是否一致(最常见原因):STM32 和串口助手必须相同
  2. 时钟配置是否正确:SystemInit 中 PLL 配置为 72MHz,波特率计算依赖系统时钟
  3. 数据位/停止位/校验位是否匹配:通常用 8N1
  4. 接线是否正确:TX 接 RX、RX 接 TX、GND 共地
  5. 电平是否匹配:STM32 是 TTL 电平(3.3V),不要直接连 RS232 电平(±12V)

6.2 printf 不工作?

  1. 是否重写了 fputc 函数?
  2. Keil 中是否勾选了 Use MicroLIB
  3. 是否调用了 Serial_Init()
  4. 波特率是否正确?

6.3 进不了接收中断?

  1. USART_ITConfig(USART1, USART_IT_RXNE, ENABLE) 是否调用?
  2. NVIC 是否配置并使能了 USART1_IRQn
  3. NVIC_PriorityGroupConfig 是否调用?
  4. 中断函数名是否正确?必须是 USART1_IRQHandler,无参数无返回值
  5. USART_Mode 是否包含 USART_Mode_Rx
  6. 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);
    }
}

本篇覆盖了串口的基础发送与单字节中断接收。下一篇将讲解如何把多个字节组织成"数据包"进行可靠通信。