嵌入式内存详解:栈、堆、全局区和静态区分别存什么?

在嵌入式项目中,很多“偶发死机”“程序跑飞”“HardFault”“任务莫名复位”,最后都可能与内存有关。
常见原因包括任务栈过小、数组越界、野指针、动态内存泄漏,以及错误使用 malloc()。

本文从 MCU 的内存布局开始,详细讲解栈、堆、全局变量、静态变量、FreeRTOS 任务栈,以及任务栈剩余空间的检测方法。


一、嵌入式系统中的内存从哪里来

以常见 STM32、GD32、NXP、ESP32 等 MCU 为例,芯片内部通常至少包含:

Flash:掉电后数据不丢失
RAM:掉电后数据丢失

Flash 主要存放:

  • 程序机器码;
  • 只读常量;
  • 中断向量表;
  • 初始化数据的原始值;
  • 固件版本信息;
  • Bootloader 和 APP;
  • 用户参数区。

RAM 主要存放:

  • 全局变量;
  • 静态变量;
  • 局部变量;
  • 函数调用现场;
  • 堆;
  • 栈;
  • FreeRTOS 任务栈;
  • 动态创建的队列、信号量和任务控制块。

一个简化的内存结构如下:

Flash
┌────────────────────────────┐
│ 中断向量表                 │
├────────────────────────────┤
│ .text:程序代码            │
├────────────────────────────┤
│ .rodata:只读常量          │
├────────────────────────────┤
│ .data变量的初始化值        │
└────────────────────────────┘


RAM
┌────────────────────────────┐ 低地址
│ .data:已初始化全局/静态变量│
├────────────────────────────┤
│ .bss:未初始化全局/静态变量 │
├────────────────────────────┤
│ Heap:堆,通常向高地址增长 │
│            ↓               │
│                            │
│            ↑               │
│ Stack:栈,通常向低地址增长 │
└────────────────────────────┘ 高地址

不同芯片和链接脚本的布局可能不同,但基本思想相同。


二、.text、.rodata、.data、.bss分别是什么

1. .text代码段

.text 中主要保存编译后的程序指令,例如:

int Add(int a, int b)
{
    return a + b;
}

编译器会把这段 C 代码转换成机器指令,通常存放在 Flash 中。

.text 一般只读,程序运行期间不会被普通代码修改。


2. .rodata只读数据段

只读常量通常存放在 .rodata:

const char device_name[] = "MCH Controller";
const uint32_t table[] = {1, 2, 3, 4};

字符串常量也通常位于 .rodata:

printf("System start\r\n");

其中 "System start\r\n" 通常保存在 Flash 的只读区域。

需要注意:

const uint32_t value = 100;

是否一定放在 Flash 中,还与编译器优化、作用域和芯片架构有关,不能只看到 const 就绝对判断其物理位置。


3. .data已初始化数据段

已经赋初值且初值不为零的全局变量和静态变量,通常放在 .data:

int g_device_id = 1;
static uint32_t retry_count = 3;

程序烧录后,变量的初始值先保存在 Flash 中。

设备启动时,启动代码会把初始值从 Flash 复制到 RAM:

Flash中的初始数据
        ↓
系统启动代码复制
        ↓
RAM中的.data区域

程序运行期间修改的是 RAM 中的数据。

例如:

g_device_id = 5;

不会修改 Flash 中的初始值。

设备重新上电后,它又会恢复为:

g_device_id = 1;

4. .bss未初始化数据段

未显式初始化,或者初始化为零的全局变量和静态变量,通常放在 .bss:

int g_error_count;
static uint8_t receive_buffer[256];
uint32_t system_state = 0;

程序启动时,启动代码会把 .bss 区域清零:

系统复位
   ↓
启动代码清零.bss
   ↓
进入main()

因此这些变量进入 main() 时通常为零。

.bss 不需要在固件中保存大量零值,只需要记录区域长度,所以可以减小固件文件体积。

例如:

static uint8_t buffer[4096];

这个数组占用 4096 字节 RAM,但不会让 .bin 文件直接增加 4096 个零字节。


三、局部变量和全局变量有什么区别

1. 局部变量

局部变量定义在函数内部:

void Sensor_Process(void)
{
    uint32_t temperature;
    uint8_t retry_count;
}

普通局部变量通常存放在当前函数所属的栈中。

它的生命周期通常是:

进入函数
   ↓
为局部变量分配栈空间
   ↓
函数执行
   ↓
函数返回
   ↓
栈空间被回收

这里的“回收”并不是把内存清零,而是栈指针恢复,原有空间可以被后续函数重复使用。

因此下面的代码存在错误:

uint8_t *GetBuffer(void)
{
    uint8_t buffer[64];

    return buffer;
}

