第4章:BSP启动阶段内存初始化(Boot ROM → SPL → U-Boot)

车载SoC上电后,内存子系统还是一片混沌。DDR控制器没配、MMU没开、页表没建——这时候CPU连局部变量都不敢声明。我见过不少刚入行的工程师,上来就写内存操作,结果板子直接挂死在启动阶段。说白了,你得先让内存"活过来"。

这一章,咱们就拆解从Boot ROM到SPL再到U-Boot这条链路上,DDRC初始化和MMU临时页表建立的关键步骤。嗯,这里面的坑,我踩过不少。

4.1 启动流程概览:三段式接力

车载SoC的启动,不是一步到位的。它分三个阶段,像接力赛一样传递控制权:

阶段 存储介质 主要任务 内存状态
Boot ROM 片上ROM(不可改) 加载SPL到SRAM,验签 DDR未初始化,仅用SRAM
SPL 片上SRAM 初始化DDRC,加载U-Boot DDR刚配好,MMU仍关闭
U-Boot DDR 建立页表,开启MMU,加载内核 DDR就绪,MMU使能

你想想看,Boot ROM只有几十KB的片上SRAM可用,连个像样的内存管理都做不了。所以它只干一件事:把SPL从Flash里搬进SRAM,验签通过后跳转。SPL呢,它跑在SRAM里,但它的使命是初始化DDR控制器——这一步搞不定,后面全白搭。

关键认知:SPL是DDR初始化的"拓荒者"。它必须在SRAM的狭小空间内完成DDRC的时序配置、ZQ校准、DQS门控训练。我早期做的一个项目,SPL代码超过SRAM容量,硬是优化到只剩32KB——每一行汇编都得精打细算。

4.2 DDRC初始化:让内存"活过来"

DDR控制器初始化,说白了就是告诉DDR颗粒:咱们用什么频率、什么时序、什么模式通信。这个过程我习惯分成四步:

  1. 时钟与复位——给DDR PHY和控制器供时钟,释放复位
  2. PHY初始化——配置DQS门控、ZQ校准、ODT阻抗
  3. 控制器配置——设置时序参数(tRCD、tCL、tRP等)、内存映射
  4. 训练与验证——写读测试,确保数据线无误

我曾经在一个项目中,DDRC初始化后读写总是不稳定。查了三天,发现是DQS门控的延迟窗口没调对——颗粒的DQS信号比时钟早了半个周期。嗯,这种问题示波器看不出来,得靠DDR PHY内部的训练结果寄存器。

4.2.1 时序参数配置示例

以LPDDR4为例,SPL里配置DDRC的代码大概长这样:

/* 配置DDRC时序寄存器 */
writel(0x001A001A, DDRC_BASE + TIMING_1);  /* tRCD=26, tRP=26 */
writel(0x00320032, DDRC_BASE + TIMING_2);  /* tRAS=50, tWR=50 */
writel(0x000F000F, DDRC_BASE + TIMING_3);  /* tRFC=15, tFAW=15 */

/* 配置内存映射:4GB地址空间,2个rank */
writel(0x00000003, DDRC_BASE + ADDR_MAP);  /* rank=2, bank=8, col=10 */

/* 启动DDRC */
writel(0x00000001, DDRC_BASE + CTRL);      /* 使能控制器 */

注意:这些时序参数必须与DDR颗粒的数据手册严格对应。我见过有人直接复制参考板的配置,结果板子跑在低温下就挂——因为温度变化会影响DDR时序裕量。建议在SPL阶段做一次温度补偿校准。

4.3 MMU临时页表建立:给CPU一张"地图"

DDR初始化完成后,U-Boot就可以在DDR里跑了。但这时候MMU还是关闭的——CPU直接物理寻址。为什么要开MMU?说白了,为了性能和安全。车载SoC里,外设地址空间和DDR地址空间经常重叠,不开MMU没法做地址隔离。

U-Boot启动早期,会建立一套临时页表。我习惯用一级页表(L1),因为简单、快速。ARMv8架构下,页表基地址放在TTBR0_EL2寄存器里。

