翻译来自:
https://www.apriorit.com/dev-blog/195-simple-driver-for-linux-os
代码下载
此Linux设备驱动程序教程将为您提供有关如何为Linux操作系统编写设备驱动程序的所有必要信息。 本文包含一个易于遵循的实用Linux驱动程序开发示例。 我们将讨论以下内容:
我们将使用Linux内核版本2.6.32。 我们可以使用更新的版本,但是它们的API可能已被修改,因此可能与我们的示例和构建系统中使用的API不同。 学习本教程后,您将熟悉为Linux操作系统编写设备驱动程序或内核模块的过程。
Linux有一个单片内核。 因此,为Linux编写设备驱动程序需要与内核进行组合编译。 另一种方法是将驱动程序实现为内核模块,在这种情况下,您无需重新编译内核即可添加其他驱动程序。 我们将关注第二个选项:内核模块。
在其基础上,模块是专门设计的目标文件。 使用模块时,Linux会将它们加载到其内核空间,从而将它们链接到内核。 Linux内核是使用C编程语言和Assembler开发的。 C实现了内核的主要部分,而Assembler实现了依赖于体系结构的部分。 不幸的是,这些是我们用于编写Linux设备驱动程序的唯一两种语言。 我们不能使用用于Microsoft Windows操作系统内核的C ++,因为Linux内核源代码的某些部分 - 特定的头文件 - 可能包含来自C ++的关键字(例如, delete或new ),而在Assembler中我们可能会遇到诸如’ : : ‘词汇。
我们在内核上下文中运行模块代码。 这需要开发人员非常专注,因为它需要承担额外的责任:如果开发人员在实现用户级应用程序时出错,在大多数情况下这不会导致用户应用程序之外的问题; 但是如果开发人员在实现内核模块时出错,后果将是系统级别的问题。 幸运的是,Linux内核有一个很好的功能,可以抵御模块代码中的错误。 当内核遇到非严重错误(例如,空指针解除引用)时,您将看到oops消息(Linux操作期间无意义的故障称为oops ),之后将卸载故障模块,允许内核和其他模块像往常一样工作。 此外,您还可以在内核日志中找到准确描述此错误的记录。 但请注意,不建议在oops消息之后继续工作,因为这样做可能会导致不稳定和内核恐慌。
内核及其模块本质上代表一个程序模块 - 因此请记住,单个程序模块使用单个全局命名空间。 为了最小化它,您必须观察模块导出的内容:导出的全局字符必须唯一命名(常用的解决方法是简单地使用将字符导出为前缀的模块的名称)并且必须剪切到最低限度。
要创建一个简单的示例模块,我们不需要做太多工作。 这里有一些代码可以证明这一点:
#include
#include
static int my_init(void)
{
return 0;
}
static void my_exit(void)
{
return;
}
module_init(my_init);
module_exit(my_exit);
这个模块唯一做的两件事就是加载和卸载自己。 要加载Linux驱动程序,我们调用my_init函数,并卸载它,我们调用my_exit函数。 module_init和module_exit宏通知内核有关驱动程序的加载和卸载。 my_init和my_exit函数必须具有相同的签名,这些签名必须完全如下:
int init(void);
void exit(void);
如果模块需要某个内核版本并且必须包含有关版本的信息,我们需要链接linux / module.h头文件。 尝试加载为另一个内核版本构建的模块将导致Linux操作系统禁止其加载。 出现这种情况的原因是:内核API的更新经常被释放,当您调用其签名已更改的模块函数时,会对整个堆栈造成损害。 module_init和module_exit宏在linux / init.h头文件中声明。
上面的示例模块非常简单; 现在我们将开始处理更复杂的事情。 然而,这个简短的Linux内核驱动程序教程的目的之一是展示如何使用登录内核以及如何与设备文件交互。 这些工具可能很简单,但它们可以为任何驱动程序派上用场,并且在某种程度上,它们使内核模式开发过程更加丰富。
首先,这里有一些有关设备文件的有用信息。 通常,您可以在/ dev文件夹中找到设备文件。 它们促进了用户和内核代码之间的交互。 如果内核必须接收任何内容,您只需将其写入设备文件即可将其传递给提供此文件的模块; 从设备文件中读取的任何内容都来自提供此文件的模块。 我们可以将设备文件分为两组:字符文件和块文件。 字符文件是非缓冲的,而块文件是缓冲的。 正如其名称所暗示的那样,字符文件允许您逐个字符地读取和写入数据,而块文件允许您只写入整个数据块。 我们将讨论块文件超出本文的范围,并将直接获得字符文件。
Linux系统有一种通过主设备号识别设备文件的方法, 主设备号识别服务设备文件或一组设备的模块,以及次要设备号 ,用于识别主设备号指定的一组设备中的特定设备。 在驱动程序代码中,我们可以将这些数字定义为常量,也可以动态分配它们。 如果已经使用了定义为常量的数字,系统将返回错误。 当动态分配一个数字时,该函数保留该数字以禁止其他任何数字使用它。
下面引用的函数用于注册字符设备:
int register_chrdev (unsigned int major,
const char * name,
const struct fops);
file_operations *
在这里,我们指定要注册它的设备的名称和主要编号,之后将链接设备和file_operations结构。 如果我们为主参数指定零,该函数将自己分配一个主设备号(即它返回的值)。 如果返回的值为零,则表示成功,而负数表示错误。 两个设备编号均在0-255范围内指定。
我们将设备名称作为name参数的字符串值传递(如果模块注册单个设备,则此字符串也可以传递模块的名称)。 然后,我们使用此字符串来标识/ sys / devices文件中的设备。 读取,写入和保存等设备文件操作由存储在file_operations结构中的函数指针处理。 这些函数由模块实现,并且指向标识该模块的module结构的指针也存储在file_operations结构中。 在这里你可以看到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函数。
由于我们要确保只使用我们的Linux驱动程序操作单一类型的设备,因此我们的file_operations结构将是全局和静态的。 相应地,在它创建之后,我们需要静态填充它。 在这里你可以看到这是如何完成的:
static struct file_operations simple_driver_fops =
{
.owner = THIS_MODULE,
.read = device_file_read,
};
THIS_MODULE宏的声明包含在linux / module.h头文件中。 我们将宏转换为指向所需模块的模块结构的指针。 稍后,我们将用原型编写函数体,但是现在我们只有指向它的指针,即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功能。 这个函数的声明包含在linux / kernel.h文件中,它的任务很简单:记录内核消息。 毫无疑问,请注意KERN_NOTICE和KERN_WARNING前缀,这些前缀出现在printk的所有列出的格式字符串中。 您可能已经猜到, NOTICE和WARNING表示消息的优先级。 级别从最无关紧要的KERN_DEBUG到关键的KERN_EMERG ,提醒内核不稳定。 这是printk函数和printf库函数之间的唯一区别。
printk函数形成一个字符串,我们将其写入循环缓冲区, klog守护程序将其读取并将其发送到系统日志。 printk函数的实现允许从内核中的任何地方调用它。 最糟糕的情况是循环缓冲区溢出,这意味着最旧的消息不会记录在日志中。
下一步是编写一个函数来恢复设备文件的注册。 如果成功注册了设备文件,则device_file_major_number的值将不为零。 这允许我们使用nregister_chrdev function撤销文件的注册,我们在linux / fs.h文件中声明了该nregister_chrdev function 。 主设备号是此函数的第一个参数,后跟包含设备名称的字符串。 register_chrdev和unresister_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结构的指针。 此file结构允许我们获取有关我们正在使用的文件的必要信息,有关此当前文件的私有数据的详细信息,等等。 已读取的数据使用第二个参数(即缓冲区)分配给用户空间。 读取的字节数在第三个参数中定义,我们从第四个参数中定义的某个偏移量开始读取字节。 执行该函数后,必须返回已成功读取的字节数,之后必须刷新偏移量。
用户在用户模式地址空间中分配特殊缓冲区。 而read函数必须执行的另一个操作是将信息复制到此缓冲区。 来自该空间的指针指向的地址和内核地址空间中的地址可以具有不同的值。 这就是我们不能简单地取消引用指针的原因。 使用这些指针时,我们有一组特定的宏和函数,我们在asm / uaccess.h文件中声明。 在我们的例子中,最合适的函数是copy_to_user() 。 它的名字不言而喻:它只是将特定数据从内核缓冲区复制到用户空间中分配的缓冲区。 此外,它还验证指针是否有效以及缓冲区大小是否足够大。 因此,可以相对容易地处理驱动器中的错误。 这是copy_to_user原型的代码:
long copy_to_user( void __user *to, const void * from, unsigned long n );
首先,该函数必须接收三个指针作为参数:指向缓冲区的指针,指向数据源的指针,以及指向复制的字节数的指针。 正如我们所提到的,错误返回的值不是零,并且在成功执行的情况下,该值将为零。 该函数包含_user宏,其任务是执行文档处理。 它有另一个有用的应用程序,它允许我们分析代码是否正确使用地址空间中的指针; 这是使用稀疏分析器完成的,稀疏分析器执行静态代码分析。 确保始终将用户地址空间指针标记为_user 。
本教程仅包含没有实际设备的Linux驱动程序编程示例。 如果在读取设备文件后不需要返回除文本字符串以外的任何内容,那么这就足够了。
这是实现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。
从源文件文件夹执行以下命令允许我们加载构建的模块:
sudo make load
执行此命令后,驱动程序的名称将添加到/proc/modules文件中,而模块注册的设备将添加到/proc/devices文件中。 添加的记录如下所示:
Character devices: 1 mem 4 tty 4 ttyS … 239 Simple-driver …
cat /proc/devices | grep Simple-driver
$ 239 Simple-driver
前三个记录包含添加的设备的名称以及与之关联的主设备号。 次编号范围(0-255)允许在/ dev虚拟文件系统中创建设备文件。
sudo mknod /dev/simple-driver c 239 0
在我们创建设备文件之后,我们需要执行最终验证以确保我们所做的工作按预期工作。 要验证,我们可以使用cat命令显示内容:
cat /dev/simple-driver
$ Hello world from kernel mode!
Demo代码下载
device_file.c
有一点编译问题,这样修正。
copy_to_user -> raw_copy_to_user
查看内核日志
dmesg
玩~