内核模块里获取当前进程和父进程的cmdline的方法及注意事项,涉及父子进程管理,和rcu的初步介绍
一、背景
在编写内核态系统监控代码时,有时候为了调试的便捷性,不仅要拿到异常事件有关的线程id,进程id和父进程id,还需要拿到当前进程和父进程的comm和cmdline。主要有几下几个原因:
1)单纯的pid或者tgid其信息本身并不能给我们多少线程有关的有效信息,除了一些系统的内核线程的pid是固定的以外,其他的线程的pid都是每次运行会发生变化的,这些信息如果不配合其他的监控信息就不能独立作为详细的调试依据
2)如果仅仅是获取comm也就是不带上args参数(cmdline当然是带上args参数的完整的命令)的话,那么其信息一般也是非常有限的,因为启动程序如果不是fork运行的话,都是要通过shell来解释运行,这时候该进程一开始运行的时候comm就继承了父进程的comm也就是bash,什么具体信息也提供不了,当然后面通过泛exec的系统调用变成了实际运行的程序以后,光一个程序名字有时候信息还是不够的,比如如果是运行一个脚本,如执行sudo python3 xxx,其实它是父子两个进程,父进程的comm是sudo,子进程的comm是python3,那么这时候如果只是抓comm信息的话,就算也同时抓父进程的comm,那也只能拿到sudo和comm这两个名字,也不知道到底运行的什么python3脚本。如下截图:
但如果去捞cmdline信息的话,你就能拿到详细的信息,能大致知道是跑的什么程序:
接下来,我们将在第二章里给出内核模块里获取到当前进程和父进程的cmdline方法,以及非常重要的注意事项,另外还会涉及父子进程管理的一些细节,并对用这些细节做了不少验证的实验,包括孤儿进程的归属问题,如child_subreaper的细节。在第三章里会贴一张第二章里讲到的获取进程和父进程的cmdline方法时用到的rcu这个内核重要的高性能神器。第三章里只会简单介绍一下rcu,关于rcu的复杂和繁琐的细节,后面的博客会逐一展开,我会在第三章里贴出总结的rcu的一些概念的思维导图截图,先对rcu相关的内容起一个头。注意,本文还并不涉及rt-linux下的rcu,rt-linux下的rcu比普通linux下的rcu在管理上更加复杂,暂且先不涉及。
本文有不少篇幅在进程线程管理细节上,尤其父子进程管理,相关的进程/线程管理的之前博文,可以参考 进程/线程创建和退出事件的捕获_for (inti=0;i<2;++i){ threads.emplace back(worker,-CSDN博客。
二、内核模块里获取当前进程和父进程的cmdline的方法
我们先在2.1一节里看一下核心代码,对代码进行一些说明,然后在2.2一节里讲一下注释事项重点时什么上下文下才能调用这段核心代码。
2.1 内核模块里获取当前进程和父进程的cmdline的核心代码
这里面其实分为两个子任务,一个是获取当前进程的父进程的pid,另一个是根据pid获取pid对应的cmdline。
为什么要这么分?因为获取当前进程的pid和其父进程的pid是没有上下文环境限制的,当前进程的pid非常简单,只需要current->pid即可,当前进程的父进程的pid的获取相对复杂一些,只需要加rcu的锁保护,确保current的real_parent变量在获取real_parent变量(real_parent变量是一个task_struct指针,指向task_struct表示的线程的父进程,一个线程通过getppid得到的是父进程的pid,而不是父进程里创建该子进程的线程id,这个会在2.3一节里做实验验证,但是如果通过real_parent->pid得到的是父进程里创建该子进程的线程id,这个会在2.4一节里做实验验证)在访问其pid信息时是有效的(在访问期间没被释放)。
而获取线程/进程的cmdline则是有上下文环境限制的,因为获取一个进程的cmdline并没有获取一个进程的comm那么简单(获取一个进程的comm直接从task_struct里的comm[16]数组里拷贝走就行了),获取一个进程的cmdline的获取期间需要加锁,需要放在可睡眠的上下文环境里,如kworker里。
2.1.1 获取当前进程的父进程的pid和comm的逻辑
先看代码:
void get_parent_pid_and_comm(struct output_items* io_pitems, struct task_struct* i_ptask)
rcu_read_lock();
parent = rcu_dereference(i_ptask->real_parent);
io_pitems->currppid = parent->pid;
strlcpy(io_pitems->currppidcomm, parent->comm, TASK_COMM_LEN);
rcu_read_unlock();
}
从上面代码可以看到,需要用rcu锁来保护,在引用rcu保护的变量时,要用rcu_dereference去引用指针,然后再去获取指针指向的内容。关于rcu的细节见第三章及后面的博文。
关于real_parent的细节见下面的 2.1.1.1 一节。
2.1.1.1 关于task_struct的real_parent指针
首先如下图,task_struct里的real_parent是一个task_struct的指针,且受到rcu的保护:
关于ppid等父进程的信息的获取,需要使用real_parent变量,而不是其他变量,可以参考getppid系统调用的实现,如下:
我们实现的方式和系统调用getppid实现方式还是有一点区别,getppid系统调用用的是父进程id,我们直接用real_parent->pid是父进程里创建子进程的那个线程的线程id。getppid的例子验证在2.3一节,我们的real_parent->pid的例子验证在2.4一节。
不管是哪种,rcu锁还是要保护,使用real_parent这个指针也是一样。
关于real_parent这个受rcu锁保护的变量,在父进程退出以后,会进行刷新动作,如果不做prctl(PR_SET_CHILD_SUBREAPER)的动作的话,刷新后的real_parent指向到了systemd(pid是1),这个会在2.5一节里做实验,而做prctl(PR_SET_CHILD_SUBREAPER)的动作的话,刷新后的real_parent指向到了做prctl(PR_SET_CHILD_SUBREAPER)的进程,这个会在2.6一节里做实验。
下面,我把上面描述的相关的内核代码的部分贴出来:
上图中通过find_new_reaper函数找到了子收尸者,然后一一线程进行real_parent指针的重新assign,这里用的是RCU_INIT_POINTER这个宏,这个宏相对于常用的rcu_assign_pointer来赋值,它有性能优势,但是要使用起来非常小心,细节可以看到内核里的RCU_INIT_POINTER的宏的注释,这里就不展开了。
关于find_new_reaper函数是如何找子收尸者的,见下图,可以从注释里看到一些逻辑上的思路细节:
2.1.2 获取线程/进程的cmdline的逻辑
先看一下核心逻辑代码:
分为几个部分,先是根据pid找到对用的struct pid,通过struct pid获取task_struct的指针,这里面find_get_pid会增加pid结构体的引用计数,所以需要调用put_pid来释放。另外get_pid_task会增加task_struct的引用计数,所以在用完后,要通过put_task_struct来释放。下面代码里有一个my_set_cmdline函数,这个函数马上会介绍。
int pid_to_get_cmdline;
struct task_struct* ptask;
struct pid* pid_struct;
char temp_commandline[128];
pid_struct = find_get_pid(pid_to_get_cmdline);
if (pid_struct) {
ptask = get_pid_task(pid_struct, PIDTYPE_PID);
if (ptask) {
my_set_cmdline(temp_commandline, 128, ptask);
put_task_struct(ptask);
}
else {
temp_commandline[0] = '\0';
}
put_pid(pid_struct);
}
else {
temp_commandline[0] = '\0';
}
my_set_cmdline函数的实现:
这个my_set_cmdline函数是调用了my_get_cmdline获取到cmdline的原始数据,但是cmdline原始数据内容每个arg参数的最后一个字节是\0,我们要把cmdline作为完整的字符串记到别的地方方便显示的话,要把这些参数最后的\0替换成空格,当然最后一个\0不能替换成空格。
void my_replace_null_with_space(char *str, int n) {
for (int i = 0; i < n - 1; i++) {
if (str[i] == '\0') {
str[i] = ' ';
}
}
}
void my_set_cmdline(char* i_pbuff, int i_buffsize, struct task_struct* i_ptask)
{
int ret = my_get_cmdline(i_ptask, i_pbuff, i_buffsize);
if (ret <= 0) {
i_pbuff[0] = '\0';
return;
}
my_replace_null_with_space(i_pbuff, ret);
i_pbuff[ret - 1] = '\0';
}
下面我们来看一下核心逻辑里的核心my_get_cmdline函数:
int my_get_cmdline(struct task_struct *task, char *buffer, int buflen)
{
int res = 0;
unsigned int len;
struct mm_struct *mm = get_task_mm(task);
unsigned long arg_start, arg_end, env_start, env_end;
if (!mm)
goto out;
if (!mm->arg_end)
goto out_mm; /* Shh! No looking before we're done */
spin_lock(&mm->arg_lock);
arg_start = mm->arg_start;
arg_end = mm->arg_end;
env_start = mm->env_start;
env_end = mm->env_end;
spin_unlock(&mm->arg_lock);
len = arg_end - arg_start;
if (len > buflen)
len = buflen;
res = access_process_vm(task, arg_start, buffer, len, FOLL_FORCE);
/*
* If the nul at the end of args has been overwritten, then
* assume application is using setproctitle(3).
*/
if (res > 0 && buffer[res-1] != '\0' && len < buflen) {
len = strnlen(buffer, res);
if (len < res) {
res = len;
} else {
len = env_end - env_start;
if (len > buflen - res)
len = buflen - res;
res += access_process_vm(task, env_start,
buffer+res, len,
FOLL_FORCE);
res = strnlen(buffer, res);
}
}
out_mm:
mmput(mm);
out:
return res;
}
上面这段代码,其实内核里有这个函数的实现,只是这个函数并不作为export symbol给外部模块使用,我们拷贝了一份到我们模块里,这个函数里的主要函数access_process_vm是export symbol的,所以拿过来用没有什么问题。
参考的内核源码里的函数是(mm/util.c里get_cmdline函数):
需要注意,相对早一些的内容,需要多包含linux/sched/mm.h这个头文件,下面的是我当前这个用到nmi和sched的tracepoint以及interrupt和workqueue和hrtimer的这些的头文件集合,供参考:
#include <linux/module.h>
#include <linux/capability.h>
#include <linux/sched.h>
#include <linux/uaccess.h>
#include <linux/proc_fs.h>
#include <linux/ctype.h>
#include <linux/seq_file.h>
#include <linux/poll.h>
#include <linux/types.h>
#include <linux/ioctl.h>
#include <linux/errno.h>
#include <linux/stddef.h>
#include <linux/lockdep.h>
#include <linux/kthread.h>
#include <linux/sched.h>
#include <linux/delay.h>
#include <linux/wait.h>
#include <linux/init.h>
#include <asm/atomic.h>
#include <trace/events/workqueue.h>
#include <linux/sched/clock.h>
#include <linux/string.h>
#include <linux/mm.h>
#include <linux/interrupt.h>
#include <linux/tracepoint.h>
#include <trace/events/osmonitor.h>
#include <trace/events/sched.h>
#include <trace/events/irq.h>
#include <trace/events/kmem.h>
#include <linux/ptrace.h>
#include <linux/uaccess.h>
#include <asm/processor.h>
#include <linux/sched/task_stack.h>
#include <linux/nmi.h>
#include <asm/apic.h>
#include <linux/version.h>
#include <linux/sched/mm.h>
我们回到内核里的get_cmdline这个函数,看看这个函数是怎么拿到任意一个task_struct的cmdline的。
由于cmdline内容是属于具体进程上下文内容部分的,需要借助具体进程上下文的vm_area_struct信息:
get_cmdline用到了access_process_vm,access_process_vm里用到了__access_remote_vm,__access_remote_vm用到了vm_area_struct和get_user_page_vma_remote函数:
而get_user_page_vma_remote其实就是调用的之前的博客 里讲到get_user_pages_remote函数,关于get_user_pages_remote和get_user_pages/pin_user_pages 参考之前的博客 非gdb方式观察应用程序的运行时的变量状态_程序运行变量监控-CSDN博客 和 内存管理之——get_user_pages和pin_user_pages及缺页异常-CSDN博客。
要注意,从get_cmdline里设的是FOLL_FORCE作为gup_flags传下来,在__access_remote_vm里带上了FOLL_WRITE参数,一块传给get_user_page_vma_remote里,虽然和get_user_pages和pin_user_pages一样最终调用了__get_user_pages,但是和get_user_pages和pin_user_pages不一样的是,get_user_pages和pin_user_pages会带上能锁住内存的FOLL_GET或FOLL_PIN标志位,而这里的__access_remote_vm里和非gdb方式观察应用程序的运行时的变量状态_程序运行变量监控-CSDN博客 博客里讲到的get_user_pages_remote函数都不会带上这两个锁内存的标志位来传给最终调用的__get_user_pages核心函数。具体关于锁内存相关内容,见 内存管理相关——malloc,mmap,mlock与unevictable列表_mlock内存可以迁移吗-CSDN博客 和 内存管理之——get_user_pages和pin_user_pages及缺页异常-CSDN博客。
2.2 什么上下文下才能调用这段核心代码
其实在2.1里也提及了部分原因,这里再强调一下,分了两个部分,获取当前进程的pid和父进程的pid是可以在任何上下文,包括中断里甚至nmi中断里。而获取一个进程的cmdline则不能在硬中断里执行,因为获取逻辑里可能会涉及缺页异常,导致中断里套中断;另外,获取逻辑会使用spin_lock,非spin_lock_irqsave,其运行期间会被硬中断打断,就算用了spin_lock_irqsave也还是会被nmi打断,所以放到硬中断里容易发生spinlock死锁,稍有不慎就panic或卡死。所以,获取进程的cmdline这段逻辑,必须运行在可睡眠的上下文里,推荐的就是运行在kworker里。
2.3 一个子进程通过getppid得到的是其父进程的pid而不是父进程里创建该子进程的线程id
关于这个,做一下实验验证:
实验代码:
include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>
// 获取线程 ID 的宏
#define gettid() syscall(SYS_gettid)
// 线程函数
void *thread_function(void *arg) {
printf("Process ID: %ld\n", getpid());
printf("Thread ID: %ld\n", gettid());
// 创建子进程
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return NULL;
}
if (pid == 0) {
// 子进程
printf("In child process:\n");
printf("Child PID: %d\n", getpid());
printf("Child TID: %ld\n", gettid());
printf("Parent PID: %d\n", getppid());
exit(0); // 子进程结束
} else {
// 父进程
wait(NULL); // 等待子进程结束
}
return NULL;
}
请用gcc来编译,用g++编译会找不到wait函数
运行后的结果:
可以看到进程105597里创建了一个线程105598,在这个105598线程里,创建了一个子进程105599,子进程里读取自己的parent pid,用的是getppid()这个函数,得到的是父进程的pid,而不是父进程里创建该子进程的线程id
这里,普及一个基础:
用户态的pid是表示进程id,用户态的tid是表示线程id
内核态的pid是表示线程id,内核态的tgid是表示当前线程所在的进程的线程id,也就是thread group id,即tgid
2.4 通过real_parent->pid得到的是父进程里创建该子进程的线程id
借助2.3的程序,在退出前增加一个while(1)死循环,然后,在insmod的内核模块的sched_stat_runtime里判断是子进程的pid就打印其real_parent的pid和tgid,来做确认。
先加上while(1)死循环逻辑,然后编译运行:
在sched_stat_runtime的注册的tracepoint回调里编写判断和打印逻辑(关于tracepoint如何添加自定义注册和内核模块里添加自定义注册的方法参考之前的博客 内核模块注册调度的tracepoint的回调,逻辑里判断当前线程处于内核态还是用户态的方法-CSDN博客 内核tracepoint的注册回调及添加的方法_tracepoint 自定义回调-CSDN博客):
dmesg里的输出如下:
这个结果其实也印证了在2.1.1.1一节里提到的getppid系统调用里的实现:
上图中用的是task_tgid_vnr,注意里面包含了tgid的字样
2.5 默认情况下,在父进程退出以后,子进程的real_parent会被指向到pid是1的systemd进程
所谓默认情况,就是不做prctl(PR_SET_CHILD_SUBREAPER)的动作。关于做prctl(PR_SET_CHILD_SUBREAPER)的实验,我们在2.6一节里做实验
下面的实验是在2.4的程序的基础上再做一下改动来实验:
把testppid.c的父进程的wait动作去掉:
cb_sched_stat_runtime里的改动:
dmesg的输出信息:
对于用户态里进行getppid获取也一样,sleep(10)以后获取的结果如下:
如果通过pstree -p | grep <pid>去查看进程父子链关系,可以看到其实如果父进程不退出的话,它的父子链是很长的
2.6 prctl(PR_SET_CHILD_SUBREAPER)设置当前进程为子“收尸者”,即child_subreaper
相关的代码逻辑在2.1.1.1里已涉及。
这里直接做实验,代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/prctl.h>
int main() {
// 设置当前进程为子收尸者
if (prctl(PR_SET_CHILD_SUBREAPER, 1) < 0) {
perror("prctl(PR_SET_CHILD_SUBREAPER) failed");
return 1;
}
// 创建父进程
pid_t parent_pid = fork();
if (parent_pid < 0) {
perror("fork failed");
return 1;
}
if (parent_pid == 0) {
// 这是父进程
// 创建子进程
pid_t child_pid = fork();
if (child_pid < 0) {
perror("fork failed");
return 1;
}
if (child_pid == 0) {
// 这是子进程
sleep(2);
printf("Child process (PID: %d)(ppid: %d) is exiting...\n", getpid(), getppid());
exit(0); // 子进程正常退出
} else {
// 父进程
printf("Parent process (PID: %d) is exiting...\n", getpid());
exit(0); // 父进程正常退出
}
} else {
// 这是祖父进程
printf("Grandparent process (PID: %d) is waiting for child...\n", getpid());
// 等待子进程结束
{
int status;
while(wait(&status) > 0); // 祖父进程等待子进程结束
}
printf("Grandparent process reaped the child process!\n");
}
return 0;
}
注意:上面的代码里在子进程退出前,我sleep了2秒,然后打印了getppid的值,即父进程的pid
实验的运行结果:
可以看到在父进程退出后,子进程child process的ppid并没有和2.5一节里的实验一样变成systemd(pid是1),而是变成了调用过prctl(PR_SET_CHILD_SUBREAPER)的祖父进程。
三、给内核的高性能神器rcu的介绍起一个头
内核的rcu锁来自于rwlock的进一步演变,rcu锁和rwlock锁都是针对读多写少的情形,但是rwlock在一些极端情况下,比如多个读者同时进行读操作,因为涉及到cas的add操作,会导致性能因为缓存一致性导致降低得很厉害,而rcu锁如果只有读者的情况下并不需要额外的针对数据和数据相关的管理的开销,并不会标记一些脏数据而导致缓存一致性mesi协议导致的性能开销。
下图是一张浓缩了一系列相关概念精华的思维导图截图,在后面的博文中会详细展开介绍: