☰
树莓派4 GPIO内核驱动开发:多引脚同步批量控制实战
2026/10/20 17:56:52 网站建设 项目流程

树莓派4上操作GPIO,很多人的第一反应是Python里那几行RPi.GPIO,或者是命令行里的gpioset。这个思路对点灯够用,但等到项目里要同时驱动十几个GPIO口,并且要求其中几个引脚在同一个瞬间完成翻转时,用户态的办法就开始露怯了。我自己做某个小运动控制板时就被这个问题堵住过一次:Linux用户态的调度抖动加上多次系统调用,让脉冲波形变得很难看,最后只能回到内核,自己写了一个Linux下可操作多个GPIO口的驱动。这篇博文把整个设计过程、关键代码和踩坑记录整理出来,适合已经会写点C、第一次想碰内核驱动的开发者参考。

1. 什么时候该考虑自己写驱动,而不是继续用库

1.1 树莓派上玩转GPIO的三条常见路线

树莓派上操作GPIO,主流路线无非是下面三种。

方案运行位置优点短板
直接mmap/dev/mem用户态实现简单,不需要编译内核模块,也绕开了各种权限问题完全没有内核管理,线程调度会带来抖动;一次只能操作一个寄存器,做多引脚同步非常困难
libgpiod / 字符设备接口用户态是当前Linux的标准GPIO接口,支持事件监听、批量读取,文档也比较完善每次操作仍然是一次系统调用,微秒级甚至纳秒级的时序约束基本做不到
自写内核驱动内核态可以独占寄存器组,通过ioctl批量写实现真正的“同一瞬间翻转”,还能接入中断和定时器开发成本高,要处理设备号、class、文件操作、并发锁以及不同内核版本的API差异

你可能会问:/dev/mem这条路不是挺方便吗?确实,很多老教程里wiringPi之类的库就是靠mmap把GPIO寄存器映射到用户态直接操作。但它的缺点在于,你根本绕过了内核的引用计数和占用管理。两个进程同时操作同一个引脚时,谁也不知道对方在干什么。而且用户态程序的调度延迟随系统负载变化很大,短则几微秒,忙时几十微秒甚至上毫秒,这种波动对步进电机脉冲这类场景是致命的。

1.2 “可操作多个GPIO”不是“循环写多个引脚”

很多人一听“驱动多个GPIO口”,下意识觉得就是写个for循环,把同一个操作套到不同引脚上。这只能说对了一半。

真正的难点在“同步”两个字。举个例子:一个步进电机驱动器,通常要求先设置方向DIR,再给STEP脉冲。如果这两步分别发生在两次用户态调用里,那么在你设置完DIR、还没来得及给STEP的这段间隙,驱动器看到的是一个“方向变了但还没有脉冲”的中间状态。用户态程序一旦在这个间隙被调度出去,整个时序就乱了。

内核驱动可以避开这个问题,因为ioctl里你可以做一个批量写命令:一条命令同时指定哪些引脚要拉高、哪些引脚要拉低,然后驱动把它们一次性写进寄存器的SET和CLR位。这种同步是用户态怎么都模拟不出来的。

所以,如果你只是控制几盏LED,直接用户态点一点也就完了;但当你需要带着时序去控制多个执行器,或者要在一个时间戳里采集多路输入状态,内核驱动就是绕不开的选项。

1.3 这个驱动最终交付什么

我最终做出来的东西,是一个字符设备驱动,加载后生成/dev/rpi_multi_gpio。open一次就可以申请多个引脚,支持四类基本操作:

  • 申请/释放引脚,避免多个进程重复占用同一个IO
  • 设置引脚方向:输入或输出
  • 单引脚读写电平
  • 批量同步写:一条ioctl指令同时翻转最多58个引脚

后面所有的代码、Makefile和测试程序都是围绕这四类操作展开的。如果你想在自己的项目里直接用,把引脚编号和测试逻辑改一改就能跑起来。

2. 树莓派4的GPIO寄存器真相:基址和功能选择

2.1 树莓派1到4,外设基址一直在变

写树莓派驱动最常见的翻车点,就是照着树莓派3的老教程,把GPIO基址写成了0x3F200000。这个地址在树莓派4上根本不存在,但可怕的是,ioremap通常不会报错——它只是把一个无效的物理地址映射过来,你写进去的寄存器值永远没有反应,然后你会花掉整个下午怀疑人生。

