ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用LED驱动入门Linux设备开发:从字符设备到GPIO与设备树实践

用LED驱动入门Linux设备开发:从字符设备到GPIO与设备树实践 1. 拆解问题一个LED驱动背后到底藏着哪几层知识我第一次接触Linux设备驱动时心里其实很懵驱动到底是个什么东西网上的入门教程大多让你写个hello world编译完insmoddmesg里打出一行字就宣布“入门成功”。但真要让一个硬件动起来比如点亮一颗LED你会发现那行字什么忙都帮不上。后来我把目标换成“用Linux控制GPIO点亮一颗LED”整个驱动的骨架才一点点清晰起来。当你决定从LED切入Linux驱动开发时表面上看只是控制一个引脚的电平高低但内核把你和硬件之间隔了很多层。你必须回答四个问题应用层怎么找到驱动驱动怎么知道LED接在哪个引脚内核用什么接口去操作GPIO驱动代码又是在什么时候被执行起来的这四个问题分别对应着字符设备框架、设备树描述、GPIO子系统、平台驱动模型。把它们串起来你就等于走完了一遍Linux设备驱动的完整开发流程。这也是我写这篇文章的目的用一颗LED把驱动开发的链路从头到尾捋一遍而不是停在“点亮了好耶”这个表面。我用一块i.MX6ULL开发板做演示Cortex-A7内核跑Linux 5.4版本。你可以换成树莓派、全志H3或者任何能跑Linux的开发板原理完全一致。区别只在设备树里的引脚编号和pinctrl配置不同。下面我们开始。2. 硬件与开发环境先把手里的牌理清楚2.1 选型与接线为什么选i.MX6ULL做驱动开发板子千万别太冷门也别太复杂。我选择i.MX6ULL是因为它有三点好处第一资料多遇到问题搜得到第二芯片内部有原生GPIO控制器不涉及外部扩展芯片点灯逻辑足够纯粹第三官方BSP和主线Linux都支持设备树文件可以直接修改。接线更简单。开发板上有一颗用户LED原理图显示它连接到了GPIO1_IO03引脚而且LED阴极接引脚、阳极通过电阻接3.3V所以这颗LED是低电平点亮。这个“低电平点亮”细节很重要后面写设备树和驱动时会直接体现出来。如果你用的是其他开发板打开原理图搜“LED”找到连接的GPIO编号即可不需要额外搭电路。2.2 交叉编译工具链宿主和目标不是一回事目标板跑的是ARM Linux但你在PC上写代码、编译所以要使用交叉编译工具链。我用的是arm-linux-gnueabihf-gcc8.3版本对应Buildroot或Yocto生成的环境。驱动模块必须和板子内核使用同一把编译器、同一份内核源码编译否则会出现版本字符串不匹配、无法加载的问题。这一点是新手最容易踩的坑。如果你直接拿板子官方BSP里提供的arm-linux-gnueabihf-工具链那基本配套。如果是自己装的通用工具链内核源码版本和配置也要能对得上。反正记住一个原则模块不是独立程序它是内核的一部分编译它必须使用内核源码树里的Makefile体系而不是直接用gcc -c。2.3 内核源码准备不用重新编译整个内核有些同学一听“内核”就以为要花两小时编译完整镜像。这里可以放宽心开发模块不需要全量编译内核只要把内核源码下载好先配置好.config执行make modules_prepare生成模块编译所需头文件即可。我习惯的做法是从开发板厂商的Git仓库拉取对应版本的内核使用厂商提供的默认配置make xxx_defconfig然后make modules_prepare。这一步走完就可以开始写驱动源码和Makefile了。整个过程不到十分钟前提是PC上要有足够的内存磁盘也要留出至少10GB空间。3. 第一个版本不碰硬件先把字符设备框架搭出来3.1 设备号、cdev和设备节点三件套在Linux里应用层操作一个硬件是通过设备文件完成的比如/dev/gpio_led。驱动一侧则需要向内核注册一个“字符设备”并分配设备号。设备号由主设备号和次设备号构成主设备号标识驱动类型次设备号用于区分同一个驱动管理的多个设备。手动指定主设备号很容易冲突更稳妥的方式是让内核动态分配。下面这段代码演示了申请设备号、初始化cdev、把设备添加到内核的全过程#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/uaccess.h #define LED_CLASS_NAME gpio_led #define LED_DEVICE_NAME gpio_led static dev_t led_devno; static struct cdev led_cdev; static struct class *led_class; static char led_state 0;在入口函数里做初始化static int __init led_init(void) { int ret; ret alloc_chrdev_region(led_devno, 0, 1, LED_DEVICE_NAME); if (ret 0) return ret; cdev_init(led_cdev, led_fops); led_cdev.owner THIS_MODULE; ret cdev_add(led_cdev, led_devno, 1); if (ret 0) goto out_unregister; led_class class_create(THIS_MODULE, LED_CLASS_NAME); if (IS_ERR(led_class)) { ret PTR_ERR(led_class); goto out_cdev_del; } device_create(led_class, NULL, led_devno, NULL, LED_DEVICE_NAME); return 0; out_cdev_del: cdev_del(led_cdev); out_unregister: unregister_chrdev_region(led_devno, 1); return ret; } module_init(led_init);这里最容易被忽略的是device_create这一行。很多教程只做cdev_add然后告诉你手动执行mknod创建设备节点。现代Linux系统里udev会监听内核发出的uevent自动在/dev下创建设备文件。而device_create就是触发uevent的关键。有了它驱动一加载/dev/gpio_led就会自动出现省去手动mknod的麻烦。3.2 file_operations找对入口函数字符设备驱动对外暴露的接口就是struct file_operations。它就像一张表格回答内核“应用层open的时候该调谁write的时候该调谁”。做LED点灯核心只需要实现write和readstatic ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { if (copy_to_user(buf, led_state, 1) ! 0) return -EFAULT; return 1; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[4]; if (count sizeof(kbuf)) count sizeof(kbuf); if (copy_from_user(kbuf, buf, count)) return -EFAULT; if (kbuf[0] 1) led_state 1; else if (kbuf[0] 0) led_state 0; return count; } static struct file_operations led_fops { .owner THIS_MODULE, .read led_read, .write led_write, };注意copy_from_user和copy_to_user这两个函数。内核空间不能直接访问用户空间的指针因为用户空间的地址可能还没有映射到内核页表。这两个函数会做地址合法性检查和数据拷贝返回非0表示拷贝失败驱动应该返回-EFAULT。内核开发里不允许出现“先用了再说”的鲁莽做法。3.3 入口出口要成对模块出口函数负责释放入口函数申请的资源。顺序正好相反先销毁设备再销毁类然后删除cdev最后释放设备号static void __exit led_exit(void) { device_destroy(led_class, led_devno); class_destroy(led_class); cdev_del(led_cdev); unregister_chrdev_region(led_devno, 1); } module_exit(led_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple gpio led driver);这个版本不碰任何硬件但你已经构建出一个“设备驱动”的壳。接下来要做的是告诉内核这个驱动到底要控制哪颗引脚。4. 设备树把硬件描述从代码里抽离出去4.1 为什么需要设备树以前的驱动会把硬件地址、中断号、引脚号写在C代码里换一块板子就要改源码重新编译。设备树改变了这种方式它用一套树状数据结构描述“板子上有什么、接在哪”驱动通过节点名称和属性去匹配、解析。换板子时通常只改设备树文件不用动驱动代码。正因如此我们的LED驱动不应该在C文件里硬编码“GPIO1_IO03”。而是让设备树告诉驱动这颗LED挂在GPIO1组的第3号引脚低电平点亮。驱动里去读这个属性即可。4.2 编写LED设备树节点在根节点/下添加一个自定义节点/ { gpio-led { compatible myled,gpio-led; pinctrl-names default; pinctrl-0 pinctrl_myled; led-gpios gpio1 3 GPIO_ACTIVE_LOW; status okay; }; };同时要配置引脚复用。i.MX6ULL里GPIO1_IO03默认可能是普通GPIO也可能被复用成其他功能必须把引脚mux成GPIO模式iomuxc { pinctrl_myled: myledgrp { fsl,pins MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x17059 ; }; };0x17059是引脚配置寄存器值包括上拉、驱动能力、压摆率等。别的平台写法不同但思路一样先让引脚变成GPIO再给GPIO一个“名字”。GPIO_ACTIVE_LOW这个宏来自include/dt-bindings/gpio/gpio.h值是1。它表达了一个语义GPIO逻辑1对应硬件“有效”而这个LED是低电平有效所以设备树里标记为GPIO_ACTIVE_LOW。驱动使用GPIO descriptor API读取这个flag后对上层代码完全透明。你调用gpiod_set_value(desc, 1)时内核底层自动输出低电平。4.3 驱动如何拿到设备树里的GPIO信息设备树写好后驱动侧通过devm_gpiod_get函数获取GPIO descstruct gpio_desc *gpio; gpio devm_gpiod_get(dev, led, GPIOD_OUT_LOW); if (IS_ERR(gpio)) return PTR_ERR(gpio);led对应节点里的led-gpios属性。内核解析时去掉-gpios后缀前面的就是属性名。GPIOD_OUT_LOW表示获取GPIO后初始状态设置为低电平。由于我们标记了GPIO_ACTIVE_LOW这里的“低”会再次被反转实际引脚输出高电平LED熄灭。这个细节非常烧脑但理解了就一劳永逸GPIO子系统帮你屏蔽了“物理电平”和“逻辑状态”的区别。5. 内核里操作GPIO的两套APIsh还是新秀5.1 旧式APIgpio_request和gpio_set_value传统写法是先通过of_get_named_gpio从设备树拿到一个整数编号再调用int gpio; int ret; gpio of_get_named_gpio(node, led-gpios, 0); ret gpio_request(gpio, led); if (ret 0) return ret; gpio_direction_output(gpio, 1); gpio_set_value(gpio, 0);这套API在早期内核里非常流行代码直观。但问题也很明显of_get_named_gpio拿到的整数是“全局GPIO编号”它依赖GPIO控制器注册顺序一旦控制器注册顺序变化或设备树里有多个控制器编号就可能改变。而且它没有自动处理GPIO_ACTIVE_LOW标志驱动还要自己判断active才输出1还是0容易出现“逻辑反了”的bug。5.2 新式APIGPIO descriptor全家桶新式API用struct gpio_desc *代替整数编号把active-low标志、方向、输出值的语义全部封装进去。配合devm_前缀生命周期也不用自己管。获取GPIO并默认输出低电平devm_gpiod_get(dev, led, GPIOD_OUT_LOW);设置输出gpiod_set_value(desc, 1); /* 逻辑点亮物理电平自动翻转 */释放由devm机制处理。驱动卸载时内核自动调用gpiod_put不需要你操心。这套做法在Linux 4.8之后成为新驱动的主流写法。我建议新手直接从新式API入手理由很简单省心且不容易写错。5.3 为什么不要裸写寄存器可能有人会问既然最终都是操作寄存器我直接ioremap物理地址对着数据手册改GPIO数据寄存器不就行了可以但那是“裸机思维”不是在写“Linux设备驱动”。直接操作寄存器有四个风险内核不知道你占用了哪个引脚其他驱动也可能操作同一引脚互相打架引脚复用配置分散在pinctrl子系统和GPIO子系统里绕开它们就会破坏一致性低有效、上拉、中断等硬件语义都要自己处理代码可移植性极差GPIO子系统附带完整的调试接口比如/sys/kernel/debug/gpio裸写寄存器就失去这些排查工具。GPIO子系统的设计目标就是给各类GPIO控制器提供一个统一抽象驱动作者只需要面向API编程。6. 把驱动升级成platform驱动让probe在合适的时机被调用6.1 为什么字符设备驱动还不够前面写的字符设备驱动能加载、能注册设备节点但它有个致命问题加载时没有与设备树建立关联。你也许在设备树里写了节点驱动却视而不见。而真实场景中内核需要知道“这个驱动对应哪个硬件节点”等硬件设备就绪后再调用驱动的probe函数。这就是platform设备驱动模型。平台驱动把工作放进probe和remove函数里。设备树解析到compatible myled,gpio-led节点时会创建平台设备驱动注册时内核通过of_match_table比对compatible字符串匹配成功后调用probe。驱动核心初始化都在probe里完成挂在设备树下自然就明白了硬件连接信息。6.2 完整代码LED驱动的最终形态把字符设备、GPIO、设备树串起来完整代码如下#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/platform_device.h #include linux/of.h #include linux/gpio/consumer.h #include linux/uaccess.h #define LED_DEVICE_NAME gpio_led struct gpio_led_dev { struct gpio_desc *desc; struct cdev cdev; struct class *class; struct device *device; dev_t devno; }; static struct gpio_led_dev *g_led_dev; static const struct file_operations led_fops; static int led_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { char val; if (!g_led_dev) return -ENODEV; val gpiod_get_value(g_led_dev-desc) ? 1 : 0; if (copy_to_user(buf, val, 1)) return -EFAULT; return 1; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[4]; if (!g_led_dev) return -ENODEV; if (count sizeof(kbuf)) count sizeof(kbuf); if (copy_from_user(kbuf, buf, count)) return -EFAULT; if (kbuf[0] 1) gpiod_set_value(g_led_dev-desc, 1); else if (kbuf[0] 0) gpiod_set_value(g_led_dev-desc, 0); return count; } static int led_probe(struct platform_device *pdev) { struct device *dev pdev-dev; int ret; g_led_dev devm_kzalloc(dev, sizeof(*g_led_dev), GFP_KERNEL); if (!g_led_dev) return -ENOMEM; g_led_dev-desc devm_gpiod_get(dev, led, GPIOD_OUT_LOW); if (IS_ERR(g_led_dev-desc)) { dev_err(dev, failed to get led gpio\n); return PTR_ERR(g_led_dev-desc); } ret alloc_chrdev_region(g_led_dev-devno, 0, 1, LED_DEVICE_NAME); if (ret 0) return ret; cdev_init(g_led_dev-cdev, led_fops); g_led_dev-cdev.owner THIS_MODULE; ret cdev_add(g_led_dev-cdev, g_led_dev-devno, 1); if (ret 0) goto out_unregister; g_led_dev-class class_create(THIS_MODULE, gpio_led); if (IS_ERR(g_led_dev-class)) { ret PTR_ERR(g_led_dev-class); goto out_cdev_del; } g_led_dev-device device_create(g_led_dev-class, dev, g_led_dev-devno, NULL, LED_DEVICE_NAME); if (IS_ERR(g_led_dev-device)) { ret PTR_ERR(g_led_dev-device); goto out_class_destroy; } dev_info(dev, gpio led driver probed\n); return 0; out_class_destroy: class_destroy(g_led_dev-class); out_cdev_del: cdev_del(g_led_dev-cdev); out_unregister: unregister_chrdev_region(g_led_dev-devno, 1); return ret; } static int led_remove(struct platform_device *pdev) { if (g_led_dev) { device_destroy(g_led_dev-class, g_led_dev-devno); class_destroy(g_led_dev-class); cdev_del(g_led_dev-cdev); unregister_chrdev_region(g_led_dev-devno, 1); } return 0; } static const struct of_device_id led_of_match[] { { .compatible myled,gpio-led }, { } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_platform_driver { .probe led_probe, .remove led_remove, .driver { .name gpio_led, .of_match_table led_of_match, }, }; module_platform_driver(led_platform_driver); static const struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .read led_read, .write led_write, }; MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(platform gpio led driver);有一点需要说明不同内核版本里class_create的参数数量有变化。Linux 5.x及以前常见的是class_create(THIS_MODULE, name)到6.x以后简化成class_create(name)。platform_driver的remove回调在6.1版本以后返回值也从int改成了void。如果你用的是新内核照着这个差异微调即可逻辑不变。module_platform_driver这个宏很关键。它展开后注册了driver又注册了module init/exit比手动调用platform_driver_register省事也避免遗漏。7. Makefile与模块装载从源码到板子上的最后一步7.1 这段Makefile新手必须背下来驱动模块的编译不同于普通C程序必须借助内核源码树里的Kbuild体系。Makefile核心内容就这几行obj-m : gpio_led.o KDIR : /home/user/linux-imx CROSS_COMPILE : arm-linux-gnueabihf- all: $(MAKE) -C $(KDIR) ARCHarm M$(PWD) CROSS_COMPILE$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KDIR) ARCHarm M$(PWD) cleanobj-m告诉Kbuild把gpio_led.c编译成内核模块。KDIR指向已配置好的内核源码目录。M$(PWD)表示要编译“外部模块”源码在Makefile所在目录。ARCHarm和CROSS_COMPILE用于交叉编译。如果忘了指定ARCH和CROSS_COMPILE编译出来的模块是你的x86宿主机架构放到ARM板子上根本加载不了。这里我踩过一个低级的坑Makefile里必须用Tab键缩进命令不能用四个空格。如果报错“missing separator”多半就是缩进有问题。7.2 拷贝与装载insmod只是其中一环编译成功后目录下会生成gpio_led.ko。把它拷贝到开发板上方式随意NFS挂载、scp、U盘都行。我常用scpscp gpio_led.ko root192.168.1.100:/root/上板后先加载insmod gpio_led.ko如果一切正常dmesg | tail应该能看到gpio led driver probed同时/dev/gpio_led节点会创建默认属主是root。用普通用户操作前可能需要chmod 666 /dev/gpio_led或者配置一条udev规则。开发阶段图省事我一般直接root测试。卸载模块用rmmod gpio_led。注意rmmod只能卸载“没有被占用”的模块。如果有应用层程序正打开着设备文件卸载会失败提示Device or resource busy。这是内核保护机制很合理。7.3 为什么insmod加载后没有probe新手遇到最多的问题是模块加载成功但dmesg里没有probe打印。这时候第一反应不要怀疑设备树没生效先检查compatiblegrep -n myled,gpio-led /sys/firmware/devicetree/base/gpio-led/compatible如果输出为空说明设备树没编进去或者节点路径不对。第二件要检查的是当前系统里有没有另一个驱动的compatible恰好也是myled,gpio-led导致平台设备被别的驱动先绑定了。可以用ls /sys/bus/platform/drivers/gpio_led/查看驱动绑定情况。这两个点最基础也最容易被忽略。8. 用户态测试赶紧让LED动起来驱动装好设备节点出现就该准备测试。最简单的测试方式直接在命令行敲echo 1 /dev/gpio_led echo 0 /dev/gpio_led如果LED没反应别急先看内核日志。如果驱动write里copy_from_user正常这行命令应该能控制LED亮灭。但为了把read和write两个接口都测透我更推荐写一个几十行的小程序#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #define DEV_PATH /dev/gpio_led int main(int argc, char *argv[]) { int fd; char buf[1]; if (argc ! 2) { fprintf(stderr, usage: %s [0|1]\n, argv[0]); return 1; } fd open(DEV_PATH, O_RDWR); if (fd 0) { perror(open); return 1; } buf[0] argv[1][0]; if (write(fd, buf, 1) ! 1) { perror(write); close(fd); return 1; } lseek(fd, 0, SEEK_SET); if (read(fd, buf, 1) 1) printf(led state: %c\n, buf[0]); close(fd); return 0; }这个小程序验证了三件事设备文件能不能正常open数据能不能从用户态传到内核态内核态还能不能把状态读回用户态。如果你熟悉Linux文件编程会发现这跟操作普通文件没什么两样。这正是字符设备驱动的精髓所在对外暴露文件接口内部各自精彩。9. 调试三板斧dmesg、debugfs、查看GPIO状态驱动写完不等于不出问题。我总结了三套最常用的调试手段按顺序用基本能覆盖绝大多数情况。9.1 dmesg永远第一个出场驱动里的dev_err、dev_info、printk都会输出到内核日志。我在probe和write路径里加了打印信息方便观察调用顺序。调试时可以直接dmesg | tail -20如果嫌日志太多按模块过滤dmesg | grep gpio_led一个建议是开发阶段打印要“有选择地加”。打印太多会刷屏尤其注意不要在中断上下文或者高频调用路径里加printk否则严重影响性能。点灯这种低频操作就没问题。9.2 debugfs里看GPIO全貌内核GPIO子系统提供了debugfs接口位置在/sys/kernel/debug/gpio。如果挂载了debugfs直接查看cat /sys/kernel/debug/gpio输出里能看到每个GPIO控制器的使用情况哪些引脚被占用了方向是什么当前值是几。例如我们使用的GPIO1_IO03会出现在GPIO控制器下面的记录里标记为gpio-3方向和value也能直接看到。这比盲目猜寄存器状态靠谱得多。9.3 用devmem验证物理层如果GPIO子系统显示正常但LED还是不亮那问题可能出在硬件层面引脚复用错了、上拉配置不对、或者LED本身就坏了。这时候可以用devmem直接读寄存器比如i.MX6ULL上GPIO1的数据寄存器地址读出来看对应bit有没有变化。这属于最后手段因为绕过驱动直接看寄存器能确认到底是“内核软件问题”还是“硬件连接问题”。正常开发流程里用到这步说明问题已经非常底层了。10. 从一颗LED到完整驱动的进阶路线写完这个驱动之后你手里已经握着一份“麻雀虽小五脏俱全”的代码。它包含了设备模型、设备树、字符设备、GPIO子系统、file_operations、模块编译全套知识点。接下来想继续深入我建议按下面顺序走每一步都在原基础上加新东西加一个按钮按键用GPIO中断上报按键事件理解中断下半部和阻塞IO把read接口改成poll机制应用层用select等待设备事件理解内核的异步通知机制用ioctl扩展控制命令比如设置闪烁频率理解应用层与驱动之间的命令协议改用内核的led-class框架把设备注册到/sys/class/leds让标准工具也能操作理解内核“类”抽象的作用尝试把同一份驱动跑在不同板子上只需要改设备树里的gpio引脚描述理解设备树带来的可移植性。我个人在实际写驱动时的习惯是每写一个功能都先确认“这个函数能不能在5.x和6.x内核里通用”一旦发现API有变化就顺手记录下来。内核接口变动很频繁能拿出手的经验往往不在于背住某个函数而在于知道去哪里查、怎么快速适配新版本。开发Linux驱动本质上是在一个约束很多的环境里干活要考虑并发访问、要考虑资源释放顺序、要考虑内核态和用户态的安全边界。一颗LED把这些约束全部暴露了一遍却又不至于让人失去信心。花一个下午把这篇文章里的代码敲一遍再亲手把设备树、Makefile、测试程序从头到尾完整跑通你对Linux设备驱动的理解会远超屏幕上那行“Hello world”。希望这份实操记录能帮你在Linux驱动开发里顺利点亮第一盏灯。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进