Skip to content

起手,咱们得把内存分配搞定 ​

好在板子成功拉起来了,至少说明咱们的工程结构是靠谱的。接下来,就轮到咱们自己内核上的代码了。

自然就有个问题:调度、内存、同步,谁先来?笔者写的时候稍微想了一下,决定从内存开始。原因很直接:调度器要登记任务,任务要有自己的栈,消息要有地方暂存;后面不管写哪个模块,咱们都得回答同一个问题——这些东西放在哪里?

为什么需要内存分配 ​

“很奇怪欸”,相信会有朋友说,“内存?不就在那里嘛?焊在 PCB 上的内存芯片?”。是的,大伙最最最开始都是直接搞一个巨大的静态数组,然后咔咔用最朴素的方式:用了就把指针往后挪,还回去就收回来。它背后对应的,正是咱们在链接脚本里划出的那片 SRAM。如果您已经忘了这条配置在哪,打开 third_party/ZerOS/src/board/stm32f103_bluepill/link.ld,能看到这行:

ld
RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K

这 20 KiB 是整片 SRAM 的预算,里面要放全局数据、任务栈、内核状态,还要给异常和中断使用的栈留空间。笔者想强调的是:运行时咱们所有活动的内存开销,就这 20 KiB。

分配器没有能力凭空变出内存。它能干的事,咱们一句话就能说清:从已有存储里划出一部分,交给某个使用者,并记录这部分现在归谁用。如果允许回收,还得在使用结束后把它交还,让后来的请求复用。

好像还是有点抽象?那咱们拿接下来要写的任务系统来说。一个任务至少需要两类存储:任务控制块保存调度器关心的状态

任务栈承载这个任务运行时的调用现场。任务切出去以后,这些内容还得留着,否则下一次切回来,连自己执行到哪里都不知道。

消息也一样:生产者提交一条消息以后,消费者可能过一会儿才来取,中间这段时间总得有地方保存它。

这就对我们产生要求了。咱们得看清楚存储要活多久、什么时候可以复用,再决定把它放在哪。拿消息缓冲区举个例子:一块缓冲区本来空闲着,生产者取得它的使用权,把消息填进去,交给消费者;等消费者处理结束,这块缓冲区就被归还回来,可以接着装下一条消息。

这里的“交给消费者”是使用权的交接;“归还”以后,这块缓冲区才可以发给下一条消息。这条约定咱们得记牢:分配接口、队列接口和调用方必须一起遵守,否则即使分配器自己没记错状态,也可能出现两个任务同时改一块内存的问题。

能提前安排的,为什么还要运行期领取 ​

啊哈,那很简单,我们需要针对不同的工程 cases 来进行分类讨论!

如果系统启动后始终只有几个固定任务,每个任务的栈大小也已经算好,那当然可以提前给每个任务留一份存储。这种情况我们最喜欢,因为场景非常明确,要就完事了。消息队列如果采用固定容量的环形缓冲区,也可以把整个缓冲区预留出来。换句话说,有内存需求,并不意味着咱们的每个模块都必须调用分配器。

但另一类需求只知道上限,不知道每次是谁在用。比如咱们允许最多同时有 8 条待处理消息,具体的消息到来时间和处理完成时间却由外部事件决定。此时可以提前备好 8 个槽位,运行时来了消息就领一个,处理完再还回来。存储总量没有增长,使用权却在不断变化,这就需要运行期的分配和回收。

还有一种很常见的中间情况:任务只在启动阶段创建,创建完就一直运行。咱们不想手工计算每个任务的地址,却也不需要运行期删除任务、回收空间。 说白了就是要出来就跑路。这种需求用一个只向前移动的分配游标就能处理,后面 ZerOS 的 Arena 正是干这个的。

到这里,咱们就能把两个容易混在一起的问题分开了:内存从哪里来,以及什么时候决定给谁用。一块静态预留的数组,既可以从头到尾专供一个任务,也可以交给分配器,在运行时分给不同使用者。本系列说的“无堆”,指不依赖通用堆分配,并不排斥后一种用法。

为什么不直接接一个 malloc ​

通用堆的便利,咱们都清楚:调用方可以按字节申请不同大小的空间,不必提前确定每一种对象的配额。为了支持分配和回收,分配器需要记录块的大小和状态,寻找合适的空闲块;具体实现还可能拆分大块、合并相邻空闲块。

这套办法完全可以在单片机上的固定内存区里实现,并不需要先有一个桌面操作系统。咱们真正要评估的,是它为当前需求带来的代价:元数据占多少空间,混合尺寸的分配会不会产生外部碎片,一次申请最坏要搜索多少个块,以及多任务共享时怎么同步。

