编写fprintk()函数
log 文件将被用来记录进程的状态转移轨迹。所有的状态转移都是在内核进行的。
在内核状态下,write() 功能失效,其原理等同于《系统调用》实验中不能在内核状态调用 printf(),只能调用 printk()。编写可在内核调用的 write() 的难度较大,所以这里直接给出源码。它主要参考了 printk() 和 sys_write() 而写成的:
#include "linux/sched.h" #include "sys/stat.h" static char logbuf[1024]; int fprintk(int fd, const char *fmt, ...) { va_list args; int count; struct file * file; struct m_inode * inode; va_start(args, fmt); count=vsprintf(logbuf, fmt, args); va_end(args); /* 如果输出到stdout或stderr,直接调用sys_write即可 */ if (fd < 3) { __asm__("push %%fs\n\t" "push %%ds\n\t" "pop %%fs\n\t" "pushl %0\n\t" /* 注意对于Windows环境来说,是_logbuf,下同 */ "pushl $logbuf\n\t" "pushl %1\n\t" /* 注意对于Windows环境来说,是_sys_write,下同 */ "call sys_write\n\t" "addl $8,%%esp\n\t" "popl %0\n\t" "pop %%fs" ::"r" (count),"r" (fd):"ax","cx","dx"); } else /* 假定>=3的描述符都与文件关联。事实上,还存在很多其它情况,这里并没有考虑。*/ { /* 从进程0的文件描述符表中得到文件句柄 */ if (!(file=task[0]->filp[fd])) return 0; inode=file->f_inode; __asm__("push %%fs\n\t" "push %%ds\n\t" "pop %%fs\n\t" "pushl %0\n\t" "pushl $logbuf\n\t" "pushl %1\n\t" "pushl %2\n\t" "call file_write\n\t" "addl $12,%%esp\n\t" "popl %0\n\t" "pop %%fs" ::"r" (count),"r" (file),"r" (inode):"ax","cx","dx"); } return count; }
因为和 printk 的功能近似,建议将此函数放入到 kernel/printk.c 中。fprintk() 的使用方式类同与 C 标准库函数 fprintf(),唯一的区别是第一个参数是文件描述符,而不是文件指针。
例如:
// 向stdout打印正在运行的进程的ID fprintk(1, "The ID of running process is %ld", current->pid); // 向log文件输出跟踪进程运行轨迹 fprintk(3, "%ld\t%c\t%ld\n", current->pid, 'R', jiffies);
jiffies,滴答
jiffies 在 kernel/sched.c 文件中定义为一个全局变量:
long volatile jiffies=0;
它记录了从开机到当前时间的时钟中断发生次数。在 kernel/sched.c 文件中的 sched_init() 函数中,时钟中断处理函数被设置为:
set_intr_gate(0x20,&timer_interrupt);
而在 kernel/system_call.s 文件中将 timer_interrupt 定义为:
timer_interrupt: ! …… ! 增加jiffies计数值 incl jiffies ! ……
这说明 jiffies 表示从开机时到现在发生的时钟中断次数,这个数也被称为 “滴答数”。
另外,在 kernel/sched.c 中的 sched_init() 中有下面的代码:
// 设置8253模式 outb_p(0x36, 0x43); outb_p(LATCH&0xff, 0x40); outb_p(LATCH>>8, 0x40);
这三条语句用来设置每次时钟中断的间隔,即为 LATCH,而 LATCH 是定义在文件 kernel/sched.c 中的一个宏:
// 在 kernel/sched.c 中 #define LATCH (1193180/HZ) // 在 include/linux/sched.h 中 #define HZ 100
再加上 PC 机 8253 定时芯片的输入时钟频率为 1.193180MHz,即 1193180/每秒,LATCH=1193180/100,时钟每跳 11931.8 下产生一次时钟中断,即每 1/100 秒(10ms)产生一次时钟中断,所以 jiffies 实际上记录了从开机以来共经过了多少个 10ms。
注意这里是 H Z = 100 HZ=100HZ=100 的情况,前文也介绍过。所以时间其实就是近似等于中断次数乘以 1 / H Z 1/HZ1/HZ
寻找状态切换点
必须找到所有发生进程状态切换的代码点,并在这些点添加适当的代码,来输出进程状态变化的情况到 log 文件中。
此处要面对的情况比较复杂,需要对 kernel 下的 fork.c、sched.c 有通盘的了解,而 exit.c 也会涉及到。
例子 1:记录一个进程生命期的开始
第一个例子是看看如何记录一个进程生命期的开始,当然这个事件就是进程的创建函数 fork(),由《系统调用》实验可知,fork() 功能在内核中实现为 sys_fork(),该“函数”在文件 kernel/system_call.s 中实现为:
sys_fork: call find_empty_process ! …… ! 传递一些参数 push %gs pushl %esi pushl %edi pushl %ebp pushl %eax ! 调用 copy_process 实现进程创建 call copy_process addl $20,%esp
所以真正实现进程创建的函数是 copy_process(),它在 kernel/fork.c 中定义为:
int copy_process(int nr,……) { struct task_struct *p; // …… // 获得一个 task_struct 结构体空间 p = (struct task_struct *) get_free_page(); // …… p->pid = last_pid; // …… // 设置 start_time 为 jiffies p->start_time = jiffies; // …… /* 设置进程状态为就绪。所有就绪进程的状态都是 TASK_RUNNING(0),被全局变量 current 指向的 是正在运行的进程。*/ p->state = TASK_RUNNING; return last_pid; }
因此要完成进程运行轨迹的记录就要在 copy_process() 中添加输出语句。
这里要输出两种状态,分别是“N(新建)”和“J(就绪)”。
例子 2:记录进入睡眠态的时间
第二个例子是记录进入睡眠态的时间。sleep_on() 和 interruptible_sleep_on() 让当前进程进入睡眠状态,这两个函数在 kernel/sched.c 文件中定义如下:
void sleep_on(struct task_struct **p) { struct task_struct *tmp; // …… tmp = *p; // 仔细阅读,实际上是将 current 插入“等待队列”头部,tmp 是原来的头部 *p = current; // 切换到睡眠态 current->state = TASK_UNINTERRUPTIBLE; // 让出 CPU schedule(); // 唤醒队列中的上一个(tmp)睡眠进程。0 换作 TASK_RUNNING 更好 // 在记录进程被唤醒时一定要考虑到这种情况,实验者一定要注意!!! if (tmp) tmp->state=0; } /* TASK_UNINTERRUPTIBLE和TASK_INTERRUPTIBLE的区别在于不可中断的睡眠 * 只能由wake_up()显式唤醒,再由上面的 schedule()语句后的 * * if (tmp) tmp->state=0; * * 依次唤醒,所以不可中断的睡眠进程一定是按严格从“队列”(一个依靠 * 放在进程内核栈中的指针变量tmp维护的队列)的首部进行唤醒。而对于可 * 中断的进程,除了用wake_up唤醒以外,也可以用信号(给进程发送一个信 * 号,实际上就是将进程PCB中维护的一个向量的某一位置位,进程需要在合 * 适的时候处理这一位。感兴趣的实验者可以阅读有关代码)来唤醒,如在 * schedule()中: * * for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) * if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) && * (*p)->state==TASK_INTERRUPTIBLE) * (*p)->state=TASK_RUNNING;//唤醒 * * 就是当进程是可中断睡眠时,如果遇到一些信号就将其唤醒。这样的唤醒会 * 出现一个问题,那就是可能会唤醒等待队列中间的某个进程,此时这个链就 * 需要进行适当调整。interruptible_sleep_on和sleep_on函数的主要区别就 * 在这里。 */ void interruptible_sleep_on(struct task_struct **p) { struct task_struct *tmp; … tmp=*p; *p=current; repeat: current->state = TASK_INTERRUPTIBLE; schedule(); // 如果队列头进程和刚唤醒的进程 current 不是一个, // 说明从队列中间唤醒了一个进程,需要处理 if (*p && *p != current) { // 将队列头唤醒,并通过 goto repeat 让自己再去睡眠 (**p).state=0; goto repeat; } *p=NULL; //作用和 sleep_on 函数中的一样 if (tmp) tmp->state=0; }
总的来说,Linux 0.11 支持四种进程状态的转移:就绪到运行、运行到就绪、运行到睡眠和睡眠到就绪,此外还有新建和退出两种情况。其中就绪与运行间的状态转移是通过 schedule()(它亦是调度算法所在)完成的;运行到睡眠依靠的是 sleep_on() 和 interruptible_sleep_on(),还有进程主动睡觉的系统调用 sys_pause() 和 sys_waitpid();睡眠到就绪的转移依靠的是 wake_up()。所以只要在这些函数的适当位置插入适当的处理语句就能完成进程运行轨迹的全面跟踪了。
修改fork.c文件
fork.c文件在kernel目录下,这里要输出两种状态,分别是“N(新建)”和“J(就绪)”,下面做出两处修改:
int copy_process(int nr,……) { struct task_struct *p; // …… // 获得一个 task_struct 结构体空间 p = (struct task_struct *) get_free_page(); // …… p->pid = last_pid; // …… // 设置 start_time 为 jiffies p->start_time = jiffies; //新增修改,新建进程 fprintk(3,"%d\tN\t%d\n",p->pid,jiffies); // …… /* 设置进程状态为就绪。所有就绪进程的状态都是 TASK_RUNNING(0),被全局变量 current 指向的 是正在运行的进程。*/ p->state = TASK_RUNNING; //新增修改,进程就绪 fprintk(3,"%d\tJ\t%d\n",p->pid,jiffies); return last_pid; }
修改sched.c文件
文件位置:kernel/sched.c
修改schedule函数
//这里仅仅说一下改动了什么 /* check alarm, wake up any interruptible tasks that have got a signal */ for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) if (*p) { if ((*p)->alarm && (*p)->alarm < jiffies) { (*p)->signal |= (1<<(SIGALRM-1)); (*p)->alarm = 0; } if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) && (*p)->state==TASK_INTERRUPTIBLE){ (*p)->state=TASK_RUNNING; fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies); } } while (1) { c = -1; next = 0; i = NR_TASKS; p = &task[NR_TASKS]; // 找到 counter 值最大的就绪态进程 while (--i) { if (!*--p) continue; if ((*p)->state == TASK_RUNNING && (*p)->counter > c) c = (*p)->counter, next = i; } // 如果有 counter 值大于 0 的就绪态进程,则退出 if (c) break; // 如果没有: // 所有进程的 counter 值除以 2 衰减后再和 priority 值相加, // 产生新的时间片 for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) if (*p) (*p)->counter = ((*p)->counter >> 1) + (*p)->priority; } //切换到相同的进程不输出 if(current->pid != task[next] ->pid) { /*新建修改--时间片到时程序 => 就绪*/ if(current->state == TASK_RUNNING) fprintk(3,"%d\tJ\t%d\n",current->pid,jiffies); fprintk(3,"%d\tR\t%d\n",task[next]->pid,jiffies); } // 切换到 next 进程 switch_to(next);
修改sys_pause函数
int sys_pause(void) { current->state = TASK_INTERRUPTIBLE; /* *修改--当前进程 运行 => 可中断睡眠 */ if(current->pid != 0) fprintk(3,"%d\tW\t%d\n",current->pid,jiffies); schedule(); return 0; }
修改sleep_on函数
void sleep_on(struct task_struct **p) { struct task_struct *tmp; if (!p) return; if (current == &(init_task.task)) panic("task[0] trying to sleep"); tmp = *p; *p = current; current->state = TASK_UNINTERRUPTIBLE; /* *修改--当前进程进程 => 不可中断睡眠 */ fprintk(3,"%d\tW\t%d\n",current->pid,jiffies); schedule(); if (tmp) { tmp->state=0; /* *修改--原等待队列 第一个进程 => 唤醒(就绪) */ fprintk(3,"%d\tJ\t%d\n",tmp->pid,jiffies); } }
修改interruptible_sleep_on函数
void interruptible_sleep_on(struct task_struct **p) { struct task_struct *tmp; if (!p) return; if (current == &(init_task.task)) panic("task[0] trying to sleep"); tmp=*p; *p=current; repeat: current->state = TASK_INTERRUPTIBLE; /* *修改--唤醒队列中间进程,过程中使用Wait */ fprintk(3,"%d\tW\t%d\n",current->pid,jiffies); schedule(); if (*p && *p != current) { (**p).state=0; /* *修改--当前进程 => 可中断睡眠 */ fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies); goto repeat; } *p=NULL; if (tmp) { tmp->state=0; /* *修改--原等待队列 第一个进程 => 唤醒(就绪) */ fprintk(3,"%d\tJ\t%d\n",tmp->pid,jiffies); } }
修改wake_up函数
void wake_up(struct task_struct **p) { if (p && *p) { (**p).state=0; /* *修改--唤醒 最后进入等待序列的 进程 */ fprintk(3,"%d\tJ\t%d\n",(*p)->pid,jiffies); *p=NULL; } }
修改exit.c文件
当一个进程结束了运行或在半途中终止了运行,那么内核就需要释放该进程所占用的系统资源。这包括进程运行时打开的文件、申请的内存等。
当一个用户程序调用exit()系统调用时,就会执行内核函数do_exit()。该函数会首先释放进程代码段和数据段占用的内存页面,关闭进程打开着的所有文件,对进程使用的当前工作目录、根目录和运行程序的i节点进行同步操作。如果进程有子进程,则让init进程作为其所有子进程的父进程。如果进程是一个会话头进程并且有控制终端,则释放控制终端(如果按照实验的数据,此时就应该打印了),并向属于该会话的所有进程发送挂断信号 SIGHUP,这通常会终止该会话中的所有进程。然后把进程状态置为僵死状态 TASK_ZOMBIE。并向其原父进程发送 SIGCHLD 信号,通知其某个子进程已经终止。最后 do_exit()调用调度函数去执行其他进程。由此可见在进程被终止时,它的任务数据结构仍然保留着。因为其父进程还需要使用其中的信息。
在子进程在执行期间,父进程通常使用wait()或 waitpid()函数等待其某个子进程终止。当等待的子进程被终止并处于僵死状态时,父进程就会把子进程运行所使用的时间累加到自己进程中。最终释放已终止子进程任务数据结构所占用的内存页面,并置空子进程在任务数组中占用的指针项。
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->state = TASK_ZOMBIE; /* *修改--退出一个进程 */ fprintk(3,"%d\tE\t%d\n",current->pid,jiffies); current->exit_code = code; tell_father(current->father); schedule(); return (-1); /* just to suppress warnings */ } // …… int sys_waitpid(pid_t pid,unsigned long * stat_addr, int options) { int flag, code; struct task_struct ** p; // …… // …… if (flag) { if (options & WNOHANG) return 0; current->state=TASK_INTERRUPTIBLE; /* *修改--当前进程 => 等待 */ fprintk(3,"%d\tW\t%d\n",current->pid,jiffies); schedule(); if (!(current->signal &= ~(1<<(SIGCHLD-1)))) goto repeat; else return -EINTR; } return -ECHILD; }
小结
总的来说,Linux 0.11 支持四种进程状态的转移:就绪到运行、运行到就绪、运行到睡眠和睡眠到就绪,此外还有新建和退出两种情况。其中就绪与运行间的状态转移是通过 schedule()(它亦是调度算法所在)完成的;运行到睡眠依靠的是 sleep_on() 和 interruptible_sleep_on(),还有进程主动睡觉的系统调用 sys_pause() 和 sys_waitpid();睡眠到就绪的转移依靠的是 wake_up()。所以只要在这些函数的适当位置插入适当的处理语句就能完成进程运行轨迹的全面跟踪了。
为了让生成的 log 文件更精准,以下几点请注意:
进程退出的最后一步是通知父进程自己的退出,目的是唤醒正在等待此事件的父进程。从时序上来说,应该是子进程先退出,父进程才醒来。
系统无事可做的时候,进程 0 会不停地调用 sys_pause(),以激活调度算法。此时它的状态可以是等待态,等待有其它可运行的进程;也可以叫运行态,因为它是唯一一个在 CPU 上运行的进程,只不过运行的效果是等待。
编译
重新编译
make all