
凌晨两点串口终端上刷出第 800 行日志你明明记得刚才有一行[E]开头的信息可手指滚轮往上翻了三屏眼睛在密密麻麻的白字里来回扫愣是没找到。更糟的是这是个偶发问题重启一次要跑十分钟才可能复现你不得不盯着满屏同色文字一行行看。这种场景我经历过太多次后来我做了一件很小的事在printf的格式化字符串里塞了几个不可见字节让串口输出带上 ANSI color错误变红、警告变黄、调试变灰。就是这几个字节把“在 500 行日志里肉眼捞针”变成了“扫一眼颜色就知道哪行有问题”。这篇讲的就是这件事怎么在串口输出里用printf打出带 ANSI color 的字体让日志按级别上色。它解决的核心问题不是“让输出好看”而是“让异常在视觉上跳出来”。适合所有靠串口看日志的人不管你是写 Cortex-M 的 MCU 固件、调 Linux 板子的驱动还是用串口跟某个模块做协议对话也适合刚上手、还在用一堆printf(1\r\n)打点定位的新人。看完你至少能做到三件事知道颜色码的字节结构、能把颜色封装成顺手可用的日志宏、能在颜色不出效果的时候自己定位到底是终端的问题还是代码的问题。1. 一条日志上色只需要 9 个字节先把成本账算清楚1.1 颜色不是“字体属性”是终端解释的字节流很多人第一次接触 ANSI color 会误以为这是某种终端设置其实完全不是。你在串口上发出去的永远是字节颜色信息本身就是字节。终端做的事只有一件逐字节扫描收到的数据流一旦遇到0x1B也就是 ESC就把后面跟着的一小段当成“控制指令”吃掉不显示成文字而是改变后续字符的显示属性。所以“用 printf 打颜色”这句话的完整含义是让 printf 输出一段包含0x1B的字符串由终端负责解释。这条链路里你的代码只负责发字节颜色是接收端渲染出来的。理解这一点非常关键后面所有“颜色不生效”的排查本质都是在问字节发对了吗终端认吗这里有个容易混淆的点0x1B这个字节在 ASCII 表里叫 ESC但它和键盘上的 Esc 键没有关系。你在代码里写\x1B[31m编译器生成的是1B 5B 33 31 6D五个字节串口上一个不多一个不少地发出去终端收到后把后续文字染成红色直到遇到复位指令。整个过程没有任何“协商”和“握手”纯单向。1.2 在 500 行日志里捞一条错误靠肉眼是在赌运气为什么值得为这点颜色折腾我算过一笔很土的账。一个中等复杂度的固件主循环里正常流程打 3 到 5 行日志状态机切换再打几行加上中断里的关键打点稳定的输出速度在每秒二三十行。跑十分钟就是一万多行。在这堆东西里找一个偶发的错误纯靠关键词搜索也不行因为很多错误行本身没有 “error” 字样只是某个数值不对。给日志分级上色之后视觉搜索变成了颜色过滤。红色只有错误才有黄色只有警告才有一眼扫过去屏幕上出现一个红色像素块眼睛会本能地被拽过去。这不是玄学是人眼对高饱和色块的捕捉优先级远高于灰度文字。我自己实测同样一个问题加颜色之前平均要往上翻五六屏加之后基本三屏内能定位遇到需要连续盯十几分钟的场景效率差得更明显。还有一层隐性收益颜色强迫你把日志分级这件事想清楚。以前很多人是随手printf想到什么打什么一旦要决定这行是红是黄还是灰你就不得不先问自己“这条信息的严重程度是什么”。这个动作本身就会让日志的组织变好。1.3 带宽、时延与 CPU 占用的量化当然颜色不是免费的。一条彩色日志最省的写法是开头 5 字节ESC [ 3 1 m加结尾 4 字节ESC [ 0 m一共多 9 个字节。如果加上加粗就是ESC [ 1 ; 3 1 m七个字节加起来 11 字节。看着不多但要放在串口带宽里算。常见的 115200 波特率8 数据位无校验 1 停止位实际有效吞吐是 115200 / 10 11520 字节每秒。一条 60 字节的日志行光传输就要 5.2 毫秒。也就是说这个波特率下日志的绝对上限大约在每秒 190 行左右而且这还是串口被日志独占的情况。波特率有效字节率60 字节行耗时理论上限行数/秒11520011520 B/s5.2 ms约 19246080046080 B/s1.3 ms约 76892160092160 B/s0.65 ms约 1536看清楚这张表你就明白为什么“日志打太多把系统拖慢”不是玄学。如果日志走的是阻塞发送一个字节一个字节等发送寄存器空那么每打一条 60 字节的彩色日志CPU 就在那儿空转 5 毫秒。主循环里有几个毫秒级的软定时任务就会被这 5 毫秒直接顶偏。加上 9 个字节的颜色开销等于又多了 0.8 毫秒。所以我的结论很明确颜色该加但日志量必须控制。具体做法是把调试级别的日志做成编译期可裁剪生产版本只留错误和警告同时把日志发送从阻塞改成“写环形缓冲区 中断或 DMA 搬运”让 printf 只负责把字节扔进缓冲区就返回。这两件事做完9 个字节的颜色开销完全可以忽略。2. ANSI SGR 序列的字节级拆解2.1 从 0x1B 到 m一条序列的四个部分一条完整的颜色指令结构是这样的0x1B [ 参数 m ESC CSI SGR0x1B是引导符[是 CSIControl Sequence Introducer的固定第二个字节中间是可以有多个、用分号隔开的十进制参数最后用字母m收尾。这个m代表 SGRSelect Graphic Rendition选择图形再现专门管显示属性。除了mCSI 后面还能跟别的终结符做别的事比如K清除行、H定位光标、J清屏这些后面第 6 节会用到。在 C 语言里写这个字符最稳的写法是\x1B[。注意不要用\e那是编译器扩展标准 C 里没有这个转义\033也行八进制写法标准支持但和后面的数字连在一起时容易产生歧义比如\033[1m没问题可\0331就会被解析成八进制 0331。所以我一律用\x1B边界清晰。多个参数可以合并成一条用分号分隔比如\x1B[1;31m表示“加粗 红色”。合并的好处是省字节坏处是可读性差一点。合并的规则是参数按顺序生效后面的覆盖前面的同类属性。2.2 常用参数速查表真正高频用到的就那么十来个我把它们整理成表直接照着抄就行。参数含义典型用途0复位所有属性每条彩色日志结尾必须加1加粗/高亮让错误行更醒目2变暗调试级别日志往后退4下划线强调某个字段7反显前景背景互换强提示慎用30–37前景色黑红绿黄蓝紫青白分级上色的主力39恢复默认前景色局部着色时收尾用40–47背景色与前景一一对应整行高亮49恢复默认背景色同上90–97高亮前景色亮灰、亮红…灰色调试日志用 90有一个细节很多人踩过30–37 里的“黄”在浅色背景的终端上非常难看清尤其是 33 号黄在白底终端里几乎糊成一片。稳妥的做法是警告用1;33加粗黄或者干脆用1;35亮紫对比度好得多。如果你的终端是浅色主题红色用31也会偏暗建议统一上1;31。另外一个实际经验灰色调试日志不要用 37 号白。37 在很多终端里就是普通前景色和默认文字没区别起不到“往后退”的效果。真正让日志显得次要的是 90亮黑也就是灰。2.3 256 色与真彩色值不值得SGR 还支持扩展色。两种写法/* 256 色38;5;n 前景48;5;n 背景n 取 0-255 */ #define CLR_FG_ORANGE_256 \x1B[38;5;208m /* 真彩色38;2;r;g;b */ #define CLR_FG_ORANGE_RGB \x1B[38;2;255;140;0m字节数上256 色序列是 10 个字节左右真彩色序列要 19 个字节比最基本的 5 字节翻了好几倍。值不值得看场景。如果你的日志要传到一个能做主题配色的终端里并且你希望不同模块用不同颜色比如通信模块青、传感器模块紫、控制模块黄256 色确实能让区分度上一个台阶。但如果只是为了区分“错误、警告、信息、调试”这四个级别基础 16 色完全够没必要为了一点色相差异多花一倍带宽。真彩色我更建议保守使用。它在相当一部分老终端上不支持收到之后会把整条序列当普通字符显示出来屏幕上直接出现[38;2;255;140;0m这样的字面量比没颜色还难受。要用的话先在你的目标终端上单独测一条确认能渲染再铺开。3. 落到 printf 上宏封装、函数封装与重定向链路3.1 编译期拼接的宏方案最简单的做法用宏把颜色码和格式化字符串在编译期拼起来。这种方式的好处是零运行时开销编译器会把整个字符串常量合并进只读段调用时只是一个普通的printf。/* color.h */ #ifndef COLOR_H #define COLOR_H #define CSI \x1B[ #define CLR_RESET CSI 0m #define CLR_BOLD CSI 1m #define CLR_DIM CSI 2m #define CLR_FG_RED CSI 31m #define CLR_FG_GREEN CSI 32m #define CLR_FG_YELLOW CSI 33m #define CLR_FG_GRAY CSI 90m #endif/* log.h */ #ifndef LOG_H #define LOG_H #include stdio.h #include color.h #define LOG_E(fmt, ...) printf(CSI 1;31m[E] fmt CLR_RESET \r\n, ##__VA_ARGS__) #define LOG_W(fmt, ...) printf(CSI 1;33m[W] fmt CLR_RESET \r\n, ##__VA_ARGS__) #define LOG_I(fmt, ...) printf(CSI 32m[I] fmt CLR_RESET \r\n, ##__VA_ARGS__) #define LOG_D(fmt, ...) printf(CSI 90m[D] fmt CLR_RESET \r\n, ##__VA_ARGS__) #endif用起来就是LOG_E(uart init failed, code%d, ret);输出整行红色。注意复位码放在\r\n之前不是之后原因我在 5.1 会讲这是个真实会踩的坑。这里##__VA_ARGS__是 GCC 和 Clang 的扩展作用是当可变参数为空时把前面的逗号吃掉这样LOG_I(hello)也能编过。标准 C23 里有了__VA_OPT__但很多嵌入式工具链还没跟上所以现阶段用##最省事。如果你用的是别的编译器链注意确认一下这个扩展是否支持。还有一个变体只给标签上色正文保持默认色。做法是把CLR_RESET挪到标签后面#define LOG_E(fmt, ...) printf(CSI 1;31m[E] CLR_RESET fmt \r\n, ##__VA_ARGS__)整行上色和只染标签视觉上完全是两种风格。整行上色冲击力强适合错误少、问题需要立刻吸引注意的场合只染标签则安静得多适合日志量大、希望整体版面干净的场景。我自己的项目里错误和警告走整行信息和调试只染标签平衡得比较好。3.2 运行时可控的函数封装宏方案有个绕不过去的缺陷颜色状态是编译期写死的。你想在运行中关掉颜色比如把日志导出来给同事看就只能重新编译一版。这在调试现场很麻烦。解决办法是换成函数封装把颜色码的选择挪到运行时。代价是格式化字符串不再能编译期拼接需要先vsnprintf到缓冲区再补颜色码。typedef enum { LOG_LVL_ERROR 0, LOG_LVL_WARN, LOG_LVL_INFO, LOG_LVL_DEBUG, LOG_LVL_MAX } log_level_t; static const char *const k_color[LOG_LVL_MAX] { \x1B[1;31m, \x1B[1;33m, \x1B[32m, \x1B[90m }; static const char *const k_tag[LOG_LVL_MAX] { E, W, I, D }; volatile uint8_t g_log_color_en 1; /* 运行时可改 */ uint8_t g_log_min_level LOG_LVL_DEBUG; void log_emit(log_level_t lvl, const char *fmt, ...) { if (lvl g_log_min_level) return; char body[128]; va_list ap; va_start(ap, fmt); vsnprintf(body, sizeof(body), fmt, ap); va_end(ap); /* 关键整行拼好再一次性发出避免转义序列被别的输出插进来打断 */ char line[160]; int n; if (g_log_color_en) { n snprintf(line, sizeof(line), %s[%s] %s\x1B[0m\r\n, k_color[lvl], k_tag[lvl], body); } else { n snprintf(line, sizeof(line), [%s] %s\r\n, k_tag[lvl], body); } if (n 0) { int len (n (int)sizeof(line)) ? n : (int)sizeof(line) - 1; uart_write_blocking((const uint8_t *)line, (uint32_t)len); } }这个版本多花了一次内存拷贝和一次vsnprintf的时间开销在 100MHz 级别的 MCU 上大约是几十微秒对于日志这种低频操作完全可以接受。换来的是能运行时开关、能运行时改级别阈值、能保证整行原子发送。需要提醒的是vsnprintf的浮点支持。很多工具链为了省空间默认关掉了 printf 的%f这时候vsnprintf遇到%f会输出一个错误占位符或者干脆什么都不输出。解决办法是在链接选项里显式打开浮点格式化支持或者干脆在日志里不打印浮点先自己转成整数比如把电压转成毫伏打印。3.3 printf 到串口中间那段路fputc、_write 与缓冲区不管用宏还是函数最后都要落到一件事printf的输出怎么到达串口。这一步在 C 库里叫重定向retarget。对基于 newlib 的工具链最省事的做法是重写_write#include errno.h #include sys/stat.h int _write(int fd, const char *buf, int len) { (void)fd; for (int i 0; i len; i) { uart_send_byte((uint8_t)buf[i]); } return len; }有些工具链走的是fputc写法类似int fputc(int ch, FILE *f) { (void)f; uart_send_byte((uint8_t)ch); return ch; }这两个写法看起来都能用但有几个细节必须注意。第一个是缓冲模式。printf不保证每次都立刻往下写C 库有自己的输出缓冲。默认行为取决于实现很多时候 stdout 被当成全缓冲于是你调了printf却看不到输出直到缓冲区满或者程序退出。在嵌入式上最保险的做法是在初始化里显式关掉缓冲setvbuf(stdout, NULL, _IONBF, 0);或者保留缓冲但在每条日志末尾fflush(stdout)。我倾向于直接关缓冲因为日志本来就是逐条实时性的东西留着缓冲只会带来“时有时无”的错觉。第二个是重定向函数里的阻塞时间。上面写的uart_send_byte是忙等发送寄存器空一条 60 字节的日志就是几十微秒到几毫秒的阻塞。如果你在中断里也打日志这个阻塞会直接把中断响应拉长引发时序问题。这时候要么改成写环形缓冲要么就不要在中断里打日志。第三个是符号裁剪。newlib 里的_write、_sbrk、_close这些底层桩函数如果链接时开了--gc-sections或者用了精简库有可能被判定为未使用而裁掉结果就是编译能过一运行就进 HardFault。表现是“printf 调用后程序卡死”排查时优先怀疑这里。稳妥的做法是在链接脚本里显式保留这几个符号或者干脆把_write放在一个不会被裁剪的段里。3.4 一份可以直接抄的最小完整实现把上面的东西拼起来一个能跑的最小版本是这样的这里用伪 API换成你自己的串口发送函数即可#include stdarg.h #include stdio.h #include stdint.h /* ---- 底层换成你自己芯片的发送函数 ---- */ extern void uart_send_byte(uint8_t b); extern void uart_write_blocking(const uint8_t *buf, uint32_t len); int _write(int fd, const char *buf, int len) { (void)fd; uart_write_blocking((const uint8_t *)buf, (uint32_t)len); return len; } /* ---- 日志模块 ---- */ #define CSI \x1B[ static const char *const k_tag[4] { E, W, I, D }; static const char *const k_color[4] { CSI 1;31m, CSI 1;33m, CSI 32m, CSI 90m }; static uint8_t s_color_en 1; static uint8_t s_min_level 3; void log_init(int color_en) { s_color_en color_en ? 1 : 0; setvbuf(stdout, NULL, _IONBF, 0); } void log_emit(int lvl, const char *fmt, ...) { if (lvl s_min_level) return; char body[128]; va_list ap; va_start(ap, fmt); vsnprintf(body, sizeof(body) - 16, fmt, ap); /* 预留复位码和换行的空间 */ va_end(ap); if (s_color_en) { printf(%s[%s] %s CSI 0m\r\n, k_color[lvl], k_tag[lvl], body); } else { printf([%s] %s\r\n, k_tag[lvl], body); } } #define LOG_E(fmt, ...) log_emit(0, fmt, ##__VA_ARGS__) #define LOG_W(fmt, ...) log_emit(1, fmt, ##__VA_ARGS__) #define LOG_I(fmt, ...) log_emit(2, fmt, ##__VA_ARGS__) #define LOG_D(fmt, ...) log_emit(3, fmt, ##__VA_ARGS__)注意vsnprintf那里我特意写了sizeof(body) - 16这是给后面的颜色复位码和换行预留空间。如果你不预留body刚好塞满时后面的复位码就会被截断结果就是这条日志的颜色一直串到下一行。这个坑我在 5.1 会展开讲。4. 颜色开关怎么设计编译期、运行时与主机侧清洗4.1 编译期开关最省事也最容易忘最朴素的做法是加一个宏配合条件编译#ifndef LOG_COLOR_EN #define LOG_COLOR_EN 1 #endif #if LOG_COLOR_EN #define LOG_TAG_E CSI 1;31m[E] CLR_RESET #else #define LOG_TAG_E [E] #endif好处是零运行时判断颜色关闭版本的固件里连颜色字符串常量都不会被编进去省 Flash。对于一个日志密集的工程这些字符串常量加起来可能有一两百字节在资源紧张的芯片上不算浪费。坏处是容易忘。我见过不止一次调试版本里颜色开得好好的出正式版本时忘了关结果客户拿到的设备通过串口输出一堆[1;31m字面量看着像固件出错了。更隐蔽的情况是日志被上位机软件记录下来导入到表格工具里每个单元格开头都带着一段乱码后面做数据处理的人一脸懵。所以我后来改了个习惯把颜色开关的默认值设成“关”调试时显式打开。不容易出事故。如果工具链支持用工程配置里的预定义宏来控制而不是改源码里的#define这样至少在版本管理上能看出来哪次提交动了这个开关。4.2 运行时开关一条串口命令搞定比编译期更灵活的是运行时开关。前面 3.2 里的g_log_color_en就是干这个的。剩下的事是给它一个入口让用户能在串口上敲命令切换。如果你有一个简易的命令行交互哪怕是轮询读串口字节、遇回车执行加几行就够了void cmd_handler(const char *cmd) { if (strcmp(cmd, color on) 0) { g_log_color_en 1; LOG_I(color enabled); } else if (strcmp(cmd, color off) 0) { g_log_color_en 0; LOG_I(color disabled); } else if (strcmp(cmd, lvl 1) 0) { g_log_min_level 1; /* 只留错误和警告 */ LOG_I(log level warn); } else { LOG_W(unknown cmd: %s, cmd); } }注意一个细节切到color off之后紧接着那条“color disabled”的日志已经是无色的了因为它是在标志位改完之后打印的。这个行为是对的但有时候会让人以为没生效。我习惯在切换时先打一行带颜色的“switch to no-color mode”再改标志位视觉上有明确的过渡。运行时开关还有个隐藏价值省带宽。在现场排查时如果你发现串口已经跟不上日志量了直接把级别调到只看错误或者把颜色关掉省那 9 个字节都是立刻生效的手段不用重新烧固件。这在客户现场特别顶用。4.3 主机侧清洗把日志文件里的颜色码剥干净即使你做了所有开关还是会有带颜色的日志被存进文件的情况串口工具自动记录、脚本重定向、tee分流。这些文件用文本编辑器打开满屏^[[31m读起来很痛苦。好消息是清洗非常简单一条命令的事# 把常见 CSI 序列颜色和清行全部删掉 sed -r s/\x1B\[[0-9;]*[mK]//g raw.log clean.log\x1B是 ESC\[是字面的左方括号[0-9;]*匹配参数[mK]匹配我们常见的那几个终结符。如果你的日志里还用了光标定位H或者清屏J把字符类扩展成[mKHJ]就行。命令行里直接看串口输出时也可以用cat -v把不可见字符显示成^[形式cat -v /dev/ttyUSB0这样你能直接确认到底发出去了什么。^[[31m说明字节是完整的ESC 正常到达如果只看到[31m而没有^[那就说明 ESC 字节在传输中丢了问题在发送侧或者波特率不匹配跟终端无关。这个判断方法我在 5.2 会再提一次因为它是最快的分流手段。如果你需要保留颜色又想在网页或文档里看可以找一个把 ANSI 转 HTML 的小工具做一次性转换不过这类工具对光标控制序列的支持参差不齐真要用建议只用最基础的颜色码。4.4 自动探测这条路为什么不好走有人会想能不能让设备自动判断终端支不支持颜色支持就开不支持就关技术上是有办法的标准终端里有一套“设备属性查询”的机制终端会回应一段特定的应答序列。但在串口这种环境里我不建议这么做。第一个原因是需要双向交互。设备发查询等终端回应这中间要处理超时、要处理终端根本不回的情况。如果你的串口上还跑着别的协议插一段查询进去很容易打乱节奏。第二个原因是很多串口终端根本不回应。它们能渲染颜色但不实现查询应答。结果就是你探测失败判定为“不支持”白白关掉了本来能用的功能。第三个原因是启动时序。设备上电时终端可能还没连上这时候探测必然失败。等你连上了设备早就认为“不支持”并关掉了颜色。所以我的做法是老老实实用显式开关默认开或者默认关看你的固件面向谁需要的时候一条命令切换。比自动探测可靠得多实现成本也低。5. 实测踩坑串色、断帧与终端不认码的排查链路5.1 症状一颜色串到后面几十行还不复位这是最常见的现象。屏幕上从某一行开始全变红后面几十行全红直到某一次输出碰巧带了复位码才恢复正常。第一反应通常是“复位码没写对”但实际排查下来原因通常有三种。第一种复位码位置错了。如果你写成printf(CSI 31m hello \r\n CLR_RESET)复位码在换行之后那么换行符本身是在红色状态下输出的。有些终端在渲染带背景色的行时会把这个状态延续到行尾视觉上就是整行都带底色。正确写法是复位码在换行之前printf(CSI 31m hello CLR_RESET \r\n)。第二种缓冲区截断把复位码吃掉了。snprintf写满缓冲区时会截断如果先算好正文长度刚好占满后面的复位码就没地方放了。表现就是正文正常显示颜色一直不恢复。这个问题很隐蔽因为日志内容看起来是对的。解决办法就是像我 3.4 那样在vsnprintf时给缓冲区留出余量或者用返回值判断是否发生截断截断了就单独补发一次复位码。第三种复位码发送时被丢弃了。如果串口发送用了非阻塞的方式缓冲区满时丢字节那么丢掉的很可能是最后几个字节也就是复位码。这种情况往往是偶发的日志量大时才出现非常难查。验证方法是把日志量降下来看复发现象是否消失或者临时改成阻塞发送对比。排查顺序我一般是先看代码里复位码的位置再看缓冲区大小够不够最后才怀疑丢字节。5.2 症状二日志里出现字面量 [1;31m屏幕上直接显示[1;31m这样的字符说明终端没有把它当控制序列处理。原因分两类字节没发对或者终端不认。先做字节层面的确认。用cat -v看原始输出如果显示的是^[[1;31m说明 ESC 字节到达了字节是对的问题在终端。如果只显示[1;31m而没有^[说明 ESC 丢了问题在发送侧。ESC 丢失的常见原因波特率不匹配接收端采样错位恰好把0x1B采成了别的值或者丢掉。这种情况通常伴随着其他字符也偶发错乱很好判断。串口接收溢出。日志刷得太快终端的接收缓冲满了丢字节。表现是丢的位置不固定且日志量大时更严重。中间经过了某种转换。比如某些 USB 转串口模块在特定配置下会过滤控制字符虽然少见但确实遇到过。终端不认的情况常见于一些老版本的串口监视工具或者某些 IDE 自带的简易终端。它们的文本框是纯文本控件压根不解析控制序列收到的0x1B就直接当不可见字符或者方框显示。这种没办法通过改代码解决只能换终端。判断终端认不认有个非常快的办法往串口发一条\x1B[2J\x1B[H如果屏幕被清空并把光标移到左上角说明支持如果屏幕没反应或者出现一堆乱码就是不支持。现象字节层验证结论处理显示^[[31mESC 存在终端不解析换终端或关颜色显示[31mESC 缺失发送/传输丢字节查波特率、降日志量、改阻塞发送显示方框或空白ESC 存在但被过滤终端控件不支持换终端颜色生效但不复位复位码缺失截断或位置错误留缓冲余量、调整位置5.3 症状三转义序列被撕成两半这个坑只在多任务或者中断里打日志时出现。现象很怪大部分日志正常偶尔某行的开头变成[1;3加上一段乱七八糟的字符然后颜色也不对。原因很直接转义序列是一串字节终端必须连续收到才能正确解析。如果你的日志发送是可被抢占的比如主循环正在一个字节一个字节地发ESC [ 1 ; 3 1 m发到一半来了个中断中断里也调了printf中断的字节插进了序列中间序列就被撕碎了。终端解析失败后面的解析状态也会错乱。解决办法有三个层次最省事让每一条完整日志一次性写入发送缓冲区而不是让 C 库一个字符一个字符地去写。前面 3.2 里“先snprintf到line再uart_write_blocking(line, len)”就是这个思路。但要注意如果uart_write_blocking本身是可抢占的比如它是轮询发送中断里能插进来问题依然存在。更稳发送前关中断发送完开中断。缺点是关中断时间等于一条日志的传输时间在 115200 下 60 字节就是 5 毫秒对实时性影响太大不推荐用于长日志。最推荐所有日志统一走一个环形缓冲区由一个低优先级任务或者发送完成中断搬运。printf只负责把整行数据塞进队列塞的过程加一个很短的临界区只保护 memcpy微秒级。这样既保证了原子性又把长时间阻塞挪到了后台。还有一种情况也会造成类似症状日志和二进制协议共用一个串口。二进制的数据帧里如果恰好有0x1B终端会把它当控制序列的开头然后一路吞字节直到遇到一个合法的终结符。表现就是日志中间莫名其妙少了一段。这种情况要么把日志和协议分流到两个串口要么在协议层做字节转义。5.4 症状四printf 只输出半行就没下文了这条最让人抓狂因为代码看起来完全正确日志也能出来就是不全。我遇到过三种原因。缓冲区没刷新。前面提过stdout 默认可能是全缓冲的一条 40 字节的日志进了缓冲区没到刷新阈值就一直在里面待着。下一个printf又追加进去直到缓冲区满才一次性吐出。表现就是日志成批出现中间有断档。解决就是setvbuf关缓冲或者每条日志后fflush。格式化参数类型不匹配。比如用%d打一个uint64_t在某些实现上会读错位导致后续输出全乱。这种问题在 32 位平台上尤其常见因为%d期待 4 字节而你给了 8 字节。用%lu、%llu或者强制转换别偷懒。字符串指针是野的。printf(%s, ptr)里ptr为 NULL 或者指向已释放的内存轻则输出(null)重则直接 HardFault。日志代码里加一层保护是个好习惯printf(%s, ptr ? ptr : (null))。这句看着啰嗦但能在关键时刻救命因为日志代码本身崩掉是最难查的你连错误信息都看不到。还有一类是栈溢出。printf系列函数在栈上开销不小尤其是带浮点格式化的时候可能要几百字节。如果你的日志函数在中断里调用而中断栈只配了 512 字节很容易踩穿。表现是偶发的、和日志内容相关的崩溃。遇到这种情况先把中断里的日志全删掉看问题是否消失这是最快的验证。5.5 把排查路径固化下来我把上面这些整理成一个固定的排查顺序遇到问题按这个走比乱猜快得多用cat -v看原始字节确认 ESC 有没有到。ESC 到了但显示字面量换一个终端再试区分是终端问题还是代码问题。ESC 没到查波特率、查日志量、查是不是丢字节。颜色生效但不复位查复位码位置和缓冲区截断。偶发的序列错乱查是不是多上下文并发写同一个串口。这套流程走下来绝大多数问题在第一步和第二步就能定位。6. 把光标控制也用起来进度条、状态行与局部刷新6.1 \r 加 \x1B[K最实用的单行进度条串口日志最烦人的一件事是刷屏。一个循环里每轮都打一行进度几千轮下来把有用的信息全冲走了。这时候用回车符\r回到行首重写就能把几十行压缩成一行。void show_progress(int percent) { const int width 30; char bar[31]; int fill percent * width / 100; for (int i 0; i width; i) { bar[i] (i fill) ? # : -; } bar[width] \0; /* \r 回行首CSI K 清掉本行剩余内容防止上一次的长内容残留 */ printf(\r CSI 36m[ %s ] CLR_RESET %3d%% CSI K, bar, percent); if (percent 100) { printf(\r\n); } }这里有两个细节值得说。第一必须带\x1B[K。如果只靠\r回行首新内容比旧内容短的时候旧内容的尾巴会留在屏幕上。比如上一次是100%这一次是9%你就会看到9%0%这种鬼东西。第二不要用\b退格。很多人第一反应是用退格符往回退但只要输出的字符数算错一个整个行就会错位而且错位会累积。\r是绝对回到行首不存在累积误差。这个技巧在固件升级、批量擦写、传感器校准这类耗时的流程里特别有用。我一般会在进度条上再叠一层状态文字比如\r[####----] 45% | writing block 23一行里同时看到进度和当前在干什么。6.2 保存/恢复光标钉在屏幕上的状态行比进度条更进阶一点的是固定状态行。比如你希望屏幕最上面一行永远显示当前电压、温度、运行时间下面的日志正常滚动。这需要保存光标位置、跳到指定行、写完再跳回来。/* 保存光标 - 跳到第 1 行第 1 列 - 写状态 - 清行 - 恢复光标 */ printf(CSI s CSI 1;1H CSI 7m V%.2fV T%.1fC UP%lus CLR_RESET CSI K CSI u, voltage, temp, uptime_s);\x1B[s保存光标\x1B[u恢复\x1B[1;1H定位到第 1 行第 1 列。中间的\x1B[7m是反显让状态行看起来像一条高亮条。要注意的是这个方案只在日志从第 2 行往下滚的时候才成立。如果你的日志本身会滚到第 1 行那状态行就会被覆盖。所以实际用的时候通常要在开头先打一行占位把第 1 行占住或者干脆每次刷新状态行之后立刻恢复光标让日志继续在下面写。还有一个坑某些终端在收到\x1B[s的时候并不保存光标而是把它当成别的东西。这种情况下状态行会跑到奇怪的位置。兼容性更好的写法是用\x1B7和\x1B8DEC 私有序列它们做的事情一样支持范围更广。我一般两个都试一遍看哪个在自己的目标终端上稳。6.3 整屏刷新与分屏把串口终端当简易仪表盘再往上就是整屏刷新的仪表盘了。核心是三个序列\x1B[2J清整屏\x1B[H光标归位到左上角\x1B[?25l隐藏光标\x1B[?25h显示光标组合起来可以做一个很原始但很实用的监控界面void dashboard_refresh(void) { printf(CSI H); /* 归位不闪屏 */ printf(CSI