《Linux设备驱动程序1-3章》

设备驱动程序简介

以Linux为代表的的开源操作系统有许多优点,其中之一就是让更多的人了解操作系统的细节,方便地进行理解、修改和验证操作系统,让操作系统更民主化。学习开发设备驱动程序是切入了解操作系统的最有效方式。

人们对Linux驱动程序开发的感兴趣的原因有很多,首先是新硬件不断面世,其次是人们需要了解驱动程序才能方便使用设备,另外硬件厂商需要为自己的设备开发驱动。

设备驱动程序的作用

设备驱动程序的终极目标是提供机制,而不是提供策略。区分机制和策略是Unix设计背后隐含的最好思想之一。大多数编程问题实际上都可以分成两部分,需要提供什么功能(机制)和如何使用这些功能(策略)。这两个问题由不同模块来实现和处理会更容易开发和维护。

在实际编程中经常遇到机制和策略的分离问题。例如LED驱动的基本功能是亮和灭,上层应用来决定什么时候亮什么时候灭,以及要亮多久。驱动程序尽可能做到不带策略。编写访问硬件的内核代码时不要给用户强加任何特定策略,因为不同用户有不同的需求,驱动程序应该处理如何使硬件可用的问题,而怎样使用硬件的问题留给上层应用程序

从软件分层角度来看,驱动程序是应用程序和实际硬件之间的一个软件层。驱动程序的设计主要考虑以下三个方面:1)提供给用户尽量多的选项。2)编写驱动程序要占用的时间。3)尽量保持程序简单不至于错误丛生。不带策略的驱动程序有一些典型特征:同时支持同步和异步操作、被多次打开、充分利用硬件特性、不具备用来“简化任务”的或提供策略相关的软件层。

可装载模块

Linux有一个很好的特性,内核提供的特性可在运行时进行扩展,这意味着当系统启动并运行时可在内核添加功能。使用insmod程序将模块连接到内核,也可以使用rmmod程序来移除连接。

设备和模块的分类

Linux系统将设备分成三种基本类型:字符模块、块模块、网络模块
字符设备:字节流设备如串口设备,特点是只能顺序访问,通常有open、close、read、write等接口。
块设备:允许一次传递任意多字节数据。块设备能够容纳文件系统。
网络接口:网络设备围绕数据包的传输和接收而设计。

安全问题

弄清楚安全问题的原则性概念。

  • 系统中的所有安全检查都由内核代码进行,如果内核有安全漏洞,则整个系统就会有安全漏洞。只允许超级用户装载模块,防止内核入侵风险。
  • 驱动程序编写者应该避免实现安全策略。安全策略问题最好在系统管理员的控制之下,由内核的高层来实现。安全检查必须由驱动程序本身完成。
  • 驱动程序编写者应避免自身原因引入安全方面的缺陷。防止出现C编程语言的典型错误,如缓冲区溢出导致的内存被踩影响其他程序执行。
  • 任何从用户得到的输入都要经过内核严格验证后才能使用
  • 小心对待未初始化的内存,内核申请的内存提供给用户之前都要处理,防止信息泄露(数据和密码)。
  • 小心使用第三方获得的软件,特别是与内核相关的。因为源码是开放的,每个人都可以修改和重新编译它,如果对源码不熟悉,可能存在某些安全漏洞。

版本编号

对内核来说,偶数编号的内核是用于正式发行的稳定版本,而奇数编号则是开发过程中的一个快照,它很快就会被下一个开发版本更新。

每个软件包都有发行编号,而软件包之间经常存在相互的依赖关系,也就是说某个软件包依赖某个软件包的特定版本,Linux发行版一般会解决了复杂的包匹配问题,但是如果替换或者更新系统中的某个软件包,则另当别论。

电子书籍:https://lwn.net/Kernel/LDD3

构造和运行模块

设置测试系统

发行商提供的内核通常打了许多的补丁,从而和主线内核有很大差异,甚至会修改内核的API,因此学习驱动程序的编写,读者应该使用标准内核。
开发的内核驱动程序可能会有很多bug,有可能导致严重系统异常,所以应该寻找一个试验、开发和测试的环境,典型的是使用QEMU环境。

hello world模块

#include <linux/init.h>
#include <linux/module.h>

static int hello_init(void)
{
    printk(KERN_ALERT"Hello world\n");
    return 0;
}
static void hello_exit(void)
{
    printk(KERN_ALERT"Goodbye, cruel world\n");
}
MODULE_LICENSE("Dual BSD/GPL"); // 开源许可声明
module_init(hello_init); // 模块加载入口声明
module_exit(hello_exit); // 模块卸载入口声明