外部碎片咱们拿个示意图看就明白了。假设总共有两段空闲区,各有 64 字节,但中间夹着一个还在使用的对象。此时空闲总量是 128 字节,却无法直接满足一个连续 96 字节的请求。合并相邻空闲块能缓解这件事,但无法跨过仍在使用的对象。实时性也要看具体算法和配置,不能仅凭“它叫堆”就判定耗时没有上界。

带着这些问题,咱们看看成熟 RTOS 已经给出了哪些选择。

常见 RTOS 如何做内存分配 ​

这些方案经常同时出现在一个 RTOS 里,应用可以按对象的用途组合使用。咱们重点看三家:FreeRTOS 把静态创建和不同堆实现摆在面前,ThreadX 同时提供定长块池与可变长字节池,Zephyr 则能帮咱们理解分配算法和等待策略的区别。

FreeRTOS:调用方提供存储,或者让内核申请 ​

咱们从创建任务看起,FreeRTOS 有两条典型路线。xTaskCreate() 让内核申请任务控制块和栈所需的 RAM;xTaskCreateStatic() 则由调用方提供栈缓冲区和控制块存储。后者把存储预算交给应用掌握,创建动作仍然可以发生在运行时——“静态创建”不等于整个创建过程都在编译期完成,这个区别在官方的静态与动态分配说明里讲得很细。

咱们选了动态分配之后,FreeRTOS 又把底层实现留作可选项。官方给出的五个实现各有侧重:

实现分配与回收方式选型时要记住的区别
heap_1只分配,不支持释放适合创建后一直保留的对象
heap_2支持释放,不合并相邻空闲块混合尺寸反复分配时要考虑碎片
heap_3封装标准库的 malloc / free依赖所接入的标准库分配器
heap_4支持释放,并合并相邻空闲块缓解外部碎片,但不能保证所有请求成功
heap_5在 heap_4 的基础上管理多个不相邻区域适合 RAM 分散在多个区域的系统

表里的取舍,笔者整理自 FreeRTOS 官方的内存管理概览。特别留意 heap_3 和 heap_5:前者接标准库,后者接多个内存区域,不能把五种实现都理解成“在同一块静态数组上做同一种分配”。

这里值得咱们借鉴的,是先问一句对象需不需要回收。如果任务只在启动时创建,后续一直存在,那么只分配的方案就有用武之地;如果需要运行期反复创建和删除对象,再考虑支持回收的方案。

提前打个招呼:下面 ThreadX 和 Zephyr 两节的内容,是笔者查资料加 LLM 交叉验证得来的,手上没有真机逐条复核,有可能不对,欢迎指正。

ThreadX:同样叫池,块池与字节池解决的需求不同 ​

咱们再看 ThreadX。它的 TX_BLOCK_POOL 提供固定大小的块,申请时取一块,释放时把整块归还。空闲块通过链表管理,分配和回收操作在空闲链表头部进行。这很适合尺寸固定、反复领取和归还的资源,例如一种消息节点。

TX_BYTE_POOL 则允许按所需字节数申请,内部会查找合适的空闲块、拆分剩余空间,并在后续分配搜索时合并相邻空闲块。您把它理解成一个“体验更接近堆的池”就行。ThreadX 还允许线程在池资源不足时等待,所以咱们评估 API 的返回时间,也要把等待行为算进去——细节在 ThreadX 官方文档的 Memory Block Pools 与 Memory Byte Pools 两章。

所以,“用了内存池”还不够具体,咱们得继续追问:池里发的是固定大小的块,还是任意长度的字节区?耗尽以后是立即失败,还是挂起当前任务?这两件事会直接决定上层代码怎么写。

Zephyr:定长块适合一种需求,可变长堆也有自己的时间约束 ​

最后咱们看 Zephyr。它的 k_mem_slab 也是固定块模型,用空闲链表管理空闲块。应用可以建多个 slab,比如把小消息和大缓冲区分开放;池空时,调用方可以选择等待可用块。它避免了单个 slab 内因可变尺寸切割造成的外部碎片,但小对象占用大块的内部浪费仍然存在,Zephyr 的 Memory Slabs 文档里有完整说明。

等到咱们需要可变长分配时,Zephyr 还提供 k_heap,底层是 sys_heap。官方文档明确为 sys_heap 的操作给出常数时间保证,并通过编译期配置限制候选块搜索次数;底层自身不做并发同步,上层 k_heap 才补上同步和等待。要注意,算法层面的常数时间,不等于这个允许等待的 API 总共花多久——等别的线程释放的时间,不在算法保证里,这部分细节见 Memory Heaps 文档。

这对咱们也是个提醒:选择定长块池,应当是因为它与对象尺寸、容量和生命周期吻合。ZerOS 可以选择较简单的实现,但理由得落在自己的需求上。

ZerOS 准备如何分配 ​

