4、内存问题排查:堆栈溢出检测、内存泄漏定位、野指针分析、内存对齐问题、MPU配置

内存问题,是嵌入式开发里最让人头疼的几类bug之一。说实话,我见过不少飞控项目,功能逻辑写得挺好,一飞起来就随机死机、随机重启。查到最后,十有八九是内存踩了雷。

今天咱们就把这几个硬骨头啃一啃。堆栈溢出、内存泄漏、野指针、内存对齐、MPU配置,一个一个来。

4.1 堆栈溢出检测

堆栈溢出,说白了就是函数调用太深,或者局部变量太大,把栈空间给撑爆了。飞控里最常见的情况是:中断嵌套太深,或者某个任务里定义了一个大数组。

我遇到过最离谱的一次,是同事在中断服务函数里定义了一个512字节的局部数组。中断一来,栈指针直接飞到了堆区,然后系统就疯了。

怎么检测?

我个人习惯用两种方法:

  • 栈填充法:启动时把整个栈区域填充成固定模式(比如0xDEADBEEF)。运行一段时间后,检查栈顶附近的数据有没有被改写。如果变了,说明栈溢出了。
  • 硬件栈指针监控:有些MCU(比如STM32)有栈溢出检测寄存器,可以设置栈边界,一旦越界就触发异常。

核心思路:栈溢出不是“会不会发生”的问题,而是“什么时候发生”的问题。一定要在开发阶段就把它揪出来。

// 栈填充示例(伪代码)
#define STACK_SIZE 1024
uint32_t stack[STACK_SIZE];

void stack_init(void) {
    for (int i = 0; i < STACK_SIZE; i++) {
        stack[i] = 0xDEADBEEF;  // 填充标记
    }
}

uint32_t stack_check(void) {
    uint32_t used = 0;
    for (int i = STACK_SIZE - 1; i >= 0; i--) {
        if (stack[i] != 0xDEADBEEF) {
            used++;
        } else {
            break;
        }
    }
    return used;  // 返回已使用的栈深度
}

小技巧:在RTOS任务中,每个任务都有自己的栈。我一般会在任务创建时把栈填满,然后每隔一段时间检查一次。这样能提前发现哪些任务栈给小了。

4.2 内存泄漏定位

内存泄漏,就是malloc了之后忘了free。飞控系统里,内存泄漏的后果很严重——飞着飞着,内存不够了,系统就挂了。

嗯,这里要注意:裸机开发里malloc用得少,但RTOS里动态内存分配很常见。比如消息队列、任务栈、动态创建的任务,都可能泄漏。

定位方法

  • 统计分配次数:自己封装malloc/free,记录每次分配和释放的地址、大小、调用位置。
  • 内存池快照:定期打印当前已分配但未释放的内存块数量和总大小。
  • 静态分析工具:比如PC-Lint、Coverity,能静态检查出一些明显的泄漏路径。

我曾经踩过的坑:有一次飞控在长时间飞行后随机重启,查了三天没找到原因。最后用内存跟踪工具一看,发现某个传感器驱动里每次读取数据都malloc一次,但只在初始化时free了一次。飞了半小时,内存就耗光了。

// 简易内存泄漏检测封装
void *my_malloc(size_t size, const char *file, int line) {
    void *ptr = malloc(size);
    if (ptr) {
        // 记录分配信息到链表
        record_alloc(ptr, size, file, line);
    }
    return ptr;
}

void my_free(void *ptr) {
    if (ptr) {
        // 从链表中移除记录
        record_free(ptr);
        free(ptr);
    }
}

// 使用宏替换标准函数
#define malloc(s) my_malloc(s, __FILE__, __LINE__)
#define free(p)   my_free(p)

建议:在飞控这种安全关键系统里,我强烈建议用静态内存分配代替动态分配。实在要用动态分配,也一定要用内存池,并且做好泄漏检测。

4.3 野指针分析

野指针,就是指向了无效内存地址的指针。它比内存泄漏更可怕——泄漏只是浪费内存,野指针是直接破坏数据。

你想想看,一个野指针写操作,可能把任务控制块、传感器数据、甚至中断向量表给覆盖了。系统怎么死的你都不知道。

常见野指针来源

  • 未初始化指针:声明了指针变量但没有赋初值。
  • 释放后继续使用:free之后没有把指针置NULL。
  • 返回局部变量地址:函数返回了栈上变量的地址。
  • 数组越界:通过指针访问了数组边界之外的内存。

我的经验:野指针最难查的地方是“释放后使用”。因为free之后,那块内存可能被别的变量占用了,你再去写它,就会破坏别人的数据。而且这种bug往往不是立即崩溃,而是过一会儿才出问题。

// 野指针典型场景
int *get_value(void) {
    int local = 42;  // 局部变量,在栈上
    return &local;   // 返回栈地址!函数返回后失效
}

