嵌入式Linux启动过程全解析——从Bootloader到用户空间

📅 2026/7/16 15:01:24
嵌入式Linux启动过程全解析——从Bootloader到用户空间
你有没有想过按下开发板的电源键之后那一两秒的时间里Linux到底做了些什么从单片机转过来做Linux的人刚开始最容易困惑的就是这个。单片机上电后从Flash直接取指令执行流程清晰得像一张流程图。但嵌入式Linux不一样——它要先把自己组装出来然后才能运转。这个过程有点像搭积木每一层都依赖前一层准备就绪。Bootloader不是引导程序那么简单很多人以为Bootloader就是启动加载器把内核从Flash拷到内存就行了。实际上它的工作远比这复杂。先看看最常用的U-Boot启动过程。SPLSecondary Program Loader是最早运行的——它被固化在ROM或片上SRAM里负责初始化最基础的硬件关闭看门狗、设置时钟、初始化DDR控制器。这一步做完内存才有了可用空间。然后SPL从启动介质NAND Flash、SD卡、eMMC上加载完整的U-Boot。这个过程涉及ECC校验、文件系统解析——比如从FAT分区读u-boot.img。有意思的是有些SoC的SPL只有8KB到16KB的空间能塞进去的代码相当有限。U-Boot完整起来之后它的工作才真正展开// 这只是示意不是U-Boot实际源码但逻辑类似 int board_init(void) { // 几乎所有的板级外设初始化都走这里 setup_serial(); // 串口不然我们什么也看不见 setup_network(); // 网卡tftp下载内核用 setup_mmc(); // 存储介质检测 setup_environment(); // 加载env分区里的启动参数 return 0; }然后U-Boot读取bootcmd环境变量——这里存着真正的启动命令。最常见的是把内核和设备树从存储介质读到内存然后跳转执行。关键在这里——U-Boot向内核传递启动参数的方式从ATAG过渡到了设备树Device Tree。这是ARM Linux历史上一次重要的架构变迁。设备树一场硬改造ARM Linux早期的做法是把板级信息硬编码在arch/arm/mach-xxx/目录下的c文件里。比如哪块GPIO接了LED哪个I2C总线挂了传感器——全写在代码里。导致一颗新SoC每出一个开发板内核代码就得加一个machine_desc。设备树的思路很巧妙把硬件描述从内核代码里抽出来变成一份独立的数据结构。U-Boot把DTBDevice Tree Blob放在约定好的内存地址内核启动时从那里读。以下是设备树中一个典型节点的写法usart1 { pinctrl-0 usart1_pins_a; pinctrl-names default; status okay; gps { compatible u-blox,neo-6m; gnss-fix-gpios gpioa 4 GPIO_ACTIVE_HIGH; }; };内核启动时解析这个结构自动匹配驱动。而驱动开发者不再需要关心这个usart1具体在哪个物理地址——这些信息都在reg属性里。一个内核镜像可以在N款不同的板子上运行只要它们的设备树不同就行。有意思的是设备树本身不是ARM发明的——PowerPC早在2005年前后就开始用了。ARM社区在2011年前后正式拥抱它算是抄了个好作业。内核启动的汇编阶段常见误区是觉得内核入口函数就是start_kernel()。实际上在那之前汇编代码已经在运行了。arch/arm/kernel/head.S或者arm64下的head.S干了几件大事首先要确认处理器模式——把CPU设为SVC模式关闭中断。然后查CPU ID是否在内核支持列表里。如果跑在不是目标CPU的芯片上这一步就会死掉。然后是创建初始页表。这时候MMU还没开代码在物理地址空间里跑需要建立最简单的映射来准备开启MMU。有意思的是为了兼顾不同平台的物理地址布局这里的页表是动态计算的。最后调用start_kernel把控制权交给C代码。从汇编到C的过渡只有一条语句b start_kernel一条简单的跳转指令。但走到这一步经过了千百行汇编的精密准备。start_kernel一个总线型的初始化start_kernel是内核初始化的总入口但它本身不做具体的事——更像一个调度器按顺序调用各个子系统的初始化函数。这段代码的调度顺序是精心设计的。举个例子set_arch_early()必须在setup_arch()之前因为前者解析的是体系结构无关的信息后者才跟具体SoC有关。同样trap_init()异常向量表初始化必须在任何中断发生之前完成。一个容易被忽略的点是start_kernel的最后会调用rest_init()rest_init()创建kernel_init进程即init进程的原型和kthreadd进程然后自身变成idle进程。这时候调度器其实还没真正运转——第一个调度的时机是cpu_startup_entry()进入idle循环之后。init进程接手kernel_init进程做的事很关键它先试着执行根文件系统上的/init程序。如果不存在依次尝试/bin/sh。这就是为什么rootfs里必须有init——否则内核在启动最后会panic。如果跑的是BusyBox/init其实就是busybox的符号链接/init - /bin/busyboxBusyBox根据argv[0]判断自己该当什么角色。所以这里有一个巧妙的POSIX机制在起作用。现代嵌入式方案更多用systemd或者OpenRC比如在Buildroot里选配。但本质上逻辑没变——内核态的任务在内核里完成然后交棒给用户态的第一个进程。用户态的这个init进程是所有其他进程的祖先。到此启动流程完成了闭环。下回按开机键的时候不妨想想U-Boot那几行刷屏信息背后发生了什么——那些[ 0.000000]到[ 1.234567]的kernel log每一行都是一次精密的交接。