一句话结论:固定块内存池不省内存(本文实测它和手写 first-fit 堆的每块开销都是 8 字节),它买的是确定性——分配永远 O(1)(池恒为 1 步,堆实测从 1 步到 129 步不等,正好等于空闲链的长度)、永远不会碎片化(同样的 4096 字节和同样的操作序列,堆里还剩 1632 字节空闲却申请不出 64 字节,池的最大可分配恒为 32 字节)、以及关中断时间能算出来。代价是只能分配一种尺寸,所以正确用法是「一种对象一个池」,而不是「用一个池替掉整个 malloc」。
一、完整工程下载
压缩包内含全部源码、platformio.ini、Makefile、README.md,解压即用,不需要额外配置。
下载 mem-pool.zip (18.5 KB,共 8 个文件)
.gitignore
Makefile
README.md
include/
mpool.h
platformio.ini
src/
main.c
mpool.c
test/
test_pool.c一、先说结论:内存池不省内存
几乎每篇讲内存池的文章都会说「减少内存碎片、节省内存」。 前一半对,后一半是误导。
固定块池的每块开销 = 块头 + 对齐填充。first-fit 堆的每块开销也 = 块头 + 对齐填充。 两者在同样的分配尺寸下可以完全相等。本文实测:32 字节的有效载荷, 池的每块是 40 字节(开销 8),堆的每块也是 40 字节(开销 8)。一样。
那为什么还要用池?因为它买的是另外三样东西,这三样在裸机上比内存更贵:
| 你买到的东西 | 池 | first-fit 堆 |
|---|---|---|
| 分配耗时 | 恒定 O(1):弹出空闲链头,恒 1 步 | 实测 1 步(链头就够)到 129 步(扫完整条链) |
| 最坏耗时 | 和平均一样 | 等于整条空闲链的长度 |
| 碎片 | 结构上不存在 | 会退化:实测 1632 字节空闲里拿不出 64 字节 |
| 关中断时间 | 几条指令,可算 | 随空闲链长度变化,算不出来 |
| 分配尺寸 | 只能一种 | 任意 |
最后两行才是嵌入式必须关心的地方。中断服务程序里调 malloc 是个经典事故, 原因不只是「malloc 不可重入」,更是你没法回答「这次分配最多关中断多久」—— 在一条几百个节点的空闲链上做 first-fit,几百微秒是常态。
二、池的物理布局
一个池就是一块静态数组 + 一个空闲链表。空闲链表不占额外内存—— 它把指针存在空闲块自己的身体里:
storage[](静态数组,按块切分)
┌────────────┬────────────┬────────────┬────────────┐
│ 块 0 │ 块 1 │ 块 2 │ 块 3 │
├──────┬─────┼──────┬─────┼──────┬─────┼──────┬─────┤
│ 头 8 │ 32 │ 头 8 │ 32 │ 头 8 │ 32 │ 头 8 │ 32 │
└──────┴─────┴──────┴─────┴──────┴─────┴──────┴─────┘
↑空闲 ↑已分配 ↑空闲 ↑空闲
头里放 next 头里放魔数 next next
└──────────────┘ └────────────┘ └──────────┘
空闲链:free_list -> 块 0 -> 块 2 -> 块 3 -> NULL关键设计:块头是一个 union。空闲时它是链表的 next 指针, 已分配时它是魔数(用来查重复释放)。两者共用同一块内存,所以:
typedef union mblk {
union mblk *next; /* 空闲时:下一个空闲块 */
uint32_t magic; /* 已分配时:魔数 */
} mblk_t;sizeof(mblk_t) 在 32 位 MCU 上是 4 字节(指针),在 64 位主机上是 8 字节。 这就是全部开销——没有第二个字段,没有每块的元数据数组。
顺便纠正一个常见写法:不少人会把块头写成 { uint32_t magic; struct blk *next; } 两个字段。那样在 32 位机上是 8 字节开销,正好翻倍。 代价是「重复释放」的检测要绕一下,见下面第四节。
三、分配 / 释放:各五行代码
void *mpool_alloc(mpool_t *p)
{
mblk_t *blk = p->free_list; /* 空闲链头就是答案,不用搜索 */
if (blk == NULL) {
p->err_exhausted++; /* 池用尽:返回 NULL,绝不越界 */
return NULL;
}
p->free_list = blk->next; /* 弹出 */
blk->magic = MP_MAGIC; /* 同一块内存,改写成魔数 */
p->used++;
if (p->used > p->peak) p->peak = p->used;
p->n_alloc++;
return (void *)(blk + 1); /* 有效载荷紧跟在头后面 */
}
void mpool_free(mpool_t *p, void *ptr)
{
mblk_t *blk;
if (ptr == NULL) return; /* free(NULL) 合法,不算错 */
blk = (mblk_t *)ptr - 1;
/* ……校验(下一节)…… */
blk->next = p->free_list; /* 压回链头 */
p->free_list = blk;
p->used--;
p->n_free++;
}注意 alloc 里没有任何循环、没有任何搜索。这是池最核心的性质: 执行时间是个常数,和池里有多少块、碎片多严重、历史上怎么分配过全都无关。
对比一下本文的 first-fit 堆,分配必须遍历空闲链找第一个够大的块:
while (cur != NULL) {
probe++; /* 每看一个节点就 +1 */
if ((cur->size & ~1u) >= want) { /* 找到了 */
...split...
break;
}
cur = heap_free_next(cur); /* 没有 next 字段,得从载荷里取 */
}那个 probe++ 是本文实验里最重要的一个计数器,第二节之后会看到它跑出来多少。
四、错误检测:为什么「正常路径零开销」也能查重复释放
块头是 union,意味着释放的那一刻,魔数就被 next 指针覆盖了。 所以第二次释放同一个块时,读到的魔数不再是 MP_MAGIC,而是一个指针值。
这足以判「这个块现在不是已分配状态」,但还不够区分两种情况:
- 重复释放:块已经在空闲链里了
- 野指针:传进来一个根本没分配过的地址
区分办法很直接——只走错误路径,才去扫一遍空闲链:
static int pool_in_freelist(const mpool_t *p, const mblk_t *blk)
{
const mblk_t *c = p->free_list;
while (c != NULL) {
if (c == blk) return 1; /* 在链里 -> 确实是重复释放 */
c = c->next;
}
return 0;
}判定顺序是这样的:
| 检查 | 结论 | 代价 |
|---|---|---|
| 地址落在 storage 范围外 | 野指针 | O(1) |
| 地址没对齐到块边界 | 野指针(用在 p+1 上了?) | O(1) |
魔数 == MP_MAGIC | 正常释放 | O(1) |
| 魔数不对 + 在空闲链里 | 重复释放 | O(n),只走错误路径 |
| 魔数不对 + 不在链里 | 野指针 | O(n),只走错误路径 |
正常路径的代价是零,因为在正常路径上魔数一定是匹配的,压根不会走到扫描那一步。 这比「每块多存一个字段」的写法好得多:把检测成本从「每个块」搬到了「每次出错」—— 而错误本来就是罕见事件。
五、什么时候池是不够的
- 分配尺寸不固定:池只能给你一种尺寸。要几种尺寸就开几个池(这叫 slab 分配器,
内核里到处都在用),但池的数量会失控。
- 分配尺寸差异很大:给「最大那个」开池,小对象全浪费。
实测里池的可分配量恒等于 payload,不会因为你要 4 字节就少占—— 用池的前提是「同类对象」,不是「随便什么对象」。
- 需要 realloc / 变长缓冲:池做不了,硬要做得靠「再申请一个大块 + 拷 + 释放」。
- 对象数量真的没法估上界:那就得回到堆,或者改成链表(每个节点自己带 next,
连分配都不用,静态数组 + 索引环形队列往往是更好的答案)。
正确的定位是这样一句话:池不是 malloc 的替代品,是「已知上界的同类对象」的专用分配器。 本文的实战用法(第七节)是把一个作业队列放进池里——作业结构体尺寸固定、 数量有上界,正好命中。
六、主机实测(本文数据来源)
配置:池和堆各 4096 字节,请求尺寸 32 字节(sizeof(mblk_t) = 8,每块 40 字节, 池里共 102 块)。全部数字来自本机 gcc 实编译实运行。
| # | 实验 | 实测结果 |
|---|---|---|
| 1 | 基本分配 / 释放 / 复用 | 三次分配地址间隔正好 40 字节;释放中间那个后再分配,拿回的是同一个地址(空闲链 LIFO) |
| 2 | 池用尽 | 连续 102 块全部成功,第 103 次返回 NULL;峰值水位 102 块 = 3264 字节 |
| 3 | 重复释放 | 同一指针释放 3 次 → 重复释放计数 0 → 2,used 没被污染,报错后池仍能正常分配 |
| 4 | 野指针 / 未对齐 | 依次传栈变量地址、a+1、storage+2 → 野指针计数 0 → 3,合法的 a 一次都没误判 |
| 5 | 碎片对比 | 两边都挤出 102 块;交错释放一半后,两边空闲都是 1632 字节、最大可分配都是 32 字节;此时申请 64 字节 → 堆失败,池成功 |
| 6 | 分配耗时 | 探测步数 恒等于空闲链节点数:链长 64 → 64 步(成功 64 步,失败也是 64 步),链长 129 → 129 步;池恒 1 步 |
| 7 | 对齐 | 连续分配 100 次,非 8 字节对齐 0 个 |
| 8 | 每块开销 | 32 字节请求:池 40 字节(开销 8),堆 40 字节(开销 8)——完全相同;4 字节请求两边都是 16 字节(浪费 75%) |
| 9 | 统计接口 | 分配 40 次、释放 30 次后:used = 10,peak = 40 |
第 5 项和第 6 项是本文的全部价值所在,值得再说一遍:
第 5 项:同样是 4096 字节,同样是「全部分配 → 交错释放一半」这个序列, 池和堆的空闲字节数完全一样(都是 1632),可申请出的最大块也一样(都是 32 字节)。 但下一步申请的 64 字节,堆拿不出来,池拿得出来。差别不在剩余空间, 而在剩余空间是否连续——这就是碎片,它是「堆还剩多少内存」这个指标完全测不出来的东西。 所以现场排查内存问题时,free 剩多少是个误导性指标, largest_free 剩多少才是真相。
第 6 项:探测步数 == 空闲链节点数,一个不差的等式。 它把「堆的分配耗时不固定」从一句定性的话变成了定量的事实: 链长 64 时 64 步,链长 129 时 129 步,池永远是 1 步。 而链长是运行时长出来的,写代码时算不出上界—— 这正是「中断里不能调 malloc」的真正原因。不是因为 malloc 一定慢, 而是因为它的最坏情况你无法证明。
完整代码
Makefile
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2 -Iinclude
LDLIBS ?=
SRC = src/mpool.c
TEST = test/test_pool.c
ifeq ($(OS),Windows_NT)
EXT = .exe
endif
BIN = build/test$(EXT)
all: run
$(BIN): $(SRC) $(TEST)
@mkdir -p build
$(CC) $(CFLAGS) $(SRC) $(TEST) -o $(BIN) $(LDLIBS)
run: $(BIN)
@$(BIN)
clean:
rm -rf build
.PHONY: all run cleaninclude/mpool.h
/**
* mpool.h - 固定块内存池(零额外元数据、O(1) 分配、可查重复释放)
*
* 设计要点:
* 1) 块头是一个 union:空闲时放链表 next,已分配时放魔数。
* 两者共用同一块内存,所以每块开销就是 sizeof(mblk_t)
* (32 位机 4 字节,64 位主机 8 字节)。
* 2) 空闲链的指针存在**空闲块自己的身体里**,不占额外数组。
* 3) alloc/free 主路径无循环、无搜索,耗时恒定。
* 4) 重复释放 / 野指针的判定要扫空闲链,但**只在错误路径上扫**。
*/
#ifndef MPOOL_H
#define MPOOL_H
#include <stddef.h>
#include <stdint.h>
#define MP_MAGIC 0x5A5AA5A5u /* 已分配的标记 */
/* 对齐到 sizeof(void*) 的倍数(指针必须天然对齐) */
#define MP_ALIGN ((uint32_t)sizeof(void *))
#define MP_ALIGN_UP(n) (((n) + (MP_ALIGN - 1u)) & ~(MP_ALIGN - 1u))
/* 每个块的头:空闲时是 next,已分配时是 magic,共用一块内存 */
typedef union mblk {
union mblk *next;
uint32_t magic;
} mblk_t;
/** 一个块的总字节数(含头) */
#define MP_BLOCK_SIZE(payload) \
MP_ALIGN_UP((uint32_t)sizeof(mblk_t) + (uint32_t)(payload))
/**
* 声明一个池:静态存储 + 池控制块。
* 存储用 union 强制最大对齐——**别用 uint8_t 数组自己凑**,
* 否则在 Cortex-M 上可能拿到非对齐指针,LDR 直接硬件异常。
*/
#define MPOOL_DEFINE(NAME, NBLOCKS, PAYLOAD) \
static union { \
void *p; \
uint32_t u; \
double d; \
char raw[(size_t)(NBLOCKS) * MP_BLOCK_SIZE(PAYLOAD)]; \
} NAME##_storage; \
static mpool_t NAME##_pool
typedef struct {
uint8_t *storage; /* 池的起始地址 */
uint32_t storage_size; /* 池的总字节数 */
uint32_t block_size; /* 每块总字节数(含头) */
uint32_t payload; /* 每块可用字节数 */
uint32_t nblocks; /* 总块数 */
mblk_t *free_list; /* 空闲链(链头是下一个会被分配的块) */
uint32_t used; /* 当前已分配块数 */
uint32_t peak; /* 峰值水位(历史最大 used) */
uint32_t n_alloc;
uint32_t n_free;
uint32_t err_double_free; /* 重复释放次数 */
uint32_t err_bad_ptr; /* 野指针 / 越界 / 未对齐次数 */
uint32_t err_exhausted; /* 池用尽的次数 */
} mpool_t;
/** 初始化:把 storage 切成 nblocks 个块并串成空闲链。返回 0 成功。 */
int mpool_init(mpool_t *p, void *storage, uint32_t storage_size, uint32_t payload);
/** 分配一个块,池空返回 NULL(不越界、不阻塞)。 */
void *mpool_alloc(mpool_t *p);
/** 归还一个块。自动识别重复释放 / 野指针(走错误路径时才会扫描)。 */
void mpool_free(mpool_t *p, void *ptr);
static inline uint32_t mpool_payload(const mpool_t *p) { return p->payload; }
static inline uint32_t mpool_used(const mpool_t *p) { return p->used; }
static inline uint32_t mpool_peak(const mpool_t *p) { return p->peak; }
static inline uint32_t mpool_free_blocks(const mpool_t *p) { return p->nblocks - p->used; }
/** 池的空闲字节数(按可分配的有效载荷算) */
static inline uint32_t mpool_free_bytes(const mpool_t *p)
{
return (p->nblocks - p->used) * p->payload;
}
/** 最大可分配字节数。池里这个值**恒等于 payload**,与历史无关。 */
static inline uint32_t mpool_largest_free(const mpool_t *p)
{
return (p->used < p->nblocks) ? p->payload : 0u;
}
/** 打印池统计 */
void mpool_dump(const mpool_t *p);
/* ---------------- 下面是一个 first-fit 堆,只为对比而存在 ---------------- */
typedef struct {
uint32_t n_alloc;
uint32_t n_free;
uint32_t probe_total; /* 分配时遍历过的空闲链表节点总数 */
uint32_t probe_max; /* 单次分配的最多探测步数 */
uint32_t fail; /* 分配失败的次数 */
} heap_stat_t;
typedef struct {
uint8_t *arena;
uint32_t arena_size;
void *free_head;
uint32_t used_bytes; /* 用户请求的字节数合计 */
uint32_t peak_used;
heap_stat_t st;
} heap_t;
void heap_init(heap_t *h, void *arena, uint32_t size);
void *heap_alloc(heap_t *h, uint32_t n);
void heap_free(heap_t *h, void *ptr);
uint32_t heap_largest_free(heap_t *h);
uint32_t heap_free_bytes(heap_t *h);
uint32_t heap_free_nodes(const heap_t *h); /* 空闲链节点数 */
uint32_t heap_block_size(heap_t *h, uint32_t n); /* 请求 n 字节实际占多少 */
void heap_dump(const heap_t *h);
#endif /* MPOOL_H */platformio.ini
[platformio]
default_envs = bluepill
[env:bluepill]
platform = ststm32
board = bluepill_f103c8
framework = arduino
upload_protocol = stlink
monitor_speed = 115200
build_flags =
-Wall
-Wextra
-Isrc
lib_ldf_mode = deep+src/main.c
/**
* main.c - STM32 上的实战用法:把固定尺寸的「作业队列」放进池里
*
* 场景:主循环产生作业,中断里把作业投出去(比如往串口发一帧)。
* 作业尺寸固定、数量有上界 —— 这正是池最合适的场景。
*/
#include "mpool.h"
#include <string.h>
/* 作业:一种对象,一个池。64 字节够放一帧 Modbus / 一条日志。 */
#define JOB_PAYLOAD 64
#define JOB_MAX 16
typedef struct {
uint8_t kind;
uint16_t len;
uint8_t data[56]; /* 总大小不超过 JOB_PAYLOAD */
uint32_t seq;
} job_t;
MPOOL_DEFINE(job_pool, JOB_MAX, JOB_PAYLOAD);
/* 极简 FIFO(静态数组 + 头尾下标,容量固定,不分配内存) */
static job_t *g_queue[JOB_MAX];
static volatile uint8_t g_head;
static volatile uint8_t g_tail;
static job_t *queue_pop(void)
{
job_t *j;
if (g_head == g_tail) return NULL;
j = g_queue[g_tail];
g_tail = (uint8_t)((g_tail + 1u) % JOB_MAX);
return j;
}
static int queue_push(job_t *j)
{
uint8_t next = (uint8_t)((g_head + 1u) % JOB_MAX);
if (next == g_tail) return -1; /* 队列满:丢新作业,不覆盖旧的 */
g_queue[g_head] = j;
g_head = next;
return 0;
}
/* 主循环:申请作业 -> 填内容 -> 投进队列 */
static int post_job(uint8_t kind, const uint8_t *buf, uint16_t len)
{
job_t *j = (job_t *)mpool_alloc(&job_pool);
if (j == NULL) {
/* 池用尽。这里**不是异常**,是设计出来的正常分支:
* 数量有上界,所以「满了怎么办」是可以提前想清楚的。 */
return -1;
}
j->kind = kind;
j->len = len;
j->seq++;
if (len > sizeof(j->data)) len = sizeof(j->data);
memcpy(j->data, buf, len);
if (queue_push(j) != 0) {
mpool_free(&job_pool, j); /* 队列满 -> 立刻归还,不留泄漏 */
return -1;
}
return 0;
}
/* 消费端:发完就还 */
static void drain_jobs(void)
{
job_t *j;
while ((j = queue_pop()) != NULL) {
/* uart_send(j->data, j->len); */
(void)j->kind;
(void)j->seq;
mpool_free(&job_pool, j); /* 唯一的归还点,不会漏 */
}
}
int main(void)
{
uint8_t demo[4] = { 1u, 2u, 3u, 4u };
mpool_init(&job_pool, job_pool_storage.raw, sizeof(job_pool_storage.raw),
JOB_PAYLOAD);
post_job(1u, demo, sizeof(demo));
drain_jobs();
for (;;) {
drain_jobs();
}
}src/mpool.c
/**
* mpool.c - 固定块内存池 + 用于对比的 first-fit 堆
*/
#include <stdio.h>
#include <string.h>
#include "mpool.h"
/* ==================== 固定块池 ==================== */
int mpool_init(mpool_t *p, void *storage, uint32_t storage_size, uint32_t payload)
{
uint32_t bs;
uint32_t n;
uint32_t i;
uint8_t *base = (uint8_t *)storage;
if (p == NULL || storage == NULL) return -1;
bs = MP_BLOCK_SIZE(payload);
if (bs == 0u) return -1;
n = storage_size / bs;
if (n == 0u) return -1;
memset(p, 0, sizeof(*p));
p->storage = base;
p->storage_size = storage_size;
p->block_size = bs;
p->payload = bs - (uint32_t)sizeof(mblk_t);
p->nblocks = n;
/* 从后往前串链,这样链头就是第 0 块,分配顺序看起来自然一些 */
p->free_list = NULL;
for (i = n; i > 0u; i--) {
mblk_t *blk = (mblk_t *)(void *)(base + (i - 1u) * bs);
blk->next = p->free_list;
p->free_list = blk;
}
return 0;
}
/* 判断某个块是否已经在空闲链里。只在错误路径上调用。 */
static int pool_in_freelist(const mpool_t *p, const mblk_t *blk)
{
const mblk_t *c = p->free_list;
while (c != NULL) {
if (c == blk) return 1;
c = c->next;
}
return 0;
}
void *mpool_alloc(mpool_t *p)
{
mblk_t *blk = p->free_list;
if (blk == NULL) {
p->err_exhausted++;
return NULL;
}
p->free_list = blk->next;
blk->magic = MP_MAGIC;
p->used++;
if (p->used > p->peak) p->peak = p->used;
p->n_alloc++;
return (void *)(blk + 1);
}
void mpool_free(mpool_t *p, void *ptr)
{
mblk_t *blk;
uint8_t *raw;
uintptr_t off;
if (ptr == NULL) return; /* free(NULL) 是合法的 no-op */
raw = (uint8_t *)ptr;
blk = (mblk_t *)ptr - 1;
/* 1) 必须在池的地址范围内 */
if (raw < (uint8_t *)(void *)(p->storage) ||
raw >= p->storage + p->storage_size) {
p->err_bad_ptr++;
return;
}
/* 2) 必须正好落在某个块的载荷起点上(查「用在 p+1 上」这类错误) */
off = (uintptr_t)(void *)blk - (uintptr_t)(void *)(p->storage);
if ((off % p->block_size) != 0u ||
off + p->block_size > p->storage_size) {
p->err_bad_ptr++;
return;
}
/* 3) 魔数。正常路径在这里就返回了,不会扫描空闲链 */
if (blk->magic != MP_MAGIC) {
if (pool_in_freelist(p, blk)) {
p->err_double_free++; /* 已经归还过了 */
} else {
p->err_bad_ptr++; /* 没分配过的地址 */
}
return;
}
/* 正常释放:同一块内存改写成 next,压回链头 */
blk->next = p->free_list;
p->free_list = blk;
p->used--;
p->n_free++;
}
void mpool_dump(const mpool_t *p)
{
printf(" 块头 %u 字节 + 载荷 %u 字节 = 每块 %u 字节\n",
(unsigned)sizeof(mblk_t), (unsigned)p->payload, (unsigned)p->block_size);
printf(" 池 %u 字节 -> %u 块;当前用 %u 块,峰值 %u 块,剩余 %u 块\n",
(unsigned)p->storage_size, (unsigned)p->nblocks,
(unsigned)p->used, (unsigned)p->peak, (unsigned)p->nblocks - p->used);
printf(" 累计 alloc %u 次 / free %u 次;峰值水位 = %u 字节\n",
(unsigned)p->n_alloc, (unsigned)p->n_free,
(unsigned)(p->peak * p->payload));
printf(" 错误计数:重复释放 %u,野指针 %u,池用尽 %u\n",
(unsigned)p->err_double_free, (unsigned)p->err_bad_ptr,
(unsigned)p->err_exhausted);
}
/* ==================== first-fit 堆(对比用) ==================== */
/*
* 块布局:
* ┌──────────────┬───────────────────┬──────────────┐
* │ prev_size(4) │ size(4) bit0=FREE│ 载荷 ... │
* └──────────────┴───────────────────┴──────────────┘
* size 是**整块**字节数(含这 8 字节头)。空闲块的载荷前 2 个指针位置
* 用来放双向空闲链,所以最小块 = 8 + 2*sizeof(void*)。
*/
#define HDR_SIZE ((uint32_t)(2u * sizeof(uint32_t))) /* 8 */
#define FREE_BIT 1u
#define MIN_BLOCK MP_ALIGN_UP(HDR_SIZE + (uint32_t)(2u * sizeof(void *)))
typedef struct {
uint32_t prev_size;
uint32_t size; /* bit0 = 1 表示空闲 */
} hdr_t;
static hdr_t *blk_next(hdr_t *b) { return (hdr_t *)(void *)((uint8_t *)b + (b->size & ~FREE_BIT)); }
static hdr_t *blk_prev(hdr_t *b) { return (hdr_t *)(void *)((uint8_t *)b - b->prev_size); }
static int blk_is_free(const hdr_t *b) { return (b->size & FREE_BIT) != 0u; }
/* 空闲链指针放在空闲块自己的载荷里 */
static void **fl_slot(hdr_t *b) { return (void **)(void *)((uint8_t *)b + HDR_SIZE); }
static hdr_t *fl_next(hdr_t *b) { return (hdr_t *)fl_slot(b)[0]; }
static hdr_t *fl_prev(hdr_t *b) { return (hdr_t *)fl_slot(b)[1]; }
static void fl_set(hdr_t *b, hdr_t *prev, hdr_t *next)
{
fl_slot(b)[0] = next;
fl_slot(b)[1] = prev;
}
uint32_t heap_block_size(heap_t *h, uint32_t n)
{
(void)h;
return MP_ALIGN_UP(HDR_SIZE + n);
}
void heap_init(heap_t *h, void *arena, uint32_t size)
{
hdr_t *b = (hdr_t *)arena;
memset(h, 0, sizeof(*h));
h->arena = (uint8_t *)arena;
h->arena_size = size;
b->prev_size = 0u;
b->size = (size & ~FREE_BIT) | FREE_BIT;
h->free_head = b;
fl_set(b, NULL, NULL);
}
static void fl_insert(heap_t *h, hdr_t *b)
{
hdr_t *head = (hdr_t *)h->free_head;
b->size |= FREE_BIT;
fl_set(b, NULL, head);
if (head != NULL) fl_slot(head)[1] = b;
h->free_head = b;
}
static void fl_remove(heap_t *h, hdr_t *b)
{
hdr_t *prev = fl_prev(b);
hdr_t *next = fl_next(b);
if (prev != NULL) fl_slot(prev)[0] = next;
else h->free_head = next;
if (next != NULL) fl_slot(next)[1] = prev;
}
static int in_arena(heap_t *h, void *p)
{
uint8_t *q = (uint8_t *)p;
return q >= h->arena && q < h->arena + h->arena_size;
}
void *heap_alloc(heap_t *h, uint32_t n)
{
uint32_t want = MP_ALIGN_UP(HDR_SIZE + n);
hdr_t *cur = (hdr_t *)h->free_head;
uint32_t probe = 0u;
if (want < MIN_BLOCK) want = MIN_BLOCK;
while (cur != NULL) {
probe++;
if ((cur->size & ~FREE_BIT) >= want) break;
cur = fl_next(cur); /* 从载荷里取 next */
}
if (probe > h->st.probe_max) h->st.probe_max = probe;
h->st.probe_total += probe;
if (cur == NULL) {
h->st.fail++;
return NULL;
}
/* 够大的话拆一块出来(剩下的部分至少得能放一个最小块) */
if ((cur->size & ~FREE_BIT) >= want + MIN_BLOCK) {
hdr_t *rest = (hdr_t *)(void *)((uint8_t *)cur + want);
hdr_t *after;
rest->prev_size = want;
rest->size = ((cur->size & ~FREE_BIT) - want) | FREE_BIT;
cur->size = want; /* 现在这一块是已分配(bit0 = 0) */
after = blk_next(rest);
if (in_arena(h, after)) after->prev_size = rest->size & ~FREE_BIT;
/* rest 顶掉了 cur 在空闲链里的位置 */
{
hdr_t *p = fl_prev(cur);
hdr_t *q = fl_next(cur);
fl_set(rest, p, q);
if (p != NULL) fl_slot(p)[0] = rest;
else h->free_head = rest;
if (q != NULL) fl_slot(q)[1] = rest;
}
} else {
fl_remove(h, cur);
cur->size &= ~FREE_BIT; /* 整块给出去 */
{
hdr_t *after = blk_next(cur);
if (in_arena(h, after)) after->prev_size = cur->size;
}
}
h->used_bytes += n;
if (h->used_bytes > h->peak_used) h->peak_used = h->used_bytes;
h->st.n_alloc++;
return (void *)(void *)((uint8_t *)cur + HDR_SIZE);
}
void heap_free(heap_t *h, void *ptr)
{
hdr_t *b;
if (ptr == NULL) return;
b = (hdr_t *)(void *)((uint8_t *)ptr - HDR_SIZE);
h->used_bytes -= (b->size & ~FREE_BIT) - HDR_SIZE;
/* 先和物理上的后一块合并 */
{
hdr_t *after = blk_next(b);
if (in_arena(h, after) && blk_is_free(after)) {
hdr_t *after2;
fl_remove(h, after);
b->size = (b->size & ~FREE_BIT) + (after->size & ~FREE_BIT);
after2 = blk_next(b);
if (in_arena(h, after2)) after2->prev_size = b->size & ~FREE_BIT;
}
}
/* 再和物理上的前一块合并 */
if (b->prev_size != 0u) {
hdr_t *prev = blk_prev(b);
if (in_arena(h, prev) && blk_is_free(prev)) {
fl_remove(h, prev);
prev->size = (prev->size & ~FREE_BIT) + (b->size & ~FREE_BIT);
b = prev;
{
hdr_t *after = blk_next(b);
if (in_arena(h, after)) after->prev_size = b->size & ~FREE_BIT;
}
}
}
fl_insert(h, b);
h->st.n_free++;
}
uint32_t heap_largest_free(heap_t *h)
{
hdr_t *cur = (hdr_t *)h->free_head;
uint32_t best = 0u;
while (cur != NULL) {
uint32_t s = cur->size & ~FREE_BIT;
if (s > best) best = s;
cur = fl_next(cur);
}
/* 减去块头,得到「能拿到的载荷」 */
return (best >= HDR_SIZE) ? (best - HDR_SIZE) : 0u;
}
uint32_t heap_free_bytes(heap_t *h)
{
hdr_t *cur = (hdr_t *)h->free_head;
uint32_t sum = 0u;
/* 只统计**真正能给用户用**的字节:块总大小减掉块头。
* 如果按块总大小统计,数字会比池大一圈,那是虚的。 */
while (cur != NULL) {
sum += (cur->size & ~FREE_BIT) - HDR_SIZE;
cur = fl_next(cur);
}
return sum;
}
uint32_t heap_free_nodes(const heap_t *h)
{
hdr_t *cur = (hdr_t *)h->free_head;
uint32_t n = 0u;
while (cur != NULL) { n++; cur = fl_next(cur); }
return n;
}
void heap_dump(const heap_t *h)
{
printf(" 堆 %u 字节;累计 alloc %u / free %u;分配失败 %u 次\n",
(unsigned)h->arena_size, (unsigned)h->st.n_alloc,
(unsigned)h->st.n_free, (unsigned)h->st.fail);
printf(" 空闲链节点 %u 个;分配探测步数合计 %u,单次最多 %u 步\n",
(unsigned)heap_free_nodes(h),
(unsigned)h->st.probe_total, (unsigned)h->st.probe_max);
printf(" 峰值使用 %u 字节;当前空闲 %u 字节,其中最大连续可分配 %u 字节\n",
(unsigned)h->peak_used,
(unsigned)heap_free_bytes((heap_t *)(void *)h),
(unsigned)heap_largest_free((heap_t *)(void *)h));
}test/test_pool.c
/**
* 主机端测试:固定块内存池 vs first-fit 堆
*
* gcc -std=c99 -Wall -Wextra -Iinclude src/mpool.c test/test_pool.c -o build/test
*/
#include <stdio.h>
#include <string.h>
#include "mpool.h"
static int failed = 0;
static void check(int cond, const char *what)
{
if (!cond) {
printf(" [FAIL] %s\n", what);
failed++;
}
}
/* ---------------- 池的存储 ---------------- */
#define POOL_STORAGE 4096u
#define POOL_PAYLOAD 32u
/* 用静态数组,通过 mpool_init 手工初始化(不依赖宏,方便测试多个池) */
static union {
void *p;
uint32_t u;
double d;
char raw[POOL_STORAGE];
} g_storage;
static mpool_t g_pool;
#define HEAP_SIZE 4096u
static union {
void *p;
uint32_t u;
double d;
char raw[HEAP_SIZE];
} g_arena;
static heap_t g_heap;
/* 记录分配到的指针,便于回放释放序列 */
static void *g_slots[512];
int main(void)
{
uint32_t bs;
uint32_t i, n;
printf("===== 固定块内存池实测 =====\n");
printf("sizeof(mblk_t) = %u 字节(32 位 MCU 上就是 4 字节,这就是每块的全部开销)\n",
(unsigned)sizeof(mblk_t));
bs = MP_BLOCK_SIZE(POOL_PAYLOAD);
printf("请求载荷 %u 字节 -> 每块 %u 字节 = 头 %u + 载荷 %u\n",
(unsigned)POOL_PAYLOAD, (unsigned)bs,
(unsigned)sizeof(mblk_t), (unsigned)(bs - sizeof(mblk_t)));
printf("池存储 %u 字节 -> %u 块\n\n",
(unsigned)POOL_STORAGE, (unsigned)(POOL_STORAGE / bs));
/* ---------- [1] 基本分配 / 释放 / 复用 ---------- */
printf("[1] 基本分配、释放、复用\n");
{
void *a, *b, *c;
uint32_t used0;
mpool_init(&g_pool, g_storage.raw, sizeof(g_storage.raw), POOL_PAYLOAD);
check(g_pool.nblocks == POOL_STORAGE / bs, "块数不对");
check(mpool_payload(&g_pool) == POOL_PAYLOAD, "载荷算错了");
a = mpool_alloc(&g_pool);
b = mpool_alloc(&g_pool);
c = mpool_alloc(&g_pool);
printf(" 分配 3 个:%p %p %p\n", a, b, c);
printf(" 相邻间隔 = %ld 字节(应等于块大小)\n",
(long)((uint8_t *)b - (uint8_t *)a));
check((uint8_t *)b - (uint8_t *)a == (long)bs, "块不是紧密排列的");
used0 = mpool_used(&g_pool);
mpool_free(&g_pool, b);
printf(" 分配 3 个后 used = %u;释放中间那个(b)后 used = %u\n",
(unsigned)used0, (unsigned)mpool_used(&g_pool));
check(mpool_used(&g_pool) == used0 - 1u, "释放没有减少 used");
{
void *d = mpool_alloc(&g_pool);
/* 空闲链是 LIFO:刚释放的 b 会被最先复用 */
printf(" 再分配一个 -> %p(刚释放的 b 是 %p)\n", d, b);
check(d == b, "空闲链不是 LIFO,没有复用刚释放的块");
mpool_free(&g_pool, d);
}
mpool_free(&g_pool, a);
mpool_free(&g_pool, c);
printf(" 全部归还后 used = %u,空闲 %u 块\n",
(unsigned)mpool_used(&g_pool),
(unsigned)mpool_free_blocks(&g_pool));
check(mpool_used(&g_pool) == 0u, "没有全部归还");
}
/* ---------- [2] 池用尽 ---------- */
printf("\n[2] 池用尽:返回 NULL,绝不越界\n");
{
void *extra;
n = g_pool.nblocks;
for (i = 0; i < n; i++) {
g_slots[i] = mpool_alloc(&g_pool);
check(g_slots[i] != NULL, "还没用尽就拿到 NULL");
}
printf(" 连续分配 %u 块全部成功\n", (unsigned)n);
check(g_pool.peak == n, "峰值水位不对");
extra = mpool_alloc(&g_pool);
printf(" 第 %u 次 -> %s\n", (unsigned)(n + 1u),
extra == NULL ? "NULL(正确)" : "居然拿到了内存(错)");
check(extra == NULL, "用尽后没有返回 NULL");
printf(" 池用尽计数 = %u,峰值水位 = %u 块 = %u 字节\n",
(unsigned)g_pool.err_exhausted, (unsigned)g_pool.peak,
(unsigned)(g_pool.peak * g_pool.payload));
printf(" -> 上界是**编译期**定死的,所以「池满了怎么办」可以提前设计,\n");
printf(" 而不是像 malloc 那样等它返回 NULL 再慌\n");
/* 归还全部 */
for (i = 0; i < n; i++) mpool_free(&g_pool, g_slots[i]);
check(mpool_used(&g_pool) == 0u, "归还后 used 不为 0");
}
/* ---------- [3] 重复释放 ---------- */
printf("\n[3] 重复释放检测(正常路径零开销,错了才扫空闲链)\n");
{
void *a = mpool_alloc(&g_pool);
void *again;
uint32_t before = g_pool.err_double_free;
check(a != NULL, "分配失败,后面测不了");
mpool_free(&g_pool, a);
mpool_free(&g_pool, a); /* 第二次 */
mpool_free(&g_pool, a); /* 第三次 */
printf(" 同一个指针释放 3 次:重复释放计数 %u -> %u\n",
(unsigned)before, (unsigned)g_pool.err_double_free);
check(g_pool.err_double_free == before + 2u, "重复释放没被查出来");
check(mpool_used(&g_pool) == 0u, "重复释放污染了 used");
/* 关键:报错之后池必须还能继续用,而且没被弄坏 */
again = mpool_alloc(&g_pool);
printf(" 报错后池仍可用:还能分配 %p,used = %u\n",
again, (unsigned)mpool_used(&g_pool));
check(again != NULL, "报错后池坏了");
mpool_free(&g_pool, again);
check(mpool_used(&g_pool) == 0u, "收尾没清干净");
}
/* ---------- [4] 野指针 ---------- */
printf("\n[4] 野指针 / 未对齐 / 越界检测\n");
{
int stack_var = 0;
uint32_t before = g_pool.err_bad_ptr;
void *a = mpool_alloc(&g_pool);
mpool_free(&g_pool, &stack_var); /* 池外地址 */
mpool_free(&g_pool, (uint8_t *)a + 1); /* 块内未对齐 */
mpool_free(&g_pool, (uint8_t *)g_storage.raw + 2);/* 落在头部里 */
printf(" 依次传入:栈变量地址、a+1、storage+2\n");
printf(" 野指针计数 %u -> %u(期望 +3)\n",
(unsigned)before, (unsigned)g_pool.err_bad_ptr);
check(g_pool.err_bad_ptr == before + 3u, "野指针没被全部查出来");
/* 真正的 a 还是好好的 */
mpool_free(&g_pool, a);
check(g_pool.err_bad_ptr == before + 3u, "合法的 a 被误判成野指针了");
printf(" 合法的 a 仍能正常释放,误判 0 次\n");
}
/* ---------- [5] 碎片:池的「最大可分配」是常量 ---------- */
printf("\n[5] 碎片实验(关键对比):同样 4096 字节,同样操作序列\n");
{
uint32_t pool_blocks = g_pool.nblocks;
uint32_t heap_blocks;
uint32_t k;
mpool_init(&g_pool, g_storage.raw, sizeof(g_storage.raw), POOL_PAYLOAD);
heap_init(&g_heap, g_arena.raw, sizeof(g_arena.raw));
/* 阶段 1:两边都申请 32 字节,直到失败 */
for (i = 0; i < 512u; i++) {
g_slots[i] = mpool_alloc(&g_pool);
if (g_slots[i] == NULL) break;
}
k = i;
for (i = 0; i < 512u; i++) {
void *p = heap_alloc(&g_heap, POOL_PAYLOAD);
if (p == NULL) break;
g_slots[256u + i] = p;
}
heap_blocks = i;
printf(" 阶段 1:池挤出 %u 块,堆挤出 %u 块\n",
(unsigned)k, (unsigned)heap_blocks);
check(k == pool_blocks, "池没挤满");
/* 阶段 2:每两个释放一个(留下「洞」) */
for (i = 0; i < k; i += 2u) mpool_free(&g_pool, g_slots[i]);
for (i = 0; i < heap_blocks; i += 2u) heap_free(&g_heap, g_slots[256u + i]);
printf(" 阶段 2:各释放一半(交错释放,制造最大碎片)\n");
printf(" 池 :空闲 %u 字节,最大可分配 %u 字节\n",
(unsigned)mpool_free_bytes(&g_pool),
(unsigned)mpool_largest_free(&g_pool));
printf(" 堆 :空闲 %u 字节,最大可分配 %u 字节\n",
(unsigned)heap_free_bytes(&g_heap),
(unsigned)heap_largest_free(&g_heap));
/* 阶段 3:申请一个比「洞」大的块 */
{
void *hp = heap_alloc(&g_heap, 64u);
void *pp = mpool_alloc(&g_pool);
printf(" 阶段 3:申请 64 字节\n");
printf(" 堆 -> %s(空闲链里还有 %u 字节)\n",
hp ? "成功" : "失败", (unsigned)heap_free_bytes(&g_heap));
printf(" 池 -> %s\n", pp ? "成功" : "失败");
check(hp == NULL, "堆竟然申请成功了,碎片实验无效");
check(pp != NULL, "池应该能继续分配");
printf(" -> 堆里明明有 %u 字节空闲,却拿不出 64 字节连续空间\n",
(unsigned)heap_free_bytes(&g_heap));
printf(" -> 池的最大可分配恒等于载荷 %u 字节,与历史完全无关\n",
(unsigned)pool_blocks * 0u + POOL_PAYLOAD);
if (pp) mpool_free(&g_pool, pp);
}
/* 阶段 4:把堆里剩下的也全释放,看能不能恢复 */
for (i = 1; i < heap_blocks; i += 2u) heap_free(&g_heap, g_slots[256u + i]);
printf(" 阶段 4:堆里剩下的全部释放 -> 空闲 %u 字节,最大可分配 %u 字节\n",
(unsigned)heap_free_bytes(&g_heap), (unsigned)heap_largest_free(&g_heap));
printf(" -> 全部释放后合并回来,堆的碎片是「可恢复」的;\n");
printf(" 但真实系统里,长期运行中总有活着的对象夹在中间,永远回不到最大块\n");
}
/* ---------- [6] 分配耗时的确定性 ---------- */
printf("\n[6] 分配耗时:池是常数,堆随空闲链长度线性增长\n");
{
heap_t h2, h3;
static union { void *p; uint32_t u; double d; char raw[8192]; } a2;
static union { void *p; uint32_t u; double d; char raw[16384]; } a3;
uint32_t t0, p1, p2, p3, p4;
uint32_t nodes_b, nodes_c, nodes_d;
void *r;
/* --- 场景 A:64 个「没用的小洞」+ 尾部一个大块 --- */
heap_init(&h2, a2.raw, sizeof(a2.raw));
for (i = 0; i < 128u; i++) g_slots[i] = heap_alloc(&h2, 16u);
for (i = 0; i < 128u; i += 2u) heap_free(&h2, g_slots[i]);
printf(" 场景 A:%u 个 24 字节的空洞 + 尾部 1 个大块\n",
(unsigned)(128u / 2u));
printf(" 空闲 %u 字节(和池的统计口径一致:只算可用载荷)\n",
(unsigned)heap_free_bytes(&h2));
/* (a) 申请 16 字节:链头那个洞就够 -> 1 步 */
printf(" 分配前空闲链节点数 = %u\n", (unsigned)heap_free_nodes(&h2));
t0 = h2.st.probe_total;
r = heap_alloc(&h2, 16u);
p1 = h2.st.probe_total - t0;
printf(" (a) 申请 16 字节(链头的洞就够)-> %s,探测 %u 步\n",
r ? "成功" : "失败", (unsigned)p1);
/* (b) 申请 40 字节:剩下的洞全都太小,必须扫到底才找到尾部大块 */
nodes_b = heap_free_nodes(&h2);
t0 = h2.st.probe_total;
r = heap_alloc(&h2, 40u);
p2 = h2.st.probe_total - t0;
printf(" 分配前空闲链节点数 = %u\n", (unsigned)nodes_b);
printf(" (b) 申请 40 字节(每个洞都太小,必须扫到底)-> %s,探测 %u 步\n",
r ? "成功" : "失败", (unsigned)p2);
/* (c) 申请一个连尾部大块都不够的尺寸 -> 走完全链后失败 */
nodes_c = heap_free_nodes(&h2);
t0 = h2.st.probe_total;
r = heap_alloc(&h2, 40000u);
p3 = h2.st.probe_total - t0;
printf(" 分配前空闲链节点数 = %u\n", (unsigned)nodes_c);
printf(" (c) 申请 40000 字节(比整个堆还大)-> %s,探测 %u 步\n",
r ? "成功" : "失败", (unsigned)p3);
/* --- 场景 B:同样手法,但把空洞数量翻一倍 --- */
heap_init(&h3, a3.raw, sizeof(a3.raw));
for (i = 0; i < 256u; i++) g_slots[i] = heap_alloc(&h3, 16u);
for (i = 0; i < 256u; i += 2u) heap_free(&h3, g_slots[i]);
nodes_d = heap_free_nodes(&h3);
t0 = h3.st.probe_total;
r = heap_alloc(&h3, 40u);
p4 = h3.st.probe_total - t0;
printf(" 场景 B:同样的操作,但空洞数翻倍(%u 个)\n",
(unsigned)(256u / 2u));
printf(" 分配前空闲链节点数 = %u\n", (unsigned)nodes_d);
printf(" (d) 申请 40 字节 -> %s,探测 %u 步\n",
r ? "成功" : "失败", (unsigned)p4);
printf(" 对照:池的 alloc 里**一个循环都没有**,探测步数恒为 1\n");
printf(" -> 结论很干净:**探测步数 == 空闲链节点数**\n");
printf(" 成功时是 %u 步(链长 %u),失败时也是 %u 步(链长 %u)\n",
(unsigned)p2, (unsigned)nodes_b, (unsigned)p3, (unsigned)nodes_c);
printf(" 链长从 %u 涨到 %u,探测步数就从 %u 涨到 %u:**线性**\n",
(unsigned)nodes_b, (unsigned)nodes_d, (unsigned)p2, (unsigned)p4);
printf(" 池是 1 步,和链长完全无关\n");
printf(" -> 这就是「关中断时间可算」和「算不出来」的区别:\n");
printf(" 池最多关中断几条指令;堆的最坏情况等于整条空闲链的长度,\n");
printf(" 而链有多长,取决于设备跑了多久、经历过什么分配序列——\n");
printf(" **你在写代码时根本算不出来**\n");
check(p1 == 1u, "(a) 链头就够,应该只探测 1 步");
check(p2 == nodes_b, "(b) 失败/成功都要扫完整条链");
check(p3 == nodes_c, "(c) 探测步数应等于链长");
check(p4 == nodes_d, "(d) 探测步数应等于链长");
check(nodes_d > 2u * nodes_b, "空洞翻倍后链长应该明显更长");
}
/* ---------- [7] 对齐 ---------- */
printf("\n[7] 对齐检查(嵌入式上非对齐访问会直接硬件异常)\n");
{
uint32_t bad = 0u;
uint32_t align = (uint32_t)MP_ALIGN;
uint32_t got = 0u;
mpool_init(&g_pool, g_storage.raw, sizeof(g_storage.raw), POOL_PAYLOAD);
for (i = 0; i < 100u; i++) {
void *p = mpool_alloc(&g_pool);
if (p == NULL) break;
if (((uintptr_t)p % align) != 0u) bad++;
got++;
}
printf(" 连续分配 %u 次,地址不是 %u 字节对齐的有 %u 个\n",
(unsigned)got, (unsigned)align, (unsigned)bad);
check(bad == 0u, "存在未对齐的载荷地址");
check(got == 100u, "没有分配到 100 次");
printf(" 载荷偏移 = %u 字节(等于块头大小),块大小 %u 是对齐的倍数\n",
(unsigned)sizeof(mblk_t), (unsigned)bs);
check((bs % align) == 0u, "块大小不是对齐倍数");
printf(" -> 池的存储用 MPOOL_DEFINE 宏时被 union 强制到最大对齐,\n");
printf(" 不要自己声明 uint8_t 数组,否则 Cortex-M 上取指针会 HardFault\n");
}
/* ---------- [8] 开销对比 ---------- */
printf("\n[8] 每块开销对比(同样的 32 字节请求)\n");
{
uint32_t pblk = MP_BLOCK_SIZE(32u);
uint32_t hblk = MP_ALIGN_UP((uint32_t)(2u * sizeof(uint32_t)) + 32u);
printf(" 池:头部 %u + 载荷 32 = %u 字节,开销 %u 字节\n",
(unsigned)sizeof(mblk_t), (unsigned)pblk,
(unsigned)(pblk - 32u));
printf(" 堆:头部 %u + 载荷 32(已对齐)= %u 字节,开销 %u 字节\n",
(unsigned)(2u * sizeof(uint32_t)), (unsigned)hblk,
(unsigned)(hblk - 32u));
printf(" -> 两者相同。**池不省内存**,这是本文想纠正的最大误解\n");
/* 小对象才是分水岭:请求 4 字节时 */
printf(" 请求 4 字节时:\n");
printf(" 池(载荷 4):每块 %u 字节,开销 %u 字节,浪费率 %.0f%%\n",
(unsigned)MP_BLOCK_SIZE(4u),
(unsigned)(MP_BLOCK_SIZE(4u) - 4u),
100.0 * (double)(MP_BLOCK_SIZE(4u) - 4u) / (double)MP_BLOCK_SIZE(4u));
printf(" 堆(请求 4):每块 %u 字节,开销 %u 字节,浪费率 %.0f%%\n",
(unsigned)MP_ALIGN_UP((uint32_t)(2u * sizeof(uint32_t)) + 4u),
(unsigned)(MP_ALIGN_UP((uint32_t)(2u * sizeof(uint32_t)) + 4u) - 4u),
100.0 * (double)(MP_ALIGN_UP((uint32_t)(2u * sizeof(uint32_t)) + 4u) - 4u) /
(double)MP_ALIGN_UP((uint32_t)(2u * sizeof(uint32_t)) + 4u));
printf(" -> 小对象两边都很难看,但池还有个额外好处:\n");
printf(" 载荷是**编译期常数**,浪费多少印在头文件里,一眼能看到\n");
}
/* ---------- [9] 统计输出 ---------- */
printf("\n[9] 统计接口(现场排查靠它)\n");
{
mpool_init(&g_pool, g_storage.raw, sizeof(g_storage.raw), POOL_PAYLOAD);
for (i = 0; i < 40u; i++) g_slots[i] = mpool_alloc(&g_pool);
for (i = 0; i < 30u; i++) mpool_free(&g_pool, g_slots[i]);
mpool_dump(&g_pool);
check(mpool_used(&g_pool) == 10u, "used 统计不对");
check(mpool_peak(&g_pool) == 40u, "peak 统计不对");
printf(" -> peak 是「该开多大的池」的唯一可靠依据:\n");
printf(" 跑一整轮最坏场景,看 peak,再乘 1.5 倍留余量\n");
for (i = 30u; i < 40u; i++) mpool_free(&g_pool, g_slots[i]);
}
printf("\n");
if (failed == 0) {
printf("===== 全部通过:以上数据由本机 gcc 实编译实运行 =====\n");
} else {
printf("===== %d 项断言失败 =====\n", failed);
}
return failed == 0 ? 0 : 1;
}实测输出
下面这段输出是把上面的核心算法用 本机 gcc 真编译、真运行得到的(不含任何硬件依赖):
===== 固定块内存池实测 =====
sizeof(mblk_t) = 8 字节(32 位 MCU 上就是 4 字节,这就是每块的全部开销)
请求载荷 32 字节 -> 每块 40 字节 = 头 8 + 载荷 32
池存储 4096 字节 -> 102 块
[1] 基本分配、释放、复用
分配 3 个:00007FF78BF0D0A8 00007FF78BF0D0D0 00007FF78BF0D0F8
相邻间隔 = 40 字节(应等于块大小)
分配 3 个后 used = 3;释放中间那个(b)后 used = 2
再分配一个 -> 00007FF78BF0D0D0(刚释放的 b 是 00007FF78BF0D0D0)
全部归还后 used = 0,空闲 102 块
[2] 池用尽:返回 NULL,绝不越界
连续分配 102 块全部成功
第 103 次 -> NULL(正确)
池用尽计数 = 1,峰值水位 = 102 块 = 3264 字节
-> 上界是**编译期**定死的,所以「池满了怎么办」可以提前设计,
而不是像 malloc 那样等它返回 NULL 再慌
[3] 重复释放检测(正常路径零开销,错了才扫空闲链)
同一个指针释放 3 次:重复释放计数 0 -> 2
报错后池仍可用:还能分配 00007FF78BF0E070,used = 1
[4] 野指针 / 未对齐 / 越界检测
依次传入:栈变量地址、a+1、storage+2
野指针计数 0 -> 3(期望 +3)
合法的 a 仍能正常释放,误判 0 次
[5] 碎片实验(关键对比):同样 4096 字节,同样操作序列
阶段 1:池挤出 102 块,堆挤出 102 块
阶段 2:各释放一半(交错释放,制造最大碎片)
池 :空闲 1632 字节,最大可分配 32 字节
堆 :空闲 1632 字节,最大可分配 32 字节
阶段 3:申请 64 字节
堆 -> 失败(空闲链里还有 1632 字节)
池 -> 成功
-> 堆里明明有 1632 字节空闲,却拿不出 64 字节连续空间
-> 池的最大可分配恒等于载荷 32 字节,与历史完全无关
阶段 4:堆里剩下的全部释放 -> 空闲 4088 字节,最大可分配 4088 字节
-> 全部释放后合并回来,堆的碎片是「可恢复」的;
但真实系统里,长期运行中总有活着的对象夹在中间,永远回不到最大块
[6] 分配耗时:池是常数,堆随空闲链长度线性增长
场景 A:64 个 24 字节的空洞 + 尾部 1 个大块
空闲 6136 字节(和池的统计口径一致:只算可用载荷)
分配前空闲链节点数 = 65
(a) 申请 16 字节(链头的洞就够)-> 成功,探测 1 步
分配前空闲链节点数 = 64
(b) 申请 40 字节(每个洞都太小,必须扫到底)-> 成功,探测 64 步
分配前空闲链节点数 = 64
(c) 申请 40000 字节(比整个堆还大)-> 失败,探测 64 步
场景 B:同样的操作,但空洞数翻倍(128 个)
分配前空闲链节点数 = 129
(d) 申请 40 字节 -> 成功,探测 129 步
对照:池的 alloc 里**一个循环都没有**,探测步数恒为 1
-> 结论很干净:**探测步数 == 空闲链节点数**
成功时是 64 步(链长 64),失败时也是 64 步(链长 64)
链长从 64 涨到 129,探测步数就从 64 涨到 129:**线性**
池是 1 步,和链长完全无关
-> 这就是「关中断时间可算」和「算不出来」的区别:
池最多关中断几条指令;堆的最坏情况等于整条空闲链的长度,
而链有多长,取决于设备跑了多久、经历过什么分配序列——
**你在写代码时根本算不出来**
[7] 对齐检查(嵌入式上非对齐访问会直接硬件异常)
连续分配 100 次,地址不是 8 字节对齐的有 0 个
载荷偏移 = 8 字节(等于块头大小),块大小 40 是对齐的倍数
-> 池的存储用 MPOOL_DEFINE 宏时被 union 强制到最大对齐,
不要自己声明 uint8_t 数组,否则 Cortex-M 上取指针会 HardFault
[8] 每块开销对比(同样的 32 字节请求)
池:头部 8 + 载荷 32 = 40 字节,开销 8 字节
堆:头部 8 + 载荷 32(已对齐)= 40 字节,开销 8 字节
-> 两者相同。**池不省内存**,这是本文想纠正的最大误解
请求 4 字节时:
池(载荷 4):每块 16 字节,开销 12 字节,浪费率 75%
堆(请求 4):每块 16 字节,开销 12 字节,浪费率 75%
-> 小对象两边都很难看,但池还有个额外好处:
载荷是**编译期常数**,浪费多少印在头文件里,一眼能看到
[9] 统计接口(现场排查靠它)
块头 8 字节 + 载荷 32 字节 = 每块 40 字节
池 4096 字节 -> 102 块;当前用 10 块,峰值 40 块,剩余 92 块
累计 alloc 40 次 / free 30 次;峰值水位 = 1280 字节
错误计数:重复释放 0,野指针 0,池用尽 0
-> peak 是「该开多大的池」的唯一可靠依据:
跑一整轮最坏场景,看 peak,再乘 1.5 倍留余量
===== 全部通过:以上数据由本机 gcc 实编译实运行 =====