news 2026/4/18 7:43:37

STM32使用HAL库实现UART通信的通俗解释

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32使用HAL库实现UART通信的通俗解释

手把手带你搞懂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(); } }

关键点解析:

  1. 时钟使能
    所有外设运行的前提!没时钟就像没电的马达,再好的代码也动不了。

  2. GPIO配置要点
    -GPIO_MODE_AF_PP:复用推挽输出,确保驱动能力强;
    -GPIO_AF7_USART1:明确指定使用AF7功能;
    -Speed设高些,避免高速通信时波形畸变。

  3. huart1.Instance = USART1
    这是在告诉HAL库:“我要操作的是USART1这个硬件单元”。

  4. 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、自定义二进制指令等。


几个容易踩的坑,提前避雷

  1. 波特率不准导致乱码?
    检查你的系统时钟配置是否正确。比如主频是72MHz还是168MHz?HAL库会据此计算BRR值。误差超过±2%就可能出现误码。

  2. 串口收不到数据?
    先确认GPIO复用功能是否配对(AF7),再查TX/RX是否接反,最后看是否有共地。

  3. 中断进不去?
    确保NVIC中断已使能。使用CubeMX生成代码时通常会自动添加,手写则需调用:
    c HAL_NVIC_EnableIRQ(USART1_IRQn);

  4. printf中文乱码?
    串口工具默认编码是ASCII,不支持中文。如需显示汉字,请改用UTF-8并确保终端支持,或改用十六进制显示。

  5. 资源占用太高?
    HAL库本身约占用几KB Flash和几百字节RAM。若资源紧张(如STM32F0系列),可考虑使用LL库替代部分功能。


最后的小结:这不是终点,而是起点

看到这里,你应该已经掌握了如何在STM32上用HAL库实现完整的UART通信:

  • ✅ 理解UART基本原理与常见配置
  • ✅ 成功配置GPIO复用与UART初始化
  • ✅ 实现轮询发送与中断接收
  • ✅ 重定向printf用于高效调试
  • ✅ 了解DMA与环形缓冲区优化思路

但这仅仅是个开始。

你会发现,这套“配置结构体 → 调用初始化 → 注册回调”的编程范式,在I2C、SPI、定时器甚至WiFi模块中反复出现。UART是你通往更复杂系统的入口钥匙

未来当你接触FreeRTOS时,可能会把串口封装成一个任务;用STM32CubeMX时,只需勾选就能生成完整代码;甚至结合LittleFS做日志存储,都不是难事。

但请记住:越是强大的工具,越需要理解其底层机制。否则一旦出问题,你就只能对着生成的代码发呆。

所以,不妨现在就动手试试——点亮你的第一个串口,让STM32对你“开口说话”。

如果你在实现过程中遇到任何问题,欢迎留言交流。我们一起把嵌入式这条路走得更稳、更远。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 9:45:46

Proton-GE终极指南:快速提升Linux游戏兼容性

Proton-GE终极指南:快速提升Linux游戏兼容性 【免费下载链接】proton-ge-custom 项目地址: https://gitcode.com/gh_mirrors/pr/proton-ge-custom 想要在Linux系统上畅玩Windows游戏吗?Proton-GE(GloriousEggroll定制版Proton&#x…

作者头像 李华
网站建设 2026/4/16 19:33:20

Qwen3Guard-Gen-8B在跨境电商客服系统中的实际部署案例

Qwen3Guard-Gen-8B在跨境电商客服系统中的实际部署案例 在全球化电商迅猛发展的今天,AI客服正逐步成为连接品牌与海外用户的核心触点。然而,随着生成式AI在多语言场景下的广泛应用,一个严峻的问题浮出水面:如何在保障对话自然流畅…

作者头像 李华
网站建设 2026/4/18 7:12:58

45分钟构建企业级中后台系统:SmartAdmin实战部署全解析

45分钟构建企业级中后台系统:SmartAdmin实战部署全解析 【免费下载链接】smart-admin SmartAdmin国内首个以「高质量代码」为核心,「简洁、高效、安全」中后台快速开发平台;基于SpringBoot2/3 Sa-Token Mybatis-Plus 和 Vue3 Vite5 Ant D…

作者头像 李华
网站建设 2026/4/18 1:02:01

如何快速完成ONNX Runtime版本迁移:从旧版到1.23.0的完整指南

如何快速完成ONNX Runtime版本迁移:从旧版到1.23.0的完整指南 【免费下载链接】onnxruntime microsoft/onnxruntime: 是一个用于运行各种机器学习模型的开源库。适合对机器学习和深度学习有兴趣的人,特别是在开发和部署机器学习模型时需要处理各种不同框…

作者头像 李华
网站建设 2026/4/17 20:51:18

Weylus全攻略:平板秒变专业绘图板的零成本方案

Weylus全攻略:平板秒变专业绘图板的零成本方案 【免费下载链接】Weylus Use your tablet as graphic tablet/touch screen on your computer. 项目地址: https://gitcode.com/gh_mirrors/we/Weylus 你是否曾经羡慕那些拥有专业绘图板的创作者,却又…

作者头像 李华
网站建设 2026/4/17 15:58:49

Cider跨平台音乐播放器:重新定义Apple Music的完美体验

Cider跨平台音乐播放器:重新定义Apple Music的完美体验 【免费下载链接】Cider A new cross-platform Apple Music experience based on Electron and Vue.js written from scratch with performance in mind. 🚀 项目地址: https://gitcode.com/gh_mi…

作者头像 李华