ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

80C196移植MicroC/OS-II实时内核裁剪与切换避坑

80C196移植MicroC/OS-II实时内核裁剪与切换避坑

简介:这份文档是一篇围绕 MicroC/OS-II 在 80C196 微控制器上移植的完整技术论文,面向嵌入式开发初学者、课程设计者及需要升级旧系统的工程人员,重点回应资源受限环境下引入抢占式多任务实时内核时,硬件适配、裁剪与优化如何落地的问题。资料包仅含 1 个 doc 文件,整体约 45KB,篇幅集中,便于快速查阅与保存。已有 283 人学习,说明其在嵌入式实时操作系统移植学习中具有一定参考价值。内容从编译器选择、OS_CPU_C.C 等硬件相关代码改写,到存储资源与时间资源的精简、任务分配、250μs 时钟节拍及临界段处理均有展开,还给出 OSRdyTbl、OS_TCB 裁剪和中断服务函数内完成任务切换等具体做法,可帮助读者理解移植思路、裁剪尺度与常见注意事项,并对照自己的 80C196 应用做方案取舍。

1. 无限循环配几个中断,为什么最后都撑不住

老一代控制板的主程序骨架基本就一句话:while(1)里顺序跑完所有处理,外面再挂几个中断服务函数。任务一多、实时性要求一上来,这套骨架就撑不住了——一个耗时长的故障处理函数就能把通信接收拖到超时,被中断打断的中间状态也没人管。MicroC/OS-II 这类抢占式多任务实时内核正是用来解决这个问题的:就绪的最高优先级任务永远先跑,低优先级任务随时可以被剥夺 CPU。难的地方不在内核本身,而在实际条件——手上这块 80C196 是已经量产的板子,硬件不能动,只有 220 字节片内 RAM、外面再扩 3.5K,时钟节拍还得压到 250μs。后面按「改哪些文件、裁到什么程度、现场怎么保、哪里容易出错」的顺序拆一遍。

2. 移植的边界在哪:三个文件与非改不可的四块配置

2.1 内核主体一行不用动,这才是移植量可控的原因

MicroC/OS-II 的源码分层很清楚:OS_CORE.C、OS_TASK.C、OS_TIME.C、OS_SEM.C 这些内核主体完全不认识硬件,它们只通过一层约定得很死的薄接口去碰处理器。这层接口就是 INCLUDES.H、OS_CPU.H、OS_CPU_C.C、OS_CPU_A.ASM 这几个位置。所以移植的工作量,跟芯片型号关系不大,跟这层接口写得多干净关系极大。80C196 这版的做法更进一步,把本来该放 OS_CPU_A.ASM 的四个函数全部用内嵌汇编塞进 OS_CPU_C.C,最终只维护一个与硬件相关的 .C 文件,分散度下来了,链接顺序带来的麻烦也省了。

要把「移植」这件事讲清楚,先得分清哪些代码是内核的、哪些是处理器的。内核代码里对硬件的全部依赖其实只有四件事:一是基本数据类型到底几个字节,二是怎么关中断开中断,三是任务栈往哪个方向长,四是切换任务时从哪个函数入口进。这四件事一旦在 OS_CPU.H 里定死,内核主体就能编译过。剩下在 OS_CPU_C.C 里要写的,是任务栈的初始布局和几个切换函数——那部分才是真正需要看芯片手册的地方。

这条边界划清楚以后,改代码的顺序也就定了:先改配置头文件让工程能编过,再写栈初始化让任务能建起来,最后写切换函数让任务能跑起来。反过来先写汇编切换、再回头调配置,很容易在一堆编译错误里丧失判断力。常见做法是分三段提交:第一段只让 OSInit 跑通,第二段让 OSTaskCreate 不崩,第三段才让 OSStart 真的切到第一个任务。

2.2 为什么把汇编全塞进 C 文件里,而不是单独留一个 .ASM

80C196 这套工具链的 C 编译器支持在 C 代码里内嵌汇编块,这是整件事成立的前提。有了它,才能做到「能用 C 写的坚决用 C 写,不能用 C 写的就地嵌一小段汇编」。这个选择对老平台尤其划算:C196 编译器对 80C196 寄存器窗口的优化本来就一般,手写大段汇编的收益有限,而把逻辑拆到两个文件里维护的成本是实打实的——改一个栈布局要同时动 .C 和 .ASM,两边对不上就是运行时跑飞。

