ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

嵌入式操作系统任务调度:从裸机到RTOS再到Linux的实战解析

嵌入式操作系统任务调度:从裸机到RTOS再到Linux的实战解析 嵌入式操作系统任务调度这个话题看起来像是教科书里的老生常谈但真正在项目里被调度问题折磨过的人都清楚这里面的水比想象中深得多。我做过不少基于 ucos 的工控板项目也在嵌入式 Linux 上处理过实时性不足导致的丢包问题踩过的坑足够写一本小册子。这篇内容不打算照本宣科地讲概念而是把任务调度这件事从裸机思维到 RTOS 再到 Linux 的完整脉络拆开重点讲清楚每个阶段调度器到底在干什么、为什么这么设计、实际项目中怎么选、遇到问题怎么排查。不管你是刚接触 ucos 的嵌入式新手还是已经在 Linux 上做驱动和应用开发的老手都能从里面找到对自己有用的东西。1. 从裸机到 RTOS为什么需要任务调度1.1 裸机前后台架构的天花板在哪里很多从单片机入门的朋友最开始写的程序都是一个大循环加中断的结构也就是所谓的前后台系统。主循环里轮询各个任务标志位中断负责处理紧急事件。这种架构在功能简单的时候非常好用代码直观没有上下文切换的开销调试也方便。但它有一个硬伤所有任务的执行时间必须可预测且足够短否则一个任务卡住后面的任务全部遭殃。我见过一个典型的案例某工控板用裸机架构做按键扫描和串口通信主循环里先处理串口数据解析再处理按键。串口数据量一大解析函数耗时超过 50ms按键响应就明显迟钝。更严重的是如果串口解析里有个 while 循环等待某个标志位一旦硬件异常导致标志位永远不来整个系统就死在那里了。这就是裸机架构的天花板——没有任务隔离没有优先级抢占一个任务出问题全系统陪葬。有人会说那我用状态机把每个任务拆成非阻塞的不就行了吗确实可以缓解但状态机会让代码复杂度急剧上升。一个简单的延时操作在状态机里要拆成多个状态还要维护计时变量。当任务数量超过五六个状态机的维护成本就变得不可接受了。这时候就需要 RTOS 出场了。1.2 RTOS 调度器到底解决了什么问题RTOS 的核心价值在于它提供了一个抽象层每个任务看起来都独占 CPU实际上调度器在背后快速切换。这种抽象带来三个关键能力。第一是任务隔离每个任务有自己的栈空间一个任务栈溢出不会直接踩到另一个任务的数据当然栈溢出检测是另一回事。第二是优先级抢占高优先级任务就绪时能立刻打断低优先级任务保证关键事件的响应时间。第三是阻塞机制任务等待信号量或延时的时候主动让出 CPU不浪费处理器时间。以 ucos 为例它的调度器本质上就是一个查表加切换的过程。每个任务有一个优先级ucos 用位图的方式管理就绪任务查找最高优先级任务的时间是确定的跟任务数量无关。这个确定性对实时系统至关重要因为最坏情况下的调度延迟是可以计算出来的。我在做电机控制的时候电流环任务必须每 100 微秒执行一次用 ucos 的 OSTimeDly 配合硬件定时器中断实测抖动在几个微秒以内完全满足要求。但这里有个常见的误解很多人以为用了 RTOS 就自动获得实时性。实际上 RTOS 只提供了实时性的基础机制真正的实时性取决于你的任务设计和中断处理。如果中断服务程序里做了耗时操作或者高优先级任务里调用了阻塞函数实时性照样崩掉。我后面会专门讲这些坑。1.3 ucos 任务调度的实现细节与 includes.h 的坑ucos 的任务调度核心在 OS_Sched 函数里它做三件事找到最高优先级就绪任务、判断是否需要切换、执行上下文切换。上下文切换在 ARM Cortex-M 上是通过 PendSV 异常实现的把当前任务的寄存器压栈恢复新任务的寄存器。这个过程在 ucos 的移植文件里用汇编写的一般不需要改动但理解它对排查问题很有帮助。说到 ucos就不得不提 includes.h 这个文件。很多新手在移植 ucos 的时候编译报错找不到各种类型定义根源就在 includes.h 的组织上。ucos 的设计是让每个 .c 文件都包含 includes.h而 includes.h 里再去包含所有的头文件。这样做的好处是统一管理坏处是编译依赖混乱改一个头文件全工程重编。我的建议是不要把所有头文件都塞进 includes.h。正确的做法是includes.h 只包含 ucos 自己的头文件、标准库头文件和必要的硬件抽象头文件。应用层的头文件在各 .c 文件里单独包含。这样能大幅减少编译时间也避免头文件循环包含的问题。另外 includes.h 里包含头文件的顺序有讲究一般先包含标准库再包含硬件相关最后包含 ucos 的。因为 ucos 的 os_cpu.h 里会用到一些硬件定义顺序反了会报错。还有一个容易忽略的点ucos 的 OS_CFG.H 里有一堆配置宏比如 OS_MAX_TASKS、OS_LOWEST_PRIO、OS_TASK_TMR_PRIO 等。这些值不是随便填的OS_LOWEST_PRIO 决定了优先级数量ucos 用位图管理就绪任务优先级数量直接影响位图的字长。在 32 位系统上如果 OS_LOWEST_PRIO 设为 63位图就是两个 32 位字查找最高优先级需要两次判断。如果设为 31一个 32 位字就够了查找更快。所以任务数量不多的时候把 OS_LOWEST_PRIO 设小一点能提升调度效率。2. 任务调度的核心机制拆解2.1 优先级抢占与时间片轮转的取舍ucos 默认只支持优先级抢占调度相同优先级的任务不能同时存在。这意味着如果你有两个同等重要的任务必须给它们分配不同的优先级然后靠信号量或消息队列来协调。这种设计简化了调度器但也限制了灵活性。后来 ucos-III 引入了时间片轮转允许同一优先级下有多个任务每个任务分配一个时间片用完就轮到下一个。那什么时候该用时间片轮转我的经验是只有当多个任务确实同等重要且都计算密集型的时候才用。比如你有两个通信协议解析任务优先级相同数据量都很大这时候时间片轮转能保证它们公平分享 CPU。但如果任务有明确的轻重缓急还是老老实实用优先级抢占。因为时间片轮转会引入额外的调度开销而且时间片大小的设置很讲究太小了切换频繁浪费 CPU太大了响应变慢。在 FreeRTOS 里时间片轮转是通过 configUSE_TIME_SLICING 宏控制的默认开启。ucos-III 里则是通过给任务分配时间片配额来实现。不管哪个 RTOS核心思想是一样的同优先级任务排成一个环形链表调度器在时钟节拍中断里递减当前任务的时间片减到零就切换到下一个。2.2 调度器如何找到最高优先级任务这是调度器最核心的操作直接决定了调度效率。ucos 用的是位图法我详细说一下。假设系统有 64 个优先级ucos 维护两个变量OSRdyGrp 是一个 8 位变量每一位对应一组每组 8 个优先级OSRdyTbl 是一个 8 字节数组每个字节的每一位对应组内的一个优先级。当某个优先级为 5 的任务就绪时优先级 5 属于第 0 组5/80组内第 5 位5%85所以 OSRdyGrp 的 bit0 置 1OSRdyTbl[0] 的 bit5 置 1。查找最高优先级时先查 OSRdyGrp 找到最低的非零位确定组号再查 OSRdyTbl[组号] 找到最低的非零位确定组内偏移两者组合就是最高优先级。ucos 用了一张查找表 OSUnMapTbl 来加速这个过程把 256 种可能的字节值映射到最低位的位置。这样整个查找过程是常数时间跟任务数量无关。FreeRTOS 的做法不同它用了一个叫做 pxReadyTasksLists 的数组每个优先级一个链表。调度器直接从这个数组的最高优先级开始往下找找到第一个非空链表就是最高优先级任务。在 Cortex-M 上FreeRTOS 用 CLZ 指令Count Leading Zeros来加速查找也是常数时间。两种方法各有优劣ucos 的位图法内存占用小但优先级数量受限于位图字长FreeRTOS 的链表法支持任意数量的优先级但每个优先级都要维护一个链表头内存占用稍大。2.3 上下文切换的开销与优化上下文切换是调度器的实际执行动作它的开销直接影响系统性能。在 Cortex-M3/M4 上硬件自动压栈 8 个寄存器xPSR、PC、LR、R12、R3-R0剩下的 R4-R11 需要软件压栈。所以一次完整的上下文切换大约需要 20-30 个时钟周期用于压栈和恢复加上调度器查找最高优先级任务的时间总共在 50 个时钟周期左右。在 72MHz 的 STM32 上大约是 0.7 微秒。这个开销看起来不大但如果切换太频繁累积起来就很可观了。我见过一个项目任务设计不合理每个任务只做一点点事情就主动让出 CPU导致每秒切换上万次CPU 有 20% 的时间花在上下文切换上。优化方法很简单让每个任务一次多做一些事情减少不必要的阻塞和唤醒。比如串口接收任务不要收到一个字节就处理一次而是攒够一帧再处理。另一个优化点是使用零拷贝的消息传递。ucos 的消息队列是值拷贝发送一个消息会把数据复制到队列的缓冲区里。如果消息很大拷贝开销就很明显。这时候可以考虑用指针队列只传递数据的指针接收方自己去取数据。但要注意指针指向的内存生命周期管理别传了个栈上的局部变量指针出去。3. 嵌入式 Linux 的调度器从 O(1) 到 CFS3.1 Linux 调度器的演进脉络嵌入式 Linux 和 RTOS 的调度思路完全不同。RTOS 追求的是确定性最坏情况下的调度延迟必须可计算。Linux 追求的是公平性和吞吐量让所有任务都能得到合理的 CPU 时间。这个差异源于应用场景RTOS 跑的是单一控制逻辑Linux 跑的是多用户多进程的复杂系统。Linux 2.4 用的是 O(n) 调度器每次调度都要遍历所有任务任务多了效率急剧下降。2.6 引入了 O(1) 调度器用两个优先级数组活跃数组和过期数组实现了常数时间的调度。但 O(1) 调度器对交互式任务不友好因为它的优先级计算机制导致桌面响应卡顿。2.6.23 引入了 CFS完全公平调度器一直用到今天。CFS 的核心思想是用红黑树管理所有可运行任务按虚拟运行时间排序。每次调度选虚拟运行时间最小的任务。虚拟运行时间不是实际运行时间而是实际运行时间按优先级权重折算后的值。优先级高的任务权重高同样的实际运行时间折算后的虚拟运行时间增长慢所以能获得更多 CPU 时间。这个设计很巧妙既保证了公平性又通过权重体现了优先级差异。3.2 CFS 的红黑树与虚拟运行时间计算CFS 的红黑树键值是 vruntime每个任务的 vruntime 初始为 0运行过程中不断累加。累加的公式是vruntime 实际运行时间 × (NICE_0_LOAD / 任务权重)。NICE_0_LOAD 是 nice 值为 0 时的权重大约是 1024。任务权重由 nice 值决定nice 值每降低 1权重增加约 1.25 倍。所以 nice 值为 -20 的任务权重是 nice 值为 19 的任务的 88761/15 ≈ 5917 倍能获得压倒性的 CPU 份额。调度器每次选红黑树最左边的节点也就是 vruntime 最小的任务。任务运行一段时间后 vruntime 增大会被重新插入红黑树可能就不再是最左边的了。这样就实现了动态的公平调度。为了防止刚创建的任务因为 vruntime 为 0 而长时间霸占 CPUCFS 会把新任务的 vruntime 设为当前红黑树中最小的 vruntime 减去一个补偿值保证它不会插队太狠。在嵌入式 Linux 上CFS 的默认配置对实时性要求高的场景不够友好。比如你做工业控制控制循环需要每毫秒执行一次CFS 可能因为其他任务的干扰导致延迟抖动。这时候就需要用到实时调度策略SCHED_FIFO 和 SCHED_RR。SCHED_FIFO 是先进先出的实时调度一旦任务开始运行除非它主动让出或被更高优先级任务抢占否则一直运行。SCHED_RR 是时间片轮转的实时调度同优先级任务轮流运行。3.3 实时补丁与调度延迟的实测对比标准 Linux 内核在实时性方面有几个硬伤中断处理不能抢占、自旋锁会导致优先级反转、内核不可抢占。这些问题导致最坏情况下的调度延迟可能达到毫秒级甚至更高。对于大多数嵌入式应用这个延迟可以接受但对于运动控制、电力电子等需要微秒级响应的场景就不够了。PREEMPT_RT 补丁把 Linux 改造成了完全可抢占的内核中断处理线程化自旋锁替换为可睡眠的互斥锁。打上补丁后调度延迟可以降到几十微秒。我在一块 i.MX6 的板子上实测过标准内核下一个 SCHED_FIFO 任务的周期抖动在 200 微秒左右打上 PREEMPT_RT 后抖动降到 30 微秒以内。这个提升对于伺服控制来说就是能用和不能用的区别。但 PREEMPT_RT 也有代价吞吐量会下降因为频繁的抢占和线程化中断增加了开销。而且不是所有驱动都支持实时补丁有些驱动里的自旋锁没改好打上补丁后反而会出问题。所以要不要上 PREEMPT_RT得看你的实际需求。如果控制周期在毫秒级标准内核加实时调度策略就够了如果要求微秒级再考虑实时补丁。4. 实际项目中的调度问题排查与优化4.1 优先级反转的三种典型场景优先级反转是 RTOS 项目里最容易踩的坑之一。经典场景是低优先级任务 L 持有互斥锁高优先级任务 H 等待这个锁中优先级任务 M 就绪后抢占了 L导致 H 被 M 间接阻塞。如果 M 一直运行H 就永远等不到锁。这个问题在火星探路者号上真实发生过导致系统不断重启。ucos 的互斥量支持优先级继承能在一定程度上缓解这个问题。当 H 等待 L 持有的互斥量时L 的优先级会被临时提升到 H 的级别这样 M 就抢不过 L 了。但优先级继承只对互斥量有效对信号量无效。所以共享资源保护一定要用互斥量不要用信号量。第二种场景是中断服务程序里调用了可能阻塞的函数。比如在中断里发送消息到满队列如果用了阻塞方式中断就会卡住整个系统响应都会受影响。正确做法是在中断里用非阻塞方式发送发不进去就丢弃或置标志位让任务去处理。第三种场景是嵌套中断导致的优先级反转。高优先级中断被低优先级中断打断如果低优先级中断里关了中断高优先级中断就得等。解决方法是在中断里尽量少关中断必须关的时候用 BASEPRI 寄存器只屏蔽低于某个优先级的中断而不是用 PRIMASK 关所有中断。4.2 栈溢出检测与任务栈大小估算任务栈溢出是另一个高频问题。ucos 提供了栈溢出检测机制在任务控制块里放一个魔术字每次切换时检查这个字有没有被改写。但这个方法只能检测到溢出已经发生不能防止溢出造成的破坏。更主动的做法是估算每个任务的最大栈使用量留出足够余量。栈大小的估算方法先给一个较大的栈比如 1KB运行一段时间后把栈区域填充一个特定模式比如 0xAA再运行一段时间看栈底有多少字节被改写了。被改写的字节数就是实际使用的栈空间乘以 1.5 到 2 倍就是安全的栈大小。ucos 的 OSTaskStkChk 函数就是干这个的它统计栈中未被使用的字节数。在 Linux 上任务栈是内核自动管理的用户态线程默认 8MB 栈一般不会溢出。但内核态的栈很小在 x86 上是 8KB在 ARM 上是 8KB 或 16KB。内核里递归调用或者大的局部数组很容易导致栈溢出而且内核栈溢出往往表现为莫名其妙的崩溃很难定位。所以内核代码里要避免大的局部变量需要大内存就用 kmalloc 动态分配。4.3 调度延迟的测量方法与工具测量调度延迟是优化调度的前提。在 RTOS 上最简单的方法是用一个 GPIO 引脚在任务开始和结束时翻转用示波器看波形。这样能直观地看到任务的执行时间和周期抖动。我在调试电机控制的时候就是用一个空闲 GPIO 输出方波示波器一挂什么问题都清楚了。在 Linux 上可以用 ftrace 的 irqsoff 和 preemptoff tracer 来测量中断关闭和抢占关闭的最长时间。这两个 tracer 会记录最坏情况下的延迟并给出调用栈直接告诉你哪里关中断太久了。用法很简单挂载 debugfs然后 echo irqsoff current_tracer运行一段时间后 cat trace 就能看到结果。还有一个工具是 cyclictest它是 rt-tests 包里的专门用来测量实时调度延迟。它创建一个 SCHED_FIFO 任务循环睡眠和唤醒统计每次唤醒的实际延迟。输出里的 Max 值就是最坏情况下的延迟。我一般会跑几个小时看 Max 值是否稳定。如果 Max 值偶尔跳得很高说明系统里有不确定的延迟源需要进一步排查。5. 调度策略选型与项目实战建议5.1 RTOS 与 Linux 的选型边界到底该用 RTOS 还是嵌入式 Linux这个问题没有标准答案但有一些经验判断标准。如果系统功能单一、实时性要求高、硬件资源有限比如只有几十 KB RAM选 RTOS。如果系统需要网络协议栈、文件系统、图形界面、多用户支持选 Linux。如果两者都需要可以用异构架构一个 Cortex-M 跑 RTOS 做实时控制一个 Cortex-A 跑 Linux 做人机交互和网络通信两者通过串口或共享内存通信。我做过一个项目就是这种架构STM32F4 跑 ucos 做电机控制和传感器采集i.MX6 跑 Linux 做数据展示和云端上传。两者通过 SPI 通信STM32 作为 SPI 从机i.MX6 作为主机。这样实时控制不受 Linux 调度延迟的影响Linux 那边也能跑复杂的应用。缺点是增加了硬件成本和通信协议的开发工作量。如果非要在 Linux 上做实时控制那就得用 PREEMPT_RT 加实时调度策略并且把控制任务绑到独立的 CPU 核上isolcpus避免被其他任务干扰。还要注意关闭 CPU 频率调节、禁用不必要的内核功能减少延迟源。这些配置下来系统能勉强达到几十微秒的抖动但跟 RTOS 的确定性还是有差距。5.2 任务划分与优先级分配的经验法则任务划分是调度设计的第一步。我的经验是按功能模块划分任务每个任务负责一个独立的逻辑任务之间通过消息队列或信号量通信。不要把所有功能塞进一个任务那样就退化成裸机的前后台架构了。也不要划分得太细任务太多会导致调度开销增加而且任务间通信的复杂度也会上升。优先级分配有个简单的法则以截止时间越短优先级越高的原则来分配。比如电机控制任务的截止时间是 100 微秒串口通信是 10 毫秒那电机控制的优先级就要远高于串口通信。具体分配的时候可以把任务分成几个等级硬实时任务截止时间微秒级、软实时任务截止时间毫秒级、非实时任务没有明确截止时间。硬实时任务给最高优先级软实时次之非实时最低。在 ucos 里优先级数值越小优先级越高0 是最高优先级一般留给系统时钟节拍任务。用户任务的优先级从 1 开始分配。要注意 ucos 的优先级数量是有限的OS_LOWEST_PRIO 设小了不够用设大了浪费内存。一般 32 个优先级够用了复杂系统可以设到 64。5.3 从调试现场总结的五个避坑要点第一个坑在中断里调用 RTOS 的 API 时必须用带 FromISR 后缀的版本。比如 FreeRTOS 的 xQueueSendFromISRucos 的 OSQPost 在中断里调用也没问题但 OSMboxPend 这种阻塞函数绝对不能在中断里调。我见过有人在中断里调 OSMboxPend 等消息结果中断卡死系统直接挂了。第二个坑任务栈大小不要凭感觉设。我见过一个项目所有任务栈都设 512 字节结果一个任务里有个 256 字节的局部数组加上函数调用开销栈直接溢出把相邻任务的数据踩了。表现是随机崩溃查了好几天才定位到。所以栈大小一定要用 OSTaskStkChk 实测后再定。第三个坑共享资源保护要用互斥量而不是信号量。信号量没有优先级继承容易导致优先级反转。ucos 的互斥量还有嵌套锁定功能同一个任务可以多次获取同一个互斥量释放的时候也要释放相同次数。这个特性在递归函数里很有用。第四个坑不要在调度器启动前创建太多任务。ucos 的 OSInit 之后、OSStart 之前可以创建任务但这时候调度器还没跑任务只是挂在就绪表里。如果创建的任务太多就绪表初始化可能出问题。一般建议在 OSStart 之前只创建一个启动任务其他任务在启动任务里创建。第五个坑Linux 上不要用 nice 值来调实时任务的优先级。nice 值只影响 CFS 调度器下的普通任务对 SCHED_FIFO 和 SCHED_RR 无效。实时任务的优先级是通过 sched_setscheduler 的 sched_priority 参数设置的范围是 1 到 99数值越大优先级越高。而且设置实时优先级需要 root 权限普通用户只能降低不能提高。5.4 调度器配置参数的调优清单对于 ucos关键配置参数有这几个OS_TASK_TMR_PRIO 是定时器任务的优先级一般设为最低或次低OS_SCHED_LOCK_TIME 是调度器锁定时间用于统计OS_TASK_STAT_EN 是统计任务使能调试阶段可以打开量产时关掉省资源。还有 OS_TICKS_PER_SEC 是时钟节拍频率一般设 100 到 1000。设太高了中断开销大设太低了时间精度不够。我做电机控制的时候设 1000也就是 1ms 一个节拍配合硬件定时器做微秒级延时。对于 Linux CFS关键参数在 /proc/sys/kernel/ 下面sched_min_granularity_ns 是最小调度粒度默认 3ms 左右调小能提升交互性但增加切换开销sched_wakeup_granularity_ns 是唤醒抢占粒度调小能让唤醒的任务更快抢占 CPUsched_latency_ns 是调度延迟目标CFS 会尽量保证每个任务在这个时间内至少运行一次。嵌入式系统可以把 sched_min_granularity_ns 调到 1mssched_wakeup_granularity_ns 调到 0.5ms提升响应速度。对于 PREEMPT_RT还要注意线程化中断的优先级。默认情况下中断线程的优先级是 50可以通过 chrt 命令调整。如果某个中断的实时性要求特别高可以把它的线程优先级调到 90 以上。但要注意不要把所有中断都调高那样反而会导致优先级混乱。6. 调度器的可观测性与性能分析6.1 用 GPIO 和示波器做最直接的调度观测在嵌入式开发里最可靠的调试手段往往是最原始的。一个空闲的 GPIO 引脚加上示波器能解决大部分调度问题。具体做法在任务开始执行时拉高 GPIO任务结束时拉低。示波器上看到的高电平宽度就是任务执行时间两个上升沿之间的间隔就是任务周期。如果周期不稳定说明调度受到了干扰。我一般会留两到三个 GPIO 做调试用。一个用来标记高优先级任务的执行一个标记低优先级任务还有一个标记中断。这样在示波器上能同时看到多个任务的时序关系一眼就能看出优先级抢占是否按预期工作。比如高优先级任务应该立刻打断低优先级任务如果示波器上看到低优先级任务的高电平没有及时变低说明抢占没生效可能是中断关了或者调度器被锁了。这个方法在 Linux 上也适用但需要写一个内核模块来操作 GPIO或者用 /sys/class/gpio 接口。用户态操作 GPIO 的延迟比较大不适合精确测量但看个大概的时序还是可以的。更精确的方法是用 ftrace 的 function_graph tracer能看到函数级的调用时序但那个信息量太大适合定位具体问题不适合日常监控。6.2 ftrace 与 perf 在调度分析中的配合使用ftrace 是 Linux 内核自带的跟踪工具功能非常强大。分析调度问题常用的 tracer 有sched_switch 记录每次任务切换能看到谁切换到了谁sched_wakeup 记录任务唤醒能看到唤醒延迟irqsoff 和 preemptoff 记录中断和抢占关闭的最长时间。这几个 tracer 配合使用基本能定位大部分调度问题。举个例子如果发现某个实时任务偶尔延迟很大先用 irqsoff tracer 看是不是有中断关了太久。如果有trace 结果会直接给出关中断的函数调用栈顺着栈去查代码就行。如果没有再用 sched_switch 看任务切换时序确认是不是被其他任务抢占了。如果被抢占了看抢占任务的优先级如果是普通任务抢占了实时任务那说明实时任务的优先级设置有问题。perf 是另一个强大的工具它能做性能计数和采样分析。用 perf sched record 记录一段时间的调度事件然后用 perf sched latency 分析每个任务的调度延迟。输出里会列出每个任务的平均延迟和最大延迟按最大延迟排序一眼就能看出哪个任务的实时性最差。这个工具在优化系统整体调度性能的时候特别有用。6.3 调度性能的量化指标与基准测试评估调度性能有几个关键指标调度延迟从任务就绪到实际开始运行的时间、切换开销一次上下文切换的耗时、抖动调度延迟的变化范围、吞吐量单位时间内完成的任务数。不同的应用关注不同的指标实时控制关注延迟和抖动服务器关注吞吐量交互式系统关注响应时间。基准测试的方法对于 RTOS可以用一个高优先级任务循环延时和翻转 GPIO用示波器测量实际周期与设定周期的偏差。对于 Linuxcyclictest 是最常用的工具它能给出延迟的统计分布。我一般会跑至少 24 小时因为有些延迟问题只在特定条件下才会出现比如内存回收、网络中断等。还有一个容易被忽略的指标是调度器的可扩展性。当任务数量增加时调度开销是否线性增长ucos 的位图法是常数时间跟任务数量无关。Linux 的 CFS 是 O(log n)因为红黑树的插入和删除是 O(log n)。在任务数量很多的时候这个差异会体现出来。不过嵌入式 Linux 上一般不会有成千上万的任务所以 CFS 的开销可以接受。7. 从单核到多核调度面临的新问题7.1 多核 RTOS 的调度挑战多核 RTOS 的调度比单核复杂得多。单核上只需要考虑优先级和时间片多核上还要考虑任务在哪个核上运行、核间负载均衡、缓存亲和性等问题。ucos 本身不支持多核但 ucos-III 有 SMP 版本FreeRTOS 也有 SMP 支持。它们的调度策略一般是全局调度所有核共享一个就绪队列空闲的核从队列里取任务执行。全局调度的优点是负载均衡好缺点是核间同步开销大。就绪队列需要加锁保护核越多锁竞争越激烈。另一种策略是分区调度每个核有自己的就绪队列任务绑定到特定核上运行。优点是同步开销小缺点是负载可能不均衡。实际项目中如果任务可以静态分配到各个核分区调度更简单可靠。如果任务负载动态变化大全局调度更合适。在 Linux 上多核调度由 CFS 的负载均衡机制处理。每个 CPU 有自己的运行队列CFS 定期做负载均衡把任务从繁忙的 CPU 迁移到空闲的 CPU。迁移的代价是缓存失效任务在新 CPU 上要重新填充缓存。所以 CFS 有缓存亲和性机制尽量让任务留在同一个 CPU 上运行。可以通过 sched_setaffinity 系统调用手动绑定任务到特定 CPU这在实时控制场景下很常用。7.2 CPU 亲和性与中断绑定的实战配置在嵌入式 Linux 上做实时控制通常会把控制任务绑定到一个独立的 CPU 核上把其他任务和中断都赶到别的核。具体做法在启动参数里加 isolcpus2,3把 CPU 2 和 3 从调度器的负载均衡中隔离出来。然后用 sched_setaffinity 把控制任务绑到 CPU 2 上。这样 CPU 2 上只有控制任务在跑没有其他任务干扰调度延迟和抖动都会大幅降低。中断绑定也很重要。默认情况下中断会分配到各个 CPU 上可能打断控制任务的执行。可以通过 /proc/irq/ 下面的 smp_affinity 文件把中断绑定到特定的 CPU。比如把网络中断绑到 CPU 0把存储中断绑到 CPU 1让 CPU 2 专心跑控制任务。但要注意有些中断是绑不了的比如本地定时器中断那个每个 CPU 都有自己的。还有一个细节CPU 频率调节会影响调度延迟。当 CPU 从低频升到高频需要时间这段时间内任务的执行速度会变慢。对于实时任务最好把 CPU 频率固定在高性能模式或者用 performance 调速器。在 /sys/devices/system/cpu/cpuX/cpufreq/scaling_governor 里可以设置。虽然费电但实时性有保障。7.3 核间通信对调度实时性的影响多核系统里任务之间经常需要跨核通信。核间通信的实现方式直接影响调度实时性。最简单的方式是共享内存加自旋锁但自旋锁在锁竞争时会浪费 CPU 时间而且如果持锁的核被抢占等待的核会一直自旋。更好的方式是使用无锁队列或者消息传递机制。Linux 上常用的核间通信机制有共享内存加信号量、rpmsg、mailbox 框架等。rpmsg 是 remoteproc 框架的一部分用于主核和从核之间的通信比如 Cortex-A 和 Cortex-M 之间的通信。它的优点是标准化、有现成的驱动缺点是延迟比共享内存大。如果对延迟要求极高还是得用共享内存加中断通知的方式。我在一个双核项目里用过共享内存加 mailbox 中断的方案两个核各有一块共享内存区域发送方写数据到共享内存然后触发 mailbox 中断通知接收方。接收方在中断里读取数据。实测延迟在几微秒左右满足控制需求。关键是要处理好内存屏障确保数据写入对另一个核可见。ARM 架构下需要用 DMB 或 DSB 指令C 语言里可以用 __sync_synchronize() 或者 atomic_thread_fence()。8. 调度设计的进阶思路与个人体会8.1 用状态机辅助任务调度设计任务调度和状态机看起来是两回事但结合起来用效果很好。每个任务内部可以用状态机来组织逻辑把阻塞操作拆成非阻塞的状态迁移。这样任务不会因为等待某个事件而长时间阻塞调度器能更灵活地分配 CPU 时间。比如一个通信任务可以设计成空闲状态、接收状态、解析状态、发送状态。每个状态执行一小段代码就返回下次调度再继续。这种设计的好处是任务响应性好不会因为一个操作卡住整个任务。缺点是代码复杂度高状态多了容易乱。我的经验是状态数量控制在 5 个以内状态迁移图要画清楚每个状态的执行时间要尽量短。如果状态机太复杂说明任务划分有问题应该拆成多个任务。在 Linux 上这种思路对应的是事件驱动编程。用 epoll 或 io_uring 管理多个文件描述符事件到来时回调处理。这样单个线程就能处理大量并发连接不需要为每个连接创建一个线程。Nginx 和 Redis 都是这种架构的典型代表。嵌入式 Linux 上做网络通信也可以借鉴这种模式减少线程数量降低调度开销。8.2 调度器锁定与临界区长度的权衡调度器锁定Scheduler Lock是 RTOS 里保护临界区的一种方式。ucos 的 OSSchedLock 能阻止任务切换但不关中断。这样临界区里的代码不会被其他任务打断但中断还能响应。临界区要尽量短因为锁定期间高优先级任务无法抢占实时性会受影响。我见过有人在临界区里做浮点运算或者内存拷贝一锁就是几百微秒高优先级任务的响应时间直接爆掉。正确的做法是临界区里只做必要的变量修改和状态更新耗时的操作放到临界区外面。如果确实需要保护一大段操作考虑用互斥量代替调度器锁定因为互斥量只阻塞需要同一资源的任务不影响其他任务。在 Linux 内核里对应的概念是 preempt_disable 和 local_irq_save。preempt_disable 禁止内核抢占但中断还能进来。local_irq_save 关中断影响更大。这两个操作的持续时间都要尽量短。用 ftrace 的 preemptoff 和 irqsoff tracer 可以测量最长的关闭时间如果超过 100 微秒就值得优化了。8.3 从实际项目出发的调度设计检查清单每次设计一个新的调度方案我都会过一遍这个清单任务划分是否合理有没有功能重叠或遗漏优先级分配是否遵循了截止时间越短优先级越高的原则共享资源是否都用互斥量保护了中断处理是否足够短有没有在中断里做耗时操作任务栈大小是否经过实测有没有优先级反转的风险调度器锁定和关中断的时间是否可控有没有留出调试用的 GPIO 和跟踪点。这个清单看起来简单但每一条背后都有血泪教训。比如任务栈大小我吃过亏之后现在每个项目都会用 OSTaskStkChk 实测一遍而且留 50% 的余量。再比如中断处理我现在坚持一个原则中断里只做标志位置位和数据搬运所有解析和处理都放到任务里做。这样中断执行时间能控制在几微秒以内对调度的影响最小。还有一点调度设计不是一次性的工作系统运行过程中任务的行为可能会变化。比如通信任务在数据量大的时候执行时间会变长如果栈大小是按空闲时的使用量设的高峰期就可能溢出。所以要在各种工况下测试确保最坏情况下调度依然正常。我一般会在实验室里模拟满负载、异常数据、硬件故障等各种场景观察调度器的表现。说到底任务调度是嵌入式系统的骨架骨架不正后面怎么调都是歪的。把调度设计好了后面的开发会顺很多。希望这些从实际项目里摸爬滚打出来的经验能帮你少走一些弯路。
返回列表