函数printk在Linux内核中定义,功能和标准C库中的函数printf类似,并且提供了打印级别控制等功能。内核需要自己单独的打印输出函数,这是因为它在运行时不能依赖C库?。模块能够调用printk是因为insmod函数装入模块后,模块就连接到了内核,因而能够访问内核的公用符号(函数、变量)。优先级只是个字符串,例如KERN_ALERT是<1>,该字符串位于printk格式字符串的前面。请注意KERN_ALERT之后并不使用逗号。

核心模块和应用程序的对比

内核模块和应用程序之间存在种种不同之处

  • 大部分应用程序从头到尾执行单个任务;而模块只是预先注册自己以便服务将来的某个请求(接口调用),换句话就是模块初始化的任务就是为以后提供功能做准备。内核模块是类似事件驱动的编程方式。
  • 事件驱动的应用程序不需要管理资源的申请和释放;内核模块要在申请资源时进行验证,退出时仔细撤销初始化函数所做的一切,否则会有残留资源再也得不到调度。
  • 应用程序通过链接第三方函数库,可以使用未定义的函数;模块仅仅链接到内核,因此只能使用内核导出的那些函数,而不存在任何可链接的函数库。
  • 异常处理方式不同,应用程序开发过程的段错误是无害的;而内核模块错误有可能会导致整个系统异常。

模块化有利于快速的测试驱动程序,不需要每次都经过冗长的关机/重启过程。内核头文件大部分保存在include/linux和include/asm目录中。

用户空间和内核空间

模块运行在内核空间,而应用程序运行在所谓的用户空间。这个概念是操作系统理论的基础之一。操作系统作为应用程序和硬件之间的软件层,为应用程序提供统一的接口,除此之外,还保护资源不受非法访问。目前所有的操作系统都具备这个功能,人们选择的方法是实现不同的操作模式。不同的操作级别具有不同的访问权限。例如最新的ARM v8系列CPU共有4个级别分为为EL0~EL3。EL0是安全世界且权限最大,EL3是应用程序的级别权限最小。

内核中的并发

内核编程为什么需要考虑并发问题

  • 首先Linux系统通常运行多个并发进程,并且多个进程可能同时使用同一个驱动程序。
  • 其次中断处理程序也会打断CPU,而且还存在内核定时器。
  • 如果是SMP系统,通常不止一个CPU运行着同样的驱动程序。

其他一些细节

  • 内核代码可通过访问全局项current来获得当前进程。current在<asm.current.h>中定义。是一个指向struct task_struct的指针,这个结构体定在<linux/sched.h>中。内核开发者设计了一种能够找到运行在相关CPU上的当前进程的机制,将task_struct结构的指针隐藏在内核栈中。
  • 应用程序在虚拟内存中布局,并且具有一块很大的栈空间。然而,内核具有非常小的栈,它可能只有一个4k的页那么小。
  • 通常具有两个下划线前缀(__)的函数名称,应该谨慎使用,这通常是接口的底层组件。

编译和装载

装载模块的命令是insmod,它和ld有些类似,将模块的代码和数据装入内核,然后使用内核的符号表解析模块中任何未解析的符号。insmod依赖于定义在kernel/module.c中的一个系统调用。函数sys_init_module给模块分配内核内存以便装载模块,然后该系统调用将模块正文复制到内存区域,并通过内核符号表解析模块中的内核引用,最后调用模块的初始化函数。通常系统调用的函数名字带有前缀sys_,而其他函数都没有这个前缀。

modprobe工具也用来装载模块到内核中,但与insmod的区别是,它会考虑装载的模块是否引用了当前内核不存在的符号,如果有这类引用,modprobe会试图找到这些引用所在的模块并一起装载到内核中。如果在这种情况下使用insmod,则该命令会失败,并在系统日志中记录“unresolved symbols”消息。

rmmod工具用来移除模块。注意,如果内核认为模块仍然在使用状态或者内配置为禁止移除模块,那么无法移除该模块。

lsmod工具用来列出当前装载到内核中的所有模块。

版本依赖

在构造模块时可将模块和当前内核树中的一个文件vermagic.o链接;该目标文件包含了大量有关内核的信息,包括目标内核版本、编译器版本、以及一些其他重要配置变量的设置。在试图装载模块时会检查模块与当前内核的兼容性,如果有任何不匹配,就不会装载模块,同时有“invalid module format”信息。在linux/version.h中会有版本号相关的宏定义,例如UTS_RELEASE被扩展为内核版本的字符串“2.6.10”。

内核符号表

