用Go看到进程中发生的系统调用
01
系统调用
在我们日常coding时候,一般写的都是用户层的代码,内核对于我们而言好像是透明的,并未关注过。但是程序却无时无刻在与内核打交道,比如当读取文件时候的read,又或者在写文件的Write,都会经过内核。用户程序不会直接与磁盘等硬件打交道,所以不能直接对文件进行操作,所以需要内核这层"垫片",用户程序既然要访问内核,就免不了要执行系统调用。
当要执行系统调用时候,CPU会切换到内核态,执行系统调用函数。
由于内核实现了很多的系统调用函数,所以内核需要为每个函数提供一个标识,代表要调用的内核函数,这个系统调用号在不同的内核架构也不同。(异常以及中断同样会使CPU切换到内核态,不展开描述)
通常一个系统调用的执行流如下
用户程序调用c库或者直接通过自身的汇编指令进行系统调用,需要传递的变量以及系统调用编号保存在cpu寄存器中
进程进入内核态通过寄存器保存的系统调用编号识别系统函数并执行系统调用
系统调用结束,结果以及返回值以及参数保存在寄存器,用户程序从中获取结果
在早期,系统调用是通过软中断触发的,如32位的x86,系统调用的中断号是128,所以会通过INT 0x80指令触发软中断进入系统调用从而进入内核态,读取寄存器存储的值并在系统调用表中找到对应的系统调用并执行,由于使用软中断的形式触发系统调用开销较大,所以渐渐退出视野,取而代之的是使用了汇编指令SYSENTER或SYSCALL的形式触发系统调用,相比软中断触发的方式减少了查询中断向量表等系列操作,提升了性能。
我们可以通过strace命令来获得一个进程的系统调用,常用用法如下
$ strace -p <pid> #查看某个进程的系统调用
$ strace <commond> #查看某条commond指令或进程的系统调用
如写一个很简单的打印函数调试,(后续会使用该程序作为被追踪程序)
#include <unistd.h>
#include <stdio.h>
int main(){
for(;;){
printf("pid=%d\n", getpid());
sleep(2);
}
return 0;
}
$ gcc -o print print.c
通过strace查看这个进程,可以看到系统调用情况
centos@xxxxxx:/app/gowork/stramgrpc/c$ strace ./print
execve("./print", ["./print"], [/* 51 vars */]) = 0
.......
getpid() = 23419
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL) = 0x55e3343e2000
brk(0x55e334403000) = 0x55e334403000
write(1, "pid=23419\n", 10pid=23419
) = 10
nanosleep({tv_sec=2, tv_nsec=0}, 0x7ffd2a6d37f0) = 0
getpid() = 23419
write(1, "pid=23419\n", 10pid=23419
) = 10
nanosleep({tv_sec=2, tv_nsec=0}, ^Cstrace: Process 23419 detached
<detached ...>
strace命令是c语言实现的,基于Ptrace系统调用。由于服务器体系不同,系统调用机制也会随之变动,所以在strace源码里面有大量的预处理器代码,阅读起来十分吃力不讨好😵。
既然Golang封装了系统调用的包,可以直接通过汇编执行系统调用,也可以用Golang实现一个简单的ptrace工具来监控进程的系统调用,我们在这里主要专注x86_64的Linux syscall。
02
ptrace
要实现一个ptrace工具,首先对ptrace做一些了解,看一下c标准库的定义规则。
long ptrace(int request, pid_t pid, void *addr, void *data);
ptrace在需要传入四个参数:
pid用于传入目标进程,也就是要跟性进程的pid;
addr和data用于传入内存地址和附加地址,通常会在系统调用结束后读取传入的参数获取系统调用结果,会因操作的不同而不同。
request用于选择一个符号标志,内核会根据这个标志决定要选用那个内核函数来执行,接下来介绍一下重点要使用的几个符号标志。
其他的用法不过多展开,感兴趣的同学可以自己探索一下。
PTRACE_ATTACH发出一个请求,连接到一个进程并开始跟踪,相反,PTRACE_DETACH从该进程断开并结束跟踪。在调用该指令后,被跟踪进程会发送信号给跟踪者进程,跟踪者进程需要使用waitpid获取该信号,并进行后续的系统调用跟踪。
PTRACE_SYSCALL发出系统调用追踪的指令,当使用了该选项时候,被追踪的进程就会在进入系统调用之前或者结束后停下来,这时候追踪者进程可以使用waitpid系统调用时候收到被追踪者发来的通知,从而分析此时的地址空间以及系统调用相关等信息;
PTRACE_GETREGS和PTRACE_SETREGS用来设置和读取CPU寄存器,在x86_64的Linux上,系统调用的编号存储在orig_rax寄存器,其他参数是在rdi、rsi、rdx等寄存器,在返回时,返回值存储在rax寄存器;
PTRACE_TRACEME:此进程允许被其父进程跟踪(用于strace+命令形式)。
......其他的使用方式还有很多,感兴趣同学可以读下《深入理解Linux内核与架构 13.3.3追踪系统调用》
03
go的实现
go中提供了syscall包可以直接调用汇编代码进行系统调用,本案例基于go1.13.5的syscall包。
03.1
思路
首先使用PTRACE_ATTACH去跟踪进程,紧接着使用wait系统调用去获取被跟踪者发出的信号。
func PtraceAttach(pid int) (err error) {...}
func Wait4(pid int, wstatus *WaitStatus, options int, rusage *Rusage) (wpid int, err error) {...}
接下来通过一个无限循环读取被跟踪者进程的系统调用
首先通过PTRACE_SYSCALL来等待被跟踪进程进入系统调用,通过wait等待被跟踪进程进入期望状态,此时,被跟踪进程还未陷入系统调用。
func PtraceSyscall(pid int, signal int) (err error) {...}
func Wait4(pid int, wstatus *WaitStatus, options int, rusage *Rusage) (wpid int, err error) {...}
这一步通过PTRACE_GETREGS获取寄存器参数,包括系统调用编号以及其他参数等。
func PtraceGetRegs(pid int, regsout *PtraceRegs) (err error) {...}
接下来使用另一个PTRACE_SYSCALL,以及wait获取系统调用等待系统调用返回;
使用PTRACE_GETREGS通过寄存器参数获取返回的结果
进入下一个循环
出现异常,使用PTRACE_DETACH断开跟踪状态
03.2
实现
type syscallTask struct {
ID uint64
Name string
}
var sTask = []syscallTask{
{0, "read"},
{1, "write"},
{2, "open"},
{3, "close"},
{4, "stat"},
......
}
func main() {
//寄存器状态数据
var regs syscall.PtraceRegs
//wait的等待状态
var wsstatus syscall.WaitStatus
//被跟踪进程pid
pid := 13070
fmt.Println(pid)
var err error
//对PTRACE_ATTACH的封装,使用attach连接并跟踪进程
err = syscall.PtraceAttach(pid)
if err != nil{
fmt.Println(err)
return
}
syscall.Wait4(pid,&wsstatus,0,nil)
//如果异常退出,则断开联系
defer func() {
//对PTRACE_DETACH的封装,断开与跟踪者的连接
err = syscall.PtraceDetach(pid)
if err != nil{
fmt.Println("PtraceDetach err :",err)
return
}
syscall.Wait4(pid,&wsstatus,0,nil)
}()
//循环获取
for {
fmt.Println("")
//等待被跟踪进程进入系统调用
syscall.PtraceSyscall(pid,0)
//使用wait系统调用,并传入等待的状态指针
_, err := syscall.Wait4(pid, &wsstatus, 0, nil)
if err != nil{
fmt.Println("line 501",err)
return
}
//根据wsstatus判断该进程是否被信号中断,如键盘的ctrl+C信号等,
//如果有,则传递该信号到被追踪进程
if wsstatus.StopSignal().String() == "interrupt"{
syscall.PtraceSyscall(pid, int(wsstatus.StopSignal()))
fmt.Println("send interrupt sig to pid ")
//打印退出码
fmt.Println("------exit status",wsstatus.ExitStatus())
return
}
//对PTRACE_GETREGS的封装,获取寄存器的数据保存到regs中
err = syscall.PtraceGetRegs(pid, ®s)
if err != nil{
fmt.Println("PtraceGetRegs err :",err.Error())
return
}
if regs.Orig_rax < 0{
fmt.Println("NULL")
}
//打印系统调用名称
fmt.Println("in syscall :",sTask[regs.Orig_rax].Name)
//第二组PTRACE_SYSCALL与waitpid,等待系统调用返回
//用于获取系统调用返回后的参数
syscall.PtraceSyscall(pid, 0)
_ ,err = syscall.Wait4(pid,&wsstatus,0,nil)
if err != nil{
fmt.Println("line 518",err)
return
}
//如果被跟踪进程退出,打印进程的退出码
if wsstatus.Exited(){
fmt.Println("------exit status",wsstatus.ExitStatus())
return
}
//同上,判断进程是否被信号打断
if wsstatus.StopSignal().String() == "interrupt"{
syscall.PtraceSyscall(pid, int(wsstatus.StopSignal()))
fmt.Println("send interrupt sig to pid ")
fmt.Println("------exit status",wsstatus.ExitStatus())
}
//获取返回后的寄存器状态
err = syscall.PtraceGetRegs(pid, ®s)
if err != nil{
fmt.Println("PtraceGetRegs err :",err.Error())
return
}
if regs.Orig_rax < 0{
fmt.Println("NULL")
}
//打印寄存器中存储的返回值参数
fmt.Println("syscall return:" ,regs.Rax)
}
使用该用例测试上述的demo
$ ./print
$ go build -o gostrace main.go
$ sudo ./gostrace
输出结果:
centos@XXXXXXX:/app/gowork/gostraces# sudo ./gostrace
20533
in syscall : restart_syscall
syscall return: 0
in syscall : getpid
syscall return: 20533
in syscall : write
syscall return: 10
in syscall : nanosleep
syscall return: 0
in syscall : getpid
syscall return: 20533 #在此处ctrl+c中断了上述print进程
send interrupt sig to pid
------exit status -1
PtraceDetach err : no such process
可以看到最简版本strace基本功能已经实现了,可以看到相比于strace少了一个传参,传参的功能就需要针对具体的系统调用读取寄存器中的数据,有兴趣的同学可以自己考虑实现下。
04
参考文章
《深入理解Linux内核与架构》第13章系统调用
《深入理解Linux内核》第10章系统调用
推荐阅读