← 返回教程库

栈与内存:栈溢出排查、堆碎片与内存监控

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 22 分钟 🟡 涉接线/强电
你将学到
  • 用 uxTaskGetStackHighWaterMark 读出每个任务栈的"剩余最小值",据此把栈调到够用又不浪费
  • 用 esp_get_free_heap_size 看空闲堆、heap_caps_get_largest_free_block 看最大连续块,分清内存泄漏和堆碎片
  • 写一段会当场触发栈溢出的代码,亲眼看 `A stack overflow in task` / Guru Meditation 长什么样
  • 搭一个定期打印高水位 + 空闲堆的监控任务,跑老化测试判断固件有没有在慢慢漏内存

上一篇FreeRTOS 任务里你已经撞过两个内存坑了:xTaskCreate 第三个参数那个栈,单位是"字"不是字节,给小了任务就崩在 ***ERROR*** A stack overflow in task。当时我留了一句"用 uxTaskGetStackHighWaterMark 看实际用了多少"——这篇就把那句话兑现,连带把它隔壁那块更大的雷区一起拆了:

[!warning] 这篇属于 R2(涉及内存安全与稳定性):栈溢出、内存泄漏、堆碎片这三种问题有个共同的恶心之处——它们经常不当场崩,而是悄悄踩坏隔壁内存,让你在几小时甚至几天后看到一个莫名其妙、复现不了的怪 bug。所以本篇给的不是"出了事怎么救",而是"怎么提前把它量出来、在它咬人之前就发现"。文中的 API 行为、单位换算请以你所用 ESP-IDF 版本的官方文档为准,文末附了官方链接。

读这篇前你需要跑通过上一篇的双任务程序,知道任务有各自独立的栈、idf.py build flash monitor 这条链路是通的、能看懂串口里的 ESP_LOGI。本篇不接任何线,全程靠串口看数。


先理清:ESP32-S3 上的内存分两摊

调代码前先把地图画清楚,不然后面的数你看不懂它在说哪块。一个任务能用的内存,分两摊,它俩出问题的症状完全不一样:

  • 栈(Stack):每个任务各有一块独立的栈,建任务时 xTaskCreate 第三个参数定死大小。它放的是局部变量、函数调用的返回地址、传参。特点是编译期就划死、运行时不能变大——函数调用一层套一层(栈往下长),或者你在函数里声明个大数组,吃的都是这块。吃超了就是栈溢出,啪一下崩。
  • 堆(Heap):全系统共享一大块malloc / calloc / new 从这里要内存,free 还回去。它是动态的、运行时按需分配。堆出的问题有两种——内存泄漏(要了不还,空闲堆一路往下掉直到耗尽)和堆碎片(还是还了,但还得七零八落,总量够、却凑不出一块够大的连续块)。

记住这个对应关系,后面所有排查都围着它转:栈的事看高水位,堆的事看空闲量 + 最大连续块。


第一步:把每个任务的栈"量"出来

上一篇说"栈拿不准就先给大点跑通,再削"。怎么削得有依据?靠这一个函数:

UBaseType_t uxTaskGetStackHighWaterMark(TaskHandle_t xTask);

它返回的是这个任务栈的**「高水位线」——从任务跑起来到现在,栈剩下的最小空闲量**,单位还是字(word)(ESP32-S3 上 1 字 = 4 字节)。名字叫"高水位"有点反直觉:它记的是栈被用得最狠那一刻、剩下了多少。这个数越小,说明栈被啃得越凶、离溢出越近;逼近 0 就是高危。传 NULL 表示查当前任务自己。

下面这段在两个任务里各跑各的活,然后由一个监控任务定期把它俩的栈余量打出来。放进 main/main.c

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include <string.h>

static const char *TAG = "mem";

// 句柄:监控任务要靠它去查别的任务的栈
static TaskHandle_t worker_handle = NULL;

