1. 为什么点灯值得写两遍:二周目才是真正的入门分水岭
看到“二周目”三个字,老读者应该能心领神会:在Linux驱动这条路上,点灯这件事,你至少会碰两次。第一次是在某个教程的开头,照着例子把helloworld模块改成“led_test”,insmod之后屏幕上打出一行字就算交差;第二次是真正面对一块开发板,想把一个挂在GPIO引脚上的LED,通过自己写的驱动点亮、熄灭、闪烁,并在用户空间用程序控制它。
二周目的意义不在于“把灯点亮”这个结果,而在于积累从模块到硬件的完整链路认知。我第一次写LED驱动时,代码里甚至没有真正操作过GPIO寄存器,只是在内核模块里printk了一个“LED ON”,当时觉得自己已经会驱动了。后来接触到GPIO控制器、寄存器偏移、设备树匹配、platform总线、cdev注册这一整套机制时,才意识到自己过去只是学了点皮毛。二周目要做的,就是把第一遍模糊跳过的东西,逐项补齐。这篇文章假定你已经知道insmod/rmmod是什么、会写最简单的module_init,接下来我们用标准字符设备框架,把一颗LED完整驱动起来。
二周目最值得关注的点,不在“点亮”这个动作本身,而在于它逼着你把这几个问题一次性想清楚:设备号和字符设备是怎么注册的?驱动如何从物理地址映射出寄存器?设备树如何描述硬件并触发probe?用户态通过ioctl和write访问设备,背后到底走了一条什么路?这些问题在任何一个复杂驱动里都会再次出现,点灯只是它们的最小测试场。
1.1 一周目到底错过了什么
大多数一周目点灯代码,就是下面这个模式:
static int __init led_init(void) { printk("LED ON\n"); return 0; } static void __exit led_exit(void) { printk("LED OFF\n"); } module_init(led_init); module_exit(led_exit); MODULE_LICENSE("GPL");这段代码能通过编译、能加载、能卸载,但它没有注册任何设备节点,没有操作任何硬件,和Linux的设备模型毫无关系。用“hello world”类比的话,它只是调用了printf,没有创建文件,更没有和硬件交互。真正的问题在后续暴露出来:一旦你想在应用层用open/ioctl去控制它,就会发现还要自己补设备号、cdev、class、device_create一大串东西。一周目如果只停留在printk层面,相当于你在窗口上画了个假的仪表盘,而二周目要做的就是真正把它接上发动机。
另外,很多人在一周目点灯时会用shell直接操作GPIO子系统,比如在板子串口里echo到/sys/class/gpio目录,这也不是坏习惯,但它只是“使用内核提供的现成接口”,并没有“实现一个驱动”。二周目我们要站在驱动开发者的位置,亲手把硬件状态暴露给用户空间,这中间的差别是巨大的。
1.2 二周目需要补齐的四块拼图
点灯驱动虽然代码量不大,但它能牵引出的知识模块很完整。我整理了一份自己常用的对照表:
| 维度 | 一周目常见做法 | 二周目该掌握的做法 |
|---|---|---|
| 设备节点 | 无,或mknod手动创建 | class_create自动生成节点 |
| 硬件操作 | shell写/sys/class/gpio | 驱动内通过gpiod接口控制 |
| 硬件描述 | 写死在源码里 | 设备树节点绑定 |
| 内核框架 | module_init直接注册 | platform_driver + probe |
| 用户态交互 | 无 | open + ioctl结构化接口 |
| 资源管理 | 手动申请、手动释放 | devm_机制自动回收 |
这张表既是二周目的目标清单,也是我后面写代码时的检查表。一件事做没做对,对照它一眼就能看出来。硬要概括的话,二周目点灯就是要求你从“会编译内核模块”升级到“会实现一个标准的平台设备驱动”。
2. 字符设备地基:设备号、cdev、自动创建节点
既然要用标准方式控制LED,就必须先把字符设备这块地基打牢。这里面的细节很多人是“会用不会讲”,尤其设备号动态分配的好处、cdev_add失败后的资源回收、device_create为什么能自动生成节点,这三件事如果不理解,写出来的驱动表面上能跑,换一个环境就崩。
2.1 动态分配设备号而不是写死
字符设备靠dev_t设备号来标识。设备号由两部分组成:主设备号标识驱动类型,次设备号标识同类型设备中的第几个实例。一个驱动管理8颗LED,可以用同一个主设备号配上8个不同的次设备号,这个设计初看简单,但决定了内核怎么找你想要的那个设备。
老式写法喜欢手动指定主设备号:
dev_t devno = MKDEV(240, 0); register_chrdev_region(devno, 1, "led_drv");这种写法的问题在于,主设备号是全局共享资源,240这个数字可能已经被其他驱动占用了。insmod的时候就会报Device or resource busy,而且排查起来很费劲,因为你根本不知道是哪个驱动占的号。现在主流做法是让内核分配:
alloc_chrdev_region(&led_dev.devno, 0, 1, "led_drv");内核会在空闲号段里挑一个主设备号,驱动拿到的是一个完整dev_t,不再关心具体数字。加载之后去/proc/devices里查一下就能看到实际分配结果。应用层如果非要静态绑定设备,也需要读取设备号再mknod,而不是写死一个数字。这一步虽然简单,却决定了驱动能不能在多套环境里重复加载。
2.2 cdev:设备号到操作函数的桥
分配好设备号只是拿到了“门牌号”,还需要把门牌号和实际操作函数挂上关系,这就是struct cdev的职责。它本质上是一个连接设备号和file_operations的中间层。常规初始化顺序是这样的:
cdev_init(&led_dev.cdev, &led_fops); led_dev.cdev.owner = THIS_MODULE; ret = cdev_add(&led_dev.cdev, led_dev.devno, 1);cdev_init负责把file_operations挂到cdev上,cdev_add则告诉内核“这个设备号由我来服务”。很多初学者会在cdev_add之外遗漏一件事:如果后续某个设备注册步骤失败了,必须要把前面已经申请的资源一路回滚掉。比如cdev_add成功了,但class_create失败了,你只return -ENOMEM却没有cdev_del,下一次加载就有概率直接内核崩溃。我见过不止一个初学者在这个地方吃过亏,写驱动的原则很简单:申请了几步,失败的路径上就要释放几步。
2.3 class和device_create,让节点自动出现
老式驱动加载后,用户需要手动执行mknod /dev/led0 c 240 0。一旦主设备号动态分配,你还要先查/proc/devices拿到主设备号,再手动创建节点。这套流程在真实项目里很痛苦,因为设备节点本身也是“设备”的一部分,应该由驱动自动暴露。
于是有了class。class_create创建一个设备类,device_create在类下生成一个具体设备实例。内核的设备模型会把这个过程自动同步到sysfs和devtmpfs,于是/dev/led0节点自动就有了。应用层直接open这个节点,驱动和用户空间之间的连接就这么搭起来了。
用class_create + device_create还有一个好处:节点路径稳定。不依赖主设备号、不依赖mknod,驱动加载完成节点就绪,卸载时自动消失,这在开发调试阶段节省了大量无意义操作。二周目之后,我几乎再也没有用mknod建过设备节点。
3. GPIO操作的三代做法:从寄存器到设备树
字符设备框架解决的是“用户空间如何访问驱动”,接下来要解决“驱动如何控制硬件”。这部分是点灯的核心,也是很多教程讲得最糊的地方。我按历史顺序梳理三代GPIO操作方式,不是为了考古,而是为了让你看懂新旧代码、理解为什么现在的驱动都在用设备树加gpiod。
3.1 直接映射寄存器,最原始也最硬核
最先出现的做法是直接操作芯片寄存器。GPIO控制器在芯片内部有一组寄存器,每个引脚的方向、输出值、输入值都对应到某个寄存器位。CPU访问这些寄存器,需要先把物理地址映射成内核虚拟地址,这就是ioremap的用途。
以某ARM核心板为例,GPIO控制器的物理基地址可能是0x020C4000,引脚方向配置在偏移0x04处,输出数据在偏移0x08处。驱动里这样操作:
void __iomem *gpio_base; gpio_base = ioremap(0x020C4000, SZ_16K); writel(readl(gpio_base + 0x04) | (1 << 0), gpio_base + 0x04); writel(readl(gpio_base + 0x08) | (1 << 0), gpio_base + 0x08);这段写出来毫无美感,但它的确是最底层、最能反映硬件本质的写法。问题在于,一旦换一颗芯片,这些地址和偏移全部作废。而且芯片手册里的地址经常有歧义,不同bank之间的基地址跳号情况也很常见,稍不留神就把别的外设寄存器给写了。这种驱动基本是“一板一代码”,维护成本太高。
3.2 老gpiolib接口,好记但容易埋雷
内核后来把GPIO操作抽象成了一套通用接口,就是gpiolib那组函数:
gpio_request(led_gpio_num, "led"); gpio_direction_output(led_gpio_num, 0); gpio_set_value(led_gpio_num, 1);相比直接读写寄存器,这套接口最大的贡献是“跨平台统一”:你不再需要关心寄存器地址,只需要知道一个全局的GPIO编号。但它也有两个被长期诟病的问题。
第一个问题是GPIO编号体系。不同的芯片、不同的bank下,相同的物理引脚对应一个不同且无规律的全局编号。设备树出现之前,驱动里得写一个头文件专门定义这些数字,一旦换板子,这些数字全部重新算。第二个问题是资源生命周期。gpio_request申请了之后,必须手动gpio_free,否则下次请求就会报EBUSY。写驱动的过程中很容易忘记释放,尤其probe中某个中间步骤失败时,很多人直接return,GPIO就永久占用了。
这些痛点直接推动了下一代接口的出现。
3.3 gpiod接口与设备树,让硬件描述回归硬件描述
现代驱动的做法是:硬件信息写在设备树里,驱动只负责通过属性名获取资源。GPIO操作改用基于描述符的gpiod接口,函数的参数不再是整数编号,而是struct gpio_desc *指针。
struct gpio_desc *desc; desc = devm_gpiod_get(&pdev->dev, NULL, GPIOD_OUT_LOW); gpiod_set_value(desc, 1);devm前缀表示这块GPIO资源交给设备管理生命周期。驱动卸载时内核自动释放,probe中途失败也会自动回收,从根本上消灭了“忘记释放”的bug类别。同时gpiod接口不关心全局编号,它只在乎设备树里你是怎么描述的,真正做到了“换个板子只改dts,驱动一行不动”。
设备树节点的典型描述是这样:
led0 { compatible = "myvendor,led"; gpios = <&gpio4 0 GPIO_ACTIVE_HIGH>; label = "board-led"; };gpios属性决定了LED接在哪个GPIO控制器的第几号脚,第三个宏定义高电平有效还是低电平有效。如果LED模块是高电平点亮,就用GPIO_ACTIVE_HIGH;如果电路是灌电流接法、高电平反而灭,就用GPIO_ACTIVE_LOW。这两个宏有个关键作用:gpiod接口操作的是逻辑值,无论硬件接法如何,驱动里gpiod_set_value(desc, 1)永远表示“点亮”。硬件差异被设备树消化掉了,这正是设备树体系最优雅的地方。
4. 二周目LED驱动完整实现:设备树、驱动代码到测试程序
理论知识铺完了,这一节开始动手。我以一个挂载在根设备树下的LED节点为例,完整展示从dts编写到驱动实现再到用户态测试的全过程。为了不糊弄事,这里不使用内核现成的leds-gpio驱动,而是亲手走一遍platform_driver流程。
4.1 设备树节点与匹配机制
在板级dts里添加如下节点:
/ { my-led { compatible = "myvendor,myled"; gpios = <&gpio4 12 GPIO_ACTIVE_HIGH>; label = "board-led"; }; };这里compatible是驱动与设备匹配的关键字符串,格式一般是“厂商名,设备名”。驱动中的of_match_table必须写完全相同的字符串才能匹配上。gpios属性指明了GPIO控制器、引脚号和有效电平。label算是一个可选描述信息,方便后面sysfs里识别设备。
匹配过程是这样的:内核启动时解析设备树,发现"my-led"节点后将它注册为一个platform_device;驱动通过module_platform_driver注册自己,当驱动的id_table里有"myvendor,myled"时,platform总线就会调用驱动的probe函数。这个机制你只要理解透,以后写I2C、SPI驱动时都是一样的套路。
4.2 驱动主体结构
下面是完整驱动代码。为了兼顾可读性,我把它拆成了几段说明。
首先是头文件与私有数据结构:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/gpio/consumer.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/uaccess.h> #include <linux/timer.h> #include <linux/slab.h> #define DRIVER_NAME "myled" struct led_dev { struct gpio_desc *desc; dev_t devno; struct cdev cdev; struct class *class; struct device *device; struct timer_list blink_timer; int blink_on; };私有结构体把驱动需要的所有资源打包在一起:GPIO描述符、设备号、cdev、class、设备节点指针,后面闪烁用的timer也放这里。这样做的目的是让probe和remove之间共享同一份状态,也让container_of可以从cdev反推出整个结构体。
然后是file_operations:
static int led_open(struct inode *inode, struct file *filp) { struct led_dev *dev = container_of(inode->i_cdev, struct led_dev, cdev); filp->private_data = dev; return 0; } static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct led_dev *dev = filp->private_data; int val; switch (cmd) { case LED_IOCTL_SET: if (copy_from_user(&val, (int __user *)arg, sizeof(val))) return -EFAULT; if (val) gpiod_set_value(dev->desc, 1); else gpiod_set_value(dev->desc, 0); break; case LED_IOCTL_BLINK: dev->blink_on = 1 - dev->blink_on; if (dev->blink_on) mod_timer(&dev->blink_timer, jiffies + msecs_to_jiffies(300)); else del_timer_sync(&dev->blink_timer); break; default: return -EINVAL; } return 0; } static int led_release(struct inode *inode, struct file *filp) { return 0; } static struct file_operations led_fops = { .owner = THIS_MODULE, .open = led_open, .unlocked_ioctl = led_ioctl, .release = led_release, };这里有两个值得说明的点。第一,container_of是驱动开发的常用技法,通过结构体成员的地址反推出结构体首地址。在open里从inode->i_cdev出发找到整个led_dev结构体,再挂到filp->private_data,后续ioctl就直接用file->private_data拿数据。第二,ioctl使用了copy_from_user来从用户空间拷贝参数,这样即使用户传的是结构体也能安全处理。有些简单设备直接用arg强制转int也能跑,但遇到64位系统上的整型传参,还是拷贝的方式更稳。
probe和remove是platform_driver的心脏:
static int led_probe(struct platform_device *pdev) { struct led_dev *dev; int ret; dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev->desc = devm_gpiod_get(&pdev->dev, NULL, GPIOD_OUT_LOW); if (IS_ERR(dev->desc)) { dev_err(&pdev->dev, "failed to get gpio\n"); return PTR_ERR(dev->desc); } ret = alloc_chrdev_region(&dev->devno, 0, 1, DRIVER_NAME); if (ret) return ret; cdev_init(&dev->cdev, &led_fops); ret = cdev_add(&dev->cdev, dev->devno, 1); if (ret) goto err_unregister_region; dev->class = class_create(THIS_MODULE, "led_class"); if (IS_ERR(dev->class)) { ret = PTR_ERR(dev->class); goto err_cdev_del; } dev->device = device_create(dev->class, &pdev->dev, dev->devno, NULL, "led0"); if (IS_ERR(dev->device)) { ret = PTR_ERR(dev->device); goto err_class_destroy; } timer_setup(&dev->blink_timer, led_blink_timer, 0); platform_set_drvdata(pdev, dev); dev_info(&pdev->dev, "myled probed\n"); return 0; err_class_destroy: class_destroy(dev->class); err_cdev_del: cdev_del(&dev->cdev); err_unregister_region: unregister_chrdev_region(dev->devno, 1); return ret; } static int led_remove(struct platform_device *pdev) { struct led_dev *dev = platform_get_drvdata(pdev); del_timer_sync(&dev->blink_timer); gpiod_set_value(dev->desc, 0); device_destroy(dev->class, dev->devno); class_destroy(dev->class); cdev_del(&dev->cdev); unregister_chrdev_region(dev->devno, 1); return 0; }probe里的错误处理路径就是一个教科书式模板:每前进一步,后续一旦失败,就要把前面申请的东西全部释放。这个顺序如果在真实项目里乱了,轻则加载失败,重则盯着dmesg排查半天。remove反过来执行注销流程,外加关掉定时器、熄灭LED,保证设备卸载后状态干净。
最后是匹配表和模块入口:
static const struct of_device_id led_of_match[] = { { .compatible = "myvendor,myled", }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver = { .probe = led_probe, .remove = led_remove, .driver = { .name = DRIVER_NAME, .of_match_table = led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE("GPL");MODULE_DEVICE_TABLE(of, ...)会把匹配信息导出到模块的modinfo字段里,方便系统自动加载时判断该模块能否绑定某个设备。module_platform_driver宏自动做了module_init和module_exit的注册,显眼又不容易写错。
4.3 用户态测试程序
驱动的价值要在应用层体现。写一个简单的测试程序:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #define LED_IOCTL_SET _IOW('L', 0, int) #define LED_IOCTL_BLINK _IO('L', 1) int main(int argc, char *argv[]) { int fd, val; fd = open("/dev/led0", O_RDWR); if (fd < 0) { perror("open"); return 1; } if (argc == 2 && strcmp(argv[1], "on") == 0) { val = 1; ioctl(fd, LED_IOCTL_SET, &val); } else if (argc == 2 && strcmp(argv[1], "off") == 0) { val = 0; ioctl(fd, LED_IOCTL_SET, &val); } else if (argc == 2 && strcmp(argv[1], "blink") == 0) { ioctl(fd, LED_IOCTL_BLINK); } else { printf("usage: %s <on|off|blink>\n", argv[0]); } close(fd); return 0; }测试时依次执行./led_test on、./led_test off、./led_test blink,LED会按照指令点亮、熄灭、以300ms周期闪烁。这一步跑通,意味着你实现了从用户空间到内核驱动到GPIO引脚的完整闭环。
5. 上板实测:insmod报错、节点权限与几个印象深刻的坑
代码写完只是开始,真正折磨人的编译和上板测试环节才是二周目的重头戏。这一节分享我在实际中重复踩过的几个坑,每个都对应一类排查思路,建议收藏。
5.1 Makefile与交叉编译细节
编译内核模块的Makefile看起来短,但有不少细节值得注意:
KERN_DIR ?= /lib/modules/$(shell uname -r)/build obj-m := myled.o all: $(MAKE) -C $(KERN_DIR) M=$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M=$(PWD) clean如果是在宿主机编译给ARM板用,需要指定交叉工具链和内核源码目录:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- \ KERN_DIR=/path/to/kernel/source编译完成后生成myled.ko。拷贝到板子之前建议先file myled.ko确认架构对得上。我遇到过很多次“insmod失败,提示Unknown symbol in module”的情况,最后发现是宿主机内核源码目录下的Module.symvers与目标板内核版本不一致。此时需要重新用目标板的内核版本编译,或者把对应内核源码树里的Module.symvers拷贝过来。这个坑的排查思路是:先看dmesg输出,大部分“Unknown symbol”都会指明缺失的符号名称,对照.config检查对应内核配置是否开启。
5.2 insmod之后没有任何输出
模块加载后probe没有打印,这是最常遇到的故障。排查顺序我总结成一套流程:
- 先确认设备树节点是否确实存在于实际生效的dtb中。在板端执行
ls /proc/device-tree/my-led/compatible,如果没有这个文件,说明dtb里根本没有该节点。 - 确认compatible字符串完全一致。大小写、下划线、点号都不能错。检查方法是在内核源码目录里用
grep -r "myvendor,myled"搜一遍,或者加载后执行cat /sys/bus/platform/devices/myled/of_node/compatible。 - 确认平台设备是否真的生成。内核启动时,设备树节点会转成platform_device,设备路径类似
/sys/bus/platform/devices/myled。如果这个目录不存在,说明of_platform总线没有创建这个设备,常见原因是节点被status = "disabled"禁用了。 - 最后看
CONFIG_OF_GPIO是否打开,gpiod接口依赖GPIO子系统,配置缺失时probe会在gpiod_get处正常返回错误。
一次加载失败不代表驱动代码有问题,先按这个方法排查,能避开大批无意义的重复编译。
5.3 设备节点的权限问题
device_create自动生成的/dev/led0节点,默认所有者和权限通常是root:root 0600。普通用户执行测试程序时open会返回Permission denied,看起来像是设备或驱动坏了。
快速验证时直接:
chmod 666 /dev/led0 ./led_test on注意这里666是临时手段,生产环境下要写udev规则按用户组授权。驱动侧如果想从根源上放宽权限,可以在class_create之后调用device_create并配合devm_device_create设置模式,或者在device_create之前通过sysfs来管理。更规范的做法是:驱动只负责节点存在,权限交给udev管理,这样安全性和灵活性都说得通。
5.4 GPIO被占用:从报错看问题本质
如果加载时看到类似gpio: gpio ... already requested的报错,说明这个引脚已经被其他驱动占用了。在这个场景里最常见的冲突来源是:设备树里还定义了一个gpio-leds节点,或者某个复用子系统抢先拿走了引脚控制权。
排查办法是先查谁占用了引脚:
cat /sys/kernel/debug/gpio这个文件会列出所有GPIO的使用状态和请求者。看到引脚被leds-gpio或soc占用时,检查设备树,把冲突节点注释掉重新编译设备树。这类问题的通用教训是:GPIO资源是互斥的,驱动里申请之前要先确认系统里没有别的地方在用。错误信息并不可怕,关键是能顺着它找到根本原因。
5.5 一个隐藏的坑:错误处理路径留下脏状态
前面probe代码里我写了完整的err标签跳转。如果不做这些清理,第二次insmod时经常出现“cdev_add失败”或“device_create返回NULL”等莫名其妙的错误。原因很简单:第一次加载虽然失败,但部分资源没有释放,内核里残留了同名的cdev或class。这批坑是肉眼很难直接看出来的,只能养成“每个错误分支都走完整清理”的习惯。就我的经验而言,驱动代码里probe的error path写得越细致,后面排错的时间就越少。
6. 点灯之后的扩展方向:从LED驱动走向完整的内核开发
写到这里,二周目点灯的主线任务已经完成。但如果你就此收手,其实有点浪费。点灯虽小,五脏俱全,从它出发可以顺着一小步一小步扩展到Linux驱动的许多常见方向。我建议在二周目之后再玩三个扩展,难度递进,每一步都能补一块知识拼图。
6.1 用内核定时器实现软件闪烁
前面测试程序里已经通过ioctl触发了闪烁,这个“闪烁”用的就是内核timer_list。闪烁的实质是在定时器回调里翻转GPIO电平,然后重新向内核注册下一次超时:
static void led_blink_timer(struct timer_list *t) { struct led_dev *dev = from_timer(dev, t, blink_timer); gpiod_set_value(dev->desc, !gpiod_get_value(dev->desc)); mod_timer(&dev->blink_timer, jiffies + msecs_to_jiffies(300)); }mod_timer用来重新启动定时器,from_timer则从timer指针反推出包含它的结构体。这里最关键的一点是:定时器回调运行在软中断上下文,不能调用可能睡眠的函数,所以GPIO操作这种短小操作是没问题的,但如果你在回调里做耗时的寄存器轮询就会拖垮系统。点灯版的定时器闪烁虽然简单,但它把“内核里的异步时间管理”这个概念具象化了,值得亲手跑一遍。
6.2 用sysfs属性文件暴露控制接口
除了ioctl,Linux更推崇用sysfs把设备参数暴露成文件。在device_create之后,可以用sysfs_create_group创建一组属性,用户空间直接读写文件来控制LED。比如创建两个属性文件brightness和trigger,echo 1 > /sys/class/leds/board-led/brightness就能点亮。
这条路走通之后,你会发现内核的led子系统(leds-gpio)本质上就是把你这套代码重新组织了一遍,再加上统一的trigger机制。换句话说,自己亲手写一遍原始代码,再看子系统源码会轻松得多。
6.3 多LED实例与设备私有数据
如果开发板上有4颗LED,你是不是打算复制4份probe代码?当然不用,让同一个驱动处理多个device实例正是linux设备模型的核心能力。每个LED节点都是独立的platform_device,probe会被调用多次,每次设备私有结构体里的desc指向不同的GPIO。此时需要注意alloc_chrdev_region的计数要匹配实例数量,device_create的设备名也要区分led0到led3。multi-instance驱动是真实项目里最常见的形态,点灯的多LED版本就是用最小成本提前体验这个设计模式。
再往后就可以考虑把驱动代码里的硬件操作抽象成一棵设备树无关的“核心层”,把字符设备注册继续下沉。到这一步,你手里已经不再是“点灯驱动”,而是一个可以挂在任何GPIO上的小型通用设备框架了。
最后说几句实在的
二周目点灯,踩过的坑比第一遍多得多,但收获也完全不在一个量级。我第一次写驱动只关注“能不能load”,这次更在意“为什么这么设计”。设备树、platform总线、cdev、gpiod、devm这五个概念在点灯场景里全部串起来之后,再看代码里随处出现的container_of、of_match_table、devm_kzalloc,就不会再觉得它们只是“模板的一部分”。如果你刚写完一个能insmod的LED驱动,建议别满足于“灯亮了”,而是把probe和remove的错误处理、把用户态ioctl的拷贝逻辑、把设备树改引脚的过程各做一遍,这一圈走下来,驱动开发的门槛才算真正跨过去了。点灯这件事,值得你认真写第三遍。