Linux驱动基础——基于 goldfish 内核的驱动开发与测试
Linux驱动基础——基于 goldfish 内核的驱动开发与测试
1 前言
在上篇《Ubuntu 下编译 goldfish 内核并使用模拟器运行》中,我们成功编译了goldfish内核,今天我们在这个环境上尝试编写和编译几个简单的驱动,初步了解Linux驱动开发的基本流程和原理。
在 Linux 系统中,内核是操作系统的核心负责 CPU 调度、进程管理、内存管理、设备管理、文件系统、网络通信等一系列底层资源的管理与调度。与之对应的是用户空间,这里运行着各种应用程序、库文件和系统服务。
出于安全性和稳定性的考虑,Linux 将内核空间和用户空间进行了严格隔离。用户空间程序无法直接访问内核空间数据,只能通过系统调用这一受控的机制向内核请求服务。
内核模块是内核的一种可扩展机制,允许我们将功能以模块的形式编译,并在运行时动态地加载或卸载,而无需重启系统。内核模块可以在需要时加载到内核,也可以在不需要时移除,从而提升了系统的灵活性和可维护性。当然,也可以选择直接将模块静态编译进内核映像。
驱动则是一种特殊类型的内核模块,专门负责管理和控制特定类型的硬件设备。驱动通常会在 /dev
目录下创建对应的设备文件,这些文件作为应用程序与硬件设备之间的桥梁,允许用户程序以类似文件的方式进行操作,如读、写、控制等,从而间接管理硬件。
2 编写Linux内核模块
实现和注册初始化/清理函数
编写一个内核模块,最基本的要求只需要实现模块加载时执行的初始化逻辑和卸载时执行的清理逻辑。并通过module_init
和module_exit
这两个宏注册初始化函数和清理函数。
它们的作用是告诉内核:
模块加载时调用哪个函数(初始化)
模块卸载时调用哪个函数(清理)
新建hello_kernel.c
,并添加以下代码。这个内核模块的功能非常简单,当模块被加载 (insmod
) 和被模块被卸载 (rmmod
) 时打印日志。
1 |
|
上面的代码通过hello_init
和hello_exit
实现了初始化和清理时的逻辑,并通过module_init
和module_exit
表明执行时机。另外在实现两个函数时还使用__init
和 __exit
宏进行修饰,作用是告诉编译器,这些函数的代码是否可以在运行时释放或丢弃:
__init:标识一个函数为初始化函数。并且初始化函数只需执行一次,当模块加载完成后就不需要了,带__init标记的函数,内核会在模块加载完成后释放这部分代码的内存以节省内存。
__exit:表示这个函数只会在模块卸载时用,如果模块不支持卸载,这段代码编译时就可以完全忽略。
3 编译模块内核
内核模块的编译方式有两种,可以作为可加载模块,也可以作为内核的一部分直接编译进内核。
3.1 将驱动编译进内核
1. 将代码放入内核源码目录
为了方便管理和避免污染源码,新建dmeo
文件夹(名字随意),并将上面的hello_kernel.c
文件复制到goldfish项目下/drivers/misc
目录。drivers/
是内核源码里专门用来放 各种设备驱动程序 的目录,里面又细分成了很多子目录,其中misc/
放的是不好归类到其他类别的小型杂项驱动。
文件目录(部分文件将在后面创建):
1 | goldfish/drivers/misc/ |
2. 编写 Kconfig 文件
Kconfig 用于定义配置选项,让用户可以在 menuconfig 里选择是否启用驱动。
编译字段的默认值有:
- y:被编译进内核
- m:作为模块
- n:不编译
在hello_kernel.c
所在目录新建Kconfig
文件,并添加以下内容:
1 | # 在内核菜单中定义一个配置项 HELLO_KERNEL |
修改 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 | # 该Makefile 会告诉内核构建系统,编译 goldfish_driver.c 并链接到最终的内核镜像。 |
或者写死,但是这样就无法通过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 | adb root # 获取 root 权限 |
可以发现内核demo已经加载并打印出我们编写的语句
1 | [ 1.630334] hello_kernel: Kernel module loaded |
3.2 编译成可加载模块
1. 修改编译配置
修改Kconfig文件,指定该选项的默认值为 m,默认启用并以模块的形式编译(生成 .ko 文件):
1 | # 在内核菜单中定义一个配置项 HELLO_HERNEL |
2. 重新编译
在内核源码目录,使用make命令重新编译内核,如果已经构建过一次整个内核,也可以只编译模块:
在内核目录打开.config
文件,如果没有CONFIG_HELLO_KERNEL=m
则添加这句配置。
然后执行:
1 | make M=drivers/misc/demo modules |
编译成功后,会在demo
文件夹下生成hello_kernel.ko
文件。
3. 加载内核模块
启动模拟器后,再打开一个新的终端,输入以下命令将ko
文件传输到模拟器tmp
文件夹,
1 | # 将hello_kernel.ko 模块文件推送到 模拟器的 /data/local/tmp/ 目录 |
然后在root环境下通过insmod
命令加载hello_kernel.ko
模块。加载前可以通过lsmod
查看当前已加载的内核模块和系统日志。
1 | # 进入设备(模拟器)shell环境 |
4. 查看日志
成功加载后,通过lsmod
可以看到内核已经加载,输入dmesg
命令可以看到日志中打印了我们编写的prink
语句(dmesg 输出可能有延迟)。
1 | generic_x86_64:/ # lsmod |
5.卸载内核模块
使用rmmod
命令卸载模块
1 | generic_x86_64:/ # rmmod hello_kernel |
在真实的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 | static const struct file_operations my_fops = { |
以下代码实现了一个简单的字符设备驱动,它在内核中注册了一个名为 /dev/hello_device
的字符设备,模块加载时注册设备,卸载时注销,并实现了简单的字符串读写功能。用户通过写操作将字符串传入设备,驱动用 copy_from_user
保存到内核缓冲区,通过读操作用 copy_to_user
将数据返回用户空间。
1 |
|
其他一些关键api:
- misc_register():用于注册一个杂项设备。设备会被分配一个次设备号,并可以通过设备文件与用户空间程序进行交互。它的参数是一个 struct miscdevice 结构体,该结构体包含设备的名称、设备的文件操作结构体以及次设备号等信息。
- misc_deregister():用于注销一个已注册的杂项设备,释放它占用的资源。
- 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 | generic_x86_64:/ # ls -l /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 | # 这条命令的作用是将字符输出到终端显示,同时写入到 文件 /dev/hello_device |
再次查看日志,可以看到调用驱动里的 hello_open() ➜ hello_write() ➜ hello_release()
,写入用户数据。
1 | [ 102.977848] hello_device: registered |
读取驱动文件 cat /dev/hello_device
,可以发现终端显示了我们上次输出的内容,同时检查日志输出,说明调用了驱动hello_open() ➜ hello_read() ➜ hello_release()
,读取内核保存的字符串。
1 | [ 845.938645] hello_device: opened |
app读写
echo/cat 等标准命令其实就是在做 open/write/read/close,我们也可以在应用层通过app的读写来测试。不过由于驱动文件是只有 root 用户 才能访问,普通 app 没权限。需要使用chmod 修改文件权限。另外由于Android 系统启用了 SELinux,还需要临时关闭它,输入以下两条命令:
1 | adb shell |
app源码,打包成apk后通过adb安装到模拟器上。
1 | class MainActivity : ComponentActivity() { |
这个驱动例子只是在 内核空间维护一段内存(str_buffer
),整个驱动的读写流程:应用程序通过 write()
方法 将数据写入设备文件 /dev/hello_device
,然后驱动通过 copy_from_user
把数据复制到内核内存 str_buffer
中;当应用程序通过read
从设备文件读取数据时,驱动又从 str_buffer 把数据用 copy_to_user 拷贝回用户空间。这样内核实现了安全地从用户空间和内核空间之间传递数据。
时序图:
1 | sequenceDiagram |
总结
本文通过两个例子初步了解了关于驱动的基础知识。以上驱动代码功能上实现的是 虚拟设备(/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 驱动 的源码,分析其内部实现原理和机制。