// 干活任务:里面声明了个 256 字节的局部数组,故意多吃点栈
void worker_task(void *arg)
{
    for (;;) {
        char buf[256];                          // 局部数组吃栈,每次进循环都占着
        memset(buf, 0, sizeof(buf));
        snprintf(buf, sizeof(buf), "worker 干活中 tick=%lu",
                 (unsigned long)xTaskGetTickCount());
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

// 监控任务:定期打印各任务栈高水位 + 全系统空闲堆
void monitor_task(void *arg)
{
    for (;;) {
        // 查 worker 任务的栈剩余最小值(单位:字)
        UBaseType_t worker_left = uxTaskGetStackHighWaterMark(worker_handle);
        // 查监控任务自己的栈剩余最小值
        UBaseType_t self_left   = uxTaskGetStackHighWaterMark(NULL);

        size_t free_heap   = esp_get_free_heap_size();                      // 当前空闲堆(字节)
        size_t largest_blk = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT); // 最大连续空闲块(字节)

        ESP_LOGI(TAG, "栈余量(字) worker=%u self=%u | 空闲堆=%u 最大连续块=%u",
                 (unsigned)worker_left, (unsigned)self_left,
                 (unsigned)free_heap, (unsigned)largest_blk);

        vTaskDelay(pdMS_TO_TICKS(3000));        // 每 3 秒体检一次
    }
}

void app_main(void)
{
    xTaskCreate(worker_task,  "worker",  2048, NULL, 5, &worker_handle);
    xTaskCreate(monitor_task, "monitor", 3072, NULL, 4, NULL);
}

编译烧录看日志:

idf.py build flash monitor

你应该看到什么

串口每 3 秒滚一行体检数据,大致长这样(具体数字随你的代码和版本会差一些,看趋势别抠绝对值):

I (3020) mem: 栈余量(字) worker=380 self=620 | 空闲堆=298540 最大连续块=270336
I (6020) mem: 栈余量(字) worker=380 self=620 | 空闲堆=298540 最大连续块=270336
I (9020) mem: 栈余量(字) worker=380 self=620 | 空闲堆=298540 最大连续块=270336

盯住三件事,每件都有讲究:

  • worker 的栈余量稳定在 380 字左右。你给它 2048 字,它实际峰值用掉了约 2048 - 380 = 1668 字。这说明 2048 给得偏宽——你可以削到 1024(峰值 1668 字会爆,得保守)……不对,1668 字已经超过 1024 字,所以最低也得 2048这就是高水位的用法:拿"分配值 - 余量"算出真实峰值,再在峰值上压一个安全余量(一般留 25%~50%)当新的栈大小。 余量留多少看任务里有没有调深层库函数——网络、TLS、JSON 这些库吃栈很猛且不好预估,余量要给厚。
  • worker 的余量基本不再下降,说明它的栈用量已经到顶、进入稳态了。要是你看到这个数还在缓慢往下掉,警惕:要么递归没收住、要么某条少走的分支里有个大数组还没触发——再跑久点、把各种路径都走一遍再读这个数才准。
  • 空闲堆和最大连续块此刻几乎相等(约 30 万字节都对得上)。这是健康态——堆基本没碎,要多大块给得出多大块。记住这两个数此刻的关系,下面讲堆碎片时全靠对比它俩。
💡 提示

高水位是"历史最小值",不是"此刻余量"。所以正确用法是让固件跑足够久、把所有功能路径都走一遍(联网、报错重连、最长的那条日志……)之后再读它,才覆盖得到真实峰值。开机就读、只跑了一条 happy path 就读,会让你把栈削得过激,上线后撞到某条深路径才爆,那种现场最难查。


第二步:亲手把栈撑爆,认得它的样子

光知道"会溢出"不够,你得亲眼见一次、把那行报错刻进脑子,以后线上一闪而过你才认得出。把上面 worker_task 换成下面这个故意吃爆栈的版本(其它不动):

// 反面教材:用递归 + 大局部数组,几层就把 2048 字的栈啃穿
void worker_task(void *arg)
{
    eat_stack(0);                  // 一进来就开始往深里递归
    vTaskDelay(portMAX_DELAY);     // 正常情况走不到这
}

// 每层吃掉一个 1KB 的局部数组,且不停往下递归,栈直线下坠
void eat_stack(int depth)
{
    char hog[1024];                            // 每层 1KB,2048 字栈 = 8KB,撑不了几层
    memset(hog, depth & 0xFF, sizeof(hog));
    ESP_LOGI(TAG, "递归到第 %d 层,还在吃栈...", depth);
    eat_stack(depth + 1);                      // 永不返回的递归,栈只进不出
}

烧进去,监视串口。

你应该看到什么

日志先打几行"递归到第 N 层",N 涨到个位数就戛然而止,紧接着崩,大概率是这两副面孔之一:

I (320) mem: 递归到第 0 层,还在吃栈...
I (322) mem: 递归到第 1 层,还在吃栈...
I (324) mem: 递归到第 2 层,还在吃栈...

***ERROR*** A stack overflow in task worker has been detected.

Backtrace: 0x40081abc:0x3ffb... ...

或者(取决于它踩坏了什么)一副 Guru Meditation 的脸:

Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled.
...
Backtrace: ...
Rebooting...

这就是栈溢出的两副典型长相,记死它A stack overflow in task <任务名> 是 FreeRTOS 栈溢出检测直接逮到的,最干脆、报错里还点了是哪个任务;Guru Meditation 是踩坏内存后引发的二次崩溃,更隐晦,但 Backtrace 末尾常能看到崩在哪个任务。报错里那个任务名,就是你该去调大栈、或去查它递归/大数组的那个任务。

[!warning] 栈溢出检测能逮住,靠的是 ESP-IDF 默认开了 CONFIG_FREERTOS_CHECK_STACKOVERFLOW(默认是"Method 2",往栈末尾埋一段哨兵值、切任务时检查有没有被踩)。这有个致命的局限:它是切任务时才查,要是你在一个时间片内就把栈干穿、踩坏了关键数据,可能还没轮到检查就先以 Guru Meditation 的形式崩了,报错就不那么"友好"。所以别把"没报 stack overflow"当成"栈一定没问题"——平时用高水位主动盯,比指望它兜底靠谱得多。

演示完把 worker_task 改回第一步那个正常版本,继续往下。


第三步:盯住堆——分清泄漏和碎片

栈是每个任务的私房钱,堆是全系统的公共池子。盯堆就两个数,上面的监控任务已经在打了:

size_t free_heap   = esp_get_free_heap_size();                          // 空闲堆总量
size_t largest_blk = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT); // 最大连续空闲块
  • esp_get_free_heap_size():此刻全系统还剩多少空闲堆(字节)。malloc 要内存就从这扣,free 还回来就加上。它是判断内存泄漏的主指标。
  • heap_caps_get_largest_free_block(MALLOC_CAP_8BIT):当前能一次性分配出的最大连续块MALLOC_CAP_8BIT 指"能按字节寻址的普通内存"(绝大多数 malloc 走这类)。它是判断堆碎片的关键。

