第三章 内核编译与构建:从源码到可启动镜像

内核编译这件事,说简单也简单,说复杂也复杂。我见过不少工程师,配好 .config 之后直接 make,然后就去喝咖啡了。结果回来发现编译失败,或者编译出来的内核根本不能启动。嗯,这里面的门道,咱们得好好捋一捋。

3.1 交叉编译工具链配置

嵌入式开发,第一关就是交叉编译。说白了,就是在你的 x86 电脑上,编译出能在 ARM、MIPS 或 RISC-V 上运行的代码。这可不是装个 gcc 就完事的。

3.1.1 工具链的选择

我个人习惯用 Linaro 或 ARM 官方提供的预编译工具链。为什么?省事。自己从源码编译工具链?我曾经干过一次,花了整整两天,最后还踩了一堆坑。除非你有特殊需求,否则别折腾。

工具链类型 适用场景 获取方式
Linaro GCC ARM 32/64 位通用 linaro.org 下载
ARM GCC ARM Cortex 系列 developer.arm.com
Buildroot 定制化根文件系统 buildroot.org
Yocto 企业级嵌入式 Linux yoctoproject.org

3.1.2 环境变量设置

工具链下载后,解压到某个目录,然后设置环境变量。我建议你写个脚本,别每次都手动 export。

# 设置交叉编译工具链路径
export CROSS_COMPILE=arm-linux-gnueabihf-
export ARCH=arm
export PATH=$PATH:/opt/gcc-arm-9.2-2019.12-x86_64-arm-linux-gnueabihf/bin

# 验证工具链是否可用
arm-linux-gnueabihf-gcc --version
注意:CROSS_COMPILE 变量后面一定要带短横线!我见过有人漏掉这个,结果 make 的时候调用了宿主机的 gcc,编译出来的东西根本跑不了。

3.2 内核编译流程

内核编译,本质上就是三步:配置、编译、安装。但每一步都有讲究。你想想看,一个几千万行代码的项目,编译一次少则几分钟,多则几小时。要是中间出错了,那真是欲哭无泪。

3.2.1 配置内核

配置内核的方式有很多种,我列一下常用的:

  • make menuconfig:基于 ncurses 的文本界面,最常用
  • make xconfig:基于 Qt 的图形界面,更直观
  • make defconfig:使用默认配置,适合快速测试
  • make oldconfig:基于现有配置更新,适合升级内核版本

我个人偏爱 menuconfig。为什么?因为它轻量,在服务器上也能用,不需要图形桌面环境。

3.2.2 编译目标详解

编译内核时,你会看到 vmlinux、zImage、uImage 这些名词。它们到底是什么关系?

核心概念:vmlinux 是未压缩的 ELF 格式内核,zImage 是压缩后的内核镜像,uImage 是加了 U-Boot 头部的 zImage。

我画了一张图,帮你理清它们的关系:

内核编译产物关系图 Linux 内核源码 编译 + 链接 vmlinux (ELF, 未压缩) zImage (压缩) uImage (U-Boot 格式) mkimage 添加头部 vmlinux 用于调试,zImage 用于启动,uImage 用于 U-Boot 引导

3.2.3 编译命令实战

下面是一个完整的编译流程。我建议你第一次编译时,用 make -j4 这样的并行编译,但别开太多线程,否则容易把机器搞死。

# 清理之前的编译产物
make clean

# 配置内核(假设你已经有了 .config)
make menuconfig

# 编译内核镜像
make -j$(nproc) zImage

# 编译设备树
make -j$(nproc) dtbs

# 编译内核模块
make -j$(nproc) modules

# 生成 uImage(如果需要)
make -j$(nproc) uImage
小技巧:用 make -j$(nproc) 可以自动使用所有 CPU 核心。但如果你在虚拟机里,建议减半,比如 make -j2。

3.3 模块编译与安装

内核模块,说白了就是可以动态加载的驱动程序。你想想看,如果每次改个驱动都要重新编译整个内核,那得多浪费时间?模块化就是为了解决这个问题。

3.3.1 模块编译

模块编译有两种方式:一种是随内核一起编译,另一种是单独编译。我建议你开发阶段用单独编译,这样快。

# 单独编译某个模块
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

# 编译并安装所有模块
make modules_install INSTALL_MOD_PATH=/path/to/rootfs

3.3.2 模块安装与依赖

模块安装后,需要更新依赖关系。我曾经遇到过模块加载失败,查了半天,结果是忘了跑 depmod。

# 安装模块到根文件系统
make modules_install INSTALL_MOD_PATH=/home/user/rootfs

# 更新模块依赖
depmod -a -b /home/user/rootfs

# 手动加载模块
insmod /lib/modules/5.10.0/kernel/drivers/mydriver.ko

# 自动加载模块(推荐)
modprobe mydriver
避坑指南:我曾经在升级内核后,直接复制了旧内核的模块目录。结果 modprobe 报各种符号找不到的错误。记住,模块必须与内核版本严格匹配!

3.4 编译优化选项

编译优化,说白了就是在编译时间和运行性能之间找平衡。我见过有人为了追求极致性能,开了 -O3,结果编译出来的内核比 -O2 还慢。为什么会这样?因为 -O3 会做更多激进的优化,有时候反而会引入问题。

3.4.1 常用优化选项

优化级别 说明 适用场景
-O0 不优化,编译最快 调试阶段
-O1 基本优化 快速测试
-O2 标准优化(内核默认) 生产环境
-Os 优化代码大小 嵌入式设备
-O3 激进优化 特定场景(慎用)

3.4.2 内核特有的优化配置

除了 GCC 的优化选项,内核本身也有一些编译优化相关的配置。我挑几个重要的说说:

  • CONFIG_CC_OPTIMIZE_FOR_SIZE:相当于 -Os,适合 Flash 小的设备
  • CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE:相当于 -O2,默认选项
  • CONFIG_TUNE_CPU:针对特定 CPU 优化,比如 Cortex-A7
  • CONFIG_LTO:链接时优化,能进一步减小内核体积

我的建议:对于大多数嵌入式设备,用 -Os 就够了。别为了省那几百 KB 空间去开 LTO,调试起来太痛苦了。

3.4.3 调试与优化的平衡

调试信息和优化是矛盾的。你想想看,开了 -O2 之后,代码顺序都变了,单步调试时跳来跳去,根本看不懂。所以我的习惯是:

  1. 开发阶段:关优化,开调试信息(CONFIG_DEBUG_INFO)
  2. 测试阶段:开 -O2,保留部分调试信息
  3. 发布阶段:开 -Os,去掉调试信息
# 调试版内核配置
make menuconfig
# Kernel hacking -> Compile-time checks and compiler options
#  -> Debug information (CONFIG_DEBUG_INFO) [Y]
#  -> Optimize for performance (CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE) [N]
#  -> Optimize for size (CONFIG_CC_OPTIMIZE_FOR_SIZE) [N]

嗯,说到调试,我不得不提一个坑。有一次我在调试一个 USB 驱动,开了 -O2 之后,某些变量被优化掉了,gdb 里根本看不到。后来我加了 volatile 关键字才解决。所以,调试阶段千万别开高级优化。

好了,内核编译这块的内容就这些。记住,编译内核不是一锤子买卖,多试几次,找到最适合你项目的配置组合,才是正道。