找回密码
 立即注册

QQ登录

只需一步,快速开始

搜索
查看: 4|回复: 0
收起左侧

从ARM到STC8H:当 uC/OS-II 删掉 OSIntNesting、用软件中断模拟 PendSV 之后,...

[复制链接]
ID:1170364 发表于 2026-9-21 13:09 | 显示全部楼层 |阅读模式
从 ARM 到 STC8H:当 uC/OS-II 删掉 OSIntNesting、用软件中断模拟 PendSV 之后,还安全吗?
—— 一份基于源码的移植版安全隐患剖析
一、前言
最近在 51 单片机圈子里流传着一个移植版:uC/OS-II@STC8H。作者 tzz1983 在 2024 年 5 月发布,把完整的 uC/OS-II 移植到了 STC8H颗 8051 内核的芯片上。作者认为这是一次 "完整的 uC/OS-II-51 核移植",并列出了几个新特性:
其中最引人注目的是:
第一,这个移植版使用一个硬件中断来模拟 ARM Cortex-M 上的 PendSV 异常行为,用来做任务切换。
第二,作者彻底删掉了官方 uC/OS-II 里的关键系统变量 OSIntNesting,ISR 里不再调用 OSIntEnter () 和 OSIntExit ()。
第三,作者同时承认:删掉 OSIntNesting 之后,内核不再能自动判断 "当前是不是在中断里",所以需要用户自己搞清楚哪些 OS 服务能在中断里调、哪些不能。
第四,作者为了弥补这一点,额外增加了 14 个带 _FROM_ISR 后缀的函数,比如:OSSemPost_FROM_ISR、OSQPost_FROM_ISR、OSFlagPost_FROM_ISR 等等,专供中断里调用。
初看起来,这是一个挺漂亮的 "简化":少了两个函数调用,少了一个全局变量,中断快进快出。本文就对着这个移植版的源码,把这两个改动的安全后果掰开揉碎讲清楚。最后会给出明确结论:哪些场景能用,哪些场景不要碰。
二、为什么官方uC/OS-II 根本不需要这些_FROM_ISR 函数
先回答一个最容易困惑的问题:官方 uC/OS-II 卖了二十多年,跑在 ARM、x86、MicroBlaze 上,从来没有 OSSemPost_FROM_ISR 这种函数。FreeRTOS 倒是有 xQueueSendFromISR,但那是另一种设计哲学。
官方 uC/OS-II 为什么不搞这套?因为它有一个更优雅的办法,这个办法就是 OSIntNesting。
我们看官方 OS_Sched () 函数的核心逻辑,关键就一行 if:
void OS_Sched (void) {
OS_ENTER_CRITICAL();
if (OSIntNesting == 0u) {
if (OSLockNesting == 0u) {
OS_SchedNew();
if (OSPrioHighRdy != OSPrioCur) {
OS_TASK_SW();
}
}
}
OS_EXIT_CRITICAL();
}
而像 OSSemPost 这种函数的末尾,只是普普通通地调了一下 OS_Sched ():
if (pevent->OSEventGrp != 0u) {
OS_EventTaskRdy(...);
OS_EXIT_CRITICAL();
OS_Sched();
}
把这两段代码放在一起看,妙处就出来了。
如果你在任务里调用 OSSemPost,此时 OSIntNesting 等于 0,OS_Sched () 里的 if 成立,立刻做任务切换。
如果你在中断里调用 OSSemPost,此时 OSIntNesting 大于 0,OS_Sched () 里的 if 不成立,函数直接返回,什么都不做。
那中断里 Post 之后,任务切换什么时候发生?
答案是:等你在 ISR 末尾调用 OSIntExit () 的时候。OSIntExit () 内部把 OSIntNesting 减一,只有减到 0,说明所有嵌套中断都退完了,才真正做一次任务切换。
这就是官方设计的三层分工:
第一层,API 层,比如 OSSemPost,只负责把任务放进就绪表,末尾无脑调 OS_Sched ()。
第二层,OS_Sched () 内部用 OSIntNesting 做闸门,任务态真切,中断态空转。
第三层,OSIntExit () 在最外层中断退出时统一调度。
这套设计的好处是什么?应用程序开发者根本不需要知道自己现在是在任务里还是在中断里。Post 类函数,比如 OSSemPost、OSMboxPost、OSQPost、OSFlagPost、OSxxxPendAbort、OSTimeDlyResume,天生就是双上下文通用的。
你在任务里调它没问题,你在中断里调它也没问题,内核自己判断。所以官方 API 表里只有一个 OSSemPost,没有 OSSemPost_FROM_ISR。一个函数就够了。
那 TZZ1 为什么要搞出 14 个 _FROM_ISR 函数?因为他把 OSIntNesting 删了。
删了之后,OS_Sched () 里那行 if 没有判断依据了,只能注释掉。注释掉之后,OS_Sched () 变成 "谁调都做切换"。这就完蛋了。
ISR 里调 OSSemPost,一路走到 OS_Sched (),再走到 OS_TASK_SW (),在中断上下文里直接触发任务切换,栈就坏了。
作者的补救办法,就是把所有 "会引起调度" 的函数都复制一份。
我们看 os_sem.c 源码就一目了然。普通版 OSSemPost 末尾调的是 OS_Sched ()。
新增的 OSSemPost_FROM_ISR 末尾调的是 OS_Sched_FROM_ISR ()。两个函数从入口到出口,业务逻辑逐字一样,就改了最后一行。
OS_Sched_FROM_ISR () 做的事情,也和 OS_Sched () 几乎一样,唯一区别是最后调 OS_TASK_SW_FROM_ISR ()。而 OS_TASK_SW_FROM_ISR () 这个宏,只做一件事:给 PendSV 软件标志写个 1,完事。不忙等,不切换,不开中断。
ISR 继续跑完自己的后半段,最后 RETI。等所有嵌套中断都退完,PendSV 硬件中断被响应,才在 PendSV 里真正切任务。这套设计本身是自洽的。
但问题在于:
这是用 "复制 14 个函数" 来补 "删掉一个全局变量" 留下的窟窿。更麻烦的是,这种补丁把安全责任从 "内核自动判断" 推给了 "用户每次选对函数"。我们看一个反直觉的场景。
在官方版里,如果你在 ISR 里不小心调了任务级 OSSemPost,会怎样?OS_Sched () 里 OSIntNesting 大于 0,自动空转。Post 生效,切换留给 OSIntExit () 兜底。行为完全正确,你甚至感觉不到自己写错了。
在 TZZ1 版里,如果你在 ISR 里不小心调了任务级 OSSemPost,会怎样?OS_Sched () 无条件调 OS_TASK_SW (),在中断现场里忙等并切换,栈帧直接污染。
这就是两个版本的本质差别。官方版即使你用错了函数,因为有 OSIntNesting 兜底,后果也不严重。TZZ1 版你一旦用错,后果是灾难性的。
再看 OSSemPend 这种明显禁止在 ISR 里调的函数。官方版入口就有一句 if (OSIntNesting> 0),直接返回 OS_ERR_PEND_ISR,零副作用。
TZZ1 版把这句注释掉了,你在 ISR 里调 OSSemPend,它会把中断现场当成任务去挂起,然后一路走到任务切换,栈直接坏。
所以这 14 个 _FROM_ISR 函数,本身不是安全隐患。它们是删了 OSIntNesting 之后不得不打的补丁。
但补丁打得再工整,也改变不了一个事实:原本由内核自动兜底的安全,现在全靠开发者每次选对函数。
对官方版来说,加这些函数是画蛇添足。
对 TZZ1 自己的移植版来说,这是不得已的补救,但不是最佳补救。