树莓派四代使用的SoC不同,ARM访问外设的物理基址也跟着不一样:

型号SoC外设物理基址GPIO基址
树莓派1BCM28350x200000000x20000000 + 0x200000
树莓派2/3BCM2836/28370x3F0000000x3F000000 + 0x200000
树莓派4BCM27110xFE0000000xFE000000 + 0x200000

注意树莓派4的BCM2711手册里,外设地址往往写成0x7E000000开头,那是SoC内部的地址表示法,ARM侧访问时必须映射到0xFE000000这个物理窗口。所以GPIO控制器的实际物理地址就是0xFE200000。

我见过不少代码把GPIO_BASE定义成0xFE000000,然后ioremap时再去加0x200000的偏移。这样没有问题,但要记得ioremap映射的是GPIO这一小段寄存器块,不是整个外设地址空间。我习惯直接定义:

#define GPIO_PHYS_BASE (0xFE000000UL + 0x200000UL) #define GPIO_BLOCK_SIZE 0x10000UL

映射0xFE200000到0xFE2000FF这一带就行了,大小给0x10000足够,余量留大一点没坏处。

2.2 六个寄存器讲透:GPFSEL、GPSET、GPCLR、GPLEV

BCM2711的GPIO控制器,日常驱动里主要碰六个寄存器组。它们的偏移相对GPIO基址是固定的:

寄存器偏移作用
GPFSEL00x00引脚0-9的功能选择,每个引脚占3位
GPFSEL10x04引脚10-19
GPFSEL20x08引脚20-29
......一直到GPFSEL5,覆盖引脚0-57
GPSET00x1C写1将引脚0-31置高,写0无效
GPSET10x20写1将引脚32-57置高,写0无效
GPCLR00x28写1将引脚0-31置低,写0无效
GPCLR10x2C写1将引脚32-57置低,写0无效
GPLEV00x34读取引脚0-31当前电平
GPLEV10x38读取引脚32-57当前电平

GPFSEL里每个引脚占3位,功能码的含义非常简单:000表示输入,001表示输出,其他值大多是对应的复用功能(比如I2C、UART、SPI走的是引脚复用的其他编码)。在驱动里做方向控制,本质上就是读写这3位。

GPSET和GPCLR是这组寄存器里最有价值的两个。它们和普通寄存器不同,写入1才生效,写入0不产生任何动作。这意味着你可以一次性把一整个位的集合丢进去,而不必担心把其他引脚的状态覆盖掉。批量同步操作的底层原理就在这里:先把所有需要拉高的引脚写进GPSET,再把所有需要拉低的引脚写进GPCLR,理论上两条指令就能完成任意组合的多引脚翻转。

2.3 为什么读写寄存器必须用ioread32/iowrite32

有人写驱动图省事,直接*(volatile u32 *)addr = val这样访问寄存器。短时间可能也能跑,但隐患很大。

第一,编译器可能优化掉连续写同一个地址的代码,也可能调整赋值顺序;第二,ARM架构下外设访问和普通内存访问不完全等价,很多总线设备要求严格按序操作。这两个问题加在一起,会出现一些非常诡异的现象:代码明明写了,示波器上却看不到波形,或者波形顺序是反的。

所以内核提供了ioread32、iowrite32这一组带内存屏障的接口。它们保证往外设寄存器写入的指令顺序和代码顺序一致,也防止编译器把两次写操作合并。你在驱动代码里看到这些函数,不要嫌啰嗦,这是内核在帮你擦屁股。

3. 多引脚管理框架:字符设备、ioctl、双层占用表

3.1 为什么用字符设备而不是注册一个gpiochip

正规的内核做法其实是实现一个gpiochip,把自己管理的一批引脚注册进内核的gpiolib框架。这样用户可以用libgpiod、可以用设备树,其他驱动也能通过标准接口来请求你的引脚。

听起来很美好,但对一个以“快速解决项目问题”为目标的驱动来说,这套流程太重了。gpiochip要实现的回调函数很多,而且用户态最终还是要通过/dev/gpiochipN来交互。我自己定义一个字符设备加几个ioctl,恰恰能按项目需要设计批量写、批量读这类专用命令,代码量小很多,调试也直观。

