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/linuxinclude/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 机制
  • 只能通过 iopermiopl 访问 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)
    • 例子是路由表:更新少查询多,性能要求高