4、GPIO驱动开发:从寄存器到用户空间

GPIO,说白了就是芯片的“手脚”。你想让某个引脚输出高电平,或者读取外部设备的状态,都得靠它。在嵌入式Linux下,GPIO驱动开发是入门必修课。今天我就带你走一遍完整流程:从GPIO子系统框架,到英伟达平台的寄存器映射,再到写一个简单的驱动,最后用sysfs在用户空间操作它。

4.1 GPIO子系统框架:Linux怎么管这些引脚?

Linux内核的GPIO子系统,其实分三层。我画个简单的逻辑图给你看:

  • 最底层:硬件相关层 —— 直接操作寄存器,由芯片厂商提供。
  • 中间层:GPIO核心层 —— 提供统一的API,比如gpio_request()、gpio_direction_output()。
  • 最上层:用户接口层 —— 通过sysfs或字符设备暴露给用户空间。

为什么要这么分层?你想想看,如果每个驱动都直接操作寄存器,那代码就乱套了。核心层帮你做了“引脚编号到寄存器地址”的映射,你只需要调用标准接口就行。

核心数据结构:每个GPIO控制器对应一个struct gpio_chip。里面定义了方向设置、读写值等回调函数。

我在项目中遇到过一个问题:某个GPIO在驱动里死活拉不高。查了半天,发现是另一个驱动抢先注册了同一个引脚。嗯,这就是没用好gpio_request()的后果。

4.2 英伟达平台GPIO控制器寄存器映射

英伟达的Tegra系列,GPIO控制器通常挂在APB总线上。每个GPIO bank有32个引脚,寄存器偏移是固定的。我以Tegra X1为例:

寄存器名称 偏移地址 描述
GPIO_CNF 0x00 方向配置(1=输出,0=输入)
GPIO_OE 0x04 输出使能
GPIO_OUT 0x08 输出值寄存器
GPIO_IN 0x0C 输入值寄存器
GPIO_INT_STA 0x10 中断状态

每个bank的基地址怎么算?设备树里会给出。比如:

gpio@6000d000 {
    compatible = "nvidia,tegra210-gpio";
    reg = <0x0 0x6000d000 0x0 0x1000>;
    interrupts = <0 32 0x04>;
    #gpio-cells = <2>;
    gpio-controller;
};

这里reg属性告诉内核:寄存器基地址是0x6000d000,范围4KB。我个人习惯在驱动里用ioremap()把物理地址映射到虚拟地址空间,然后直接读写。

注意:英伟达的GPIO寄存器是32位的,但有些引脚可能只用了低16位。写寄存器时千万别把高16位搞乱了,否则会影响其他bank。

4.3 编写简单的GPIO控制驱动

好,理论说完了,咱们动手写一个。这个驱动要做的事很简单:注册一个GPIO,然后提供文件操作接口,让用户空间能读写它。

先看核心代码:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/gpio.h>
#include <linux/platform_device.h>

#define GPIO_PIN 184  // 假设是某个GPIO

static int gpio_drv_open(struct inode *inode, struct file *file) {
    return 0;
}

static ssize_t gpio_drv_write(struct file *file, const char __user *buf,
                              size_t count, loff_t *ppos) {
    char val;
    if (copy_from_user(&val, buf, 1))
        return -EFAULT;
    
    if (val == '1')
        gpio_set_value(GPIO_PIN, 1);
    else
        gpio_set_value(GPIO_PIN, 0);
    
    return count;
}

static struct file_operations gpio_fops = {
    .owner = THIS_MODULE,
    .open = gpio_drv_open,
    .write = gpio_drv_write,
};

static int __init gpio_drv_init(void) {
    int ret;
    
    ret = gpio_request(GPIO_PIN, "my-gpio");
    if (ret) {
        pr_err("Failed to request GPIO %d\n", GPIO_PIN);
        return ret;
    }
    
    gpio_direction_output(GPIO_PIN, 0);
    
    // 注册字符设备...
    return 0;
}

static void __exit gpio_drv_exit(void) {
    gpio_free(GPIO_PIN);
    // 注销字符设备...
}

module_init(gpio_drv_init);
module_exit(gpio_drv_exit);
MODULE_LICENSE("GPL");

这段代码里,我用了gpio_request()来申请引脚。为什么要申请?防止别的驱动乱用。我曾经在调试时忘了这步,结果两个驱动同时操作同一个引脚,电平乱跳,排查了一下午。

小技巧:调试时可以用gpio_set_value()配合示波器看波形。如果波形不对,先检查gpio_direction_output()有没有调用。

4.4 用户空间通过sysfs操作GPIO

其实,很多时候你不需要写内核驱动。Linux的GPIO子系统已经提供了sysfs接口,用户空间可以直接操作。步骤很简单:

  1. 导出GPIO:echo 184 > /sys/class/gpio/export
  2. 设置方向:echo out > /sys/class/gpio/gpio184/direction
  3. 写值:echo 1 > /sys/class/gpio/gpio184/value
  4. 读值:cat /sys/class/gpio/gpio184/value
  5. 释放:echo 184 > /sys/class/gpio/unexport

为什么会有这套机制?说白了,是为了让应用层工程师也能控制硬件,不用碰内核代码。你想想看,如果每次改个引脚电平都要重新编译内核,那得多麻烦。

不过要注意,sysfs接口在较新的内核里已经被标记为“过时”,推荐用libgpiod或字符设备接口。但作为学习,sysfs依然是最直观的入门方式。

避坑指南:我曾经在用户空间用shell脚本循环写GPIO,频率一高就卡死。后来发现是每次写文件都要经过VFS层,开销很大。如果要做高频操作,还是得写内核驱动或用libgpiod的bulk操作。

4.5 总结与思考

这一章我们走完了GPIO驱动的完整链路。从内核框架到寄存器映射,从驱动代码到用户空间操作。我个人觉得,理解GPIO子系统的分层思想,比记住具体寄存器地址更重要。因为换一个芯片平台,寄存器地址就变了,但框架思想是通用的。

下一章,我们会深入中断子系统。到时候你会发现,GPIO和中断是密不可分的——毕竟,光会拉电平还不够,还得能响应外部事件。