1. 为什么你需要亲手写一个GPIO驱动?

很多朋友玩树莓派都是从点亮一个LED开始的,用的往往是wiringPi或者RPi.GPIO这样的库。一行代码digitalWrite(4, HIGH),小灯就亮了,感觉特别简单,对吧?这就像你开一辆自动挡的车,踩油门就走,很方便。但如果你只会开自动挡,有一天给你一辆手动挡的车,或者让你去造一辆车,你可能就束手无策了。

wiringPi库就是那个“自动挡”。它帮你封装好了所有底层复杂的硬件操作,你只需要调用简单的函数。但你想过没有,这个库是怎么让那个小小的物理引脚电平发生变化的?它的底层到底做了什么?如果你未来做的项目不是树莓派,而是一块全新的、没有现成库的嵌入式Linux板子,你该怎么办?

这就是学习编写Linux内核驱动的核心意义:掌握从软件到硬件的完整控制权。通过自己写驱动,你会真正理解“一切皆文件”在Linux里是如何实现的,你会明白用户程序的一个write()调用是如何穿越层层抽象,最终变成芯片寄存器里一个比特位的翻转。这个过程,就是嵌入式Linux开发者的“内功”。我刚开始学的时候也觉得内核驱动深不可测,但当你亲手把代码跑通,看到LED随着你的指令明灭时,那种“我完全掌控了硬件”的成就感,是任何库函数都给不了的。

所以,这篇指南的目标,就是带你从“会用车”到“会修车、会造车”。我们不依赖任何第三方库,只依靠Linux内核源码和树莓派的芯片手册,从零构建一个能控制GPIO的字符设备驱动。我会把我自己踩过的坑、调试时的心得都揉进去,让你不仅“照做能成功”,更能“理解为什么这么做”。

2. 驱动开发第一课:理解“一切皆文件”

在开始写代码前,我们必须把Linux驱动最基本的概念搞清楚。这就像盖房子要先打地基,概念通了,后面写代码才不会迷糊。

2.1 设备号:硬件的“身份证”

在Linux里,鼠标、键盘、U盘,甚至我们即将要操作的GPIO引脚,在系统看来都是“文件”。你可以在/dev目录下找到它们,比如/dev/ttyUSB0(串口)、/dev/sda1(硬盘分区)。那么问题来了,系统打开/dev/pin4和打开/dev/ttyUSB0,怎么知道该去操作哪个硬件呢?

答案就是设备号。你可以用ls -l /dev看看,每个设备文件前面都有两个数字,像231, 0这样。这就是设备号,它由主设备号和次设备号组成。

  • 主设备号:用来区分不同类型的设备。比如所有键盘可能共享一个主设备号,所有GPIO驱动共享另一个主设备号。它就像汽车的品牌。
  • 次设备号:用来区分同一类型下的不同个体。比如你的树莓派上有几十个GPIO引脚,每个引脚对应的驱动实例可以用不同的次设备号区分。它就像同品牌下的具体车型。

内核内部维护着一个巨大的驱动链表,所有加载的驱动都按设备号挂在这个链表上。当你打开一个设备文件时,内核就是根据文件对应的设备号,去这个链表里找到对应的驱动函数来执行的。我们写驱动的核心工作之一,就是向这个链表“注册”我们自己写的驱动,并告诉内核我们的“身份证”(设备号)是多少。

2.2 从用户空间到硬件:一次open的奇幻漂流

让我们用一个具体的例子,把上层应用到底层硬件的路径串起来。假设我们写了一个简单的用户程序:

int fd = open("/dev/pin4", O_RDWR);

