Skip to content

Linux Device Drivers, 3rd Edition

这本书基于 Linux 2.6。

一些相关的资源

本篇笔记使用 martinezjavier/ldd3 进行实验。

第一章:设备驱动介绍

三类设备:

  • 字符设备:提供字节流
    • 一般实现 openclosereadwrite
    • 例子:/dev/console
  • 块设备
  • 网络接口

杂项:

  • 概述了 Linux 系统的整体架构
  • 提及了安全问题:
    • 驱动编写者应当将尽可能将安全策略移交给更高层次,而不是在驱动中实现。如果对设备的操作可能影响整个系统的安全,就需要做好访问控制,比如只有特权用户才能加载的内核模块。加载模块时,系统调用 init_module 会检查对应进程是否有加载模块的权限。
    • 此外要防范缓冲区溢出等 C 语言容易产生的问题。
    • 在验证之前,不能信任任何从用户空间传来的东西,并且要设计为不论用户给到什么信息都不至于使系统崩溃。从内核拿到的内存一定要初始化,检查内存泄露等。
  • Linux 可以完全关闭模块功能,此时驱动必须编译进内核。

第二章:构建和运行模块

配置测试系统

首先需要拉取内核源码树并构建,下面记录我的操作过程:

构建内核过程
$ git clone https://
$ cd linux
$ yes "" | make oldconfig
$ make -j

样例模块

内核模块与应用的对比

  • 内核模块只与内核链接,因此只能使用内核导出的函数,不能使用标准库函数。
  • 内核模块运行在内核空间,内存映射和地址空间也与普通应用不一样。
  • 当应用执行系统调用或被硬件中断时,进入内核空间。系统调用运行在进程的上下文中,可以访问进程的地址空间。硬件中断是异步的,与特定进程无关。
  • 需要考虑并发场景。并发的来源有:多进程使用驱动、硬件操作过程中发生中断、内核计时器、驱动在多个 CPU 上执行、抢占等。内核与内核模块都需要做到 reentrant,即可以同时运行在多个上下文中。
  • current 指针指向当前进程的 task_struct 结构体。
  • 内核的栈非常小,可以小到一个 4K 页。我们写的函数与内核空间的调用链一起使用这个栈。因此,不应当使用大型局部变量,应当动态分配。

编译和加载

内核模块的编译使用内核构建系统 kbuild。在内核模块的目录下放置这样的 Makefile:

ifneq ($(KERNELRELEASE),)
    obj-m := hello.o

# Otherwise we were called directly from the command line; invoke the kernel build system.

else

    KERNELDIR ?= /lib/modules/$(shell uname -r)/build
    PWD := $(shell pwd)

default:
    $(MAKE) -C $(KERNELDIR) M=$(PWD) modules

endif

通过下面的命令编译:

KERNELDIR=/path/to/kernel make

其中的原理涉及 GNU Make 扩展语法,暂略。大致过程是让 make 调用内核的 Makefile,使用内核构建系统的 module 目标来构建内核模块。编译完成的模块文件是 hello.ko

Todo

martinezjavier/ldd3 可以使用当前运行的 6.1 内核源码编译,却不能使用下载的 5.10 内核源码编译。看报错是缺了函数定义。不知道是 Debian Patch 还是内核 API 变动导致的。

jit.c:96:47: error: implicit declaration of function ‘pde_data’ [-Werror=implicit-function-declaration]
96 |         return single_open(file, jit_fn_show, pde_data(inode));

几个实用工具:

insmod modprobe rmmod lsmod
  • 加载内核模块时可以指定参数。
  • insmod 通过 sys_init_module 系统调用加载模块,这会为模块分配一块内存区域,将模块的代码和数据复制到这个区域,然后进行解析模块的符号表等操作。
  • 使用 modprobe 时,会检查模块中是否有内核符号表中的符号,如果没有,会自动加载依赖的模块。
  • 如果内核检测到模块导出的设备等正在使用,会拒绝卸载模块。

内核版本依赖:

  • 内核模块与特定内核版本紧密相关。构建内核模块时与 vermagic.o 链接,该文件中包含内核版本、处理器架构等信息,加载时会以此检查兼容性。
  • 编写跨内核版本的模块时,与版本有关的接口,通常应当放置在一个头文件中用一层宏包装。