三、彻底删掉OSIntNesting 之后,内核还能拦住错误调用吗
这一节我们专门说说 OSIntNesting 本身。很多初学者以为 OSIntNesting 就是个 "中断嵌套计数器",主要用来统计嵌套深度。这是误会。
OSIntNesting 在官方 uC/OS-II 里同时承担三件事,每件都和安全直接相关:
第一件事,是作为 "最外层中断退出才调度" 的闸门。
8051 和 Cortex-M 都支持中断嵌套。假设第二层中断里 OSSemPost 把一个高优先级任务唤醒了,此时第一层 ISR 还有一半工作没做完。如果立刻切走,第一层 ISR 的寄存器现场、半成品状态就被打断了。
OSIntNesting 保证:嵌套期间只更新就绪表,不做切换。一直减到 0,说明所有嵌套 ISR 都退完了,才一次性选最高优先级任务切换。
第二件事,是作为 "任务态专用 API" 的运行期拦截器。
原版在三十多个 API 入口都有同一道防线。比如 OSSemPend 里:if (OSIntNesting> 0) 就返回 OS_ERR_PEND_ISR。被同样拦截的还有 OSTimeDly、OSTaskCreate、OSTaskDel、OSTaskSuspend、OSTaskChangePrio、OSMutexPend、OSSemDel、OSMemGet、OSFlagPend、OSQPend、OSMboxPend 等等。
为什么必须拦?因为这些函数的语义是 "把当前任务挂起,调度出去"。如果在 ISR 里调 OSSemPend,内核会把当前中断现场当成一个任务去挂起。
但 ISR 没有独立 TCB,没有独立任务栈,挂起它等于把 CPU 的中断返回地址当成任务上下文存档。等以后再 "恢复" 这个任务时,恢复出来的根本不是一个合法任务,直接跑飞。
第三件事,是作为调度锁和临界区的状态前提。
OSSchedLock () 也检查 OSIntNesting 是否为 0,禁止在 ISR 里锁调度器。
一句话,OSIntNesting 是 uC/OS-II 的 "上下文类型标签"。它让内核在运行期知道 "我现在站在谁的鞋里",从而决定能不能调度、能不能挂起。
现在我们看 TZZ1 版是怎么对待这三件事的。
第一件事,最外层中断退出才调度,作者用 "PendSV 只置标志、ISR 跑完才 RETI" 替代了。这在 8051 上,如果 PendSV 优先级配对正确,是成立的。这部分替换是合理的。
第二件事,三十多个 API 入口的运行期拦截,作者全部注释掉了。
你在源码里搜 OSIntNesting,会发现所有 if 语句前面都加了双斜杠。
第三件事,OSSchedLock 里的检查也被注释掉了。
也就是说,作者只替换了第一件事,把第二件和第三件事一起删了。
这就留下了巨大的安全窟窿。
我们具体推演一下。假设你在某个 ISR 里不小心写了 OSTimeDly (100)。
函数一路执行:关临界区,把 OSTCBCur 的 OSTCBDly 设成 100,开临界区,调 OS_Sched ()。
OS_Sched () 关临界区,发现高优先级任务就绪,调 OS_TASK_SW ()。
OS_TASK_SW () 这个宏做的事情是:置 PendSV 标志,开中断,然后 while 循环等标志被清。CPU 一旦开中断,PendSV 立即被响应。
PendSV 中断服务函数里第一件事是 PUSHALL,把 ACC、B、DPH、DPL、PSW、R0 到 R7 压入 IDATA 硬件栈。然后它把 IDATA 硬件栈内容、Keil C51 的 IBP、XBP 帧指针,一并转存到 OSTCBCur 指向的 xdata 任务栈。
问题来了。此时 IDATA 硬件栈里压的是谁?
是这个 ISR 的返回地址,是 ISR 执行到一半的寄存器现场。PendSV 把它当成 "OSTCBCur 任务的栈帧" 存起来了。然后恢复新任务,RETI。
新任务跑起来了。但被误当作 "任务" 挂起的那个 ISR 现场,永远不会被正确恢复。
因为 IDATA 硬件栈里那个 ISR 的返回地址,已经被 PendSV 收走了。
等以后再切回 OSTCBCur 时,从那个被污染的栈帧恢复 PC,CPU 直接跑飞到一个随机地址。
原版这里有一道闸。OSTimeDly 入口的 if (OSIntNesting> 0) 会直接拒绝,函数返回,现场毫发无损。
TZZ1 版把这道闸拆了。错误从 "优雅报错" 变成了 "栈帧损坏、随机跑飞"。
更隐蔽的是 OSSemPost 这种函数。如果你在 ISR 里误调了任务级 OSSemPost,恰好有任务在等待,就会触发上面这条灾难路径。但如果没有任务在等待,信号量计数自增,OSSemPost 不调 OS_Sched,表面上一切正常。同一个函数,在不同运行路径下,有时候安全,有时候是灾难。这种 "概率性故障" 在工业现场最难排查。
所以回到结论。彻底取消 OSIntNesting,是否符合 uC/OS-II 设计的安全原则?
要分两半说。调度时机那一半,也就是 "何时切换",用硬件中断优先级替代软件计数,是合理的工程优化。
但运行期误用拦截那一半,也就是 "别在 ISR 里调错 API",被完全删掉,这违反了安全内核 "不信任调用者、在函数入口做上下文校验" 的基本原则。
原版作者 Jean J. Labrosse 在书里反复强调过:这些 OSIntNesting 检查不是性能开销,是故障保险。目的是把编程错误尽早暴露成可返回的错误码,而不是让它变成随机跑飞。

