
在嵌入式开发里串口打印调试信息几乎是每个工程师的日常动作。用printf往串口丢英文和数字大家都熟得不能再熟可一旦要打印中文问题就来了——要么屏幕上蹦出一堆乱码要么干脆什么都不显示要么编译直接报错说空间不够。我见过不少刚接触单片机的朋友卡在为什么英文能打、中文就乱码这个问题上折腾好几天。这篇内容就围绕 Keil 环境下单片机串口通信中如何用printf正确发送中文这件事把原理、配置、代码和踩坑经验一次讲透。不管你是用 51 单片机还是 STM32不管你是刚上手的新人还是想回头补基础的老手只要涉及串口打印中文这里面的思路和操作都能直接拿去用。1. 先搞清楚 printf 在单片机里到底走了哪条路很多人写单片机程序时习惯性地#include stdio.h然后直接调printf在电脑上这么写没问题但在 Keil 的 ARM 或 C51 编译器里printf默认根本不知道往哪儿输出。它需要一个出口这个出口就是底层字符发送函数。理解这一点是解决所有中文乱码问题的起点。1.1 标准库的 printf 依赖一个叫 fputc 的底层函数在 Keil MDKARM 平台里C 标准库的printf最终会调用一个名为fputc的函数把每一个字符逐个送出去。这个函数在标准库里是弱定义weak的意思是你可以自己写一个同名函数把它覆盖掉让字符流向你想要的地方——比如串口。如果你不覆盖它标准库会用一个默认实现那个实现通常什么都不做或者指向一个半主机semihosting模式结果就是你在串口助手里啥也看不到。在 Keil C5151 单片机平台里机制略有不同printf依赖的是putchar函数。这个差异很关键因为很多人从 STM32 转到 51或者反过来照着网上的代码抄结果发现不生效就是因为底层函数名搞错了。所以第一步要明确你用的是哪个平台对应的底层重定向函数是哪个。ARM 用fputcC51 用putchar。这是两条不同的路不能混。1.2 重定向的本质是把字符一个一个塞进串口寄存器重定向fputc的代码通常长这样int fputc(int ch, FILE *f) { while (!(USART1-SR USART_SR_TXE)); USART1-DR (ch 0xFF); return ch; }这段代码的逻辑很朴素等发送寄存器空然后把字符写进去。每调用一次fputc就发一个字节。printf(hello)会触发 5 次fputc调用每次发一个字母。这里有个容易被忽略的点printf传下来的ch是int类型但串口一次只能发 8 位。所以要做ch 0xFF截断只取低 8 位。对于 ASCII 字符来说高 24 位本来就是 0截不截断无所谓但对于中文情况就复杂了后面会详细讲。1.3 为什么英文能打中文就乱码根子在编码英文能正常显示是因为 ASCII 编码里一个字母就是一个字节范围 0 到 127一个字节搞定。而中文在计算机里从来不是一个字节能表示的。常见的编码方式有 GBK 和 UTF-8 两种GBK一个汉字占 2 个字节比如中的 GBK 编码是0xD6 0xD0。UTF-8一个汉字通常占 3 个字节比如中的 UTF-8 编码是0xE4 0xB8 0xAD。问题就出在这里。你的代码文件用什么编码保存编译器就按什么编码把中文字符串编进程序而你的串口助手用什么编码解析就决定了屏幕上显示什么。如果两边不一致乱码就出现了。这跟printf本身没关系printf只是忠实地把字节一个个发出去它不负责编码转换。我见过最常见的场景是Keil 编辑器默认用 GBK 保存文件串口助手默认也是 GBK理论上应该能对上但偏偏有人从网页复制代码网页是 UTF-8粘进 Keil 后编码就混了结果发出来一堆问号。所以编码这条线必须从头到尾捋清楚。2. 中文乱码的三种典型表现与对应根因乱码不是一种病是好几种病长得很像。搞清楚你遇到的是哪一种才能对症下药。下面这三种情况基本覆盖了 90% 以上的中文打印问题。2.1 显示一堆问号或者方块这是最典型的一种。屏幕上出现???或者一排小方块说明串口助手收到了字节但它不认识这些字节对应的字符。原因通常是编码不匹配单片机发的是 GBK 字节串口助手按 UTF-8 解析或者反过来。还有一种可能是串口助手的字体不支持中文。有些轻量级串口工具默认用的等宽字体里根本没有汉字字形收到中文编码也只能显示成方块。这种情况换个字体或者换个助手就好了跟代码无关。排查方法很简单把串口助手切到十六进制HEX显示模式看看收到的字节是什么。如果发中字GBK 应该是D6 D0UTF-8 应该是E4 B8 AD。看到实际字节就能反推编码对不对。2.2 只显示第一个字节或者半个字这种情况比较隐蔽。屏幕上可能显示一个奇怪的符号或者中文只出来一半。根因往往在fputc的截断处理上。前面说过ch 0xFF只取低 8 位对于单字节字符没问题但如果某个环节把多字节的中文当成了单字节处理就会丢字节。另一个常见原因是发送缓冲区太小或者发送函数没等上一个字节发完就写下一个导致字节丢失。串口发送必须严格按顺序、一个接一个中间不能跳。如果while等待那一步写错了比如判断条件写反了就会出现丢字节。2.3 编译报错说代码空间不够这个跟乱码看起来不搭边但实际很常见。中文字符串占的空间比英文大得多。一个 10 个汉字的提示语GBK 要 20 字节UTF-8 要 30 字节。如果你用的是资源紧张的 51 单片机比如只有 4KB Flash 的型号多加几段中文提示代码空间一下就满了编译器直接报错。解决办法有两个一是精简中文字符串能少写就少写二是把中文字符串放到代码段code而不是数据段data在 C51 里用code关键字修饰。这个细节后面会展开。乱码表现最可能根因快速验证方法问号、方块编码不匹配或字体不支持切 HEX 模式看字节半个字、丢字节发送函数截断或等待逻辑错误检查 fputc 实现编译空间不足中文字符串占用过大查看编译输出的 ROM 占用3. Keil 工程里必须做对的几项配置代码写对了配置没对照样乱码。Keil 这个工具链有几个地方跟中文编码直接相关必须逐个确认。3.1 源文件编码设置Edit 菜单里的 ConfigurationKeil uVision5 里源文件的编码是在Edit - Configuration - Editor标签页里设置的。这里有个Encoding选项可以选Chinese GB2312或者UTF-8。你选什么Keil 就按什么编码保存和解析源文件。我的建议是统一用 GB2312GBK。原因很实际——大部分串口助手默认就是 GBK而且 GBK 里一个汉字只占 2 字节比 UTF-8 省空间对单片机这种资源紧张的环境更友好。如果你团队里有人用 UTF-8那就要么统一要么在代码里做转换否则一定出乱子。设置完之后最好把已有的源文件重新保存一遍确保编码真正生效。有时候改了设置但文件没重存编码还是旧的。3.2 编译器对中文字符串的处理别让它优化掉Keil 的编译器有时候会把没被引用的字符串优化掉或者对字符串做合并。对于中文提示语如果发现打印不出来可以检查一下编译器的优化等级。在Options for Target - C/C里优化等级设得太高比如-O3可能会影响字符串的处理。调试阶段建议先用-O0或-O1确认功能正常后再往上调。另外ARM 编译器有个--locale相关的选项虽然平时不用动但如果遇到中文处理异常可以查一下工程设置里有没有跟 locale 相关的配置被改过。3.3 串口助手的编码和字体要跟代码对齐这一条虽然不属于 Keil 工程配置但它是整个链路的一环必须一起考虑。串口助手这边要做两件事第一确认接收编码。大部分助手有 GBK/UTF-8 的切换选项把它设成跟你源文件一致的编码。第二确认字体。选一个支持中文的字体比如宋体、微软雅黑。有些助手默认的 Consolas 字体不含汉字收到中文也是白搭。我个人的习惯是代码用 GBK串口助手也用 GBK字体用宋体这套组合最稳几乎不会出问题。提示如果你在调试时改了编码设置记得把单片机重新上电或者复位一次有时候旧的字符串还缓存在发送缓冲里会导致你看到的还是旧结果。4. 完整可用的代码实现与逐行拆解理论讲完了上代码。下面分 ARMSTM32和 C5151 单片机两个平台给出完整实现都是可以直接编译运行的。4.1 STM32 平台重定向 fputc 到 USART1先看 ARM 平台的完整代码。假设你用 USART1波特率 115200。#include stm32f10x.h #include stdio.h void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // TX: PA9 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // RX: PA10 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate 115200; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE); } int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; } int main(void) { USART1_Init(); printf(系统启动成功\n); printf(当前温度%d 摄氏度\n, 25); while (1); }这段代码的关键在fputc。它等 TXE 标志位置位说明发送数据寄存器空了然后把字符写进 DR 寄存器。printf会自动调用它把每个字节送出去。注意printf里的\n。在串口助手里\n是换行符但有些助手需要\r\n才能正确换行。如果发现换行不正常把\n改成\r\n试试。4.2 C51 平台重定向 putchar 到 SBUF51 单片机的代码结构不同底层函数是putchar。#include reg52.h #include stdio.h void UART_Init(void) { SCON 0x50; // 模式18位UART允许接收 TMOD 0x0F; TMOD | 0x20; // 定时器1模式28位自动重装 TH1 0xFD; // 波特率960011.0592MHz晶振 TL1 0xFD; TR1 1; // 启动定时器1 ES 1; // 开串口中断 EA 1; // 开总中断 } void UART_SendByte(unsigned char dat) { SBUF dat; while (!TI); TI 0; } char putchar(char ch) { UART_SendByte(ch); return ch; } void main(void) { UART_Init(); printf(单片机启动\n); printf(数值%d\n, 100); while (1); }51 这边有个坑putchar的返回类型和参数类型不同版本的 Keil C51 库可能有细微差异。如果编译报错说类型不匹配把char改成int试试。另外51 的printf默认不支持浮点数如果要打印%f需要在工程设置里开启浮点支持但那会显著增加代码体积51 上一般不建议用。4.3 中文打印的实测效果与验证方法代码烧进去之后怎么确认中文真的对了我的做法是分三步验证第一步先打印纯英文确认串口通路是通的。如果英文都不出来说明是硬件或初始化问题跟中文无关。第二步打印一个简单的中文比如你好同时用串口助手的 HEX 模式看字节。GBK 下你是C4 E3好是BA C3。看到这四个字节说明发送端没问题。第三步切回文本模式确认显示正常。如果 HEX 对但文本乱码那就是助手编码设置的问题不是代码的问题。这个三步法能帮你快速定位问题出在链路的哪一段避免盲目改代码。5. 那些文档里不会写的踩坑经验下面这些是我和身边同行在实际项目里踩出来的网上教程基本不提但每一个都能让你少熬一个晚上。5.1 字符串常量放错段51 上直接跑飞在 51 单片机里默认的变量是存在 data 段内部 RAM的而内部 RAM 非常小只有 128 或 256 字节。如果你写char str[] 很长的一段中文提示;这个数组会占用宝贵的 RAM很容易就溢出了程序表现就是跑飞或者打印出乱七八糟的东西。正确做法是用code关键字把字符串放到程序存储区Flashchar code str[] 系统运行正常; printf(str);code告诉编译器这个数组是只读的放 Flash 里不占 RAM。这一条对 51 开发极其重要很多人不知道结果被 RAM 溢出坑得很惨。5.2 printf 的缓冲区会吃内存标准库的printf内部有一个缓冲区默认大小可能是几十到几百字节。在资源紧张的单片机上这个缓冲区可能占掉一大块 RAM。如果你发现加了printf之后程序就不正常了可以试着减小缓冲区或者用更轻量的输出函数替代。在 Keil MDK 里可以通过修改__stdout相关的配置来调整但更简单的办法是调试阶段用printf正式发布时把它换成自己写的轻量发送函数一个字节一个字节直接发不经过缓冲区。5.3 中断里调 printf 是个危险动作printf不是可重入函数它内部有状态。如果你在主循环里调printf同时在串口中断里也调printf两者可能打架导致输出错乱甚至死锁。我见过一个项目主循环打印状态中断里打印接收到的数据结果输出全是乱的查了两天才发现是这个原因。解决办法中断里只做最必要的事把要打印的内容存到一个缓冲区回到主循环再打印。或者给printf加互斥保护但在单片机上实现互斥本身也有开销不如从设计上避免。5.4 波特率不对中文比英文更容易暴露问题英文短波特率稍微偏一点可能还能勉强认出来中文对时序更敏感波特率偏差大一点就全是乱码。如果你发现英文勉强能看、中文完全乱码先别怀疑编码去查波特率。波特率计算跟晶振频率直接相关。51 单片机常用 11.0592MHz 晶振就是因为这个频率能整除出标准的波特率。如果你用了 12MHz 晶振波特率会有误差9600 下误差约 0.16%短数据还能扛长数据尤其是中文就容易出错。所以选晶振的时候11.0592MHz 是更稳的选择。常见问题根因解决方向51 上程序跑飞字符串占了 RAM用 code 关键字加了 printf 内存不够库缓冲区太大换轻量发送函数输出错乱中断和主循环同时调 printf中断里只存不打印中文乱码英文正常波特率偏差换 11.0592MHz 晶振6. 从能用到好用几个进阶优化思路把中文打出来只是第一步实际项目里还有更高的要求。这一节聊几个让串口输出更专业、更省心的做法。6.1 封装一个带等级和颜色的日志宏直接调printf写调试信息时间长了会乱。更好的做法是封装一层日志宏带上等级标签#define LOG_INFO(fmt, ...) printf([INFO] fmt \r\n, ##__VA_ARGS__) #define LOG_WARN(fmt, ...) printf([WARN] fmt \r\n, ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) printf([ERROR] fmt \r\n, ##__VA_ARGS__)这样输出就是[INFO] 系统启动成功一眼能看出信息等级。##__VA_ARGS__是 GCC 的扩展语法Keil 的 ARM 编译器也支持作用是当可变参数为空时自动去掉前面的逗号。如果串口助手支持 ANSI 转义序列还可以加颜色#define LOG_ERROR(fmt, ...) printf(\033[31m[ERROR] fmt \033[0m\r\n, ##__VA_ARGS__)\033[31m是红色\033[0m是恢复默认。这样错误信息在屏幕上就是红的非常醒目。不过要注意不是所有串口助手都支持 ANSI 颜色用之前先确认一下。6.2 用宏开关控制调试输出的编译正式发布的固件里不应该带一堆调试打印既占空间又影响性能。用一个宏开关控制#define DEBUG_ENABLE 1 #if DEBUG_ENABLE #define DEBUG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define DEBUG_PRINT(fmt, ...) #endif发布时把DEBUG_ENABLE改成 0所有调试打印在编译阶段就被去掉了不占任何空间。这个做法在量产项目里是标配。6.3 中文提示语的取舍什么时候该用中文最后聊一个设计层面的问题。串口调试信息到底该用中文还是英文我的经验是分场景开发调试阶段用中文看得快理解成本低尤其是给团队里非技术背景的人看的时候。正式发布的产品日志建议用英文因为英文占空间小而且不受编码影响跨平台兼容性最好。面向国内用户的设备如果日志是给用户看的中文更友好但要把编码问题彻底解决。我自己的习惯是调试信息用中文错误码和关键状态用英文加数字这样既方便自己看又不会因为编码问题在客户现场出洋相。注意如果你的产品要出口或者给不同地区的团队用中文日志会带来编码和字体的一系列麻烦这种情况下从一开始就用英文能省掉后面很多事。7. 关于编码转换的一个实用补充有时候你没法控制源文件编码比如团队强制用 UTF-8但串口助手只认 GBK。这时候可以在发送前做一次编码转换。思路是把 UTF-8 的中文字节序列转成 GBK 字节序列再发出去。这个转换需要一个映射表把常用汉字的 UTF-8 和 GBK 编码对应起来。完整映射表很大但实际项目里常用的汉字也就几百个可以只做这部分。转换函数大致长这样// 简化示意实际需要完整的映射表 const unsigned char utf8_to_gbk_table[][3] { {0xE4, 0xB8, 0xAD, 0xD6, 0xD0}, // 中 // ... 更多映射 };这种做法在资源充足的 ARM 平台上可行但在 51 上基本不现实映射表本身就占满了 Flash。所以 51 项目还是老老实实用 GBK 源文件别折腾转换。另一个更省事的办法是在 PC 端做转换。单片机只管发原始字节PC 端的接收程序负责按正确编码解析。但这要求你能控制接收端软件通用串口助手做不到。综合来看统一编码永远是最省心的方案。与其在转换上花功夫不如在项目开始前就把编码规范定死所有人、所有工具都按同一个编码来。8. 一套可以直接抄的排查流程最后把我自己常用的排查流程整理出来遇到中文打印问题按这个顺序走基本都能定位。第一步确认硬件。串口线接对没有TX 对 RXRX 对 TX共地。这一步错了后面全白搭。第二步确认波特率。两边设成一样的优先用 9600 或 115200 这种标准值。第三步打印纯英文测试。英文通了说明底层通路没问题。第四步打印一个中文用 HEX 模式看字节。对照 GBK 或 UTF-8 编码表确认字节对不对。第五步字节对但显示乱码查串口助手的编码和字体设置。第六步字节就不对回头查源文件编码和fputc/putchar的实现。第七步编译报空间不足检查中文字符串是不是放错了段51 上记得用code。这套流程走下来从硬件到软件、从发送到接收每一环都覆盖到了。我带的几个新人用这个流程基本都能自己解决问题不用再来问我。串口打印中文这件事说穿了就是编码一致性和底层重定向两件事。把这两件事做对剩下的都是细节。真正难的不是技术本身而是遇到问题时知道往哪个方向查。希望这篇内容能帮你建立起这套排查思路下次再遇到乱码不用慌按流程一步步来就行。