从LED驱动源码拆解入手,搞懂Linux驱动开发核心框架
如果要我推荐一个入门Linux驱动开发的练手对象LED驱动几乎是绕不开的那个。代码量不大但它把模块机制、平台驱动模型、设备树匹配、字符设备框架、GPIO操作这几个Linux驱动开发的核心环节全部串在了一起。很多人一开始直接啃那些好几万行的网卡驱动、触摸屏驱动结果被各种抽象层绕晕其实完全没必要把LED驱动的源码一行一行拆清楚很多驱动的基础套路就自然通了。这篇文章会带着你从源码出发完整走一遍字符设备LED驱动的生命周期从模块入口开始看设备树怎么和驱动对上probe函数做了什么应用层写一个字符到/dev节点之后内核里发生了什么最后再落到编译、加载、调试这些实操细节。中间还会穿插一些我自己踩过的坑比如GPIO被占用、模块加载报错的排查思路以及gpiod接口和直接操作寄存器两种写法的取舍。1. 为什么选择LED驱动作为源码分析入口1.1 LED驱动是麻雀虽小、五脏俱全的最佳例子先说说我为什么推荐从LED驱动开始。一个典型的LED驱动源码通常在几百行以内结构清楚没有复杂的协议栈也没有大量中断和DMA逻辑。它的核心任务很简单让应用层能通过一个设备节点控制一盏灯的亮灭。但简单归简单它覆盖的知识面一点都不浅。你要写一个完整的LED驱动至少要接触这么几块东西内核模块的加载和卸载机制module_init、module_exit平台驱动模型platform_driver、platform_device设备树匹配compatible字符串、of_match_table字符设备框架file_operations、miscdevice或cdevGPIO子系统gpiod_get、gpiod_set_value或ioremap直接操作寄存器这几块内容几乎在Linux下任何一个实际驱动里都会出现。所以你现在把这几百行看懂后面看SPI驱动、I2C驱动、甚至网络驱动时会发现自己至少能看懂一半的骨架代码。我见过不少同事真正拉开差距的就是这套基础框架熟练不熟练而不是具体某个外设的协议细节。1.2 源码分析前先建立概念驱动、模块、设备树谁听谁的在贴出源码之前我想先花点时间把几个容易混淆的概念理清楚。很多新人看代码时最大的困惑不是某个函数不认识而是搞不清这些函数到底是什么时候被调用的以及是谁调用的。首先要分清楚驱动和模块的关系。模块是编译出来的一个.ko文件它只是驱动代码的载体。你可以把驱动编译成模块运行时用insmod加载也可以直接编进内核镜像开机就存在。两者代码基本相同差别主要在Makefile和Kconfig的处理方式上。然后是平台驱动模型。在设备树成为主流之后一个硬件设备比如板子上的LED会先在设备树里描述出来成为一个platform_device而你写的驱动则是一个platform_driver。内核里有个机制负责把这两者配对配对成功的依据就是设备树节点里的compatible属性和驱动里of_match_table数组里声明的compatible字符串一致。最后再说字符设备。驱动完成probe之后会向内核注册一个字符设备节点比如/dev/simple-led。应用层的程序对/dev节点执行open、write、close内核就会通过file_operations结构体找到你的驱动回调函数执行真正的硬件操作。这三层关系可以用一句话概括模块是代码的打包方式设备树告诉内核硬件长什么样驱动负责把设备树描述的资源变成用户可以操作的文件接口。2. 驱动骨架源码拆解入口、匹配与probe生命周期2.1 module_init与platform_driver结构体的衔接关系先看一段我实际项目中常用的LED驱动骨架代码它使用的是miscdevice这种轻量级字符设备接口比传统的cdev设备号那一套简单不少非常适合LED这种单一外设。#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_gpio.h #include linux/gpio/consumer.h #include linux/fs.h #include linux/uaccess.h #include linux/miscdevice.h #include linux/slab.h struct led_device { struct gpio_desc *led_gpio; struct miscdevice miscdev; }; static int led_open(struct inode *inode, struct file *filp) { struct led_device *dev container_of(filp-private_data, struct led_device, miscdev); filp-private_data dev; return 0; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *pos) { struct led_device *dev filp-private_data; char value 0; if (copy_from_user(value, buf, 1)) return -EFAULT; if (value 1) gpiod_set_value(dev-led_gpio, 1); else if (value 0) gpiod_set_value(dev-led_gpio, 0); else return -EINVAL; return 1; } static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .write led_write, }; static int led_probe(struct platform_device *pdev) { struct led_device *dev; int ret; dev devm_kzalloc(pdev-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-led_gpio devm_gpiod_get(pdev-dev, led, GPIOD_OUT_LOW); if (IS_ERR(dev-led_gpio)) return PTR_ERR(dev-led_gpio); dev-miscdev.minor MISC_DYNAMIC_MINOR; dev-miscdev.name simple-led; dev-miscdev.fops led_fops; ret misc_register(dev-miscdev); if (ret) return ret; platform_set_drvdata(pdev, dev); return 0; } static void led_remove(struct platform_device *pdev) { struct led_device *dev platform_get_drvdata(pdev); misc_deregister(dev-miscdev); } static const struct of_device_id led_of_match[] { { .compatible example,simple-led }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_platform_driver { .probe led_probe, .remove led_remove, .driver { .name simple-led, .of_match_table led_of_match, }, }; module_platform_driver(led_platform_driver); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(A simple LED driver example);这段代码里最关键的地方在最后那几行。module_platform_driver(led_platform_driver)是一个宏它展开之后相当于帮你声明了module_init和module_exit两个入口初始化函数内部会调用platform_driver_register(led_platform_driver)把led_platform_driver注册到内核的platform总线上去。卸载时对应的platform_driver_unregister也会被自动生成。也就是说你不需要自己写零散的init和exit函数只要把platform_driver结构体的各个字段填好剩下的注册动作由宏帮你完成。这种写法在Linux 5.x、6.x内核里已经是主流老式代码里那种自己定义led_init再用module_init(led_init)手动注册的写法也能跑但没必要了。2.2 设备树匹配驱动是怎么被系统找出来的of_match_table是设备树匹配的钥匙。驱动里定义了led_of_match数组数组里是struct of_device_id每个节点可以指定compatible字符串、名字、类型等匹配条件。最常见的就是compatible它做的事情相当于给驱动打上一个标签告诉内核我支持这种硬件。在真实项目中设备树节点长这样led_test: led-test0 { compatible example,simple-led; label board-led; led-gpios gpio1 6 GPIO_ACTIVE_LOW; };设备树中的compatible是example,simple-led驱动里的compatible也是example,simple-led两者一致时内核的platform总线匹配逻辑就会把platform_device和platform_driver绑定在一起随后调用驱动的probe函数。设备树编译成dtb之后内核启动时会把dtb解析成device_node结构其中compatible属性会被提取出来用于和驱动的of_match_table比较。这个过程和USB设备插入后遍历驱动列表找匹配项是一个思路只是平台总线走的匹配流程更简单直接。这里有一个常见的坑compatible字符串必须是厂商名,设备名这种风格而且大小写、分隔符都要和设备树里完全一致。因为一个大小写写错内核不会报编译错误只会安静地不匹配驱动probe永远不会被调用你会在sysfs里看到platform设备挂在那里但对应的驱动目录一直没有出现。2.3 probe回调里完成了哪三件关键事probe函数是整个驱动真正开始做事的起点。它被调用意味着内核已经把设备和驱动配对成功现在可以把设备树里描述的资源分配给驱动使用了。第一个关键动作是分配内存。我用的是devm_kzalloc注意前面多了一个devm_这是内核的托管资源分配函数。它分配的这块内存不需要你在remove时手动释放设备移除时内核会自动帮你回收。这种devm前缀的资源管理方式在现在的主流驱动里非常常见写代码时能少操心很多资源泄漏问题。第二个关键动作是获取GPIO。devm_gpiod_get(pdev-dev, led, GPIOD_OUT_LOW)从设备树节点中拿led-gpios属性对应的GPIO同时把它初始化为输出低电平。用法上要注意第二个参数是led和设备树里led-gpios是对应的gpiod框架会自动补上-gpios后缀。如果设备树里没有这个属性会返回错误指针所以必须用IS_ERR判断。第三个关键动作是注册字符设备。misc_register注册的是miscdevice它会自动在/dev下生成一个名为simple-led的节点主设备号固定为10次设备号自动分配。LED这种只有一个外设的设备用miscdevice非常合适比手动分配设备号、创建class、创建device那套流程省了一大半代码。probe的最后用platform_set_drvdata(pdev, dev)把前面的私有数据结构保存到platform_device里方便remove时取回来清理。3. 用户态与硬件之间的数据通路file_operations逐行解析3.1 open/release私有数据怎么和文件挂钩应用层对/dev/simple-led执行open操作时内核的虚拟文件系统会找到这个设备节点对应的miscdevice结构然后调用你的.open回调。这里有个细节值得注意misc子系统在处理open时已经提前把filp-private_data指向了对应的miscdevice结构体。所以我的open回调里直接用container_of从miscdevice拿到了自己定义的led_device再把它重新存回filp-private_data。container_of的用法是根据结构体成员指针反推出整个结构体的起始地址。这里filp-private_data存放的是miscdevice成员的地址通过container_of(filp-private_data, struct led_device, miscdev)就能从成员地址倒推回led_device的首地址。这个宏在驱动开发里几乎天天用看不懂它是没法读内核源码的。我的这个驱动没有实现release回调原因是没有什么资源需要在关闭时释放。但有一点要注意如果你在open里申请了单独的资源或者给设备加了引用计数那么必须在release里做对称的释放和递减否则每次open一次就泄漏一次。3.2 write回调从用户空间到GPIO寄存器的一次完整旅程应用层往设备节点写入数据最终到达的是你的led_write函数。这是整个驱动里最核心的一段路径。看看参数buf是用户空间的缓冲区地址count是写入的字节数filp是当前打开的文件实例。你第一个要养成的习惯是永远不要在内核空间直接解引用用户空间的指针。用户空间传过来的地址是虚拟地址内核不能直接访问必须通过copy_from_user或copy_to_user系列的接口来搬运数据。我的代码里用copy_from_user(value, buf, 1)只拷贝一个字节到内核局部变量value。如果拷贝失败说明用户传了一个非法地址返回-EFAULT。拷贝成功后判断写入的内容是1还是0然后调用gpiod_set_value把GPIO拉高或拉低。注意返回值。我返回了1表示成功消费了1个字节。这个返回值会作为write系统调用的返回值传给应用层。如果把count原样返回应用层就会认为整个缓冲区都被写入了。这里我刻意只处理1字节逻辑上更严谨。3.3 并发场景两个进程同时写LED会怎样一个容易忽略的问题是并发。如果两个进程同时打开设备节点同时向里面写数据会发生什么首先filp-private_data是每个文件实例独立的两个struct file各自保存着自己的led_device指针互不干扰。其次gpiod_set_value操作的是同一个GPIO描述符写的是寄存器这种单寄存器的读写操作本身就是原子的两个进程交错执行也不至于让系统崩溃。但在更复杂的驱动场景里这种裸写是危险的。假设你增加了计数功能比如记录了开关了多少次两个进程同时执行dev-count就会因为非原子操作导致计数丢失。再比如你以后在驱动里加了亮度渐变、定时闪烁状态这些共享状态就必须要用锁保护。所以我的建议是LED驱动在这几行代码里不用锁没问题但你心里要清楚这是当前逻辑足够简单才不用锁而不是驱动开发不需要锁。真正的并发问题通常出现在你给驱动加功能之后普通的一两个模块互相不干扰一旦涉及共享内核状态更改就必须用mutex、spinlock甚至原子变量来做保护。4. GPIO操作的两种写法通用gpiod接口与寄存器直操4.1 gpiod接口设备树友好、也容易踩空指针以上代码里用的是gpiod_get和gpiod_set_value这套API是内核推荐的GPIO访问方式。好处很明显它和设备树天然配合led-gpios属性一旦写好驱动里一行devm_gpiod_get就能拿到GPIO它屏蔽了底层到底是内存映射的寄存器还是I2C扩展的GPIO比如PCAL9535这种IO扩展芯片它自动处理GPIO的方向、初始电平内部还会做引用计数管理它配套了devm_版本remove时不需要手动释放但踩坑的点也不少。最常见的错误是IS_ERR判断漏掉。gpiod_get失败时返回的不是NULL而是ERR_PTR编码的错误值。直接拿它去做gpiod_set_value会触发内核的访问异常。我见过不少新人只判断了if (!dev-led_gpio)结果设备树里配错GPIO时驱动陷入异常排查了半天。另一个坑是gpiod_set_value和gpiod_set_value_cansleep的区别。如果你的GPIO挂在I2C总线扩展芯片上设置电平的过程可能涉及I2C通信会睡眠。这种GPIO如果在原子上下文比如中断回调里使用就必须用gpiod_set_value_cansleep不然会触发内核调度器的睡眠检查警告。4.2 ioremap寄存器操作读芯片手册再动手一些老式驱动或面试题里会让直接用寄存器方式操作GPIO。思路是查芯片参考手册找到GPIO控制器的基地址和偏移量然后用ioremap把物理地址映射到内核虚拟地址空间再通过readl/writel读写。void __iomem *gpio_base; int led_pin 6; gpio_base ioremap(0x01C00000, SZ_4K); writel(readl(gpio_base GPIO_OE) | (1 led_pin), gpio_base GPIO_OE); writel(readl(gpio_base GPIO_DATAOUT) | (1 led_pin), gpio_base GPIO_DATAOUT);这段代码的意思是先映射GPIO控制器所在的4KB物理地址空间然后把对应引脚的方向寄存器设置为输出再把数据寄存器中的对应位拉高。实际使用时GPIO_OE、GPIO_DATAOUT这些宏要从芯片参考手册里查不同厂商的芯片偏移量差别很大不能照抄。寄存器方式的优点是没有抽象层开销适合对性能极致敏感的场景。但缺点也很明显代码可移植性差换个芯片平台就要重写而且容易踩内存屏障和缓存一致性的坑。4.3 为什么推荐优先使用gpiod接口除非你写的是引导早期或者极致精简的内核代码否则我建议优先使用gpiod接口。我实际开发中得出的经验是第一gpiod接口降低了对硬件细节的耦合。板子上的LED可能从GPIO改到了IO扩展芯片gpiod接口这行代码几乎不用变只要设备树里改一下gpios属性的引用即可。如果用寄存器直操扩展芯片方案基本等于重写驱动。第二错误处理更完善。gpiod接口会做设备树属性的解析校验属性不存在、引脚被占用都能反馈清晰错误码。寄存器直操则需要你自己去判断某个引脚是否已经被别的driver占用容易写出两个驱动互相踩GPIO的问题。第三驱动的生命周期管理更省心。devm_gpiod_get会自动注册资源管理回调remove时自动释放GPIO描述符。你少写一行gpiod_put就少一处泄漏风险。5. 编译、加载与实测把代码变成亮的灯5.1 Makefile的两个关键变量以及编进内核与模块编法的区别源码写完还不是一个可用的.ko文件你需要Makefile帮你完成编译。LED驱动模块的Makefile通常长这样obj-m : simple-led.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这里最关键的是obj-m。它告诉内核构建系统simple-led.o要编译成外部模块。如果你想把驱动直接编进内核而不是模块要做两处改动一是把整个驱动源码目录放进内核源码树的drivers目录下二是新增或修改Kconfig通过配置项让构建系统生成obj-$(CONFIG_LED_SIMPLE) simple-led.o的形式。我强烈建议学习阶段按模块方式编译。理由是模块方式可以本地单独编译甚至用发行版自带的内核头文件编出了问题重新insmod一次就行不用烧写整个内核。编进内核虽然在产品量产时更常见但每次修改都要重新编译内核并重启调试效率低很多。编译时最容易遇到的是/lib/modules/$(uname -r)/build不存在。这是缺少内核头文件导致的用对应内核版本的headers包安装即可。另外还要特别注意内核版本匹配用本机运行的内核版本编译出来的模块拿到目标板上跑必须和目标板的内核完全一致否则直接加载会报版本不匹配。5.2 insmod失败一个错误码一个坑模块编译好之后加载这一步就开始出各种幺蛾子了。我把常见情况整理成了一张表方便你对照排查加载命令输出含义常见原因insmod: ERROR: could not insert module: File exists模块已经加载之前insmod过一次没有rmmod或者模块同名冲突insmod: ERROR: could not insert module: Operation not permitted权限或安全限制没用root权限或者内核开启了模块签名强制校验insmod: ERROR: could not insert module: No such device没有匹配的设备设备树节点不存在或compatible不匹配probe未被调用insmod: ERROR: could not insert module: Unknown symbol符号找不到依赖的内核符号没有导出或依赖模块未加载dmesg中提示GPIO busyGPIO已被占用该GPIO已被其他驱动或预留配置占用排查这类问题的第一工具永远是dmesg。insmod报错只是告诉你加载失败但具体原因都藏在内核日志里。比如GPIO被占用时日志里会明确告诉你哪个设备占用了这个GPIO编号compatible不匹配时则多半没有任何probe相关日志。我遇到过印象最深的坑是设备树里写了gpio-leds节点同时还用gpiod_get自己写驱动。两个驱动都想占用同一个引脚结果后加载的一方在devm_gpiod_get处直接返回-EBUSY。排查方法就是把设备树里冲突的节点删掉或者换引脚。5.3 应用层怎么配合驱动测试驱动加载成功后查看/sys/class/misc或/dev目录应该能看到simple-led节点。此时用命令就可以点灯ls -l /dev/simple-led echo 1 /dev/simple-led echo 0 /dev/simple-ledecho 1 /dev/simple-led这条命令实际上就是打开文件写入字符1关闭文件正对应驱动里的led_open、led_write和默认的release流程。你可以用strace查看系统调用过程会看到open、write、close三个系统调用按顺序执行。如果不想通过shell间接操作也可以写个小C程序用open、write、close直接操作设备节点。注意写入时不是写整型1而是写一个字符1因为驱动的解析逻辑是按字符处理的。这一点很容易踩坑应用层写一个int类型的1驱动里取buf[0]得到的可能是0x01不是字符1判断value 1就会失败返回-EINVAL。用miscdevice生成的设备节点默认权限通常是root可用。如果你需要在普通用户下测试可以在misc_register之后用class_create和device_create配合udev规则调整权限或者临时用chmod 666改变节点权限。开发阶段直接chmod最省事量产产品建议用udev规则保证权限一致性。6. 从点灯到点亮一个子系统几个进阶方向6.1 led-class子系统把LED变成通用件你现在这个驱动能跑但它还只是一个自定义的字符设备驱动。Linux内核里其实已经有了成熟的LED子系统也就是led-class框架。用这个框架写LED驱动不需要自己定义file_operations内核会统一提供/sys/class/leds下的接口。led-class驱动的核心是注册一个led_classdev设备实现brightness_set或brightness_set_blocking回调。应用层不再通过echo到/dev节点而是直接写/sys/class/leds/led名称/brightness文件。这样做的好处是所有LED驱动对外接口统一用户可以像操作标准文件一样控制任意厂商的LED还能用上内核自带的trigger机制比如让LED在系统心跳时闪烁。如果你觉得字符设备写法更自由确实但产品开发时我更建议使用led-class理由很朴素统一接口意味着后面接的应用程序、脚本、上层服务都不用关心底层硬件是谁家的换一块开发板应用层可以原样复用。6.2 内核定时器与PWM呼吸灯点亮和熄灭只是第一步。加点进阶思考你可以用内核的timer_list实现闪烁效果在定时器回调里翻转GPIO电平并重新设置超时时间。要注意的是定时器回调运行在软中断上下文不能调用可能睡眠的函数GPIO操作要选cansleep或不睡眠的接口。如果开发板的LED支持PWM功能还可以通过pwm子系统实现呼吸灯效果。流程是从设备树获取PWM通道设置周期和占空比然后启动PWM输出。呼吸灯本质上就是周期性改变PWM占空比代码量也不大但涉及pwmchip、pwm_state、pwm_config的联动是理解PWM子系统很好的实战项目。6.3 源码阅读路线图接下来该看什么如果你的目标是彻底吃透Linux驱动开发我建议你把这条路线走一遍先看本篇文章里这个simple-led驱动确保每行都能讲清楚再去看内核源码里drivers/leds/led-class.c和gpio-led.c理解标准子系统、trigger机制、sysfs接口怎么封装接下来选一个I2C接口的外设驱动比如温度传感器理解i2c_driver的匹配和读写流程最后选一个带中断的设备理解request_irq、中断下半部、线程化中断的处理方式等你走完这条路线再回头看当初觉得头晕的复杂驱动会发现很大一部分其实都在做重复的事情匹配设备、配置资源、注册接口、处理数据。真正的核心逻辑反而集中在那么几个函数里。这也正是我一开始说的LED驱动虽然小但它是一把理解整个Linux驱动体系的好钥匙。