前面提到,modprobe工具会解决模块间依赖,并把相关模块一同装载到内核中,这其实是模块层叠技术的体现。通过将模块分为多个层,能够缩短开发时间。Linux内核头文件提供了一个方便的方法来管理符号对模块外部的可见性,从而减少了可能造成的名称空间污染,并且适当隐藏信息。如果一个模块需要向其他模块导出符号,则应该使用下面的宏。_GPL版本使得要导出的模块只能被GPL许可证下的模块使用。符号必须是全局的变量。(更多信息查看linux/module.h文件)

EXPORT_SYSMBOL(name)
EXPORT_SYSMBOL_GPL(name)

其他模块声明:
MODULE_LICENSE("GPL") 指定代码使用的许可证,内核能够识别的许可证还有“GPL”(任一版本的GNU通用公共许可证)、“GPL v2”、“Dual BSD/GPL”以及“Proprietary”(专有)。如果没有显示声明的话,则假定为专有的。
MODULE_AUTHOR("name") 描述作者姓名
MODULE_DESCRIPTION("function") 描述模块简短作用
MODULE_VERSION("ver") 描述代码修订号

模块初始化和关闭的注意事项

初始化

模块初始化函数负责注册模块所提供的任何设施,这里的设施指的的一个新功能。初始化函数应该是static的,意味着不应该对其他文件可见。__init标记暗示内核该函数仅在初始化期间使用,在模块装载之后这部分内存可释放发来。module_init声明是强制的。

清除函数

清除函数没有返回值。__exit标记该段代码仅用于模块卸载。module_exit声明是强制的。

初始化过程中的错误处理

  • 当我们在内核中注册设施时,要时刻铭记注册可能会失败。即便最简单的动作需要判断是否成功,因此模块代码必须始终检查返回值。
  • 如果注册设施时遇到任何错误,首先判断模块是否可以继续初始化,是否可以降低功能来继续运转。
  • 如果发生特定错误无法继续提供服务,那么要仔细核对撤销出错之前已注册的工作,因为没有撤销已注册的设施,那么内核会处于一种不稳定状态。
  • 错误恢复的处理通常使用goto语句,可避免大量复杂的、高度缩进的结构化逻辑。
  • 每次返回合适的错误编码是一个好习惯,可帮助迅速找到问题原因。
  • 如果已注册设施过多,则goto方法可能变得难以管理,那么每次失败调用同一个清楚函数会更清晰(清除函数要检查注册设施的状态)。

模块装载竞态

首先要铭记的是,在注册完成之后,内核的某些部分可能会立即使用我们刚刚注册的任何设施。因此在注册设施之前务必要做完该设施的初始化。

模块参数

在insmod装载模块时可向模块传入参数。参数必须使用module_param宏来声明,才能对外部可见。module_param需要三个参数,变量名字、类型、以及用于sysfs入口项的访问许可掩码。这些宏定义在<moduleparam.h>文件。perm访问许可值,决定了模块参数在sys/module路径下的读写属性。如果参数通过sysfs修改,则如同内核修改了这个参数的值一样,但是内核不会以任何方式通知模块。在加载模块时可传递相应的参数值。

static char *who = "world";

static int howmany = 1;

module_param(who, charp, S_IRUGO);
module_param(howmany, int, S_IRUGO);
module_param_array(name, type, num, perm); // 还可定义数组

字符设备驱动程序

开发字符设备驱动程序的原因是因为此类驱动程序适合大多数简单地硬件设备,scull:simple character utility for loading localities。scull的优点在于它不依赖于硬件,而只是操作从内核分配的一些内存。

主设备号和次设备号

如果执行命令 ls -l /dev,则在设备文件项的最后修改日期前看到两个数(用逗号分隔),分别对应主设备号和次设备号。
主设备号标识设备对应的驱动程序;次设备号由内核程序使用,标识同类型设备的不同设备。

img

设备编号的内部表示

Linux内核中,设备号用dev_t来描述,在<linux/types.h>中定义。dev_t是一个32位无符号整数,其中高12位用来表示主设备号,低20位表示次设备号。这些都是通过宏定义的,我们软件不能做任何假定。获取主次设备号要通过宏的方式<linux/kdev_t.h>

#define MINORBITS    20
#define MINORMASK    ((1U << MINORBITS) - 1)
#define MAJOR(dev)    ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev)    ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi)    (((ma) << MINORBITS) | (mi))

分配和释放设备编号

在建立一个字符设备之前,我们的驱动程序首先要做的事情就是获得一个或者多个设备编号。<linux/fs.h>中声明了有关接口,register_chrdev_region用于明确知道设备编号的情况;alloc_chrdev_region则用于动态申请设备编号,不论使用哪种方法分配设备编号,都应该在不再使用时释放这些编号。强烈建议新驱动程序使用动态分配机制获取主设备号,避免软件开源后与其他程序产生冲突。

