LKD 读书笔记 Part 1
这是 LKD 的读书笔记,希望能对自己以后在 Linux 下开发内核程序有所帮助。
Linux Kernel Development Chapter 2
第二章主要内容是内核基础知识例如如何编译等。
内核配置
有多种手段对内核编译选项进行配置。
- 最简单的
make config
会遍历询问每个选项的Y/N
,非常 tedious。 - TUI 的
make menuconfig
是由 ncurse 支持的界面选项 (我一般用这个)。 - GUI 的
make gconfig
,这个我还没用过。
特殊配置命令
使用架构默认配置
make defconfig
检查并更新配置(建议每次配置完都运行)
make oldconfig
内核编程的特殊性
与用户程序相比,内核程序有着自己的特殊特点。
无法使用 C 库
在内核中我们无法使用标准库,而应当转用由内核提供的例如 <linux/string.h>
这类头文件。
内核函数的命名方式也有所不同,例如 printf -> printk
。
内联函数
当内核需要时间敏感的函数时,通常使用 static inline
来定义内联函数,例如。
static inline void wolf(unsigned long tail_size)
这类函数声明必须在任何使用之前否则编译器无法进行内联,通常将声明放置于头文件之中,因为 static
函数并不会创建一个导出的函数(TODO: 没看懂,有空了解一下)。
分支标注
gcc 编译器提供了一个内建的功能来指明“更可能”运行到的分支,名为 likely
和 unlikely
,其用法如下。
if (unlikely(error)) {
/* ... */
}
LKD Chapter 3 Process 进程
在 Linux 中,进程和线程并没有明显区分,只是一部分进程“恰好”共享了一些资源(文件描述符,内存空间等)。 进程的生命周期如下。
- 父进程调用
fork()
库函数,其返回两次,一次在父进程,另一次在子进程。 - 子进程通常在
fork()
返回后立刻使用exec()
来创建新的地址空间并加载程序。 - 当一个程序通过
exit()
退出时,父进程可以调用wait4()
来查询其状态,如果父进程不调用,该进程会被置于僵尸进程区。
进程描述符与任务结构体
内核使用循环双向链表来存储类型为 struct task_struct
的进程描述符,其定义在 <linux/sched.sh>
。
这类型的描述符在 32 位机器上大小为 1.7 KB,还是比较大的,但它包含了所有内核需要的进程信息,例如进程的地址空间,挂起的信号,进程状态等。
分配进程描述符
为了便于利用 sp
获取进程描述符位置,通常将 task_struct
放于内核栈顶部[最低地址](prior 2.4),
这样只需要一个寄存器 sp
以及一些计算,即可知道 task_struct
位置。
由于 task_struct
太大了,逐渐不适合直接放置于内核栈顶部,后来改为使用 slab
分配器分配 task_struct
,
以此允许使用对象重用(object reuse)和缓存染色(cache coloring)。
并使用 thread_info
替代 task_struct
放入内核栈顶部(2.6~4.8),thread_info
如下所示。
struct thread_info {
struct task_struct *task;
struct exec_domain *exec_domain;
__u32 flags;
__u32 status;
__u32 cpu;
int preempt_count;
mm_segment_t addr_limit;
struct restart_block restart_block;
void *sysenter_return;
int uaccess_err;
};
再后来,由于 Linux 引入了 percpu
全局变量描述当前 CPU 上执行任务的信息,thread_info
内存储的
信息也逐渐减少,具体见该 commit。
存储进程描述符
系统通过 pid_t pid;
来识别每个进程,为了与 UNIX 兼容,其最大值仅为 32,768
,
不过可以通过 /proc/sys/kernel/pid_max
修改。获取进程描述符的方式(prior 4.9),
可以通过将栈顶指针的低 13 位清零获得位于最低地址的进程描述符。
movl $-8192, %eax
andl %esp, %eax
这些都是由 current_thread_info()
函数实现的,最后可以通过其 task
成员获得 task_struct
。
current_thread_info() -> task;
进程状态
进程状态分为 5 种,定义在 /include/linux/sched.h
中。
TASK_RUNNING
指可以运行的进程,它要么正在运行要么正在运行队列中等待,一个用户进程想要执行, 它必然处于这种状态。TASK_INTERRUPTIBLE
指正在休眠的进程,等待某种条件满足,它可能因为两种原因被激活。- 等待的条件满足
- 接收到信号
TASK_UNINTERRUPTIBLE
与前者相同,只是它不会被信号唤醒,通常是因为进程必须不被中断的等待,或者等待的时间一般很短。__TASK_TRACED
和__TASK_STOPPED
分别代表正在被调试的进程和已经终止的进程。
进程状态可以通过 <linux/sched.h>
中的 set_task_state(task, state)
设置,它等价于下面的语句(单线程情况)。
task->state = state;
进程上下文
在用户态进程触发系统调用/异常之后,会陷入内核态,此时内核态可以视为正在该进程的上下文内执行(current
宏可用)。
进程树
每一个进程有且仅有一个父进程,并且可能有 0 ~ n 个子进程。其访问方式如下。
struct task_struct *my_parent = current -> parent;
struct task_struct *task;
struct list_head *list;
list_for_each(list, ¤t->children) {
task = list_entry(list, struct task_struct, sibling);
/* do stuff with task */
}
通过双向链表,我们也可以获取进程信息,或者通过 for_each_process
宏。
list_entry(task->tasks.next, struct task_struct, tasks)
list_entry(task->tasks.prev, struct task_struct, tasks)
struct task_struct *task;
for_each_process(task) {
/* this could take a long time. */
}
进程创建
UNIX 将进程创建分为两步,fork()
和 exec()
,前者将当前进程直接复制一遍,除了 pid
,ppid
,和信号等完全一致。
后者加载一个新的可执行文件并且开始执行。两者结合后与其他操作系统提供的单一接口功能类似。
Copy-on-Write
在 fork()
调用时,Linux 并不会拷贝所有资源,而是利用页表将数据段标识为不可读(non-readable),在进程尝试写入时,
触发异常,再进行拷贝。
这使得 fork()
的唯一性能损失来自于复制父进程的页表以及创建新的进程描述符。通常 exec()
都是紧接着 fork()
执行,
因此这不会引起过多性能损失。
Forking
Linux 通过 clone()
系统调用实现 fork()
,不管是 fork()
,vfork()
和 __clone()
,最后都会
调用 clone()
,然后 clone()
会使用 kernel/fork.c
中的 do_fork()
来进行实际的操作。
在 do_fork()
中,大部分工作都是由 copy_process()
函数实现的。
dup_task_struct()
创建一个新的内核栈,并且初始化新的thread_info
和task_struct
结构体。 这些结构体的内容和父进程完全一致。- 检查新的子进程并没有超出系统设置的上限。
- 将多个进程描述符中的信息清空或者设回默认值。
child->state = TASK_UNINTERRUPTIBLE
以防止其被执行或者接受到任何信号。copy_process()
调用copy_flags()
来更新新进程的flags
成员。 清空PF_SUPERPRIV
标志,该标志代表该进程是否具有 superuser 权限。 设置PF_FORKNOEXEC
标志,该标志代表该进程还未执行exec()
。- 调用
alloc_pid()
来分配新的 PID。 - 根据
flags
内容决定是复制资源还是共享资源,资源包含打开的文件,文件系统信息,信号处理器。 - 清理并返回指向
child
的指针。
Linux 会先执行子进程,因为其可能立刻执行
exec()
,这会抵消父进程写入地址空间带来的 CoW 损失。
Vfork
不带页表复制的 fork()
,子进程要么执行 exec()
要么 exit()
,感觉比较丑陋。
线程
Linux 并不具有特殊的”线程“,他们只是恰好共享了”资源“的进程。
根据传入 clone()
系统调用的内容不同,共享的资源也不同,例如。
// This creates a "thread" in common sense
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
// This is a normal fork would do
clone(SIGCHLD, 0);
// This is a vfork would do
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
具体列表在此,CLONE 文档
内核线程
Linux 内核会创建多个内核线程来进行后台操作,例如 flush
和 ksoftirqd
。
他们没有自己的地址空间 mm = NULL
,并且不会与用户空间进行上下文切换。
所有新的内核进程都是由 kthreadd
fork 出来的,接口定义在 <linux/kthread.h>
。
// 创建但不执行,需要使用 wake_up_process 唤醒
struct task_struct *kthread_create(int (*threadfn)(void *data), void* data, ...)
// 创建并执行
struct task_struct *kthread_run(int (*threadfn)(void *data), void* data, ...)
// 结束内核线程
struct task_struct *kthread_stop(struct task_struct *k)
进程终止
进程终止的流程对于创建来说更加复杂,其主要由 <kernel/exit.c>
中的 do_exit
函数负责,分为下列步骤。
- 在
task->flags
中设置PF_EXITING
标志 del_timer_sync
删除所有内核定时器,并且确保在该函数返回时没有定时器事件在队列中且定时器处理函数不在运行。acct_update_integrals
更新统计数据,如果 BSD Accounting 功能启用。exit_mm
来释放mm_struct
结构体,如果该结构体未被共享,内核将直接删除它。exit_sem
来释放 IPC 同步锁。exit_files
和exit_fs
来释放占用的文件和文件系统资源,减少它们的计数器。- 在
task
中设置exit_code
。 exit_notify
通知父进程,将被终止进程的子进程 reparent 到init
或者同一进程组内的其他进程。task->exit_state = EXIT_ZOMBIE
schedule()
到一个新进程,do_exit
不会返回。
进程描述符的释放
为什么不在终止时同时释放描述符? 这允许内核在退出子进程后仍能获得一个子进程的信息。
释放行为 release_task()
会被 wait4
调用。
__exit_signal() -> __unhash_process() -> detach_pid()
来删除pidhash
并将描述符移出链表。__exit_signal()
释放所有仍被占用的资源。- 如果该进程是进程组的最后一个进程,
release_task
会通知该进程的父进程。 put_task_struct()
来释放包含内核栈的页。