串口是嵌入式开发里用得最多的通信方式——没有之一。
调试靠它打印log、和上位机通信靠它、连WiFi模块(ESP8266)也靠它。
这篇从UART协议原理讲到printf重定向,帮你彻底搞定串口通信。


一、为什么串口这么重要?

你做嵌入式开发,90%的调试都依赖串口。

程序跑没跑通?串口打印一下。
传感器读对没?串口打印一下。
哪里出了Bug?串口打印一下。

没有串口的嵌入式开发,就像闭着眼睛开车。

串口(UART)是最简单、最稳定、最通用的通信方式:

  • 只需要3根线:TX、RX、GND
  • 不需要时钟线(靠约定波特率)
  • 几乎所有MCU都有串口外设
  • 电脑用USB转TTL就能连

二、UART协议原理

2.1 UART是什么

UART(Universal Asynchronous Receiver/Transmitter),通用异步收发器。

关键词是异步——没有时钟线,收发双方靠约定相同的波特率来对齐数据。

2.2 通信连接

STM32           USB转TTL模块        电脑
 ┌──┐            ┌──┐            ┌──┐
 │  │ TX ──────► RX │            │  │
 │  │            │  │  USB      │  │
 │  │ RX ◄────── TX │◄─────────►│  │
 │  │            │  │            │  │
 │  │ GND ────── GND│            │  │
 └──┘            └──┘            └──┘

注意:TX接RX,RX接TX,交叉连接。GND必须共地。

2.3 数据帧格式

一个UART数据帧的组成:

 空闲    起始位   数据位(8位)        校验   停止位
  ‾‾‾‾‾    ‾       D0 D1...D7        P      ‾‾
        ┌──┐ ┌─┐ ┌─┐ ┌─┐        ┌─┐ ┌─┐ ┌──┐
  ‾‾‾‾‾‾‘  └‘ └‘ └─┘ └─┘  ...   └─┘ └─┘ └  └‾‾‾‾
        起始  D0  D1              D7  P   停止
        (低)                                    (高)
组成部分 说明
空闲状态 总线保持高电平
起始位 1位低电平,标志数据开始
数据位 5-9位,通常用8位
校验位 奇校验/偶校验/无校验,通常用无
停止位 1或2位高电平,通常用1位

最常用配置:8位数据位 + 无校验 + 1位停止位,简称"8N1"。

2.4 波特率

因为UART是异步通信,收发双方必须用相同波特率,否则数据会乱码。

常用波特率:9600、115200(最常用)、460800、921600。

波特率误差不能超过±2%,否则采样错位,数据全乱。这也是为什么高波特率建议用外部晶振而不是内部RC振荡器。


三、STM32串口配置(三种方式)

USART1(PA9-TX, PA10-RX),115200波特率,8N1为例。

3.1 寄存器版

void uart1_init(void)
{
    /* 1. 开时钟:USART1和GPIOA都在APB2 */
    RCC->APB2ENR |= (1 << 2);    /* GPIOA时钟 */
    RCC->APB2ENR |= (1 << 14);   /* USART1时钟 */

    /* 2. 配置PA9(TX)为复用推挽输出,PA10(RX)为浮空输入 */
    GPIOA->CRH &= ~(0xFF << 4);  /* 清零PA9、PA10配置 */
    GPIOA->CRH |= (0x0B << 4);   /* PA9: 复用推挽输出,50MHz */
    GPIOA->CRH |= (0x04 << 8);   /* PA10: 浮空输入 */

    /* 3. 配置波特率115200,系统时钟72MHz */
    /*    BRR = 72MHz / 115200 = 625 = 0x271 */
    USART1->BRR = 625;

    /* 4. 使能发送和接收 */
    USART1->CR1 |= (1 << 3);     /* TE: 发送使能 */
    USART1->CR1 |= (1 << 2);     /* RE: 接收使能 */
    USART1->CR1 |= (1 << 13);    /* UE: USART使能 */
}

