ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

实时性不是跑得快:嵌入式系统中截止时间与失稳详解

实时性不是跑得快:嵌入式系统中截止时间与失稳详解 做嵌入式这些年每次有人问我“实时性是什么”我听到最多的回答就是“跑得快”“不卡顿”“响应及时”。这个理解不能算错但离真正的实时系统差得很远。尤其在第十七届蓝桥杯嵌入式真题、各种嵌入式面试题里反复出现的“实时性”与“截止时间”这两个词本质上讨论的是另一件事系统能不能在规定的截止时间之前完成规定的操作。快只是副产品准时才是硬指标。错过截止时间轻则功能异常重则整个系统震荡发散也就是所谓的“失稳”。这篇文章我想从几个层面把这件事拆开聊实时性的本质到底是什么截止时间错过之后系统为什么会“失稳”以及我们在做嵌入式开发时哪些代码习惯会悄悄谋杀实时性。内容不偏向某个具体平台裸机、RTOS、甚至嵌入式Linux都会涉及一点适合正在做控制类、通信类项目的朋友参考也适合准备嵌入式面试的人把“实时性”这个概念讲得比大多数人更清楚。1. 实时性拆开看不是处理器跑得快而是任务“不迟到”1.1 用公交车思维理解实时性很多人把“实时”和“高速”混在一起我习惯用一个比喻来解释高速处理器像一辆超跑零百加速两秒多但上了城市拥堵路段照样迟到而实时系统更像公交车速度一般但到站时间可以精确到分钟级每天同一时刻出现在同一个站台。嵌入式系统的“实时性”核心诉求是行为的确定性determinism也就是“在给定的条件下系统从事件发生到任务完成的时间能被严格控制在某个范围内”。这个范围就是我们说的截止时间deadline。至于这个时间范围是微秒级还是毫秒级取决于应用场景不取决于处理器主频。举个具体的例子一个使用1GHz应用处理器的Linux设备跑人脸识别算法可能只要30ms但整个系统从摄像头中断产生、事件队列调度、算法执行到输出结果的延迟可能抖动在10ms到50ms之间。另一个100MHz的单片机实时性反而可能更好——因为它的中断响应是确定性的上下文切换开销是确定性的任务的执行时间也是可以测到最坏情况的。确定性比绝对速度重要得多。1.2 “平均快”不等于“实时准”做实时系统的人最该警惕的就是“平均性能”这个概念。一个任务平均执行时间只有50微秒听起来很快但如果它的最坏执行时间WCET是5毫秒那在周期是1毫秒的控制回路里它依然会错过截止时间。实时性关注的是尾延迟是那条最长的尾巴而不是平均值。我在面试嵌入式岗位时经常问一个问题一个周期任务运行10次9次用了1ms1次用了10ms它的实时性合格吗绝大多数人会说“基本合格偶发抽风”。但在实时控制领域这个答案要反过来不合格甚至很危险。因为那一次10ms的超时可能就是系统从稳定变成发散的那一根稻草。控制系统设计时所有稳定性分析都是建立在“采样周期固定”这个前提下的一旦某个控制周期没有在截止时间内完成系统就会用过期数据计算相当于给控制环注入了一个未知扰动。这里有一个很常见的误区把实时性问题当成性能问题去优化。比如为了“提速”把任务从裸机搬到一个带缓存的MPU上结果缓存命中率不稳定执行时间的抖动反而更大了。再比如为了“提速”引入了动态内存分配结果malloc在堆碎片化严重时一次分配耗时可能是平时的几十倍。这些优化手段提升了平均速度但牺牲了确定性实时性不但没有变好反而变差了。2. 截止时间为什么要命错过它控制环真的会“物理失稳”2.1 硬实时、固实时、软实时的区别聊截止时间之前得先分清楚实时性的等级。学术界和工业界一般把实时性分成三类类型错过截止时间的后果典型场景硬实时系统失效甚至引发安全事故安全气囊点爆、伺服驱动电流环、电网保护装置固实时结果迟到就失去意义但系统不会崩溃音视频解码、雷达数据融合软实时偶尔迟到可容忍但要保证统计指标触摸屏事件处理、网络协议栈、GUI刷新硬实时和固实时的区别很微妙我用一句话概括固实时迟到结果是垃圾硬实时迟到系统整个完蛋。硬实时系统的截止时间通常是物理规律给的没有任何商量的余地。以电机控制为例FOC磁场定向控制的电流环周期通常是10kHz到20kHz也就是50到100微秒要完成一次完整的采样、坐标变换、PI调节和PWM比较值更新。如果某个周期超时了PWM输出就会晚一拍电流波形会出现畸变电机噪音变大母线电压波动加剧。如果持续错过多个周期电流环可能直接发散驱动器过流保护跳闸再严重一点功率模块都可能烧掉。这就是“失稳”的物理形态。2.2 控制回路里的失稳是怎么一步步发生的很多做上层应用的工程师不理解“失稳”到底是个什么状态我习惯把它拆成三个递进的阶段来分析。第一阶段是数据过期。控制系统的标准流程是传感器采样→处理器计算控制量→执行器输出。如果计算超过采样周期下一次采样到达时上一次控制量才刚要输出系统相当于滞后了一个周期在运作。第二阶段是相位滞后。自动控制原理里有一个基本结论反馈回路中的延迟会降低相位裕度。采样-计算-输出的延迟每多出一个周期信号的相位就多滞后一块。相位裕度变小之后系统对参数变化的敏感性急剧增加原本稳定的闭环可能变成临界稳定。第三阶段是振荡发散。当相位滞后累积到一定程度控制器输出的控制量不但没有校正误差反而在误差方向上“推了一把”。这时候系统就会震荡幅度逐渐增大直到触发保护或者物理损坏。我们常说的“飞控炸机”“伺服啸叫”“电源环路振荡”很多根源就是实时性失守。我用一个直观的例子说明。无人机姿态控制中陀螺仪以1kHz的频率输出角速度数据飞控需要在1ms内完成姿态解算和控制输出。如果某个控制周期用了2ms才完成那飞控用的就是1ms之前的旧陀螺数据来算当前的控制量。姿态变化越快旧数据和当前真实姿态的偏差就越大。当飞行器处于剧烈机动状态时这个偏差带来的“额外扰动”可能超过控制器自身的修正能力飞机就会开始颤抖接着彻底失控。大家看到的“炸机”不一定是算法写得不好有时候是实时性链条崩断了。2.3 错过截止时间还有一种隐蔽模式优先级反转说到截止时间失守优先级反转Priority Inversion是绕不开的话题。这是个经典的实时系统问题也是每次嵌入式面试几乎必问的考点。优先级反转的场景是这样的系统里有三个任务A优先级最高B中等C最低。A和C共享一把互斥锁C先拿到了锁然后被B抢占因为B优先级高于C但低于A。现在A被唤醒了它需要等C释放锁但C被B压着根本没有机会运行于是A只能干等。结果是最高优先级的A被中等优先级的B阻塞因为一个低优先级的C卡在中间。A的截止时间就这么被错过了。优先级反转的危害在于它让优先级调度彻底失效系统行为变得不可预测。火星探路者在1997年就栽在这个问题上任务调度被周期性的优先级反转卡住系统反复重启。后来工程师通过在地面复现确认了是高优先级任务被低优先级任务阻塞最终用VxWorks的优先级继承机制修复了。解决优先级反转的两种主流方案优先级继承和优先级天花板。优先级继承的思路是当高优先级任务被低优先级任务持有的锁阻塞时把低优先级任务的优先级临时提升到高优先级任务的水平让它尽快跑完释放锁。优先级天花板的思路更激进静态分析出每把锁可能被哪些任务使用把持锁任务的优先级直接提到所有使用者的最高优先级之上从根源上杜绝反转发生。3. 调度、中断和代码习惯三个最容易弄丢截止时间的环节3.1 先算一笔账你的调度方案真的可行吗很多人写实时系统习惯是“先把任务写出来再琢磨调度”。这个顺序反了。合格的实时系统应该先做调度可行性分析再决定任务怎么拆、优先级怎么定。在单核处理器上经典的可调度性分析工具是速率单调调度RMS和最早截止时间优先EDF。RMS是静态优先级调度任务的优先级根据周期决定周期越短优先级越高。RM调度的充分条件是所有周期任务的CPU利用率之和小于 $U n(2^{1/n} - 1)$其中n是任务数量。当n趋向无穷大时U的极限大约是ln2约等于0.693。举个例子系统里有三个周期任务周期分别是10ms、20ms、50ms对应的最坏执行时间分别为3ms、2ms、4ms。它们的CPU利用率是3/10 2/20 4/50 0.3 0.1 0.08 0.48。n3时U3×(2^(1/3)-1)≈0.78。0.48小于0.78所以用RMS调度这三个任务理论上可以保证在截止时间内完成。但这只是充分条件不是必要条件利用率高于0.78也不一定必然失败需要做更精确的最坏响应时间分析Response Time Analysis。我见过不少项目在需求阶段没有做这个数学计算等到联调时才发现高优先级任务一直抢不到CPU辛辛苦苦调好的控制算法在强负载下频繁超时。早算这笔账能省掉后面一大半的调试时间。顺便说一句EDF在理论上能实现更高的利用率最多到100%但在实际嵌入式系统里动态优先级带来的调度开销和不可预测性往往让它的优势发挥不出来所以工业界还是静态优先级调度如RMS更常见。3.2 中断优先级配置一个容易失衡的地方在有RTOS的嵌入式系统里任务的优先级是由调度器管的但中断的优先级是由硬件控制器管的。两者之间的交互是错过截止时间的一个高发区。先澄清一个概念中断不是任务中断优先级也不是任务优先级。在Cortex-M内核中中断可以抢占正在运行的任何任务包括最高优先级的任务。所以如果系统中有一个频繁触发的中断它的ISR执行时间又很长那么无论任务优先级怎么设计所有任务都可能被这个中断反复打断导致截止时间失守。我见过一个真实的“事故”有人在STM32上用PWM中断做按键消抖中断服务函数里有毫秒级的延时系统跑起来后其他一切任务都被拖垮。后来把按键检测从ISR里挪到低优先级的任务里问题立刻解决。在实际项目中我建议遵守这几条铁律ISR里只做最紧急的事读寄存器、清标志、记录时间戳、给任务发一个信号量。真正的处理逻辑放到任务里去做。这是嵌入式领域经典的上半部/下半部模型去中断上下文回任务上下文。尽量不关中断关也要短进入临界区时通常是关中断或者用BASEPRI屏蔽低优先级中断。这个时间控制在几微秒以内比较合适。如果一段临界区要执行上百微秒一定要重新设计逻辑。时间和安全相关的中断优先级最高比如PWM周期中断、编码器捕获中断、故障保护中断。这些中断优先级要设成系统最高档不允许被其他普通外围中断干扰。3.3 那些让你的执行时间“发飘”的代码习惯执行时间的抖动Jitter是实时性的大敌。很多任务的平均执行时间很好看但最坏执行时间大得离谱根源就是代码里埋了确定性的“炸弹”。第一个炸弹是动态内存分配。malloc/free的耗时和堆当前的状态强相关。堆碎片化之后一次malloc可能需要遍历大量的空闲块最坏耗时可能是平均耗时的几十倍。实时系统里尽量在系统初始化阶段把内存一次性申请好运行期间不再动态分配。RTOS的任务栈、消息队列、信号量凡是能在启动时静态创建的就不要在运行中再创建。第二个炸弹是缓存。CPU缓存命中时访问内存只要几个周期未命中时要到DDR去取可能几百个周期。任务的执行时间会因此大幅波动。实时系统做缓存管理有两种思路一是用锁缓存行cache lock把关键代码和数据固定在缓存里保证这部分访问时间是确定的二是在关键路径之前主动做缓存预取或clean把不确定性转移到非关键路径上。第三个炸弹是分支预测和乱序执行。现代处理器上条件分支的预测错误会带来管道冲刷的惩罚执行时间会出现明显的抖动。如果在硬实时代码里大量使用难以预测的分支比如依赖外部输入数据的条件跳转WCET分析会变得非常困难。尽量用查表、无分支算法branchless替代复杂分支或者至少把易于预测的分支放在关键路径上。第四个炸弹是阻塞等待。任务里直接while循环等待某个外设标志是一种典型的确定性杀手。正确的做法是使用中断信号量/事件标志组让任务在等待时让出CPU而不是空转消耗时间。这是一个跟执行时间无关的问题但同样会直接错过截止时间。4. 实操怎么测量和验证一个嵌入式系统是否真的实时4.1 不要“感觉实时”要“测出实时”做嵌入式系统的实时性优化最忌讳凭感觉判断“应该来得及”。要证明一个系统满足实时性要求唯一的方法是实测。我常用的测量手段分硬件和软件两条路。硬件手段是用逻辑分析仪或示波器测GPIO电平翻转。比如在周期任务的开头和结尾各翻转一次某个引脚用示波器测量高电平的持续时间就能直接看到任务的执行时间。再比如在中断入口处翻转一个引脚可以测中断响应延迟。这种方法最直观也最可靠因为测量本身不依赖被测系统的任何软件资源。软件手段是使用调试器和RTOS的trace工具。FreeRTOS可以开启系统视图System View的traceSEGGER SystemView、Tracealyzer这些工具能以时间轴的方式显示任务切换、中断触发、信号量获取的完整时序。从这些图上你能一眼看到哪个任务在哪个时间段被抢占了哪个任务在等待信号量上浪费了大量时间。我给一个实测脚本简单但很实用在周期任务里记录每次运行的实际耗时可以用硬件定时器计数器把最大值、最小值、平均值、超时次数统计出来。系统跑上几个小时甚至几天观察最大耗时是否触碰到了截止时间红线。这个数据比任何仿真分析都有说服力。4.2 一个完整的实时性测试思路我自己的一个标准流程是这样的第一步明确截止时间。控制周期是多少从事件到响应允许的最大延迟是多少把这些写成代码里的宏定义不要留在需求文档里睡觉。第二步测量每个任务的最坏执行时间。在任务里插入时间戳记录开中断让系统在负载最高的条件下连续跑记录每个任务的WCET。注意要覆盖各种极端输入场景比如最复杂的分支路径、最长的循环次数、最差的内存访问模式。第三步计算利用率做可调度性分析。根据任务的周期和WCET算出CPU利用率用RMS或其他算法的判定条件做预检。如果利用率过高要么拆分任务降低WCET要么降低任务周期要么换更快的处理器。第四步跑长稳压测。系统的实时性问题往往不是刚开机就暴露的要跑几小时甚至几天。过程中要持续记录超时次数看趋势是收敛还是发散。第五步做异常注入。人为制造一些异常条件把某个外设的响应变慢、让一个低优先级任务持续占用CPU、故意触发优先级反转场景。看系统在异常下还能不能保住截止时间。这个环节最能暴露系统的真实鲁棒性。4.3 利用定时器计数器做精确时间戳很多MCU的定时器是16位或32位的频率很高。用定时器的计数器做时间戳时要注意溢出处理。16位计数器在72MHz主频下约0.9毫秒就溢出了一次不做处理的话时间戳会被截断。我的做法是在定时器溢出中断里维护一个变量做计数器高位取时间戳时先禁掉该定时器中断读出计数器和溢出次数再恢复中断。这样可以得到一个不会溢出、且不会因竞争条件读错的高精度时间戳。类似这样volatile uint32_t timer_overflow_count; volatile uint16_t last_counter; void TIMx_IRQHandler(void) { if (TIM_GetITStatus(TIMx, TIM_IT_Update)) { TIM_ClearITPendingBit(TIMx, TIM_IT_Update); timer_overflow_count; } } uint32_t get_timestamp_us(void) { uint32_t ticks; uint32_t overflow; __disable_irq(); overflow timer_overflow_count; ticks TIMx-CNT; __enable_irq(); // 依据定时器频率把ticks换算成微秒 return overflow * (uint32_t)(1000000 / TIMER_FREQ) ticks / (TIMER_FREQ / 1000000); }这里要特别注意取时间戳时关中断是为了防止溢出中断恰好发生在读取序列中间导致读到不一致的状态。关中断的时间极短对实时性的影响可以忽略。如果你用的是Cortex-M自带的DWT计数器精度更高而且不需要额外配置定时器很多调试工具都基于它做时间测量。5. 常见问题与排查速查当你觉得系统“不稳”时先查这几件事5.1 一张排查表覆盖最常见的超时场景现象可能原因排查方法高优先级任务仍然错过截止时间被更高优先级中断抢占或自身临界区/关中断段太长测量中断响应延迟检查代码中所有关中断段长度观察ISR执行时间系统偶发卡顿几十微秒到几百微秒动态内存分配、缓存未命中、外部总线事务被其他主设备抢占用时间戳统计任务执行时间最大值对比WCET替换掉malloc考虑缓存锁定优先级反转导致任务调度错乱低优先级任务持有锁中优先级任务抢占检查互斥锁的使用场景开启优先级继承或优先级天花板协议运行一段时间后系统死机或重启任务栈溢出、看门狗超时未喂、堆耗尽检查任务栈使用峰值确认所有任务喂狗周期是否小于看门狗超时开启内存统计控制回路在负载高时性能骤降利用率超标调度器在多个任务间抖动截止时间被挤压计算CPU利用率做可调度性分析必要时降低低优先级任务频率或拆分任务进入某个分支后执行时间暴涨分支判断后执行了复杂计算或触发了缓存驱逐用trace工具定位执行时间最长的路径将复杂计算从实时路径移走添加分支耗时保护5.2 我个人排查时的一个总顺序当我拿到一个“实时性有问题”的反馈时不会急着看算法逻辑而是先按这个顺序排查第一打开所有能开的时间统计。把每个实时任务、每个关键中断的入口和出口都打上时间戳先看数据在说话而不是猜问题。第二对比“最坏”和“平均”。如果任务的执行时间最大最小值差距超过3倍先怀疑缓存和分支的影响如果差距不大但平均值本身就接近截止时间先怀疑CPU利用率超标如果出现偶尔一次特别大的尖峰先怀疑动态内存分配、DMA总线冲突或者外部中断风暴。第三画调度时序图。用SystemView或Tracealyzer把调度过程可视化重点看谁在什么时间段抢占了谁。优先级反转、中断风暴、任务饿死这些时序图上一眼秒懂。第四压测和长稳跑。保持最恶劣负载跑几小时记录超时次数和趋势。如果超时次数是持续增长的说明系统存在累积性问题如果是偶发且不增长大概率是缓存命中或总线竞争引起。5.3 分享几条我实践中的铁律做实时系统这几年来我总结出几条近乎偏执的习惯现在分享出来供大家参考。每个周期任务在代码注释里写清截止时间和WCET预算。如果一个任务的截止时间是10毫秒最坏执行时间是8毫秒那么在需求阶段就要敲响警钟因为裕量只有20%。我个人的做法是实时任务的预算最多占截止时间的70%剩下的留给调度抖动、中断抢占和未来功能扩展。不要相信“平均执行时间”只信“最坏执行时间”。这是实时性和普通性能开发最大的区别。普通应用看平均和分位数实时系统看的是上限。每次代码变更后都要重新测WCET不要假设改动不影响执行时间。所有阻塞型系统调用都要加超时参数。RTOS的信号量、消息队列获取几乎都支持等待超时参数。永远不要用无限等待否则一个外力导致信号量不来任务就会在等待队列里卡死整个控制链条断掉。就算逻辑上“信号量一定会来”也要设置一个合理的超时在超时分支里做错误处理。写在最后一个关于实时性的小习惯最后分享一个我自己的习惯。每次新项目启动时我会在代码初始化阶段做一次“实时性体检”把所有实时任务的截止时间都以宏定义的形式集中在一处启动时对所有任务的CPU利用率做一次静态检查如果超过设定阈值直接打印告警编译期提示。这个习惯帮我拦截了不少潜在问题。比如某次新需求加了一个密集型的通信任务如果靠人工肉眼判断很难发现问题但启动时的诚实的利用率检查直接告诉我们实时任务预算已经用满了不加独立处理器/降级方案的话迟早会出事。所谓实时性说到底就是这种“事事有预算、时时可验证、处处留证据”的系统工程习惯。希望大家可以在自己的项目里试试这套方法论。
返回列表