第 1 章: 导论
mechanism 和 policy
相当于接口和实现的区别, 驱动程序应该提供接口 – 提供而且只提供设备能做的事情.
内核子系统
主要地, 有 5 个子系统.
- 进程管理 -> CPU.
- 内存管理 -> 内存
- 文件系统 -> 磁盘和 CD
- 设备控制 -> 控制台
- 网路访问 -> 网络接口
设备的分类
- 字符设备: 提供字节流的访问模型. 通过文件系统中特殊文件访问, 如
/dev/tty
. - 块设备: 提供一块一块的访问模型. 通过文件系统中特殊文件访问, 如
/dev/sda
. - 网络设备: 通过网络接口表示. 通过接口名访问, 如
eth0
, 但是没有对应的文件系统中的结点.
第 2 章: 内核模块
内核模块需要考虑的并发
- 多个用户程序都调用内核模块
- 中断处理 (软/硬)
- SMP
- 内核抢占
内核开发需要注意的其他事项
- 内核栈只有 1 页 – 4096 字节.
- 不能使用浮点运算: 不然每次进入离开内核都需要保存恢复浮点寄存器
第 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 章: 并发问题
造成并发问题的原因
- 多个用户程序同时调用驱动
- 多核 SMP
- 内核是可抢占的 – 驱动执行过程中可能随时被抢占
- 外部中断
- 热插拔
概念
-
竞争条件: 最本质的原因就是多个执行流之间的资源的共享. 为避免竞争条件, 首先考虑尽量少的资源共享, 如避免全局变量.
-
互斥: 任何时刻只有一个线程能够操纵共享的资源.
-
临界区: 任何时刻至多有一个线程执行某个临界区中的代码. 实现临界区的方法有很多, 一种就是基于锁的.
Linux 信号量
文件: linux/semaphore.h
接口如
-
DEFINE_SEMAPHORE
: 声明和定义. -
down
: 完成之后, 调用者就持有锁了. 过程是不可打断的. 通常, 除非必须, 否则不使用不可打断的信号量, 而是使用down_interruptible
. 注意信号量的语义中,down
是可能导致当前线程被调出 (sleep) 的 -
down_interruptible
: 完成之后, 调用者不一定持有锁. 可能是因为被打断了. 通常, 检测到打断错误之后, 有两种选择. 一种是撤销改动并返回-ERESTARTSYS
, 让内核重启这个调用. 一种是直接返回-EINTR
, 内核不会重启调用而是报告用户程序. 但是, 打断错误后不能释放锁: 因为线程打断时并未持有锁. -
down_trylock
: 非阻塞. -
up
: 用于释放锁.down
和up
必须配对. 尤其要注意, 临界区中出错退出函数时, 也要释放锁.
Linux 读写锁
文件: linux/rwsem.h
有时互斥的不是代码片段, 而是对数据的操纵. 这时应当允许多个读者同时读, 就不适用于临界区概念了.
Linux 的读写锁是写优先的读写锁, 一旦写者得到锁, 读者在写者放弃写锁之前不能读取数据.
接口如
-
DECLARE_RWSEM
down_read
: 取得读锁. 可能导致线程进入 uniterruptable sleep (sleep 时不接受信号的形态)down_read_trylock
-
up_read
: 以上这三个都是读者的 down_write
: 取得写锁.down_write_trylock
up_write
downgrade_write
: 把当前线程持有的写锁换成读锁. 读写锁的性质保证哦, 如果一个线程持有写锁, 那么它可以把写锁换成读锁 (锁的强度降低), 中间不放弃锁. 但是持有读锁的线程不能把读锁换成写锁.
Linux completion
文件 include/completion.h
一种情形是, 当前线程等待外部线程的某个异步事件. 信号量的解决方案是, 当前线程使用 down 等待异步事件, 外部使用 up 提醒异步事件. 虽然这样原理上没有问题, 但是效率不好. 因为信号量的实现假设了信号量基本上总是可用的 “heavily optimized for the ‘available’ case”.
接口如
-
DECLARE_COMPLETION
wait_for_completion
: 类似down
, 这个方法是不可打断的wait_for_completion_interruptible
-
wait_for_completion_timeout
complete
: 唤醒等待的某一个线程complete_all
: 唤醒等待的所有线程. 如果有complete_all
, 那么重用 completion 结构的时候要重新初始化.
对外部模块还有 complete_and_exit
, 驱动中也较常用.
Linux 自旋锁
文件 linux/spinlock.h
自旋锁通常需要硬件支持. 准确的, 单核可以关中断, 多核必须硬件支持.
自旋锁只有在多核, 或者允许抢占内核的单核上才有用. 非抢占的内核上自旋锁没有用处, 而且一旦等待 spinlock, 它就死机了.
spin_lock_init
spin_lock
spin_lock_irqsave
: 加锁同时关中断, 但是保存当前中断情况spin_lock_irq
: 加锁同时关中断, 但是不保存当前中断. 用于能够保证当前不在关中断情况下spin_lock_bh
: 关闭软件中断 (如信号量, 计时队列), 但是允许硬件中断spin_trylock
-
spin_trylock_bh
: 很显然 spinlock 的 trylock 没有关中断的版本. spin_unlock
spin_unlock_irqrestore
spin_unlock_irq
spin_unlock_bh
另外还有 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_INIT
atomic_set
atomic_read
atomic_add
atomic_sub
atomic_inc
atomic_dec
- …
位操作
文件 atomic/bitops.h
考虑共享 flag mask 的情况. 这种情况下, 需要原子性的是修改某内存单元的某一位.
接口如
set_bit
clear_bit
change_bit
...
RCU
文件 include/linux/rcupdate.h
, Documentation/RCU/rcu.txt
被保护的数据只能通过内核提供的指针访问.
基本原理是: 写者希望更新数据时, 它复制数据, 之后更新副本, 最后让指针指向副本. 这样实际上是原子地完成了对数据的修改. 当所有使用旧指针的线程都完成数据访问时, 内核需要回收旧数据的内存.
通常使用在写很少的情况.