2. ARM架构基础

说实话,内核编译这件事,很多工程师把它想得太玄乎了。其实说白了,就是把一堆 C 代码变成能在 ARM 板上跑起来的二进制文件。但这里面的门道,远不止敲个 make 那么简单。

我刚开始做驱动移植那会儿,就吃过一次亏。编译出来的内核死活起不来,折腾了两天才发现——Kconfig 里有个依赖没选上。从那以后,我对这套编译体系就再也不敢马虎了。

内核编译流程:从 .config 到 zImage

整个编译流程,我习惯把它拆成四个阶段:

  1. 配置阶段——生成 .config 文件,决定哪些功能进内核、哪些编成模块
  2. 依赖解析——根据 .config 生成头文件依赖树
  3. 编译阶段——逐文件编译成 .o 目标文件
  4. 链接阶段——把所有目标文件链接成 vmlinux,再压缩成 zImage

你想想看,一个典型的内核编译命令长这样:

# 清理旧配置
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- distclean

# 加载默认配置(以 vexpress 开发板为例)
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- vexpress_defconfig

# 菜单配置(可选,用于微调)
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig

# 正式编译
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage -j4

# 编译设备树
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- dtbs

这里有个细节我特别想强调:-j4 这个参数。它指定并行编译的线程数,一般设成 CPU 核心数的 1.5 到 2 倍。设太小编译慢,设太大反而会因为上下文切换过多而变慢。

我的习惯:每次编译前先跑 make ARCH=arm CROSS_COMPILE=... savedefconfig,把当前配置精简保存。这样下次换内核版本时,直接 make defconfig 就能恢复,省得重新选一堆选项。

Kconfig 与 Makefile 语法:驱动工程师的必修课

Kconfig 和 Makefile,说白了就是内核的「菜单」和「菜谱」。Kconfig 决定哪些菜能点,Makefile 决定菜怎么做。

Kconfig 语法核心

一个典型的 Kconfig 条目长这样:

config MY_DRIVER
    tristate "My Custom Driver Support"
    depends on ARM
    select SPI
    help
      This is a custom driver for ARM platform.
      Say Y here to compile it into kernel,
      M to build it as a module.

这里 tristate 是重点——它表示三种状态:Y(编入内核)、M(编成模块)、N(不编译)。如果是 bool 类型,就只有 Y 和 N 两种选择。

我在项目中遇到过最坑的情况是 depends on 和 select 用反了。depends on 是「依赖」,如果依赖项没选,这个选项就不可见;select 是「反向选择」,选了当前项会自动选上被 select 的项。用反了会导致依赖链断裂,编译报错。

关键字 含义 示例
tristate 三态选择(Y/M/N) 驱动、文件系统等可模块化功能
bool 二态选择(Y/N) 架构特性、调试开关等
depends on 前置依赖 depends on ARM
select 反向依赖 select SPI
default 默认值 default y

Makefile 中的编译控制

内核的 Makefile 写法跟普通应用层程序不太一样。它用的是 obj-y、obj-m 这种特殊变量:

# 编入内核
obj-y += my_driver.o

# 编成模块
obj-m += my_driver.o

# 带条件编译
obj-$(CONFIG_MY_DRIVER) += my_driver.o

# 多文件模块
my_driver-objs := core.o ioctl.o probe.o
obj-$(CONFIG_MY_DRIVER) += my_driver.o

嗯,这里要注意:obj-y 和 obj-m 不能混用。同一个 .o 文件要么编入内核,要么编成模块,不能同时出现在两个变量里。

避坑指南:我曾经在移植一个 SPI 驱动时,把 obj-$(CONFIG_SPI_MY) 写成了 obj-$(CONFIG_MY_SPI)。Kconfig 里定义的是 CONFIG_SPI_MY,Makefile 里却用了不同的命名。编译没报错,但驱动就是没编进去。查了两小时才发现是名字对不上。

内核模块编写与加载:实战驱动框架

写一个内核模块,其实就三步:入口、出口、许可证声明。我给你们看一个最简模板:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init my_module_init(void)
{
    printk(KERN_INFO "My module loaded on ARM\n");
    return 0;
}

static void __exit my_module_exit(void)
{
    printk(KERN_INFO "My module unloaded\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple ARM driver module");

编译这个模块的 Makefile 也很固定:

obj-m := my_module.o

KDIR := /path/to/kernel/source
PWD := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

加载和卸载的命令:

# 加载模块
insmod my_module.ko

# 查看已加载模块
lsmod

# 卸载模块
rmmod my_module

# 查看内核日志
dmesg | tail
注意:在 ARM 嵌入式板上,insmod 和 modprobe 有区别。insmod 只加载指定模块,不处理依赖;modprobe 会自动加载依赖模块。我建议调试时用 insmod,正式部署用 modprobe。

模块参数与依赖:让驱动更灵活

模块参数允许你在加载时动态传值,不用重新编译。这个功能我在调试 GPIO 驱动时经常用:

#include <linux/moduleparam.h>

static int gpio_pin = 17;
static char *device_name = "my_device";

module_param(gpio_pin, int, 0644);
module_param(device_name, charp, 0644);

MODULE_PARM_DESC(gpio_pin, "GPIO pin number");
MODULE_PARM_DESC(device_name, "Device name string");

加载时传参:

insmod my_driver.ko gpio_pin=23 device_name="test_dev"

模块依赖这块,内核通过 MODULE_DEVICE_TABLE 和符号导出机制来管理。如果你的模块 A 调用了模块 B 导出的函数,那加载 A 之前必须先加载 B。

查看依赖关系可以用:

# 查看模块依赖
modinfo my_module.ko

# 查看已加载模块的依赖链
lsmod | grep my_module
我的经验:模块参数的文件权限位(0644)决定了这个参数是否能在 /sys/module/ 下动态修改。设为 0 表示只读,加载后就不能改了。调试阶段我习惯设成 0644,方便运行时调整。

知识体系总览

下面这张图把本章的核心脉络串起来了:

内核编译与模块机制知识体系 内核编译与模块机制知识体系 Linux 内核源码 内核编译流程 ① 配置阶段 (.config) ② 依赖解析 ③ 编译 (.o 目标文件) ④ 链接 → zImage 内核模块机制 模块编写 (init/exit) 模块加载 (insmod/modprobe) 模块参数与依赖 符号导出与依赖链 Kconfig + Makefile 贯穿全流程 Kconfig 定义「能不能编」→ Makefile 定义「怎么编」→ 编译系统执行「编出来」

这张图把整个知识体系分成了左右两条主线。左边是内核编译流程,从源码到 zImage 的四步走;右边是模块机制,从编写到加载的完整链路。底部是 Kconfig 和 Makefile,它们像胶水一样贯穿始终。

嗯,说到这我想起一个事。有一次我在调试一个 USB 驱动,模块加载总是报 Unknown symbol 错误。查了半天,发现是模块依赖的另一个模块没编译进去。从那以后,我每次编译前都会先跑一遍 make kernelversion 确认源码版本,再用 make help 看看有哪些可用的编译目标。这些小习惯,能省下大把排查时间。