Linux驱动基础——基于 goldfish 内核的驱动开发与测试

1 前言

在上篇《Ubuntu 下编译 goldfish 内核并使用模拟器运行》中,我们成功编译了goldfish内核,今天我们在这个环境上尝试编写和编译几个简单的驱动,初步了解Linux驱动开发的基本流程和原理。

在 Linux 系统中,内核是操作系统的核心负责 CPU 调度、进程管理、内存管理、设备管理、文件系统、网络通信等一系列底层资源的管理与调度。与之对应的是用户空间,这里运行着各种应用程序、库文件和系统服务。

出于安全性和稳定性的考虑,Linux 将内核空间和用户空间进行了严格隔离。用户空间程序无法直接访问内核空间数据,只能通过系统调用这一受控的机制向内核请求服务。

内核模块是内核的一种可扩展机制,允许我们将功能以模块的形式编译,并在运行时动态地加载或卸载,而无需重启系统。内核模块可以在需要时加载到内核,也可以在不需要时移除,从而提升了系统的灵活性和可维护性。当然,也可以选择直接将模块静态编译进内核映像。

驱动则是一种特殊类型的内核模块,专门负责管理和控制特定类型的硬件设备。驱动通常会在 /dev 目录下创建对应的设备文件,这些文件作为应用程序与硬件设备之间的桥梁,允许用户程序以类似文件的方式进行操作,如读、写、控制等,从而间接管理硬件。

2 编写Linux内核模块

实现和注册初始化/清理函数
编写一个内核模块,最基本的要求只需要实现模块加载时执行的初始化逻辑和卸载时执行的清理逻辑。并通过module_initmodule_exit 这两个宏注册初始化函数和清理函数。
它们的作用是告诉内核:

  • 模块加载时调用哪个函数(初始化)

  • 模块卸载时调用哪个函数(清理)

新建hello_kernel.c,并添加以下代码。这个内核模块的功能非常简单,当模块被加载 (insmod) 和被模块被卸载 (rmmod) 时打印日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

// 模块加载函数 - 在 insmod 加载模块时调用
static int __init hello_init(void) {
printk(KERN_INFO "hello_kernel: Kernel module loaded\n");
return 0;
}

// 模块卸载函数 - 在 rmmod 卸载模块时调用
static void __exit hello_exit(void) {
printk(KERN_INFO "hello_kernel: Kernel module unloaded\n");
}

// 指定初始化和卸载函数
module_init(hello_init);
module_exit(hello_exit);

// 模块信息
MODULE_LICENSE("GPL");
MODULE_AUTHOR("fyr89757");
MODULE_DESCRIPTION("A simple Linux kernel module");
MODULE_VERSION("1.0");

上面的代码通过hello_inithello_exit实现了初始化和清理时的逻辑,并通过module_initmodule_exit 表明执行时机。另外在实现两个函数时还使用__init__exit宏进行修饰,作用是告诉编译器,这些函数的代码是否可以在运行时释放或丢弃:

  • __init:标识一个函数为初始化函数。并且初始化函数只需执行一次,当模块加载完成后就不需要了,带__init标记的函数,内核会在模块加载完成后释放这部分代码的内存以节省内存。

  • __exit:表示这个函数只会在模块卸载时用,如果模块不支持卸载,这段代码编译时就可以完全忽略。

3 编译模块内核

内核模块的编译方式有两种,可以作为可加载模块,也可以作为内核的一部分直接编译进内核。

3.1 将驱动编译进内核

1. 将代码放入内核源码目录
为了方便管理和避免污染源码,新建dmeo文件夹(名字随意),并将上面的hello_kernel.c文件复制到goldfish项目下/drivers/misc目录。drivers/ 是内核源码里专门用来放 各种设备驱动程序 的目录,里面又细分成了很多子目录,其中misc/放的是不好归类到其他类别的小型杂项驱动。
文件目录(部分文件将在后面创建):

1
2
3
4
5
6
7
goldfish/drivers/misc/
├── Makefile
├── Kconfig
└── demo/
├── hello_kernel.c
├── Makefile
└── Kconfig

2. 编写 Kconfig 文件
Kconfig 用于定义配置选项,让用户可以在 menuconfig 里选择是否启用驱动。
编译字段的默认值有:

  • y:被编译进内核
  • m:作为模块
  • n:不编译

