PAYATHON 2026

STM32 开发中 malloc 使用与 heap_size 配置排查

排障赵师傅

结论

在 MDK-ARM 调试器中,可以直接为 malloc 设置函数断点。程序停在入口后,通过 Call Stack、LR 寄存器和反汇编窗口可以找到调用者。.map 文件主要记录符号与目标文件之间的引用关系,一般不能直接定位到某一行 C 源码。

启动文件中的 Heap_Size 通常为 C/C++ 运行库预留堆空间。FreeRTOS、网络协议栈和自定义内存池不会自动使用这段空间,除非它们最终调用了运行库分配器,或明确将这段区域作为内存来源。修改 Heap_Size 还会改变 RAM 布局,所以增大后程序不再崩溃,并不足以证明原来的问题就是堆空间不足。越界写、栈溢出和内存重叠也可能因布局变化而暂时不再暴露。

#define malloc mymalloc 只会替换当前编译单元内位于宏定义之后的 malloc。它无法修改已经编译好的库,也不会覆盖 calloc、realloc、C++ new 等其他分配入口。

在 MDK 中定位 malloc 调用

为 malloc 设置函数断点

进入 Debug 模式,打开 Breakpoints 窗口,添加以函数名为目标的断点:

malloc

只要调试器能够解析该符号,程序执行到 malloc 入口时就会停止。此时可以检查:

  • Call Stack 窗口中的上一层调用者
  • LR 寄存器保存的返回地址
  • Disassembly 窗口中 BL malloc 或间接调用指令附近的代码
  • Registers 窗口中的第一个参数。在 ARM AAPCS 下,申请长度通常通过 R0 传递

运行库没有调试信息时,调试器可能只显示汇编地址,但仍然可以定位调用者。在 malloc 入口停下后查看 LR,再跳转到该地址附近,通常可以找到发起调用的代码。

建议同时为以下入口设置断点:

malloc
calloc
realloc
free

C++ 工程还应检查:

operator new
operator new[]
operator delete
operator delete[]

不同版本的 Arm Compiler、运行库和 MicroLib 可能还有额外的内部符号。例如,malloc 入口可能继续调用某个 __rt_* 内部函数。这些名称属于工具链的实现细节,应以当前 ELF 文件和 .map 文件中实际存在的符号为准。

断点没有命中时检查什么

函数断点没有命中,程序仍有可能使用动态分配。可以继续检查以下情况:

  1. 目标文件调用了 calloc、realloc 或 operator new,没有调用 malloc。
  2. 网络库通过函数指针或自己的分配接口调用分配器。
  3. 分配发生在初始化阶段,而断点设置得太晚。
  4. 工程启用了较高等级的优化、链接时优化或函数内联,导致调用栈信息不完整。
  5. 实际链接了 MicroLib 或另一套 C 运行库,底层符号与预期不同。
  6. 某个预编译库在内部调用 malloc,源代码中的宏对它不起作用。

排查时可以暂时保留调试信息并降低优化等级,最终发布配置不必随之改变。

使用链接器的交叉引用信息

.map 文件中的普通符号表通常只能显示哪些目标文件引用了 malloc。可以在 MDK 的 Linker 配置中启用 Map、Cross Reference 和 Call Graph 等链接报告。选项名称可能随 MDK 和 Arm Compiler 版本有所不同。

交叉引用中通常会出现类似信息:

malloc
    Referenced from network.o
    Defined in ...

它可以把范围缩小到某个 .o 文件,但未必能直接定位源码行。要继续追踪,可以:

  • 在反汇编窗口中搜索调用 malloc 的 BL 指令
  • 生成包含源码与汇编对应关系的编译列表
  • 使用工具链附带的反汇编工具分析最终 ELF/AXF 文件
  • 为对应模块启用完整调试信息

Call Graph 适合分析直接函数调用。通过函数指针进行的间接调用,静态分析不一定能够准确还原。

宏替换为什么不能完全接管 malloc

下面的宏:

#define malloc mymalloc

只是预处理阶段的文本替换,仅对包含该宏的源文件生效。例如:

#define malloc mymalloc

void *p = malloc(128);

预处理后相当于:

void *p = mymalloc(128);

它不会影响:

  • 已经编译成 .lib 或 .o 的代码
  • 宏定义之前的代码
  • 没有包含该头文件的源文件
  • 通过函数指针保存的原始 malloc
  • calloc、realloc 和 C++ new
  • 运行库内部对私有分配入口的调用

也不建议在公共头文件中直接重定义标准函数名。标准头文件的包含顺序一旦改变,可能引发难以发现的声明冲突。

更稳妥的做法是使用网络库提供的内存分配钩子。例如:

void *net_alloc(size_t size)
{
    return mymalloc(size);
}

void net_free(void *ptr)
{
    myfree(ptr);
}

如果需要接管整个程序的 C 运行库分配接口,可以提供签名完全一致的实现:

#include <stddef.h>

void *malloc(size_t size)
{
    return mymalloc(size);
}

void free(void *ptr)
{
    myfree(ptr);
}

