第 1 章: 导论

mechanism 和 policy

相当于接口和实现的区别, 驱动程序应该提供接口 – 提供而且只提供设备能做的事情.

内核子系统

主要地, 有 5 个子系统.

设备的分类


第 2 章: 内核模块

内核模块需要考虑的并发

内核开发需要注意的其他事项


第 3 章: 字符设备

例子: scull.

访问字符设备: 通过文件系统中的结点 (特殊文件) 可以通过 ls 命令看出字符设备文件, 以及 major 号和 minor 号.

设备号

内核中使用 `dev_t` 来表示字符设备, 其中包含设备的 major 号和 minor 号.
`dev_t` 被定义为 `unsigned`.
文件有 `linux/types.h` 和 `linux/kdev_t.h` 提供 `dev_t` 和 major/minor 号转化的宏
`MAJOR(dev)`, `MINOR(dev)`, `MKDEV(ma,mi)`

获取放弃设备号
linux/fs.h
	register_chrdev_region	用于有一个希望申请的major 号
	alloc_chrdev_region	让内核动态分配一个 major 号 (minor 号并不由内核管理)
	unregister_chrdev_region	放弃设备号

现代的做法是申请 major 号, 但特殊文件结点需要知道 major 号才能建立
	因此驱动还要自己手动建立特殊文件结点.

LDD3 中说, 可以用一个脚本来装载驱动而不是使用 `insmod`.
	如对于装载, 这个脚本执行如下的工作
	1. 调用 `insmod` 来装载驱动
	2. 删除旧的特殊文件结点
	3. 利用 /proc/devices/ 中的信息得到设备的 major 号
	4. 建立新的特殊文件结点, 并且设定用户群组等的权限

文件相关的数据结构: linux/fs.h: struct file_operations owner 通常就是初始化为 .owner = THIS_MODULE llseek 修改文件偏移量. 不应当被留为 NULL. ll 表示偏移量是 long long read / write 读写 ioctl 给设备的指令 open 如果留为 NULL, 打开总是成功 release readv / writev 分散读写: 应用希望读写多个分散的区域的时候, 使用这个系统调用 可以一次读写完. 减少了系统调用的开销. struct file 代表一个文件的一个打开的实例 f_mode 文件系统中的读写许可 f_pos 文件的偏移量 f_op 关联的文件操作 private_data struct inode 代表一个文件

设备注册 内核中还有用来表示字符设备的数据结构: struct cdev linux/cdev.h cdev_alloc cdev_init cdev_add 这个函数一旦完成, 系统就可能调用这个驱动了; 应当放在初始化完成最后执行 cdev_del

open 和 release release 和 close 有区别 当且仅当文件打开计数达到 0 的时候, release 才会被调用 其中回收 struct file 等 (事实上就是撤销 open 做过的一切) 其他的情况下调用 close, 只是减少一个文件打开计数 fork 等并没有新打开文件; 它们只是调用了 dup.

read / write __user 表示用户态指针, 不能直接访问 1. 内核和用户态有不同的地址空间 2. 用户态数据可能缺页, 内核不允许缺页 3. 不能信任用户数据

一般, read / write 如果做到一半出错,
那么返回值是已经读到的字节数 (不返回错误), 并且保存错误码
下次 read / write 的时候再返回错误码

第 5 章: 并发问题

造成并发问题的原因

概念

Linux 信号量

文件: linux/semaphore.h

接口如

Linux 读写锁

文件: linux/rwsem.h

有时互斥的不是代码片段, 而是对数据的操纵. 这时应当允许多个读者同时读, 就不适用于临界区概念了.

Linux 的读写锁是写优先的读写锁, 一旦写者得到锁, 读者在写者放弃写锁之前不能读取数据.

接口如

Linux completion

文件 include/completion.h

一种情形是, 当前线程等待外部线程的某个异步事件. 信号量的解决方案是, 当前线程使用 down 等待异步事件, 外部使用 up 提醒异步事件. 虽然这样原理上没有问题, 但是效率不好. 因为信号量的实现假设了信号量基本上总是可用的 “heavily optimized for the ‘available’ case”.

