USART — printf 重定向
固件库: GD32F30x_Firmware_Library V3.0.3 芯片: GD32F307 (GD32307C-EVAL-V1.1 开发板) 源文件:
GD32F30x_Firmware_Library_V3.0.3/Examples/USART/Printf/main.c
功能简介
这个例程要解决一个非常朴素但极其重要的问题:让 MCU “说话”。
在电脑上写 C 程序,一句 printf("hello") 就能在黑窗口看到字。但在 MCU 上,printf 默认不知道该把字符送到哪里去 —— 芯片上既没有屏幕也没有控制台。
本例程做的事就是把 printf 的出口"接"到串口 USART0 上。接好之后,你在代码里写的每一句 printf,都会通过 PA9 引脚以 115200 波特率发出去,被电脑上的串口助手接收并显示。
这项技能的价值有多大? 打个比方:不会 printf 之前,调试程序就像闭着眼睛在黑屋里找东西,只能靠"灯亮不亮"猜;学会 printf 之后,屋里的灯打开了,你能直接看到每个变量的值、程序走到了哪一行、中断进了几次。
程序运行后只做一件事:打印一句 a usart transmit test example!,然后进入死循环。看起来平淡无奇,但重点全在"怎么接通"的那几行配置上。
硬件准备
| 器材 | 说明 |
|---|---|
| GD32307C-EVAL-V1.1 开发板 | 或任意 GD32F30x 系列板子 |
| USB 转串口模块 | CH340、CP2102、FT232 任选,几块钱一个 |
| 杜邦线 3 根 | 接 TX / RX / GND |
| 电脑串口助手 | SSCOM、XCOM、Putty、VOFA+ 均可 |
接线(新手最容易错的地方)
|
|
必须记住的两条规则:
- TX 接 RX,RX 接 TX(交叉接)。想想两个人打电话,你的嘴要对着对方的耳朵。TX 接 TX 是两个人都在说话,谁也听不见。
- GND 必须接! 串口靠"电压高低"传数据,而"高"和"低"是相对于地的。不共地就没有共同的电压参考,通信必然失败或极不稳定。
偷懒技巧:GD32307C-EVAL 板子上有一个自带的 USB 转串口芯片,把跳线帽按丝印插好就能直接用板上的 USB 口通信,不用外接模块。查一下你板子的原理图看有没有这个便利。
串口助手设置
| 参数 | 值 |
|---|---|
| 波特率 | 115200 |
| 数据位 | 8 |
| 停止位 | 1 |
| 校验位 | None |
| 流控 | 无 |
完整代码
第一部分:main.c
|
|
第二部分:真正的"魔法"—— fputc 重写
上面的 main.c 里其实少了最关键的一块拼图。 printf 凭什么就知道要往 USART0 送数据?
秘密在 gd32f307c_eval.c 文件的末尾,官方 BSP 里已经帮你写好了这一段:
|
|
就这 3 行,是整个例程的灵魂所在。 下面详细解释它的原理。
代码分段讲解
第 1 段:printf 重定向的原理(本篇最重要的部分)
C 标准库的 printf 内部并不是一个整体,它是分层的:
|
|
核心理解:printf 负责"把复杂的格式化字符串变成一串普通字符",fputc 负责"把一个字符送出去"。前者标准库已经写好了(那部分逻辑跟硬件无关),后者必须由我们提供(因为只有我们知道板子上用哪个串口)。
这个设计叫重定向(Retargeting),是 C 标准库为嵌入式预留的钩子。
第 2 段:为什么 TX 用 AF_PP,RX 用 IN_FLOATING
|
|
这是新手第二大坑(第一大是忘开时钟)。
PA9 (TX) 为什么必须是 GPIO_MODE_AF_PP?
如果配成普通的 GPIO_MODE_OUT_PP,那这根引脚的电平就由你的程序通过 gpio_bit_set() 控制。但我们希望它由 USART0 外设硬件自动按波特率翻转 —— 每秒 11 万多次,程序哪来得及一位一位地写?
AF_PP 就是告诉 GPIO 控制器:"这根脚的控制权我交出去了,谁申请谁用"。USART0 使能后自动接管 PA9,按 115200 的时序精确输出每一位。
记住这条:任何要交给外设自动驱动的输出引脚,都必须配 AF 模式。 做 PWM 输出、SPI 时钟、I2C 都是同理。
PA10 (RX) 为什么是输入模式?
接收是"听",引脚被动感知外部电平变化,所以是输入。IN_FLOATING(浮空)适用于对方(USB 转串口模块)已经在主动驱动这根线的场合。
用 GPIO_MODE_IPU(上拉输入)也可以,而且更保险 —— 万一线断了或对方掉电,上拉能保证引脚是稳定的高电平(串口空闲态是高),不会因为浮空乱跳而产生假的起始位。实际项目推荐用 GPIO_MODE_IPU。
第 3 段:usart_deinit() 为什么要先调用
|
|
usart_deinit() 是"把 USART0 的所有寄存器恢复到芯片复位后的初始值"。
为什么要这一步? 两个原因:
- 保证起点干净。如果程序之前用过 USART0(比如 Bootloader 用过、或者你反复初始化),寄存器里可能残留旧配置。deinit 相当于"格式化"。
- 拿到已知的默认值。复位后 USART 的默认配置正好是 8 位数据、无校验、1 位停止位,也就是最常用的 8-N-1。所以官方例程里连
usart_word_length_set()、usart_parity_config()、usart_stop_bit_set()都不用写了 —— deinit 之后它们已经是我们想要的值。
如果你需要非标准配置(比如 9 位数据 + 偶校验),就要在 deinit 之后显式设置:
|
|
第 4 段:发送/接收/总使能,三个开关的关系
|
|
可以理解成一个带三个开关的收音机:
usart_transmit_config→ “喇叭"开关(REN 位)usart_receive_config→ “麦克风"开关(TEN 位)usart_enable→ 整机"电源"开关(UEN 位)
电源不开,喇叭和麦克风开了也没用。所以 usart_enable() 必须调用,而且习惯上放在最后。
如果你的项目只需要打印(不接收),可以只开发送省点功耗:
|
|
第 5 段:\n 和 \r 到底该写哪个
|
|
这是个历史遗留的坑:
| 转义符 | 名字 | ASCII | 作用(源自打字机) |
|---|---|---|---|
\n |
LF (Line Feed) | 0x0A | 换行:纸往上滚一行,但打字头横向位置不变 |
\r |
CR (Carriage Return) | 0x0D | 回车:打字头回到行首,但不换行 |
所以:
- 只发
\n→ 有些串口助手里会看到"阶梯状"输出(每行开头越来越靠右) - 只发
\r→ 后面的字会把前面的字覆盖掉,永远显示在第一行 - 发
\r\n两个 → 才是标准的"回到行首 + 换到下一行”
实际写法建议统一用
"\r\n"(先回车再换行,符合 Windows/串口通用惯例)。本例程写成"\n\r"顺序反了,虽然大部分串口助手都能正确处理,但不是标准写法。
第 6 段:fputc 里 while(TBE) 的位置很讲究
|
|
注意顺序是"先发后等”,不是"先等后发"。 为什么这样也能工作?
USART 内部有两级寄存器:
|
|
- 复位后 TDATA 是空的,TBE 本来就是 1,所以第一次直接写入没问题。
- 写入后硬件立即把数据挪到移位寄存器,TBE 重新置 1。
- 我们
while等的就是这个"挪走了"的信号,等到之后就可以放心返回,让printf送下一个字符。
这种写法的效率其实相当不错:CPU 等待的只是"从 TDATA 挪到移位寄存器"这一小段时间,而不是等整个字节 8.68μs × 10 位全部发完。移位寄存器在慢慢发的同时,CPU 已经可以准备下一个字符了 —— 相当于一个 1 字节深的流水线。
注意
USART_FLAG_TBE和USART_FLAG_TC的区别。如果你做 RS485 通信,发完要立刻把收发芯片切回接收方向,这时必须等TC(Transmission Complete,最后一位真的离开引脚了),用 TBE 会把最后一个字节截断。这是 RS485 项目的经典 bug。
实验现象
- 按上面的接线图接好 3 根线。
- 编译下载程序。
- 电脑上打开串口助手,选对 COM 口,波特率设 115200,点击"打开串口"。
- 按开发板上的复位键。
串口助手窗口会立刻显示:
|
|
注意:这句话只在复位/上电时打印一次。如果你打开串口助手之前板子已经跑起来了,是看不到的 —— 按一下复位键就有了。
想看到持续输出?改成这样
|
|
串口助手会每秒多一行:
|
|
看到数字持续增长,说明程序在正常跑 —— 这也是一种最简单的"程序心跳"监控手段。
常见问题
Q1:串口助手一个字都收不到
按顺序排查:
- COM 口选错了。打开设备管理器 → 端口(COM 和 LPT),确认 CH340 是 COM 几。多个串口设备时很容易选错。
- TX/RX 没交叉。把两根线互换试试,这是最常见的问题。
- 忘了共地。GND 必须接,不接可能偶尔收到几个乱码字符。
- 打开串口助手后没复位。程序只打印一次,错过就没了。
- 驱动没装好。CH340 在某些 Windows 上需要手动装驱动,设备管理器里有黄色感叹号就是这个问题。
fputc没有被编译进去。检查工程里有没有把gd32f307c_eval.c加进编译列表。如果没有,printf会链接到库里的空实现,什么都不输出。
Q2:收到的全是乱码(锟斤拷、ÿÿ、� 这类)
几乎 100% 是波特率不匹配。
- 串口助手波特率改成 115200,逐一试试 9600 / 38400 / 115200,如果某个波特率下能看到部分正常字符,说明主频有偏差。
- 实际主频和
SystemCoreClock不一致。波特率是由外设时钟分频得到的,如果外部晶振起振失败,芯片自动跑内部 8MHz RC,实际波特率就全错了。加一句验证:
|
|
如果这句本身就是乱码,那说明确实是时钟问题。改用调试器看 SystemCoreClock 变量的值,GD32F307 应该是 120000000。
- 晶振频率宏配错了。检查
gd32f30x.h里的HXTAL_VALUE是否和板子上实际的晶振一致(EVAL 板常见 8MHz 或 25MHz)。晶振是 8MHz 但宏写 25MHz,波特率会差 3 倍多。
Q3:程序卡死在 printf 那一行
用调试器单步,如果卡在 fputc 的 while(TBE) 循环里出不来,说明 USART 硬件没真正工作:
- 忘了
usart_enable(USART0)。总开关没开,TBE 永远不会置位。 - 忘了
rcu_periph_clock_enable(RCU_USART0)。外设时钟没开,寄存器写不进去。 - 用了 EVAL_COM0 但它不是 USART0。检查
gd32f307c_eval.h里EVAL_COM0的定义,跟你 main 里配置的串口要一致。这是"配了 USART0 但 fputc 往 USART1 写"的经典错配。
Q4:printf("%f", 3.14) 打不出小数
浮点打印需要额外的库支持,两种解决办法:
- Keil MDK:勾选
Options for Target → Target → Use MicroLIB。MicroLIB 是精简版 C 库,体积小且默认支持基本浮点格式化。 - 不用浮点 printf(推荐,省 Flash 也省时间):
|
|
嵌入式里浮点 printf 会额外链接几 KB 代码,而且执行慢,能避免就避免。
Q5:为什么有的教程写 fputc,有的写 _write 或 __io_putchar?
这取决于你用的工具链的 C 库实现:
| 工具链 / C 库 | 需要重写的函数 |
|---|---|
| Keil MDK + ARMCC / MicroLIB | int fputc(int ch, FILE *f) |
| GCC + newlib (标准) | int _write(int fd, char *ptr, int len) |
| GCC + newlib-nano | int __io_putchar(int ch)(配合 #define PUTCHAR_PROTOTYPE) |
| IAR | size_t __write(int handle, const unsigned char *buf, size_t size) |
GCC 下的通用写法:
|
|
Q6:printf 太慢,影响程序实时性怎么办
printf 是阻塞式的,一句 50 个字符的打印在 115200 下要占用 CPU 约 4.3ms。在中断服务函数或高实时性循环里频繁 printf 会严重拖慢系统。
三个优化方向:
- 提高波特率。改成 921600 能快 8 倍,现代 USB 转串口芯片都支持。代码只改一个数字。
- 改用 DMA 发送。把要打印的内容先写入缓冲区,然后让 DMA 后台慢慢发,CPU 立刻返回。参考本模块的
DMA_transmitter&receiver示例。 - 正式版本关掉打印。用宏开关一键屏蔽所有调试输出:
|
|
这个 DBG_LOG 宏自动带上函数名和行号,是实际项目里非常好用的调试工具,强烈建议抄进你自己的工程模板。
小结
| 知识点 | 要记住的核心 |
|---|---|
| printf 重定向原理 | 库负责格式化,你负责实现 fputc 把单个字符送出去 |
| TX 引脚模式 | 必须 GPIO_MODE_AF_PP,交给外设驱动 |
| RX 引脚模式 | GPIO_MODE_IN_FLOATING 或更保险的 GPIO_MODE_IPU |
| 两套时钟 | GPIO 时钟 + USART 时钟,都要开 |
| deinit 的意义 | 复位到干净状态,且默认就是 8-N-1 |
| 三个使能开关 | 发送器、接收器、外设总开关,缺一不可 |
\r\n |
回车 + 换行都要发,顺序推荐 \r\n |
| TBE vs TC | TBE 是缓冲区空,TC 是真正发完;RS485 切方向必须用 TC |
| 乱码 = 波特率 | 看到乱码第一反应查波特率和主频配置 |
USART0 配置五步曲(背下来,以后写串口不用查手册):
|
|
拓展应用
printf 通了之后,你的能力会发生质变。下面这些项目都建立在串口之上:
入门级(现在就能做)
- 串口监控台:把 ADC 采样值、按键状态、定时器计数值全部实时打印出来,做一个"MCU 状态仪表盘"。这是排查任何 bug 的第一手段。
- 参数打印式调试法:在每个函数入口打印
printf("enter %s\r\n", __func__),程序跑飞时看最后打印的是哪个函数,立刻定位问题范围。 - 开机自检报告:上电依次检测 Flash、外部 RAM、EEPROM、传感器,把每一项的 OK/FAIL 打印成一份漂亮的启动日志。产品级设备都有这个。
进阶级(配合中断接收)
- 串口命令行(Shell):接收电脑发来的命令字符串,解析后执行。比如输入
led on就点灯,adc read就返回采样值,help列出所有命令。这是嵌入式项目里最实用的一个功能,让你不用重新编译就能测试各个模块。 - 上位机通信协议:设计自己的数据帧(如
帧头 0xAA + 长度 + 命令 + 数据 + CRC16 + 帧尾),配合 C# / Python / Qt 写的上位机软件做双向通信。这是绝大多数电子产品的标准架构。 - 串口 Bootloader(IAP):通过串口接收新的固件 bin 文件,写入 Flash 后跳转执行,实现"不用拆机、不用下载器就能升级程序"。结合本站的 FMC 擦写示例就能做出来。
- AT 指令驱动模块:用串口驱动 ESP8266 WiFi 模块、SIM800 4G 模块、HC-05 蓝牙模块。发
AT+CWJAP="ssid","pwd"就能联网,是 IoT 项目的必经之路。
数据分析级
- VOFA+ / SerialPlot 实时波形显示:用固定格式打印数据(如
printf("%f,%f\r\n", a, b)),上位机自动画成实时曲线。调 PID 参数时看着曲线整理超调和振荡,比盲调效率高 10 倍。 - 数据记录仪:把传感器数据按 CSV 格式打印(
printf("%d,%d,%d\r\n", t, temp, humi)),串口助手保存成文件后直接用 Excel 打开画图分析。 - GPS 数据解析:GPS 模块通过串口不断吐出 NMEA 格式语句(
$GPGGA,...),解析后就能得到经纬度、海拔、卫星数,做定位记录仪、车载追踪器。
工业实战级
- Modbus RTU 从站:在串口上实现 Modbus 协议,你的板子立刻能被任何工业组态软件(如组态王、WinCC)识别和读写。这是工控行业的通用语言。
- RS485 多机总线:加一颗 MAX485 芯片把 TTL 电平转成差分信号,通信距离从 1 米延长到 1200 米,还能挂 32 个节点组成总线网络。注意发送完必须等
TC标志再切换方向。 - 产品出厂测试治具:通过串口下发测试指令,自动完成各项功能检测并输出报告,一台治具测几千台产品。
下一步该学什么:Transmitter&receiver_interrupt(学会中断接收,让 MCU 能"听")→ IDLE_receive_interrupt(处理不定长数据帧,这是实际项目最常用的接收方案)→ DMA_transmitter&receiver(高效大数据传输)。