启动链路里最容易犯的错误,是把“汇编搬段”“初始化外部内存”“启动调度器”当成一件事。它们其实是四段,每段都有自己的前置条件,顺序换一下就会失败,而且失败现象都长得很像:没有任何输出。

这一系列先讲画面怎么流出芯片,再往下走一层,讲它流出之前依赖的东西:启动的顺序,以及这些段和它们的地址是怎么定下来的。

1. 启动分成四段

背景:RISC-V 的启动入口与内存两种角色

RISC-V 和 ARM Cortex-M 在启动约定上有个明显差别:基础指令集里没有固定的复位入口,也没有固定放在某个地址的中断向量表。复位后从哪里取第一条指令、栈指针设成多少,都由芯片厂商在链接脚本和启动代码里约定。这颗芯片把这件事交给一个启动符号(Reset_Handler)去做,它是上电后第一段有实际意义的代码。

启动代码要解决四件事:把 C 语言运行需要的最小环境建起来(栈、全局指针)、把有初值的段从镜像搬到运行地址、把无初值的段清零、把异常与中断入口装好。搬段要用到链接脚本里的两个地址概念:

  • 加载地址:初值在镜像里存放的位置。
  • 运行地址:程序运行时真正访问的位置。

两者可以不同,这正是“搬段”存在的原因。

内存也分两种角色:复位之后立刻能用的只有片上内存;外部内存(DDR 或片内高速 RAM)必须先初始化,之后才能访问。段复制的顺序由这个可访问性决定,与代码里写的先后无关。

中断方面,RISC-V 的机器模式用 mtvec 指向异常入口;带中断控制器的实现(如 CLIC)另有一个向量表基址 mtvt,中断按向量号查表跳转。

段做什么前置条件
复位入口建立最小可运行的 C 环境只有片上内存可用
片上初始化时钟、地址属性、Cache、外设基础入口已建立,栈可用
外部内存初始化 DDR 或片内高速 RAM,再复制对应段时钟与地址属性已配好
内核启动板级初始化、组件注册、调度器启动所有代码与数据都在可访问的内存里

分段的意义在于定位:如果连串口都没有输出,问题在第一段或第二段;如果串口有输出但某个组件没起来,问题在第四段的注册顺序;如果系统能起但画面没有,启动链路大概率已经跑完了,该去查采集和输出。

2. 复位入口:先造出能跑 C 的环境

复位后的第一段代码,目标不是把系统初始化好,而是让 C 代码具备可以开始运行的最小条件。它按固定顺序做四件事:

  1. 把处理器状态清成已知值:31 个通用寄存器逐个清零。复位前它们的内容不确定,而后面所有代码都默认“从头开始”,清零就是让这个前提成立。
  2. 建立 C 运行的最小环境:设置全局指针,供 gp 相对寻址使用;并把栈顶指向中断栈,此时还没有线程,唯一可用的栈就是这一块。
  3. 装上异常与中断入口:把异常入口(mtvec)指向默认处理入口,向量表基址(mtvt)指向向量表。
  4. 搬入片上段并清零无初值段:复位后可访问的只有片上内存,所以第一批要处理的段都落在片上:需要运行的代码段与有初值的数据段从镜像复制到运行地址,运行时必须为零的段直接清零。

这里有两处容易忽略。一是第 2 步建立的是中断栈,不是函数调用栈:此后的代码可以调用函数,但可用深度受这一块栈的大小限制,深调用与大局部变量在启动早期就会踩栈,这也是早期阶段不放开中断的原因之一。二是搬运必须两头都做:把需要访问外部内存的段放在这一步复制,就是往一块还没初始化的空间里写数据;漏掉片上段,第一条用到这些数据的指令就会取到错值。

段的搬运由加载地址与运行地址两条地址驱动,顺序只受一件事约束:目的地此刻能不能访问。

flowchart TB
  classDef src fill:#f3f4f6,stroke:#6b7280,color:#1f2937
  classDef on fill:#dbeafe,stroke:#2563eb,color:#172554
  classDef gate fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b
  classDef off fill:#fef3c7,stroke:#d97706,color:#78350f
  classDef rt fill:#dcfce7,stroke:#16a34a,color:#14532d

  LINK["链接脚本:给出每个段的加载地址与运行地址"]:::src
  IMG["镜像:初值按加载地址存放"]:::src
  LINK --> IMG

  subgraph B1["第一段:只碰复位后可访问的片上内存"]
    direction LR
    A1["复制:需要运行的代码段与有初值段"]:::on --> A2["清零:运行时必须为零的段"]:::on
  end
  G["外部内存初始化:返回后可访问性才改变"]:::gate
  subgraph B2["第三段:搬进外部内存"]
    direction LR
    C1["复制:外部内存中的代码段与数据段"]:::off --> C2["清零:各模块中不需要初值的段"]:::off
  end
  RT["代码与数据都在可访问的内存里:进入内核启动"]:::rt

  IMG -.->|第一段| A1
  A2 --> G
  G -->|等它返回| C1
  C2 --> RT