/* 发送一个字节 */
void uart_send_byte(uint8_t data)
{
    while (!(USART1->SR & (1 << 7)));  /* 等待发送数据寄存器空(TXE) */
    USART1->DR = data;
}

/* 接收一个字节 */
uint8_t uart_recv_byte(void)
{
    while (!(USART1->SR & (1 << 5)));  /* 等待读数据寄存器非空(RXNE) */
    return USART1->DR;
}

3.2 标准库版

void uart1_init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct;
    USART_InitTypeDef USART_InitStruct;

    /* 1. 开时钟 */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);

    /* 2. 配置引脚 */
    GPIO_InitStruct.GPIO_Pin   = GPIO_Pin_9;              /* PA9 TX */
    GPIO_InitStruct.GPIO_Mode  = GPIO_Mode_AF_PP;         /* 复用推挽输出 */
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    GPIO_InitStruct.GPIO_Pin  = GPIO_Pin_10;              /* PA10 RX */
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;    /* 浮空输入 */
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    /* 3. 配置USART */
    USART_InitStruct.USART_BaudRate            = 115200;
    USART_InitStruct.USART_WordLength          = USART_WordLength_8b;
    USART_InitStruct.USART_StopBits            = USART_StopBits_1;
    USART_InitStruct.USART_Parity              = USART_Parity_No;
    USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStruct.USART_Mode                = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(USART1, &USART_InitStruct);

    /* 4. 使能 */
    USART_Cmd(USART1, ENABLE);
}

/* 发送一个字节 */
void uart_send_byte(uint8_t data)
{
    USART_SendData(USART1, data);
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
}

3.3 HAL库版(CubeMX生成)

CubeMX配置USART1后,自动生成MX_USART1_UART_Init(),你只需调用:

/* 发送一个字节 */
HAL_UART_Transmit(&huart1, &data, 1, 100);

/* 发送字符串 */
HAL_UART_Transmit(&huart1, (uint8_t*)"Hello\r\n", 7, 100);

/* 接收一个字节 */
HAL_UART_Receive(&huart1, &data, 1, 100);

三种方式对比和点灯那篇类似:寄存器底层、标准库过渡、HAL库主流。日常开发用HAL库,理解原理看寄存器。


四、发送字符串和数字

光会发一个字节不够,实际开发要发字符串、发数字。

4.1 发送字符串

void uart_send_string(UART_HandleTypeDef *huart, const char *str)
{
    while (*str) {
        HAL_UART_Transmit(huart, (uint8_t *)str, 1, 100);
        str++;
    }
}

/* 使用 */
uart_send_string(&huart1, "Hello Embedded!\r\n");

4.2 发送数字

void uart_send_number(UART_HandleTypeDef *huart, int num)
{
    char buf[16];
    sprintf(buf, "%d\r\n", num);
    uart_send_string(huart, buf);
}

/* 使用 */
uart_send_number(&huart1, 12345);

这种方式能用,但每次都要手动调函数,不够方便。有没有更优雅的方式?有——printf重定向


五、printf重定向(本文重点)

5.1 为什么要重定向printf?

在电脑上写C程序,printf会输出到屏幕。但STM32没有屏幕,printf默认不知道往哪输出。

重定向就是告诉printf:“你不用往屏幕输出了,往串口输出吧。”

重定向后,你就可以这样写:

printf("温度: %.1f°C, 湿度: %.1f%%\r\n", temp, humi);
int count = 10;
printf("计数: %d\r\n", count);

比手动调uart_send_string方便100倍。

5.2 重定向实现(HAL库版)

只需重写fputc函数:

#include <stdio.h>   /* 必须包含 */

extern UART_HandleTypeDef huart1;  /* 你的USART1句柄 */

/* 重定向fputc到USART1 */
int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100);
    return ch;
}

5.3 Keil中必须勾选微库

在Keil里使用printf,必须勾选Use MicroLIB,否则会卡死或链接报错:

Keil设置步骤:
Options for Target → Target标签页 → 勾选 "Use MicroLIB"

什么是半主机模式? 半主机模式是ARM的一种调试机制,标准C库的printf会把输出通过调试器(J-Link/ST-Link)转发到电脑的调试窗口,而不是通过串口。但脱离调试器后,程序会卡在等待调试器响应的BKPT指令上,导致死机。

MicroLIB是Keil提供的精简版C库,去掉了半主机模式依赖,printf直接走我们重定向的fputc函数(通过串口输出),体积也小很多。所以勾选MicroLIB就解决了半主机模式卡死的问题。

5.4 标准库版重定向

如果你用标准库(SPL),重定向代码略有不同:

#include <stdio.h>

int fputc(int ch, FILE *f)
{
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}

5.5 printf的坑:浮点数不显示

STM32用MicroLIB时,printf默认不支持%f浮点输出printf("%.1f", 3.14)会打印出%f或空。

原因:MicroLIB为了省体积,去掉了浮点格式化功能。这不是FPU(硬件浮点单元)的问题,是C库实现的问题。

解决方法1:Keil不勾选MicroLIB,改用标准库

Options for Target → Target标签页 → 取消勾选 "Use MicroLIB"

但标准库需要额外处理半主机模式(加以下代码):

#pragma import(__use_no_semihosting)  /* 禁用半主机模式 */
struct __FILE { int handle; };
FILE __stdout;
void _sys_exit(int x) { x = x; }  /* 半主机模式需要的空实现 */

解决方法2:用sprintf转换再发(最简单,推荐新手)

char buf[32];
sprintf(buf, "温度: %.2f°C\r\n", temp);  /* sprintf在MicroLIB下支持%f */
uart_send_string(&huart1, buf);

注意:标准库的sprintf也支持%f,MicroLIB的sprintf也支持%f,只有MicroLIB的printf不支持。所以用sprintf转成字符串再用printf发字符串是最省心的方案。


六、中断接收(实用进阶)

上面都是发送,实际项目里接收用中断更高效(不用死等)。

6.1 为什么用中断?

【轮询方式】主循环里一直查"有没有数据?" → 浪费CPU
【中断方式】来数据了MCU自动跳到中断函数处理 → CPU该干嘛干嘛

6.2 HAL库中断接收实现

第1步:CubeMX里开启USART1全局中断

NVIC Settings → USART1 global interrupt → Enable

第2步:启动中断接收

uint8_t rx_buf[1];

/* 在main函数里启动中断接收 */
HAL_UART_Receive_IT(&huart1, rx_buf, 1);

第3步:重写接收完成回调函数

/* HAL库会自动调用这个函数(名字不能改) */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        /* 收到一个字节,存入缓冲区 */
        printf("收到: 0x%02X\r\n", rx_buf[0]);

        /* 重新启动下一次接收(必须!) */
        HAL_UART_Receive_IT(&huart1, rx_buf, 1);
    }
}

注意:HAL库的中断接收是"一次性的",收到1个字节就停。必须在回调函数里重新调用HAL_UART_Receive_IT,否则只能收一次。

6.3 环形缓冲区(进阶)

实际项目里收到的数据不止1字节(比如ESP8266的AT响应可能50字节),需要用环形缓冲区缓存数据。

为什么不用普通数组? 因为串口数据是持续流入的,普通数组写到末尾就满了。环形缓冲区通过"写指针head和读指针tail在环上追逐"的方式,让旧数据被自动覆盖,实现无限循环接收。就像一个圆形跑道,head在前面跑(写入),tail在后面追(读出),到末尾了绕回开头继续。

#define BUF_SIZE 256
uint8_t ring_buf[BUF_SIZE];
volatile uint16_t buf_head = 0;  /* 写位置(中断里写入) */
volatile uint16_t buf_tail = 0;  /* 读位置(主程序读出) */

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        ring_buf[buf_head] = rx_buf[0];      /* 存入环形缓冲 */
        buf_head = (buf_head + 1) % BUF_SIZE;
        HAL_UART_Receive_IT(&huart1, rx_buf, 1);  /* 重新启动接收 */
    }
}