这俩数怎么读出门道,看下面这张对照——内存泄漏和堆碎片,症状不一样,根因也不一样

你观察到的现象 这是什么病 根因 / 怎么继续查
空闲堆单调往下掉,永不回升,跑几小时后逼近 0 → malloc 失败 / 系统重启 内存泄漏 某处 malloc / xTaskCreate 后没配对 free / vTaskDelete。开 heap_caps_get_info 或 heap-tracing 看是谁在漏;先盯"每次某操作后空闲堆是否净减"
空闲堆总量还很够,但最大连续块远小于空闲堆,分配一个大 buffer 却失败 堆碎片 频繁 malloc/free 大小不一的块,把堆切得七零八落。改成预分配 + 复用、用内存池、或大块走 PSRAM
空闲堆稳定不动,最大连续块 ≈ 空闲堆 健康 没事,这就是你想看到的样子
一上电空闲堆就很低、建任务直接 xTaskCreate 失败 静态占用过高 任务栈给太大 × 任务太多,把堆吃光了。回第一步用高水位削栈

判内存泄漏的标准动作是老化测试:让固件连续跑几个小时甚至过夜,每隔几秒记一次空闲堆。健康的固件,这个数会在一个区间里上下波动(分配、释放、再分配)但长期持平;漏了的固件,这条线会肉眼可见地往下斜——把日志拉出来看趋势,斜率就是泄漏速度,一眼定性。单看一两个瞬时值看不出泄漏,泄漏是趋势病,必须拉长时间轴。

💡 提示

堆碎片最隐蔽:你 esp_get_free_heap_size() 看着还有 100KB 觉得宽裕,结果 malloc(40KB) 直接返回 NULL——因为这 100KB 是被切成一堆小块的,凑不出 40KB 的整段。所以判断"还能不能分配一个大 buffer",要看 heap_caps_get_largest_free_block,不是看空闲总量。 嵌入式里对付碎片的根本办法是少在运行期反复 malloc/free 不同大小的块,能开机一次性预分配的就别动态来回要。

ESP32-S3 的 PSRAM 一句话

