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 文件中实际存在的符号为准。
断点没有命中时检查什么
函数断点没有命中,程序仍有可能使用动态分配。可以继续检查以下情况:
- 目标文件调用了
calloc、realloc或operator new,没有调用malloc。 - 网络库通过函数指针或自己的分配接口调用分配器。
- 分配发生在初始化阶段,而断点设置得太晚。
- 工程启用了较高等级的优化、链接时优化或函数内联,导致调用栈信息不完整。
- 实际链接了 MicroLib 或另一套 C 运行库,底层符号与预期不同。
- 某个预编译库在内部调用
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。
建议的排查顺序
- 为
malloc、calloc、realloc、free和 C++new设置断点。 - 从程序复位开始运行,以免漏掉启动阶段的分配。
- 断点命中后,记录 Call Stack、
LR、申请长度和返回地址。 - 启用链接器 Cross Reference,确认引用来自哪个
.o或.lib。 - 检查网络库注册分配器的过程,尤其是函数指针和默认回退逻辑。
- 为自定义分配器增加失败计数、当前占用量和峰值统计。
- 检查每次分配后是否判断了
NULL。 - 发生 HardFault 时,查看
CFSR、HFSR、BFAR、MMFAR和故障现场。 - 检查栈水位、数组边界,以及自定义堆是否与链接区域重叠。
- 确认系统堆和自定义堆各自的大小、起止地址及释放接口,避免交叉使用两套分配器。
如果确认没有任何代码使用 C 运行库堆,可以将 Heap_Size 调小,甚至设为零。这样配置的前提是启动代码和当前运行库支持,并且链接、启动及完整功能测试均能通过。
备注:内容仅供参考。