hello_kernel.c所在目录新建Kconfig文件,并添加以下内容:

1
2
3
4
5
6
# 在内核菜单中定义一个配置项 HELLO_KERNEL
config HELLO_KERNEL
tristate "Hello kernel"
default y
help
This is a simple hello world kernel module.

修改 drivers/misc/Kconfig文件 以包含 上面这个配置,找到 drivers/misc/Kconfig,在合适位置(一般在末尾)添加:

1
source "drivers/misc/demo/Kconfig"

3. 编写Makefile文件
Makefile 就是内核编译系统的“说明书”或“配方表”,它指挥编译器如何处理模块、文件与目录,最终生成内核镜像或模块文件。
hello_kernel.c所在目录创建配置文件Makefile(无后缀),添加以下内容,其中CONFIG_HELLO_DRIVER变量就是上述demo/Kconfig中配置HELLO_DRIVER模块default字段的值,obj-y 表示直接编译进内核,obj-m 表示编译为可加载模块。。

1
2
3
# 该Makefile 会告诉内核构建系统,编译 goldfish_driver.c 并链接到最终的内核镜像。
obj-$(CONFIG_HELLO_KERNEL) += hello_kernel.o

或者写死,但是这样就无法通过menuconfig图形界面动态配置编译与否

1
# obj-y := hello_kernel.o

修改 drivers/misc/Kconfig 以包含 demo 选项,找到 drivers/misc/Makefile,添加:

1
obj-$(CONFIG_HELLO_KERNEL) += demo/

4. 重新编译内核
参考上篇编译 goldfish 内核,执行 make命令或者sh build.sh编译脚本, 重新编译goldfish,编译完成会输出类似以下字符,同时可以在misc/demo/文件夹生成了hello_kernel.o中间文件。

