2. ARM架构基础
说实话,内核编译这件事,很多工程师把它想得太玄乎了。其实说白了,就是把一堆 C 代码变成能在 ARM 板上跑起来的二进制文件。但这里面的门道,远不止敲个 make 那么简单。
我刚开始做驱动移植那会儿,就吃过一次亏。编译出来的内核死活起不来,折腾了两天才发现——Kconfig 里有个依赖没选上。从那以后,我对这套编译体系就再也不敢马虎了。
内核编译流程:从 .config 到 zImage
整个编译流程,我习惯把它拆成四个阶段:
- 配置阶段——生成
.config文件,决定哪些功能进内核、哪些编成模块 - 依赖解析——根据
.config生成头文件依赖树 - 编译阶段——逐文件编译成
.o目标文件 - 链接阶段——把所有目标文件链接成
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 文件要么编入内核,要么编成模块,不能同时出现在两个变量里。
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
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
/sys/module/ 下动态修改。设为 0 表示只读,加载后就不能改了。调试阶段我习惯设成 0644,方便运行时调整。
知识体系总览
下面这张图把本章的核心脉络串起来了:
这张图把整个知识体系分成了左右两条主线。左边是内核编译流程,从源码到 zImage 的四步走;右边是模块机制,从编写到加载的完整链路。底部是 Kconfig 和 Makefile,它们像胶水一样贯穿始终。
嗯,说到这我想起一个事。有一次我在调试一个 USB 驱动,模块加载总是报 Unknown symbol 错误。查了半天,发现是模块依赖的另一个模块没编译进去。从那以后,我每次编译前都会先跑一遍 make kernelversion 确认源码版本,再用 make help 看看有哪些可用的编译目标。这些小习惯,能省下大把排查时间。