4.3.1 页表建立步骤

  1. 清零页表内存——确保所有表项初始为无效
  2. 映射DDR区域——设置为普通内存,可缓存、可写
  3. 映射外设区域——设置为设备内存,不可缓存、不可推测
  4. 设置页表基地址——写TTBR0_EL2
  5. 使能MMU——置位SCTLR_EL2的M位

你想想看,如果外设区域被映射成可缓存的,CPU读UART状态寄存器时可能读到缓存里的旧值——这bug查起来能让人崩溃。我早期就踩过这个坑,UART轮询死活收不到数据,最后发现是MMU把UART地址映射成了普通内存。

4.3.2 临时页表示例代码

/* 页表基地址,对齐到4KB */
#define PAGE_TABLE_BASE  0x80000000

void setup_page_table(void)
{
    uint64_t *pte = (uint64_t *)PAGE_TABLE_BASE;
    int i;

    /* 清零所有表项 */
    for (i = 0; i < 512; i++)
        pte[i] = 0;

    /* 映射DDR: 0x80000000 - 0xFFFFFFFF */
    /* 属性: 普通内存, 可缓存, 可写, 可执行 */
    for (i = 0x800; i < 0x1000; i++) {
        pte[i] = (i << 21) | 0x701;  /* 1GB块映射 */
    }

    /* 映射外设: 0x40000000 - 0x4FFFFFFF */
    /* 属性: 设备内存, 不可缓存, 不可推测 */
    for (i = 0x400; i < 0x500; i++) {
        pte[i] = (i << 21) | 0x401;  /* 设备内存属性 */
    }

    /* 设置页表基地址 */
    asm volatile("msr ttbr0_el2, %0" : : "r" (PAGE_TABLE_BASE));

    /* 使能MMU */
    uint64_t sctlr;
    asm volatile("mrs %0, sctlr_el2" : "=r" (sctlr));
    sctlr |= 0x1;  /* 置位M位 */
    asm volatile("msr sctlr_el2, %0" : : "r" (sctlr));
}

个人经验:临时页表不要映射整个4GB地址空间。只映射U-Boot需要的DDR区域和必要外设,能减少页表大小。我一般只映射512MB DDR + 256MB外设,页表只占4KB,刚好一个页面。

4.4 核心流程图:启动阶段内存初始化

下面这张图,把整个流程串起来了。我建议你对照着看代码,理解每个阶段的内存状态变化。

车载SoC启动阶段内存初始化流程 启动阶段内存初始化流程 Boot ROM 片上SRAM运行 加载SPL到SRAM 验签跳转 SPL SRAM运行 初始化DDRC 加载U-Boot U-Boot DDR运行 建立页表/开MMU DDRC初始化子流程(SPL阶段) 时钟/复位 PHY初始化 控制器配置 训练 MMU临时页表建立子流程(U-Boot阶段) 清零页表 映射DDR 映射外设 使能MMU

4.5 避坑指南:我踩过的三个坑

做BSP这么多年,内存初始化这块的坑,我基本都踩了一遍。挑三个典型的说说:

  • DDRC时序参数写错——有一次我把tRCD和tRP的值写反了,板子在常温下能跑,一进高低温箱就挂。后来我养成了习惯:每个时序参数都加注释,标明数据手册的页码。
  • 页表映射覆盖了Boot ROM区域——U-Boot里建立页表时,我不小心把0x00000000开始的区域映射成了普通内存。结果Boot ROM的中断向量表被覆盖,系统一触发异常就死机。嗯,这个bug查了我两天。
  • MMU使能后TLB没刷——修改页表后忘了执行TLB失效操作,CPU拿着旧的页表缓存继续跑。我建议每次修改页表后,都执行一次tlbi vmalle1指令。

总结一下:DDRC初始化是SPL的核心使命,MMU页表建立是U-Boot的早期关键任务。这两个环节出问题,系统连内核都看不到。我个人的习惯是,在SPL里加一段DDR读写测试,在U-Boot里加一段页表验证——确保每一步都走稳了,再往下走。