内核符号表

  • 加载模块后,EXPORT_SYMBOL() 的符号进入内核符号表,其他模块可以使用。
  • EXPORT_SYMBOL_GPL() 只允许 GPL 许可证的模块使用。

准备工作

  • 使用 MODULE_LICENSE() 定义模块的许可证。如果未定义,内核会认为是专有的。加载这样的模块时,会有 taint 警告。

初始化和关闭

static int __init init_func(void)
{
    return 0;
}
module_init(init_func);
module_exit(exit_func);

初始化函数向系统注册一些设备。注册可能失败,因此需要检查返回值。失败时,可能继续执行但提供有限的功能,也可能直接退出,此时要注意清理已经注册的资源,这些编程经常使用 goto 语句。

函数声明中的 __init__exit 具有特定功能和作用,使用时需要注意。比如 __init 在模块加载后会被丢弃,__exit 会放在特殊的段中,只能在模块卸载时调用请,其他情况下产生错误。

需要考虑模块加载时的竞争条件,可能初始化函数尚未执行完毕,就会有对模块的访问。因此模块内部的初始化应当在注册设备之前完成。

模块参数

模块参数可以在命令行中指定,如:

insmod hello.ko howmany=3 whom="Mom"

也可以在 /etc/modules.conf 中配置。

模块参数不支持浮点数。

在模块中声明参数:

static int howmany = 1;
module_param(name, type, perm);
module_param_array(name, type, num, perm);

所有模块参数都应该有默认值。perm 控制 sysfs 中参数的权限。

在用户空间内操作

libusb 就是一个实现在用户空间的 USB 驱动。实现在用户空间有一些好处,比如可以使用标准库、调试过程更简单等。但这也有很多限制,比如网络接口、块设备都无法在用户空间实现,DMA 受限,速度较慢等。

第三章:字符设备

本章讲解 Simple Character Utility for Loading Localities (scull) 设备的实现。它操作一块内存,表现为一个字符设备。

scull 的设计

它将提供下列设备:

scull0-3
scullpipe0-3
scullsingle
scullpriv
sculluid
scullwuid

主要和次要设备号

/dev 中使用 ls -l 会发现原先显示文件大小的地方被设备号占据。一般来说,主设备号标识管理该设备的驱动(现在 Linux 允许多个驱动共享一个主设备号),次设备号标识设备的实例。

常用固定设备号见内核文档 devices.txt。可以在 /proc/devices 中查看当前系统中注册的设备。

设备号存储在 dev_t 类型中,使用宏操作它:

MAJOR(dev_t dev);
MINOR(dev_t dev);
MKDEV(int major, int minor);

设备号相关函数:

linux/fs.h
//int register_chrdev_region(dev_t first, unsigned int count, const char *name);
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name);
void unregister_chrdev_region(dev_t first, unsigned int count);

拿到设备号后,还需要将设备号与驱动内部实现硬件操作的函数关联起来。

一些重要的数据结构

  • file_operations

    • 定义于 linux/fs.h,包含了设备驱动的操作函数指针。每个打开的文件 file 都含有 f_op 指向文件操作相关的系统调用,实现 openread 等操作。
    • 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 *)
    int (*open) (struct inode *, struct file *)
    int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long)
    ...
    
  • file

    • 定义于 linux/fs.h,与标准库中的 FILE 不同,表示打开的文件的描述符。open 时创建,传给每个用到的函数,最后 close 时释放。
    mode_t f_mode;
    loff_t f_pos;
    struct file_operations *f_op;
    ...
    
  • inode

    • 一个文件可以有多个 file 描述符,但都指向同一个 inode
    dev_t i_rdev;
    struct cdev *i_cdev;
    

注册字符设备

先初始化,再注册:

struct cdev *my_cdev = cdev_alloc();
my_cdev->ops = &my_fops;
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
my_cdev->owner = THIS_MODULE;

int cdev_add(struct cdev *dev, dev_t num, unsigned int count);
void cdev_del(struct cdev *dev);

打开和释放

open 的工作:

  • 检查设备相关错误。
  • 如果第一次打开,初始化设备。
  • 必要时更新 f_op
  • 分配并填充 filp->private_data 中的结构。

release 的工作:

  • 释放 open 分配的内存。
  • 再最后一个文件描述符关闭时,关闭设备。
  • 不是每个 close 调用都会导致 release 调用,内核做了引用计数,因此 dupfork 能安全使用文件。
  • 但每次 close 时总会调用 flush

