ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LKDS3.Linux内核的双向链表代码解析(3) 进程树遍历(上) 前置知识: children、sibling、real_parent、parent以及ptrace()调试进程

LKDS3.Linux内核的双向链表代码解析(3) 进程树遍历(上) 前置知识: children、sibling、real_parent、parent以及ptrace()调试进程 目录1.内核串联进程的方法全局的进程链表父子进程链表childrensiblingreal_parent和parent的含义和区别实验1: 普通情况测试父进程创建子进程父子进程不退出lx_task_by_pid()测试父进程创建子进程父进程退出子进程不退出实验2: 特殊情况: ptrace()ptrace()的介绍回顾进程状态参考资料测试读取进程的寄存器值反思: tracer进程为什么wait()或waitpid()来等待tracee进程?规范调用ptrace()的代码补充资料: “parent” vs. “real_parent” 分析文章上文讲了Linux内核的双向链表代码的遍历算法,下面看看内核是怎么遍历进程树的,顺便再用此思想解决LeetCode 扁平化多级双向链表1.内核串联进程的方法全局的进程链表之前在LKDS2.Linux内核的双向链表代码解析(2) 遍历算法文章简单提到过内核中全局的进程链表,在task_struct的定义中(/include/linux/sched.h):struct task_struct { //...... struct list_head tasks; //...... };其次,不管是什么类型的进程(守护进程、父进程、子进程、init进程、轻量级进程......),内核都必须将它们的task_struct对象放到全局的进程链表结论: 内核将所有进程的task_struct对象放到全局的进程链表父子进程链表struct task_struct里面还有其它类型为struct list_head的成员,如下:struct task_struct{ //...... struct list_head children; struct list_head sibling; struct list_head thread_node; /* Real parent process: */ struct task_struct __rcu *real_parent; /* Recipient of SIGCHLD, wait4() reports: */ struct task_struct __rcu *parent; //...... };下面解释这些成员的含义childrenchildren指的是该父进程的子进程链表,也就是说,父进程产生一个子进程,这个子进程的task_struct就挂在父进程的children下sibling从进程的血缘关系来说,父进程调用了2次fork()产生了子进程a和子进程b,那么子进程a和子进程b就是兄弟姐妹关系即: 具有同一个父进程的子进程们是兄弟姐妹关系具体内核是怎么操作children和sibling两个链表的,见LKDS5.Linux内核的双向链表代码解析(5) 进程树遍历(下) fork()内对children、sibling的设置、遍历算法前置知识real_parent和parent的含义和区别实验1: 普通情况这里做一个实验,编译内核后上gdb调试父进程创建子进程代码注: 方法在x86_64下,编译 qemu运行默认配置的Linux 6.18.8内核的步骤(详细步骤)和gdb本地调试qemu内运行的Linux 6.18.6内核文章讲了,同样使适用于编译调试v7.x测试父进程创建子进程父子进程不退出main.c:#include stdio.h #include sys/types.h #include unistd.h int main() { pid_t ret_idfork(); if (ret_id0) { while(1) { printf(children process is running! PID %d,PPID %d\n,getpid(),getppid()); sleep(2); } } else if (ret_id0) { while(1) { printf(father process is running! PID %d,PPID %d\n,getpid(),getppid()); sleep(1); } } else { printf(error!); } return 0; }在宿主机上编译,建议使用静态编译,防止虚拟机内部找不到so文件gcc -static main.c -o main.out由于我编译的内核是x86_64,和我宿主机是一样的,那么我可以直接把main.out复制到虚拟机的根文件系统:先启动qemu:sudo qemu-system-x86_64 -kernel 生成的bzImage的路径 \ -hda 生成的rootfs.ext4的路径 \ -append nokaslr root/dev/sda rw consoletty0 \ -s -S后在内核目录内(否则gdb找不到vmlinux和py脚本)启动gdb:gdb -ex add-auto-load-safe-path pwd -ex file vmlinux -ex target remote :1234可以看到main.out在里面:启动该文件,发现父进程的PID为68,子进程的PID为69使用gdb看看这2个进程的task_struct的real_parent和parent,这里通过lx_task_by_pid()脚本函数lx_task_by_pid()定义在/scripts/gdb/linux/tasks.py中,是个python函数:结论: lx_task_by_pid(子进程的PID)可以获得该进程的task_struct对象如何确认子进程的real_parent和parent是哪个进程的? 答: 每个进程task_struct里面都会有进程的PID,那么可以打印PID,如real_parent-pid、parent-pid,命令如下:p $lx_task_by_pid(子进程的PID)-real_parent-pid p $lx_task_by_pid(子进程的PID)-parent-pid发现子进程的real_parent和parent都是父进程的!测试父进程创建子进程父进程退出子进程不退出测试方法和之前一样,但是main.c的代码为:#include stdio.h #include sys/types.h #include unistd.h #include stdlib.h int main() { pid_t ret_idfork(); if (ret_id0) { while(1) { printf(children process is running! PID %d,PPID %d\n,getpid(),getppid()); sleep(2); } } else if (ret_id0) { printf(father process is running! PID %d,PPID %d\n,getpid(),getppid()); printf(father process will exit in 1s...); sleep(1); exit(0); } else { printf(error!); } return 0; }等父进程退出:再使用gdb查看: 发先父进程退出后,子进程被PID1的init进程领养,前者成为孤儿进程,而且此时孤儿进程的real_parent和parent都是init进程从2个实验结果来看,发现对于普通情况而言,进程A创建了进程B,那么进程B的parent和real_parent都是进程A,假设进程A比进程B先终止,那么进程B成为了孤儿进程,被init进程收养,那么进程B的parent和real_parent都变成init进程结论: 普通情况下,parent和real_parent指的都是同一个进程这样看貌似real_parent和parent没什么区别,但其实是有特殊情况的!实验2: 特殊情况: ptrace()ptrace()的介绍如果使用了ptrace(),那么子进程的real_parent和parent就不一定一样了ptrace()在之前的文章没有讲过,这里简单说明一下:man ptraceptrace全称是processtrace,简而言之,ptrace()是由一个进程(叫tracer进程)发起来跟踪另外一个进程(叫tracee进程)的结论: 发起控制的进程被称为tracer进程,被控制的进程被称为tracee进程tracer进程可以通过ptrace()来观察和控制tracee进程,比如控制后者让其执行任意代码、查看或修改后者的寄存器、内存等,当然gdb调试器、strace命令的底层就是通过调用ptrace()来实现调试指定进程的解释该函数的参数:long ptrace(enum __ptrace_request op, pid_t pid, void *addr, void *data);op: 要执行什么任务,比如op PTRACE_GETREGS是获取进程的寄存器值、op PTRACE_SETREGS是设置进程的寄存器值、......pid: 要跟踪的进程的进程号addr: 如果要访问(读或写)被跟踪的进程的地址空间,那么addr不能为空,addr给出的是要访问的进程地址空间的位置data: 如果要向进程的地址空间写入内容,那么data不能为空,要指向提供的写入数据,或者保存从被跟踪进程的地址空间中的数据,即data的作用是保存读取出或者要写入的数据这里看几个函数来演示ptrace()使用,为了方便,这里我使用gaffe23/linux-inject: Tool for injecting a shared object into a Linux process项目里面的ptrace.c的其中几个函数://获取寄存器值 void ptrace_getregs(pid_t target, struct REG_TYPE* regs) { //addr为NULL,因为不需要访问进程的地址空间 if(ptrace(PTRACE_GETREGS, target, NULL, regs) -1) { fprintf(stderr, ptrace(PTRACE_GETREGS) failed\n); exit(1); } } //读地址空间 void ptrace_read(int pid, unsigned long addr, void *vptr, int len) { int bytesRead 0; int i 0; long word 0; long *ptr (long *) vptr; while (bytesRead len) { //这里addr不为空,是读取被跟踪的进程的地址空间的addr bytesRead地址处的数据 word ptrace(PTRACE_PEEKTEXT, pid, addr bytesRead, NULL); if(word -1) { fprintf(stderr, ptrace(PTRACE_PEEKTEXT) failed\n); exit(1); } bytesRead sizeof(word); ptr[i] word; } } //写地址空间 void ptrace_write(int pid, unsigned long addr, void *vptr, int len) { int byteCount 0; long word 0; while (byteCount len) { memcpy(word, vptr byteCount, sizeof(word)); //这里addr和data都不为空,将数据word写入被跟踪进程地址空间中的addr byteCount处 word ptrace(PTRACE_POKETEXT, pid, addr byteCount, word); if(word -1) { fprintf(stderr, ptrace(PTRACE_POKETEXT) failed\n); exit(1); } byteCount sizeof(word); } }回顾进程状态回顾之前在OS19.【Linux】进程状态(1)和OS20.【Linux】进程状态(2) 僵尸进程、孤儿进程和进程优先级讲的Linux中进程的几个状态:1.: 运行状态2. S: 睡眠状态(浅睡眠)3. D: 不可中断的状态(深睡眠)4. T: 停止运行状态,5. t: 被跟踪的状态,指的是该进程被其他进程跟踪6. Z: 僵尸状态这里要看的是t状态,当tracer进程调用ptrace()跟踪tracee进程时,tracee进程的状态是t状态,一个进程进入t状态可以通过两种方式,这里只讲其中一种:进程不主动要求被调试的情况,具体来说是由tracer进程主动发起附着(PTRACE_ATTACH)操作结论: tracer进程调用ptrace(),并在op参数处传递PTRACE_ATTACH,并出tracee进程的pid,从而让tracee进程进入t状态参考资料1.有关入门ptrace()调试的详细介绍参见威力巨大的系统调用——ptrace文章,主要讲使用PTRACE_TRACEME的调试方法2.ptrace()详细介绍参见Ryan ONeill所著的Learning Linux binary analysis测试读取进程的寄存器值x86_64环境下,让shell启动进程a和进程b,进程a一直在后台运行,不打印提示信息,进程b调用ptrace()获取进程a的部分寄存器的值并打印的屏幕上,之后使用gdb查看a进程的real_parent和parenta.c:int main() { for(;;); }b.c:根据man手册的说明,tracee进程必须先附加到tracer进程上!! 使用op PTRACE_ATTACH的ptrace()这里就读取一次寄存器:#include unistd.h #include ptrace.c int main() { pid_t target_pid; struct user_regs_struct regs; printf(The process is running! PID %d\n,getpid()); printf(Input a target process PID to view its regs:); scanf(%d,target_pid); //目标进程必须处于暂停状态才能使用ptrace()获取寄存器 ptrace_attach(target_pid); ptrace_getregs(target_pid,regs); printf(target process regs\n); printf(rax 0x%llx\n, regs.rax); printf(rbx 0x%llx\n, regs.rbx); printf(rcx 0x%llx\n, regs.rcx); printf(rdx 0x%llx\n, regs.rdx); printf(rip 0x%llx\n, regs.rip); for(;;); return 0; }注: 需要把linux-inject里面的ptrace.c、ptrace.h下载下来,和b.c放到同一个目录下,master.zip下载链接为: linux-inject-master.zip注: user_regs_struct定义在/include/uapi/asm/ptrace.h中struct user_regs_struct { unsigned long gr[32]; /* PSW is in gr[0] */ unsigned long sr[8]; unsigned long iaoq[2]; unsigned long iasq[2]; unsigned long sar; /* CR11 */ unsigned long iir; /* CR19 */ unsigned long isr; /* CR20 */ unsigned long ior; /* CR21 */ unsigned long ipsw; /* CR22 */ unsigned long cr0; unsigned long cr24, cr25, cr26, cr27, cr28, cr29, cr30, cr31; unsigned long cr8, cr9, cr12, cr13, cr10, cr15; unsigned long _pad[80-64]; /* pad to ELF_NGREG (80) */ };对于x86_64而言,具体user_regs_struct里面放了哪些类型的寄存器,见/usr/include/sys/user.h里面的user_regs_struct将a.c和b.c分别静态编译为a.out和b.out然后复制到虚拟机的根文件系统中,启动qemugdb后:先后台启动执行无限循环的a.out:./a.out之后使用pidof查看a.out,记下PID:pidof a.out启动b.out(不需要sudo):./b.out输入a.out的PID,刚刚使用pidof查看的:转到gdb,执行一下命令:p $lx_task_by_pid(a.out的PID)-real_parent-pid p $lx_task_by_pid(a.out的PID)-parent-pid运行结果: 发现被b.out跟踪的a.out的parent是b.out,但real_parent是谁?可以直接查看a.out的real_parent的task_struct的进程名字段comm,发现是shell结论: 对于ptrace()的特殊情况,被跟踪进程的parent是调用ptrace()来跟踪的进程,real_parent是原先创建子进程的父进程,当然,也可以是父进程跟踪子进程,这样子进程的parent和real_parent都是父进程,显然特殊情况下,parent和real_parent可能相等,可能不等; task_struct-real_parent是血缘上的父进程与 task_struct-parent可能是调试上的父进程,也可能是血缘上的父进程其实上面的测试代码并不规范,要想观察到寄存器的多次变化,tracer进程必须使用wait()或waitpid()来等待tracee进程,然后再调用反思: tracer进程为什么wait()或waitpid()来等待tracee进程?本文前面提到了,由tracer进程主动发起附着(PTRACE_ATTACH)操作来跟踪tracee进程,但漏了一点: 之后tracee进程会收到tracer进程发来的SIGSTOP信号!!!tracee进程处理SIGSTOP信号需要一段时间,因此tracer进程需要等待tracee进程发生状态变化,比如从R状态到t状态之前在OS25.【Linux】进程等待 (上)说过wait()或waitpid()是父进程用于等待子进程发送状态变化的,但上面的实验说明tracer进程可能不是tracee进程的真正父进程,为什么下图的man手册中说可以wait或waitpid来等待被跟踪的子进程呢?换句话说,wait()或waitpid()可被父进程调用来等待子进程,但ptrace()实验表明,tracer进程可能不是tracee进程的真正父进程,却依然能通过wait()或waitpid()来获取其状态变化,这看似与讲wait手册中‘等待子进程’的描述相矛盾结合本文上方的real_parent和parent结论,推导: 父进程调用wait()或waitpid()时,内核必须校验父进程是否有权等待目标子进程,为了防止父进程误等非自己的子进程,所以内核必须校验传入wait()或waitpid()的子进程PID,但内核根据PID找到目标进程,再校验父子关系依据的到底是子进程task_struct里面的real_parent指针还是parent指针?答: 这里讲起来比较复杂,需要连带分析glibc和linux内核源码,所以分析会比较长只推导从waitpidI()开始的调用链,因为wait()是waitpid()的子集,所以wait()就不推导了代码中调用的waitpid(),实际上调用的是glibc的__waitpid(),定义在/posix/waitpid.c中:/* Wait for a child matching PID to die. If PID is greater than 0, match any process whose process ID is PID. If PID is (pid_t) -1, match any process. If PID is (pid_t) 0, match any process with the same process group as the current process. If PID is less than -1, match any process whose process group is the absolute value of PID. If the WNOHANG bit is set in OPTIONS, and that child is not already dead, return (pid_t) 0. If successful, return PID and store the dead childs status in STAT_LOC. Return (pid_t) -1 for errors. If the WUNTRACED bit is set in OPTIONS, return status for stopped children; otherwise dont. */ pid_t __waitpid (pid_t pid, int *stat_loc, int options) { return __wait4 (pid, stat_loc, options, NULL); } libc_hidden_def (__waitpid) weak_alias (__waitpid, waitpid) //表示waitpid()是__waitpid()的弱别名,它们指向同一个函数入口、同一段代码,这里说明调用waitpid()等价为调用__waitpid()__wait4在glibc的/posix/wait4.c中:pid_t __wait4 (__pid_t pid, int *stat_loc, int options, struct rusage *usage) { __set_errno (ENOSYS); return (pid_t) -1; } stub_warning (wait4)貌似__wait4并没有调用子函数,但是底下有个stub_warning (wait4),其实表示glibc的__wait4调用linux内核的wait4()系统调用,wait4的4(four)是简写的for,因为four和for的读音一样下面进入linux内核分析,我选的是v7.2.2,由于调用链比较长,这里给出长图:总结调用链waitpid() __waitpid() └─ wait4() └─ kernel_wait4() └─ do_wait() └─ __do_wait() └─ do_wait_pid() └─ ......放到用户内核的各个层再看一次下图来自Anatomy of the Linux kernel:对图加上之前的调用链:最重要的是最后的do_wait_pid()和里面的is_effectively_child()!可以看到,do_wait_pid()内部会调用2次is_effectively_child(),一次是ptrace为false,一次是ptrace为true/* * Optimization for waiting on PIDTYPE_PID. No need to iterate through child * and tracee lists to find the target task. */ static int do_wait_pid(struct wait_opts *wo) { bool ptrace; struct task_struct *target; int retval; ptrace false; target pid_task(wo-wo_pid, PIDTYPE_TGID); if (target is_effectively_child(wo, ptrace, target)) { retval wait_consider_task(wo, ptrace, target); if (retval) return retval; } ptrace true; target pid_task(wo-wo_pid, PIDTYPE_PID); if (target target-ptrace is_effectively_child(wo, ptrace, target)) { retval wait_consider_task(wo, ptrace, target); if (retval) return retval; } return 0; }分析当ptrace为false,且获取到了目标进程,执行is_effectively_child(),此时是第一次调用:static bool is_effectively_child(struct wait_opts *wo, bool ptrace, struct task_struct *target) { struct task_struct *parent !ptrace ? target-real_parent : target-parent; return current parent || (!(wo-wo_flags __WNOTHREAD) same_thread_group(current, parent)); }is_effectively_child()首先找目标进程的父进程,使用的是三目运算符:!ptrace ? target-real_parent : target-parent,如果ptrace为true,找目标进程的real_parent; 如果ptrace为false,找目标进程的parent,这个就是问题的答案!!!接着判断目标进程的父进程是不是执行waitpid的进程,如果是那么调用waitpid的进程current应该为struct task_struct *parent当ptrace为true,进行第二次调用is_effectively_child(),此时struct task_struct *parent获得到的是target-parent,这个指向是tracer进程的task_struct里面的parent,和本文前面的实验结果是吻合的那为什么do_wait_pid()要分2次调用呢?应对这种情况: 进程A创建进程B(那么进程B的task_strcuct-real_parent就是进程A),进程C不是进程A的子进程,但是进程C跟踪进程B(那么进程B的task_strcuct-parent就是进程C),而且进程A和进程C都调用waitpid()来等待进程B可以发现,两次调用时传入wait_consider_task()的第二个参数ptrace的值不一样,一个是false,一个是true结论: 对于非ptrace()追踪情况,父进程调用waitpid()等待的子进程必须满足: 子进程的task_struct-real_parent必须指向父进程的; 对于ptrace()追踪情况,tracer进程调用waitpid()等待的tracee进程必须满足: tracee进程的task_struct-parent必须指向tracer进程的; tracer进程跟踪tracee进程,tracer进程会tracee进程发送SIGSTOP信号,tracee进程处理该信号需要一段时间,因此tracer进程需要等待tracee进程发生状态变化,即tracer进程需要调用waitpid()tracer进程访问完tracee进程地址空间的内容后,需要让tracee进程恢复执行,此时可以使用带PTRACE_CONT的ptrace()有了前面的铺垫,可以写一个规范的调用ptrace()代码了规范调用ptrace()的代码过程: tracer进程调用带PTRACE_ATTACH和tracee进程的PID的ptrace(),那么tracer进程会向tracee进程发送SIGSTOP,tracer进程只需要执行waitpid()等待tracee进程的状态发生变化即可,当tracee进程为t状态时,tracer进程可以再次调用ptrace()访问tracee进程地址空间的内容,访问完毕后,tracer进程调用带PTRACE_CONT的ptrace()让tracee进程恢复执行,如果tracer还想继续看tracee进程的进程地址空间内容,可以手动向tracee进程发送SIGSTOP信号,比如kill(pid,SIGSTOP)那waitpid()怎么写?waitpid(pid,NULL,0)可等待子进程收到SIGSTOP发送状态变化,有些文章会为waitpid()的第三个参数添加WUNTRACED选项,但其实没有必要,手册里面说的是but not traced via ptrace(2),没有被ptrace跟踪我想实现一个每个1s查看tracee进程的rip寄存器,代码如下:trace-rip.c:#include stdio.h #include errno.h #include unistd.h #include string.h #include signal.h #include sys/ptrace.h #include sys/user.h #include sys/wait.h int main() { pid_t target_pid; struct user_regs_struct regs; printf(The process is running! PID %d\n,getpid()); printf(Input a target process PID to view its regs:); scanf(%d,target_pid); if(ptrace(PTRACE_ATTACH, target_pid, NULL, NULL) -1) { fprintf(stderr, ptrace(PTRACE_ATTACH) failed: %s\n, strerror(errno)); return 1; } for (;;) { if (waitpid(target_pid,NULL,0) ! target_pid) { fprintf(stderr, waitpid failed: %s\n, strerror(errno)); return 2; } if(ptrace(PTRACE_GETREGS, target_pid, NULL, regs) -1) { fprintf(stderr, ptrace(PTRACE_GETREGS) failed: %s\n, strerror(errno)); return 3; } printf(target process rip 0x%llx\n,regs.rip); if(ptrace(PTRACE_CONT, target_pid, NULL, NULL) -1) { fprintf(stderr, ptrace(PTRACE_CONT) failed: %s\n, strerror(errno)); return 4; } sleep(1); kill(target_pid,SIGSTOP); } return 0; }编译:gcc trace-rip.c -o trace-rip.outtracee进程使用stress进程,安装命令为:sudo apt-get install stress启动stress进程:stress --cpu 1 --timeout 120s #运行120s虽然使用了--cpu 1来限制stress进程在单核上运行,但是实际上stress会开两个进程:pidof stress真正执行计算任务的进程是PID较大的那个:启动tracer进程:sudo ./trace-rip.out运行结果: 跟踪的目的达到了,rip寄存器的值会变化补充资料: “parent” vs. “real_parent” 分析文章参见parent vs. real_parent in struct task_struct - Peilin Yes blog,里面是通过安装内核模块的方式来调试parent和real_parent的
返回列表