Linux 驱动开发入门:从最简单的 hello 驱动到硬件交互
🎉 写给未来的自己和领导 :本文是 Linux 驱动开发的 入门级保姆教程,从零开始搭建驱动框架,逐行解释代码,记录每一个踩过的坑。无论你是刚接触内核编程,还是想快速上手 GPIO 中断,都能在这里找到清晰的思路和可复现的步骤。
📚 目录
- 引言:驱动是什么?
- 驱动的基本框架 ------ 一切皆文件
- 实战:第一个 hello 驱动
- 3.1 完整的驱动源码(带详细注释)
- 3.2 编译驱动 ------ Makefile 解析
- 3.3 上机测试 ------ 从 insmod 到读写 /dev/hello
- 3.4 常见错误与解决方法
- 驱动与 APP 的数据传输 ------ copy_to/from_user
- 驱动提供能力,不提供策略 ------ 四种访问方式
- GPIO 子系统 ------ 用编号控制引脚
- 6.1 确定 GPIO 编号的方法
- 6.2 基于 sysfs 操作 GPIO(用户态验证)
- 6.3 GPIO 子系统的内核函数
- 中断处理 ------ 让驱动响应硬件事件
- 7.1 中断申请流程
- 7.2 按键驱动框架(含定时器防抖)
- 总结与后续学习建议
1. 引言:驱动是什么?

一句话白话 :驱动就是 内核中的"翻译官"。APP 说"我要读数据",驱动把它翻译成硬件能懂的指令(拉高拉低 GPIO、读写寄存器),然后把硬件返回的结果再翻译回 APP 能理解的数据。
生活化类比 🏢:
- APP = 公司老板,只会说"我要营业额"。
- 驱动 = 财务经理,知道怎么查数据库、算报表,最后交给老板一个数字。
- 硬件 = 服务器,只接受底层指令。
在 Linux 中,驱动最终以 .ko(kernel object)文件存在,可以动态加载和卸载。
2. 驱动的基本框架 ------ 一切皆文件
Linux 的设计哲学是 "一切皆文件" 。硬件设备也被抽象成文件(比如 /dev/hello),APP 使用标准的 open / read / write / ioctl / close 来访问。
2.1 核心结构体 file_operations
这个结构体是一张 函数跳转表,告诉内核:当 APP 对设备文件调用某个系统调用时,应该执行驱动的哪个函数。
c
static const struct file_operations hello_drv = {
.owner = THIS_MODULE,
.open = hello_open,
.read = hello_read,
.write = hello_write,
.release = hello_release,
};
2.2 驱动编写四步曲
- 构造
file_operations:填充分发函数。 - 注册字符设备 :
register_chrdev告诉内核这个驱动的主设备号。 - 入口函数:模块加载时执行,完成注册和自动创建设备节点。
- 出口函数:模块卸载时执行,清理资源。
2.3 自动创建设备节点 ------ class 和 device
传统方式需要手动 mknod 创建设备节点,太麻烦。现代驱动会这样做:
c
hello_class = class_create(THIS_MODULE, "hello_class");
device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello");
class_create在/sys/class下创建一个类。device_create会在/dev下自动生成/dev/hello节点。
3. 实战:第一个 hello 驱动
我们的目标是:写一个驱动,提供一个 /dev/hello 设备,APP 可以向它写入字符串,再读出来。
3.1 步骤

添加02.1_hello_transfer

