实战指南:树莓派Linux内核GPIO驱动开发从零到精通
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);
这行简单的代码背后,发生了一场跨越“世界”的旅行:
- 用户空间:你的程序执行
open系统调用。这实际上触发了一个软中断(中断号0x80),就像按下了通往内核世界的门铃。 - 陷入内核:CPU切换到特权模式,跳转到内核中固定的系统调用处理入口(
sys_call)。 - 虚拟文件系统(VFS)层:内核的
sys_open函数被调用。VFS是Linux一个伟大的抽象层,它给上层提供了一个统一的文件操作接口,不管下层是磁盘文件、USB设备还是我们的GPIO,都用open、read、write来访问。sys_open会根据路径/dev/pin4找到其对应的inode节点,里面就藏着设备号。 - 查找驱动:内核拿着这个设备号,去我们前面说的驱动链表里挨个比对,就像用身份证去人口系统里查人一样。
- 执行驱动:找到了!链表节点里有一个关键的结构体
file_operations,里面是一堆函数指针,比如.open、.read、.write。内核就会调用我们驱动里自己实现的pin4_open函数。 - 操作硬件:在我们的
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内核源码目录下进行。因为驱动代码里包含了大量内核头文件,引用了内核的数据结构,它必须知道当前内核的详细配置。
准备工作:
- 确保你有一份树莓派内核源码。如果你之前为树莓派编译过内核,那就有现成的。如果没有,可以从树莓派基金会GitHub仓库获取对应版本。
- 将我们写的
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
有两种解决方法:
sudo ./pin4test:用root权限运行。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组):
- GPFSEL0 (GPIO Function Select 0):功能选择寄存器。每个GPIO引脚都可以复用为多种功能(输入、输出、复用功能如UART、SPI等)。这个寄存器的每3个比特控制一个引脚的功能。例如,GPIO4由
GPFSEL0寄存器的[14:12]位控制。b001表示输出,b000表示输入。 - GPSET0 (GPIO Pin Output Set 0):输出置位寄存器。向这个寄存器的某一位写1,对应的GPIO引脚就会输出高电平。例如,向
GPSET0的[4]位写1,GPIO4就变高。写0无效。 - 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 完整实战与硬件验证
将上述所有代码整合到最初的框架中,重新编译驱动和用户程序,传到树莓派。
- 加载新驱动:
sudo insmod pin4drive.ko。 - 连接硬件:用杜邦线将树莓派的GPIO4(物理引脚第7号)连接到一个LED(记得串联一个220Ω-1kΩ的电阻)再到GND。
- 运行测试程序:你需要修改用户程序,让它从命令行或scanf读取一个整数(0或1),然后调用write。运行程序,输入1,LED应该点亮;输入0,LED应该熄灭。
- 终极验证:除了看LED,还可以在树莓派上运行
gpio readall命令(需要安装wiringPi,仅用于查看)。当你的驱动设置成功后,你会看到GPIO4那一行的Mode显示为OUT,V列会随着你的命令在0和1之间切换。同时,dmesg里也会有你自定义的打印信息。
当LED随着你编写的驱动亮起和熄灭的那一刻,你就完成了从应用层到内核层,再到硬件层的完整控制闭环。你亲手实现了一个简化版的digitalWrite。这个过程里对芯片手册的查阅、对地址映射的理解、对位操作的掌握,就是嵌入式Linux驱动开发最核心的实战能力。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)