简介:Linux内核模块是操作系统与硬件交互的核心载体,其编译过程并非独立完成,而是高度依赖内核源码树中的头文件、配置宏和符号表。随着内核版本迭代,驱动API频繁调整,老版本源码在新环境中往往遭遇隐式函数声明、version magic不匹配或未知符号等编译加载问题。理解字符设备驱动的基本框架,掌握file_operations、cdev和class_create之间的协作关系,是排查这些异常的基础。从设备号注册到自动创建节点,从proc文件到unlocked_ioctl,API迁移成为驱动开发中绕不开的实践技能。在嵌入式系统或虚拟化场景下,正确移植老驱动不仅提升开发效率,更能加深对内核机制的理解。本文以《Linux设备驱动开发详解》4.0版本源码为例,系统梳理编译环境搭建、典型报错根因及迁移到6.x内核的实用方法,帮助开发者快速完成从老旧代码到现代内核的平稳过渡。 拿到《Linux设备驱动开发详解——基于最新的Linux4.0内核》这套书的时候,绝大部分人第一件事肯定是开箱、翻目录、找到随书源码。但真实情况往往是:源码解压出来,照着README敲make,然后被一堆“implicit declaration”“version magic”或者“undefined symbol”劝退。这个场景我在这些年带人的过程中见了太多次。这套书的源码包本身是个好东西,它把字符设备、并发同步、中断、平台设备驱动这些知识点拆成了几十个能直接编译的demo,但它的年代感和内核版本绑定决定了你不能指望解压即用。这篇东西就当成一份“过来人帮你排雷”的记录,从环境搭建、字符设备框架,到实际编译报错、API迁移,最后带你看一眼怎么把4.0时代的源码搬到今天的新内核上。
1. 解压源码之后别急着make:先把4.0内核环境搭出来
1.1 源码包里的东西:目录结构决定学习顺序
随书源码一般是按章节组织的,比如chapter3_hello、chapter4_globalmem、chapter7_globalfifo这类命名,另外还可能带一个公共的头文件目录和若干Makefile。整套代码的写法有一个明显特点:越是前面的章节越朴素,动不动就是register_chrdev、printk、老的ioctl,后面章节才慢慢引入cdev、platform_driver、设备树相关的内容。
这就带来一个实际问题:如果你拿着新开发板或者新版Ubuntu自带的5.x/6.x内核去编前面的demo,大概率编译不过。因为内核API在4.0之后又经历了好几轮调整,很多老接口被删除或者改了签名。所以我的建议非常直接:不要用“本机当前内核”去配合这本书,而是专门准备一个Linux 4.0时代的环境,让源码包里的Makefile和代码能原样跑起来,你先复现,再理解,最后才谈移植。
1.2 为什驱动模块编译必须依附内核源码树
一个可加载内核模块(.ko文件)不是独立编译的。它在编译阶段需要用到内核源码树里生成的大量头文件、配置宏和脚本工具。尤其是这几类东西:
include/generated/autoconf.h:内核的配置选项会直接影响很多结构体的字段和函数的行为。include/config/:同样由内核配置生成,很多模块代码会判断CONFIG_XXX来决定编译分支。scripts/mod/modpost:用来做模块符号检查,确保你引用的EXPORT_SYMBOL真的存在。Module.symvers:记录符号版本信息,如果目标内核没有导出某个符号,或者符号版本不匹配,insmod时会直接报Unknown symbol。
所以你在Makefile里写的KDIR本质上不是“源码”,而是一棵已经配置好、接近可以直接编译模块的完整内核树。书里源码之所以要配一个4.0内核,正是因为它要保证所有示例用到的API、头文件路径、结构体字段在新旧版本之间不会出现偏差。
1.3 虚拟机、发行版还是开发板:三种环境怎么选
书里的源码分两大块。一块是纯软件类demo,也就是字符设备、内核同步、等待队列、内存分配这些,它们不依赖具体硬件,跑在虚拟机上完全没毛病。另一块是硬件相关代码,比如LED、按键、LCD、I2C、SPI驱动,这些就需要开发板或者至少QEMU模拟出来的特定外设。
个人经验最稳妥的搭配是这样:
- 准备一台虚拟机,装上Ubuntu 16.04或者CentOS 7这类自带4.x内核的发行版。如果系统内核版本高于4.0也没关系,但尽量选4.4/4.15这个阶段,API差别还小,手动处理起来不太痛苦。
- 再准备一套4.0内核源码,从kernel.org下载linux-4.0.tar.xz,解压到
/usr/src/linux-4.0,然后做一次默认配置,编译出可用的Module.symvers和头文件。 - 如果觉得交叉编译麻烦,先不碰板子,直接在虚拟机里用
gcc编x86架构的内核模块,insmod/rmmod验证。这对前期学习字符设备完全够用。
很多新人喜欢一头扎进ARM开发板,想“一步到位把驱动跑在真实硬件上”。我的看法是,板子上的问题往往是双份的:一份是驱动本身的问题,一份是交叉工具链、bootloader、设备树、内核配置的问题。两件事混在一起,你很难判断到底是哪里错了。先把书里的纯软件demo在虚拟机上跑通,建立起“加载模块→创建设备节点→应用层读写”的完整闭环,再上板子,效率会高很多。
2. 字符设备驱动框架:从源码demo到把原理彻底吃透
2.1 最小模块骨架:这部分建议直接背下来
书里最早的Hello模块差不多长这样,但是这里我给它加上一点注释,让你知道每个东西是干嘛用的:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init hello_init(void) { printk(KERN_INFO "hello: module loaded\n"); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO "hello: module unloaded\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple module");__init和__exit这两个宏值得说清楚。__init表示该函数仅在模块加载期间使用,加载完成后这段内存可以被释放,操作系统在启动内核时对很多初始化函数也做同样的处理。__exit表示该函数只在模块卸载时用到,如果你把模块编译进内核而不是编译成.ko,这个段根本不会链接进去。另外,MODULE_LICENSE("GPL")不是走形式,很多内核符号只对GPL代码导出,非GPL模块引用它们时直接链接失败。
新手最容易犯的错是把printk当成printf用,以为打印级别无所谓。实际上printk的级别决定了日志是否显示在控制台上,级别低于console_loglevel的日志会被丢弃或延迟。调试最直观的方式是printk(KERN_DEBUG ...),然后通过dmesg查看。
2.2 设备号、cdev和class_create:字符设备的完整生命周期
书里在字符设备章节会讲两种注册方式。老式的是register_chrdev,一次注册一大片设备号,优点是一行代码搞定,缺点是设备号固定、不灵活、不支持超过256个次设备号。新式的是用cdev接口,配合alloc_chrdev_region动态分配主设备号。这里的核心逻辑是:设备号只是用户空间访问内核对象的入口编号,真正干活的是file_operations。
凑一个能跑的最小字符设备demo,伪代码如下:
#include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> static int major; static struct class *cls; static struct device *dev; static struct cdev cdev; static char data_buf[128] = "hello from kernel\n"; static int demo_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { size_t len = strlen(data_buf); if (*ppos >= len) return 0; if (count > len - *ppos) count = len - *ppos; if (copy_to_user(buf, data_buf + *ppos, count)) return -EFAULT; *ppos += count; return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { if (count >= sizeof(data_buf)) count = sizeof(data_buf) - 1; if (copy_from_user(data_buf, buf, count)) return -EFAULT; data_buf[count] = '\0'; return count; } static const struct file_operations fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, .write = demo_write, }; static int __init demo_init(void) { dev_t devno; alloc_chrdev_region(&devno, 0, 1, "demo_dev"); major = MAJOR(devno); cdev_init(&cdev, &fops); cdev.owner = THIS_MODULE; cdev_add(&cdev, devno, 1); cls = class_create(THIS_MODULE, "demo_class"); dev = device_create(cls, NULL, devno, NULL, "demo_dev"); return 0; } static void __exit demo_exit(void) { dev_t devno = MKDEV(major, 0); device_destroy(cls, devno); class_destroy(cls); cdev_del(&cdev); unregister_chrdev_region(devno, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");重点理解class_create和device_create的作用。它们不是必需的内核机制,而是为了让用户空间的udev或者内核的devtmpfs能在/dev下自动生成设备节点。如果没有它们,你就算加载了模块,用户态用open("/dev/demo_dev")打不开,因为根本没这个文件。老驱动里经常要求你手动mknod,一方面是因为当时代码写得糙,另一方面是设备节点的自动管理机制还不完善。
2.3 file_operations里的每个回调,背后都对应一次系统调用
file_operations是这个框架的灵魂。open对应应用层的open(),read对应read(),write对应write(),unlocked_ioctl对应ioctl()。这些回调都运行在进程上下文,所以理论上可以睡眠,可以调用copy_to_user/copy_from_user来访问用户空间的内存。
读接口的返回值要特别小心:返回0表示读到文件末尾(EOF),返回正数表示读取的字节数,返回负数是错误码。很多新手在read里不管ppos指针,每次都返回固定长度,导致应用层循环读取时要么死循环、要么读到多余数据。上面demo里*ppos的处理方式可以说是字符设备几乎通用的模板,建议先照着抄,理解后再考虑改动。
3. 书里源码编译不过的几类典型报错:先把这些原因摸清
3.1 隐式函数声明:老API换新壳子的典型症状
4.0时代的书里源码如果搬到新内核上,最先炸的就是隐式函数声明。比如create_proc_read_entry在4.0就已经被proc_create替代,再比如老的.ioctl回调在2.6.36之后改成了.unlocked_ioctl。编译器报错往往是:
error: assignment to ‘long int (*)(struct file *, unsigned int, long unsigned int)’ from incompatible pointer type或者:
warning: implicit declaration of function ‘create_proc_entry’这其实是个好消息,说明你用的内核版本比书里的代码新,API变了,但功能还在。处理办法就是去内核源码里搜新接口,把旧调用换成新调用。后面我会专门给一张API对照表。
3.2 version magic不匹配:insmod阶段最经典的错误
模块编译通过不代表能加载成功。最常见的加载错误是这个:
insmod: ERROR: could not insert module hello.ko: Invalid module format多敲一条dmesg | tail,你会看到:
version magic '4.0.0+ mod_unload ' should be '4.4.0-31-generic '意思很简单:你在A内核树下编译的模块,想加载到B内核里,两者版本字符串不一致。uname -r显示的版本,和/lib/modules/$(uname -r)/build指向的内核源码树版本,必须严格对上。所以我在前面第1章反复强调:在虚拟机里先确认uname -r和KDIR指向的源码是同一个版本,否则后面一定出问题。
3.3 链接阶段出现Unknown symbol:模块导出符号没对齐
有些demo会用到书里自定义的导出符号,比如globalmem驱动被其他模块引用。如果两个模块不是在同一棵内核树下编译的,加载后会出现:
hello: Unknown symbol xxx (err 0)这种问题一半是内核树版本不一致,一半是模块加载顺序不对。先insmod提供符号的模块,再insmod依赖符号的模块,这跟动态库依赖的顺序是一个道理。
4. 把书里demo搬到开发板之后的三个真实坑
4.1 主机内核配置与开发板内核配置不一致
有段时间我习惯在PC上把模块编译好,再拷到板子上加载。如果PC上的内核源码树是通用发行版配置,而板子上的内核是厂家裁剪过的,很多配置项对不上,比如CONFIG_PREEMPT、CONFIG_SMP、CONFIG_MODULE_UNLOAD这些,都会导致vermagic串不一样。最后我不走捷径了,直接在开发板的源码树根目录下编译模块,把KDIR指向板子对应的内核源码路径,彻底绕开这个坑。
4.2 设备节点没自动创建,应用打不开设备
书里的很多demo只用register_chrdev注册设备号,并没有实现class_create和device_create。在老的系统里,这确实没关系,因为早期开发人员会手动mknod。但现在的嵌入式系统大量使用udev或者devtmpfs,如果驱动没有创建device,并且/dev下没有预建节点,应用层open自然失败。
解决办法有两个方向。临时方向是看/proc/devices拿到主设备号,然后sudo mknod /dev/test c 240 0手工创建。正规方向是补上class_create和device_create调用,让内核自动生成节点。建议优先采用后者,这才是现代驱动标准写法。
4.3 copy_to_user没问题,read却总返回0
这种事情很隐蔽。驱动里明明把数据塞进data_buf,读取时也用copy_to_user拷贝了,但应用层read结果显示为空。原因十有八九是read回调里的count/ppos处理方式不对。经典的错误写法是:
static ssize_t bad_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { copy_to_user(buf, data_buf, strlen(data_buf)); return 0; // 返回0,应用层认为读到EOF }这样copy_to_user确实执行了,但返回0让用户态认为没读到头,数据也丢了。更麻烦的是,如果count比strlen(data_buf)小,直接拷贝就会越界。所以我前面提供的那个带ppos和count判断的版本才是所有字符设备驱动的通用范式。你可以讽刺地我踩过这个坑,所以才把这段当成重点写出来。
5. 从4.0到6.x:教你一套靠谱的驱动迁移思路
5.1 迁移第一步,先查这个驱动到底用了哪些内核API
拿到一个老驱动,不要急着改代码。先把源文件里的内核API找出来,可以用grep或者cscope,把带linux/头文件、EXPORT_SYMBOL、struct xxx_ops出现的地方全部列出来。然后去当前内核源码对应的include/和Documentation/目录下查这些接口是否还存在。内核源码目录下的Documentation/process/里甚至有专门讲API变更的文档,比如deprecated.rst、stable-api-nonsense.rst,先翻一遍会省很多事。
5.2 常用API迁移对照表
这里我整理了一份4.0到6.x常见变化的速查表,适合拿到手就对照着改:
| 功能 | Linux 4.0时代写法 | 新内核主流写法 | 备注 |
|---|---|---|---|
| 字符设备注册 | register_chrdev/alloc_chrdev_region + cdev_add | alloc_chrdev_region + cdev_add | 老的register_chrdev不建议再使用 |
| 设备类创建 | class_create(THIS_MODULE, "name") | class_create("name") | 4.0之后THIS_MODULE参数被移除 |
| proc文件创建 | proc_create("name", mode, parent, &fops) | proc_create("name", mode, parent, &proc_ops) | 4.x后期到6.x用struct proc_ops包装回调 |
| ioctl | .ioctl = xxx_ioctl | .unlocked_ioctl = xxx_ioctl | 老.ioctl在2.6.36之后就不再使用 |
| 初始化互斥量 | init_MUTEX(&sem) | sema_init(&sem, 1)/DEFINE_SEMAPHORE | init_MUTEX早已删除 |
| 等待队列 | init_waitqueue_head | init_waitqueue_head基本不变 | 但要注意wait_event_interruptible返回值处理 |
| 内核日志 | printk(KERN_INFO ...) | pr_info(...)/dev_info(...) | 标准规范,推荐dev_* |
| 模块参数权限 | module_param(name, type, 0666) | module_param(name, type, 0644) | 内核不再允许任意权限位暴露参数 |
表里的proc_ops变化值得多说一句。4.0时代的proc_create接收的是struct file_operations *,因为proc文件本质上也是一种文件。后面内核改成struct proc_ops *,专门提供.proc_read、.proc_write等回调,意图是明确proc文件的特殊语义,避免和通用文件操作混在一起。你迁移时只要把file_operations换成proc_ops,把字段名从.read改成.proc_read,.write改成.proc_write,基本就解决了。
5.3 一个简化的迁移实例:字符设备+proc结合
假设老代码是这样:
static const struct file_operations proc_fops = { .owner = THIS_MODULE, .read = proc_read, }; proc_create("demo_info", 0, NULL, &proc_fops);在6.x内核下的迁移版本:
static int proc_read(char *buffer, char **start, off_t offset, int count, int *peof, void *data) { return snprintf(buffer, count, "major=%d\n", major); } static const struct proc_ops proc_fops = { .proc_read = proc_read, }; proc_create("demo_info", 0444, NULL, &proc_fops);注意几个细节:新内核的proc_read回调里加入了void *data参数,用来接收proc_create最后一个参数传入的私有数据;权限位从0改成0444,更符合“只有读权限”的语义。迁移完后重新编译,模块就能在新内核上正常加载了。
6. 我的一个实用经验:源码的价值不在“能跑”,而在改动过程中的磕磕绊绊
最后说点我在实际工作中带新人最深的一点体会。很多人拿到这本书的源码,追求的是“我把所有demo跑通了”,然后觉得自己会写驱动了。但真正让我进步最大的时刻,反而是那些“编译不过、加载失败、设备节点不出现”的时刻。每一次报错都在逼着我去看内核源码,去理解file_operations里每个回调的语义,去搞清楚cdev_add和device_create之间的配合关系。
所以如果你现在还卡在某个报错上,别急着换环境、换工具链、甚至换成另一本书。先停下来,跟着dmesg的输出逐行排查,把所有可疑点列成清单。比如:KDIR路径对不对?uname -r和编译用源码版本是否一致?设备节点是否由内核自动生成还是需要手动mknod?结构体字段名是否在当前内核中还存在?这些其实就是真正驱动开发工作中的常规排查思路。
等你把书里一个简单的字符设备驱动完整地改到新内核上,并且成功让用户态程序读写数据,那种成就感比把全部例子跑通还要大得多。因为那意味着你已经开始拥有“移植”和“排错”的能力,而这两样,才是嵌入式Linux驱动日常工作里最值钱的部分。
本文还有配套的精品资源,点击获取