接口如

对外部模块还有 complete_and_exit, 驱动中也较常用.

Linux 自旋锁

文件 linux/spinlock.h

自旋锁通常需要硬件支持. 准确的, 单核可以关中断, 多核必须硬件支持.

自旋锁只有在多核, 或者允许抢占内核的单核上才有用. 非抢占的内核上自旋锁没有用处, 而且一旦等待 spinlock, 它就死机了.

另外还有 spinlock 实现的读写锁 rwlock. 文件 include/linux/spinlock.h

TODO: 什么时候使用 spin_lock[_bh] 相对与 spin_lock_irqsave 有更好的效果?

原子上下文 atomic context 下的 spinlock

考虑线程如果在持有 spinlock 的情况下被调出. 在它被调出的时候, 若有任何线程希望得到它持有的 spinlock, 那么该线程就会空转. 取决于是否有内核抢占, 是否关中断, 可能造成该 CPU 上空转一个完整的时间片甚至死锁.

于是有原则: spinlock 保护的临界区必须是原子的, 即其中执行的线程不能被调出, 不能调用 sleep, 不能调用可能 sleep 的函数如 kmalloc 等.

seqlock

文件 linux/seqlock.h

也是一种读写锁. 写者可以随便写, 只需要加锁防止多个写者. 读者要检查自己读的过程中有没有发生写, 防止数据不一致. 不会造成写者饥饿.

读者通常的代码如 do { seq = read_seqbegin(&foo); ... } while (read_seqretry(&foo, seq));

常见问题

说不清楚的锁

任何锁都是保护资源的. 说不清楚某个锁保护的是什么, 那么这里就一定有问题. 或者是代码的问题, 或者是代码作者思维混乱.

重复加锁

在 linux 的非递归锁机制下, 持有锁的函数不能再次获取锁. 通常的问题出现在, 持有锁的函数调用了其他需要获取锁的函数.

这种情况, 可以针对需要获取锁的函数创建一个副本, 它不需要获取锁 (即假设调用者已经得到锁了). 当然, 这个副本只应当出现在内部接口中.

加锁的顺序

如果需要给多个锁上锁, (所有需要给多个锁上锁的函数中) 上锁的顺序必须一样, 否则会造成死锁.

一般应尽量避免需要多次上锁的情况.

锁的粒度

锁分成粗粒度和细粒度的. 粗粒度锁就是所有共享资源笼统地使用同一个锁, 如 Linux 2.0 开始的 big kernel lock. (到 3.0 开始就没有了) 细粒度锁就是针对不同资源用不同的锁. 细粒度锁的 scalability 和并行度更好; 但是锁更多, 很难追踪什么时候用那些锁按照什么顺序, 也就更容易引入 bug.

避免使用锁

为了避免锁相关的问题, 最好的方法就是不用锁.

修改算法

一个例子是, 使用循环队列实现只有单个生产者单个消费者的 producer-consumer 问题. 只需要对写者要求, 在更新队尾指针前, 必须把完整的数据准备好放到队列尾.

内核中也有实现, 如 linux/kfifo.h

原子整数类型

文件: asm/atomic.h

如果只是保护对 一个 整数的访问, 可以使用 atomic_t. 从 2.6.3 开始, atomic_t 的范围就是整个范围而非 24 位了.

接口如

位操作

文件 atomic/bitops.h

考虑共享 flag mask 的情况. 这种情况下, 需要原子性的是修改某内存单元的某一位.

接口如

RCU

文件 include/linux/rcupdate.h, Documentation/RCU/rcu.txt

被保护的数据只能通过内核提供的指针访问.

基本原理是: 写者希望更新数据时, 它复制数据, 之后更新副本, 最后让指针指向副本. 这样实际上是原子地完成了对数据的修改. 当所有使用旧指针的线程都完成数据访问时, 内核需要回收旧数据的内存.

通常使用在写很少的情况.