如果你的vi compile_commands.json内容很少的话
那应该将"cc"改为"arm-buildroot-linux-gnueabihf-gcc"
3.1 完整的驱动源码(带详细注释)
文件:hello_drv.c
c
#include <linux/module.h>
#include <linux/fs.h> // file_operations, register_chrdev
#include <linux/uaccess.h> // copy_to_user, copy_from_user
#include <linux/device.h> // class_create, device_create
static int major; // 主设备号,由内核自动分配
static unsigned char hello_buf[100]; // 存储 APP 写入的数据
// 当 APP 调用 open("/dev/hello") 时,这个函数会被执行
static int hello_open(struct inode *node, struct file *filp)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
return 0;
}
// 当 APP 调用 read() 时执行
static ssize_t hello_read(struct file *filp, char __user *buf,
size_t size, loff_t *offset)
{
unsigned long len = size > 100 ? 100 : size;
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
// 将内核空间的数据拷贝到用户空间
if (copy_to_user(buf, hello_buf, len)) {
return -EFAULT;
}
return len;
}
// 当 APP 调用 write() 时执行
static ssize_t hello_write(struct file *filp, const char __user *buf,
size_t size, loff_t *offset)
{
unsigned long len = size > 100 ? 100 : size;
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
// 将用户空间的数据安全拷贝到内核空间
if (copy_from_user(hello_buf, buf, len)) {
return -EFAULT;
}
return len;
}
// 当 APP 调用 close() 时执行
static int hello_release(struct inode *node, struct file *filp)
{
printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
return 0;
}
// 定义 file_operations 结构体,并初始化各个成员
static const struct file_operations hello_drv = {
.owner = THIS_MODULE,
.open = hello_open,
.read = hello_read,
.write = hello_write,
.release = hello_release,
};
// 模块加载时执行的入口函数
static int __init hello_init(void)
{
// 注册字符设备,动态分配主设备号
major = register_chrdev(0, "100ask_hello", &hello_drv);
if (major < 0) {
printk("register_chrdev failed\n");
return major;
}
// 创建一个类,用于自动生成设备节点
hello_class = class_create(THIS_MODULE, "hello_class");
if (IS_ERR(hello_class)) {
printk("class_create failed\n");
unregister_chrdev(major, "100ask_hello");
return PTR_ERR(hello_class);
}
// 在 /dev 下创建设备节点 /dev/hello
device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello");
printk("hello driver loaded, major=%d\n", major);
return 0;
}
// 模块卸载时执行的出口函数
static void __exit hello_exit(void)
{
device_destroy(hello_class, MKDEV(major, 0));
class_destroy(hello_class);
unregister_chrdev(major, "100ask_hello");
printk("hello driver unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
代码解释(为什么要这么做):
__init和__exit:告诉内核这些函数只在加载/卸载时使用,执行完后可以释放内存。register_chrdev(0, "name", &fops):第一个参数 0 表示让内核自动分配主设备号。返回的主设备号保存在major中,用于后续创建设备节点。copy_to_user/copy_from_user:绝不能直接使用memcpy拷贝用户空间的数据,因为用户空间可能非法或不在当前进程地址空间。这些函数会做安全检查。IS_ERR判断 :class_create失败时返回的不是 NULL,而是一个错误码指针,需要用IS_ERR判断。
3.2 编译驱动 ------ Makefile 解析
在同一目录下创建 Makefile:
makefile
# 指定内核源码路径(根据你的开发板修改)
KERN_DIR = /home/book/100ask_imx6ull-sdk/Linux-4.9.88
all:
make -C $(KERN_DIR) M=$(PWD) modules
$(CROSS_COMPILE)gcc -o hello_test hello_test.c
clean:
make -C $(KERN_DIR) M=$(PWD) modules clean
rm -rf hello_test
obj-m += hello_drv.o
解释:
-C $(KERN_DIR):切换到内核源码目录,读取它的顶层 Makefile。M=$(PWD):告诉内核回到当前目录编译模块。obj-m += hello_drv.o:表示将hello_drv.c编译成hello_drv.ko模块。- 最后一行编译测试程序
hello_test.c,使用交叉编译工具链(环境变量已提前设置)。
执行 make 后,会生成 hello_drv.ko 和 hello_test。
3.3 上机测试 ------ 从 insmod 到读写 /dev/hello
步骤 1:将文件推送到开发板
bash
adb push hello_drv.ko /root
adb push hello_test /root
步骤 2:加载驱动
bash
adb shell
cd /root
insmod hello_drv.ko
加载成功后,内核会打印 hello driver loaded, major=...。此时可以查看设备节点:
bash
ls -l /dev/hello # 应该存在
cat /proc/devices | grep hello # 查看主设备号
步骤 3:运行测试程序
测试程序 hello_test.c 源码:
c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv)
{
int fd;
int len;
char buf[100];
if (argc < 2) {
printf("Usage: %s <dev> [string]\n", argv[0]);
return -1;
}
fd = open(argv[1], O_RDWR);
if (fd < 0) {
printf("can not open file %s\n", argv[1]);
return -1;
}
if (argc == 3) {
// 写操作:将命令行参数写入驱动
len = write(fd, argv[2], strlen(argv[2]) + 1); // +1 包含 '\0'
printf("write ret = %d\n", len);
} else {
// 读操作:从驱动读取之前写入的字符串
len = read(fd, buf, 100);
buf[99] = '\0';
printf("read str : %s\n", buf);
}
close(fd);
return 0;
}
执行写操作:
bash
./hello_test /dev/hello 100ask
# 输出:write ret = 7
执行读操作:
bash
./hello_test /dev/hello
# 输出:read str : 100ask
步骤 4:查看内核打印信息
在另一个终端(或串口)执行 dmesg | tail,可以看到 hello_open, hello_write, hello_read, hello_release 的打印。
步骤 5:卸载驱动
bash
rmmod hello_drv
ls /dev/hello # 应该已经消失
3.4 常见错误与解决方法
| 错误现象 | 可能原因 | 解决方法 |
|---|---|---|
insmod: ERROR: could not insert module hello_drv.ko: Device or resource busy |
主设备号冲突或已有同名驱动 | 检查 cat /proc/devices,换一个名字或用动态分配 |
can not open file /dev/hello |
设备节点未自动创建 | 检查 class_create 和 device_create 是否执行成功;手动 mknod /dev/hello c 245 0 临时测试 |
write ret = -1 |
驱动中的 copy_from_user 失败 |
检查用户空间指针是否有效,确认 len 不超过缓冲区 |
编译时 warning: ignoring return value of 'copy_from_user' |
未检查返回值 | 应该处理返回值,但初学可忽略 |
⚠️ 特别提醒 :如果
register_chrdev忘记写或参数错误,会导致major为 0,device_create失败,最终/dev/hello不会出现。上面的源码中已经修正。
4. 驱动与 APP 的数据传输 ------ copy_to/from_user
为什么不能直接 memcpy?
因为用户空间和内核空间是 隔离的 。用户进程的虚拟地址在内核中可能没有映射,直接访问会导致 缺页异常 甚至内核崩溃。copy_to_user 和 copy_from_user 会检查地址有效性,并且处理缺页。
使用格式:
c
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
- 返回值:未能拷贝的字节数。0 表示全部成功,非 0 表示出错。
- 因此正确用法应该检查返回值:
c
if (copy_to_user(buf, hello_buf, len)) {
return -EFAULT; // 返回错误码
}
5. 驱动提供能力,不提供策略 ------ 四种访问方式

驱动只负责 能不能读写 ,而 什么时候读写 由 APP 决定。常见的四种访问方式(以读取按键为例):
| 方式 | 生活化类比 | 驱动实现 | APP 行为 |
|---|---|---|---|
| 非阻塞(查询) | 妈妈时不时进房间看孩子醒了没 | read 函数立即返回数据或 -EAGAIN | 循环调用 read,每次不等待 |
| 阻塞(休眠-唤醒) | 妈妈陪孩子睡,醒了才醒 | 没有数据时让进程休眠,中断中唤醒 | read 会一直等待直到有数据 |
| poll(定闹钟) | 妈妈陪睡一会儿,设个闹钟 | 实现 .poll 函数,支持超时 | 调用 poll/select 设定等待时间 |
| 异步通知(信号) | 孩子醒了主动跑出房间喊妈妈 | 在中断中发送 SIGIO 信号 | 注册信号处理函数,无需主动读 |
理解"不提供策略":驱动不应该规定 APP 必须用哪种方式,而是提供所有可能(非阻塞、阻塞、poll、异步通知),让 APP 根据自己的需求选择。
6. GPIO 子系统 ------ 用编号控制引脚
驱动最终要操作硬件引脚。Linux 内核提供了 GPIO 子系统,统一管理所有 GPIO。
6.1 确定 GPIO 编号的方法
方法一:通过 /sys/kernel/debug/gpio 查看
bash
cat /sys/kernel/debug/gpio
输出示例:
text
gpiochip0: GPIOs 0-31, parent: platform/209c000.gpio, 209c000.gpio:
gpio-5 ( |goodix_ts_int ) in hi IRQ
gpio-19 ( |cd ) in hi IRQ
...
方法二:在 /sys/class/gpio 下查看每个 gpiochip 的 label
bash
ls /sys/class/gpio/gpiochip* -d
cat /sys/class/gpio/gpiochip0/label # 得到 "209c000.gpio" 等
对于 IMX6ULL,GPIO 编号公式:(bank-1)*32 + pin。例如 GPIO5_3 → (5-1)*32+3 = 131。
6.2 基于 sysfs 操作 GPIO(用户态验证)
不需要写驱动,就可以在命令行操作 GPIO(前提是该引脚没有被占用)。
bash
# 导出引脚
echo 131 > /sys/class/gpio/export
# 设为输出
echo out > /sys/class/gpio/gpio131/direction
# 输出高电平
echo 1 > /sys/class/gpio/gpio131/value
# 解除导出
echo 131 > /sys/class/gpio/unexport
如果出现 write error: Device or resource busy,说明该引脚已被某个驱动占用。
6.3 GPIO 子系统的内核函数
内核推荐使用 descriptor-based 的新接口(以 gpiod_ 开头):
| 功能 | 新接口 | 旧接口 |
|---|---|---|
| 获取 GPIO | gpiod_get() |
gpio_request() |
| 设置方向 | gpiod_direction_input() |
gpio_direction_input() |
| 输出值 | gpiod_set_value() |
gpio_set_value() |
| 输入值 | gpiod_get_value() |
gpio_get_value() |
| 释放 | gpiod_put() |
gpio_free() |
通常还需要配合设备树或平台数据来获取 GPIO 描述符。简单的测试可以直接使用旧接口。
7. 中断处理 ------ 让驱动响应硬件事件
以按键为例,我们希望按下按键时,驱动程序能立即通知 APP。
7.1 中断申请流程
- 获得中断号 :
gpio_to_irq(gpio_num) - 注册中断处理函数 :
request_irq(irq, handler, flags, name, dev) - 在中断处理函数中 :
- 分辨中断(如果有多个中断源)
- 处理数据(如读取按键值,唤醒等待队列)
- 清除中断(硬件相关)
- 卸载时释放中断 :
free_irq(irq, dev)
7.2 按键驱动框架(含定时器防抖)
为什么需要定时器? 机械按键在按下和释放时会产生多个抖动,导致多次中断。用定时器延迟一小段时间,再读取稳定状态。
驱动骨架示例:
c
#include <linux/interrupt.h>
#include <linux/gpio.h>
static int gpio_irq;
static struct timer_list key_timer;
// 定时器回调函数:用于防抖
static void key_timer_func(struct timer_list *t)
{
int val = gpio_get_value(KEY_GPIO);
if (val == 0) { // 按下(假设低电平有效)
// 通知 APP(唤醒等待队列,或发送信号)
}
}
// 中断处理函数
static irqreturn_t key_isr(int irq, void *dev_id)
{
// 修改定时器,延迟 20ms 后执行防抖
mod_timer(&key_timer, jiffies + msecs_to_jiffies(20));
return IRQ_HANDLED;
}
static int __init key_init(void)
{
// 申请 GPIO
gpio_request(KEY_GPIO, "my_key");
gpio_direction_input(KEY_GPIO);
// 获得中断号并注册
gpio_irq = gpio_to_irq(KEY_GPIO);
request_irq(gpio_irq, key_isr, IRQF_TRIGGER_FALLING, "my_key", NULL);
// 初始化定时器
timer_setup(&key_timer, key_timer_func, 0);
return 0;
}
static void __exit key_exit(void)
{
free_irq(gpio_irq, NULL);
gpio_free(KEY_GPIO);
del_timer(&key_timer);
}
jiffies是内核的全局时间戳,msecs_to_jiffies(20)将 20 毫秒转换成节拍数。mod_timer会修改定时器的超时时间,如果定时器还未触发,就重新计时。
8. 总结与后续学习建议
通过本文,你已经掌握了:
- ✅ 驱动的基本框架(
file_operations, 注册/注销, 自动创建设备节点) - ✅ 内核与用户空间的数据传输(
copy_to/from_user) - ✅ 四种访问方式的概念
- ✅ GPIO 编号的确定和 sysfs 操作
- ✅ 中断申请与定时器防抖
下一步可以学习:
- 设备树:如何描述 GPIO 和中断资源,让驱动更通用。
- platform 驱动模型:将驱动和设备分离。
- input 子系统:按键、触摸屏等输入设备的统一框架。
- 内核调试技巧 :
printk的级别,/sys/kernel/debug,ftrace。
推荐实验:
- 修改 hello 驱动,增加
ioctl方法,实现清空缓冲区功能。 - 写一个完整的按键驱动,支持阻塞和非阻塞读,并用
poll测试。 - 将按键驱动和 LED 驱动结合,实现"按一下开关灯,再按一下关灯"。
🎉 恭喜你完成了 Linux 驱动开发的入门!记住:驱动就是提供能力,不提供策略。多写代码,多读内核源码,你会越来越强大。