Linux操作系统的简单驱动程序






4.91/5 (48投票s)
在本文中,我将描述为 Linux OS 编写和构建一个简单驱动程序模块的过程。
目录
简介
在本文中,我将描述为 Linux OS 编写和构建一个简单驱动程序模块的过程。同时,我将探讨以下问题:
- 内核日志系统
- 与字符设备交互
- 从内核处理“用户级别”内存
本文关注 Linux 内核版本 2.6.32,因为其他内核版本可能具有修改过的 API,这些 API 在示例或构建系统中有所使用。
一般信息
Linux 是一个单体内核。因此,驱动程序要么与内核本身一起编译,要么以内核模块的形式实现,以避免在需要添加驱动程序时重新编译内核。本文重点介绍内核模块。
模块是一种以特殊方式准备的对象文件。Linux 内核可以将其加载到其地址空间并与其链接。Linux 内核是用 C 和汇编语言(特定于体系结构的部分)编写的。Linux OS 的驱动程序开发只能用 C 和汇编语言进行,而不能用 C++ 语言(微软 Windows 内核是这样)。这是因为内核源代码片段,即头文件,可能包含 C++ 关键字,如 `new`、`delete`,而汇编代码片段可能包含 `‘::’` 词法单元。
模块代码在内核上下文中执行。这给开发者带来了一些额外的责任:如果用户模式程序出现错误,该错误主要会影响用户程序;如果内核模块中出现错误,则可能影响整个系统。但 Linux 内核的一个特点是其对模块代码中错误具有相当高的抵抗力。如果模块中存在非关键性错误(例如解引用空指针),则会显示 `oops` 消息(`oops` 是 Linux 正常工作的一种偏离,在这种情况下,内核会创建一个带有错误描述的日志记录)。然后,出现错误的模块会被卸载,而内核本身和其余模块将继续工作。然而,在 `oops` 消息之后,系统内核通常可能处于不一致状态,后续操作可能导致内核恐慌。
内核及其模块被构建成一个几乎单一的程序模块。因此,值得记住的是,在一个程序模块内,使用的是一个全局命名空间。为了最大限度地减小对全局命名空间的污染,应确保模块仅导出必要的全局字符,并且所有导出的全局字符都具有唯一的名称(良好的做法是在导出的字符名称前加上导出该字符的模块名称作为前缀)。
模块加载和卸载功能
创建最简单的模块所需的代码片段非常简单且简洁。它看起来如下:
#include <linux/init.h>
#include <linux/module.h>
static int my_init(void)
{
return 0;
}
static void my_exit(void)
{
return;
}
module_init(my_init);
module_exit(my_exit);
这段代码本身不做任何事情,但允许加载和卸载模块。加载驱动程序时,会调用 `my_init` 函数;卸载驱动程序时,会调用 `my_exit` 函数。我们通过 `module_init` 和 `module_exit` 宏向内核传达这一点。这些函数必须具有完全相同的签名:
int init(void);
void exit(void);
链接 `linux/module.h` 头文件对于将模块构建所针对的内核版本信息添加到模块本身是必需的。Linux OS 不允许加载为另一个内核版本构建的模块。这是因为内核 API 会发生剧烈变化,并且模块中使用的某个函数的签名发生变化会导致调用该函数时堆栈损坏。`linux/init.h` 头文件包含 `module_init` 和 `module_exit` 宏的声明。
字符设备注册
我们不纠缠于如此简单的模块。我想演示如何处理设备文件和内核中的日志记录。这些是每个驱动程序都会有用的工具,并且将在一定程度上扩展 Linux OS 内核模式下的开发。
首先,我想简单介绍一下设备文件。设备文件是通常位于 `/dev/` 文件夹层次结构中的文件。它是用户代码和内核代码交互的最简单、最易于访问的方式。简而言之,写入此类文件的所有内容都会传递给内核,传递给服务该文件的模块;从该文件读取的所有内容都来自服务该文件的模块。设备文件有两种类型:字符(无缓冲)文件和块(有缓冲)文件。字符文件意味着可以按字符读取和写入信息,而块文件仅允许整体读取和写入数据块。本文仅涉及字符设备文件。
在 Linux OS 中,设备文件由两个正数标识:**主设备号**和**次设备号**。**主设备号**通常标识服务设备文件或由模块服务的一组设备的模块。**次设备号**在定义**主设备号**的范围内标识特定设备。这两个数字可以定义为驱动程序代码中的常量,也可以动态获取。在第一种情况下,系统将尝试使用定义的数字,如果它们已被使用,则会返回错误。动态分配设备号的函数还会保留已分配的设备号,以便动态分配的设备号在分配或使用时不能被另一个模块使用。
可以使用以下函数来注册字符设备:
int register_chrdev (unsigned int major,
const char * name,
const struct fops);
file_operations *
它使用指定的名称和主设备号注册设备(如果 `major` 参数等于零,则会分配主设备号),并将 `file_operations` 结构链接到设备。如果该函数分配了主设备号,则返回值将等于分配的数字。否则,零值表示成功完成,负值表示错误。已注册设备与定义的主设备号关联,次设备号的范围是 0 到 255。
作为 `name` 参数传递的字符串是设备名称或模块名称(如果后者仅注册一个设备),用于在 `/sys/devices` 文件中标识该设备。`file_operations` 结构包含指向处理设备文件操作(如 open、read、write 等)的函数的指针,以及指向 `module` 结构的指针,该结构标识实现这些函数的模块。内核版本 2.6.32 的结构如下所示:
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, struct dentry *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*readv) (struct file *, const struct iovec *, unsigned long,
loff_t *);
ssize_t (*writev) (struct file *, const struct iovec *, unsigned long,
loff_t *);
};
为了使用该文件,不必实现 `file_operations` 结构中的所有函数。如果某个函数未实现,则相应的指针可以为零值。在这种情况下,系统将为该函数实现一些默认行为。对于我们的示例,实现 `read` 函数就足够了。
由于我们的驱动程序将提供一种设备的运行,我们可以创建一个全局 `static file_operations` 结构并静态填充它。它可以看起来像这样:
static struct file_operations simple_driver_fops =
{
.owner = THIS_MODULE,
.read = device_file_read,
};
这里,`THIS_MODULE` 宏(在 `linux/module.h` 中声明)将转换为指向与我们的模块对应的 `module` 结构的指针。`device_file_read` 是一个函数指针,其原型稍后将编写其主体。
ssize_t device_file_read (struct file *, char *, size_t, loff_t *);
因此,当我们有了 `file_operations` 结构后,我们可以编写一对函数来注册和注销设备文件:
static int device_file_major_number = 0;
static const char device_name[] = "Simple-driver";
static int register_device(void)
{
int result = 0;
printk( KERN_NOTICE "Simple-driver: register_device() is called." );
result = register_chrdev( 0, device_name, &simple_driver_fops );
if( result < 0 )
{
printk( KERN_WARNING "Simple-driver: can\'t register
character device with errorcode = %i", result );
return result;
}
device_file_major_number = result;
printk( KERN_NOTICE "Simple-driver: registered character device
with major number = %i and minor numbers 0...255"
, device_file_major_number );
return 0;
}
我们将主设备号存储在全局变量 `device_file_major_number` 中,因为在“驱动程序生命”结束时,我们将需要它来进行设备文件注销。
在上面的列表中,唯一没有提到的函数是 `printk()` 函数。它用于记录来自内核的消息。`printk()` 函数声明在 `linux/kernel.h` 文件中,其工作方式类似于 `printf` 库函数,除了一个细微之处。正如您已经注意到的,此列表中的每个 `printk` 格式字符串都有 `KERN_SOMETHING` 前缀。这是消息优先级,它可以有八个级别,从最高的零级(`KERN_EMERG`),表示内核不稳定,到最低的第七级(`KERN_DEBUG`)。
`printk` 函数形成的字符串被写入一个循环缓冲区。然后,`klogd` 守护进程从那里读取该字符串并将其发送到系统日志。`printk` 函数的编写方式使其可以从内核的任何地方调用。最坏的情况是循环缓冲区溢出,导致最旧的消息无法进入系统日志。
现在,我们只需要编写设备文件注销函数。其逻辑很简单:如果我们成功注册了设备文件,`device_file_major_number` 的值将不为零,我们将能够使用声明在 `linux/fs.h` 中的 `unregister_chrdev` 函数来注销它。第一个参数是**主设备号**,第二个参数是设备名称字符串。`unregister_chrdev` 函数的作用与 `register_chrdev` 函数完全对称。
我们得到以下用于设备注册的代码片段:
void unregister_device(void)
{
printk( KERN_NOTICE "Simple-driver: unregister_device() is called" );
if(device_file_major_number != 0)
{
unregister_chrdev(device_file_major_number, device_name);
}
}
用户模式下分配的内存使用
我们需要编写一个从设备读取字符的函数。它必须具有适合 `file_operations` 结构签名的签名:
ssize_t (*read) (struct file *, char *, size_t, loff_t *);
此函数的第一个参数是指向 `file` 结构的指针,我们可以从中找出详细信息:我们正在处理哪个文件,与之关联的私有数据是什么,等等。第二个参数是用户空间中为读取数据分配的缓冲区。第三个参数是要读取的字节数。第四个参数是从文件开始计算字节的偏移量(位置)。函数执行后,文件中的位置应得到刷新。此外,该函数应返回成功读取的字节数。
我们的 read 函数应执行的操作之一是将信息复制到用户在用户模式地址空间中分配的缓冲区。我们不能直接解引用用户地址空间的指针,因为该指针引用的地址在内核地址空间中可能具有不同的值。有一组特殊的函数和宏(声明在 `asm/uaccess.h` 中)用于处理来自用户地址空间的指针。`copy_to_user()` 函数最适合我们的任务。顾名思义,它将数据从内核缓冲区复制到用户分配的缓冲区。此外,`copy_to_user()` 函数会检查指针的有效性和用户空间中分配的缓冲区的大小是否足够。这使得驱动程序中的错误处理更加容易。`copy_to_user` 的原型如下:
long copy_to_user( void __user *to, const void * from, unsigned long n );
应传递给函数的第一个参数是缓冲区的用户指针。第二个参数应该是数据源的指针,第三个是复制的字节数。函数成功时返回 0,错误时返回非 0。函数原型中的 `__user` 宏用于文档记录。它还允许使用 `sparse` 静态代码分析器分析代码片段,以确保用户地址空间指针使用的正确性。用户地址空间的指针应始终标记为 `__user`。
我们只创建一个驱动程序示例,我们没有实际设备。因此,如果从我们的设备文件中读取总是返回一个文本字符串(例如,Hello world from kernel mode!),那就足够了。
现在,我们可以开始编写 `read` 函数的代码片段了:
static const char g_s_Hello_World_string[] = "Hello world from kernel mode!\n\0";
static const ssize_t g_s_Hello_World_size = sizeof(g_s_Hello_World_string);
static ssize_t device_file_read(
struct file *file_ptr
, char __user *user_buffer
, size_t count
, loff_t *position)
{
printk( KERN_NOTICE "Simple-driver:
Device file is read at offset = %i, read bytes count = %u"
, (int)*position
, (unsigned int)count );
/* If position is behind the end of a file we have nothing to read */
if( *position >= g_s_Hello_World_size )
return 0;
/* If a user tries to read more than we have, read only
as many bytes as we have */
if( *position + count > g_s_Hello_World_size )
count = g_s_Hello_World_size - *position;
if( copy_to_user(user_buffer, g_s_Hello_World_string + *position, count) != 0 )
return -EFAULT;
/* Move reading position */
*position += count;
return count;
}
内核模块构建系统
现在,当整个驱动程序代码片段都写好后,我们希望构建它并看看它的效果。在 2.4 版本内核中,要构建模块,开发者必须自己准备编译环境并使用 **GCC** 编译器编译驱动程序。编译结果是一个可加载到内核的 **.o** 文件。此后,内核模块的构建顺序发生了变化。现在,开发者只需编写一个特殊的 **makefile**,它将启动内核构建系统并告知内核应该构建什么模块。要从单个源文件构建模块,只需编写一个单行 **makefile** 并启动内核构建系统:
obj-m := source_file_name.o
模块名称将对应于源文件名,模块本身将具有 **.ko** 扩展名。
要从多个源文件构建模块,我们应该添加一行:
obj-m := module_name.o
module_name-objs := source_1.o source_2.o … source_n.o
我们可以使用 `make` 命令启动内核构建系统:
make –C KERNEL_MODULE_BUILD_SYSTEM_FOLDER M=`pwd` modules
用于模块构建,以及
make –C KERNEL_MODULES_BUILD_SYSTEM_FOLDER M=`pwd` clean
用于清理构建文件夹。
模块构建系统通常位于 `/lib/modules/`uname -r`/build` 文件夹中。在构建第一个模块之前,我们应该准备好模块构建系统。为此,我们应该进入构建系统文件夹并执行以下操作:
#> make modules_prepare
让我们将这些知识整合到一个 makefile 中:
TARGET_MODULE:=simple-module
# If we are running by kernel building system
ifneq ($(KERNELRELEASE),)
$(TARGET_MODULE)-objs := main.o device_file.o
obj-m := $(TARGET_MODULE).o
# If we running without kernel build system
else
BUILDSYSTEM_DIR:=/lib/modules/$(shell uname -r)/build
PWD:=$(shell pwd)
all :
# run kernel build system to make module
$(MAKE) -C $(BUILDSYSTEM_DIR) M=$(PWD) modules
clean:
# run kernel build system to cleanup in current directory
$(MAKE) -C $(BUILDSYSTEM_DIR) M=$(PWD) clean
load:
insmod ./$(TARGET_MODULE).ko
unload:
rmmod ./$(TARGET_MODULE).ko
endif
`load` 和 `unload` 目标用于加载已构建的模块和将其从内核中删除。
在我们的示例中,驱动程序由 `main.c` 和 `device_file.c` 这两段源代码文件编译而成,并且名称为 `simple-module.ko`。
模块加载及其使用
构建完模块后,我们可以通过在源文件所在的文件夹中执行以下命令来加载它:
#> make load
之后,在特殊的 `/proc/modules` 文件中会出现一个包含我们驱动程序名称的字符串。在我们模块注册的设备会在特殊的 `/proc/devices` 文件中出现一个字符串。它看起来会像这样:
Character devices:
1 mem
4 tty
4 ttyS
…
250 Simple-driver
…
设备名称前的数字是与之关联的**主设备号**。我们知道我们设备**次设备号**的范围(0...255),因此我们可以创建 `/dev` 虚拟文件系统中的设备文件:
#> mknod /dev/simple-driver c 250 0
创建设备文件后,我们将检查一切是否正常工作,并使用 `cat` 命令显示其内容:
$> cat /dev/simple-driver
Hello world from kernel mode!
参考文献列表
- Jonathan Corbet, Alessandro Rubini,Greg Kroah-Hartman Linux Device Drivers, Third Edition, O’Reilly, ISBN 978-0-596-00590-0 http://lwn.net/Kernel/LDD3/
- Peter Jay Salzman Ori Pomerantz The Linux Kernel Module Programming Guide https://tldp.cn/LDP/lkmpg/2.6/html/lkmpg.html
- Linux Cross Reference http://lxr.free-electrons.com/ident