buffer 是局部数组,函数返回后对应栈空间已经失效。

返回它的地址会形成悬空指针。


2. 全局变量

全局变量定义在所有函数之外:

uint32_t g_system_tick;
uint8_t g_device_address;

全局变量的生命周期是整个程序运行周期:

系统启动时创建
一直存在
系统复位或断电后消失

全局变量通常存放在 .data 或 .bss 中。

优点:

  • 生命周期长;
  • 多个函数可以访问;
  • 地址固定;
  • 适合保存系统状态。

缺点:

  • 容易被多个模块随意修改;
  • 依赖关系不清晰;
  • 多任务并发访问可能产生竞争;
  • 过多全局变量会降低代码可维护性。

3. 全局变量不等于掉电保存

下面的变量:

uint32_t g_device_address = 1;

虽然是全局变量,但它仍然在 RAM 中运行。

断电后修改值不会自动保留。

需要掉电保存时,应写入:

  • 内部 Flash;
  • EEPROM;
  • FRAM;
  • 外部 SPI Flash;
  • 文件系统。

四、静态变量是什么

static 在不同位置有不同作用。

1. 函数内部的静态局部变量

void Communication_Process(void)
{
    static uint32_t call_count = 0;

    call_count++;
}

call_count虽然定义在函数内部,但不会随着函数返回而销毁。

第一次调用:

call_count = 1

第二次调用:

call_count = 2

它的生命周期是整个程序运行期间。

但它的可见范围仍然只在当前函数内。

这种变量通常放在 .data 或 .bss,不是放在函数栈中。


2. 文件内部的静态全局变量

static uint32_t sensor_error_count;

它的生命周期也是整个程序运行期间,但只能在当前 .c 文件内访问。

其他文件即使写:

extern uint32_t sensor_error_count;

也不能正常访问它。

这种写法适合隐藏模块内部数据:

/* sensor.c */
static uint8_t sensor_state;
static uint32_t sensor_timeout;

通过函数接口访问:

uint8_t Sensor_GetState(void)
{
    return sensor_state;
}

这比直接把所有变量暴露为全局变量更安全。


3. 静态函数

static void Sensor_Reset(void)
{
}

表示该函数只允许当前 .c 文件使用。

它和静态变量的“存储区域”不是同一个问题,主要是限制链接可见性。


五、什么是栈

栈是一块按“后进先出”方式使用的内存区域。

函数调用时,栈中可能保存:

  • 函数参数;
  • 局部变量;
  • 返回地址;
  • CPU寄存器;
  • 中断现场;
  • 临时计算结果;
  • 函数调用链信息。

例如:

void FunctionA(void)
{
    uint8_t a[32];

    FunctionB();
}

void FunctionB(void)
{
    uint8_t b[64];

    FunctionC();
}

调用过程:

FunctionA入栈
    ↓
FunctionB入栈
    ↓
FunctionC入栈
    ↓
FunctionC返回并释放
    ↓
FunctionB返回并释放
    ↓
FunctionA返回并释放

栈空间会随着函数调用逐渐消耗,函数返回后逐渐恢复。


六、什么是堆

堆是用于动态内存分配的区域。

C语言中常见接口:

void *malloc(size_t size);
void free(void *ptr);

例如:

uint8_t *buffer;

buffer = malloc(256);

if (buffer == NULL)
{
    /* 分配失败 */
    return;
}

/* 使用buffer */

free(buffer);
buffer = NULL;

与栈不同,堆内存不会在函数返回时自动释放。

必须由程序显式调用:

free();

七、栈和堆的区别

项目栈堆
分配方式编译器和函数调用自动管理程序调用 malloc() 分配
释放方式函数返回自动释放调用 free()释放
分配速度通常较快通常较慢
确定性较强受碎片和当前状态影响
空间大小通常有限取决于预留堆空间
常见风险栈溢出泄漏、碎片、重复释放
适用场景局部变量、调用现场大小动态变化的数据
实时性较好相对难以保证

栈不是无限的,堆也不是无限的。

嵌入式设备只有几十KB或几百KB RAM时,任何不受控制的内存使用都会产生严重后果。


八、为什么大数组不适合随便定义成局部变量

例如:

void Protocol_Process(void)
{
    uint8_t rx_buffer[2048];
    uint8_t tx_buffer[2048];
}

这两个数组会消耗约4096字节栈空间。

如果任务栈只分配了2048字节,就可能在函数一进入时发生栈溢出。

更合适的方式可能是:

static uint8_t rx_buffer[2048];
static uint8_t tx_buffer[2048];

这样数组放在 .bss,不占用任务栈。

