从零开始写Linux字符设备驱动:一个不操作硬件的Hello驱动

目录

写在前面:驱动到底是干嘛的?

一、APP打开文件,内核里发生了什么?

二、字符设备的核心:file_operations

三、编写Hello驱动的完整步骤(一共7步)

四、完整代码(先看框架,细节稍后讲)

[4.1 驱动源码:hello_drv.c](#4.1 驱动源码:hello_drv.c)

[4.2 测试程序:hello_drv_test.c](#4.2 测试程序:hello_drv_test.c)

[五、编译驱动:Makefile 详解](#五、编译驱动:Makefile 详解)

[5.1 一个典型的内核模块Makefile](#5.1 一个典型的内核模块Makefile)

[5.2 交叉编译器的问题:arm-linux-gnueabihf-gcc: command not found](#5.2 交叉编译器的问题:arm-linux-gnueabihf-gcc: command not found)

六、上机实验(完整流程)

[6.1 编译驱动](#6.1 编译驱动)

[6.2 推送到开发板并加载](#6.2 推送到开发板并加载)

[6.3 测试读写](#6.3 测试读写)

[6.4 卸载驱动](#6.4 卸载驱动)

七、常见问题及解决方法

[7.1 Invalid module format 或 disagrees about version of symbol module_layout](#7.1 Invalid module format 或 disagrees about version of symbol module_layout)

[7.2 can not open file /dev/hello](#7.2 can not open file /dev/hello)

[7.3 交叉编译器找不到](#7.3 交叉编译器找不到)

八、总结

九、预留位置:两个.c文件的详细讲解


写在前面:驱动到底是干嘛的?

很多新手听到"驱动"两个字就觉得很高深。其实你可以这样理解:

驱动就是应用和硬件之间的翻译 + 桥梁。APP 不用懂硬件,硬件不用懂 APP,驱动负责两边传话。

今天我们不操作任何硬件,只写一个最最简单的"Hello驱动"。它不做任何实事,只是让你看清楚:一个驱动到底长什么样,怎么注册进内核,APP怎么找到它

这篇博客会带你一步步写出这个驱动,并成功在开发板上运行。全程无废话,每一步都有解释


一、APP打开文件,内核里发生了什么?

你在APP里写一行代码:

cs 复制代码
c

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

这个fd是一个整数,叫文件句柄。你可以把它理解为"取号小票"------你拿着它,就能找到对应的服务窗口。

在内核里,每个文件句柄都对应一个struct file结构体。 你可以把struct file想象成一张档案卡,记录着:

  • 你是以读还是写的方式打开的(f_flags

  • 现在读到哪个位置了(f_pos,文件偏移)

  • 最重要的是:这个设备该怎么操作f_op,指向操作函数表)

一句话:APP拿到的整数句柄,是内核里那张"档案卡"的编号。所有操作都靠这张卡来查。

下面这张图(来自老师课件)很清楚地展示了这个关系:

cs 复制代码
text

APP:   fd = open("/dev/hello", O_RDWR);
                ↓
内核:   struct file {
            f_flags = O_RDWR;
            f_pos = 0;
            f_op = 指向驱动提供的操作函数表;
        }

二、字符设备的核心:file_operations

如果说struct file是档案卡,那struct file_operations就是一本操作手册

这本手册里写满了函数指针:

cs 复制代码
c

struct file_operations {
    int (*open) (...);    // 打开设备时该干什么
    int (*read) (...);    // 读设备时该干什么
    int (*write) (...);   // 写设备时该干什么
    int (*release) (...); // 关闭设备时该干什么
};

驱动的本质工作,就是写这本手册:你告诉内核,当APP打开你的设备时,调用哪个函数;读数据时,调用哪个函数。

生活类比:你去租车,车行给你一本手册。手册上写着"按红色按钮启动,踩右边踏板加速"。你按手册操作,车就动了。驱动就是给内核写这本手册。


三、编写Hello驱动的完整步骤(一共7步)

下面我们手写一个最简单的驱动。它不操作任何硬件,只是在各个函数被调用时打印一句话,并且能保存APP写入的数据,供APP读取。

这7步是标准流程,记下来以后所有字符设备驱动都按这个套路写。

步骤 做什么 对应的代码
确定主设备号(或让内核分配) major = register_chrdev(0, "hello", &hello_drv);
定义file_operations结构体 static struct file_operations hello_drv = {...};
实现open/read/write/release函数 hello_drv_open, hello_drv_read
file_operations告诉内核(注册) register_chrdev
写入口函数,安装驱动时调用 module_init(hello_init);
写出口函数,卸载驱动时调用 module_exit(hello_exit);
自动创建设备节点(不用手动mknod) class_create, device_create

完整代码稍后会展示,这里先理解步骤。


四、完整代码(先看框架,细节稍后讲)

4.1 驱动源码:hello_drv.c

1. 头文件:驱动需要的"工具箱"

cs 复制代码
c

#include <linux/module.h>   // 模块必备:MODULE_LICENSE、module_init/exit
#include <linux/fs.h>       // 文件系统相关:struct file_operations, register_chrdev
#include <linux/device.h>   // 设备类相关:class_create, device_create
#include <linux/uaccess.h>  // 用户空间与内核空间数据拷贝:copy_to_user, copy_from_user
  • <linux/module.h> :所有内核模块都必须包含,它定义了 module_initmodule_exitMODULE_LICENSE 等宏。

  • <linux/fs.h> :提供 struct file_operationsregister_chrdev 等核心函数。

  • <linux/device.h> :提供自动创建设备节点所需的 classdevice 相关函数。

  • <linux/uaccess.h> :提供安全地在用户态和内核态之间拷贝数据的函数。因为内核不能直接访问用户空间的内存(会崩溃),必须用专用函数。

类比 :你从北京(内核)寄快递到上海(用户程序),不能直接用手扔过去,必须通过快递公司(copy_to_user / copy_from_user)安全传递。


2. 全局变量:驱动的小仓库

cs 复制代码
c

static int major = 0;               // 主设备号,0表示让内核自动分配
static char kernel_buf[1024];       // 一块内核空间的内存,用来存APP写入的数据
static struct class *hello_class;   // 设备类指针,用于自动创建 /dev/hello
  • major :字符设备的主设备号。写 0 表示让内核帮忙选一个没被占用的号码。成功注册后,内核会把实际分配的数字填进去。

  • kernel_buf:驱动用来保存数据的缓冲区。APP 写进来的数据存在这里,读的时候从这里取走。

  • hello_class :配合 class_createdevice_create,让系统在 /dev 下自动生成 hello 设备文件,这样 APP 就能用 open("/dev/hello") 找到驱动。

为什么用 static 让这些变量只在这个文件内部可见,不会被其他驱动意外修改。


3. read 函数:APP 读数据时触发

cs 复制代码
c

static ssize_t hello_drv_read(struct file *file, char __user *buf, size_t size, loff_t *offset)
{
    int err;
    printk("hello_drv_read called\n");
    err = copy_to_user(buf, kernel_buf, size < 1024 ? size : 1024);
    return size < 1024 ? size : 1024;
}
  • 函数原型:这是内核规定的固定写法,参数含义:

    • file:对应的 struct file 指针。

    • buf用户空间的缓冲区指针(APP 传进来的)。

    • size:APP 想读多少字节。

    • offset:文件偏移(本例未使用)。

  • printk :内核版的 printf,打印的信息可以用 dmesg 命令查看。这里只是打个标记,方便你调试时看到驱动被调用了。

  • copy_to_user :把内核空间 kernel_buf 中的数据拷贝到用户空间的 buf 中。第三个参数是拷贝的长度(最多 1024 字节)。

  • 返回值 :成功返回实际读取的字节数,错误返回负值。这里简单返回 size(如果 size 超过 1024 则返回 1024)。

重点绝对不能用 memcpy 直接拷贝! 用户空间的内存可能是虚拟地址,内核直接访问会触发缺页错误导致系统崩溃。copy_to_user 会处理这些细节并检查地址合法性。


4. write 函数:APP 写数据时触发

cs 复制代码
c

static ssize_t hello_drv_write(struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
    int err;
    printk("hello_drv_write called\n");
    err = copy_from_user(kernel_buf, buf, size < 1024 ? size : 1024);
    return size < 1024 ? size : 1024;
}
  • copy_from_user :把用户空间 buf 的数据拷贝到内核空间 kernel_buf

  • 其他逻辑与 read 对称。

类比copy_from_user 就像快递员把上海(用户)的包裹安全送到北京(内核)的仓库。


5. open 和 release(close)函数

cs 复制代码
c

static int hello_drv_open(struct inode *node, struct file *file)
{
    printk("hello_drv_open called\n");
    return 0;   // 0 表示成功
}

static int hello_drv_close(struct inode *node, struct file *file)
{
    printk("hello_drv_close called\n");
    return 0;
}
  • 这两个函数只是打印信息,没有做实际工作。在实际驱动中,open 里通常会初始化硬件增加使用计数 等;release 里会释放资源

  • 返回值 0 代表成功。如果返回负数,APP 端的 openclose 会报错。


6. file_operations 结构体:操作手册

cs 复制代码
c

static struct file_operations hello_drv = {
    .owner   = THIS_MODULE,          // 模块所有者,防止正在使用时被卸载
    .open    = hello_drv_open,
    .read    = hello_drv_read,
    .write   = hello_drv_write,
    .release = hello_drv_close,
};
  • 这是驱动最核心的结构体 ,它告诉内核:当 APP 调用 open 时,请执行 hello_drv_open;调用 read 时,请执行 hello_drv_read...... 未设置的函数指针(如 llseek)内核会使用默认行为。

  • .owner = THIS_MODULE 是标准写法,防止模块在操作过程中被 rmmod 卸载导致崩溃。

类比 :这本书就是给内核看的"说明书",说明书上写了:按"开"键(open)就执行 hello_drv_open,按"读"键(read)就执行 hello_drv_read


7. 初始化函数(入口):安装驱动时自动执行

cs 复制代码
c

static int __init hello_init(void)
{
    printk("hello_init called\n");
    major = register_chrdev(0, "hello", &hello_drv);
    
    hello_class = class_create(THIS_MODULE, "hello_class");
    device_create(hello_class, NULL, MKDEV(major, 0), NULL, "hello");
    return 0;
}
  • __init 提示内核:这个函数只在初始化时使用,完成后可以释放内存。

  • register_chrdev(0, "hello", &hello_drv)

    • 第一个参数 0 → 让内核自动分配主设备号。

    • 第二个参数 "hello" → 设备名,会在 /proc/devices 中显示。

    • 第三个参数 → 我们写的操作手册。

    • 返回值:成功时返回分配的主设备号(赋值给 major),失败返回负数。

  • class_create :创建一个设备类(在 /sys/class/ 下会出现 hello_class 目录)。这一步是为了配合 device_create 自动生成 /dev/hello

  • device_create :在刚刚创建的类下面创建一个设备,内核会自动在 /dev 下生成 hello 设备节点。参数 MKDEV(major, 0) 把主设备号和次设备号(0)组合成一个设备编号。

没有这两行会怎样? 你需要手动用 mknod 命令创建 /dev/hello,否则 APP 无法 open 设备。自动创建省去了这个麻烦。


8. 出口函数:卸载驱动时自动执行

cs 复制代码
c

static void __exit hello_exit(void)
{
    printk("hello_exit called\n");
    device_destroy(hello_class, MKDEV(major, 0));
    class_destroy(hello_class);
    unregister_chrdev(major, "hello");
}
  • __exit 类似 __init,提示内核这个函数只在卸载时使用。

  • 销毁的顺序与创建的顺序相反:先销毁设备,再销毁类,最后注销字符设备。

  • unregister_chrdev:把之前注册的驱动从内核中移除,释放主设备号。


9. 模块加载/卸载宏

cs 复制代码
c

module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
  • module_init :告诉内核,当 insmod 安装这个 .ko 文件时,请执行 hello_init

  • module_exit :告诉内核,当 rmmod 卸载时,请执行 hello_exit

  • MODULE_LICENSE("GPL"):声明许可证,内核强制要求,否则会抱怨"kernel tainted"。

4.2 测试程序:hello_drv_test.c

1. 头文件和 main 参数解析

cs 复制代码
c

#include <stdio.h>
#include <string.h>
#include <fcntl.h>   // 提供 open 函数和 O_RDWR 等标志
#include <unistd.h>  // 提供 read, write, close

int main(int argc, char **argv)
{
    int fd;
    char buf[1024];
    int len;

    if (argc < 2) {
        printf("Usage: %s -w <string>\n", argv[0]);
        printf("       %s -r\n", argv[0]);
        return -1;
    }
  • argcargv 是命令行参数。例如:

    • ./hello_drv_test -w hello → argc=3, argv[0]="./hello_drv_test", argv[1]="-w", argv[2]="hello"

    • ./hello_drv_test -r → argc=2

  • 如果参数个数小于2,打印使用说明并退出。


2. 打开设备文件

cs 复制代码
c

    fd = open("/dev/hello", O_RDWR);
    if (fd == -1) {
        printf("can not open /dev/hello\n");
        return -1;
    }
  • open("/dev/hello", O_RDWR)

    • 路径 /dev/hello 是驱动自动创建的设备节点。

    • O_RDWR 表示可读可写打开。

  • 如果 open 失败(比如驱动没加载,或设备节点不存在),返回 -1,程序退出。

重点 :这里的 open 会触发内核调用驱动的 hello_drv_open 函数。


3. 根据参数执行写或读

cs 复制代码
c

    if (strcmp(argv[1], "-w") == 0 && argc == 3) {
        len = strlen(argv[2]) + 1;          // 字符串长度 + 1(包括 '\0')
        len = len < 1024 ? len : 1024;      // 最多 1024 字节
        write(fd, argv[2], len);
    } else {
        len = read(fd, buf, 1024);
        buf[len] = '\0';                    // 手动添加字符串结束符
        printf("APP read: %s\n", buf);
    }
  • 写分支

    • strcmp(argv[1], "-w") 判断第一个参数是否是 -w,且 argc == 3 确保有要写入的字符串。

    • 计算字符串长度(包括结尾的 \0),但不能超过 1024。

    • write(fd, argv[2], len):把字符串写入设备。内核会调用驱动的 hello_drv_write

  • 读分支

    • read(fd, buf, 1024):从设备读取最多 1024 字节。内核会调用驱动的 hello_drv_read

    • 读完后在末尾加 '\0' 保证字符串打印正常。

    • 打印读取到的内容。


4. 关闭文件

cs 复制代码
c

    close(fd);
    return 0;
  • close 会触发内核调用驱动的 hello_drv_close(即 .release 成员)。

🧠 两个文件之间的"对话"流程

  1. 加载驱动insmod hello_drv.ko → 内核执行 hello_init → 注册设备,创建 /dev/hello

  2. APP 写./hello_drv_test -w "abc"open → 触发 hello_drv_openwrite → 触发 hello_drv_write → 数据拷贝到 kernel_bufclose → 触发 hello_drv_close

  3. APP 读./hello_drv_test -ropenread → 触发 hello_drv_read → 从 kernel_buf 拷贝数据给 APP → 打印结果 → close

  4. 卸载驱动rmmod hello_drv → 内核执行 hello_exit → 销毁设备节点、注销设备。


五、编译驱动:Makefile 详解

5.1 一个典型的内核模块Makefile

cs 复制代码
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_drv_test hello_drv_test.c 

clean:
    make -C $(KERN_DIR) M=`pwd` modules clean
    rm -rf modules.order
    rm -f hello_drv_test

obj-m   += hello_drv.o

逐行解释:

  • KERN_DIR:指向你开发板对应的内核源码目录 。这个目录必须已经配置过(有.config文件)并且编译过。千万不能指向PC的Ubuntu内核目录 ,否则编译出来的.ko无法在开发板上运行。

  • make -C $(KERN_DIR):切换到内核源码目录,读取它的Makefile。

  • M=\pwd``:告诉内核Makefile,回到当前驱动目录去编译模块。

  • obj-m += hello_drv.o:表示把hello_drv.c编译成一个内核模块(.ko)。

  • 下面的$(CROSS_COMPILE)gcc ...:用交叉编译器编译测试程序。

5.2 交叉编译器的问题:arm-linux-gnueabihf-gcc: command not found

很多新手在编译时执行make,会看到这样的错误:

cs 复制代码
text

arm-linux-gnueabihf-gcc: command not found

为什么会出现?

因为你的Makefile或环境变量中设置了CROSS_COMPILE=arm-linux-gnueabihf-,但你的系统中并没有安装这个交叉编译器,或者安装后没有把它的路径加入PATH

解决方法:

  1. 安装交叉编译器(Ubuntu系统):

    bash 复制代码
    bash
    
    sudo apt install gcc-arm-linux-gnueabihf

    安装后,arm-linux-gnueabihf-gcc 就会在 /usr/bin/ 下,系统能直接找到。

  2. 或者使用开发板SDK自带的交叉编译器(比如百问网提供的Buildroot工具链):

    bash 复制代码
    bash
    
    export PATH=/home/book/DevelopmentEnvConf/100ask_imx6ull-sdk/Buildroot_2020.02.x/output/host/bin:$PATH
    export CROSS_COMPILE=arm-buildroot-linux-gnueabihf-

    然后验证:

    bash 复制代码
    bash
    
    arm-buildroot-linux-gnueabihf-gcc --version

    如果能显示版本号,说明可用了。

  3. 临时解决:在Makefile中直接写死编译器路径(不推荐):

    bash 复制代码
    makefile
    
    CC = /home/book/.../arm-buildroot-linux-gnueabihf-gcc

记住 :交叉编译器必须与编译开发板内核时使用的完全一致 ,否则即使编译成功,加载模块时也会报 Invalid module formatdisagrees about version of symbol module_layout。你可以在开发板上执行 cat /proc/version 查看内核是用哪个编译器编译的。


六、上机实验(完整流程)

假设你已经有了开发板(比如百问网i.MX6ULL),并且通过USB连接,adb可用。

6.1 编译驱动

bash 复制代码
bash

# 进入驱动目录
cd ~/01_hello_drv

# 设置环境变量(根据你的实际交叉编译器路径)
export ARCH=arm
export CROSS_COMPILE=arm-linux-gnueabihf-   # 或者 arm-buildroot-linux-gnueabihf-

# 编译
make clean
make

编译成功后,你会看到生成了 hello_drv.kohello_drv_test

6.2 推送到开发板并加载

bash 复制代码
bash

# 推送到开发板的 /root 目录
adb push hello_drv.ko hello_drv_test /root/

# 进入开发板shell
adb shell

# 在开发板上操作
cd /root
insmod hello_drv.ko

如果没有任何错误,驱动就加载成功了。可以用 lsmod 查看已加载的模块。

6.3 测试读写

bash 复制代码
bash

# 写入数据
./hello_drv_test -w "www.100ask.net"

# 读取数据
./hello_drv_test -r

你应该看到:

bash 复制代码
text

APP read: www.100ask.net

同时,在开发板上执行 dmesg | tail 可以看到驱动中 printk 打印的信息(hello_drv_open called 等)。

6.4 卸载驱动

bash 复制代码
bash

rmmod hello_drv

七、常见问题及解决方法

7.1 Invalid module formatdisagrees about version of symbol module_layout

原因:编译驱动时使用的内核源码树与开发板上运行的内核不匹配(配置、编译器版本、符号CRC不同)。

解决方法

  • 确保 KERN_DIR 指向开发板对应版本 的内核源码,并且该内核源码已经配置并编译过 (执行过 make xxx_defconfigmake modules)。

  • 使用与编译内核完全相同 的交叉编译器(查看 /proc/version 确认)。

  • 如果以上都正确,可以尝试在开发板上执行 depmod 然后 modprobe

7.2 can not open file /dev/hello

原因:设备节点没有自动创建,或者驱动没有加载成功。

解决方法

  • 确保驱动加载成功(insmod 无报错,lsmod 能看到)。

  • 检查 /dev/hello 是否存在:ls -l /dev/hello。如果不存在,手动创建:

    bash 复制代码
    bash
    
    cat /proc/devices | grep hello   # 查看主设备号
    mknod /dev/hello c <主设备号> 0
  • 如果驱动中使用了 class_create / device_create,设备节点会自动创建,前提是 /sys 挂载正常。


八、总结

今天你学会了:

✅ APP的open对应内核的struct file

file_operations就是一本操作手册

✅ 编写字符设备驱动的7个标准步骤

✅ 如何写Makefile并使用交叉编译器

✅ 如何加载、测试、卸载驱动

下一步你可以:在驱动中加入真正的硬件操作(比如控制GPIO点灯),或者尝试实现更复杂的读写逻辑。


如果你在实验中遇到任何问题,欢迎留言,我会尽力帮你解答

相关推荐
Benszen2 小时前
Ansible自动化运维实战
linux·运维·自动化·ansible
搜佛说2 小时前
比SQLite更快,比InfluxDB更轻:sfsDb的降维打击
jvm·数据库·物联网·架构·sqlite·边缘计算·iot
LilySesy2 小时前
【与AI+】英语day4——数据库与性能优化
数据库·oracle·性能优化·sap·abap·自动翻译
前进的李工2 小时前
MySQL角色管理:权限控制全攻略
前端·javascript·数据库·mysql
爱丽_2 小时前
MySQL `EXPLAIN`:看懂执行计划、判断索引是否生效与排错套路
android·数据库·mysql
小红的布丁2 小时前
Redis 持久化详解:AOF、RDB 与混合持久化如何平衡性能和可靠性
数据库·redis·缓存
qqxhb2 小时前
23|工具生态全景:本地文件、网络、数据库、浏览器自动化
网络·数据库·自动化·ai编程·最小权限·人工确认
艾莉丝努力练剑2 小时前
C++ 核心编程练习:从基础语法到递归、重载与宏定义
linux·运维·服务器·c语言·c++·学习