代价也要说清楚。内嵌汇编块对寄存器的分配控制弱,编译器不一定会把你想要的变量放进指定寄存器,所以凡是「必须精确控制入栈出栈顺序」的地方,代码要写得非常直白,别指望编译器帮你优化。另一个代价是调试信息会变差,汇编块里的单步跟踪往往对不上源码行号,出问题只能靠看栈内存。这两点在接受范围内,因为真正的汇编块总共就四小段,加起来不到一百行。

编译器选型上还有一条隐性约束:尽量用编译器自带的启动代码和段分配规则,别自己去改链接脚本。80C196 的片内 RAM 和片外 RAM 在地址空间上是分开的,链接器靠段名区分,自己动链接脚本很容易把内核的数据段放到片外,速度立刻掉一截。这个坑在后面讲存储裁剪时还会碰到。

2.3 改 OS_CPU.H:数据类型、栈方向、临界段、切换入口

这个文件是整个移植的地基,四块内容缺一不可。下面这版是 80C196 上比较稳的写法,注释标了每一块为什么这么定。

/* OS_CPU.H —— 80C196 上必须自己定的四块 */ /* 1) 基本数据类型:80C196 是 16 位机,int 就是 16 位,别按 32 位机的习惯写 */ typedef unsigned char BOOLEAN; typedef unsigned char INT8U; typedef signed char INT8S; typedef unsigned int INT16U; /* int 在 C196 上是 16 位 */ typedef signed int INT16S; typedef unsigned long INT32U; typedef signed long INT32S; /* 栈元素宽度必须和 push/pop 的粒度一致,80C196 一次压一个字 */ typedef unsigned int OS_STK; typedef unsigned int OS_CPU_SR; /* 2) 栈增长方向:80C196 的栈向低地址生长,所以是减栈 */ #define OS_STK_GROWTH 1 /* 3) 临界段:80C196 有 DI / EI 两条现成指令 */ #define OS_ENTER_CRITICAL() asm DI #define OS_EXIT_CRITICAL() asm EI /* 4) 任务级切换的入口,内核在 OS_Sched 里调它 */ #define OS_TASK_SW() OSCtxSw()

四块里最容易出错的是第三块。DI和EI是「一律关、一律开」,它们不记录进入临界段之前中断到底是不是开着的。只要代码里存在嵌套的临界段,或者某个函数在已关中断的上下文里被调用,OS_EXIT_CRITICAL就会把本该关着的中断提前打开,后果是随机时刻的数据错乱,而且极难复现。原文这版用的是最简写法,在中断层级简单、临界段不嵌套的应用里能跑;一旦应用里出现「中断里调用了会进临界段的函数」这种结构,就得改成保存 PSW 再恢复的写法,这个在第 5 章展开。

第二块也值得多说一句。OS_STK_GROWTH定成 1 之后,内核里所有跟栈有关的判断(比如栈溢出检查OSTaskStkChk)才会按减栈算。定错了不会立刻报错,但栈检查的结果会完全失真——你以为还剩 40 字节,实际早就压穿了。这个宏最好在第一次跑通任务切换之后就写死,不要留到后期再改。

数据类型那块看起来最没技术含量,其实是编译期最容易出问题的地方。INT32U如果错误地定义成unsigned int,内核里所有跟时间戳、计数相关的运算都会在 65535 处回绕,症状是系统跑几十分钟后定时全部错乱。

2.4 OS_CFG.H 的裁剪开关:先关,再按需打开

配置头文件不是「越多越好」,是「默认全关,用到哪个开哪个」。80C196 这点 RAM,一个用不上的功能模块带进来的静态变量都可能是压垮骆驼的那根稻草。

配置宏设定值判断依据
OS_MAX_TASKS6应用实际任务数,多一个就多一份 TCB 和栈
OS_LOWEST_PRIO5优先级 0~5,正好落在一个 8 位就绪表里
OS_TASK_STAT_EN0统计任务要独立栈和 CPU 时间,调试期才开
OS_TASK_DEL_EN0应用里没有任务删除需求
OS_TIME_DLY_HMSM_EN0弃用OSTimeDly系列,见第 3 章
OS_TIME_GET_SET_EN0不对外提供系统时间读取
OS_SEM_EN1任务间同步还需要,保留
OS_Q_EN/OS_MBOX_EN/OS_MEM_EN0应用用消息队列和内存分区
OS_SCHED_LOCK_EN0没有调度锁场景
OS_DEBUG_EN0量产配置,断言全部关掉

