尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从偶发卡顿到根因:机器人RTOS优先级反转与调度抖动排查

从偶发卡顿到根因:机器人RTOS优先级反转与调度抖动排查 机器人关节在低速爬行时忽然顿一下伺服电流环里冒出一声异响轨迹精度莫名其妙超差——这种偶发卡顿是嵌入式机器人开发里最磨人的一类问题。很多人第一反应是电机参数没调好、编码器有干扰、机械有间隙折腾一圈后发现根子上是RTOS 的调度与优先级反转在作怪。这篇文章不打算复述教材里的调度算法定义而是把我在实际机器人控制器上遇到的一次完整卡顿排查摊开讲抖动是怎么被量化出来的、抢占式调度的时间账本怎么算、优先级反转的三段式现场长什么样、怎么用互斥量和队列把它按死在设计阶段以及从裸机迁到 RTOS 时那些没人提醒你的坑。适合已经写过裸机控制循环、准备上 RTOS 或者正在被 RTOS 抖动折磨的工程师也适合想搞明白优先级反转到底是什么的入门读者——不需要你懂调度器源码但需要你愿意动手量一次波形。1. 机器人抖动现场从偶发卡顿到示波器上的一根毛刺先说清楚现象因为卡顿这个词太笼统了不同的人嘴里的卡顿根本不是一回事。我遇到的场景是六轴协作臂的关节控制器主控跑 RTOS1kHz 的速度环 10kHz 的电流环插补周期 1ms。表现是手动示教低速运行基本正常一旦切换到轨迹模式做连续圆弧插补大概每隔几十秒会出现一次单周期延迟肉眼看上去就是末端顿了一下用激光跟踪仪测轨迹偏差能到 0.3mm 左右超出允许范围一截。这个现象最坑的地方在于它不是持续的复现概率大概只有几个百分点你盯着看十分钟可能一次都不出。1.1 卡顿在机器人上到底长什么样很多人的第一直觉是把卡顿等同于任务没跑完其实在实时系统里卡顿更准确的定义是单个控制周期的实际执行时刻偏离了理论时刻。这个偏离量就是抖动英文叫 jitter。同样一个 1ms 周期的控制任务理想情况下每次都在 t0、1、2、3ms 触发抖动为零实际上它可能在 1.000ms 触发下次在 1.037ms 触发再下次回到 2.001ms。这 37μs 的偏差就是抖动它本身不改变任务的执行时间但改变了电流环采样与 PWM 更新之间的相位关系对 FOC 这种对相位敏感的控制算法来说相位一乱电流波形上就出现毛刺再经过减速器放大就变成了你能听见的那声异响。我在现场做的第一件事就是把这个感觉变成数字把控制任务的入口到一个空闲 GPIO 拉高出口拉低用示波器看这个方波的周期。自由运行模式下方波周期应该死死钉在 1.000ms实测大部分时间是 1.000ms ± 2μs偶尔蹦到 1.037ms、1.052ms。有了这条波形问题就从感觉有点卡变成了最大抖动 52μs需要定位这 52μs 从哪来。这一步非常关键因为它把后续所有讨论都锚定在可测量的量上而不是靠猜。1.2 为什么偶发两个字最要命持续性的延迟反而好查因为你可以稳定复现、逐步二分定位。偶发延迟的麻烦在于它的触发条件往往藏在时序的交叉点上低优先级任务刚好持有锁、中优先级任务刚好开始跑一段长计算、一个中断刚好在临界区结束后挤进来。这三个条件同时满足的概率可能只有百分之几但机器人是连续运行的设备一天跑八小时百分之几的概率意味着每天都会出问题。这里有个经验值得记下来偶发问题不要试图一次抓全先想办法提高复现概率。我当时的做法是人为给中优先级任务加负载——把通信任务的数据处理量放大三倍同时让低优先级任务更频繁地访问共享资源。改完之后复现概率从 3% 涨到了 60% 以上问题立刻从玄学变成了可以坐下来慢慢看的普通 bug。这个手法在后面第 5 章还会细讲因为它本质上是把反转的窗口撑大。1.3 先给抖动分类三类源头别混在一起查在动手之前先做个分类判断能省下大量时间。机器人控制器的抖动来源基本可以归成三类它们的特征完全不同抖动类型典型量级时间特征主要诱因中断抖动1~20μs单次、随机临界区过长、中断优先级配置错误、中断嵌套调度抖动10μs~10ms周期性或准周期性优先级反转、时间片轮转、任务阻塞超时通信抖动100μs~数ms与总线周期相关现场总线丢帧重传、CAN 总线仲裁冲突、协议栈缓冲不足我这台设备的抖动是 50μs 量级、偶发、与总线负载有相关性第一眼像是通信类但实测总线周期抖动只有几个微秒排除了。剩下的就落在调度抖动上而调度抖动里最经典、最容易被忽略的成因就是接下来要讲的优先级反转。2. 抢占式调度的时间账本tick、上下文切换与中断延迟要理解反转得先理解调度器到底在你背后做了什么。很多人用 RTOS 的状态是能跑就行反正vTaskDelay好使、队列好使至于每次切换花了多少时间、tick 是怎么打断任务的一概不清楚。这种状态下遇到抖动基本只能靠猜因为你连时间账本上有几个科目都不知道。2.1 一次上下文切换要花掉多少时间Cortex-M 系列上任务切换通常由 PendSV 异常完成硬件会自动压栈 8 个寄存器xPSR、PC、LR、R12、R3~R0剩下的寄存器由软件压栈。整个上下文切换的开销取决于几个因素主频、是否使用浮点、是否需要搬运 FPU 寄存器、栈是否命中缓存。在 72MHz、无 FPU 的 M3 上我实测下来一次切换大约 1.5~2.5μs开了 FPU 的 M4 在切到有浮点上下文的任务时多出来的那部分懒加载/压栈开销会让它逼近 5μs。这个数字听起来不大但你要算总账。假设系统里有 8 个任务都在活跃一个 1ms 周期的控制任务被切换出去又切回来中间可能经历 4~6 次上下文切换那就是 10μs 级别的额外延迟如果调度策略设计得不好比如用了大量同优先级任务靠时间片轮转切换次数会成倍增加。所以在机器人这种控制周期只有 1ms 甚至 100μs 的场景里减少不必要的切换次数和同优先级任务数量本身就是一种实时性优化手段而且它不需要你改任何算法。2.2 SysTick 节拍粒度决定了你能看到多细RTOS 的 tick 通常由 SysTick 定时器产生1kHz 是最常见的配置也就是每个 tick 1ms。这里有个容易被忽略的细节tick 中断本身就是一个中断它的处理函数会执行调度相关逻辑如果 tick 处理太重比如在 tick 钩子函数里做了耗时操作它就会变成一个稳定的、周期性的抖动源。更重要的是1ms 的 tick 意味着所有基于 tick 的延时精度都是 1ms 量级你用vTaskDelay(1)想让任务每 1ms 跑一次实际周期可能是 1.0ms、1.5ms、2.0ms 这种阶梯状的。对控制任务来说绝对不能用vTaskDelay做控制周期定时应该用硬件定时器触发或者vTaskDelayUntil做补偿。我见过有项目用vTaskDelay(1)跑电流环结果周期在 1~2ms 之间乱跳还以为是电机参数问题。把 tick 提到 10kHz 能改善精度但代价是 tick 中断开销变成原来的十倍这是个需要权衡的取舍我的建议是控制周期用独立硬件定时器tick 保持 1kHz 不动。2.3 中断延迟被临界区闷住的那些微秒这是被最多人低估的一项开销。RTOS 为了保护内核数据结构必须提供临界区机制Cortex-M 上的实现通常是操作 BASEPRI 寄存器把低于某个优先级的中断屏蔽掉。这个动作的直接后果是临界区内无论发生什么高优先级的中断都进不来必须等临界区结束。于是就有了一个硬约束临界区的长度直接决定了系统的最坏中断延迟。如果你的临界区里有 20μs 的代码那么系统里任何中断的最坏响应时间至少是 20μs 起。对 10kHz 的电流环来说100μs 的周期里被闷掉 20μs虽然没超时但采样时刻的相位已经偏了 20%足以让控制性能下降。注意临界区的长度不是平均长度而是最坏路径长度。写代码时要按最坏情况估比如一个 for 循环里如果有 continue 或分支要按最长的分支算。很多抖动问题的根因就是某个临界区在特定数据下会走一条特别长的分支。3. 优先级反转的三段式现场一个低优先级任务如何锁死整条控制链终于说到正题。优先级反转的定义很简洁高优先级任务因为等待一个被低优先级任务持有的资源而被间接延迟。定义谁都看得懂但真正在现场定位它需要你把三段式的时间线完整画出来。3.1 三段式反转的完整还原假设系统里有三个任务优先级从高到低控制任务 H优先级 51ms 周期单次执行预算 300μs、通信任务 M优先级 3处理一帧数据需要 5ms、日志任务 L优先级 1需要访问一块共享的传感器数据缓冲区持锁时间约 200μs。反转的完整过程是这样的时刻事件后果t0L 拿到互斥量开始写缓冲区H 尚未就绪一切正常t050μsH 就绪1ms 周期到H 抢占 L但此时不需要锁先跑其他部分t0120μsH 需要读同一块缓冲区请求锁锁被 L 持有H 被阻塞t0130μs调度器切到 L锁的持有者L 继承 H 的优先级继续跑t0150μsM 就绪因为 L 已继承到优先级 5M 抢不过它正常t0200μsL 释放锁H 立刻拿到锁继续执行t0200μsH 完成总延迟 200μs在预算内上面这个时间线是有优先级继承的情况反转是有界的最坏延迟等于低优先级任务持锁的最长时间大约 200μs。但如果这段代码用的是二进制信号量而不是互斥量时间线会变成另一个样子时刻事件后果t0120μsH 请求锁被阻塞H 挂起等待t0130μs调度器切到 LL 优先级仍是 1继续跑t0150μsM 就绪M优先级 3抢占 Lt0150μs~5150μsM 跑满 5ms5ms 内 L 拿不到 CPUH 一直阻塞t05150μsM 跑完L 才有机会释放锁H 延迟了 5ms同样是三个任务仅仅因为用的是二进制信号量而不是互斥量H 的最坏延迟从 200μs 涨到了 5ms。这就是我现场看到的那 50μs 抖动的放大版——真实系统里 M 的执行时间没那么夸张但机制完全一致。3.2 信号量和互斥量一字之差很多 RTOS 教材把二进制信号量、计数信号量、互斥量放在一起讲导致新手觉得它们只是计数能力不同。实际上互斥量和信号量在语义上是两回事信号量是同步原语用于任务间或中断与任务间的事件通知它不关心谁拿谁放也不属于任何任务。互斥量是资源保护原语它必须由持有者释放内核知道当前持有者是谁因此才能做优先级继承。FreeRTOS 里xSemaphoreCreateMutex()创建的互斥量自带优先级继承前提是configUSE_MUTEXES打开xSemaphoreCreateBinary()创建的二进制信号量没有。用法上两者几乎一样都能Take/Give这正是危险所在——你可能只是随手选了一个却丢掉了内核提供的保护机制。下面是要点对照对比项互斥量Mutex二进制信号量Binary Semaphore主要用途保护共享资源事件通知、任务同步所有权有必须持有者释放无任何任务都能 Give优先级继承支持需配置开启不支持递归获取默认不支持需用递归互斥量不适用能否在中断里 Give一般不允许允许带 FromISR 版本我给自己定的规则很简单保护数据用互斥量通知事件用信号量。如果一段代码里这两个用途混着用那基本可以判定这里迟早出事。3.3 无界反转链条上多了个中等优先级任务有界反转可怕的是延迟无界反转可怕的是它可能永远不结束。什么叫无界如果 M 不是跑 5ms 就结束而是一个持续处理数据的循环任务而 L 因为某种原因一直没有拿到 CPU那么 H 就有可能长时间甚至无限期地等下去。更极端的情况是链式依赖H 等 L 的锁L 等另一个更低优先级任务的锁理论上可以绕出一个环。上世纪九十年代那次著名的深空探测器反复复位事件根因就是这类反转触发了看门狗超时。它的意义不在于某个系统出过问题而在于说明了这类 bug 在功能测试阶段几乎不可能被发现——系统全部功能都是正常的只是时序在最坏情况下不满足约束而最坏情况需要特定的事件交叉才能触发。判断自己的系统是否存在无界反转风险可以问两个问题第一持锁任务在等锁期间会不会因为别的原因被无限期推迟第二锁的等待有没有超时如果第二个问题的答案是没有超时那风险就已经存在了。4. 把反转按死在设计阶段从共享内存到消息驱动的重构定位到根因之后的修复才是真正体现设计功力的地方。我给一个基本原则能被彻底消除的问题不要用机制去缓解。优先级继承是缓解手段重构掉共享资源才是消除手段。4.1 临界区瘦身只保护数据不保护逻辑最常见的错误写法是这样的拿到锁之后在锁里面做数据解析、格式转换、甚至调用其他函数。这些操作耗时几十微秒到几毫秒不等而锁的持有时间直接等于反转的最坏延迟。正确的做法是把临界区压缩成只搬数据、不做计算/* 不推荐锁内做完整处理 */ if (xSemaphoreTake(xDataLock, pdMS_TO_TICKS(2)) pdTRUE) { parse_frame(raw, parsed); /* 耗时 800us 的解析 */ apply_calibration(parsed); /* 又一次耗时操作 */ copy_to_shared(parsed); xSemaphoreGive(xDataLock); } /* 推荐锁内只做一次结构体拷贝 */ uint8_t local_buf[FRAME_LEN]; if (xSemaphoreTake(xDataLock, pdMS_TO_TICKS(2)) pdTRUE) { memcpy(local_buf, g_shared_buf, FRAME_LEN); /* 几十字节1~2us */ xSemaphoreGive(xDataLock); } parse_frame(local_buf, parsed); /* 处理放到锁外 */ apply_calibration(parsed);同样的功能锁持有时间从 800μs 降到 2μs 量级反转的最坏延迟直接缩小两个数量级。这个改动的收益比换一颗更快的 MCU 大得多而且不需要任何硬件成本。提示拷贝到局部缓冲区这个手法有个前提——局部缓冲区不能放在栈上太大的位置。帧长几百字节的话先在任务创建时分配好静态缓冲避免栈溢出。任务栈大小要按最坏调用深度 最大局部缓冲来算别只留 256 字。4.2 用队列替掉共享变量如果两个任务之间是生产者-消费者关系那根本不需要锁用消息队列就行。队列自带互斥保护和阻塞唤醒机制内核实现里已经做了优化而且它天然把数据的所有权从一个任务转交给另一个任务从结构上就消灭了共享。/* 生产者采集任务优先级 2 */ SensorFrame_t frame; frame.timestamp get_hw_tick(); frame.value adc_read(); if (xQueueSendToBack(xSensorQueue, frame, 0) ! pdTRUE) { xQueueOverwrite(xSensorQueue, frame); /* 覆盖旧数据控制场景更合适 */ } /* 消费者控制任务优先级 5 */ SensorFrame_t rx; if (xQueueReceive(xSensorQueue, rx, 0) pdTRUE) { run_control_loop(rx); }这里有两个设计选择值得说明。第一用xQueueOverwrite而不是丢弃新数据因为对控制系统来说最新的数据永远比完整的历史数据重要丢旧保新能让控制环始终基于最新采样。第二控制任务用非阻塞接收超时 0因为它不能被队列阻塞队列空的时候它应该走默认逻辑而不是等。队列唯一的成本是拷贝开销和内存占用。一帧数据几十字节的话拷贝也就一两微秒比持锁的风险小得多。4.3 优先级继承打开之后并不等于万事大吉就算用了互斥量也有几个容易踩的细节。第一优先级继承只解决持锁者在等锁队列里这一种情况如果持锁任务还需要等另一个资源比如另一个队列、另一个信号量反转链条会继续延长继承机制管不了这种链式等待。第二FreeRTOS 的优先级继承实现里继承的优先级只提升到等待者中的最高优先级如果同时有多个任务在等优先级会动态变化用跟踪工具看的时候会看到任务优先级忽高忽低这不是 bug。第三也是我最想强调的别把互斥量用在中断和任务之间。中断里不能阻塞等待也不能成为锁的持有者。中断和任务之间的数据传递应该用带FromISR后缀的 API或者用无锁的单生产者单消费者环形缓冲。/* 中断里给任务发通知正确写法 */ void EXTI_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; clear_exti_flag(); vTaskNotifyGiveFromISR(xCtrlTaskHandle, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }注意最后那个portYIELD_FROM_ISR不能省。省了它中断退出后不会立刻切换到被唤醒的高优先级任务要等到下一个 tick 才切这就凭空多出最多 1ms 的延迟——又是一个典型的看起来只差一行、实际差一个数量级的例子。4.4 天花板协议RTOS 不给就自己搭一层优先级继承有个固有缺陷它只在冲突真的发生时才提升优先级属于事后补救。优先级天花板协议Priority Ceiling是事前预防任何任务在获取某个锁时优先级直接提升到所有可能使用该锁的任务中的最高优先级。这样从拿到锁的那一刻起它就不可能被任何中优先级任务抢占反转在有界范围内被彻底消除。FreeRTOS 内核没有内置天花板协议但有几种折中做法。一是在任务设计阶段就约定谁持锁谁就必须是当前系统里优先级最高的那个也就是把资源访问集中到一个高优先级任务里其他任务通过队列向它请求。二是手动抬高优先级在拿锁前vTaskPrioritySet(NULL, CEILING_PRIO)放锁后恢复原优先级——但要注意手动改优先级会干扰内核的继承逻辑恢复时机的处理必须严格配对一旦中间有提前 return 就会永久卡在高优先级上反而把低优先级任务饿死。我个人更倾向于第一种做法的变体把一组强相关的共享资源整体交给一个资源所有者任务管理别的任务只能发请求。这在架构上叫资源集中代价是多了一次任务切换收益是反转问题从系统里彻底消失。5. 排查链路复盘从现象到根因的五步定位法前面讲的是原理和修法这一章讲怎么找。我按自己实际用的顺序整理成五步前后有依赖关系跳步容易绕远路。5.1 给每个任务装一个打卡器第一步是量化。给每个关心的任务在入口和出口各翻转一个空闲 GPIO或者用 DWT 周期计数器打时间戳。DWT 的用法在 Cortex-M3/M4 上是这样的/* 初始化只做一次 */ CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; /* 任务入口打时间戳 */ uint32_t t_enter DWT-CYCCNT; /* ... 任务逻辑 ... */ uint32_t t_exit DWT-CYCCNT; uint32_t cycles t_exit - t_enter; /* 除以主频得到秒数 */用 GPIO 的好处是示波器能直接看到多个任务的相对时序关系一眼就能看出谁挤了谁用 DWT 的好处是精度高、不占引脚适合统计最大值和分布。两者结合最好GPIO 看关系DWT 看数字。我在现场就是靠一个 GPIO 波形发现控制任务的入口时刻出现了间歇性后移这才把怀疑范围从通信缩小到调度。5.2 抓一次完整的调度轨迹有了量化数据下一步是把调度事件记录下来。手工打点只能看到结果看不到过程。这时候调度可视化工具就派上用场了——基于调试探针的事件记录工具能在不占用多少 CPU 的前提下把任务切换、中断进出、队列操作全部记成时间线。拿到这条时间线之后反转的现场就很直观了你会看到控制任务进入 Blocked 状态然后中间插进来一大段中等优先级任务的执行而低优先级任务在它后面干等。如果手头没有这类工具退一步的做法是在关键位置打日志到 RAM 缓冲区事后导出。注意日志本身不能太长否则记录行为会干扰被测行为这个观测者效应在实时系统里非常真实我踩过一次日志缓冲区写满触发了 flash 擦写导致抖动从 50μs 涨到 3ms查了半天才发现是自己加的分析代码造成的。5.3 堆栈高水位和阻塞原因很多人查调度问题只盯着时间忽略了栈。栈溢出会破坏相邻任务的控制块表现出的症状可能是任务莫名不执行、优先级字段被改乱进一步放大反转的效果。FreeRTOS 提供了uxTaskGetStackHighWaterMark(NULL)返回任务有史以来剩余栈空间的最小值。这个数值如果小于 20% 总量就该扩容了。同时要检查每个任务的阻塞原因。一个控制任务正常应该只因为等待硬件定时器而阻塞如果它长时间停在某个队列或互斥量上那就说明它的执行路径上存在意外的依赖。vTaskGetRunTimeStats能给出每个任务占用 CPU 的比例比例异常高的任务往往就是反转里的那个中优先级捣乱者。5.4 对照实验把反转复现出来这一步是验证假设的关键。我的做法是三组对照第一组原样运行记录抖动分布第二组把中优先级任务的执行时间人为加长如果抖动明显恶化说明反转链条确实存在第三组把涉及共享资源的互斥量换成二进制信号量如果抖动从几十微秒跳到毫秒级那基本可以确认问题机制。第三组实验有风险因为它会真的破坏系统实时性只能在测试台架上做而且要提前把看门狗超时放宽或者临时关闭否则会不断复位。做完之后立刻改回去别留在代码库里。这三组实验花掉的时间不到一天但它把我觉得是反转变成了我证明是反转后面的修复才有底气。5.5 修复后的验收指标修完不算完得定验收标准。我给自己的三条门槛第一控制任务的周期抖动最坏值小于控制周期的 2%1ms 周期就是 20μs 以内第二连续运行 24 小时最大抖动不超过门槛值且抖动分布没有长尾第三人为制造极端负载所有低优先级任务同时抢资源时抖动不超过 5% 周期。第三条是压力测试专门用来验证反转确实被消灭而不是被运气好掩盖了。这里有个小技巧验收时不要只看平均值。抖动这类问题的危害集中在最大值上平均值再漂亮也没意义。我的做法是连续采样十万个周期只统计 P99.9 和最大值两个数字这两个数字达标了才算通过。6. 落地细节与常见追问裸机、Linux 与移植路上的坑最后聊几个几乎每个从裸机转 RTOS 的人都会问的问题这些问题的答案直接决定了你会不会重走我已经走过的弯路。6.1 裸机时间片调度会不会出现优先级反转会而且比你想的更隐蔽。裸机时间片调度或者前后台架构里如果两个模块共享一个缓冲区前台中断和后台主循环之间没有优先级这个概念但抢占关系是真实存在的——中断随时会打断主循环。如果主循环正在写缓冲区中间状态时中断进来读读到的就是半新半旧的数据。反过来讲裸机架构里有一种更简单的规避方式用状态机加双缓冲让中断和主循环永不同时访问同一块内存。中断只写 Buffer A主循环只读 Buffer B指针在中断里原子切换。这个手法搬到 RTOS 里同样有效而且比任何锁机制都便宜。所以我的看法是反转不是 RTOS 的专利只要存在共享资源 抢占关系就有反转的可能性RTOS 只是把这个风险显性化了。6.2 RTOS 和 Linux 的调度不是一回事经常有人拿 Linux 的调度经验往 RTOS 上套这是两个不同的世界。Linux 的调度器CFS 之类设计目标是吞吐量和公平性任务优先级更多体现为权重一个高优先级任务无法保证在确定的微秒级时间内被执行Linux 的实时补丁能把最坏延迟压到几十微秒但它的量级和 RTOS 的几微秒仍然差一个数量级。RTOS 的调度目标是可预测性它的价值不在于跑得快而在于最坏情况下也慢不到哪去。选型的判断标准很简单如果应用的最坏情况延迟要求严于 100μs直接上 RTOS如果容许几十毫秒的抖动用 Linux 反而更省事因为你能用上完整的文件系统和网络栈。机器人控制器里常见的分工是关节控制器跑 RTOS负责电流环和位置环上位运动规划跑 Linux负责轨迹规划和视觉。这个分层本身就是按实时性要求切的。6.3 从裸机移植到 RTOS 最容易踩的四个坑第一个坑是把裸机的 1ms 定时器中断处理函数原封不动搬过来。裸机里中断处理函数里写几百行代码没人管你搬到 RTOS 里它变成一个超长临界区直接抬高整个系统的中断延迟上限。正确做法是中断里只做标记和发通知重活交给任务。第二个坑是在不同任务里直接读写同一个全局变量。裸机时代没有并发除了中断全局变量随便用上了 RTOS任何被两个任务访问的变量都需要保护。我建议的做法是移植时把所有全局变量过一遍凡是跨任务访问的要么改队列要么加互斥量看起来是只读的也要检查因为只读可能在别的任务里是读改写。第三个坑是任务优先级凭感觉定。很多人是把最关心的功能设成最高优先级剩下的随便排。正确的做法是先算每个任务的周期和截止时间再根据速率单调或截止时间单调的原则排优先级——周期越短、截止时间越紧的优先级越高。我见过把日志任务设成最高优先级的项目理由是日志不能丢结果日志一写 SD 卡就把控制环堵住了。第四个坑是忽略栈大小。裸机的栈是编译器统一分配的RTOS 里每个任务一个独立栈默认 128 或 256 字在小任务上够用但一旦用了浮点运算、局部大数组、printf 这类重函数栈会迅速吃紧。我的经验是先用一个偏大的值跑起来用uxTaskGetStackHighWaterMark观察高水位稳定后再按高水位加 50% 余量收紧。这几个坑有一个共同点它们都是裸机习惯留下的而不是 RTOS 本身的问题。移植的正确姿势不是把代码搬过去改几个 API而是把整个并发模型重新想一遍。想清楚谁在什么时候访问什么资源比记住任何 API 都重要。
返回列表