Skip to content

操作系统小记:进程生命周期

老实验框架的问题

在老版本的 OS 实验中,task 实现为预制菜:在 task_init() 中预制好,然后通过时钟中断调度。本学期我们希望实现 task 的生命周期管理,即动态创建和销毁,从而能够模拟理论课上的场景以计算优先级调度的响应比。

借鉴 Linux 内核的实现,task 将在结束时进入 do_task_dead(),将进程置为 TASK_DEAD 状态后调用 __schedule() 主动发起调度。

image-20251008233801754

在老版本实验框架下,进程的上下文切换 __switch_to() 总是在中断上下文中进行的,因为只有时钟中断会触发调度。于是我们将进程的初始上下文设置为从中断返回,使进程上下文切换的所有情况一致。但 do_task_dead() 等情况引入了非中断上下文的进程上下文切换,让我们需要重新思考这一步骤。

考虑切换前后 Task 的状态,可以分类讨论如下:

prev
Trap 中
next
首次调度
next
Trap 中
处理
不可能 __dummy 加载起始地址到 sepcsret
原路返回,由 _traps 退出时 sret 返回到中断发生处继续执行
无法处理:Trap 设置的 SPP、SPIE 未被清除,这意味着没有离开 Trap 就切换了上下文,造成混乱
不可能 无法处理:此时 SPIE 为 0,sret 后中断被禁用,造成混乱
无法处理:此时 SPP = 0,sret 将可能把内核进程带入进入用户态,造成混乱
原路返回,由 __switch_to() 返回到 schedule() 处继续执行

笔者思考了两种解决方案:

  • 全部使用中断:令 schedule() 触发中断(可以是软件中断或 ecall),然后在中断处理中调用 __schedule(),这样所有调度都在中断上下文中进行
  • 保存中断上下文信息:把 sstatus 保存到 thread_struct 中,并在 __switch_to() 中恢复。

这两种方法都能解决问题。让我们来看看 Linux 是怎么实现的。

进程创建

让我们从 Linux 启动过程,第一个用户态和内核态线程的创建开始。

  • _start()

    Note

    OpenSBI 进入内核时,sstatus0x200000000,也就是仅设置了 UXL 为 2(64 位用户态),其他位均为 0。我们知道,在这种情况下 sret 将因为 SPP=0 而进入用户态。

  • _start_kernel():将预制的 init_task 加载到 tp,令自己成为第一个 task(PID 0)

    arch/riscv/kernel/head.S
    la tp, init_task
    la sp, init_thread_union + THREAD_SIZE
    addi sp, sp, -PT_SIZE_ON_STACK
    
  • start_kernel():各项资源初始化

  • rest_init():创建第一个用户态线程 kernel_init(PID 1)和内核态线程 kthreadd(PID 2)

    init/main.c
    pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
    pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES);
    
flowchart TD
 n1["user_mode_thread"]
 n2["kernel_thread"]
 n3["kernel_clone"]
 n2 --- n3
 n1 --- n3
 n3 --- n4["copy_process"]
 n4 --- n5["copy_thread"]

进程初始状态

copy_thread() 设置进程上下文的初始状态:

