十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux设备驱动开发入门:从内核模块到字符设备实战

Linux设备驱动开发入门:从内核模块到字符设备实战 Linux 设备驱动开发一直是“想学的人多、真正学明白的人少”。原因不是这门技术没有资料而是很多入门资料一上来就贴大段内核源码读者连编译环境都没跑通就被劝退。最近看到《手把手教你学Linux设备驱动开发》正式出版的消息借着这个由头我想把设备驱动入门这条路上最影响成败的几个节点完整拆一遍。这篇文章适合两类人一是刚接触嵌入式 Linux、准备往系统底层走的开发者二是已经在做应用层开发、想搞懂内核和设备交互逻辑的工程师。最值得关注的点不是某一类驱动怎么写而是“内核模块、字符设备、真实板卡”这条学习链路怎么一步步打通。下面按我实际带人入门时的顺序来讲。1. 先想清楚学设备驱动到底在学什么1.1 驱动在内核里的位置设备驱动不是一个独立运行的应用程序而是内核里用来管理某类硬件的一段代码。它负责三件事向上给应用层提供操作硬件的接口向下读写硬件寄存器中间处理中断、内存、并发这些内核资源。很多新手容易理解偏差以为设备驱动就是“写代码控制硬件”。这个说法不算错但少了一个关键视角驱动运行在内核态它的编译方式、运行环境、出错后果和应用层程序完全不同。应用层写崩了最多进程退出内核模块写崩了可能直接宕机。所以在学代码之前先建立一个系统视角驱动是 Linux 内核的一部分它受内核的版本、配置、内存管理方式、并发模型共同约束。理解了这一点后面遇到各种莫名其妙的问题至少知道该往哪个方向查。1.2 哪些人适合走这条路结合这几年的经验适合学设备驱动的人大概有三类。第一类是嵌入式 Linux 开发者日常要接触 GPIO、I2C、SPI、UART、LCD 这些外设跑裸机程序或者用现成内核不能满足需求的时候就得自己写或改驱动。第二类是应用层工程师工作里经常遇到“设备节点打不开”“ioctl 调用失败”“read 返回异常”这类问题。如果他们能看懂驱动代码定位问题会快很多而且和驱动工程师沟通起来也不用靠猜。第三类是纯粹对操作系统感兴趣的人想通过设备驱动理解进程、内存、中断、文件系统这些概念在真实内核里是怎么协作的。如果不属于这三类只是听说设备驱动工资高、门槛高就想冲一冲我建议先冷静。这个方向需要耐心短期内看不到明显产出如果没有具体场景驱动很难坚持超过两周。1.3 驱动开发和应用开发最本质的区别应用开发的核心是“逻辑正确”驱动开发还要再加一条“环境匹配”。同一个 .c 文件在不同内核版本上编译结果可能不同甚至同一份 .config 配置变了模块加载结果也不同。应用开发可以大量使用现成库驱动开发大部分情况只能使用内核提供的 API。你不能在驱动里随便调用 glibc、不能直接用 malloc、不能轻易打印浮点数、不能在中断上下文里做耗时操作。这些限制不是故意为难人而是内核运行环境的特殊性决定的。所以入门设备驱动第一步不是急着抄代码而是把“内核态编程的约束清单”装进脑子里。后面每写一个模块都先对照这份清单过一遍能省下一大半排查时间。2. 动手前先把环境准备好省得一路踩坑2.1 选一台合适的开发机器设备驱动入门阶段不需要马上买开发板。我建议先在普通 x86 机器上装一本 Linux 系统或者用虚拟机安装 Linux把驱动模块这套流程跑通再考虑嵌入式板子和交叉编译。为什么先不用开发板因为交叉编译会引入一整套额外变量交叉工具链、内核源码版本、设备树、U-Boot、烧录方式。任何一个环节出问题都会让新手误以为是驱动代码写错了很难排查。而在本机跑模块编译和运行环境是同一个出问题的范围小很多。系统选择上Debian、Ubuntu、Rocky Linux 这类主流发行版都可以。如果追求省事建议用 Ubuntu 这种生态资料多的发行版遇到问题更容易搜到解决方案。2.2 安装内核头文件和编译工具编译一个内核模块不是只用 gcc 就能完成。模块需要引用内核源码树里的头文件和构建系统所以必须先安装与当前运行内核版本匹配的头文件包。先确认内核版本uname -rDebian/Ubuntu 下安装编译工具和内核头文件sudo apt update sudo apt install build-essential linux-headers-$(uname -r)安装完成后检查头文件目录是否存在ls /lib/modules/$(uname -r)/build这个目录正常会链接到内核源码树。如果目录不存在说明头文件没装好后面 make 基本必然报错。这里有一个非常容易踩的坑升级内核之后旧的内核模块往往无法继续加载。因为模块与内核版本是强绑定的你升级完系统发现之前编译的 .ko 文件用 insmod 加载时报错不要惊讶。重新编译一次模块即可。2.3 理解内核版本对齐这件事模块不是“一次编译到处运行”。它依赖内核内部的符号、结构和配置项。同一个模块在 5.15 编译的不一定能在 5.10 加载。甚至在同一个大版本下如果内核配置不同也可能加载失败。我见过最典型的报错是Invalid module format后面跟一串 hex 值。这种问题大多数情况下不是代码错而是用错版本的内核头文件编译了模块。所以入门阶段要养成一个习惯每次编译模块前先执行uname -r确认内核版本再看头文件目录是否存在。这个习惯能帮你避免一半以上的环境问题。3. 第一个驱动模块先跑通加载和卸载3.1 最小内核模块的结构任何内核模块至少要有两部分模块加载入口和模块卸载出口。入口函数在 insmod 时执行出口函数在 rmmod 时执行。// hello_drv.c #include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello, driver module loaded!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO driver module removed!\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL);module_init和module_exit是告诉内核这个模块的入口函数是哪个、出口函数是哪个。__init和__exit是优化标记表示这些函数只在加载或卸载时使用系统可以在合适时机释放相关内存。注意MODULE_LICENSE(GPL)。这不是可有可无的声明。内核里很多核心符号只对 GPL 协议的模块导出没有这行某些 API 链接时会直接报Unknown symbol。3.2 用 Makefile 驱动内核构建系统模块编译不能直接gcc hello_drv.c要交给内核的 Kbuild 系统处理。标准写法是obj-m : hello_drv.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean然后执行make如果环境正确会生成hello_drv.ko文件。编译过程会输出一堆内核构建日志不要被这堆输出吓到只看最后有没有error即可。3.3 加载、查看日志、卸载sudo insmod hello_drv.ko加载后查看内核日志确认入口函数有没有执行dmesg | tail能看到hello, driver module loaded!就说明模块加载成功。再卸载sudo rmmod hello_drv dmesg | tail看到driver module removed!说明出口函数也正常了。到这一步你已经把“内核模块的最小生命周期”完整跑通了。这里有个新手容易忽略的点应用层的 printf 输出到终端但驱动的 printk 输出到内核日志缓冲区。如果发现模块加载了却没有输出先检查是不是 dmesg 权限问题再看 printk 的日志级别。不要一上来就怀疑代码写错了。4. 字符设备开发让驱动真正和应用程序对话4.1 设备号、file_operations、设备节点会加载模块只是入门第一步。真正让驱动有实际价值是要让应用层能访问它。Linux 里最常见的设备类型就是字符设备比如串口、GPIO、LED 控制器都属于字符设备。字符设备的核心工作有三块申请一个设备号用来标识设备。注册一个 file_operations 结构体里面放 open、read、write、ioctl 等函数指针。创建设备节点也就是 /dev 下面的文件。应用层访问设备本质就是open(/dev/xxx)这个文件操作。VFS 会根据设备文件的主设备号找到对应的驱动然后调用驱动里注册的函数。4.2 一个最小字符设备驱动下面是简化版示例先不要急着改复杂功能只做 open 和 read 两个接口。#include linux/init.h #include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #define DEVICE_NAME mydemo static int major 0; static struct cdev my_cdev; static dev_t dev_num; static int my_open(struct inode *inode, struct file *filp) { printk(KERN_INFO mydemo open called\n); return 0; } static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { char data[] hello from driver\n; size_t len sizeof(data) - 1; if (count len) { return -EINVAL; } if (copy_to_user(buf, data, len)) { return -EFAULT; } return len; } static const struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .read my_read, }; static int __init my_init(void) { int ret; ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { return ret; } major MAJOR(dev_num); cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { unregister_chrdev_region(dev_num, 1); return ret; } printk(KERN_INFO mydemo init, major%d\n, major); return 0; } static void __exit my_exit(void) { cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); printk(KERN_INFO mydemo exit\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL);alloc_chrdev_region是动态申请设备号返回值放在dev_num里。MAJOR(dev_num)取出主设备号。cdev_init和cdev_add把 file_operations 注册到内核。这里的copy_to_user是必须的内核空间不能直接向用户空间内存地址写入。编译加载后通过 dmesg 查看实际的主设备号比如是 240。然后创建设备节点sudo mknod /dev/mydemo0 c 240 0主设备号以你机器上的 dmesg 输出为准。4.3 写一个简单的应用测试#include stdio.h #include fcntl.h #include unistd.h int main(void) { int fd; char buf[128] {0}; fd open(/dev/mydemo0, O_RDWR); if (fd 0) { perror(open); return 1; } read(fd, buf, sizeof(buf)); printf(read: %s, buf); close(fd); return 0; }编译运行gcc test_app.c -o test_app ./test_app如果输出read: hello from driver说明整个链路已经打通应用层 open - VFS - 驱动 my_open - read - copy_to_user - 应用层读到数据。4.4 为什么这里不要急着加功能很多新手到这一步就想立刻写 ioctl、写中断、写 DMA我建议先稳住。字符设备驱动最核心的价值是把“应用层调用”和“内核实现”这条路径跑通。先把 open/read/write 这几个基础接口练熟理解设备号的申请和释放、cdev 的注册和删除、用户空间和内核空间数据拷贝后面加复杂功能才有稳定的地基。另外注意设备节点的权限。mknod 创建的节点属主通常是 root普通用户直接 open 可能报Permission denied。测试时可以先加 sudo或者用 chmod 调整节点权限但生产环境不要图方便随便 chmod 777。5. 内核编程最容易翻车的几个地方5.1 内存操作不能照搬应用层习惯驱动里不能使用 malloc对应的是kmalloc释放用kfree如果希望清零可以用kzalloc。分配结果要检查返回值内核里没有异常处理机制可以兜底。char *buf kzalloc(size, GFP_KERNEL); if (!buf) { return -ENOMEM; }还要注意GFP_KERNEL表示分配时可以睡眠但如果当前上下文是中断处理函数就不能用GFP_KERNEL要用GFP_ATOMIC。这个区别直接影响系统的稳定性。另一个常见翻车点是用copy_to_user和copy_from_user时没有检查返回值。这两个函数的返回值是“没拷贝成功的字节数”不是错误码。很多新手判断反了导致驱动返回成功但数据实际没传过去。5.2 并发访问锁不是摆设驱动是运行在内核态的代码可能有多个进程同时打开设备节点也可能有中断随时进来。如果对共享资源不加以保护会出现数据错乱甚至崩溃。锁的选择有一个基本标准临界区很短且不会睡眠用自旋锁。临界区较长可能阻塞等待用互斥锁。只保护一个整数用原子操作。很多驱动崩溃的根因不是逻辑复杂而是在不该睡眠的地方睡了或者在中断上下文里拿了自旋锁又调用了可能睡眠的函数。排查这类问题需要反复阅读内核文档和代码注释。5.3 不要在中断上下文里做耗时的活中断处理函数的执行环境非常受限不能睡眠、不能做长时间循环、不能调用 copy_to_user 这类可能阻塞的接口。如果硬件中断触发后确实有大量数据要处理应该用底半部机制把耗时任务延后到合适时机执行。新手写驱动时最容易在中断里直接操作复杂的业务逻辑。在 x86 上可能偶尔能跑但放到嵌入式板子上实时性和稳定性都会出问题。原则很简单中断里只做最短处理其余延后。5.4 驱动里不要碰这些东西整理一份常见禁忌清单都是我实际踩过或看别人踩过的不要用浮点运算除非你非常清楚内核怎么保存 FPU 状态。不要在中断或持锁状态下休眠。不要在内核里直接访问用户空间指针必须用 copy_from_user/copy_to_user。不要在栈上开大数组内核栈非常有限。不要忘记检查所有可能失败的内核 API 的返回值。不要在内核日志里随便打大量 printk尤其在高频路径上。如果踩到内存相关的问题先确认是不是访问了非法地址再看是不是并发保护不到位。不要一上来就怀疑编译器编译器在大多数情况下比我们想象中可靠得多。6. 驱动跑不通时按这个顺序排查6.1 先看现象和日志驱动开发里最能帮你定位问题的工具是 dmesg。内核几乎所有关键路径都会打日志。遇到问题第一步不是改代码而是执行dmesg | tail -50把最近的日志看完。日志里有明确的异常栈、错误码、调用路径很多时候答案已经写在里面了。我遇到过不少新手连日志都没看就反编译源码找问题结果问题就是设备树节点路径写错了。6.2 再确认环境匹配模块加载失败先按这个顺序排查模块用哪个内核版本编译的当前运行内核版本是什么编译时头文件目录是否与运行内核一致模块依赖的符号是否都已经导出尤其是在嵌入式 Linux 场景内核经常是团队自己裁剪配置的模块编译时必须使用与目标内核完全一致的源码和配置。用错源码编译加载时报Unknown symbol或者Invalid module format的概率极高。6.3 最后查设备、内存和并发如果模块能加载但设备操作异常比如 open 失败、read 空返回、写数据后系统崩溃优先级是先确认设备节点的主设备号和次设备号对不对。再确认 file_operations 里注册的函数有没有被真正调用可以临时加 printk。接着查内存拷贝方式是否正确检查 copy_to_user 返回值。最后查并发访问确认共享资源是否有锁保护。6.4 常见报错速查报错现象排查方向常见原因insmod 提示 Invalid module formatdmesg、内核版本、编译配置头文件版本与运行内核不一致模块加载成功但 dmesg 无输出printk 级别、dmesg 权限日志级别过低或权限不足open 设备节点失败No such device主次设备号、cdev_add 返回值设备节点号与驱动注册号不一致open 设备节点失败Permission denied节点权限、用户组普通用户无权限需调整权限Unknown symboldmesg、模块依赖、内核符号导出依赖符号未 EXPORT_SYMBOL系统死机或卡死内存访问、锁、中断上下文访问非法地址或死锁排查时不要同时改多个变量。一次只改一个地方改完重新编译、加载、测试。如果一次改了两三处出了问题根本不知道是哪一处引起的。7. 从入门到能干活学习路线怎么安排7.1 第一阶段把基础模块练到条件反射这一阶段的目标不是做复杂项目而是让你对“编译-加载-调试-卸载”这套流程形成肌肉记忆。建议完成这些练习写一个带参数的内核模块加载时传递参数。在模块里创建 /proc 或 /sys 文件读写数据。写字符设备实现 open、read、write、ioctl 四个接口。写一个简单的互斥保护逻辑比如同一时刻只允许一个进程打开设备。这些练习全部在 x86 本机完成不涉及开发板。练熟后你已经具备阅读大部分常见驱动源码的基础。7.2 第二阶段精读一类真实设备的驱动理论知识到这一步已经不少了接下来要开始接触真实设备。我建议选一类最常见的设备比如 GPIO、LED、按键去内核源码树里找对应 driver 来读。读源码不是从头到尾读而是带着问题读这个驱动的 file_operations 里注册了哪些函数数据是怎么从硬件寄存器传到用户空间的中断是怎么申请和处理的设备树里需要哪些节点属性和平台相关的代码放在哪里这个阶段会遇到一个绕不开的概念设备树。对嵌入式 Linux 来说设备树用来描述硬件拓扑、寄存器地址、中断号、引脚复用等资源。驱动通过of_系列 API 从设备树里读取配置。学设备树不要死记语法先理解“描述硬件资源 - 驱动解析资源 - 初始化设备”这条主线。7.3 第三阶段结合具体硬件平台到这一阶段你已经有能力独立写一个完整驱动了。这时候再入手一块开发板不要选太冷门的型号优先选择内核主线支持较好的平台资料多、社区活跃、出问题容易找人问。在板子上做实验和 x86 本机最大的区别是需要配置交叉编译工具链。需要编译完整内核或者至少确保模块与板载内核版本一致。需要熟悉 U-Boot、内核镜像、设备树、根文件系统怎么打包烧录。调试手段受限串口日志往往是唯一可靠的信息来源。很多人在这一步放弃因为变量突然变多了。我的建议是先用板子自带的内核只编译自己的模块不要一上来就重新编译整个内核。等模块跑通了再逐步研究内核裁剪和设备树修改。7.4 一本书能帮你什么不能帮你什么像《手把手教你学Linux设备驱动开发》这类书价值在于把零散的知识体系化告诉你先学什么、再学什么避免在错误的方向上浪费时间。它能把内核模块、字符设备、并发、中断、设备树这些主线串起来让初学者有一条清晰的学习路径。但书不能替代动手。设备驱动是典型的“纸上得来终觉浅”的领域。同一个函数你在书上看十遍不如自己编译一次、加载一次、故意制造一个 bug 再解决一次理解得深。我个人建议把书当路线图用每读完一章就按书里的步骤完整跑一遍实验然后不看书重新实现一遍。如果能够不看书写出来这一章才算真正过完。如果写不出来就回头再看不要急着往后翻。7.5 长期主义驱动开发没有“速通”最后说点实在的。设备驱动开发的学习曲线确实陡峭前期投入大、反馈慢。它不像 Web 开发写几行代码就能看到页面效果。内核模块的反馈常常是“没反应”或者“直接死机”成就感来得非常慢。但它的回报也长期存在一旦建立起内核态编程的心智模型再看任何操作系统、嵌入式系统、硬件相关的问题都会比别人多想一层。这种底层视野是单纯做应用开发很难获得的。如果只是学习默认配置够用如果准备进入这个行业就要把日志、设备树、交叉编译、内核源码阅读这些基本功提前打磨好。踩过几次坑之后你会发现很多问题不是工具能力不够而是前置环境和输入材料没有处理干净。先跑通最小链路再谈复杂功能这条原则在任何阶段都适用。
返回列表