做嵌入式 Linux 项目,大多数人的日常是:拿到开发板、烧镜像、跑 demo,然后根据需求写应用。
问题是,出了事你得知道去哪查。串口不出数据、内核 panic、某个 I2C 设备不工作——这三个问题的根子完全不在一层,蒙着头到处改只会越改越乱。
这篇文章想把底层逻辑说清楚:一块板子从上电到跑应用,经历了哪几层,每层干了什么,配置文件在哪。
嵌入式 Linux 和桌面发行版的本质差别
桌面 Linux 下个 ISO 装上就能跑,因为 x86 的硬件规范相对统一,内核一套打天下。
嵌入式不一样。NXP i.MX8、ST STM32MP1、RK3568,每家芯片的内存控制器、时钟树、中断控制器都不同。你没法拿一个通用镜像烧进去就跑——得针对这块板子,把下面四样东西配齐:
Bootloader + 内核 + 设备树 + RootFS
这四样东西的分工,一句话说就是:Bootloader 负责"把系统叫醒",内核负责"管着所有硬件",设备树负责"告诉内核板子长什么样",RootFS 负责"给用户空间提供地基"。
上电之后发生了什么
芯片上电,最先跑的是片内 ROM 里固化的代码。这段代码不可修改,厂商出厂时就固化了。它做一件事:从约定好的存储介质(eMMC、SD 卡、SPI Flash)里找 Bootloader,加载进来,跳过去执行。
从这一步开始,控制权按顺序向上交接:
ROM → Bootloader → 内核(+设备树)→ RootFS → 应用程序
每一层依赖下一层已经把环境准备好。任何一层没跑起来,后面的就不会出现。这就是为什么串口静默、内核 panic、应用崩溃是三种完全不同的故障——它们卡在了不同的层。
Bootloader:系统跑起来之前的那段代码
最常见的是 U-Boot以及Yocto。它不属于 Linux 系统本身,但 Linux 能不能起来,它说了算。
核心工作:
- • 初始化时钟树,外设时钟没开,驱动 probe 必然失败
- • 从存储介质读内核镜像(
zImage / Image)到内存 - • 把设备树(
.dtb)地址传给内核,跳到内核入口
U-Boot 启动脚本一般长这样,实际项目里会把地址写进环境变量,而不是硬编码:
# 设置启动参数,告诉内核根文件系统在哪setenv bootargs "console=ttymxc0,115200 root=/dev/mmcblk1p2 rootwait rw"# 从 eMMC 分区 1 加载内核和 DTB 到内存load mmc 1:1 ${kernel_addr_r} /boot/Imageload mmc 1:1 ${fdt_addr_r} /boot/my-board.dtb# 引导内核,中间的 '-' 表示不使用 initrdbooti ${kernel_addr_r} - ${fdt_addr_r}
booti 的参数顺序是 [内核地址] [initrd地址] [DTB地址],不带 initrd 时用 - 占位。bootm 用于 uImage 格式,bootz 用于 zImage,booti 专用于 arm64 的 Image 格式,三个命令不能混用。
大多数工程师不需要从零写 U-Boot,直接用芯片原厂 BSP 里提供的配置,改一下存储介质和分区就够了。真正需要动 Bootloader 的场景,通常是做安全启动(Secure Boot)或者自定义 OTA 升级策略。
内核:管着所有硬件的那一层
内核是整个系统的中枢。驱动加载、内存分配、进程调度、中断处理,全在这一层。
嵌入式场景里,内核需要按项目需求裁剪。不用蓝牙的产品把蓝牙驱动关掉,不需要某个文件系统的把它去掉,能让系统更小、启动更快。操作入口是 make menuconfig,基于原厂 BSP 提供的 defconfig 开始,按需调整后编译:
# 以 NXP i.MX8 系列为例,加载原厂默认配置make imx_v8_defconfig# 打开图形配置界面,按需增删模块make menuconfig# 交叉编译内核和设备树,根据实际工具链调整 CROSS_COMPILEmake ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image dtbs -j$(nproc)
编译产物:内核镜像在 arch/arm64/boot/Image,各板子的 .dtb 在 arch/arm64/boot/dts/ 对应厂商目录下。
设备树:告诉内核板子长什么样
早期 Linux 里,不同板子的硬件信息直接写在内核 C 代码里,一块板子一份代码,维护起来很难。
设备树解决了这个问题:把硬件描述从内核代码里剥离出来,写成单独的 .dts 文件,编译成 .dtb 二进制由 Bootloader 传给内核。同一个内核镜像,换一个 DTB,就能支持不同的板子。
一个真实的设备树节点长这样,描述一个挂在 I2C1 总线上的温度传感器:
/* arch/arm64/boot/dts/vendor/myboard.dts */&i2c1 { clock-frequency = <400000>; /* I2C 速率 400kHz */ pinctrl-names = "default"; pinctrl-0 = <&pinctrl_i2c1>; status = "okay"; temp_sensor: tmp102@48 { compatible = "ti,tmp102"; /* 内核用这个字符串匹配驱动 */ reg = <0x48>; /* I2C 设备地址 */ interrupt-parent = <&gpio1>; interrupts = <4 IRQ_TYPE_LEVEL_LOW>; };};
compatible = "ti,tmp102" 是内核匹配驱动的依据。内核启动时扫描设备树,每碰到一个节点,就拿 compatible 字符串去跟已注册的驱动列表比对,找到匹配的就调用该驱动的 probe 函数。对应的驱动侧代码是这样的:
/* drivers/hwmon/tmp102.c(内核源码中实际存在) */static conststruct of_device_id tmp102_of_ids[] = { { .compatible = "ti,tmp102", }, { } /* 哨兵,表示列表结束 */};MODULE_DEVICE_TABLE(of, tmp102_of_ids);staticstruct i2c_driver tmp102_driver = { .driver = { .name = "tmp102", .of_match_table = tmp102_of_ids, /* 指向上面的匹配表 */ }, .probe = tmp102_probe, .remove = tmp102_remove,};
驱动找不到,设备不工作,但内核照常起来,不会崩,dmesg 里会有 no driver found for device 之类的提示。
工程实践里通常是拿厂商板级 .dts 模板,根据自己的硬件增减节点,然后单独编译 DTB 验证:
# 单独编译某块板子的 DTB,不需要重新编译整个内核make ARCH=arm64 vendor/my-board.dtb
RootFS:用户空间的地基
内核初始化完成后,挂载根文件系统,运行第一个用户态进程(/sbin/init 或 systemd)。从这里开始才进入"用户空间"。
根文件系统里有:
- • BusyBox 提供的基础命令和 Shell(
ls、mount、ifconfig 这些) - • C 库(嵌入式通常用 musl 或 uClibc-ng,比 glibc 体积小很多)
构建工具一般用 Buildroot。从厂商 BSP 的 defconfig 开始,用 make menuconfig 添加需要的软件包,编译后得到可以直接烧录的镜像:
# 加载厂商提供的默认配置make imx8mm_evk_defconfig# 打开配置界面,按需勾选软件包# 常见的有:Python3、openssl、dropbear(SSH)、iperf3 等make menuconfig# 编译,产物在 output/images/# rootfs.ext4 是可直接烧录的文件系统镜像make -j$(nproc)
值得注意的一点:RootFS 和 CPU 架构绑定,不和板卡绑定。 同样是 ARM Cortex-A 架构的板子,一份 RootFS 基本可以复用,不像设备树那样每块板子都要单独维护。
出问题,先看哪一层
dmesg 是最直接的线索。内核启动时每个驱动 probe 成功或失败都会打印,看到 probe 成功才是真的初始化好了:
# 过滤某个设备的 probe 信息dmesg | grep tmp102# 典型的成功输出:# [ 2.347] tmp102 1-0048: tmp102 temperature sensor# 失败时会看到类似:# [ 2.348] tmp102: probe of 1-0048 failed with error -5
按现象对应到层,省掉盲猜的时间:
| |
|---|
| |
| |
| 某外设不响应,dmesg 没有 probe 成功记录 | |
sh: xxx: not found | |
| |
最后
四层东西,分工明确:
- • Bootloader:芯片厂给的,改动少,主要配存储介质和分区、启动参数
- • 内核:基于厂商 defconfig 裁剪,
make menuconfig 前先把 Kconfig 的 help 文档看一遍 - • 设备树:按自己的板子增减节点,改完用
dtc -I dtb -O dts 反编译检查一下实际生效的内容 - • RootFS:Buildroot 构建,同架构可复用,按需加软件包,注意工具链版本要和内核编译时一致
对大多数嵌入式 Linux 工程师来说,日常不是从零搭这套系统,而是在 BSP 框架里做定制。搞清楚每一层的职责和配置入口,比背接口用处大得多。