前言:
上一篇讲到Linux内存管理机制,内存管理机制的存在使得cpu和进程可以使用比物理内存大的多的内存空间,这是现代计算机高吞吐量和高可靠性的保障。接下来学习内存管理机制之外的Linux比较重要的几种机制。
x86架构的CPU执行任务分四个保护级,0级最优先,3优先级最低。Linux内核代码和数据都是0级别,而所有用户进程的优先级都是3级。当用户态进程发生了中断或者调用系统接口时,进程从用户态内陷到内核态执行内核代码,执行的内核代码会使用当前进程的内核栈。
Linux系统中,任务0–swapper进程是人工启动的第一个程序,它的作用是调度其他进程,并在内核启动的时候做一些初始化工作。任务1是init进程,它是任务0(swapper进程)调用fork创建的进程。此外所有的进程的父进程都是init进程。
Linux0.11同时只允许64个进程存在,是因为Linux通过一个64位的位图来管理进程,一个位表示一个进程的状态,0表示进程不存在,1表示存在,当64个位全部占满,表示系统中没有多余的控制快可以使用。
由于Linux0.11使用的是32位操作系统,因此系统内存最多是2^{32}=4G,而Linux同时允许64个进程存在,因此每个进程可使用的地址空间为4G÷64=64M。而一个页大小是4k,一个页表是1024个页,也就是4M,因此每个进程最多可拥有16个页表。
区分一下虚拟地址和线性地址:虚拟地址表示进程的可用空间,在Linux0.11中,系统为每个进程最多分配64M。而线性地址空间表示系统虚拟内存的总空间。例如在Linux0.11中线性地址空间为4G。
那么Linux0.11中实际地址是多大呢,由于Linux0.11的系统为32为总线,其中高20位表示页框大小,低12位表示页内偏移。2^20=1M,而2的12次方=4096,所以Linux最多可以使用4G的线性地址空间,而实际的物理地址空间应该是16M。
可编程中断控制器(PIC)通过连接设备的中断引脚接受设备的中断信号。当PIC接收到多个中断请求的时候,PIC会对它们进行优先级比较并选出最好优先级的中断请求。而当处理器正在执行中断请求的时候,PIC还会将选出来的中断和处理器正在处理的中断进行比较,并根据比较的结果决定是否项处理器发出中断请求。PIC连接处理器的INT引脚,让处理器接收PIC发出的中断信号。而PIC则通过数据总线项处理器发送中断号,处理器根据中断号去中断向量表中查找中断向量,并执行相关的中断服务程序。以上是设备的中断,服务器总共有256个中断支持,大多是软件的中断或异常。
x86使用8259A芯片处理中断信号,每个8259A芯片可以处理8个中断。如图所示,8259A芯片通过级联的方式,共可处理15种中断请求。8259A芯片的INT引脚连接到8259A祝芯片的IR2引脚,从芯片的中断信号作为主芯片的输入。
如图所示,主芯片的INT引脚连接CPU芯片的INTR引脚,一旦中断控制器通过中断判优选出中断信号后,通过INTR通知CPU有外部中断需要处理,CPU通过数据线D7-D0获取中断向量,即对应中断服务程序的入口值,然后执行对应的中断服务程序。
CPU收到从PIC发出的中断信号,并从数据总线得到中断号,然后根据中断信号从中断向量表中查询中断向量。这个过程是怎样实现的呢?
在80X86架构中,每个中断向量大小是4字节,共1024位,其中包含了中断服务程序的段值和段内偏移。我们知道当系统启动的时候ROM BIOS程序会在0地址处设置中断向量表,那么假设中断号是N,那么其中断向量地址就是:0+N*4,即对应中断服务程序的入口地址。
系统调用(syscalls)是用户程序和内核交互的唯一入口,通过调用0x80中断号,并在eax寄存器中传递系统调用的功能号。
很容易理解,系统定时器使用CPU时钟频率方波的上升沿进行计数,每一个计数称为一个滴答(10ms),英文为jiffies。Linux系统有专门计数滴答数的函数do_time(),当系统定时任务计数响铃后,CPU根据定时任务的优先级进行取舍。如果当前运行的是用户程序,那么CPU中断当前程序转而运行定时任务;而如果当前运行的是内核程序,那么CPU则不会调度程序切换。也就是在CPU执行内核代码时是不可抢占的。CPU时间片也是同样的道理,通过do_time()计数滴答数,jiffies没增加1,CPU时间片时间就相应减少10ms,当CPU时间片减为0时,CPU发生进程切换。
Linux系统通过进程表来管理进程,每个进程在进程表中占有一项。而进程表项是一个task_struct的数据结构,称为任务数据结构,任务数据结构定义在include/linux/sched.h中,称为PCB(processor control block)进程控制块或进程描述符(processor descriptor)。任务数据结构中保存着进程的状态信息、信号、进程号、父进程号、运行时间累计值、正在使用的文件和本任务的局部描述符以及任务状态信息。在Linux中,进程的所有PCB信息都保存在/proc/PID/路径下。
进程在其生命周期中,会有很多中不同的工作状态,这些状态信息保存在state字段中。Linux系统中,进程的生命周期有:
schedule()函数首先扫描任务数组,通过比较每个就绪任务的运行时间递减滴答计数counter值来确定当前哪个进程运行时间最少,counter越大表示进程运行时间短,于是就选中该进程。如果此时task_running状态的进程时间片都已经用完,那么系统会根据每个进程优先权值priority对系统中所有进程重新计算每个进程的时间片值counter。
内核通过上述过程选出一个新的可运行进程,schedule函数就会调用swith_to()宏执行实际的进程切换操作。首先内核全局变量current置为新任务的指针,然后唱跳转到新任务的状态段TSS中,这会造成CPU切换操作,此时CPU会把旧进程的所有寄存器状态保存到TR寄存器的tss数据结构中,并将新进程的tss数据结构信息回复到CPU中。正式执行新任务。
进程通过调用exit()系统调用来终止进程,调用了exit()之后,系统会调用内核函数do_exit():
1 释放进程的内存和文件描述符
2 将进程状态置为EXIT_ZOMBIE,表示进程已经退出但还没有被父进程回收
3 如果其父进程已经退出,则init进程将变成此进程的父进程,内核回收该进程进程表项和任务数据结构
4 如果其父进程还在运行,那么系统将会发送sigchld信号给其父进程,通知其子进程已经退出,同时保存该进程的状态信息,等待父进程回收
5 父进程调用wait()或waitpid()方法来获取子进程退出状态信息,并将该进程资源释放掉。
以下是内核函数do_exit()方法释放内存和关闭打开的文件的代码片段:
void do_exit(long code)
{
int i;
/* 首先,我们需要获取当前进程的进程控制块(PCB) */
struct task_struct *tsk = current;
/* 关闭进程所打开的所有文件 */
for (i = 0; i < NR_OPEN; i++)
if (tsk->filp[i])
sys_close(i);
/*
* 接下来,释放进程所占用的所有内存
* 注意,我们需要释放内核栈、用户栈、代码段和数据段
*/
/* 释放用户态堆栈所占用的内存 */
free_page((unsigned long) tsk->start_stack & 0xfffff000);
/* 释放进程所占用的所有内存页面 */
for (i = 0; i < MAX_MAPS; i++) {
if (tsk->mem_map[i]) {
free_page((unsigned long) tsk->mem_map[i]);
} else {
break;
}
}
/* 确保进程的信号处理程序不会被再次调用 */
tsk->signal = 0;
/* 将进程的状态设置为“已经停止运行” */
tsk->state = TASK_ZOMBIE;
/* 保存进程的退出码 */
tsk->exit_code = code;
/*
* 最后,唤醒父进程(如果有的话)
* 并立即进行进程切换
*/
/* 如果当前进程有父进程,则需要唤醒父进程 */
if (tsk->father)
wake_up(tsk->father);
/* 进程切换 */
schedule();
}
总结:本篇主要学了Linux0.11内核中的进程管理,包括从第一个进程的初始化、进程创建、进程调度、进程状态和进程终止。其中第一个进程为进程号是0的swap进程,它主要用来初始化Linux内核以及创建init进程。进程调度时,系统扫描running数组中的任务,选出剩余滴答数最大的进程进行切换,因为滴答数越大代表该进程的运行时间最短。而进程的终止是用户进程调用exit()系统调用,然后系统调用do_exit()方法终止进程。