当然,如果你要做的是量产主板而不是项目原型,我建议还是老老实实走gpiolib路线。内核的好处是它管住并发和资源,坏处是你要先花时间学会它的规矩。本文先把字符设备路线讲透,理解了寄存器操作和多引脚占用管理之后,再看gpiolib就很容易了。

3.2 双层引脚占用表:全局表管冲突,私有表管归属

多进程同时打开一个设备文件时,最怕两个进程操作同一个引脚。怎么解决这个问题,我最后设计了两层占用表。

第一层是全局的一张pin_used[58]位图,标记某个引脚是不是已经被某个打开的设备文件“认领”了;第二层是每个文件打开时分配的一个上下文体pin_requested[58]位图,记录我这个fd申请过哪些引脚。

为什么需要两层?试想一下,如果没有第二层,一个进程申请了GPIO17,另一个进程用一个新的fd也能直接对这个引脚做方向设置和电平输出,因为驱动只看到全局表,不知道操作的是别人家的引脚。

有了私有表之后,每次ioctl操作引脚前,驱动都会先检查这个引脚是否在当前这个fd的请求表里,不在就返回-EPERM。只有请求过、并且没有被别人占用的引脚,才允许操作。这就是一个比较干净的权限模型:全局表防止抢,私有表防止偷。

这个设计看起来多花了一点内存,但省掉的是无数个“代码莫名其妙改错了别人的IO”的调试时间。

3.3 ioctl命令集设计

我最终定义了五个命令,全部放在一个公共头文件里,内核模块和用户测试程序共用同一份定义:

命令作用传参
GPIO_CMD_REQUEST申请一个引脚struct gpio_ctl
GPIO_CMD_SET_DIR设置引脚方向struct gpio_ctl
GPIO_CMD_WRITE单引脚写电平struct gpio_ctl
GPIO_CMD_READ单引脚读电平struct gpio_ctl
GPIO_CMD_BATCH_WRITE批量同步写多个引脚struct gpio_batch

单引脚的命令用struct gpio_ctl就够:pin是BCM编号(比如物理引脚11对应BCM GPIO17),dir的0是输入、1是输出,value是电平值。批量写则用struct gpio_batch,里面是一个64位的mask和64位的values,mask里某一位为1,表示要操作对应编号的引脚;values里的同一位表示这个引脚该输出高还是低。

64位位图听起来很炫,实际上树莓派4总共也就57个GPIO编号,64位完全够用,每一根对决一个引脚编号,直观得很。

4. 驱动代码逐段实现:从ioremap到批量写

4.1 公共头文件与核心数据结构

先看公共头文件,内核模块和测试程序都要包含它:

/* rpi_multi_gpio.h */ #ifndef RPI_MULTI_GPIO_H #define RPI_MULTI_GPIO_H #include <linux/ioctl.h> #define GPIO_IOC_MAGIC 'G' #define GPIO_CMD_REQUEST _IOW(GPIO_IOC_MAGIC, 1, struct gpio_ctl) #define GPIO_CMD_SET_DIR _IOW(GPIO_IOC_MAGIC, 2, struct gpio_ctl) #define GPIO_CMD_WRITE _IOW(GPIO_IOC_MAGIC, 3, struct gpio_ctl) #define GPIO_CMD_READ _IOWR(GPIO_IOC_MAGIC, 4, struct gpio_ctl) #define GPIO_CMD_BATCH_WRITE _IOW(GPIO_IOC_MAGIC, 5, struct gpio_batch) #define MAX_GPIO_PINS 58 struct gpio_ctl { int pin; int dir; int value; }; struct gpio_batch { unsigned long long mask; unsigned long long values; }; #endif

驱动内部的核心结构体长这样:

struct gpio_drv { void __iomem *base; /* ioremap 后的虚拟地址 */ struct mutex lock; /* 保护寄存器读写的互斥锁 */ dev_t devno; struct cdev cdev; struct class *cls; bool pin_used[MAX_GPIO_PINS]; /* 全局占用表 */ }; struct gpio_open_ctx { bool pin_requested[MAX_GPIO_PINS]; /* 当前 fd 的请求表 */ };

__iomem标记是内核里用来标识映射地址的,别漏掉,有些编译器检查会警告。mutex必须加,因为批量写虽然快,但多个进程并发操作同一个寄存器组时,读改写序列还是可能互相踩踏。

