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

Linux 驱动开发入门:从最简单的 hello 驱动到硬件交互写给未来的自己和领导 本文是 Linux 驱动开发的 入门级保姆教程 从零开始搭建驱动框架 逐行解释代码 记录每一个踩过的坑 无论你是刚接触内核编程 还是想快速上手 GPIO 中断 都能在这里找到清晰的思路和可复现的步骤 引言 驱动是什么 驱动的基本框架 一切皆文件 实战 第一个 hello 驱动 3 1 完整的驱动源码 带详细注释 3 2

大家好,我是讯享网,很高兴认识大家。这里提供最前沿的Ai技术和互联网信息。



🎉 写给未来的自己和领导 :本文是 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. 总结与后续学习建议

一句话白话 :驱动就是 内核中的"翻译官"。APP 说"我要读数据",驱动把它翻译成硬件能懂的指令(拉高拉低 GPIO、读写寄存器),然后把硬件返回的结果再翻译回 APP 能理解的数据。

生活化类比 🏢:

  • APP = 公司老板,只会说"我要营业额"。
  • 驱动 = 财务经理,知道怎么查数据库、算报表,最后交给老板一个数字。
  • 硬件 = 服务器,只接受底层指令。

在 Linux 中,驱动最终以 .ko(kernel object)文件存在,可以动态加载和卸载。


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 节点。

  • 我们的目标是:写一个驱动,提供一个 /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 
                 
                

    #include // file_operations, register_chrdev #include // copy_to_user, copy_from_user #include // 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 

    ", FILE, FUNCTION, LINE);

    return 0; 

    }

    // 当 APP 调用 read() 时执行 static ssize_t hello_read(struct file *filp, char __user *buf,

     size_t size, loff_t *offset) return len; 

    }

    // 当 APP 调用 write() 时执行 static ssize_t hello_write(struct file *filp, const char __user *buf,

     size_t size, loff_t *offset) return len; 

    }

    // 当 APP 调用 close() 时执行 static int hello_release(struct inode *node, struct file *filp) {

    printk("%s %s line %d 

    ", 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)

    // 创建一个类,用于自动生成设备节点 hello_class = class_create(THIS_MODULE, "hello_class"); if (IS_ERR(hello_class)) { printk("class_create failed 

    ");

     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 

    ", 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 

    "); }

    module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL");

     

    代码解释(为什么要这么做):

    • initexit:告诉内核这些函数只在加载/卸载时使用,执行完后可以释放内存。
    • 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 
                 
                

    #include #include #include #include #include

    int main(int argc, char argv)

    fd = open(argv[1], O_RDWR); if (fd < 0) { printf("can not open file %s 

    ", argv[1]);

     return -1; } if (argc == 3) { // 写操作:将命令行参数写入驱动 len = write(fd, argv[2], strlen(argv[2]) + 1); // +1 包含 '0' printf("write ret = %d 

    ", len);

    } else { // 读操作:从驱动读取之前写入的字符串 len = read(fd, buf, 100); buf[99] = '0'; printf("read str : %s 

    ", 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 不会出现。上面的源码中已经修正。


    为什么不能直接 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; // 返回错误码 

    }

     
           
        
             

    驱动只负责 能不能读写 ,而 什么时候读写 由 APP 决定。常见的四种访问方式(以读取按键为例):

    方式 生活化类比 驱动实现 APP 行为 非阻塞(查询) 妈妈时不时进房间看孩子醒了没 read 函数立即返回数据或 -EAGAIN 循环调用 read,每次不等待 阻塞(休眠-唤醒) 妈妈陪孩子睡,醒了才醒 没有数据时让进程休眠,中断中唤醒 read 会一直等待直到有数据 poll(定闹钟) 妈妈陪睡一会儿,设个闹钟 实现 .poll 函数,支持超时 调用 poll/select 设定等待时间 异步通知(信号) 孩子醒了主动跑出房间喊妈妈 在中断中发送 SIGIO 信号 注册信号处理函数,无需主动读

    理解"不提供策略":驱动不应该规定 APP 必须用哪种方式,而是提供所有可能(非阻塞、阻塞、poll、异步通知),让 APP 根据自己的需求选择。


    驱动最终要操作硬件引脚。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 描述符。简单的测试可以直接使用旧接口。


    以按键为例,我们希望按下按键时,驱动程序能立即通知 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 
                    
                   

    #include

    static int gpio_irq; static struct timer_list key_timer;

    // 定时器回调函数:用于防抖 static void key_timer_func(struct timer_list *t)

    }

    // 中断处理函数 static irqreturn_t key_isr(int irq, void *dev_id)

    static int __init key_init(void)

    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 会修改定时器的超时时间,如果定时器还未触发,就重新计时。

  • 通过本文,你已经掌握了:

    • ✅ 驱动的基本框架(file_operations, 注册/注销, 自动创建设备节点)
    • ✅ 内核与用户空间的数据传输(copy_to/from_user
    • ✅ 四种访问方式的概念
    • ✅ GPIO 编号的确定和 sysfs 操作
    • ✅ 中断申请与定时器防抖

    下一步可以学习

    • 设备树:如何描述 GPIO 和中断资源,让驱动更通用。
    • platform 驱动模型:将驱动和设备分离。
    • input 子系统:按键、触摸屏等输入设备的统一框架。
    • 内核调试技巧printk 的级别,/sys/kernel/debugftrace

    推荐实验

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

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

    小讯
    上一篇 2026-04-19 11:20
    下一篇 2026-04-19 11:18

    相关推荐

    版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请联系我们,一经查实,本站将立刻删除。
    如需转载请保留出处:https://51itzy.com/kjqy/266203.html