关掉一个_EN宏,对应的 .C 文件里整段函数体会被条件编译切掉,同时 OS_CPU_C.C 里那一堆OSTaskCreateHook、OSTaskDelHook、OSTaskSwHook、OSTaskStatHook、OSTimeTickHook也会被编译成空函数,最终一个字节都不占。这是 MicroC/OS-II 可裁剪性最实在的地方——裁剪是编译期完成的,不是运行期判断,没有分支开销。

配置里唯一不能省的是OS_LOWEST_PRIO和就绪表的对应关系。它决定了OSRdyTbl的数组长度,也决定了OSUnMapTbl的索引方式。这块一旦和实际任务数对不上,编译能过,跑起来却会出现「任务建了但永远不被调度」这种不容易判断的问题。

3. 220 字节片内 RAM 与 250μs 节拍:裁剪要做到什么程度

3.1 先算一笔账:片内 RAM 优先给谁

80C196 这块板子的存储分布是:片内可用 220 字节,片外扩展 3584 字节。这个差距决定了分配原则——片内 RAM 的访问速度远高于片外,凡是高频访问的东西必须放片内,否则每次任务切换、每次节拍中断都要多花几十个周期去等片外总线。

按这个原则排下来,片内 RAM 的第一优先级是内核的热数据结构:当前任务 TCB 指针、当前优先级、就绪表、栈指针暂存变量。第二优先级是应用里被中断服务函数频繁读写的计数器,比如 250μs 节拍里那几个OS250usCount、OS1msCount、OS50msDly。第三优先级才是普通任务里的局部状态。任务栈是例外——栈的访问频率和函数调用深度直接相关,调用层次深的栈必须放片内,但片内放不下六个任务的完整栈,只能按任务的实际深度分开安置。

这里有个容易忽略的点:C 编译器默认把全局变量放在哪个段,是可以配的。如果不管,链接器可能把内核的OSRdyTbl、OSPrioCur这些变量丢到片外,节拍中断里每次读写都要走外部总线。常见做法是用编译器支持的段限定关键字,把内核热数据显式钉到片内段,剩下的全局变量才交给默认分配。这一步不做,后面所有的代码优化都是白费。

3.2 存储裁剪的五个动作:从就绪表到 TCB

原文给出的五条措施,本质上是同一件事的五个方向——把「实际用不到的结构」在编译期消灭掉。

第一个动作是裁掉不使用的功能模块及其变量。信号量、消息队列、内存分区这些模块一旦_EN关掉,它们用到的控制块数组全部消失。这一刀砍下去通常能省下几百字节,是收益最高的一步。

第二个动作是按实际规模裁数组。应用里只有 6 个任务,OSRdyTbl就没必要按 8 组去开。原文的做法更激进:把OSRdyTbl直接改成一个 8 位变量,并把OSRdyGrp去掉——因为 6 个任务全部落在同一组里,OSRdyGrp恒等于那个固定值,索引计算里那一段可以直接省掉。改完以后OSPrioHighRdy = OSUnMapTbl[OSRdyTbl]这一行仍然成立,只是表变成单字节索引,省下的不只是内存,还有每次调度时的一次查表和一次移位。

第三个动作是改OS_InitTaskIdle的内容,把内核自带的空闲任务换成应用里优先级最低的那个任务。空闲任务本身不做事,但它的栈和 TCB 是实打实占地方的。既然应用里本来就有个最低优先级的后台任务,让它兼任空闲任务,等于白赚一份栈空间。

第四个动作是裁OS_TCB的成员。OSTCBY和OSTCBBitY这两个变量存的是「本任务在就绪表里的行号和位掩码」,在标准的 8 组结构里用于快速置位;一旦就绪表简化成单字节单组,这两个变量永远不会被读到,直接从结构体里删掉,每个 TCB 省两个字节,六个任务省十二个。这类「永远不会被使用到的成员」值得逐个核对一遍。