4.2 寄存器操作函数:方向、置高、置低、读电平

这些函数是整个驱动的底层地基。注释已经写清楚关键计算逻辑。

static void gpio_set_dir(struct gpio_drv *drv, int pin, int output) { u32 reg = GPFSEL0 + (pin / 10) * 4; u32 shift = (pin % 10) * 3; u32 val; val = ioread32(drv->base + reg); val &= ~(7U << shift); /* 清掉该引脚的3位功能码 */ val |= (output ? 1U : 0U) << shift; /* 000=输入 001=输出 */ iowrite32(val, drv->base + reg); } static void gpio_write_pin(struct gpio_drv *drv, int pin, int level) { u32 group = pin < 32 ? 0 : 1; u32 bit = BIT(pin & 31); u32 reg = (level ? GPSET0 : GPCLR0) + group * 4; iowrite32(bit, drv->base + reg); } static int gpio_read_pin(struct gpio_drv *drv, int pin) { u32 group = pin < 32 ? 0 : 1; u32 reg = GPLEV0 + group * 4; return !!(ioread32(drv->base + reg) & BIT(pin & 31)); }

几个细节要注意:

pin / 10算出的是GPFSEL寄存器编号,因为每个寄存器管10个引脚;pin % 10再乘以3,得到这个引脚在寄存器里的起始位。7U << shift是一个3位的掩码,先清掉原值,再写入新的功能码。

置高置低统一走GPSET和GPCLR,而不是直接写GPLEV。写GPSET时只写一个位,对应引脚编号取模32后的位置;如果引脚大于等于32,就去第二组寄存器。

4.3 open/release与引脚生命周期管理

打开设备文件时,分配一个上下文结构体,放在filp->private_data里:

static int gpio_open(struct inode *inode, struct file *filp) { struct gpio_open_ctx *ctx; ctx = kzalloc(sizeof(*ctx), GFP_KERNEL); if (!ctx) return -ENOMEM; filp->private_data = ctx; return 0; }

release时把当前fd申请过的引脚全部释放,并且复位为输入状态。这一步经常被初学者忽略,但非常关键:如果测试程序崩了,引脚还保持输出高电平,可能一直驱动着外设,甚至烧东西。

static int gpio_release(struct inode *inode, struct file *filp) { struct gpio_open_ctx *ctx = filp->private_data; int i; mutex_lock(&g_gpio.lock); for (i = 0; i < MAX_GPIO_PINS; i++) { if (ctx->pin_requested[i]) { gpio_set_dir(&g_gpio, i, 0); /* 恢复输入 */ g_gpio.pin_used[i] = false; /* 清除全局占用 */ } } mutex_unlock(&g_gpio.lock); kfree(ctx); return 0; }

release里恢复输入是一个安全设计。我实际项目里吃过大亏,测试程序被Ctrl+C中断后,某个输出脚还保持高电平,直接把后面的执行器驱动板顶坏了。从那以后,我所有GPIO驱动的release都默认做这个动作。

4.4 ioctl主流程:请求、方向、读写、批量写

ioctl是驱动和应用层交互的窗口。整体流程是:先从用户态拷贝参数,加锁,然后按命令类型执行操作,最后把需要回传的数据再拷回用户态。