int register_chrdev_region(dev_t from, unsigned count, const char *name)
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
void unregister_chrdev_region(dev_t from, unsigned count);

读取cat /proc/devices文件可知道系统下面所有设备编号对应的驱动程序。

Character devices:
1 mem
4 /dev/vc/0
4 tty
5 /dev/tty
5 /dev/console

Block devices:
1 ramdisk
7 loop
8 sd

重要的数据结构

文件操作 file_operations

迄今为止,我们申请了设备编号,但尚未将任何驱动程序操作连接到这些编号。file_operations结构就是用来建立这种连接的,这个结构体定义在<linux/fs.h>中。每个打开的文件在内核中用file结构体表示,file结构体包含一个file_operations结构的指针。我们可以认为文件是一个对象,而操作它的函数是方法,这是内核应用面向对象编程的一个例证。file_operations结构或者指向它的指针称为fops,这个结构中的每个字段都必须指向驱动程序中实现特定操作的函数,对于不支持的操作对应字段可设置为NULL值。从file_operations结构的成员函数来看,其入参大多为file结构,也验证了其操作对象主要为file。

struct file_operations {
  struct module *owner;
  loff_t (*llseek) (struct file *, loff_t, int);
  ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
  ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
  ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
  ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
  __poll_t (*poll) (struct file *, struct poll_table_struct *);
  int (*mmap) (struct file *, struct vm_area_struct *);
  void (*show_fdinfo)(struct seq_file *m, struct file *f);
}

文件结构 file

在<linux/fs.h>定义的struct file是设备驱动程序所使用的重要数据结构,注意与用户空间程序中的FILE没有任何联系。FILE在C库中定义且不会出现在内核代码中。而struct file是一个内核结构,它不会出现在用户程序中。file结构代表一个打开的文件。它由内核打开并传递给在该文件上操作的所有函数。指向struct file的指针通常称为file或者filp文件指针。

const struct file_operations *f_op; /* 与文件相关的操作 */

unsigned int f_flags;  /* 文件标志 */

fmode_tf_mode;  /* 文件模式 */

loff_tf_pos; /* 当前文件位置 */

inode结构

内核用inode结构表示文件。它和file结构不同,file表示打开的文件描述符,inode是文件在内核中的组织结构。对单个文件,可能存在许多个表示打开的文件描述符file结构,但它们都指向单个inode结构。

dev_t i_rdev 表示设备文件的inode结构,该字段包含真正的设备编号

struct cdev *i_cdev 表示字符设备的内核的内核结构。

img

file_operations重要的函数操作

open方法

open方法提供给驱动程序以初始化的能力,从而为以后的操作完成初始化做准备。在大部分驱动程序中,open应完成如下工作:

  • 检查设备特定的错误,诸如设备未就绪或类似的硬件问题
  • 如果设备是首次打开,则对其进行初始化
  • 如有必要,更新f_op指针
  • 分配并填写置于filp->private_data里的数据结构

int (*open) (struct inode *, struct file *);

release/close方法

release方法的作用于open相反,有时这个方法被称为device_close而不是device_release。释放由open分配、保存在filp->private_data中的所有内容;在最后一次关闭操作时关闭设备。并不是每次close系统调用时都会调用release方法。内核维持一个文件被使用的次数(fork/dup)都不创建新文件,而只是新增结构中的计数。当调用close递减为0时才执行release。

read/write方法

对于这两个方法,filp是文件指针,count是请求传输数据的大小。buff是指向用户空间的缓冲区(这个缓冲区保存要写入的数据),这个offp是用户正在读取文件filp的位置。__user标识buff为用户空间指针,buff不能被内核直接引用,在代码中没有其他实际作用,可用于静态检查。

read和write工作否核心是在用户空间和内存地址之间进行整段数据的拷贝,这种能力是通过copy_from_user / copy_to_user 内核函数提供的。copy*函数用于用户空间和内核空间传输,它们的作用不仅限于memcpy,还会检查用户空间指针的有效性。

ssize_t read(struct file *filp, char __user *buff, size_t count, loff_t *offp);
ssize_t write(struct file *filp, const char __user *buff, size_t count, loff_t *offp);

在用户空间和内核空间拷贝数据
unsigned long copy_from_user (void *to, const void *from, unsigned long count);
unsigned long copy_to_user (void *to, const void *from, unsigned long count);

posted @ 2021-07-11 22:30  zephyr~  阅读(509)  评论(0编辑  收藏  举报