用户态进程:如何创建,如何销毁

如何创建

分为两个步骤:

  1. fork()/clone() 创建新内核线程
  2. execve() 执行新用户态程序

新 forked 出来的线程如何开始工作

fork()/clone() 的过程中:

  1. copy_process() 调用体系结构相关的 copy_thread() 来构造新线程的上下文;
  2. wake_up_new_task() 将新线程的 task_struct 挂到某个 CPU 的 run queue 上,待调度器调度执行。

x86_64

 1copy_thread(p, args) {
 2    struct pt_regs *childregs = task_pt_regs(p);
 3    struct fork_frame *fork_frame = container_of(childregs, struct fork_frame, regs);
 4    struct inactive_task_frame *frame = &fork_frame->frame;
 5
 6    frame->bp = encode_frame_pointer(childregs);
 7    frame->ret_addr = (unsigned long) ret_from_fork;  // assembly func
 8    p->thread.sp = (unsigned long) fork_frame;  // used by __switch_to_asm to
 9                                                // locate the top stack frame
10
11    if (/* spawned from kernel thread */) {
12        kthread_frame_init(frame, args->fn, args->fn_arg) {
13            frame->bx = (unsigned long)fun;
14            frame->r12 = (unsigned long)arg;
15        }
16    }
17}

刚 forked 出来的线程(代码注释中称之为inactive task)的上下文构造并记录于 struct fork_frame 中。该结构包括两部分:

  • struct inactive_task_frame:一层内核态栈帧,其中保存的寄存器的排布需与 __switch_to_asm() 对齐;
  • struct pt_regs:保存于内核栈底的用户态寄存器。

如下图所示,内核在创建新线程的 task_struct 时会为其分配一段长度为 THREAD_SIZE 的栈空间并记录于 task.stack 成员。通过这些信息可以找到位于内栈底的 struct pt_regs,然后即可获得 struct fork_frame 的基地址。

结合 __switch_to_asm() 可以看出,struct inactive_stack_frame 中的寄存器排布与上下文切换的过程严丝合缝,且最后的 return 会将内核栈帧中的 ret_addr 弹出并将执行流导向这个地址,即 ret_from_fork()

 1switch_to(prev, next, last) {
 2    last = __switch_to_asm(prev, next) {
 3        
 4        /* switch stack */
 5        movq    %rsp, TASK_threadsp(%rdi)
 6        movq    TASK_threadsp(%rsi), %rsp
 7
 8        /* load registers from inactive_stack_frame */
 9        popq    %r15
10        popq    %r14
11        popq    %r13
12        popq    %r12
13        popq    %rbx
14        popq    %rbp
15        jmp     __switch_to /* (prev_p, next_p) */ {
16            return prev_p; // pop out and jump to ret_addr
17        }
18    }
19}

这里 ret_addr 在内核栈帧中的位置与 x86_64 的调用约定(Calling Convention)也有关,详情请参考本站的另一篇博文《Things That Happen Around Function Calls》 对 x86_64 栈帧结构的讲解。

ARM64

 1copy_thread(p, args) {
 2    childregs = task_pt_regs(p);
 3    if (likely(!args->fn)) {
 4        *childregs = *current_pt_regs();
 5        childregs->regs[0] = 0;
 6    } else {
 7        memset(childregs, 0, sizeof(struct pt_regs));
 8        p->thread.cpu_context.x19 = (unsigned long)args->fn;
 9        p->thread.cpu_context.x20 = (unsigned long)args->fn_arg;
10    }
11    p->thread.cpu_context.pc = (unsigned long)ret_from_fork; // assembly func
12    p->thread.cpu_context.sp = (unsigned long)childregs;
13}

