多年前曾经调一块RK3568板子,内核启动卡在"Starting kernel..."就不动了,重编内核,折腾了两天一无所获,连个panic都没来得及打。从那以后我就觉得,ARM64启动这东西,光知道流程图是不够的。你得知道u-boot _start跳到board_init_f之前到底干了什么,内核head.S里的primary_entry和老版本的_text有啥区别,start_kernel里那一百多个初始化函数谁是瓶颈——不然出了问题只能瞎猜。这篇文章把ARM64嵌入式Linux的启动过程从源码层面拆开:u-boot怎么从裸板跑起来,Linux 5.10内核的head.S做了哪些大改,start_kernel的完整调用链是什么,根文件系统怎么从头做出来。全程带代码,不讲废话。一、ARM64嵌入式Linux启动全景
x86 PC启动是BIOS/UEFI干完活交给GRUB,GRUB加载内核,流程相对统一。ARM64完全不一样——没有统一固件标准,每个SoC厂商的ROM代码都不一样,bootloader得自己配。ROM Code:芯片出厂烧死在硅片里的代码,上电最先执行,负责从Boot介质加载下一阶段U-Boot SPL/TPL:U-Boot的精简版,RAM还没初始化时用,负责DDR训练和初始化U-Boot:完整的U-Boot,负责硬件初始化、加载内核和设备树到内存Linux Kernel:从汇编入口primary_entry开始,到挂载根文件系统、启动init进程用户空间(根文件系统):init进程拉起各种服务,最终到login shell重点讲第3和第4阶段,因为这两个阶段出了问题最难定位——U-Boot有命令行可以调试,用户空间有日志可以看,唯独内核启动早期,串口还没初始化、MMU刚配好、中断还没开,出了错就是个死机,连个提示都没有。二、U-Boot启动流程:从_start到board_init_f
2.1 U-Boot在ARM64上的入口
U-Boot的入口在arch/arm/cpu/armv8/start.S,标签名就是_start。上电后CPU跳到这里,此时MMU是关的、Cache是关的、中断是关的——裸机环境,啥都没有。/* arch/arm/cpu/armv8/start.S */.globl _start_start:#if defined(CONFIG_LINUX_KERNEL_IMAGE_HEADER) /* * 有些平台需要Linux内核镜像头部,U-Boot把自己伪装成 * 内核格式,让ROM Code能正常加载 */ b reset /* 跳过头部,到实际入口 */ .long 0 /* 偏移量占位 */ .quad 0 /* image_header占位 */ /* ... 共64字节头部 ... */#endif b reset
这里有个坑:如果你用的是RK或NXP的SoC,它们的ROM Code可能要求U-Boot带内核镜像头部。不带的话ROM Code拒绝加载,板子直接变砖——串口啥都不会输出。配置U-Boot时确认CONFIG_LINUX_KERNEL_IMAGE_HEADER是否需要勾选。2.2 reset标签:真正的初始化起点
_start跳到reset,这里才是干正事的地方:/* arch/arm/cpu/armv8/start.S */reset: /* 关中断 */ msr daifset, #0xf /* 根据当前异常级别做不同处理 */ mrs x0, CurrentEl cmp x0, #0xC /* EL3? */ b.eq 1f cmp x0, #0x8 /* EL2? */ b.eq 2f cmp x0, #0x4 /* EL1? */ b.eq 3f b reset_serror1: /* EL3: 安全态,先切到EL2 */ mrs x0, scr_el3 orr x0, x0, #0x4 /* 设置EL2为非安全态 */ msr scr_el3, x0 msr sp_el2, x0 eret /* 返回到EL2 */2: /* EL2: 设置异常向量表 */ adrp x0, vectors add x0, x0, #:lo12:vectors msr vbar_el2, x0 /* 关MMU和Cache */ mrs x0, sctlr_el2 bic x0, x0, #(1 << 0) /* 关MMU */ bic x0, x0, #(1 << 2) /* 关DCache */ bic x0, x0, #(1 << 12) /* 关ICache */ msr sctlr_el2, x0 isb /* 计数器频率设置(ARM64定时器) */ mrs x0, cntfrq_el0 cbz x0, 1f ldr x1, =CONFIG_COUNTER_FREQUENCY msr cntfrq_el0, x11: /* 初始化栈 */ ldr x0, =CONFIG_SYS_INIT_SP_ADDR mov sp, x0 /* 跳C语言入口 */ b board_init_f
异常级别判断:ARM64有EL0-EL3四个异常级别,U-Boot通常在EL2运行,但有些SoC的ROM Code把CPU带到EL3。reset里先判断当前级别,如果在EL3就切到EL2——因为U-Boot不需要安全态权限,而且后续内核期望在EL2或EL1启动关MMU和Cache:上电后这些寄存器状态不确定,有的SoC ROM Code可能开了Cache,U-Boot必须先全关掉再自己重建cntfrq_el0:ARM64通用定时器频率寄存器。如果ROM Code没设好,U-Boot得自己写,否则后续delay函数全乱套2.3 board_init_f:C语言世界
跳到board_init_f后进入C代码,做两件事:早期硬件初始化,以及为U-Boot自身重定位做准备。/* common/board_f.c */void board_init_f(ulong boot_flags){ gd->flags = boot_flags; gd->have_console = 0; /* 调用initcall链,逐个执行初始化函数 */ initcall_run_list(init_sequence_f); /* 重定位U-Boot到RAM顶部 */ relocate_code(addr_sp, gd, relocaddr);}
init_sequence_f是一个函数指针数组,按顺序执行一系列初始化:串口、DRAM、计时器、环境变量……每个板子可以通过board_early_init_f等弱函数插入自己的初始化逻辑。重定位(relocate)是U-Boot的一个设计亮点:U-Boot一开始从Flash直接XIP(eXecute In Place),运行速度很慢。等DRAM初始化好后,U-Boot把自己整个搬到RAM顶部运行,速度提升几十倍。2.4 U-Boot常用命令速查
命令 | 干什么 | 示例 |
|---|
bdinfo
| 看板级信息:内存起止地址、波特率、bootargs | bdinfo
|
printenv
| 打印环境变量 | printenv bootargs
|
version
| U-Boot版本号 | version
|
命令 | 干什么 | 示例 |
|---|
mmc info
| SD/eMMC信息 | mmc info
|
mmc read addr blk cnt
| 从eMMC读到内存 | mmc read 0x40080000 0x1000 0x2000
|
sf probe
| 探测SPI Flash | sf probe 0
|
sf read addr off len
| 从SPI Flash读到内存 | sf read 0x40080000 0x100000 0x800000
|
命令 | 干什么 | 示例 |
|---|
md addr len
| 看内存内容 | md 0x40080000 100
|
mw addr val
| 写内存 | mw 0x40080000 0x12345678
|
cp src dst len
| 内存拷贝 | cp 0x40080000 0x50000000 0x1000
|
命令 | 干什么 | 示例 |
|---|
dhcp
| DHCP获取IP | dhcp
|
tftpboot addr file
| TFTP下载文件 | tftpboot 0x40080000 Image
|
ping ip
| 测网络 | ping 192.168.1.100
|
命令 | 干什么 | 示例 |
|---|
booti
| 启动ARM64 Image | booti 0x40080000 - 0x40200000
|
bootz
| 启动zImage | bootz 0x40080000 - 0x40200000
|
bootm
| 启动uImage | bootm 0x40080000
|
命令 | 干什么 | 示例 |
|---|
setenv
| 设置变量 | setenv bootargs 'console=ttyAMA0,115200 root=/dev/mmcblk1p2 rootwait rw'
|
saveenv
| 保存到Flash | saveenv
|
editenv
| 编辑变量 | editenv bootargs
|
ARM64必须用booti,不能用bootz。booti三个参数分别是:内核Image地址、initramfs地址(没有就填-)、设备树dtb地址。我见过有人把dtb地址填错位置,内核启动后设备树全是零,各种驱动加载失败,查了好久才定位到。三、Linux 5.10内核启动:head.S的大改与源码逐行拆解
Linux内核的启动流程在不同版本间变化很大。4.x时代的ARM64 head.S入口是_text,到5.10已经彻底重构成primary_entry,引入了KASLR、fixmap页表、更复杂的多核启动逻辑。如果还在看老版本的启动代码来盲猜5.10的板子启动流程,会踩很多坑。3.1 Linux 5.10 head.S的架构变化
老版本只有一个_text标签做所有事。5.10把它拆成三层:primary_entry:外部可见的主入口,链接脚本把内核Image的入口指向这里__primary_entry:主CPU的入口,负责创建页表、开启MMU、跳转C代码__secondary_entry:从CPU的入口,负责等主CPU初始化完成后被唤醒5.10中ARM64正式支持内核地址随机化(KASLR)。内核不再固定在PAGE_OFFSET运行,而是可以通过kaslr_seed在设备树的/chosen节点中传递随机种子,内核启动时自我重定位到一个随机地址。这导致head.S里的所有地址操作都必须用相对寻址(adrp),绝对地址全部失效。5.10在创建内核页表时引入了fixmap机制——先映射一小块"临时窗口"用于访问DTB和早期内存分配器,等正式页表建好后再切换。这比老版本直接映射整个内核空间要安全得多。3.2 primary_entry:5.10内核的真正起点
基于Linux 5.10.209稳定版,逐段拆解arch/arm64/kernel/head.S:/* * arch/arm64/kernel/head.S * Linux 5.10 ARM64内核入口 */ .section ".head.text", "ax"SYM_CODE_START(primary_entry) /* * 内核镜像的前4字节 * ROM Code或U-Boot加载后跳到这里 * b指令相对跳转,与加载地址无关 */ b __primary_entry .long 0 /* 偏移量到pe_header */ .quad PE_HEADER /* PE/COFF头部偏移(EFI启动兼容) */SYM_CODE_END(primary_entry)
primary_entry就一条跳转指令。为什么要多包一层?因为ARM64 Image的头部格式有要求:前4字节必须是可执行指令,后面跟PE/COFF头部用于EFI启动。这个包装层让同一份内核镜像既支持U-Boot直接加载,也支持UEFI固件加载。3.3 __primary_entry:主CPU真正的初始化
/* * __primary_entry - 主CPU入口 * x0 = dtb物理地址(U-Boot通过x0传入) */SYM_CODE_START(__primary_entry) /* 保存dtb地址到x21 * ARM64调用约定:x0是内核入口的第一个参数 * U-Boot在booti时把dtb地址放到x0 * x21在head.S全程用于保存dtb地址,不能被覆盖 */ mov x21, x0 /* 保存当前物理地址到x24 * 后续KASLR重定位需要知道内核被加载到哪里 * adrp获取当前PC对应的页对齐物理地址 */ adrp x24, __primary_entry /* * 检查异常级别,设置boot mode标志 * 内核期望在EL2启动(虚拟化支持更好) * 如果在EL3则先降到EL2 */ bl set_cpu_boot_mode_flag /* * 创建临时页表并启用MMU * 5.10的核心变化:创建idmap、kernel image映射、fixmap */ bl __create_page_tables /* * 配置SCTLR_EL1为后续C代码做准备 */ ldr x0, =SCTLR_EL1_FLAGS msr sctlr_el1, x0 isb /* * 设置异常返回地址为__mmap_switched * eret后CPU会跳到这里,此时MMU已开 * 用eret而不是b来切换上下文 * 因为eret会自动清除特权级转换的状态 */ ldr x8, =__mmap_switched adr_l x9, __primary_switched#ifdef CONFIG_RANDOMIZE_BASE adr_l x9, __primary_switched + KASLR_OFFSET#endif br x8SYM_CODE_END(__primary_entry)
dtb地址保存到x21——ARM64 calling convention规定x0是第一个参数,U-Boot调booti时把dtb的物理地址放到x0。内核入口用x21保存这个值,因为x21是callee-saved寄存器,后续调用的函数不会覆盖它。如果你发现内核启动后设备树解析全错,第一个要查的就是x0/x21的值对不对。3.4 __create_page_tables:5.10的三套页表映射
老版本只创建一个简单的恒等映射加内核映射,5.10引入了fixmap,分三步走:/* * __create_page_tables - 创建早期页表 * * 创建三套映射: * 1. idmap: 恒等映射,物理地址=虚拟地址 * 用于MMU开启瞬间的过渡执行 * 2. kernel image: 内核镜像映射 * 将内核映射到最终的虚拟地址(PAGE_OFFSET + KASLR偏移) * 3. fixmap: 固定映射区域 * 用于早期访问DTB、earlycon、FDT等 */SYM_FUNC_START(__create_page_tables) mov x28, lr /* 获取页表所在内存 */ adrp x0, idmap_pg_dir adrp x1, swapper_pg_dir /* 清零页表内存 */ mov x2, #IDMAP_DIR_SIZE add x2, x2, #SWAPPER_DIR_SIZE bl __clear_page_tables /* 第一步:创建恒等映射(idmap) * 将内核当前所在的物理地址区域映射到相同的虚拟地址 * 为什么需要这个?因为开启MMU的瞬间,PC还在物理地址上执行 * 必须保证当前执行代码在虚拟地址空间也能访问 * 否则MMU一开,CPU取指失败,直接挂 */ adrp x0, idmap_pg_dir mov x1, #IDMAP_BLOCK_SHIFT mov x2, #IDMAP_BLOCK_SIZE bl map_memory /* 第二步:创建内核镜像映射 * 将内核物理地址映射到PAGE_OFFSET + KASLR偏移的虚拟地址 * 没有KASLR时偏移为0,就是标准的PAGE_OFFSET */ adrp x0, swapper_pg_dir mov x1, #SWAPPER_BLOCK_SHIFT mov x2, #SWAPPER_BLOCK_SIZE bl map_memory /* 第三步:创建fixmap映射 * fixmap是5.10引入的关键改进 * 在虚拟地址空间顶部固定位置映射几个页面 * 用于: * - FIX_FDT: 访问设备树DTB * - FIX_EARLYCON: 早期串口输出 * - FIX_PGD: 临时页表页面 * * 早期代码不能直接用内核虚拟地址访问DTB * (因为dtb可能不在内核映射范围内) * fixmap提供了"窗口"来访问这些不在映射范围内的内存 */ adrp x0, swapper_pg_dir mov x1, #SWAPPER_BLOCK_SHIFT ldr x2, =FIXADDR_START bl map_fixmap ret x28SYM_FUNC_END(__create_page_tables)
恒等映射是生死线。MMU开启的那一刻,CPU的PC寄存器还指向物理地址。如果这个物理地址没有同时映射到虚拟地址空间的相同位置,CPU取下一条指令时MMU做地址翻译会miss,触发translation fault,CPU直接异常——你连个串口输出都看不到,板子静悄悄地死了。fixmap是5.10的精髓。没有fixmap的老版本中,内核直接用物理地址访问DTB。5.10更严谨:先在fixmap里映射DTB所在的那一页,然后通过fixmap的虚拟地址窗口来解析设备树。好处是DTB可以在内存的任何位置,不要求它在内核镜像附近。3.5 KASLR在5.10中的实现
ARM64的KASLR(Kernel Address Space Layout Randomization)从4.6开始引入,到5.10已经成熟。核心思路:内核不再固定加载到PAGE_OFFSET,而是在启动时根据随机种子偏移一段距离。/* * arch/arm64/kernel/kaslr.c * Linux 5.10 KASLR实现 * * 随机种子来源: * 1. 设备树 /chosen/kaslr-seed 属性 * 2. EFI的random seed * 3. 硬件RNG(如果支持) */u16 __initdata memstart_offset_seed;static int __initkaslr_init(void){ u64 seed; /* 从设备树获取kaslr-seed */ if (!fdt_get_kaslr_seed(&seed)) { /* 没有种子,KASLR不生效 */ return 0; } /* * 偏移量必须是2MB对齐(ARM64的block size) * 范围:0 ~ KASLR_RANGE_MAX(通常几GB) */ kaslr_offset = (seed % KASLR_RANGE_MAX) & ~(SZ_2M - 1); return 0;}
/ { chosen { /* * kaslr-seed: 64位随机种子 * U-Boot可以在启动时用硬件RNG填充这个值 * 没有这个属性则KASLR不生效 */ kaslr-seed = <0x12345678 0x9abcdef0>; };};
KASLR对调试的影响:开了KASLR后,内核的虚拟地址每次启动都变,gdb调试时addr2line和vmlinux的符号地址对不上。调试时可以在bootargs里加nokaslr关闭随机化。另一个坑是:KASLR要求内核镜像必须用adrp/adr等PC相对寻址,如果驱动里有硬编码的绝对地址,开了KASLR必崩。3.6 __mmap_switched:切换到C语言世界
MMU开启后,__mmap_switched负责最后的环境准备:/* * __mmap_switched - MMU已开,切换到C运行环境 */SYM_CODE_START(__mmap_switched) /* 清空BSS段 * C代码期望BSS段全零,这里保证这一点 */ adr_l x0, __bss_start mov x1, xzr adr_l x2, __bss_stop sub x2, x2, x0 bl __pi_memset /* 保存dtb地址到全局变量,C代码后续使用 */ adr_l x0, __fdt_pointer str x21, [x0] /* 保存KASLR偏移量 */ adr_l x0, kaslr_offset str x24, [x0] /* 跳转到start_kernel,正式进入C语言世界 */ b start_kernelSYM_CODE_END(__mmap_switched)
到这里,汇编部分全部结束。dtb地址从x21存到了全局变量__fdt_pointer,KASLR偏移从x24存到了kaslr_offset,BSS段清零完成——C代码可以放心跑了。四、Linux 5.10 start_kernel:一百多个初始化函数的完整调用链
start_kernel是内核C代码的入口,在init/main.c(5.10中约在第879行)。这个函数一口气调了上百个初始化函数,是整个内核启动中最长的函数。不用全记住,但要搞清楚哪几个是关键瓶颈。/* init/main.c, line 879, Linux 5.10 */asmlinkage __visible void __init __no_sanitize_address start_kernel(void){ char *command_line; char *after_dashes; /* 第1步:打印内核版本 */ pr_notice("%s", linux_banner); /* 第2步:架构相关最早期的初始化 */ setup_arch(&command_line); /* ARM64上这个函数做了非常多的事: * - 解析设备树(unflatten_device_tree) * - 初始化内存布局(memblock) * - 设置内核虚拟地址映射 * - 检测CPU特性(elf_hwcap) */ /* 第3步:打印命令行参数 */ pr_notice("Command line: %s\n", saved_command_line); /* 第4步:设置各种核心子系统 */ setup_nr_cpu_ids(); /* CPU数量 */ setup_per_cpu_areas(); /* per-cpu变量区 */ smp_prepare_boot_cpu(); /* SMP启动准备 */ /* 第5步:引导时钟 */ boot_cpu_init(); /* 第6步:内存管理初始化 */ mm_core_init(); /* 核心内存分配器 */ setup_per_cpu_pageset(); /* per-cpu页缓存 */ mem_init(); /* buddy分配器 */ kmem_cache_init_late(); /* slab分配器 */ /* 第7步:进程调度器 */ sched_init(); /* CFS调度器初始化 */ /* 第8步:RCU读拷贝更新 */ rcu_init(); /* 关键!很多后续初始化依赖RCU */ /* 第9步:中断和时钟 */ init_IRQ(); /* 中断控制器(GIC)初始化 */ tick_init(); /* 时钟事件设备 */ timer_init(); /* ARM64 arch_timer */ /* 第10步:驱动模型 */ driver_init(); /* 设备模型核心:kobject、class、platform bus */ /* 第11步:设备树驱动匹配 */ of_platform_populate(NULL, of_default_bus_match_table, NULL, NULL); /* 第12步:挂载根文件系统 */ vfs_caches_init(); rest_init(); /* 创建kernel_init线程,最终挂载rootfs */}
mm_core_init()替代了旧的mm_init(),更早初始化memblockrcu_init()位置提前了,因为太多子系统依赖RCU设备树解析从setup_arch()里拆出了更多子函数rest_init()最终创建kernel_init线程,由它来挂载根文件系统4.2 setup_arch()里的设备树解析(5.10版)
setup_arch()是ARM64内核中最重要的架构初始化函数,在arch/arm64/kernel/setup.c(5.10第226行):/* arch/arm64/kernel/setup.c, line 226 */void __initsetup_arch(char **cmdline_p){ /* 1. 早期设备树扫描 — 只读关键信息 */ setup_machine_fdt(__fdt_pointer); /* 上面这个函数做了一件很关键的事: * 把U-Boot传过来的dtb物理地址(存在__fdt_pointer中) * 通过fixmap映射到虚拟地址空间,然后扫描/compatible、/memory等节点 * 此时正式页表还没建好,所以只能用fixmap窗口 */ /* 2. 解析内核命令行 */ *cmdline_p = boot_command_line; parse_early_param(); /* 3. 内存布局初始化 */ arm64_memblock_init(); /* memblock是内核早期的简单内存分配器 * 在buddy分配器初始化之前,所有内存申请都靠它 * 这里把设备树里的/memory节点信息注册到memblock * 同时把内核镜像、dtb、initramfs等区域标记为reserved */ /* 4. 解析CPU特性 */ cpu_read_bootcpu_ops(); init_cpu_features(&arm64_features); /* 5. 后续初始化 */ paging_init(); /* 建立正式的内核页表,替换head.S的临时页表 */ arm64_numa_init(); /* NUMA支持(如果启用) */ zone_sizes_init(); /* 内存zone初始化(DMA/NORMAL/HIGHMEM) */}
4.3 unflatten_device_tree:设备树展开
扁平形态(FDT):U-Boot传过来的原始dtb二进制,紧凑存储,只能顺序扫描展开形态(unflattened):内核解析后转换成的树形数据结构,可以随机访问转换在drivers/of/fdt.c中完成(5.10第1245行):/* drivers/of/fdt.c, line 1245 */void __init unflatten_device_tree(void){ /* 1. 分配内存,将FDT展开为struct device_node树 */ __unflatten_device_tree(initial_boot_params, NULL, &of_root, early_init_dt_alloc_memory_arch, false); /* 2. 获取/choosen节点信息:bootargs、initrd、kaslr-seed等 */ of_alias_scan(early_init_dt_alloc_memory_arch); /* 3. 此后设备树不再是二进制blob * 变成了内核里的struct device_node链表 * 驱动可以通过of_find_node_by_path()等API随机访问 */}
这个转换发生得很早,在setup_arch() -> paging_init()之后。一旦展开,内核就不再需要fixmap来访问DTB了——设备树信息已经变成了内核数据结构。4.4 rest_init()和kernel_init:从内核空间到用户空间
start_kernel最后调用rest_init(),它创建两个内核线程:/* init/main.c, line 679, Linux 5.10 */noinline void __ref rest_init(void){ /* 创建kernel_init线程(PID=1),即未来的init进程 */ pid = kernel_thread(kernel_init, NULL, CLONE_FS); /* 创建kthreadd线程(PID=2),内核线程管理器 */ pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES); /* 当前CPU0进入idle循环 */ cpu_startup_entry(CPUHP_ONLINE);}
kernel_init线程最终完成根文件系统挂载和init进程启动:/* init/main.c, line 1413, Linux 5.10 */static int __refkernel_init(void *unused){ /* 1. 等待设备驱动初始化完成 */ kernel_init_freeable(); /* 2. 尝试执行init程序 */ if (ramdisk_execute_command) { if (run_init_process(ramdisk_execute_command)) pr_err("Failed to execute %s\n", ramdisk_execute_command); } if (execute_command) { if (run_init_process(execute_command)) pr_err("Failed to execute %s\n", execute_command); } /* 3. 按顺序尝试4个默认init路径 */ if (!run_init_process("/sbin/init") || !run_init_process("/etc/init") || !run_init_process("/bin/init") || !run_init_process("/bin/sh")) return 0; /* 全部失败,panic */ panic("No working init found. Try passing init= option to kernel.");}
注意5.10的一个改动:错误信息从"No init found"变成了"No working init found",加了"working"这个词——因为init文件可能存在但不可执行,或者动态库缺失导致执行失败,这些情况不算"找不到",算"不能用"。五、设备树在启动中的完整角色
ARM64架构完全依赖设备树描述硬件信息,整个启动过程中设备树参与了三个阶段:U-Boot把dtb文件从存储设备加载到内存,通过booti命令的第三个参数指定dtb地址,U-Boot在跳转内核前把dtb地址放到x0寄存器。setup_arch() -> setup_machine_fdt()阶段,通过fixmap映射读取FDT,提取/memory、/chosen等关键信息。此时还是扁平二进制格式,只能顺序读取。unflatten_device_tree()把FDT转为struct device_node树。此后of_platform_populate()遍历设备树所有节点,通过of_match_table匹配驱动,完成设备-驱动绑定。/ { chosen { stdout-path = "serial0:115200n8"; bootargs = "console=ttyAMA0,115200 root=/dev/mmcblk1p2 rootwait rw"; linux,initrd-start = <0x84000000>; linux,initrd-end = <0x85000000>; };};
六、手动制作根文件系统
根文件系统是内核挂载的第一个文件系统,包含系统运行必需的命令、库、配置文件。6.1 目录结构
/├── bin/ 基础命令(ls、cp、mv等)├── sbin/ # 系统管理命令(ifconfig、mount等)├── etc/ # 配置文件(inittab、fstab、init.d等)├── dev/ # 设备文件(tty、mmcblk等)├── proc/ # procfs挂载点├── sys/ # sysfs挂载点├── tmp/ # 临时文件├── root/ # root家目录├── lib/ # 动态链接库├── usr/ # 用户程序├── mnt/ # 外部存储挂载点└── linuxrc # init进程入口
6.2 方式1:BusyBox做最小系统
BusyBox把几百个Linux命令打包成一个二进制,做嵌入式最小根文件系统的首选。wget https://busybox.net/downloads/busybox-1.36.0.tar.bz2tar xjf busybox-1.36.0.tar.bz2cd busybox-1.36.0make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfigmake ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- menuconfig勾选 Settings -> Build static binary (no shared libs)# 静态编译省去拷贝动态库的麻烦make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- install# 产物在_install目录
cd _installmkdir -p dev etc proc sys tmp mnt root etc/init.d/etc/inittab — init进程的配置cat > etc/inittab << EOF::sysinit:/etc/init.d/rcSttyAMA0::askfirst:-/bin/sh::ctrlaltdel:/sbin/reboot::shutdown:/sbin/swapoff -a::shutdown:/bin/umount -a -rEOF# /etc/init.d/rcS — 启动脚本cat > etc/init.d/rcS << EOF#!/bin/shmount -t proc proc /procmount -t sysfs sysfs /sysmount -t tmpfs tmpfs /tmpmdev -sifconfig lo upEOFchmod +x etc/init.d/rcS# /etc/fstabcat > etc/fstab << EOFproc /proc proc defaults 0 0sysfs /sys sysfs defaults 0 0tmpfs /tmp tmpfs defaults 0 0EOF# 设备节点sudo mknod dev/console c 5 1sudo mknod dev/null c 1 3
dd if=/dev/zero of=rootfs.ext4 bs=1M count=128mkfs.ext4 rootfs.ext4mkdir rootfs_mountsudo mount rootfs.ext4 rootfs_mountsudo cp -rf _install/* rootfs_mount/sudo umount rootfs_mount
6.3 方式2:Buildroot做完整系统
BusyBox做出来的系统太精简,连个ssh都没有。需要更完整的功能就用Buildroot,它一键编译交叉工具链、U-Boot、内核、根文件系统。git clone https://git.buildroot.net/buildrootcd buildrootmake qemu_aarch64_virt_defconfigmake menuconfigTarget options -> ARM64# Filesystem images -> ext2/3/4 root filesystem# Target packages -> 按需选(nginx、openssh、python3等)make -j$(nproc)
输出产物在output/images/,rootfs.ext4即根文件系统镜像6.4 根文件系统踩坑清单
"Kernel panic - not syncing: No init found":检查/sbin/init是否存在、权限是否755、bootargs中init=参数是否正确。BusyBox默认装的是/linuxrc,有些内核配置找不到就panic"VFS: Cannot open root device":两件事——确认内核编译了根分区的存储驱动(MMC驱动是CONFIG_MMC_SDHCI),确认bootargs的root=写对了动态链接程序报"No such file or directory":文件明明在,但运行报错。大概率是缺动态链接器或共享库。用aarch64-linux-gnu-readelf -d your_program查看依赖,把缺的.so拷到/lib启动问题排查速查
这些问题我基本都踩过,排了无数小时的坑总结出来的:现象 | 查什么 | 怎么解 |
|---|
U-Boot启动后串口无输出 | 串口基地址、波特率、时钟频率 | 检查CONFIG_DEBUG_UART_BASE、CONFIG_BAUDRATE,拿示波器看TX引脚有没有波形 |
U-Boot加载内核失败 | 存储设备状态、镜像地址、镜像完整性 | md看内存里内核头部magic是否正确(ARM64 Image头部是0x644d5241即"ARM\x64"),不对应就重烧
|
"Starting kernel..."后卡死 | bootargs的console参数、串口驱动 | 确保console=ttyAMA0,115200,内核配了CONFIG_SERIAL_AMBA_PL011。还有一个可能:dtb地址错了 |
内核panic:No init found | init程序存在性、权限、动态库 | init=/bin/sh跳过正常init直接进shell调试,先确认根文件系统能挂上
|
进shell后无法输入 | /dev/console设备节点
| 手动创建:mknod /dev/console c 5 1,权限600 |
设备树解析后驱动全挂 | dtb地址、dtb完整性 | 在U-Boot里fdt addr 0x40200000 && fdt print先验证dtb能不能正常解析 |
八、总结
ARM64嵌入式Linux启动,三个阶段各有各的难:U-Boot:硬件初始化是黑盒,不同SoC差异大,关键是搞清楚异常级别切换和内存重定位内核head.S:5.10大改了入口结构和页表创建方式,KASLR和fixmap是两个必须理解的新机制根文件系统:BusyBox做最小系统、Buildroot做完整系统,踩坑都在动态库和设备节点上调试启动问题有个通用思路:从后往前排除。先确认根文件系统镜像没问题(loop挂载看内容),再确认内核能找到root分区(加init=/bin/sh),再确认U-Boot传参正确(printenv检查bootargs和dtb地址),最后才去看硬件问题。大部分启动卡死都不是硬件的锅。