嵌入式Linux C应用编程——GPIO

GPIO应用编程

概述

我们可以在 Linux 应用层通过 sysfs 文件系统操控 GPIO 引脚的方法。sysfs 挂载于 /sys 目录,内核将 GPIO 控制器及每个引脚以文件和目录的形式导出,应用程序可以通过读写这些文件来配置引脚方向、读取电平、触发中断等。这种方式是嵌入式 Linux 中最常用的 GPIO 用户态操作方法,适用于 LED 控制、按键检测、传感器读取等场景。

操控GPIO的方法

1. GPIO 在 sysfs 中的组织

进入 /sys/class/gpio 目录,可以看到两类内容:

  • gpiochipX 目录:每个 gpiochipX 对应 SoC 的一个 GPIO 控制器(如 I.MX6UL 的 GPIO1~GPIO5,分别对应 gpiochip0gpiochip32gpiochip64gpiochip96gpiochip128)。每个目录下有三个只读属性文件:

    • base:该控制器管理的 GPIO 编号最小值。
    • label:控制器名称。
    • ngpio:该控制器管理的引脚数量。
      通过这些文件可以计算出任意引脚对应的编号,例如 GPIO4_IO16 对应编号 96 + 16 = 112。96表示的是这个组里最小的标号值。
  • exportunexport 文件:均为只写文件。

    • export 写入一个编号,可以导出对应的 GPIO 引脚,成功后会在当前目录下生成一个名为 gpioX 的目录(X 为编号)。
    • unexport 写入该编号,可以删除该目录,释放引脚。

img

注意:并非所有 GPIO 都能成功导出。如果该引脚已被内核中的其他驱动占用(例如作为 LCD 背光、SD 卡检测等),则导出会失败,echo 操作会提示 Device or resource busy

2. 导出后生成的 gpioX 目录

该目录下包含多个属性文件,用于控制引脚:

  • direction:可读可写。写入 "in""out" 配置为输入或输出;读取可查看当前模式。
  • value:可读可写。
    • 输出模式:写入 "1" 输出高电平,"0" 输出低电平。
    • 输入模式:读取获取当前电平("0""1")。
  • active_low:可读可写,默认 "0"。当设置为 "1" 时,电平极性反转(即 value 写入 1 实际输出低电平,写入 0 输出高电平;输入同理)。
  • edge:可读可写,用于配置中断触发模式。可选值:
    • "none":无中断
    • "rising":上升沿触发
    • "falling":下降沿触发
    • "both":双边沿触发
      配置中断前需先将 direction 设为 "in"

img

3. 常用操作示例

# 导出 GPIO 编号 0(即 GPIO1_IO0)
echo 0 > /sys/class/gpio/export# 配置为输出并设置高电平
echo out > /sys/class/gpio/gpio0/direction
echo 1 > /sys/class/gpio/gpio0/value# 配置为输入并读取电平
echo in > /sys/class/gpio/gpio0/direction
cat /sys/class/gpio/gpio0/value# 配置边沿触发中断
echo in > /sys/class/gpio/gpio0/direction
echo both > /sys/class/gpio/gpio0/edge# 删除导出
echo 0 > /sys/class/gpio/unexport

4. /sys/class/gpio 目录的来源

该目录下的文件并非由开发板厂商“提供”,而是由 Linux 内核在启动时动态创建。其生成过程如下:

  • 内核中的 GPIO 驱动(如 gpiolib)在初始化时调用 class_createdevice_create 接口,在 /sys/class/ 下创建 gpio 目录及其子项。
  • 设备树(.dtb)描述了 SoC 中的 GPIO 控制器信息(如寄存器基地址、中断号等),内核解析设备树后匹配相应驱动,进而创建对应的 gpiochipX 目录。
  • exportunexport 文件由 gpiolib-sysfs 模块提供,当用户写入编号时,该模块负责动态生成 gpioX 目录。

5. gpiochipX 符号链接的实际指向

从上面的图可以看出,这些 gpiochip文件实际上是链接文件,它指向 /sys/devices/platform/soc/.../gpio/gpiochip0 等路径,那里是内核设备模型中该 GPIO 控制器的真实位置。/sys/class/ 下的目录为了方便应用层按功能查找,使用符号链接指向物理设备树。

如果查看芯片手册,就可以知道实际上209c000gpio1_0的起始地址。
img

GPIO输出

1. 实现思路