杂项:

  • container_of(pointer, container_type, container_field) 宏,定义于 linux/kernel.h,用于从结构体的成员指针获取结构体指针。

scull 的内存使用

linux/slab.h
void *kmalloc(size_t size, gfp_t flags);
void kfree(const void *ptr);

读和写

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

注意缓冲区是用户空间地址,无法被内核程序直接访问。有几个原因:

  • 根据体系结构的不同,用户空间的地址可能在内核空间中无效。
  • 即使是同一个地址,用户空间内存是分页的,可能不在 RAM 中,会让内核产生段错误。
  • 安全问题。
asm/uaccess.h
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);

拷贝时进程可能被 sleep(等待页从磁盘加载到内存),因此需要考虑并发和 reentrant。

ldd_ch3_dev_read

read 函数的返回值:

  • 与要求的字节数相同,表示成功。
  • 小于要求的字节数,有多种原因。一般情况下,用户程序会再次尝试,直到传输完成。
  • 0,表示到达文件尾。
  • 负数,表示错误。

此外还有当前没有数据,但马上会有数据的情况,在后文讨论阻塞时详细说明。

write 函数返回值和 read 一致。

它们有向量版本:

ssize_t (*readv) (struct file *filp, const struct iovec *iov, unsigned long count, loff_t *pos);
ssize_t (*writev) (struct file *filp, const struct iovec *iov, unsigned long count, loff_t *pos);
struct iovec {
    void __user *iov_base;
    __kernel_size_t iov_len;
};

使用新设备

可以用它吃满内存:

cp /dev/zero /dev/scull0

使用 strace 可以查看驱动中的系统调用。

第四章:调试技术

内核调试支持

自行编译内核,启用一般发行版自带内核没有启用的调试选项。

CONFIG_DEBUG_KERNEL
CONFIG_DEBUG_SLAB
CONFIG_DEBUG_PAGEALLOC
CONFIG_DEBUG_SPINLOCK
CONFIG_DEBUG_SPINLOCK_SLEEP
...

通过打印调试

本节描述了内核日志系统,如何控制输出级别、控制台输出等。

介绍了通过宏包装的便捷调试函数:

#undef PDEBUG /* undef it, just in case */
#ifdef SCULL_DEBUG
# ifdef __KERNEL__
/* This one if debugging is on, and kernel space */
# define PDEBUG(fmt, args...) printk( KERN_DEBUG "scull: " fmt, ## args)
# else
/* This one for user space */
# define PDEBUG(fmt, args...) fprintf(stderr, fmt, ## args)
# endif
#else
# define PDEBUG(fmt, args...) /* not debugging: nothing */
#endif
#undef PDEBUGG
#define PDEBUGG(fmt, args...) /* nothing: it's a placeholder */
Makefile
# Comment/uncomment the following line to disable/enable debugging
DEBUG = y

# Add your debugging flag (or not) to CFLAGS
ifeq ($(DEBUG),y)
  DEBFLAGS = -O -g -DSCULL_DEBUG # "-O" is needed to expand inlines
else
  DEBFLAGS = -O2
endif

CFLAGS += $(DEBFLAGS)

对于驱动还有一些函数:

int print_dev_t(char *buffer, dev_t dev);
char *format_dev_t(char *buffer, dev_t dev);

通过查询调试

/proc 文件系统中的每个文件都对应一个内核函数,产生该文件的内容。

linux/proc_fs.h
int (*read_proc)(char *page, char **start, off_t offset, int count, int *eof, void *data);
struct proc_dir_entry *create_proc_read_entry(const char *name,
    mode_t mode, struct proc_dir_entry *base,
    read_proc_t *read_proc, void *data);

本节还介绍了 seq_file 接口。

通过观测调试

本节介绍使用 strace 观察从用户空间发出的系统调用。

调试系统错误

驱动发生错误时,可能导致使用该驱动的应用程序崩溃。此时动态分配到该上下文的数据可能丢失。程序崩溃时,内核会使用 close 关闭打开的文件,驱动程序可以收回 open 时分配的资源。

发生错误时,oops 消息打印处理器当前状态,如寄存器、EIP 等。本节用解引用 NULL 指针和缓冲区溢出两个例子演示了如何阅读 oops 消息。

还介绍了应对死机的方法。

调试器和相关工具

介绍了使用 GDB、kdb 和其他各类工具调试内核的方法。由于没有自行编译内核,故略去。

