Linux GPIO子系统的核心设计思想
GPIO(通用输入输出口)是嵌入式Linux开发中最基础也最常用的硬件资源,小到LED闪烁、按键检测,大到外设控制、引脚扩展,都离不开GPIO操作。不同于裸机开发中直接操作寄存器的方式,Linux内核提供了一套成熟的GPIO子系统框架,统一了不同平台的GPIO操作接口,开发者不需要关心硬件底层细节,只需要调用通用API就能完成GPIO驱动开发。本文就从GPIO子系统的基本设计出发,梳理Linux中通用GPIO驱动的写法,结合实际应用场景分析常见的开发技巧与坑点。
一、Linux GPIO子系统的核心设计思想
Linux GPIO子系统的核心设计思路是分层抽象与平台无关:内核把不同厂商SoC的GPIO控制器底层逻辑封装在板级驱动中,向上提供统一的操作接口,不管是ARM架构还是RISC-V架构,不管是哪个厂商的SoC,上层驱动都用同一套API操作GPIO,实现了GPIO驱动的通用性。
GPIO子系统分为三层结构:
底层控制器驱动:由芯片厂商实现,负责操作SoC内部的GPIO控制器硬件,完成引脚复用、中断设置、电平读写这些底层操作,向上注册GPIO芯片(struct gpio_chip)到核心层。
核心层:内核维护所有GPIO的全局状态,提供统一的API给上层使用,处理GPIO编号映射、权限管理、资源冲突检测,把上层调用转发到底层控制器驱动处理。
上层驱动/应用:驱动开发者调用核心层提供的通用API,根据业务需求操作GPIO,不需要关心底层硬件是怎么实现的。
这套分层设计带来了两个明显的好处:一是上层驱动不用改代码就能适配不同平台,比如一个按键驱动,在NXP的i.MX6上和兆易创新的GD32VF上都能跑,只需要底层GPIO控制器驱动适配好就行;二是内核统一管理GPIO资源,避免多个驱动同时操作同一个GPIO导致冲突,驱动申请GPIO失败就能直接知道这个引脚已经被占用了,不会出现莫名其妙的硬件错误。
Linux内核从3.x版本开始逐步推新的GPIO API,旧的gpio_request、gpio_direction_input这套接口虽然还在兼容,但现在更推荐使用基于GPIO描述符(gpiod_*)的新API,新API更安全、更易用,本文也主要围绕新的通用GPIO驱动写法展开。
二、GPIO驱动开发的基本步骤
不管是什么类型的GPIO应用,驱动开发的基本流程都是固定的,我们分步骤来看:
第一步:获取GPIO编号/解析设备树节点
在现代Linux系统中,板级信息一般都通过设备树(DT)描述,我们不需要在代码里硬编码GPIO编号,只需要在设备树节点中定义好GPIO属性,驱动中直接解析就行,这也是设备树推荐的写法。
比如我们要驱动一个简单的GPIO控制的LED,设备树节点一般会这么写:
gpio_led {
compatible = "my,gpio-led";
led-gpio = <&gpio1 3 GPIO_ACTIVE_LOW>;
status = "okay";
};
其中<&gpio1 3>表示这个LED连接到GPIO1组的第3号引脚,GPIO_ACTIVE_LOW表示低电平有效,也就是说拉低引脚的时候LED点亮。
驱动中解析设备树节点,获取GPIO的方式非常简单,用devm_gpiod_get这个函数就可以:
struct gpio_desc *led_gpio;
led_gpio = devm_gpiod_get(&pdev->dev, "led", GPIOD_OUT_LOW);
if (IS_ERR(led_gpio)) {
dev_err(&pdev->dev, "failed to get led gpio\n");
return PTR_ERR(led_gpio);
}
第一个参数是设备指针,第二个参数是设备树中GPIO属性的前缀,我们设备树里是led-gpio,所以这里填"led",第三个参数是初始输出电平,GPIOD_OUT_LOW就是初始拉低,GPIOD_OUT_HIGH是初始拉高,输入的话用GPIOD_IN。
devm_开头的函数是内核提供的资源托管函数,驱动卸载的时候会自动释放申请到的GPIO资源,不需要我们手动释放,避免了内存泄漏,现在都推荐用devm_开头的函数申请资源。
如果不用设备树,也可以通过GPIO编号直接申请,用gpio_to_desc把GPIO编号转换成GPIO描述符:
struct gpio_desc *my_gpio = gpio_to_desc(103); // 103是GPIO编号
但这种写法硬编码了GPIO编号,换平台就要改代码,不符合通用驱动的设计思想,不推荐使用。
第二步:设置GPIO方向
拿到GPIO描述符之后,我们需要设置GPIO的方向,是输入还是输出,如果我们在devm_gpiod_get的时候已经指定了方向,比如GPIOD_OUT_LOW或者GPIOD_IN,就不需要再重复设置了,如果需要动态切换方向,可以调用通用API:
// 设置为输入
gpiod_direction_input(my_gpio);
// 设置为输出,初始值为value(0低电平,1高电平)
gpiod_direction_output(my_gpio, value);
内核会自动处理底层方向设置,我们不需要关心寄存器怎么配置,只需要调用通用接口就行。
第三步:读写GPIO电平
设置好方向之后,就可以读写电平了,操作非常简单:
输入模式下,读取电平用gpiod_get_value:
int value = gpiod_get_value(my_gpio);
返回值就是当前引脚的电平,0是低电平,1是高电平,如果读取失败会返回负数错误码。
输出模式下,设置电平用gpiod_set_value:
gpiod_set_value(my_gpio, value); // value是0或1
同样,我们不需要关心底层硬件,内核会帮我们完成电平设置。
这里要注意GPIO_ACTIVE_LOW的处理,如果我们在设备树中指定了低电平有效,内核会自动帮我们做电平翻转,比如我们调用gpiod_set_value(my_gpio, 1)想要打开LED,低电平有效的情况下,内核会自动把引脚拉低,不需要我们自己手动翻转,非常方便,这也是推荐用设备树解析GPIO的原因之一,极性处理内核已经帮我们做好了。
第四步:GPIO中断的配置(输入场景常用)
很多GPIO输入场景,比如按键检测,需要用中断来响应引脚变化,不需要一直轮询,节省CPU资源,GPIO子系统也提供了通用的中断申请接口。
申请GPIO中断的基本写法如下:
int irq = gpiod_to_irq(my_gpio); // 从GPIO描述符获取中断号
if (irq < 0) {
dev_err(&pdev->dev, "failed to get irq\n");
return irq;
}
/* 申请中断,devm_开头自动释放 */
request_irq(irq, my_key_interrupt, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, "my-key", my_data);
这里我们先通过gpiod_to_irq把GPIO转换成对应的中断号,不同平台的中断号映射内核已经处理好了,我们不需要自己算,然后调用request_irq申请中断,IRQF_TRIGGER_FALLING表示下降沿触发,也就是引脚从高变低的时候触发中断,IRQF_TRIGGER_RISING是上升沿触发,IRQF_TRIGGER_BOTH是双边沿触发,根据需求选择就行。
中断处理函数的框架也很固定:
static irqreturn_t my_key_interrupt(int irq, void *dev_id)
{
/* 在这里处理中断,比如消抖、唤醒队列、上报按键事件 */
return IRQ_HANDLED;
}
GPIO中断是内核通用的处理逻辑,不管什么平台,写法都是一样的,不需要额外适配。
第五步:驱动卸载的资源释放
如果我们用了devm_开头的函数申请GPIO和其他资源,驱动卸载的时候不需要手动释放,内核会自动帮我们清理所有资源,只需要卸载其他手动申请的资源就行,大大简化了驱动卸载的代码。
三、字符设备框架下GPIO驱动的完整示例
我们用一个最简单的例子:实现一个字符设备驱动,应用层可以读写这个设备控制GPIO输出,也可以读取输入电平,把整个流程串起来,核心代码框架如下:
#include
#include
#include
#include
#include
#include
#include
static struct gpio_desc *my_gpio;
static dev_t my_devt;
static struct cdev my_cdev;
static struct class *my_class;
static int my_open(struct inode *inode, struct file *file) { return 0; }
static int my_release(struct inode *inode, struct file *file) { return 0; }
static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
{
char val_buf;
int val = gpiod_get_value(my_gpio);
val_buf = val ? '1' : '0';
val_buf = '\n';
if (copy_to_user(buf, val_buf, 2)) return -EFAULT;
return 2;
}
static ssize_t my_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
char val;
if (copy_from_user(&val, buf, 1)) return -EFAULT;
gpiod_set_value(my_gpio, (val == '1') ? 1 : 0);
return count;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int my_probe(struct platform_device *pdev)
{
// 从设备树获取GPIO
my_gpio = devm_gpiod_get(&pdev->dev, "user", GPIOD_IN);
if (IS_ERR(my_gpio)) return PTR_ERR(my_gpio);
// 申请设备号
if (alloc_chrdev_region(&my_devt, 0, 1, "my_gpio_dev") < 0) return -1;
cdev_init(&my_cdev, &my_fops);
cdev_add(&my_cdev, my_devt, 1);
my_class = class_create(THIS_MODULE, "gpio_demo");
device_create(my_class, NULL, my_devt, NULL, "mygpio");
return 0;
}
static int my_remove(struct platform_device *pdev)
{
device_destroy(my_class, my_devt);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(my_devt, 1);
return 0;
}
static const struct of_device_id my_of_match[] = {
{ .compatible = "my,gpio-demo" },
{ },
};
MODULE_DEVICE_TABLE(of, my_of_match);
static struct platform_driver my_platform_driver = {
.probe = my_probe,
.remove = my_remove,
.driver = {
.name = "gpio-demo",
.of_match_table = my_of_match,
.owner = THIS_MODULE,
},
};
module_platform_driver(my_platform_driver);
MODULE_LICENSE("GPL");
这个驱动加载之后,我们就可以在用户空间直接操作/dev/mygpio来控制GPIO:echo 1 > /dev/mygpio就能把GPIO拉高,echo 0 > /dev/mygpio拉低,cat /dev/mygpio就能读取当前GPIO的电平,整个驱动只需要几十行核心代码,大部分都是字符设备框架的固定流程,GPIO操作只需要几个API就能完成,非常简洁。
四、应用场景与常见开发技巧
1. 按键输入与消抖处理
按键是GPIO输入最常见的场景,因为机械按键按下和松开的时候会有几毫秒的抖动,直接触发中断会导致多次触发,所以必须做消抖处理。常用的消抖方法有两种:
一种是定时器消抖:中断触发之后,启动一个10~20ms的定时器,定时器超时之后再读取GPIO电平,判断当前状态,这样就能避开抖动期,得到稳定的电平,这种方法资源占用少,效果好,是最常用的方法。另一种是多路按键可以用输入子系统统一上报按键事件,内核已经做好了事件处理,上层应用可以直接通过input子系统读取按键,不需要自己处理,适合有多个按键的场景。
2. LED驱动与电源控制
GPIO输出最常见的就是LED指示和外设电源控制,现在内核自带了LED子系统,只需要在设备树中定义好GPIO LED,内核会自动驱动LED,支持触发器设置,比如心跳闪烁,不需要自己写驱动,非常方便。如果是控制外设电源,比如给传感器上电,只需要在驱动初始化的时候拉高GPIO,给传感器上电,卸载的时候拉低断电,GPIO的通用API一句话就能完成。
3. 模拟总线驱动
很多低速外设比如DS18B20、1-Wire、SPI软件模拟、I2C软件模拟,都需要GPIO模拟时序,用GPIO子系统的通用API就能很方便实现,只需要按时序翻转电平就行,不需要操作寄存器,换平台直接就能用,很多开源的模拟总线驱动都是基于通用GPIO API写的,兼容性非常好。
4. 轮询vs中断:怎么选择
输入场景到底用轮询还是中断?如果是需要及时响应的事件,比如按键按下,肯定用中断,轮询会占用CPU,响应也慢;如果是低频率的环境检测,比如每秒读一次引脚状态,用轮询就足够了,不需要开中断,代码更简单,也不会因为干扰导致误触发。
五、常见坑点与解决方法
很多初学者写GPIO驱动的时候经常遇到问题,常见的坑点有这么几个:
第一个坑:GPIO被其他驱动占用,申请失败。一个GPIO只能被一个驱动申请,如果被复用或者已经被其他驱动用了,devm_gpiod_get会返回-EBUSY,这个时候需要检查设备树,看看引脚复用是不是冲突,有没有其他节点已经用了这个GPIO,很多Soc的引脚复用配置不对,就会导致申请失败。
第二个坑:电平读取一直是0或者一直是1。大部分情况是引脚方向设置错了,或者没有配置引脚复用,引脚被配置成了其他功能,不是GPIO模式,这个时候需要检查设备树的引脚复用配置,确保引脚被配置成GPIO功能,方向设置正确。
第三个坑:GPIO中断触发不对,比如按一次触发多次。大部分是没有做消抖处理,机械抖动导致多次触发,按照前面说的加定时器消抖就能解决,还有一种情况是触发方式设置错了,比如想要下降沿触发,结果设置成了双边沿,当然会触发两次。
第四个坑:旧API和新API混用。现在内核不推荐旧的gpio_request这套API,新的代码应该全部用gpiod_*这套API,混用会导致资源管理出问题,出现莫名其妙的错误,尽量全部用新API开发。
第五个坑:上拉下拉设置不对。很多输入场景需要开启GPIO内部的上拉或者下拉电阻,比如按键,不开上拉会导致电平浮动,读取不稳定,内核提供了gpiod_set_pull_up_down这个API来设置上拉下拉:
// 开启上拉
gpiod_set_pull_up_down(my_gpio, GPIOD_PULL_UP);
// 开启下拉
gpiod_set_pull_up_down(my_gpio, GPIOD_PULL_DOWN);
// 不开启上拉下拉
gpiod_set_pull_up_down(my_gpio, GPIOD_PULL_OFF);
根据实际场景设置对了,就能避免输入电平不稳定的问题。
六、用户空间操作GPIO的方法
除了在内核驱动中操作GPIO,Linux还提供了用户空间操作GPIO的方法,就是sysfs GPIO和libgpiod,适合不需要内核驱动的简单场景,比如调试、原型开发:
sysfs GPIO是老方法,只需要echo GPIO编号到/sys/class/gpio/export导出,就能在/sys/class/gpio/gpioX目录下看到direction和value文件,直接写文件就能设置方向和电平,cat就能读电平,不需要写驱动就能测试GPIO,非常方便调试。现在sysfs GPIO已经被标记为废弃,新的方法是libgpiod,提供了用户空间的库,直接调用库函数就能操作GPIO,更稳定更安全,很多嵌入式项目现在都用libgpiod在用户空间操作GPIO,不需要写内核驱动,开发更快。
Linux通用GPIO驱动开发其实并不复杂,核心就是用好内核提供的统一API,遵循设备树+新GPIO描述符API的开发流程,就能写出跨平台的通用GPIO驱动。GPIO子系统的分层设计,把底层硬件细节都封装了起来,上层开发者只需要专注于业务逻辑,不需要关心不同平台的底层差异,这也是Linux驱动框架设计的巧妙之处。
不管是简单的LED闪烁、按键检测,还是复杂的模拟时序、中断处理,通用GPIO API都能满足需求,只要理清基本流程,避开常见的坑点,就能快速完成GPIO驱动开发。对于嵌入式Linux开发者来说,掌握通用GPIO驱动的写法,是学习更复杂驱动的基础,也是必须掌握的核心技能。