这行简单的代码背后,发生了一场跨越“世界”的旅行:

  1. 用户空间:你的程序执行open系统调用。这实际上触发了一个软中断(中断号0x80),就像按下了通往内核世界的门铃。
  2. 陷入内核:CPU切换到特权模式,跳转到内核中固定的系统调用处理入口(sys_call)。
  3. 虚拟文件系统(VFS)层:内核的sys_open函数被调用。VFS是Linux一个伟大的抽象层,它给上层提供了一个统一的文件操作接口,不管下层是磁盘文件、USB设备还是我们的GPIO,都用open、read、write来访问。sys_open会根据路径/dev/pin4找到其对应的inode节点,里面就藏着设备号。
  4. 查找驱动:内核拿着这个设备号,去我们前面说的驱动链表里挨个比对,就像用身份证去人口系统里查人一样。
  5. 执行驱动:找到了!链表节点里有一个关键的结构体file_operations,里面是一堆函数指针,比如.open、.read、.write。内核就会调用我们驱动里自己实现的pin4_open函数。
  6. 操作硬件:在我们的pin4_open函数里,我们终于可以“为所欲为”了——通过读写芯片的寄存器来配置GPIO引脚。对于树莓派,就是去设置GPFSEL寄存器某几位为001,把这个引脚设为输出模式。

这个过程听起来复杂,但你想,这其实和单片机编程P1=0xFF;的本质是一样的,都是操作寄存器。只不过在庞大的Linux系统中,为了安全、管理和抽象,多走了几步路。理解这条路,你就理解了Linux驱动模型的精髓。

3. 搭建你的第一个驱动框架

理论说再多不如动手。我们现在就来搭建一个最简单的字符设备驱动框架。这个框架像是一个模板,以后你写任何字符设备驱动(比如按键、ADC、PWM),都可以在这个基础上修改。

3.1 必不可少的“八股文”:驱动框架代码

驱动代码和普通C程序有点不一样,它必须遵循内核规定的格式,有些“八股文”是省不掉的。别怕,我们一段段看。

#include <linux/fs.h>        // file_operations 结构体
#include <linux/module.h>    // module_init, module_exit 宏
#include <linux/init.h>      // __init, __exit 宏
#include <linux/device.h>    // class_create, device_create
#include <linux/uaccess.h>   // copy_from_user
#include <linux/types.h>     // dev_t 类型
#include <asm/io.h>          // ioremap, iounmap

// 1. 定义一些全局变量,用来记录驱动信息
static struct class *pin4_class;
static struct device *pin4_class_dev;
static dev_t devno;                 // 设备号变量
static int major = 231;             // 我们选定的主设备号,只要不冲突就行
static int minor = 0;               // 次设备号从0开始
static char *module_name = "pin4";  // 驱动名字

// 2. 实现 open 函数:当用户层调用 open("/dev/pin4", ...) 时,这个函数被调用
static int pin4_open(struct inode *inode, struct file *file)
{
    printk("pin4_open called!\n"); // 内核用 printk 打印,信息在 dmesg 里看
    return 0; // 返回0表示成功
}

// 3. 实现 write 函数:当用户层调用 write(fd, ...) 时,这个函数被调用
static ssize_t pin4_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
    printk("pin4_write called!\n");
    return 1; // 返回实际写入的字节数,这里先写死返回1
}

// 4. 最核心的结构体:file_operations
// 它像一个“菜单”,告诉内核我们这个驱动支持哪些操作(函数)
static struct file_operations pin4_fops = {
    .owner = THIS_MODULE, // 固定写法
    .open = pin4_open,    // 将我们实现的函数挂接到“菜单”上
    .write = pin4_write,
    // 这里还可以添加 .read, .release 等,我们暂时用不到
};

// 5. 驱动的“安装”函数(模块加载时自动执行)
int __init pin4_drv_init(void)
{
    int ret;
    printk("Pin4 driver init start...\n");

    // 步骤1:创建设备号(把主、次设备号打包)
    devno = MKDEV(major, minor);

    // 步骤2:注册字符设备驱动!这是向内核链表报到的关键一步
    ret = register_chrdev(major, module_name, &pin4_fops);
    if (ret < 0) {
        printk("register chrdev failed!\n");
        return ret;
    }

    // 步骤3:自动创建设备文件节点(就是在/dev下生成pin4文件)
    // 有了这俩函数,就不用手动mknod了,非常方便
    pin4_class = class_create(THIS_MODULE, "pin4_class");
    pin4_class_dev = device_create(pin4_class, NULL, devno, NULL, module_name);

    printk("Pin4 driver init success!\n");
    return 0;
}

// 6. 驱动的“卸载”函数(模块移除时自动执行)
void __exit pin4_drv_exit(void)
{
    // 卸载是初始化的逆序
    device_destroy(pin4_class, devno); // 删除 /dev/pin4 文件
    class_destroy(pin4_class);         // 删除类
    unregister_chrdev(major, module_name); // 从内核驱动链表里把自己摘掉
    printk("Pin4 driver exit.\n");
}