操作 GPIO 输出电平的基本步骤:

  1. 确定 GPIO 编号。
  2. 检查 /sys/class/gpio/gpioX 目录是否存在,若不存在则通过 export 导出。
  3. direction 文件写入 "out" 配置为输出模式。
  4. value 文件写入 "1""0" 控制高低电平。

2. 示例程序

通过命令行参数控制可以指定 GPIO 输出电平的功能。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>static char gpio_path[100];static int gpio_config(const char *attr, const char *val)
{char file_path[100];int len;int fd;sprintf(file_path, "%s/%s", gpio_path, attr);if (0 > (fd = open(file_path, O_WRONLY))) {perror("open error");return fd;}len = strlen(val);if (len != write(fd, val, len)) {perror("write error");close(fd);return -1;}close(fd);return 0;
}int main(int argc, char *argv[])
{/* 校验传参 */if (3 != argc) {fprintf(stderr, "usage: %s <gpio> <value>\n", argv[0]);exit(-1);}/* 判断指定编号的 GPIO 是否导出 */sprintf(gpio_path, "/sys/class/gpio/gpio%s", argv[1]);if (access(gpio_path, F_OK)) {  // 目录不存在则需要导出int fd;int len;if (0 > (fd = open("/sys/class/gpio/export", O_WRONLY))) {perror("open error");exit(-1);}len = strlen(argv[1]);if (len != write(fd, argv[1], len)) {  // 导出 gpioperror("write error");close(fd);exit(-1);}close(fd);}/* 配置为输出模式 */if (gpio_config("direction", "out"))exit(-1);/* 极性设置(默认 0) */if (gpio_config("active_low", "0"))exit(-1);/* 控制 GPIO 输出高低电平 */if (gpio_config("value", argv[2]))exit(-1);exit(0);
}

代码解释

程序执行时需要传入两个参数:argv[1] 为 GPIO 编号,argv[2] 为电平值("0""1")。运行过程如下:

  1. 通过 sprintf 构造 gpio_path,格式为 /sys/class/gpio/gpioX
  2. 调用 access(gpio_path, F_OK) 判断该目录是否存在。若不存在(返回 -1),则打开 /sys/class/gpio/export 文件,将编号写入以完成导出。
  3. 调用 gpio_config("direction", "out"),该函数构造 gpio_path/direction 文件路径,打开并写入 "out"
  4. 调用 gpio_config("active_low", "0"),设置极性为默认值(可选,默认即为 0)。
  5. 调用 gpio_config("value", argv[2]),向 value 文件写入 "1""0" 控制电平。

gpio_config 函数封装了打开属性文件 → 写入字符串 → 关闭的通用操作,简化了代码。

注意:写入 value 的内容必须是字符串形式,即 "1""0",不能是二进制整数 10。同样,direction 也要写入字符串 "out" 而非二进制值。

注意事项

程序中使用 access(gpio_path, F_OK) 判断目录是否存在,这是一种安全的做法。如果直接尝试导出已经导出的引脚,向 export 写入会返回错误,因为该引脚已被占用。

在输出模式下,虽然可以读取 value 文件,但读到的是最后写入的值,并非引脚实际电平(例如引脚被外部电路拉低时,读到的仍可能是 "1")。要获取实际电平,需配置为输入模式。

GPIO 输入

读取 GPIO 输入电平的流程与输出控制基本一致,主要区别在于将 direction 设置为 "in",然后通过读取 value 文件获取当前电平。该操作常用于检测按键状态、传感器数字信号等。

示例程序 gpio_in.cgpio_out.c 结构非常相似,仅改动了配置和读取部分。下面是 main 函数中关键代码段。

/* 配置为输入模式 */
if (gpio_config("direction", "in"))exit(-1);/* 极性设置 */
if (gpio_config("active_low", "0"))exit(-1);/* 配置为非中断方式 */
if (gpio_config("edge", "none"))exit(-1);/* 读取 GPIO 电平状态 */
sprintf(file_path, "%s/%s", gpio_path, "value");if (0 > (fd = open(file_path, O_RDONLY))) {perror("open error");exit(-1);
}if (0 > read(fd, &val, 1)) {perror("read error");close(fd);exit(-1);
}printf("value: %c\n", val);

代码执行过程:

  1. 通过 gpio_config("direction", "in") 将引脚设为输入模式。
  2. active_low 保持默认 0,若需要极性反转可置 1,此时 value 读到的电平与实际物理电平相反。
  3. edge 写入 "none" 明确关闭中断功能(默认即无中断,此步可省略,但显式写出可避免后续误配置)。
  4. 构造 value 文件的完整路径并打开(只读)。
  5. 调用 read 读取 1 个字符,存入 val,该字符为 '0''1'
  6. 打印该字符。

读取 value 文件时,返回的是 ASCII 字符 '0''1',而非二进制数值 0 或 1。比较时应使用字符常量,如 if (val == '1')

GPIO 中断

当需要检测引脚电平变化(比如按键按下、传感器触发)时,用轮询(死循环读 value)会白白消耗 CPU 资源,实际项目中更常见的做法是使用中断。GPIO 的中断也是通过 sysfs 来配置的:往 edge 文件写入 "rising""falling""both" 来设定触发边沿,然后用 poll 函数阻塞等待中断事件。

示例程序 gpio_intr.c 跟前面输入输出的代码非常相似,导出引脚和设置方向的代码几乎一样,关键的不同在于配置 edge 以及后续的 poll 等待逻辑。下面只给出 main 函数中与中断相关的核心代码段。

/* 配置为输入模式 */
if (gpio_config("direction", "in"))exit(-1);/* 极性设置 */
if (gpio_config("active_low", "0"))exit(-1);/* 配置中断触发方式: 上升沿和下降沿 */
if (gpio_config("edge", "both"))exit(-1);/* 打开 value 属性文件 */
sprintf(file_path, "%s/%s", gpio_path, "value");
if (0 > (pfd.fd = open(file_path, O_RDONLY))) {perror("open error");exit(-1);
}/* 调用 poll */
pfd.events = POLLPRI;  // 只关心高优先级数据可读(中断)read(pfd.fd, &val, 1); // 先读取一次清除状态
for ( ; ; ) {ret = poll(&pfd, 1, -1);  // 阻塞等待中断if (0 > ret) {perror("poll error");exit(-1);} else if (0 == ret) {fprintf(stderr, "poll timeout.\n");continue;}/* 校验高优先级数据是否可读 */if (pfd.revents & POLLPRI) {if (0 > lseek(pfd.fd, 0, SEEK_SET)) {perror("lseek error");exit(-1);}if (0 > read(pfd.fd, &val, 1)) {perror("read error");exit(-1);}printf("GPIO 中断触发<value=%c>\n", val);}
}

代码的执行逻辑可以分为几个步骤:

  1. 将引脚设为输入模式后,向 edge 文件写入 "both",表示上升沿和下降沿都会触发中断。你也可以根据需要改成 "rising""falling"
  2. 打开 value 文件,获得文件描述符。注意这里只打开一次,后续 poll 和读写都复用这个描述符。
  3. 在进入主循环之前,先调用一次 read 把当前状态消费掉,避免之前的残留状态干扰后续的中断等待。
  4. pollevents 字段指定为 POLLPRI(高优先级数据可读),而不是普通的 POLLIN。GPIO 中断在内核里被归类为高优先级事件,用 POLLPRI 才能抓到。
  5. poll 返回后检查 revents 是否包含 POLLPRI,如果是则说明中断发生了。此时必须先 lseek 把文件偏移移到开头,然后再 read。这两个操作配合才能把内核里的中断状态清除掉,否则下次 poll 可能会直接返回,或者无法正确等待下一次中断。

在驱动中,读操作会让内核自动清理状态,第二次之所以需要用Iseek把读位置移动到头部正是因为第一次的读取使读位置后移了。

实际跑起来之后,用杜邦线把引脚在 3.3V 和 GND 之间来回碰一下,终端就会打印出相应的触发信息。

poll 函数在之前有学习过,具体参考我之前的笔记:https://juejin.cn/post/7660712622824718390#heading-7

它本来是用来同时监视多个文件描述符的,但在这里只监视一个 GPIO 的 value 文件,也能很好地完成阻塞等待中断的工作,比轮询节省 CPU 得多。

每次 poll 返回后,必须先 lseekread,这一步是必须做的。其次,必须先读取一次清除状态,如果没有这一步,下一次 poll 可能会因为状态没有被清除而表现异常。

poll-1 超时参数表示无限等待,直到中断发生。如果想让程序在超时后做点别的事情,可以设置一个正数超时值(单位毫秒)。