4、第一个驱动:Hello World模块
终于到了动手写代码的时候了。说实话,每次带新人入门,我都会让他们从 Hello World 模块开始。这不是走形式——你想想看,一个能正常加载和卸载的内核模块,背后涉及了编译环境、内核头文件、Makefile 语法、模块生命周期这几个关键环节。任何一个地方出问题,insmod 都会直接报错。
我个人习惯把第一个模块当作「驱动开发的体检」。它能帮你确认三件事:交叉编译链好不好使、内核源码树有没有配错、目标板能不能正常接收模块。这三件事但凡有一个没搞定,后面写再多代码都是白搭。
4.1 模块编写基础
一个最简单的内核模块,其实就两个函数:module_init 和 module_exit。前者在 insmod 时被调用,后者在 rmmod 时被调用。内核不像应用程序有 main 函数,它通过这两个宏来注册入口和出口。
来看代码。我习惯把模块文件命名为 hello.c,简单直接:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, Zynq World!\n");
printk(KERN_INFO "Module loaded successfully.\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, Zynq World!\n");
printk(KERN_INFO "Module unloaded.\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World module for Zynq");
MODULE_VERSION("1.0");
这里有几个细节我得提一下:
__init和__exit是内核的优化标记。带__init的函数在初始化完成后,内核会释放它占用的内存。带__exit的函数在模块被编译进内核(而非模块)时会被忽略。说白了,就是帮内核省点内存。printk是内核空间的打印函数,相当于用户空间的 printf。但它有日志级别,KERN_INFO表示信息级日志。如果不指定级别,默认会打到KERN_WARNING,你可能会在终端看到一堆方括号开头的输出。MODULE_LICENSE("GPL")不是摆设。如果不声明 GPL,内核会认为你的模块是「污染内核」,某些内核符号就不让你用。我在项目中遇到过因为忘记加 GPL 声明,导致symbol lookup error的坑。
4.2 Makefile 编写
写好了 C 文件,接下来就是 Makefile。内核模块的编译方式和普通应用程序完全不同——它依赖内核源码树提供的 Kbuild 系统。
我个人习惯把 Makefile 写得尽量通用,方便以后复用。下面是针对 Zynq 平台的 Makefile:
# 指定交叉编译工具链前缀
CROSS_COMPILE = arm-xilinx-linux-gnueabi-
# 指定内核源码树路径,根据你的实际环境修改
KDIR = /home/user/xilinx/linux-xlnx
# 当前目录
PWD := $(shell pwd)
# 模块名称
obj-m := hello.o
all:
$(MAKE) -C $(KDIR) M=$(PWD) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) clean
这里有几个关键点:
| 变量/参数 | 说明 | 常见错误 |
|---|---|---|
CROSS_COMPILE |
交叉编译工具链前缀,Zynq 通常用 arm-xilinx-linux-gnueabi- | 路径没配到 PATH 里,导致找不到编译器 |
KDIR |
内核源码树路径,必须指向已经配置好的内核源码 | 指向了只解压没配置的内核,编译会报 missing .config |
ARCH=arm |
指定目标架构为 ARM | 忘记加这个参数,默认用 x86 架构的头文件 |
obj-m := hello.o |
告诉 Kbuild 要编译一个名为 hello.ko 的模块 | 文件名拼写不一致,编译出来是空的 .ko |
编译时,在 hello.c 和 Makefile 所在目录下直接执行 make。如果一切顺利,你会看到:
$ make
make -C /home/user/xilinx/linux-xlnx M=/home/user/driver/hello ARCH=arm CROSS_COMPILE=arm-xilinx-linux-gnueabi- modules
CC [M] /home/user/driver/hello/hello.o
Building modules, stage 2.
MODPOST 1 modules
CC /home/user/driver/hello/hello.mod.o
LD [M] /home/user/driver/hello/hello.ko
生成 hello.ko 文件,这就是我们要的内核模块。
scripts/Makefile.build,报了一堆莫名其妙的错误。记住,KDIR 必须指向完整的内核源码,不是 /usr/src/linux-headers-xxx。
4.3 模块加载与卸载
编译好的 hello.ko 需要传到 Zynq 目标板上。我一般用 scp 或者 NFS 挂载的方式。假设你已经通过串口或 SSH 登录到目标板:
# 加载模块
insmod hello.ko
# 查看已加载的模块
lsmod | grep hello
# 卸载模块
rmmod hello
如果 insmod 没有报错,说明模块加载成功。但你看不到任何打印信息——printk 的输出默认不会直接显示在终端上。别急,下一节告诉你怎么看。
这里有个常见的坑:如果你在开发板上执行 insmod hello.ko 时提示 Exec format error,大概率是交叉编译的架构不对。检查一下 Makefile 里的 ARCH=arm 和 CROSS_COMPILE 是否正确。
file hello.ko 命令查看模块的架构信息。如果是 ARM 架构,输出应该包含 ARM 或 aarch32 字样。
4.4 查看内核日志
printk 的输出会写入内核的环形缓冲区。查看这个缓冲区有几种方式:
# 查看所有内核日志
dmesg
# 只查看最近的日志
dmesg | tail -20
# 实时监控内核日志(类似 tail -f)
dmesg -w
加载 hello 模块后,执行 dmesg | tail -5,你应该能看到类似这样的输出:
[ 12.345678] Hello, Zynq World!
[ 12.345680] Module loaded successfully.
卸载模块后:
[ 15.123456] Goodbye, Zynq World!
[ 15.123458] Module unloaded.
方括号里的数字是系统启动后的秒数。如果你觉得 dmesg 的输出太杂乱,可以用 dmesg -n 1 只显示紧急级别的日志,或者用 grep 过滤关键字。
4.5 知识体系图
下面这张图总结了 Hello World 模块从编写到验证的完整流程:
4.6 常见问题与排查
第一次编译内核模块,几乎每个人都会遇到问题。我把最常见的几个列出来:
| 错误现象 | 可能原因 | 解决方法 |
|---|---|---|
make: *** /path/to/kernel: No such file |
KDIR 路径错误 | 检查 KDIR 是否指向内核源码根目录 |
arch/arm/Makefile: No such file |
ARCH 参数缺失或内核未配置 | 确保 ARCH=arm,且内核已执行过 make menuconfig |
insmod: ERROR: could not insert module |
模块与内核版本不匹配 | 用 modinfo hello.ko 查看 vermagic,与目标板 uname -r 对比 |
Exec format error |
架构不对,编译成了 x86 | 检查 CROSS_COMPILE 和 ARCH 是否设置正确 |
| dmesg 看不到任何输出 | 日志级别太高被过滤 | 用 dmesg -n 7 降低控制台日志级别 |
loglevel 给过滤了。在 U-Boot 的 bootargs 里加上 loglevel=8 就能看到所有级别的内核日志。
好了,到这里你已经完成了第一个内核模块的编写、编译、加载和验证。虽然只是打印了两行字,但这套流程——编写代码、配置 Makefile、交叉编译、传输目标板、insmod、dmesg——是后面所有驱动开发的基础。后面的章节里,我们会在这个框架上不断增加新的内容:设备树、字符设备、中断、DMA……但核心的「编辑-编译-部署-验证」循环,永远不会变。