4. 内核配置与编译:从源码到可运行镜像

内核配置与编译,说白了就是给硬件“配衣服”。衣服合不合身,直接决定系统能不能跑起来。我做了这么多年嵌入式,见过太多“内核起不来”的案例,十有八九是配置出了问题。

这一章,我们聚焦四个核心话题:源码管理(linux.hash、linux.mk)、配置片段(kernel.config)、设备树(Device Tree)以及内核模块的编译与安装。嗯,都是实战中绕不开的硬骨头。

内核配置与编译知识体系 内核构建流程 源码管理 linux.hash / linux.mk 配置片段 kernel.config 设备树 Device Tree 内核模块 编译与安装 版本锁定 补丁管理 最小化配置 碎片合并 dts/dtsi overlay ko文件 depmod 💡 核心思路 Buildroot 通过 linux.hash 锁定源码版本 用 linux.mk 驱动配置→编译→安装全流程

4.1 内核源码管理:linux.hash 与 linux.mk

Buildroot 管理内核源码的方式,我个人觉得非常优雅。它不直接操作源码树,而是通过两个文件来“遥控”:linux.hash 和 linux.mk。

4.1.1 linux.hash:版本锁与完整性校验

这个文件的作用很简单——锁定内核源码的版本和哈希值。你想想看,如果每次构建都去网上下载最新源码,那项目还怎么保证一致性?

典型的 linux.hash 内容长这样:

# 从 kernel.org 下载的 Linux 6.1.28
sha256  a0a3c6b3f5d7e8f9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3  linux-6.1.28.tar.xz
# 本地补丁文件的哈希
sha256  b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2  my-board-fix.patch

为什么需要 hash 文件?

  • 防篡改:下载过程中被中间人攻击?哈希不匹配直接报错
  • 版本锁定:团队所有人用同一个源码版本,避免“在我机器上能跑”
  • 缓存命中:Buildroot 根据哈希判断是否需要重新下载

我曾经在一个项目中,因为没锁版本,结果同事的构建环境自动拉了个小版本更新,导致某个驱动接口变了。排查了两天……嗯,从那以后,我每个项目都强制提交 linux.hash。

4.1.2 linux.mk:构建流程的“总导演”

linux.mk 才是真正干活的文件。它定义了内核的下载、配置、编译、安装四个阶段。Buildroot 的包框架非常统一,内核也不例外。

核心变量一览:

变量名 作用 典型值
LINUX_SOURCE 源码包文件名 linux-6.1.28.tar.xz
LINUX_SITE 下载地址 https://cdn.kernel.org/pub/linux/kernel/v6.x/
LINUX_DEPENDENCIES 依赖的包 host-lzop, host-uboot-tools
LINUX_KCONFIG_FILE 内核配置文件路径 board/myboard/kernel.config
LINUX_INSTALL_TARGET 是否安装到目标系统 YES / NO

我个人习惯在 linux.mk 里加一些自定义钩子,比如:

define LINUX_APPLY_MY_PATCHES
    # 在解压后、配置前打上板级补丁
    for p in $(BR2_EXTERNAL)/board/myboard/patches/linux/*.patch; do \
        (cd $(@D) && patch -p1 < $$p); \
    done
endef
LINUX_POST_EXTRACT_HOOKS += LINUX_APPLY_MY_PATCHES

小技巧:如果你有多个板子共用同一个内核版本,可以把公共补丁放在 BR2_EXTERNAL 的公共目录下,板级特有补丁放在 board 目录里。这样既复用又隔离。

4.2 内核配置片段:kernel.config 的艺术

内核配置,说白了就是决定“要什么、不要什么”。Buildroot 推荐的做法是使用配置片段(fragment),而不是维护一个完整的 .config 文件。

4.2.1 为什么用配置片段?

完整的内核 .config 文件动辄几千行。你想想看,如果每次内核版本升级都要重新 diff 一遍,那得多痛苦?配置片段的思路是:

  • 只保留你修改过的选项
  • 其他选项使用内核的默认值
  • 多个片段可以叠加

举个例子,我的 kernel.config 可能只有几行:

CONFIG_CMDLINE="console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait"
CONFIG_EXT4_FS=y
CONFIG_USB_NET_DRIVERS=y
# 关闭不需要的调试功能
# CONFIG_DEBUG_KERNEL is not set

4.2.2 配置片段的合并机制

Buildroot 在构建内核时,会执行以下步骤:

  1. 运行 make defconfig 生成默认配置
  2. 读取 LINUX_KCONFIG_FILE 指定的片段文件
  3. 使用 scripts/kconfig/merge_config.sh 合并
  4. 最终生成完整的 .config

注意:配置片段的语法必须严格遵循 Kconfig 格式。我曾经犯过一个错误——在片段里写了个 CONFIG_XXX=m,但内核里这个选项不支持模块化,结果合并后直接报错。所以,写片段前最好先 make menuconfig 确认一下选项类型。

4.2.3 最小化配置原则

我建议你遵循“够用就好”的原则。嵌入式设备资源有限,每多一个驱动就多一分风险。怎么做到最小化?

  • 先用 make localmodconfig 基于当前系统生成配置
  • 然后手动裁剪掉不需要的驱动
  • 最后用 make savedefconfig 生成精简配置

记得有一次,我帮客户优化一个路由器项目。他们的内核镜像有 8MB,Flash 只有 16MB。我花了两天时间裁剪配置,最后内核缩到 3.2MB,还多出了 4MB 空间给应用层。客户当场就愣住了。

4.3 设备树(Device Tree)管理

设备树,说白了就是硬件的“说明书”。内核通过它知道:CPU 是什么、内存多大、外设挂在哪里。没有设备树,内核就是个瞎子。

4.3.1 设备树文件结构

一个典型的设备树项目包含:

文件类型 扩展名 作用
设备树源文件 .dts 板级描述,包含具体硬件信息
设备树包含文件 .dtsi SoC 级通用描述,可被多个板子复用
设备树编译输出 .dtb 二进制格式,Bootloader 加载后传给内核
设备树叠加层 .dtbo 运行时动态修改设备树

4.3.2 在 Buildroot 中管理设备树

Buildroot 通过 BR2_LINUX_KERNEL_DTS_SUPPORT 和 BR2_LINUX_KERNEL_INTREE_DTS_NAME 来指定设备树。我个人习惯把设备树文件放在板级目录下:

board/myboard/
├── kernel.config
├── patches/
│   └── linux/
│       └── 0001-add-myboard-dts.patch
└── myboard.dts

然后在 Buildroot 配置中设置:

BR2_LINUX_KERNEL_CUSTOM_DTS_PATH="board/myboard/myboard.dts"

设备树调试技巧:如果内核启动时设备树解析失败,可以在 U-Boot 中执行 fdt list 查看当前设备树内容。我曾经遇到过一个问题——设备树里 GPIO 编号写错了,导致 LED 死活不亮。用 fdt list /soc/gpio@... 一看,果然偏移量差了 32。

4.3.3 设备树叠加层(Overlay)

叠加层是个好东西。它允许你在不修改主设备树的情况下,动态添加或修改硬件描述。比如:

// my-overlay.dts
/dts-v1/;
/plugin/;

/ {
    fragment@0 {
        target = &i2c1;
        __overlay__ {
            status = "okay";
            my_sensor@48 {
                compatible = "my,pressure-sensor";
                reg = <0x48>;
            };
        };
    };
};

编译成 .dtbo 后,可以在运行时通过 configfs 加载:

# echo my-overlay.dtbo > /sys/kernel/config/device-tree/overlays/my-overlay/dtbo

4.4 内核模块编译与安装

内核模块,说白了就是“按需加载的驱动”。不是所有驱动都需要编译进内核,有些可以做成模块,需要时再加载。

4.4.1 模块编译流程

在 Buildroot 中,模块编译是自动的。你只需要在配置片段里把驱动设为 m:

CONFIG_USB_SERIAL=m
CONFIG_USB_SERIAL_PL2303=m

Buildroot 会执行 make modules 编译所有标记为 m 的驱动,然后通过 make modules_install 安装到目标文件系统的 /lib/modules/<kernel-version>/ 目录下。

4.4.2 模块依赖与 depmod

模块之间可能有依赖关系。比如加载 usbserial.ko 之前,必须先加载 usbcore.ko。Buildroot 在安装模块后会自动运行 depmod,生成 modules.dep 文件。

你可以通过 modinfo 查看模块信息:

# modinfo usbserial
filename:       /lib/modules/6.1.28/kernel/drivers/usb/serial/usbserial.ko
license:        GPL
description:    USB Serial Driver core
depends:        usbcore

经验之谈:如果你的目标系统空间紧张,可以只安装需要的模块。在 Buildroot 中设置 BR2_LINUX_KERNEL_INSTALL_MODULES=y 后,可以通过 LINUX_MODULES_DIR 指定安装路径,然后手动裁剪不需要的 .ko 文件。

4.4.3 模块自动加载

想让模块在系统启动时自动加载?有两种方式:

  1. /etc/modules-load.d/:在目标文件系统中创建配置文件,每行一个模块名
  2. udev 规则:当硬件被检测到时自动加载对应模块

我个人更推荐 udev 方式,因为它更灵活。比如:

# /etc/udev/rules.d/99-usb-serial.rules
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="067b", ATTR{idProduct}=="2303", RUN+="/sbin/modprobe pl2303"

避坑指南:我曾经在某个项目里,把所有 USB 串口驱动都编译成了模块,但忘了在文件系统里放 udev 规则。结果设备插上去后,内核虽然检测到了 USB 设备,但没加载对应的驱动模块。用户以为硬件坏了……嗯,从那以后,我每次都会检查模块加载机制是否完整。

4.5 本章小结

内核配置与编译,是嵌入式 Linux 开发中最基础也最容易出错的环节。记住几个关键点:

  • 源码管理:用 linux.hash 锁版本,用 linux.mk 控制流程
  • 配置片段:只维护修改过的选项,遵循最小化原则
  • 设备树:把硬件描述和驱动代码分离,善用叠加层
  • 内核模块:按需编译,注意依赖关系和自动加载机制