static long gpio_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct gpio_open_ctx *ctx = filp->private_data; struct gpio_ctl ctl; struct gpio_batch batch; long ret = 0; int i; switch (cmd) { case GPIO_CMD_BATCH_WRITE: if (copy_from_user(&batch, (void __user *)arg, sizeof(batch))) return -EFAULT; break; default: if (copy_from_user(&ctl, (void __user *)arg, sizeof(ctl))) return -EFAULT; break; } mutex_lock(&g_gpio.lock); switch (cmd) { case GPIO_CMD_REQUEST: if (ctl.pin < 0 || ctl.pin >= MAX_GPIO_PINS) { ret = -EINVAL; break; } if (g_gpio.pin_used[ctl.pin]) { ret = -EBUSY; break; } ctx->pin_requested[ctl.pin] = true; g_gpio.pin_used[ctl.pin] = true; gpio_set_dir(&g_gpio, ctl.pin, 0); /* 默认输入,安全 */ break; case GPIO_CMD_SET_DIR: if (!ctx->pin_requested[ctl.pin]) { ret = -EPERM; break; } gpio_set_dir(&g_gpio, ctl.pin, ctl.dir ? 1 : 0); break; case GPIO_CMD_WRITE: if (!ctx->pin_requested[ctl.pin]) { ret = -EPERM; break; } gpio_write_pin(&g_gpio, ctl.pin, ctl.value ? 1 : 0); break; case GPIO_CMD_READ: if (!ctx->pin_requested[ctl.pin]) { ret = -EPERM; break; } ctl.value = gpio_read_pin(&g_gpio, ctl.pin); break; case GPIO_CMD_BATCH_WRITE: for (i = 0; i < MAX_GPIO_PINS; i++) { if (((batch.mask >> i) & 1ULL) && !ctx->pin_requested[i]) { ret = -EPERM; goto out_unlock; } } iowrite32((u32)(batch.values & batch.mask), g_gpio.base + GPSET0); iowrite32((u32)((~batch.values) & batch.mask), g_gpio.base + GPCLR0); iowrite32((u32)((batch.values >> 32) & (batch.mask >> 32)), g_gpio.base + GPSET1); iowrite32((u32)((~(batch.values >> 32)) & (batch.mask >> 32)), g_gpio.base + GPCLR1); break; default: ret = -ENOTTY; break; } out_unlock: mutex_unlock(&g_gpio.lock); if (cmd == GPIO_CMD_READ) { if (copy_to_user((void __user *)arg, &ctl, sizeof(ctl))) ret = -EFAULT; } return ret; }

这批代码里最值得停下来琢磨的是GPIO_CMD_BATCH_WRITE的实现。它没有循环去一个引脚一个引脚地写,而是充分利用GPSET写1才生效、写0无效的特性:

  • 需要拉高的引脚:在values里对应位是1,把它和mask按位与,写入GPSET
  • 需要拉低的引脚:在values里对应位是0,取反后再和mask按位与,写入GPCLR

这样4条iowrite32,就完成了指定集合内所有引脚的同步翻转。寄存器操作之间只有纳秒级的延迟,微秒级的中间态问题彻底消失了。

注意在REQUET时,我把默认方向设成了输入。理由很简单:申请一个引脚后,在用户明确设置方向之前,谁也不确定这个引脚连的是什么设备,贸然输出可能伤到硬件。

4.5 module_init/exit与设备节点创建

模块初始化的流程是固定的:先ioremap寄存器区域,再申请字符设备号,然后注册cdev,最后创建class和设备节点。

static int __init rpi_multi_gpio_init(void) { int ret; g_gpio.base = ioremap(GPIO_PHYS_BASE, GPIO_BLOCK_SIZE); if (!g_gpio.base) { pr_err("rpi_multi_gpio: ioremap failed\n"); return -ENOMEM; } mutex_init(&g_gpio.lock); ret = alloc_chrdev_region(&g_gpio.devno, 0, 1, "rpi_multi_gpio"); if (ret) goto err_iounmap; cdev_init(&g_gpio.cdev, &g_gpio_fops); g_gpio.cdev.owner = THIS_MODULE; ret = cdev_add(&g_gpio.cdev, g_gpio.devno, 1); if (ret) goto err_unregister; g_gpio.cls = class_create("rpi_multi_gpio"); if (IS_ERR(g_gpio.cls)) { ret = PTR_ERR(g_gpio.cls); goto err_cdev_del; } device_create(g_gpio.cls, NULL, g_gpio.devno, NULL, "rpi_multi_gpio"); pr_info("rpi_multi_gpio: loaded, base=%lx\n", (unsigned long)GPIO_PHYS_BASE); return 0; err_cdev_del: cdev_del(&g_gpio.cdev); err_unregister: unregister_chrdev_region(g_gpio.devno, 1); err_iounmap: iounmap(g_gpio.base); return ret; } static void __exit rpi_multi_gpio_exit(void) { device_destroy(g_gpio.cls, g_gpio.devno); class_destroy(g_gpio.cls); cdev_del(&g_gpio.cdev); unregister_chrdev_region(g_gpio.devno, 1); iounmap(g_gpio.base); pr_info("rpi_multi_gpio: unloaded\n"); } module_init(rpi_multi_gpio_init); module_exit(rpi_multi_gpio_exit); MODULE_LICENSE("GPL");

