2. ECU内存布局:Flash、RAM、EEPROM分区策略,Bootloader与App的地址映射

好,咱们进入第二个核心话题。内存布局,说白了就是给ECU的“脑子”画个地图。你想想看,一个ECU里既要跑Bootloader,又要跑应用程序,还得存各种配置参数,这地盘怎么分?分不好,升级一次就死机一次,我见过太多这样的案例了。

2.1 三大存储介质的角色分工

先说说Flash、RAM和EEPROM这三个家伙各自干什么活。我习惯用一个比喻:

  • Flash:就像硬盘,存代码和固定数据。掉电不丢,但写入慢,还得按块擦除。
  • RAM:就像办公桌,跑程序时临时放变量、堆栈。速度快,但一断电就清零。
  • EEPROM:就像记事本,存那些需要经常改又得记住的参数,比如座椅位置、校准值。可以按字节改,但寿命有限。

嗯,这里要注意一点:很多MCU会把EEPROM和Flash做在同一个die上,甚至共用地址空间。比如我常用的Infineon TC3xx系列,它的DFlash(数据Flash)就能当EEPROM用。说白了,物理上都是Flash,只是操作方式不同。

2.2 典型的Flash分区策略

我个人习惯把Flash分成三大块:Bootloader区、应用程序区、参数存储区。拿一个512KB的Flash举例:

分区名称 起始地址 大小 内容
Bootloader 0x08000000 64KB 启动代码、升级协议栈
App_Region_1 0x08010000 192KB 应用程序主副本
App_Region_2 0x08040000 192KB 应用程序备份副本(用于A/B升级)
Parameter_Area 0x08070000 64KB 校准参数、故障码、配置字

为什么Bootloader只给64KB?因为它的任务很纯粹:检测升级请求、校验固件、跳转。代码量不会太大。我在项目中遇到过有人把Bootloader塞到128KB,结果App空间不够用,最后不得不重新划分——这种坑,前期规划好就能避免。

关键原则:Bootloader区必须写保护。一旦Bootloader被意外擦除,ECU就变砖了。我一般会在链接脚本里把Bootloader区设为只读,只有通过特殊指令才能解锁。

2.3 RAM的分区策略

RAM的分配相对灵活,但有几个关键区域必须规划好:

  • 栈(Stack):函数调用、局部变量。大小取决于调用深度,我一般给4KB-8KB。
  • 堆(Heap):动态内存分配。嵌入式里我建议少用malloc,容易碎片化。
  • 全局变量区(.bss/.data):静态变量和初始化数据。
  • 升级缓冲区:接收固件包时用的临时存储。这个大小很关键——太小了得频繁写Flash,太大会挤占其他空间。

举个例子,一个128KB的RAM,我可能会这样分:

/* 链接脚本示例(简化版) */
MEMORY
{
    RAM (rwx)  : ORIGIN = 0x1FFF0000, LENGTH = 128K
}

SECTIONS
{
    .stack   : { . = ALIGN(8); *(.stack) } > RAM  /* 8KB */
    .heap    : { . = ALIGN(8); *(.heap)  } > RAM  /* 4KB */
    .bss     : { . = ALIGN(4); *(.bss)   } > RAM  /* 剩余空间 */
    .data    : { . = ALIGN(4); *(.data)  } > RAM
    .upgrade_buf : { . = ALIGN(256); *(.upgrade_buf) } > RAM  /* 16KB */
}

你可能会问:为什么升级缓冲区要256字节对齐?因为Flash的写入操作通常要求对齐到页或扇区边界。不对齐的话,写入会失败。我曾经因为没注意这个,调试了整整两天才找到原因。

2.4 Bootloader与App的地址映射

这是最容易出问题的地方。Bootloader和App是两个独立的工程,它们的链接脚本必须协调一致。说白了,就是Bootloader要知道App的入口地址在哪里,App要知道自己该跑在哪个地址上。

典型的做法是:

  1. Bootloader端:在Flash的固定位置(比如0x08000000)存放一个向量表。上电后先执行Bootloader,检查是否需要升级。如果需要,就接收固件并写入App区;如果不需要,就直接跳转到App的入口。
  2. App端:它的向量表要重定位到App区的起始地址。比如App从0x08010000开始,那么它的向量表就在0x08010000。中断发生时,CPU会去这个地址找中断服务函数。

小技巧:我习惯在App的链接脚本里把向量表地址固定下来,然后在Bootloader里通过读取App区的前4个字节(栈指针)和第5-8个字节(复位向量)来获取入口地址。这样即使App版本变了,跳转逻辑也不用改。

跳转代码通常长这样:

/* Bootloader跳转到App */
typedef void (*app_entry_t)(void);

void jump_to_app(uint32_t app_addr)
{
    uint32_t stack_ptr = *(volatile uint32_t*)app_addr;
    uint32_t reset_vec = *(volatile uint32_t*)(app_addr + 4);

    /* 关闭全局中断 */
    __disable_irq();

    /* 设置主栈指针 */
    __set_MSP(stack_ptr);

    /* 跳转到复位向量 */
    app_entry_t app_entry = (app_entry_t)reset_vec;
    app_entry();
}

嗯,这里有个坑:跳转前一定要关闭所有外设中断,否则App还没初始化完,中断就来了,直接跑飞。我见过有人忘了关定时器中断,结果跳转后系统反复复位。

2.5 避坑指南

我曾经踩过的坑:

  • 向量表对齐问题:有些MCU要求向量表必须对齐到512字节或1KB边界。如果App的起始地址没对齐,中断根本不会触发。
  • Flash擦除影响中断:擦除Flash时,如果代码正在从Flash执行,CPU会卡死。所以擦除操作必须在RAM中执行,或者用DMA。
  • EEPROM磨损均衡:座椅ECU会频繁保存位置参数,如果每次都写同一个地址,EEPROM很快会报废。我建议用环形缓冲区或者磨损均衡算法。

最后说一句:内存布局这件事,最好在项目一开始就定死。后期改布局,意味着所有固件都得重新做地址映射,测试工作量翻倍。我个人的习惯是,先画一张内存地图,贴在工位上,每次写链接脚本都对照着看一遍。