第五个动作是裁掉中断嵌套相关的内容。标准内核用OSIntNesting支持多重中断嵌套,每层嵌套都要维护状态。如果应用里的中断结构是「高优先级中断不会被低优先级中断打断」,那嵌套计数器就永远只会是 0 或 1,相关的判断和变量都可以简化掉。

这五刀下去,收益是可以算的:功能模块几百字节、就绪表几十字节、TCB 成员十几字节、空闲任务一份栈、嵌套状态几字节。加起来,才让内核在这块板子上有了容身之处。

3.3 时间资源的四刀:250μs 节拍带来的额外开销

存储好算,时间不好算。MicroC/OS-II 官方推荐的时钟节拍是 10~200ms,本系统实际用的是 250μs,中间差了四十倍以上。节拍中断每次进来都要做现场保存、节拍处理、就绪扫描、可能的任务切换、现场恢复,这套动作在 10ms 节拍下摊到每个毫秒只有十分之一,在 250μs 节拍下就是每一次都要全做一遍。CPU 时间被大量吃掉,剩下的才是应用逻辑。

原文的应对是四刀。

第一刀,弃用OSTimeDly,直接操作任务定时器再调OS_Sched。OSTimeDly内部要遍历延时链表、要做参数检查、要处理唤醒逻辑,一层套一层。既然应用里的延时需求很单纯(到点就绪),就没必要走通用路径,直接把任务的延时计数写进 TCB,节拍中断里减一到零就置就绪位。

第二刀,弃用OSIntExit和OSIntCtxSw,把它们的源码直接展开进软件定时器中断服务函数里。这两次函数调用的开销在 250μs 节拍下被放大得厉害——每次调用都要压栈返回地址、要跳转、要重新加载栈指针。展开之后省掉的是两次跳转和两次返回,看着不多,乘上每秒四千次就是实打实的数字。

第三刀,压缩其他中断服务函数的代码长度,并且明确不在这些中断里做任务切换。中断里只做「置就绪位」这个动作,真正的切换推迟到节拍中断里统一做。这样中断响应时间可控,也不会出现「在中断里切换任务导致栈帧混乱」这种麻烦事。

第四刀,根据编译器生成的汇编代码反过来优化 C 源码。这一步很多人跳过,但对老编译器收益明显。C196 对小函数的处理有时候会生成非常啰嗦的序言和收尾,把几个被高频调用的小函数改成宏,或者手工把循环展开,能省下可观的周期。

3.4 裁完怎么自检:三个不需要仪器的判据

裁完不能直接上板子跑,得先有几个不依赖示波器的自检点。

第一个判据是映射文件。链接完打开 map 文件,确认内核的热数据结构确实落在片内段,确认总的片内占用留了至少 15% 余量。这个余量是给中断嵌套和后续小改动留的,占满就等着后面改一行代码就崩。

第二个判据是栈水位。给每个任务的栈填充固定图案(比如全填 0x5A5A),跑满最坏工况后回读,看最深处被写到哪。这一步能直接暴露「哪个任务的栈开小了」,比事后查栈溢出可靠得多。

第三个判据是节拍抖动。在节拍中断的入口和出口各翻转一个空闲 IO 口,用逻辑分析仪看周期是否稳定在 250μs。抖动超过 10% 就说明中断里做的事还是太多,得继续往下裁。

4. 堆栈与上下文切换:四个现场保存点的写法

4.1 OSTaskStkInit:任务第一次被调度时栈里必须摆好什么

这个函数只在任务创建时调用一次,作用是伪造一个「刚被中断打断」的现场,让切换函数第一次把栈弹出来的时候,能顺利跳到任务入口。栈里的内容顺序必须和切换函数里的出栈顺序严格镜像,这是整件事的核心约束。

OS_STK *OSTaskStkInit(void (*task)(void *pd), void *pdata, OS_STK *ptos, INT16U opt) { OS_STK *stk; opt = opt; /* 未使用,防止编译器告警 */ stk = (OS_STK *)ptos; /* ptos 由调用者保证已按减栈方向对齐 */ *--stk = (OS_STK)task; /* 任务入口地址,最后由 ret 弹走 */ *--stk = (OS_STK)0x0000 | gimask; /* PSW,里面含中断允许位 */ *--stk = (OS_STK)0x0000 | gimask1; /* IMASK1 与 WSR,窗口选择 */ *--stk = (OS_STK)4; /* DX */ *--stk = (OS_STK)3; /* CX */ *--stk = (OS_STK)2; /* BX */ *--stk = (OS_STK)1; /* AX */ return ((OS_STK *)stk); /* 返回新的栈顶,内核存进 TCB */ }