这里有一个内核版本差异一定要提:旧内核(5.x)里class_create需要传两个参数,比如class_create(THIS_MODULE, "rpi_multi_gpio");而从6.4左右开始,class_create只需要传一个名字参数,第二个参数被去掉了。树莓派官方OS的内核版本跨度很大,你编译如果报参数个数错误,按这个提示改一个参数就能解决,不用怀疑自己写错了。

5. 编译、加载与测试:从头到尾跑通

5.1 准备内核头文件与Makefile

在树莓派上编译内核模块,前提是装了和你当前运行内核完全匹配的头文件。先看一下内核版本:

uname -r

然后安装对应的内核头文件。树莓派OS一般可以通过软件源直接装,我这里以官方源常见的包名为例:

sudo apt install raspberrypi-kernel-headers

装完之后确认/lib/modules/$(uname -r)/build这个符号链接存在。如果不存在,建一个指向/usr/src下对应目录的链接。

Makefile非常简单:

obj-m := rpi_multi_gpio.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

把rpi_multi_gpio.c、rpi_multi_gpio.h和Makefile放在同一个目录,执行make。第一次编译内核模块可能会比较慢,因为要解析整个内核头文件树,正常现象。

5.2 测试程序:申请引脚、设置方向、批量同步翻转

测试程序是验证驱动最直接的工具。下面的示例会申请GPIO17和GPIO22作为输出,申请GPIO27作为输入,然后先单引脚操作,再批量同步翻转。

/* test_rpi_gpio.c */ #include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <errno.h> #include <string.h> #include "rpi_multi_gpio.h" int main(void) { int fd, ret; struct gpio_ctl ctl; struct gpio_batch batch; fd = open("/dev/rpi_multi_gpio", O_RDWR); if (fd < 0) { perror("open /dev/rpi_multi_gpio"); return 1; } ctl.pin = 17; ctl.dir = 1; ret = ioctl(fd, GPIO_CMD_REQUEST, &ctl); if (ret) printf("req17 failed: %s\n", strerror(errno)); ret = ioctl(fd, GPIO_CMD_SET_DIR, &ctl); if (ret) printf("dir17 failed: %s\n", strerror(errno)); ctl.pin = 22; ctl.dir = 1; ret = ioctl(fd, GPIO_CMD_REQUEST, &ctl); if (ret) printf("req22 failed: %s\n", strerror(errno)); ret = ioctl(fd, GPIO_CMD_SET_DIR, &ctl); if (ret) printf("dir22 failed: %s\n", strerror(errno)); ctl.pin = 27; ctl.dir = 0; ret = ioctl(fd, GPIO_CMD_REQUEST, &ctl); if (ret) printf("req27 failed: %s\n", strerror(errno)); ret = ioctl(fd, GPIO_CMD_SET_DIR, &ctl); if (ret) printf("dir27 failed: %s\n", strerror(errno)); /* 单引脚写 */ ctl.pin = 17; ctl.value = 1; ret = ioctl(fd, GPIO_CMD_WRITE, &ctl); printf("write17=1 ret=%d\n", ret); /* 批量同步:GPIO17拉低,GPIO22拉高 */ batch.mask = (1ULL << 17) | (1ULL << 22); batch.values = (1ULL << 22); ret = ioctl(fd, GPIO_CMD_BATCH_WRITE, &batch); printf("batch write ret=%d\n", ret); /* 读回GPIO27 */ ctl.pin = 27; ret = ioctl(fd, GPIO_CMD_READ, &ctl); printf("read27=%d ret=%d\n", ctl.value, ret); close(fd); return 0; }

这里注意我特意在部分ioctl失败后打印了错误字符串。很多新手跑第一个驱动时,ioctl调用失败了却完全不看返回值,然后一脸茫然地问“为什么灯不亮”。我的建议是:凡是调用内核接口,先把返回值打印出来看一遍。

测试程序里把所有ioctl的返回值都打出来,真跑起来哪个调用失败一目了然,不用瞎猜。

5.3 加载模块、查看节点、验证结果

编译完成后,加载模块:

sudo insmod rpi_multi_gpio.ko

看内核日志确认初始化成功:

dmesg | tail

正常情况下能看到rpi_multi_gpio: loaded。接着确认设备节点存在:

ls -l /dev/rpi_multi_gpio

设备节点由驱动里的device_create自动创建。如果没出现,检查是否有udev规则干扰,或者直接手动创建:

cat /proc/devices | grep rpi_multi_gpio sudo mknod /dev/rpi_multi_gpio c 主设备号 0

设备文件默认权限是root,测试时要么用sudo运行测试程序,要么临时授权:

sudo chmod 666 /dev/rpi_multi_gpio

然后执行测试程序:

./test_rpi_gpio

把GPIO17接一个LED(串联电阻),GPIO22接另一个LED,GPIO27接按键或跳线到3.3V。程序跑完后,两个LED应该并行翻转。如果手里有示波器或者逻辑分析仪,接在GPIO17和GPIO22上,能看到两路波形几乎同时变化。

6. 避坑清单:从“能用”到“敢在产品里用”

6.1 最容易踩的坑:基址、class_create、权限

这个驱动写下来,我跟朋友反复讲得最多的坑有这么几个,整理成一张表:

坑典型症状原因对策
基址照搬树莓派3insmod成功,写寄存器毫无反应Pi4外设基址是0xFE000000,Pi3是0x3F000000用GPIO_PHYS_BASE = 0xFE200000
class_create参数错误编译报参数个数不对新旧内核API不同根据内核版本调整一个或两个参数
/dev节点权限不足open返回Permission denied设备默认root所有chmod 666或写udev规则
ioctl失败但不检查返回值灯不亮,程序不报错调用失败被忽略每个ioctl都打印返回值/errno
引脚被pinctrl或其他驱动占用功能设置后很快被改回设备树里该引脚已经声明为复用功能避免操作已被占用的引脚

树莓派4和旧版的地址差异是“血泪之首”。如果你在网上搜了三天资料,一定要先确认你看到的代码是给树莓派几写的。我最初就是从Pi3的教程改出来的,调试了整整一个晚上才发现是基址问题。

6.2 并发与内存屏障:驱动不是应用层循环

很多应用层开发转内核的人,会下意识把驱动当成“跑得快一点的裸循环”。但驱动有自己的规则。

并发方面,我的驱动用了互斥锁。最开始我用的是自旋锁,后来想了想,ioctl本身在进程上下文,没必要自旋,互斥锁更温和,还允许睡眠。等哪天你要在中断上下文里读寄存器,再改回spinlock也不迟。

内存屏障方面,我前面已经提过,所有寄存器访问都必须走ioread32/iowrite32。这个规矩不只是风格问题,而是ARM处理器的内存模型所要求的。千万不要因为觉得“多写一个函数难看”就改用指针解引用,真出了问题时序错乱问题,示波器都救不了你。

6.3 进阶方向:gpiolib、中断、hrtimer

这套驱动跑通后,你可以继续往三个方向扩展。

第一个方向是迁移到gpiolib。把引脚注册成标准GPIO,配合设备树工作,这样其他驱动和用户态工具能统一管理。代价是要处理更多回调函数,并且批量同步操作的优势会被削弱一些。

第二个方向是加中断。树莓派4上每个GPIO都可以作为中断源,request_irq之后可以捕获边沿事件。配合一个环形缓冲区,把带时间戳的事件上报给用户态,就是一个很好的输入采集驱动。但要注意,中断回调里不能调用可能睡眠的函数,copy_to_user这类工作要放到内核线程或者workqueue里做。

第三个方向是精确定时。如果你要输出PWM或者步进电机的脉冲序列,单纯靠ioctl手动翻转是不够的。用hrtimer在内核里定时翻转引脚,精度能到微秒级,这才是内核驱动真正的优势所在。

最后补一句我自己的使用心得。这套驱动最初是为了运动控制板写的,但真正让我觉得“值”的,并不是它比用户态快了多少,而是它把多引脚的操作和生命周期管住了:每个fd知道自己申请了哪些引脚,批量写在纳秒级完成同步,release时干净利落地恢复所有引脚。树莓派4上的Linux GPIO资料很多,但大部分停留在应用层,或者沿用老基址,真正自己动手写一遍ioremap和批量写之后,你才会理解为什么内核文档不厌其烦地强调内存屏障和资源管理。如果你的项目也开始涉及多引脚同步、精确时序或者输入中断,这份驱动骨架值得留下来当作起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询