/* 从环形缓冲区读一个字节 */
int8_t ring_buf_read(uint8_t *data)
{
    if (buf_head == buf_tail) return -1;     /* 空 */
    *data = ring_buf[buf_tail];
    buf_tail = (buf_tail + 1) % BUF_SIZE;
    return 0;
}

环形缓冲区是嵌入式面试常考点,原理:head追tail,用取模实现循环。ESP8266、GPS模块这种"不定长数据流"接收都用它。


七、常见踩坑

坑1:串口助手收到乱码

现象:串口助手显示一堆乱码或问号。

排查顺序

  1. 波特率对不对:代码115200,助手也必须115200
  2. GND接没接:TX/RX接了但GND没共地,必乱码
  3. TX/RX接反:STM32的TX要接TTL模块的RX
  4. 时钟配错:系统时钟72MHz配成了8MHz,波特率计算就错了

最常见的坑:系统时钟没配,默认8MHz跑,但波特率按72MHz算,差9倍,必乱码。


坑2:printf无输出

现象:调了printf但串口助手什么都收不到。

排查

  1. 勾选MicroLIB了吗?(Keil最容易漏的)
  2. fputc函数写了吗?只include了stdio.h不够
  3. USART句柄对不对&huart1别写成&huart2
  4. 先用HAL_UART_Transmit测试:如果这个能发但printf不能,说明是重定向问题

坑3:接收中断只触发一次

现象:第一次收数据正常,后面就没反应了。

原因:HAL库的HAL_UART_Receive_IT是"一次性"的,回调函数里忘了重新启动。

解决:在HAL_UART_RxCpltCallback里必须再次调用:

HAL_UART_Receive_IT(&huart1, rx_buf, 1);  /* 重新启动! */

坑4:高波特率(921600)出错

现象:115200正常,换921600就丢字节。

原因

  1. 内部RC振荡器精度不够(±3%),高波特率误差超2%
  2. 中断处理太慢,来不及处理上一个字节

解决

  1. 换外部晶振(8MHz或16MHz)
  2. 高波特率改用DMA接收,不依赖中断

八、完整示例:串口收发回显

把上面学的串起来,做一个"收到什么就回发什么"的回显程序:

#include "main.h"
#include <stdio.h>

UART_HandleTypeDef huart1;
uint8_t rx_buf[1];

int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100);
    return ch;
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        /* 回显收到的字节 */
        HAL_UART_Transmit(&huart1, rx_buf, 1, 100);
        /* 重新启动接收 */
        HAL_UART_Receive_IT(&huart1, rx_buf, 1);
    }
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();

    printf("=== STM32 UART Echo Demo ===\r\n");
    printf("输入任意字符,将原样返回\r\n");

    HAL_UART_Receive_IT(&huart1, rx_buf, 1);  /* 启动中断接收 */

    while (1) {
        /* 主循环空闲,可以做其他事 */
        HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
        HAL_Delay(500);
    }
}

这个例程包含了:串口初始化 + printf重定向 + 中断接收 + 回显。理解透这个,串口通信就入门了。


九、总结

要点 内容
UART核心 异步通信,约定波特率,3线(TX/RX/GND)
数据帧 起始位 + 8数据位 + 停止位(8N1最常用)
三种开发方式 寄存器(底层)/ 标准库(过渡)/ HAL库(主流)
printf重定向 重写fputc + 勾选MicroLIB
中断接收 HAL_UART_Receive_IT + 回调里重新启动
常见坑 波特率、GND、MicroLIB、中断重启

一句话总结:串口是嵌入式调试的命脉,printf重定向 + 中断接收这两招学会,调试效率翻10倍。


下一篇预告:《STM32定时器详解:从延时到PWM输出》

如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。

作者:嵌入式阿蔡

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