ESP32-S3 多数模组带外部 PSRAM(常见 2MB/8MB),是片内 SRAM 之外的一大块"扩展堆"——内置 RAM 那 512KB 不够用时,大 buffer(图像帧、音频、网络缓冲)可以放 PSRAM。开了 PSRAM 后,普通 malloc 在内置 RAM 紧张时可能自动落到 PSRAM;想强制从 PSRAM 要,用 heap_caps_malloc(size, MALLOC_CAP_SPIRAM)。代价是 PSRAM 走 SPI 总线、比片内 SRAM 慢,时间敏感的小数据别往那放。本篇先点到,做摄像头/音频那类吃大内存的活时会专门用到。


第四步:把监控做成固件的常驻体检

前面的 monitor_task 别只在调试时用——让它常驻在你每个正经固件里,就是一套免费的健康看护。两条实践经验:

  1. 关键任务建完就读一次高水位、跑一段时间再读一次,把"分配值、初始余量、稳态余量"记进你的工程笔记。这样下次有人随手把某个栈从 4096 改成 2048,你一对比体检日志就知道危不危险,不用等它上线崩。
  2. 空闲堆设一条红线告警。在监控任务里加一句:低于某阈值就 ESP_LOGW 喊一嗓子。配合老化测试,泄漏会在它真把堆耗光、引发玄学崩溃之前,先被这条告警逮住。
// 在 monitor_task 循环里补一段:堆掉破红线就告警
if (free_heap < 50 * 1024) {           // 阈值按你的工程定,这里举例 50KB
    ESP_LOGW(TAG, "空闲堆告警! 只剩 %u 字节,疑似泄漏或占用过高", (unsigned)free_heap);
}
// 栈余量逼近 0 也告警
if (worker_left < 128) {               // 剩不到 128 字,离溢出很近了
    ESP_LOGW(TAG, "worker 栈余量危险! 只剩 %u 字", (unsigned)worker_left);
}

这就是产品级固件和"能跑就行"的 demo 的差距:前者一直在自己量自己的栈和堆,问题在"咬人"之前就被数据照出来了。


故障排查:内存出问题,按这张表对号

现象 / 日志 最可能的原因 怎么办
***ERROR*** A stack overflow in task <名> 然后重启 那个任务栈给小了(溢出) 调大 xTaskCreate 第三个参数(单位字);用 uxTaskGetStackHighWaterMark 量出真实峰值,按峰值 + 25%~50% 余量重新定
Guru Meditation Error ... LoadProhibited/StoreProhibited + Backtrace 然后 Rebooting 栈溢出踩坏内存 / 野指针 / 用了已 free 的指针 Backtrace 末尾的任务名定位是谁;先查它栈够不够,再查有没有 use-after-free
空闲堆(esp_get_free_heap_size)跑几小时一路往下掉 内存泄漏malloc/任务/句柄要了不还 老化测试拉长时间轴看趋势;用 heap-tracing 抓是谁漏的;检查每个 malloc/xTaskCreate 有没有配对释放
空闲堆总量够,但 malloc(大块) 返回 NULL 堆碎片:连续块凑不出来 heap_caps_get_largest_free_block 确认;改预分配 + 复用 / 内存池;大块走 PSRAM
一上电堆就很低、xTaskCreate 返回失败建不出任务 任务栈给太大 × 任务太多,静态占用挤爆堆 用高水位把每个任务的栈削到够用 + 余量;合并能合的任务
高水位读出来非常大(比如给 8192 字、余 7000 多) 给得太宽,白吃内存 按"分配 - 余量"算峰值,往下削,把省出的堆还给系统
栈溢出检测没报、却时不时玄学崩 / 数据莫名其妙被改 在一个时间片内就把栈干穿,没轮到检查 别只靠检测兜底,主动用高水位盯;缩小函数里的大局部数组、消除深递归

[!warning] 这三类内存问题(栈溢出、泄漏、碎片)最坑的共性是延迟发作——崩的那一刻往往不是出 bug 的那一刻。所以排查思路要反过来:不是"等崩了看 Backtrace",而是平时就用高水位 + 空闲堆把内存"可视化",让趋势暴露在你眼前。这也是为什么本篇花大力气讲监控,而不是只讲怎么读崩溃日志。


变体:两个更进一步的查内存手段

变体一:heap_caps_get_info 一次看清堆的全貌