四、ARM 官方版和STC8H 移植版的风险对比
这一节我们把两个平台摆在一起,看硬件底座的差别。
先看 ARM 官方版的 PendSV 汇编。Cortex-M 有三个硬件特性,是 STC8H 这种 8051 内核根本没有的。
第一个是双栈指针。任务跑在 PSP 上,所有中断自动用 MSP。任务栈和中断栈物理隔离,永不交叉。
第二个是自动压栈。进入异常时,硬件自动把 xPSR、PC、LR、R12、R3 到 R0 压入当前栈。退出异常时,硬件自动弹出这些寄存器。软件只需要补 R4 到 R11。
第三个是 PendSV 可编程最低优先级。
汇编里把 PendSV 优先级写成 0xFF,也就是最低。NVIC 硬件保证:任何硬件中断都能抢占 PendSV。PendSV 只在所有活跃异常退出后才执行。
而且 PendSV_Handler 第一句就是 CPSID I,关中断。整个上下文切换过程是原子的,没有被嵌套打断的窗口。
再看 STC8H 移植版。STC8H 没有这三样硬件。
任务栈是 xdata 区的 "软件模拟栈"。IDATA 硬件栈只有 128 到 256 字节,所有任务、所有嵌套中断、PendSV 现场、ISR 局部变量,全共用这一片。寄存器保存靠软件宏 PUSHALL,手工压 ACC、B、DPH、DPL、PSW、R0 到 R7。压错一个、少压一个,就是 bug。
模拟 PendSV 是一个普通硬件中断,优先级靠 IE2 寄存器软件配置。
作者注释里写 "设置为最低优先级",但 8051 的中断优先级只有两级,STC8H 扩展到四级,仍然是有限的级别。因此在STC8H里有许多关键中断的优先级与 PendSV 一样高,PendSV一旦触发就会影响其他 ISR 中断响应。这在 ARM 上是不可能的,因为 PendSV 优先级硬件保证焊死在汇编里。
我们再看任务级切换的写法。
ARM 版的 OSCtxSw 函数,就是往 NVIC_INT_CTRL 寄存器写一个 PENDSVSET 值,然后 BX LR 返回。不忙等,不阻塞,调用者无感。STC8H 版的 OS_TASK_SW () 宏是这样写的:
#define OS_TASK_SW() do { \
PendSv_SetFlag(); \
OS_EXIT_CRITICAL(); \
while (PendSv_GetFlag()); \
return; \
} while (0)
注意那个 while 循环。任务在置完标志、开中断之后,原地自旋等 PendSV 把标志清掉。
ARM 版没有这个忙等。为什么 STC8H 需要忙等?因为没有自动压栈和双栈指针,软件必须在任务上下文里 "原地" 把当前 IDATA 栈状态转存到 xdata。这不是作者设计得不好,是硬件逼的。
现在我们把两个维度乘起来看。
第一个维度是硬件隔离度。
ARM 有 PSP 和 MSP 双栈指针,即使软件在 ISR 里误调了 OSSemPend,因为栈是分开的,栈帧本身不损坏,错的是 TCB 状态字段。TCB 状态错乱,你可以用调试器停下来看,是可诊断的故障。
STC8H 没有双栈指针,同样的误用,PendSV 直接把 ISR 现场当成任务栈存进 xdata,栈帧本身被污染。恢复的时候,CPU 从一个错误的栈帧里弹出 PC,直接跑飞。故障不可诊断,没有现场。
第二个维度是软件防御。
ARM 官方版有 OSIntNesting,三十多个 API 入口都有运行期检查。即使硬件隔离强,软件还多一道保险。
STC8H 移植版没有 OSIntNesting,三十多个入口检查全注释掉了。两个维度相乘,风险等级就出来了。
ARM 官方版:硬件隔离强,软件防御在,低风险。
STC8H 现状:硬件隔离无,软件防御无,高风险,栈帧损坏,不可诊断。
这就是为什么我们反复强调:删 OSIntNesting 这个动作本身,在 ARM 上是降级但可控,在 STC8H 上是降级且不可控。
风险不是来自 "删 OSIntNesting" 这一个动作。而是来自 "删的地方恰好没有硬件隔离兜底"。
还有一个 STC8H 独有的隐患,值得单独说。
看 pendSV_ISR_Handler 的代码结构。PUSHALL 之后,作者在中段显式关了一次 EA,做 OSTaskSwHook、清 PendSV 标志、更新 OSPrioCur 和 OSTCBCur。然后又开了 EA。之后进入恢复阶段,从 xdata 任务栈往回搬 IDATA 栈内容。
在 "开了 EA" 到 "恢复 XBP 之前" 这段窗口里,如果有更高优先级中断进来,会继续往 IDATA 硬件栈压数据。而此时 IDATA 栈正在被 PendSV 软件搬运。两边会踩到一起。
ARM 版是整个 PendSV_Handler 都在 CPSID I 保护下,没有这个窗口。
STC8H 版这个窗口很小,但它存在。再加上 8051 没有 MPU,没有内存保护,没有栈溢出硬件检测,一旦踩坏就是直接死机。

