Administrator
发布于 2026-09-25 / 0 阅读
0
0

固定块内存池:为什么嵌入式该用它替掉 malloc(附真实碎片实测)

一句话结论:固定块内存池不省内存(本文实测它和手写 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 clean

include/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 实编译实运行 =====

评论