// 7. 告诉内核哪个是入口,哪个是出口
module_init(pin4_drv_init);
module_exit(pin4_drv_exit);

// 8. 声明许可证(GPL是必须的,否则可能无法加载)
MODULE_LICENSE("GPL v2");

这段代码虽然长,但核心逻辑就三块:定义操作函数(open/write)、填好file_operations菜单、在init函数里注册驱动。其他都是固定套路。我建议你先不要纠结每一行的细节,把代码复制下来,成功编译运行一次,建立信心。后面我们再回头细嚼。

3.2 编写一个用户程序来测试

驱动写好了,我们得写个“用户”来调用它。这个程序就太简单了,它根本不知道底层是GPIO,它只是像操作普通文件一样去操作/dev/pin4。

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
    int fd;
    char buf = '1'; // 我们打算写入一个字符'1'

    // 1. 打开设备文件
    fd = open("/dev/pin4", O_RDWR);
    if (fd < 0) {
        printf("Error: cannot open /dev/pin4\n");
        perror("Reason");
        return -1;
    }
    printf("Open /dev/pin4 success! fd = %d\n", fd);

    // 2. 向设备写入数据
    int n = write(fd, &buf, 1); // 写入1个字节
    printf("Write %d byte(s) to driver.\n", n);

    // 3. 关闭文件(这里省略了,程序结束会自动关闭)
    close(fd);
    return 0;
}

这个程序的作用就是触发我们驱动里的pin4_open和pin4_write函数,让内核的printk打印出信息。这是验证驱动框架是否正常工作的第一步。

4. 编译、加载与测试:让驱动跑起来

现在我们有了一对“兄弟”:内核驱动模块(.ko文件)和用户测试程序。接下来就是让它们在树莓派上团聚并工作。

4.1 驱动模块的编译:需要内核源码树

编译驱动和编译普通程序有个巨大区别:它必须在Linux内核源码目录下进行。因为驱动代码里包含了大量内核头文件,引用了内核的数据结构,它必须知道当前内核的详细配置。

准备工作:

  1. 确保你有一份树莓派内核源码。如果你之前为树莓派编译过内核,那就有现成的。如果没有,可以从树莓派基金会GitHub仓库获取对应版本。
  2. 将我们写的pin4drive.c文件,放到内核源码的drivers/char/目录下。字符设备驱动通常放这里,当然你也可以自己新建目录并修改对应的Makefile。

修改Makefile: 进入drivers/char/目录,编辑Makefile文件。你会在里面看到很多类似obj-y += ...或obj-m += ...的行。我们需要添加一行,告诉编译系统要编译我们的模块。

obj-m += pin4drive.o

obj-m表示编译成可加载模块(.ko),而不是直接编译进内核镜像(obj-y)。模块化的好处是可以动态加载和卸载,方便调试。

执行编译: 回到内核源码的根目录,执行模块编译命令。注意这里需要用到交叉编译工具链(因为Ubuntu是x86,树莓派是ARM)。

ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL=kernel7 make modules
  • ARCH=arm:指定架构为ARM。
  • CROSS_COMPILE=...:指定交叉编译器前缀。
  • KERNEL=kernel7:针对树莓派2/3/Zero等(CPU是BCM2835/6/7)。
  • make modules:只编译模块,速度比编译整个内核快很多。

编译成功后,在drivers/char/目录下就能找到pin4drive.ko文件。把它通过scp传到树莓派上。

4.2 用户程序的交叉编译

在Ubuntu上,用交叉编译器编译我们的测试程序:

arm-linux-gnueabihf-gcc pin4test.c -o pin4test -static

-static选项是静态链接,这样编译出来的可执行文件不依赖树莓派上的动态库,直接就能运行,避免库版本问题。同样,用scp传到树莓派。

4.3 在树莓派上加载驱动并测试

登录到你的树莓派,开始激动人心的测试。

第一步:加载驱动模块

sudo insmod pin4drive.ko

insmod就是install module。如果成功,不会有任何输出(Linux哲学:没有消息就是好消息)。你可以用lsmod命令查看所有已加载的模块,应该能看到pin4drive。