实施前必须确认当前运行库允许用户覆盖这些符号,同时还要处理 calloc 和 realloc。不同分配器不能混用,例如,mymalloc 返回的地址不能交给另一套 free:

void *calloc(size_t count, size_t size)
{
    if (count != 0U && size > SIZE_MAX / count) {
        return NULL;
    }

    size_t total = count * size;
    void *ptr = mymalloc(total);

    if (ptr != NULL) {
        memset(ptr, 0, total);
    }

    return ptr;
}

自定义分配器还要满足以下要求:

  • 返回地址符合目标 ABI 的对齐要求
  • malloc(0) 等边界行为与调用方的预期兼容
  • 分配失败时返回 NULL
  • 能够安全处理多线程和中断环境下的并发访问
  • free、realloc 与分配器使用相同的内存元数据

启动文件中的 Heap_Size 有什么作用

MDK 的 STM32 启动文件中常见以下写法:

Heap_Size       EQU     0x00000400

                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

其中,0x400 表示预留 1024 字节 RAM。链接器会为 HEAP 区域分配地址,并通过 __heap_base、__heap_limit 等符号把范围交给运行库。具体使用哪些符号、如何初始化,取决于启动文件、是否启用 MicroLib,以及所用的 Arm Compiler 版本。

这段空间通常供以下功能使用:

  • malloc、calloc、realloc
  • C++ 动态对象分配
  • 某些运行库内部的动态存储需求

以下组件通常不会自动使用它:

  • FreeRTOS 的 heap_1.c 至 heap_5.c
  • 网络协议栈自己的内存池
  • 用户定义的静态数组内存池
  • DMA 缓冲区
  • 自定义 mymalloc 管理的独立区域

例如,自定义分配器使用自己的数组:

static unsigned char my_heap[4096];

这 4096 字节与启动文件中的 Heap_Size 是两块独立区域。程序中可能因此同时存在自定义堆和 C 运行库堆。

增大 Heap_Size 后程序为什么恢复运行

一种情况是运行库确实使用了堆。原有堆空间太小,分配失败后调用方又没有检查 NULL:

void *p = malloc(512);

if (p == NULL) {
    /* 记录错误或进入安全处理 */
    return;
}

另一种情况是 RAM 布局发生变化,暂时掩盖了其他错误。增大堆区会移动后续段,或改变堆栈之间的距离,从而改变以下问题的表现:

  • 数组或缓冲区越界
  • 栈溢出
  • 堆元数据被覆盖
  • DMA 写入错误地址
  • 自定义链接布局发生重叠
  • 未初始化指针或野指针写内存

因此,不能仅凭增大 Heap_Size 后程序不再崩溃,就决定永久增加堆空间。还需要检查 Fault 寄存器、栈使用峰值、分配失败记录和链接后的 RAM 分布。

哪些函数可能间接使用堆

以下接口可以确定涉及动态分配:

malloc
calloc
realloc

下面这些接口也经常涉及动态内存。它们是否使用启动文件中预留的运行库堆,取决于工具链和库的具体实现:

  • C++ 的 new、new[]、STL 容器和 std::string
  • strdup 等返回新分配字符串的扩展函数
  • fopen、freopen、setvbuf 以及标准 I/O 的缓冲管理
  • C++ iostream
  • locale、宽字符转换或复杂格式化处理
  • TLS、JSON、压缩、文件系统和网络库内部的上下文对象

不能脱离具体运行库,直接断定 printf、sprintf、sscanf 等函数是否会调用堆。有些实现只使用栈和静态数据,有些配置或格式分支则可能申请临时空间。判断时仍应以函数断点、链接交叉引用和最终镜像的反汇编结果为准。

第三方库还可能保存一组分配函数指针:

typedef void *(*alloc_fn)(size_t);
typedef void (*free_fn)(void *);

struct allocator {
    alloc_fn alloc;
    free_fn release;
};

静态 Call Graph 未必会把这种调用显示为对 malloc 的直接调用。需要查看库的初始化代码,确认函数指针最终绑定到 mymalloc 还是系统 malloc。

建议的排查顺序

  1. 为 malloc、calloc、realloc、free 和 C++ new 设置断点。
  2. 从程序复位开始运行,以免漏掉启动阶段的分配。
  3. 断点命中后,记录 Call Stack、LR、申请长度和返回地址。
  4. 启用链接器 Cross Reference,确认引用来自哪个 .o 或 .lib。
  5. 检查网络库注册分配器的过程,尤其是函数指针和默认回退逻辑。
  6. 为自定义分配器增加失败计数、当前占用量和峰值统计。
  7. 检查每次分配后是否判断了 NULL。
  8. 发生 HardFault 时,查看 CFSR、HFSR、BFAR、MMFAR 和故障现场。
  9. 检查栈水位、数组边界,以及自定义堆是否与链接区域重叠。
  10. 确认系统堆和自定义堆各自的大小、起止地址及释放接口,避免交叉使用两套分配器。

如果确认没有任何代码使用 C 运行库堆,可以将 Heap_Size 调小,甚至设为零。这样配置的前提是启动代码和当前运行库支持,并且链接、启动及完整功能测试均能通过。

备注:内容仅供参考。