Linux驱动二周目:从点灯到标准字符设备与设备树实战 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中某个中间步骤失败时很多人直接returnGPIO就永久占用了。这些痛点直接推动了下一代接口的出现。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_operationsstatic 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 blinkLED会按照指令点亮、熄灭、以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 ARCHarm CROSS_COMPILEarm-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属性文件暴露控制接口除了ioctlLinux更推崇用sysfs把设备参数暴露成文件。在device_create之后可以用sysfs_create_group创建一组属性用户空间直接读写文件来控制LED。比如创建两个属性文件brightness和triggerecho 1 /sys/class/leds/board-led/brightness就能点亮。这条路走通之后你会发现内核的led子系统leds-gpio本质上就是把你这套代码重新组织了一遍再加上统一的trigger机制。换句话说自己亲手写一遍原始代码再看子系统源码会轻松得多。6.3 多LED实例与设备私有数据如果开发板上有4颗LED你是不是打算复制4份probe代码当然不用让同一个驱动处理多个device实例正是linux设备模型的核心能力。每个LED节点都是独立的platform_deviceprobe会被调用多次每次设备私有结构体里的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的拷贝逻辑、把设备树改引脚的过程各做一遍这一圈走下来驱动开发的门槛才算真正跨过去了。点灯这件事值得你认真写第三遍。