这几个赋值的顺序不是随便排的。80C196 的栈向低地址生长,每压一次栈指针减二,所以最先写进去的task地址落在最高地址,最后写进去的AX落在最低地址,也就是新的栈顶。切换函数恢复现场时从栈顶往下弹,先弹 AX、BX、CX、DX,再恢复 IMASK1/WSR 和 PSW,最后执行ret——而ret弹出来的正是最早写进去的那个task地址,控制流就跳到了任务入口。

gimask和gimask1这两个常量的值决定了任务第一次运行时中断是开还是关。填错的话,任务第一次跑起来可能全程关着中断,症状是「任务创建成功但系统不响应任何外设」,非常容易误判成任务没跑起来。常见做法是先填「中断关闭」的值,让任务在自己的初始化代码里显式开中断,这样每个任务的中断状态都是自己可控的。

pdata参数在这版里没有压栈,因为应用里的任务都没有用参数传递。如果要支持,需要在task地址之后再多压一个字,并且切换函数的出栈序列也要跟着改一处,两边必须同步。

4.2 OSStartHighRdy:第一个任务怎么被弹出来

系统启动时调用一次,做的事和普通切换几乎一样,区别只在于它不需要保存「上一个任务」的现场——因为根本没有上一个任务。

void OSStartHighRdy(void) { OSRunning = TRUE; /* 置系统运行标志 */ OSTCBCur = OSTCBHighRdy; /* 当前 TCB 指向最高优先级就绪任务 */ psp = OSTCBHighRdy->OSTCBStkPtr; /* 取出它的栈指针 */ asm { pop dx; /* 顺序必须和 OSTaskStkInit 相反 */ pop cx; pop bx; pop ax; popa; /* 恢复整组通用寄存器 */ ret; /* 弹出 task 地址,跳到任务入口 */ } }

关键在于出栈顺序和OSTaskStkInit的压栈顺序完全镜像。这两处只要有一处写反,ret弹出来的就不是任务入口地址,而是一个随机值,程序直接跑飞。这种时候现象往往不是「什么都没发生」,而是「跑到某个毫不相干的地址上执行了一段乱七八糟的代码」,用仿真器看 PC 值才能定位。

psp这个变量在这里的用法值得单独说一下:它是这个移植里约定的「当前栈指针」载体,切换时先把psp存进旧 TCB,再从新 TCB 取回psp。所以OSStartHighRdy里必须先给psp赋值,再进出栈序列,否则出栈的就是上一次遗留的旧值。

4.3 OSCtxSw:任务级切换的完整闭环

任务主动让出 CPU(比如等信号量、调OS_Sched)时走这条路。它是一个完整的「保存旧现场、切换 TCB、恢复新现场」的闭环。

void OSCtxSw(void) { asm { /* 保存被中断任务的现场 */ pusha; push ax; push bx; push cx; push dx; } OSTCBCur->OSTCBStkPtr = psp; /* 旧任务的栈指针存进它的 TCB */ OSTCBCur = OSTCBPrioTbl[OSPrioHighRdy]; /* 切到新的 TCB */ OSPrioCur = OSPrioHighRdy; /* 更新当前优先级 */ psp = OSTCBCur->OSTCBStkPtr; /* 取出新任务的栈指针 */ asm { /* 恢复新任务的现场,顺序与压栈严格相反 */ pop dx; pop cx; pop bx; pop ax; popa; } }

这个函数的写法和OSStartHighRdy最大的差别在于开头和结尾:它必须先把旧现场压栈并把psp写回旧 TCB,否则下次切回来的时候栈就对不上了。中间这两行 TCB 指针操作是整个切换的核心,OSTCBPrioTbl是以优先级为下标的 TCB 指针数组,OSPrioHighRdy由调度器在调用本函数之前算好。

OSCtxSw末尾没有ret,因为它不是被调用后返回原处,而是返回到新任务的栈帧里继续执行。这一点在调试时很容易造成误判——单步跟到这里,下一句就跑到别的任务里去了,看起来像「函数没返回」,其实是正常的。

