简介面向使用瑞萨RA单片机与e2studio开发环境的嵌入式开发者该资源是一套完整的e2studio工程源码用于解决printf、sprintf等标准输出函数在嵌入式串口调试中的重定向问题。资源包大小1.3MB共169个文件以h头文件与c源文件为代码主体并包含o、d等编译中间产物mk/makefile构建脚本、ld链接脚本、jlink调试配置和cproject工程配置可直接导入开发环境查看完整项目结构。源码覆盖r_sci_uart串口底层驱动、bsp_clocks时钟初始化、r_ioport引脚分配、startup启动文件、system系统初始化以及bsp_common基础库完整呈现从格式化输出函数到串口外设发送的底层实现链路。压缩包目录结构清晰头文件与源码分层明确便于对比FSP配置逐模块理解初始化、中断与字符输出过程。已有1147人学习下载适合正在学习瑞萨RA外设驱动、需要快速搭建串口打印调试通道的入门及中级嵌入式开发者参考。1. 从点灯到打印为什么 e2studio 里 printf 不是拿来就能用拿到一块基于 RA 系列芯片的开发板大多数人的第一步是点灯第二步就是调通串口打印。原因很简单嵌入式调试里printf 是最直观的“眼睛”。但很多人在 e2studio 里写完 printf 之后发现串口助手一片空白或者程序一运行就卡死。这不是语法问题而是标准库的 printf 在单片机上少了一个出口——它的字符最终要落进某个硬件而芯片默认没有为它接上串口。本文要做的就是把这个出口打通先讲清 printf 从库函数到串口之间的调用链再按 e2studio FSP 的常规流程一步步配置 UART、重定向 fputc、打开浮点数支持最后给出一份可以照着排查的踩坑清单。适合正在使用 FSP 做 RA 系列开发、又被串口打印卡住的人。2. 先搞懂 printf 在单片机里的真正去向再动手改代码2.1 标准库的调用链printf 为什么要落到 fputc 手里printf 在 C 标准库里是一个格式化输出函数但它本身并不直接操作硬件。它的内部逻辑大致是解析格式串把参数转成可打印字符然后通过一个“字符出口”把这些字符逐个送出去。在 PC 上这个出口是 stdout最终写入终端在单片机上这个出口默认是不存在的或者被链接到了调试器专用的半主机通道。GCC 工具链下这个出口通常是 fputc 或者 putchar。printf 每处理完一个字符就会调用 fputc(ch, stdout) 一次。所以要让 printf 在串口上工作本质上就是自己写一个 fputc 函数把收到的字符通过 UART 发出去。写完之后库函数不会再去找别的出口你的串口就成为了 printf 的显示器。这里有一个很容易踩的坑很多人以为实现了 fputcprintf 就会自动走串口但实际还要看工具链对底层符号的解析方式。比如在某些 ARM GCC 版本里printf 最终调用的不是 fputc 而是 _write 或 _write_r。如果你用的是 e2studio 自带的 GNU GCC 工具链实现 fputc 通常就够但如果换了 LLVM 工具链照抄 fputc 代码可能没有效果这时需要实现 _write 函数。动手之前先确认自己工程用的编译器类型否则代码是对的方向是错的。再说半主机模式。ARM 调试体系里有一个称为 semihosting 的机制它允许目标板上的程序通过调试器在主机上执行文件操作、终端输出等。默认情况下没有重定向的 printf 会尝试走这条通道。调试器没有启用该功能时程序就会停在某个断点指令上表现就是“加了 printf 之后程序跑飞”。这不是个例是新手最容易遇到的问题而且是靠读代码发现不了的。2.2 选 UART 还是 RTT两条常见路线的取舍解决 printf 输出通道业界常见做法有两条。第一条是把 UART 重定向成 printf 的出口字符通过串口发到 PC 的串口助手。优点是通用任何串口工具都能看适合长时间运行、记录日志、和上位机联调缺点是占用一个串口而且波特率有限数据量大时会有瓶颈。第二条是用调试器厂商提供的 RTT 技术比如 J-Link 配套的 RTT Viewer。RTT 利用调试接口在内存里圈一块缓冲目标板把日志写进缓冲调试器通过 SWD 高速读走不占串口速度快得多。代价是你必须挂着调试器离了 J-Link 日志就看不到。两条路我都用过。如果这块板子串口资源紧张比如串口要留给 Modbus 或者 AT 指令通信RTT 是最优解如果只是日常调试打印我会直接用 UART因为它不依赖调试器程序脱机跑也能通过串口助手看日志。本文后面的流程围绕 UART 重定向展开这也是 e2studio 下最主流的做法。最后会额外给一点 RTT 的思路作为串口被占时的一条退路。2.3 fputc 重定向的核心逻辑异步发送与等待FSP 生成的 UART 驱动发送 API 是异步的。R_SCI_UART_Write 调用之后数据并不是立刻出现在 TX 引脚上而是先进入驱动队列由中断/事件触发逐步发完。如果你在 fputc 里只调用一次 R_SCI_UART_Write 就直接返回那么下一个字符进来时上一个字符可能还没发完字符会相互覆盖最终串口助手收到的是一团乱码或者少字符。所以重定向的核心在于“发一个字节等它真正发完再返回”。这个等待不能盲目用延时因为波特率不同单字节发送耗时不同延时写大了拖慢系统写小了照样出错。正确做法是利用 UART 发送事件注册回调函数在发送完成事件里置一个标志位fputc 里写完后原地等待这个标志位置位。这就是完整的握手逻辑。理解了这一层后面写代码就不容易出原则性错误。很多人把 printf 移植不成功的原因归咎于芯片其实是没弄明白异步驱动和库函数之间需要一座“同步桥”。3. 用 FSP 配置 UART 并重定向 fputc最小可跑工程3.1 新建工程与 FSP 引脚配置在 e2studio 里新建 RA 工程后第一步是进入 FSP 配置界面。以我手头某块 RA6M4 核心板为例板载串口接在 SCI0 上对应引脚是 P500(TXD) 和 P501(RXD)不同开发板引脚不同请务必对照你自己的原理图。打开项目里的 configuration.xml先切到 Pins 标签页。在 Pins 页面左侧找到 Connectivity 分类展开对应 SCI 编号把 TX 和 RX 勾选到目标引脚上。如果没有特殊要求不要启用 CTS/RTS 流控它会多占两个引脚也会让串口助手那边需要额外配置新手在这里翻车不值得。接着切到 Stacks 标签页点击 Add New Stack在 Connectivity 分类下选择 UARTFSP 会默认添加 r_sci_uart 驱动。选中新加的 UART 模块右侧属性面板会出现关键参数按下面这张表配置。参数项典型值说明Nameg_uart0代码里引用这个句柄Channel0对应 SCI0Baud Rate115200与串口助手保持一致Data Bits8常用Stop Bits1常用ParityNone无校验Callbackg_uart0_callback发送完成事件在这里处理Interrupt PriorityPriority 12数值越小优先级越高按需调整注意 Name 这个字段。FSP 会按这个名字生成 g_uart0_ctrl 和 g_uart0_cfg 两个全局变量后面所有代码都通过它们操作串口取个短名字能少打很多字。配置完成后点击 Generate Project Content让 FSP 生成底层驱动代码。生成完不要急着写应用先编译一次确认空工程没有问题避免把配置错误和代码错误混在一起排查。3.2 实现 fputc打通 printf 到 UART 的最后一公里在工程里新建一个源文件命名为 debug.c 或者直接写在 main.c 里。如果写在 main.c注意放在 extern “C” 的作用范围之外纯 C 工程没有这个问题。下面的代码是一个最小可用的重定向实现。/* debug.c - printf 重定向到 UART0 */ #include hal_data.h #include stdio.h #include stdbool.h /* 发送完成标志 */ volatile bool uart_tx_done true; /* FSP UART 回调函数在中断上下文中被调用 */ void g_uart0_callback(uart_callback_args_t *p_args) { if (p_args-event UART_EVENT_TX_DATA_EMPTY || p_args-event UART_EVENT_TX_COMPLETE) { uart_tx_done true; } } /* 标准库 printf 逐字符调用本函数 */ int fputc(int ch, FILE *f) { /* 准备一个字节的数据并触发异步发送 */ uart_tx_done false; R_SCI_UART_Write(g_uart0_ctrl, (uint8_t *)ch, 1); /* 等待发送完成事件带超时保护防止硬件异常时死等 */ uint32_t timeout 10000; while (!uart_tx_done timeout--) { /* 空转等待回调会置位 */ } return ch; }这段代码的核心是 R_SCI_UART_Write 加等待标志。R_SCI_UART_Write 把数据交给底层驱动后立即返回发送完成后中断会触发回调回调里把 uart_tx_done 置位fputc 里的循环才会退出。这样就能保证一个字节彻底发完下一个字节才开始发送。超时保护是我后来加上的。早期版本没有 timeout某次串口硬件异常导致回调一直不来主循环卡死在 printf 里连看门狗都喂不了。加一个计数器就算任务卡住最终也能退出来。如果你用的是 LLVM 工具链这个 fputc 可能不会被调用还需要实现 _write。可以一并加上写法如下。/* LLVM 工具链下 printf 最终走这里 */ int _write(int fd, char *p_buffer, int size) { for (int i 0; i size; i) { fputc(p_buffer[i], NULL); } return size; }写完重定向后在 main 函数里加一句输出烧录后打开串口助手。如果你的串口助手收到了第一行打印说明链路已经通了。/* main.c */ #include hal_data.h #include stdio.h void main(void) { R_SCI_UART_Open(g_uart0_ctrl, g_uart0_cfg); printf(Hello e2studio, printf works!\r\n); while (1) { /* 用户代码 */ } }这里有一个细节字符串里写了 \r\n。串口助手的换行只认 \n 或 \r\n 取决于配置但很多终端程序对裸 \n 处理不友好。我在嵌入式打印里一律用 \r\n这样可以避免各大串口助手之间的显示差异。3.3 打开浮点数打印printf 输出 3.14 而不是空白字符串和整数都能正常打印之后大部分人会在浮点数上栽跟头。printf(“%.2f”, 3.14) 输出的不是 3.14而是空白、f 或者一组无意义字符。原因不在 UART而在 C 库。e2studio 默认使用 newlib-nano 作为 C 库这版库为了节省 flash默认不包含浮点数格式化代码。printf 遇到 %f 时不知道如何处理就什么都不输出。解决方法是让链接器强行把浮点格式化代码拉进来。在 e2studio 里右键工程进入 Properties找到 C/C Build - Settings展开 GNU ARM Cross C Linker - Miscellaneous在 Other linker flags 里加上下面这个参数-u _printf_float如果是用 LLVM 工具链同样的位置、同样的参数。加完参数后重新编译浮点数打印就正常了。代价是 flash 占用会增加大约 10KB 左右对于 RA 系列的 flash 容量来说完全可以接受不要因为省空间而关掉它调试效率比那点空间值钱。如果加了参数仍然不能打印浮点数检查工程是否真的在用 newlib-nano。有时候链接器配置被还原了或者工程混用了两套标准库这类情况放到后面的避坑章节详细说。3.4 一个更快的发送路径整帧输出替代单字符fputc 逐字符发送虽然稳定但在大量打印时效率低。每发一个字符都要等待一次完成事件调用一次驱动 API实际有效数据率不高。如果你要在主循环里高频打印传感器数据可以考虑跳过 printf 逐字符机制直接用 R_SCI_UART_Write 发送整个缓冲。/* 一次发送整行数据比逐字符 fputc 快得多 */ void uart_send_line(const char *line) { uint16_t len strlen(line); uart_tx_done false; R_SCI_UART_Write(g_uart0_ctrl, (uint8_t *)line, len); while (!uart_tx_done timeout--) { /* 等待整帧发送完成 */ } }这个函数配合 sprintf 使用先用 sprintf 格式化到缓冲区再一次性把整行发出去。发送次数少了CPU 开销明显降低。后面第 6 章的日志功能会用到这个思路。4. sprintf 的正确打开方式格式化、缓冲区与安全边界4.1 sprintf 与 printf 的差异从“输出到串口”到“输出到内存”sprintf 和 printf 的格式解析逻辑完全相同唯一区别是输出目的地。printf 把字符逐个丢给 fputc最终到达串口sprintf 把字符写入一段内存缓冲区不做任何硬件交互。正因如此sprintf 在嵌入式里的应用场景比 printf 更广。常见用途有三个。第一是拼协议帧比如把 ADC 采样值、温湿度、状态字拼成一个带校验的字符串帧再整体发出去第二是驱动 LCD 或 OLED 显示先把要显示的内容格式化到缓冲区再调用显示驱动刷新第三是生成日志缓冲攒够一行再一次发送减少串口中断频率。下面这段代码演示了把三个变量拼进一个字符串再通过 UART 发出去的完整流程。/* 用 sprintf 拼帧一次发送 */ char line[64]; uint16_t adc_val 1024; float temp 26.5; uint8_t status 0x03; sprintf(line, adc%d temp%.1f status0x%02X\r\n, adc_val, temp, status); /* 这里没有走 fputc直接驱动发送整帧 */ uart_send_line(line);注意 uart_send_line 是我们前面自定义的整帧发送函数。先用 sprintf 把数据整理成可读文本再一次发送比 printf 逐字符发送效率高一个量级。4.2 缓冲区大小是一个必须手算的安全边界sprintf 没有长度限制参数格式化后的字符串长度完全取决于数据和格式串。缓冲区给小了字符串就会写到缓冲区边界之外覆盖掉相邻的变量、栈数据甚至返回地址表现出来就是诡异的死机、HardFault、变量被莫名篡改。这是 sprintf 在嵌入式里被诟病最多的地方。缓冲区大小不能靠猜。以三个浮点数为例每个浮点按 %.3f 格式最多占 15 个字符左右符号、整数部分、小数点、三位小数三个就是 45 个字符加上前缀、逗号、结尾换行至少 55 个字符。这种情况下 char buf[32] 一定溢出。安全的写法是改用 snprintf它接受缓冲区大小参数格式化的内容超过大小就截断不会越界。/* snprintf 安全写法 */ char line[64]; int len snprintf(line, sizeof(line), adc%d temp%.1f status0x%02X\r\n, adc_val, temp, status); if (len 0 len (int)sizeof(line)) { uart_send_line(line); /* 正常长度发送 */ } else { /* 内容被截断了说明缓冲区不够需要加大 */ }snprintf 的返回值表示应该写入的字符数如果这个值大于等于缓冲区大小说明内容被截断了。判断这个返回值比盲目加大缓冲区更可靠。我自己的习惯是先按最大可能值估算长度再加上 16 字节余量最后用 snprintf 的返回值验证是否合理。三层保险下来sprintf 系列函数基本不会出越界问题。4.3 可变参数的隐患格式串与参数不匹配会直接踩坏内存sprintf 和 printf 都是可变参数函数编译器能做有限的格式检查但很多情况下警告会被忽略。参数类型和格式串不匹配时比如 %s 给了整数、%d 给了长整型格式化函数会按错误的方式读取参数栈轻则打印出垃圾值重则读取越界地址导致 HardFault。这是我认为 sprintf 在嵌入式里最危险的地方。某个项目里出现过这样的情况A 同学把 uint8_t 直接传给 %d因为 uint8_t 在可变参数中会被自动提升为 int侥幸正常但换了一个人传 uint16_t 给 %d在某些编译选项下结果就完全错乱。排查这类问题有一个低成本的手段打开编译器警告。e2studio 的 GCC 默认会开启部分 -Wformat 检查编译时留意 format 相关的 warning不要把警告当噪音。命中的每一条警告都代表一个潜在的崩溃点。4.4 把临时大缓冲区移出栈避免栈溢出sprintf 需要缓冲区缓冲区放哪里也值得讲究。局部声明的 char buf[128] 在栈上分配RA 系列默认栈大小通常只有几 KB主调函数多、中断嵌套深时栈空间很紧张。一个 128 字节的临时缓冲区就可能让栈溢出。两种解决思路。第一种是把缓冲区声明为 static它会被放到全局数据区不占栈空间。代价是这个函数失去重入能力不适合在中断和主循环同时调用。/* static 缓冲区不占栈函数不可重入 */ static char line[128]; void log_send(void) { snprintf(line, sizeof(line), adc%d temp%.1f\r\n, adc, temp); uart_send_line(line); }第二种思路是用更小的缓冲区分多次发送限制单次格式化长度。前一种在实际工程里更常见因为日志函数本身就不需要重入加个互斥保护就够了。5. printf 移植避坑与排查手册5.1 一调用 printf 程序就卡死现象代码编译正常烧录后执行到 printf 时程序不再运行调试器停在一条 BKPT 指令附近。和我第一次移植时的经历一模一样当时还以为是开发板坏了。原因标准库的 printf 默认走半主机模式期望通过调试器在主机上做输出。调试器没有启用半主机程序就会卡在等待上。实现的 fputc 并没有被调用因为库函数在调用 fputc 之前已经先卡在半主机的入口上。解决先实现 fputc确保库函数不会走默认路径。如果已经实现还是卡死检查工具链是 GCC 还是 LLVM。GCC 下 fputc 有效LLVM 下需要补 _write 函数。另外确认一下是否启用了微库微库对半主机的处理与标准库不同。最直接的验证方法是在 fputc 里打断点看程序到底有没有进来。5.2 浮点数打印不出输出空白或者 f现象printf(“%.2f”, 3.14) 输出空白或者输出一个 f整数打印正常。原因newlib-nano 默认裁剪了浮点转换代码链接器没有把 _printf_float 相关代码拉进来。另一种可能是缓冲区溢出导致格式解析中断。解决在链接器设置里加 -u _printf_float重新编译。如果已经加了还没有效果检查工具链换 LLVM 工具链时链接参数的位置可能不对。再加一个保险手段临时打印一个字符串测试 printf(“float test”) 是否正常排除串口通路问题。把问题拆成“格式问题”和“传输问题”两部分定位会快得多。5.3 串口助手收到乱码部分字符错误现象printf 能输出但内容是乱码常见于波特率不匹配、时钟配置错误。比如所有字符都变成“?”或者明显缺半截。原因我遇到最多的是 FSP 时钟配置和板载晶振不一致。有些开发板外部晶振是 8MHzFSP 默认按 12MHz 计算波特率产生器分频结果就偏了。UART 的波特率误差超过 2% 就会出现乱码。另外 TX/RX 接反也会导致完全收不到但那是无输出而不是乱码。解决先在 e2studio 里检查时钟树确认外部晶振频率和板子一致。再用逻辑分析仪抓 TX 引脚的波形看实际波特率是多少和配置值对照。如果偏差明显优先修时钟配置而不是改串口助手的波特率来迁就错误。5.4 加了 printf 之后中断响应变慢现象主循环里高频使用 printf 后中断响应出现周期性卡顿传感器数据偶尔丢帧。原因fputc 里是忙等发送完成的。每打印一个字符CPU 就阻塞在 while 循环里等待 TX 完成。115200 波特率下一个字符大约 87 微秒一行日志几十个字符就是几毫秒。这段阻塞如果发生在中断即将触发时中断响应就会被拉长。解决不要用 printf 直接高频输出。改成 sprintf 拼帧再通过 uart_send_line 一次发送减少等待次数。更进一步把日志放到较低优先级的任务里不要在定时器中断里做任何打印。串口打印本质上是调试辅助手段优先级永远低于业务逻辑。5.5 两个串口同时用printf 总跑到同一个串口上现象配置了 UART0 和 UART1分别写了不同的发送函数但 printf 打印的内容总是只出现在 UART0 上UART1 怎么调都不输出。原因printf 重定向的入口只有一个就是 fputc 或 _write。它不会自动感知你开了几个串口更不会轮流发。fputc 里写的是 UART0所有 printf 自然都走 UART0。解决在 fputc 里加一个全局的日志输出通道选择变量。通道指向 UART0 时R_SCI_UART_Write 发给 g_uart0_ctrl通道指向 UART1 时换句柄。这样就能用一套 printf 语法把不同模块的日志路由到不同串口。/* 通过全局变量选择输出串口 */ volatile uint8_t log_port 0; int fputc(int ch, FILE *f) { uart_tx_done false; if (log_port 0) { R_SCI_UART_Write(g_uart0_ctrl, (uint8_t *)ch, 1); } else if (log_port 1) { R_SCI_UART_Write(g_uart1_ctrl, (uint8_t *)ch, 1); } while (!uart_tx_done timeout--) { /* 等待完成 */ } return ch; }这个设计的代价是全局变量在多任务环境下的同步问题但日常调试场景完全够用。更讲究的做法是把日志通道封装成独立模块内部用临界区保护选择变量。6. 把打印升级成调试利器日志分级与时间戳printf 跑通只是起点真正让调试效率翻倍的是在它上面加一层日志封装。这层封装做三件事加时间戳、分级别、批量发送。时间戳让日志之间有了先后关系的依据定位问题不再靠猜级别开关可以只开需要的模块不用删代码。一个轻量的做法是用宏包住完整的格式化过程避免主业务代码里到处散落裸 printf。/* debug_log.h - 极简日志封装 */ #define LOG_INFO(...) log_output(I, __VA_ARGS__) #define LOG_WARN(...) log_output(W, __VA_ARGS__) #define LOG_ERROR(...) log_output(E, __VA_ARGS__) void log_output(char level, const char *fmt, ...) { char line[96]; int len 0; int remaining sizeof(line); /* 写时间戳和级别 */ len snprintf(line, remaining, [%c][%04d ms] , level, get_tick_ms()); remaining - len; /* 写用户数据 */ va_list args; va_start(args, fmt); vsnprintf(line len, remaining, fmt, args); va_end(args); /* 补充换行并整帧发送 */ strncat(line, \r\n, sizeof(line) - strlen(line) - 1); uart_send_line(line); }使用时的代码就简洁很多业务逻辑不会被打印代码淹没。get_tick_ms 可以用任意一个毫秒时基比如定时器中断里递增的全局变量。LOG_INFO(adc%d temp%.1f status0x%02X, adc_val, temp, status); LOG_ERROR(timeout on channel %d, ch);这套封装从应用层调用到最终发送经过了 vsnprintf 的长度约束不会越界整帧通过 uart_send_line 发送不会在 fputc 里逐字符忙等级别宏也方便用条件编译整体关闭日志输出。做这个封装之前我曾在一次排查中把日志直接写进了一个高频中断里结果日志本身拉长了中断处理时间制造出了新的故障。后来所有日志都移出中断放到主循环或者低优先级任务里批量处理问题才彻底消失。串口打印是辅助手段别让它成为系统的一部分。希望这些思路能帮你在 e2studio 里少踩几个坑把 printf 和 sprintf 真正变成顺手好用的调试工具。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?