按生命周期分工 ​

回到咱们自己的代码。下面的路径都相对于 third_party/ZerOS/,您可以打开文件看看接口,不必这一篇就读完实现:

用途当前源码入口存储安排
显式预留任务控制块和任务栈include/ZerOS/kernel/sched/task.hpp、stack.hppTaskSlot 把两份存储放在一起,调用方可以静态定义
启动阶段按配置创建任务include/ZerOS/kernel/sched/arena.hpp、src/arch/arm_cortex_m3/launch.cppArena 按对齐要求向前切出空间,不提供单独释放
需要反复申请、归还的固定大小存储include/ZerOS/kernel/mem/bitmap_allocate.hppBitmapPool 发出整块,释放后重新可用
在池的块里构造、销毁对象include/ZerOS/kernel/mem/typeable.hppMake / Destroy 连接对象生命周期与块的领取、归还

对需要回收的对象,咱们一次发一块 ​

咱们看池的形状,它在模板参数里给定。例如 BitmapPool<64, 8, false> 表示每块 64 字节,共 8 块,关闭 poison 检查策略。使用者能拿来放内容的区域就是 64 × 8 = 512 字节;整个池对象还要容纳位图、计数和对齐填充,不能把 512 字节当成它的总占用。

运行时咱们申请一块,就取得 64 字节存储;使用结束,整块归还。这里没有把一个大空闲块拆成不同尺寸小块的过程,也不需要在释放时把相邻块合起来。对这个池而言,只要还有空块,一个符合尺寸要求的单块请求就有位置可放。申请第九块时,如果前八块都没归还,raw_allocate() 就返回 OutOfMemory。

尺寸必须提前算好。8 字节的数据也会占一整块 64 字节的空间,这是内部浪费。80 字节的对象,咱们也不能指望它自动拼接两个块,因为当前池一次只发一块。不同类型可以在不同时间使用同一个池,只要大小和对齐都符合要求;也可以按尺寸或用途拆成多个池。共享能复用空闲容量,独立池则能给关键资源保留配额,接口没有要求全系统必须共用一个池。

诶诶!配额用尽以后,上层要有处理办法。 定长块池不会消除资源耗尽。如果普通消息和关键控制消息共用一个池,前者可能把所有块占完。咱们需要在上层决定是丢弃本次消息、稍后重试,还是为关键消息单独预留容量。对于我们当前 BitmapPool 没有等待队列,也不会自动挂起任务等别人释放。

对象的构造,咱们再交给另一层。按咱们项目的 C++23 配置,Make<T> 先检查对象的大小和对齐要求,再向池领取原始存储,最后用 placement new 在指定地址构造对象。标准的非分配 placement new 使用调用方已经提供的地址,不会再去通用堆申请一份空间;对应的 Destroy 先析构对象,再归还块。这种分工,笔者认为可以让“管理哪块可用”和“怎样构造这个类型”各自保持清楚。

位图负责回答:哪一块还空着 ​

BitmapPool!我们终于说Bitmap,也就是位图了!

咱们已经决定按整块发内存,接下来需要记录每块是否占用。可以用空闲链表,也可以另开一张状态表。前面看到的 ThreadX 块池和 Zephyr slab 证明了空闲链表是成熟的选择;ZerOS 这里选位图,主要是希望把状态数据和块内的内容分开放。

做法很直观,咱们一块记一个 bit:0 表示空闲,1 表示占用。下面按块号从小到大画出来,注意这不是通常高位在左的二进制数写法:

text
块号          0 1 2 3 4 5 6 7
初始          0 0 0 0 0 0 0 0
申请一块      1 0 0 0 0 0 0 0
再申请一块    1 1 0 0 0 0 0 0
归还 0 号     0 1 0 0 0 0 0 0
再次申请      1 1 0 0 0 0 0 0

咱们的策略是从低块号开始找第一个 0,所以释放后的 0 号会优先被复用。这使选择顺序容易推演,但它并不提供任务之间的公平性,也不是轮流使用所有块。

实际实现里,咱们按 32 位字组织状态。找空位时,先跳过已满的字,再在未满的字里找第一个空闲位。判断一个字是否已满,要看其中所有有效位是否都为 1。一个字不等于零,只能说明里面有占用位,不能说明它还有空位。最后一个字如果没有装满 32 个真实块,还要排除那些不对应任何块的填充位。

咱们再看 BitmapPool 在这基础上加的一层摘要:第二层一位记录一个块,第一层一位记录第二层的一个字是否已满。找一个未满的组,再在组内找空块,就不用每次从头检查所有底层字。这也解释了为什么下一篇要把可按位、也可按字访问的 base::Bitmap 写出来,两个层次需要的接口都在那里补齐。

块内容与位图分开还有一个好处,咱们到 poison 就看到了。

