1. 从零吃透GPIO驱动为什么它值得你花时间搞嵌入式或者Linux驱动开发的人绕不开GPIO。你可以把GPIO理解成芯片伸出来的“手脚”——它不负责复杂的运算也不管数据存储但它能直接感知外部世界的电平高低也能直接控制LED亮灭、继电器吸合、按键读取。说白了GPIO驱动就是让你的代码能“摸到”物理世界的那根神经。我刚开始接触Linux驱动的时候觉得GPIO最简单——不就是拉高拉低吗后来踩了一堆坑才发现GPIO驱动涉及的东西远比想象中多引脚复用怎么配、设备树怎么描述、中断怎么挂、用户空间怎么调试、不同SoC平台的GPIO控制器差异怎么处理。这些问题不搞清楚你的驱动要么跑不起来要么跑起来了但不稳定甚至会把板子上的其他功能搞挂。这篇内容适合谁看如果你正在学Linux字符设备驱动框架或者你在用GD32、RK3566、STM32这类平台做GPIO控制又或者你在调试w25q32jvssiq这类SPI Flash时发现CS引脚死活拉不对——那这篇东西就是写给你的。我会从GPIO驱动的整体设计思路讲起把设备树配置、内核API、用户空间接口、常见排查手段全部串一遍尽量做到你照着就能复现。2. GPIO驱动的整体设计思路与核心框架2.1 为什么GPIO驱动不是“拉高拉低”这么简单很多人第一次写GPIO驱动脑子里想的是找到寄存器地址往里面写个1完事。在裸机时代确实可以这么干但在Linux内核里这种思路会撞墙。原因有三个第一引脚复用。一颗SoC的物理引脚通常有多个功能比如PA5可能同时是GPIO、UART2_TX、SPI1_SCK。你直接操作寄存器很可能把别的驱动正在用的引脚给抢了。内核的pinctrl子系统就是专门管这事的你得先通过设备树声明“我要把PA5用作GPIO”pinctrl才会帮你把复用关系配好。第二GPIO控制器的抽象。不同芯片的GPIO控制器寄存器布局完全不同。GD32F407VGT6的GPIO和RK3566的GPIO寄存器地址、位宽、时钟门控都不一样。内核的gpiolib框架把这些差异统一封装成了gpio_chip结构体你作为驱动开发者调用的是gpiod_get()、gpiod_set_value()这些通用API而不是直接怼寄存器。第三资源管理。GPIO是一种共享资源。如果两个驱动同时操作同一个引脚结果不可预期。gpiolib提供了引用计数和申请机制确保一个引脚在同一时刻只被一个驱动使用。所以一个合格的GPIO驱动核心工作不是“写寄存器”而是正确地申请资源、配置方向、读写电平、处理中断并在不需要时释放资源。2.2 gpiolib框架的层次结构Linux的GPIO子系统大致分三层最底层是GPIO控制器驱动这部分通常由SoC厂商提供比如gpio-dwapb.c、gpio-rockchip.c。它负责描述这颗芯片有多少个GPIO bank、每个bank有多少引脚、寄存器怎么访问。中间层是gpiolib核心提供统一的API接口管理GPIO的申请、释放、方向设置、电平读写、中断映射。最上层是GPIO消费者驱动也就是我们自己写的驱动通过gpiod_*系列函数来使用GPIO。你写“GPIO驱动2”这个练习大概率是在写消费者驱动也可能涉及简单的控制器驱动。两者的写法差别很大下面分别说。2.3 新旧API的取舍gpio_* vs gpiod_*内核里有两套GPIO API老式的gpio_基于整数编号和新式的gpiod_基于描述符。老API在早期内核里广泛使用但有几个硬伤整数编号容易冲突、不支持设备树里的GPIO属性解析、错误处理不友好。新API用struct gpio_desc指针来标识一个GPIO通过设备树或ACPI自动解析支持gpiod_get_index()、gpiod_get_optional()等灵活接口。我强烈建议新项目一律用gpiod_老API只在你维护旧代码时才需要了解。一个典型的gpiod使用流程是这样的struct gpio_desc *led_gpio; led_gpio 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); } gpiod_set_value(led_gpio, 1); // 拉高 gpiod_set_value(led_gpio, 0); // 拉低 gpiod_put(led_gpio); // 释放注意gpiod_get()的第二个参数“led”它会去设备树里找名为“led-gpios”的属性。这个命名规则是固定的name-gpios或name-gpio。3. 设备树配置GPIO驱动的“户口本”3.1 设备树里怎么描述一个GPIO设备树是Linux驱动获取硬件信息的标准方式。对于GPIO设备树里通常有两种角色一种是GPIO控制器节点一种是GPIO消费者节点。控制器节点一般由SoC的.dtsi文件提供比如RK3566的gpio1: gpiofe740000 { compatible rockchip,gpio-bank; reg 0x0 0xfe740000 0x0 0x100; interrupts GIC_SPI 34 IRQ_TYPE_LEVEL_HIGH; clocks cru PCLK_GPIO1; gpio-controller; #gpio-cells 2; interrupt-controller; #interrupt-cells 2; };关键属性说明gpio-controller声明这个节点是一个GPIO控制器。#gpio-cells 2表示引用这个控制器的GPIO时需要两个参数通常是引脚号和标志位如GPIO_ACTIVE_LOW。interrupt-controller如果这个GPIO控制器支持中断需要加这个属性。消费者节点则是我们自己的设备节点比如my_led { compatible mycompany,my-led; led-gpios gpio1 12 GPIO_ACTIVE_HIGH; status okay; };这里的led-gpios就是驱动里gpiod_get(pdev-dev, led, ...)会去匹配的属性。gpio1 12 GPIO_ACTIVE_HIGH表示使用gpio1控制器的第12号引脚高电平有效。3.2 pinctrl与GPIO的配合光在设备树里写led-gpios还不够你还得确保这个引脚被配置成了GPIO功能而不是被其他外设占用。这就是pinctrl子系统的工作。通常你需要在设备树里加一个pinctrl节点pinctrl { my_led_pin: my-led-pin { rockchip,pins 1 RK_PB4 RK_FUNC_GPIO pcfg_pull_none; }; }; my_led { pinctrl-names default; pinctrl-0 my_led_pin; };这段配置的意思是把gpio1的PB4引脚复用为GPIO功能不加上下拉。pinctrl-names default表示驱动在probe时会自动应用这套配置。注意很多初学者会忘记配pinctrl结果gpiod_get()成功了但引脚电平死活不变。原因就是引脚还处于其他复用功能下GPIO控制器根本控制不了它。3.3 设备树调试技巧设备树写完之后怎么确认它被正确解析了我常用的方法有两个第一个是看/sys/kernel/debug/gpiocat /sys/kernel/debug/gpio这个文件会列出所有已注册的GPIO控制器和已被申请的GPIO。如果你看到自己的引脚出现在列表里说明申请成功了。第二个是看/proc/device-tree/下对应节点的属性ls /proc/device-tree/my_led/ cat /proc/device-tree/my_led/led-gpios如果属性存在且值正确说明设备树编译和加载没问题。4. 内核GPIO API实操从申请到中断4.1 GPIO申请与释放的正确姿势gpiod_get()系列函数有好几个变体用哪个取决于你的场景函数用途是否必须成功gpiod_get()获取单个GPIO是gpiod_get_optional()获取可选GPIO否不存在返回NULLgpiod_get_index()获取多个GPIO中的某一个是devm_gpiod_get()带设备管理的获取是我强烈建议用devm_前缀的版本比如devm_gpiod_get()。它会在设备卸载时自动释放GPIO省得你手动调gpiod_put()减少资源泄漏的风险。led_gpio devm_gpiod_get(pdev-dev, led, GPIOD_OUT_LOW); if (IS_ERR(led_gpio)) return PTR_ERR(led_gpio);第三个参数GPIOD_OUT_LOW表示申请时就把引脚配成输出模式并输出低电平。类似的还有GPIOD_IN输入、GPIOD_OUT_HIGH输出高、GPIOD_ASIS保持原状。4.2 方向设置与电平读写申请到GPIO之后方向设置和电平读写是最常用的操作// 设置为输出 gpiod_direction_output(led_gpio, 0); // 设置为输入 gpiod_direction_input(btn_gpio); // 输出高电平 gpiod_set_value(led_gpio, 1); // 读取输入电平 int val gpiod_get_value(btn_gpio);这里有个容易混淆的地方gpiod_set_value()设置的是“逻辑值”不是物理电平。如果设备树里写了GPIO_ACTIVE_LOW那么gpiod_set_value(desc, 1)实际上会把引脚拉低。这个设计是为了让驱动代码不关心硬件极性只关心逻辑语义。如果你确实需要操作物理电平可以用gpiod_set_raw_value()但一般不建议这么干。4.3 GPIO中断处理GPIO中断是实际项目里非常常见的需求比如按键检测、外部事件触发。Linux里GPIO中断通过gpiod_to_irq()把GPIO描述符转成中断号然后走标准的中断注册流程int irq; irq gpiod_to_irq(btn_gpio); if (irq 0) return irq; ret devm_request_irq(pdev-dev, irq, my_btn_handler, IRQF_TRIGGER_FALLING | IRQF_ONESHOT, my-btn, NULL);中断触发类型常用的有IRQF_TRIGGER_RISING上升沿触发IRQF_TRIGGER_FALLING下降沿触发IRQF_TRIGGER_HIGH高电平触发IRQF_TRIGGER_LOW低电平触发IRQF_TRIGGER_BOTH双边沿触发实操心得按键中断一定要做消抖。硬件消抖靠RC电路软件消抖可以在中断处理里加一个延时再读一次电平或者用定时器延迟处理。我见过太多人中断里直接读电平结果一次按键触发几十次中断。4.4 用户空间操作GPIOsysfs与gpiod工具有时候你不想写内核驱动只想在用户空间快速验证GPIO功能。Linux提供了两种方式老式sysfs接口已废弃但很多老系统还在用echo 12 /sys/class/gpio/export echo out /sys/class/gpio/gpio12/direction echo 1 /sys/class/gpio/gpio12/value echo 12 /sys/class/gpio/unexport新式gpiod工具推荐gpiodetect # 列出所有GPIO控制器 gpioinfo # 查看所有GPIO状态 gpioset gpiochip1 121 # 设置gpio1第12脚为高 gpioget gpiochip1 12 # 读取电平gpiod工具需要安装libgpiod库在Ubuntu上可以apt install gpiod libgpiod-dev。相比sysfsgpiod工具更安全不会出现“export之后忘记unexport导致引脚被占用”的问题。5. 常见问题与排查技巧实录5.1 GPIO申请失败怎么办这是最常见的问题报错通常是-EBUSY或-ENODEV。排查思路如下现象可能原因排查方法-EBUSY引脚已被其他驱动占用cat /sys/kernel/debug/gpio 查看谁在用-ENODEV设备树属性名写错检查name-gpios是否匹配-EINVALgpio-cells数量不对检查控制器#gpio-cells和引用参数申请成功但电平不变pinctrl未配置检查引脚复用状态5.2 电平控制无效的排查如果gpiod_set_value()调用了但引脚没反应按这个顺序查确认引脚方向是输出gpiod_direction_output()有没有调用。确认pinctrl配置正确引脚是否被复用成了GPIO。确认没有其他驱动抢占/sys/kernel/debug/gpio里看引脚状态。用万用表或示波器量实际电平排除软件问题后可能是硬件电路问题比如引脚被外部电路拉死。5.3 中断不触发的排查中断不触发通常有这几个原因触发类型配错按键按下是下降沿你配了上升沿自然不触发。中断被屏蔽检查request_irq的返回值确认注册成功。引脚复用不对中断功能也需要pinctrl配置。硬件问题按键电路没有上拉电阻引脚浮空。我踩过的一个坑是在RK3566上配按键中断设备树里pinctrl配了GPIO功能但忘记加bias-pull-up结果引脚浮空中断乱触发。加上内部上拉之后就稳定了。5.4 不同平台的GPIO差异GD32F407VGT6这类MCU的GPIO和Linux SoC的GPIO在使用思路上差别很大。MCU上你直接操作寄存器或者用HAL库的HAL_GPIO_WritePin()没有设备树、没有gpiolib。而Linux SoC上必须走gpiolib框架。如果你是从MCU转Linux驱动最容易犯的错误就是“想直接找寄存器地址”。在Linux里寄存器地址被GPIO控制器驱动接管了你只能通过gpiolib API间接操作。另外GD32F407VGT6能带动多少个GPIO这颗芯片有112个GPIO引脚分PA到PG共7组。但实际能用的数量取决于你的封装和引脚复用配置。LQFP100封装的话可用GPIO大约80个左右。这个数字在做项目规划时很重要别等到PCB画完了才发现引脚不够。6. 从字符设备框架到GPIO驱动的完整实现6.1 字符设备驱动框架回顾GPIO驱动通常寄生在一个字符设备驱动里。标准的字符设备驱动框架包括static int major; static struct class *my_class; static struct cdev my_cdev; static int my_open(struct inode *inode, struct file *filp) { return 0; } static int my_release(struct inode *inode, struct file *filp) { return 0; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { // 在这里操作GPIO return len; } static struct file_operations my_fops { .owner THIS_MODULE, .open my_open, .release my_release, .write my_write, }; static int __init my_init(void) { major register_chrdev(0, my_gpio, my_fops); my_class class_create(THIS_MODULE, my_gpio); cdev_init(my_cdev, my_fops); cdev_add(my_cdev, MKDEV(major, 0), 1); device_create(my_class, NULL, MKDEV(major, 0), NULL, my_gpio); return 0; }这个框架本身不难难的是把GPIO操作正确地嵌进去。我的建议是GPIO的申请和释放放在probe/remove里具体的电平操作放在write/ioctl里。6.2 平台驱动模型下的GPIO实现更规范的做法是用平台驱动模型static int my_probe(struct platform_device *pdev) { struct my_dev *dev; dev devm_kzalloc(pdev-dev, sizeof(*dev), GFP_KERNEL); dev-led_gpio devm_gpiod_get(pdev-dev, led, GPIOD_OUT_LOW); if (IS_ERR(dev-led_gpio)) return PTR_ERR(dev-led_gpio); platform_set_drvdata(pdev, dev); return 0; } static const struct of_device_id my_of_match[] { { .compatible mycompany,my-led }, {}, }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_driver { .probe my_probe, .driver { .name my-led, .of_match_table my_of_match, }, }; module_platform_driver(my_driver);这种写法跟设备树配合得最好compatible属性匹配上之后自动调用probeGPIO从设备树解析整个流程非常干净。6.3 一个完整的LED控制驱动示例把上面的东西串起来一个完整的LED驱动大概长这样#include linux/module.h #include linux/platform_device.h #include linux/gpio/consumer.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h struct my_led_dev { struct gpio_desc *led; dev_t devno; struct cdev cdev; struct class *cls; }; static ssize_t led_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { struct my_led_dev *dev filp-private_data; char kbuf[8]; if (len sizeof(kbuf) - 1) len sizeof(kbuf) - 1; if (copy_from_user(kbuf, buf, len)) return -EFAULT; kbuf[len] \0; if (kbuf[0] 1) gpiod_set_value(dev-led, 1); else gpiod_set_value(dev-led, 0); return len; } static int led_open(struct inode *inode, struct file *filp) { struct my_led_dev *dev container_of(inode-i_cdev, struct my_led_dev, cdev); filp-private_data dev; return 0; } static struct file_operations led_fops { .owner THIS_MODULE, .open led_open, .write led_write, }; static int led_probe(struct platform_device *pdev) { struct my_led_dev *dev; int ret; dev devm_kzalloc(pdev-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-led devm_gpiod_get(pdev-dev, led, GPIOD_OUT_LOW); if (IS_ERR(dev-led)) { dev_err(pdev-dev, get led gpio failed\n); return PTR_ERR(dev-led); } ret alloc_chrdev_region(dev-devno, 0, 1, my_led); if (ret) return ret; cdev_init(dev-cdev, led_fops); dev-cdev.owner THIS_MODULE; cdev_add(dev-cdev, dev-devno, 1); dev-cls class_create(THIS_MODULE, my_led); device_create(dev-cls, NULL, dev-devno, NULL, my_led); platform_set_drvdata(pdev, dev); dev_info(pdev-dev, my led driver probed\n); return 0; } static int led_remove(struct platform_device *pdev) { struct my_led_dev *dev platform_get_drvdata(pdev); device_destroy(dev-cls, dev-devno); class_destroy(dev-cls); cdev_del(dev-cdev); unregister_chrdev_region(dev-devno, 1); return 0; } static const struct of_device_id led_of_match[] { { .compatible mycompany,my-led }, {}, }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver { .probe led_probe, .remove led_remove, .driver { .name my-led, .of_match_table led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE(GPL);对应的设备树my_led: my-led { compatible mycompany,my-led; led-gpios gpio1 12 GPIO_ACTIVE_HIGH; pinctrl-names default; pinctrl-0 my_led_pin; status okay; };编译加载之后echo 1 /dev/my_led就能点亮LEDecho 0 /dev/my_led就能熄灭。6.4 编译与加载的实操步骤写完之后怎么编译如果是内核树内的驱动在Makefile里加obj-$(CONFIG_MY_LED) my_led.o然后make modules。如果是树外模块写一个独立的Makefileobj-m my_led.o KDIR : /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean编译完成后insmod my_led.ko dmesg | tail -20 ls /dev/my_led echo 1 /dev/my_led如果insmod报错“Unknown symbol”说明依赖的符号没导出检查内核配置里GPIO相关选项是否打开。如果probe没被调用检查compatible字符串是否和设备树完全一致——大小写、连字符都不能错。7. 进阶话题GPIO与SPI Flash的配合7.1 w25q32jvssiq的CS引脚控制w25q32jvssiq是一颗常见的SPI NOR Flash容量32Mbit。它的片选CS引脚通常由SPI控制器自动控制但有些场景下你需要手动控制CS比如多从机共享SPI总线。在Linux里SPI控制器的CS引脚通常由SPI子系统管理不需要你手动配GPIO。但如果你用的是GPIO模拟的CS就需要在设备树里用cs-gpios属性spi1 { status okay; cs-gpios gpio1 10 GPIO_ACTIVE_LOW; flash0 { compatible winbond,w25q32jvssiq; reg 0; spi-max-frequency 50000000; }; };这里cs-gpios告诉SPI子系统用gpio1的第10脚作为片选低电平有效。SPI子系统会在每次传输前自动拉低CS传输后拉高。7.2 GPIO模拟SPI的可行性分析有些极端情况下硬件SPI不够用需要用GPIO模拟SPIbit-banging。Linux内核提供了spi-gpio驱动可以在设备树里这样配spi_gpio: spi-gpio { compatible spi-gpio; gpio-sck gpio1 11 GPIO_ACTIVE_HIGH; gpio-mosi gpio1 12 GPIO_ACTIVE_HIGH; gpio-miso gpio1 13 GPIO_ACTIVE_HIGH; cs-gpios gpio1 10 GPIO_ACTIVE_LOW; num-chipselects 1; status okay; };但我要泼一盆冷水GPIO模拟SPI的性能很差时钟频率通常只能跑到几百KHz到1MHz而且CPU占用率高。w25q32jvssiq支持50MHz的SPI时钟用GPIO模拟完全是浪费。除非你只是做低速调试否则老老实实用硬件SPI控制器。8. 调试工具与实战经验汇总8.1 常用调试命令速查命令用途cat /sys/kernel/debug/gpio查看所有GPIO状态gpiodetect列出GPIO控制器gpioinfo查看GPIO详细信息gpioset设置GPIO电平gpioget读取GPIO电平gpiofind按名称查找GPIOdmesg查看内核日志cat /proc/device-tree/...查看设备树属性8.2 我踩过的三个坑第一个坑忘记配pinctrl。在RK3566上调试一个LEDgpiod_get()返回成功gpiod_set_value()也没报错但LED就是不亮。查了半天才发现pinctrl没配引脚还在I2S功能下。加上pinctrl配置后立刻正常。第二个坑GPIO_ACTIVE_LOW搞反了。设备树里写了GPIO_ACTIVE_LOW驱动里用gpiod_set_value(desc, 1)想点亮LED结果LED灭了。后来才理解逻辑值和物理电平是反的。这个设计其实很合理但第一次遇到确实容易懵。第三个坑中断里做耗时操作。按键中断处理函数里调用了msleep()结果内核报“scheduling while atomic”。中断上下文不能睡眠耗时操作要放到工作队列或线程化中断里处理。8.3 性能与稳定性建议GPIO操作本身很快但有几个地方会影响性能频繁的gpiod_set_value()调用会有函数调用开销如果只是简单翻转可以考虑用gpiod_set_value_cansleep()的异步版本。中断处理尽量短把耗时逻辑放到下半部。多个GPIO操作如果有顺序要求注意加锁保护。稳定性方面最重要的是资源管理。用devm_前缀的API可以避免大部分资源泄漏问题。如果必须手动管理确保remove路径里释放了所有申请的资源。9. 从GPIO驱动延伸出去的学习路径GPIO驱动是Linux驱动开发的入门砖但它连接着很多进阶方向。往深了走你可以研究pinctrl子系统的实现、gpiolib的源码、中断子系统的分层设计。往广了走GPIO和I2C、SPI、PWM这些子系统经常配合使用比如用GPIO做复位引脚、用PWM做背光调节。如果你在学字符设备驱动框架GPIO驱动是一个很好的练手项目——它足够简单能让你专注在框架本身又足够真实涉及设备树、平台驱动、中断、用户空间接口这些实际项目中一定会遇到的东西。我自己在带新人的时候通常让他们先写一个LED驱动再写一个按键中断驱动最后把两个合起来做一个“按键控制LED”的完整驱动。这三步走下来Linux驱动的基本套路就摸清楚了。后面再学I2C、SPI、USB这些复杂子系统会发现底层逻辑是相通的。最后分享一个小技巧调试GPIO的时候手边一定要有万用表或逻辑分析仪。软件层面看寄存器、看sysfs、看dmesg只能告诉你“软件以为发生了什么”只有量到实际电平才能确认“硬件真的发生了什么”。我遇到过好几次软件显示电平正确但硬件没反应的情况最后都是硬件电路的问题——上拉电阻没焊、引脚虚焊、电平不匹配。软硬结合排查效率最高。
阅读完成 · 觉得有帮助?