说到嵌入式Linux驱动入门,“点灯”几乎是所有人绕不开的第一课。我自己当初也是跟着某套很经典的手把手教程,从裸机寄存器操作一路写到基于GPIO子系统的LED驱动。这套东西看起来简单,但它背后串起来的知识点一点都不少:设备树怎么描述硬件、platform总线怎么匹配驱动、GPIO子系统怎么帮你管理引脚、字符设备怎么和用户态交互。把这些理清楚,后面再写按键、中断、串口驱动都会顺很多。
这篇文章就围绕“基于GPIO子系统的LED驱动程序”这个经典任务展开,我把设备树写法、驱动代码骨架、Makefile、调试手段和常见坑一次说清楚。适合刚看完裸机点灯、正准备进入Linux驱动开发的同学参考,也适合那些写过杂七杂八驱动、但始终没把GPIO子系统完整走一遍的人查漏补缺。
1. 这个驱动为什么值得认真写一遍
1.1 从寄存器直操作到子系统管理
在单片机和裸机阶段,点LED逻辑很简单:找到GPIO控制寄存器的地址,查数据手册找到对应位,往寄存器里写值,电平就出来了。无非是“配置方向、设置输出、循环延时”,整个工作量很小,出了问题翻手册就好。
但一旦跑起Linux内核,事情就不一样了。系统里几十个驱动同时在跑,如果每个驱动都直接去操作物理寄存器,必然乱套。你今天把某个引脚当GPIO输出用,明天声卡驱动把它当I2C引脚初始化一遍,两边就会打架。内核给出的解决方案是资源统一管理,把引脚的使用权收归几个子系统,驱动要用引脚就去“申请”,用完就“释放”。
这也是GPIO子系统出现的原因。它给驱动开发者提供了一套统一API,比如申请引脚、设置方向、读写电平。驱动不用关心引脚在哪个bank、偏移多少位、寄存器地址是什么,只需要给系统一个gpio号,剩下的硬件操作由底层芯片驱动完成。换一颗SoC,只要子系统适配好了,驱动代码一行不用改,或者只改设备树。
1.2 GPIO子系统和pinctrl子系统一起工作
刚开始接触这套东西,很多人分不清GPIO子系统和pinctrl子系统,以为它们是同一个东西。实际分工很明确。
pinctrl负责管引脚本身,包括引脚复用、上下拉、驱动能力。比如某颗SoC的一个引脚,默认可能是普通GPIO,也可以复用成UART的TX。想让这个引脚作为GPIO使用,必须先通过pinctrl把它配置成GPIO功能,否则它是UART状态,你写电平根本没反应。
GPIO子系统则负责管电平读写、方向设置和中断。pinctrl配置好的是“这个引脚现在能干什么”,GPIO子系统管的是“具体怎么干”。
在设备树里体现得更清楚。一个LED节点里,既有pinctrl-0属性用来指定引脚的复用配置,又有led-gpios属性用来告诉GPIO子系统要用几号引脚、什么极性。
1.3 设备树就是驱动的硬件说明书
老式内核时代,驱动里经常直接写死寄存器地址、中断号、GPIO编号。换一块板子,引脚变了就得改驱动代码重新编译,维护起来非常痛苦。
设备树把这种信息从源码里剥离了出来。它描述“硬件长什么样、引脚怎么连、中断是哪个”,驱动通过节点、属性去读取这些资源。同一个驱动,只要设备树里属性写得对,拿到不同的硬件上都能适配。这也是现在的驱动Why普遍喜欢使用设备树的原因。
学习GPIO子系统LED驱动,本质上就是学习驱动如何从设备树节点中获取gpio资源,然后调用GPIO子系统的API完成操作。这条链路是理解后续很多外设驱动的样板。
1.4 学完这套得到的通用骨架
别小看这个LED驱动,它几乎包含了字符设备驱动的全部要素:
- module_init/module_exit入口;
- platform_driver结构体与设备树compatible匹配;
- probe函数里获取资源、初始化硬件;
- file_operations结构体的open、write、ioctl实现;
- class_create和device_create创建设备节点;
- Makefile的模块编译方式。
这个骨架换个马甲,再加点中断、定时器、内核线程、设备模型,就是按键驱动、PWM驱动、I2C驱动、SPI驱动的雏形。所以这套代码值得反复敲几遍,直到能不看源码独立写出来为止。
2. 写驱动之前,先把这些准备工作做扎实
2.1 硬件接线与原理图阅读
别一上来就写代码,先把硬件搞明白。LED一般是长脚接正极、短脚接负极,嵌在开发板上的LED其实已经通过限流电阻接到了某个GPIO上。你需要做的是找到原理图,确认LED连接的是哪个GPIO,以及LED点亮方式是“GPIO输出高电平点亮”还是“输出低电平点亮”。
很多人在这一步就埋了坑。同一个LED,你的板子可能是低电平点亮,驱动里如果按高电平写,灯死活不亮。极性判断错了,代码写得再对也没用。
还需要确认这个GPIO是否引出了其他用途。有些开发板的引脚同时连接了按键、I2C、音频芯片等外设,被别的驱动占用了,你申请GPIO时会失败。所以准备阶段最好查一下开发板默认设备树里这个引脚有没有被其他地方使用。
2.2 内核源码、设备树和交叉编译环境
这套驱动需要编译成内核模块,所以本地得有一套与开发板匹配的内核源码。建议直接用板卡厂商提供的内核源码,不要自己找一份版本不对的,否则编译出来的模块加载很可能直接报错。
内核源码需要预先编译通过,至少要有编译设备树和模块的基础环境。交叉编译工具链也要配好,确认gcc版本和内核源码使用的编译器一致。
模块编译好之后,怎么拷到开发板上也需要提前准备好。最简单的是通过NFS挂载根文件系统,或者用tftp、U盘、scp拷贝,根据自己的习惯来。
2.3 先确认Linux内核能否启动
很多初学者拿到开发板就开始写驱动,结果驱动加载失败,排查半天发现是内核本身没起来或者rootfs有问题。建议先跑一遍官方提供的出厂系统,确认内核、文件系统、网络都正常,再着手开发。
启动后先执行:
mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/gpio这个文件会列出当前系统中所有GPIO的占用状态、方向、当前电平。先看清楚目标GPIO是不是已经被占用,再决定要不要继续用这个引脚。
如果板子默认的GPIO信息太多,也可以用下面命令过滤关键内容:
cat /sys/kernel/debug/gpio | grep -i "led"2.4 准备一个最简单的验证用户程序
驱动写完总要测,不用写多复杂的程序,一个最简用户态测试工具就够了。直接写一个脚本或者小程序,往设备节点里写0或1,观察LED对应亮灭变化。用户程序的逻辑越简单越好,这样驱动行为异常时,你能把问题锁定在驱动侧还是用户侧。
3. 设备树节点的写法,这一步错了后面全白搭
3.1 一个完整的LED设备树节点
设备树是驱动对外部世界的唯一认识来源,节点写错,驱动里怎么补救都有限。下面给出一个典型的LED节点,你可以对照自己的平台修改:
led_test { compatible = "example,led"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_led_default>; led-gpios = <&gpio1 18 GPIO_ACTIVE_LOW>; status = "okay"; };这个节点挂在设备树的根下或者某个soc主节点下都可以,关键是要能被内核正确解析到。名称不唯一也没关系,内核匹配主要看compatible属性。
3.2 各属性含义逐个拆解
先说compatible。驱动里platform_driver的of_match_table里必须写一个一模一样的字符串,两者对上,驱动probe才会被调用。这是设备和驱动握手的关键。
pinctrl-names和pinctrl-0告诉内核,这个设备有一组默认的引脚配置,配置内容指向pinctrl_led_default。内核在设备probe之前会主动去应用这组配置,把引脚复用成GPIO功能。如果少了这个,引脚可能还停留在别的复用状态。
led-gpios是真正给GPIO子系统用的属性。它由三个部分组成:第一个是GPIO控制器的phandle,你可以理解为指向哪个GPIO寄存器组,比如&gpio1;第二个是引脚在该控制器内的编号;第三个是极性标志,GPIO_ACTIVE_HIGH表示高电平有效,GPIO_ACTIVE_LOW表示低电平有效。
3.3 pinctrl子节点怎么配
pinctrl子节点没有完全通用的写法,各芯片厂商定义的格式五花八门。拿主流SoC来说,通常是在pinctrl控制器节点下面增加一个子节点,内容大致如下:
&pinctrl { pinctrl_led_default: led_default { pinmux = <LED_GPIO_PINMUX>; bias-disable; drive-strength = <2>; }; };这里的pinmux具体值需要查你所用平台的数据手册。有的平台用宏定义,有的平台直接用寄存器地址和掩码。你不用把每种平台记牢,重要的是理解:这个节点存在的目的是告诉pinctrl子系统,把某个引脚配置成GPIO模式,并设置上下拉和驱动能力。
强烈建议先用厂家提供的设备树模板,找到某个已有的pinctrl节点照着改,比从零写省力得多。
3.4 编译设备树并反向验证
设备树文件改好后,需要编译成dtb再烧录或替换到开发板的/boot分区。具体命令依赖你的内核构建系统,常见做法是在内核源码根目录执行:
make dtbs如果设备树语法写错了,编译会直接报错,这也是第一道检查。能编过不代表正确,最好反编译看看实际生成的结构:
dtc -I dtb -O dts -o test.dts test.dtb打开test.dts检查led_test节点是否存在、led-gpios里的phandle和值是否正确。很多情况下,设备树在dts里看起来没问题,但解析出来的gpio号不对,反编译能提前发现问题。
加载内核后,还可以通过/sys/firmware/devicetree/base目录查看实际生效的设备树。如果能看到led_test节点,说明设备树确实被内核正确加载了:
ls /sys/firmware/devicetree/base/led_test cat /sys/firmware/devicetree/base/led_test/compatible4. 驱动代码骨架,照着敲一遍就理解了
4.1 让驱动和设备树对上话
驱动部分的头尾框架每个模块都差不多,关键是module_init、module_exit和platform_driver结构体的注册方式。下面是一个基础模板:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of_gpio.h> #include <linux/gpio.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/device.h> #include <linux/cdev.h> #include <linux/slab.h> static struct platform_driver led_driver; static int led_probe(struct platform_device *pdev) { // 获取设备树资源、初始化LED return 0; } static int led_remove(struct platform_device *pdev) { // 释放资源、关闭LED return 0; } static const struct of_device_id led_of_match[] = { { .compatible = "example,led", }, { }, }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver = { .probe = led_probe, .remove = led_remove, .driver = { .name = "example_led", .of_match_table = led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("GPIO LED Driver");module_platform_driver宏会依次展开成module_init和module_exit,把platform_driver注册进内核。of_match_table里写了compatible,内核在遍历设备树时发现compatible匹配,就会调用probe。
有个细节值得说明:platform_driver的driver.name只是一个额外标识符,设备树匹配主要依靠of_match_table,而不是这个字符串。所以有时候你在别处看到name和compatible完全不一样,这完全是正常的。
4.2 probe函数:获取GPIO并完成初始化
probe是驱动的核心,它的任务是获取设备树里的资源,并使用GPIO子系统API完成LED初始化。这里我写一个完整可参考的版本:
static int led_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct device_node *np = dev->of_node; int ret, gpio; if (!np) { dev_err(dev, "no device node\n"); return -EINVAL; } gpio = of_get_named_gpio(np, "led-gpios", 0); if (gpio < 0) { dev_err(dev, "failed to get gpio: %d\n", gpio); return gpio; } dev_info(dev, "gpio=%d\n", gpio); if (!gpio_is_valid(gpio)) { dev_err(dev, "invalid gpio %d\n", gpio); return -EINVAL; } ret = devm_gpio_request(dev, gpio, "led"); if (ret < 0) { dev_err(dev, "failed to request gpio %d: %d\n", gpio, ret); return ret; } ret = gpio_direction_output(gpio, 0); if (ret < 0) { dev_err(dev, "failed to set output\n"); return ret; } led_gpio = gpio; return 0; }逐个解释关键API的作用。
of_get_named_gpio从当前设备的device_node中查找led-gpios属性,返回一个gpio号。这个gpio号是把GPIO控制器phandle和引脚号换算后的全局编号,GPIO子系统内部用它定位硬件。
gpio_is_valid用来检查gpio号是否合法,防止设备树写错导致后续操作存取越界。
devm_gpio_request是“devm”版本,带有自动释放功能,probe失败或设备移除时不光不用手动释放,系统还会自动调用gpio_free。早期驱动使用gpio_request,卸载时如果忘记gpio_free,再次加载就会申请失败,这个经典问题很多人遇到过。所以新代码里优先用devm_前缀API是个好习惯。
gpio_direction_output设置引脚方向为输出并给初值。第二个参数0对应低电平,1对应高电平。这里别忘了,如果LED是低电平点亮,给0实际上是点亮,给1是熄灭。方向与初始电平一次完成,省去单独再调用gpio_set_value。
4.3 file_operations:把用户操作映射到GPIO
驱动最终要面向用户态,需要实现操作函数。这里实现最基本的open、write和ioctl,还有一个release。
static int led_open(struct inode *inode, struct file *filp) { if (led_gpio < 0) return -EINVAL; return 0; } static ssize_t led_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { char value; if (len < 1) return -EINVAL; if (copy_from_user(&value, buf, 1)) return -EFAULT; if (value == '1') gpio_set_value(led_gpio, 1); else gpio_set_value(led_gpio, 0); return len; } static long led_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { switch (cmd) { case 0: gpio_set_value(led_gpio, 0); break; case 1: gpio_set_value(led_gpio, 1); break; default: return -EINVAL; } return 0; } static int led_release(struct inode *inode, struct file *filp) { return 0; } static const struct file_operations led_fops = { .owner = THIS_MODULE, .open = led_open, .write = led_write, .unlocked_ioctl = led_ioctl, .release = led_release, };write实现里,copy_from_user从用户空间拷贝1个字节,然后判断是不是字符'1'。这已经是最简驱动器能应付的交互方式,很直观。
ioctl是很多驱动用来收发控制命令的接口。这里我把0和1作为点亮、熄灭的命令,方便用户态调用。真正复杂的驱动会定义大量私有命令号,但在LED这里保持简单即可。
open函数里没有做太多事情,只是一次合法性检查。注意LED状态不应该在open时重置,否则用户程序刚打开设备就把灯关了,体验很怪。
4.4 设备节点的创建与销毁
只有register_chrdev还不够,用户态访问/dev/led_drv需要设备节点。最常用的方式是利用class_create和device_create,让udev或mdev自动创建设备节点。
static dev_t led_dev_num; static struct class *led_class; static struct device *led_device; static int __init led_init(void) { int ret; ret = alloc_chrdev_region(&led_dev_num, 0, 1, "led_drv"); if (ret < 0) return ret; cdev_init(&led_cdev, &led_fops); ret = cdev_add(&led_cdev, led_dev_num, 1); if (ret < 0) goto err_cdev; led_class = class_create(THIS_MODULE, "led_class"); if (IS_ERR(led_class)) { ret = PTR_ERR(led_class); goto err_class; } led_device = device_create(led_class, NULL, led_dev_num, NULL, "led_dev"); if (IS_ERR(led_device)) { ret = PTR_ERR(led_device); goto err_device; } return 0; err_device: class_destroy(led_class); err_class: cdev_del(&led_cdev); err_cdev: unregister_chrdev_region(led_dev_num, 1); return ret; } static void __exit led_exit(void) { device_destroy(led_class, led_dev_num); class_destroy(led_class); cdev_del(&led_cdev); unregister_chrdev_region(led_dev_num, 1); } module_init(led_init); module_exit(led_exit);alloc_chrdev_region分配动态主设备号,主设备号由内核分配,避免手动指定时和其他设备冲突。cdev_add把字符设备注册进内核,之后用户open时就能找到我们实现的file_operations。
class_create之后,在sysfs里会生成/sys/class/led_class目录。device_create则会在该class下创建一个叫led_dev的设备,udev/mdev检测到后自动生成/dev/led_dev。这也是为什么很多教程里强调必须做class_create和device_create,否则/dev下没有设备节点,用户态根本打不开文件。
4.5 Makefile写法与常见编译问题
内核模块的编译方式不同于普通应用程序,要用内核的kbuild系统。一个标准的Makefile长这样:
obj-m := led_drv.o KDIR := /home/xxx/linux-kernel PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) cleanobj-m表示编译成外部模块。KDIR是内核源码绝对路径,建议使用与开发板运行内核完全对应的源码目录。编译时不要用sudo,普通用户权限就够了,除非内核源码目录本身权限受限。
编译输出是led_drv.ko,用file命令看一下架构对不对:
file led_drv.ko如果交叉编译环境没配对,这里会显示x86而不是arm,加载时必然报错。
4.6 加载、测试和卸载的基础操作
把led_drv.ko拷贝到开发板后,按顺序执行:
insmod led_drv.ko dmesg | tail ls -l /dev/led_dev echo 1 > /dev/led_dev echo 0 > /dev/led_dev rmmod led_drvinsmod加载后,dmesg能看到probe里的dev_info输出,包括gpio号。ls确认设备节点存在。echo写1和写0切换LED状态。
如果echo的时候提示Operation not permitted,多半是设备节点权限问题,可以直接chmod 666,或者用root用户测试。驱动开发阶段建议直接root操作,别让权限问题干扰调试。
卸载前先确认没有进程占用设备节点,否则rmmod会提示Device or resource busy。可以用fuser命令查看占用进程。
5. 实际开发中必然遇到的坑,我一个个踩给你看
5.1 probe没有被调用,最常见的低级失误
驱动模块加载成功,insmod没有报错,但dmesg里完全没有probe的打印。这种情况90%是compatible字符串不匹配。
检查驱动里的of_match_table和实际设备树里compatible属性是不是一字不差。常见错误包括多打了个空格、大小写不统一、忘了加引号。比较稳妥的办法是在开发板上直接读设备树节点的compatible,然后写进驱动。
另一个原因是设备树虽然改了,但内核启动时用的不是新dtb。很多开发板启动用的是boot分区里的dtb,而不是你编辑的那个dts。改了dts但没重新生成dtb,或者生成了dtb但没有替换到正确位置,都会导致probe不跑。排查手段就是前面说的查看/sys/firmware/devicetree/base下节点是否存在。
5.2 设备树里明明写着gpio,驱动读出来却是负数
of_get_named_gpio返回负数,常见原因是属性名拼写错误。比如设备树里写的是led-gpio,驱动里面写led-gpios,两者没对上就会返回-ENOENT。
还有一类原因:GPIO控制器的phandle写错了。比如节点里写成&gpio1,但这个平台根本没有gpio1这个节点,或者实际引脚在gpio0上,解析自然失败。这类问题可以通过反编译dtb来快速确认phandle指向是否正确。
还有一点容易忽略:如果设备树里led-gpios属性所在的节点本身没有处于enabled状态,内核可能根本不会解析这个节点。常见做法是显式加上status = "okay",有些平台默认是disabled,忘记了就完了。
5.3 gpio_request失败,引脚被谁抢走了
devm_gpio_request失败时,dmesg会打印“failed to request gpio X”。此时打开/sys/kernel/debug/gpio,看看这个gpio的占用者是谁。
我遇到过一次,某颗SoC自带音频编解码芯片默认占用了大量GPIO,其中恰好包含想用的LED引脚。这种情况下要么换一个空闲引脚,要么在内核配置里关掉不用外设。驱动层面没有太好的解决办法,毕竟引脚管理由内核全局统筹,单个驱动无权抢占别的设备正在使用的资源。
还有一种情况是同一个gpio被同一个驱动重复request。比如模块卸载不彻底又重新加载,或者probe被调用两次。使用devm_gpio_request后,这类问题少了很多,因为系统会自动释放,但如果你同时用了gpio_request又没释放,地址很容易踩雷。
5.4 流程都正常,灯就是不亮
有时候dmesg里gpio号正确,gpio_request也成功了,echo写值也不报错,但灯纹丝不动。这种问题最费时间,往往是几个地方同时出问题。
先排查极性。用万用表量一下GPIO引脚电平,echo 1时是高还是低。如果LED是低电平点亮,echo 1对应熄灭,echo 0才对应点亮,逻辑写反了。
再审pinctrl配置。如果引脚还被复用成其他功能,输出电平由其他外设控制,GPIO子系统设置的输出根本不起作用。偏置也可能干扰,比如引脚被外部电路拉死,GPIO输出的电平被强行拉低。这时要重新检查pinctrl子节点里bias-disable、bias-pull-up这些配置。
最后查硬件连接。某次我发现开发板上的LED不亮,是因为焊接的LED引脚松了,或者限流电阻虚焊,跟驱动半毛钱关系没有。硬件问题往往要借助万用表来确认,光看代码没有用。
5.5 模块卸载后再次加载,设备节点起冲突
只执行rmmod,然后立刻insmod,第二次加载时dmesg可能提示Device or resource busy,或者看不到/dev/led_dev节点。
这类现象多由资源释放不完整导致。比如手动request的gpio没有释放,字符设备cdev没删除,class和device没有销毁。使用devm_后gpio释放问题缓解,但cdev和class的清理必须自己保证。尤其要注意,cdev_del必须在unregister_chrdev_region之前调用,顺序反了会留下僵尸设备号和僵尸节点。
如果遇到设备节点残留的情况,先手动清理:
rm -f /dev/led_dev然后再重新加载驱动。当然这只是暂时止血,根本办法还是把module_exit里的释放逻辑捋清楚。
5.6 设备节点没自动创建,权限还跟着捣乱
insmod成功后,/dev/led_dev并不存在,原因通常是class_create或device_create失败。dmesg里会有明确报错,检查一下是不是class名字被其他驱动占用了,换个类名重新试。
设备节点只要存在,但不属于当前用户,普通用户echo就会报Permission denied。学习阶段最简单的是chmod 666 /dev/led_dev,或者用root跑测试程序。正式产品里可以用udev规则给设备指定用户和权限组,避免直接开放所有权限。
5.7 调试技巧:多打日志,但别打满屏
驱动开发调试主要靠printk/dev_info。probe开始、成功获取gpio、成功request、成功配置方向,每一个关键节点都可以打印一条信息。这样出错时dmesg能直接定位到失败在哪一步。
不过打印太多会影响性能,调试完成后记得删除无用日志。可以用dev_dbg替代dev_info,然后通过内核动态调试机制控制输出,这样平时不打印、需要时再打开,比改源码重新编译友好得多。
最后说点实际的体会
这个驱动我自己至少写了五遍。第一遍照着教程抄,很多API看完就忘;第二遍试图不看教程自己写,结果卡在device_create的参数上;第三遍开始理解为什么用devm、为什么compatible不匹配不probe;到第五遍的时候,我基本能闭着眼睛把整个骨架默写出来。
我觉得这个项目真正的价值不在于点亮一颗LED,而在于它把Linux驱动开发里“设备树解析、总线匹配、字符设备、内核资源管理”这些核心概念串成了一条完整的链路。走通一次,再去看I2C驱动、SPI驱动、中断子系统,都会有一种似曾相识的感觉。
如果你正在学这套东西,我的建议是:别看懂了就急着过,找一块板子,从设备树写起到用户态点灯完整走一遍,中途遇到问题解决问题。踩过一遍坑,比看过十遍教程都管用。