linux源码解读(一):进程的创建、调度和销毁
不论是做正向开发,还是逆向破解,操作系统、编译原理、数据结构和算法、计算机组成原理、计算机网络、密码学等都是非常核心和关键的课程。为了便于理解操作系统原理,这里从linux 0.11开始解读重要和核心的代码!简单理解:操作系统=计算机组成原理+数据结构和算法!
用户从开机上电开始,cpu会先用bios读取初始化代码,这一系列的初始化流程请详见本人之前撰写的x86系列教程(https://www.cnblogs.com/theseventhson/p/13030374.html),这里不再赘述;先看一下代码的整体结构,从文件名就能很容易猜出来各个模块分别是干啥的:硬件启动、文件系统、头文件、整个初始化、内核、库函数、内存管理、工具等;
linux代码大部分是C写的,所以入口也不免俗,也是用main开始的,具体就是init/main.c文件的main函数,主要干了这么几件事:设置根文件和驱动、设置内存、初始化idt表、打开中断、切换到用户模式;从这里可以看出:前面这些代码执行的时候是屏蔽了中断的,所以在这个阶段,用户移动鼠标、敲击键盘什么的都是没用的!这些准备工作都做完后,终于生成了“永世不灭”的0号进程:从linus的注释看,只要没有其他任务运行了就运行0号进程;pause函数也只是查看是否有其他可以运行的任务。如果有就跳转过去运行,如果没有继续在这里死循环!
注意:这里面有个buffer_memory_end字段,标识了内核缓冲区;一般情况下:cpu往块设备写数据,会先写入这里的缓存,缓存到一定数量后统一写入设备,能提升一些效率;比如本人之前用ida去trace x音时,log文件并不是实时更新的;要等trace借结束后才会统一写入磁盘的log文件!
//main函数 linux引导成功后就从这里开始运行 void main(void) /* This really IS void, no error here. */ { /* The startup routine assumes (well, ...) this */ /* * Interrupts are still disabled. Do necessary setups, then * enable them */ //前面这里做的所有事情都是在对内存进行拷贝 ROOT_DEV = ORIG_ROOT_DEV;//设置操作系统的根文件 drive_info = DRIVE_INFO;//设置操作系统驱动参数 //解析setup.s代码后获取系统内存参数 memory_end = (1<<20) + (EXT_MEM_K<<10); //取整4k的内存大小 memory_end &= 0xfffff000; if (memory_end > 16*1024*1024)//控制操作系统的最大内存为16M memory_end = 16*1024*1024; if (memory_end > 12*1024*1024) buffer_memory_end = 4*1024*1024;//设置高速缓冲区的大小,跟块设备有关,跟设备交互的时候,充当缓冲区,写入到块设备中的数据先放在缓冲区里,只有执行sync时才真正写入;这也是为什么要区分块设备驱动和字符设备驱动;块设备写入需要缓冲区,字符设备不需要是直接写入的 else if (memory_end > 6*1024*1024) buffer_memory_end = 2*1024*1024; else buffer_memory_end = 1*1024*1024; main_memory_start = buffer_memory_end; #ifdef RAMDISK main_memory_start += rd_init(main_memory_start, RAMDISK*1024); #endif //内存控制器初始化 mem_init(main_memory_start,memory_end); //异常函数初始化,主要是初始化idt表 trap_init(); //块设备驱动初始化 blk_dev_init(); //字符型设备出动初始化 chr_dev_init(); //控制台设备初始化 tty_init(); //加载定时器驱动 time_init(); //进程间调度初始化 sched_init(); //缓冲区初始化 buffer_init(buffer_memory_end); //硬盘初始化 hd_init(); //软盘初始化 floppy_init(); sti(); //从内核态切换到用户态,上面的初始化都是在内核态运行的 //内核态无法被抢占,不能在进程间进行切换,运行不会被干扰 move_to_user_mode(); if (!fork()) { //创建0号进程 fork函数就是用来创建进程的函数 /* we count on this going ok */ //0号进程是所有进程的父进程 init(); } /* * NOTE!! For any other task 'pause()' would mean we have to get a * signal to awaken, but task 0 is the sole exception (see 'schedule()') * as task 0 gets activated at every idle moment (when no other tasks * can run). For task0 'pause()' just means we go check if some other * task can run, and if not we return here. */ //0号进程永远不会结束,他会在没有其他进程调用的时候调用,只会执行for(;;) pause(); for(;;) pause(); }
1、main中有个非常核心的函数:fork;linux中所有的进程创建都通过fork函数;这个函数本质上是个系统调用,实现代码在system_call.s中,如下:
_sys_fork://fork的系统调用 call _find_empty_process//调用这个函数 testl %eax,%eax js 1f push %gs pushl %esi pushl %edi pushl %ebp pushl %eax call _copy_process// addl $20,%esp 1: ret
整个fork的执行过程并不复杂:先是找空进程,再复制进程,这两个函数到底是怎么做的了?在kernel/fork.c中有他们的实现过程,先来看看find_empty_process: 这个版本的NR_TASKS=64,也就是说最多支持64个进程“同时”运行(直观感觉这算个漏洞啊,如果别有用心的人想办法短时间内恶意把进程数提升到64个,是不是就没法运行新的程序了?间接达到DOS的效果)!整个代码很简单:直接遍历64个task_struct结构体,看看哪个结构体还是null的,说明这个结构体还未初始化,没被使用,直接返回这个task结构体在数组的index。这个index也被用来作为进程的编号,也就是pid!
int find_empty_process(void) { int i; repeat: if ((++last_pid)<0) last_pid=1; for(i=0 ; i<NR_TASKS ; i++) if (task[i] && task[i]->pid == last_pid) goto repeat; for(i=1 ; i<NR_TASKS ; i++) if (!task[i])//直到找到一个空的task结构体 return i; return -EAGAIN;//达到64的最大值后,返回错误码 }
一旦找到空的进程(实际上是还没使用的task结构体,就是所谓的进程槽),继续执行copy_process,这里又拷贝了啥了?
- 新建一个task结构体,并分配内存
- 根据上一步得到的pid,把task结构体保存在这个index
- 用传入的参数初始化task结构体(主要是context寄存器)
- 设置子进程的ldt,并复制父进程的数据段(注意:不复制代码段,否则没必要生成子进程了)
- 父进程打开过的文件,子进程继承该属性
- 在gdt中设置该子进程的tss和ldt
- 子进程设置成运行态
总结:函数名称叫copy_process,实际上只是拷贝了父进程的数据段,继承了父进程打开文件的数量,其他都是“个性化”设置的,所以linus当初为啥要取名为copy_process了?这里很不解!
// 对内存拷贝 // 主要作用就是把代码段数据段等栈上的数据拷贝一份 int copy_mem(int nr,struct task_struct * p) { unsigned long old_data_base,new_data_base,data_limit; unsigned long old_code_base,new_code_base,code_limit; code_limit=get_limit(0x0f); data_limit=get_limit(0x17); old_code_base = get_base(current->ldt[1]); old_data_base = get_base(current->ldt[2]); if (old_data_base != old_code_base) panic("We don't support separate I&D"); if (data_limit < code_limit) panic("Bad data_limit"); //数据段和代码段的base地址是一样的 new_data_base = new_code_base = nr * 0x4000000; //设置新进程代码入口地址 p->start_code = new_code_base; //设置新进程的idt set_base(p->ldt[1],new_code_base); set_base(p->ldt[2],new_data_base); //新进程直接简单粗暴地复制了父进程的数据,但是代码并未复用 if (copy_page_tables(old_data_base,new_data_base,data_limit)) { free_page_tables(new_data_base,data_limit); return -ENOMEM; } return 0; } /* * Ok, this is the main fork-routine. It copies the system process * information (task[nr]) and sets up the necessary registers. It * also copies the data segment in it's entirety. */ // 所谓进程创建就是对0号进程或者当前进程的复制 // 就是结构体的复制 把task[0]对应的task_struct 复制一份 //除此之外还要对栈堆拷贝 当进程做创建的时候要复制原有的栈堆 // nr就是刚刚找到的空槽的pid int copy_process(int nr,long ebp,long edi,long esi,long gs,long none, long ebx,long ecx,long edx, long fs,long es,long ds, long eip,long cs,long eflags,long esp,long ss) { struct task_struct *p; int i; struct file *f; //其实就是malloc分配内存 p = (struct task_struct *) get_free_page();//在内存分配一个空白页,让指针指向它 if (!p) return -EAGAIN;//如果分配失败就是返回错误 task[nr] = p;//把这个指针放入进程的链表当中 *p = *current;//把当前进程赋给p,也就是拷贝一份 /* NOTE! this doesn't copy the supervisor stack */ //后面全是对这个结构体进行赋值相当于初始化赋值 p->state = TASK_UNINTERRUPTIBLE; p->pid = last_pid; p->father = current->pid; p->counter = p->priority; p->signal = 0; p->alarm = 0; p->leader = 0; /* process leadership doesn't inherit */ p->utime = p->stime = 0; p->cutime = p->cstime = 0; p->start_time = jiffies;//当前的时间 p->tss.back_link = 0; p->tss.esp0 = PAGE_SIZE + (long) p; p->tss.ss0 = 0x10; p->tss.eip = eip; p->tss.eflags = eflags; p->tss.eax = 0;//把寄存器的参数添加进来 p->tss.ecx = ecx; p->tss.edx = edx; p->tss.ebx = ebx; p->tss.esp = esp; p->tss.ebp = ebp; p->tss.esi = esi; p->tss.edi = edi; p->tss.es = es & 0xffff; p->tss.cs = cs & 0xffff; p->tss.ss = ss & 0xffff; p->tss.ds = ds & 0xffff; p->tss.fs = fs & 0xffff; p->tss.gs = gs & 0xffff; p->tss.ldt = _LDT(nr); p->tss.trace_bitmap = 0x80000000; if (last_task_used_math == current)//如果使用了就设置协处理器 __asm__("clts ; fnsave %0"::"m" (p->tss.i387)); if (copy_mem(nr,p)) {//老进程向新进程代码段和数据段进行拷贝 task[nr] = NULL;//如果失败了 free_page((long) p);//就释放当前页 return -EAGAIN; } for (i=0; i<NR_OPEN;i++)// if (f=p->filp[i])//父进程打开过文件 f->f_count++;//就会打开文件的计数+1,说明会继承这个属性 if (current->pwd)//跟上面一样 current->pwd->i_count++; if (current->root) current->root->i_count++; if (current->executable) current->executable->i_count++; //设置GDT表的tss和ldt set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss)); set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt)); p->state = TASK_RUNNING;//把状态设定为运行状态 /* do this last, just in case */ return last_pid;//返回新创建进程的id号 }
fork执行结束后,如果是0号进程(也就是父进程)成功创建,会继续执行init函数:这个函数内部的代码也很特别,不知道大家有没有注意到:里面有个while(1)循环,而且没有break打断,也就是说这里面的代码会一直执行,直到被中断/系统调用等方式打断;等中断/系统调用执行万后又会遇到这里继续执行!作者本意因该是父进程只负责创建子进程,创建好的子进程来执行/bin/sh,周而复始一直循环执行!所以问题又来了:为什么要不停的生成子进程去执行/bin/sh了?sh是用来接受用户输入并执行的程序,为了让用户随时随地可以输入指令,这里只好不停地生成进程执行/bin/sh了!
void init(void) { int pid,i; //设置了驱动信息 setup((void *) &drive_info); //打开标准输入控制台 句柄为0, 方便进程交互 (void) open("/dev/tty0",O_RDWR,0); (void) dup(0);//打开标准输入控制台 这里是复制句柄的意思 (void) dup(0);//打开标准错误控制台 printf("%d buffers = %d bytes buffer space\n\r",NR_BUFFERS, NR_BUFFERS*BLOCK_SIZE); printf("Free mem: %d bytes\n\r",memory_end-main_memory_start); if (!(pid=fork())) {//这里创建1号进程 close(0);//关闭了0号进程的标准输入输出 if (open("/etc/rc",O_RDONLY,0))//如果1号进程创建成功打开/etc/rc这里面保存的大部分是系统配置文件 开机的时候要什么提示信息全部写在这个里面 _exit(1); execve("/bin/sh",argv_rc,envp_rc);//运行shell程序 _exit(2); } if (pid>0)//如果这个不是0号进程 while (pid != wait(&i))//就等待父进程退出,等待期间啥也不干 /* nothing */; while (1) { if ((pid=fork())<0) {//再创建新进程:如果创建失败 printf("Fork failed in init\r\n"); continue; } //如果创建成功 if (!pid) {//这段代码是在子进程执行的 close(0);close(1);close(2);//关闭上面那几个输入输出错误的句柄 setsid();//重新设置id (void) open("/dev/tty0",O_RDWR,0); (void) dup(0); (void) dup(0);//重新打开 _exit(execve("/bin/sh",argv,envp));//这里不是上面的argv_rc和envp_rc了是因为怕上面那种创建失败,换了一种环境变量来创建,过程和上面是一样的其实 } //这段代码是在父进程执行的:如果还在父进程,那么等待子进程结束退出,并重新开始循环 while (1) if (pid == wait(&i)) break; printf("\n\rchild %d died with code %04x\n\r",pid,i); sync(); } _exit(0); /* NOTE! _exit, not exit() */ }
进程相关重要的结构体: task_struct:描述了task的方方面面,比如时间片、优先级、信号、pid、运行时间、ldt、tss等,每个进程都会生成一个task结构体来存储该进程的所有属性!
//task即进程的意思,这个结构体把进程能用到的所有信息进行了封装 struct task_struct { /* these are hardcoded - don't touch */ long state; //程序运行的状态/* -1 unrunnable, 0 runnable, >0 stopped */ long counter; //时间片 //counter的计算不是单纯的累加,需要下面这个优先级这个参数参与 long priority;//优先级 long signal;//信号 struct sigaction sigaction[32];//信号位图 long blocked;//阻塞状态 /* bitmap of masked signals */ /* various fields */ int exit_code;//退出码 unsigned long start_code,end_code,end_data,brk,start_stack; long pid,father,pgrp,session,leader; unsigned short uid,euid,suid; unsigned short gid,egid,sgid; long alarm;//警告 long utime,stime,cutime,cstime,start_time;//运行时间 //utime是用户态运行时间 cutime是内核态运行时间 unsigned short used_math; /* file system info */ int tty; //是否打开了控制台 /* -1 if no tty, so it must be signed */ unsigned short umask; struct m_inode * pwd; struct m_inode * root; struct m_inode * executable; unsigned long close_on_exec; struct file * filp[NR_OPEN];//打开了多少个文件 /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */ struct desc_struct ldt[3];//ldt包括两个东西,一个是数据段(全局变量静态变量等),另一个是代码段,不过这里面存的都是指针 /* tss for this task */ struct tss_struct tss;//进程运行过程中CPU需要知道的进程状态标志(段属性、位属性等) };
因为最多“同时”运行64个进程,为了快速遍历、查找目标进程,这里用一个数组来管理所有的进程,如下:
extern struct task_struct *task[NR_TASKS];//进程的“链表”数组
只要拿到task数组,就等于得到了所有的task结构体,也就掌控了所有的进程;我们平时用的ps命令、用调试器查看所有进程疑似就是这样得到进程列表的!用数组管理task结构体属于早期方法,这种方法的缺点也很明显:由于数组定长,这里只能“同时”运行64个进程,所以后来windows改成了双向链表:进程和线程之间都是通过双向链表连接的,遍历也是通过双向链表,这样对于进程或线程的数量就没有限制了(只要内存足够大)!
2、进程创建好后就需要调度了,核心代码在sched.c的schedule函数中,如下:
- 如果时间到点了,设置进程的sigalrm信号;如果进程处于可中断休眠状态,那么把该进程设置为可运行状态;
- 接着找到时间片最大的进程;如果没找到,就根据优先级重新计算进程的时间片,公式很简单:counter = counter/2 + priority
- 最后切换到目标进程执行
// 时间片分配 void schedule(void) { int i,next,c; struct task_struct ** p;//双重指针,指向task结构体数组 /* check alarm, wake up any interruptible tasks that have got a signal */ for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)//通过task结构体数组从后往前遍历每个task if (*p) {//在哪个jiffies发生警告? if ((*p)->alarm && (*p)->alarm < jiffies) {//alarm存在,并且已经到点 (*p)->signal |= (1<<(SIGALRM-1));//新增一个警告信号量 (*p)->alarm = 0;//警告清空 } //如果该进程为可中断睡眠状态 则如果该进程有非屏蔽信号出现就将该进程的状态设置为running if (((*p)->signal //有singal & ~(_BLOCKABLE & (*p)->blocked)) && //并且非阻塞 (*p)->state==TASK_INTERRUPTIBLE) //并且状态是可中断的 (*p)->state=TASK_RUNNING; } /* this is the scheduler proper: */ // 以下思路,循环task列表 根据counter大小决定进程切换 while (1) { c = -1; next = 0; i = NR_TASKS; p = &task[NR_TASKS];//从最后一个任务开始循环 while (--i) { if (!*--p)//task结构体是空,继续循环 continue; //task结构体不为空,说明有进程 if ((*p)->state == TASK_RUNNING && (*p)->counter > c)//找出c最大的task c = (*p)->counter, next = i; } if (c) break;//如果c找到了,就终结循环;如果为0,说明所有进程的时间片都用光了 //如果没有找到最大时间片的进程,就根据优先级进行时间片的重新分配 for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) if (*p)//这里很关键,在低版本内核中,是进行优先级时间片轮转分配,这里搞清楚了优先级和时间片的关系 //counter = counter/2 + priority (*p)->counter = ((*p)->counter >> 1) + (*p)->priority; } //切换到下一个进程 这个功能使用宏定义完成的 switch_to(next); }
任务切换前面的代码容易理解,最后一个switch_to的代码如下:首先声明了一个_tmp的结构,这个结构里面包括两个long型,32位机里面long占32位,声明这个结构主要与ljmp这个长跳指令有关(任务跳转就是通过ljmp指令实现的),这个指令有两个参数,一个参数是段选择符,另一个是偏移地址,所以这个_tmp就是保存这两个参数。再比较任务n是不是当前任务,如果不是则跳转到标号1,否则交互ecx和current的内容,交换后的结果为ecx指向当前进程,current指向要切换过去的新进程;最后执行长跳,%0代表输出输入寄存器列表中使用的第一个寄存器,即"m"(*&__tmp.a),这个寄存器保存了*&__tmp.a,而_tmp.a存放的是32位偏移,_tmp.b存放的是新任务的tss段选择符,长跳到段选择符会造成任务切换;
/* * switch_to(n) should switch tasks to task nr n, first * checking that n isn't the current task, in which case it does nothing. * This also clears the TS-flag if the task we switched to has used * tha math co-processor latest. */ // 进程切换是用汇编宏定义实现的 //1. 将需要切换的进程赋值给当前进程的指针 //2. 将进程的上下文(TSS和当前堆栈中的信息)切换 #define switch_to(n) {\ struct {long a,b;} __tmp; \ __asm__("cmpl %%ecx,_current\n\t" \ "je 1f\n\t" \ "movw %%dx,%1\n\t" \ "xchgl %%ecx,_current\n\t" \ "ljmp %0\n\t" \ "cmpl %%ecx,_last_task_used_math\n\t" \ "jne 1f\n\t" \ "clts\n" \ "1:" \ ::"m" (*&__tmp.a),"m" (*&__tmp.b), \ "d" (_TSS(n)),"c" ((long) task[n])); \ }
这里的切换是通过TSS实现的,这也是x86硬件提供的切换方式,图示如下:
3、进程(为了便于辨识,这里叫A进程)通过schedule开始执行后,不太可能一直运行,中途可能需要等待,比如等待需要某些资源,这时就需要主动把cpu让出去,让其他task执行,避免自己“占着茅坑不拉屎”,这时就需要让进程sleep了,0.11版本的linux是这么干的: 最核心的就是把task结构体中的state改成TASK_UNINTERRUPTIBLE后重新调用schedule函数运行其他任务;由于本任务的状态已经不是TASK_RUNNING了,所以schedule函数不会跳转到当前task执行!注意:调用schedule函数后,如果有时间片高的进程B,会通过ljmp跳转到B进程执行,所以A进程的schedule此时时不返回的,导致下面的if(tmp)代码是不执行的!直到其他某个进程比如C进程调用wake_up函数,把A进程的状态改成runable,C进程再调用schedule函数,A进程才可能继续执行后续的if(tmp)代码!
// 把当前任务置为不可中断的等待状态,并让睡眠队列指针指向当前任务。 // 只有明确的唤醒时才会返回。该函数提供了进程与中断处理程序之间的同步机制。函数参数P是等待 // 任务队列头指针。指针是含有一个变量地址的变量。这里参数p使用了指针的指针形式'**p',这是因为 // C函数参数只能传值,没有直接的方式让被调用函数改变调用该函数程序中变量的值。但是指针'*p' // 指向的目标(这里是任务结构)会改变,因此为了能修改调用该函数程序中原来就是指针的变量的值, // 就需要传递指针'*p'的指针,即'**p'. void sleep_on(struct task_struct **p) { struct task_struct *tmp; // 若指针无效,则退出。(指针所指向的对象可以是NULL,但指针本身不应该为0).另外,如果 // 当前任务是任务0,则死机。因为任务0的运行不依赖自己的状态,所以内核代码把任务0置为 // 睡眠状态毫无意义。 if (!p) return; if (current == &(init_task.task)) panic("task[0] trying to sleep"); // 让tmp指向已经在等待队列上的任务(如果有的话),例如inode->i_wait.并且将睡眠队列头的 // 等等指针指向当前任务。这样就把当前任务插入到了*p的等待队列中。然后将当前任务置为 // 不可中断的等待状态,并执行重新调度。 tmp = *p; *p = current; current->state = TASK_UNINTERRUPTIBLE; schedule(); // 只有当这个等待任务被唤醒时,调度程序才又返回到这里,表示本进程已被明确的唤醒(就 // 续态)。既然大家都在等待同样的资源,那么在资源可用时,就有必要唤醒所有等待该该资源 // 的进程。该函数嵌套调用,也会嵌套唤醒所有等待该资源的进程。这里嵌套调用是指一个 // 进程调用了sleep_on()后就会在该函数中被切换掉,控制权呗转移到其他进程中。此时若有 // 进程也需要使用同一资源,那么也会使用同一个等待队列头指针作为参数调用sleep_on()函数, // 并且也会陷入该函数而不会返回。只有当内核某处代码以队列头指针作为参数wake_up了队列, // 那么当系统切换去执行头指针所指的进程A时,该进程才会继续执行下面的代码,把队列后一个 // 进程B置位就绪状态(唤醒)。而当轮到B进程执行时,它也才可能继续执行下面的代码。若它 // 后面还有等待的进程C,那它也会把C唤醒等。在这前面还应该添加一行:*p = tmp. if (tmp) // 若在其前还有存在的等待的任务,则也将其置为就绪状态(唤醒). tmp->state=0; }
上述整个过程图示如下:
唤醒进程的代码也很简单,如下:核心也是把task的state改成0,也就是runable!注意:这里把*p=NULL是为啥了? 唤醒进程后,进程终于可以从sleep_on函数中的schedule()下一行代码开始运行,此时会通过tmp->state=0把状态改成runable,所以如果这个进程下次再被sleep时,wake_up这里不需要再设置状态了!
void wake_up(struct task_struct **p) { if (p && *p) { (**p).state=0; *p=NULL; } }
4、进程的代码运行完毕,用户也拿到了想要的结果,进程就可以销毁了;销毁进程的入口在kernel/exit.c/do_exit()函数里,流程也不复杂:
- 释放ldt占用的内存
- 如果是某个进程的父进程,更子进程的新父进程为1号进程
- 关闭文件
- 关闭终端、清空协处理器
- 给父进程发signal
- 重新调度
int do_exit(long code) { int i; //释放内存页 free_page_tables(get_base(current->ldt[1]),get_limit(0x0f)); free_page_tables(get_base(current->ldt[2]),get_limit(0x17)); //current->pid就是当前需要关闭的进程 for (i=0 ; i<NR_TASKS ; i++) if (task[i] && task[i]->father == current->pid) {//如果当前进程是某个进程的父进程 task[i]->father = 1;//就让1号进程作为新的父进程 if (task[i]->state == TASK_ZOMBIE)//如果是僵死状态 /* assumption task[1] is always init */ (void) send_sig(SIGCHLD, task[1], 1);//给父进程发送SIGCHLD } for (i=0 ; i<NR_OPEN ; i++)//每个进程能打开的最大文件数NR_OPEN=20 if (current->filp[i]) sys_close(i);//关闭文件 iput(current->pwd); current->pwd=NULL; iput(current->root); current->root=NULL; iput(current->executable); current->executable=NULL; if (current->leader && current->tty >= 0) tty_table[current->tty].pgrp = 0;//清空终端 if (last_task_used_math == current) last_task_used_math = NULL;//清空协处理器 if (current->leader) kill_session();//清空session current->state = TASK_ZOMBIE;//设为僵死状态 current->exit_code = code; tell_father(current->father); schedule(); return (-1); /* just to suppress warnings */ }
在执行do_exit方法的时候,间接调用了一些重要的函数,如下:
(1)release函数:释放task结构体本身占用的内存,并重新调度
void release(struct task_struct * p) { int i; if (!p) return; for (i=1 ; i<NR_TASKS ; i++)//在task[]中进行遍历 if (task[i]==p) { task[i]=NULL; free_page((long)p);//释放内存页 schedule();//重新进行进程调度 return; } panic("trying to release non-existent task"); }
(2)给指定的进程发送信号,本质就是通过task结构体给对方进程的signal字段增加一个值!为了确保安全,给对方发信号需要具备以下三个条件之一:
- 权限不为0
- euid确实是当前进程的
- 系统超级用户
static inline int send_sig(long sig,struct task_struct * p,int priv) { if (!p || sig<1 || sig>32) return -EINVAL; if (priv //要么权限不为0 || (current->euid==p->euid) //要么euid相等(当前进程使用者) || suser()) //要么是超级用户才能给另一个进程发信号,这里可以确保安全,避免接收到恶意信号 p->signal |= (1<<(sig-1)); else return -EPERM; return 0; }
(3)关闭进程间对话的session:居然也是给task结构体的signal字段增加一个值!
//关闭session static void kill_session(void) { struct task_struct **p = NR_TASKS + task;//指向最后一个task结构体 while (--p > &FIRST_TASK) {//从最后一个开始扫描(不包括0进程) if (*p && (*p)->session == current->session)//确认确实是当前会话 (*p)->signal |= 1<<(SIGHUP-1); } }
(4)通知被销毁进程的父进程:通过遍历task结构体数组找到父进程的task结构体,然后增加signal字段的sigchld值!(本人调试x音的时候ida经常会收到这个消息,只要点击确认x音就直接退出)
static void tell_father(int pid) { int i; if (pid) for (i=0;i<NR_TASKS;i++) { if (!task[i]) continue; if (task[i]->pid != pid) continue; task[i]->signal |= (1<<(SIGCHLD-1));//找到父亲发送SIGCHLD信号 return; } /* if we don't find any fathers, we just release ourselves */ /* This is not really OK. Must change it to make father 1 */ printk("BAD BAD - no father found\n\r"); release(current);//释放子进程 }
总结:这里的tell_father、kill_session都是通过发送signal实现的;发送signal的方式也很简单:直接找到对方的task结构体,通过“或”逻辑运算增加信号量!
(5)还有一个“挂羊头、卖狗肉”的方法:sys_kill如下:名字叫sys_kill,实际上是在给目标task结构体发信号!linux的shell中kill命令就是用这个函数实现的!
// 系统调用 向任何进程 发送任何信号(类比shell中的kill命令也是发送信号的意思) int sys_kill(int pid,int sig) { struct task_struct **p = NR_TASKS + task;//指向最后 int err, retval = 0; if (!pid) while (--p > &FIRST_TASK) { if (*p && (*p)->pgrp == current->pid) //如果pid=0,就给当前进程所在的进程组发信号 if (err=send_sig(sig,*p,1)) retval = err; } else if (pid>0) while (--p > &FIRST_TASK) {//pid>0给对应进程发送信号 if (*p && (*p)->pid == pid) if (err=send_sig(sig,*p,0)) retval = err; } else if (pid == -1) while (--p > &FIRST_TASK)//pid=-1给任何进程发送 if (err = send_sig(sig,*p,0)) retval = err; else while (--p > &FIRST_TASK)//pid<-1 给进程组号为-pid的进程组发送信息 if (*p && (*p)->pgrp == -pid) if (err = send_sig(sig,*p,0)) retval = err; return retval; }
5、父进程创建子进程时,有时候需要等待子进程执行完毕,需要调用sys_waitpid函数阻塞父进程自己,如下:如果子进程是僵死状态,就把子进程运行的时间叠加到父进程,然后释放子进程的task结构体!如果子进程还在运行,父进程通过schedule让出cpu,把自己阻塞;下次被唤醒后再次检查子进程是否给自己发送了SIGCHLD信号;如果没收到,重复检查的过程,直到子进程庄涛变为僵死后返回继续执行后续的代码!
int sys_waitpid(pid_t pid,unsigned long * stat_addr, int options) { int flag, code; struct task_struct ** p; verify_area(stat_addr,4);//验证区域是否可以用 repeat: flag=0; for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) { if (!*p || *p == current) continue; if ((*p)->father != current->pid) continue; if (pid>0) { if ((*p)->pid != pid) continue; } else if (!pid) { if ((*p)->pgrp != current->pgrp) continue; } else if (pid != -1) { if ((*p)->pgrp != -pid) continue; } switch ((*p)->state) { case TASK_STOPPED://子进程是stop状态 if (!(options & WUNTRACED)) continue; put_fs_long(0x7f,stat_addr); return (*p)->pid; case TASK_ZOMBIE://子进程是僵死状态:把子进程消耗的时间叠加到父进程,并释放子进程的结构体 current->cutime += (*p)->utime; current->cstime += (*p)->stime; flag = (*p)->pid; code = (*p)->exit_code; release(*p); put_fs_long(code,stat_addr); return flag; default: flag=1;//子进程还在运行,设置flag为1,好让下面的代码执行 continue; } } if (flag) {//说明子进程状态不是stop或zombie,父进程需要阻塞等待,最直接的办法就是让出cpu if (options & WNOHANG) return 0; current->state=TASK_INTERRUPTIBLE;//设置程可种段的 schedule();//父进程阻塞,让出cpu //当父进程再次被唤醒后,检查一下是否收到了子进程结束的通知;如果没有,再次从repeate开始执行 if (!(current->signal &= ~(1<<(SIGCHLD-1)))) goto repeat; else return -EINTR; } return -ECHILD; }
这么一圈代码解读下来,个人觉得的需要总结的一些要点:
- 所谓进程,本质上就是task结构体;结构体包含了很多字段属性,用来描述进程的方方面面(借鉴了面向对象的思想);
- 对于进程的各种操作,本质上就是修改task结构体的属性,通过这些属性的逻辑组合完成各种复杂的功能;这里有点像汽车:汽车本质上也是由螺丝钉、齿轮、轴承等基础零配件构成的,但这些零配件通过一定的逻辑关系结合,就实现了复杂的功能!
- 找到task数组就等于找到所有进程的task结构体;
为了便于记忆和理解,整理了一些要点:
后续解读linux源码的时候,会发现大量的struct,每个struct又有很多字段构成,了解清楚每个字段的含义才能真正理解操作系统的各个细节,这里简单总结一下struct内部各个变量的类型:
- 指针:也就是地址
- 计数/计量的,比如size、length、index、count、height、amount、sequenceNo等
- 有应用业务意义的值:id、name等
- 标记位/控制位:flags等
参考:
1、源码下载:https://mirrors.edge.kernel.org/pub/linux/kernel/