压栈和出栈的指令顺序必须严格成镜像。pusha加手工压四个寄存器的组合看着冗余,实际是为了让栈布局和恢复序列对齐,改任何一处都必须同时改另一处。这类对称代码最好在文件顶部写一行注释标注栈布局,否则半年后回来改几乎必然出错。

4.4 把中断级切换内联进节拍服务函数

这是整个移植里省时间最狠的一刀。标准内核里,中断服务函数做完事要调OSIntExit,OSIntExit里判断有没有更高优先级任务就绪,有的话再调OSIntCtxSw做切换。这条链路在 250μs 节拍下每秒要跑四千次,两次函数调用加两次返回的开销必须省掉。

原文的做法是把这两段源码直接展开进软件定时器中断服务函数:

void OSTickISR(void) { /* ... 重置软件定时器 ... */ OS250usCount++; /* 250μs 计数 */ if ((OS250usCount & 0x03) == 0) { /* 每 4 次即 1ms 触发一次 */ OS1msCount++; OS50msDly++; OSRdyTbl |= 0x20; /* 置 TaskChk 就绪,位 5 对优先级 5 */ } /* 下面这段是 OSIntExit + OSIntCtxSw 的展开版 */ OSPrioHighRdy = OSUnMapTbl[OSRdyTbl]; /* 查表取最高优先级 */ if (OSPrioHighRdy != OSPrioCur) { /* 确实需要切换才做 */ OSTCBCur->OSTCBStkPtr = psp; /* 存旧任务栈指针 */ OSTCBCur = OSTCBPrioTbl[OSPrioHighRdy]; OSPrioCur = OSPrioHighRdy; psp = OSTCBCur->OSTCBStkPtr; /* 取新任务栈指针 */ asm { pop dx; pop cx; pop bx; pop ax; popa; ret; /* 这个 ret 必须有 */ } } }

OS250usCount & 0x03这个掩码的意思是每四次节拍才进一次 1ms 分支,也就是 250μs×4=1ms,避免了取模运算。就绪位置位用的是|= 0x20,第 5 位置一,对应优先级 5——也就是TaskChk这个 1ms 级的任务。这里必须保证OSRdyTbl的位定义和任务优先级严格对应,错一位就是「置了就绪但调度不起来」,非常隐蔽。

if (OSPrioHighRdy != OSPrioCur)这个判断不能省。没就绪任务比当前任务优先级更高时直接返回,省掉整个现场保存恢复流程——在大部分节拍里其实都没必要切换,这个判断能砍掉绝大部分开销。

末尾那个ret是最容易被漏掉的一处。因为切换分支里手工做了出栈,如果不补ret,函数返回时会再弹一次栈,栈指针整体偏移两个字,表现为「系统跑一阵子之后随机跑飞」。凡是手工展开出栈序列的地方,都必须想清楚:这次返回到哪里、由谁负责弹返回地址。

5. 避坑与排查:五条反复遇到的现场记录

5.1 现象是任务跑飞,根因在堆栈指针错位

现象:系统启动后第一个任务能跑几十毫秒,然后 PC 跳到一个不存在的地址,仿真器显示栈指针偏移了一个固定值。

原因:OSTaskStkInit里的压栈项数和切换函数里的出栈项数不一致。比如栈里多压了一个pdata,但出栈序列没跟着改,ret弹出来的是参数而不是任务入口地址。

解决:把栈布局写成一张固定的顺序表贴在文件头,压栈和出栈两边都对照这张表写。更保险的做法是在OSTaskStkInit里多压一个特征字(比如 0x4F53),首次切换后立刻核对栈顶几个字是否和预期一致,对不上就在OSStart里直接停机,别让它跑下去。

5.2 现象是随机数据错乱,根因在临界段被提前打开

现象:跑几分钟到几十分钟不等,偶发一个全局结构体字段被写成错误值,重启后又能正常跑很久。

原因:OS_ENTER_CRITICAL/OS_EXIT_CRITICAL用的是最简的DI/EI。如果某个函数在已经关中断的上下文里被调用,退出时的EI就把中断打开了,外层还没做完的临界段被中断打断。这类问题在中断服务函数里调内核 API 的时候最容易出现。

解决:改成保存并恢复 PSW 的写法——进入时把 PSW 压进一个局部变量再DI,退出时把那个变量弹回 PSW。这样不管嵌套多少层、进入前中断是什么状态,退出后都能精确还原。代价是每个临界段多两条指令,在 250μs 节拍下要重新核算一遍预算。

