资讯动态

Linux设备驱动开发:从4.0内核配套代码到工程实践

发布时间:2026/9/13 2:22:42 来源:尧图企业网站定制
简介这是《Linux设备驱动开发详解——基于最新的Linux4.0内核》一书的配套代码包面向嵌入式Linux开发学习者、驱动工程师及内核源码爱好者。代码以learn-ldd-master-main目录组织覆盖字符设备、块设备、网络设备等典型驱动并围绕设备模型、驱动注册、中断处理、DMA传输、I/O端口与内存映射、file_operations、模块化加载与卸载、udev规则、sysfs/procfs接口及电源管理等核心主题展开。压缩包共274个文件、约24.56MB以148个C源码、27个头文件和29个Makefile构建脚本为主辅以25个Markdown说明文档与若干内核源码包便于对照阅读、编译和二次修改。已有1013人学习下载。通过源码、Makefile和笔记的配合读者既可循序渐进地理解Linux4.0内核与硬件的交互机制也能在实际项目中借鉴成熟的驱动框架与调试思路是一份兼具教学与工程参考价值的配套资料。1. 在Linux 4.0内核时代配套代码仍是设备驱动入门的快速路径很多人下载《Linux设备驱动开发详解——基于最新的Linux 4.0内核》的配套代码zip包第一反应是先解压然后盯着目录发呆几十个文件夹、几百个源文件README写得模棱两可Makefile里的变量看着眼熟却不知道从哪编译起。更麻烦的是自己机器上的内核版本可能早不是4.0编译时满屏报错于是这个zip包很快被扔进硬盘角落。实际上这套配套代码的价值恰恰不在“4.0”这个版本号而在于它把字符设备、平台设备、设备树、中断、等待队列、i2c这六类驱动骨架用最少的代码量讲清楚了。这篇文章会按“框架—编译—机制—调试”的顺序把这套代码真正变成你自己的工程经验即便你手上的板子跑的是更新的内核思路也一样能用。2. 字符设备驱动框架与平台设备配套代码里的两种驱动骨架2.1 先看清字符设备驱动框架的入口与出口配套代码里出现频率最高的是字符设备驱动框架在Linux 4.0时代已经非常固定模块入口里注册设备号、初始化cdev并添加到内核再通过class_create和device_create在sysfs中自动生成设备节点模块出口则逆序释放。这一套动作在配套代码的demo字符设备例程里基本是模板化的我一般会把它提炼成下面这个最小骨架#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h static int demo_major 0; static struct cdev demo_cdev; static struct class *demo_class; 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) { return 0; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, }; static int __init demo_init(void) { dev_t devno; int ret; ret alloc_chrdev_region(devno, 0, 1, demo); if (ret 0) return ret; demo_major MAJOR(devno); cdev_init(demo_cdev, demo_fops); ret cdev_add(demo_cdev, devno, 1); if (ret 0) goto err_cdev; demo_class class_create(THIS_MODULE, demo); if (IS_ERR(demo_class)) { ret PTR_ERR(demo_class); goto err_class; } device_create(demo_class, NULL, devno, NULL, demo); return 0; err_class: cdev_del(demo_cdev); err_cdev: unregister_chrdev_region(devno, 1); return ret; } static void __exit demo_exit(void) { dev_t devno MKDEV(demo_major, 0); device_destroy(demo_class, devno); class_destroy(demo_class); cdev_del(demo_cdev); unregister_chrdev_region(devno, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);这段代码里最需要理解的是alloc_chrdev_region与cdev_add的分工前者申请一个未使用的主设备号后者把字符设备真正挂到内核的设备表里。class_create和device_create的作用是在/sys/class/demo/下生成目录并在udev的配合下自动创建设备节点/dev/demo这样应用层直接open(/dev/demo)就能访问驱动。配套代码里很多例程用静态方式声明了设备号我不太建议在开发前期这么用静态指定容易和设备号冲突动态分配配合cat /proc/devices查询更省事等产品化时再固定下来。2.2 平台设备与设备树4.0时代驱动的匹配方式Linux 4.0里设备树已经是主流配套代码中老式的“在board文件里注册platform_device”写法在真实项目中基本看不到了替代方案是设备树节点加platform_driver。驱动侧的核心是of_match_table它把驱动的compatible字符串和设备树节点的compatible属性对应起来probe函数只有在匹配成功时才会被调用。#include linux/module.h #include linux/platform_device.h #include linux/of.h static const struct of_device_id demo_of_match[] { { .compatible vendor,demo-device }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_of_match); static int demo_probe(struct platform_device *pdev) { struct resource *res; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; dev_info(pdev-dev, probe ok, reg base 0x%llx\n, (unsigned long long)res-start); return 0; } static int demo_remove(struct platform_device *pdev) { return 0; } static struct platform_driver demo_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo, .of_match_table demo_of_match, }, }; module_platform_driver(demo_driver); MODULE_LICENSE(GPL);设备树里只需要一段对应的节点demo: demo1000 { compatible vendor,demo-device; reg 0x1000 0x100; interrupts 17; };platform_get_resource在这里把reg属性里的物理地址和长度打包成struct resource返回驱动后续用ioremap映射即可访问寄存器。需要留意的是MODULE_DEVICE_TABLE(of, demo_of_match)这一行它会把匹配表写进模块的.modinfo段设备模型才能知道这个模块支持哪些设备缺失这一行即使设备树里有节点驱动也可能不会被自动加载。2.3 配套代码里的Makefile与Kconfig组织方式配套代码的示例大多以独立目录组织每个目录自带一个Makefile常见写法是obj-m : demo.o KERN_DIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERN_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M$(PWD) cleanobj-m表示把demo.o编译成内核模块KERN_DIR指向内核源码树或编译好的头文件目录M$(PWD)告诉Kbuild到当前目录找目标文件。配套代码里有的例程用obj-y把源文件直接编进内核镜像这适合做功能验证但每次都重新编译内核太慢我一般改成obj-m先用模块方式调通逻辑再统一做系统裁剪时改回obj-y。2.4 zip包内代码的版本校验与目录管理解压配套代码zip包之前先确认目标环境的工具链和内核版本避免拿到一份和本地环境完全对不上的源码。常见做法是unzip Linux设备驱动开发详解_配套代码.zip cd 配套代码目录 find . -name Makefile | head -20 uname -r cat /proc/version ls /lib/modules/$(uname -r)/builduname -r输出当前内核版本号/proc/version能看到编译器和gcc版本信息/lib/modules/$(uname -r)/build指向内核头文件的符号链接。配套代码若以“Linux 4.0”命名源码里大概率用到class_create(THIS_MODULE, name)这类旧式API在6.x内核上编译会报参数个数错误这一点在下一章展开讲这里只需确认目标内核版本判断哪些例程需要做接口适配。3. 编译Linux 4.0配套代码从本机到嵌入式板卡的完整命令链3.1 先用三个命令确认编译对象拿到配套代码后第一步不是编译而是确认本地内核的版本、源码树和模块编译支持。以下三条命令基本够用uname -r cat /proc/version ls /lib/modules/$(uname -r)/build/Makefile第一条给出内核版本第二条能看到内核编译时用的gcc版本和编译时间第三条确认内核头文件完整。build目录通常是一个软链接指向/usr/src/linux-headers-$(uname -r)如果这个路径不存在说明系统只装了运行内核没装开发头文件需要先安装对应版本的linux-headers包。配套代码里有些例程依赖的配置项如CONFIG_DEBUG_FS、CONFIG_DEVTMPS在默认发行版内核里可能没打开后面编译或测试时遇到找不到文件的情况先回到这里查配置。3.2 最小内核编译与模块编译的参数差异在x86的PC上验证配套代码不必完整编译整个内核直接用发行版内核头文件编模块就行。最小操作是cd 配套代码目录/01_hello make -C /lib/modules/$(uname -r)/build M$(PWD) modules sudo insmod hello.ko dmesg | tail sudo rmmod hellomake -C切换工作目录到内核构建目录M$(PWD)指明外部模块所在路径modules是目标动作。编出来的.ko文件通过insmod加载dmesg查看内核日志确认module_init是否执行。若本机确实需要完整编一次内核来跑配套代码里的ftrace或内核裁剪章节完整命令链是make ARCHx86_64 defconfig make -j$(nproc) make modules -j$(nproc) sudo make modules_install sudo make installdefconfig生成该架构的默认配置-j$(nproc)按CPU核数并行编译modules_install把模块安装到/lib/modules/版本号/make install更新引导。完整编译内核动辄半小时以上而外部模块编译只要几秒这是两者在调试节奏上最大的区别。配套代码后的多个示例我都是一个个目录单独编译模块验证不碰整个内核树。3.3 嵌入式交叉编译与部署到板卡配套代码面向Linux 4.0很多读者手里是ARM开发板这时需要交叉编译。前提是先装好交叉工具链通常叫arm-linux-gnueabihf-*然后这样编译模块export ARCHarm export CROSS_COMPILEarm-linux-gnueabihf- make -C /path/to/kernel_source M$(PWD) modulesARCHarm告诉Kbuild目标架构是ARMCROSS_COMPILE指定交叉编译器前缀/path/to/kernel_source必须是你板卡同版本、且已经配置过至少执行过make menuconfig或make defconfig的内核源码目录。这里最常踩的坑是用了主机x86的内核头文件去编ARM模块编出来的.ko在板卡上加载时报invalid module format。部署到板卡的方式取决于硬件环境常见做法是NFS根文件系统挂载后直接insmod或者用scp把.ko拷到板卡的/lib/modules/下再depmod -a更新依赖。注意配套代码里有些例程同时涉及硬件寄存器操作这类例程必须在真实板卡上验证qemu模拟只能覆盖纯逻辑部分。3.4 编译失败时先查这三个位置配套代码编译报错九成出在三个位置。第一内核头文件路径错误或版本不匹配报错通常是找不到linux/module.h或一堆implicit declaration先确认/lib/modules/$(uname -r)/build软链接指向正确。第二源码用了过时的内核API最典型的就是class_create参数个数变化在4.0里是class_create(THIS_MODULE, demo)在近年内核里改成单参数class_create(demo)报错信息会直接指出参数数量不匹配把THIS_MODULE去掉即可。第三配置项缺失导致头文件里的某些结构体没有被定义比如用CONFIG_DEBUG_FS相关接口时没开内核配置这时要看报错文件里#ifdef包裹的内容。下表整理了这三类问题的快速定位方法现象典型报错排查位置头文件路径错误fatal error: linux/module.h: No such filebuild软链接、linux-headers包API不兼容too few arguments to function class_create内核版本差异查该API的变更记录配置未开启struct xxx has no member named yy/boot/config-$(uname -r)里查CONFIG项提示配套代码里老式写法遇到编译错误优先去include/linux/device.h看当前内核里的函数原型而不是猜测参数改一行往往就能编过。4. 等待队列、中断与i2c注册配套代码中驱动机制的三处必读4.1 等待队列与阻塞IOread函数在等什么配套代码里头一次让初学者卡住的机制通常是等待队列。它的场景很典型应用层read()一个设备设备没有数据时不能让read直接返回错误而是要睡在那里直到中断或内核其他路径把数据准备好再唤醒它。实现依赖wait_queue_head_t和一对宏static wait_queue_head_t rx_wait; static int data_ready; static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { wait_event_interruptible(rx_wait, data_ready); data_ready 0; return 0; } static void demo_push_data(void) { data_ready 1; wake_up_interruptible(rx_wait); }wait_event_interruptible做的事是检查第二个参数data_ready是否为真如果为假当前进程进入TASK_INTERRUPTIBLE睡眠状态并挂到rx_wait队列上。wake_up_interruptible会把队列里所有睡眠的进程唤醒它们重新检查条件条件满足才真正继续往下走。这种“唤醒后重新检查条件”的设计是为了应对所谓的spurious wakeup伪唤醒所以条件变量不能省。配套代码里有的版本用wake_up而非wake_up_interruptible两者的区别在于前者也会唤醒正在执行不可中断睡眠的任务但常规驱动里几乎用不到。要注意data_ready这个标志位最好加上atomic_t或锁保护否则理论上有并发竞争这点配套代码受篇幅限制未必处理你自己写的时候要补上。4.2 中断处理与底半部tasklet和workqueue怎么选配套代码里的中断例程会展示从request_irq注册中断处理函数到用tasklet或workqueue把耗时工作推迟执行的完整路径。中断上下文里不能调用可能睡眠的函数所以tasklet运行在软中断上下文和workqueue运行在进程上下文是最常用的两种底半部机制。代码骨架大致如下static void demo_tasklet_handler(unsigned long data) { /* 这里可以做加锁保护的数据处理 */ } static irqreturn_t demo_isr(int irq, void *dev_id) { tasklet_schedule(demo_tasklet); return IRQ_HANDLED; } static int demo_probe(struct platform_device *pdev) { int irq platform_get_irq(pdev, 0); tasklet_init(demo_tasklet, demo_tasklet_handler, 0); return request_irq(irq, demo_isr, IRQF_TRIGGER_RISING, demo, dev_id); }platform_get_irq从设备树节点的interrupts属性拿到中断号request_irq注册中断处理函数irq参数告诉内核中断号dev_id用于共享中断时区分设备。tasklet_schedule把底半部挂到当前CPU的tasklet链表上中断处理函数立刻返回耗时的读取FIFO、处理数据包等工作放到tasklet里。什么时候改用workqueue当底半部里需要访问i2c、spi这类可能睡眠的总线时tasklet就不够用了schedule_work配合work_struct可以把工作推到内核线程里执行代价是延迟更高、上下文切换开销更大。4.3 i2c设备驱动的注册函数与设备树节点配套代码中i2c部分的核心是把i2c_driver注册进i2c子系统并在设备树中描述设备地址。驱动侧用module_i2c_driver宏简化模板static const struct i2c_device_id demo_i2c_id[] { { demo, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, demo_i2c_id); static int demo_i2c_probe(struct i2c_client *client) { return 0; } static struct i2c_driver demo_i2c_driver { .probe demo_i2c_probe, .remove demo_i2c_remove, .id_table demo_i2c_id, .driver { .name demo, .of_match_table demo_of_match, }, }; module_i2c_driver(demo_i2c_driver);设备树里对应这样一段i2c1 { demo: demo50 { compatible vendor,demo-i2c; reg 0x50; }; };module_i2c_driver这个宏展开后会自动生成module_init和module_exit内部调用i2c_add_driver和i2c_del_driver省去手写入口函数的样板代码。id_table是纯设备ID匹配方式of_match_table走设备树compatible匹配两者可以同时存在实际匹配时设备树优先。probe函数里的client-addr就是设备树里reg指定的i2c地址驱动后续通过i2c_smbus_read_byte_data(client, reg)这样的API读写寄存器。4.4 内核同步自旋锁与互斥体的边界配套代码后半部分会出现内核同步的多个变体最常见的误用是把自旋锁和互斥体搞混。自旋锁保护的临界区不能睡眠因为持有自旋锁时进程不会被调度出去一旦在临界区里调用msleep或copy_to_user行为是不确定的轻则死锁重则整机卡死。互斥体允许睡眠适合临界区较长的场景但进程会陷入睡眠再被唤醒延迟比自旋锁高一个数量级。实际选择标准就一句话临界区里是否可能睡眠可能就互斥体不可能就自旋锁。spinlock_t lock; spin_lock_init(lock); spin_lock(lock); /* 临界区只做寄存器读写、链表操作 */ spin_unlock(lock); struct mutex mtx; mutex_init(mtx); mutex_lock(mtx); /* 临界区可能睡眠的操作 */ mutex_unlock(mtx);还有一个配套代码里容易忽略的变体是spin_lock_irqsave当中断处理函数和进程上下文共享数据时普通spin_lock会让中断处理函数等待一个睡眠中的进程释放锁造成死锁spin_lock_irqsave在获取锁的同时保存并关闭中断能规避这个风险。编写设备驱动时凡是中断和进程上下文共用的数据优先考虑spin_lock_irqsave/spin_unlock_irqrestore这对组合。5. 逆向读代码用动态调试验证设备树与probe路径配套代码的例程虽然多但阅读顺序有讲究。我建议按“hello模块—字符设备—平台设备—中断—同步—i2c”的顺序推进原因是每一级都在前一级基础上增加一个概念hello模块只展示模块加载卸载字符设备加入文件操作平台设备引入设备和驱动分离中断加入异步事件同步解决并发i2c则是一种具体总线框架。如果一上来就啃i2c例程等于同时面对总线协议、设备模型和中断三个难题很容易劝退。下面这张顺序表可以作为阅读打卡清单阶段例程类型核心学习点完成后可回答的问题1hello_modulemodule_init/exit、printk模块生命周期是什么2字符设备cdev、file_operationsopen/read如何到达驱动3平台设备设备树匹配、probe设备和驱动如何相遇4中断request_irq、tasklet中断上下文在哪受限5同步spinlock、mutex并发错乱长什么样6i2ci2c_driver注册一条总线驱动的套路调试手段上配合动态debug能快速确认代码路径是否执行。先挂载debugfs再打开具体文件的动态打印sudo mount -t debugfs none /sys/kernel/debug echo file demo.c p /sys/kernel/debug/dynamic_debug/control dmesg -n 8echo file demo.c p把demo.c里所有pr_debug和dev_dbg打开dmesg -n 8让所有级别的日志都输出到控制台这样驱动一probe相关调试信息就会出现在dmesg里。验证设备树匹配是否成功除了看dmesg里的probe ok还可以直接查/proc/device-treecat /proc/device-tree/demo1000/compatible cat /proc/device-tree/demo1000/reg ls -l /sys/class/demo/ cat /proc/devices | grep demo/proc/device-tree是设备树的运行时视图compatible文件内容应该和驱动of_match_table里的字符串完全一致不一致就说明驱动不会加载。最后/proc/devices能看到字符设备主设备号和驱动名的对应关系/sys/class/demo/下如果生成了设备目录说明device_create执行成功/dev/demo节点也应当存在。整套验证下来配套代码里哪段逻辑有问题、哪段压根没执行基本一清二楚这也是后续自己写驱动时最值得保留的排查习惯。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价