Linux Device Drivers
概述
机制与策略
“the role of a device driver is providing mechanism, not policy.” Corbet et al., 2005, p. 2
这是一个很有趣的划分,区分了机制与策略,或者用政治术语说,它区分了体制与政策。 体制决定了如何定义资源、如何对资源进行分类以及根据这种分类而作出的初步的分配,而政策决定了在具体实施中如何分配资源。 后者需要一个常态化的机构来保证运行。
所谓「Linux 内核」便是这样一个常态化的机构,它居于体制的中心地位,发布政策。 在进程的层面上,我们可以说底层的硬件机制是处于本体论层面的。 我们在现象界我们所能看到的是一个不可穿透的硬核,它颁布各种政策、调控各进程之间的资源分配,它把本体论的构造给隐藏掉了。
内核作为这一中介,它一定是落后于本体论上的实质进展的。 因为现实的发展瞬息万变,而内核要保持这些资源的抽象性,就需要不断地进行扩展。 一切现实之中的博弈,一定首先体现在内核的变化中。 这样,内核非但不是处于中心被保护的东西,而是必定处于混乱的最前线。 因而旧的应用程序可以保持稳定,而内核的代码是永远也写不完的。
内核的这一特殊身份,令其具有了「不可穿透性」。这种不可穿透性是在现象领域中才具有意义。 这意味着一旦我们尝试穿越它,就发现它后面什么也没有,有的只是一些在我们看来相当混乱的情况。 例如随时随地出现的竞争情况。 那些符合我们日常道德标准的「良好设计」在此都不完全适用了。
正是内核的「可重入性」保证了我们日常的良好设计规范。 可重入性意味着,当应用程序执行一个系统调用时,它仿佛在执行一个纯函数一般,不会破坏其内环境的稳态。 为了保证可重入性,内核不得不令自己暴露在无数竞争状态得以成立的可能性之下。
资源是一切进程向这个内核申请的某种东西,它可以是:
- CPU 核心位置、优先级、调度策略
- 文件描述符、Socket 及其数量限制
- 进程、线程及其数量限制
- 内存空间、共享内存
- I/O(或对文件的操作)
- 信号、中断、计时器
但是,内核不能超发资源,因为资源是有限的。内核只能超发资源的描述符。
……这其中不乏一些「人为制造的稀缺」,它们被列在 ulimit 下。
设备类型的划分
在内核-设备的界面上,一种对于设备类型的划分,由内核规定:
- 字符设备 char:实现字节流访问
- 块设备 block:实现字节流访问,往往更复杂,带有缓存机制
- 网络设备 net:提供分组访问
在规范-设备的界面上,可以有另一种对于设备类型的划分,由这些标准规定:
- USB
- SCSI
- etc.
这些划分彼此之间是正交的,比如:
- USB x net = USB 网卡:由 USB 模块处理,显示为 net 设备
- USB x block = USB 大容量存储设备:由 USB 模块处理,显示为 block 设备
- USB x char = USB 串口:由 USB 模块处理,显示为 char 设备
驱动的应用,典型例子是建立在块设备之上的文件系统。 设备驱动模块和文件系统模块的共同点在于它们都是内核模块,这意味着它们为一系列系统调用提供了具体的实现。 设备驱动将磁盘映射为块设备,文件系统将块设备映射为文件结构。
安全问题
- 对上层屏蔽敏感操作
- 避免引入安全问题(保护内核)
- 谨慎对待用户进程输入,避免把敏感信息输出给用户进程
内核模块
编写内核模块
环境配置
编译内核源码并生成符号
make menuconfig # generate build config file
bear -- make -j$(nproc) # generate compilation database for clangd
基本原则
内核模块不能引用任何外部的库,大多数情况下只会用到 include/linux
及 include/asm
目录下的库。
例如,不同于 c 标准函数 printf
,在内核中需要使用 kprintf
函数来打印信息。除了 <stdarg.h>
和一些技术局的情况外。
内核模块从根本上说是一组运行在内核空间的过程,而非进程。 在其中,没有任何操作系统所提供的进程管理机制能为其提供这样的抽象,它们可能在任意时间点上被执行(取决于来自用户和硬件的事件)。 因此,它们需要实现可重入性,尽量减少副作用,避免数据竞争。
由于和内核中的其他模块共用很小的一片调用栈,要避免一切栈上的大块内存分配。
内核代码不计算浮点数,以避免切换处理器状态。
代码风格
- 连接的
vermigic.o
会保证不符合规范的模块不会被内核加载(版本不兼容的、处理器不同的) - 使用
<linux/version.h>
来检查内核版本 - 避免包裹
#ifdef
宏,而要分为几个文件 - tabstop 为 8。
- 使用 goto 语句进行错误处理
示例
#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("Dual BSD/GPL");
static int __init hello_init(void) {
printk(KERN_ALERT "Hello, world\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_ALERT "Goodbye, cruel world\n");
}
module_init(hello_init);
module_exit(hello_exit);
其中 hello_init
是初始化函数:
static
保证函数对外部隐藏__init
宏允许内核在初始化完毕时释放对应的内存空间(.text 段)。 类似的还有__initdata
(.data 段),以及适用于热插拔设备的__devinit
和__devinitdata
。- 它应该完成初始化工作、注册一些能力。
- 它要检查初始化中可能发生的错误并处理(或者进行并返回错误码,或者降级继续运行)。
注册能力的示例:
static int __init my_init_function(void)
{
int err;
/* registration takes a pointer and a name */
err = register_this(ptr1, "skull");
if (err) goto fail_this;
err = register_that(ptr2, "skull");
if (err) goto fail_that;
err = register_those(ptr3, "skull");
if (err) goto fail_those;
return 0; /* success */
fail_those: unregister_that(ptr2, "skull");
fail_that: unregister_this(ptr1, "skull");
fail_this: return err; /* propagate the error */
}
static void __exit my_cleanup_function(void)
{
unregister_those(ptr3, "skull");
unregister_that(ptr2, "skull");
unregister_this(ptr1, "skull");
return;
}
初始化函数和清理函数的类型:
typedef int (*initcall_t)(void);
typedef void (*exitcall_t)(void);
module_init(initfn)
及 module_exit(exitfn)
宏打包初始化所需要的数据和过程,并创建暴露给内核进行调用的接口。
初始化函数的错误码定义在 <linux/errno.h>
。
编译内核模块
Linux 有自己的一套编译方式。
# To build modules outside of the kernel tree, we run "make"
# in the kernel source tree; the Makefile these then includes this
# Makefile once again.
# This conditional selects whether we are being included from the
# kernel Makefile or not.
LDDINC=$(PWD)/../include
EXTRA_CFLAGS += -I$(LDDINC)
ifeq ($(KERNELRELEASE),)
# Assume the source tree is where the running kernel was built
# You should set KERNELDIR in the environment if it's elsewhere
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
# The current directory is passed to sub-makes as argument
PWD := $(shell pwd)
modules:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
modules_install:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules_install
clean:
rm -rf *.o *~ core .depend .*.cmd *.ko *.mod.c .tmp_versions *.mod modules.order *.symvers
.PHONY: modules modules_install clean
else
# called from kernel build system: just declare what our modules are
obj-m := hello.o hellop.o seq.o jiq.o sleepy.o complete.o \
silly.o faulty.o kdatasize.o kdataalign.o jit.o
endif
适配所有硬件的方式:
- 贡献给主线
- 发布源代码
加载内核模块
加载的过程是一个内存拷贝的过程: 复制加载时参数到指定内存地址,然后复制模块到指定内存地址,然后连接到内核的符号表,最调用初始化过程。
- 使用
insmod hello.ko
加载内核模块。如果存在依赖关系,可以用modprobe
批量加载依赖的模块。 - 使用
rmmod hello.ko
卸载内核模块,如果模块仍在使用,卸载将会失败。 - 使用
dmesg -w
来查看内核模块的输出。
模块加载中的竞争
模块在注册能力的时候,一旦注册函数被调用,其他模块可能会立即调用被注册的函数,而不管模块是否已经完成初始化。 这是因为模块在其初始化阶段就已经被视为加载到内核之中了,所有预先检查都完成了,模块已经随时可用了。 因而注册能力的函数应该在模块初始化函数的最后调用,并且保证它们能安全地被以任何可能的方式被访问。
以及,如果初始化失败了,如何处理正在被调用的过程?必须等待这些过程完成。
内核符号表
当内核模块加载时,其导出的部分符号将被加入到内核符号表中,其他模块可以调用它们。 这一技术用于实现 module stacking。需要注意避免命名冲突。
EXPORT_SYMBOL(name);
EXPORT_SYMBOL_GPL(name);
其中 _GPL
表示仅供 GPL 许可证的模块使用。
模块参数
模块参数的声明:
// linux/moduleparam.h
/**
* module_param - typesafe helper for a module/cmdline parameter
* @name: the variable to alter, and exposed parameter name.
* @type: the type of the parameter
* @perm: visibility in sysfs.
*
* @name becomes the module parameter, or (prefixed by KBUILD_MODNAME and a
* ".") the kernel commandline parameter. Note that - is changed to _, so
* the user can use "foo-bar=1" even for variable "foo_bar".
*
* @perm is 0 if the variable is not to appear in sysfs, or 0444
* for world-readable, 0644 for root-writable, etc. Note that if it
* is writable, you may need to use kernel_param_lock() around
* accesses (esp. charp, which can be kfreed when it changes).
*
* The @type is simply pasted to refer to a param_ops_##type and a
* param_check_##type: for convenience many standard types are provided but
* you can create your own by defining those variables.
*
* Standard types are:
* byte, hexint, short, ushort, int, uint, long, ulong
* charp: a character pointer
* bool: a bool, values 0/1, y/n, Y/N.
* invbool: the above, only sense-reversed (N = true).
*/
#define module_param(name, type, perm) \
module_param_named(name, name, type, perm)
/**
* module_param_array - a parameter which is an array of some type
* @name: the name of the array variable
* @type: the type, as per module_param()
* @nump: optional pointer filled in with the number written
* @perm: visibility in sysfs
*
* Input and output are as comma-separated values. Commas inside values
* don't work properly (eg. an array of charp).
*
* ARRAY_SIZE(@name) is used to determine the number of elements in the
* array, so the definition must be visible.
*/
#define module_param_array(name, type, nump, perm) \
module_param_array_named(name, name, type, nump, perm)
其中,perm 可以用到的参数有:
// linux/stat.h
#define S_IRWXUGO (S_IRWXU|S_IRWXG|S_IRWXO)
#define S_IALLUGO (S_ISUID|S_ISGID|S_ISVTX|S_IRWXUGO)
#define S_IRUGO (S_IRUSR|S_IRGRP|S_IROTH)
#define S_IWUGO (S_IWUSR|S_IWGRP|S_IWOTH)
#define S_IXUGO (S_IXUSR|S_IXGRP|S_IXOTH)
所有模块参数应该被定义为 static,所有模块参数应该有一个默认值。
用户空间设备驱动
例子:libusb、X server。通常实现为一个独占设备的服务器,为客户端提供服务。
优势:
- 有 C 标准库支持
- 支持 debug 工具
- 可以被杀掉
- 可以使用大量内存
- 适合闭源实现
劣势:
- 无法使用中断
- 无法利用 DMA 机制
- 只能通过
ioperm
和iopl
访问 I/O 接口 - 从硬件到客户端的响应慢
- 服务端可能被挪到交换空间中,需要用特权用户的
mlock
锁定 - 网络接口、块设备等重要设备无法在用户空间中处理
scull:简单字符设备驱动
设备:
- scull0 - scull3:简单字符设备
- scullpipe0 - scullpipe3:管道设备
- scullsingle、scullpriv、sculluid、scullwuid:特殊限制的设备
主设备号 & 次设备号:
- Major number:设备的主设备号,通常与指定设备驱动
- Minor number:设备的次设备号,用于指定唯一设备实例
注册设备
// linux/kdev_t.h
// get major number & minor number from dev
MAJOR(dev_t dev);
MINOR(dev_t dev);
// get dev from major & minor number
MKDEV(int major, int minor);
注册设备号对应设备号的设备:
// linux/fs.h
// manually set dev number (major + minor)
int register_chrdev_region(dev_t first, unsigned int count, char *name);
void unregister_chrdev_region(dev_t first, unsigned int count);
动态获取设备号对应的设备,其中主设备号有内核管理,而 /dev 设备可以有 udev 创建:
// linux/fs.h
// dynamic set dev number (minor)
int alloc_chrdev_region(dev_t *dev, unsigned firstminor, unsigned count, char *name);
其中 dev_t
即为次设备号。
实现操作
The scull device driver implements only the most important device methods. Its file_operations structure is initialized as follows:
struct file_operations scull_fops = {
.owner = THIS_MODULE,
.llseek = scull_llseek,
.read = scull_read,
.write = scull_write,
.ioctl = scull_ioctl,
.open = scull_open,
.release = scull_release,
};j
Internally, scull represents each device with a structure of type struct scull_dev. This structure is defined as:
struct scull_dev {
struct scull_qset *data; /* Pointer to first quantum set */
int quantum; /* the current quantum size */
int qset; /* the current array size */
unsigned long size; /* amount of data stored here */
unsigned int access_key; /* used by sculluid and scullpriv */
struct semaphore sem; /* mutual exclusion semaphore */
struct cdev cdev; /* Char device structure */
};
scull 设备被设想为指针的列表。
打开和释放
int (*open)(struct inode *inode, struct file *filp)
int (*release)(struct inode *inode, struct file *filp)
打开设备需要: - 检查设备相关错误(比如设备没有准备好) - 初始化设备 - 更新 f_op - 申请 filp->private_data,填充自定义结构
释放设备需要: - 释放相关资源
release 与 close 不同,只有释放设备时 close 才真正调用 release.
读取和写入
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);
其中 buff
是用户空间的指针,不可以直接解引用。可以通过下面的方法做转换,注意用户空间可能会发生缺页。
// asm/uaccess.h
// linux/uio.h
ssize_t copy_to_user(void __user *to, const void *from, unsigned long count);
ssize_t copy_from_user(void *to, const void __user *from, unsigned long count);
ssize_t (*readv) (struct file *filp, const struct iovec *iov, unsigned long count, loff_t *ppos)
ssize_t (*writev) (struct file *filp, const struct iovec *iov, unsigned long count, loff_t *ppos);
read 和 write 返回读取和写入返回的字节数,或者负数表示错误。 因为驱动需要尽量不缓存数据而是转发,所以可能出现传输了一定量的数据之后发生错误的情况。 如果发生了这类情况,那么需要中止函数的运行、返回已读取的数据量,然后在该函数下一次被调用时,返回错误码 errno。
在一次用户空间的 read/write 中,内核会多次调用驱动模块提供的 read/write 函数。 这种设计允许驱动设计者按照自己设计的数据结构按块返回数据。
向量版本的读写操作(v 后缀)代表向量读写操作,可以一次处理多个数据块,提高效率。
调试技术
内核配置
与调试相关的内核配置:
- CONFIG_DEBUG_KERNEL:标志,没有实质功能
- CONFIG_DEBUG_SLAB:调试内存分配,会被初始化为 0xa5,释放后会被 0x6b,会对越界行为进行额外检查
- CONFIG_DEBUG_PAGEALLOC:强
- CONFIG_DEBUG_SPINLOCK:检查自旋锁相关的问题
- CONFIG_INIT_DEBUG:检查是否在初始化后访问被
__init
和__initdata
标记的数据。 - CONFIG_DEBUG_INFO:加入调试信息,允许通过 gdb 调试内核。
- CONFIG_FRAME_POINTER:加入帧指针,以便 gdb 进行调试。
- CONFIG_MAGIC_SYSRQ:启用 magic SysRq 键盘(往往是 PrintScreen 键)
- CONFIG_DEBUG_STACKOVERFLOW:检查栈溢出
- CONFIG_DEBUG_STACK_USAGE:检查栈使用情况(通过栈溢出或 SysRq)
- CONFIG_KALLSYMS:默认启用,为内核崩溃报告启用调试信息,否则会输出十六进制
- CONFIG_IKCONFIG、CONFIG_IKCONFIG_PROC:启用内核配置信息
- CONFIG_ACPI_DEBUG:启用 ACPI 相关信息
- CONFIG_DEBUG_DRIVER:启用驱动调试信息
- CONFIG_SCSI_CONSTANTS:启用 SCSI 常量
- CONFIG_INPUT_EVBUG:启用输入事件调试
- CONFIG_PROFILING:启用乡村可能检查
系统日志默认输出到当前 console 中,其次会输出到 /proc/kmsg FIFO。 这是一块循环队列,可以用 dmesg 查看。 也可以通过 klogd 将其输出的日志 /var/log/messages 中,但是这样做会拖慢系统速度。
日志等级
kprintf 有几个等级:
- KERN_EMERG<0>:紧急情况,通常导致一个崩溃
- KERN_ALERT<1>:严重错误,需要立即采取行动
- KERN_CRIT<2>:严重错误,通常涉及到意图外的失败
- KERN_ERR<3>:错误,通常报告已知的困难
- KERN_WARNING<4>:警告,默认日志等级(DEFAULT_MESSAGE_LOGLEVEL)
- KERN_NOTICE<5>:通知,一个提醒
- KERN_INFO<6>:信息,通常在启动时报告找到的硬件设备
- KERN_DEBUG<7>:调试信息
提供查询能力
/proc
在 /proc 文件系统下创建文件,以标准文件访问操作为接口。
旧 api,在 3.10 被移除:
struct proc_dir_entry *create_proc_entry(const char *name, mode_t mode,
struct proc_dir_entry *parent);
void remove_proc_entry(const char *name, struct proc_dir_entry *parent);
int remove_proc_subtree(const char *name, struct proc_dir_entry *parent);
-新 api:
struct proc_dir_entry *proc_create_single_data(const char *name, umode_t mode,
struct proc_dir_entry *parent,
int (*show)(struct seq_file *, void *), void *data);
#define proc_create_single(name, mode, parent, show) \
proc_create_single_data(name, mode, parent, show, NULL)
void proc_remove(struct proc_dir_entry *);
void remove_proc_entry(const char *, struct proc_dir_entry *);
int remove_proc_subtree(const char *, struct proc_dir_entry *);
为了简化操作,Linux 内核还提供了 seq_file 接口,来简化对于 /proc 的管理(例如分页)。 参考 seq_file.rst。大体上,如果 kprint 对应着 printf,那么 seq_file 相关的操作就对应着 fprintf。模块需要实现:
- 迭代器接口 ct_seq_start,来在一次 read() 系统调用中遍历所要提供的数据。
static const struct seq_operations ct_seq_ops = {
.start = ct_seq_start,
.next = ct_seq_next,
.stop = ct_seq_stop,
.show = ct_seq_show
};
- 文件操作接口 file_operations,来引入一个虚拟文件的操作。
static const struct file_operations ct_file_ops = {
.owner = THIS_MODULE,
.open = ct_open,
.read = seq_read,
.llseek = seq_lseek,
.release = seq_release
};
ioctl
使用 ioctl:ioctl() 是一个系统调用,其接口是应用程序接口,这意味着需要在用户空间编写应用程序来调用系统调用。 其优势是比读写 /proc 更加高效。
sysfs
通过 sysfs 导出属性
主动监控
在 strace 下运行该程序,或通过 -p
追加已经运行的进程。strace 在系统调用失败时,会显示 errno 信息。
-t
显示每次调用的是用时,-e
显示要追踪的调用类型。
检查 Faults
oops messages
系统挂起
magic SysRq key
实时调试
使用 gdb
打开 CONFIG_DEBUG_INFO
选项。
gdb /usr/src/linux/vmlinux /proc/kcore
在 .text .bss 和 .data 段检查内核模块地址,加载调试符号:
(gdb) add-symbol-file .../scull.ko 0xd0832000 \
-s .bss 0xd0837100 \
-s .data 0xd0836be0
使用 kdb
打开 CONFIG_KDB
选项。
kdb /usr/src/linux/vmlinux /proc/kcore
用户模式 Linux
User-Mode Linux
追踪工具
Linux Trace Toolkit
动态 probes
避免竞争状态
资源竞争的必要条件:
- 存在共享资源
- 存在对共享资源的并发访问操作
- 这些并发访问操作不是原子化操作
无锁算法
- 循环队列作为缓冲区
- 使用原子变量
,根据不同平台有不同实现 - 设置位的原子操作
,根据不同平台有不同实现 - 使用顺序锁 seqlock
,允许无锁读+有锁写 - 可能会出现数据不一致
- 读者可能需要重新获取:
do { seq = read_seqbegin(&the_lock); } while read_seqretry(&the_lock, seq);
- 适合保护对简单类型的操作。
- IRQ-safe 版本:
read_seqbegin_irqsave()
和read_seqretry_irqrestore()
- Read-Copy-Update 机制
: - 读者无锁访问
- 写者先创建一份拷贝(
kmalloc()
),在此基础上进行修改,然后更新原有指针(rcu_assign_pointer()
),然后等待读者读完原有数据以释放原有内存(call_rcu(rcu_dereference()->rcu_head)
) - 例子是路由表:更新少查询多,性能要求高