1. 串口打印中文这件事,为什么值得单独拎出来讲
搞过单片机的人都有一个共同习惯:调试阶段第一件事就是把串口打通,然后用printf往电脑上怼信息。打印英文和数字从来不是问题,一行printf("Hello\r\n")就能跑起来。可一旦你试着打印一句中文,比如printf("温度过高\r\n"),大概率会看到一串乱码,或者干脆什么都不显示。这个问题在 Keil 环境下尤其常见,因为 Keil 的默认编译器设置、编码格式、终端解码方式三者之间很容易对不上。
我自己刚开始用 STC 单片机做项目的时候,就踩过这个坑。当时用 Keil C51 写了个串口发送程序,英文打印一切正常,换成中文就变成了一堆问号或者方块。查了半天以为是波特率不对,后来才发现问题出在字符编码和重定向实现上。所以这篇内容就是把这个过程完整拆开,从原理到代码到排查,一步步讲清楚怎么在 Keil 环境下用printf稳定地发送中文。
这篇适合谁看?如果你正在用 51 单片机、STM32 或者任何在 Keil 环境下开发的芯片,需要通过串口输出中文调试信息,那这篇内容可以直接抄作业。如果你只是刚接触串口通信,也能从里面搞明白printf重定向到底是怎么回事,为什么它不能直接用在单片机上。下面我会按照实际操作的顺序,把每个环节的关键点和坑都讲透。
2. 整体思路与方案选型:为什么 printf 不能直接用在单片机上
2.1 printf 在标准 C 里的工作方式
在 PC 上写 C 语言程序,printf之所以能把文字显示到屏幕上,是因为操作系统提供了底层输出接口。标准库里的printf最终会调用一个叫fputc或者类似的底层函数,把字符一个一个送到标准输出设备。这个过程在 PC 上是操作系统帮你兜底的,你不需要关心字符怎么从内存跑到屏幕上。
但单片机没有操作系统,也没有标准输出设备。printf在 Keil 的 C 库里面依然存在,但它需要一个“出口”。这个出口就是串口。你要做的事情就是告诉编译器:当你调用printf的时候,底层字符不要往别的地方送,统统送到串口寄存器里去。这个动作就叫重定向。
2.2 重定向的核心原理
Keil 的 C 库(无论是 C51 还是 ARM 的 MicroLIB)都预留了一个底层字符输出函数。对于 C51 来说,这个函数是putchar或者fputc,取决于你用的库版本。对于 ARM 的 MicroLIB,通常是fputc。你只需要在自己的代码里重新实现这个函数,在里面把字符写入串口发送寄存器,printf就能正常工作了。
这里有一个关键点:重定向函数的签名必须和库里面声明的一致。如果你写错了参数类型或者返回值类型,编译器不会报错,但链接的时候会找不到符号,或者运行时行为异常。这是很多人第一次做重定向时容易忽略的地方。
2.3 中文打印的特殊性
英文和数字在 ASCII 编码下只占一个字节,范围是 0 到 127。串口按字节发送,终端按 ASCII 解码,一一对应,不会出问题。但中文不一样。中文在计算机里面通常用两个字节表示(GBK 编码)或者三个字节表示(UTF-8 编码)。如果你发送的是 GBK 编码的中文字节流,但终端用 UTF-8 解码,就会显示乱码。反过来也一样。
所以中文打印的问题实际上分成两层:第一层是printf重定向有没有成功,第二层是发送端和接收端的编码格式有没有对齐。很多人只解决了第一层,发现英文能打印了,就以为大功告成,结果中文还是乱的。下面我会把这两层都拆开讲。
3. 核心细节解析:编码、重定向与串口配置
3.1 中文字符编码在 Keil 里的处理
Keil 编辑器默认的源文件编码方式在不同版本里不太一样。Keil uVision5 在中文 Windows 系统下,默认可能是 GB2312 或者 GBK。也就是说,你在代码里写printf("温度过高\r\n"),编译器看到的这几个中文字符在源文件里是以 GBK 编码存储的,每个汉字占两个字节。
当你编译的时候,这些字节会被原封不动地放进字符串常量里。运行时printf把这些字节一个一个通过串口发出去。所以串口上传输的就是 GBK 编码的中文字节流。
这时候如果你用电脑上的串口助手接收,串口助手必须也设置为 GBK 解码,才能正确显示中文。如果你用的是 UTF-8 解码,就会看到乱码。这就是为什么有些人说“我代码没问题,但就是乱码”——问题出在终端解码设置上。
注意:Keil 的编辑界面里,中文注释和中文串口输出是两回事。注释在编译时会被丢弃,不影响运行结果。但字符串常量里的中文会实实在在进入二进制文件。
3.2 重定向函数的正确写法
对于 STC 51 单片机,常用的重定向函数是putchar。下面是一个可以直接用的实现:
#include <reg52.h> void UartInit(void) { SCON = 0x50; // 串口工作方式1,允许接收 TMOD &= 0x0F; TMOD |= 0x20; // 定时器1工作方式2,8位自动重装 TH1 = 0xFD; // 波特率9600,晶振11.0592MHz TL1 = 0xFD; TR1 = 1; // 启动定时器1 ES = 1; // 打开串口中断(如果不用中断可以关掉) EA = 1; } void UartSendByte(unsigned char dat) { SBUF = dat; while (!TI); TI = 0; } char putchar(char c) { UartSendByte(c); return c; }这段代码里,putchar就是重定向的关键。Keil C51 的库在调用printf时,会逐个字符调用putchar。你在putchar里面把字符通过串口发出去,printf就能正常工作了。
对于 STM32 在 Keil MDK 环境下,通常使用 MicroLIB,重定向函数是fputc:
#include "stm32f10x.h" #include <stdio.h> int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }提示:在 Keil MDK 里使用 MicroLIB 需要在工程选项的 Target 标签页里勾选 “Use MicroLIB”。如果不勾选,标准库的重定向方式会不一样,需要实现
_sys_write等函数,比较麻烦。所以建议直接勾选 MicroLIB。
3.3 波特率与晶振的匹配问题
串口通信对波特率的误差很敏感。51 单片机常用 11.0592MHz 晶振,因为这个频率能让定时器产生的波特率误差接近零。如果你用 12MHz 晶振,9600 波特率的误差会比较大,可能导致通信不稳定。
具体计算过程是这样的:定时器1工作在方式2时,波特率 = (2^SMOD / 32) * (晶振频率 / (12 * (256 - TH1)))。以 11.0592MHz 晶振、SMOD=0、波特率9600为例,反推 TH1 = 256 - (11059200 / (384 * 9600)) = 256 - 3 = 253 = 0xFD。这就是上面代码里 TH1 和 TL1 都设为 0xFD 的原因。
如果你换成 12MHz 晶振,同样的公式算出来 TH1 约等于 250.5,不是整数,实际波特率会有偏差。短距离调试可能看不出来,但长时间传输或者高波特率下就容易丢数据。
3.4 串口助手的编码设置
电脑端的串口助手是接收端,它的解码设置直接决定了你能不能看到正确的中文。常见的串口助手比如 SSCOM、XCOM、友善串口调试助手,都有编码选项。你需要把它设置成和单片机发送端一致的编码。
如果你在 Keil 里写的中文是 GBK 编码,串口助手就选 GBK 或者 GB2312。如果你把 Keil 源文件转成了 UTF-8,串口助手就选 UTF-8。两边必须一致。
注意:有些串口助手默认是 UTF-8,有些默认是 GBK,不同版本还不一样。所以每次换工具的时候,先发一句中文测试一下,确认编码设置正确再继续调试。
3.5 发送缓冲区与中断的配合
如果你用中断方式发送串口数据,printf重定向的时候要注意缓冲区的问题。printf是阻塞式调用,它会一直等到所有字符都发送完毕才返回。如果你在中断服务函数里面调用printf,可能会导致中断嵌套或者死锁。
我一般的做法是:printf只用在主循环或者非中断上下文里。如果需要在中断里发数据,就自己写一个环形缓冲区,把数据存进去,在主循环里慢慢发。这样既不影响中断响应速度,也不会因为printf阻塞导致其他中断被延迟。
4. 完整实操过程:从新建工程到中文打印成功
4.1 工程创建与基础配置
打开 Keil uVision5,新建一个 Project,选择你的单片机型号。如果是 STC89C52,就选 Atmel 的 AT89C52 或者直接选 STC 的器件库(需要额外安装)。如果是 STM32,选对应的芯片型号。
创建完工程后,新建一个main.c文件,添加到工程里。然后在工程选项里确认晶振频率设置正确。对于 51 单片机,在 Target 标签页里把 Xtal 设为 11.0592MHz。对于 STM32,在 Target 标签页里勾选 Use MicroLIB。
4.2 串口初始化代码
以 STC89C52 为例,完整的串口初始化代码如下:
#include <reg52.h> #include <stdio.h> void UartInit(void) { SCON = 0x50; // 0101 0000:方式1,8位UART,允许接收 TMOD &= 0x0F; // 清除定时器1模式位 TMOD |= 0x20; // 定时器1方式2,8位自动重装 TH1 = 0xFD; // 波特率9600 TL1 = 0xFD; TR1 = 1; // 启动定时器1 ES = 1; // 使能串口中断 EA = 1; // 使能总中断 } void UartSendByte(unsigned char dat) { SBUF = dat; while (!TI); TI = 0; } char putchar(char c) { UartSendByte(c); return c; } void main(void) { UartInit(); printf("系统启动成功\r\n"); printf("当前温度:25.6℃\r\n"); printf("中文测试:你好,世界!\r\n"); while (1) { // 主循环 } }这段代码编译下载后,打开串口助手,波特率设为9600,数据位8,停止位1,无校验,编码设为GBK,就能看到中文正常显示了。
4.3 STM32 版本的实现
如果你用的是 STM32F103 在 Keil MDK 环境下,串口初始化和重定向代码如下:
#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); // PA9 TX 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); // PA10 RX GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 9600; 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("STM32 串口中文测试\r\n"); printf("温度:26.3℃ 湿度:58%%\r\n"); while (1) { } }注意printf里面的百分号需要写成%%才能输出一个百分号,这是 C 语言格式化字符串的规则。
4.4 验证步骤与预期结果
下载程序后,按以下步骤验证:
- 打开串口助手,选择正确的 COM 口。
- 波特率设为 9600,数据位 8,停止位 1,无校验。
- 编码设置为 GBK。
- 打开串口,按一下单片机复位键。
- 观察接收窗口,应该能看到“系统启动成功”、“当前温度:25.6℃”、“中文测试:你好,世界!”这几行文字。
如果英文正常但中文乱码,先检查串口助手的编码设置。如果连英文都不正常,检查波特率和接线。如果什么都没有,检查串口线是否接反(TX 接 RX,RX 接 TX)。
4.5 关键参数速查表
| 参数项 | 51单片机典型值 | STM32典型值 | 说明 |
|---|---|---|---|
| 晶振频率 | 11.0592MHz | 8MHz(外部) | 51单片机建议用11.0592MHz |
| 波特率 | 9600 | 9600 | 调试阶段不建议超过115200 |
| 定时器模式 | 方式2 | 不用定时器 | 51用定时器1产生波特率 |
| 重定向函数 | putchar | fputc | 取决于编译器和库 |
| 编码格式 | GBK | GBK | 与串口助手保持一致 |
| 数据位 | 8 | 8 | 标准配置 |
| 停止位 | 1 | 1 | 标准配置 |
| 校验位 | 无 | 无 | 调试阶段通常不用校验 |
5. 常见问题与排查技巧实录
5.1 中文乱码的三种典型情况
第一种:英文正常,中文显示为乱码。这几乎可以肯定是编码不匹配。Keil 源文件是 GBK,串口助手是 UTF-8,或者反过来。解决办法是把串口助手编码改成 GBK,或者在 Keil 里把源文件转成 UTF-8(但 Keil 对 UTF-8 的支持不太稳定,不建议)。
第二种:英文和中文都乱码。这说明波特率不对,或者串口初始化有问题。先检查晶振频率和 TH1 的值是否匹配,再检查串口助手的波特率设置。
第三种:中文显示为问号。这通常是串口助手不支持中文,或者编码选项里选了一个不支持中文的字符集。换一个串口助手试试,比如 SSCOM 或者 XCOM。
5.2 printf 没有输出的排查顺序
如果printf完全没反应,按以下顺序排查:
- 确认串口初始化代码被调用了。有时候
UartInit()写了但忘记在main里调用。 - 确认重定向函数的名字和签名正确。C51 是
putchar,MDK 是fputc,写错了不会报错但不会生效。 - 确认 MicroLIB 已勾选(STM32 在 Keil MDK 下)。
- 确认串口线接对了。TX 接 RX,RX 接 TX,GND 接 GND。
- 用示波器或者逻辑分析仪看一下 TX 引脚有没有波形输出。如果没有波形,说明单片机根本没发数据。
5.3 中断方式发送的注意事项
如果你用中断方式发送串口数据,printf重定向的时候要注意:putchar或fputc里面不要用中断发送,直接用查询方式(等待 TI 标志位)最稳妥。因为printf本身是阻塞的,它在等待每个字符发送完成,如果你在putchar里又开了中断,逻辑会变得很复杂,容易出问题。
我一般的做法是:调试阶段直接用查询方式发送,简单可靠。等产品定型了,如果需要高效率传输,再改成 DMA 或者中断加环形缓冲区的方式。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 英文正常,中文乱码 | 编码不匹配 | 串口助手改为GBK |
| 全部乱码 | 波特率错误 | 检查晶振和TH1值 |
| 无任何输出 | 重定向函数名错误 | C51用putchar,MDK用fputc |
| 无任何输出 | 未勾选MicroLIB | 工程选项里勾选 |
| 输出断断续续 | 波特率误差大 | 换11.0592MHz晶振 |
| 中文显示问号 | 串口助手不支持 | 换SSCOM或XCOM |
| 复位后第一行乱码 | 上电瞬间电平不稳 | 延时后再初始化串口 |
| 打印浮点数异常 | 未启用浮点支持 | 检查printf库配置 |
5.5 一个容易被忽略的坑:字符串长度限制
Keil C51 的printf对字符串长度有一定的限制,特别是当你使用sprintf格式化到缓冲区的时候。如果你的中文句子很长,可能会超出缓冲区导致溢出。我建议单次printf输出的内容不要超过 80 个字符,如果需要输出长文本,分成多次调用。
另外,C51 的printf默认不支持浮点数。如果你要打印温度值25.6,直接用%f会输出错误的结果。解决办法是先把浮点数转成整数和小数两部分,分别用%d打印,或者使用sprintf手动格式化。
float temp = 25.6; int temp_int = (int)temp; int temp_dec = (int)((temp - temp_int) * 10); printf("温度:%d.%d℃\r\n", temp_int, temp_dec);这样就能在不支持浮点打印的环境下正确输出小数了。
6. 进阶技巧:让串口调试更顺手
6.1 封装一个带标签的调试打印宏
在实际项目里,直接写printf会让代码里到处都是格式化字符串,不好管理。我习惯封装一个宏:
#define DEBUG_PRINT(fmt, ...) printf("[DEBUG] " fmt "\r\n", ##__VA_ARGS__) #define INFO_PRINT(fmt, ...) printf("[INFO] " fmt "\r\n", ##__VA_ARGS__) #define ERROR_PRINT(fmt, ...) printf("[ERROR] " fmt "\r\n", ##__VA_ARGS__)这样打印出来的信息自带标签和换行,串口助手里看起来一目了然。而且以后要关闭调试输出,只需要把宏定义改成空就行,不用一行一行删printf。
6.2 用串口助手做简易数据监控
除了看文字,串口助手还可以用来监控数据变化。比如你可以在主循环里每隔一秒打印一次传感器数值,然后在串口助手里观察数据的变化趋势。有些串口助手支持数据绘图功能,可以把接收到的数值直接画成曲线,对于调试温度、电压之类的模拟量非常直观。
6.3 中文打印在 LCD 上的延伸
串口中文打印搞明白之后,你会发现同样的编码逻辑也适用于 LCD 显示。LCD 显示中文需要字模数据,而字模的编码方式(GBK 或 UTF-8)和串口打印是同一套逻辑。你在 Keil 里怎么处理中文字符串,在 LCD 上就怎么处理。所以把这个环节搞透,后面做带中文界面的项目会轻松很多。
6.4 关于编码转换的实用建议
如果你的项目需要同时支持 GBK 和 UTF-8,比如上位机用 UTF-8 而下位机用 GBK,那就需要在发送前做编码转换。简单的做法是在 PC 端做转换,单片机只管发 GBK。如果非要在单片机里转换,就需要引入编码转换表,会占用不少 Flash 空间。对于资源紧张的 51 单片机,不建议这么做。
我个人的经验是:统一用 GBK。Keil 在中文 Windows 下默认就是 GBK,串口助手也支持 GBK,两边不用做任何转换,最省事。除非你的项目有明确的 UTF-8 需求,否则不要给自己找麻烦。
6.5 调试完成后的代码清理
产品定型之前,记得把调试用的printf清理掉或者用宏开关关掉。串口打印会占用 CPU 时间,特别是在高频率调用的时候,会影响实时性。而且留着调试代码也有暴露内部信息的风险。我一般会在工程里定义一个DEBUG_ENABLE宏,发布版本把这个宏关掉,所有调试打印自动消失。
#ifdef DEBUG_ENABLE #define DEBUG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define DEBUG_PRINT(fmt, ...) #endif这样在开发阶段打开DEBUG_ENABLE,发布的时候注释掉这一行定义,所有调试输出就都没了,代码也不用改。
串口打印中文这件事,说到底就是三个对齐:重定向函数对齐、波特率对齐、编码格式对齐。三个都对了,中文自然就出来了。我见过太多人卡在其中一个环节上,反复检查代码却忽略了串口助手的编码设置。希望这篇内容能帮你少走点弯路。