当前位置:首页 > 技术学院 > 技术前线
[导读]GPIO(通用输入输出口)是嵌入式Linux开发中最基础也最常用的硬件资源,小到LED闪烁、按键检测,大到外设控制、引脚扩展,都离不开GPIO操作。不同于裸机开发中直接操作寄存器的方式,Linux内核提供了一套成熟的GPIO子系统框架,统一了不同平台的GPIO操作接口,开发者不需要关心硬件底层细节,只需要调用通用API就能完成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驱动的写法,是学习更复杂驱动的基础,也是必须掌握的核心技能。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
换一批
延伸阅读

字符设备是Linux中最基础的设备类型之一,键盘、鼠标、串口、I2C设备、LED驱动这类常用的嵌入式设备,大多都属于字符设备,掌握字符设备驱动的基本框架,是学习Linux驱动开发的第一步。Linux内核从早期的2.6版本...

关键字: Linux 字符

树莓派3B凭借低成本、高性能、丰富的外设资源,一直是嵌入式爱好者和开发者学习RTOS的热门平台,而RT-Thread作为国内生态最完善的开源实时操作系统,对树莓派3B有着完善的原生支持。但很多刚接触的开发者,往往卡在环境...

关键字: RT-Thread Linux

本节演示如何使用AIE DIALECTS和AIE API,在AMD Ryzen AI Phoenix中对复杂数字信号在频域进行“相位变换”。

关键字: Linux 相位变换 AI

在360环视系统的初始验证阶段,我们采用了一套直观且广泛使用的技术栈:OpenCV负责从采集到显示的全部图像处理任务。功能层面,这套方案完全跑通了——四路鱼眼去畸变、透视投影、鸟瞰拼接,所有算法逻辑均正确。但当我们将目光...

关键字: GPU Linux CPU

谈到Linux的最大并发数,很多开发者会本能想到系统配置里的ulimit -n,觉得改大这个值就能支持更多并发,甚至默认“Linux最大并发可以到几十万上百万”。但实际生产环境中,经常遇到明明把文件句柄数改到了10万,并...

关键字: Linux 并发数

当我们在代码里调用read读取文件,调用malloc分配内存,调用socket创建网络连接的时候,最终都会落到系统调用上。但很多开发者只知道系统调用是用户程序请求内核服务的接口,却说不清系统调用到底是怎么实现的:为什么用...

关键字: Linux 系统调用

在工业自动化现场,我们时常听到这样的抱怨:"明明 Linux 上跑个 EtherCAT 主站协议栈很简单,可一到多轴联动、精密组装这类场景,周期一不小心就'飘'了,轨迹抖得让人心慌。" 问题就出在"硬实时"三个字上。要在...

关键字: 工业自动化 Linux 半导体

在云原生技术蓬勃发展的今天,容器凭借轻量、高效、可移植的特性,成为构建现代应用的核心载体。然而,容器并非绝对安全的“隔离堡垒”——当内核存在漏洞时,攻击者可通过容器逃逸突破隔离限制,直接获取宿主机的控制权,进而威胁整个集...

关键字: 内核 Linux

在探讨Linux可执行文件如何装载进虚拟内存之前,我们首先需要理解虚拟内存这一核心概念。虚拟内存是计算机系统内存管理的一种关键技术,它为应用程序构建了一个看似连续完整的地址空间,让程序认为自己拥有一块独立且连续的内存区域...

关键字: Linux 虚拟内存

在软件开发过程中,调试是定位和解决问题的关键环节。GDB(GNU Debugger)作为Linux平台下最常用的调试工具,支持对C、C++等多种语言程序的调试,能够帮助开发者监控程序执行、检查变量值、定位崩溃原因。然而,...

关键字: GDB Linux
关闭