简介:本资源是一份面向STM32初学者与嵌入式开发者的实战型UART通信教学程序,聚焦于UART2模块的printf格式化输出与scanf交互式输入功能实现,解决库函数环境下标准I/O重定向这一典型学习难点。压缩包共161个文件,含28个C源码(如stm32f10x_rcc.c、stm32f10x_uart.c等核心驱动)、29个头文件(h)、32个汇编文件(s)及若干编译中间文件(o、d、crf)和工程配置文件(uvproj、uvopt、sct等),完整保留Keil MDK-ARM神舟IV号开发板工程结构,总大小3.07MB。已有866人学习下载,说明其在实践教学中具备较强参考价值。读者可直接导入MDK工程编译运行,获取已验证的UART2初始化配置、_write()重定向实现、中断式接收缓冲区设计、简易scanf解析逻辑及配套串口调试方法,同时附带清晰的中文注释与模块化组织,便于理解底层通信机制并快速迁移至其他UART端口或项目场景。
1. 把 printf/scanf 搬进 STM32 串口2:不是魔术,是库函数级的底层重定向实操
你有没有试过在 STM32 上用printf("cnt=%d\r\n", i);调试,结果串口助手一片死寂?或者敲scanf("%s", buf);却卡在半路,连回车都吞不下去?这不是编译器玄学,而是标准 C 库的_write和_read两个黑匣子没被你亲手撬开。本资源是一份完整可运行的 STM32 神舟 IV 号开发板(基于 STM32F103VE)配套工程,专为 UART2 定制,采用标准外设库(Standard Peripheral Library)而非 HAL,所有重定向逻辑全部落在usart2_printf.c/h和syscalls.c里,无任何中间层封装、无宏替换陷阱、无隐式初始化依赖。它不教你怎么配时钟树,但会告诉你为什么USART2_IRQn必须手动使能、为什么__io_putchar里必须加超时等待、为什么scanf读字符串时getchar()会吃掉第一个字符——这些全是真实项目里翻车过三次才写进注释的血泪经验。适合正在调试传感器协议栈、需要快速验证数据收发逻辑、或被fputc/fgetc返回值搞晕的新手;也适合想确认自己重定向是否健壮、准备把串口调试模块抽成独立组件的熟手。
2. 从标准库到硬件寄存器:printf/scanf 重定向的三层映射原理与选型依据
2.1 标准 C 库的 IO 接口到底在调什么?
printf和scanf表面是格式化函数,底层实际依赖 Newlib(ARM GCC 默认 C 库)提供的弱符号系统:_write负责输出字节流,_read负责输入字节流。它们默认指向syscalls.c中的桩函数(stub),返回-1并置errno = ENOSYS。要让printf打印到 UART2,就必须强定义这两个函数,把字节流导向USART_SendData(USART2, ch);同理,scanf要读取,就得让_read从USART_ReceiveData(USART2)拿数据。这不是“改个函数名”就能搞定的事——_write的buf参数是char *,但USART_SendData一次只发一个uint16_t;_read的len是请求长度,而串口是字节流,你得自己缓存、判帧、处理超时。本资源选择完全接管_write/_read,而非仅重载fputc/fgetc,是因为后者无法支撑scanf的多字节解析(比如%d需要连续读直到非数字字符),且fputc在 Newlib 中仍会调用_write,绕一圈反而增加不可控路径。
2.2 为什么是 UART2?而不是 USART1 或 USART3?
神舟 IV 号板载资源明确:USART1 连接 USB 转串口芯片(CH340),用于下载和主调试通道;USART2 的 TX/RX 引脚(PA2/PA3)直接引出到 DB9 插座,物理上独立,专为外设通信或双机调试预留。若强行将printf绑定到 USART1,会导致下载时串口助手抢断通信,烧录失败率飙升;而 USART3 未焊接外围电路,悬空引脚易受干扰。本工程在usart2_init.c中严格按 RM0008 手册配置:
- 时钟源:APB1 总线(PCLK1=36MHz),波特率 115200 →
USARTDIV = (36000000)/(16×115200) ≈ 19.53→ 整数部分DIV_MANTISSA=19,小数部分DIV_FRACTION=8(即0x08); - 模式:8N1(8 数据位、无校验、1 停止位),硬件流控关闭;
- 中断:仅使能
USART_IT_RXNE(接收非空中断),禁用 TXE 中断——因为_write是阻塞式发送,靠轮询USART_GetFlagStatus(USART2, USART_FLAG_TXE)更可控,避免中断嵌套打乱printf的原子性。
提示:
USART2的 GPIO 初始化必须在RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE)之后,且GPIO_PinAFConfig(GPIOA, GPIO_PinSource2, GPIO_AF_USART2)必须显式调用,否则 PA2/PA3 不会进入复用功能模式,这是新手最常漏写的一步。
2.3 库函数版 vs HAL 版:为什么这里坚持用标准外设库?
HAL 库虽有HAL_UART_Transmit和HAL_UART_Receive,但其内部状态机复杂,HAL_UART_Receive默认阻塞在HAL_UART_STATE_BUSY_RX,而_read要求非阻塞或带超时——HAL 的Timeout参数在底层仍依赖 SysTick,一旦printf正在发数据,SysTick 中断可能被抢占,导致scanf永久挂起。标准外设库的USART_ReceiveData()是纯寄存器操作,无状态依赖,配合while(!USART_GetFlagStatus(USART2, USART_FLAG_RXNE));可精确控制等待行为。本资源中usart2_printf.c的usart2_getchar_timeout()函数实现了10ms 超时机制:用SysTick_GetValue()计算流逝时间,超时返回-1,scanf自动放弃本次读取,避免整机卡死。这种细粒度控制,在 HAL 的抽象层下几乎无法安全实现。
3. 工程结构拆解与核心文件逐行注释:从初始化到重定向落地
3.1 工程目录与关键文件职责划分
本资源解压后为标准 KEIL MDK 工程结构,核心文件如下表所示:
| 文件路径 | 功能说明 | 关键技术点 |
|---|---|---|
USER/main.c | 主函数入口,调用usart2_init()并演示printf/scanf | printf输出前必须确保USART2已使能且NVIC_EnableIRQ(USART2_IRQn)已调用 |
HARDWARE/usart2/usart2_init.c/h | UART2 初始化、发送/接收基础函数 | usart2_send_byte()内含while(!USART_GetFlagStatus(...))防止发送缓冲区溢出 |
HARDWARE/usart2/usart2_printf.c/h | printf/scanf重定向实现,含usart2_puts()、usart2_gets() | usart2_gets()使用环形缓冲区(大小 64 字节),支持退格键\b删除 |
SYS/syscalls.c | 强定义_write和_read,桥接标准库与硬件 | _write中对\n自动补\r,适配 Windows 串口助手换行显示 |
STM32F10x_FWLib/src/stm32f10x_usart.c | 标准外设库源码(已编译进库),无需修改 | 注意:本工程使用的是V3.5.0版本,与V3.0.0的USART_GetITStatus()参数顺序不同 |
注意:
syscalls.c必须加入工程编译,且不能放在USE_STDPERIPH_DRIVER条件编译块内,否则链接时_write仍为弱符号,导致printf无声无息失败。
3.2syscalls.c:重定义_write与_read的完整实现
#include "stm32f10x.h" #include "usart2_printf.h" // _write: 将 buf 中 len 个字节输出到 USART2 int _write(int fd, char *ptr, int len) { int i; if (fd != 1 && fd != 2) return -1; // 只处理 stdout/stderr for (i = 0; i < len; i++) { if (*ptr == '\n') { // Windows 串口助手需 \r\n 换行 usart2_send_byte('\r'); } usart2_send_byte(*ptr++); } return i; // 返回实际写入字节数 } // _read: 从 USART2 读取最多 len 个字节到 buf int _read(int fd, char *ptr, int len) { int i; if (fd != 0) return -1; // 只处理 stdin for (i = 0; i < len; i++) { int ch = usart2_getchar_timeout(10); // 10ms 超时 if (ch == -1) break; // 超时则停止读取 *ptr++ = (char)ch; if (ch == '\r' || ch == '\n') break; // 遇到回车/换行结束 } return i; }逻辑说明与参数说明:
_write的fd参数是文件描述符,1代表stdout,2代表stderr,0是stdin;本实现只响应fd==1或2,其他返回-1,符合 POSIX 规范;usart2_send_byte()内部调用while(!USART_GetFlagStatus(USART2, USART_FLAG_TXE));确保发送寄存器空闲,再写入USART_SendData(USART2, ch),避免数据覆盖;_read中usart2_getchar_timeout(10)是关键:它用SysTick_GetValue()获取当前滴答值,循环检测USART_GetFlagStatus(USART2, USART_FLAG_RXNE),若 10ms 内无数据到达,则返回-1,scanf解析器据此判断输入结束;scanf("%s", buf)实际会多次调用_read,每次读 1 字节,因此超时机制必须精细到毫秒级,否则长字符串输入会异常中断。
3.3usart2_printf.c:printf/scanf辅助函数的健壮性设计
// usart2_gets: 读取一行(支持退格删除),存入 buf,最大长度 len-1 char* usart2_gets(char *buf, int len) { int i = 0; int ch; while (i < len - 1) { ch = usart2_getchar_timeout(10); if (ch == -1) continue; // 超时跳过 if (ch == '\r' || ch == '\n') { // 回车或换行结束 buf[i] = '\0'; break; } else if (ch == '\b' || ch == 0x7F) { // 退格键(ASCII 0x08 或 0x7F) if (i > 0) { i--; usart2_send_byte('\b'); // 发送退格 usart2_send_byte(' '); // 清屏 usart2_send_byte('\b'); // 回退光标 } } else if (ch >= ' ' && ch <= '~') { // 可见字符 buf[i++] = (char)ch; usart2_send_byte((uint8_t)ch); } } return buf; }逻辑说明与参数说明:
usart2_gets()是scanf内部调用的底层函数,len参数必须传入缓冲区真实大小(如char buf[32]则传32),函数自动在末尾写\0,防止溢出;- 退格键处理分三步:先删
buf[i],再发\b让光标左移,再发空格擦除原字符,最后再发\b回退光标——这是为了让串口助手显示效果与终端一致; - 对
ch == 0x7F的判断是兼容 macOS/Linux 终端的退格码(Delete 键),Windows 串口助手通常发0x08,双保险; usart2_send_byte()在发送退格和空格时,同样经过TXE等待,确保字符顺序不乱。
4. 避坑指南:UART2 重定向中五个真实踩过的坑与根治方案
4.1 现象:printf("Hello")无输出,但usart2_send_byte('H')能发出去
原因:_write函数未被链接器识别,仍是 Newlib 的弱符号桩函数。常见于syscalls.c未加入工程编译列表,或文件编码为 UTF-8 with BOM(KEIL 无法正确解析)。
解决:右键工程 → “Options for Target” → “C/C++” → 确认syscalls.c在 “Source Group” 中;用 Notepad++ 将syscalls.c编码转为 “UTF-8 without BOM”;编译后查看 map 文件,搜索_write,确认其地址不再是0x00000000。
4.2 现象:scanf("%d", &num)输入数字后卡住,printf后续语句不执行
原因:scanf读取%d时,会一直等待非数字字符(如空格、回车),但_read的超时时间(10ms)太短,导致scanf认为“输入流已结束”,返回0,num未赋值,程序逻辑错乱。
解决:在main.c中调用scanf前,先清空接收缓冲区:while(USART_GetFlagStatus(USART2, USART_FLAG_RXNE) != RESET) USART_ReceiveData(USART2);;或修改_read中的超时为100(100ms),适配人工输入节奏。
4.3 现象:串口助手显示乱码(如?、``),但用逻辑分析仪看波形正常
原因:PC 端串口助手波特率设置错误,或USARTDIV计算偏差。本工程按 PCLK1=36MHz 计算,若实际系统时钟被误配为 72MHz(如SystemInit()中PLL倍频错误),则实际波特率为72e6/(16×19.53)≈230400,与 115200 差一倍。
解决:用示波器测 PA2 引脚,量一个字符宽度(10 bit),计算实际波特率;检查system_stm32f10x.c中RCC_CFGR配置,确认PLLMUL和HPRE设置;重新计算DIV_MANTISSA和DIV_FRACTION。
4.4 现象:printf输出中文(如"温度:%d℃")显示为方块或乱码
原因:printf是 ASCII 字节流函数,不支持 GB2312/UTF-8 编码。"℃"是 Unicode 字符(U+2103),在源文件中以 UTF-8 编码存储为0xE2 0x84 0x83三个字节,但usart2_send_byte()一次只发一个字节,串口助手按单字节解释,自然乱码。
解决:禁用源文件 UTF-8 编码,改用 ANSI(GBK);或彻底放弃printf输出中文,改用usart2_puts("温度:"); usart2_printf_num(temp); usart2_puts("℃");分段发送。
4.5 现象:scanf读取字符串时,第一个字符总是丢失(如输入"abc",buf中为"bc\0")
原因:scanf("%s", buf)内部会先调用_read读一个字符判断是否空白,这个字符被消耗但未存入buf。若此时USART2的 RXNE 标志已被之前操作置位(如调试时发过命令),_read第一次就立即返回该字符,导致scanf误判。
解决:在main()开头强制清空一次接收缓冲区:while(USART_GetFlagStatus(USART2, USART_FLAG_RXNE)) USART_ReceiveData(USART2);;或在usart2_init()最后加一句USART_ClearFlag(USART2, USART_FLAG_RXNE);。
5. 实战验证四步法:从烧录到交互式调试的完整闭环
5.1 烧录前必检清单:五项硬性条件缺一不可
在将.hex或.axf文件烧录进神舟 IV 号前,请逐项核对以下条件,任一缺失都会导致printf/scanf失效:
| 检查项 | 正确值 | 验证方法 | 不满足后果 |
|---|---|---|---|
| 1. RCC 时钟配置 | RCC->CFGR &= ~(uint32_t)RCC_CFGR_PPRE1;(PCLK1 = HCLK) | 查system_stm32f10x.c第 212 行 | 波特率计算错误,输出乱码 |
| 2. GPIO 复用功能 | GPIO_PinAFConfig(GPIOA, GPIO_PinSource2, GPIO_AF_USART2) | 查usart2_init.c第 48 行 | PA2/PA3 为普通 IO,无信号输出 |
| 3. NVIC 中断使能 | NVIC_EnableIRQ(USART2_IRQn) | 查main.c第 56 行 | scanf无法触发接收中断,永远等待 |
| 4. USART2 使能 | USART_Cmd(USART2, ENABLE) | 查usart2_init.c第 72 行 | 寄存器全锁死,SendData无效 |
| 5. syscalls.c 编译 | 工程左侧栏可见syscalls.c,且编译日志有syscalls.o | 编译后看 Output 窗口 | _write为弱符号,printf静默失败 |
提示:KEIL 编译日志中搜索
Warning: L6314W表示符号未定义,此时务必检查syscalls.c是否在编译列表中。
5.2 串口助手配置与交互式调试技巧
使用 XCOM(推荐)、SSCOM 或 Tera Term 进行测试,配置必须严格匹配:
| 参数 | 必须值 | 说明 |
|---|---|---|
| 波特率 | 115200 | 与usart2_init.c中USARTDIV计算一致 |
| 数据位 | 8 | USART_WordLength_8b |
| 校验位 | None | USART_Parity_No |
| 停止位 | 1 | USART_StopBits_1 |
| 流控 | None | 硬件流控未连接,开启会导致握手失败 |
交互式调试技巧:
- 验证
printf:在main()中插入printf("STM32 UART2 Test OK!\r\n");,烧录后应立即看到该字符串; - 验证
scanf数字:输入123后按回车,程序应能正确解析并打印num = 123; - 验证
scanf字符串:输入hello(无空格),scanf("%s", buf)后printf("Got: %s\r\n", buf)应输出Got: hello; - 压力测试:连续快速输入
1234567890(10 个数字),观察是否丢字符——本工程环形缓冲区为 64 字节,可承受约 6 次连续输入不丢。
5.3 用逻辑分析仪抓包:定位物理层与协议层问题
当软件逻辑看似无误但通信仍失败时,必须用 Saleae Logic 16 或类似设备抓取 PA2(TX)和 PA3(RX)引脚波形。重点关注三点:
- 起始位低电平宽度:应为
1 / 115200 ≈ 8.68μs,若为4.34μs,说明波特率被设为 230400; - 数据位顺序:LSB 在前,
'H'(0x48 =01001000)应显示为0 0 0 0 1 0 0 1(反序); - 帧间隔:连续两个字符间应有至少 1 位空闲时间(
1/115200 s),若粘连,说明usart2_send_byte()未等待TXE标志。
抓包发现波形正确但 PC 端收不到,大概率是 PC 串口助手驱动问题(尤其 CH340 在 Win11 下需手动更新为 V3.5.2021.07.01 驱动);若波形本身错误,则回到第 5.1 节检查时钟和 GPIO。
6. 进阶技巧:把 UART2 重定向封装成可移植模块,并适配多串口场景
6.1 模块化改造:从硬编码到可配置接口
原始工程中所有USART2相关寄存器和引脚都是硬编码,不利于移植到其他型号(如 STM32F407 的 USART6)。我将其重构为usart_port.h/c,核心是定义usart_port_t结构体:
// usart_port.h typedef struct { USART_TypeDef* usart; uint32_t rcc_usart; uint32_t rcc_gpio; GPIO_TypeDef* gpio_port; uint16_t gpio_pin_tx; uint16_t gpio_pin_rx; uint8_t gpio_af; uint32_t baudrate; } usart_port_t; extern const usart_port_t usart2_port; extern void usart_port_init(const usart_port_t* port); extern int usart_port_write(const usart_port_t* port, char *ptr, int len); extern int usart_port_read(const usart_port_t* port, char *ptr, int len);// usart_port.c const usart_port_t usart2_port = { .usart = USART2, .rcc_usart = RCC_APB1PERIPH_USART2, .rcc_gpio = RCC_APB2PERIPH_GPIOA, .gpio_port = GPIOA, .gpio_pin_tx = GPIO_Pin_2, .gpio_pin_rx = GPIO_Pin_3, .gpio_af = GPIO_AF_USART2, .baudrate = 115200 }; void usart_port_init(const usart_port_t* port) { RCC_APB2PeriphClockCmd(port->rcc_gpio, ENABLE); RCC_APB1PeriphClockCmd(port->rcc_usart, ENABLE); // ... 后续初始化代码全部用 port-> 成员访问 }好处:新增 USART3 只需定义usart3_port结构体,调用usart_port_init(&usart3_port)即可,_write/_read中通过全局指针current_port切换目标,无需改任何重定向逻辑。
6.2 多串口共存方案:printf到 UART2,debug_log到 UART1
实际项目中常需分离功能:UART2 接传感器,UART1 接 PC 调试。此时不能让_write绑死一个串口。我的做法是:
- 在
syscalls.c中定义static const usart_port_t* current_stdout = &usart2_port;; - 提供
void set_stdout_port(const usart_port_t* port)函数,运行时切换; _write改为return usart_port_write(current_stdout, ptr, len);;- 在
main()开头调用set_stdout_port(&usart1_port),调试阶段用 UART1; - 在传感器协议初始化后,调用
set_stdout_port(&usart2_port),切回 UART2。
这样printf始终可用,只是目标可变,比写两套printf_uart1/printf_uart2函数干净得多。
6.3scanf的安全增强:添加输入长度限制与非法字符过滤
原始scanf("%s", buf)极易因超长输入导致缓冲区溢出。我在usart2_printf.c中增加了usart2_scanf_safe():
// 安全版 scanf,max_len 为 buf 最大容量 int usart2_scanf_safe(char *buf, int max_len, const char* prompt) { if (prompt) usart2_puts(prompt); int i = 0; int ch; while (i < max_len - 1) { ch = usart2_getchar_timeout(100); if (ch == -1) continue; if (ch == '\r' || ch == '\n') break; if (ch == '\b' || ch == 0x7F) { if (i > 0) { i--; usart2_backspace(); } } else if ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_' || ch == '-') { // 只允许字母数字下划线横杠 buf[i++] = (char)ch; usart2_send_byte((uint8_t)ch); } } buf[i] = '\0'; return i; }参数说明:max_len必须传入sizeof(buf),函数内部自动留一位给\0;prompt为可选提示字符串(如"Input ID:");非法字符(如@#$%)直接忽略,不回显,避免用户困惑。
从那以后我每次做串口交互模块,都强制走一遍这四步:先用逻辑分析仪抓 TX 波形确认物理层 OK,再用usart2_send_byte()单字节验证寄存器层,然后跑通usart2_puts()确认字符串层,最后才接入printf/scanf。少跳任何一环,后面三天都在找那个本该一眼看到的NVIC_EnableIRQ漏写。希望帮到你。
本文还有配套的精品资源,点击获取