但也不是所有局部数组都应该改成静态数组,因为静态变量会带来:

  • 多任务重入问题;
  • 同一个函数不能安全并发调用;
  • 生命周期延长;
  • 始终占用RAM;
  • 数据可能被不同调用共享。

所以应该根据使用场景判断,而不是机械修改。


九、什么是内存泄漏

内存泄漏是指:

程序申请了一块动态内存,但后续失去了这块内存的地址,导致它无法继续使用,也无法释放。

例如:

void Receive_Process(void)
{
    uint8_t *buffer = malloc(128);

    if (buffer == NULL)
    {
        return;
    }

    if (ReceiveData(buffer) != 0)
    {
        return;
    }

    free(buffer);
}

如果 ReceiveData()失败,函数直接 return,没有执行:

free(buffer);

每失败一次就泄漏128字节。

如果设备长期运行:

第一次失败:泄漏128字节
第二次失败:累计256字节
第三次失败:累计384字节
……

最终可能出现:

malloc() == NULL

程序随后可能发生:

  • 创建任务失败;
  • 创建队列失败;
  • 通信缓冲区分配失败;
  • 系统异常;
  • 看门狗复位。

正确处理方式

void Receive_Process(void)
{
    uint8_t *buffer = malloc(128);

    if (buffer == NULL)
    {
        return;
    }

    if (ReceiveData(buffer) != 0)
    {
        free(buffer);
        buffer = NULL;
        return;
    }

    free(buffer);
    buffer = NULL;
}

或者使用统一出口:

int Receive_Process(void)
{
    int result = -1;
    uint8_t *buffer = NULL;

    buffer = malloc(128);

    if (buffer == NULL)
    {
        goto exit;
    }

    if (ReceiveData(buffer) != 0)
    {
        goto exit;
    }

    result = 0;

exit:
    free(buffer);
    return result;
}

C语言中,经过规范设计的统一清理出口比多个遗漏释放的 return 更可靠。


十、什么是内存碎片

即使所有动态内存最终都被释放,频繁分配不同大小的内存也可能产生碎片。

例如堆中依次分配:

100字节
40字节
200字节
60字节

随后释放部分内存,可能形成:

已使用 | 空闲40 | 已使用 | 空闲60 | 已使用

虽然总空闲空间为100字节,但如果程序需要一块连续80字节的空间,可能仍然分配失败。

这就是外部碎片。

嵌入式设备长期运行时,碎片会导致:

  • 剩余总内存看起来够;
  • 但申请大块连续内存失败;
  • 故障出现时间不固定;
  • 重启后暂时恢复。

这种问题很难复现和定位。


十一、为什么嵌入式项目谨慎使用malloc

不是说嵌入式系统绝对不能使用 malloc(),而是要控制它的使用方式。

主要风险包括:

1. 分配时间不确定

动态分配器需要搜索空闲内存块、拆分和合并内存块,执行时间可能随堆状态变化。

对强实时控制任务来说,这种不确定性可能不可接受。


2. 可能产生内存碎片

不同大小、不同生命周期的对象反复分配释放,会导致堆碎片。


3. 容易内存泄漏

某个错误分支忘记 free(),可能运行几天后才出现故障。


4. 分配失败需要处理

下面的代码是错误的:

uint8_t *buffer = malloc(1024);

memset(buffer, 0, 1024);

如果分配失败:

buffer == NULL

继续调用 memset() 会访问空地址,可能直接进入 HardFault。

必须判断:

uint8_t *buffer = malloc(1024);

if (buffer == NULL)
{
    /* 记录错误并执行降级处理 */
    return;
}

5. 多任务并发问题

多个任务同时调用内存分配函数时,需要保证分配器线程安全。

标准库的 malloc() 是否线程安全,与工具链和运行库有关,不能默认认为一定安全。


6. 故障难以复现

内存分配结果与程序历史状态有关。

同一个功能:

第一次运行正常
运行10小时后失败
重启后又正常

这种问题比固定地址的静态内存更难分析。


十二、嵌入式项目可以怎样替代malloc

常见方案包括:

1. 静态数组

static uint8_t communication_buffer[512];

优点是大小和地址确定。


2. 固定大小内存池

内存池中预先准备16个64字节块
使用时取一个
不用时归还

优点:

  • 分配时间稳定;
  • 不容易产生碎片;
  • 最大使用数量可计算;
  • 便于检查泄漏。

3. 对象池

针对固定结构体预先创建对象:

typedef struct
{
    uint8_t device_id;
    uint8_t data[32];
} Message_t;

static Message_t message_pool[16];

4. 启动阶段动态分配,运行阶段不再分配

一些系统在启动时创建所有任务、队列和缓冲区。

