从零开始看懂ARM64内核启动:一场汇编与C的交接仪式
你有没有想过,一块通电的ARM64芯片,是如何从第一条机器指令一步步走到printf("Hello World\n");的?
这不像写个“Hello, World”那么简单。在操作系统真正开始运行之前,CPU其实处于一种“裸奔”状态——没有栈、没有全局变量、甚至连内存映射都还没建立。而这一切,都要靠一段精巧的汇编代码来完成。
本文不讲空泛理论,也不堆砌术语,而是带你亲手走一遍ARM64平台上的内核启动全流程,从复位向量到main()函数调用,从EL3特权级切换到MMU开启那一刻的惊心动魄。你会发现,这段过程不仅是技术细节的堆叠,更是一场精心设计的“权力交接”。
启动的第一步:谁说了算?异常级别(EL)是关键
当你的ARM64设备按下电源键,CPU并不会直接跳进C语言世界。它首先进入的是一个叫EL3(Exception Level 3)的最高特权模式。
为什么是EL3?因为它掌管着整个系统的信任根(Root of Trust)。你可以把它理解为“保安队长”,负责检查所有后续加载的代码是否可信。像ARM Trusted Firmware(ATF)中的BL1阶段,就是在这个层级运行的。
ARM64有四个异常级别:
| EL | 权限等级 | 典型用途 |
|---|---|---|
| EL3 | 最高 | 安全监控、TrustZone切换 |
| EL2 | 次高 | 虚拟机管理器(Hypervisor) |
| EL1 | 内核级 | Linux内核 |
| EL0 | 用户级 | 应用程序 |
✅重点来了:系统上电后默认运行在EL3,但Linux内核要跑在EL1。所以整个启动过程的核心任务之一,就是安全地从EL3降级到EL1。
这个降级不是简单跳转就能完成的。你必须设置好两个关键寄存器:
-ELR_EL3:指定降级后要执行的第一条指令地址;
-SPSR_EL3:定义目标EL的处理器状态(如中断使能、异常级别等);
然后通过一条eret指令触发异常返回,硬件才会真正切换上下文。否则,轻则死机,重则被安全机制锁住。
异常来了怎么办?向量表决定第一响应人
CPU不可能预知什么时候会发生中断或错误。为了应对这些突发事件,ARM64设计了一套叫做异常向量表(Vector Table)的机制。
想象一下:CPU就像一个值班室,每当发生异常(比如复位、未定义指令、IRQ中断),它就会根据事件类型自动跳转到对应的处理函数入口。这些入口集中在一起,形成一张“应急响应清单”——这就是向量表。
每个异常条目占128字节,共支持16种情况,整张表大小固定为2KB,并且必须按2KB对齐。
举个例子,当芯片上电复位时,硬件会自动跳转到向量表的第一个位置(偏移0x000),那里应该放一条指向reset_handler的跳转指令。
我们来看一段真实的汇编实现:
.section ".vectors", "ax" .align 11 /* 2^11 = 2048 bytes alignment */ vector_table_el3: b reset_handler_el3 /* Reset */ b undefined_handler_el3 /* Undefined instruction */ b supervisor_call_el3 /* SVC */ b prefetch_abort_el3 /* Prefetch abort */ b data_abort_el3 /* Data abort */ b . /* Reserved */ b interrupt_handler_el3 /* IRQ */ b fast_interrupt_el3 /* FIQ */紧接着,我们在reset_handler_el3中做最基础的初始化:
reset_handler_el3: mov x21, #0x80000000 /* 假设栈起始地址 */ mov sp, x21 /* 设置栈指针 */ ldr x0, =__bss_start ldr x1, =__bss_end sub x2, x1, x0 bl __memset_zero /* 清BSS段 */ b c_setup_main /* 跳转到C环境准备函数 */看到这里你可能会问:为什么不直接写C代码?
答案很现实:C语言依赖运行时环境,而此时环境还不存在。
C语言为何不能“裸上”?三大前提缺一不可
想让C函数正常工作,至少需要满足三个条件:
栈指针(SP)已就位
所有局部变量、函数调用帧都依赖栈空间。AArch64 ABI要求栈在调用C函数前保持16字节对齐。BSS段已被清零
.bss段存放未初始化的全局变量(如int buf[1024];)。虽然不占用镜像空间,但在程序启动前必须清零,否则读取结果不可控。数据段若需重定位,必须搬移
如果链接地址 ≠ 实际加载地址(例如内核被加载到DRAM但期望运行在另一区域),就需要手动复制.data段。
其中,栈和BSS初始化是最基本、最普遍的要求,哪怕是最小的裸机程序也不能跳过。
下面这段汇编完成了核心准备工作:
.globl c_setup_main c_setup_main: ldr x0, =stack_top mov sp, x0 /* 设置栈顶 */ ldr x0, =__bss_start ldr x1, =__bss_end sub x2, x1, x0 /* 计算长度 */ mov x3, #0 1: cbz x2, 2f str x3, [x0], #8 sub x2, x2, #8 b 1b 2: bl kernel_main /* 终于可以调C了! */ b .一旦bl kernel_main执行成功,你就正式进入了C的世界。
MMU开启前夜:页表怎么建?虚拟地址如何映射?
接下来是整个启动过程中最具挑战性的一步:启用MMU(内存管理单元)。
MMU的作用是将虚拟地址翻译成物理地址,并实施访问权限控制。但在启用之前,你得先准备好页表结构。
ARM64通常采用四级页表(可配置为三级),每级使用9位索引,加上最低12位页内偏移,构成48位虚拟地址空间。
最关键的几个系统寄存器如下:
| 寄存器 | 功能说明 |
|---|---|
TTBR0_EL1 | 存放用户/内核页表基地址 |
TCR_EL1 | 配置VA/PA宽度、页粒度、共享属性 |
MAIR_EL1 | 定义内存类型(如Normal WB、Device memory) |
SCTLR_EL1 | 主控开关,M=1开启MMU,C=1开启缓存 |
假设我们要建立一个简单的恒等映射(identity mapping),即虚拟地址 == 物理地址,适用于早期启动阶段。
先看TCR_EL1的典型配置(48位地址空间,4KB页):
uint64_t tcr_val = (16ull << 37) | // T0SZ = 16 → 48-bit VA (2ull << 32) | // TG0 = 2 → 4KB granule (1ull << 20) | // SH0 = 1 → Inner Shareable (4ull << 16) | // ORGN0 = 4 → Outer Write-back Write-Allocate (4ull << 12); // IRGN0 = 4 → Inner Write-back Write-Allocate write_sysreg(tcr_val, TCR_EL1);再设置页表基址:
extern uint64_t level0_page_table[]; write_sysreg((uint64_t)level0_page_table, TTBR0_EL1);最后别忘了告诉MMU哪些内存类型对应什么行为:
// MAIR: Memory Attribute Indirection Register uint64_t mair_val = (0xff << 0) | // Attr0: Normal WB Cacheable (0x00 << 8); // Attr1: Device-nGnRnE write_sysreg(mair_val, MAIR_EL1);一切就绪后,就可以尝试开启MMU了:
uint64_t sctlr = read_sysreg(SCTLR_EL1); sctlr |= (1 << 0); // M bit: enable MMU sctlr |= (1 << 2); // C bit: enable cache write_sysreg(sctlr, SCTLR_EL1); // ⚠️ 此刻之后的所有地址访问都将经过MMU转换!🔥致命陷阱提醒:如果你的页表没有覆盖当前代码所在的物理地址,开启MMU瞬间就会因取指失败而崩溃。务必确保恒等映射包含当前运行区域!
和x86_64比一比:架构哲学的不同体现
很多人熟悉x86的启动流程:实模式 → 保护模式 → 长模式。那条长长的兼容链背后,是几十年历史包袱的积累。
而ARM64完全不同。它是原生64位设计,没有分段机制,也没有实模式。它的启动哲学更接近“自上而下”的权限管控:
| 对比维度 | x86_64 | ARM64 |
|---|---|---|
| 初始模式 | 实模式(16位) | 直接运行在EL3(64位) |
| 地址空间 | 分段 + 分页 | 平坦虚拟地址 + 多级页表 |
| 异常处理 | IDT(中断描述符表) | 向量表(每EL独立) |
| 权限模型 | RING0 ~ RING3 | EL0 ~ EL3 |
| 安全扩展 | SMX/SMM | TrustZone(基于EL3的安全世界切换) |
| 启动固件 | BIOS/UEFI | ATF、U-Boot等 |
可以说,x86是在不断挣脱过去的束缚,而ARM64是从一开始就选择了简洁与可控。
这也解释了为什么现代嵌入式系统、服务器甚至苹果Mac都在转向ARM架构——它更适合构建确定性高、安全性强的系统。
实战中的常见坑点与调试秘籍
即便你知道了全部流程,在实际移植或调试中仍可能踩坑。以下是几个高频问题及解决方案:
❌ 问题1:板子上电后毫无反应,串口无输出
排查思路:
- 是否正确设置了向量表地址?检查VBAR_EL3是否指向正确的基址;
- 栈指针是否指向无效RAM?确认DDR是否已初始化;
- 是否关闭了看门狗定时器?某些SoC默认开启会导致快速复位。
❌ 问题2:BSS清零后全局变量仍是随机值
原因:链接脚本中未正确定义__bss_start和__bss_end符号。
解决方法:在linker.lds中明确声明:
.bss : { __bss_start = .; *(.bss) *(COMMON) __bss_end = .; } > RAM❌ 问题3:开启MMU后立即死机
最大可能:页表未覆盖当前代码运行区域。
调试技巧:
- 在开启MMU前插入汇编断言:armasm dsb sy isb
- 使用JTAG调试器查看PC停在哪条指令;
- 确保恒等映射范围足够大(建议至少覆盖前64MB)。
✅ 秘籍:早期打印(early printk)救星登场
在MMU和设备驱动未就绪前,可以通过直接操作串口寄存器输出调试信息:
void early_uart_putc(char c) { volatile uint32_t *uart_reg = (void*)0x1c090000; while ((*uart_reg & (1<<6)) == 0); // 等待发送缓冲空 *uart_reg = c; }哪怕只是输出一个'.',也能帮你判断代码是否执行到了某一点。
整体流程串起来:从上电到内核主循环
让我们把上述所有环节串联成一个完整的启动链条:
[Power On] ↓ CPU从Boot ROM执行第一条指令(通常位于0x0) ↓ 跳转至Reset Vector → 进入 vector_table_el3 ↓ reset_handler_el3 设置 SP、清BSS ↓ 初始化时钟、串口、DDR控制器(可能由BL1完成) ↓ 建立恒等映射页表,配置TCR/MAIR/TTBR ↓ 开启MMU + Cache(注意一致性操作) ↓ 设置ELR_EL3 = kernel_entry, SPSR_EL3 = target_state ↓ eret → 切换至EL1,跳转到kernel_entry ↓ 继续执行head.S中的setup_arch()等初始化 ↓ 调用start_kernel() → 进入C主导的内核初始化 ↓ 设备树解析、调度器启动、内存子系统初始化 ↓ fork出init进程 → 用户空间启动这一连串动作,如同精密齿轮咬合,任何一环断裂都会导致系统无法启动。
掌握这项技能,你能做什么?
深入理解ARM64启动流程,不只是为了应付面试题。它实实在在能帮你解决以下问题:
- 移植Linux内核到新SoC平台:你需要修改
head.S、调整页表布局、适配新的中断控制器; - 开发定制Bootloader:无论是简化版U-Boot还是自制OS引导器,都需要亲手搭建C环境;
- 调试早期崩溃(Oops in head.S):知道每一步发生了什么,才能精准定位问题;
- 实现安全启动(Verified Boot):利用EL3进行签名验证,防止恶意固件注入;
- 优化启动时间:剔除不必要的初始化步骤,提升产品响应速度。
更重要的是,随着RISC-V等新生代架构崛起,其启动模型也大量借鉴了ARM64的设计思想——异常级别、向量表、页表初始化、C环境搭建,这些模式正在成为通用范式。
结尾彩蛋:未来的演进方向
ARM并未止步于此。近年来推出的新特性进一步丰富了启动生态:
- Realm Management Extension (RME):在原有TrustZone基础上增加“领域(Realm)”概念,实现更强的隔离;
- Scalable Vector Extension (SVE):启动时需检测CPU是否支持向量扩展;
- Memory Tagging Extension (MTE):启用后可在早期内存分配中加入标签检测,防溢出攻击。
尽管底层机制会变,但那个永恒的主题不会改变:如何从汇编走向C,如何从裸金属走向操作系统。
当你下次看到kernel_main()被调用时,不妨停下来想一想:在这之前,有多少行汇编代码默默完成了使命?
如果你在实践中遇到具体问题,欢迎留言交流。我们可以一起分析启动日志、反汇编崩溃点,甚至手把手教你写一个最小可运行的ARM64裸机程序。