本系列文章是对《Linux Device Drivers》一书的个人笔记,作为学习过程的记录,供日后参考回顾用,不保证可读性和条理性。
设备驱动程序简介
机制和策略
区分机制和策略是Unix的设计哲学之一。
- 机制:需要提供什么功能
- 策略:如何使用这些功能
举例一:TCP/IP网络,位于下层的操作系统负责提供套接字抽象,但在所传输的数据上没有附加任何策略;上层的服务器则分别提供不同的服务。
举例二:ftpd服务器提供文件传输机制,用户可以使用任何自己喜欢的客户端传输文件,例如命令行和图形客户端。
驱动程序应当不带策略:编写访问硬件的内核代码时,不要给用户强加任何特定策略。不同的用户有不同的需求,驱动程序应该处理如何使用硬件可用的问题,而降怎样使用硬件的问题留给上层应用程序。因此,当驱动程序只提供了放文件硬件的功能而没有附加任何限制是,这个驱动程序就比较灵活。
不带策略的驱动程序包括一些典型特征:
- 支持同步和异步操作
- 驱动程序能够被多次打开、充分利用硬件特性
- 不具备用来简化任务的软件层
- 不具备提供与策略相关的软件层
内核功能划分
- 进程管理:创建和销毁进程,处理进程的输入输出,进程间通信,进程调度器
- 内存管理:虚拟内存,分配释放内存
- 文件系统:Unix中一切对象皆文件
- 设备控制:驱动物理设备
- 网络功能:应用程序和网络接口间传递数据包,路由和地址解析
设备和模块的分类
可装载模块,在运行时添加和移除模块:
insmod
rmmod
设备分类:
- 字符设备
- 能够像字节流(类似文件)一样被访问的设备
- 设备节点,比如
/dev/console, /dev/tyys0
- 与普通文件的差别在于字符设备是一个只能顺序访问的数据通道,不能前后移动访问位置
- 块设备
- 和字符设备类似,通过
/dev
目录下的文件系统节点来访问 - 块设备上容纳文件系统
- IO操作时块设备每次只能传输一个或多个完整的块,每个块包含512(或其他)字节
- 和字符设备类似,通过
- 网络接口
- 网络接口不是面向流的设备,没有映射到文件系统中的节点
- 分配唯一的名字,比如
eth0
- 内核和网络设备驱动之间的通信是数据包传输相关的函数
安全问题
- 尽量避免在驱动程序代码中实现安全策略,应该在系统管理员的控制下,由内核的高层来实现
- 对有可能会影响整个系统的行为,进行必要的安全检查,由驱动程序来完成
- 避免代码引入的缺陷,比如缓冲区溢出
- 对任何用户的输入进行严格的验证后使用
- 对任何内存都进行初始化
- 将某些敏感操作限于特权用户
用户空间和内核空间
OS的作用是位应用程序提供一个对计算机硬件的一致视图。OS必须负责程序的独立操作并保护资源不受非法访问。这个重要任务只有在CPU能够保护系统软件不受应用程序的破坏时才能完成。
CPU中实现不同的操作模式,不同级别具有不同的功能,在较低级别中将禁止某些操作。Unix使用了这样的级别,当CPU存在多个级别时,Unix使用最高级别和最低级别:
- 内核运行在最高级别,可以进行所有的操作;
- 用户程序运行在最低级别,CPU控制着对硬件的直接访问以及对内存的非授权访问。
通常将运行模式称作内核空间和用户空间。说明两种模式具有不同的优先权等级,每个模式都有自己的内存映射,即自己的地址空间。
应用程序执行系统调用或者被硬件中断挂起时,Unix切换到内核空间:
- 执行系统调用的内核代码运行在进程上下文中,它代表调用进程执行操作,能够访问进程地址空间的所有数据。
- 处理硬件中断的内核代码和进程是异步的,与任何一个特定进程无关。
一个驱动程序要执行两类任务:
- 模块中的某些函数作为系统调用的一部分执行;
- 其他函数负责处理中断;
构造和运行模块
在编写内核模块之前,需要配置好编译和测试环境;关键是设置与运行的内核版本一致的内核源码树,并完成内核的编译。
Hello World模块
示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A Simple Module");
MODULE_VERSION("1.0.0");
//MODULE_DEVICE_TABLE(table_info);
//MODUEL_ALIAS(alternate_name);
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_init(hello_init);
module_exit(hello_exit);Makefile
1
2
3
4
5
6
7
8
9
10
11
12
13
14obj-m := hello_module.o
KERNELDIR := ~/Workspace/linux-rpi-4.19.y
PWD := $(shell pwd)
CROSS_TOOL := arm-linux-gnueabihf-
TARGET_ARCH := arm
# 指定交叉编译工具链和目标平台架构
all:
make -C $(KERNELDIR) M=$(PWD) modules CROSS_COMPILE=$(CROSS_TOOL) ARCH=$(TARGET_ARCH)
# 首先改变目录到 -C 指定的内核源代码目录,其中有内核的顶层makefile文件;
# M= 选项让make在构造modules目标之前返回到模块源代码目录;
# modules 目标指向 obj-m 变量中定义的模块;
# 其他选项指定交叉编译工具链和目标硬件平台架构;安装运行:
sudo insmod ./hello.ko
modprobe
与insmod类似,但是它考虑要装载的模块是否引用了一些当前内核中不存在的符号;如果有,modprobe会在当前模块搜索路径查找定义了该符号的其他模块,并进行加载。lsmod
列出当前装载到内核中的所有模块;lsmod通过读取/proc/modules
虚拟文件系统来获得这些信息。- 有关当前已加载模块的信息也可以在 sysfs 虚拟文件系统的
/sys/module
下找到。
查看log:
dmesg
或cat /var/log/messages
内核中的并发
Linux内核代码包括驱动程序代码必须是可重入的,它必须能够同时运行在多个上下文中。要仔细编写代码,处理并发问题,防止竞态发生。
当前进程
前面提到,内核模块会运行在进程上下文中。内核代码可以通过访问全局项 current
来获得当前进程。current定义在 struct task_struct
的指针,task_struct结构定义在
1 | struct task_struct *current; |
current指针指向当前正在运行的进程。在open,read等系统调用的执行过程中,当前进程指的是调用这些系统调用的进程。
对current的引用会频繁发生,不依赖于特定架构的实现机制通常是将指向task_struct结构的指针隐藏在内核栈中。
下面的代码通过访问某些成员打印当前进程的ID和命令名:
1 | printk(KERN_INFO "The process is \"%s\" (pid %i)\n", |
内核编程的限制
- 内核栈非常小,通常大小只有一个页4096字节;不要声明大的自动变量;
- 具有2个下划线(__)前缀的函数通常是接口的底层组件,应谨慎使用。双下划线告诉程序员,谨慎调用,否则后果自负;
- 内核代码不能实现浮点运算;
内核版本宏
内核版本相关的宏定义在 <linux/version.h>
中,该头文件自动包含于 <linux/module.h>
中。
UTS_RELEASE
:描述内核版本的字符串,比如 “2.6.10”LINUX_VERSION_CODE
:内核版本的整数表示,比如2.6.10对应的是 132618KERNEL_VERSION(major,minor,release)
:根据输入的三个参数,创建整数版本号;比如 KERNEL_VERSION(2,6,10)扩展为 132618
内核符号表
模块被装入内核后,所导出的任何符合都会变成内核符号表的一部分;这些导出的符号可以为其他模块所用。
模块使用下面的宏导出符号:
1 | EXPORT_SYMBOL(name); |
符合只能在模块文件的全局部分导出,上述宏被扩展为特殊变量声明,该变量必须是全局,并保存在可执行文件的一个特殊ELF段中。
初始化和关闭
初始化函数的定义如下:
1 | static int __init initialization_function(void) |
__init
标记表示该函数尽在初始化期间使用;在模块被装载后,模块装载器会将初始化函数扔掉,释放部分内存。__initdata
作用类似,用于标记数据结构- 注意,不要在结束初始化后仍要使用的函数或数据结构上使用这两个标记
__devinit/__devinitdata
在内核未被配置为支持热插拔设备的情况下,这两个标记才会被翻译为对应的上述两个标记
卸载函数的定义如下:
1 | static void __exit cleanup_function(void) |
__exit/__exitdata
标记表明该代码仅用于卸载模块;- 被标记为__exit的函数只能在模块被卸载或者系统关闭时被调用,其他任何用法都是错误的
- 如果一个模块未定义卸载函数,则内核不允许卸载该模块
初始化错误处理
模块注册可能会失败,因为需要内存分配,而所需内存可能无法获得。因此,模块代码必须始终检查返回值,确保所请求的操作已真正成功。
内核错误编码定义在 <linux/errno.h>
中的负整数,比如 -ENODEV/-ENOMEM
;程序可以通过 perror 函数将错误编码转换为错误文字信息。
如果注册过程中发生了严重错误,则要讲出错之前的任何注册工作撤销掉。
- 错误处理可以使用
goto
语句(可能是唯一使用goto的情况),内核经常使用goto来处理错误。 - 另外的方法,是发生错误时从初始化函数中调用清楚函数,清除函数在撤销每项设施之前必须检查它的状态。
- 需要注意的是,因为卸载函数被非退出代码使用,不能将其标记为__exit
模块装载竞争
- 在初始化函数还在运行的时候,内核完全可能会调用我们的模块。因此在首次注册完成后,代码就应该准备被内核其他部分调用;在用来支持某个设施的内部初始化完成前,不要注册任何设施。
- 如果 初始化失败,则应该仔细处理内核其他部分正在进行的操作,并且要等待这些操作的完成。
模块参数
使用insmod或modprobe命令装载模块时传递参数,例子如下:
1 | insmod hello.ko howmany=10 whom="Bob" |
模块必须让参数(howmang,whom)对insmod命令可见。
参数使用宏module_param
来声明,并且放在任何函数之外,通常是在源文件的头部。使用如下代码声明参数并使之对insmod可见:
1 | // 定义在 moduleparam.h 中 |
注意,所有的模块参数都必须给一个默认值。
内核支持的模块参数类型如下:
type | remarks |
---|---|
bool, invbool | invbool类型反转其值,true变为false,false变为true |
charp | 字符指针值,内核位用户提供的字符串分配内存,设置相应指针 |
int, long, short, uint, ulong, ushort | 不同长度的基本整数值 |
支持数组参数,提供数组值时用逗号划分各数组成员;声明数组参数使用下面的宏:
1 | module_param_array(name, type, num, perm); |
module_param中最后一个参数是访问许可值,应使用<linux/stat.h>
中定义的值。这个值用来控制谁能够访问sysfs中对模块参数的表述。如果perm被设置为0,就不会有对应的sysfs入口项;否则,模块参数会在/sys/module
中出现,并设置为给定的访问许可。
- S_IRUGO:任何人可读取该参数,但不能修改;
- S_IRUGO|S_IWUSR:允许root用户修改该参数;
- 注意,如果参数通过sysfs被修改,如同模块修改了该参数的值一样,但是内核不会以任何方式通知模块;
- 大多数情况下,模块参数不可写;
To be continued…