news 2026/4/23 22:39:17

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/23 13:33:00

3步搭建AzerothCore魔兽服务器:Docker容器化部署全攻略

3步搭建AzerothCore魔兽服务器:Docker容器化部署全攻略 【免费下载链接】azerothcore-wotlk Complete Open Source and Modular solution for MMO 项目地址: https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk 还在为魔兽世界服务器搭建的复杂环境配…

作者头像 李华
网站建设 2026/4/23 14:00:55

终极浏览器翻译扩展:Linguist完整功能解析

终极浏览器翻译扩展:Linguist完整功能解析 【免费下载链接】linguist Translate web pages, highlighted text, Netflix subtitles, private messages, speak the translated text, and save important translations to your personal dictionary to learn words ev…

作者头像 李华
网站建设 2026/4/23 13:59:23

终极指南:30分钟快速搭建AzerothCore魔兽服务器

终极指南:30分钟快速搭建AzerothCore魔兽服务器 【免费下载链接】azerothcore-wotlk Complete Open Source and Modular solution for MMO 项目地址: https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk 还在为复杂的服务器配置而烦恼吗?…

作者头像 李华
网站建设 2026/4/23 17:23:33

Qwen3Guard-Gen-8B与腾讯云CLS日志分析平台集成

Qwen3Guard-Gen-8B与腾讯云CLS日志分析平台集成 在当前AIGC应用快速落地的浪潮中,一个现实挑战正变得愈发尖锐:如何在保障生成内容自由度的同时,有效规避潜在的安全风险?我们见过太多案例——从智能客服无意中输出不当言论&#x…

作者头像 李华
网站建设 2026/4/23 15:31:00

Mezzanine CMS终极指南:5步搭建高效团队内容协作平台

Mezzanine CMS终极指南:5步搭建高效团队内容协作平台 【免费下载链接】mezzanine CMS framework for Django 项目地址: https://gitcode.com/gh_mirrors/me/mezzanine Mezzanine是一个基于Django的开源CMS框架,专为团队协作设计,提供完…

作者头像 李华
网站建设 2026/4/23 9:47:12

Gumbo HTML5解析器:构建稳健网页处理系统的核心技术解析

Gumbo HTML5解析器:构建稳健网页处理系统的核心技术解析 【免费下载链接】gumbo-parser An HTML5 parsing library in pure C99 项目地址: https://gitcode.com/gh_mirrors/gum/gumbo-parser 在现代Web开发中,处理不规范的HTML文档已成为每个开发…

作者头像 李华