尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32C542串口调试:UART配置与printf重定向实战指南

STM32C542串口调试:UART配置与printf重定向实战指南 作为一个常年跟 STM32 打交道的嵌入式工程师每次拿到一块新板子我做的前三件事里一定包含调通 UART。点灯固然重要但灯只能告诉你“它活了”串口却能告诉你“它活成了什么样”。这次接到 STM32C542 的项目第一反应仍然是先把串口配置和 printf 重定向搞定把串口终端里的第一行 Hello 打出来。这篇就是围绕 STM32C542 的 UART 配置和 printf 重定向做的一次完整记录也是我前后踩了不少坑之后沉淀下来的操作流程。无论你是刚入手这块芯片还是从别的系列迁过来只要跟着这套思路走串口基本可以一次点亮。1. 为什么拿到 STM32C542 的第一件事是调通串口1.1 串口是所有调试链路的“地面通道”很多入门教程喜欢把点灯放在最前面我也承认 LED 是确认主频和 GPIO 的最快方式但点灯只能暴露“程序有没有跑起来”暴露不了“程序跑到哪一步挂掉”。串口不一样它可以把变量、模块状态、错误码、函数执行顺序全部吐出来尤其是遇到硬 fault 或者逻辑死循环串口日志加上几个关键位置的标记点往往比调试器单步还快。STM32C542 这颗芯片本身定位是中低功耗、高性价比的 Cortex-M33 平台意味着你大概率以后要在它上面挂各种传感器、无线模组、外设协议。而这些模块最常用的交互接口就是串口要么是 AT 指令要么是自定义二进制协议。所以早一点把 UART 通路验证好后面所有模块接入都会顺很多。我这边的习惯是在 main 函数初始化完时钟和串口之后立刻打印固件版本号和编译时间然后才进主循环。这个做法看着简单但能极大缩短后期“这代码到底跑没跑”的确认时间。1.2 这颗芯片上的 USART 与 LPUART 该怎么选看 STM32C542 的参考手册串口资源一般不会只有一组通常包含若干个通用 USART 以及一个低功耗 LPUART。USART 的能力比较全支持异步、同步、智能卡、LIN、IrDA 这些模式还有 DMA 和中断LPUART 则主打低功耗场景在停机模式下只要时钟源合适还能接收唤醒信号。我的建议是调试串口优先选 USART 里的“老大”比如 USART1。原因不是它性能高多少而是它的时钟源选择通常更灵活引脚排列在封装上也更友好而且很多评估板默认把 USART1 的 TX/RX 接到了板载调试器或 USB-TTL 上。如果你一上来就挑一个偏门的 UART 引脚哪怕配置全对还要跟板子的物理走线作斗争完全没有必要。LPUART 这种资源留给真正需要低功耗唤醒的产品功能调试阶段先别碰。1.3 这是系列第三篇但你不需要翻回前两篇这篇是这个系列的第三篇前面两篇分别解决了环境和工程框架的问题。不过这一篇的内容是自洽的你只要有一块 STM32C542 的开发板、一套能编译烧录的 STM32CubeMX 工程以及一根可以用的串口线就能跟着做。需要说明的是我下面所有的操作路径都是基于 STM32CubeMX 生成 HAL 库工程再手动补充重定向代码。这种方式的好处是一旦你掌握套路换到同系列的另外一颗芯片基本只需要重新选引脚和时钟代码结构不需要大改。2. 先啃时钟树和引脚复用串口才不会见面就乱码2.1 波特率误差是怎么来的又怎么控制UART 是异步协议收发双方没有共享时钟靠的是双方约定波特率。但 MCU 内部并没有一个专门为 115200 生成的晶振波特率往往是通过外设时钟分频得到的。分频结果不可能是所有波特率的整数倍所以必然有误差。误差大了接收端采样的点就会偏移到数据位的边缘轻则误码重则乱码。STM32C542 的 UART 外设时钟来自 APB 总线时钟通常需要经过总线预分频。CubeMX 的时钟树页面会帮你把 APB 时钟算好然后在 UART 配置页里自动计算误差你只要看它给出的 Baud rate 误差是否在可接受范围内。按我的经验对于标准 8N1 帧格式总误差尽量控制在 2% 以内极限也不要超过 3% 到 4%。如果在里面看到误差超过这个数别硬调波特率而是去调外设时钟分频让 USART 的时钟不是刚好落在某个整数倍附近有时候把 PCLK 从 64MHz 改成 80MHz误差立刻小一大截。2.2 引脚复用表必须查不能凭样板代码猜STM32 的 GPIO 脚很多都挂了一堆复用功能。同一个引脚上AF1 可能是 UART TXAF2 可能是 SPIAF3 又可能是定时器。你光看原理图上有 “PC6/USART1_TX” 还不行必须去 datasheet 里的 “Alternate function mapping” 表格确认 USART1_TX 对应的 AF 编号。CubeMX 的图形化配置界面其实已经帮你规避了这个问题你在引脚上选中 USART 功能后它会自动填好 AF但如果你手工写寄存器或者参考了别家板子的代码这里就特别容易翻车。还有一个很容易忽略的点GPIO 的复用配置通常在 HAL_UART_MspInit 回调里完成而不是在 MX_USART1_UART_Init 里。很多人看到串口初始化函数没配置 GPIO以为生成代码漏了其实 HAL 库的设计是外设基础配置和引脚/时钟配置分开。你自己写代码或者移植代码的时候如果把 MspInit 里的 GPIO 时钟使能或 AF 配置丢了串口寄存器明明是对的但引脚没有任何波形这类问题特别隐蔽。2.3 CubeMX 参数页里这次该选什么调试串口不需要花哨功能参数越简单越不容易出问题。我在 CubeMX 里通常这样配置配置项推荐值说明ModeAsynchronous异步模式不选同步时钟输出波特率115200调试终端常用速度与稳定性平衡数据位8 Bits常规上位机默认配置奇偶校验None不需要校验位停止位1 Bit标准 8N1 帧流控None调试串口不接 CTS/RTSUSART1 中断暂不使能先用阻塞方式验证通路这里的核心原则是“先跑通再起飞”。调试阶段最怕的就是把中断、DMA、FIFO 全部打开出了问题不知道是底层驱动的问题还是自己业务代码的问题。先把模式切成最朴素的异步阻塞收发等回环测试通过后再按需增加中断和 DMA。3. CubeMX 最小工程落地从生成代码到回环自测3.1 新建工程时最容易漏掉的三个配置如果是从零建工程有三天配置经常会漏掉每一个都会让串口问题变得非常诡异。第一调试接口没有保持开启。STM32 的 SWD 引脚默认是调试功能但如果你在 CubeMX 里把 SWD 引脚剥夺给了某个外设或者不小心在 GPIO 配置里把它拉高拉低下一次烧录很可能就进不去了。新建工程时我习惯在 SYS 页面把 Debug 设置为 Serial Wire烧录和调试口永远留一条命。第二RCC 时钟源没有选对。有些板子接了外部高速晶振有些板子没有。CubeMX 默认可能使用内部 HSI但工程模板里如果启动文件里先初始化了外部晶振而实际板上没有单片机就跑在一个错误甚至不稳定的时钟上串口波特率自然乱。所以用 CubeMX 时务必确认 RCC 这一项和板子实际硬件一致。第三Project Manager 里的生成选项。代码生成时建议勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”否则所有外设初始化都堆在 main.c 里后面维护会很难受。另外需要确认 Toolchain 选择的是你实际编译器对应版本CubeMX 生成后直接打开就好省去手工配置 Flash 算法和 Debug 器的时间。3.2 初始化顺序和 MspInit 的关系CubeMX 生成的 main 函数里代码流程一般是int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); // 在这里加一句 printf 测试 while (1) { } }注意MX_USART1_UART_Init内部会调用HAL_UART_Init而HAL_UART_Init在执行过程中又会调用HAL_UART_MspInit。这个MspInit才是完成 GPIO 时钟、复用、以及可选中断配置的地方。有些人喜欢手动改初始化顺序把串口初始化放到某个外设之后但没改到 GPIO导致串口模块初始化顺序不对UART 外设时钟没开发送函数直接卡死。我的经验是在第一次调用 HAL_UART_Transmit 之前必须确保 SystemClock_Config 和 MX_USART1_UART_Init 都已经被执行过。如果你把 printf 放在外设初始化之前那一瞬间串口发送必然失败失败之后 printf 普遍不会有报错提示只会静默丢数据很容易排查很久。3.3 用 TX 接 RX 的回环测试代替盲写 printf如果你一上来就直接写 printf结果串口终端一片空白那你去排查重定向代码、编译器选项、杂七杂八的配置费时费力。更稳妥的做法是先用一个裸数组发出去验证最底层的 UART 寄存器通路。先把 USART1 的 TX 和 RX 引脚用杜邦线短接然后在 main 里写这样一段uint8_t tx_data[] Hello STM32C542\r\n; HAL_UART_Transmit(huart1, tx_data, sizeof(tx_data) - 1, 1000); uint8_t rx_data[sizeof(tx_data)]; HAL_Receive(huart1, rx_data, sizeof(rx_data) - 1, 1000);HAL_Receive 会阻塞等待接收指定数目的字节因为我们把 TX 直接接到了 RXMCU 自己发的数据自己会收到。如果收发缓存内容一致说明串口的外设时钟、GPIO 复用、波特率分频全部没问题。到这一步再去做 printf 重定向心态就会稳很多因为你知道底层发送是通的问题只会出现在重定向层。4. printf 重定向的三种实现方式与背后的 C 库原理4.1 为什么 MCU 上的 printf 不会自己往串口跑printf 是 C 标准库提供的格式化输出函数它本身不知道“串口”是什么。在 PC 上标准库把 stdout 定向到终端窗口在 MCU 上没有操作系统接管标准输出所以必须由你提供底层字符输出函数。不同工具链对这个底层函数的命名不一样ARM 编译器MDK-ARM期望你重写fputcGCC 工具链期望你重写_writeIAR 则可能是putchar或__write。很多人在串口已经回环测试通过之后printf 还是没输出原因就是把精力集中在 printf 本身的格式化参数上忽略了“printf 的字符最终通过谁发出去”这一环。记住一句话printf 只是帮你格式化重定向才是帮它找人发。4.2 MDK-ARM 环境下用 fputc MicroLIB 重定向如果你用的是 Keil MDK最常见的做法是重写fputc同时勾选 MicroLIB。MicroLIB 是一个精简版 C 库它不会依赖半主机模式省去很多底层系统调用的麻烦。在 Keil 里操作时打开 Options for Target在 Target 选项卡勾选 Use MicroLIB然后在代码中加入#include stdio.h int fputc(int ch, FILE *f) { uint8_t data (uint8_t)ch; HAL_UART_Transmit(huart1, data, 1, 1000); return ch; }这样printf里的每个字符都会走fputc进而从串口发送出去。这里有个细节HAL_UART_Transmit的返回值要判断但没必要每次都在fputc里全面处理因为printf一旦在 MCU 中执行大部分是调试用途真发送失败我们也只能干瞪眼。不过超时时间建议给一个有限值不要给HAL_MAX_DELAY否则串口故障时程序会永久卡死。MicroLIB 有一个老生常谈的缺点对浮点%f的支持不是默认开启的。具体表现是打印1.23f会出不来或者显示0。如果你的日志里需要浮点一个方案是放弃 MicroLIB改用标准库配合retarget函数另一个方案是先把浮点转成字符串再打印。我更推荐前者后面会提到标准库方式的坑。4.3 GCC 工具链下通过 _write 重定向如果你用的工具链是 arm-none-eabi-gcc那 printf 底层调用的函数是_write而不是fputc。这个函数不仅负责 printf还负责 write 系统调用所以签名和语义也略有不同int _write(int file, char *ptr, int len) { (void)file; for (int i 0; i len; i) { uint8_t ch (uint8_t)ptr[i]; if (HAL_UART_Transmit(huart1, ch, 1, 1000) ! HAL_OK) { return i; } } return len; }逐字节发送速度不快但作为调试日志完全够用。如果你希望高效一点可以一次性把整个ptr指针传给 HAL_UART_Transmit。但要注意len是 int 类型而 HAL 的 Size 参数是 uint16_t如果某个日志非常长超过 65535 会溢出需要分包处理。大多数日志不会那么长不过严谨起见还是应该加个判断。使用 GCC 时链接阶段需要加上--specsnano.specs --specsnosys.specs。前者使用精简 C 库后者避免依赖半主机 syscall。如果你不加nosys.specs链接器会尝试链接一些半主机相关的符号运行到 printf 时可能卡在异常或 BKPT 指令上这种现象很经典我最初遇到时还以为板子坏了。4.4 printf “哑火”的几个典型原因重定向代码写完后printf 还是没输出常见原因集中在四个地方串口初始化晚于 printf 调用。printf 在MX_USART1_UART_Init()之前执行底层的 HAL_UART_Transmit 根本没准备好。工具链选错了重定向目标。在 GCC 里写fputc或在 MDK 里写_write当然不生效。标准库的半主机模式没有被关闭。半主机模式下 printf 不是走串口而是走调试器代码可能卡在断点/异常。串口发送中断被阻塞但 HAL_UART_Transmit 使用阻塞方式等待。如果某个中断优先级设置不合理导致 TXE 标志一直不被清发送永远等不到完成。这个多发生在新手配置了串口中断又没写中断处理函数的情况。排查建议是一层层往下剥先用HAL_UART_Transmit直接发固定字符串能通说明 UART 层没问题再到重定向函数里设置断点看fputc或_write有没有被调用最后再看链接器选项和启动文件是否引入了半主机。5. 调试串口最容易翻车的四个细节点5.1 乱码先别怀疑代码检查这三处串口乱码的根源绝大部分不在“串口配置代码”本身而是在时钟和物理链路。我最常遇到的三个原因是波特率不匹配上位机工具和 MCU 配置两边不一致外部晶振频率配置错误例如板子上实际是 8MHz 晶振CubeMX 里却写了 25MHz导致系统时钟频率漂移USB-TTL 模块电压不匹配3.3V 逻辑的板子接了 5V TTL 模块虽然有些模块标称兼容但时序边沿已经畸变速度一高就乱。排查乱码最快的方法是把波特率降到 9600。低速波特率对时钟误差和信号质量容忍度更高如果 9600 下依然乱码重点排查晶振和电平如果 9600 正常而 115200 乱码重点检查波特率误差和串口线长度。有条件的话用逻辑分析仪抓一下 TX 引脚波形看字长、停止位和实际波特率是不是和预期一致这是最直观的。5.2 卡死在 HAL_UART_Transmit 超时循环的完整排查HAL_UART_Transmit 的最后一个参数是超时时间。如果传入HAL_MAX_DELAY底层会进入一个无线循环等待 TXE 标志位。一旦串口外设没有正确初始化或 GPIO 被占住导致时钟未使能这个循环就永远等不到结束表现就是程序卡死。遇到这种卡死先别急着屏蔽 HAL 等待逻辑。我按这个顺序排查第一步看SystemClock_Config是否正常执行UART 外设时钟有没有被使能第二步看MX_USART1_UART_Init是否真的被调用以及huart1实例是否被MX_USART1_UART_Init正确填充第三步看HAL_UART_MspInit里 GPIO 时钟和复用是否配置通常生成代码没问题但手动移植容易漏第四步检查你的调试器是否停在了HAL_UART_Transmit内部的 while 循环里这一点断点就能看出来。还有一个容易忽视的情况如果在串口中断服务函数里调用了阻塞式 HAL_UART_Transmit而发送的中断优先级高于当前中断则可能导致死锁。中断场景下发送应该用HAL_UART_Transmit_IT或者把超时时间设成很短并接受丢帧而不是在 ISR 里做 1000ms 的阻塞等待。5.3 复位后第一行日志莫名丢失代码跑起来后终端里最开头往往缺失一行。很多人以为是重定向没写好实际上原因常常是MCU 复位时 TX 引脚处于未初始化状态此时电平被拉到低电平USB-TTL 转换器会把这一段低电平“识别”成一个0x00字节导致 PC 端串口工具收到一个空字节紧接着你 printf 第一行正常数据又来了上位机可能把前导空字节当成脏数据丢弃了或者串口工具的启动时刻晚于 MCU 复位。解决方式很简单在串口发送第一行日志之前加一个几十毫秒的延时比如HAL_Delay(100); printf(System Boot OK\r\n);也有人会在 GPIO 初始化前先把 TX 引脚配置为带上拉的 GPIO 模式让电平先稳定在高位再切入复用功能。这种做法更彻底但会打乱 CubeMX 生成代码结构一般调试阶段用HAL_Delay就足够了。5.4 RTOS 环境下 printf 打架的问题一旦把 FreeRTOS 或其它 RTOS 跑起来多任务同时调用 printf 会出现明显的互相穿插日志一行里混着两个任务的输出非常难读。根本原因是 printf 内部的输出过程和串口发送不是原子操作任务 A 打印到一半任务 B 也进来打印两个字符串就交错在一起。解决办法是在重定向函数外层加互斥锁。FreeRTOS 下可以用一个SemaphoreHandle_tstatic SemaphoreHandle_t uart_lock; void UART_PrintfInit(void) { uart_lock xSemaphoreCreateMutex(); } int fputc(int ch, FILE *f) { xSemaphoreTake(uart_lock, portMAX_DELAY); uint8_t data (uint8_t)ch; HAL_UART_Transmit(huart1, data, 1, 1000); xSemaphoreGive(uart_lock); return ch; }不过每个字符都拿锁放锁开销有些大。更高效的方式是在printf调用外层加锁但这样侵入业务代码。实际上调试阶段更省事的方法就是让任务打印时自带前缀比如[task1] ...交错时也能分辨。如果要做比较正式的日志系统还是建议把整条日志先格式化到一个缓冲区然后一次性加锁发送。6. 从“能打印”到“好用”日志宏、DMA 与个人习惯6.1 阻塞发送只配调试不配产品日志阻塞式HAL_UART_Transmit和 printf 重定向最大的缺点是会占住 CPU。如果你的主循环里每毫秒要处理一堆业务而串口日志频繁输出那 CPU 很大一部分时间都在等待串口移位寄存器慢慢吐数据这在高主频 MCU 上显得特别浪费。产品上线之后我们不建议把调试日志留在主循环里高频打印除非你接受它对实时性的影响。一个折中方案是使用 DMA 发送例如HAL_UART_Transmit_DMA。但 DMA 发送需要保证缓冲区在传输期间不被修改因此最稳妥的做法是维护一个环形缓冲区把要发送的字符串先抄进环形队列再由 DMA 逐块发送。这个方案更适合做成一个模块化 logger而不是简单重定向。6.2 一个带时间戳和等级控制的日志宏printf 重定向只是起点真正好用的调试系统应该带有日志等级和时间戳。下面这个宏是我常用的模板基于标准 printf 实现不依赖额外文件系统#define LOG_LEVEL_ERROR 0 #define LOG_LEVEL_WARN 1 #define LOG_LEVEL_INFO 2 #define LOG_LEVEL_DEBUG 3 #define LOG_LEVEL LOG_LEVEL_DEBUG #define LOG_PRINT(level, tag, ...) \ do { \ if (level LOG_LEVEL) { \ printf([%lu][%s] , \ (unsigned long)HAL_GetTick(), tag); \ printf(__VA_ARGS__); \ printf(\r\n); \ } \ } while (0) #define LOG_ERROR(...) LOG_PRINT(LOG_LEVEL_ERROR, E, __VA_ARGS__) #define LOG_WARN(...) LOG_PRINT(LOG_LEVEL_WARN, W, __VA_ARGS__) #define LOG_INFO(...) LOG_PRINT(LOG_LEVEL_INFO, I, __VA_ARGS__) #define LOG_DEBUG(...) LOG_PRINT(LOG_LEVEL_DEBUG, D, __VA_ARGS__)HAL_GetTick()返回的是系统上电以来的毫秒数配合时间戳你能清楚看到事件发生的时间间隔对排查超时、时序问题非常有用。日志等级可以在编译期裁剪比如发布版本把LOG_LEVEL改成LOG_LEVEL_ERROR调试信息就全部消失不需要逐行删代码。6.3 我给所有串口调试工程定的几条铁律最后说点和技术无关但很重要的经验。第一固定调试串口不要今天用 USART1明天换 USART3。调试串口一旦定了就把它当成一个固定资源保留新增功能时尽量不要和它抢引脚。第二所有对外发布测试固件都保留一个隐藏串口打印入口打印版本、编译时间、启动原因这些信息在工厂测试和现场问题回溯时特别有价值。第三串口线要买质量好的我有过整整一个下午被乱码折磨最后发现是一根 USB-TTL 线太劣质换线后一切正常。硬件上的坑经常比软件上的坑更耽误时间。这些步骤走完之后你那块 STM32C542 的串口应该已经能稳定输出 printf 内容了。后面再接无线模组、传感器或者调试 RTOS 任务优先级都会有更扎实的地基。我个人的开发模板里这段 UART 配置和 printf 重定向的代码已经变成固定模块换芯片型号时只改引脚、时钟和一小段重定向函数其他逻辑几乎不用动。希望能给你的项目省下一些时间。
返回列表