一个标准Intel架构处理器的图示
想要理解进程切换,首先我们要介绍处理器状态的概念.
现代计算机在执行多个进程的时候是宏观上并行,微观上串行的,想要串行执行程序并且进程之间相互不影响,现代计算机的处理方式时在切换进程时将整个CPU内的所有信息恢复到上次程序断点处的情况。
以Linux为例,Linux的进程使用task_struct这一数据结构存储其所有的信息.
struct task_struct {
long state;
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked;
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;
unsigned short used_math;
int tty;
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];
struct desc_struct ldt[3];
struct tss_struct tss;
};
其中的结构体tss_struct就是用来存储进程上下文的数据结构:
struct tss_struct {
long back_link;
long esp0;
long ss0;
long esp1;
long ss1;
long esp2;
long ss2;
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es;
long cs;
long ss;
long ds;
long fs;
long gs;
long ldt;
long trace_bitmap;
struct i387_struct i387;
};
这个数据结构里的所有东西,就被称作进程上下文,也就是所谓的处理器状态.
下面挑几个比较重要的寄存器进行简单说明。
eflagseflags就是所谓的psw。它的结构如下图
其中: OF, SF, ZF, CF用于ALU的计算;
IF是允许中断位,对应着汇编指令cli/sti。 其余不再叙述.
eipeip即pc,标识着当前指令的线性地址.
ss堆栈段标识. 注意内核态的堆栈段处于内存低位,但是任意程序的堆栈段在内存高位.
我们理解了什么叫作进程上下文,下面来看看进程切换是怎么实现的。
一种切换方式是CPU调度程序对于程序上下文进行的切换,这段源码如下所示,它是由于系统时钟中断后调用schedule()函数实现的。
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
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;
}
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);
}
前面的代码都是用来实现进程选择,最后的switch_to使用汇编指令进行了上下文切换.
#define switch_to(n) {
struct {long a,b;} __tmp;
__asm__("cmpl %%ecx,_currentnt"
"je 1fnt"
"movw %%dx,%1nt"
"xchgl %%ecx,_currentnt"
"ljmp %0nt"
"cmpl %%ecx,_last_task_used_mathnt"
"jne 1fnt"
"cltsn"
"1:"
::"m" (*&__tmp.a),"m" (*&__tmp.b),
"d" (_TSS(n)),"c" ((long) task[n]));
}
那么提到时钟中断,中断又是什么呢?
中断的提出是因为I/O的传输速度在计算机发展的过程中已慢慢不能跟得上CPU高速的处理速度,而早期的计算机程序为了等待I/O使用的是停等策略,这种策略在CPU速度远快于I/O的时候会导致CPU很长时间都在空转,这对于宝贵的CPU时间来说是不可接受的,于是人们提出了中断的概念,这种技术类似于一种消息传递机制:在这种消息传递机制下,每当一个进程需要I/O数据时,操作系统将它挂起;而I/O操作结束时,对应端口的I/O信号处理器会向CPU发出一个处理完成的信号,这个时候CPU直接对端口进行读取就可以得到数据了。
下面是第二种进程上下文切换的方式,它与刚才进程调度的区别在于,这种切换是主动的,它通过fork() -> execve()的方法进行进程上下文切换.
那么fork(), execve()这些看起来像是函数的东西到底是什么呢?
操作系统的理论学家给它们起了一个名字,叫做系统调用。系统调用也是一种中断,但是这种中断是用户可以自己发起的.在Linux系统中,系统调用的中断向量号是0x80,在执行一段代码时,如果在代码中嵌入汇编代码(称为c的内嵌汇编,例子可以看刚才的switch_to宏定义)int 0x80, 系统就会转去执行系统调用了。
那么,为什么是0x80这个数字呢?为什么不是其他的数字,或者什么函数呢?
在Intel 8086中,CPU被设计为能够处理256种中断,这些琳琅满目的中断,每个中断都由一个确定的中断向量号标识;而每个中断向量号都对应了一个中断处理程序。通过硬件设计,在收到中断信号后,CPU会自动地保存当前进程的上下文、转去对应的中断处理程序。如此多的程序被保存在操作系统的一个叫做中断向量表的数据结构中,它的每一项对应着一个函数指针,这个时候CPU就可以放心大胆地将PC设为中断向量的地址转去中断程序了*。
好了,再回过头来看fork()和execve()。
.align 2 _sys_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系统调用是用汇编写成的,因为它要涉及到对寄存器的保护。具体的实现细节我们不再讨论。
execve的函数因为过长,我们不在这里展示.
具体而言,这两个函数要执行的功能如下:
首先,fork寻找进程表中一个空闲的位置,将当前进程的所有信息完全复制一份到新的程序中,然后跳转进程到新的程序中。这时两个程序没有任何区别,它们的代码段、数据段甚至堆栈都是一模一样的!
随后的execve函数进行对于具体的可执行文件的加载——具体而言,首先在文件系统中找到对应的inode,在内存中分配一块新的minode,修改这块内存的信息,然后将PC跳转到新的minode处。注意!这里操作系统并不真的把代码段加载到进程,这部分工作是在跳转之后出现缺页中断时由缺页中断处理程序执行的。
又出现了好多新名词!让人头晕眼花。我们逐一解释刚才的所有名词。
什么是可执行文件的加载呢?
现代编译器太过于强大了,它可以优化掉无用代码、进行库函数的加载、轻轻点击一下编译运行键我们的程序就能跑起来。但是这些是我们作为用户体会到的它的好处,具体而言,它都做了些什么呢?
现代编译器的编译分为以下四步:预处理、编译、汇编、链接。
预处理将源代码中的宏进行处理;编译将代码初步处理为汇编代码;
汇编将汇编代码处理为对应CPU架构下的机器代码。
而链接比较复杂,我们这里不去花大量的篇幅介绍它,只需要知道,在汇编完成后会生成一个.o后缀的可重定位E文件(Linux下,格式为ELF,感兴趣可以自己学习),可重定位文件并没有为内部的变量、函数分配一个具体的地址,这一部分是要链接器ld做的。
让链接器去做这件事的好处是,这样会使得一个比较庞大的代码工程中的函数可以相互引用(静态链接);或者说,c的标准库不需要为系统中同时执行的所有程序都在内存中拷贝一个相同的副本(动态链接)。
那么,什么又是inode,mnode呢?
我们知道,内存是由RAM和ROM制成的。RAM又可以分为SRAM(速度较快,价格昂贵,用于Cache)和DRAM(速度不如SRAM,价格便宜,但是需要经常进行刷新)。他们有一个统一的特性,是掉电后数据会丢失,也就是说,它们需要高电平使能。另外的ROM是不易失的存储设备,价格更为昂贵。所以我们现在的大容量内存都是用DRAM阵列实现的。
而CPU只能直接和内存、Cache打交道,程序由都是存储在外存(硬盘等设备)上的,这时就需要操作系统进行一个比较高层次的抽象,使用户能够比较方便的使用外部设备。
一个比较直观的想法是,在内存中开辟一块固定的区域,用于存储外存和内存交换的数据。很幸运,Linux系统就是这样实现的,这样一块区域叫做Buffer,它的结构如图:
缓冲区低端使用哈希方法使得操作系统能够比较快地处理缓冲区中的数据;缓冲块中则存储了对应的数据,这是外存中数据块的拷贝。
inode和mnode是Linux中的数据结构,用于标识内外存的文件信息。inode是mno de的一个子集。
struct m_inode {
unsigned short i_mode;
unsigned short i_uid;
unsigned long i_size;
unsigned long i_mtime;
unsigned char i_gid;
unsigned char i_nlinks;
unsigned short i_zone[9];
struct task_struct * i_wait;
unsigned long i_atime;
unsigned long i_ctime;
unsigned short i_dev;
unsigned short i_num;
unsigned short i_count;
unsigned char i_lock;
unsigned char i_dirt;
unsigned char i_pipe;
unsigned char i_mount;
unsigned char i_seek;
unsigned char i_update;
};
刚才所说的缺页中断的处理方式是这样的:进行缺页中断处理时,首先操作系统检查缓冲区中时候含有对应逻辑块的信息,如果有直接进行内存块复制;否则,进行文件系统的系统调用,去外存中找到对应的逻辑块的信息,外存写中断后回到缺页中断,然后进行内存块复制。
那么什么又是逻辑块呢?
如图是任意一个外存设备的存储结构图。数据区被我们进行连续编址。引导块用于操作系统的加载;超级块说明了外设的信息;位图用于块的分配管理。(这部分在内存管理中也会涉及到)inode刚才已经叙述。
就此,对于一个程序进行进程切换时所做的一切已经被讨论完了。