3. 片上初始化:时钟、地址属性与中断

第二段是一串顺序不能换的初始化。每一项都在为后面某项创造前提,提前做或跳过,都会以别处的失败形式暴露出来:

顺序初始化项前置条件提前或跳过的后果
1调试通路入口已建立挂不上调试器,后面只能靠猜
2核的可选能力:扩展指令、精确故障定位、非对齐访问、浮点状态入口已建立用到这些能力的代码触发非法指令
3系统时钟前项已完成依赖时序的外设都跑在错误频率上
4地址属性映射:哪些区间可缓存、哪些必须强序访问时钟已就位可缓存与强序区间判错,CPU 与 DMA 看到的数据不一致
5外部内存初始化时钟与地址属性已配好外部内存不可访问,第三段的段复制全部失败
6中断控制器:分级、清挂起、建立向量前项已完成中断收不到,或全部落到默认处理入口
7使能指令与数据 Cache地址属性已生效Cache 按默认属性缓存了本不该缓存的区间
8机器模式软件中断前项已完成核间通知与软中断调度不可用

这个顺序里有两处不能交换。时钟必须早于任何依赖时序的外设;地址属性必须早于 Cache 使能,否则 Cache 会按默认属性去缓存本不该缓存的区间,而且改过属性也不会让已经进 Cache 的内容自动作废:属性必须在使能之前配好,没有事后补救的机会。地址属性与一致性之间的取舍,见已发的 嵌入式软硬件缓存一致性。

表里还有一处容易看错:初始化外部内存是第二段执行流里的一步(第 5 项),把段复制进外部内存才属于第三段。两者之间隔着一次可访问性的变化,外部内存初始化返回之后,第三段的搬运才成立,这是第三段必须等它返回的原因。

中断入口的建立与放开

中断可用不是一次配置的结果,而要按顺序建立入口、清干净挂起状态,最后才放开。启动早期保持关中断,是为了不在栈与向量表还没就绪时被中断打断:第一段建好的只有那一块中断栈,见上一节末。