第二步:检查设备文件 加载驱动后,我们的device_create函数应该已经在/dev下创建了pin4文件。用ls -l /dev/pin4看看。你应该能看到类似crw------- 1 root root 231, 0 ...的输出。注意看231, 0,这就是我们代码里设定的设备号!这说明驱动注册和设备创建都成功了。

第三步:运行用户程序 直接运行可能会遇到权限问题,因为默认创建的设备文件只有root能读写。

./pin4test
# 很可能报错:Permission denied

有两种解决方法:

  1. sudo ./pin4test:用root权限运行。
  2. sudo chmod 666 /dev/pin4:修改设备文件权限,让所有用户都能读写。(调试阶段推荐这个,方便)

再次运行./pin4test,你应该能在终端看到“Open success”和“Write 1 byte”的打印。

第四步:查看内核日志——驱动工作的证据 用户程序打印了,但驱动里的printk信息去哪了?它们在内核日志里。用dmesg命令查看:

dmesg | tail -10  # 查看最后10条内核信息

或者用dmesg | grep pin4过滤。你应该能看到类似这样的输出:

[  123.456789] Pin4 driver init start...
[  123.456790] Pin4 driver init success!
[  123.567890] pin4_open called!
[  123.567891] pin4_write called!

看到这些,就证明你的用户程序成功穿越了用户与内核的边界,调用了你写的驱动函数!虽然现在这个驱动除了打印什么也没做,但整个通信链路已经打通了。这是万里长征最关键的第一步,值得庆祝一下。

5. 直击硬件:操作GPIO寄存器

框架通了,接下来就是让驱动真正“驱动”硬件。我们要让pin4_open函数把物理引脚4设置为输出模式,在pin4_write函数里根据用户传入的值,输出高电平或低电平。这就要和芯片手册打交道了。

5.1 读懂树莓派芯片手册的GPIO章节

树莓派的核心是一颗博通(Broadcom)的SOC,比如BCM2835/6/7。我们需要去树莓派基金会官网下载对应的芯片手册(Datasheet)。别被英文和厚度吓到,我们只看GPIO相关部分。

你需要找到并理解三个最关键的寄存器(以GPIO分组0为例,因为GPIO4属于第0组):

  1. GPFSEL0 (GPIO Function Select 0):功能选择寄存器。每个GPIO引脚都可以复用为多种功能(输入、输出、复用功能如UART、SPI等)。这个寄存器的每3个比特控制一个引脚的功能。例如,GPIO4由GPFSEL0寄存器的[14:12]位控制。b001表示输出,b000表示输入。
  2. GPSET0 (GPIO Pin Output Set 0):输出置位寄存器。向这个寄存器的某一位写1,对应的GPIO引脚就会输出高电平。例如,向GPSET0的[4]位写1,GPIO4就变高。写0无效。
  3. GPCLR0 (GPIO Pin Output Clear 0):输出清除寄存器。向这个寄存器的某一位写1,对应的GPIO引脚就会输出低电平。例如,向GPCLR0的[4]位写1,GPIO4就变低。写0无效。

操作逻辑总结:

  • 配置为输出:在pin4_open中,将GPFSEL0的[14:12]位设置为001。
  • 输出高电平:在pin4_write中,当用户传入1时,向GPSET0的[4]位写1。
  • 输出低电平:在pin4_write中,当用户传入0时,向GPCLR0的[4]位写1。

5.2 地址映射:连接软件与硬件的桥梁

知道了要操作哪个寄存器,但我们在C代码里能直接写0x3f200000 = 0x1000吗?绝对不能。0x3f200000是物理地址,是CPU地址总线看到的真实地址。而Linux内核使用了虚拟内存,所有代码(包括内核代码)操作的都是虚拟地址。直接访问物理地址会导致段错误。

所以,我们需要一个“翻译官”,把物理地址映射成内核可以访问的虚拟地址。这个翻译官就是ioremap函数。

void *ioremap(unsigned long phys_addr, unsigned long size);
  • phys_addr:要映射的起始物理地址。
  • size:要映射的区域大小(字节)。

