简介这份资源面向数字电焊机设计工程师及单片机爱好者提供基于普冉PY32F002A的焊机数字电压表完整工程。该芯片为国产M0内核32位单片机单价约0.5元性价比突出适合低成本仪表类项目参考。程序采用定时器中断计时切换任务函数的方法组织多任务是学习裸机任务调度的实用范例。压缩包共468个文件约4.13MB以321个C源文件与89个头文件为主体另含13个汇编文件、11个目标文件及Keil工程配置、映射与烧录文件涵盖HAL库驱动、DSP数学库与工程构建产物结构完整可直接编译研究。目前已有588人学习下载。读者可从中获取电压采集与显示逻辑、定时器中断任务切换框架、外设驱动配置及完整工程目录组织方式便于快速移植到同类焊机或仪表项目也可作为国产M0单片机入门与排错参考。1. 手工焊机数字电压表为什么选普冉PY32F002A一颗八毛钱MCU的极限压榨去年帮朋友改造一台老式手工焊机他想在面板上加个数码管电压表实时显示焊机输出端的直流电压。市面成品表头要么量程不对要么刷新率太低跳数跳得没法看。我第一反应是用常见的八脚单片机搭一个翻了一圈库存发现普冉PY32F002A这颗芯片值得认真聊一聊——它便宜到离谱TSSOP20封装主频最高24MHz内置12位ADC关键是它只有20KB Flash和3KB SRAM资源紧到让人头皮发麻。这个标题讲的就是在这种极限资源下怎么用定时器中断来分时切换任务函数把ADC采样、数码管动态扫描、电压换算和按键处理全部塞进一颗小芯片里。适合谁看适合手头有PY32F002A或者类似低端MCU、想做小体积低成本仪表、又不想上RTOS的嵌入式开发者。你不需要操作系统不需要复杂框架只需要一个定时器和一个任务调度表。2. 定时器中断分时调度没有RTOS怎么让四个任务同时跑2.1 为什么不用裸机while大循环新手写单片机程序最容易写成这样主循环里先采ADC再算电压再刷数码管再读按键循环往复。这种写法在任务少的时候能跑但一旦某个任务耗时变长——比如ADC采样需要等待转换完成或者数码管刷新需要保持一段时间——其他任务就会被阻塞。数码管会出现闪烁按键会反应迟钝ADC采样间隔也会变得不均匀。PY32F002A的ADC在12位分辨率下采样加转换大约需要几微秒到十几微秒看起来不长但如果你在while里用软件延时去等它CPU就白白浪费了。更麻烦的是数码管动态扫描你需要每隔几毫秒切换一位如果主循环里其他任务耗时波动扫描间隔就不稳定人眼就能看到闪烁。所以必须把时间基准从主循环里剥离出来交给硬件定时器。2.2 定时器中断做时间片的三个关键参数我一般用TIM1或者TIM14来做这个时间片配置成1ms中断一次。为什么是1ms因为数码管动态扫描通常需要每位保持1到5ms1ms作为一个基本节拍既能满足扫描需求又不会让中断过于频繁导致CPU开销过大。PY32F002A的主频如果跑24MHz1ms中断意味着24000个时钟周期进一次中断中断服务函数只要控制在几百个周期以内CPU占用率就很低。三个关键参数预分频器PSC、自动重装载值ARR、中断优先级。假设系统时钟24MHzPSC设为23得到1MHz的计数频率ARR设为999就是1ms中断一次。中断优先级方面如果还有别的中断比如串口接收定时器中断优先级可以设低一点但如果你对数码管刷新稳定性要求极高就把它设成最高优先级。// PY32F002A TIM1 初始化1ms 中断 void TIM1_Init(void) { RCC-APB2ENR | RCC_APB2ENR_TIM1EN; // 使能 TIM1 时钟 TIM1-PSC 23; // 24MHz / (231) 1MHz TIM1-ARR 999; // 1MHz / (9991) 1kHz - 1ms TIM1-DIER | TIM_DIER_UIE; // 使能更新中断 TIM1-CR1 | TIM_CR1_CEN; // 启动定时器 NVIC_EnableIRQ(TIM1_BRK_UP_TRG_COM_IRQn); NVIC_SetPriority(TIM1_BRK_UP_TRG_COM_IRQn, 0); }这段代码里PSC和ARR的取值直接决定了中断周期。注意PY32F002A的TIM1中断向量名和STM32不完全一样写的时候要查对应的头文件。中断服务函数里不要做浮点运算不要调用printf只做标志位置位和计数器递增。2.3 任务调度表的实现函数指针数组加时间片轮转调度的核心思路很简单定义一个函数指针数组每个元素对应一个任务函数再定义一个时间片计数器数组记录每个任务下次执行的时间点。定时器中断里只做一件事——递增全局毫秒计数器然后遍历任务表如果某个任务的到期时间到了就置位它的就绪标志。#define TASK_NUM 4 typedef struct { void (*func)(void); // 任务函数指针 uint16_t period; // 执行周期单位 ms uint16_t counter; // 当前计数 uint8_t ready; // 就绪标志 } Task_t; Task_t taskTable[TASK_NUM] { {Task_ADC_Sample, 10, 0, 0}, // ADC 每 10ms 采样一次 {Task_Display_Scan, 2, 0, 0}, // 数码管每 2ms 扫描一位 {Task_Key_Scan, 20, 0, 0}, // 按键每 20ms 扫描一次 {Task_Volt_Calc, 50, 0, 0}, // 电压换算每 50ms 一次 }; volatile uint32_t g_msTick 0; void TIM1_IRQHandler(void) { if (TIM1-SR TIM_SR_UIF) { TIM1-SR ~TIM_SR_UIF; // 清除中断标志 g_msTick; for (uint8_t i 0; i TASK_NUM; i) { if (taskTable[i].counter taskTable[i].period) { taskTable[i].counter 0; taskTable[i].ready 1; // 置位就绪标志 } } } }中断里只做计数和置标志真正的任务执行放在主循环里。主循环遍历任务表发现ready为1就调用对应函数调用完清零。这样每个任务的执行时间互不影响ADC采样不会被数码管刷新阻塞按键也不会因为电压换算耗时而漏检。注意任务函数里如果有耗时操作比如浮点除法要评估最坏情况下的执行时间。如果某个任务执行时间超过它的周期就会导致后续任务堆积。PY32F002A没有FPU浮点运算靠软件模拟能不用就不用。2.4 主循环怎么写才不拖后腿主循环的代码结构非常简洁int main(void) { SystemInit(); GPIO_Init(); ADC_Init(); TIM1_Init(); while (1) { for (uint8_t i 0; i TASK_NUM; i) { if (taskTable[i].ready) { taskTable[i].ready 0; taskTable[i].func(); // 执行任务 } } } }这里有个细节任务函数执行时间要尽量短。比如ADC采样任务不要在里面等转换完成而是启动转换后立刻返回下次任务到期时再读取结果。数码管扫描任务每次只刷新一位2ms后刷新下一位四位数码管8ms完成一轮刷新率125Hz人眼完全看不到闪烁。3. ADC采样与电压换算12位精度怎么用到刀刃上3.1 PY32F002A的ADC配置要点PY32F002A内置一个12位逐次逼近型ADC最多支持8个外部通道。配置的时候有几个关键点采样时间、参考电压、数据对齐方式。采样时间决定了内部采样保持电容的充电时间如果信号源内阻较大采样时间就要设长一些。我一般用最长的采样周期牺牲一点速度换稳定性。参考电压方面PY32F002A的VDD就是ADC参考电压。如果你用3.3V供电满量程就是3.3V。但焊机输出电压可能到几十伏必须用电阻分压。分压电阻的精度直接影响测量精度建议用1%精度的金属膜电阻。void ADC_Init(void) { RCC-APB2ENR | RCC_APB2ENR_ADCEN; ADC1-CFGR1 | ADC_CFGR1_CONT; // 连续转换模式 ADC1-CFGR1 | ADC_CFGR1_ALIGN; // 右对齐 ADC1-SMPR | ADC_SMPR_SMP_7; // 最长采样时间 ADC1-CHSELR | ADC_CHSELR_CHSEL0; // 选择通道0 ADC1-CR | ADC_CR_ADEN; // 使能 ADC while (!(ADC1-ISR ADC_ISR_ADRDY)); // 等待就绪 ADC1-CR | ADC_CR_ADSTART; // 启动转换 }这段代码里CONT位设为连续转换模式ADC会不停地采样。但我不建议在中断里直接读ADC数据因为连续模式下数据更新太快你读到的可能是旧值。更好的做法是用定时器触发单次转换每次采样任务启动一次转换下次任务读取结果。3.2 分压电路与校准两个电阻决定测量上限焊机输出电压范围通常是0到60V甚至更高而ADC输入不能超过VDD。假设VDD是3.3V分压比就要根据最大电压来算。如果最大测60V分压比至少是60/3.3≈18.2。用两个电阻上分压电阻R1180k下分压电阻R210k分压比就是(18010)/1019满量程对应62.7V留了一点余量。但电阻有误差ADC也有增益误差和偏移误差。我一般会在程序里做两点校准用已知电压V1和V2分别测量得到ADC读数D1和D2然后计算斜率和截距。这样可以把电阻误差和ADC误差一起补偿掉。// 两点校准计算 float adc_to_voltage(uint16_t adc_val) { // 校准点V110.0V - D1520, V250.0V - D22600 const float V1 10.0f, V2 50.0f; const float D1 520.0f, D2 2600.0f; float slope (V2 - V1) / (D2 - D1); float intercept V1 - slope * D1; return slope * (float)adc_val intercept; }这个函数里slope和intercept是提前算好的常数实际运行时只做一次乘法和一次加法。如果不想用浮点可以把slope放大1000倍变成整数最后结果再除以1000。3.3 数码管动态扫描与电压显示数码管用四位共阳管段选接GPIO位选通过三极管驱动。动态扫描的关键是刷新率要稳定不能有肉眼可见的闪烁。2ms刷新一位四位8ms一轮刷新率125Hz完全够用。void Task_Display_Scan(void) { static uint8_t digit 0; // 消隐 GPIOA-BRR SEG_ALL_PINS; // 输出段码 GPIOA-BSRR seg_code[display_buf[digit]]; // 选通位 GPIOB-BRR DIGIT_ALL_PINS; GPIOB-BSRR digit_pin[digit]; digit (digit 1) % 4; }display_buf里存的是四位要显示的数字seg_code是共阳数码管的段码表。注意消隐要在切换位选之前做否则会出现拖影。这个任务每2ms执行一次执行时间只有几十个时钟周期对CPU负担极小。4. 避坑与排查那些让我熬夜的翻车现场4.1 数码管闪烁中断里干了不该干的事现象数码管亮度不均匀某一位明显比其他位暗或者整体有轻微闪烁。原因我一开始把数码管扫描直接放在定时器中断里执行中断服务函数里做了GPIO操作和数组查表导致中断执行时间过长。1ms中断里如果执行超过200us主循环里的其他任务就会被严重挤压ADC采样间隔变得不均匀电压显示跳数。解决中断里只做计数和置标志所有GPIO操作和查表都放到主循环的任务函数里。中断服务函数控制在50us以内。4.2 ADC读数跳动参考电压不稳和采样时间不足现象电压显示最后一位不停跳动变化超过0.5V。原因两个问题叠加。一是VDD直接用开关电源供电纹波较大ADC参考电压不稳二是采样时间设得太短分压电阻180k内阻较大采样保持电容充电不充分。解决在VDD和GND之间加100nF和10uF电容滤波把ADC采样时间设到最长在软件里做滑动平均滤波连续采8次去掉最大最小值再取平均。4.3 任务周期冲突电压换算和ADC采样抢数据现象电压显示偶尔出现明显错误值比如显示0V或者满量程。原因ADC采样任务和电压换算任务周期没协调好。ADC每10ms采样一次电压换算每50ms执行一次但换算任务读取ADC数据时ADC可能正在更新读到了半新半旧的数据。解决ADC采样任务完成后把结果存入一个缓冲区电压换算任务从缓冲区读取。或者用双缓冲机制ADC写缓冲区A换算读缓冲区B定时切换。4.4 Flash不够用浮点库和printf的代价现象编译报错提示Flash空间不足。原因PY32F002A只有20KB Flash用了浮点运算和printf之后代码体积迅速膨胀。一个printf可能就占2KB以上浮点库也要占几KB。解决去掉printf用简单的整数转字符串函数代替浮点运算改成定点运算把电压值放大100倍用整数表示显示的时候再插入小数点。4.5 中断优先级配置错误导致死机现象程序运行一段时间后死机或者按键完全无响应。原因定时器中断优先级设得太高而且中断服务函数里没有正确清除中断标志导致中断反复触发主循环永远得不到执行。解决检查中断标志清除代码确保在中断服务函数开头就清除标志。中断优先级根据实际需求设置如果还有串口中断定时器中断优先级可以设低一些。5. 进阶技巧把3KB SRAM用到极致的几个习惯5.1 用联合体省内存PY32F002A只有3KB SRAM定义几个全局数组就用掉一大半。我习惯用联合体来复用内存比如显示缓冲区和ADC采样缓冲区可以共用一块内存因为显示和采样不会同时读写同一块区域。typedef union { uint8_t display_buf[4]; uint16_t adc_buf[8]; uint8_t raw[16]; } SharedMem_t; SharedMem_t g_shared;这个联合体只占16字节但可以当显示缓冲区用也可以当ADC缓冲区用。用的时候注意时序确保不会冲突。5.2 定点数代替浮点数电压换算用定点数把电压值放大100倍用uint32_t存储。比如实际电压12.34V存储为1234。显示的时候整数部分除以100小数部分对100取余。uint32_t voltage_fixed(uint16_t adc_val) { // slope 和 intercept 也放大 100 倍 // voltage slope * adc intercept return (uint32_t)(SLOPE_FIXED * adc_val) / 1000 INTERCEPT_FIXED; }这样整个换算过程只有整数乘法和除法速度比浮点快很多代码体积也小。5.3 用查表法代替实时计算数码管段码、按键键值、电压校准曲线这些都可以提前算好做成表格存在Flash里。查表比实时计算快而且不占SRAM。PY32F002A的Flash有20KB放几个小表格绰绰有余。5.4 验证方法用示波器看任务执行时间怎么确认任务调度是否合理我一般会在任务函数入口拉高一个GPIO出口拉低用示波器看脉冲宽度。每个任务的执行时间一目了然。如果某个任务脉冲宽度接近它的周期就说明该优化了。任务周期实测执行时间CPU占用ADC采样10ms80us0.8%数码管扫描2ms30us1.5%按键扫描20ms50us0.25%电压换算50ms120us0.24%总CPU占用不到3%剩下97%的时间CPU都在主循环里空转。如果你觉得浪费可以在主循环里加个低功耗指令让CPU在空闲时进入睡眠模式定时器中断唤醒。5.5 一个让我后悔没早做的习惯早期我写任务调度的时候没有给每个任务加执行时间监控。结果有一次电压换算任务里不小心加了一个除法执行时间从120us涨到800us虽然还没超过50ms周期但数码管扫描开始出现轻微抖动。后来我养成了一个习惯每个任务函数开头和结尾都操作一个调试GPIO用逻辑分析仪抓波形。这个习惯帮我提前发现了不少性能问题。如果你也在用PY32F002A或者类似的低资源MCU做仪表类项目建议从第一天就把任务执行时间监控加上。别等到数码管闪了、按键卡了再去查那时候排查成本高得多。希望帮到你。本文还有配套的精品资源点击获取