系统进入正常运行状态后,不再调用动态内存分配。

这样可以减少长期运行中的碎片和不确定性。


十三、什么是野指针

野指针是指:

指针保存了一个无效地址,但程序仍然尝试通过它访问内存。

常见来源有三种。

1. 指针未初始化

uint8_t *buffer;

buffer[0] = 1;

buffer的值是不确定的,可能指向任意地址。

应该初始化:

uint8_t *buffer = NULL;

但注意,初始化为 NULL 只是让错误更容易发现,不代表可以直接访问。


2. 内存释放后继续使用

uint8_t *buffer = malloc(128);

free(buffer);

buffer[0] = 1;

内存已经释放,继续访问属于“释放后使用”。

释放后建议:

free(buffer);
buffer = NULL;

这样后续错误访问更容易被检查出来。


3. 返回局部变量地址

uint8_t *GetBuffer(void)
{
    uint8_t buffer[32];

    return buffer;
}

函数返回后,buffer对应栈空间已经失效。


十四、什么是悬空指针

悬空指针是野指针的一种,通常指向“曾经有效、现在已经失效”的内存。

例如:

uint8_t *p = malloc(100);

free(p);

此时 p 中仍保存原地址,但该地址对应内存已经不属于它。

所以应执行:

p = NULL;

不过如果多个指针同时指向同一块内存:

uint8_t *p1 = malloc(100);
uint8_t *p2 = p1;

free(p1);
p1 = NULL;

此时:

p2

仍然是悬空指针。

因此,真正解决问题依赖清晰的内存所有权设计,而不是只把一个指针设置为 NULL。


十五、什么是数组越界

数组越界是指访问了数组合法范围以外的元素。

例如:

uint8_t buffer[10];

buffer[10] = 0;

合法下标是:

0~9

buffer[10]已经越界。


数组越界为什么危险

C语言通常不会自动检查数组边界。

越界写入可能覆盖:

  • 相邻局部变量;
  • 函数返回地址;
  • 任务控制数据;
  • 其他任务栈;
  • 全局状态;
  • 堆管理信息;
  • 中断现场。

例如:

void Process(void)
{
    uint8_t buffer[8];
    uint8_t state = 1;

    for (uint8_t i = 0; i <= 8; i++)
    {
        buffer[i] = 0;
    }
}

循环条件写成了:

i <= 8

应该是:

i < 8

最后一次写入可能覆盖 state 或其他栈数据。

程序不一定立即崩溃,可能几秒、几分钟后才出现异常,因此非常难查。


常见字符串越界

char name[8];

strcpy(name, "MCH-Controller");

目标数组明显不足,strcpy()不会检查边界。

更安全的方式是限制长度:

snprintf(name, sizeof(name), "%s", "MCH-Controller");

但即使使用 snprintf(),也要检查数据是否被截断。


十六、什么是栈溢出

栈溢出是指:

实际使用的栈空间超过了为该栈分配的范围。

常见原因:

  • 任务栈设置太小;
  • 局部数组过大;
  • 函数调用层级太深;
  • 递归没有退出;
  • 中断嵌套过深;
  • printf()等函数占栈较大;
  • 浮点格式化;
  • 使用大型局部结构体;
  • 异常路径调用更深;
  • 库函数内部占用大量栈。

十七、栈溢出为什么会导致程序跑飞

栈中通常保存:

  • 局部变量;
  • 寄存器现场;
  • 函数返回地址;
  • 任务上下文;
  • 调度相关数据。

栈越界后可能破坏这些内容。

例如返回地址被覆盖后,函数执行:

return;

CPU可能跳到一个错误地址:

0x00000000
0xFFFFFFFF
非法Flash地址
错误RAM地址

随后可能出现:

  • HardFault;
  • BusFault;
  • UsageFault;
  • PC跑到异常地址;
  • 任务突然消失;
  • 看门狗复位;
  • 变量无故变化;
  • 系统随机死机。

这就是为什么栈溢出经常表现为:

程序并不是在定义大数组的地方立刻报错,而是在另一个看似无关的位置突然跑飞。


十八、普通裸机栈和FreeRTOS任务栈有什么区别

1. 裸机系统

普通裸机程序通常只有一个主栈。

它用于:

  • main()函数;
  • 普通函数调用;
  • 中断现场;
  • 异常处理。

2. FreeRTOS系统

FreeRTOS中,每个任务都有独立任务栈。

例如:

通信任务 → 自己的任务栈
采样任务 → 自己的任务栈
控制任务 → 自己的任务栈
日志任务 → 自己的任务栈

任务切换时,FreeRTOS会把当前任务的寄存器现场保存到该任务栈中,然后恢复另一个任务的上下文。