开启 poison 策略后,释放时把整块填成 0x67,下次选中这块时检查填充值是否被改动,用来发现部分释放后的非法写入。当前代码额外使用 ever_poisoned_ 记录哪些块曾经填过这个值,避免把初始存储误当成损坏。这个检查会读写块内容,也不能发现所有悬空指针问题,但状态数据不占用块内字节,整块检查才容易实现。

“时间可预测”要具体到代码里的循环 ​

固定容量让最坏路径容易分析,但咱们不能直接说“无论池多大,分配和释放都只需相同时间”。当前 Bitmap::find_first_zero() 会逐字搜索;两级池先搜索摘要位图,摘要够大时仍然会检查多个字。对于 N 个块,第一层摘要占用 ceil(N / 1024) 个 32 位字,这给出了摘要扫描次数的上界。

咱们拿数字代入:N 不超过 1024 时,摘要只占一个字,查找只需定位摘要中的位,再定位对应组中的位。池继续增大,这个成本也会增长。若开启 poison 策略,释放时还要填充整个块,再次分配曾释放的块时要检查其内容,工作量与块大小有关。构造、析构对象的耗时,也需要另外计算。

**非阻塞接口不自动等于中断安全。**当前 BitmapPool 没有内置锁,位图修改也不是原子操作。try_allocate() 只是把申请结果转换成指针或空指针,不会顺手解决竞争,不要去做明显可以通过组合完成的事情。多个任务或任务与 ISR 共用一个池时,需要咱们额外加临界区或独占访问约定,并把临界区耗时计入响应时间。

拿现成代码验证一次领取与归还 ​

这一篇咱们还不实现分配器,不过可以拿仓库已有代码验证刚才的模型。把下面的程序保存为 /tmp/zeros_pool_intro.cpp,用支持 C++23 的主机编译器运行。程序分三步:把 8 块全部领走,再要第九块验证耗尽,最后释放一块验证复用。

C++
// Platform: host; C++ Standard: C++23
#include "ZerOS/kernel/mem/bitmap_allocate.hpp"

#include <array>
#include <cassert>
#include <cstdio>

int main()
{
    ZerOS::memory::BitmapPool<64, 8, false> pool;
    std::array<void*, 8> blocks{};
    for (auto& block : blocks) {
        auto result = pool.raw_allocate();
        assert(result.has_value());
        block = *result;
    }

BitmapPool<64, 8, false> 就是咱们刚才说的每块 64 字节、共 8 块、关掉 poison。前八次 raw_allocate() 全部成功,指针存进 blocks。接着要第九块:

C++
    auto extra = pool.raw_allocate();
    assert(!extra.has_value());
    assert(extra.error() == ZerOS::memory::MemoryAllocationError::OutOfMemory);

    auto released = pool.raw_deallocate(blocks[0]);
    assert(released == ZerOS::memory::MemoryAllocationError::Ok);
    auto reused = pool.raw_allocate();
    assert(reused.has_value() && *reused == blocks[0]);

咱们看第九次申请:拿到的是 OutOfMemory。把 0 号块 raw_deallocate 还回去之后再申请,拿回来的还是同一个地址——释放的块被复用了。最后把 8 块全部归还,打印一行汇总:

C++
    for (auto block : blocks) {
        auto result = pool.raw_deallocate(block);
        assert(result == ZerOS::memory::MemoryAllocationError::Ok);
    }
    std::puts("8 blocks allocated; ninth rejected; released block reused.");
}

咱们从教程仓库根目录执行,源码和可执行文件都放在 /tmp/:

bash
# 嘿嘿,丢到/tmp下不打扰咱们的仓库开发!
g++ -std=c++23 -Wall -Wextra -Werror \
    -I third_party/ZerOS/include \
    /tmp/zeros_pool_intro.cpp -o /tmp/zeros_pool_intro
/tmp/zeros_pool_intro

笔者本次在主机 GCC 16.1.1 下运行,输出如下:

text
8 blocks allocated; ninth rejected; released block reused.

这里验证的是容量耗尽与释放后复用,不是板上的最坏执行时间。池对象在这个小程序中是局部对象,存储跟随它存在;放进内核时,咱们还要给池本身安排足够长的生命周期。BitmapPool 管的是自己内部的存储,不要求存储一定来自全局变量。

回头看,要解决的需求已经很具体了:长期固定对象直接预留,启动期任务装配交给 Arena,需要反复领取和归还的固定大小资源用块池,位图负责记录哪块空着。下一篇咱们把位图写出来(定容量的 Bitmap),把“找一块空的”落实成可测试的代码。

完事!我们准备动手更加具体的设计咯!(下一篇见~)

pdf-latest-4-g85128cc · 85128cc · 2026-10-05