1. 项目概述
在嵌入式开发,尤其是基于STM32这类MCU的项目中,串口通信是调试和与外界交互的“生命线”。无论是打印调试信息、接收上位机指令,还是输出系统状态,都离不开它。而输出字符串,则是其中最基础、最高频的操作。看起来简单,不就是把一串字符发出去吗?但实际做起来,新手和老手写出的代码,在效率、可维护性和资源占用上,往往天差地别。
我见过不少项目,串口输出部分写得相当随意,要么是满屏的HAL_UART_Transmit直接硬怼字符数组,要么是printf一用了之,结果不是内存碎片就是输出阻塞,调试复杂功能时问题频出。所以,今天我们就来深挖一下在STM32上,通过串口输出字符串到底有哪几种方法,每种方法背后的原理、适用场景是什么,以及在实际工程中,我们该如何根据需求做出最合适的选择。这篇文章会从最底层的寄存器操作,讲到标准库和HAL库的封装,再深入到自定义高效输出机制的实现,希望能帮你构建一个清晰、实用的串口输出知识体系。
2. 核心需求与方案选型解析
2.1 为什么输出字符串是个“技术活”?
在桌面编程中,printf(“Hello World\n”)几乎不需要思考。但在资源受限的STM32上,我们需要考虑更多:
- 内存开销:
printf家族的函数通常依赖于标准C库,可能会引入数KB甚至更多的代码体积(例如浮点数格式化支持),这对于仅有几十KB Flash的芯片可能是不可接受的。 - 执行效率:字符串输出可能发生在中断服务函数中,或者对实时性要求高的循环里。低效的输出方式(如忙等待发送每个字节)会严重占用CPU时间。
- 线程/中断安全:在多任务环境(如RTOS)或高优先级中断中调用输出函数,如果函数本身不可重入,可能导致数据错乱或系统死锁。
- 功能与灵活性:是否需要格式化输出(如
%d,%f,%x)?是否需要支持重定向到多个串口或其他设备?输出是否需要缓冲以提高整体吞吐量?
基于这些考量,STM32上输出字符串的方法大致可以划分为几个层次和方向,我将它们总结为以下四种主流方案,并附上选型决策逻辑:
| 方案类别 | 核心方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 基础直接法 | 直接调用HAL/LL库发送API | 简单直接,无需额外配置 | 效率低(忙等待),阻塞CPU | 初始化配置、极简应用、发送固定标语 |
| 标准库重定向法 | 重写_write或fputc,使用printf | 开发便捷,格式化能力强 | 代码体积大,效率一般,可能非线程安全 | 调试阶段,Flash空间充足,需要复杂格式化的应用 |
| 自定义轻量格式化法 | 实现自定义的my_printf或使用第三方轻量库 | 体积小巧,效率可控,功能可定制 | 需要自行实现或集成 | 产品级应用,对代码体积和效率有要求 |
| 高级缓冲队列法 | 基于DMA或中断+环形队列 | 极高效率,CPU占用低,非阻塞 | 实现复杂,需要管理缓冲区 | 高吞吐量通信、RTOS任务间通信、实时数据流 |
选型心法:没有最好的,只有最合适的。在项目初期或调试时,可以快速使用
printf重定向。进入产品化阶段,则必须评估Flash和RAM开销,通常自定义轻量库或缓冲队列法是更专业的选择。对于单纯的输出固定字符串,直接法就足够了。
2.2 环境准备与硬件连接
在开始代码实操前,确保你的硬件和工程环境就绪。这里以最常见的STM32F103C8T6(Blue Pill)和STM32CubeIDE开发环境为例。
- 硬件连接:将开发板的USART1_TX(PA9)引脚连接到USB转TTL模块的RX,GND对接GND。通常我们使用USART1进行调试。
- 工程创建:
- 使用STM32CubeMX新建工程,选择对应芯片。
- 在
Pinout & Configuration界面,使能USART1,模式选择为Asynchronous(异步通信)。 - 配置参数:波特率115200,数据位8,停止位1,无校验,无硬件流控。这些是调试串口的通用配置。
- 在
Project Manager标签页,设置好工程名、路径和IDE,注意在Advanced Settings中,将Generated Function Calls的UART选项设置为Enable All,这样初始化代码会更清晰。 - 生成代码,用STM32CubeIDE打开工程。
3. 方法一:基础直接发送法
这是最原始、最直接的方法,直接调用ST官方库提供的发送函数,将字符串的每个字符依次发送出去。
3.1 使用HAL库发送
HAL库提供了阻塞和非阻塞两种发送方式。
阻塞式发送 (HAL_UART_Transmit)这是最简单的方式,函数会一直等待,直到整个字符串发送完毕或超时才会返回。
// 发送一个字符串(阻塞式) void UART_SendString_Blocking(UART_HandleTypeDef *huart, const char *str) { // 计算字符串长度(不包含结尾的'\0') uint16_t len = 0; const char *p = str; while (*p++ != '\0') len++; // 调用HAL发送函数,超时时间设为最大值HAL_MAX_DELAY HAL_UART_Transmit(huart, (uint8_t*)str, len, HAL_MAX_DELAY); } // 在主函数或中断中调用 char msg[] = "Hello STM32 via HAL Blocking!\r\n"; UART_SendString_Blocking(&huart1, msg);代码解析与注意事项:
HAL_UART_Transmit的最后一个参数是超时时间(毫秒)。HAL_MAX_DELAY是一个特殊值,表示无限等待。这在调试时没问题,但在正式产品中要慎用,因为如果串口线被拔掉或对方设备故障,程序会永远卡在这里。- 我们手动计算了字符串长度。为什么不直接用
strlen?在嵌入式领域,尤其是对性能敏感或禁用标准库的场景,strlen需要遍历字符串,而编译器优化后的手写循环可能效率相近。更重要的是,这体现了对底层细节的掌控。当然,在允许使用标准库的情况下,用strlen更清晰。 - 阻塞式发送的致命缺点:CPU在发送期间被完全挂起,无法执行其他任务。对于长字符串或低波特率,这是不可接受的。
非阻塞式发送 (HAL_UART_Transmit_IT)利用中断进行发送,函数调用后立即返回,发送工作在后台由中断服务程序完成。
// 定义一个发送完成标志位 volatile uint8_t uart_tx_done = 1; // 发送字符串(中断非阻塞式) void UART_SendString_IT(UART_HandleTypeDef *huart, const char *str) { // 等待上一次发送完成 while(uart_tx_done == 0) { // 可以在这里加入超时机制,防止死等 } uint16_t len = 0; const char *p = str; while (*p++ != '\0') len++; uart_tx_done = 0; // 标记发送开始 HAL_UART_Transmit_IT(huart, (uint8_t*)str, len); } // 需要在USART1的全局中断回调函数中处理发送完成 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uart_tx_done = 1; // 标记发送完成 // 可以在这里触发下一次发送,实现连续输出 } }实操心得:
- 使用非阻塞中断发送,必须管理好“发送状态”。上面的
uart_tx_done标志位是一个简单的互斥机制,防止上一次发送未完成就启动下一次发送,导致数据覆盖。 HAL_UART_TxCpltCallback是HAL库提供的弱定义回调函数,我们需要在用户文件中重写它。这是HAL库事件驱动编程的典型模式。- 这种方式虽然解放了CPU,但代码复杂度立即上升。你需要处理状态、可能的中断嵌套,并且在
UART_SendString_IT中仍有while循环等待标志位,严格来说只是将忙等待从“等硬件”变成了“等软件标志”,在单任务环境中并未彻底解决阻塞问题。
3.2 使用LL库发送
LL库更接近寄存器,代码更精简,效率更高。这里展示LL库的阻塞发送。
// 假设已通过CubeMX配置并生成了LL库的初始化代码 void UART_SendString_LL_Blocking(USART_TypeDef *USARTx, const char *str) { const char *p = str; while (*p != '\0') { // 等待发送数据寄存器空(TDR已准备好接收新数据) while (!LL_USART_IsActiveFlag_TXE(USARTx)) { // 空循环等待,也可以加入超时退出逻辑 } // 将数据写入发送数据寄存器(TDR) LL_USART_TransmitData8(USARTx, (uint8_t)(*p)); p++; } // 可选:等待传输完成(TC标志置位),确保最后一个字节已完全发出 while (!LL_USART_IsActiveFlag_TC(USARTx)); }核心原理与避坑指南:
LL_USART_IsActiveFlag_TXE:检查“发送数据寄存器空”标志。当该标志为1时,表示TDR寄存器为空,可以写入下一个要发送的数据。这是发送单个字节前必须检查的条件。LL_USART_IsActiveFlag_TC:检查“发送完成”标志。当该标志为1时,表示包括停止位在内的整个帧已从移位寄存器发送出去。在发送完最后一个字节后等待此标志,可以确保数据完全离开硬件,在关闭串口或进入低功耗模式前特别有用。- 常见误区:很多人只等
TXE就发送下一个字节,这在大部分情况下没问题。但在发送最后一个字节后,如果不等待TC就立即进行后续操作(如切换引脚模式、关闭时钟),可能导致最后一个字节发送不完整。这是一个非常隐蔽的Bug。
4. 方法二:重定向标准库printf法
这是调试阶段最受欢迎的方法,因为我们可以直接使用熟悉的printf、puts等函数,格式化输出非常方便。
4.1 重写_write系统调用
在ARM GCC(STM32CubeIDE使用此工具链)环境中,printf最终会调用_write函数。我们只需要重写这个函数,将其输出指向串口。
#include <stdio.h> // 必须包含 #include <unistd.h> // 声明 _write // 重写 _write 函数 int _write(int file, char *ptr, int len) { (void)file; // 避免未使用参数警告 // 调用HAL库阻塞发送 HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, HAL_MAX_DELAY); return len; // 返回成功写入的字节数 }配置要点:
- 在CubeMX生成代码时,需要在
Project Manager->Advanced Settings中,确保Linker设置里勾选了Use float with printf from newlib-nano (-u _printf_float)。如果你需要打印浮点数(%f),这一步至关重要!否则,浮点数会被打印成“?”或错误的值。 - 在代码中务必包含
#include <stdio.h>。 - 重写
_write后,你就可以在代码中任意使用printf了。
int value = 42; float voltage = 3.3f; printf("System Booted.\r\n"); printf("Value: %d, Voltage: %.2fV\r\n", value, voltage);4.2 优劣分析与体积对比
优点:开发效率极高,格式化输出强大,可无缝使用%d,%f,%s,%x等所有格式符。缺点:
- 代码体积暴增:这是最大的问题。以STM32F103C8T6(64KB Flash)为例,一个简单的
printf(“Hello”)就可能增加数KB的代码。如果使能了浮点数支持(-u _printf_float),增加10KB以上是常有的事。 - 线程不安全:标准库的
printf通常不是可重入的。在RTOS的多任务或中断中调用,可能导致数据错乱或崩溃。 - 性能一般:
printf内部解析格式字符串需要消耗CPU周期,对于高频调用场景不够高效。
体积实测对比(STM32F103C8T6,优化等级-Os):
- 基础工程(仅点灯):约2KB Flash。
- 加入
_write重写和printf(“test”):Flash增加约6KB。 - 再启用浮点数支持:Flash再增加约8KB,总计增加约14KB。
经验之谈:在资源紧张的产品项目中,我几乎从不使用完整的
printf。仅在项目早期调试阶段,且Flash空间绝对充裕时,才会考虑使用。一旦功能稳定,就会用更轻量的方法替换掉它。
5. 方法三:自定义轻量格式化输出法
为了兼顾格式化输出的便利性和代码体积的效率,自己实现一个简化版的printf(通常叫my_printf、xprintf或upritnf)是嵌入式老手的常见做法。核心思想是:只实现自己需要的功能。
5.1 实现一个最简的my_printf
下面实现一个支持%d(十进制整数)、%x(十六进制整数)、%s(字符串)和%c(字符)的版本。
// 自定义串口发送字符函数(基础) static void uart_putchar(char c) { // 这里使用阻塞发送,实际可根据需要改为中断或DMA while (!LL_USART_IsActiveFlag_TXE(USART1)) {} LL_USART_TransmitData8(USART1, c); } // 递归函数:将整数转换为十进制字符串并发送 static void print_number(int num) { if (num < 0) { uart_putchar('-'); num = -num; } if (num / 10 != 0) { print_number(num / 10); // 递归处理高位 } uart_putchar((num % 10) + '0'); // 发送当前位 } // 核心:自定义简化版 printf void my_printf(const char *fmt, ...) { va_list args; va_start(args, fmt); // 初始化变参列表 while (*fmt != '\0') { if (*fmt == '%') { fmt++; // 跳过'%' switch (*fmt) { case 'd': { // 整数 int val = va_arg(args, int); print_number(val); break; } case 'x': { // 十六进制(小写) unsigned int val = va_arg(args, unsigned int); char hex_chars[] = "0123456789abcdef"; int i; // 简单发送8位十六进制数 uart_putchar('0'); uart_putchar('x'); for (i = 7; i >= 0; i--) { uart_putchar(hex_chars[(val >> (i*4)) & 0xF]); } break; } case 's': { // 字符串 char *str = va_arg(args, char*); while (*str != '\0') { uart_putchar(*str++); } break; } case 'c': { // 字符 char ch = (char)va_arg(args, int); // 注意,char在变参中提升为int uart_putchar(ch); break; } default: uart_putchar(*fmt); // 格式符未知,原样输出 break; } } else { uart_putchar(*fmt); // 普通字符,直接输出 } fmt++; } va_end(args); // 清理变参列表 }使用示例:
my_printf("Device ID: %d, Status: 0x%x, Name: %s\r\n", 1001, 0xABCD, "STM32F103"); // 输出:Device ID: 1001, Status: 0x0000abcd, Name: STM32F1035.2 进阶:使用第三方轻量库 (如 mpaland/printf)
自己维护格式化库比较麻烦,一个更优的选择是使用开源社区验证过的轻量级实现。例如, mpaland/printf 就是一个非常流行的、可高度裁剪的printf/sprintf实现。
集成步骤:
- 将仓库中的
printf.c和printf.h添加到你的工程。 - 在
printf.h中,通过宏定义来裁剪不需要的功能,大幅减小体积。
// 在 printf.h 或你的项目配置文件中定义 #define PRINTF_DISABLE_SUPPORT_FLOAT // 禁用浮点数支持 #define PRINTF_DISABLE_SUPPORT_EXPONENTIAL // 禁用指数表示 #define PRINTF_DISABLE_SUPPORT_LONG_LONG // 禁用long long类型 // ... 其他裁剪选项- 实现库需要的底层输出函数
_putchar。
// 提供给 printf 库的字符输出函数 void _putchar(char character) { // 指向你的串口发送函数,可以是阻塞、中断或DMA uart_putchar(character); }- 现在,你就可以在工程中使用
printf了,而且体积比标准库小得多。
体积对比优势:
- 全功能版
mpaland/printf:约3-5KB Flash。 - 禁用浮点和长整型后:可压缩到1-2KB左右。
- 这相比标准库的10KB+,优势非常明显。
避坑指南:使用第三方库时,务必仔细阅读其许可证(通常是MIT或BSD),确保符合你的项目要求。此外,在RTOS环境中,要注意
printf函数本身是否可重入。mpaland/printf默认是不可重入的,如果需要在多任务中调用,要么使用互斥锁保护,要么寻找其可重入的配置选项或分支。
6. 方法四:基于DMA与环形队列的高阶输出法
当你的应用需要高频、大量、非阻塞地输出数据时(如高速数据采集、实时日志流),前面所有方法都会遇到瓶颈。此时,DMA(直接存储器访问)+ 环形队列(Ring Buffer)的组合是终极解决方案。
6.1 架构设计
核心思想是:解耦数据产生与数据发送。
- 生产者(你的应用代码)将想要发送的字符串放入一个环形队列(内存中的一块循环缓冲区)。
- 消费者(DMA传输)在后台持续地从环形队列中取出数据,通过串口发送出去,完全不需要CPU参与搬运数据。
这样做的好处是:
- CPU占用极低:CPU只需将数据拷贝到队列,DMA负责搬运和发送。
- 非阻塞:只要队列未满,数据产生函数可以立即返回。
- 高吞吐:DMA可以以硬件最高速度搬运数据。
6.2 代码实现详解
我们来实现一个简化但可用的版本。
第一步:定义环形队列结构
#define UART_TX_BUFFER_SIZE 512 // 发送缓冲区大小,根据需求调整 typedef struct { uint8_t buffer[UART_TX_BUFFER_SIZE]; volatile uint16_t head; // 写指针(生产者) volatile uint16_t tail; // 读指针(消费者/DMA) } uart_tx_ring_buffer_t; static uart_tx_ring_buffer_t tx_ring_buf; static UART_HandleTypeDef *huart_global; // 全局串口句柄第二步:队列操作函数(关键)
// 初始化队列 void uart_tx_buffer_init(UART_HandleTypeDef *huart) { huart_global = huart; tx_ring_buf.head = 0; tx_ring_buf.tail = 0; // 使能UART的DMA发送请求 __HAL_UART_ENABLE_DMA_TX(huart); } // 判断队列是否为空 static inline bool is_tx_buffer_empty(void) { return (tx_ring_buf.head == tx_ring_buf.tail); } // 判断队列是否已满 static inline bool is_tx_buffer_full(void) { return ((tx_ring_buf.head + 1) % UART_TX_BUFFER_SIZE) == tx_ring_buf.tail; } // 向队列写入一个字节(生产者调用) static bool uart_tx_buffer_put(uint8_t data) { if (is_tx_buffer_full()) { return false; // 队列满,写入失败 } tx_ring_buf.buffer[tx_ring_buf.head] = data; tx_ring_buf.head = (tx_ring_buf.head + 1) % UART_TX_BUFFER_SIZE; return true; } // 从队列读取一个字节(DMA传输完成中断调用) static bool uart_tx_buffer_get(uint8_t *data) { if (is_tx_buffer_empty()) { return false; // 队列空,读取失败 } *data = tx_ring_buf.buffer[tx_ring_buf.tail]; tx_ring_buf.tail = (tx_ring_buf.tail + 1) % UART_TX_BUFFER_SIZE; return true; }第三步:启动DMA传输与中断处理
// 启动一次DMA传输(从队列中取数据发送) static void uart_start_dma_transfer(void) { if (is_tx_buffer_empty() || huart_global->hdmatx->State != HAL_DMA_STATE_READY) { return; // 队列为空或DMA忙,不启动 } // 计算连续可发送的数据长度(注意环形队列的折返) uint16_t bytes_to_send; if (tx_ring_buf.head >= tx_ring_buf.tail) { bytes_to_send = tx_ring_buf.head - tx_ring_buf.tail; } else { bytes_to_send = UART_TX_BUFFER_SIZE - tx_ring_buf.tail; } // 限制单次DMA传输的最大长度(例如不超过256,取决于DMA配置) if (bytes_to_send > 255) bytes_to_send = 255; if (bytes_to_send > 0) { // 配置DMA源地址为队列中的读指针位置 HAL_UART_Transmit_DMA(huart_global, &tx_ring_buf.buffer[tx_ring_buf.tail], bytes_to_send); // 注意:这里不要立即更新tail指针,等DMA完成中断再更新 } } // DMA传输完成中断回调函数 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart == huart_global) { // 更新读指针(tail),移动的长度就是刚刚DMA发送的长度 uint32_t sent_len = huart->hdmatx->Instance->CNDTR; // 获取剩余未传输数据量(仅对某些DMA模式有效) // 更可靠的方式:在启动DMA时记录本次发送长度 // 这里为简化,假设我们知道长度,实际项目需要记录 // tx_ring_buf.tail = (tx_ring_buf.tail + recorded_len) % UART_TX_BUFFER_SIZE; // 尝试启动下一次传输(如果队列中还有数据) uart_start_dma_transfer(); } }第四步:用户API——非阻塞的字符串发送函数
// 用户调用的发送函数(非阻塞) bool uart_send_string_nonblocking(const char *str) { const char *p = str; bool ret = true; // 关闭中断/或使用临界区,防止多任务竞争 // __disable_irq(); while (*p != '\0') { if (!uart_tx_buffer_put(*p)) { ret = false; // 队列满,未能完全写入 break; } p++; } // __enable_irq(); // 尝试启动DMA传输 uart_start_dma_transfer(); return ret; }6.3 关键细节与避坑指南
- 临界区保护:
uart_tx_buffer_put和更新tail指针的操作,如果在RTOS或多处被调用(包括中断),必须用互斥锁或关中断的方式进行保护,否则会导致队列状态错乱。 - DMA传输长度管理:这是最容易出错的地方。在环形队列中,DMA需要一段连续的内存。如果待发送数据在队列中发生了“折返”(即
tail在head后面,且数据跨越了数组末尾),我们需要分两次DMA传输。上面的简化代码只处理了不折返或第一次传输的情况。完整的实现需要更复杂的逻辑。 - 缓冲区大小:
UART_TX_BUFFER_SIZE需要精心设计。太小容易满,导致数据丢失;太大会浪费RAM。需要根据数据产生速率和串口发送速率进行估算。 - DMA配置:在CubeMX中配置串口DMA时,模式建议选择
Normal(每次需要重新启动)而非Circular(循环模式)。内存地址递增,外设地址不变,数据宽度为Byte。 - 错误处理:需要处理DMA传输错误(
HAL_UART_ErrorCallback),例如在出错时重置DMA和队列状态。
7. 方案对比与实战选型建议
我们将四种方法放到一个表格中,进行终极对比:
| 特性维度 | 基础直接法 | printf重定向 | 自定义轻量库 | DMA+环形队列 |
|---|---|---|---|---|
| 代码复杂度 | 极低 | 低 | 中 | 高 |
| Flash占用 | 极小 | 极大 (6-15KB+) | 小 (1-5KB) | 中 (依赖DMA驱动) |
| CPU占用 | 高 (阻塞) | 中 | 中 | 极低 |
| 实时性 | 差 (阻塞) | 中 | 中 | 极好 (非阻塞) |
| 功能灵活性 | 无格式化 | 全格式化 | 可定制格式化 | 需结合前三种之一 |
| 多任务/中断安全 | 需自行处理 | 通常不安全 | 通常不安全 | 需加锁,但架构友好 |
| 适用场景 | 上电标语、极简应用 | 开发调试、原型验证 | 产品级应用、资源受限 | 高速数据流、实时系统、RTOS |
给新手的实战建议:
- 学习与调试阶段:直接使用
printf重定向。快速验证想法,查看变量值,不要过早优化。关注CubeMX中关于Use float with printf的配置。 - 简单项目或产品:使用自定义轻量库(如
mpaland/printf)。在工程中替换掉标准库printf,并禁用不需要的功能,在便利性和体积间取得完美平衡。 - 仅输出固定字符串:使用HAL库阻塞发送或LL库直接操作。代码简单明了。
- 高性能、高实时性要求项目:必须采用DMA+环形队列的架构。这是嵌入式工程师的进阶技能,前期设计虽复杂,但一劳永逸,是高质量嵌入式软件的标志之一。
一个常见的混合策略: 在实际项目中,我经常采用混合模式。例如:
- 使用一个轻量级的
log_printf函数,内部基于mpaland/printf。 - 这个
log_printf函数将格式化后的字符串放入一个全局的环形队列。 - 由一个低优先级的RTOS任务,或一个DMA中断服务程序,负责从队列中取出数据并通过串口发送出去。
- 这样,任何任务、任何中断都可以调用
log_printf来打印日志,而不会阻塞,且输出是线程安全的。
8. 常见问题排查与调试技巧
即使理解了原理,实际调试中还是会遇到各种问题。这里记录几个最经典的“坑”。
问题1:使用printf打印浮点数,输出全是?或f。
- 原因:没有在链接器设置中启用浮点数支持。
- 解决:在STM32CubeIDE的工程属性中,
C/C++ Build->Settings->Tool Settings->MCU GCC Linker->Miscellaneous,在Other flags末尾添加-u _printf_float。或者在CubeMX生成代码时正确配置。
问题2:发送数据丢失最后一个字节,或最后一个字符乱码。
- 原因:在发送完最后一个字节后,未等待“发送完成”(TC)标志就复位了硬件或进入了低功耗模式。
- 解决:在发送函数的最后,添加等待TC标志的代码。对于HAL库,可以在
HAL_UART_Transmit后调用while(__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) == RESET);。对于LL库,如3.2节所述。
问题3:使用中断或DMA发送,数据出现错乱、重复或丢失。
- 原因:通常是对发送状态的管理不当,产生了竞争条件。例如,在前一次DMA传输未完成时,又启动了新的传输,或者写队列和读队列的指针被同时修改。
- 解决:
- 加锁:在操作环形队列的
head和tail指针时,使用关中断(__disable_irq/__enable_irq)或RTOS的互斥信号量进行保护。 - 状态机:设计清晰的发送状态机(如IDLE, BUSY),只有在IDLE状态才允许启动新的发送。
- 调试助手:在队列操作函数中加入断言或边界检查,并可以通过一个调试命令实时输出队列的
head、tail和剩余空间,这对排查问题非常有帮助。
- 加锁:在操作环形队列的
问题4:波特率正确,但接收端全是乱码。
- 原因1:最常见的可能是停止位、数据位或校验位配置不匹配。你的代码配置是8N1(8数据位,无校验,1停止位),但PC端串口助手可能默认是8E1(偶校验)或其他。
- 解决:核对两端软件和硬件的串口参数,确保完全一致。
- 原因2:系统时钟配置错误,导致串口波特率发生器计算的实际波特率与设定值偏差太大。通常误差超过2%就会导致持续乱码。
- 解决:检查STM32CubeMX中系统时钟树(Clock Configuration)的配置,特别是HSE(外部高速时钟)的值是否与实际板载晶振一致。使用示波器或逻辑分析仪测量实际发送的波特率进行验证。
掌握字符串输出,是STM32开发中从“能用”到“好用”的关键一步。它背后牵扯到CPU效率、内存管理、中断处理、DMA应用等核心概念。希望这篇长文能帮你理清思路,下次在项目中面对串口输出时,能够自信地选出最适合的那把“利器”。