因此每个任务的栈必须能够容纳:

任务局部变量
函数调用链
寄存器上下文
库函数临时数据
中断或异常相关影响

某一个任务栈溢出,不一定立即破坏自己,也可能破坏相邻任务的数据。


十九、FreeRTOS任务栈大小怎么设置

创建任务时常见代码:

xTaskCreate(
    CommunicationTask,
    "COMM",
    256,
    NULL,
    3,
    &communication_task_handle
);

其中:

256

是任务栈深度。

最容易犯的错误是把它直接理解为256字节。

实际上,xTaskCreate()的栈深度单位通常是:

StackType_t

不是字节。

如果:

sizeof(StackType_t) == 4

那么:

256个StackType_t
= 256 × 4
= 1024字节

在32位Cortex-M平台上,StackType_t通常是32位,但应以具体FreeRTOS端口定义为准。


推荐写法

不要只写没有单位说明的数字:

#define COMM_TASK_STACK_DEPTH 256U

并注明:

/* 单位:StackType_t,不是字节 */
#define COMM_TASK_STACK_DEPTH 256U

也可以定义辅助宏:

#define STACK_DEPTH_FROM_BYTES(bytes) \
    (((bytes) + sizeof(StackType_t) - 1U) / sizeof(StackType_t))

使用:

xTaskCreate(
    CommunicationTask,
    "COMM",
    STACK_DEPTH_FROM_BYTES(1024U),
    NULL,
    3,
    &communication_task_handle
);

但要确认当前FreeRTOS接口的单位和端口实现。


二十、任务栈不是越大越好

给每个任务分配很大的栈虽然能降低溢出概率,但会浪费RAM。

例如10个任务,每个多分配2KB:

10 × 2KB = 20KB

对于只有64KB RAM的MCU,这是很大的开销。

合理做法是:

先根据代码估算
        ↓
留出安全余量
        ↓
运行压力测试
        ↓
检查最小剩余栈
        ↓
调整任务栈大小

不能只靠“程序暂时没崩溃”判断栈足够。


二十一、哪些代码特别占用任务栈

1. 大型局部数组

void NetworkTask(void *argument)
{
    uint8_t packet_buffer[2048];
}

2. 大型局部结构体

void SensorTask(void *argument)
{
    SensorData_t sensor_data[32];
}

3. 深层函数调用

Task()
  → Protocol()
    → Parse()
      → Decode()
        → Verify()

每层函数都可能增加栈使用。


4. 递归

void Recursive(uint32_t value)
{
    uint8_t buffer[64];

    if (value > 0)
    {
        Recursive(value - 1);
    }
}

递归深度不受控时,栈使用会持续增加。

资源受限嵌入式项目通常谨慎使用递归。


5. printf()和浮点格式化

printf("voltage = %.3f\r\n", voltage);

格式化字符串,特别是浮点格式化,可能占用较多栈和代码空间。

测试栈空间时,必须覆盖实际日志开启场景。


6. C库函数和协议栈

某些函数内部可能调用多层库函数,例如:

  • TLS;
  • 文件系统;
  • JSON解析;
  • 网络协议栈;
  • printf;
  • 加密算法;
  • 压缩解压。

表面看任务代码很短,实际调用链可能很深。


二十二、如何检测FreeRTOS任务剩余栈空间

FreeRTOS提供了高水位检测接口。

常见函数:

uxTaskGetStackHighWaterMark()

以及部分版本中的:

uxTaskGetStackHighWaterMark2()

示例:

UBaseType_t remaining_stack;

remaining_stack =
    uxTaskGetStackHighWaterMark(
        communication_task_handle
    );

printf(
    "COMM stack high water mark = %lu\r\n",
    (unsigned long)remaining_stack
);

高水位值是什么意思

这里返回的不是“历史最大使用量”,而是:

任务运行以来,栈曾经剩余的最小空间。

也可以理解为最危险时刻还剩多少栈。

例如任务分配:

256个StackType_t

高水位返回:

40

表示该任务历史上最少还剩40个 StackType_t。

如果每个 StackType_t为4字节:

40 × 4 = 160字节

不是当前剩余空间

任务现在可能处于空闲状态,当前剩余很多,但之前进入某个深层函数时可能只剩40。

高水位记录的是历史最小余量,因此很适合做栈容量评估。


二十三、如何查看所有任务的栈信息

可以在调试任务中周期性读取:

void StackMonitorTask(void *argument)
{
    while (1)
    {
        printf(
            "COMM stack remain: %lu\r\n",
            (unsigned long)
            uxTaskGetStackHighWaterMark(
                communication_task_handle
            )
        );

        printf(
            "SENSOR stack remain: %lu\r\n",
            (unsigned long)
            uxTaskGetStackHighWaterMark(
                sensor_task_handle
            )
        );

        printf(
            "CTRL stack remain: %lu\r\n",
            (unsigned long)
            uxTaskGetStackHighWaterMark(
                control_task_handle
            )
        );

        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

不要过于频繁打印,以免监控任务反过来影响系统实时性。


二十四、如何通过任务状态列表查看栈余量

部分FreeRTOS配置下可以使用:

vTaskList();

示例:

char task_list_buffer[512];

vTaskList(task_list_buffer);

printf(
    "Name State Prio Stack Num\r\n%s\r\n",
    task_list_buffer
);

输出可能类似:

Name       State  Prio  Stack  Num
COMM       B      3     80     2
SENSOR     R      4     45     3
IDLE       R      0     110    1

其中 Stack一般表示高水位余量,但具体输出格式要结合FreeRTOS版本和配置。

需要在 FreeRTOSConfig.h 中开启对应功能,例如:

#define configUSE_TRACE_FACILITY        1
#define configUSE_STATS_FORMATTING_FUNCTIONS 1

二十五、FreeRTOS如何检测栈溢出

在 FreeRTOSConfig.h 中可以设置:

#define configCHECK_FOR_STACK_OVERFLOW 2

然后实现:

void vApplicationStackOverflowHook(
    TaskHandle_t xTask,
    char *pcTaskName)
{
    taskDISABLE_INTERRUPTS();

    printf(
        "Stack overflow: %s\r\n",
        pcTaskName
    );

    while (1)
    {
    }
}

实际项目中,Hook函数里不要依赖复杂的动态分配、阻塞日志或可能已经损坏的系统资源。

可以进行:

  • 保存任务名;
  • 保存错误码;
  • 点亮故障LED;
  • 记录复位原因;
  • 触发看门狗复位;
  • 写入少量可靠故障信息。

configCHECK_FOR_STACK_OVERFLOW = 1

通常在任务切换时检查栈指针是否超出合法范围。

优点是开销较小。

但如果栈越界后又回到范围内,可能无法发现。


configCHECK_FOR_STACK_OVERFLOW = 2

除了检查栈指针,通常还检查栈边界预填充的特殊模式是否被破坏。

检测能力更强,但也不是绝对防护。

如果一次严重越界直接破坏了内核数据,Hook函数可能还没执行,系统就已经崩溃。

因此:

栈溢出检测是辅助机制,不是可以无限依赖的安全边界。


二十六、静态创建任务有什么优势

动态创建任务:

xTaskCreate(...);

任务控制块和任务栈通常从FreeRTOS堆中申请。

静态创建任务:

static StaticTask_t communication_tcb;
static StackType_t communication_stack[256];

TaskHandle_t communication_handle;

communication_handle =
    xTaskCreateStatic(
        CommunicationTask,
        "COMM",
        256,
        NULL,
        3,
        communication_stack,
        &communication_tcb
    );

优点:

  • 任务栈地址固定;
  • 不依赖运行时堆分配;
  • 不会因堆不足而突然创建失败;
  • 内存使用在编译阶段更明确;
  • 更适合长期运行和高可靠性系统。

缺点是:

  • 内存一直占用;
  • 最大任务数量和栈大小需要提前规划;
  • 灵活性较低。

对于任务数量固定的嵌入式产品,静态创建往往更容易控制。


二十七、FreeRTOS中的堆方案

FreeRTOS常见内存管理实现包括:

heap_1.c
heap_2.c
heap_3.c
heap_4.c
heap_5.c

heap_1

只允许分配,不支持释放。

特点:

  • 实现简单;
  • 确定性较好;
  • 不会产生释放后的碎片;
  • 适合系统启动时创建对象,运行后不删除。

heap_2

支持分配和释放,但合并空闲块能力有限。

长期运行可能产生碎片,现在一般不作为首选。


heap_3

封装标准C库:

malloc()
free()

是否线程安全、实时性如何,取决于C运行库实现。


heap_4

支持相邻空闲块合并,是很多项目常用方案。

相较于简单分配器,碎片控制更好,但仍然不是完全不会碎片。


heap_5

支持多个不连续内存区域。

适合芯片存在多块RAM,例如:

SRAM1
SRAM2
CCM RAM
外部SDRAM

具体哪些区域能用于DMA、CPU访问或高速缓存,需要结合芯片硬件限制。


二十八、如何查看FreeRTOS堆剩余空间

常用接口:

xPortGetFreeHeapSize()

查看当前剩余堆空间:

printf(
    "Free heap = %lu bytes\r\n",
    (unsigned long)xPortGetFreeHeapSize()
);

查看历史最小剩余空间:

xPortGetMinimumEverFreeHeapSize()

示例:

printf(
    "Minimum ever free heap = %lu bytes\r\n",
    (unsigned long)
    xPortGetMinimumEverFreeHeapSize()
);

两者区别:

xPortGetFreeHeapSize:
当前还剩多少堆

xPortGetMinimumEverFreeHeapSize:
系统运行以来最少剩过多少堆

如果最小剩余值不断下降,即使任务数量固定,也要重点怀疑内存泄漏。


二十九、动态内存分配失败怎么处理

FreeRTOS中可以开启内存分配失败Hook:

#define configUSE_MALLOC_FAILED_HOOK 1

实现:

void vApplicationMallocFailedHook(void)
{
    taskDISABLE_INTERRUPTS();

    /* 保存错误码、点亮故障灯 */

    while (1)
    {
    }
}

这可以捕获:

  • 创建任务失败;
  • 创建队列失败;
  • 创建信号量失败;
  • 调用 pvPortMalloc()失败。

但业务代码仍应主动检查接口返回值,不能只依赖Hook。

例如:

QueueHandle_t queue;

queue = xQueueCreate(10, sizeof(Message_t));

if (queue == NULL)
{
    /* 队列创建失败 */
}

三十、栈大小应该怎样评估

推荐采用以下流程。

第一步:静态估算

检查任务中:

  • 最大局部数组;
  • 最大局部结构体;
  • 最深函数调用;
  • 是否使用递归;
  • 是否使用浮点 printf;
  • 是否调用复杂库函数。

第二步:预留合理余量

不能把栈设置成刚好等于理论使用值。

还需要考虑:

  • 编译器优化变化;
  • 新增日志;
  • 异常路径;
  • 中断和上下文保存;
  • 固件后续升级;
  • 不同编译配置。

第三步:压力测试

测试时应覆盖:

  • 最大通信报文;
  • 最大数据包;
  • 最深错误处理;
  • 所有日志开启;
  • 网络断开;
  • 传感器异常;
  • 固件升级;
  • 多任务同时运行;
  • 高频中断。

第四步:读取高水位

使用:

uxTaskGetStackHighWaterMark()

观察历史最小剩余栈。


第五步:保留安全空间

高水位不是越接近0越好。

如果只剩几个字,系统已经处于危险状态。

安全余量需要根据项目风险确定,高可靠系统通常会保留更大余量。


三十一、任务栈溢出的典型现象

任务栈溢出不一定表现为明确的“Stack Overflow”。

常见现象包括:

  • 某个任务突然不运行;
  • 任务状态异常;
  • 软件定时器失效;
  • 队列数据莫名改变;
  • HardFault;
  • PC或LR出现异常地址;
  • 系统随机重启;
  • 看门狗频繁复位;
  • 日志打印到一半停止;
  • 加一条打印后程序反而崩溃;
  • Debug版本正常,Release版本异常;
  • 开启优化后故障位置变化;
  • 修改一个无关变量后故障消失。

因为内存布局变化后,被覆盖的对象也会变化,所以故障表现不稳定。


三十二、为什么“加一条printf后故障消失”更值得警惕

添加日志后,编译器可能改变:

  • 栈布局;
  • 寄存器分配;
  • 函数内联;
  • 变量地址;
  • 执行时序;
  • 任务切换时机。

原来的越界写可能不再覆盖关键数据,于是问题暂时消失。

这不代表日志修复了程序,而可能说明存在:

  • 栈溢出;
  • 数组越界;
  • 未初始化变量;
  • 竞争条件;
  • 时序问题。

这种现象通常被称为“海森堡Bug”:一观察,问题表现就变化。


三十三、如何定位任务栈溢出

1. 开启FreeRTOS栈溢出检测

#define configCHECK_FOR_STACK_OVERFLOW 2

实现:

vApplicationStackOverflowHook()

2. 周期读取高水位

uxTaskGetStackHighWaterMark()

记录各任务的最小剩余栈。


3. 临时增加可疑任务栈

例如从:

256

增加到:

512

如果故障明显消失,说明栈不足的可能性较高,但这只能作为定位线索,不能代替根因分析。


4. 检查大型局部变量

搜索:

大数组
大结构体
递归
sprintf
printf
浮点格式化
协议解析临时缓冲区

5. 查看HardFault现场

重点记录:

  • PC;
  • LR;
  • SP;
  • CFSR;
  • HFSR;
  • BFAR;
  • MMFAR;
  • 当前任务名;
  • 当前任务栈范围;
  • 高水位;
  • 复位原因。

如果SP已经不在任务栈合法范围内,栈溢出可能性很高。


6. 使用MPU保护

支持MPU的Cortex-M芯片可以为任务栈设置保护区域。

当任务越界访问时立即触发内存管理异常,比静默破坏其他内存更容易定位。


三十四、常见错误代码与改进

错误一:任务局部数组过大

void CommunicationTask(void *argument)
{
    uint8_t rx[2048];
    uint8_t tx[2048];

    while (1)
    {
    }
}

改进方向:

  • 增加任务栈;
  • 把长期缓冲区改为静态;
  • 使用统一通信缓冲池;
  • 减小协议最大报文;
  • 分块处理数据。

错误二:不检查动态分配结果

uint8_t *buffer = pvPortMalloc(1024);

buffer[0] = 0;

正确写法:

uint8_t *buffer = pvPortMalloc(1024);

if (buffer == NULL)
{
    /* 记录ERR_NO_MEMORY */
    return;
}

buffer[0] = 0;

错误三:释放后继续使用

vPortFree(buffer);

SendData(buffer, length);

释放后不能再使用。


错误四:重复释放

vPortFree(buffer);
vPortFree(buffer);

重复释放可能破坏堆管理结构。


错误五:数组长度使用错误

uint8_t buffer[16];

memcpy(buffer, input, input_length);

如果:

input_length > 16

会越界。

正确处理:

if (input_length > sizeof(buffer))
{
    return;
}

memcpy(buffer, input, input_length);

三十五、推荐的任务监控代码框架

typedef struct
{
    const char *name;
    TaskHandle_t handle;
    UBaseType_t stack_depth;
} TaskStackInfo_t;

static TaskStackInfo_t task_stack_table[] =
{
    {"COMM", communication_task_handle, 256U},
    {"SENSOR", sensor_task_handle, 256U},
    {"CONTROL", control_task_handle, 384U},
};

void TaskStack_Print(void)
{
    size_t i;

    for (i = 0;
         i < sizeof(task_stack_table) /
             sizeof(task_stack_table[0]);
         i++)
    {
        UBaseType_t remain;

        remain =
            uxTaskGetStackHighWaterMark(
                task_stack_table[i].handle
            );

        printf(
            "%s: depth=%lu, minimum_remain=%lu\r\n",
            task_stack_table[i].name,
            (unsigned long)
                task_stack_table[i].stack_depth,
            (unsigned long)remain
        );
    }
}

注意:全局任务句柄通常要在任务创建完成后再填入表中,不能在句柄尚未初始化时直接复制其值。

更稳妥的方式是保存句柄地址,或者在初始化函数中建立表格。


三十六、文章总结

嵌入式系统中的内存大致可以理解为:

代码和只读常量 → Flash
已初始化全局/静态变量 → .data
未初始化全局/静态变量 → .bss
普通局部变量和调用现场 → 栈
动态申请的内存 → 堆

其中:

  • 栈由编译器和函数调用自动管理;
  • 堆由程序动态申请和释放;
  • 全局变量贯穿整个程序运行周期;
  • 静态局部变量不会随函数退出销毁;
  • 数组越界可能破坏任意相邻数据;
  • 野指针可能访问已经失效的内存;
  • 内存泄漏会让可用堆逐渐减少;
  • 栈溢出可能破坏返回地址和任务上下文;
  • FreeRTOS每个任务都有独立任务栈;
  • xTaskCreate()的栈深度通常以 StackType_t 为单位;
  • uxTaskGetStackHighWaterMark()可以检查历史最小剩余栈;
  • configCHECK_FOR_STACK_OVERFLOW可以辅助检测栈越界;
  • 长期运行的嵌入式产品应谨慎使用动态内存。

可以用一句话总结:

很多“程序随机跑飞”并不随机,只是内存已经在更早之前被越界写坏,而故障直到后面才表现出来。


程序莫名其妙跑飞,可能是任务栈溢出了

当程序出现以下现象时,应优先检查任务栈:

增加一条日志后故障位置变化
Debug正常但Release异常
任务运行一段时间后突然停止
HardFault地址每次不一样
看门狗频繁复位
数组或结构体内容莫名改变
网络压力增大后开始死机
开启浮点printf后出现异常

排查顺序建议为:

开启FreeRTOS栈溢出检测
        ↓
实现StackOverflowHook
        ↓
打印所有任务高水位
        ↓
检查大型局部数组
        ↓
检查深层调用和递归
        ↓
检查printf和复杂库函数
        ↓
增加可疑任务栈进行对比
        ↓
结合HardFault寄存器确定根因

真正可靠的处理不是简单把所有任务栈都扩大,而是:

找到哪一个任务在什么调用路径下使用了多少栈,然后给出可计算、可监控并留有安全余量的栈配置。


更多推荐