资讯动态

嵌入式Linux GPIO驱动开发实战:设备树与gpiod API详解

发布时间:2026/10/28 1:10:59 来源:尧图企业网站定制
1. GPIO驱动开发的核心思路与整体设计1.1 为什么GPIO驱动值得单独拿出来讲很多人刚接触嵌入式Linux的时候觉得GPIO不就是拉高拉低吗有什么好讲的。但真正做过项目的人都知道GPIO驱动是整个驱动开发体系里最基础、也最容易踩坑的一块。它看起来简单实际上涉及了从硬件寄存器操作、时钟管理、引脚复用、设备树描述到内核GPIO子系统框架、sysfs接口、字符设备接口等一整条链路。你如果连GPIO驱动都没吃透后面去搞I2C、SPI、中断控制器这些基本就是空中楼阁。我这次做的这个GPIO驱动项目目标很明确在Linux环境下基于设备树的方式写一个可加载的GPIO驱动模块能够控制开发板上的LED灯同时能够读取按键状态。这个需求听起来简单但它覆盖了GPIO驱动开发中最核心的几个环节。做完这个项目之后你对Linux GPIO子系统的理解会上一个台阶。适合看这篇内容的人有一定C语言基础、了解Linux基本操作、想入门嵌入式Linux驱动开发的朋友。如果你已经做过字符设备驱动那这篇会让你对GPIO子系统的框架有更系统的认识。如果你完全没接触过驱动建议先把字符设备驱动框架过一遍再来看。1.2 整体方案选型为什么用GPIO子系统而不是直接操作寄存器这是我想重点聊的一个问题。很多教程一上来就教你ioremap然后直接写寄存器这种方式在裸机或者RTOS下没问题但在Linux内核里直接操作寄存器是非常不推荐的做法。原因有几个第一Linux内核已经有成熟的GPIO子系统gpiolib它帮你管理了GPIO的申请、释放、方向设置、电平读写、中断映射等所有事情。你自己去操作寄存器等于绕过了整个框架出了问题内核帮不了你。第二直接操作寄存器意味着你的驱动和具体芯片强绑定。换个平台寄存器地址变了你的驱动就废了。而用GPIO子系统加设备树的方式驱动代码可以做到平台无关换平台只需要改设备树。第三内核的GPIO子系统提供了引用计数和冲突检测机制。如果你直接操作寄存器两个驱动同时操作同一个引脚出了问题你根本查不到原因。而通过gpio_request申请引脚如果有冲突内核会直接告诉你。所以我的方案是使用Linux内核的GPIO子系统API通过设备树描述硬件连接关系驱动代码通过of_get_named_gpio或者gpiod_get系列接口获取GPIO然后使用gpiod_set_value、gpiod_get_value等接口进行读写操作。1.3 新旧两套API的选择gpiod vs legacy这里有一个很多人容易搞混的地方。Linux内核的GPIO子系统有两套API一套是老式的基于整数编号的APIgpio_request、gpio_direction_output、gpio_set_value等另一套是新的基于描述符的APIgpiod_get、gpiod_set_value等。新API的好处是它把GPIO描述符和具体的GPIO编号解耦了而且天然支持设备树。老API在设备树场景下需要先用of_get_named_gpio把设备树里的GPIO转换成整数编号然后再用gpio_request申请。新API直接用gpiod_get就能从设备树里拿到描述符一步到位。我这次用的是新APIgpiod系列因为它是内核社区推荐的方向而且代码更简洁。但你在看一些老教程或者老代码的时候会大量看到老API所以两种都得了解。2. 核心细节解析与实操要点2.1 设备树中GPIO的描述方式设备树是Linux驱动开发绕不开的东西。对于GPIO驱动来说设备树的核心作用就是告诉驱动你控制的是哪个GPIO控制器上的哪个引脚这个引脚是输出还是输入默认电平是什么。先看一个典型的设备树节点写法my_gpio_device { compatible mycompany,my-gpio-device; led-gpios gpio1 3 GPIO_ACTIVE_HIGH; key-gpios gpio0 12 GPIO_ACTIVE_LOW; status okay; };这里有几个关键点需要解释。gpio1表示引用的是GPIO控制器1后面的3表示这个控制器上的第3号引脚。GPIO_ACTIVE_HIGH表示高电平有效也就是说你设置值为1的时候引脚输出高电平。如果是GPIO_ACTIVE_LOW那你设置值为1的时候引脚实际上输出低电平内核帮你做了反转。这个GPIO_ACTIVE_LOW非常有用。比如按键电路通常是按下接地那么按下时引脚是低电平松开是高电平。如果你在设备树里标了GPIO_ACTIVE_LOW那驱动里读到的值就是按下为1松开为0。逻辑上更直观。还有一个容易忽略的点led-gpios这个属性名后面的-gpios后缀是必须的。内核的of_gpio解析函数会识别这个后缀。你写成led或者led-gpio少个s都可能解析失败。这个坑我踩过当时查了半天才发现是属性名写错了。2.2 GPIO控制器的设备树结构上面说的是设备节点里怎么引用GPIO。但GPIO控制器本身的设备树节点也需要正确配置。以常见的SoC为例GPIO控制器的节点大概长这样gpio1: gpio0209c000 { compatible fsl,imx6ul-gpio, fsl,imx35-gpio; reg 0x0209c000 0x4000; interrupts GIC_SPI 66 IRQ_TYPE_LEVEL_HIGH, GIC_SPI 67 IRQ_TYPE_LEVEL_HIGH; gpio-controller; #gpio-cells 2; interrupt-controller; #interrupt-cells 2; };gpio-controller这个属性表明这个节点是一个GPIO控制器。#gpio-cells 2表示引用这个控制器的GPIO时需要用两个参数第一个是引脚编号第二个是标志位比如GPIO_ACTIVE_HIGH。这两个属性缺一不可少了任何一个你在设备节点里引用GPIO的时候都会报错。2.3 GPIO编号的计算方式虽然新API不需要你手动算GPIO编号但了解编号的计算方式对调试非常有帮助。Linux内核里有一个全局的GPIO编号空间每个GPIO控制器的引脚都会被映射到这个空间里。计算公式是全局编号 控制器基地址 引脚在控制器内的偏移。控制器基地址可以通过gpiochip_find_base或者查看/sys/class/gpio/下的gpiochip目录来确定。比如ls /sys/class/gpio/ # gpiochip0 gpiochip32 gpiochip64 gpiochip96这说明第一个控制器管理0-31号引脚第二个管理32-63号以此类推。如果你要操作gpio1的第3号引脚全局编号就是32335。在sysfs接口下你可以这样操作echo 35 /sys/class/gpio/export echo out /sys/class/gpio/gpio35/direction echo 1 /sys/class/gpio/gpio35/value但注意sysfs的GPIO接口在内核里已经被标记为deprecated了新代码应该用字符设备接口/dev/gpiochipN。不过调试的时候sysfs还是很方便的。2.4 GPIO的8种工作模式与选择逻辑虽然Linux驱动层面主要关心输入和输出但硬件层面GPIO的工作模式远不止这两种。常见的8种模式包括浮空输入、上拉输入、下拉输入、模拟输入、开漏输出、推挽输出、复用开漏、复用推挽。在Linux驱动开发中你通常不需要手动配置这些模式因为引脚复用和电气特性通常由pinctrl子系统管理。你需要在设备树里配置pinctrl节点指定引脚的功能和电气属性。比如iomuxc { pinctrl_led: ledgrp { fsl,pins MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10B0 ; }; };这里的0x10B0就是引脚的电气配置值包含了上拉/下拉、驱动能力、转换速率等信息。具体每一位的含义需要查芯片的数据手册。这个值配错了引脚可能输出不了正确电平或者驱动能力不够带不动负载。2.5 字符设备接口与GPIO子系统的结合一个完整的GPIO驱动通常需要向上层提供一个可操作的接口。最常见的方式是注册一个字符设备实现open、read、write、ioctl等文件操作接口。我的做法是在probe函数里申请GPIO注册字符设备创建设备节点。在write函数里根据写入的内容控制LED在read函数里返回按键状态。这样用户空间就可以通过/dev/my_gpio_device来操作硬件。这里有一个细节GPIO的申请和释放必须成对出现。在probe里gpiod_get在remove里gpiod_put。如果忘了释放卸载模块的时候会报错而且下次再加载可能申请不到GPIO。3. 实操过程与核心环节实现3.1 环境准备与内核头文件配置在开始写代码之前你需要确保开发环境是完整的。我用的是Ubuntu 22.04作为开发主机目标板是IMX6ULL。你需要准备的东西包括交叉编译工具链arm-linux-gnueabihf-目标板的内核源码版本必须和板子上运行的内核一致目标板的根文件系统内核源码的版本一定要匹配。你可以通过uname -r查看板子上运行的内核版本然后下载对应版本的内核源码。如果版本不一致编译出来的模块加载时会报“version magic”错误。编译模块的Makefile大概长这样obj-m my_gpio_driver.o KERNELDIR : /home/user/linux-imx6ull CROSS_COMPILE : arm-linux-gnueabihf- ARCH : arm all: make -C $(KERNELDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) modules clean: make -C $(KERNELDIR) M$(PWD) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) clean注意KERNELDIR要指向你编译过的内核源码目录而且这个内核必须已经执行过make modules_prepare否则编译模块时会找不到一些生成的头文件。3.2 驱动代码框架搭建先看驱动的主体框架。我把它分成几个部分设备树匹配、probe函数、remove函数、文件操作接口。#include linux/module.h #include linux/platform_device.h #include linux/gpio/consumer.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #include linux/of.h #define DEVICE_NAME my_gpio_device #define CLASS_NAME my_gpio_class static dev_t dev_num; static struct cdev my_cdev; static struct class *my_class; static struct device *my_device; struct my_gpio_priv { struct gpio_desc *led; struct gpio_desc *key; }; static struct my_gpio_priv *priv;这里我用了gpio_desc结构体指针来保存GPIO描述符。gpio_desc是GPIO子系统内部用来描述一个GPIO的结构你不需要关心它的具体内容只需要通过gpiod系列函数来操作它。3.3 probe函数的完整实现probe函数是驱动的核心入口当设备树里的compatible字符串和驱动里的of_device_id匹配时probe就会被调用。static int my_gpio_probe(struct platform_device *pdev) { int ret; struct device *dev pdev-dev; priv devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-led devm_gpiod_get(dev, led, GPIOD_OUT_LOW); if (IS_ERR(priv-led)) { dev_err(dev, Failed to get led gpio\n); return PTR_ERR(priv-led); } priv-key devm_gpiod_get(dev, key, GPIOD_IN); if (IS_ERR(priv-key)) { dev_err(dev, Failed to get key gpio\n); return PTR_ERR(priv-key); } ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { dev_err(dev, Failed to alloc chrdev region\n); return ret; } cdev_init(my_cdev, my_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { unregister_chrdev_region(dev_num, 1); return ret; } my_class class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_class); } my_device device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(my_device)) { class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_device); } dev_info(dev, my gpio driver probed successfully\n); return 0; }这里我用了devm_gpiod_get而不是gpiod_get。devm_前缀表示设备资源管理内核会在设备卸载时自动释放这些资源你不需要在remove函数里手动调用gpiod_put。这是一个很好的实践能减少资源泄漏的风险。GPIOD_OUT_LOW表示申请为输出方向初始电平为低。GPIOD_IN表示申请为输入方向。这些标志定义在linux/gpio/consumer.h里。3.4 文件操作接口的实现文件操作接口是用户空间和驱动交互的桥梁。我实现了open、release、read、write四个接口。static int my_gpio_open(struct inode *inode, struct file *filp) { filp-private_data priv; return 0; } static ssize_t my_gpio_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { struct my_gpio_priv *p filp-private_data; char kbuf[8]; if (count sizeof(kbuf) - 1) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; if (kbuf[0] 1) gpiod_set_value(p-led, 1); else if (kbuf[0] 0) gpiod_set_value(p-led, 0); else return -EINVAL; return count; } static ssize_t my_gpio_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { struct my_gpio_priv *p filp-private_data; char kbuf[4]; int val; val gpiod_get_value(p-key); snprintf(kbuf, sizeof(kbuf), %d\n, val); if (copy_to_user(buf, kbuf, strlen(kbuf))) return -EFAULT; return strlen(kbuf); }这里有一个细节gpiod_get_value返回的是逻辑值不是物理电平。如果你在设备树里标了GPIO_ACTIVE_LOW那按键按下时物理电平是低但gpiod_get_value返回1。这个逻辑反转是内核帮你做的非常方便。3.5 设备树节点的完整配置驱动写好了还需要在设备树里添加对应的节点。以IMX6ULL为例iomuxc { pinctrl_my_gpio: mygpiogrp { fsl,pins MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10B0 MX6UL_PAD_GPIO1_IO04__GPIO1_IO04 0x10B0 ; }; }; my_gpio_device { compatible mycompany,my-gpio-device; pinctrl-names default; pinctrl-0 pinctrl_my_gpio; led-gpios gpio1 3 GPIO_ACTIVE_HIGH; key-gpios gpio1 4 GPIO_ACTIVE_LOW; status okay; };注意pinctrl-0引用了上面定义的pinctrl节点确保引脚被正确复用为GPIO功能。如果这一步漏了引脚可能还处于其他功能模式GPIO操作不会生效。3.6 编译、加载与测试编译模块make编译成功后会在当前目录生成my_gpio_driver.ko。把ko文件拷贝到开发板scp my_gpio_driver.ko root192.168.1.100:/root/在开发板上加载模块insmod my_gpio_driver.ko如果加载成功你会看到/dev/my_gpio_device设备节点。测试LEDecho 1 /dev/my_gpio_device echo 0 /dev/my_gpio_device测试按键cat /dev/my_gpio_device按下按键时应该读到1松开读到0因为设备树里标了GPIO_ACTIVE_LOW。查看内核日志确认没有错误dmesg | tail -204. 常见问题与排查技巧实录4.1 GPIO申请失败怎么办这是最常见的问题。加载模块时如果看到Failed to get led gpio说明GPIO申请失败了。排查思路如下第一检查设备树里有没有对应的节点compatible字符串是否匹配。你可以通过/proc/device-tree/目录查看设备树是否被正确解析。第二检查GPIO是否被其他驱动占用了。通过cat /sys/kernel/debug/gpio可以查看所有GPIO的占用情况。如果看到你的引脚已经被别的驱动申请了那就需要先释放或者换一个引脚。第三检查pinctrl配置是否正确。如果引脚没有被复用为GPIO功能申请也会失败。你可以通过cat /sys/kernel/debug/pinctrl/下的文件查看引脚状态。4.2 电平控制无效的排查有时候模块加载成功了GPIO也申请到了但写值的时候引脚电平不变。这种情况通常是以下几个原因引脚被其他功能复用了pinctrl没有正确配置硬件电路问题比如LED没有接限流电阻或者引脚驱动能力不够设备树里标了GPIO_ACTIVE_LOW你写1实际输出低电平你以为没生效其实生效了我遇到过一次查了半天发现是pinctrl节点里的电气配置值写错了导致引脚驱动能力太弱用万用表量电压只有1.2V带不动LED。后来查数据手册把配置值改成0x10B0就正常了。4.3 模块加载报version magic错误这个错误说明你编译模块用的内核源码版本和板子上运行的内核版本不一致。解决方法很简单用板子上运行的内核源码重新编译。你可以通过uname -r查看板子内核版本然后下载对应版本源码。如果实在找不到完全一致的源码可以在内核配置里关掉版本检查不推荐或者修改include/config/kernel.release文件。4.4 常见问题速查表问题现象可能原因排查方法GPIO申请失败引脚被占用或pinctrl未配置查看/sys/kernel/debug/gpio电平控制无效引脚复用错误或硬件问题万用表量电压检查pinctrl模块加载失败内核版本不匹配uname -r对比源码版本设备节点不存在字符设备注册失败dmesg查看错误信息按键读取值反了GPIO_ACTIVE_LOW配置检查设备树标志位卸载模块报错资源未释放使用devm_系列函数4.5 实操心得与避坑建议做了几个GPIO驱动项目之后我总结了几个经验都是文档里不会写的第一尽量用devm_系列函数。devm_gpiod_get、devm_kzalloc这些函数会在设备卸载时自动释放资源你不需要在remove函数里手动清理。这能避免很多资源泄漏的问题。第二设备树属性名一定要带-gpios后缀。我见过有人写成led-gpio编译没问题但运行时就是申请不到GPIO。内核的of_gpio解析函数只认-gpios和-gpio两种后缀但推荐用-gpios。第三调试GPIO的时候sysfs接口比字符设备接口方便得多。你可以直接echo和cat不需要写测试程序。虽然sysfs接口被标记为deprecated但调试阶段还是很好用的。第四pinctrl的电气配置值一定要查数据手册。不同芯片的配置值格式不一样同一个芯片不同引脚的推荐值也可能不同。不要随便抄一个值就用。第五如果GPIO控制的是LED记得在硬件上接限流电阻。我见过有人直接接LED不加电阻结果引脚烧了。一般LED的限流电阻在220欧到1K欧之间具体看LED的规格。4.6 扩展方向从GPIO到中断GPIO驱动做完了下一步很自然的就是GPIO中断。按键如果要用中断方式检测就需要把GPIO申请为中断模式注册中断处理函数。核心API是gpiod_to_irq把GPIO描述符转换成中断号然后request_irq注册处理函数。设备树里需要给GPIO控制器节点添加interrupt-controller属性并且在设备节点里用interrupts或者interrupts-extended描述中断。中断方式比轮询方式效率高得多特别是在低功耗场景下。但中断处理函数里不能做耗时操作只能做最简单的处理比如记录时间戳、唤醒等待队列把实际处理放到工作队列或者线程化中断里做。这个内容展开又能写一篇了后面有机会再单独聊。GPIO驱动这块把设备树、gpiod API、pinctrl这三块吃透基本上就够用了。剩下的就是多动手多踩坑多查dmesg。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