1
Kernel: arch/x86/boot/bzImage is ready  (#3)

5.运行和检验
编译完成启动模拟器:

1
emulator -avd Pixela10 -kernel /home/zhg/Workplace/goldfish/arch/x86_64/boot/bzImage -verbose

待模拟器启动后,输入命令查看内核日志:

1
2
adb root  # 获取 root 权限
adb shell dmesg | grep hello_kernel

可以发现内核demo已经加载并打印出我们编写的语句

1
[    1.630334] hello_kernel: Kernel module loaded

3.2 编译成可加载模块

1. 修改编译配置
修改Kconfig文件,指定该选项的默认值为 m,默认启用并以模块的形式编译(生成 .ko 文件):

1
2
3
4
5
6
# 在内核菜单中定义一个配置项 HELLO_HERNEL
config HELLO_KERNEL
tristate "Hello kernel"
default m
help
This is a simple hello world kernel module.

2. 重新编译
在内核源码目录,使用make命令重新编译内核,如果已经构建过一次整个内核,也可以只编译模块:
在内核目录打开.config文件,如果没有CONFIG_HELLO_KERNEL=m则添加这句配置。
然后执行:

1
make M=drivers/misc/demo modules

编译成功后,会在demo文件夹下生成hello_kernel.ko文件。

3. 加载内核模块
启动模拟器后,再打开一个新的终端,输入以下命令将ko文件传输到模拟器tmp文件夹,

1
2
3
# 将hello_kernel.ko 模块文件推送到 模拟器的 /data/local/tmp/ 目录
adb push hello_kernel.ko /data/local/tmp/

然后在root环境下通过insmod命令加载hello_kernel.ko模块。加载前可以通过lsmod查看当前已加载的内核模块和系统日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 进入设备(模拟器)shell环境
adb shell

# 获取root 权限
su

# lsmod 列出当前内核中已加载的所有模块
lsmod

# 加载指定的内核模块
insmod /data/local/tmp/hello_kernel.ko

# 显示内核日志,并过滤出包含 "hello" 的行
# dmesg | grep hello

4. 查看日志
成功加载后,通过lsmod可以看到内核已经加载,输入dmesg命令可以看到日志中打印了我们编写的prink语句(dmesg 输出可能有延迟)。

1
2
3
4
5
generic_x86_64:/ # lsmod                                                       
Module Size Used by
hello_kernel 16384 0
generic_x86_64:/ # dmesg | grep hello
[ 346.885617] hello_kernel: Kernel module loaded

5.卸载内核模块
使用rmmod命令卸载模块

1
2
3
4
5
6
generic_x86_64:/ # rmmod hello_kernel
generic_x86_64:/ # lsmod
Module Size Used by
generic_x86_64:/ # dmesg | grep hello
[ 346.885617] hello_kernel: Kernel module loaded
[ 489.731953] hello_kernel: Kernel module unloaded

在真实的Android 设备中,内核模块通常会根据硬件设备和需求在运行时动态加载,例如Wi-Fi 模块可能在设备启动时或用户打开 Wi-Fi 功能时加载。

4 编写Linux驱动

前面提到驱动也属于内核模块,内核可以是任何功能(比如:文件系统、网络协议、驱动等),而驱动专指专门用于操作具体的硬件或虚拟设备。

分类
Linux 驱动根据所服务的设备类型与功能,可以大致分为以下几类:

  • 字符设备驱动
    • 按字节流顺序访问,无固定数据块结构,支持直接读写。通过文件节点(如 /dev/ttyS0)操作,实现 open()、read()、write()、ioctl() 等方法。
  • 块设备驱动
    • 以固定大小的数据块为单位访问,支持缓存和随机访问。挂载为文件系统(如 /dev/sda1),通过 request_queue 处理块I/O请求。
  • 网络设备驱动
    • 面向数据包传输,与网络协议栈(TCP/IP)交互,无 /dev 节点。通过 net_device 结构体管理,处理 sk_buff 数据包。

4.1 代码实现

与内核模块相比,驱动除了需要实现并使用 module_init()module_exit()模块加载和卸载函数,还需要注册设备驱动。

本文主要讨论字符设备驱动中的杂项设备miscdevice。驱动设备需要分配和管理主次设备号,而杂项设备是Linux中一类简化的字符设备,这类设备不需要自己分配主设备号,而是共用一个固定的主设备号:10,通过不同的次设备号来区分。

除了设备驱动号,还需要定义 file_operations,这是Linux 内核源码中定义的一个结构体,用于描述一个设备驱动对文件操作的实现方式。当用户空间通过系统调用(如 open(), read(), write() 等)访问设备文件(如/dev/xxx)时,内核会根据设备对应的 file_operations 指针调用驱动中对应的函数。
file_operations 结构体定义了一组关键的函数指针,用于处理用户空间对设备文件的各种操作。以下是部分关键函数指针:

1
2
3
4
5
6
7
8
9
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open, // 打开设备文件时调用。常用于初始化设备、检查权限等
.release = my_release, // 关闭设备文件时调用,做清理工作
.read = my_read, // 从设备读取数据
.write = my_write, // 向设备写入数据
.unlocked_ioctl = my_ioctl,// 控制命令接口,支持用户自定义操作
};

以下代码实现了一个简单的字符设备驱动,它在内核中注册了一个名为 /dev/hello_device 的字符设备,模块加载时注册设备,卸载时注销,并实现了简单的字符串读写功能。用户通过写操作将字符串传入设备,驱动用 copy_from_user 保存到内核缓冲区,通过读操作用 copy_to_user 将数据返回用户空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/miscdevice.h>
#include <linux/fs.h>
#include <linux/uaccess.h> // copy_from_user, copy_to_user
#include <linux/string.h> // strlen

#define DEVICE_NAME "hello_device" // 设备名
#define BUFFER_SIZE 128 // 字符缓冲区大小

static char str_buffer[BUFFER_SIZE] = {0}; // 存储字符串数据
static size_t str_len = 0; // 当前字符串长度(不含 \0)

// 打开设备
static int hello_open(struct inode *inode, struct file *file) {
printk(KERN_INFO "hello_device: opened\n");
return 0;
}

// 释放设备
static int hello_release(struct inode *inode, struct file *file) {
printk(KERN_INFO "hello_device: closed\n");
return 0;
}

// 写字符串到设备
static ssize_t hello_write(struct file *file, const char __user *buf, size_t len, loff_t *offset) {
if (len >= BUFFER_SIZE) {
printk(KERN_WARNING "hello_device: write too large\n");
return -EINVAL;
}

if (copy_from_user(str_buffer, buf, len)) {
return -EFAULT;
}

str_buffer[len] = '\0'; // 确保字符串结束
str_len = len;

printk(KERN_INFO "hello_device: received string: %s\n", str_buffer);
return len;
}

