Linux 驱动开发入门:从最简单的 hello 驱动到硬件交互

Linux 驱动开发入门:从最简单的 hello 驱动到硬件交互

🎉 写给未来的自己和领导 :本文是 Linux 驱动开发的 入门级保姆教程,从零开始搭建驱动框架,逐行解释代码,记录每一个踩过的坑。无论你是刚接触内核编程,还是想快速上手 GPIO 中断,都能在这里找到清晰的思路和可复现的步骤。


📚 目录

  1. 引言:驱动是什么?
  2. 驱动的基本框架 ------ 一切皆文件
  3. 实战:第一个 hello 驱动
    • 3.1 完整的驱动源码(带详细注释)
    • 3.2 编译驱动 ------ Makefile 解析
    • 3.3 上机测试 ------ 从 insmod 到读写 /dev/hello
    • 3.4 常见错误与解决方法
  4. 驱动与 APP 的数据传输 ------ copy_to/from_user
  5. 驱动提供能力,不提供策略 ------ 四种访问方式
  6. GPIO 子系统 ------ 用编号控制引脚
    • 6.1 确定 GPIO 编号的方法
    • 6.2 基于 sysfs 操作 GPIO(用户态验证)
    • 6.3 GPIO 子系统的内核函数
  7. 中断处理 ------ 让驱动响应硬件事件
    • 7.1 中断申请流程
    • 7.2 按键驱动框架(含定时器防抖)
  8. 总结与后续学习建议

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 驱动编写四步曲

  1. 构造 file_operations:填充分发函数。
  2. 注册字符设备register_chrdev 告诉内核这个驱动的主设备号。
  3. 入口函数:模块加载时执行,完成注册和自动创建设备节点。
  4. 出口函数:模块卸载时执行,清理资源。

2.3 自动创建设备节点 ------ classdevice

传统方式需要手动 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.kohello_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_createdevice_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_usercopy_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 中断申请流程

  1. 获得中断号gpio_to_irq(gpio_num)
  2. 注册中断处理函数request_irq(irq, handler, flags, name, dev)
  3. 在中断处理函数中
    • 分辨中断(如果有多个中断源)
    • 处理数据(如读取按键值,唤醒等待队列)
    • 清除中断(硬件相关)
  4. 卸载时释放中断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/debugftrace

推荐实验

  1. 修改 hello 驱动,增加 ioctl 方法,实现清空缓冲区功能。
  2. 写一个完整的按键驱动,支持阻塞和非阻塞读,并用 poll 测试。
  3. 将按键驱动和 LED 驱动结合,实现"按一下开关灯,再按一下关灯"。

🎉 恭喜你完成了 Linux 驱动开发的入门!记住:驱动就是提供能力,不提供策略。多写代码,多读内核源码,你会越来越强大。

相关推荐
Bert.Cai5 小时前
Linux useradd命令详解
linux·运维
无忧.芙桃6 小时前
进程控制(上)
linux·运维·服务器
十年编程老舅6 小时前
深入 Linux 中断:原理详解 + 实战落地
linux·网络·linux内核·c/c++·中断
Bert.Cai6 小时前
Linux rm命令详解
linux·运维
知识浅谈7 小时前
OpenClaw保姆级安装教程:基于ubuntu系统
linux·ubuntu·node.js
hweiyu007 小时前
Linux命令:suspend
linux·运维·服务器
cyber_两只龙宝8 小时前
【Docker】Docker的资源限制
linux·运维·服务器·docker·云原生·容器
xlq223228 小时前
41.线程封装与互斥
linux·开发语言
赴前尘9 小时前
Linux/Unix 系统中以后台方式运行程序
linux·服务器·unix