机器人关节在低速爬行时忽然"顿"一下,伺服电流环里冒出一声异响,轨迹精度莫名其妙超差——这种"偶发卡顿"是嵌入式机器人开发里最磨人的一类问题。很多人第一反应是电机参数没调好、编码器有干扰、机械有间隙,折腾一圈后发现根子上是RTOS 的调度与优先级反转在作怪。这篇文章不打算复述教材里的调度算法定义,而是把我在实际机器人控制器上遇到的一次完整卡顿排查摊开讲:抖动是怎么被量化出来的、抢占式调度的时间账本怎么算、优先级反转的三段式现场长什么样、怎么用互斥量和队列把它按死在设计阶段,以及从裸机迁到 RTOS 时那些没人提醒你的坑。适合已经写过裸机控制循环、准备上 RTOS 或者正在被 RTOS 抖动折磨的工程师,也适合想搞明白"优先级反转到底是什么"的入门读者——不需要你懂调度器源码,但需要你愿意动手量一次波形。
1. 机器人抖动现场:从"偶发卡顿"到示波器上的一根毛刺
先说清楚现象,因为"卡顿"这个词太笼统了,不同的人嘴里的卡顿根本不是一回事。我遇到的场景是六轴协作臂的关节控制器,主控跑 RTOS,1kHz 的速度环 + 10kHz 的电流环,插补周期 1ms。表现是:手动示教低速运行基本正常,一旦切换到轨迹模式做连续圆弧插补,大概每隔几十秒会出现一次单周期延迟,肉眼看上去就是末端"顿"了一下,用激光跟踪仪测轨迹,偏差能到 0.3mm 左右,超出允许范围一截。这个现象最坑的地方在于它不是持续的,复现概率大概只有几个百分点,你盯着看十分钟可能一次都不出。
1.1 卡顿在机器人上到底长什么样
很多人的第一直觉是把卡顿等同于"任务没跑完",其实在实时系统里,卡顿更准确的定义是单个控制周期的实际执行时刻偏离了理论时刻。这个偏离量就是抖动,英文叫 jitter。同样一个 1ms 周期的控制任务,理想情况下每次都在 t=0、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(优先级 5,1ms 周期,单次执行预算 300μs)、通信任务 M(优先级 3,处理一帧数据需要 5ms)、日志任务 L(优先级 1,需要访问一块共享的传感器数据缓冲区,持锁时间约 200μs)。
反转的完整过程是这样的:
| 时刻 | 事件 | 后果 |
|---|---|---|
| t0 | L 拿到互斥量,开始写缓冲区 | H 尚未就绪,一切正常 |
| t0+50μs | H 就绪(1ms 周期到) | H 抢占 L,但此时不需要锁,先跑其他部分 |
| t0+120μs | H 需要读同一块缓冲区,请求锁 | 锁被 L 持有,H 被阻塞 |
| t0+130μs | 调度器切到 L(锁的持有者) | L 继承 H 的优先级,继续跑 |
| t0+150μs | M 就绪 | 因为 L 已继承到优先级 5,M 抢不过它,正常 |
| t0+200μs | L 释放锁 | H 立刻拿到锁继续执行 |
| t0+200μs | H 完成 | 总延迟 200μs,在预算内 |
上面这个时间线是有优先级继承的情况,反转是有界的:最坏延迟等于低优先级任务持锁的最长时间,大约 200μs。但如果这段代码用的是二进制信号量而不是互斥量,时间线会变成另一个样子:
| 时刻 | 事件 | 后果 |
|---|---|---|
| t0+120μs | H 请求锁被阻塞 | H 挂起,等待 |
| t0+130μs | 调度器切到 L | L 优先级仍是 1,继续跑 |
| t0+150μs | M 就绪 | M(优先级 3)抢占 L |
| t0+150μs~5150μs | M 跑满 5ms | 5ms 内 L 拿不到 CPU,H 一直阻塞 |
| t0+5150μs | M 跑完,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 都重要。