如果要我推荐一个入门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或cdev)
- GPIO子系统(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-test@0 { 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 busy | GPIO已被占用 | 该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驱动体系的好钥匙。