arch/riscv/kernel/process.c
struct pt_regs *childregs = task_pt_regs(p);
/* p->thread holds context to be restored by __switch_to() */
if (unlikely(args->fn)) {
    /* Kernel thread */
    memset(childregs, 0, sizeof(struct pt_regs));
    /* Supervisor/Machine, irqs on: */
    childregs->status = SR_PP | SR_PIE;

    p->thread.s[0] = (unsigned long)args->fn;
    p->thread.s[1] = (unsigned long)args->fn_arg;
    p->thread.ra = (unsigned long)ret_from_fork_kernel_asm;
} else {
    *childregs = *(current_pt_regs());
    /* Turn off status.VS */
    riscv_v_vstate_off(childregs);
    if (usp) /* User fork */
      childregs->sp = usp;
    if (clone_flags & CLONE_SETTLS)
      childregs->tp = tls;
    childregs->a0 = 0; /* Return value of fork() */
    p->thread.ra = (unsigned long)ret_from_fork_user_asm;
}
  • task_pt_regs() 为我们揭示了 Linux 中内核栈的内存布局:

    arch/riscv/include/asm/processor.h
    #define task_pt_regs(tsk)      \
    ((struct pt_regs *)(task_stack_page(tsk) + THREAD_SIZE  \
          - ALIGN(sizeof(struct pt_regs), STACK_ALIGN)))
    
    内存地址 (高地址 ↑)
    +-----------------------------+  ← task_stack_page(tsk) + THREAD_SIZE (栈页面结束)
    |          pt_regs            |  ← task_pt_regs(tsk) 指向此处
    |   (寄存器快照结构体)        |
    +-----------------------------+  ← 栈顶部 (sp 附近)
    |                             |
    |       栈数据 (局部变量、     |
    |       函数调用等,向下增长)  |
    |                             |
    +-----------------------------+  ← task_stack_page(tsk) (栈页面起始,低地址)
    内存地址 (低地址 ↓)
    
    task_struct (位于其他内存区域):
    +-----------------------------+
    |  task_struct 字段          |
    |  (如 pid, state, stack 等)  |
    |  stack 字段 → 指向栈页面   |
    +-----------------------------+
    
  • kernel_thread()user_mode_thread() 都会将传入的函数作为 args->fn因此都会走内核线程的分支,我们稍后会看到 kernel_init() 如何将自己移动到用户态

    Note

    • 注意,内核线程设置了 SR_PP | SR_PIE,这意味着 sret 将返回内核态且开启中断
    • 那么其他情况下创建用户态进程一定是系统调用,位于 Trap 中,所以使用 current_pt_regs() 获取 Trap 时的寄存器快照

进程首次调度

arch/riscv/kernel/entry.S
SYM_CODE_START(ret_from_fork_kernel_asm)
 call schedule_tail
 move a0, s1 /* fn_arg */
 move a1, s0 /* fn */
 move a2, sp /* pt_regs */
 call ret_from_fork_kernel
 j ret_from_exception
SYM_CODE_END(ret_from_fork_kernel_asm)

SYM_CODE_START(ret_from_fork_user_asm)
 call schedule_tail
 move a0, sp /* pt_regs */
 call ret_from_fork_user
 j ret_from_exception
SYM_CODE_END(ret_from_fork_user_asm)
  • schedule_tail() 本质上是在给前一个进程的上下文切换做清理工作,它会调用 finish_task_switch(prev)
  • 内核线程会先完成内核线程的工作,然后再返回用户态。

    arch/riscv/kernel/process.c
    asmlinkage void ret_from_fork_kernel(void *fn_arg, int (*fn)(void *), struct pt_regs *regs)
    {
        fn(fn_arg);
    
        syscall_exit_to_user_mode(regs);
    }
    
    asmlinkage void ret_from_fork_user(struct pt_regs *regs)
    {
        syscall_exit_to_user_mode(regs);
    }
    

    syscall_exit_to_user_mode() 和它们的名字并不一样,它们不会执行 sret 等操作,只是为系统调用返回做一些准备。

  • ret_from_exception 将从 pt_regs 恢复中断上下文并最终 sret

移动到用户态

kernel_init() 在内核态继续做一些工作,最终通过下面的步骤进入用户态:

  • run_init_process()
  • kernel_execve()
  • bprm_execve()
    • sched_exec():将 fn 放入某个 CPU 的 RunQueue
      • stop_one_cpu()
    • exec_binprm()

      从这里开始的内容与 exec 系统调用相同,可以看这篇文章:How does the Linux Kernel start a Process

      • search_binary_handler()
        • fmt->load_binary():对于 ELF 格式,这就是 load_elf_binary(),它将解析 ELF 文件并负责进程加载,我们将在 Lab4 实现它
          • START_THREAD():设置用户态进程的初始上下文
include/linux/elf.h
#ifndef START_THREAD
#define START_THREAD(elf_ex, regs, elf_entry, start_stack) \
  start_thread(regs, elf_entry, start_stack)
#endif
arch/riscv/kernel/process.c
void start_thread(struct pt_regs *regs, unsigned long pc,
 unsigned long sp)
{
    regs->status = SR_PIE;
    if (has_fpu()) {
      //...
    }
    regs->epc = pc;
    regs->sp = sp;

#ifdef CONFIG_64BIT
//...
#endif
}

