
做 RTOS 底层内存管理这件事我一开始也觉得位图Bitmap是那种“面试题里好看、工程里没意思”的数据结构。直到真把内存池从空闲链表换成位图追踪才发现分配定位从几十次指针跳转变成几条位运算快是其次更重要的是分配行为变得可预期。C23 把std::countr_zero、std::countl_zero这些位操作正式收进标准库之后写位图管理器的代码量比我之前那版短了一大截而且可以constexpr直接在编译期验证边界情况。这篇文章是这个系列的第 2 篇。上一篇我们用链表实现了一个可供 RTOS 各任务共享的堆分配器这篇就聊聊怎么用位图替换掉链表来管理空闲块它解决了什么问题、C23 提供了什么便利、有哪些坑、以及怎么排查“bitmap 中出现标记为已使用但实际没人用”这种奇特问题。适合谁看打算自己做 RTOS 内存子系统、或者准备在嵌入式环境里用 C23、想学位图工程化的朋友都可以顺着过一遍。1. 为什么是 Bitmap从链表到位图这一步我到底折腾出了什么1.1 内存池场景下链表的两个短板回顾上一篇的方案RTOS 的堆管理用的是双向空闲链表。分配时从链表头开始找第一个大小够的块不够就 split释放时做合并或放回链表。逻辑没错但实际用下来有几个麻烦。第一遍历开销不稳定。堆里块很多时从链表头查找一块合适区域可能要走过几十次 next。多数 RTOS 内核既不保证调度延迟也不允许任务在临界区里呆太久。任务里频繁 new/delete 时分配的耗时抖动会传导到处死线敏感的代码里说白了就是“平时挺快偶发卡顿”。第二链表本身要占用块的头部空间。双向链表至少要两个指针这意味着每个内存块都有最少 8 字节32 位平台甚至 16 字节64 位平台的元数据开销。小块场景下这个成本非常难看。而维护链表结构也需要前驱后继更新一旦在中断上下文里释放一个块就必须考虑链表改造是否安全。第三链表天然适合大小任意的连续池但日后的池子越来越多是定长块池。比如 DMA 描述符池、消息节点池、定时器句柄池每块大小固定这种场景链表没有优势反而要为每个块额外藏指针。所以位图的优势很清晰一个池里的 N 个块对应一张 N 位的表。每块只用 1 bit0/1 标记空闲或占用不需要在内存块里写任何头部信息不需要 split/merge——至少定长池场景完全不需要。1.2 Bitmap 在内存管理里的三种常见角色RTOS 内存管理里位图最常见的用法有三类定长池buffer pool每个 bit 对应一个固定大小的 buffer。常用于网络栈的 mbuf 池、DMA 描述符池、消息队列节点池。分配就是找第一个可用 bit回收就是清零。连续块游标在伙伴系统或分段式分配器中位图辅助标记某一层块的占用情况帮助快速找到一段连续的 free run。文件系统分配位图这是另一种经典场景。FAT/NTFS 文件系统在元数据区记录“分配位图”在磁盘上逐簇标记是否已分配。很多系统工具扫盘时会报“bitmap 中有标记为已使用的未用簇”本质就是持久化的位图状态和真实分配状态不一致——这和内存池里的位图漂移是同一类问题排查思路完全可以互相参考。本篇文章的重点是前两类但第三类会在第 5 章作为对照案例展开。1.3 选型为什么不用红黑树你可能会问为什么不直接上平衡树比如红黑树来管理空闲块我实际比较下来结论分场景定长池场景位图 O(1) 定位内存开销几乎为零红黑树节点至少要两个指针每块还要存父节点颜色完全没得比。伙伴系统场景位图能支持 O(logN) 定位空闲 run配合移位操作比树直白得多。调度器场景如果需求是“找第一个空闲的优先级位/定时器槽位”位图本身就是最优解。Linux 内核的调度器、FreeRTOS 的定时器 id 分配核心都是位图。位图当然有天然弱点它是稀疏性数据结构。如果空闲块分布得七零八落连续块查找可能退化成线性扫描。但 RTOS 内存池的块数通常不多几十到几千已经算大池。以 2048 块的池为例整张位图不过 256 字节一次扫描只需要看 32 个 uint64_t这个成本完全可接受。2. C23 工具箱标准库让位运算变成了零成本抽象2.1 最常用的三个函数C20 开始在bit头里加入一批位操作函数C23 又补齐了更多。我在实现里最常用的是这几个std::countr_zero(x)返回从最低位开始连续 0 的个数。比如countr_zero(0b11000u)结果是 3。std::countl_zero(x)返回最高位之前的 0 个数常用于容量对齐计算。std::popcount(x)统计 1 的个数算空闲数时很好用。std::has_single_bit(x)判断 x 是不是 2 的幂我写伙伴系统时经常用。std::bit_floor/std::bit_ceil分别向下和向上取 2 的幂。这些函数基本都封装了编译器内置指令比如__builtin_ctzll、__builtin_popcountll所以是真正的零成本抽象既保留代码可读性又不会比手写汇编慢。这一点对 RTOS 环境特别重要。早年没有这些标准库时我们通常得手写一个 while 循环数位或者直接平台宏判断 CPU 是否支持 CLZ 指令。现在一行std::countr_zero搞定编译器自动选最优指令。2.2 嵌入式里敢用 constexpr 的底气C23 里标记为constexpr的函数可以在编译期被求值。我把位图的核心算子都声明成constexpr并在static_assert里放一组自测。这么做的收益是编译期就能验证位偏移、边界、统计逻辑是否正确运行时调用经过编译后和普通内联函数一样没有额外状态不需要引入任何测试框架只需一个static_assert。当然C23 的constexpr还不允许在常量表达式里做常规动态内存分配这块到 C26 才逐渐放开而位图这种栈上定长对象恰恰可以完美避开这些限制。你有一张定长数组所有操作都是位运算天然符合constexpr要求。2.3 关于 optional 和异常编译选项RTOS 的交叉编译环境通常开-fno-exceptions和-fno-rtti此时std::optional可用吗我实测下来只要工具链的 libstdc 或 libc 支持对应头文件optional本身不依赖异常机制。但如果你用的工具链比较老或者嵌入式 ABI 特殊最省事的方式是放弃optional用size_t返回值加一个npos哨兵值表示找不到。我在下面的实现里就按后者写少惹争议。方案额外开销异常需求可读性适用场景std::optionalsize_t通常 8-16 字节无但要头文件支持好现代嵌入式工具链size_t npos哨兵值零无中等老工具链 / 极简环境我用的 arm-none-eabi-gcc 实测过加了-fno-exceptions后optional依然能过编译。如果你还纠结直接上哨兵值最稳反正 API 差不多。3. Bitmap 的核心实现数据结构、位偏移与边界处理3.1 布局设计对齐、定长、模板参数先给出完整的头文件骨架。为了能在栈上放任意大小的池我直接用非类型模板参数N表示块数。这样 128 块的池只需要 2 个uint64_t16 字节开辟和传递都没有额外成本。#include bit #include cstddef #include cstdint template size_t N class block_bitmap { public: static constexpr size_t bits_per_word 64; static constexpr size_t word_count (N bits_per_word - 1) / bits_per_word; static constexpr size_t npos static_castsize_t(-1); alignas(std::uint64_t) std::uint64_t words_[word_count]{}; constexpr void reset() noexcept { for (size_t i 0; i word_count; i) { words_[i] 0; } if constexpr (N % bits_per_word ! 0) { // 把最后一个 word 的高位“幽灵位”置 1视为永远占用 words_[word_count - 1] ~((std::uint64_t{1} (N % bits_per_word)) - 1); } } // ... };这里有个非常关键的细节如果 N 不是 64 的倍数最后一个 word 的高位是空置的。如果不把这些“幽灵位”处理掉find_first_free()扫描到尾部时会返回一个超出 N 的下标分配器就会在越界地址上干活。这种 bug 极其隐蔽因为它只会在池大小是非 64 的倍数时出现而且概率还取决于分配顺序。我选择在reset()时把幽灵位置 1表示“永远占用”。这个决定背后的逻辑很简单与其让查找逻辑到处判断下标越界不如在一开始就把非法位排除掉。3.2 find_first_free~word 遇上 countr_zero分配路径最频繁的操作是找第一个空闲位。我的语义是1 表示已占用0 表示空闲。那么取反之后空闲位变成 1已占用位变成 0。只要某个 word 取反后不为 0就说明这个 word 里存在空闲块。constexpr size_t find_first_free() const noexcept { for (size_t w 0; w word_count; w) { std::uint64_t free_bits ~words_[w]; if (free_bits ! 0) { size_t bit static_castsize_t(std::countr_zero(free_bits)); return w * bits_per_word bit; } } return npos; }注意std::countr_zero(0)是未定义行为所以必须先判断free_bits ! 0再调用。这是 C20 位操作刚进标准库时最容易踩的坑之一老手也翻过车。这套组合拳比逐位扫描快得多。一次循环检查 64 个块只要池子不大整张位图几轮就扫完。当你分配顺序恰好是“从前往后填满又释放后面”时find_first_free几乎每次都能命中最前面的 word。3.3 设置与清空set / clear / test有了查找设置和清空就是最基本的位写操作constexpr void set(size_t i) noexcept { size_t w i / bits_per_word; size_t b i % bits_per_word; words_[w] | (std::uint64_t{1} b); } constexpr void clear(size_t i) noexcept { size_t w i / bits_per_word; size_t b i % bits_per_word; words_[w] ~(std::uint64_t{1} b); } constexpr bool test(size_t i) const noexcept { size_t w i / bits_per_word; size_t b i % bits_per_word; return (words_[w] (std::uint64_t{1} b)) ! 0; }这三段代码不值得炫技。理论上可以把w和b拟合成一次移位但现代编译器对这类整数除法/取模会优化成乘法和移位尤其当 bits_per_word 是 2 的幂时一条逻辑右移加一条与运算就出来了。RTOS 内存管理代码最忌花哨我需要的是任何人三个月后回来看都能秒懂。3.4 连续空闲块查找伙伴系统的地基在变长池或伙伴系统里往往需要“连续 n 块都空闲”才分配。最简单的扫描法是逐位检查constexpr size_t find_first_free_run(size_t n) const noexcept { if (n 0) return npos; if (n 1) return find_first_free(); size_t run 0; for (size_t i 0; i N; i) { if (test(i)) { run 0; } else { run; if (run n) return i - n 1; } } return npos; }这是 O(N)。对几百块的池子足够。如果 N 上到几千且分配频繁再考虑基于 word 的加速先算出每个 word 的free_bits ~words_[w]再想办法在 word 里快速找连续 n 个 1。数学上这可以用乘法加掩码做“串匹配”但实现复杂度和可读性都会迅速恶化。我实测过一块 1 万个块的池O(N) 扫描在 Cortex-M4 168MHz 下大约几微秒而 RTOS 任务切换本身量级也是几微秒。结论是除非池规模上万且分配极端频繁否则不值得为这点复杂度引入跳表或分段位图。这个结论可能跟很多理论派讲的“必须优化”不太一样但它来自实测不是推演。3.5 free_count统计空闲块数内存统计接口需要知道还剩多少块。std::popcount在这里派上用场constexpr size_t free_count() const noexcept { size_t cnt 0; for (size_t w 0; w word_count; w) { cnt static_castsize_t(std::popcount(~words_[w])); } // 减去幽灵位的影响 if constexpr (N % bits_per_word ! 0) { cnt - bits_per_word - (N % bits_per_word); } return cnt; }popcount对uint64_t通常会编译成 POPCNT 指令或者一组掩码加法指令。不管哪种都足够快。这里的坑在于如果 N 不是 64 的倍数reset 时把幽灵位置 1取反后它们会被当成“空闲”。统计时必须把多余的高位数量扣掉。不处理这个细节你会看到池子显示永远多出若干空闲块追查半天。4. 接入内存管理器用 Bitmap 重写分配与回收路径4.1 定长池接入最顺的路径定长池是位图最舒服的应用场景。以 DMA 描述符池为例class dma_pool { static constexpr size_t pool_size 32; static constexpr size_t block_size 64; block_bitmappool_size bitmap_; alignas(16) std::byte storage_[pool_size][block_size]; public: void* acquire() { size_t idx bitmap_.find_first_free(); if (idx block_bitmappool_size::npos) { return nullptr; } bitmap_.set(idx); return storage_[idx]; } void release(void* p) { std::ptrdiff_t diff reinterpret_caststd::byte*(p) - reinterpret_caststd::byte*(storage_); size_t idx static_castsize_t(diff) / block_size; bitmap_.clear(idx); } };映射关系很简单池首地址storage_是连续的块大小固定所以指针转下标就是一次减法和一次除法。如果块大小是 2 的幂甚至可以优化成移位但编译器通常也能识别。我在一个项目里用这套结构管理了三类池DMA 描述符池、定时器句柄池、消息队列节点池。原来消息节点用链表管理一层消息嵌一层消息时频繁 split/merge现在全部固定大小分配回收都是常数时间。调试信息也直观多了直接 dump 位图字节哪个块被占用人人可查。4.2 回收路径加一道 O(1) 的重复释放检测release()里除了clear还要考虑一个问题重复释放。链表方案检测重复释放通常要遍历链表成本很高所以很多 RTOS 直接不查。位图方案天然支持 O(1) 检测void release(void* p) { size_t idx address_to_index(p); if (!bitmap_.test(idx)) { // 该块已经是空闲状态说明重复释放或指针错误 handle_error(...); return; } bitmap_.clear(idx); }为了这个 O(1) 的重复释放检测我宁愿在 release 里多一次test。RTOS 的问题千奇百怪内存状态越早暴露越好别等系统跑了几小时才在某个角落里炸开。真机上抓到的重复释放、野指针越界大多不是同一时刻发生的而是状态一点点被改写最后才暴露。4.3 与链表的对比一组真实数据我拿典型负载做了对比16 个任务共用一个消息队列池2048 个块、每块 128 字节每秒大约 10 万次 acquire/release。分配耗时分布链表版本存在偶发长尾位图版本最坏也就是扫描几个 word几乎恒定。碎片率定长池本就没有碎片链表版本在反复 split/merge 后会产生不可用的小块。中断上下文释放位图只需清一个位链表要处理前后节点改写不小心就损坏结构。元数据开销2048 位只有 256 字节链表方案每个块若带头指针按 32 位平台算至少 2048 * 4 8KB加上对齐填充更大。这些数字因平台而异但差距是数量级的。这也是我把系列第 2 篇放在位图上的主要原因在 RTOS 内存管理里数据结构选对了很多调度和稳定性问题自然消失。5. 避坑实录状态不一致的完整排查链路5.1 问题复现未用但标记为已使用真机调试时遇到过一个非常经典的问题系统跑一段时间后概率性“内存泄漏”。分配器显示某个块还占着但具体是哪个任务在用完全说不清。我在控制台 dump bitmap发现某些池子里从来没分配过的块bit 却是 1读该块内容里面是上次的旧数据。这就是标题里提到的“bitmap 中有标记为已使用的未用簇”文件系统报这种错内存池也会报。磁盘的分配位图状态漂移了内核工具扫描时能发现内存池里漂移了往往只能靠 dump 或者统计对账发现。5.2 排查过程从无并发到并发复现第一步排除分配器自身逻辑 bug。写一个压测用例在无中断、单任务条件下跑 10 万次随机 acquire/release位图状态完全一致未发现异常。这说明非并发环境下逻辑正确。第二步加入多任务并发。分配/释放来自多个任务并刻意频繁切换任务很快复现。但此时无法确定是谁改坏了状态。我怀疑过任务切换时序、怀疑过缓存一致性最后发现跟这些都无关。第三步在 set/clear 外加临界区先关中断再操作复现率显著下降但不为零。第四步在 release 函数里发现了第一个可疑点我算下标用的是除法size_t idx (reinterpret_caststd::byte*(p) - base_ptr) / block_size;看着没问题但block_size是成员变量而不是编译期常量时工具链会把它编译成除法指令。只要块大小不能整除地址差或者指针本身来自外部传入就会偶尔算到错误的 idx 上。这个属于计算错误修掉之后复现率又降了一截但还没根除。5.3 根因分析中断里的非原子位操作真正的问题出在并发读改写上。位图的 set/clear 都涉及“读一个 word、改一个 bit、写回 word”三步。在单核嵌入式里只要不是临界区内的原子操作中断可能在任意一条 load/store 之间打断。打个比方任务 A 正在clear(3)它先把 word0 读进寄存器刚改完 bit3还没来得及写回中断来了中断里执行set(7)它读到的还是旧的 word0改完 bit7 写回。等任务 A 恢复继续把改过的 word0 写回bit7 的修改就被覆盖了。于是 bit7 对应的块明明已经被占用了位图上却还是空闲或者反过来——块已经释放了bit 还是 1。我们碰到的“未用但标记为已使用”底层就是这个丢更新问题中断里 clear 掉的 bit被任务里后写回的旧 word 又置回 1。链表方案其实也有类似问题但因为链表操作通常是大临界区反而容易整体保护位图方案因为追求“快速小函数”我一开始只在外部加锁忽略了 word 级正交性带来的细粒度竞争。5.4 修复与验证修复办法分两层一是所有位操作进临界区。RTOS 里最直接的是关中断保护因为位操作本身就几条指令关中断时间极短不会影响实时性。封装一个 RAII 风格的锁class irq_lock_guard { public: irq_lock_guard() : basepri_(get_basepri()) { set_basepri(MAX_IRQ_PRI); } ~irq_lock_guard() { set_basepri(basepri_); } private: uint32_t basepri_; };所有公共接口都套上这个 guard。查找、设置、清空、统计统一保护。二是release 时 test 再 clear。test 和 clear 组合成一个原子操作既避免重复释放检测被并发击穿又保证位图状态与真实状态一致。修复之后我重新压测在定时器中断里以 10kHz 频率随机释放池块主循环以同样频率分配跑 1000 万次循环位图状态始终一致。我还把同一套逻辑放到了文件系统位图模拟里做对照实验验证了“状态漂移大多来自非原子修改”的判断。5.5 一个额外的坑别把幽灵位当异常还有一个坑值得一提。排查最初阶段我曾以为状态不一致是 reset 时幽灵位没处理导致于是在故障现场直接清空最后一个 word 的高位。结果更糟幽灵位一旦被当成真实块分配出去越界地址立刻把相邻内存打花。后来才想明白正常做法是把幽灵位置 1表示永远占用。所以各位如果调试时看到最后一个 word 的高位有奇怪的“已占用”标志第一时间别怀疑那是保护边界用的不是故障。6. 测试策略与后续扩展6.1 constexpr 自测试编译期干掉 90% 边界问题C23 里constexpr函数可以在static_assert的常量表达式里执行。这让我们可以写一段零成本的编译期测试constexpr bool test_basic() { block_bitmap128 bm; bm.reset(); if (bm.find_first_free() ! 0) return false; bm.set(0); if (bm.find_first_free() ! 1) return false; bm.set(1); bm.set(2); if (bm.find_first_free() ! 3) return false; if (bm.free_count() ! 128 - 3) return false; bm.clear(1); if (bm.find_first_free() ! 1) return false; return true; } static_assert(test_basic());编译期执行测试通过就编译通过失败直接编译报错。这套方法对 RTOS 调试环境特别友好你不需要 JTAG、不需要 print、不需要在真机上打断点最基础的下标换算和统计逻辑在编译阶段就被验证了。非 64 倍数的情况也可以专门写一个block_bitmap100的测试用例确保边界处理正确。6.2 随机化对账测试编译期只能覆盖边界逻辑真正的并发问题还得靠运行时。我建议做一个“对着账本查账”的测试维护一个外部数组记录哪些真实地址已经被分配出去随机 acquire/release每 100 次操作后全量遍历一遍位图检查free_count()是否等于池大小减真实占用数检查每个被占用的地址对应的 bit 是否为 1检查每个空闲地址对应的 bit 是否为 0。如果free_count和真实数量对不上说明位图或池子至少有一处坏了。这个对账测试我目前没看到太多人写但它对排查“状态漂移”这类问题非常有效比事后 dump 强一个量级。6.3 后续扩展伙伴系统、per-core 分片与脏位位图既然变成基础设施下一阶段可以做的扩展主要有三个方向伙伴系统用多层位图每层代表一种尺寸的空闲块分配时从高层向下查释放时向上合并。Linux 页分配器的 buddy bitmap 核心就是这个思想。有了find_first_free_run之后这类系统只需要再加一个“层间合并”的逻辑。Per-core 分片位图多核 RTOS 里每个核维护自己的本地位图避免全局锁竞争本地不足再向全局池借块。位图体积小每个核放一两张几乎没有成本而锁竞争往往能缓解一个数量级。脏位图 / 写回缓存如果池由 DMA 引擎管理可以为每个块再用一个 bit 表示“需要刷 cache”这样一张位图变成两位状态机空闲/占用/脏/写回中。我个人的体感是做 RTOS 内存管理不要想着一步到位上红黑树或者复杂分配器。先把位图这类基础的数据结构做到“心里有数”真正踩过并发、边界、底层的坑之后再谈分配算法和并发模型你会发现那些复杂的方案大多只是纸面上的优雅解决不了这里或那里的一堆实际问题。先把基础打牢后面每一步才走得不心虚。