对于树莓派3B+(BCM2837),GPIO外设的物理基地址是0x3f000000,GPIO模块的偏移是0x200000,所以GPIO的物理基地址是0x3f200000。GPFSEL0的偏移是0x00,GPSET0偏移0x1c,GPCLR0偏移0x28。

我们在驱动的init函数里完成映射:

volatile unsigned int *GPFSEL0, *GPSET0, *GPCLR0; // 用指针保存映射后的虚拟地址

int __init pin4_drv_init(void) {
    // ... 其他初始化代码 ...
    GPFSEL0 = (volatile unsigned int *)ioremap(0x3f200000, 4); // 映射4字节
    GPSET0 = (volatile unsigned int *)ioremap(0x3f20001C, 4);
    GPCLR0 = (volatile unsigned int *)ioremap(0x3f200028, 4);
    // 一定要检查映射是否成功(为简洁省略),失败则goto error
    // ...
}

注意volatile关键字,它告诉编译器“这个指针指向的内容可能会被硬件意外改变,不要做激进的优化(比如缓存读取的值)”,每次都必须从内存中重新读取。操作硬件寄存器必须加volatile。

在驱动的exit函数里,要用iounmap解除映射,释放资源。

5.3 位操作:精准控制寄存器

我们不能直接*GPFSEL0 = 0x1000;,因为这会覆盖整个32位寄存器,影响到同组的其他GPIO(GPIO0~GPIO9)。我们需要用位操作,只修改我们关心的位。

将GPIO4设置为输出(操作GPFSEL0[14:12]):

*GPFSEL0 &= ~(0x6 << 12); // 第一步:将第13、14位清零
*GPFSEL0 |=  (0x1 << 12); // 第二步:将第12位置1
  • 0x6是二进制110,左移12位后,110正好对准[14:12]。取反(~)后与寄存器原值做“与”操作,就能把这两位清零,其他位不变。
  • 0x1是二进制001,左移12位后,与寄存器原值做“或”操作,就把第12位置1了。两步结合,结果就是[14:12]=001。

根据用户命令设置高低电平: 我们需要修改pin4_write函数,从用户空间获取数据。

static ssize_t pin4_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
    int user_cmd;
    // 从用户空间拷贝数据到内核空间
    if (copy_from_user(&user_cmd, buf, sizeof(user_cmd))) {
        printk("copy_from_user failed!\n");
        return -EFAULT;
    }

    if (user_cmd == 1) {
        printk("Set GPIO4 HIGH\n");
        *GPSET0 |= (1 << 4); // 第4位置1,输出高电平
    } else if (user_cmd == 0) {
        printk("Set GPIO4 LOW\n");
        *GPCLR0 |= (1 << 4); // 第4位置1,输出低电平
    } else {
        printk("Invalid command: %d\n", user_cmd);
        return -EINVAL;
    }
    return sizeof(user_cmd); // 返回成功写入的字节数
}

copy_from_user是内核提供的安全拷贝函数,用于从用户空间缓冲区拷贝数据到内核空间。必须使用它,不能直接指针访问。

5.4 完整实战与硬件验证

将上述所有代码整合到最初的框架中,重新编译驱动和用户程序,传到树莓派。

  1. 加载新驱动:sudo insmod pin4drive.ko。
  2. 连接硬件:用杜邦线将树莓派的GPIO4(物理引脚第7号)连接到一个LED(记得串联一个220Ω-1kΩ的电阻)再到GND。
  3. 运行测试程序:你需要修改用户程序,让它从命令行或scanf读取一个整数(0或1),然后调用write。运行程序,输入1,LED应该点亮;输入0,LED应该熄灭。
  4. 终极验证:除了看LED,还可以在树莓派上运行gpio readall命令(需要安装wiringPi,仅用于查看)。当你的驱动设置成功后,你会看到GPIO4那一行的Mode显示为OUT,V列会随着你的命令在0和1之间切换。同时,dmesg里也会有你自定义的打印信息。

当LED随着你编写的驱动亮起和熄灭的那一刻,你就完成了从应用层到内核层,再到硬件层的完整控制闭环。你亲手实现了一个简化版的digitalWrite。这个过程里对芯片手册的查阅、对地址映射的理解、对位操作的掌握,就是嵌入式Linux驱动开发最核心的实战能力。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