与 x86 在栈上构造上下文不同,ARM64 直接将上下文保存在 task_struct 里面,用一个专门的成员 thread.cpu_context 来保存,因此这部分的代码逻辑也简单得多。结合 cpu_switch_to() 来看,在上下文切换的时候直接通过事先记录好的偏移量(即 THREAD_CPU_CONTEXT)从前后两个线程的 task_struct 中获取到 thread.cpu_context,然后保存和加载寄存器的值。

 1switch_to(prev, next, last) {
 2	last = __switch_to(prev, next) {
 3        last = cpu_switch_to(prev, next) {
 4            /* DEFINE(THREAD_CPU_CONTEXT, offsetof(struct task_struct, thread.cpu_context)) */
 5            mov     x10, #THREAD_CPU_CONTEXT
 6            /* registers -> prev.thread.cpu_context */
 7            add     x8, x0, x10
 8            stp     x19, x20, [x8], #16
 9            ...
10            /* next.thread.cpu_context -> registers */
11            add     x8, x1, x10
12            ldp     x19, x20, [x8], #16
13            ...
14        }
15        return last;
16	}
17}

execve() 如何执行新用户态程序

  1. 修改保存于内核栈底的用户态寄存器值
  2. 通过 return 返回到用户态
 1do_execveat_common/kernel_execve()
 2    bprm = alloc_bprm(fd, filename);
 3    retval = bprm_execve(bprm, fd, filename, 0)
 4        retval = exec_binprm(bprm) {
 5            for (depth = 0;; depth++) {
 6                ret = search_binary_handler(bprm) {
 7                    list_for_each_entry(fmt, &formats, lh) {
 8                        retval = fmt->load_binary(bprm);
 9                    }
10                }
11            }
12        }

由于运行的是 ELF 文件,因此 fmt->load_binary() 这个钩子函数实际执行的是 fs/binfmt_elf.c 中的 load_elf_binary()

1load_elf_binary(bprm)
2    struct pt_regs *regs = current_pt_regs();
3    START_THREAD(elf_ex, regs, elf_entry, start_stack:bprm->p)
4    == start_thread(regs, elf_entry, start_stack) {
5        /* arch related */
6    }

start_thread() 的实现是体系结构相关的,但原理大差不差,都是去修改 pt_regs 里的值。

  • x86_64

    1start_thread(regs, new_ip:elf_entry, new_sp:start_stack) {
    2    start_thread_common(regs, new_ip, new_sp, _cs:__USER_CS, _ss:__USER_DS, 0) {
    3        regs->ip    = new_ip;
    4        regs->sp    = new_sp;
    5        regs->cs    = _cs;
    6        regs->ss    = _ss;
    7        regs->flags = X86_EFLAGS_IF;
    8    }
    9}
    
  • ARM64

    1start_thread(regs, elf_entry, start_stack) {
    2    start_thread_common(regs, pc) {
    3        previous_syscall = regs->syscallno;
    4        memset(regs, 0, sizeof(*regs));
    5        regs->syscallno = previous_syscall;
    6        regs->pc = pc;
    7    }
    8    regs->sp = sp;
    9}
    

如何销毁

strace 随便观测一个用户态程序的运行,比如 strace ls,可以观察到最后一个 syscall:exit_group(n),其中 n 为用户态程序退出时的返回值。

总之就是各种释放,销毁,并且设置进程状态为 TASK_DEAD。最终调度器会对带有该标志的进程做最后的释放工作,包括释放其 struct task_struct

1SYSCALL_DEFINE1(exit, error_code)
2    do_exit(...)
3        do_task_dead()
4            set_special_state(TASK_DEAD)
5            __schedule(SM_NONE)
 1__schedule(...)
 2    context_switch()
 3        finish_task_switch(prev) {
 4            prev_state = READ_ONCE(prev->__state);
 5            if (unlikely(prev_state == TASK_DEAD)) {
 6                put_task_struct_rcu_user(prev) {
 7                    call_rcu(&task->rcu, delayed_put_task_struct)
 8                }
 9            }
10        }
11
12delayed_put_task_struct(rhp) {
13    struct task_struct *tsk = container_of(rhp, struct task_struct, rcu);
14    put_task_struct(t:tsk)
15        __put_task_struct(t)
16            free_task(tsk)
17                free_task_struct(tsk)
18                    kmem_cache_free(task_struct_cachep, tsk)
19}

关于 SELinux 策略的点滴
ARM64 PAC 相关场景与代码流程