void bad_example(void) {
    int *ptr = malloc(sizeof(int));
    free(ptr);
    *ptr = 100;  // 野指针!内存已释放
}

避坑指南:我曾经在调试一个飞控姿态解算模块时,发现姿态数据每隔几秒就跳变一次。查了两天,最后发现是一个传感器驱动里,某个指针在中断中被释放了,但主循环还在用。指针指向的内存已经被任务栈覆盖了。

4.4 内存对齐问题

内存对齐,说白了就是数据在内存中的存放地址要满足一定的规则。比如4字节的int,地址必须是4的倍数。

为什么要有对齐?因为CPU访问对齐的数据效率高,有些CPU甚至不支持非对齐访问(一访问就触发异常)。

常见对齐问题

  • 结构体对齐:结构体成员之间可能有填充字节,导致sizeof比你想象的大。
  • 强制类型转换:把一个字节数组强制转换成结构体指针,如果地址不对齐,就出问题。
  • DMA传输:DMA通常要求缓冲区地址对齐到4字节或8字节。

我遇到过的情况:有一次用DMA从SPI读取传感器数据,数据总是错位。查了半天,发现接收缓冲区定义在全局变量区,但地址没有对齐到4字节。DMA传输时,数据就乱了。

// 结构体对齐示例
#pragma pack(push, 1)  // 取消对齐
typedef struct {
    uint8_t  a;
    uint32_t b;
    uint16_t c;
} __attribute__((packed)) SensorData;
#pragma pack(pop)

// 强制对齐
typedef struct {
    uint8_t  a;
    uint8_t  padding1[3];  // 手动填充
    uint32_t b;
    uint16_t c;
    uint8_t  padding2[2];  // 手动填充
} SensorDataAligned;

建议:在飞控系统中,我一般会使用__attribute__((aligned(4)))来强制对齐关键数据结构。特别是那些要通过DMA或CAN传输的数据包,对齐问题一定要在代码审查时就解决。

4.5 MPU(内存保护单元)配置

MPU是MCU里的一个硬件模块,可以给内存区域设置访问权限。比如:某块内存只能读不能写,或者只能由特权模式访问。

说白了,MPU就是给内存上锁。它能帮你提前发现野指针和栈溢出问题——一旦程序试图访问非法区域,MPU会立即触发异常,而不是让系统悄悄崩溃。

MPU能做什么?

  • 保护关键数据:比如任务控制块、中断向量表,设为只读。
  • 检测栈溢出:给每个任务的栈设置边界,越界就触发异常。
  • 隔离任务:在RTOS中,每个任务只能访问自己的内存区域。

我的经验:在飞控项目中,我习惯把整个Flash区域设为只读,把RAM中的关键数据结构(比如姿态数据、控制参数)也设为只读。这样即使有野指针,也不会破坏核心数据。

// MPU配置示例(基于ARM Cortex-M)
void MPU_Config(void) {
    // 禁用MPU
    MPU->CTRL = 0;
    
    // 配置区域0:Flash(只读,特权模式可写)
    MPU->RBAR = (FLASH_BASE & 0xFFFFFFE0) | 0x10;  // 区域号0,可执行
    MPU->RASR = (0x1 << 0)   |  // 使能
                 (0x3 << 1)   |  // 全访问
                 (0x1 << 8)   |  // 只读
                 (0x1 << 16)  |  // 大小:1MB
                 (0x0 << 24);    // 子区域使能
    
    // 配置区域1:RAM(读写,但禁止执行)
    MPU->RBAR = (RAM_BASE & 0xFFFFFFE0) | 0x11;
    MPU->RASR = (0x1 << 0)   |  // 使能
                 (0x3 << 1)   |  // 全访问
                 (0x0 << 8)   |  // 读写
                 (0x1 << 16)  |  // 大小:1MB
                 (0x1 << 28);    // 禁止执行
    
    // 使能MPU
    MPU->CTRL = 0x01;
    __DSB();
    __ISB();
}

注意:配置MPU时一定要小心。我曾经在调试时把MPU配置错了,导致系统一启动就进HardFault。后来发现是区域大小设置不对——MPU要求区域大小必须是2的幂次方,而且起始地址要对齐到区域大小。

实用建议:在开发阶段,我建议把MPU配置得严格一些——能只读的就只读,能禁止执行的就禁止执行。这样能尽早发现内存访问问题。到了发布阶段,可以适当放宽权限,但关键区域一定要保护。

好了,内存问题排查这块就聊到这儿。说白了,堆栈溢出、内存泄漏、野指针、对齐问题、MPU配置,这五个点其实是环环相扣的。MPU能帮你检测前三个问题,对齐问题又会影响MPU的配置。嗯,在实际项目中,我建议先把MPU配好,再逐个排查其他问题。这样能省下不少调试时间。