【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 10 篇】

📅 发布时间:2026/8/26 17:33:14
【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 10 篇】 从第 1 篇的任务调度走到第 10 篇我们终于揭开 uC/OS-II 最后两套独立机制内存分区 OSMem 和软件定时器 OSTmr。前者用固定大小分区换来零碎片与 O(1) 确定性后者用独立任务 哈希轮 信号量把回调安全地挪出中断上下文。本文带你逐行读完收官源码并用一张表回顾全系列 10 篇的知识地图。开篇为什么收官要讲这两个配角如果你一路读到这里应该已经掌握任务、调度、信号量、消息队列、内存管理这些核心组件。但细心的你会发现uC/OS-II 里还有两个不太起眼的模块os_mem.c和os_tmr.c。它们体量不大却补全了整个内核的拼图。内存分区解决的是动态内存分配在实时系统中不可控的问题。软件定时器解决的是回调函数不该在中断上下文里执行的问题。一个管空间一个管时间。把它们讲完uC/OS-II 的核心机制就全部收官了。内存分区 OSMem用固定换来确定为什么需要分区这种原始设计先回忆一个痛点。在 Linux/桌面开发里malloc随用随取很方便。但实时系统不敢这么干碎片不可控频繁申请/释放不同大小内存堆会碎成一片最终导致明明内存够却申请不到大块。时间不确定malloc 底层要遍历空闲链表、可能触发合并耗时随堆状态波动。uC/OS-II 的答案是固定分区。创建内存分区时把一整块内存切成 nblks 个大小完全相同的块每个 blksize 字节。之后分配永远只给你其中一块归还也只收一块。没有大小变化就没有碎片没有遍历查找就有 O(1) 的确定性。OSMemCreate一条用指针串起来的单向链表看源码os_mem.c:59-131核心是构造空闲链表plink(void**)addr;pblk(INT8U*)addr;loopsnblks-1u;for(i0u;iloops;i){pblkblksize;/* 指向下一块 */*plink(void*)pblk;/* 当前块头部存下一块指针 */plink(void**)pblk;/* 移动到下一块 */}*plink(void*)0;/* 最后一块指向 NULL */这段代码有个精妙设计空闲块的第一字节被复用为 next 指针。当块空闲时它的头部存着下一块空闲块在哪一旦被分配出去这块内存完全归用户使用链表信息自动转移到别的块上。链表信息与内存块本身融为一体不需要额外的管理结构零开销。pmem结构体记录分区元数据起始地址OSMemAddr、空闲链表头OSMemFreeList、当前空闲块数OSMemNFree、总块数OSMemNBlks、每块大小OSMemBlkSize。OSMemGet / OSMemPut头摘头插O(1) 且防重复归还分配一块os_mem.c:153-187从链表头取if(pmem-OSMemNFree0u){pblkpmem-OSMemFreeList;/* 取链表头 */pmem-OSMemFreeList*(void**)pblk;/* 头摘下一块成为新头 */pmem-OSMemNFree--;return(pblk);}*perrOS_ERR_MEM_NO_FREE_BLKS;归还一块os_mem.c:331-358插到链表头if(pmem-OSMemNFreepmem-OSMemNBlks)return(OS_ERR_MEM_FULL);/* 防重复归还 */*(void**)pblkpmem-OSMemFreeList;/* 头插 */pmem-OSMemFreeListpblk;pmem-OSMemNFree;注意这个细节如果空闲块数已经等于总块数说明这块本来就是空闲的拒绝再次归还。相比之下标准库的free若被重复调用可能直接崩溃。OSMem 用一行判断就把重复释放这个经典 bug 挡在了门外。对比malloc 与 OSMem维度mallocOSMem内存来源堆大小任意固定分区块大小固定碎片风险有随运行累积零碎片分配耗时不确定随堆状态波动O(1)恒定时间归还安全free 重复调用易崩溃显式防重复归还实时系统的第一原则是确定性。malloc 的方便在实时场景里反而是风险。OSMem 牺牲灵活性换来的是绝不意外。使用模式很简单需要缓冲区时OSMemGet拿一块用完OSMemPut还回去——拿和还必须在同一个分区块的大小天然匹配。配合第 8 篇的指针语义生产者 Get 一块装数据把块指针 Post 进队列消费者从队列拿到指针、读完数据、Put 归还——整个数据流没有一次堆分配。适用场景周期性消息缓冲区配合第 8 篇讲的指针语义、第 9 篇场景三的固定数据交换。软件定时器 OSTmr把回调搬出中断为什么定时器回调不能在 ISR 里跑先问一个问题定时器到期后要执行什么通常是一个回调函数。那这个回调在哪里执行最自然的想法是放在节拍中断OSTimeTick里。但紧接着就遇到两个硬伤中断上下文不能调用阻塞 API。回调里若想发信号量、唤醒任务这些操作在 ISR 中处处受限。回调执行时间不可控。一个回调跑 1ms整个系统的中断响应就被拖住 1ms——这是实时系统不能接受的。uC/OS-II 的解法是起一个独立的定时器任务。节拍路径只负责做一件事——OSTmrSignal里OSSemPost发个信号量。真正的回调等定时器任务被唤醒后在任务上下文里执行。此时可以正常调用OSSemPost等内核 API想跑多久也不拖累中断。OSTmrCreate从静态池拿一个定时器创建定时器os_tmr.c:108-175时参数dly首次延时、period周期、optONE_SHOT 或 PERIODIC、callback、callback_arg校验规则ONE_SHOT 要求dly 0PERIODIC 要求period 0从静态数组OSTmrTbl[OS_TMR_CFG_MAX]的空闲池中取出一个 OS_TMR初始状态 STOPPED注意这里没有动态内存分配。定时器对象是编译期就定死的静态数组和 TCB 一样再一次体现确定性优先的设计哲学。OSTmr_Link到期时间哈希到轮槽定时器何时到期OSTmr_Linkos_tmr.c:938-971来决定它挂在哪个槽if(typePERIODIC)ptmr-OSTmrMatchptmr-OSTmrPeriodOSTmrTime;else{if(dly0)MatchPeriodTime;elseMatchDlyTime;}spokeOSTmrMatch%OS_TMR_CFG_WHEEL_SIZE;/* 哈希到轮槽 */pspokeOSTmrWheelTbl[spoke];/* 轮槽链表头插 */OS_TMR_CFG_WHEEL_SIZE 8os_cfg_r.h:140所以有 8 个轮槽每个槽一条双向链表。哈希轮的核心思想到期时刻决定了它挂在哪个槽。查表时根本不用遍历全部定时器——只需看当前时刻对应的那个槽即可。OSTmr_Task信号量唤醒后只查一个槽定时器任务的主循环os_tmr.c:1034-1071for(;;){OSSemPend(OSTmrSemSignal,0u,err);/* 等节拍信号 */OSSchedLock();OSTmrTime;/* 定时器时间 1 */spokeOSTmrTime%OS_TMR_CFG_WHEEL_SIZE;pspokeOSTmrWheelTbl[spoke];ptmrpspoke-OSTmrFirst;while(ptmr!0){if(OSTmrTimeptmr-OSTmrMatch){/* 到期 */OSTmr_Unlink(ptmr);if(PERIODIC)OSTmr_Link(ptmr,PERIODIC);elseptmr-OSTmrStateOS_TMR_STATE_COMPLETED;if(ptmr-OSTmrCallback!0)(*ptmr-OSTmrCallback)(ptmr,ptmr-OSTmrCallbackArg);}ptmrptmr_next;}OSSchedUnlock();}把流程拆开看等信号量OSTmrSemSignal由OSTmrSignal发出而OSTmrSignal只有一句话OSSemPost(OSTmrSemSignal)os_tmr.c:717-722。节拍路径ISR仅需这一句绝不拖泥带水。时间 1OSTmrTime代表系统 tick 前进一格。只看一个槽OSTmrTime % 8得到当前槽只遍历这个槽的链表。到期就摘下来周期定时器重新入轮单次定时器标记 COMPLETED。回调执行此刻在定时器任务上下文里可以正常调用OSSemPost等 API。另外内核里其实有两个信号量OSTmr_Initos_tmr.c:846 附近一个用于节拍通知OSTmrSemSignal另一个保护轮表本身。这也是多任务访问共享结构时的必要手段。算一笔复杂度账没有哈希轮的话每 tick 要遍历全部定时器找谁到期了O(n)n定时器总数有了哈希轮每 tick 只查当前槽的链表平均 n/8。轮子越大摊得越开但到期时间相同的定时器永远挤在同一个槽——这就是为什么 PERIODIC 定时器周期为 8 的倍数时会退化成单槽链表它们全部哈希到同一个槽。理解了这个你就知道OS_TMR_CFG_WHEEL_SIZE该怎么配了。回调里的禁区回调在任务上下文比 ISR 自由很多但仍有一个坑回调里不能调用会阻塞的 Pend。为什么因为回调正在定时器任务里跑如果它 Pend 一个信号量而这个信号量恰好需要这个定时器任务继续推进才能释放——那就自锁了。对比OSTimeDly 与 OSTmr维度OSTimeDly第 5 篇OSTmr 软件定时器执行者任务自己睡自己醒独立定时器任务统一管理到期动作任务恢复运行回调函数被调用周期模式需自己写循环PERIODIC 自动重新入轮查找效率遍历 TCB 链表哈希轮每 tick 只查一个槽一句话总结延时是我睡我自己定时器是别人到点叫我做事。收官全系列 10 篇知识地图到此uC/OS-II 的核心机制全部走完。回头看这条阅读路径篇目主题核心知识点第 1 篇解剖内核文件布局、四大对象、配置驱动、编译模型第 2 篇任务与 TCBOSTaskCreate 六步、初始栈帧、任务状态机第 3 篇调度就绪表位图、OSUnMapTbl、O(1) 两步查表、OS_Sched 三道门槛第 4 篇切换OSCtxSw/OSIntCtxSw、OSIntEnter/OSIntExit、OSTimeTick 节拍第 5 篇时间与延时OSTimeDly 三步、HMSM 换算、OSTimeDlyResume、无延时链表真相第 6 篇信号量OS_EVENT 统一模型、等待表就绪表孪生、Pend 五步、Post 先转交后计数第 7 篇互斥量优先级翻转、PIP 固定继承、继承挪位子、所有权语义第 8 篇邮箱与队列环形缓冲回绕、OSTCBMsg 交付、指针语义零拷贝第 9 篇事件标志组双向链表等待表、AND/OR 四模式、CONSUME 消费第 10 篇内存分区定时器OSMem 零碎片、OSTmr 哈希轮、回调线程模型从这 10 篇里你能提炼出 uC/OS-II 一以贯之的设计哲学用空间换时间位图表、用静态换确定静态数组、用独立任务换安全回调线程模型。这套思维搬到 FreeRTOS、Zephyr 甚至你自己的业务代码里照样适用。还有一个藏在细节里的线索这 10 篇里几乎每一个机制——就绪表、等待表、延时、超时、定时器——都在围绕OSTime 这一个计数器转动。时钟是内核的轴心这一点在任何 RTOS 里都成立。写在最后内存分区告诉我们想确定性就限制灵活性。软件定时器告诉我们想安全就给回调一个合适的上下文。一个管空间一个管时间。两者加起来uC/OS-II 的拼图终于完整。读完这 10 篇你心里应该有一个实时内核到底在解决什么问题的完整答案了。从看懂到能改中间还差一份实践。建议你挑一个模块比如自己动手把OS_TMR_CFG_WHEEL_SIZE从 8 改成 16跑一遍测试体会哈希轮大小对性能的影响。你正在用哪款 RTOS 做项目内存管理和定时器这两块实际开发中踩过哪些坑欢迎在留言区聊聊。觉得有用就点个关注。从第 1 篇到第 10 篇我们用 6736 行代码把实时内核从头看到尾——下一篇系列见。