简介:《GPIO应用Linux编程》是一份面向嵌入式Linux开发者与驱动入门学习者的技术文档,围绕GPIO在应用层的访问与控制展开,帮助读者理解从sysfs接口到Shell命令、再到C程序系统调用的完整操作路径。资源包内仅含1个PDF文件,大小约419KB,篇幅精简、便于随查随用;正文按GPIO基本操作、sysfs目录与属性文件、输入输出设置、应用编程及实例等模块组织,对export/unexport、direction、value、edge等文件的作用给出说明,并配有M28x GPIO应用编程、EPC-28x资源汇总及Shell与C代码操作范例。已有338人学习,适合需要快速掌握GPIO电平读写、方向配置与中断检测的读者作为案头参考,也可为排查数据丢失等问题提供思路。
1. 从一盏点不亮的指示灯说起:sysfs GPIO 到底管什么
前阵子给一块嵌入式工控板做上电自检,指示灯怎么都不亮,代码里明明写的是输出高电平,示波器一量引脚始终是低。查了半天才发现,direction文件没设成out,引脚还停在导出后的默认输入态,写value相当于往一个只读的输入脚上瞎写。GPIO 应用 Linux 编程踩的坑,十有八九都出在这种"以为设了、其实没设"的地方。
sysfs GPIO 接口做的事其实很朴素:把内核里的 GPIO 驱动展开成一堆文本文件,挂在/sys/class/gpio下,你用echo、cat,或者 C 里的open/write/read,就能导出引脚、设方向、读写电平、等边沿中断,全程不用碰驱动源码,不用改设备树。它适合手里有板子、要在应用层快速验证引脚、写产线自检脚本、或者给某个外设做临时逻辑替换的人。下面的顺序是:编号怎么算、命令怎么敲、C 怎么封装、中断怎么等、坑在哪。
2. 从引脚丝印到 gpioN 目录:BANK×32+N 的换算与导出链路
底板丝印上写的是P2_4这种编号,而/sys/class/gpio里认的是gpio68这种全局编号,中间隔着一层换算。这一层算错,后面所有操作都会落到一个完全不相干的引脚上——更麻烦的是它往往不报错,你只是发现电平没反应。所以我把"读账本 → 换算 → 导出 → 设方向 → 读写"当成一条固定链路,每一步都先用命令确认再往下走。
2.1 先读芯片账本:gpiochipN 里的 base 和 ngpio
进目录先看有什么。
ls /sys/class/gpio/ # export gpiochip0 gpiochip32 gpiochip64 gpiochip96 export 之外还有 unexportexport和unexport是两个写入接口,剩下的gpiochipN是符号链接,每个指向一个 GPIO 控制器(芯片里的一"组"或一"bank")。控制器内部并不直接告诉你"我管 32 号到 63 号",得进去读三个文件:base、ngpio、label。
for c in /sys/class/gpio/gpiochip*; do echo "--- $c" echo -n "base="; cat "$c/base" echo -n "ngpio="; cat "$c/ngpio" echo -n "label="; cat "$c/label" done这段循环把每个控制器的账本一次列全。base是这个控制器第一个引脚的全局编号起点,ngpio是它一共管多少个引脚,两者相加就是这个控制器覆盖的编号区间[base, base+ngpio)。label是设备信息,多个控制器时靠它区分哪一组是主 GPIO 控制器、哪一组来自扩展芯片。
gpiochipN目录下还有几个文件,用途如下表,真正需要你关心的只有前三个:
| 文件 | 作用 | 实际使用频率 |
|---|---|---|
base | 该控制器第一根引脚的全局编号 | 必读,换算的起点 |
ngpio | 该控制器管理的引脚总数 | 必读,判断编号是否越界 |
label | 控制器名称信息 | 多控制器时用来核对 |
power | 供电相关信息 | 基本不碰 |
subsystem | 符号链接,指向父目录 | 不用管 |
uevent | 内核与 udev 的通信接口 | 写 udev 规则时才看 |
有个容易误解的点:gpiochip0、gpiochip32这种名字里的数字是目录创建时的编号,不等于base。名字碰巧和 base 一样是常态,但别拿名字当编号用,老老实实cat base。
2.2 换算全局编号:BANK×32+N 与 base 的交叉核对
这类 ARM 平台通常把 IO 分成若干个 BANK,每个 BANK 32 根引脚,所以丝印上P2_4(BANK 2 的第 4 脚)对应的全局编号是:
全局编号 = BANK × 32 + N = 2 × 32 + 4 = 68这个公式成立的前提是每个 BANK 恰好 32 根、且全局编号从 0 开始连续排布。实际上主控厂商的编号规则经常有出入,有的 BANK 只有 16 根可用,有的前几根被系统预留,所以算完必须再核对一次:68 是否真的落在某个gpiochip的区间里。
GPIO=68 for c in /sys/class/gpio/gpiochip*; do base=$(cat "$c/base") n=$(cat "$c/ngpio") if [ "$GPIO" -ge "$base" ] && [ "$GPIO" -lt $((base + n)) ]; then echo "$GPIO 落在 $c 的区间 [$base, $((base + n - 1))]" fi done跑出来有且只有一行,说明换算对上了;一行都没有,说明 BANK 划分和 32 的假设不成立,得回去翻硬件手册里的引脚复用表;跑出多行,说明有控制器区间重叠,那是驱动配置本身有问题,先别往下做。
还有一条比公式更重要的经验:只动底板丝印上明确标注为 GPIO 的引脚。那些默认被复用成 I2C、SPI、UART 或者 PWM 的脚,你把它当 GPIO 导出来用,短时间里可能"能用",但对应的外设就废了,而且这种故障在系统日志里往往只留一行很隐晦的复用冲突提示。我一般会把丝印上推荐的引脚列成一张小表贴在工位上,非表内的脚一律不导出。
2.3 用 Shell 走通一条链路:export → direction → value
编号确认后,先在 Shell 里手动跑通一遍,别急着上代码。手动能跑通,后面 C 里出问题就只可能是代码问题,排查范围小一半。
cd /sys/class/gpio echo 68 > export # 导出 68 号引脚,成功后应出现 gpio68 目录 ls gpio68/ # active_low direction edge power subsystem uevent value导出这一步如果报write error: Device or resource busy,说明这个引脚要么已经被别的驱动占用,要么之前导出过没清理。先echo 68 > unexport再试一次,还是不行就换脚。
目录出来之后看方向。默认是输入,cat一下确认。
cat gpio68/direction # 输出 in,表示当前是输入 echo out > gpio68/direction cat gpio68/direction # 输出 out设成输出之后写电平,0是低、1是高:
echo 1 > gpio68/value cat gpio68/value # 回读,确认电平真的变了 echo 0 > gpio68/value用完记得还回去:
echo 68 > unexport ls gpio68/ # 目录应该已经消失direction除了in/out,部分内核版本还支持直接写high/low,语义是"设成输出并同时给出初始电平",一次写操作完成两件事,省掉上电瞬间的毛刺。如果你的内核不认这两个值(写进去报Invalid argument),退回out+ 写value两步走,中间那段几十微秒的不确定电平,对继电器、MOS 管这类负载要留意。
另外active_low这个文件容易被忽略。它决定value里的 0/1 是否翻转,写1之后就变成"写 1 出低电平"。默认是 0,硬件上是低有效驱动的器件(很多光耦、部分按键电路)才需要打开。我见过有人调了一下午电平反相,最后就是上一手把active_low留成了 1。
3. 用 C 把 sysfs 包起来:四个系统调用与两个偏移陷阱
Shell 适合验证,真正落到产品里还是得 C。sysfs 的 GPIO 操作在 C 里就是open/write/read/close四个调用的组合,逻辑不复杂,但细节特别密:写多长、什么时候复位文件偏移、读到的字节怎么转数字,每一条错了都表现为"程序不报错但结果不对"。这一章按封装顺序拆。
3.1 导出与方向的封装:先写一个 write_str
所有对 sysfs 的写操作形态都一样——打开、写字符串、关掉。先抽一个函数出来,把错误处理收在一处。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> /* 向 sysfs 属性文件写入一个字符串,成功返回 0,失败返回 -1 并打印 errno */ static int write_str(const char *path, const char *val) { int fd = open(path, O_WRONLY); if (fd < 0) { fprintf(stderr, "open %s failed: %s\n", path, strerror(errno)); return -1; } /* 只写有效字符,不要带结尾的 '\0':部分内核对多余字节返回 EINVAL */ ssize_t n = write(fd, val, strlen(val)); close(fd); if (n != (ssize_t)strlen(val)) { fprintf(stderr, "write %s failed: %s\n", path, strerror(errno)); return -1; } return 0; }write的长度用strlen(val)而不是sizeof(val)。sizeof("in")是 3,会把结尾的\0一起写进去,绝大多数内核能容忍(内部用字符串比较,遇到\0就截断了),但少数版本会直接返回EINVAL,而EINVAL又不会告诉你"是长度问题",很容易卡住。统一用strlen,这个习惯能省掉一类莫名其妙的失败。
导出和设方向的调用顺序是固定的:先export,等gpioN目录出现,再写direction。
#define GPIO_NUM 68 #define SYS_GPIO "/sys/class/gpio" static int gpio_export(int num) { char path[64]; snprintf(path, sizeof(path), SYS_GPIO "/gpio%d", num); if (access(path, F_OK) == 0) { return 0; /* 已经导出过,直接复用,避免重复导出报 busy */ } snprintf(path, sizeof(path), SYS_GPIO "/export"); return write_str(path, /* 数字转字符串 */ (char[]){0}[0] ? "" : ""); }上面这个函数的最后一行是占位写法,实际要先把编号转成字符串再写:
static int gpio_export(int num) { char path[64], buf[16]; snprintf(path, sizeof(path), SYS_GPIO "/gpio%d", num); if (access(path, F_OK) == 0) { return 0; /* 已导出,复用,避免 EBUSY */ } snprintf(buf, sizeof(buf), "%d", num); snprintf(path, sizeof(path), SYS_GPIO "/export"); return write_str(path, buf); } static int gpio_set_dir(int num, const char *dir) /* dir: "in" 或 "out" */ { char path[64]; snprintf(path, sizeof(path), SYS_GPIO "/gpio%d/direction", num); return write_str(path, dir); }access(path, F_OK)这个判断很关键。很多板子的启动脚本已经导出过某些引脚,程序里再导一次会拿到EBUSY,然后流程直接走失败分支,看起来像"权限问题"。先判断目录在不在,在就跳过导出,能挡掉一大半假故障。
3.2 读电平时的文件偏移与长度陷阱
读value是最容易翻车的一步,因为它有两个陷阱叠在一起。
第一个是文件偏移。sysfs 的属性文件不是普通文件,read之后内核不会把偏移复位。第一次read拿到'1',第二次read直接返回 0(读到末尾),你却以为是电平变了或者引脚断了。
第二个是长度。value文件的内容是0\n或1\n,两个字节。开一个 8 字节的缓冲区read全部,你会拿到"1\n",用atoi能转对,但拿buf[0]做比较时又得记得跳过换行。
/* 读一个 GPIO 的电平,成功时 *level 为 0 或 1 */ static int gpio_read_level(int num, int *level) { char path[64]; char c = 0; snprintf(path, sizeof(path), SYS_GPIO "/gpio%d/value", num); int fd = open(path, O_RDONLY); if (fd < 0) { fprintf(stderr, "open %s failed: %s\n", path, strerror(errno)); return -1; } lseek(fd, 0, SEEK_SET); /* 每次读之前复位偏移,不能省 */ ssize_t n = read(fd, &c, 1); /* 只要第一个字节,'\n' 丢掉 */ close(fd); if (n != 1) { fprintf(stderr, "read %s got %zd bytes\n", path, n); return -1; } *level = (c == '1') ? 1 : 0; return 0; }只读 1 个字节,c必然是'0'或'1',转成整数就是一行三元表达式的事,不用引入atoi,也不用处理换行。lseek复位是硬性要求,我一般把它和read绑在同一个函数里,不给调用方漏掉的机会。
写电平则简单,但方向要先确认。写之前cat一下direction是最省事的做法,落地成代码就是:
static int gpio_write_level(int num, int level) { char path[64], val[2]; snprintf(path, sizeof(path), SYS_GPIO "/gpio%d/value", num); val[0] = level ? '1' : '0'; val[1] = '\0'; return write_str(path, val); }注意这里的write_str写的是 1 个字符,不带换行——行不行?sysfs 对value的写解析通常能接受单个字符,但为了和 Shell 的echo行为保持一致、少一类边界问题,我还是习惯写"1\n"或"0\n"两个字节。这属于习惯问题,两种写法在主流内核上都能工作,团队里统一一种就行,别一半一半。
3.3 交叉编译、上板与返回值校验
写完之后交叉编译。编译器前缀按你们工具链实际名字来,常见的是arm-linux-gnueabi-这类。
arm-linux-gnueabi-gcc -Wall -O2 -o gpio_demo gpio_demo.c file gpio_demo # 确认是 ARM 架构的 ELF,别拿 x86 的拷上去file这一步别省。我遇到过直接gcc编译完拷到板子上,运行报Exec format error,回头查半天权限和库,其实就是架构不对。静态链接(-static)在板子根文件系统比较精简、缺动态库时更省事,代价是体积大一点。
上板之后的验证方式,最省事的是硬件回环:用一根杜邦线把输出脚和另一个输入脚接起来(或者接 GND / VCC 做固定电平测试),程序里写 1 再读回来,比对。
int lv = -1; gpio_write_level(68, 1); usleep(100000); /* 留 100ms 给负载和走线,别立刻读 */ gpio_read_level(68, &lv); printf("write 1, read back %d\n", lv);这个usleep不是可选的。sysfs 的写入是异步落到驱动的,尤其输出脚上挂了电容或者长走线时,立刻回读会拿到旧值,然后你会花两小时怀疑驱动。100ms 是个保守值,实际几十微秒就够,但测试脚本里多睡一会儿不亏。
返回值校验要逐层做。open失败看errno,ENOENT是引脚没导出、EACCES是权限、EBUSY是占用;write失败通常是方向不对或者引脚被复用。把这些错误信息打印到日志里,比只打印一句 "gpio init failed" 强太多——现场排查的时候,日志里那行strerror往往就是唯一线索。
4. 中断与边沿:edge 属性配合 poll 的正确姿势
轮询读电平在按键、限位开关这类场景里很浪费 CPU,稍微认真一点的做法是用edge属性把引脚配成中断源,再用poll等事件。这套机制能用,但它对调用顺序、事件类型、事件清除都有要求,任何一处顺序错了,表现都是"poll 一直返回"或者"永远不返回",两个极端都不好查。
4.1 edge 的四个取值与设置时机
edge决定什么样的电平变化会触发事件:
| 取值 | 触发条件 | 典型用途 |
|---|---|---|
none | 不触发,默认值 | 纯轮询场景 |
rising | 上升沿(低→高) | 按键释放、计数脉冲 |
falling | 下降沿(高→低) | 按键按下(上拉+接地按键) |
both | 双边沿 | 需要完整波形、状态机切换 |
设置时机非常关键:direction必须先设成in,再设edge。如果顺序反了,某些内核会返回EINVAL,另一些会接受但中断永远不生效。这条顺序我吃过亏,所以现在都写成固定两行:
gpio_set_dir(68, "in"); /* 故意分开写,不做成一次调用,顺序错了不好排查 */ write_str("/sys/class/gpio/gpio68/edge", "both");改edge之前最好先close掉可能还开着的value文件描述符,避免事件还在 pending 状态下被清理。
4.2 poll + POLLPRI 的最小等待循环
打开value文件之后用poll等,注意事件类型是POLLPRI(紧急数据),不是通常的POLLIN。这是 sysfs GPIO 和普通文件、socket 最大的差别,用POLLIN在部分内核上能"碰巧"工作,换块板子就失效。
#include <poll.h> static int gpio_wait_edge(const char *value_path, int timeout_ms) { int fd = open(value_path, O_RDONLY); if (fd < 0) { fprintf(stderr, "open %s failed: %s\n", value_path, strerror(errno)); return -1; } struct pollfd pfd; pfd.fd = fd; pfd.events = POLLPRI | POLLERR; /* 边沿事件走 POLLPRI */ pfd.revents = 0; int r = poll(&pfd, 1, timeout_ms); if (r > 0 && (pfd.revents & POLLPRI)) { char c = 0; lseek(fd, 0, SEEK_SET); /* 必须复位偏移,否则读到 EOF */ read(fd, &c, 1); /* 必须读一次,清掉 pending 事件 */ printf("edge, level=%c\n", c); close(fd); return c == '1' ? 1 : 0; } close(fd); if (r == 0) return -2; /* 超时,不是错误 */ if (r < 0) fprintf(stderr, "poll failed: %s\n", strerror(errno)); return -1; }三件事必须做全:events里带POLLPRI;poll返回后lseek复位;read一次把事件清掉。少做第三件,poll会立刻再次返回可读,形成忙等,CPU 直接跑满,你看到的现象是"程序在跑但风扇狂转"。
revents里还要留意POLLERR。引脚被复用掉了、edge在等待过程中被别的进程改了,都可能以POLLERR的形式冒出来,这时poll返回正数但POLLPRI没置位,代码里如果不判这一条,会卡在一个永远不进业务分支的循环里。
4.3 消抖、事件丢失与 CPU 占用的三角取舍
硬件按键的抖动通常持续 5~20ms,edge设成both之后,一次按下可能触发十几个事件。软件消抖最简单的做法是在处理完一个事件后usleep(20000)再回到poll,把这段时间内的事件全部吞掉。代价是这 20ms 内的真实边沿也会丢,如果信号是编码器脉冲这类高频输入,就不能这么干,得改用计数或者干脆换用其他方式采样。
这里有个真实取舍:sysfs 的中断路径经过内核 → 文件系统 →poll三层,事件从引脚跳变到你的poll返回,延迟在几十微秒到几百微秒之间,抖动很大。做按键、限位开关、慢速传感器完全够用;做微秒级精度的脉冲捕获就不合适,那是另一套方案的活。我在项目里对 GPIO 应用场景画过一条线:响应时间要求在 1ms 以内的,别用 sysfs。
事件丢失的另一种成因是poll处理太慢。如果业务逻辑里有阻塞操作(写文件、发网络包),把它挪到队列里,poll循环只负责取事件和打时间戳。让poll循环保持"读一次、记一笔、立刻回来"的节奏,比在循环里做重活稳得多。
5. 避坑实录:导出失败、电平不动、读不到值的五条排查路径
这一章是我自己踩过的记录,按"现象 → 原因 → 解决"写,遇到同款表现可以直接照方抓。
5.1 导出时报 Device or resource busy
现象:echo 68 > export返回write error: Device or resource busy,gpio68目录不出现。
原因有三种:引脚已经被本程序或其他服务导出过(重复导出必然 busy);引脚被内核里的某个驱动(按键、LED、电源管理)占用;引脚被复用成了别的外设功能。前两种好办,第三种最隐蔽,因为从应用层看不出来。
解决:先echo 68 > unexport试一次,再不行就把/sys/class/gpio下已有的gpio*目录全列一遍,看编号有没有被占;还不行就查复用配置,把引脚配回 GPIO 模式。我一般还会顺手dmesg | tail -20,复用冲突的提示常常只在这里露一行。
5.2 direction 写成了 out,电平就是不变
现象:cat direction明确显示out,echo 1 > value也返回成功,用万用表量引脚始终是 0V。
原因:引脚物理上被外部电路拉死(下拉电阻、对地短路、负载过大),或者这个引脚本来就不支持 GPIO 输出(某些 BANK 的部分引脚只做输入)。
解决:先断开外部负载单独量,排除电路问题;再看硬件手册确认这个脚支持输出;最后确认丝印上标的编号和实际焊的引脚是同一个——我遇到过一次底板改版,丝印没跟着改,前后差了 4 个脚。
5.3 C 程序里连续读两次,第二次读到 0
现象:第一次read正常返回'1',第二次返回 0 字节,循环里判断成"电平异常"。
原因:文件偏移没复位。sysfs 属性文件在read之后偏移停在了末尾,第二次读直接 EOF。
解决:每次read前lseek(fd, 0, SEEK_SET)。更彻底的写法是把lseek和read封进同一个函数,外部拿不到裸 fd,也就漏不掉。
5.4 poll 一直返回,CPU 占用跑满
现象:程序进入等待循环后风扇声明显变大,top看进程占 90% 以上 CPU,日志里事件刷屏。
原因:边沿事件没有被清除。poll返回后只读了电平没做lseek复位,或者压根没read,内核认为事件还 pending,下一次poll立即返回。
解决:poll返回且POLLPRI置位后,必须lseek+read走一遍。如果业务上不关心具体电平,也要读一次丢掉。
5.5 程序退出后引脚停在输出态,下次启动行为不一致
现象:kill掉进程或者程序异常退出后,引脚保持上一次的电平,重新上电或者重新导出后状态和预期对不上;严重的时候重新导出还报 busy。
原因:没有unexport。sysfs 的导出状态跟着内核走,进程死了引脚还挂着。异常退出(段错误、被 kill -9)更是完全跳过清理逻辑。
解决:正常路径在atexit里unexport,异常路径用signal捕获SIGINT/SIGTERM做清理;启动脚本里加一句幂等的echo N > unexport 2>/dev/null || true,先清后导。至于"数据丢失",我在现场见过最多的版本是:程序崩溃后引脚停在驱动某个外设的电平上,导致那个外设上电自检失败,看起来像数据丢了。把清理流程补全,这类问题基本绝迹。
6. 把它收进一个小库:三条能直接抄的落地习惯
做到这里,sysfs GPIO 的单个动作都会了,剩下的问题是怎么让它在项目里活得久一点。我的做法是收成三个文件的迷你库:gpio.h只暴露gpio_export / gpio_set_dir / gpio_read / gpio_write / gpio_wait_edge五个函数,gpio.c里把路径拼接、lseek复位、错误打印全部封死,第三个文件是上板自测的gpio_selftest.c。调用方永远拿不到裸文件描述符,也就没机会漏掉那些"必须做但很容易忘"的步骤。
第二条习惯是把引脚编号做成配置而不是硬编码。编号跟着 BANK 走,换一块底板就可能全变,我一般在配置文件里写三列:丝印名、全局编号、方向,程序启动时读表初始化。这样换硬件只改配置,代码一行不动。
| 丝印名 | 全局编号 | 方向 | 用途 |
|---|---|---|---|
P2_4 | 68 | out | 状态指示灯 |
P2_5 | 69 | in | 按键输入 |
P3_0 | 96 | out | 继电器控制 |
第三条是自测脚本必须进产线流程。我给每块板子留一个gpio_selftest,跑法是硬件回环:把待测输出脚和待测输入脚用杜邦线短接,脚本依次输出 1 和 0,读回比对,两次都对才算过。这个脚本拦住过一次整批的焊接不良——某块板子上一个引脚虚焊,功能测试全过,回环一测立刻暴露。从那以后我每次改完引脚配置,都强制把回环自测走一遍,不管改动多小。希望帮到你。
本文还有配套的精品资源,点击获取