5.3 现象是节拍抖动越来越大,根因在中断里做了任务切换

现象:逻辑分析仪上看节拍中断的 IO 翻转,一开始周期很稳,运行一段时间后抖动逐渐变大,最坏能超出 30%。

原因:在其他中断服务函数里也做了任务切换。切换本身要保存恢复大量寄存器,如果这个中断的触发频率不稳定(比如通信中断),节拍中断就会被它延迟,抖动随负载累积。

解决:所有中断里只做「置就绪位」这一个动作,真正的切换统一推迟到节拍中断里做。改完之后每个中断的执行时间变得可预测,节拍抖动立刻回到 5% 以内。这条经验的价值在于:切换的时机集中,比切换的次数少更重要。

5.4 现象是某个任务永远不被调度,根因在就绪表和优先级对不上

现象:六个任务建了五个,第六个任务创建返回成功,但断点永远不命中。

原因:OSRdyTbl简化成单字节之后,置位用的掩码和任务优先级没有对应上。比如优先级 5 应该对应0x20,代码里写成了0x04,那位永远置不上,任务就一直挂在就绪链表外面。

解决:把「优先级 → 掩码」的对应关系做成一个常量表,置位时统一查表,不手工写立即数。同时在OSTaskCreate之后加一句断言,检查该优先级对应的位能不能被正确置位和清除,跑一次就能暴露问题。

5.5 现象是故障处理拖垮通信,根因在优先级分配

现象:通信正常时一切正常,一旦出现一次通信故障,接收任务就开始丢数据,恢复时间长达几百毫秒。

原因:故障处理任务被放在了较高优先级。故障处理本身的执行时间很长(要重试、要复位外设),它一抢占 CPU,接收任务就得不到调度,新的数据继续堆积,形成恶性循环。

解决:把故障处理降到通信接收之后。通信故障一般都有容忍时间,只要在容忍窗口内处理完就不影响功能;而接收任务的实时性要求是不可协商的。同类工作也要分优先级——接收高、发送中、故障处理低,这个顺序在多数通信场景下都成立。

6. 移植到底成没成:三个能量化的验收动作

判断一次移植成功与否,不能靠「跑起来没崩」。下面这三个动作是我每次做完这类移植都会走一遍的,全部可量化,不需要昂贵设备。

验收项测量方法合格线不达标时的第一怀疑对象
节拍抖动节拍中断入口翻转空闲 IO,逻辑分析仪看周期250μs ±5%中断里做的事太多,或临界段过长
栈水位建任务前把栈区填 0x5A5A,最坏工况后回读最深使用不超过栈深的 70%某个任务调用层次比预估深
切换耗时切换函数入口出口各翻转一次 IO,看脉宽稳定且不随任务数增长切换路径里有函数调用没展开

栈水位这一项最容易被跳过,但它救过我两次。做法是在OSTaskCreate之前把整块栈区按字填成固定图案,系统跑完一轮覆盖所有分支的最坏工况后,从栈底往栈顶扫,第一个不是图案的位置就是历史最深水位。这个数字比任何静态分析都可靠,因为它包含了中断嵌套、库函数调用这些你在源码里数不出来的深度。

节拍抖动的测量有个细节:翻转 IO 的语句要放在中断服务函数的最开头和最末尾,不要放在中间。放中间测的是「某一段代码的耗时」,放两头测的才是「中断从进到出的完整延迟」,后者才是影响系统实时性的量。

切换耗时这项,重点不是看绝对值,是看它稳不稳定。如果同一个切换点的脉宽忽宽忽窄,说明切换路径里有条件分支没走同一条路,通常是某个钩子函数没被完全裁掉,或者编译器把某段代码优化成了不同的路径。把它找出来展开掉,抖动就消失了。

还有一个习惯值得坚持:每次改完内核配置或者栈大小,这三个验收全部重跑一遍,不要只跑改动相关的那一项。裁剪是有耦合的——关掉一个功能模块可能让另一个模块的某个分支走不到,栈水位跟着变,只测一项等于没测。

从那以后我每次动这类移植,哪怕只改了一行配置,都会把栈水位和节拍抖动这两项强制走一遍,宁可多花半小时,也不想在量产板上再抓一次随机跑飞。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进