1.题目要求
在Linux内核中增加一个系统调用,并编写对应的linux应用程序。利用该系统调用能够遍历系统当前所有进程的任务描述符,并按进程父子关系将这些描述符所对应的进程id(PID)组织成树形结构显示。
2.基础知识
2.1 什么是系统调用?
系统调用,本质上就是内核态函数的调用。CPU在内核态下可以随意转成用户态,但在用户态下不能随意切换到内核态。(内核态和用户态是操作系统的两种运行级别,内核态下CPU可执行任何指令,用户态下CPU只能执行非授权指令)一般程序处于用户态下,如果想通过调用内核态函数使用系统资源时,必须调用软中断的方式,将用户态陷入内核态。
例如,一个运行在用户态的进程,如果要执行文件写操作,必须要调用write等系统调用,也就是通过调用内核中的代码来完成操作。此时,进程从用户态切换到内核态,进入到内核地址空间执行相应的代码,(Linux进程的4GB地址空间,3G-4G部分大家是共享的,是内核态的地址空间,这里存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。)当内核函数执行完成后,再切换到用户态。
2.2 如何添加系统调用?
对于每个系统调用都会有一个对应的系统调用号作为唯一标识,内核维护一张系统调用表(sys_call_table),而系统调用号就是系统调用在系统调用表里的偏移量。
用户空间通过调用syscall函数,产生0x80号软中断,让程序从用户态切换到内核态。在内核空间中接收到软中断0x80信号,系统会执行system_call函数。当0x80号中断执行的时候,系统调用号会被放在eax寄存器中,system_call函数通过读取eax寄存器来获取系统调用号。将获取的系统调用号乘以4得到偏移地址,而偏移地址加上系统调用表(sys_call_table)的基地址就是所要执行服务程序的地址。也就是说,服务程序地址=(eax)×4+sys_call_table的基地址。具体流程如下图所示。
2.3 如何对系统中当前运行的进程遍历?
Linux系统刚开始启动的时候,内核(kernel)只建立了一个init进程。Linux kernel并不提供直接建立新进程的系统调用。剩下的所有进程都是init进程通过fork机制建立的。新的进程要通过老的进程复制自身得到,这就是fork。fork是一个系统调用。进程存活于内存中。每个进程都在内存中分配有属于自己的一片空间 (address space)。当进程fork的时候,Linux在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行。
老进程成为新进程的父进程(parent process),而相应的,新进程就是老的进程的子进程(child process)。一个进程除了有一个PID之外,还会有一个PPID(parent PID)来存储的父进程PID。如果我们循着PID不断向上追溯的话,总会发现其源头是init进程。所以说,所有的进程也构成一个以init为根的树状结构。这样只要获取了本进程的PID就可以探知系统中进程树,从而获取当前系统中所有进程。
3.程序设计与实现
3.1 程序设计思路
添加一个新的系统调用来实现对当前进程遍历,其本质就是在内核中添加一个可以遍历当前进程的函数。可以采用添加内核模块的方法实现。
linux内核模块是一些可以让操作系统在需要时载入和执行的代码,不需要时可以由内核卸载的程序代码。在加载内核模块时无需重启系统,这样可以使内核短小精悍。通过这种方法省去了重新编译带来的麻烦,而且避免了使内核臃肿。
通过内核模块实现添加系统调用,这种方法其实是对系统调用的拦截。系统调用服务程序的地址是放在sys_call_table中,可以通过系统调用号定位到具体的地址,那么我们通过编写内核模块将sys_call_table中的系统调用的地址修改为我们自己定义的函数的地址,这样调用该系统调用时就会执行我们自定义的函数。
下图为本程序的结构图。
3.2 系统调用模块设计与实现
程序基本思路是,通过编写内核模块来修改sys_call_table中的系统调用的地址为我们自己定义的函数的地址,就可以实现系统调用的拦截。因此,按照程序思路,本部分的阐述分为程序基本框架、初始化函数,写保护设置三个部分。
3.2.1 程序基本框架
开始时,我们先要包含一些常见的文件头,并用预定义的宏来描述模块。
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
module_init(init_addsyscall);
module_exit(exit_addsyscall);
MODULE_LICENSE("GPL");
模块入口函数为init_addsyscall(),由module_init()宏指定,在模块被加载的时候被调用向系统注册。入口函数的返回值:0表示成功,非0表示失败。模块的退出函数为exit-_addsyscall(),由module_exit()宏指定,在模块被卸载时被调用向系统注销,主要来完成资源的清理工作。它被调用完毕后,就模块就被内核清除了。一个模块最少需要有入口和退出函数。
3.2.2 内核初始化函数
在这里使用系统空闲的223号空闲的系统调用号。首先保存原223号系统调用的地址,然后修改该系统调用的地址将其指向进程遍历函数。
static int __init init_addsyscall(void)
{
sys_call_table = (unsigned long *)sys_call_table_address;//获取sys_call_table的首地址
anything_saved = (int(*)(void)) (sys_call_table[my_syscall_num]);//保存原始系统调用的地址
orig_cr0 = clear_and_return_cr0();//修改sys_call_table写属性
sys_call_table[my_syscall_num]= (unsigned long)&sys_mycall;//将223号指向自己写的调用函数
setback_cr0(orig_cr0);//恢复页表只读属性
return 0;
}
移除内核模块时,将原有的系统调用进行还原。
static void __exit exit_addsyscall(void)
{
//设置cr0中对sys_call_table的更改权限。
orig_cr0 = clear_and_return_cr0();//设置cr0可更改
//恢复原有的中断向量表中的函数指针的值。
sys_call_table[my_syscall_num]= (unsigned long)anything_saved;
//恢复原有的cr0的值
setback_cr0(orig_cr0);
}
3.2.3 写保护设置
根据程序设计思路,首先要找到sys_call_table的地址。可以在终端输入命令:
grep sys_call_table/boot/System.map-
uname-r’
这样就得到了sys_call_table的地址0xc06224e0,但同时也得到了一个重要的信息,该符号对应的内存区域是只读的。所以我们要修改它,必须对它进行清除写保护。
我们知道控制寄存器cr0的第16位是写保护位。cr0的第16位置为了禁止超级权限,若清零了则允许超级权限往内核中写入数据,这样我们可以再写入之前,将那一位清零,使我们可以写入。然后写完后,又将那一位复原就行了。
以下代码为清除写保护和恢复写保护
unsigned int clear_and_return_cr0(void) //清除写保护
{
unsigned int cr0 = 0;
unsigned int ret;
asm("movl %%cr0, %%eax":"=a"(cr0));//读取cr0寄存器的值放入eax中,同时赋值给cr0
ret = cr0;
cr0 &= 0xfffeffff;
asm("movl %%eax, %%cr0"::"a"(cr0));
return ret;
}
void setback_cr0(unsigned int val)
{
asm volatile("movl %%eax, %%cr0"::"a"(val)); //读取val的值到eax寄存器,再将eax寄存器的值放入cr0中
}
3.3 进程遍历模块设计与实现
Linux内核中所有进程以树形结构来表示进程之间的父子关系,首先,我们最容易得到的是当前进程的进程信息,通过使用current,我们可以获得只想当前进程的task_struct结构,并判断当前进程是否是跟进程,如果不是跟进程,则可以通过循环找到根进程,在得到根进程后,我们就可以对Linux进程进行遍历,为了能够进行树形打印,可以将遍历的进程信息进行保存,可以定义一个结构体,来储存当前进程信息,包括进程task_struct及其深度,如下所示。cp变量记录task_struct指针,dep记录该进程的深度。并将结构体记录在一个链表中,最后通过使用循环将其进行树形输出。
void processtree(struct task_struct * p,int b)
{
struct list_head * l;
a[counter].pid = p -> pid;
a[counter].depth = b;
counter ++;
for(l = p -> children.next; l != &(p->children); l = l->next)
{
syscall(223,&a);
for(i = 0; i < 512; i++)
{
for(j = 0; j < a[i].depth; j++)
printf("|-");
printf("%d\n",a[i].pid);
if(a[i+1].pid == 0)
break;
}
task_struct *t = list_entry(l,struct task_struct,sibling);
processtree(t,b+1);
}
}
其中for ( p = current; p != &init_task; p =p->parent );语句即为通过当前current进行向上查找,知道p指向根进程init_task。其中getprocesstree(,)生成进程的树结构并进行保存。代码如下图所示。其中pi为储存进程信息的processinfo类型的数组。
3.4 用户态实例模块设计与实现
在用户态空间我们使用syscall()这个函数去触发223的系统调用。
syscall(223,&a);
for(i = 0; i < 512; i++)
{
for(j = 0; j < a[i].depth; j++)
printf("|-");
printf("%d\n",a[i].pid);
if(a[i+1].pid == 0)
break;
}
4.程序编译
4.1 Makefile文件的编写
KVERS = $(shell uname -r)
# Kernel modules
obj-m += ProgressTreePrintKernel.o
# Specify flags for the module compilation.
#EXTRA_CFLAGS=-g -O0
build: kernel_modules user_test
kernel_modules:
make -C /lib/modules/$(KVERS)/build M=$(CURDIR) modules
user_test:
gcc -o ProgressTreePrintUser ProgressTreePrintUser.c
clean:
make -C /lib/modules/$(KVERS)/build M=$(CURDIR) clean
5.程序运行
1.编译代码
在终端输入make即可。
2.将编译出来的内核模块ProgressTreePrintKernel.ko加载到内核中
3. 运行测试程序,输出树状打印结果
4.卸载模块