gVisor ptrace平台学习
本文是我将gVsior移植到RISC-V架构过程中的学习记录,移植项目在我的Github上:TeddyNight/gvisor-riscv,其他文档在这里。
Ptrace是一个系统调用,是类Unix系统上通过原生调试器监测被调试进程的主要机制,使用Ptrace,跟踪进程可以暂停被跟踪进程,检查和设置寄存器和内存,监视系统调用,拦截系统调用。
PTRACE_SYSEMU
ptrace是Linux用于程序调试的系统调用,被gdb,strace等工具使用,功能较多,这里简化分析只是选取了直接与PTRACE_SYSEMU和PTRACE_SYSCALL相关的部分。
函数原型
long ptrace(enum __ptrace_request op, pid_t pid, void *addr, void *data)
便于描述,给两个进程起个名字:追踪进程(调用PTRACE的)是A进程,被追踪进程是B进程
第一步:设置被追踪进程
在gVisor的情况是,首先A通过fork创建子进程,A调用waitpid等待来自B进程的SIGSTOP信号。
随后A调用PTRACE,设置请求为PTRACE_ATTACH,pid为B进程的pid。
Linux内核收到请求之后会在A进程和B进程的task结构体中设置相应的字段。
task结构体中与ptrace相关的字段有三个,一个是ptrace字段,保存是否跟踪的标识,ptraced被追踪进程链表,ptrace_entry追踪父进程链表。
在ptrace.c的ptrace_attach()
设置B被追踪进程task结构体的ptrace字段,添加PT_PTRACED
标识,然后ptrace_attach调用ptrace_link
在B进程的ptrace_entry链表中加入A进程的task,在A进程的ptaced
链表中加入B进程的task,同时将A进程设置为B进程的父进程(这样A waitpid就能收到B进程的SIGTRAP信号)
第二步:A进程调用PTRACE_SYSCALL/PTRACE_SYSEMU恢复运行B进程
这里是循环的开始。
在唤醒A进程前会先使用PTRACE调用初始化B进程寄存器上下文,使得稍后恢复执行时执行的是期待启动的用户程序而不是stub程序。
A进程使用PTRACE_SYSCALL
标识调用PTRACE,将他自己注册为B进程的syscall调试器:ptrace.c的ptrace_resume
函数设置被追踪进程B的task结构体的syscall_work标识为SYSCALL_TRACE
在gVisor中使用PTRACE_SYSEMU
标识调用PTRACE,设置的是SYSCALL_WORK_SYSCALL_EMU标识。
第三步:捕获B进程的系统调用
每次系统调用进入或者退出的时候,内核的系统调用的入口代码会检查请求系统调用的用户线程的TIF_SYSCALL_TRACE标识(对于SYSEMU的情况检查标识)。
这里值得注意的是x86和RISC-V架构都切换到了generic_entry,对系统调用的处理使用generic entry。
使用generic entry的架构,syscall的处理流程大致是这样的:
noinstr void syscall(struct pt_regs *regs, int nr)
{
arch_syscall_enter(regs);
nr = syscall_enter_from_user_mode(regs, nr);
instrumentation_begin();
if (!invoke_syscall(regs, nr) && nr != -1)
result_reg(regs) = __sys_ni_syscall(regs);
instrumentation_end();
syscall_exit_to_user_mode(regs);
}
而RISC-V架构上,处理系统调用的函数是do_trap_ecall_u
。
syscall_enter_from_user_mode
最终会走到syscall_trace_enter
,设置ptrace标识会走到ptrace_report_syscall
,调用ptrace_notify
发送SIGTRAP
给父进程,此时的message是PTRACE_EVENTMSG_SYSCALL_ENTRY
,之后进入ptrace_stop
ptrace_notify
调用ptrace_do_notify
,最终走到ptrace_stop
spin_lock_irq(¤t->sighand->siglock);
signr = ptrace_do_notify(SIGTRAP, exit_code, CLD_TRAPPED, message);
spin_unlock_irq(¤t->sighand->siglock);
在ptrace_stop
设置发出系统调用的被追踪进程的SIGTRAP信号,然后调用do_notify_parent_cldstop
通知并唤醒追踪进程,进程进入就绪队列,随后调用schedule()
调度执行追踪进程A。
进程A随后从waitpid返回,对于PTRACE_SYSCALL_EMU
的情况,SIGTRAP产生的原因只有syscall_enter_stop
,不存在syscall_exit_stop
,所以不需要调用PTRACE_GETSIGINFO
读取被追踪进程的信号进行区分。
(这一部分忽略了比较多的细节)
第四步:进程A模拟执行系统调用
进程A模拟执行系统调用,使用PTRACE_GETREGSET
选项读取进程B的上下文信息,获取系统调用参数,模拟执行后,再调用PTRACE_SETREGSET
将结果保存在对应的寄存器上下文中。
值得注意的是在RISC-V上,a0寄存器既是函数调用的第一个参数又是函数调用的返回值。因此在do_trap_ecall_u中会将原始的a0保存在orig_a0中(因为内核会修改上下文信息的a0,在do_trap_ecall_u会设置a0为ENOSYS,),确保syscall_handler获取的是系统调用的第一个参数。
进程B恢复执行时a0寄存器是从寄存器上下文信息中的orig_a0
变量读取的,但是在至少v6.9(我测试gVisor的版本)RISC-V上这里有问题,orig_a0
并不释放给用户空间,也就是不能由PTRACE_SETREGSET
设置,所以还需要修改,将orig_a0释放给用户空间修改。
PTRACE_SYSEMU
的情况较为简单:随后回到第二步,进程B设置为就绪状态,之后内核调度执行进程B,进程B此时仍在内核态,从schedule之后继续往后执行,之后回到do_trap_ecall_u
,调用syscall_exit_to_user_mode
,此时没有syscall-exit-stop,直接执行exit_to_user_mode
,从内核态回到用户态,进程B恢复执行,进入下一轮循环。
PTRACE_SYSCALL
的情况复杂一些:在do_trap_ecall_u
中系统调用被内核的syscall_handler执行(因此对于UML,gVisor这类模拟系统调用的用户态内核,使用PTRACE_SYSCALL
需要将原本的syscall替换为一个无副作用的syscall比如getpid),随后才进入syscall_exit_to_user_mode
调用ptrace_report_syscall_exit
进入syscall-exit-stop,情况和第三步发送PTRACE_EVENTMSG_SYSCALL_ENTRY
类似,只是此时message换成了PTRACE_EVENTMSG_SYSCALL_EXIT
,之后从ptrace_report_syscall_exit
返回,往后执行exit_to_user_mode
,从内核态回到用户态,进程B恢复执行,进入下一轮循环。
因为PTRACE_SYSEMU
没有syscall-exit-stop,上下文切换由PTRACE_SYSCALL
的两次变为一次,性能略有提升,数据可以参考这里。
PTRACE在Linux RISC-V内核中的支持
- 在这个commit:https://github.com/torvalds/linux/commit/f0bddf50586da81360627a772be0e355b62f071e之后,riscv kernel entry部分的代码切换到了指令集架构无关的通用代码中
kernel/entry/common.c中
- syscall_enter_from_user_mode() 函数最终会调用 syscall_trace_enter(),该函数依次处理 Syscall User Dispatch、ptrace 和 seccomp
syscall_trace_enter()中处理ptrace部分,会判断syscall work的option是否是SYSCALL_WORK_SYSCALL_EMU
- 说明RISC-V上ptrace是支持emu的,不再需要额外的移植工作
PTRACE平台移植到RISC-V上需要完成的工作
- arch/vdso/time/signal 等 package里架构相关代码的编写
- ptrace平台pkg/sentry/platform/ptrace中架构相关代码的编写
New()
New()首先会调用stubInit()初始化Stub代码的加载,透过MMAP将Stub加载到内存中的另一个位置
Stub代码是与指令集架构相关的,目前已经完成了Stub代码的编写
需要加载Stub代码到另外的内存区域是为了加载和运行用户程序时能保持原系统中用户进程内存空间布局,不影响用户程序代码的加载和使用
New()中随后会newSubprocess()透过fork创建一个子进程,这个子进程的第一个子线程运行stub,作为master,负责后续其他子进程的创建,因而是全局资源的管理者
stubInit()
stubInit()在进程地址空间walk,调用MMAP在地址空间的指定起始地址中创建一个匿名映射,直到MMAP返回的起始地址与指定的起始地址相同
最初的时候gVisor运行不起来,通过STRACE追踪系统调用发现一直在MMAP并且返回的地址和预期的地址相差很远。
后来排查Linux内核代码与MMAP相关的部分发现了这个patch:[LKML: Charlie Jenkins: [PATCH 0/3] riscv: mm: Use hint address in mmap if available](https://lkml.org/lkml/2024/1/29/1473)
也就是说这里MMAP一直不停重试的原因是MMAP并不会按照提示的地址去创建映射,更换内核(v6.9-rc1之后版本包含该patch)后问题解决
NewSubprocess()
New()和后续NewAddressSpace()都会调用这个函数获取一个可用的子进程,这个函数会首先尝试从进程池里获取一个进程,如果没有则新建一个
NewSubprocess()新建进程过程会启动一个goroutine
这个goroutine先创建一个子进程,负责attach子进程中的一个线程,并在一个循环体中接收后续在子进程中创建子线程的请求
New()中第一个stub线程的创建创建线程调用的是createStub()完成seccomp初始化等步骤最终走到forkStub()调用clone创建子进程,此时子进程中调用stubCall运行stub程序,stub程序调用SIGSTOP通知sentry可用attach了,sentry随后grabInitRegs()将此时的寄存器上下文保存到initRegs,rewind pc到ecall的位置便于后续syscall的注入,便完成了第一个线程的attach
后续NewAddressSpace()创建子进程调用的是globalPool.master.createStub(),globalPool.master是第一个stub线程,globalPool.master.createStub()注入syscall时会通过initChildProcessPPID()设置寄存器S7=master进程pid,S8=1,之后注入syscall执行,子进程stub重新执行一次begin段,SIGSTOP之后sentry完成attach
// stub_riscv64.s
begin:
// N.B. This loop only executes in the context of a single-threaded
// fork child.
MOV $SYS_PRCTL, A7
MOV $PR_SET_PDEATHSIG, A0
MOV $SIGKILL, A1
ECALL
BNE ZERO, A0, error
// If the parent already died before we called PR_SET_DEATHSIG then
// we'll have an unexpected PPID.
MOV $SYS_GETPPID, A7
ECALL
BNE A0, S7, parent_dead
MOV $SYS_GETPID, A7
ECALL
BLT A0, ZERO, error
MOV ZERO, S8
// SIGSTOP to wait for attach.
//
// The SYSCALL instruction will be used for future syscall injection by
// thread.syscall.
MOV $SYS_KILL, A7
MOV $SIGSTOP, A1
ECALL
// The sentry sets S8 to 1 when creating stub process.
MOV $1, T1
BEQ T1, S8, clone
done:
// Notify the Sentry that syscall exited.
EBREAK
JMP done // Be paranoid
clone:
// subprocess.createStub clones a new stub process that is untraced,
// thus executing this code. We setup the PDEATHSIG before SIGSTOPing
// ourselves for attach by the tracer.
//
// S7 has been updated with the expected PPID.
BEQ ZERO, A0, begin
// The clone system call returned a non-zero value.
JMP done
Switch()
Switch()负责在给定的addressSpace中运行指定的context
Switch()拿到addressSpace中创建好的新进程s,调用s.switchApp()
switchToApp()中首先调用s.sysemuThreads.lookupOrCreate()在sysemuThreads线程池中查找或新建一个与当前线程绑定的sysemuThread线程,如果不存在则会通过channel通知NewSubprocess()创建的goroutine通过向进程的stub线程注入一个clone syscall创建线程
goroutine收到请求之后,调用firstThread.clone(),向stub线程注入一个clone syscall,返回一个包含新线程tid的thread对象
之后SwitchToApp()设置thread的寄存器,执行PTRACE调用启动线程,sentry wait直到线程调用syscall触发SIGTRAP线程停止执行,syscall交由sentry进行处理,处理完成后重新Switch恢复线程的执行
Reference
c - How does ptrace work in Linux? - Stack Overflow
Entry/exit handling for exceptions, interrupts, syscalls and KVM — The Linux Kernel documentation