五、结论和工业应用建议
最后给明确结论。
第一,彻底取消 OSIntNesting,不符合 uC/OS-II 原版的设计安全原则。
第二,软件模拟 PendSV 本身是可行的工程妥协。
但这个妥协把三处原本由硬件承担的安全保证,全部转嫁给了软件。
(1)第一处,任务栈和中断栈隔离,变成了软件手工搬运 IDATA 栈和 xdata 栈。
(2)第二处,PendSV 最低优先级,变成了软件配置中断优先级。
(3)第三处,寄存器自动压栈,变成了软件 PUSHALL 宏手工维护。
每一处转嫁都引入了 "人写错" 的可能。
第三,对工业生产的具体建议,按场景分三档。
第一档,消费电子、小家电、数码管类产品,非安全相关,有看门狗,有人值守。
这个移植版可以用。
前提是团队严格遵守 "ISR 里只用 _FROM_ISR 系列 Post,禁用所有 Pend、Dly、Create、Delete" 这条铁律。
作者自己也声明过,这个移植版没经过长时间验证,用在项目里要自行评估风险。
第二档,一般工业控制,比如 PLC、传感器节点、变频器,非安全回路。
有风险,不建议作为新设计的首选。
第三档,安全关键场景,比如 IEC 61508 SIL2 以上、ISO 26262、医、轨道交通、核电。
不要用。
理由有四个。
一是 8051 内核本身无 MPU、无内存保护、栈溢出无硬件检测。
二是软件模拟 PendSV 的三处安全保证,都没有硬件证据。
三是删掉了运行期误用拦截,不符合安全标准对 "非法 API 调用必须被检测和报告" 的要求。
四是作者自己声明 "未经过长时间验证"。
最后一句话总结。
ARM 官方版的安全,是硬件兜底加软件兜底的双保险。
STC8H 移植版把两个兜底都拆了,只留下 "开发者别写错" 这一条。学习和小产品可以玩。工业生产要谨慎。安全关键场景不要用。
这个结论不是否定作者的劳动。
作为一个业余爱好者,花几天时间在 STC8H 上跑出完整的 uC/OS-II,技术能力值得敬佩。
但工业安全看的不是作者多努力,而是系统有没有硬件证据、有没有运行期检测、有没有长期验证。
这三样,这个移植版都不具备。用在哪里,不用在哪里,读者可以自己掂量。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|51黑电子论坛 |51黑电子论坛6群 QQ 管理员QQ:125739409;技术交流QQ群281945664

Powered by 单片机教程网

快速回复 返回顶部 返回列表