// 从设备读取字符串
static ssize_t hello_read(struct file *file, char __user *buf, size_t len, loff_t *offset) {
if (*offset >= str_len) // 全部读取完了
return 0;

if (len > str_len - *offset)
len = str_len - *offset;

if (copy_to_user(buf, str_buffer + *offset, len)) {
return -EFAULT;
}

*offset += len; // 更新偏移
printk(KERN_INFO "hello_device: sent string data: %.*s\n", (int)len, str_buffer + *offset - len);
return len;
}

// 文件操作接口
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.open = hello_open,
.release = hello_release,
.write = hello_write,
.read = hello_read,
};

// 注册杂项设备
static struct miscdevice hello_misc_device = {
.minor = MISC_DYNAMIC_MINOR,
.name = DEVICE_NAME,
.fops = &hello_fops,
};

// 模块初始化
static int __init hello_init(void) {
int ret = misc_register(&hello_misc_device);
if (ret) {
printk(KERN_ERR "hello_device: register failed\n");
return ret;
}

printk(KERN_INFO "hello_device: registered\n");
return 0;
}

// 模块退出
static void __exit hello_exit(void) {
misc_deregister(&hello_misc_device);
printk(KERN_INFO "hello_device: unregistered\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("fyr89757");
MODULE_DESCRIPTION("A simple misc device driver for string read/write");

其他一些关键api:

  1. misc_register():用于注册一个杂项设备。设备会被分配一个次设备号,并可以通过设备文件与用户空间程序进行交互。它的参数是一个 struct miscdevice 结构体,该结构体包含设备的名称、设备的文件操作结构体以及次设备号等信息。
  2. misc_deregister():用于注销一个已注册的杂项设备,释放它占用的资源。
  3. copy_from_user():用于将数据从用户空间复制到内核空间。如果复制成功,返回 0;如果出错(例如,用户空间内存无法访问),返回错误码。

4.2 编译及测试

参考前面章节的内容,编译出hello_device.ko,然后使用 insmod加载它。
可以看到驱动正常注册,并输出了:

1
[  260.723088] hello_device: registered

前面提到驱动通常会在 /dev 目录下创建对应的设备文件,我们使用ls命令查看这个文件夹确实有这个文件。实际上,当这个驱动通过misc_register() 注册成功后,Linux 就会在dev文件夹自动创建 /dev/hello_device 这个字符设备文件,但它并不存储数据,而是提供了访问设备驱动的接口,数据存储在 static char str_buffer[BUFFER_SIZE] = {0}; 变量,它位于驱动程序里的内核缓冲区。
可以用 ls /dev/hello_device 查看:

1
2
generic_x86_64:/ # ls -l /dev/hello_device
crw------- 1 root root 10, 51 2025-03-20 15:44 /dev/hello_device
  • c:字符设备(character device),
  • rw:仅 root 用户有读(r)写(w)权限
  • 1 root root:设备文件的所有者和用户组
  • 10, 51:主设备号,表示设备的类型(10 号通常是 misc 设备);次设备号,标识特定的 misc 设备。

4.3 测试读取

终端读写
我们使用命令往hello_device驱动(也就是/dev/hello_device文件)写入一段字符。

1
2
# 这条命令的作用是将字符输出到终端显示,同时写入到 文件 /dev/hello_device
echo "test test" | tee /dev/hello_device

再次查看日志,可以看到调用驱动里的 hello_open() ➜ hello_write() ➜ hello_release(),写入用户数据。

1
2
3
4
5
[  102.977848] hello_device: registered
[ 413.585239] hello_device: opened
[ 413.586297] hello_device: received string: test test\x0a
[ 413.588225] hello_device: closed

读取驱动文件 cat /dev/hello_device,可以发现终端显示了我们上次输出的内容,同时检查日志输出,说明调用了驱动hello_open() ➜ hello_read() ➜ hello_release(),读取内核保存的字符串。

1
2
3
[  845.938645] hello_device: opened
[ 845.939414] hello_device: sent string data: test test\x0a
[ 845.941108] hello_device: closed

app读写
echo/cat 等标准命令其实就是在做 open/write/read/close,我们也可以在应用层通过app的读写来测试。不过由于驱动文件是只有 root 用户 才能访问,普通 app 没权限。需要使用chmod 修改文件权限。另外由于Android 系统启用了 SELinux,还需要临时关闭它,输入以下两条命令:

1
2
3
4
5
6
7
8
9
10
adb shell

su

# 修改设备节点权限
chmod 666 /dev/hello_device

# 临时关闭 SELinux
setenforce 0

app源码,打包成apk后通过adb安装到模拟器上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class MainActivity : ComponentActivity() {
companion object {
const val TAG = "MainActivity"
// 驱动对应的设备文件路径
const val DEVICE_PATH = "/dev/hello_device"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DriveDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Button(onClick = { Log.i(TAG, "readFromDevice:${readFromDevice()}") }) {
Text("读取")
}
Button(onClick = { writeToDevice("test data form app") }) {
Text("写入")
}
}
}
}
}
}

