裸机时间片调度任务框架:从大循环到多任务实现
很多做单片机裸机开发的朋友应该都遇到过这种局面逻辑一多main函数里的while(1)就变成一个不断膨胀的大循环按键扫描、传感器读取、显示刷新、通信解析全堆在一起。今天加一个功能明天改一个时序后天真机一跑就发现某个任务的响应变慢了。我也从这种“堆代码”阶段走过来后来在裸机环境下实现了基于时间片调度的任务框架才算把多任务处理的思路彻底理清楚。这篇博文就把这套框架从设计思路、数据结构到具体代码、调试要点完整拆开讲适合正在做裸机多任务开发、想摆脱大循环噩梦、又暂时不想上完整操作系统的嵌入式工程师参考。1. 为什么裸机也需要时间片调度1.1 前后台系统的局限与时间片调度要解决的核心问题裸机开发里最典型的程序结构是“前后台系统”后台是一个超级循环while(1)前台是各类中断服务函数。后台按顺序轮询处理不同任务前台用中断及时响应外部事件。说实话在任务量少、逻辑简单的时候这种结构完全够用。但如果任务一多前后台结构的缺陷就会非常明显。举一个我实际遇到过的例子某个设备既要每隔 5ms 采集一次传感器数据又要在按键按下时立刻更新显示还要周期性地向上位机发送一段协议帧。如果把这些事全部顺序写在主循环里传感器的采集延时取决于按键处理是否阻塞、显示刷新函数耗时多少。更麻烦的是一旦某个任务内部出现了长时间的等待比如等待某个外设的标志位整个后台循环都会被堵住其他任务集体“饿死”。这就是典型的“串行执行”问题——所有任务都在同一条流水线上排队一个环节卡住后面的全部遭殃。时间片调度解决的就是这个核心矛盾把 CPU 时间分割成固定长度的小片每个任务轮流获得一小段运行时间时间片用完就切换到下一个任务。从人的视角看每个任务都在“同时”推进谁也不会长期霸占 CPU。这里可以用一个厨房的类比帮助理解前后台系统就像只有一个厨师、一口锅炒完 A 菜才能炒 B 菜时间片调度则是给每道菜设定一个“翻炒周期”每隔几秒回来翻一下虽然单看某一刻锅里的菜不同但整体上所有菜都在往前做。1.2 裸机时间片调度到底在调度什么要理解裸机时间片调度首先得把三个基本概念分清楚时间片、任务、调度器。时间片由硬件定时器比如常见的 SysTick 或通用定时器产生固定的中断节拍这个节拍就是系统的时基。通常我们会把时基配置在 1ms 到 10ms 之间具体看实际业务对实时性的要求。1ms 是很多场景的折中值既不会让定时器中断太频繁也能保证毫秒级的响应。任务一个任务本质上是一个函数这个函数必须写成“非阻塞”风格也就是要么快速执行完要么用状态机分步推进不能在内部死等。调度器调度器是一段管理代码它决定“下一个该跑哪个任务”。在时间片轮转调度中调度规则很简单所有就绪任务按固定顺序依次执行每个任务在单位时间内最多运行一个时间片。与真正的实时操作系统RTOS相比裸机时间片调度省掉了内核、任务栈、信号量、消息队列等重量级机制代码量可以控制在两三百行以内ROM/RAM 占用极低非常适合资源紧张的单片机场景。但这里要强调一个关键区别很多裸机框架里的“时间片调度”其实是“协作式多任务”也就是说任务的切换并不是凭空打断的而是由主循环按照时基去“提醒”某个任务该执行了任务执行完毕后主动返回而更严格的“抢占式时间片调度”则需要在定时器中断里直接打断正在运行的任务并保存现场。这两种方案后面我会分别给出实现方式。2. 方案选型与任务模型设计2.1 几种裸机多任务实现方案的对比在决定手写的调度框架之前我先后评估过几种常见的裸机多任务实现方案这里直接把我比较后的结论整理成一张表方案实现难度实时性资源占用适用场景普通大循环 状态机低差受任务长度影响极少任务量少、实时性要求低定时器中断 主循环任务表扫描中低较好非抢占极低大部分中小型嵌入式业务定时器中断 上下文切换抢占式高好可精确控制时间片中等需额外栈空间对实时性要求较高的特定场景直接移植 RTOS中高好较高需较多 RAM/ROM任务多、需要 IPC 等高级机制对于大多数产品型项目我建议优先选“定时器中断 主循环任务表扫描”这种非抢占方案。原因很朴素在裸机上做真正的抢占式调度要处理现场保存、栈切换、临界区嵌套等一系列细节稍不留神就会踩坑而它带来的收益在很多场景里并不明显。非抢占调度配合状态机设计已经能覆盖传感器采集、按键扫描、显示刷新、通信协议解析等绝大部分日常需求。真正需要抢占的场景不如直接评估上 RTOS性价比更高。2.2 任务控制块TCB与任务表的数据结构设计手写调度框架最关键的一步是定义好任务控制块Task Control BlockTCB。TCB 是调度器管理任务的最小单位它记录了一个任务的全部状态。我常用的一个精简定义如下#define TASK_MAX_NUM 8 typedef void (*task_func_t)(void); typedef struct { task_func_t func; /* 任务函数指针 */ uint32_t period; /* 任务的执行周期单位tick */ uint32_t counter; /* 倒计时的计数值每 tick 递减 */ uint8_t enable; /* 是否使能1 为运行0 为暂停 */ } task_tcb_t;func函数入口调度器检查到某个任务到期后就通过这个指针调用函数。period任务的执行周期。比如某个任务是每 10 个 tick 执行一次那么这里就填 10。counter倒计时变量。每个 tick 中断里把所有使能任务的counter减 1当减到 0 时说明该任务在该轮询周期内“到点了”可以执行。enable任务使能开关方便在运行时动态挂起或者恢复某个任务。任务表就是一个全局数组直接定义固定大小不用动态内存static task_tcb_t task_table[TASK_MAX_NUM]; static uint8_t task_count 0;为什么用数组而不是链表因为单片机场景里任务数量通常固定数组的遍历效率高、代码简单、不会产生内存碎片。等任务数量真的多到需要动态增删时那基本就该考虑 RTOS 而不是裸机框架了。3. 手写一个可用的非抢占时间片调度框架3.1 基础数据结构与任务注册实现有了 TCB 定义下一步就是任务的注册和初始化。所谓的注册就是把任务函数、周期这些信息填进任务表里。我的实现里提供了一个task_register接口uint8_t task_register(task_func_t func, uint32_t period) { if (task_count TASK_MAX_NUM) { return 0xFF; /* 任务数量已满注册失败 */ } task_table[task_count].func func; task_table[task_count].period period; task_table[task_count].counter period; /* 初始时直接到期让任务尽快先跑一次 */ task_table[task_count].enable 1; task_count; return (task_count - 1); /* 返回任务编号也可以作为后面操作任务的句柄 */ }这里有一个细节注册任务时把counter初始化为period而不是从 0 开始是为了让任务在系统启动后的第一个周期内马上执行一次避免刚开机时所有任务都要等满一个周期才动起来。调度器的核心启动逻辑就是“定时中断倒计时 主循环扫描执行”。初始化完成后在main函数里开定时器、开总中断然后进入一个空转的主循环剩下的全部交给调度器。3.2 定时器时基配置与中断服务函数时基是整套框架的心脏。我用的是通用的硬件定时器配置成固定中断频率。以常见的 1ms 时基为例定时器中断服务函数要做的事情其实很简单遍历所有任务的 TCB把使能且未暂停的任务的counter减 1。代码大致是void timer_isr(void) /* 由硬件定时器触发的 1ms 中断 */ { uint8_t i; for (i 0; i task_count; i) { if (task_table[i].enable task_table[i].counter 0) { task_table[i].counter--; } } }这段代码非常轻几十条指令就执行完毕对主循环的打断时间可以忽略不计。中断服务函数里不要做任何业务处理这个原则必须坚持。很多初学者喜欢在中断里直接调用任务函数或者做复杂的数据处理这会大大增加中断关闭时间导致其他中断丢失是整个框架最隐蔽的性能杀手。3.3 主循环扫描与任务执行逻辑主循环里做的事情同样简单就是遍历任务表找到counter 0的任务重置counter为period然后调用对应的函数void scheduler_run(void) { uint8_t i; for (i 0; i task_count; i) { if (!task_table[i].enable) { continue; } if (task_table[i].counter 0) { task_table[i].counter task_table[i].period; task_table[i].func(); /* 执行任务函数 */ } } }main函数最终看起来是这样的int main(void) { board_init(); timer_init(); /* 配置定时器1ms 中断 */ task_register(task_scan_key, 2); /* 每 2ms 扫描一次按键 */ task_register(task_read_sensor, 5); /* 每 5ms 读取一次传感器 */ task_register(task_update_disp, 20); /* 每 20ms 刷新一次显示 */ task_register(task_send_frame, 10); /* 每 10ms 发送一次通信帧 */ __enable_irq(); while (1) { scheduler_run(); } }任务函数本身要遵守一条核心约定不能阻塞。比如按键扫描任务不要在函数里面用延时函数去消抖而是每次只扫描一次、记录状态变化抖动判断用状态机分多步完成。传感器读取也一样如果传感器转换需要时间就把它拆成“发起读取”和“检查结果”两个状态分别放在几个周期里完成。做到这一点整个调度框架才真正稳定运行。3.4 扩展一给任务增加延时等待能力很多时候任务之间需要同步或者任务内部需要等待一段时间再继续推进。如果直接在任务函数里用delay(100)整个系统都会被卡住。我认为合理的做法是把这种等待也转换成“状态机 时间计数”的方式。也就是给 TCB 增加一个delay_count字段任务在执行中需要等待时不是调用延时函数而是把自身切换到一个“睡眠”状态让调度器跳过它直到计数结束。在原始 TCB 基础上增加两个字段即可typedef struct { task_func_t func; uint32_t period; uint32_t counter; uint8_t enable; uint8_t suspended; /* 1 表示任务主动挂起等待 */ uint32_t suspend_cnt; /* 剩余挂起 tick 数 */ } task_tcb_t;任务内部调用task_suspend(ticks)时把自己的suspended置 1设置suspend_cnt ticks中断服务函数里把挂起中的任务suspend_cnt递减到 0 后恢复suspended 0。主循环扫描时直接跳过suspended为 1 的任务。这样任务就能表达“我需要等 100ms这段时间不参与调度”的需求同时又不会阻塞整个系统。3.5 扩展二关于抢占式时间片调度的实现思路非抢占方案配合状态机能解决大部分问题。但也有个别场景确实需要“硬实时”的时间片切换比如两个任务都必须严格每 5ms 轮换执行一次谁也不准超时。这时就需要抢占式调度。抢占式调度和非抢占的本质区别在于谁拥有强制切换任务的权力。非抢占模式下主动权在任务自己手里任务不主动让出 CPU谁都没办法抢占式则是由定时器中断来决定“你这个时间片用完了立刻换下一个”。实现抢占式调度核心是上下文切换。简单说就是每个任务要有独立的栈切换时要保存当前任务的寄存器现场包括栈指针、返回值地址、通用寄存器等再恢复下一个任务的现场。在常见的 ARM Cortex-M 内核 MCU 上一般利用 PendSV 异常来完成切换而 SysTick 只负责产生 tick 信号。SysTick 中断里判断当前任务时间片是否用完如果需要切换就触发一次 PendSV在 PendSV 中完成真正的现场保存和任务栈切换。用 PendSV 而不是直接在 SysTick 里切换任务是为了处理中断嵌套问题——如果在硬件中断里做任务切换一旦切换过去之后中断现场不好收拾容易出各种玄学问题。不过我要强调一句裸机抢占式调度的代码量和调试难度都会明显上升而且一旦任务栈深度没算好就会出现栈溢出、程序跑飞这类极难排查的问题。所以在资源允许、场景确实需要的时候我更推荐评估轻量级 RTOS而不是自己在裸机上硬造抢占式内核。4. 常见问题与排查技巧实录4.1 任务函数嵌套过深导致栈溢出裸机没有任务栈和内核栈的概念所有函数共用同一个栈区。如果多个任务函数的调用层级都很深比如任务 A 调用函数 A1A1 又调用了某个很深的工具函数主循环的栈臂就会相应加深。多个任务叠加下来一旦超过启动文件里设置的栈大小程序就会跑飞。这个问题不太好提前算准我的排查思路是先在工程里设置一个足够大的栈比如把堆栈值从默认 0x400 调到 0x1000验证功能正常然后用“栈水印”的方法去量实际用量。做法是初始化时把整个栈区填成固定字节0xAA跑完整个业务流程后扫描栈区看最高处的0xAA被改写到了哪里就能估算出真实峰值栈需求。之后再按这个数值加上安全余量把栈大小调回来。4.2 共享资源的竞争与临界区保护时间片调度的主循环是轮流执行各个任务的但中断服务函数和主循环之间、以及多个任务之间依然会共享变量。举一个实际踩过的坑显示刷新任务正在向一个全局缓冲区写入一帧显示内容写到一半时定时器中断来了中断服务函数里又更新了这个缓冲区的某个字段。结果主循环恢复后继续写缓冲区内容就处于“一半是旧数据、一半是新数据”的中间状态显示就会闪烁甚至花屏。解决这类问题的要点是识别临界区。最简单的保护手段是开关中断__disable_irq(); /* 临界区修改共享变量、读写共享缓冲区 */ __enable_irq();但注意关闭中断会让定时器 tick 中断延迟进而影响整个调度的节拍稳定性。所以临界区要尽量短最好只保护“几个变量的赋值、一段数据拷贝”不要在临界区里调用耗时较长的函数更不能在里面执行任务自身的业务逻辑。如果两个任务之间要共享数据优先考虑用全局标志加消息缓冲的方式把“数据交换”这一步集中到调度器统一管理而不是让任务各自乱改公共变量。4.3 时间片长度与任务周期的选择时间片长度直接取决于时基定时器的中断频率。很多人一开始不知道该怎么选这里给一个我常用的参考流程。先列出所有任务的实时性要求。比如按键扫描要求 5ms 内响应一次传感器采样周期 10ms显示刷新 50ms 以内都行。选出最小的时间粒度一般取 1ms 作为时基。为什么是 1ms 而不是 0.1ms因为更短的时基会让定时器中断频率达到 10kHzCPU 光在中断里就要花掉可观的开销在低主频 MCU 上会影响任务本身的执行效率而 1ms 时基下中断函数里只做减计数开销极小。每个任务的period按时基的倍数去配置。比如 5ms 的任务填 550ms 的任务填 50。这里还有一个平衡问题如果时基选得太小比如 0.1msCPU 大量时间花在中断进栈出栈上任务实际执行时间被压缩如果选得太大比如 10ms所有任务的响应时间都至少是 10ms某些快速事件可能来不及处理。我会根据实际效果在 1ms ~ 2ms 之间做权衡这个区间在多数中低主频 MCU 上都比较从容。4.4 调试调度框架的实用技巧调度器这种东西出问题时不直观跑飞了也不好定位。我调试时最常用的手段是“用 GPIO 看心跳、用逻辑分析仪看波形”。具体做法是找一个空闲 GPIO在主循环的scheduler_run()入口拉高退出前拉低再找另一个 GPIO在定时器中断里翻转。这样逻辑分析仪上就有两路方波信号一路反映主循环的运行情况一路反映中断的触发情况。如果任务执行时间过长第一路波形的高电平宽度就会异常变大如果中断被长期关闭第二路波形就会中途缺失。靠这个办法我几次快速定位到了“某任务内部误关中断时间过长”的问题。另外建议在 TCB 里加一个调试字段记录每个任务的执行次数或最大执行时间。等系统稳定后再用串口打印出来看哪种任务的执行频率和理论值对不上就能顺藤摸瓜找到问题。4.5 中断服务函数与任务函数的分工最后想提醒一个很容易被忽略的设计原则中断服务函数里只做“标记事件”不处理业务逻辑实际的数据处理放在对应的用户任务里。举个例子串口收到一个字节中断里就把它压入一个环形缓冲区同时置位一个“收到新数据”的标志通信解析任务每隔几个 tick 去检查这个标志有数据就取出来解析。这样中断保持轻量任务之间的数据耦合也清晰。不要图方便在中断里直接调用任务函数或者在中断里做协议解析、浮点运算这类耗时工作。中断时间一长轻则影响 tick 计数精度重则导致其他外部中断丢失整个系统的实时性就破功了。5. 框架的后续扩展与个人经验5.1 下一步可以往哪个方向扩展如果这套基础的时间片框架跑顺了我建议可以根据项目需求加一些实用的增强功能。第一个是“任务优先级”。如果某个任务计时到点但其他优先级更高的任务也到点了可以让高优先级的先执行。整体思路是给 TCB 增加一个优先级字段在主循环扫描时先找优先级最高的到期任务。由于是协作式调度这种“优先级”只影响执行顺序并不会带来抢占式调度的复杂性实现成本很低。第二个是“空闲任务”。当所有任务的counter都没到 0 时主循环实际上在空转。可以利用这段时间让 MCU 进入低功耗模式等下一个 tick 再唤醒。做法也很简单在scheduler_run()判断本轮没有任务执行时调用__wfi()或者其他低功耗指令。不过要注意如果使用了低功耗模式定时器必须能唤醒 MCU否则整个系统会睡死过去。第三个是“动态启停任务”。现在 TCB 里的enable字段已经支持暂停和恢复可以封装task_start(id)、task_stop(id)这两个接口。这样某些只在特定阶段才需要的任务比如初始化阶段的慢速自检任务、进入省电模式后的后台轮询任务就能在运行时灵活启停。5.2 我踩过几次坑之后的一个建议说了这么多实现细节回到选型这个点。我个人的体会是裸机时间片调度框架最适合的场景是任务数量在十个以内、任务间交互简单、实时性要求中等毫秒级即可的产品。如果你发现自己在裸机调度器上越补越多机制——又要优先级、又要阻塞等待、又要任务间通信消息——那其实已经是在“手搓 RTOS”了这时候与其继续给框架打补丁不如认真评估一个现成的轻量级 RTOS。反过来讲任务数量不多的时候引入完整 RTOS 也是一种负担因为要面对调度器配置、内存占用、调试工具不熟悉等一系列问题。裸机时间片调度用两三百行代码就把“多任务”这层窗口纸捅破了让程序结构清晰很多出问题也好查。这也是我一直推荐中小型单片机项目先从这个框架入手的根本原因。以后再看项目代码就不再是一锅粥的大循环而是一张清清楚楚的任务表哪个任务多长周期跑一次一眼就能看明白。