1. 为什么还在用ARM7跑焊接机控制第一次接触焊接机控制系统的人脑子里冒出来的第一个问题通常是现在Cortex-M4、M7满地跑RISC-V也卷得厉害为什么还要拿ARM7这种老架构来做主控我当初接手这个项目的时候也是这个反应。但把需求摊开一看就明白了——焊接机控制系统的核心诉求根本不是算力而是确定性和长期供货稳定性。焊接机的工作场景很具体一个焊接周期通常包含预压、放电、维持、冷却几个阶段每个阶段的时序精度要求达到毫秒级甚至亚毫秒级。比如储能焊的放电时间可能只有几毫秒电流爬升曲线必须严格受控否则焊点要么虚焊要么炸火。这种场景下CPU跑多快不是关键关键是中断响应时间可预测、任务切换抖动小、长时间运行不漂移。ARM7TDMI-S这颗核主频一般跑在40MHz到60MHz带片内Flash和SRAM外设资源对于焊接机来说够用PWM输出控制放电、ADC采样电流电压、GPIO驱动气缸和继电器、UART接触摸屏或上位机。它的三级流水线结构简单中断延迟相对固定没有Cache带来的不确定性大部分ARM7不带Cache或者Cache行为可预测这对硬实时控制来说反而是优势。μC/OS-II这个RTOS选型也是同样的逻辑。它内核精简源码公开任务调度基于优先级抢占最坏情况下的调度时间可以算出来。整个内核编译完占用的ROM大概6KB到10KBRAM占用取决于任务数量和栈大小通常几KB就够。对于焊接机这种功能相对固定、不需要跑文件系统、不需要网络协议栈的场景μC/OS-II的体量刚刚好。注意选ARM7μC/OS-II不是因为它先进而是因为它可预测、可验证、可长期维护。工业设备的生命周期动辄十年以上选型的核心指标是“十年后还能买到芯片、还能找到人维护”而不是“跑分最高”。这套组合能解决什么问题简单说就是把焊接机从“单片机裸跑大循环”升级到“多任务实时调度”。裸跑大循环的问题在于一旦某个环节比如ADC采样耗时波动整个焊接时序就会抖动焊点质量一致性差。用RTOS之后焊接时序控制、电流采样、人机交互、故障检测各自独立成任务优先级分明时序抖动可以控制在几十微秒以内。适合谁来参考如果你在做工业控制类项目主控资源有限但对实时性有要求或者你手上有ARM7的老平台需要维护升级这篇文章的内容可以直接拿去用。如果你只是想做个小玩具那没必要上RTOS裸跑更省事。2. 系统整体架构与任务划分思路2.1 硬件资源盘点与分配策略先把手上的牌看清楚。典型的ARM7焊接机控制板资源大概是这样资源类型具体配置用途分配CPUARM7TDMI-S 48MHz主控片内Flash256KB程序存储片内SRAM64KB任务栈全局变量缓冲区PWM2路放电控制、气缸驱动ADC8通道10位电流采样、电压采样、温度采样UART2路触摸屏通信、上位机调试GPIO32个继电器、限位开关、指示灯定时器3个32位系统Tick、焊接计时、看门狗64KB的SRAM看着不少但分到每个任务栈上就得精打细算。我的分配原则是优先级越高的任务栈给得越充裕因为高优先级任务一旦栈溢出后果是系统直接跑飞。低优先级任务栈可以压缩但也不能低于安全线。具体分配方案焊接时序控制任务优先级最高比如优先级4栈1KB电流ADC采样任务优先级次高优先级5栈512B故障检测任务优先级6栈512B人机交互任务优先级8栈1KB因为要处理字符串和协议解析上位机通信任务优先级10栈768B空闲任务优先级最低栈256BμC/OS-II最多支持64个任务但实际用6到8个就够了。任务太多反而增加调度开销和栈内存压力。2.2 任务优先级划分的底层逻辑优先级怎么定核心原则只有一条谁的截止时间最紧谁优先级最高。焊接时序控制任务的截止时间是以微秒计的。比如放电阶段PWM占空比需要在特定时刻精确切换晚了几十微秒就可能导致电流波形畸变。所以它必须是最高优先级而且它的执行时间要尽可能短——只做时序判断和PWM寄存器操作不做任何耗时计算。电流采样任务紧随其后。ADC转换完成后需要及时读取数据并做简单滤波如果被延迟太久采样窗口就错过了电流闭环控制就失效了。但它的截止时间比时序控制稍宽松所以优先级低一级。故障检测任务包括过流、过温、气压不足等判断。它的响应时间要求是毫秒级不需要微秒级所以优先级可以再低一级。但要注意故障检测任务一旦发现异常需要能立即抢占低优先级任务去执行保护动作所以它的优先级必须高于人机交互和通信任务。人机交互和上位机通信放在低优先级因为它们对实时性不敏感。触摸屏响应慢个几十毫秒操作员根本感觉不到。上位机通信更是可以容忍几百毫秒的延迟。实操心得μC/OS-II的任务优先级是唯一的不能有两个任务同优先级。分配的时候建议在相邻任务之间留出空隙比如用4、5、6、8、10这样的间隔方便后期插入新任务而不用大改优先级。2.3 任务间通信机制的选择μC/OS-II提供了信号量、互斥信号量、消息邮箱、消息队列、事件标志组这几种通信机制。选哪种取决于数据量和同步需求。焊接时序控制任务和电流采样任务之间需要同步时序任务启动ADC转换采样任务读取结果。这里用信号量就够了——时序任务释放一个信号量采样任务等待这个信号量。数据本身通过全局变量传递因为只有一个写者一个读者不需要额外保护。人机交互任务需要把参数修改比如焊接电流设定值、放电时间传递给时序控制任务。这里用消息邮箱比较合适因为传递的是一个结构体指针数据量不大而且需要保证时序任务能及时收到。消息邮箱的机制是发送方把消息指针放入邮箱接收方从邮箱取出。如果邮箱已满发送方可以选择阻塞或立即返回。故障检测任务需要向所有人机交互任务发送故障代码这里用消息队列。因为故障可能连续发生需要排队处理消息队列的FIFO特性正好满足。通信场景机制选择理由时序任务↔采样任务信号量只需同步数据量小人机交互→时序任务消息邮箱单条消息需要及时送达故障检测→人机交互消息队列多条消息需要排队多任务共享资源互斥信号量防止优先级反转互斥信号量在μC/OS-II中支持优先级继承能有效防止优先级反转问题。比如人机交互任务正在写一个全局参数结构体时序任务突然要读这个结构体如果没有互斥保护可能读到半新半旧的数据。用了互斥信号量之后时序任务会等待人机交互任务写完再读而且因为优先级继承人机交互任务的优先级会被临时提升到时序任务的级别避免被中间优先级的任务打断。3. 核心控制环节的实操细节3.1 焊接时序控制的实现与优化焊接时序是整个系统的灵魂。一个典型的储能焊时序是这样的预压阶段气缸下压等待限位开关触发延时50ms确保压力稳定放电阶段PWM输出触发可控硅放电时间可设通常1ms到10ms维持阶段保持压力延时可设通常5ms到50ms冷却阶段气缸抬起延时可设完成返回待机状态用μC/OS-II实现的时候最直接的做法是在一个任务里用OSTimeDly()做延时。但OSTimeDly()的精度取决于系统Tick频率。如果Tick设为1ms那延时精度就是1ms对于放电阶段来说太粗了。我的做法是粗延时用OSTimeDly()精确定时用硬件定时器中断。具体来说预压、维持、冷却这些阶段对精度要求不高用OSTimeDly()就够了。但放电阶段必须用硬件定时器。流程是这样的// 放电阶段伪代码 void WeldDischargeTask(void *pdata) { while(1) { // 等待放电启动信号 OSSemPend(WeldStartSem, 0, err); // 启动硬件定时器设定放电时间 Timer_SetDischargeTime(discharge_time_us); // 打开PWM输出 PWM_Enable(); // 等待定时器中断释放信号量 OSSemPend(DischargeDoneSem, 0, err); // 关闭PWM PWM_Disable(); // 通知采样任务停止 OSSemPost(SampleStopSem); } }定时器中断服务程序里只做一件事关闭PWM释放信号量。中断服务程序越短越好这是硬实时系统的铁律。void Timer_ISR(void) { PWM_Disable(); // 立即关闭输出 OSIntEnter(); OSSemPost(DischargeDoneSem); OSIntExit(); }注意OSIntEnter()和OSIntExit()必须成对使用而且要在中断服务程序的最外层调用。OSIntExit()会检查是否有更高优先级任务就绪如果有就触发任务切换。这个机制保证了中断返回后能立即执行最高优先级任务。放电时间的精度能做到多少实测下来用48MHz主频、定时器预分频设为1定时器分辨率是1/48MHz≈20.8ns。中断响应延迟加上OSIntEnter/Exit的开销总抖动大概在2μs到5μs之间。对于放电时间1ms以上的场景这个精度完全够用。3.2 电流采样与闭环控制的参数计算电流采样是保证焊点质量的关键。储能焊的电流波形通常是一个快速上升然后指数衰减的脉冲峰值电流可能达到几千安培。采样电阻分流器的方式最常见采样电阻把大电流转换成小电压ADC读取这个电压。假设采样电阻是0.1mΩ峰值电流5000A那么采样电阻上的电压是5000×0.00010.5V。ADC参考电压是3.3V10位ADC的分辨率是3.3/1024≈3.22mV。0.5V对应的ADC读数是0.5/0.00322≈155。这个读数偏小量化误差会比较大。改进方案有两个一是加一级运放放大把0.5V放大到3V左右二是换用12位或16位外置ADC。我选的是加运放成本低改动小。运放增益设为6倍0.5V变成3VADC读数约930量化误差从原来的1/155降到1/930精度提升6倍。采样时机也很关键。放电阶段的电流波形变化很快如果采样频率不够可能采不到峰值。根据奈奎斯特采样定理采样频率至少要是信号最高频率的2倍。但实际工程中为了捕捉峰值采样频率通常设为信号带宽的5到10倍。放电脉冲的上升时间大约100μs等效带宽约3.5kHz。采样频率设为20kHz即每50μs采一次。放电时间如果是5ms那就能采100个点足够描绘出电流波形。在μC/OS-II中ADC采样任务这样实现void AdcSampleTask(void *pdata) { while(1) { // 等待采样启动信号 OSSemPend(SampleStartSem, 0, err); for(int i 0; i SAMPLE_COUNT; i) { // 启动ADC转换 ADC_StartConversion(); // 等待转换完成用延时或查询方式 OSTimeDly(1); // 如果Tick是1ms这里精度不够 // 读取ADC值 adc_buffer[i] ADC_Read(); } // 采样完成通知控制任务 OSSemPost(SampleDoneSem); } }上面这段代码有个问题OSTimeDly(1)的精度是1ms而我们需要50μs的采样间隔。所以实际实现中不能用OSTimeDly()要用硬件定时器触发ADC转换或者用查询方式配合微秒级延时函数。我的做法是用硬件定时器触发ADCADC转换完成后产生中断中断服务程序把数据存入缓冲区。这样采样间隔由硬件定时器保证精度可以做到微秒级。void AdcTimer_ISR(void) { OSIntEnter(); ADC_StartConversion(); // 触发ADC OSIntExit(); } void AdcConversion_ISR(void) { OSIntEnter(); adc_buffer[sample_index] ADC_Read(); if(sample_index SAMPLE_COUNT) { sample_index 0; OSSemPost(SampleDoneSem); } OSIntExit(); }3.3 人机交互与参数存储的工程实现人机交互任务看起来简单实际上坑不少。触摸屏通信通常走UART协议可能是Modbus或者自定义的帧格式。解析协议的时候要注意不能在中断里做复杂解析中断只负责收字节存入环形缓冲区解析放在任务里做。环形缓冲区的实现要点#define UART_BUF_SIZE 256 typedef struct { uint8_t buffer[UART_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuffer; // 中断中调用 void UART_ISR(void) { uint8_t data UART_ReadByte(); uint16_t next (uart_rx_buf.head 1) % UART_BUF_SIZE; if(next ! uart_rx_buf.tail) { // 缓冲区未满 uart_rx_buf.buffer[uart_rx_buf.head] data; uart_rx_buf.head next; } // 如果缓冲区满了丢弃数据 }head和tail用volatile修饰因为它们在中断和任务中都会被访问。判断缓冲区满的条件是(head1)%SIZE tail这样会浪费一个字节的空间但实现简单不容易出错。参数存储用片内Flash模拟EEPROM。ARM7片内Flash通常支持按扇区擦除擦除次数有限一般10万次左右。焊接参数不会频繁修改所以寿命够用。但要注意Flash擦写期间CPU会暂停如果擦写时间太长会影响实时性。所以参数存储任务要放在最低优先级而且擦写前要确保焊接过程已经结束。void ParamSaveTask(void *pdata) { while(1) { // 等待保存请求 OSSemPend(ParamSaveSem, 0, err); // 检查是否在焊接过程中 if(weld_state ! WELD_IDLE) { // 焊接中延迟保存 OSTimeDly(100); OSSemPost(ParamSaveSem); continue; } // 关中断执行Flash擦写 CPU_SR_ALLOC(); OS_ENTER_CRITICAL(); Flash_EraseSector(PARAM_SECTOR); Flash_WriteBuffer(PARAM_ADDR, (uint8_t*)weld_params, sizeof(weld_params)); OS_EXIT_CRITICAL(); } }实操心得Flash擦写期间关中断是必要的否则中断服务程序可能从Flash取指令而Flash正在擦写时无法读取会导致取指错误。关中断的时间要尽量短擦除一个扇区通常需要几十毫秒这段时间系统无法响应中断。如果焊接机有紧急停止按钮这个按钮的信号必须用硬件直接切断PWM输出不能依赖软件响应。4. 系统优化与稳定性提升4.1 中断延迟的测量与优化中断延迟是硬实时系统的核心指标。它由三部分组成中断响应时间CPU完成当前指令并跳转到ISR的时间、ISR执行时间、任务切换时间如果有更高优先级任务就绪。ARM7TDMI-S的中断响应时间相对固定通常是几个时钟周期。但μC/OS-II的中断管理会增加一些开销。OSIntEnter()和OSIntExit()的执行时间取决于当前中断嵌套层数层数越深OSIntExit()检查任务就绪表的开销越大。测量中断延迟的方法用一个GPIO引脚在中断入口拉高在中断出口拉低用示波器测量高电平持续时间。这个时间就是ISR的执行时间加上μC/OS-II的中断管理开销。我实测的数据在48MHz主频下一个空的中断服务程序只调用OSIntEnter()和OSIntExit()执行时间约1.5μs。加上实际的PWM关闭操作总时间约2μs。这个延迟对于焊接控制来说完全可以接受。优化中断延迟的手段减少中断嵌套层数把不紧急的中断处理放到任务里做中断只做最紧急的事缩短ISR执行时间ISR里不做浮点运算、不做循环、不调用可能阻塞的函数提高系统Tick频率Tick频率越高OSTimeDly()的精度越高但中断开销也越大。1ms到10ms是常见选择我用的是1ms4.2 任务栈溢出的检测与预防μC/OS-II提供了栈溢出检测功能通过OSTaskCreateExt()创建任务时可以指定栈底标记系统在任务切换时检查栈底标记是否被覆盖。但这个功能会增加任务切换开销而且只能在栈溢出发生后才能检测到不能预防。预防栈溢出的根本方法是准确估算每个任务的最大栈使用量。估算方法静态分析看任务里调用了哪些函数每个函数的局部变量和调用深度动态测量任务创建时把栈全部填成特定值比如0xAA运行一段时间后检查栈中还有多少0xAA剩下的就是未使用空间我用的动态测量方法// 创建任务时填充栈 void Task_StackFill(void *pdata) { // 栈填充在OSTaskCreateExt中通过OPT参数指定 } // 检查栈使用量 uint16_t Task_CheckStack(OS_STK *stack_base, uint32_t stack_size) { uint32_t i; for(i 0; i stack_size; i) { if(stack_base[i] ! 0xAAAAAAAA) { break; } } return stack_size - i; // 已使用的栈空间 }实测下来焊接时序控制任务用了约300B栈人机交互任务用了约600B栈。我分配的是1KB和1KB余量充足。注意栈使用量会随着代码修改而变化每次修改代码后都要重新测量。特别是增加了新的函数调用或者使用了递归栈使用量可能大幅增加。4.3 系统Tick与功耗的平衡焊接机大部分时间处于待机状态这时候CPU不需要全速运行。ARM7支持空闲模式和掉电模式在空闲模式下CPU时钟停止外设继续运行中断可以唤醒。μC/OS-II的空闲任务里可以加入低功耗指令void Task_Idle(void *pdata) { while(1) { // 进入空闲模式等待中断唤醒 PCON | 0x01; // ARM7的空闲模式位 // 中断唤醒后继续执行 } }但要注意进入空闲模式后系统Tick中断会定期唤醒CPU所以功耗降低有限。如果Tick是1ms那CPU每毫秒被唤醒一次平均功耗大概降低30%到50%。如果焊接机有长时间待机需求可以在待机时把Tick频率降低比如从1ms改为10ms这样唤醒次数减少到原来的十分之一功耗进一步降低。但Tick频率改变会影响所有使用OSTimeDly()的任务需要重新计算延时参数。我的做法是待机时把Tick改为10ms同时把所有任务的延时参数乘以10。焊接启动时再改回1ms。这个切换过程要关中断防止Tick中断在切换过程中触发。void SetTickRate(uint8_t fast) { CPU_SR_ALLOC(); OS_ENTER_CRITICAL(); if(fast) { Timer_SetReload(TICK_1MS); OSTimeSet(0); } else { Timer_SetReload(TICK_10MS); OSTimeSet(0); } OS_EXIT_CRITICAL(); }5. 常见问题与排查实录5.1 焊接时序抖动问题的排查现象焊点质量不稳定偶尔出现虚焊或炸火。排查思路先确认是硬件问题还是软件问题。用示波器抓PWM输出波形看放电时间是否稳定。如果放电时间抖动超过10μs基本可以确定是软件问题。常见原因一中断被长时间关闭。检查代码中是否有地方长时间关中断比如Flash擦写、大块内存拷贝。解决方法是把耗时操作放到任务里做中断只做最紧急的事。常见原因二任务优先级分配不合理。如果电流采样任务的优先级高于焊接时序控制任务采样任务执行时间过长时会延迟时序控制。解决方法是确保时序控制任务优先级最高。常见原因三OSTimeDly()精度不够。如果放电阶段用OSTimeDly()做延时精度受Tick频率限制。解决方法是放电阶段用硬件定时器。排查工具用GPIO引脚做标记在关键代码段前后拉高拉低用示波器观察时间关系。这个方法简单粗暴但非常有效。5.2 通信丢包与数据错乱的解决现象触摸屏偶尔显示异常或者参数修改不生效。排查思路先检查UART波特率是否匹配再检查环形缓冲区是否溢出。常见原因一环形缓冲区溢出。如果中断接收数据的速度快于任务处理的速度缓冲区会满新数据被丢弃。解决方法是增大缓冲区或者提高任务处理速度。常见原因二数据竞争。如果任务在读取缓冲区时被中断打断而中断又修改了head指针可能导致读取到错误的数据。解决方法是读取head和tail时关中断或者使用原子操作。uint16_t RingBuffer_Available(void) { uint16_t head, tail; CPU_SR_ALLOC(); OS_ENTER_CRITICAL(); head uart_rx_buf.head; tail uart_rx_buf.tail; OS_EXIT_CRITICAL(); return (head - tail UART_BUF_SIZE) % UART_BUF_SIZE; }常见原因三协议解析错误。如果协议帧没有校验字段或者校验算法有误可能把噪声当成有效数据。解决方法是在协议中加入CRC校验解析时先校验再处理。5.3 系统死机与看门狗的配合现象焊接机运行一段时间后死机所有操作无响应。排查思路先看门狗是否触发复位。如果看门狗没有触发说明系统还在运行但某个任务卡死了。如果看门狗触发了复位说明系统完全跑飞了。常见原因一任务栈溢出。栈溢出会覆盖相邻内存导致不可预测的行为。解决方法是测量栈使用量确保余量充足。常见原因二死锁。如果两个任务互相等待对方释放信号量就会死锁。解决方法是避免嵌套等待信号量或者使用超时等待。// 使用超时等待避免永久阻塞 OSSemPend(Sem, timeout, err); if(err OS_ERR_TIMEOUT) { // 超时处理 }常见原因三看门狗喂狗时机不当。如果看门狗在中断里喂而中断还在正常运行但任务已经卡死看门狗就不会复位。解决方法是在最低优先级任务里喂狗这样只有所有任务都正常运行看门狗才会被喂。void Task_Idle(void *pdata) { while(1) { Watchdog_Feed(); PCON | 0x01; // 进入空闲模式 } }实操心得看门狗喂狗间隔要小于看门狗溢出时间的一半留出余量。比如看门狗溢出时间是1秒那喂狗间隔设为400ms比较安全。5.4 常见问题速查表问题现象可能原因排查方法解决方案焊接时序抖动中断被长时间关闭示波器抓PWM波形缩短关中断时间焊接时序抖动任务优先级不合理检查优先级分配时序任务设为最高优先级通信丢包环形缓冲区溢出检查缓冲区使用量增大缓冲区或提高处理速度通信数据错乱数据竞争检查head/tail访问关中断保护或原子操作系统死机任务栈溢出测量栈使用量增大栈空间系统死机死锁检查信号量等待使用超时等待看门狗误复位喂狗时机不当检查喂狗位置在最低优先级任务喂狗参数保存失败Flash擦写失败检查Flash状态寄存器擦写前先解锁擦写后检查6. 一些踩过的坑和实际体会Flash模拟EEPROM的时候我一开始没注意擦写寿命的问题。焊接参数虽然不常改但调试阶段频繁修改一天可能擦写几百次。Flash的擦写寿命是10万次按这个频率不到一年就写坏了。后来改成参数修改后先存在RAM里只有确认保存时才写入Flash而且写入前先比较新旧参数如果没变化就不写。这样擦写次数大幅降低。还有一次遇到一个诡异的问题系统运行几个小时后人机交互任务响应变慢。查了半天发现是消息队列满了。故障检测任务在检测到故障后会向消息队列发送故障代码但人机交互任务处理故障代码的速度跟不上。后来改成故障代码只保留最新的新的故障覆盖旧的用消息邮箱代替消息队列。因为对于操作员来说知道当前有故障就够了不需要知道历史上发生过多少次故障。μC/OS-II的OSTimeDly()在Tick频率改变时会有问题。如果任务正在延时这时候改变Tick频率延时时间会变得不准确。我的解决方法是改变Tick频率前先确保所有任务都不在延时状态或者接受短暂的延时误差。实际使用中待机切换时焊接过程已经结束延时误差影响不大。最后分享一个调试技巧在ARM7上跑μC/OS-II如果遇到莫名其妙的问题先检查栈。栈溢出是嵌入式系统最常见的问题之一而且症状千奇百怪。把每个任务的栈使用量打印出来看看余量够不够往往能发现问题的根源。