// 将数据写入设备
private fun writeToDevice(data: String) {
try {
FileOutputStream(DEVICE_PATH).use { fos ->
fos.write(data.toByteArray())
fos.flush()
Log.d(TAG, "writeToDevice:${data}")
}
} catch (e: IOException) {
e.printStackTrace()
}
}

private fun readFromDevice(): String {
return try {
FileInputStream(DEVICE_PATH).use { fis ->
val data = fis.readBytes()
String(data)
}
} catch (e: IOException) {
e.printStackTrace()
""
}
}
}

这个驱动例子只是在 内核空间维护一段内存(str_buffer),整个驱动的读写流程:应用程序通过 write()方法 将数据写入设备文件 /dev/hello_device,然后驱动通过 copy_from_user 把数据复制到内核内存 str_buffer 中;当应用程序通过read 从设备文件读取数据时,驱动又从 str_buffer 把数据用 copy_to_user 拷贝回用户空间。这样内核实现了安全地从用户空间和内核空间之间传递数据。
时序图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sequenceDiagram
participant User as 用户空间
participant Syscall as 系统调用(sys_write)
participant VFS as VFS层
participant Driver as 驱动(hello_write)

User->>Syscall: write(fd, buf, len)
Syscall->>VFS: 进入虚拟文件系统
VFS->>Driver: 调用hello_write()
Driver->>Driver: copy_from_user(str_buffer, buf, len)
Driver->>Driver: 更新str_len
Driver-->>VFS: 返回实际写入长度
VFS-->>Syscall: 传递返回值
Syscall-->>User: 返回结果

User->>Syscall: read(fd, buf, len)
Syscall->>VFS: 进入虚拟文件系统
VFS->>Driver: 调用hello_read()
Driver->>Driver: copy_to_user(buf, str_buffer + offset)
Driver->>Driver: 更新offset
Driver-->>VFS: 返回实际读取长度
VFS-->>Syscall: 传递返回值
Syscall-->>User: 返回结果

总结

本文通过两个例子初步了解了关于驱动的基础知识。以上驱动代码功能上实现的是 虚拟设备(/dev/hello_device),并没有涉及真实的硬件管理。 真实的设备也会通过驱动在dev下创建设备文件,在Linux中,所有的硬件、进程、网络等资源都通过文件接口进行统一管理,这就是 Linux 所说的“一切皆文件”理念。
例如:

  • 串口设备:这类设备的驱动会创建 /dev/ttyS0 等设备文件,用户程序通过操作这些文件与串口设备进行数据交换。

  • 输入设备:如键盘、鼠标、触摸屏等驱动会创建 /dev/input/eventX 设备文件,内核驱动捕获到来自硬件的事件后,将这些事件数据存储内核缓冲区并由设备文件提供给用户空间程序的,用户程序通过 open()打开设备文件,并使用 read() 函数读取输入设备发送的按键或触摸的坐标、状态等事件数据。

  • 虚拟网卡设备:VPN 客户端它会通过系统API申请创建一个虚拟网卡设备 /dev/tun0 ,程序通过 read() 从设备中读取数据包,或者通过 write() 向设备写入数据包。

实际上,在 Android 系统中,Binder 驱动也是一个字符设备驱动,它通过 /dev/binder 设备文件与用户空间交互,使得进程间可以通过 Binder 进行通信。下一篇我们将探讨 Binder 驱动 的源码,分析其内部实现原理和机制。