Archive:

LKD 读书笔记 Part 1


这是 LKD 的读书笔记,希望能对自己以后在 Linux 下开发内核程序有所帮助。

Linux Kernel Development Chapter 2

第二章主要内容是内核基础知识例如如何编译等。

内核配置

有多种手段对内核编译选项进行配置。

  1. 最简单的 make config 会遍历询问每个选项的 Y/N,非常 tedious。
  2. TUI 的 make menuconfig 是由 ncurse 支持的界面选项 (我一般用这个)。
  3. 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 编译器提供了一个内建的功能来指明“更可能”运行到的分支,名为 likelyunlikely,其用法如下。

if (unlikely(error)) {
    /* ... */
}

LKD Chapter 3 Process 进程

在 Linux 中,进程和线程并没有明显区分,只是一部分进程“恰好”共享了一些资源(文件描述符,内存空间等)。 进程的生命周期如下。

  1. 父进程调用 fork() 库函数,其返回两次,一次在父进程,另一次在子进程。
  2. 子进程通常在 fork() 返回后立刻使用 exec() 来创建新的地址空间并加载程序。
  3. 当一个程序通过 exit() 退出时,父进程可以调用 wait4() 来查询其状态,如果父进程不调用,该进程会被置于僵尸进程区。

进程描述符与任务结构体

内核使用循环双向链表来存储类型为 struct task_struct 的进程描述符,其定义在 <linux/sched.sh>。 这类型的描述符在 32 位机器上大小为 1.7 KB,还是比较大的,但它包含了所有内核需要的进程信息,例如进程的地址空间,挂起的信号,进程状态等。

image.png

分配进程描述符

为了便于利用 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 位清零获得位于最低地址的进程描述符。

image.png

movl $-8192, %eax
andl %esp, %eax

这些都是由 current_thread_info() 函数实现的,最后可以通过其 task 成员获得 task_struct

current_thread_info() -> task;

进程状态

进程状态分为 5 种,定义在 /include/linux/sched.h 中。

  1. TASK_RUNNING 指可以运行的进程,它要么正在运行要么正在运行队列中等待,一个用户进程想要执行, 它必然处于这种状态。
  2. TASK_INTERRUPTIBLE 指正在休眠的进程,等待某种条件满足,它可能因为两种原因被激活。
    • 等待的条件满足
    • 接收到信号
  3. TASK_UNINTERRUPTIBLE 与前者相同,只是它不会被信号唤醒,通常是因为进程必须不被中断的等待,或者等待的时间一般很短。
  4. __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, &current->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(),前者将当前进程直接复制一遍,除了 pidppid,和信号等完全一致。 后者加载一个新的可执行文件并且开始执行。两者结合后与其他操作系统提供的单一接口功能类似。

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() 函数实现的。

  1. dup_task_struct() 创建一个新的内核栈,并且初始化新的 thread_infotask_struct 结构体。 这些结构体的内容和父进程完全一致。
  2. 检查新的子进程并没有超出系统设置的上限。
  3. 将多个进程描述符中的信息清空或者设回默认值。
  4. child->state = TASK_UNINTERRUPTIBLE 以防止其被执行或者接受到任何信号。
  5. copy_process() 调用 copy_flags() 来更新新进程的 flags 成员。 清空 PF_SUPERPRIV 标志,该标志代表该进程是否具有 superuser 权限。 设置 PF_FORKNOEXEC 标志,该标志代表该进程还未执行 exec()
  6. 调用 alloc_pid() 来分配新的 PID。
  7. 根据 flags 内容决定是复制资源还是共享资源,资源包含打开的文件,文件系统信息,信号处理器。
  8. 清理并返回指向 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 内核会创建多个内核线程来进行后台操作,例如 flushksoftirqd。 他们没有自己的地址空间 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 函数负责,分为下列步骤。

  1. task->flags 中设置 PF_EXITING 标志
  2. del_timer_sync 删除所有内核定时器,并且确保在该函数返回时没有定时器事件在队列中且定时器处理函数不在运行。
  3. acct_update_integrals 更新统计数据,如果 BSD Accounting 功能启用。
  4. exit_mm 来释放 mm_struct 结构体,如果该结构体未被共享,内核将直接删除它。
  5. exit_sem 来释放 IPC 同步锁。
  6. exit_filesexit_fs 来释放占用的文件和文件系统资源,减少它们的计数器。
  7. task 中设置 exit_code
  8. exit_notify 通知父进程,将被终止进程的子进程 reparent 到 init 或者同一进程组内的其他进程。
  9. task->exit_state = EXIT_ZOMBIE
  10. schedule() 到一个新进程,do_exit 不会返回。

进程描述符的释放

为什么不在终止时同时释放描述符? 这允许内核在退出子进程后仍能获得一个子进程的信息。

释放行为 release_task() 会被 wait4 调用。

  1. __exit_signal() -> __unhash_process() -> detach_pid() 来删除 pidhash 并将描述符移出链表。
  2. __exit_signal() 释放所有仍被占用的资源。
  3. 如果该进程是进程组的最后一个进程,release_task 会通知该进程的父进程。
  4. put_task_struct() 来释放包含内核栈的页。