嵌入式Linux应用开发:用户空间GPIO控制的三种姿势
你有没有想过,一个嵌入式Linux应用到底是怎么跟真实世界的硬件打交道的?比如你想点亮一颗LED、读取一个按键状态,在单片机上可能直接操作寄存器就行了,但在跑着Linux的板子上,事情没那么简单——有内核态和用户态的壁垒,有设备文件的抽象,有好几套API摆在面前。
我们今天就来拆开这三套方案,看清楚它们各自的脾气。
/sys/class/gpio:最老派,也最危险
早期嵌入式Linux开发者最熟悉的应该就是这个了。说白了就是在/sys文件系统里读写文件来控制GPIO。
// 导出GPIO
int fd = open("/sys/class/gpio/export", O_WRONLY);
write(fd, "17", 2);
close(fd);
// 设置为输出
char buf[64];
snprintf(buf, sizeof(buf), "/sys/class/gpio/gpio17/direction");
fd = open(buf, O_WRONLY);
write(fd, "out", 3);
close(fd);
// 输出高电平
snprintf(buf, sizeof(buf), "/sys/class/gpio/gpio17/value");
fd = open(buf, O_WRONLY);
write(fd, "1", 1);
close(fd);
这段代码看起来人畜无害,写一行字符串就能控制一个引脚。但问题藏在细节里——每次操作都要open、write、close,一次电平翻转的延迟可能高达几百微秒,因为你每次都在穿越VFS层、遍历路径、分配inode。这还不算完,如果你并发操作同一个GPIO号,两个进程同时export同一个引脚,后一个会直接报错。内核没有帮你做序列化。
所以这套接口基本被标记为"deprecated"了,虽然它还活着,但新项目不太建议用了。
libgpiod:给GPIO配上C语言接口
2017年之后,内核社区开始推广新的GPIO字符设备接口(gpiochip设备),基于此之上有了libgpiod库。用起来比sysfs干净得多:
#include <gpiod.h>
struct gpiod_chip *chip;
struct gpiod_line *line;
int ret;
chip = gpiod_chip_open_by_number(0);
if (!chip) {
perror("打开gpiochip0失败");
return -1;
}
line = gpiod_chip_get_line(chip, 17);
ret = gpiod_line_request_output(line, "my-app", 0);
gpiod_line_set_value(line, 1);
// 一批操作效率高不少
struct gpiod_line_bulk bulk;
gpiod_line_bulk_add(&bulk, line);
ret = gpiod_line_set_value_bulk(&bulk, &val);
gpiod_chip_close(chip);
一个有意思的设计是gpiod_chip_open_by_number(0)。/dev/gpiochip0这个设备代表了SoC上的一组GPIO控制器,它背后是一个gpio_chip内核对象。为什么是数字0?因为你的SoC可能有多个GPIO控制器——比如STM32MP1就有gpiochip0到gpiochip5,分别对应GPIOA到GPIOF。
关键在于,libgpiod把GPIO操作封装成了对字符设备的ioctl调用,每次操作的系统调用次数大幅减少。如果配合gpiod_line_set_value_bulk做批量操作,一个ioctl就能搞定一批引脚的状态更新。
还有一个很方便的工具值得一试——命令行里的gpioset、gpioget:
# 设置GPIO17输出高电平
gpioset gpiochip0 17=1
# 读取GPIO18
gpioget gpiochip0 18
调试阶段用这些命令比写C代码快太多了。
GPIO cdev直呼:性能玩家的选择
但libgpiod也有它的天花板。如果你做的是高速GPIO操作——比如软件模拟某个时序协议,需要微秒级的翻转精度,libgpiod的每次函数调用仍然有一次ioctl的开销。这时候直接操作gpiochip的字符设备文件会更合适。
#include <linux/gpio.h>
int fd = open("/dev/gpiochip0", O_RDONLY);
struct gpiohandle_request req;
req.lineoffsets[0] = 17;
req.flags = GPIOHANDLE_REQUEST_OUTPUT;
req.default_values[0] = 0;
strcpy(req.consumer_label, "my-app");
req.lines = 1;
ioctl(fd, GPIO_GET_LINEHANDLE_IOCTL, &req);
close(fd);
struct gpiohandle_data data;
data.values[0] = 1;
ioctl(req.fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data);
这里直接操作ioctl给内核发指令,绕过了libgpiod的所有封装层。代价是你得自己理解struct gpiohandle_request里每个字段的含义——比如flags可以组合GPIOHANDLE_REQUEST_OUTPUT、GPIOHANDLE_REQUEST_ACTIVE_LOW、GPIOHANDLE_REQUEST_OPEN_DRAIN等。
当你在考虑CPU占用和延迟时,这个差别很关键。一个典型的对比数据:sysfs方式一次翻转约80-150微秒,libgpiod约20-40微秒,直接ioctl可以压到5-10微秒。当然具体数值取决于你的SoC跑多快、内核配置、总线负载等等。
选哪一套?
如果你的需求只是点个灯、读个按键、控制个继电器,gpioset/gpioget命令行或者libgpiod就足够了,没必要自己操作ioctl。只有当你需要做高频GPIO操作的时候,才值得走底层那条路。
sysfs那套可以了解,但别在新项目里用了。这倒不是因为它不能工作——它一直能工作——而是因为内核社区已经在kernel 6.x里把/sys/class/gpio列为可选功能了,有些发行版默认就不编译它。与其等踩到坑再改,不如从一开始就用规范的做法。
嵌入式Linux的有趣之处就在这里——同样一个功能,你在应用层看到的是简洁的API,往下挖一层是字符设备的ioctl交互,再往下是gpio_chip驱动框架,最底层是SoC的寄存器。每一层都是一种抽象,每一层都有自己的心智模型。你在哪一层写代码,决定了你能控制什么、不能控制什么。
更多推荐

所有评论(0)