项目地址:WillOS/WillOS/Inc/allocator.h at master · LieWill/WillOS
在 RTOS(实时操作系统)的世界里,内存分配器(Allocator)就像是一个地产开发商。它负责将物理内存这片“荒地”规划成整齐的“地块”,让系统中的其他组件(特别是接下来的调度器)能够在这些地块上安家落户。
在 WillOS 中,WillOS/Src/allocator.c 实现了一个确定性高且具备内存合并能力的动态堆管理器。
1. 思考:为什么我们需要“动态”分配内存和栈?
在学习具体的分配算法之前,新手最容易产生的疑问是:“我直接定义全局变量数组(静态分配)不行吗?为什么要搞这么复杂的堆(Heap)管理器?”
1.1 静态 vs. 动态:从“买房”到“租房”
- 静态分配(买房):在编译时就定死了每个任务用多少内存。如果你有 10 个功能,哪怕其中 9 个现在不运行,它们的内存也一直被占着。在内存紧缺的嵌入式芯片里,这极大地浪费了资源。
- 动态分配(租房):只有当一个任务(Task)被创建时,系统才从堆里切出一块地给它;当任务结束(Delete)时,这块地立刻归还。这种按需分配的能力,让有限的 RAM 能跑起更复杂的应用。
1.2 为什么要给每个任务分配独立的“栈”?
每一个任务在运行时,都需要存储局部变量、函数跳转地址、以及中断发生时的现场。这些数据必须存在**栈(Stack)**里。
- 机理:如果多个任务共用一个栈,任务 A 执行到一半被切走,任务 B 进来就会把 A 的数据覆盖掉。
- 物理本质:在 WillOS 中,为任务分配内存的本质,就是为它划定一个私有的、受保护的“沙盒”。有了这个沙盒,任务才能在里面放心地进行函数调用,而不用担心被别人“强拆”。
2. 演进之路:从“裸奔”的 v1.0 到“工业级”的 v2.0
WilOS 的内存管理并不是一蹴而就的,它经历了一次从“逻辑原型”到“工业方案”的彻底进化。
2.1 第一版方案 (v1.0: kalloc.c) —— 逻辑原型的探索
最初的代码采用了基于单向总链表的 First-Fit 算法:
- “裸奔”状态:完全没有任何临界区保护。在单任务下没问题,但一旦进入多任务并发,两个任务同时申请内存就会把链表指针对冲,引发
HardFault。 - 搜索低效:由于空闲块和已分配块混在一起,每次分配都需要跳过大量已占用的块,搜索复杂度随运行时间呈 O(N) 增长。
- 脆弱的扩容:
realloc只是简单地“重新找个地儿拷贝”,造成了极大的总线带宽浪费。
2.2 第二版方案 (v2.0: allocator.c) —— 工业级的重塑
现在的 v2.0 是针对 RTOS 环境量身定制的:
- 双向分离链表:将“空闲块”和“已分配块”分属不同链表管理。分配时只搜索空闲列表,效率呈倍数提升。
- 临界区铁甲:所有接口均用
EnterCritical包裹,确保多任务抢占时的原子性。 - 黑科技:Bond 指针与原位扩容:这是 v2.0 的精髓,也是实现“碎片自动吞噬”的关键。
3. 资源勘探:内存版图的边界
内存分配器的首要任务是明确:我能管哪块地?
WillOS 通过链接脚本提供的符号,自动识别内存版图:
- 起始点 (
_end):这是 BSS 段(全局变量)结束的地方,标志着堆空间的开始。 - 终止点 (
_estack - _Min_Stack_Size):为了保证主系统栈(MSP)不被挤压,分配器会预留一部分空间,在此之前的区域都是安全的“可分配区”。
这种设计避免了在代码中“硬编码”地址,实现了很好的硬件适配性。
4. 核心机理:Bond 指针与“碎片回收魔法”
RTOS 内存管理的头号敌人是碎片(Fragmentation)。WillOS 核心机理中最精妙的设计莫过于 bond 指针。
4.1 每一个块都是自描述的
每一个内存块都被一个头部保护着,其中包含一个关键字段:bond。
- 物理锚点:
bond指针记录了该内存块在物理内存上的结束边界。 - 邻里识别:通过
bond,分配器可以瞬间知道自己的物理邻居是谁。
4.2 碎片自动回收:合纵连横
当用户调用 kfree 时,系统不只是简单地把块扔回链表,而是触发一次“邻里大搜救”:
- 右邻合并:检查
my_block->bond是否等于某个空闲块的起始地址。如果是,说明由于刚才的释放,这两块地连起来了!立即合并。 - 左邻合并:同理,寻找物理终点恰好接在自己头上的空闲邻居。
机理效果:只要系统还在运行,小尺寸的碎片就会由于这种“物理聚合”不断合成为大块内存,系统永远不会因为“碎地太多”而无法分配大任务。
4.3 原位扩容 (In-place Expansion)
当你调用 krealloc 想要增加内存时,v2.0 展现出了它的智慧:
- 不急着搬家:它首先通过
bond观察现在的地块“右侧”是不是刚好有一块空闲地。 - 无感扩充:如果是,它直接吞掉右侧的部分空闲空间,修改一下
bond边界值。
意义:这种“原位扩容”不需要进行昂贵的内存拷贝(memcpy),对运行效率的提升是巨大的,这也是衡量一个内核分配器是否达到“工业级”的标准之一。
5. 安全流程:临界区的铠甲
在多任务并发环境下,内存链表是非常脆弱的。如果两个任务同时尝试修改链表指针,系统会瞬间崩溃。
- EnterCritical / ExitCritical:你可以看到分配器的每一个对外接口(
kmalloc,kfree)都套了一层“铠甲”。 - 机理:通过关闭中断(或提升优先级),确保内存分配操作是原子性的。这意味着在地产商“分地”的时候,任何人都不准进来打扰。
6. 承上启下:为调度器(Scheduler)搭建舞台
理解了内存分配器,我们就触碰到了操作系统的脉搏。内存分配器在这里扮演了“承上启下”的角色:
6.1 承上:对硬件资源的抽象
它通过简单的 kmalloc(Size),将复杂的物理 SRAM 地址抽象成了任务随时可以申请的“资源”。
6.2 启下:为“任务”提供土壤
在下一篇文章中,我们将讨论 Scheduler(调度器)。你会发现调度的第一阶段就是调用 ThreadCreate,而它内部的核心逻辑是:
// 调度器中的代码片段
TCB_Ring_t *new = (TCB_Ring_t *)kmalloc(Size + sizeof(TCB_Ring_t));
这里隐藏了一个精妙的设计:
调度器向分配器申请了一块连续的内存。这块内存的前半部分存储 TCB(任务控制块)——也就是任务的身份证;后半部分直接作为 Stack(任务栈)——也就是任务的办公室。
这种“身份证+办公室”合二为一的布局,极大地提高了缓存命中率和内存利用率。
总结
没有 Allocator,RTOS 只能是死板的静态系统。有了它,操作系统才拥有了在运行时“动态创造生命(任务)”的基本物质基础。
既然现在我们已经能为任务分配“地块”和“办公室”了,那么接下来的问题是:我们该如何管理这些排队等地的任务,并让它们有序地轮流使用 CPU 呢?