这个系列我写RTOS的高频问题已经很久了,“RTOS 中的任务到底是什么”这个问题看起来基础,但每次线下交流都能发现不少开发者其实只停留在“任务就是个函数”的层面。真要把任务讲透,绕不开状态机、任务控制块、任务栈、调度器和优先级这几座山。如果看这篇文章之前还在裸机大循环里被各种功能互相卡顿折磨,或者刚把任务创建出来却发现系统隔三差五莫名重启,那这篇内容正好对症。我尽量用大白话加真实项目里踩过的坑,从任务的定义一直讲到任务划分的实操建议,初学能听懂,有经验的人也能抠出一些系统层面的细节。
1. 先从裸机开发说起:为什么需要“任务”这个概念
1.1 超级循环里的“伪并发”:一个while(1)的烦恼
做嵌入式开发的几乎都写过这种经典裸机代码:
while (1) { led_toggle(); // 翻转LED key_scan(); // 扫描按键 data_process(); // 处理数据 uart_send(); // 串口发送 }表面上看,四个函数轮流执行,好像没什么问题。但实际是串行执行:如果data_process()偶尔要跑 10 毫秒,那么key_scan()就不得不等 10 毫秒才能跑。按键扫描漏事件、LED 闪烁周期忽快忽慢,都是这种“尽力而为”的调度方式带来的必然结果。中断确实能在一定程度上缓解,但如果把耗时的逻辑搬到中断服务函数里,主循环照样被卡得死去活来,而且中断嵌套还会引入更难查的嵌套问题。
这就是RTOS出场的地方。RTOS把每个功能拆成一个独立的执行单元,这个执行单元就是“任务”。任务之间并不是真的并行,而是由内核里的调度器决定:谁先跑、谁让路、谁可以先去睡一觉。你写业务逻辑的时候,不需要再像裸机那样反复计算“这段代码会不会卡住另一个功能”,只要功能划分合理,调度器会自动帮你安排时间。
1.2 任务并不神秘:它就是一个可以被挂起和恢复的执行流
任务从代码层面看,入口就是一个普通函数。但它和普通函数的区别在于:普通函数调用时,调用方必须等它返回才能继续;任务却可以在执行到中途时“停下来”,比如在某个系统调用处等待队列、等待信号量、延时几毫秒。停下来之后CPU被调度给别的任务,等条件满足了,内核再让这个任务从这个停下来的位置继续往下跑。
那这个“接着跑”是怎么实现的?关键在栈。任务每次被切换走时,内核会把这个任务当时的CPU寄存器值、函数返回地址、局部变量等等现场信息,保存在这个任务自己的栈里。下次切回来时,再从栈里恢复这些信息,任务自己完全感知不到被切走过。
用一个生活里的类比:你在写一份文档,写到一半被人叫去开会。你没把整个文档扔掉,而是把当前进度记在笔记本上,开完会回来接着写。任务的“写着写着突然被暂停,然后又接着写”,就是执行流被内核挂起和恢复的过程。
这里特别提醒:任务函数通常不会返回。你写的任务函数主体一般是个死循环,比如循环里读传感器、发消息、延时。任务创建时,内核是把它当作“永不结束的执行流”来对待的。如果任务函数真的返回了,在不同内核里可能触发错误钩子,或者任务会被内核自动清理掉,但正常设计一定不要让它返回。
1.3 任务和中断服务函数的区别要点
裸机开发接触过中断的人,容易把任务和中断搞混。中断也是“暂停当前工作去处理另一件事”,但两者本质不同:
- 中断由硬件事件触发,什么时候来是“异步”的,优先级由硬件中断控制器决定,内核只能被动响应;任务由软件调度器决定什么时候运行,内核完全可控。
- 中断服务函数要求短小、快速,不能阻塞,也不适合调用大部分内核阻塞API;任务可以随意阻塞等待,可以校准延时。
- 中断服务函数里使用内核的通信API时,通常必须调用专门的“从ISR中调用”版本,任务里就没有这种限制。
很多新手在中断里写延时等待外设完成,结果把整个中断拖住,低优先级中断全部被饿死。这在裸机时代可能还能凑合,到了RTOS环境下就是灾难。中断服务和任务的分工应该是:中断只负责快速标记事件,实际的耗时处理放到任务里。
2. 任务的状态机:一个任务这辈子到底在忙什么
2.1 状态只有运行、就绪、阻塞、挂起这几种
如果去看RTOS文档或源码,状态图永远绕不开。任务这辈子其实只有四种核心状态,搞清楚它们就够用了。
- 运行态:任务正在占用CPU执行代码。同一个CPU核在同一时刻只能有一个任务处于运行态。
- 就绪态:任务已经具备运行条件,正在等待CPU资源。它在就绪队列里排队,只要调度器选到它,立马就能跑。
- 阻塞态:任务在等待某个条件,比如延时还没结束、信号量还没释放、消息队列还是空的。条件不满足时,哪怕CPU闲着,它也不会运行。
- 挂起态:任务被其他任务或内核主动暂停,必须由别人主动恢复才能继续运行。
有的内核还会把“新建态”“删除态”分开标注。比如任务刚创建还没进入调度队列,或者任务函数返回后被标记为待回收,这些都是受管理阶段的特殊状态,但理解了上面四种,其它状态基本都能套进去。
阻塞和挂起很容易被混淆,但逻辑差别很大。阻塞是任务主动“等着什么东西”,条件满足后内核会自动唤醒它,不需要别人操作;挂起则是被“按了暂停键”,只有外部调用恢复API,它才会回到就绪态。二者用对场景很重要。
2.2 状态迁移是怎么发生的:一个典型任务代码示例
用一个最常见的任务函数来演示状态迁移:
void sensor_task(void *param) { uint16_t raw; while (1) { raw = read_sensor(); // 运行态:读传感器 queue_send(&data_queue, &raw, 100); // 运行态:尝试发队列;若队列满,进入阻塞态等待最多100ms event_wait(&sample_event, 2000); // 阻塞态:等待采样事件,最多等2s } }任务第一次被调度时进入运行态。执行到queue_send时,如果队列满了,任务从运行态进入阻塞态,内核切换到别的任务。另一个任务从队列里取走一条数据之后,内核发现等待这个队列的任务条件满足,把sensor_task从阻塞链表摘出来,放入就绪链表,它进入就绪态。如果此时它的优先级最高,调度器会立刻又把它切回运行态。
这里有个关键认知:状态切换不是任务自己完成的,而是内核在做完上下文切换后,根据任务当时调用的API来更新状态。任务代码层面根本感觉不到自己被切走,它只觉得自己在“等一个函数返回”。就像医院排队叫号,你在队伍里等着,叫号系统怎么记录顺序、怎么排序,你不用管,轮到你了自然往前走。
2.3 不同内核状态命名差异:别被文档绕晕
不同的RTOS对这些状态的命名并不统一。有的把运行态叫 Running,就绪态叫 Ready,阻塞态叫 Blocked 或 Waiting,挂起态叫 Suspended;有的把阻塞态再按等待原因拆成“因延时阻塞”“因信号量阻塞”“因队列阻塞”。中文资料里更是“就绪”“运行”“等待”“挂起”混着用。
我在看内核实时状态视图时,通常不会死记名字,只看这个任务当前被挂在哪个链路上:是挂在就绪链表里,还是挂在某个等待链表里。这个视角最不容易被文档绕晕。调试时如果发现一个本该阻塞的任务长期显示为就绪态,大概率是代码里出现了忙等轮询;如果有任务一直卡在阻塞态,就要怀疑它在等一个永远等不到的事件。
3. 任务控制块(TCB)与任务创建背后的秘密
3.1 TCB里到底存了什么
任务控制块(Task Control Block,TCB)是内核用来管理任务的核心数据结构。任务本身是一段散落在代码里的执行流,TCB则是系统为每个任务建立的一份“户籍档案”,记录着任务当前的运行线索。
一个典型的TCB结构体大概长这样:
typedef struct tcb { uint32_t *stack_ptr; // 当前栈顶指针,调度时从这里恢复现场 uint32_t *stack_base; // 栈起始地址 uint32_t stack_size; // 栈大小 uint8_t priority; // 任务优先级 uint8_t state; // 任务状态 void (*entry)(void *); // 任务入口函数 void *arg; // 任务创建时传入的参数 list_node_t ready_node; // 就绪链表节点 list_node_t wait_node; // 等待链表节点 } tcb_t;其中stack_ptr是最核心的字段。每次任务被切换走,内核把所有CPU寄存器值压入任务自己的栈,然后更新stack_ptr;切换回来时,从stack_ptr指向的位置弹出所有寄存器,任务接着执行。可以理解成:整个上下文切换的本质就是“换栈指针”。
priority是调度器的依据,ready_node和wait_node把任务挂到对应的链表上。内核遍历链表找出最高优先级任务,而不是扫描所有代码。所以你写的业务函数只是任务的外壳,这个TCB才是内核真正操作的“任务本体”。
3.2 创建任务时,内核偷偷做了四件事
业务里创建任务,通常就是一行API:
task_handle_t handle; task_create(&handle, "sensor", sensor_task, NULL, 2048, 4);但这行代码背后,内核做的事情远比你想象得多。
第一步,分配资源。动态创建时,内核从堆里分配一块TCB内存和一块任务栈内存;静态创建时,直接使用你提供的TCB数组和栈数组。
第二步,初始化TCB字段。把优先级、任务入口、参数、栈地址都填进去。
第三步,初始化任务栈,伪造一份“初始现场”。任务还没运行过,没有历史上下文,内核就在新任务的栈上手动构造一个“看起来刚被调度走”的现场:把任务入口地址、初始的CPU标志位、程序状态字等按顺序压入栈。这样任务第一次被调度时,走的跟普通任务恢复是完全一样的路径。
第四步,把TCB挂到就绪链表,并触发一次调度判断。如果新任务优先级比当前运行任务高,内核立刻发起上下文切换,让新任务马上跑起来。
很多人不理解第三步“伪造现场”的意义。其实这是内核设计里很漂亮的地方:切换任务时,调度器不需要区分“首次启动”和“恢复运行”,统一走同一条恢复现场的逻辑,代码简单不少,也不容易出错。
3.3 动态创建与静态创建的选择
动态创建的优点是灵活,TCB和栈都从内核堆里分配,任务删除后可以回收内存。缺点也明显:堆管理会带来不确定性,频繁创建删除任务容易产生内存碎片,而且堆本身也要吃掉一块RAM。
静态创建则需要你提前定义好任务栈数组和TCB变量,比如:
static uint8_t sensor_task_stack[2048]; static tcb_t sensor_task_tcb; task_handle_t sensor_handle; task_create_static(&sensor_handle, "sensor", sensor_task, NULL, sensor_task_stack, sizeof(sensor_task_stack), 4);好处是没有堆分配,没有碎片问题,内存布局在编译期就可以确定,实时性和安全性更可预期。缺点是灵活性差,创建几号任务必须亲手准备几块内存。
在RAM紧张的常见MCU上,我更倾向于能用静态就用静态。栈大小直接写在数组定义里,也更容易做审查和调试。还是那句话,静态创建不意味着栈溢出风险更小,栈数组给小了照样爆。
再补充一个很容易忽略的点:任务句柄。task_create返回的handle本质是一个指向TCB的指针。你以后要挂起、恢复、删除、给这个任务发通知,全靠句柄。句柄丢了,任务还在系统里跑,但你已经失去了控制它的入口,相当于把一个陌生人放进了你家,却把他钥匙扔了。所以创建任务后,把句柄统一存到全局变量或模块结构体里,不要随手丢弃。
4. 任务栈:每个任务都有一块专属工作台
4.1 为什么任务不能共用一块栈
任务切换时,CPU寄存器现场要保存在自己的栈上。如果多个任务共用一块栈,任务A刚把现场压栈,切到任务B后B开始调用函数,新压入的数据会直接把A的现场覆盖。等切回A时,恢复出来的寄存器和返回地址全是B留下的半成品,A跑几步就崩溃。
所以每个任务必须有一块独立的栈空间。这也是RTOS内存开销的大头。12个任务,每个给1KB栈,光任务栈就吃掉12KB RAM。在资源有限的MCU上,这可不是小数目。任务栈规划是RTOS工程里的正经事。
理解“换栈指针”这个本质之后,很多问题都通了:为什么每个任务都感觉自己在独占CPU?因为切走的任务把现场完整留在了自己的栈里,切回来时现场原封不动。栈,就是每个任务专属的“办公桌”,别人收拾得再整齐,也不会碰你这张桌子。
4.2 栈大小怎么估算
任务栈大小的估算,本质是回答一个问题:这个任务最坏情况下会同时占用多少字节栈空间。影响要素至少有四类:
- 任务函数以及它所调用函数的局部变量大小。局部数组哪怕不初始化,也会在进入函数时占用栈空间。
- 函数调用嵌套深度,尤其是递归。RTOS任务里应当尽量避免递归,因为递归的深度无法预先控制。
- 是否使用了printf、浮点格式化等“栈大户”。很多嵌入式C库的printf实现,一次调用可能吃下几百字节栈。
- 中断嵌套开销。某些内核环境下,中断服务函数会嵌套在任务栈上执行,这部分栈开销也必须计入。
实际工程里,我不会一开始就精确计算,而是先用一个偏大的值,跑完功能后看栈高水位线,用实际值反推。操作方法是:创建任务后,把整个任务栈填充成固定标记值,比如0xA5,运行一段时间后在调试器里查看栈区域,找到还没被覆盖的字节数。那个数字就是空闲栈空间,任务真实栈使用量等于“栈总大小减去空闲字节数”。在这个实测值基础上再留30%到50%余量,作为后续加功能的安全缓冲。
值得注意的是,任务栈底和栈顶经常预埋标记值,比如0xCCCCCCCC,内核的栈溢出检测钩子会定期检查。一旦发现标记值被改写,就知道栈越界了。这个方法在前面调试阶段百试百灵。
4.3 栈溢出检测三板斧
栈溢出是RTOS项目里出现最频繁、表现最诡异的一类问题。溢出后的数据会踩到相邻任务栈、TCB或全局变量,表现可能是:系统运行几小时后随机死机、函数返回后局部变量被改、通信数据偶尔错乱、调试器断下来发现程序跑飞。这些症状很难直接联想到栈溢出,所以我把排查手段整理成了三板斧。
第一板斧,系统视图法。利用内核或调试器集成的任务栈使用情况查看功能,实时看每个任务栈的水位。哪个栈已经用了93%以上,就先查哪个。这能发现“栈偏小”的问题,但缺点是必须等系统跑起来。
第二板斧,金丝雀法。任务创建时把整个栈填充0xA5,栈顶栈底放0xCC。系统崩溃或重启后,用内存搜索0xCC被改写的位置。如果损坏发生在栈底附近,基本就是任务栈容量不够;如果发生在栈顶附近,则可能是中断嵌套太深或某个局部变量太大。这套方法不依赖硬件MPU,适合开发早期。
第三板斧,硬件保护法。如果MCU支持MPU,就给每个任务栈区域设置“不可写”属性。任务一旦越界写栈,立即触发内存访问异常。异常处理里可以拿到出错的地址和现场寄存器,能精确还原出哪个函数在哪一步越界。代价是MPU区域切换逻辑复杂一些,但安全要求高的产品值得这么投入。
我的使用顺序是:开发阶段用金丝雀加视图法,进入稳定测试阶段再考虑硬件保护。千万别拖到用户反馈“偶尔死机”才开始查栈,那时候排查成本是最高的。
5. 调度器与优先级:CPU下一步该听谁的
5.1 抢占式优先级调度到底怎么抢
RTOS内核最核心的模块就是调度器,它的职责只有一个:在所有就绪任务里,选出那个应该占用CPU的任务。抢占式调度是一种规则:只要一个更高优先级的任务进入就绪态,内核立刻暂停当前任务,把CPU让给高优先级任务。
用一个完整例子来理解抢占。设任务A优先级5,任务B优先级8。当前A正在运行,B因为等待串口数据处于阻塞态。串口中断到来,中断服务函数收到数据后,把B从阻塞态唤醒,B进入就绪态。但此刻CPU还处在中断上下文里,不能马上切换任务。于是内核置位一个名为PendSV的低优先级异常,中断服务函数一退出,PendSV立即触发,上下文切换才在PendSV里完成,B成功抢占A。
所以你在任务代码层面感受到的是“高优先级任务一就绪,低优先级任务立刻靠边站”,但真正的切换动作发生在中断尾部。你写业务逻辑时不需要直接接触PendSV,但理解这个过程对排查时序问题很有帮助。
这里必须加一条提醒:不同RTOS对优先级大小的约定不一样。有的数字越大优先级越高,有的数字越小优先级越高。换一个内核平台,一定先查手册,别凭经验硬套。这个坑我见过不止一次,有人从A内核迁到B内核,把优先级写反了,结果整个系统的响应顺序完全颠覆。
5.2 时间片轮转:同优先级任务怎么分时间
如果两个任务优先级相同,调度器按优先级就选不出赢家。这时需要时间片轮转机制:每个任务运行一小段固定时间(比如几个毫秒),时间片用完还没有主动阻塞,就把CPU切换给下一个同优先级任务。
用代码示意:
void task_a(void *p) { while (1) { do_a(); } } void task_b(void *p) { while (1) { do_b(); } } // 两任务优先级相同,设为3如果do_a()和do_b()都是无阻塞死循环,调度器会按时间片让它们交替执行。注意,时间片轮转只发生在“优先级相同的任务”之间。高优先级任务一旦就绪,不管时间片是否用完,都会插队。
设计系统时,把多个任务设成相同优先级可能是正常的,但要注意它们各自在时间片内的耗时。如果任务A在一次时间片内做了很重的事,同优先级任务B就被拖得很久。这时候可以加大时间片配置,或者干脆给A一个稍高一点的优先级,从设计上规避不公平。
5.3 优先级反转:一个很容易踩的经典坑
优先级反转是实时系统里最经典、最坑人的调度问题。场景是:有三个任务,A高优先级,B中优先级,C低优先级。A和C共享一把锁。A先运行,尝试获取锁,但锁被C持有,于是A阻塞等待。C继续运行,还没跑几步,B就绪了,B优先级比C高,B抢占C开始运行。B是个耗时的后台任务,C一直得不到运行,锁也就一直释放不了。最终结果是:和锁毫无关系的B,把高优先级的A死死拖住。
这种现象在实时系统里会导致任务响应严重超时,表现出来就是系统卡死、对外通信超时。解决办法主要两种:
一种是优先级继承。A等待锁时,内核临时把持有锁的C的优先级提升到A的级别,C能抢回CPU,尽快释放锁,释放后再恢复原来优先级。
另一种是优先级天花板。创建锁时指定一个最高的使用优先级,任何任务拿到锁后,其优先级都被提升到这个天花板。简单说,就是让低优先级任务在持锁期间“临时升职”。
说句大实话:如果项目里需要保护共享资源,一定要用带优先级继承机制的互斥量,而不是二值信号量。二值信号量没有优先级继承机制,拿来做互斥保护,优先级反转的坑十个里有八个是这么踩出来的。
5.4 优先级分配的一些经验法则
任务优先级分配没有绝对公式,但我的习惯能避开大部分问题:
- 响应速度要求高、事件紧急的任务给高优先级。比如急停按钮、故障保护、电流过压保护。
- 周期性短任务给中等优先级。比如传感器采集、控制计算、状态机推进。
- 低频后台任务给低优先级。比如日志存储、OLED刷新、统计信息上传。
- 空闲任务优先级最低。它负责回收被删除任务的资源,很多内核里还会承担CPU使用率统计。
需要警惕的是,高优先级并不等于高CPU占用率。高优先级意味着它可以插队,不代表它能长时间霸占CPU。一个高优先级任务如果做得太重,系统里所有低优先级任务都会被饿死。实时系统调度是按照“事件重要程度”排序,不是按“谁抢CPU能力强”排序。优先级的价值是保证关键时刻的响应速度,而不是给某个任务开无限量的时间支票。
6. 任务之间的通信与同步:别让任务“忙着等”
6.1 忙等是任务协作的毒药
任务不是孤岛。任务A要等待任务B产生的数据,最直接的想法是写一个轮询标志:
while (!ready_flag); data = buffer;这段代码的问题很大。如果A的优先级高于B,A等待时不释放CPU,B作为低优先级任务永远得不到运行,ready_flag永远不会变,A就在那儿死等。这就是死锁。就算A优先级比B低,空转等待也在白白烧CPU。
正确做法是让任务进入阻塞态等待:
queue_receive(&data_queue, &data, 200);代码不再轮询标志,而是把“等数据”这件事委托给内核。数据没到,任务阻塞,CPU让给其它任务;数据到了,内核把任务唤醒,队列接收函数返回。这是RTOS下任务协作的基本姿势。
任务本质是事件驱动的:平时的样子是阻塞等待,事件一来就快速处理,处理完再继续阻塞。如果一个任务里大量出现空转轮询while (1) { if (get_status()) {...} },那说明你还没有真正用上RTOS,你只是把裸机调度方式搬进了RTOS。
6.2 常用内核通信机制的适用场景
任务间通信和同步的内核对象有好几种,各有用处,选对场景比背API更重要。
我用一张表来整理常见选择:
| 机制 | 本质 | 典型使用场景 | 注意点 |
|---|---|---|---|
| 二值信号量 | 表达“事件发生了” | 中断通知任务处理数据 | 不适合保护共享资源 |
| 计数信号量 | 记录可用资源数量 | 缓冲池有几个空闲位置 | 注意计数的增减一致性 |
| 互斥量 | 保护共享资源 | 多任务访问全局结构体、外设 | 带优先级继承 |
| 消息队列 | 任务间传输数据块 | 生产者消费者模型 | 队列长度和数据大小需评估 |
| 事件标志组 | 等待一组条件 | 等待多个任务就绪完成 | 支持任一或全部等待模式 |
| 任务通知 | 轻量级事件发送 | 高性能小项目场景 | 每个任务只有一份通知,需要合并使用 |
举个例子:传感器采集任务定期读ADC,把数据通过消息队列发给处理任务;处理任务计算完成之后,用一个互斥量保护共享显示缓冲区的更新;外部触发中断里,用二值信号量唤醒采集任务立刻采样。这套设计下来,所有任务都在阻塞等待中过日子,没有空转,CPU能效非常高。
6.3 中断与任务之间怎么正确协作
中断服务函数的基本纪律是越短越好。因为中断会抢占任务执行,在中断里停留太久,实时任务的响应时间就会被拉长。常规协作模式是:中断里只完成“清中断标志、读硬件寄存器、给某个任务发信号”三件事,真正的数据处理放到任务里。
比如串口接收中断可以这样设计:
void uart_isr(void) { uint8_t ch = UART_READ_DATA(); // 读数据寄存器 ringbuf_push(&rx_buf, ch); // 压入环形缓冲区 semaphore_give(&rx_sem); // 唤醒接收任务 // 绝不在中断里解析帧、处理业务 }接收任务靠semaphore_take(&rx_sem)阻塞等待,中断一给信号,任务立即被唤醒,然后从环形缓冲区里取数据做协议解析。这种模式把“实时响应”和“耗时处理”完美分开。
中断里调用内核API要特别小心。绝大多数内核API都有“从ISR调用”的专用版本。用错版本,轻则信号量唤醒不生效,重则破坏内核链表导致死机。我项目里的约定是:所有中断服务函数统一命名,内部只调用ISR版本API,并禁止在中断里调用任何阻塞延时函数。设计成习惯之后,中断相关的问题少了一大半。
7. 任务设计实战与踩坑记录
7.1 任务划分的三个原则
划分任务时,下面三个原则是我屡试不爽的。第一个是事件驱动原则:任务核心逻辑应该沿着“等事件、处理事件、再等待”的循环走,而不是空转。第二个是频率匹配原则:快周期任务(1毫秒到10毫秒级别)独立成任务或干脆放在定时器中断里处理,慢速任务(秒级)可以合并,减少任务数量和切换开销。第三个是资源内聚原则:同一个外设的读写尽量打包进同一个任务,避免多个任务同时操作外设寄存器造成冲突。
任务数量并不是越多越好。每个任务都要吃一块TCB和一块栈RAM,任务切换也有开销。一个16KB RAM的MCU,塞6个任务就得精打细算了。我在做任务划分前,都会先画一张简单的依赖图,写清楚“谁在等谁的数据”“谁给谁发消息”。别嫌这一步土,它能把很多后期死锁问题提前暴露在纸面上。等到代码全写完,再发现任务A等B、B等A的环形等待,那就得动大手术了。
7.2 别在任务函数里写巨型代码
裸机开发时,很多人习惯把一个大功能写成几百行的大函数。在RTOS环境里,这种习惯非常危险。一个任务函数如果从头到尾都在做耗时操作,比如大数组排序、一轮完整算法处理,就会把比它低优先级的所有任务全部堵死。
合适的形式是把任务函数设计成一个小而精的状态机,把一个大处理切分成多次“唤醒、处理、再等待”。或者干脆把重计算拆给一个专门的任务,控制任务只负责发起和收集结果。
我后来给自己定了一条规矩:每个任务主函数控制在100行以内,超出就拆。拆出来的子函数只做单一动作,任务主函数只负责调度这些动作。这样查栈水位变得更直观,模块可以单独测试,调试器查看任务调用栈时也不会被层层嵌套刷屏。
7.3 几个让我印象深刻的栈与调度问题
有一回调一块板子,系统每天零散随机重启一次,日志里是HardFault,但现场很难抓。排查很久没头绪,后来我把所有任务栈里填充标记值0xA5,怀疑对象放在一个使用了局部大数组的任务上。单独把那个任务栈从768字节放大到2048字节后,问题消失了。定位过程不复杂,但很说明问题:全局搜索代码,找到那个数组,一旦数组越界或任务栈过小,崩溃是必然的。
还有一次,某个控制任务优先级设得很高,里面又调用了printf往串口打印调试日志。打印一次是几十毫秒,高优先级任务把CPU卡住以后,低优先级任务长期得不到运行,看门狗被饿得超时重启。当时看日志以为是任务死锁,查来查去发现是高优先级任务里执行了重IO操作。后来我把调试打印单独拆成一个低优先级任务,日志通过队列异步输出,控制任务只负责核心计算,问题才彻底解决。
这两个例子合起来,其实说明同一个道理:RTOS里的大多数运行时问题不是RTOS自身的问题,而是任务栈或者任务时间行为不符合预期。排查顺序很重要,我一般按照“任务栈水位、任务耗时、优先级关系”去查,而不是漫无目的地从头到尾读代码。
7.4 最后分享一个排查技巧
如果你遇到“任务互相等死”的诡异现象,先别急着改代码。打开内核的实时视图,看每个任务的状态和它正在等待的内核对象。如果有任务长期停在阻塞态等待某个队列,而生产这个队列的任务又在等另一个互斥量,那就把注释里所有任务的等待关系列出来,重点找“环形等待链”。
多数内核都提供查看“某个TCB挂在哪个等待链表上”的工具信息,有条件就优先利用。实在没有工具,就在可疑任务进出通信API的前后,加一片用GPIO翻转的探针,上示波器或逻辑分析仪看时序。这个方法在我的项目中救过很多次:花十分钟看任务状态分布,远胜于在键盘前面瞪眼瞎猜几小时。
RTOS里的任务,说穿了就是一段带独立栈、独立控制块、可以被调度和阻塞的执行流。把状态、栈、TCB和调度规则想明白,后面再学消息队列、信号量、事件组,都会顺很多。这个系列下一篇我打算把“任务切换到底是怎么发生的”拆开讲,想持续跟的朋友,建议先把今天这篇里的栈现场保存与恢复部分吃透,那是处理器层面最核心的东西。