于是我们理解了关键的一点:用户态进程的初始状态

  • sstatus
    • SPIE 置 1,使得返回时用户态进程启用中断
    • 其余位置 0,也包括 SPP,这意味着 sret返回用户态
  • sepc 设置为 ELF 文件的入口地址
  • sp 设置为用户栈顶

从首次调度返回

上面介绍的所有过程都没有涉及 sret 离开 copy_thread() 设置好的初始上下文。实际上的返回将发生在 ret_from_exception() 中,恢复保存在 pt_regs 中的寄存器状态并执行 sret

  • 对于 PID 2 kthreadd 来说,这永远不会发生,因为它运行着无限循环,无任务时主动调用 schedule(),这是不在中断上下文中主动触发的调度
  • 对于 PID 1 kernel_init 来说,start_thread 完成 pt_regs 的设置后将沿上述的路径逐层返回,一直返回到 ret_from_fork_kernel(),然后进入用户态

此后,用户态的程序一定是从系统调用或中断进入内核态的,因此 sret 一定是从 _traps 返回的。但是内核态的线程呢?

中断上下文与调度分离

读完上面的内容,我们能够确认 Linux 内核中会发生内核态线程和用户态线程的切换,并且这些切换并不总是发生在中断上下文中。

这会引发一个问题,中断上下文与非中断上下文的程序如何混合调度?__switch_to() 并不切换 sstatus,也仅仅使用 ret 返回 ra

调度器入口

image-20251009071158887

flowchart TD
 n5["用户态进程"] --> n11["系统调用"]
 n11 --> n7["__do_sys_exit_group"]
 n11 --> n6["__do_sys_exit"]
 n7 --> n8["do_sys_exit_group"]
 n8 --> n4["do_exit"]
 n6 --> n4
 n4 --> n9["do_task_dead"]
 n9 --> n10["__schedule()"]

 n1["kthread"]
 n2["内核线程"] --> n1
 n1 --> n3["kthread_exit"]
 n3 --> n4

 style n11 fill:#FF3131

让我们从内核启动开始,设置这些断点:

启动:start_kernel,rest_init

调度:schedule

切换:__switch_to,context_switch

进程:kernel_clone

Init_task:进入 start_kernel 时会发现 tp 已经被设置好,它在 init/init_task.c 中定义,并在 head.S 中设置为 tp

image-20251009003305963

Linux 的 task_struct 关联在好几个链表中:tasks、children、sibling、

第一个用户态线程:kernel_init,它会等待 kthreadd_done,然后去 run_init_process(调用 kernel_execve)

第一次发生 context_switch 发生在第一个内核态 kthreadd 的创建过程中,内存分配进行了条件调度

image-20251009003312928

Ø Code that is known to be prone to long loops will, on occasion, call cond_resched() to give the scheduler a chance to run a higher-priority process.

通过 preempt_schedule_common 进入 __schedule

这里 context_switch 会尝试从 PID0 即 init_task 进入 PID1,此时其 comm 为 swapper

这次 switch 成功了,进入了 kernel_init,而它 wait_for_completion 又再次从 schedule_timeout() 进入 schedule(),并切换回 init_task。检查其 thread.ra 发现是 __schedule() 中__switch_to 后返回的地方,可以看出是从 __schedule() 原路返回的

image-20251009003326540

这里会发现返回路径上有一个 finish_task_switch(),它与上次切换时的 prepare_task_switch() 匹配。这里负责检查是否移动了 CPU 等情况。

来到 start_kernel 的最后,这里 schedule_preempt_disabled 进入 schedule,此后自己执行 cpu_startup_entry,它将循环调用 do_idle,成为 idle 线程,核心是 cpu_relax。在 RISC-V 架构下实现为:arch/riscv/include/asm/vdso/processor.h。

最后这一次调度进入 kthreadd(2),它很快进入了 trap handler。通过 sepc 回溯发现调用链如下:

Head.S _start ->_start_kernel :禁用所有中断,为 init_task 设置 tp 并 setup_vm、relocate_enable_mmu 最后 start_kernel

如果你 watch sepc,将获得龟速执行

首次调度:

image-20251009003334323

更多关于调度和上下文的资料:

[The long road to lazy preemption LWN.net]