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

资讯详情

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

嵌入式printf导致脱机死机?关闭半主机模式与串口重定向详解

嵌入式printf导致脱机死机?关闭半主机模式与串口重定向详解 不知道你有没有遇到过这种场景KEIL工程里跑得好好的裸机程序因为要在调试时看一个变量的值顺手加了一句printf重新编译下载之后板子直接没有任何反应了。更邪门的是调试器在线的时候printf是正常的一旦断开调试器、单独上电程序就跟死了一样LED不闪、串口不出数据、按键也没反应。最近我在调瑞萨RA系列RA4M1、RA6M5的板子时又栽了一次最后翻到瑞萨的应用笔记LAT1472才把问题彻底弄明白。这篇文章把来龙去脉、解决方案和几个容易一起踩的周边坑都整理出来给同样被printf“坑死”过的人一个完整的排查参考。1. 问题现象与根因定位为什么printf会让程序“死掉”1.1 一个典型的故障现场先说一个最典型的复现路径。开发环境是KEIL MDK 5.37编译器用AC5芯片是瑞萨RA6M5外设库用FSPFlexible Software Package生成。串口SCI已经初始化好了直接调用R_SCI_UART_Write发送一个字符数组是正常的串口助手能收到数据。但main函数里一旦加上printf(Hello RA\n)整个工程就变了调试器在线运行时printf的输出会出现在KEIL的Debug (printf) Viewer窗口里程序功能也正常。拔掉调试器重新给板子上电程序不再运行。LED不闪、串口没有输出、连中断里置位的标志也不翻转。再接上调试器全速运行后暂停代码停在一个看起来很不像业务逻辑的地方甚至能看到BKPT 0xAB这样的指令。如果只看现象很多人第一反应是硬件坏了或者供电有问题。但实际上问题几乎都出在“printf的实现方式”上。RA系列芯片本身没有任何问题KEIL工程配置也对问题出在标准C库的printf在默认情况下跟调试器绑定了。1.2 根因半主机模式与printf的输出路径要理解这个现象得先搞清楚ARM编译器环境下printf到底是怎么把数据送出去的。在默认情况下ARM Compiler 5AC5的标准C库实现里printf最终会调用底层I/O函数比如fputc、fwrite、_sys_write这一系列接口。这些底层的默认实现走的是“半主机模式”Semihosting——ARM设计的一种调试机制目标芯片通过特定的指令SVC或BKPT把请求发给主机上的调试器然后由调试器代为完成I/O操作比如把字符显示到调试器的Output窗口、读主机键盘输入等。换句话说默认状态下printf的“输出设备”不是你的串口而是调试器。如果你在线调试调试器会响应半主机请求把字符显示到Debug (printf) Viewer窗口程序继续运行表面上一切正常。但程序一旦脱离调试器单独运行半主机请求发出后没有任何主机响应CPU就会卡死在调试异常状态程序自然就跑不下去了。打个比方你住酒店拿起房间里的电话拨0想叫客房服务但电话线其实根本没接通前台你拿着免提等客服回复等到天荒地老也不会有声音。printf里的半主机请求就是这个“拨0”的动作而调试器就是那个“前台”。1.3 LAT1472应用笔记的背景LAT1472是瑞萨官方发布的一篇应用笔记专门讲RA系列芯片在KEIL环境下使用printf时的注意事项和重定向方法。瑞萨在文档里明确建议不要在最终固件里依赖半主机模式必须把printf的底层输出重新定向到实际可用的外设通常是UART/SCI或者直接关闭半主机请求。文档里给出的核心思路就是我现在要讲的这套方案我在RA4M1、RA6M5上都验证过确认可行。2. 方案一关闭半主机模式根治“死机”问题2.1 使用__use_no_semihosting关闭半主机要解决这个问题最直接的办法是告诉链接器这个工程不需要半主机支持。在代码文件的开头加上一句编译指令#pragma import(__use_no_semihosting)加上这一句之后链接器在编译阶段就不会链接半主机相关的库函数这样程序里即便调用了printf也不会执行到半主机的SVC/BKPT请求。但这里有一个常见的编译报错加了这个指令后如果工程里还直接或间接引用了半主机相关的符号链接器会报类似这样的错误Error: L6915E: Library reports error: __use_no_semihosting was requested, but _ttywrch was referenced碰到这种情况不要慌这是链接器在提醒你你的代码里还残留着对半主机函数的引用常见的是_sys_exit、_ttywrch这些符号。解决办法是手动补上这些底层函数的空实现让链接器满意。下面这段代码是AC5下比较标准的补齐写法#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; FILE __stdin; void _sys_exit(int x) { x x; while (1); } void _ttywrch(int ch) { ch ch; } fpos_t _sys_ensure(void) { return 0; }注意不同编译器版本对空实现的要求略有差异有些版本还需要补_sys_close、_sys_open、_sys_read、_sys_write等函数全部写成空实现或简单返回即可。2.2 重定向printf输出到串口关闭半主机只是第一步让printf输出的字符真正从串口发出去才是关键。在RA系列FSP生成的工程里串口驱动函数是现成的只需要在重定向函数里调用R_SCI_UART_Write把单个字符发出去。我的工程中串口回调函数里有一个发送完成标志定义成volatile全局变量volatile bool g_uart_tx_done true; void sci_uart0_callback(uart_callback_args_t *p_args) { if (p_args-event UART_EVENT_TX_DATA_EMPTY) { g_uart_tx_done true; } }然后重写fputc#include stdio.h #include hal_data.h extern volatile bool g_uart_tx_done; int fputc(int ch, FILE *f) { uint8_t byte (uint8_t)ch; R_SCI_UART_Write(g_uart0_ctrl, byte, 1); while (!g_uart_tx_done) { ; } g_uart_tx_done false; return ch; }这段代码的逻辑很简单把printf要输出的字符转成uint8_t调用R_SCI_UART_Write发出去然后等待发送完成的回调标志。这里必须等待发送完成再返回否则printf连续输出多个字符时后面一个字节可能覆盖前面还没发完的寄存器导致串口数据错乱或丢失。R_SCI_UART_Write的机制是异步的它会把数据交给FSP驱动发送完成后通过回调通知上层。所以这里用一个volatile标志位来做同步是最简单也最可靠的做法。2.3 为什么这个方案能根治问题关闭半主机加fputc重定向实际上是两件事同时做关闭半主机切断了程序对调试器的依赖避免离线运行时卡死在半主机请求上。重定向fputc让printf产生的字符流有了真实的输出出口——串口。这两步缺一不可。只重定向fputc但没关闭半主机printf调用的底层函数可能仍然包含半主机路径离线时依然可能出问题。只关闭半主机但没重定向fputcprintf的数据根本没地方去输出等于没有。这套方案的根本优势在于不管调试器在不在线程序的行为都是一致的printf就是向串口发送字符不依赖任何外部工具。这才是量产固件里应该出现的行为。3. 方案二启用MicroLIB减少依赖3.1 KEIL MDK里的MicroLIB选项在KEIL MDK工程里还有一个更省事的办法启用MicroLIB。点击菜单栏的Options for Target或者快捷键AltF7切到Target选项卡在Code Generation区域勾选Use MicroLIB重新编译即可。MicroLIB是ARM专门为嵌入式场景裁剪的一套轻量级C库。它最大的特点是代码量小、不依赖半主机模式非常适合裸机MCU工程。启用MicroLIB之后printf仍然需要你提供fputc重定向但不再需要手动写#pragma import(__use_no_semihosting)那套“劝退”代码链接器也不会因为半主机符号报错。对于不想深究半主机原理的开发者这是最快速的解决路径。3.2 MicroLIB的取舍与浮点printf的坑MicroLIB虽然方便但也不是没有代价。它为了压缩代码体积裁剪了很多完整C库的功能。实际使用中我遇到过两个需要特别注意的地方第一个是浮点格式化输出。MicroLIB对printf的%f支持要看具体编译器版本有时候打印浮点数会得到0.000000或者不输出。这个问题排查起来很头疼因为它不报错、不崩溃就是结果不对。我的建议是如果项目里必须打印浮点数先在开发板上验证一下目标编译环境对%f的支持情况。如果不能正确打印就别硬刚了简单粗暴的办法是把浮点数转成整数部分和小数部分分别打印float temp 25.36f; uint32_t int_part (uint32_t)temp; uint32_t frac_part (uint32_t)((temp - int_part) * 100); printf(%u.%02u\n, int_part, frac_part);第二种是标准函数的裁剪。MicroLIB里有些函数行为跟标准库不完全一致比如部分字符串处理函数、malloc行为等。如果FSP生成的中间件代码依赖了完整C库的行为换用MicroLIB后可能触发某些边界问题。我在RA6M5上遇到过FSP的USB中间件在MicroLIB下行为异常的情况后来排查发现是中间件内部用了较大块的动态内存分配MicroLIB的堆管理策略和标准库有差异。所以启用MicroLIB后一定要把工程里用到的中间件功能逐个过一遍。3.3 FSP生成代码与MicroLIB的兼容性关注RA系列的朋友会问FSP生成的代码能不能配合MicroLIB用从我的实测来看FSP生成的HAL驱动代码本身不依赖标准C库的复杂特性启用MicroLIB基本没有兼容性问题。FSP内部用到的都是一些基础类型定义和位操作不像某些第三方协议栈那样依赖snprintf、memcpy等函数。不过有一点要注意如果某个外设驱动或中间件是用C写的MicroLIB对C运行时的支持比较弱可能需要额外处理。好在RA系列大部分应用场景还是以C语言为主实际踩到C问题的概率不高。4. 堆栈、编译优化与RTOS场景下的隐性坑4.1 printf的栈开销可能触发HardFault有些人可能已经按照前面的方法改了半主机、重定向了fputc、甚至启用了MicroLIB程序却依然无法正常运行不过这次的现象变了程序跑起来了但运行一段时间后跳进HardFault_Handler或者在一次printf调用时当场死机。这个现象背后最常见的原因是栈空间不够。printf是一个出了名的“吃栈”函数。一次简单的printf(Hello\n)调用完整的函数调用链会用到几百字节的栈空间。如果格式化参数复杂比如多个%s、%d混用或者涉及浮点数转换栈开销可能突破1KB。RA系列FSP生成的裸机工程启动文件里默认的主栈大小Stack_Size一般是0x400也就是1KB。这个大小对简单轮询程序够用但一旦引入printf就很危险。栈溢出不会立刻崩溃而是会悄悄覆盖相邻内存区域的数据导致各种莫名其妙的故障全局变量被改写、函数返回地址错乱、中断无法正常触发……解决办法是打开启动文件比如startup_ra6m5.s找到Stack_Size EQU 0x400改大一些Stack_Size EQU 0x1000我一般直接改成0x10004KB起步如果是带RTOS的工程每个任务的栈也要单独评估。FreeRTOS里通过xTaskCreate创建任务时有一个usStackDepth参数单位是字word不是字节。一个默认任务栈给256字1KB是远远不够跑printf的建议至少512字2KB起步视任务内调用链复杂度增加。4.2 多任务环境下printf不是线程安全的如果你的工程已经上了RTOSFreeRTOS、ThreadX等在多个任务里同时调用printf就会遇到另一个经典问题输出内容互相穿插、串数据严重时可能因为共享资源竞争导致任务卡死。printf内部自带了一个缓冲区但不是为多任务设计的。两个任务同时调用printf底层fputc可能交替发送不同任务的字符导致串口输出变成“Task1数Task2据Task1混Task2乱”这种惨状。更严重的是如果fputc里等待发送完成标志的while循环被打断可能导致标志位状态错乱某个任务永远等不到标志卡死在printf里。解决方案有几种最简单的是加一个互斥锁包住printf调用。FreeRTOS里可以用vSemaphoreCreateBinary或xSemaphoreCreateMutex创建一个互斥量在每个任务调用printf之前获取锁打印完释放extern SemaphoreHandle_t xPrintfMutex; void safe_printf(const char *fmt, ...) { va_list args; xSemaphoreTake(xPrintfMutex, portMAX_DELAY); va_start(args, fmt); vprintf(fmt, args); va_end(args); xSemaphoreGive(xPrintfMutex); }另一种思路是让每个任务先把自己的日志拼接到局部缓冲区然后在临界区内一次性输出降低锁的粒度。不过这种方案对缓冲区大小要求更高如果日志内容比较长任务栈压力会更大。4.3 编译优化等级与调试行为的关系最后讲一个容易被忽视的坑编译优化等级会影响printf相关代码的执行行为。在KEIL的Options for Target里Level -O0是关闭优化适合调试Level -O2/-O3是高优化。我遇到过一个现象同一个工程用-O0编译一切正常改用-O3之后fputc里的while等待标志位循环似乎“失效”了串口输出乱码。原因其实不复杂。高优化等级下编译器可能对循环、变量访问做各种优化。如果用来做标志位的全局变量没有加volatile修饰编译器认为它在循环体内没有被修改可能直接把读取操作优化掉导致循环条件永远不更新或者干脆把整个等待循环内联掉。所以所有在中断回调里被修改、又在主循环或函数里等待的变量一定要用volatile修饰。这是我的老生常谈但在printf问题上不重视它就会栽跟头。调试时先用-O0验证功能最后做性能优化时再提高优化等级并仔细观察串口输出是否仍然正常。5. 故障排查步骤与速查表5.1 快速判定故障根因的调试技巧如果你的程序加了printf之后出问题先别急着改代码用调试器停住目标芯片查看PC指针停在哪个位置这一步能快速缩小问题范围。如果PC停在BKPT 0xAB指令附近说明程序执行到了半主机请求调试器不在线或者半主机没有被正确关闭。解决办法就是走第2节的路子。如果PC停在HardFault_Handler或者某个看起来像异常向量的地址大概率是栈溢出或者内存访问越界。检查Stack_Size、任务栈大小、局部缓冲区大小。如果PC停在while等待标志的循环里可能是UART发送没有产生预期中断或者回调标志没有正确置位。检查中断配置、回调函数注册、以及标志变量的volatile声明。还有一种情况更隐蔽程序能运行但串口输出乱码。这时候检查的重点是波特率配置、时钟树配置以及是否在fputc里等待了发送完成标志。乱码问题通常是时钟配置错误导致的波特率漂移跟printf本身关系不大。5.2 验证代码与测试步骤我建议按照下面这个顺序一步一步验证不要跳步先跑一个最简程序初始化串口然后循环调用R_SCI_UART_Write发送固定字符串确认串口硬件通路正常。加上fputc重定向调用printf(Hello\n)确认输出正常。关闭调试器重新上电确认程序在脱机状态下依然能正常输出。再逐步加上其他业务代码每加一部分就验证一次printf输出避免问题被新代码干扰。如果最终要跑RTOS把printf放到一个任务里测试确认多任务环境下输出不串台。第3步特别重要。很多人开发时调试器一直挂着printf的异常被掩盖了等到量产前才发现固件脱离调试器跑不了。这个步骤应该成为常规测试项。5.3 问题排查速查表现象可能原因解决方案程序完全无响应PC停在BKPT 0xAB半主机模式未关闭添加#pragma import(__use_no_semihosting)并补齐空函数或启用MicroLIB程序跳入HardFault_Handler栈空间不足printf调用链溢出调大Stack_Size或RTOS任务栈大小串口输出乱码波特率配置错误、fputc未等待发送完成检查时钟树和波特率寄存器在fputc中等待发送完成标志多任务下输出内容穿插printf线程不安全使用互斥锁保护printf调用优化等级高时输出异常volatile修饰缺失变量被优化给中断回调中共享的标志变量加volatile%f打印不出正确浮点数MicroLIB浮点支持不完整浮点转整数分拆打印或用sprintf转字符串这张表基本覆盖了我这些年遇到过的printf相关故障类型可以当成一个快速检索的参考。最后说一点个人体会。现在我拿到RA系列FSP生成的工程第一件事就是把printf重定向模板贴进去关闭半主机、重定向fputc、设置好回调标志、把Stack_Size调大到0x1000。这套固定动作做完后面调试任何功能都不会在打印日志上浪费时间。另外提醒一句KEIL MDK的评估模式或社区授权够用不必为了一时的便利去用来路不明的库和工具工程数据和代码安全远比省那点授权费重要。希望这篇文章能帮你把printf这个老坑填平少走点弯路。
返回列表