有趣的是本节提及了内核为什么不提供调试器:

The answer, quite simply, is that Linus does not believe in interactive debuggers. He fears that they lead to poor fixes, those which patch up symptoms rather than addressing the real cause of problems. Thus, no built-in debuggers.

第五章:并发和竞争条件

并发及其管理

硬件和很多软件资源都需要共享,一般使用锁机制来控制线程对共享资源的访问。

旗语(Semaphore)和锁(Mutex)

旗语由一个整数值和 P、V 操作组成。当进程进入关键区时使用 P 操作将值减一,离开时使用 V 操作将值加一。当值为 0 时,进程会被阻塞。

将旗语整数值设置为 1 即可实现互斥锁,有时称为 mutex(即 mutual exclusion)。

在 Linux 中,V 对应 up,P 对应 down。源码目前位于 linux/include/semaphore.h

此外,内核还提供

  • rwsem 读写锁,允许多个进程同时读,但只允许一个进程写。读写锁可能造成饥饿。
  • completion 等待操作/子进程完成。
  • spinlock 可以用在不可 sleep 的任务(如中断处理)中。需要获得锁的程序会不断轮训,因此称为自旋。这种情况容易造成死锁:持有锁的线程休眠,而需要锁的线程占用 CPU。因此,持有锁的进程应当执行原子化操作(期间不可 sleep)。
  • rwlock 读写版的自旋锁。

不同类型的锁针对不同的使用场景进行性能优化。

锁的常见错误

略。

锁的替代品

circular buffer、atomic variable、seqlock 等可以实现无锁数据结构。这些在驱动中很常见,比如网络适配器就使用 circular buffer。

此外还有 Read-Copy-Update 算法,类似于写时复制。

第六章:高级字符驱动操作

ioctl

阻塞 I/O

当读写请求无法立即完成时,驱动会将进程置于睡眠状态。本节学习进程睡眠的相关知识。睡眠就是将进程标记为一种状态,从调度器的可运行队列中移除。

  • 可以在 wait_queue_head_t 中找到正在等待的进程并将其唤醒。
  • 使用 wait_event(queue, condition) 使进程进入睡眠。
  • 使用 wake_up(queue) 唤醒进程。
  • filp->f_flagsO_NONBLOCK 标记,驱动可以考虑支持非阻塞 I/O。有些操作需要花费较长时间时可以使用非阻塞 I/O 返回 EAGAIN,让应用程序执行轮询。

本节以一个阻塞 I/O 的例程结尾:

  • read 请求阻塞,直到硬件发出中断。作为中断处理的一部分,驱动唤醒等待的进程。
  • 示例程序因为没有硬件,采用另一个进程生成数据并唤醒。
  • 驱动具有读/写队列和缓冲区。

本章剩余部分暂略。

第七章:时间、延迟与延后工作

现实世界中的驱动还需要处理时间、内存管理和硬件访问等问题。

测量时间

  • 使用内核的 jiffies 计数器:内核通过时钟中断来记录时间。时钟中断一般 1000 次/秒,可以通过 HZ 宏查看。时钟中断发生时,内核中的计数器 jiffies 就递增。
  • 使用平台特定的寄存器:如果需要测量精确的时间,可以牺牲一些移植性,使用硬件的计时器。

    • 现代 CPU 的缓存、指令重排、分支预测等技术导致指令的时间无法预知。厂商通过提供时钟周期计数器解决高精度计时的问题。
    • 这样的计时器因平台而异,可能 64 位或 32 位,可能只读或可写,可能无法从用户空间访问。
    • 最有名的计时器是 x86 平台上的 TSC(Time Stamp Counter)。这个 64 位寄存器记录时钟周期数,可以从内核和用户空间访问。

      asm/msr.h
      rdtsc(low32, high32);
      rdtscl(low32);
      rdtscll(var64);
      

      举个例子,1 GHz 的 CPU 约 4.2 秒溢出一次 32 位计数器。

      unsinged long ini, end;
      rdtscl(ini); rdtscl(end);
      printk("Time: %lu\n", end - ini);
      
    • 内核提供 rdtsc 的替代品 get_cycles,与平台无关。

    • 本节还介绍了使用汇编内联代码的方法。下面这个宏在 MIPS 平台上实现了 rdtsc

      #define rdtscl(dest) \
          __asm__ __volatile__("mfc0 %0,$9; nop" : "=r" (dest))
      
    • 需要注意的是,平台特定的计时器在 SMP 系统上可能不同步。因此,需要关闭 Linux 的抢占机制,使得代码始终在同一个 CPU 上运行。

    • 当前时间(Wall-clock time):这个英文很形象,就是挂载墙上的钟的时间
    • 对于驱动来说,一般不会使用这个时间。相关操作一般放到用户空间进行,如 cron
    • 内核提供了 gettimeofday 等函数,可以获取当前时间戳。