esp_get_free_heap_size 只给一个总数,想看得更细——总空闲、最大连续块、历史最低空闲(low watermark)一次拿全,用 heap_caps_get_info

multi_heap_info_t info;
heap_caps_get_info(&info, MALLOC_CAP_8BIT);
ESP_LOGI(TAG, "空闲=%u 最大连续块=%u 历史最低空闲=%u 分配块数=%u",
         (unsigned)info.total_free_bytes,
         (unsigned)info.largest_free_block,
         (unsigned)info.minimum_free_bytes,   // 历史曾掉到的最低点,堆版的"高水位"
         (unsigned)info.allocated_blocks);

那个 minimum_free_bytes 特别有用——它是堆这辈子掉到过的最低水位,相当于堆版的"高水位线"。哪怕你没全程盯着,它也替你记下了最危险那一刻还剩多少,判断"留的余量够不够"一锤定音。

变体二:用 heap tracing 抓"是谁在漏"

确认了在漏,但不知道哪行代码漏的——ESP-IDF 的 heap tracing 能录下一段时间内"分配了没释放"的块,连同它们的申请调用栈一起打出来,直接点名凶手。开 menuconfig 里的 CONFIG_HEAP_TRACING,用 heap_trace_start(HEAP_TRACE_LEAKS) / heap_trace_stop() 框住怀疑的代码段,再 heap_trace_dump() 看报告。这是定位泄漏的终极武器,细节随版本变化较大,用时对着官方 Heap Memory Debugging 文档照做。

本篇代码与数值为参考实现,需结合你所用的最新 ESP-IDF 文档与实物自校:uxTaskGetStackHighWaterMark 的单位(字)、栈溢出检测的方法与默认开关、heap_caps_* 的能力位(CAP)含义、PSRAM 的启用与 menuconfig 选项,都可能随版本或你的工程配置微调,以官方文档为准。


动手挑战

别只看,动手量一次:

  1. 用第一步的程序,把 worker_task 的栈从 2048 慢慢往下调(204815361024),每次重烧后看高水位余量和有没有崩。记下它在哪个值开始报 stack overflow——这个"临界点 - 余量"就是你对这个任务栈的实测理解,比抄别人的数字踏实一百倍。
  2. 写一个故意漏内存的任务:每秒 malloc(1024) 一次、故意不 free,让监控任务同时打空闲堆。把日志跑 5 分钟,导出来画一条"空闲堆 vs 时间"的线,亲眼看它直线下坠——这就是内存泄漏的标准长相。然后加上 free,看那条线变回水平。

卡住了?把你的代码、idf.py monitor 的完整日志(尤其崩溃时的 Backtrace 和任务名、还有连续几条体检行的数值趋势)一起发给 AI,说清"我看到栈余量/空闲堆是多少、怎么变化的、期望是什么",它帮你定性泄漏还是碎片会准得多。


小结 · 你现在掌握了什么

  • 你能用 uxTaskGetStackHighWaterMark 读出任务栈的剩余最小值(单位字,越接近 0 越危险),并用"分配值 - 余量"算出真实峰值,把栈调到够用又不浪费。
  • 你亲手用递归 + 大局部数组把栈撑爆,认得了 A stack overflow in taskGuru Meditation 这两副栈溢出的脸,知道报错里的任务名就是该查的那个。
  • 你会用 esp_get_free_heap_size(空闲总量)和 heap_caps_get_largest_free_block(最大连续块)盯堆,能分清内存泄漏(总量单调下降)和堆碎片(总量够但最大块不够),也知道 ESP32-S3 的 PSRAM 是大 buffer 的去处。
  • 你知道判泄漏要靠老化测试看趋势、判碎片要看最大连续块,并且会把监控任务做成固件常驻的体检 + 红线告警,让问题在"咬人"之前先被数据照出来。

栈和堆现在你都能量、能盯了,但当问题真的发生、你面对一屏 Backtrace 一脸懵时,怎么把它快速读懂?下一步学用 AI 调试 FreeRTOS 与崩溃——教你把崩溃日志、Backtrace、体检数值喂给 AI,让它帮你把"哪个任务、栈还是堆、什么根因"一步步逼出来,把本篇量到的数变成能定位问题的线索。

想看 L3 这一级还有哪些课、整条进阶路线长什么样,回L3 关卡总览完整路线图

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明