目录
[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 交叉编译器找不到)
写在前面:驱动到底是干嘛的?
很多新手听到"驱动"两个字就觉得很高深。其实你可以这样理解:
驱动就是应用和硬件之间的翻译 + 桥梁。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_init、module_exit、MODULE_LICENSE等宏。 -
<linux/fs.h>:提供struct file_operations、register_chrdev等核心函数。 -
<linux/device.h>:提供自动创建设备节点所需的class和device相关函数。 -
<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_create和device_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 端的
open或close会报错。
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;
}
-
argc和argv是命令行参数。例如:-
./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成员)。
🧠 两个文件之间的"对话"流程
-
加载驱动 :
insmod hello_drv.ko→ 内核执行hello_init→ 注册设备,创建/dev/hello。 -
APP 写 :
./hello_drv_test -w "abc"→open→ 触发hello_drv_open→write→ 触发hello_drv_write→ 数据拷贝到kernel_buf→close→ 触发hello_drv_close。 -
APP 读 :
./hello_drv_test -r→open→read→ 触发hello_drv_read→ 从kernel_buf拷贝数据给 APP → 打印结果 →close。 -
卸载驱动 :
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。
解决方法:
-
安装交叉编译器(Ubuntu系统):
bashbash sudo apt install gcc-arm-linux-gnueabihf安装后,
arm-linux-gnueabihf-gcc就会在/usr/bin/下,系统能直接找到。 -
或者使用开发板SDK自带的交叉编译器(比如百问网提供的Buildroot工具链):
bashbash export PATH=/home/book/DevelopmentEnvConf/100ask_imx6ull-sdk/Buildroot_2020.02.x/output/host/bin:$PATH export CROSS_COMPILE=arm-buildroot-linux-gnueabihf-然后验证:
bashbash arm-buildroot-linux-gnueabihf-gcc --version如果能显示版本号,说明可用了。
-
临时解决:在Makefile中直接写死编译器路径(不推荐):
bashmakefile CC = /home/book/.../arm-buildroot-linux-gnueabihf-gcc
记住 :交叉编译器必须与编译开发板内核时使用的完全一致 ,否则即使编译成功,加载模块时也会报
Invalid module format或disagrees 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.ko 和 hello_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 format 或 disagrees about version of symbol module_layout
原因:编译驱动时使用的内核源码树与开发板上运行的内核不匹配(配置、编译器版本、符号CRC不同)。
解决方法:
-
确保
KERN_DIR指向开发板对应版本 的内核源码,并且该内核源码已经配置并编译过 (执行过make xxx_defconfig和make modules)。 -
使用与编译内核完全相同 的交叉编译器(查看
/proc/version确认)。 -
如果以上都正确,可以尝试在开发板上执行
depmod然后modprobe。
7.2 can not open file /dev/hello
原因:设备节点没有自动创建,或者驱动没有加载成功。
解决方法:
-
确保驱动加载成功(
insmod无报错,lsmod能看到)。 -
检查
/dev/hello是否存在:ls -l /dev/hello。如果不存在,手动创建:bashbash cat /proc/devices | grep hello # 查看主设备号 mknod /dev/hello c <主设备号> 0 -
如果驱动中使用了
class_create/device_create,设备节点会自动创建,前提是/sys挂载正常。
八、总结
今天你学会了:
✅ APP的open对应内核的struct file
✅ file_operations就是一本操作手册
✅ 编写字符设备驱动的7个标准步骤
✅ 如何写Makefile并使用交叉编译器
✅ 如何加载、测试、卸载驱动
下一步你可以:在驱动中加入真正的硬件操作(比如控制GPIO点灯),或者尝试实现更复杂的读写逻辑。
如果你在实验中遇到任何问题,欢迎留言,我会尽力帮你解答