1. GPIO 到底是什么——先把它当成一个能听能喊的引脚
嵌入式开发这一行,不管你最后是做单片机、还是 Linux 驱动、还是所谓“嵌入式架构师”,有一个外设你绝对绕不开,那就是 GPIO。GPIO 的全称是 General Purpose Input/Output,通用输入输出接口。我做了这么多年驱动,说句实话:很多复杂的驱动,比如 I2C、SPI、UART、PWM,它们的底层逻辑追根溯源,都离不开 GPIO 的基本操作。可以说,GPIO 是嵌入式驱动的“最小公约数”,你把 GPIO 玩明白了,后面学什么外设都快。
这一期我想分享的,就是我在实际项目里反复打磨出来的 GPIO 驱动开发经验。适合谁看?刚入门嵌入式想搞懂驱动的学生、从单片机转 Linux 驱动的工程师、以及那些已经在写驱动但总感觉“原理没吃透”的朋友。
先花几分钟把 GPIO 的底层逻辑搞清楚,再谈 Linux 下的驱动怎么写,顺序不能反。很多人一上来就翻内核文档、抄设备树,结果引脚不工作、中断不触发,查了半天发现连“推挽输出”和“开漏输出”的区别都没弄明白,那后面全是白费功夫。
1.1 引脚物理特性:电压、电流和上下拉
GPIO 引脚在物理上就是一个可以输出高电平或低电平、也可以读取外部电平状态的引脚。听起来很简单,但“电平”背后牵扯的东西不少。
首先,GPIO 的电压等级取决于芯片的供电电压。常见的 ARM 处理器,GPIO 电压域一般是 3.3V,也有 1.8V 的 bank,甚至有些芯片支持 5V 容忍(5V tolerant)。如果你把 3.3V 的引脚直接接到 5V 的逻辑电平上,短时间可能没问题,长时间大概率烧引脚。我之前做过一个项目,把 3.3V 的 GPIO 接到了 5V 的光耦输出上,刚开始一切正常,跑了两天之后引脚就挂了,查了半天才发现是电平不匹配。现在我做任何板级设计,第一步就查原理图,把每个 GPIO 的电压域确认一遍,然后再决定要不要加电平转换芯片。
其次是上下拉电阻。GPIO 内部一般都有可配置的上拉或下拉电阻,阻值通常几十千欧。上拉就是把引脚通过电阻接到 VCC,默认是高电平;下拉就是通过电阻接到 GND,默认是低电平。为什么要配置这个?因为 GPIO 在浮空状态下,电平是不确定的,读出来可能一会儿 0 一会儿 1。按键扫描、I2C 总线、开漏输出这些场景,上下拉配置错一个,工作状态就会非常诡异。
这里有个实操经验:很多芯片的上下拉电阻默认是关闭的,需要在初始化时显式配置。如果你用复用功能(比如 UART RX),外部电路又没有上下拉,建议打开内部上拉,避免线路悬空导致误触发。
1.2 GPIO 的 8 种工作模式,一张表讲透
STM32 的 GPIO 有 8 种工作模式,虽然 Linux 驱动开发中不直接用这个叫法,但你写设备树、配 pinctrl 的时候,底层原理完全一致。我把这 8 种模式整理一下:
| 模式 | 方向 | 说明 | 典型场景 |
|---|---|---|---|
| 输入浮空 | 输入 | 引脚不接上下拉,电平由外部决定 | 外部已经有明确上下拉的信号 |
| 输入上拉 | 输入 | 内部上拉,无外部信号时读高 | 按键一端接地 |
| 输入下拉 | 输入 | 内部下拉,无外部信号时读低 | 按键一端接 VCC |
| 模拟输入 | 输入 | 直接进 ADC 采样 | 采集电压 |
| 开漏输出 | 输出 | 只能拉低,拉高靠外部上拉 | I2C 数据线、电平适配 |
| 推挽输出 | 输出 | 既能拉高又能拉低,驱动能力强 | LED、蜂鸣器、普通信号输出 |
| 复用推挽 | 输出 | 由外设控制推挽输出 | UART TX、PWM |
| 复用开漏 | 输出 | 由外设控制开漏输出 | I2C、某些总线信号 |
这里最容易被忽视的就是“开漏输出”。开漏输出在拉高的时候其实是“释放”引脚,让外部上拉电阻把电平拉上去。这意味着两个设备可以同时挂在同一根线上,谁拉低谁就占据总线——这就是 I2C 能双向通信的物理基础。如果你把开漏输出的引脚直接内部拉高,那就要看芯片支不支持内部上拉,STM32 的 I2C 引脚内部上拉比较弱,外部通常还要再并联一个上拉电阻。
1.3 寄存器视角:别害怕,控制 GPIO 其实就是改几个寄存器
对于写驱动的人来说,GPIO 的控制最终要落到寄存器上。以 STM32 为例,每个 GPIO 端口有一组寄存器,常用的有:
- GPIOx_MODER:模式寄存器,决定引脚是输入、输出还是复用功能,每两位控制一个引脚。
- GPIOx_OTYPER:输出类型寄存器,决定推挽还是开漏。
- GPIOx_OSPEEDR:输出速度寄存器,决定翻转速率。
- GPIOx_PUPDR:上下拉配置寄存器。
- GPIOx_IDR:输入数据寄存器,读引脚电平。
- GPIOx_ODR:输出数据寄存器,写引脚电平。
- GPIOx_BSRR:置位/复位寄存器,一次操作可以原子地置高或拉低某个引脚。
为什么强调 BSRR?因为在多任务或中断环境下,用 ODR 做“读-改-写”可能会被其他代码打断,导致引脚错误翻转。而 BSRR 是硬件原子操作,写 0 到指定 bit 不会影响其他引脚。我早期写裸机代码时图省事直接操作 ODR,结果在中断里翻转引脚经常出诡异问题,后来全面改成 BSRR,问题就消失了。这个习惯后来在 Linux 驱动里写 gpio_set_value 的时候,底层实现也是同样的逻辑思路。
2. Linux 下 GPIO 驱动的三种境界:sysfs、libgpiod、设备树 + pinctrl
很多人问“Linux 下怎么操作 GPIO”,其实答案分时代。早期内核用 sysfs 接口,后来内核 4.8 开始引入 libgpiod 字符设备接口,现在新项目基本都推荐用后者。但 sysfs 接口在调试阶段仍然非常好用,因为你只需要几条命令就能测引脚,不用编译任何程序。
2.1 sysfs 接口:最快验证 GPIO 的工具
sysfs 接口的路径在/sys/class/gpio。操作流程是:
# 导出某个 GPIO,假设编号是 100 echo 100 > /sys/class/gpio/export # 设置方向 echo out > /sys/class/gpio/gpio100/direction # 输出高电平 echo 1 > /sys/class/gpio/gpio100/value # 变换方向为输入 echo in > /sys/class/gpio/gpio100/direction # 读取电平 cat /sys/class/gpio/gpio100/value这里有个坑:GPIO 编号不是你在芯片手册上看到的引脚号。在 Linux 里,GPIO 编号是一个全局递增的整数,由 GPIO controller 注册时决定。你需要看/sys/kernel/debug/gpio或设备树里的gpio定义来换算引脚号。我自己在调试时经常直接访问 debugfs,比瞎猜编号靠谱得多。
sysfs 接口虽然简单,但有个致命缺点:没有严格的状态管理,用户态可以随意导出释放,很容易发生竞争。而且它无法应对中断、事件监听这类需求。所以 sysfs 只适合临时调试,不适合正式应用。
2.2 libgpiod:新时代的 GPIO 用户态接口
libgpiod 是当前 Linux 下操作 GPIO 的推荐方式。它提供了一组用户态库和命令行工具,底层通过 ioctl 和字符设备交互,性能比 sysfs 好,语义也更清晰。
命令行工具常用:
# 列出所有 GPIO chip gpiodetect # 显示某个 chip 的 line 信息 gpioinfo gpiochip0 # 读取一个 line gpioget gpiochip0 4 # 设置一个 line 为输出高 gpioset gpiochip0 4=1 # 监听 line 事件 gpiomon gpiochip0 4驱动代码里常用的 API 包括:
gpiod_get():获取某个 GPIO 描述符gpiod_direction_output():设置为输出gpiod_set_value():设置输出值gpiod_get_value():读取输入值gpiod_to_irq():把 GPIO 转为中断号
用 libgpiod 最大的好处是它天然支持按名称查找 GPIO,设备树里给引脚起了响亮的名字,代码里就不用记数字编号了。我现在的项目基本都在用这套,稳定性比 sysfs 高了一个量级。
2.3 设备树里的 GPIO 描述与 pinctrl 子系统
如果你的驱动只操作一个 GPIO,直接在驱动代码里gpiod_get()就行。但如果涉及引脚复用、上下拉配置、输出速度设置,那就得靠 pinctrl 子系统。
设备树中典型的 GPIO 节点长这样:
&gpio1 { status = "okay"; }; led_pinctrl: led_pinctrl { fsl,pins = < MX6UL_PAD_GPIO1_IO09__GPIO1_IO09 0x1b0b0 >; }; led { compatible = "mycompany,led"; pinctrl-names = "default"; pinctrl-0 = <&led_pinctrl>; led-gpios = <&gpio1 9 GPIO_ACTIVE_LOW>; status = "okay"; };这里MX6UL_PAD_GPIO1_IO09__GPIO1_IO09是指定引脚复用为 GPIO 功能,后面的0x1b0b0是配置寄存器值,包括上下拉、速度、驱动能力等。led-gpios则是标准的 GPIO 属性绑定。GPIO_ACTIVE_LOW表示低电平有效,这很关键——如果你的硬件设计是高电平点亮 LED,写成GPIO_ACTIVE_LOW,代码里gpiod_set_value就会自动做电平翻转,不用自己操心硬件是高有效还是低有效。
pinctrl 到底解决什么问题?它把“引脚功能选择”和“GPIO 使用”分开了。一个引脚在某种时刻可能是 GPIO,另一种时刻可能是 UART RX,pinctrl 负责在驱动 probe 时把引脚配置成对应的功能。这个抽象层级非常重要,否则每个驱动都得自己去操作引脚复用寄存器,代码会乱成一锅粥,不同驱动之间还会互相踩。
3. 实操:写一个带中断的 GPIO 按键驱动
说了这么多理论,咱们直接写一个真实能跑的 GPIO 按键驱动。这个驱动不是那种 hello world 级别的 demo,而是我在产品项目里用过的简化版本,包含中断申请、去抖、设备树匹配、报告按键事件等完整链路。理解了这个驱动,你再去写 LED、蜂鸣器、继电器、拨码开关之类的 GPIO 外设,基本就是套模板。
3.1 设备树节点:先给驱动“配好引脚”
我们用一个平台设备描述按键:
key_pinctrl: key_pinctrl { fsl,pins = < MX6UL_PAD_UART2_RX_DATA__GPIO1_IO20 0x1b0b0 >; }; gpio-key { compatible = "mycompany,gpiokey"; pinctrl-names = "default"; pinctrl-0 = <&key_pinctrl>; key-gpios = <&gpio1 20 GPIO_ACTIVE_LOW>; interrupt-parent = <&gpio1>; interrupts = <20 IRQ_TYPE_EDGE_FALLING>; status = "okay"; };这段设备树要表达的意思很明确:把 UART2 的 RX 引脚复用为 GPIO1_IO20,配置上拉(0x1b0b0 中包含了上拉和高速配置),按键按下时该引脚会被拉低,所以用GPIO_ACTIVE_LOW,中断触发方式选下降沿。interrupt-parent指向 GPIO 控制器,说明这个中断由 GPIO 模块来管理。
注意:设备树里写
interrupts时,20是引脚编号,不是全局中断号。很多新手以为要去查 IRQ 号,其实在设备树里只需要写 GPIO 控制器视角的编号,内核会自动映射。
3.2 驱动代码:骨架、probe、中断申请
驱动代码我尽量精简,保留关键部分:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/gpio/consumer.h> #include <linux/interrupt.h> #include <linux/of.h> #include <linux/input.h> struct key_drv { struct gpio_desc *key_gpio; int irq; struct timer_list debounce_timer; }; static struct key_drv key_dev; static void key_timer_handler(struct timer_list *t) { // 按键去抖后处理 pr_info("key pressed, value=%d\n", gpiod_get_value(key_dev.key_gpio)); } static irqreturn_t key_isr(int irq, void *data) { // 修改超时时间,实现去抖 mod_timer(&key_dev.debounce_timer, jiffies + msecs_to_jiffies(20)); return IRQ_HANDLED; } static int key_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; key_dev.key_gpio = devm_gpiod_get(dev, "key", GPIOD_IN); if (IS_ERR(key_dev.key_gpio)) { dev_err(dev, "failed to get key gpio\n"); return PTR_ERR(key_dev.key_gpio); } key_dev.irq = gpiod_to_irq(key_dev.key_gpio); if (key_dev.irq < 0) { dev_err(dev, "failed to get irq\n"); return key_dev.irq; } timer_setup(&key_dev.debounce_timer, key_timer_handler, 0); return request_irq(key_dev.irq, key_isr, IRQF_TRIGGER_FALLING, "gpio-key", NULL); } static const struct of_device_id key_of_match[] = { { .compatible = "mycompany,gpiokey", }, { }, }; MODULE_DEVICE_TABLE(of, key_of_match); static struct platform_driver key_driver = { .probe = key_probe, .driver = { .name = "gpio-key", .of_match_table = key_of_match, }, }; module_platform_driver(key_driver); MODULE_LICENSE("GPL");这个驱动里有几个关键的工程决策值得解释。
首先,为什么用devm_gpiod_get而不是老的gpio_request?因为 devm 系列 API 会在设备移除时自动释放资源,你少写一半错误处理代码。现在的内核开发趋势就是多用 devm,少用裸的申请函数。凡是看到gpio_request的老代码,我基本都会建议改成devm_gpiod_get。
其次,为什么按键要加定时器去抖?机械按键在按下和释放的瞬间,金属触点会弹跳,产生一串几毫秒到几十毫秒的脉冲。如果每一个脉冲都触发一次中断,你一次按键可能会被识别成十几次。而定时器去抖的思路是:每次中断到达时,我们都重新启动定时器,如果 20ms 内没有新的中断,说明信号稳定了,就认为这是一次有效按键。这个方案简单可靠,也是驱动中最普遍的做法。
3.3 中断处理与底半部机制
中断处理的代码要短,这是 Linux 内核的硬性要求。中断服务程序(top half)里只做寄存器级别的快速操作,真正耗时的工作放到底半部,比如 tasklet、workqueue、threaded IRQ 或者 timer。
上面代码把 timer 当成底半部用,实际上是一种变通的“threaded”思路。你也可以用request_threaded_irq,直接把中断当作一个内核线程来跑:
request_threaded_irq(key_dev.irq, NULL, key_threaded_isr, IRQF_TRIGGER_FALLING, "gpio-key", NULL);这样 irq handler 会在线程上下文中执行,可以放心调用gpiod_get_value、msleep这类函数,不用怕阻塞系统。我做的很多项目里,GPIO 中断驱动都是首选 threaded IRQ,代码简洁而且不容易踩锁的坑。
3.4 编译、加载与验证流程
驱动编译有两种方式:编成模块或编进内核。开发阶段强烈建议编成模块,这样迭代速度快。假设你的内核源码在 Linux 目录,驱动文件在 drivers/misc 下,你需要修改 Kconfig 和 Makefile。如果只是想快速测试,也可以独立编译:
obj-m += key_drv.o KDIR := /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M=$(PWD) modules编译完成后,把 .ko 传到板子上:
insmod key_drv.ko # 查看内核日志 dmesg | tail # 按一下按键,应该能看到 key pressed 的打印需要注意:如果设备树没有配上,probe 不会执行;如果引脚复用冲突,probe 可能在 pinctrl 阶段就报错。所以调试的顺序应该是:先确认设备树节点被解析、再确认 gpiod_get 成功、最后再确认中断号有效。
4. 常见问题与排查技巧实录
GPIO 驱动开发最大的痛点不是写代码,而是出问题时不知道从哪里查。这些年我踩过不少坑,把高频问题整理成一个速查表,再逐个展开说排查思路。
| 现象 | 可能原因 | 检查方法 |
|---|---|---|
| 驱动 probe 不执行 | 设备树 compatible 不匹配 / 节点没被加载 | cat /proc/device-tree查看节点 |
| 引脚无输出 | 引脚复用冲突 / 被其他驱动占用 | cat /sys/kernel/debug/gpio |
| 引脚反复翻转 | 上下拉配置错误 / 驱动能力不足 | 检查 pinctrl 配置、示波器实测 |
| 中断不触发 | 中断号映射错误 / 触发类型不对 | cat /proc/interrupts看计数 |
| 中断频繁误触发 | 信号毛刺 / 缺少去抖 | 示波器观察波形,加软件去抖 |
| 高电平不亮 | GPIO_ACTIVE_LOW 配置错误 | 检查设备树 flag,确认硬件有效电平 |
4.1 驱动 probe 不执行,先查设备树
这个问题的排查方法就三步:第一,确认.compatible字符串是否和设备树里的完全一致。注意设备树里的字符串可能带逗号,驱动里的 of_device_id 必须一字不差,大小写也不能错。
第二,确认设备树节点有没有被系统实际加载。在板子上执行:
cat /sys/firmware/devicetree/base/mycompany/gpiokey/compatible如果报错或者没有内容,说明设备树没编进去,或者 DTS 编译时被跳过了。第三,确认设备节点有没有被 platform bus 匹配上。可以看/sys/bus/platform/devices/下有没有对应设备目录。如果匹配失败,常见原因是设备树里缺少status = "okay",或者父节点状态是 disabled。
4.2 GPIO 被占用,怎么找是谁占的
写驱动时报gpio_request: gpio-X already requested,别急着重启。先看 debugfs:
cat /sys/kernel/debug/gpio这个文件会列出所有 GPIO controller、每个 GPIO 的占用状态、申请者名字和当前电平。你会看到类似:
gpiochip0: GPIOs 0-31, parent: platform/xxxx, can sleep: gpio-0 ( |sysfs ) in lo gpio-20 ( |gpio-key ) in hi一眼就能看出是谁占了 GPIO。如果看到 sysfs 占着,就去/sys/class/gpio目录下找有没有 unexport 掉。这种占用冲突最常见的场景是:调试时用 sysfs 导出了某个引脚,后来忘了释放,直接 insmod 自己的驱动,结果 pin 被 sysfs 锁住。还有一种是 pinctrl 里配置了同一个引脚的多组复用,不同的 controller 都要用这个 pin,内核会报 pin conflict。
4.3 中断不触发的深坑排查
中断不触发,先看/proc/interrupts:
cat /proc/interrupts找到你申请的中断号,看触发计数有没有增加。没有增加,说明硬件中断根本没有进 GPIO 控制器,问题大概率在引脚复用电平域,或者按键电路本身。如果计数在增加,但你的 handler 没有打印,说明中断号映射错误,读到的是 GPIO 中断总线的别的子中断号。
另一个比较隐蔽的坑是“边沿触发”的初始状态。假设按键按下前引脚已经是低电平,你申请的是下降沿中断,那么永远也等不到下降沿。这种场景要在设备树里确认硬件设计和触发类型的匹配。还有一些 GPIO 控制器默认只支持电平触发或单一触发沿,需要看芯片手册确认,实在不行就把触发方式换成 level-low,然后配合去抖代码解决。
4.4 用示波器和 devmem 做硬件级排查
软件查不出问题时,工具要用起来。板子上如果还有串口终端,你就可以用 busybox devmem 直接操作 GPIO 寄存器。举例来说,AM335x 的 GPIO0 数据寄存器地址是 0x48030138,快速翻转某个引脚:
devmem 0x48030138 32 0xFFFFFFFF devmem 0x48030138 32 0x00000000如果引脚电平随之变化,说明硬件通路和设备树配置没问题,问题出在更上层的驱动。如果没变化,重点查引脚复用寄存器、时钟使能、电压域。
示波器就更直接了。看波形要看三个点:上升沿斜率是否正常、高电平是否达到芯片要求的 VIH、有没有毛刺和振铃。毛刺是干扰源,振铃是阻抗不匹配,电平不够看驱动能力。这些硬件信号问题,示波器一测就有结论,比对着代码猜效率高太多。
5. 从 GPIO 出发:驱动开发的学习路线与工程思维
聊完了具体技术,我想再分享一点方法论层面的东西,也算是给想长期走嵌入式开发这条路的朋友一些参考。
5.1 为什么 GPIO 是学习的“最小闭环”
一个 GPIO 驱动虽然简单,但它包含了一个完整驱动该有的要素:设备树描述、pinctrl 配置、平台总线匹配、GPIO 子系统、中断子系统、底半部处理、调试手段。你把 GPIO 驱动的每个环节吃透,就等于掌握了 Linux 设备模型的一根主线。
后续学的 I2C 驱动,不过是有个 adapter 在底层帮你做了时序;SPI 驱动,不过是有个 controller 帮你搬运数据;看门狗驱动,不过是一个定时器加一个 GPIO。所有外设驱动都在重复一套模式:设备树描述硬件,总线接口建立连接,核心子系统管理资源,驱动代码实现业务逻辑。GPIO 驱动就是你理解这套模式的最佳入口。
5.2 工程实践中的分层思维
写驱动不是只要能跑就行,还要考虑可维护性。我一般会把 GPIO 驱动分成三层:
第一层是硬件抽象层,直接调用 gpiod API 操作引脚;第二层是业务逻辑层,比如按键去抖、状态机、防重复触发;第三层是接口层,把按键事件上报给应用或输入子系统。分层的价值体现在后续换硬件平台时,你只需要改第一层,业务逻辑完全不用动。
我见过很多项目,驱动代码里业务逻辑和硬件操作混在一起,维护起来非常痛苦。一时图快,后面付出几倍成本。项目经验越多,越理解“分层设计”这四个字的分量。
5.3 嵌入式面试中 GPIO 相关的高频问题
最近总有人问我嵌入式面试八股文该准备什么。面试官问 GPIO,从来不会只问“GPIO 是什么”,而是从浅到深一套组合拳:
- GPIO 的推挽输出和开漏输出有什么区别?
- 按键检测为什么需要消抖?硬件消抖和软件消抖各自怎么做?
- 驱动中申请 GPIO 和申请中断的顺序是什么?为什么 GPIO 转 IRQ 要在申请 GPIO 之后?
- Linux 中 GPIO 号和 IRQ 号是什么关系?
- 设备树里 GPIO_ACTIVE_HIGH 和 GPIO_ACTIVE_LOW 的实现原理是什么?
- sysfs 和 libgpiod 各自优缺点是什么?
- 如果 GPIO 引脚被复用冲突,内核会怎么报错?如何定位?
这些问题看起来零散,但核心都在考察你有没有真正实操过、有没有系统性地理解过 GPIO 的完整链路。我建议你在准备面试时,把每道题都结合自己实际做过的一个例子去讲,不要背概念。面试官想听的是你对这个系统的理解深度,而不是搜索引擎式的名词背诵。
5.4 我的习惯:每个项目都要有一个“GPIO 自检板”
做了这么多年驱动,我养成一个习惯:每次拿到新板子,先写一个简单的 GPIO 自检驱动,把所有要用的引脚都拉一遍电平,然后接上 LED 或万用表逐一验证。这个自检驱动不追求复杂功能,就是把芯片手册上说的“这个引脚默认功能是什么、复用以后是什么”全部过一遍。新板子跑通自检驱动之后,我才开始写具体业务驱动。
这个习惯帮我避开了无数低级问题。有些引脚在布线上有串联电阻,有些引脚复用了下载器信号,有些引脚默认就被其他 bootloader 配置了——自检驱动一跑,心里全有数。做嵌入式就是这样,犯错不可怕,可怕的是一遍一遍在同一个地方犯错还不记录。
我自己最受益的一个小技巧:在驱动里多留一条“手工测试接口”。比如在/sys/kernel/debug下挂一个读写 GPIO 的函数,调试时就省得反复加载模块。或者用 debugfs 暴露一些状态变量。调试完再删掉都行,但有了这条路径,排查问题的速度会快很多。GPIO 驱动入门不难,难的是把细节都做到可控。你把引脚、电平、复用、中断、时序这些细节全部搞清楚,后面面对任何复杂外设,心态都会稳很多。