ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux设备驱动开发实战:从源码包到字符设备调试

Linux设备驱动开发实战:从源码包到字符设备调试 简介《Linux设备驱动开发详解——基于最新的Linux4.0内核》配套源码包围绕Linux4.0内核环境整理面向正在学习字符设备、块设备、网络设备驱动以及中断、设备树、I2C/SPI/PCI总线与电源管理的嵌入式Linux开发人员适合作为书本示例的补充工程。压缩包共159个文件仅709KB其中既有30个.c驱动源码、12个.ko内核模块、24个.o目标文件也有36个cmd编译记录、Makefile/order/symvers/mod等构建配套便于直接对照源码完成模块编译、加载与卸载验证。代码示例覆盖globalmem、globalfifo、multi_globalmem、vmem_disk等典型场景从基本字符设备到并发访问和虚拟磁盘配合中断处理与调试脚本能帮助读者理清file_operations注册、设备节点创建、内核模块初始化和设备树解析的整体脉络。目前已有960人学习使用特别适合正在研读Linux4.0内核驱动源码、需要参考真实可编译模块的进阶开发者。1. 为什么值得把这本书的源码包翻出来从入门到能改驱动的距离Linux设备驱动开发这件事最大的门槛不是C语言而是“不知道代码该往哪儿放”。很多人买过《Linux设备驱动开发详解——基于最新的Linux4.0内核》翻完了前几章觉得懂了真到要改一个串口驱动、加一个GPIO控制时又不知道从哪个文件下手。这份源码.zip的价值就在这儿它不是书的电子版而是把书里每一章讲过的驱动样例全部整理成了可编译、可加载、可验证的工程从字符设备到并发控制再到中断处理代码都在。适合两类人一是刚入行嵌入式Linux、想在真实内核上跑通第一个驱动的初学者二是做了几年应用开发、想往内核方向转的C工程师。你不需要一台开发板用一台普通的x86 Linux机器就能把大部分样例跑起来。2. 源码包的目录骨架先认出每个驱动样例再谈编译加载2.1 按章节组织的源码目录先认样例拿到zip解压后我一般先不看README直接开目录树。这份源码包的组织方式跟书章节基本一一对应每个子目录就是一个独立的驱动工程里面最少包含一个.c源文件和一个Makefile有些还带了readme或测试脚本。典型的目录结构长这样linux-4.0-driver-src/ ├── char/ │ ├── globalmem/ │ │ ├── globalmem.c │ │ └── Makefile │ ├── globalfifo/ │ │ ├── globalfifo.c │ │ └── Makefile │ └── helloworld/ │ ├── hello.c │ └── Makefile ├── misc/ ├── platform/ ├── interrupt/ ├── timer/ ├── mm/ │ └── (内存映射样例) ├── net/ ├── usb/ ├── pci/ └── block/这里说三个最关键的目录。char/是最先要看的地方字符设备框架、设备号分配、file_operations 这些书里的核心知识点都落在这些文件里先把hello.c和globalmem.c编译加载一次后续所有驱动调试都建立在“我能让一个模块在我的机器上跑起来”这个前提上。interrupt/和timer/是第二优先中断注册、tasklet、工作队列、内核延迟这些机制光看书容易混淆看代码能一眼分清request_irq和setup_timer的调用时机。platform/目录对应设备树和平台驱动部分这块在涉及具体板卡时最有用但前提是你对platform_driver和platform_device的匹配关系有概念。还有一个容易忽略的文件每个工程里的Makefile。你不需要自己从零写直接改obj-m那一行的文件名就行。2.2 编译环境与Makefile在什么内核上跑先说结论这份源码基于Linux 4.0内核但并不意味着你必须装一个4.0的系统。只要你的内核版本不太旧3.x以上大部分样例把API对齐后就能编译过。内核模块的编译方式跟普通应用程序完全不同它依赖你当前系统内核的构建目录也就是/lib/modules/$(uname -r)/build。每个子目录下的 Makefile 基本都是这个模板obj-m : globalmem.o KERNELDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean重点解释一下这三行。obj-m : globalmem.o告诉内核构建系统“把 globalmem.c 编译成一个内核模块”如果你要把多个.c文件编成一个.ko写法会变成obj-m : mydriver.o加上mydriver-objs : file1.o file2.o。KERNELDIR指向当前内核的构建目录Debian/Ubuntu 上一般需要先安装linux-headers-$(uname -r)包否则这个目录不存在编译会直接报错。M$(PWD)是“到当前目录来找模块源码”的意思这是内核模块编译的标准姿势-C先切到内核源码目录读取全局配置再通过M切回来编译你的文件。如果你是交叉编译给ARM开发板用只需在 Makefile 里追加两行架构变量把KERNELDIR指向板级内核源码路径ARCH ? arm CROSS_COMPILE ? arm-linux-gnueabihf-这个改法在嵌入式场景里几乎是固定的。用x86机器学习时不需要这两行直接本机编译本机加载效率最高。2.3 从modinfo到insmod加载一份驱动的最小流程编译成功后你会看到一个.ko文件。加载一个内核模块的完整流程我习惯分成三个命令加一个验证步骤make modinfo ./globalmem.ko sudo insmod ./globalmem.ko dmesg | tail -20modinfo这一步很多人会跳过但它非常值钱。它会读出模块的作者、描述、依赖以及vermagic字符串。vermagic里包含内核版本号和编译时用的配置选项如果它跟你当前uname -r对不上insmod就会报Invalid module format。这一步能提前发现问题不用等insmod报错再回头查。insmod执行后没有任何输出是正常的模块初始化函数里的printk会进内核缓冲区必须用dmesg看。很多新手加载完说“没反应”其实是没看dmesg。加载完后验证设备节点是否生成cat /proc/devices | grep globalmem ls -l /dev/globalmem第一行确认主设备号有没有被注册第二行确认识别设备节点有没有被udev自动创建。如果没有/dev节点说明代码里没调class_create和device_create下一章会专门讲这条链路。3. 字符设备驱动框架从register_chrdev_region到fops的完整链路3.1 设备号分配与cdev注册注册链路里的四个关键调用字符设备驱动是内核驱动里最基础的一类也是这套源码包里占比最大的部分。一个标准字符设备的注册链路包含四个调用register_chrdev_region或alloc_chrdev_region、cdev_init、cdev_add、class_createdevice_create。看globalmem.c里的初始化函数代码结构非常典型static int __init globalmem_init(void) { dev_t devid; int ret; if (major) { /* 静态指定主设备号 */ devid MKDEV(major, 0); ret register_chrdev_region(devid, 1, globalmem); } else { /* 动态分配 */ ret alloc_chrdev_region(devid, 0, 1, globalmem); major MAJOR(devid); } if (ret 0) return ret; cdev_init(g_dev.cdev, globalmem_fops); g_dev.cdev.owner THIS_MODULE; ret cdev_add(g_dev.cdev, devid, 1); if (ret 0) goto fail_cdev_add; g_class class_create(THIS_MODULE, globalmem); device_create(g_class, NULL, devid, NULL, globalmem); return 0; fail_cdev_add: unregister_chrdev_region(devid, 1); return ret; }这里最需要理解的是major这个变量。代码开头通常会有static int major 240;的默认值如果写死成非零就走register_chrdev_region静态注册抢占一个固定的主设备号如果设为0就走alloc_chrdev_region让内核自动分配返回值在devid里再用MAJOR(devid)取出来。内核里推荐用动态分配因为静态分配会跟其他驱动冲突/proc/devices里能看到已经被占用的主设备号新手选了一个已经被占用的号register_chrdev_region会直接返回-EINVAL。cdev_add的第三个参数是设备号个数这里只注册了一个次设备号。如果想支持多个同类设备这里要写设备数量并且device_create要循环创建多个节点。很多人在/dev下只看到一个设备文件就是这里只传了1的原因。3.2 file_operations回调open、read、write各自该做什么用户态的程序对/dev/globalmem执行open、read、write时最终会落到这个结构体注册的回调函数上static const struct file_operations globalmem_fops { .owner THIS_MODULE, .open globalmem_open, .read globalmem_read, .write globalmem_write, .release globalmem_release, };globalmem_open的实现惯用container_of从inode反推出设备结构体然后挂到filp-private_data上static int globalmem_open(struct inode *inode, struct file *filp) { struct globalmem_dev *dev container_of(inode-i_cdev, struct globalmem_dev, cdev); filp-private_data dev; return 0; }这段代码看起来绕但它是驱动开发的固定套路。inode-i_cdev指向的就是cdev_add时注册的字符设备对象它被嵌在globalmem_dev这个结构体的某个字段里通常是cdev所以container_of能从cdev字段的地址倒推出整个设备结构体的起始地址。拿到这个地址后存进filp-private_data后面read、write、ioctl全部通过filp-private_data访问设备数据这样多个进程打开同一个设备时数据不会串。read和write的骨架如下static ssize_t globalmem_read(struct file *filp, char __user *buf, size_t size, loff_t *ppos) { struct globalmem_dev *dev filp-private_data; if (copy_to_user(buf, dev-mem, size)) return -EFAULT; return size; } static ssize_t globalmem_write(struct file *filp, const char __user *buf, size_t size, loff_t *ppos) { struct globalmem_dev *dev filp-private_data; if (copy_from_user(dev-mem, buf, size)) return -EFAULT; return size; }注意这里必须用copy_to_user/copy_from_user不能直接memcpy。原因在于内核和用户态处于不同的地址空间用户态传入的buf指针在进入内核后依然是用户态地址直接解引用会触发页错误甚至导致内核崩溃。这两个函数内部会做地址合法性校验和数据拷贝返回值非0表示有copy失败的字节数惯例是返回-EFAULT。这个知识点在面试里几乎必问。3.3 设备节点的自动创建class_create与device_create的配合老式驱动里加载完模块后要自己执行mknod /dev/globalmem c 主设备号 0非常不友好。现代驱动通过udev在用户态自动创建设备节点但前提是内核驱动里注册了class和deviceg_class class_create(THIS_MODULE, globalmem); if (IS_ERR(g_class)) goto fail_class; device_create(g_class, NULL, devid, NULL, globalmem);class_create的第一个参数是模块所有者第二个参数是类名会在/sys/class/下生成一个同名目录。device_create的第一个参数就是这个类第三个参数是设备号最后一个参数是设备文件名会直接变成/dev/globalmem。udev监听内核的 uevent 事件检测到新设备后自动在/dev下创建节点。卸载时的释放顺序跟创建正好相反static void __exit globalmem_exit(void) { device_destroy(g_class, MKDEV(major, 0)); class_destroy(g_class); cdev_del(g_dev.cdev); unregister_chrdev_region(MKDEV(major, 0), 1); }如果你在rmmod后发现/dev/globalmem还在多半是device_destroy没调用或者卸载函数写得不完整。这套源码包里每个驱动模块都带完整的 exit 函数这正好是学习资源释放顺序的绝佳参照。4. 并发与延迟机制信号量、自旋锁、等待队列的源码级用法4.1 信号量与自旋锁临界区保护的选型边界驱动程序运行在进程上下文和中断上下文两种环境里临界区保护方式完全不同。这套源码包里并发控制的部分主要就是围绕信号量、互斥体和自旋锁展开。先看信号量在globalfifo里的用法。globalfifo是一个带缓冲区的字符设备它有读端和写端两个接口缓冲区是一个环形队列。整个设备结构体长这样struct globalfifo_dev { struct cdev cdev; unsigned char mem[GLOBALFIFO_SIZE]; struct semaphore sem; wait_queue_head_t r_wait; wait_queue_head_t w_wait; int read_pos; int write_pos; };初始化时用sema_init(dev-sem, 1)把信号量初始化为1相当于一把互斥锁。在写函数里加锁和解锁static ssize_t globalfifo_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { struct globalfifo_dev *dev filp-private_data; int ret; down(dev-sem); /* 把用户数据拷入环形缓冲区更新 write_pos */ ret copy_from_user(...); up(dev-sem); return ret; }信号量适合临界区执行时间长、可能睡眠的场景比如copy_from_user本身就可能触发缺页而睡眠。自旋锁则完全相反它忙等待绝不能在持锁期间调用copy_from_user、kmalloc(..., GFP_KERNEL)这类可能睡眠的函数否则内核会直接报BUG: scheduling while atomic。源码里用自旋锁保护的临界区通常很短典型场景是修改一个共享变量或链表操作static DEFINE_SPINLOCK(g_lock); static ssize_t xxx_read(...) { unsigned long flags; spin_lock_irqsave(g_lock, flags); /* 只读一个 int 变量 */ val dev-counter; spin_unlock_irqrestore(g_lock, flags); return val; }spin_lock_irqsave/spin_unlock_irqrestore是中断上下文里必须用的版本它会把当前中断状态保存到flags释放时恢复防止持锁期间被中断打断造成死锁。在普通进程上下文中用spin_lock/spin_unlock就够但为了保险大多数驱动会直接上_irqsave版本。选型边界一句话能说清会睡眠的临界区用信号量/互斥体绝对不会睡眠的短临界区用自旋锁。4.2 等待队列与阻塞IOglobalfifo的读写模型globalfifo的第二大看点是等待队列它实现了真正意义上的阻塞读和阻塞写。用户态cat /dev/globalfifo时如果缓冲区为空读进程应该挂起而不是直接返回0写进程在缓冲区满时也应该挂起等待空间释放。读端的核心逻辑static ssize_t globalfifo_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct globalfifo_dev *dev filp-private_data; int ret 0; down(dev-sem); while (dev-read_pos dev-write_pos) { /* 缓冲区空 */ up(dev-sem); if (filp-f_flags O_NONBLOCK) { return -EAGAIN; } /* 睡眠等待写端唤醒 */ if (wait_event_interruptible(dev-r_wait, dev-read_pos ! dev-write_pos)) return -ERESTARTSYS; down(dev-sem); } /* 从环形缓冲区拷贝数据 */ ret copy_to_user(...); up(dev-sem); /* 唤醒写端 */ wake_up_interruptible(dev-w_wait); return ret; }这段代码有几个关键点。while而不是if来判断缓冲区空因为内核里存在“虚假唤醒”必须循环确认条件满足才继续这是写等待队列的铁律。filp-f_flags O_NONBLOCK用来支持非阻塞模式当用户态用open(/dev/globalfifo, O_RDWR | O_NONBLOCK)打开时条件不满足就返回-EAGAIN这是非阻塞IO的标准返回码用户态会收到Resource temporarily unavailable。wait_event_interruptible的返回值如果是非0说明进程收到了信号这时返回-ERESTARTSYS内核会在信号处理完后自动重启这个系统调用。这个框架在真实驱动里极其常见触摸屏驱动的缓冲区读、串口驱动的数据等待底层都是同样的模型。你把globalfifo改造成虚中断驱动后写端变成中断处理函数逻辑也能直接复用。4.3 中断、tasklet与工作队列延迟机制怎么选中断处理是这本书后半部分的重头戏源码包的interrupt/目录里有完整样例。先看注册中断的接口int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev)参数含义逐个说。irq是中断号在x86上可以用cat /proc/interrupts查看已经被占用的中断号选择一个空闲的在ARM上来自设备树或板级文件。handler是中断处理函数原型是static irqreturn_t xxx_irq_handler(int irq, void *dev_id)返回值用IRQ_HANDLED表示已处理。flags里最常用的是IRQF_TRIGGER_RISING上升沿触发、IRQF_TRIGGER_LOW低电平触发和IRQF_SHARED共享中断。dev是一个自定义参数会在中断处理时传给handler共享中断场景下用于区分是哪个设备触发的中断。中断处理函数里不能做耗时操作因为它在关闭本地中断的上下文里执行。源码包里的做法是中断处理函数只做标记和唤醒真正的事留给下半部机制。下半部有三种tasklet、工作队列、软中断。tasklet基于软中断实现在硬中断返回后立即执行处于原子上下文不能睡眠工作队列则运行在进程上下文可以睡眠。看一个典型组合static void button_work_handler(struct work_struct *work) { /* 这里可以 sleep可以调用 copy_to_user */ printk(KERN_INFO button pressed: %d\n, pressed_value); } static irqreturn_t button_irq_handler(int irq, void *dev_id) { schedule_work(button_work); /* 把工作挂入系统工作队列 */ return IRQ_HANDLED; }schedule_work会把工作挂到内核默认的工作队列events上由内核线程在进程上下文执行这意味着button_work_handler里可以放心地调用会睡眠的函数。选择边界只需要快速响应的短操作用tasklet要执行耗时操作、可能睡眠用工作队列要求最低延迟的实时场景用线程化中断request_threaded_irq。5. 编译、装载与常见问题排查五条能救命的报错记录5.1 现象insmod报 Invalid module format原因几乎可以锁定是版本或配置不匹配。内核模块在编译时会往.ko文件里写入vermagic字符串加载时内核会校验它跟当前运行内核是否一致。具体表现是insmod直接拒绝dmesg里可以看到version magic 4.0.0 mod_unload ... should be 4.4.0-31-generic ...。解决确认/lib/modules/$(uname -r)/build存在不存在就安装对应内核头文件sudo apt-get install linux-headers-$(uname -r)然后重新编译。如果用的是源码包里的 Makefile注意KERNELDIR一定要指向当前运行内核的构建目录。在嵌入式开发板上跑时要用板卡厂商提供的内核源码重新编译模块不能拿 x86 上编出的.ko直接拷过去用踩过一次就会记住这个。5.2 现象编译时提示 implicit declaration of function xxx翻车最常见的位置是 API 表不匹配。源码基于 Linux 4.0而当前系统可能是 5.x 或 6.x部分函数的名称和参数变了。举例来说create_proc_read_entry在 3.10 之后被移除init_timer在新内核里被timer_setup取代set_memory_rw的参数也有所调整。编译报错只会告诉你“隐式声明”不会告诉你该用哪个新接口。解决用grep在内核源码里搜替代接口。先在/lib/modules/$(uname -r)/build下找同名函数的头文件定义确认原型再查内核树的Documentation/或drivers/里的同类驱动看新写法长什么样。这套源码包的另一个用途就是当“API 差异对照表”你不需要全部新写只改报错的那几行即可。5.3 现象open /dev/globalmem 报 No such device分两种情况。第一种是设备节点根本不存在ls /dev/globalmem看不到说明驱动里没调device_create或者udev没来得及创建。第二种是节点存在但open失败报No such device这通常是cdev_add返回了错误最常见的原因是主设备号冲突。/proc/devices里能看到已经注册的所有主设备号如果你静态指定的号已被占用注册就会失败。解决优先检查/proc/devices确认你的驱动是否在列表里。不在就说明初始化函数在中途返回了错误用dmesg看具体返回值-EINVAL基本是设备号问题-ENOMEM是内存分配失败。把静态指定改成动态分配让内核自动分配空闲主设备号能避开大多数冲突。5.4 现象read/write 返回 Bad addresscopy_to_user/copy_from_user返回非零值时驱动一般直接返回-EFAULT这就是用户态看到的Bad address。原因通常不是内核代码的问题而是用户态程序传入了无效指针、空指针或者缓冲区大小与驱动期望不符。很多驱动新手以为是内核代码崩了其实用户态传个NULL进来内核态照样报EFAULT。解决在用户态测试程序里先检查malloc返回值确认缓冲区有效。驱动端如果要给用户更明确的反馈可以把copy_from_user的返回值打印出来它表示没拷贝成功的字节数能帮你判断是长度问题还是地址问题。另外注意size参数要校验上限不然用户传个超大的count内核拷贝越界处理起来非常麻烦。5.5 现象rmmod 挂死或报 Device or resource busy模块卸载不干净是驱动开发里的常见现场。rmmod返回Device or resource busy说明模块的使用计数不为0有进程还持有该设备打开的文件。排查顺序先看进程再用fuser确认谁占着设备fuser -v /dev/globalmem lsof /dev/globalmem sudo kill -9 pid如果是模块rmmod直接卡死大概率是exit函数里死锁了常见原因是spin_lock_irqsave加锁后没有unlock就返回了或者中断处理函数里free_irq之前还有未完成的工作队列任务。这种情况下dmesg里通常有BUG: scheduling while atomic或者soft lockup的提示。我的习惯是卸载前先确认没有进程挂在设备上同时exit函数里严格按“先device_destroy再cdev_del最后unregister_chrdev_region”的顺序写少一个都会埋雷。6. 用debugfs和sysfs定位内核问题不靠printk的调试习惯调试驱动时printk是最直接但不一定是最有效的手段尤其当你需要频繁读取驱动内部状态时打日志、重启、再看日志的循环太慢了。更稳妥的做法是把驱动内部的关键变量暴露到用户态用sysfs或debugfs实时查看。这套源码包里虽然没有专门的 debugfs 章节样例但基于globalmem的结构加一个属性节点只需十几行代码。sysfs 方式是给设备添加属性文件。比如我想看globalmem_dev里的缓冲区长度和读写位置在device_create之后增加一个属性static ssize_t status_show(struct device *dev, struct device_attribute *attr, char *buf) { struct globalmem_dev *gdev dev_get_drvdata(dev); return sprintf(buf, read_pos%d write_pos%d\n, gdev-read_pos, gdev-write_pos); } static DEVICE_ATTR_RO(status); /* init 函数里 */ device_create_file(dev, dev_attr_status);DEVICE_ATTR_RO宏会生成名为dev_attr_status的属性结构体内核根据属性名自动在/sys/devices/virtual/globalmem/globalmem/下创建status文件执行cat status就能看到实时状态。驱动程序里任何全局变量都能这样暴露出来调试中断标志位、锁状态、缓冲区水位都非常直观。debugfs 更灵活不需要绑定到具体设备。在init函数里创建一个目录再用debugfs_create_u32直接映射变量地址struct dentry *dbg_dir; dbg_dir debugfs_create_dir(globalmem, NULL); debugfs_create_u32(read_pos, 0644, dbg_dir, gdev.read_pos); debugfs_create_u32(write_pos, 0644, dbg_dir, gdev.write_pos);挂载后就能直接读sudo mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/globalmem/read_pos用 debugfs 的好处是不用写show回调一个u32变量一行代码搞定新手也能快速上手。缺点是要注意/sys/kernel/debug需要 root 权限嵌入式产品上一般默认不挂载。从那以后我每次写完驱动都会顺手加一个debugfs目录把关键变量暴露出来不再靠满屏printk去猜状态。这个习惯帮我省了大量调试时间——尤其是中断触发次数和锁竞争这类问题光看日志真的很难定位。真正上手这套源码时建议你也先花半天把globalmem跑通再给它加一个status属性用自己的代码把驱动内部看透。希望帮到你。本文还有配套的精品资源点击获取
返回列表