手把手带你搞懂STM32的UART通信:从原理到HAL库实战
你有没有过这样的经历?
刚上电调试STM32,代码烧进去后板子“毫无反应”,连个日志都不输出。想查问题吧,又不知道程序卡在哪一步……最后只能靠“点灯大法”——一个LED闪一下代表初始化完成,两下代表进入主循环。
这显然不是长久之计。
其实,解决这个问题最简单、最直接的方式就是:把串口用起来。而实现它的核心技术,正是我们今天要聊的主角——UART + HAL库。
在嵌入式世界里,UART就像MCU的“嘴巴和耳朵”。它能让你看到程序内部发生了什么(打印日志),也能让外部设备告诉你该做什么(接收命令)。更重要的是,它是你学习其他外设通信(如I2C、SPI)前必须跨过的第一道门槛。
本文不堆术语、不抄手册,咱们一起从零开始,像搭积木一样,一步步构建出一个真正可用的UART通信系统。全程基于ST官方推荐的HAL库,适合初学者入门,也值得老手温故知新。
为什么是UART?因为它够“接地气”
先别急着写代码,我们先回答一个问题:为什么几乎所有STM32项目都会用到UART?
很简单——它不需要复杂的协议栈,也不依赖操作系统,只要接两根线,就能立刻双向传数据。
比如:
- 你想知道某个传感器读数是否正常?printf("Temp: %.2f°C\n", temp);
- 你想远程控制电机启停?通过串口发个'S'字符就行。
- 调试时发现逻辑异常?加一行printf("Reached here!\n");立刻定位。
这些操作的背后,都是UART在默默工作。
更关键的是,UART是异步通信。什么叫异步?就是发送方和接收方没有共用的时钟线,全靠事先约定好的节奏来“对暗号”。
这个“暗号”包括:
- 每秒传多少位(波特率)
- 数据有几位(通常是8位)
- 是否有校验位(检查错误)
- 结束标志占几位(1或2位)
最常见的配置叫115200-N-8-1,意思是:
- 波特率 115200 bps
- 无校验(None)
- 数据位 8 位
- 停止位 1 位
只要两边都按这个规则来,哪怕中间隔着USB转TTL模块、飞线甚至无线模块,数据照样能准确送达。
HAL库:让寄存器不再“吓人”
以前玩单片机,想配UART得翻几十页参考手册,手动算BRR寄存器值、设置CR1/CR2控制位、配置GPIO复用功能……稍有不慎就“静音”了。
但现在不一样了。ST推出了HAL库(硬件抽象层),目的很明确:让你少跟寄存器打交道,多关注业务逻辑。
你可以把它理解为一套“标准化遥控器”。不管你是用STM32F1还是F4,甚至是H7,只要调用HAL_UART_Init()就能初始化串口;用HAL_UART_Transmit()发数据;用HAL_UART_Receive_IT()开启中断接收。
而且这套API设计非常统一:
HAL_xxx_Init() // 初始化 HAL_xxx_Start() // 启动 HAL_xxx_Stop() // 停止 HAL_xxx_Callback() // 回调函数这种模式一旦掌握,迁移到I2C、SPI、ADC等外设时几乎不用重新学习。
当然,有人会说:“HAL库效率低、代码臃肿。”
这话没错,但它换来了开发速度提升十倍、移植性大幅增强、新手友好度拉满。对于大多数应用来说,这点性能代价完全值得。
硬件怎么连?两个引脚搞定
假设你要用USART1实现串口通信,典型连接方式如下:
[PC] ↓ (USB-TTL模块,如CH340/CP2102) [TX → PA9] [RX ← PA10] ↑ [STM32]注意交叉连接:
- STM32的TX → 接USB-TTL的RX
- STM32的RX ← 接USB-TTL的TX
另外,GND一定要共地,否则信号对不上电平。
那PA9和PA10为啥能当串口用?因为它们支持复用功能(Alternate Function)AF7。也就是说,这两个IO不仅可以当普通GPIO用,还能“变身”成USART1的发送和接收端。
只要在代码中告诉芯片:“我现在要用它做串口”,硬件就会自动切换内部通路。
第一步:初始化UART——三步走战略
我们来看一段最核心的初始化代码。别怕长,我一句句拆开讲。
UART_HandleTypeDef huart1; void UART1_Init(void) { // 1. 使能时钟 __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); // 2. 配置GPIO: PA9(TX), PA10(RX) GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_9 | GPIO_PIN_10; gpio.Mode = GPIO_MODE_AF_PP; // 复用推挽输出 gpio.Alternate = GPIO_AF7_USART1; // AF7对应USART1 gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Pull = GPIO_NOPULL; HAL_GPIO_Init(GPIOA, &gpio); // 3. 配置UART参数 huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 4. 执行初始化 if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); } }关键点解析:
时钟使能
所有外设运行的前提!没时钟就像没电的马达,再好的代码也动不了。GPIO配置要点
-GPIO_MODE_AF_PP:复用推挽输出,确保驱动能力强;
-GPIO_AF7_USART1:明确指定使用AF7功能;
-Speed设高些,避免高速通信时波形畸变。huart1.Instance = USART1
这是在告诉HAL库:“我要操作的是USART1这个硬件单元”。HAL_UART_Init() 内部做了啥?
它会根据你设定的波特率和系统时钟,自动计算并写入BRR(波特率寄存器),还会配置CR1/CR2/CR3等一系列控制位,根本不用你动手。
一句话总结:结构体赋值 + 一键初始化 = 快速上线
怎么发数据?轮询就够了
初始化完成后,就可以发数据了:
uint8_t tx_data[] = "Hello, STM32!\r\n"; HAL_UART_Transmit(&huart1, tx_data, sizeof(tx_data)-1, 100);就这么简单。
- 第一个参数:哪个UART实例
- 第二个:数据首地址
- 第三个:发送长度(减1是为了去掉末尾的
\0) - 第四个:超时时间(单位ms)
这个函数采用轮询方式,内部不断检查状态寄存器中的TXE(发送寄存器空)标志,直到所有字节发完或超时为止。
优点是逻辑清晰,适合调试打印这类低频操作。缺点是会阻塞CPU,不能干别的事。
所以,如果你只是想输出一句“系统启动成功”,用它正合适。
怎么收数据?别再死等了,用中断!
如果只用轮询接收,你的主循环就得一直卡在那里:
while (1) { if (HAL_UART_Receive(&huart1, &ch, 1, 10) == HAL_OK) { // 处理收到的数据 } }这显然是不可接受的。
更好的做法是:开启中断接收,数据来了自然会通知你。
uint8_t rx_byte; void Start_Reception(void) { HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { // 收到一个字节后的处理 HAL_UART_Transmit(&huart1, &rx_byte, 1, 100); // 回显 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 继续开启接收 } }你看,这里有个精妙的设计:
-HAL_UART_Receive_IT()只启动一次单字节接收;
- 收到数据后触发中断,执行回调函数;
- 在回调里立即再次调用Receive_IT,形成“永不停歇”的接收链。
这样一来,CPU可以自由执行主任务,完全不受通信影响。
不过要注意:回调函数里不要放耗时操作!比如延时、复杂计算、大量打印。否则会影响实时响应。
建议做法是:在回调中只做“标记”或“入队”,真正的解析留给主循环处理。
让 printf 直接输出到串口,爽翻了!
你肯定用过printf调试C语言程序。但在STM32上,默认它是无效的——因为你没有显示器。
但我们可以通过重定向标准输出函数,让printf的内容自动走UART发出去。
#ifdef __GNUC__ #define PUTCHAR_PROTOTYPE int __io_putchar(int ch) #else #define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f) #endif PUTCHAR_PROTOTYPE { HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 100); return ch; }加上这段代码后,你就可以肆无忌惮地写了:
int counter = 0; while (1) { printf("Counter: %d, Time: %.2fs\n", counter++, HAL_GetTick()/1000.0f); HAL_Delay(1000); }效果如下:
Counter: 0, Time: 0.00s Counter: 1, Time: 1.00s Counter: 2, Time: 2.00s ...是不是瞬间有种“Linux终端”的感觉?这就是调试效率的飞跃。
高阶玩法:环形缓冲区 + DMA,应对高速数据流
前面的方法适用于低速场景。但如果要接收GPS、音频、传感器阵列这类持续高速数据,光靠中断可能不够用——万一两个字节挨得太近,第二个还没来得及处理,就被覆盖了?
解决方案有两个:
方案一:加个环形缓冲区(Ring Buffer)
#define RX_BUFFER_SIZE 128 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_head = 0, rx_tail = 0; // 在中断回调中 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { rx_buffer[rx_head] = rx_byte; rx_head = (rx_head + 1) % RX_BUFFER_SIZE; HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } } // 主循环中安全提取 uint8_t get_char(void) { if (rx_tail == rx_head) return 0; // 空 uint8_t ch = rx_buffer[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUFFER_SIZE; return ch; }这样即使CPU暂时忙,数据也不会丢。
方案二:直接上DMA(直接内存访问)
DMA可以让UART外设直接把数据搬到内存,全程不打扰CPU。
启用方式也很简单:
// 初始化后启动DMA接收 uint8_t dma_rx_buffer[64]; HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, 64);然后你可以在适当时候检查__HAL_DMA_GET_COUNTER()查看已接收数量,或者使用HAL_UART_RxCpltCallback()获取完成通知。
DMA特别适合固定帧长或协议明确的数据包接收,比如Modbus、自定义二进制指令等。
几个容易踩的坑,提前避雷
波特率不准导致乱码?
检查你的系统时钟配置是否正确。比如主频是72MHz还是168MHz?HAL库会据此计算BRR值。误差超过±2%就可能出现误码。串口收不到数据?
先确认GPIO复用功能是否配对(AF7),再查TX/RX是否接反,最后看是否有共地。中断进不去?
确保NVIC中断已使能。使用CubeMX生成代码时通常会自动添加,手写则需调用:c HAL_NVIC_EnableIRQ(USART1_IRQn);printf中文乱码?
串口工具默认编码是ASCII,不支持中文。如需显示汉字,请改用UTF-8并确保终端支持,或改用十六进制显示。资源占用太高?
HAL库本身约占用几KB Flash和几百字节RAM。若资源紧张(如STM32F0系列),可考虑使用LL库替代部分功能。
最后的小结:这不是终点,而是起点
看到这里,你应该已经掌握了如何在STM32上用HAL库实现完整的UART通信:
- ✅ 理解UART基本原理与常见配置
- ✅ 成功配置GPIO复用与UART初始化
- ✅ 实现轮询发送与中断接收
- ✅ 重定向
printf用于高效调试 - ✅ 了解DMA与环形缓冲区优化思路
但这仅仅是个开始。
你会发现,这套“配置结构体 → 调用初始化 → 注册回调”的编程范式,在I2C、SPI、定时器甚至WiFi模块中反复出现。UART是你通往更复杂系统的入口钥匙。
未来当你接触FreeRTOS时,可能会把串口封装成一个任务;用STM32CubeMX时,只需勾选就能生成完整代码;甚至结合LittleFS做日志存储,都不是难事。
但请记住:越是强大的工具,越需要理解其底层机制。否则一旦出问题,你就只能对着生成的代码发呆。
所以,不妨现在就动手试试——点亮你的第一个串口,让STM32对你“开口说话”。
如果你在实现过程中遇到任何问题,欢迎留言交流。我们一起把嵌入式这条路走得更稳、更远。