延迟执行

第八章:分配内存

kmalloc 的细节

linux/slab.h
void *kmalloc(size_t size, int flags);

最常用的 flagsGFP_KERNELGFP_ATOMIC。前者表示为一个进程分配内存,可能会睡眠;后者可用于中断处理程序等不在进程上下文中的地方,且不会睡眠。

Linux 内核知道至少三种内存区域:DMA 使能内存、普通内存和高端内存。使用不同的 flags 可以指定从不同的区域分配内存。

  • 对于绝大多数平台,所有的内存都在 DMA 区域。
  • 高端内存用于 x86 平台访问大量内存,需要建立特别的映射。
  • 对于 NUMA 架构来说,会尽量分配本地内存。

Linux 创建内存池,内存储中有固定大小的内存对象。分配内存时,从具有足够大的内存对象的池中取出内存,将其整块返回给调用者。内核编程者应当注意这一点,即分配的内存可能比请求的内存大。

本节没有深入探究具体机制。

Lookaside 缓存

本章剩余部分略。

第九章:与硬件沟通

I/O 端口和 I/O 内存

这是两种不同的硬件访问方式:

  • 外围设备通过读/写其寄存器进行控制。每个设备有一些寄存器,在内存空间或 I/O 地址空间中连续分布。
  • 在硬件层面上,这些内存区域没有本质区别,都是向地址总线和控制总线发送信号,从数据总线读取或写入数据。
  • 有的 CPU 设计为只有一个地址空间,有些 CPU(比如 x86 家族)则为外围设备的 I/O 端口划分了专门的线路,使用特殊的指令访问。
  • 外围设备是根据外围总线设备的,因此即使是没有给外围设备独立地址空间的架构,也需要假装在读写 I/O 端口。
  • 因此,Linux 也实现 I/O 端口的概念。
  • 即使外围总线有 I/O 端口独立的地址空间,有些设备也不会把寄存器映射到 I/O 端口,而是映射到内存地址空间(比如 PCI 设备)。因为这样不需要特殊指令,且编译器在访存模式的选择上较为自由。

I/O 操作有副作用,而内存没有:

  • 内存对 CPU 的性能非常重要,因此进行了很多优化:
    • 编译器可以进行缓存,使用寄存器或 CPU Cache,不进入物理内存。
    • 编译和硬件执行时都可能发生重排,
  • 上面这些优化会造成 I/O 操作的 Bug,因此需要解决:
    • 缓存问题由 Linux 解决,在访问 I/O 区域时关闭所有硬件缓存。
    • 编译器和硬件的重排优化通过 memory barrier 解决。

      linux/kernel.h
      void barrier(void);
      
      asm/system.h
      void mb(void);
      

      值得注意的是,与同步有关的原语(如 spinklock 和 atomic_t 操作)也会起到 memory barrier 的作用。

使用 I/O 端口

linux/ioport.h
struct resource *request_region(unsigned long start, unsigned long len, const char *name);
void release_region(unsigned long start, unsigned long len);

/proc/ioports 中可以查看当前系统中的 I/O 端口。

硬件可能提供 8、16、32 位的端口,因此有不同的函数用于访问:

asm/io.h
unsigned inb(unsigned port);
void outb(unsigned char byte, unsigned port);
unsigned inw(unsigned port);
void outw(unsigned short word, unsigned port);
...

GNU C 提供了在用户空间访问 I/O 端口的方法,但是需要系统调用来获取权限。

有些处理器还实现了一次向 I/O 端口读写多个字节的指令。

I/O 指令与处理器架构紧密相关,本节剩余部分概述了 Linux 支持的这些架构。

一个 I/O 端口例子

暂略。

使用 I/O 内存

暂略。

第十章:中断处理

第十一章:内核中的数据类型

第十二章:PCI 驱动

第十三章:USB 驱动

第十四章:Linux 驱动模型

第十五章:内存映射与 DMA