1. 为什么一块几块钱的PY32F002B开发板值得折腾串口打印
PY32F002B这颗芯片这两年在低成本嵌入式圈子里热度不低,ARM Cortex-M0+内核,主频能跑到24MHz,封装小、价格便宜,做小家电控制、简单传感器节点、LED驱动这类活儿非常合适。但很多朋友拿到开发板的第一反应是:怎么让它把信息打印出来?没有printf,调试基本靠猜,点个灯还行,一旦涉及变量状态、寄存器值、流程走向,整个人就抓瞎了。
串口调试就是解决这个问题的。把PY32F002B的UART配置好,重定向printf到串口,你就能在串口调试助手里看到程序运行时的各种信息,变量值、函数进出、错误码,一目了然。这篇内容就是围绕“PY32F002B开发板串口调试”这个核心,从零开始把printf输出的完整链路讲清楚,包括时钟配置、GPIO复用、UART初始化、重定向实现、常见坑点排查。适合刚接触PY32系列的新手,也适合从STM32转过来、想快速上手国产芯片的开发者。
我手上这块PY32F002B开发板是常见的核心板形态,引出全部IO,带一个Micro USB接口(只供电,不带串口芯片),所以需要外接一个USB转TTL模块。如果你用的是带CH340或CP2102的板子,那就更省事,直接一根USB线搞定。下面所有操作和代码都基于PY32F002B的官方SDK和Keil MDK环境,其他IDE思路一致。
2. 动手前的硬件与软件准备清单
2.1 硬件连接:别小看那几根杜邦线
PY32F002B的UART引脚是复用的,默认情况下PA0和PA1可以配置为UART1的TX和RX,但具体要看你的开发板原理图。我手上这块板子PA0接了LED,PA1空着,PA2和PA3引出来了,所以我把UART1重映射到PA2(TX)和PA3(RX)。这一步必须查原理图确认,不能凭感觉。
USB转TTL模块选CH340G或CP2102都行,几块钱一个。接线就三根:模块的TX接板子的RX,模块的RX接板子的TX,GND对GND。注意TX和RX必须交叉,我见过太多人直连然后纳闷为什么没输出。供电方面,如果板子已经通过USB供电,那TTL模块的VCC就不要接了,只接GND和两根信号线,避免双电源冲突。
注意:有些USB转TTL模块的IO电平是5V的,PY32F002B是3.3V器件,长期接5V TX到3.3V RX可能会损伤引脚。建议选带3.3V/5V跳线的模块,或者串一个1k电阻限流。我实测CH340G在3.3V供电下输出高电平约3.1V,可以直接用。
2.2 软件环境:SDK、Keil和串口助手
PY32F002B的官方SDK可以从厂商官网或GitHub仓库获取,里面包含启动文件、外设驱动库、例程。Keil MDK需要安装PY32F002B的器件支持包(Pack),如果没有,可以选ARM Cortex-M0+的通用配置,手动添加启动文件和链接脚本。串口调试助手我用的是SSCOM,界面简单,支持HEX显示和定时发送,够用了。也有人用XCOM或者串口调试助手专业版,功能大同小异,选顺手的就行。
编译环境配置好之后,先跑一个点灯例程确认工具链没问题,再开始改串口。这一步能排除很多低级错误,比如下载算法选错、时钟配置不对导致芯片根本没跑起来。
3. 串口初始化的核心细节与时钟计算
3.1 时钟树配置:波特率准不准全看这里
PY32F002B默认使用内部HSI 8MHz RC振荡器,也可以通过PLL倍频到24MHz。串口波特率的精度直接依赖系统时钟,如果时钟偏了,波特率就会偏,表现为串口助手收到乱码。所以第一步是确认系统时钟频率。
我选择HSI 8MHz直接作为系统时钟,不启用PLL,这样功耗低、配置简单。UART1挂载在APB总线上,APB时钟默认等于系统时钟,即8MHz。波特率计算公式是:
BRR = fCK / baudrate对于8MHz时钟和115200波特率,BRR = 8000000 / 115200 ≈ 69.44。PY32的UART波特率寄存器是16位整数加4位小数,实际写入值需要按手册格式计算。不过用SDK库函数UART_Init时,只需要传入波特率结构体,库会自动算。但你要知道背后的逻辑,万一通信不正常,可以回头检查时钟配置。
实操心得:如果你用HSI 8MHz跑115200,误差大约0.16%,完全在容忍范围内。但如果用内部RC的24MHz(HSI经过PLL),RC振荡器本身精度可能只有±1%,累积误差可能导致通信不稳定。建议高波特率时用外部晶振,或者降低波特率到9600。
3.2 GPIO复用配置:AF模式别配错
PY32F002B的GPIO复用功能通过AFR寄存器选择。以PA2和PA3为例,需要配置为复用推挽输出(TX)和浮空输入或上拉输入(RX)。具体步骤:
- 使能GPIOA时钟和UART1时钟。
- 配置PA2为复用推挽输出,速度选高或中。
- 配置PA3为浮空输入或上拉输入。
- 设置AFR寄存器,将PA2和PA3映射到UART1的TX和RX功能。
这里有个容易踩的坑:PY32的AFR寄存器编号和STM32不同,不能照搬STM32的代码。必须查PY32F002B的参考手册,找到UART1_TX和UART1_RX对应的AF编号。我查到的资料显示PA2对应AF4,PA3对应AF4,但不同封装可能不一样,务必以手册为准。
// GPIO复用配置示例(基于SDK库) GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_UART1_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_2; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Alternate = GPIO_AF4_UART1; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_3; GPIO_InitStruct.Mode = GPIO_MODE_AF_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);3.3 UART参数设置:数据位、停止位、校验位
标准配置是8位数据位、1位停止位、无校验、无硬件流控。波特率115200。这些参数在串口助手里要一一对应,否则收到的就是乱码或者根本收不到。
UART_HandleTypeDef huart1; huart1.Instance = UART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1);初始化完成后,可以先发一个字节测试,比如HAL_UART_Transmit(&huart1, (uint8_t*)"A", 1, 100),串口助手收到“A”就说明链路通了。这一步别跳过,直接上printf容易把问题复杂化。
4. printf重定向的三种实现方式与代码实操
4.1 方法一:重写fputc(最常用)
Keil MDK默认使用MicroLIB时,printf最终会调用fputc来输出字符。我们只需要重写fputc,把字符通过UART发出去。
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 0xFFFF); return ch; }然后在Keil的Target选项中勾选“Use MicroLIB”。如果不勾选,标准库的printf会依赖半主机模式,导致程序卡在BKPT指令。这是新手最常遇到的坑之一,现象是程序下载后不运行,或者串口没输出。
注意:MicroLIB不支持浮点printf,如果你要打印float,需要自己实现浮点转字符串,或者改用标准库并重写_write。我一般用整数放大法,比如把电压值乘以1000变成毫伏,用%d打印,省事又可靠。
4.2 方法二:重写_write(标准库方案)
如果不使用MicroLIB,就需要重写_write函数,并且可能需要关闭半主机模式。在代码里添加:
#include <stdio.h> #include <rt_misc.h> int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, 0xFFFF); return len; } __asm(".global __use_no_semihosting\n\t");这种方式支持浮点,但代码体积会大一些。对于PY32F002B这种Flash只有20KB的芯片,能用MicroLIB就用MicroLIB,省空间。
4.3 方法三:自己实现printf变体(最轻量)
如果连MicroLIB都不想用,可以自己写一个简单的串口打印函数,支持可变参数:
#include <stdarg.h> #include <string.h> void uart_printf(const char *fmt, ...) { char buf[128]; va_list args; va_start(args, fmt); vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); HAL_UART_Transmit(&huart1, (uint8_t*)buf, strlen(buf), 0xFFFF); }这种方式不依赖任何库,代码可控,但功能有限,不支持宽度、精度等高级格式。适合资源极度紧张的场景。
4.4 三种方式对比与选型建议
| 方式 | 代码体积 | 浮点支持 | 依赖 | 适用场景 |
|---|---|---|---|---|
| 重写fputc + MicroLIB | 小 | 不支持 | MicroLIB | 资源紧张,整数打印 |
| 重写_write + 标准库 | 大 | 支持 | 标准库 | 需要浮点,Flash充足 |
| 自定义uart_printf | 最小 | 不支持 | 无 | 极简应用,固定格式 |
我一般首选第一种,Flash占用小,配置简单。如果项目确实需要打印浮点,再换第二种。第三种适合做bootloader或者极简测试代码。
5. 串口调试中那些让人抓狂的坑与排查方法
5.1 串口助手收到乱码
乱码是最常见的现象,原因通常有三个:波特率不匹配、时钟配置错误、数据位/停止位不对。排查顺序:
- 确认串口助手的波特率和代码里一致,都是115200。
- 用示波器或者逻辑分析仪测TX引脚波形,计算实际波特率。如果没有仪器,可以临时把波特率降到9600,看是否正常。如果9600正常而115200乱码,基本是时钟精度问题。
- 检查系统时钟配置,确认HSI频率和PLL倍频系数。PY32F002B的HSI出厂校准值在寄存器里,但如果你改了时钟配置没更新,就会偏。
我遇到过一次,代码里写的115200,但系统时钟实际是4MHz(HSI分频了),结果BRR算出来差一倍,串口助手收到全是乱码。后来把时钟配置改回8MHz就好了。
5.2 printf没有输出,程序卡死
如果程序下载后没反应,串口也没输出,先检查是否勾选了MicroLIB。没勾选的话,printf会触发半主机,程序停在BKPT。其次检查UART初始化是否成功,可以在初始化后点个灯,确认程序跑到了主循环。
还有一种情况是TX引脚配置成了普通GPIO而不是复用功能,或者AF编号写错。用万用表测TX引脚电压,空闲时应该是高电平(3.3V)。如果一直是低电平,说明引脚配置有问题。
5.3 打印中文乱码
串口助手默认是ASCII显示,中文需要UTF-8或GBK编码。Keil源文件默认是GBK,但串口助手如果选UTF-8就会乱码。解决办法:要么源文件存为UTF-8,要么串口助手切换编码。我一般统一用UTF-8,避免跨平台问题。
另外,MicroLIB对中文支持没问题,但要注意字符串长度,别超过缓冲区。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 无输出 | MicroLIB未勾选 | 勾选Use MicroLIB |
| 无输出 | TX/RX接反 | 交叉接线 |
| 乱码 | 波特率不匹配 | 统一波特率 |
| 乱码 | 时钟配置错误 | 检查HSI/PLL配置 |
| 卡死 | 半主机模式 | 重写_write或勾选MicroLIB |
| 中文乱码 | 编码不一致 | 统一UTF-8 |
| 偶尔丢数据 | 发送过快无缓冲 | 加延时或DMA |
6. 进阶技巧:用DMA和环形缓冲提升串口输出效率
6.1 为什么需要DMA
printf是阻塞式发送,每打一个字符都要等UART发送完成。如果主循环里频繁打印,会严重拖慢程序运行。比如你在1ms定时中断里打印调试信息,阻塞发送可能导致中断响应延迟,影响实时性。
DMA方式可以把数据搬到UART发送寄存器,CPU不用管,发完了再中断通知。这样printf几乎不占用CPU时间。PY32F002B支持DMA,但通道资源有限,需要查手册确认UART1_TX对应哪个DMA通道。
6.2 环形缓冲+空闲中断接收
发送用DMA,接收可以用环形缓冲加空闲中断。串口接收中断里把数据存入环形缓冲,主循环慢慢处理。空闲中断用来判断一帧数据结束。这种方式适合接收不定长指令,比如AT指令或者自定义协议。
#define RX_BUF_SIZE 128 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head = 0, rx_tail = 0; void UART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE)) { uint8_t data = (uint8_t)(huart1.Instance->DR & 0xFF); uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { rx_buf[rx_head] = data; rx_head = next; } } if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 一帧接收完成,置标志位 } }实操心得:PY32F002B的UART空闲中断标志清除需要先读SR再读DR,顺序不能错,否则清不掉。我在这上面卡了半天,后来查手册才发现。
6.3 用DMA发送printf数据
把printf的输出先格式化到缓冲区,然后启动DMA发送。注意DMA发送期间缓冲区不能被修改,所以要么用双缓冲,要么等发送完成再格式化下一帧。
char dma_tx_buf[256]; void uart_dma_printf(const char *fmt, ...) { va_list args; va_start(args, fmt); int len = vsnprintf(dma_tx_buf, sizeof(dma_tx_buf), fmt, args); va_end(args); HAL_UART_Transmit_DMA(&huart1, (uint8_t*)dma_tx_buf, len); }这种方式适合低频打印,比如每秒几次。如果打印频率很高,还是建议用环形缓冲加DMA循环模式。
7. 从串口调试延伸出去的几个实用方向
串口调通之后,这块板子的玩法就多了。你可以用串口接收上位机指令,控制GPIO输出,做简单的命令解析器。也可以把传感器数据通过串口上报,配合串口调试助手的数据记录功能,做简易的数据采集。甚至可以用串口做IAP升级,通过上位机发送固件数据,Bootloader接收后写入Flash。
我最近一个项目就是用PY32F002B做温湿度采集,串口每5秒打印一次数据,格式是“T=25.3,H=60.2”,上位机用Python脚本读取串口并存入CSV文件。整个链路简单可靠,成本不到十块钱。
如果你想让交互更友好,可以移植一个轻量级的命令行shell,比如Letter Shell,支持Tab补全和历史命令。不过PY32F002B的RAM只有3KB,移植时要裁剪功能,只保留核心解析。我试过在2KB RAM的芯片上跑精简版shell,勉强够用,但要注意栈深度。
最后分享一个我常用的调试技巧:在串口输出里加时间戳。用一个1ms的SysTick计数器,每次printf前打印当前计数值,这样能直观看到各段代码的执行时间,定位性能瓶颈非常方便。比如:
printf("[%lu] Sensor init done\n", HAL_GetTick());这样一眼就能看出初始化花了多少毫秒,比盲猜强多了。