1. 拿到PY32F002B开发板后,为什么第一步该调串口而不是点灯
很多人拿到一块新的MCU开发板,第一反应是写个GPIO翻转让LED闪起来,觉得看到灯亮了就说明板子没问题。这个思路放在STM32或者ESP32上没毛病,但放在PY32F002B这块板子上,我建议你把顺序反过来——先把串口打通,让printf能输出字符,再去折腾别的外设。
原因很直接:PY32F002B是一颗ARM Cortex-M0+内核的芯片,封装小、引脚少、SRAM和Flash都非常有限。这类资源紧张的芯片,一旦程序跑飞或者卡在某个初始化环节,你靠点灯根本判断不出问题出在哪——灯不亮可能是GPIO配错了,可能是时钟没使能,也可能是芯片压根没跑起来。但串口不一样,只要你能从串口收到一个字符,就说明时钟配置对了、GPIO复用功能配对了、波特率算对了、中断或者轮询逻辑跑通了,一次性验证了一整条链路。
我自己的习惯是:新板子到手,先花二十分钟把串口调通,后面所有调试都靠它输出信息。PY32F002B的串口配置和STM32F0系列非常接近,但有几个坑是它独有的,后面会详细说。
1.1 PY32F002B这颗芯片的定位和资源边界
先把这个芯片的基本盘说清楚,不然后面配置的时候你不知道哪些东西能省、哪些不能省。
PY32F002B是普冉半导体推出的一颗低功耗MCU,ARM Cortex-M0+内核,最高主频24MHz。Flash容量根据具体型号有16KB、20KB、24KB几个档位,SRAM只有3KB。封装常见的有TSSOP20、QFN20这种超小体积的。外设方面,它有两个USART、一个SPI、一个I2C、一个12位ADC、若干定时器,基本上该有的都有,但数量都很少。
3KB的SRAM是什么概念?你如果开一个256字节的串口接收缓冲区,再开一个256字节的发送缓冲区,就已经用掉六分之一了。所以在这颗芯片上做串口调试,核心思路就是:能省则省,不要搞复杂的环形缓冲区,不要开大的printf缓冲区,能用轮询就不用中断。
注意:PY32F002B的参考手册和例程包在官方渠道可以获取,但网上流传的很多例程是针对PY32F003或者PY32F030的,寄存器地址和外设数量有差异,直接拿来用会出问题。一定要确认你手上的例程对应的是F002B这个具体型号。
1.2 串口调试在这类小板子上的真实价值
有人会问,都什么年代了,还用串口调试?JTAG/SWD在线调试不香吗?
香,但要看场景。PY32F002B这种板子通常用在成本敏感的产品里,实际部署的时候你可能只有一个SWD接口,甚至为了省引脚连SWD都复用掉了。而且很多实际问题是运行时才出现的——比如某个中断偶发触发、某个状态机在特定时序下卡死,你在调试器里单步走很难复现。这时候串口打印就是最可靠的观测手段。
另外,printf输出这个东西,一旦调通了,后面写代码的效率会高一个档次。你可以直接在代码里插printf("adc value = %d\r\n", adc_val);,不用打断点、不用看变量窗口,信息直接刷到串口助手上。对于PY32F002B这种资源紧张的芯片,把printf定向到串口比定向到SWO或者半主机模式要实际得多。
2. 从零搭建PY32F002B的串口工程:时钟、引脚、波特率三件事
这一章是整篇的核心操作部分。我会按照实际配置的顺序来讲:先确认时钟树,再配GPIO复用,再算波特率,最后把printf重定向接上。每一步都会说清楚为什么这么做,以及PY32F002B和STM32在这方面的差异。
2.1 时钟配置:PY32F002B的HSI和PLL该怎么选
PY32F002B内部有一个HSI振荡器,默认频率是8MHz,可以通过PLL倍频到最高24MHz。也可以用外部晶振HSE,但很多小封装型号没有外部晶振引脚,所以大多数情况下我们用HSI+PLL。
串口波特率的精度直接取决于系统时钟的精度。HSI的精度一般在±1%到±2%之间,对于波特率9600、115200这些常见值来说,这个精度是够用的。但如果你要用更高的波特率,比如921600,HSI的偏差就可能导致误码。所以我的建议是:调试阶段用HSI跑24MHz系统时钟,波特率用115200,这个组合最稳。
配置时钟的代码大概长这样(基于常见的HAL库风格):
// 使能HSI RCC->CR |= RCC_CR_HSION; while(!(RCC->CR & RCC_CR_HSIRDY)); // 配置PLL:HSI/2 * 6 = 24MHz RCC->CFGR &= ~RCC_CFGR_PLLSRC; RCC->CFGR |= RCC_CFGR_PLLMUL6; RCC->CR |= RCC_CR_PLLON; while(!(RCC->CR & RCC_CR_PLLRDY)); // 切换系统时钟到PLL RCC->CFGR &= ~RCC_CFGR_SW; RCC->CFGR |= RCC_CFGR_SW_PLL; while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);这里有个细节:PY32F002B的PLL配置寄存器和STM32F0不完全一样,倍频系数的编码方式有差异。如果你直接抄STM32F0的代码,PLL可能不锁或者频率不对。一定要对着PY32F002B的参考手册确认PLLMUL字段的编码。
2.2 GPIO复用:TX和RX引脚到底接在哪两个脚上
PY32F002B的USART1默认引脚是PA9(TX)和PA10(RX),但这两个脚在很多小封装型号上可能没有引出来。所以实际用的时候,通常要把USART1重映射到PA2(TX)和PA3(RX),或者用USART2的PA14(TX)和PA13(RX)。
具体用哪组引脚,取决于你的开发板把哪两个脚引到了排针上。我手上这块板子是把PA2和PA3引出来了,所以用USART1的重映射功能。
配置GPIO的步骤:
// 使能GPIOA时钟 RCC->IOPENR |= RCC_IOPENR_GPIOAEN; // PA2配置为复用推挽输出,高速 GPIOA->MODER &= ~GPIO_MODER_MODE2; GPIOA->MODER |= GPIO_MODER_MODE2_1; // 复用模式 GPIOA->OTYPER &= ~GPIO_OTYPER_OT_2; // 推挽 GPIOA->OSPEEDR |= GPIO_OSPEEDER_OSPEED2; // 高速 GPIOA->AFR[0] |= (1 << 8); // AF1 = USART1 // PA3配置为复用输入 GPIOA->MODER &= ~GPIO_MODER_MODE3; GPIOA->MODER |= GPIO_MODER_MODE3_1; GPIOA->PUPDR |= GPIO_PUPDR_PUPD3_0; // 上拉 GPIOA->AFR[0] |= (1 << 12); // AF1 = USART1提示:RX引脚一定要配上拉,否则串口空闲时可能收到乱码。TX引脚不需要上拉,配推挽输出就行。这个细节很多例程里没写,但实际用的时候如果RX悬空,串口助手会一直收到0x00或者0xFF。
2.3 波特率计算:为什么你的115200可能变成114000
波特率的计算公式是:USARTDIV = fCK / (16 * baudrate)。
以24MHz系统时钟、115200波特率为例:USARTDIV = 24000000 / (16 * 115200) = 13.0208。
整数部分是13,小数部分是0.0208。小数部分乘以16得到0.333,四舍五入为0。所以BRR寄存器的值应该是(13 << 4) | 0 = 0xD0。
但如果你直接用这个值,实际波特率是24000000 / (16 * 13) = 115384,误差0.16%,这个误差在可接受范围内。但如果你的系统时钟不是精确的24MHz,比如HSI实际跑在23.8MHz,那误差就会累积。
我实测下来,PY32F002B用HSI跑24MHz、波特率115200的时候,串口助手收到的数据基本没有误码。但如果你把波特率提到230400以上,就建议用外部晶振了。
| 系统时钟 | 目标波特率 | BRR值 | 实际波特率 | 误差 |
|---|---|---|---|---|
| 24MHz | 9600 | 0x9C40 | 9600 | 0% |
| 24MHz | 19200 | 0x4E20 | 19200 | 0% |
| 24MHz | 115200 | 0x00D0 | 115384 | 0.16% |
| 24MHz | 230400 | 0x0068 | 230769 | 0.16% |
| 8MHz | 115200 | 0x0045 | 111111 | 3.5% |
从表里能看出来,8MHz时钟下跑115200的误差已经到了3.5%,这个就偏大了,串口助手可能会收到乱码。所以系统时钟尽量往高了配。
2.4 printf重定向:fputc和fgetc到底怎么写
printf重定向的本质是把C库标准输出流定向到串口。在ARM GCC环境下,需要重写_write或者fputc函数。在Keil MDK环境下,需要重写fputc。在IAR环境下,需要重写__write。
我用的是Keil MDK + ARMCC,所以重写fputc:
#include <stdio.h> int fputc(int ch, FILE *f) { // 等待发送数据寄存器空 while(!(USART1->ISR & USART_ISR_TXE)); // 写入数据 USART1->TDR = (uint8_t)ch; // 等待发送完成 while(!(USART1->ISR & USART_ISR_TC)); return ch; }如果你用的是GCC,代码要改成:
int _write(int file, char *ptr, int len) { for(int i = 0; i < len; i++) { while(!(USART1->ISR & USART_ISR_TXE)); USART1->TDR = ptr[i]; while(!(USART1->ISR & USART_ISR_TC)); } return len; }注意:重定向之后,一定要在工程设置里勾选"Use MicroLIB"(Keil)或者链接时加上
-specs=nosys.specs(GCC),否则printf可能不工作或者卡死。这个坑我踩过好几次,代码没问题,就是printf没输出,最后发现是没开MicroLIB。
3. 串口调通了但printf输出乱码?排查链路和根因分析
串口调试最让人抓狂的不是完全没输出,而是有输出但全是乱码。这一章我把常见的乱码原因按排查顺序列出来,你照着一步步查,基本能定位到问题。
3.1 第一层排查:波特率、数据位、停止位是否匹配
串口助手和MCU两边的配置必须完全一致:波特率、数据位(通常8位)、停止位(通常1位)、校验位(通常无)。任何一项不一致都会导致乱码。
我遇到过的情况是:代码里配的是115200,串口助手默认打开是9600,结果收到一堆乱码。这种问题最蠢但也最常见。所以第一步永远是确认两边配置一致。
PY32F002B的USART配置代码:
USART1->CR1 &= ~USART_CR1_UE; // 先关闭USART USART1->CR1 &= ~USART_CR1_M; // 8位数据 USART1->CR2 &= ~USART_CR2_STOP; // 1位停止位 USART1->CR1 &= ~USART_CR1_PCE; // 无校验 USART1->BRR = 0x00D0; // 115200 @ 24MHz USART1->CR1 |= USART_CR1_TE | USART_CR1_RE; // 使能收发 USART1->CR1 |= USART_CR1_UE; // 使能USART3.2 第二层排查:系统时钟是否真的跑到了预期频率
如果两边配置一致但还是乱码,就要怀疑系统时钟了。你以为跑的是24MHz,实际可能是8MHz或者别的频率。
验证方法很简单:用示波器或者逻辑分析仪量一下TX引脚上的波形,测一个位的宽度。115200波特率下,一个位的宽度是1/115200 ≈ 8.68微秒。如果量出来是26微秒左右,那说明实际波特率是38400,系统时钟可能只有8MHz。
没有示波器怎么办?可以写一个GPIO翻转的测试代码,翻转频率设成1kHz,然后用示波器量。或者更简单:把波特率降到9600,如果9600能正常输出,115200不行,那基本就是时钟频率不对。
3.3 第三层排查:printf的中文乱码和格式符问题
中文乱码是另一个高频问题。根本原因是编码格式不匹配:你的代码文件可能是UTF-8编码,但串口助手按GBK解码,或者反过来。
解决方法有两个:一是把串口助手的编码设成UTF-8,二是把代码文件转成GBK编码。我一般用后者,因为很多串口助手默认是GBK。
另外,printf的格式符也要注意。在ARMCC下,%f默认是不支持的,需要开启浮点打印支持。%d对应int,%ld对应long,%u对应unsigned int。如果你用错了格式符,输出的数值会完全不对。
// 正确的用法 int adc_val = 1234; printf("ADC: %d\r\n", adc_val); // 错误的用法:用%f打印int printf("ADC: %f\r\n", adc_val); // 输出会是0.000000或者乱码3.4 一个容易被忽略的坑:发送完成标志和TXE的区别
在fputc函数里,我用的是先等TXE(发送数据寄存器空),写入TDR,再等TC(发送完成)。这两个标志的区别是:TXE表示数据已经从TDR搬到了移位寄存器,可以写入下一个字节了;TC表示移位寄存器也发完了,整个帧发送完毕。
如果你只等TXE不等TC,在连续发送大量数据的时候没问题,但在发送最后一个字节后立刻进入低功耗模式或者关闭USART,最后一个字节可能没发完就被截断了。所以稳妥的做法是等TC。
但等TC也有代价:每次printf都要多等一个帧的时间。对于115200波特率,一个字节大概87微秒,等TC会多花几十微秒。如果你对性能敏感,可以只在关键位置等TC,平时只等TXE。
4. 进阶实战:用环形缓冲区让串口收发不再阻塞主循环
前面讲的printf重定向是阻塞式的:每调用一次printf,CPU就停在fputc里等发送完成。对于调试输出,这没问题。但如果你要在主循环里频繁打印,或者要在中断里打印,阻塞式就会影响实时性。
这一章讲怎么用环形缓冲区把串口收发做成非阻塞的。这个方案在PY32F002B这种资源紧张的芯片上要特别小心内存占用,我会给出一个精简版的实现。
4.1 环形缓冲区的最小实现:64字节够不够
3KB的SRAM,我建议发送缓冲区给64字节,接收缓冲区给64字节,一共128字节,占SRAM的4%左右。这个大小对于调试输出足够了。
环形缓冲区的核心结构:
typedef struct { uint8_t buffer[64]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; ring_buffer_t tx_buf; ring_buffer_t rx_buf;写入和读取的函数:
uint8_t rb_write(ring_buffer_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % 64; if(next == rb->tail) return 0; // 缓冲区满 rb->buffer[rb->head] = data; rb->head = next; return 1; } uint8_t rb_read(ring_buffer_t *rb, uint8_t *data) { if(rb->head == rb->tail) return 0; // 缓冲区空 *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % 64; return 1; }提示:head和tail要用volatile修饰,因为它们会在中断和主循环里同时被访问。另外,取模运算
% 64在Cortex-M0+上没有硬件除法,会比较慢。可以把缓冲区大小设成2的幂,然后用& 63代替% 64,速度快很多。
4.2 发送中断的使能时机:TXE中断不是随便开的
非阻塞发送的逻辑是:printf把数据写入tx_buf,然后使能TXE中断。在TXE中断服务函数里,从tx_buf读一个字节写入TDR。当tx_buf空了,关闭TXE中断。
关键点是:TXE中断的使能时机。如果你在写入第一个字节之前就使能了TXE中断,中断会立刻触发,但此时tx_buf里可能还没有数据,导致读取出错。正确的做法是:先写入数据,再使能TXE中断。
void uart_send_byte(uint8_t data) { while(!rb_write(&tx_buf, data)); // 等待缓冲区有空位 USART1->CR1 |= USART_CR1_TXEIE; // 使能TXE中断 } void USART1_IRQHandler(void) { if(USART1->ISR & USART_ISR_TXE) { uint8_t data; if(rb_read(&tx_buf, &data)) { USART1->TDR = data; } else { USART1->CR1 &= ~USART_CR1_TXEIE; // 缓冲区空,关闭中断 } } }4.3 接收中断和空闲中断的配合使用
接收这边,我建议用RXNE中断加IDLE中断的组合。RXNE中断在每收到一个字节时触发,把数据存入rx_buf。IDLE中断在总线空闲时触发,表示一帧数据接收完毕。
void USART1_IRQHandler(void) { // 发送处理 if(USART1->ISR & USART_ISR_TXE) { // ... 同上 } // 接收处理 if(USART1->ISR & USART_ISR_RXNE) { uint8_t data = USART1->RDR; rb_write(&rx_buf, data); } // 空闲中断 if(USART1->ISR & USART_ISR_IDLE) { USART1->ICR |= USART_ICR_IDLECF; // 清除空闲标志 // 这里可以设置一个标志位,告诉主循环一帧数据收完了 rx_frame_ready = 1; } }IDLE中断的好处是:你不需要知道对方发了多少个字节,只要总线空闲下来,就说明一帧结束了。这对于接收不定长数据特别方便。
5. 那些年我在PY32F002B串口上踩过的坑
这一章不讲理论,只讲实际踩过的坑。每个坑都是真实遇到过的,有些坑花了我大半天才定位到。
5.1 坑一:使能USART之前必须先使能GPIO时钟
这个坑说起来很蠢,但确实容易犯。PY32F002B的外设时钟是分开使能的,GPIOA、USART1各有各的时钟使能位。如果你先使能USART1再使能GPIOA,USART1的引脚复用可能不生效。
正确的顺序是:先使能GPIO时钟,配置GPIO复用,再使能USART时钟,配置USART。
RCC->IOPENR |= RCC_IOPENR_GPIOAEN; // 先GPIO // ... 配置GPIO复用 RCC->APBENR1 |= RCC_APBENR1_USART1EN; // 再USART // ... 配置USART5.2 坑二:TX引脚配置成复用输出后,还要设置AFR寄存器
PY32F002B的GPIO复用功能是通过AFR寄存器选择的。PA2和PA3的USART1功能对应的是AF1。如果你只配了MODER为复用模式,没配AFR,引脚不会有任何输出。
这个坑的隐蔽之处在于:代码编译没问题,运行也不报错,就是串口没输出。我第一次遇到的时候查了半天时钟和波特率,最后才发现是AFR没配。
5.3 坑三:printf的缓冲区溢出导致程序卡死
标准库的printf默认会使用一个缓冲区。在PY32F002B这种小内存芯片上,如果printf的格式化字符串很长,或者你连续调用printf,缓冲区可能溢出,导致程序卡死或者输出错乱。
解决方法:把printf的缓冲区设小一点,或者用setvbuf把缓冲区设成无缓冲。
setvbuf(stdout, NULL, _IONBF, 0); // 无缓冲无缓冲的代价是每次printf都会直接调用fputc,效率低一点,但在调试阶段完全够用。
5.4 坑四:串口助手的DTR/RTS信号导致芯片复位
有些串口助手在打开串口的时候会拉低DTR或者RTS信号。如果你的开发板把这两个信号接到了MCU的复位引脚或者BOOT引脚上,打开串口助手就会导致芯片复位或者进入Bootloader模式。
这个坑的表现是:程序烧进去之后运行正常,一打开串口助手芯片就重启。解决方法是在串口助手里把DTR和RTS关掉,或者硬件上不接这两个信号。
5.5 坑五:中断优先级配置不当导致串口数据丢失
PY32F002B的Cortex-M0+内核只有4级中断优先级。如果你把串口中断的优先级设得比较低,而系统里有其他高优先级中断频繁触发,串口中断可能被延迟响应,导致RXNE标志溢出,数据丢失。
我的建议是:串口中断优先级设成最高的0级或者1级,确保数据能及时取走。特别是用RXNE中断接收的时候,如果中断响应不及时,下一个字节来了就会覆盖上一个字节。
6. 串口调试之外:PY32F002B还能怎么玩
串口调通之后,这颗芯片还有很多可以折腾的地方。这一章简单聊聊后续可以扩展的方向,给已经跑通串口的读者一些思路。
6.1 用串口做命令行交互
printf输出只是单向的。如果你把接收也做好,就可以在串口助手里输入命令,MCU解析命令并执行。比如输入"led on"点亮LED,输入"adc"读取ADC值。
这个功能的实现思路是:在IDLE中断里设置帧接收完成标志,主循环检测到标志后解析rx_buf里的数据,根据命令内容执行对应操作。命令解析可以用简单的字符串比较,不需要复杂的解析库。
if(strcmp((char*)rx_buf.buffer, "led on") == 0) { GPIOA->BSRR = GPIO_BSRR_BS_5; // 点亮PA5 printf("LED ON\r\n"); }6.2 串口+ADC:做一个简易的数据采集器
PY32F002B有一个12位ADC,配合串口可以做一个简易的数据采集器。ADC采样之后,把数值通过串口打印出来,在串口助手里就能看到实时数据。
ADC的配置这里不展开,核心是:使能ADC时钟,配置采样通道,启动转换,读取结果,printf输出。注意ADC的参考电压通常是VDDA,如果VDDA不稳定,采样值会跳动。
6.3 串口+定时器:输出PWM占空比实时监控
如果你在用PY32F002B做电机控制或者LED调光,可以用串口实时输出PWM的占空比。这样在调参的时候,不用反复烧录程序,直接看串口输出就能知道当前参数。
实现方式是:在定时器中断里更新PWM占空比,同时把占空比数值存入一个全局变量。主循环里每隔一段时间printf一次这个变量。
6.4 关于低功耗模式下串口的注意事项
PY32F002B支持低功耗模式,但在低功耗模式下串口是停止工作的。如果你需要在低功耗模式下保持串口唤醒功能,要配置USART的唤醒中断,并且注意在进入低功耗之前把串口配置成能唤醒的模式。
这个部分比较复杂,涉及到的寄存器操作也多。我的建议是:调试阶段先不要碰低功耗,等串口和主要功能都稳定了,再考虑低功耗优化。
7. 完整代码框架和工程配置要点
最后把完整的代码框架整理一下,方便你直接参考。代码基于Keil MDK + ARMCC环境,使用HAL库风格但直接操作寄存器。
7.1 工程文件结构和编译选项
工程目录建议这样组织:
PY32F002B_UART_Demo/ ├── CMSIS/ │ ├── core_cm0plus.h │ └── py32f002b.h ├── User/ │ ├── main.c │ ├── uart.c │ ├── uart.h │ └── retarget.c └── Project/ └── PY32F002B.uvprojx编译选项里要加上:
-DUSE_MICROLIB(Keil下勾选Use MicroLIB)- 优化等级建议用
-O0或者-O1,调试阶段不要开高优化,否则断点和变量观察会不准
7.2 main函数里的初始化和主循环
#include "py32f002b.h" #include "uart.h" #include <stdio.h> int main(void) { // 时钟配置:HSI + PLL = 24MHz SystemClock_Config(); // 串口初始化 UART_Init(115200); // 重定向printf setvbuf(stdout, NULL, _IONBF, 0); printf("PY32F002B UART Demo Start\r\n"); printf("System Clock: %d Hz\r\n", SystemCoreClock); uint32_t counter = 0; while(1) { printf("Counter: %d\r\n", counter++); for(volatile int i = 0; i < 1000000; i++); // 简单延时 } }7.3 烧录和验证步骤
烧录工具用PY32官方的烧录器或者通用的SWD工具都行。烧录之后,打开串口助手,选择对应的COM口,波特率115200,8位数据,1位停止,无校验。如果一切正常,你应该能看到"PY32F002B UART Demo Start"和递增的Counter值。
如果没输出,按这个顺序查:
- 串口助手的COM口选对了吗
- 波特率、数据位、停止位、校验位匹配吗
- 开发板的TX引脚接到USB转串口模块的RX引脚了吗(TX接RX,RX接TX,别接反了)
- 开发板和USB转串口模块共地了吗
- 系统时钟配置对吗
- GPIO复用和AFR配置对吗
这六步查完,基本能解决90%的串口无输出问题。
7.4 一个实用的调试技巧:用GPIO翻转辅助定位
如果你实在找不到问题,可以在fputc函数里加一句GPIO翻转,然后用示波器或者逻辑分析仪看这个GPIO有没有波形。如果有波形但串口没输出,说明问题在串口配置或者硬件连接上;如果没波形,说明printf根本没被调用到,问题在代码逻辑上。
int fputc(int ch, FILE *f) { GPIOA->BSRR = GPIO_BSRR_BS_5; // PA5置高 while(!(USART1->ISR & USART_ISR_TXE)); USART1->TDR = (uint8_t)ch; while(!(USART1->ISR & USART_ISR_TC)); GPIOA->BSRR = GPIO_BSRR_BR_5; // PA5置低 return ch; }这个技巧我用了很多年,特别是在没有调试器或者调试器连不上的时候,一个GPIO加一个示波器就能定位大部分问题。
串口调试这件事,说难不难,说简单也不简单。PY32F002B这颗芯片的串口配置和STM32F0系列很像,但细节上的差异不少。把上面这些坑都避开,基本上一两个小时就能把printf跑通。跑通之后,后面所有的调试都会轻松很多。