flowchart TB
  classDef cfg fill:#dbeafe,stroke:#2563eb,color:#172554
  classDef ctrl fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b
  classDef gate fill:#fef3c7,stroke:#d97706,color:#78350f
  classDef isr fill:#dcfce7,stroke:#16a34a,color:#14532d

  subgraph CFG["启动阶段:先把入口与向量表装好"]
    direction LR
    C1["异常入口 mtvec
指向默认处理入口"]:::cfg --> C2["向量表基址 mtvt
指向向量表"]:::cfg --> C3["中断控制器:分级、
逐源清挂起、启用向量模式"]:::ctrl --> C4["放开中断:
至此中断才可能进入"]:::gate end REQ["外设:请求线置位并保持"]:::ctrl ARB["中断控制器:按优先级仲裁,
给出处理函数地址"]:::ctrl VEC["硬件向量跳转
按中断号取入口"]:::gate SVC["中断服务函数:
读状态、清标志、发事件"]:::isr RET["mret 返回,恢复现场"]:::isr CFG -.->|放开之后才接受请求| REQ REQ --> ARB --> VEC --> SVC --> RET RET -.->|下一次请求| REQ

链路本身是固定的:外设请求线置位,中断控制器仲裁后提供处理函数地址,硬件向量跳转进入服务函数,服务函数只做读状态、清标志、发事件三件事,其余工作留给线程。这条路径的完整版本(含 CLIC 向量模式与中断、线程的职责边界)见已发的 嵌入式 SD 卡上电加载,这里不重复。

flowchart TB
  classDef seg1 fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b
  classDef seg2 fill:#dbeafe,stroke:#2563eb,color:#172554
  classDef seg3 fill:#fef3c7,stroke:#d97706,color:#78350f
  classDef seg4 fill:#dcfce7,stroke:#16a34a,color:#14532d

  subgraph S1["第一段 复位入口:建立最小 C 环境(只有片上内存可用)"]
    direction LR
    A1["清通用寄存器"]:::seg1 --> A2["装向量表与栈"]:::seg1 --> A3["复制片上代码段与数据段"]:::seg1 --> A4["清零无初值段"]:::seg1
  end
  subgraph S2["第二段 片上初始化:时钟、地址属性、中断"]
    direction LR
    B1["时钟配置"]:::seg2 --> B2["地址属性映射"]:::seg2 --> B3["中断控制器分级"]:::seg2 --> B4["使能 Cache"]:::seg2
  end
  subgraph S3["第三段 外部内存:先初始化,再复制段"]
    direction LR
    C1["初始化外部内存"]:::seg3 --> C2["复制有初值段与模块段"]:::seg3 --> C3["清零未初始化段"]:::seg3
  end
  subgraph S4["第四段 内核启动:板级初始化到调度器"]
    direction LR
    D1["板级初始化"]:::seg4 --> D2["组件注册"]:::seg4 --> D3["创建 main 线程"]:::seg4 --> D4["启动调度器"]:::seg4
  end
  S1 --> S2 --> S3 --> S4

4. 外部内存与内核启动

外部内存那一段的执行顺序是固定的:先初始化外部内存本身,等它返回之后,再复制放在外部内存里的代码段和数据段,然后是各模块自己的内存段,最后清零各模块中不需要初值的段。把这段顺序写反(先搬数据再初始化内存)是容易写错的一处结论。

外部内存初始化完成后,启动进入内核阶段:板级初始化建立串口、控制台、系统节拍与堆,随后依次初始化定时器与调度器、创建主线程、创建定时器线程与空闲线程,最后启动调度器。这一段有两条需要记住:主线程在调度器启动之前只是就绪状态,一行都不执行;板级自动初始化和后面组件注册的自动初始化不在同一时刻,板级自动初始化发生在板级初始化里,组件注册要等主线程跑起来之后。

这里的顺序同样是依赖关系,不是习惯:板级初始化必须最先,因为组件注册、线程创建与后续的调试输出都要用到串口、控制台、节拍和堆,这几样都在这一步建立;调度器初始化必须在创建线程之前,否则新建的线程没有可用的调度基础设施;三个线程都建好之后,调度器才有可切换的对象,所以启动调度器是最后一步。创建与启动是两个动作:创建只把线程放进就绪队列,启动才把它交给调度器,主线程“建好了却一行都没跑”正是这两个动作被分开的结果。

上面两条结论指向同一套等级机制:自动初始化按等级从低到高执行,板级初始化内部跑的是最低的两级(板级与内核前置对象),主线程运行之后才轮到组件、环境与应用这三级。两个区间之间隔着调度器启动,所以在早的那一级里创建线程或申请堆内存都不成立,那时调度器与内存堆还没就绪。完整的等级表与两条容易弄反的顺序判据,见已发的 嵌入式 SD 卡上电加载。

内核对象之间的创建与依赖关系:

flowchart LR
  classDef k fill:#dbeafe,stroke:#2563eb,color:#172554
  classDef t fill:#dcfce7,stroke:#16a34a,color:#14532d
  classDef app fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b

  ST["启动入口"]:::k --> HW["板级初始化
串口、控制台、节拍、堆"]:::k HW --> REG["组件注册
按等级依次执行"]:::k REG --> RT["创建 main 线程"]:::app RT --> TH["创建定时器线程"]:::t RT --> IDLE["创建空闲线程"]:::t TH --> SCH["启动调度器"]:::k IDLE --> SCH SCH -.->|此前 main 只是就绪态| RT

5. 段落在哪里、地址是谁定的

段复制的顺序定了,还剩一个问题:这些段和它们的地址是谁定的,为什么换一个构建参数,地址就全变了。

同一块内存,往往同时有四份说法。四份都对,只是回答的问题不同:

说法回答的问题依据
物理能力芯片上有哪几块内存、能不能缓存芯片手册
链接布局段允许放在哪里、容量多少、从哪里加载当前生效的链接脚本
运行范围堆的范围、地址合法性校验、缓存区间怎么解释运行期头文件
本次产物每个段和对象实际落在哪里、多大符号表与映射文件

分开看才有用:改容量要同时动链接脚本和运行期头文件,只改一处等于没改;判断一个地址合不合法要用运行期头文件,判断它是否真的被分配要用本次产物。这两组问题混着问,就会得出错误结论。而且链接脚本与运行期头文件是两份互相独立的表达,中间没有工具做一致性检查,写岔了不会有任何提示。

flowchart TB
  classDef src fill:#f3f4f6,stroke:#6b7280,color:#1f2937
  classDef lay fill:#dbeafe,stroke:#2563eb,color:#172554
  classDef use fill:#fef3c7,stroke:#d97706,color:#78350f
  classDef out fill:#dcfce7,stroke:#16a34a,color:#14532d
  classDef warn fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b

  MANUAL["芯片手册:物理能力
有哪几块内存、能否缓存"]:::src LS["链接脚本:链接布局
段放哪里、容量多少、从哪里加载"]:::lay HDR["运行期头文件:运行范围
堆范围、地址校验、缓存区间解释"]:::lay MAP["本次产物:实际结果
符号表与映射文件"]:::out ASK1["改容量:两处一起改,只改一处等于没改"]:::use ASK2["判合法性看头文件,判是否真的分配看产物"]:::use GAP["两份独立表达,没有工具对齐"]:::warn MANUAL --> LS LS --> MAP HDR -.-> GAP MAP -.-> GAP LS --> ASK1 HDR --> ASK1 HDR --> ASK2 MAP --> ASK2

构建参数先定,地址才有答案

任何讲地址的结论都要先声明构建参数,否则结论不成立:

  • 核编号与内存变体开关决定最终挑中哪一份链接脚本。
  • 外部内存类型不参与挑脚本,它决定启动时初始化哪一块内存、以及镜像参数怎么给。

同一条启动路径在不同变体下并不等价,这也是下一节里“先记录构建参数,再谈任何地址”这条动作的由来。

flowchart LR
  classDef param fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b
  classDef ls fill:#dbeafe,stroke:#2563eb,color:#172554
  classDef mem fill:#fef3c7,stroke:#d97706,color:#78350f
  classDef attr fill:#dcfce7,stroke:#16a34a,color:#14532d

  P1["核编号"]:::param --> LS["被选中的链接脚本
给出每个段的加载地址与运行地址"]:::ls P2["内存变体开关"]:::param --> LS P3["外部内存类型"]:::param -.->|不参与挑脚本| EXTIC["外部内存初始化
决定初始化哪一块"]:::mem LMA["加载地址:初值留在片上镜像区"]:::mem V1["运行地址:片上 RAM"]:::mem V2["运行地址:外部内存"]:::mem ATTR["地址属性:按区间配可缓存或强序
在片上初始化的第 4 项完成"]:::attr LS --> LMA LS --> V1 LS --> V2 LMA -->|第一段复制| V1 LMA -->|初始化返回后复制| V2 EXTIC --> V2 V1 -.->|"能不能这么用,还要过属性这一关"| ATTR V2 -.-> ATTR

加载地址与运行地址:两条推论

链接脚本给每个段两个地址:加载地址是初值在镜像里的位置,运行地址是代码执行、数据被访问的位置。两者可以不同,典型写法是段运行在外部内存、初值却留在片上镜像区(> EXT AT > ROM)。启动代码负责把初值从镜像区复制到运行区,这也是“复制必须发生在外部内存初始化之后”的另一半原因。没有初值的段根本不进镜像,启动时直接在运行地址上清零:不占镜像空间,但占内存空间,核算容量时不能漏。

由此得到两条实用推论。一是某段数据在启动后表现异常,先分清它是“从镜像复制来的”还是“启动时清零的”:复制来的出错查复制范围与顺序,清零来的出错查清零范围,两类问题的排查方向完全不同。二是判断某块缓冲安不安全,要先知道它落在哪个区间,而区间边界来自实际配置入参,源码注释里的区间说明常常早已过时;属性配错或漏配的现象通常不是崩溃,而是偶发读到旧数据(CPU 改了内存、DMA 仍读到旧值),这种问题不会稳定复现,容易被误判成硬件故障。

6. 启动失败时的判断顺序

判断有一个前提:越早的段越沉默。输出通道本身依赖时钟与引脚配置,这两项到第二段才建立,所以第一段与第二段早期失败的现象就是“没有任何输出”。沉默是定位信息:它把范围限定在输出能力建立之前。

先把现象归到阶段:

现象最可能停在哪个阶段先查什么
换了构建参数后行为变化,或按旧产物判断地址启动之前:链接与镜像本次构建参数、实际生效的链接脚本
完全没有输出,一行日志都没有第一段或第二段早期栈、向量表、片上段是否搬对;时钟是否起振
有早期输出,进入外部内存相关处理后卡死或复位第三段外部内存初始化的返回值与地址属性
系统能起,某个组件不工作第四段注册函数的等级与依赖顺序
主线程没有运行第四段末调度器是否已启动、主线程是否就绪、注册函数是否阻塞
系统正常但没有画面启动链路之外转去查采集、处理与输出

再按两个固定动作确认前提:

  1. 先记录本次实际使用的构建参数(核编号、变体、外部内存类型),再谈任何地址。
  2. 从本次产物的符号表确认入口、各段符号与地址,不用旧产物对照。

前两个坑也有人踩过:把只读存储里的引导行为、镜像头解析和 Reset_Handler 当成同一层软件看;以及把构建配置里存在的条件分支当成对应链接脚本一定存在。第一种会让排查方向整体跑偏,第二种会在换变体时直接构建失败。

比事后排查更省事的做法,是给每一段留一个可观测点:一段结束时打一行日志、翻转一个 GPIO,或写下一个自检结果,把“沉默”变成“停在哪一段”。第一段与第二段早期还没有输出能力,只能用调试器或调试通路提供的信号补上。

与 MCU 二级 Bootloader 设计 讲的 OTA 与安全启动不是一回事,本文只讲应用核从复位到调度器这一段;多核 SMP 下调度器初始化与核间启动是另一类问题,见 双核 ARM SoC 上跑 RT-Thread SMP。