Fork函数详细解读
一.写在前面
先来看下面两张图,是我在操作系统书籍中截取的,回忆一下进程的组成
进程控制块是操作系统中最为重要的数据结构,每个进程控制块包含了操作系统 管理所需的所有进程信息,进程控制块的集合事实上定义了一个操作系统的当前状态。 进程控制块使用或修改权仅属于操作系统程序,包括调度程序、资源分配程序、中断 处理程序、性能监视和分析程序等。有了进程控制块进程才能被调度执行
二.简介和例子
fork函数会从父进程复制一个新的子进程(和父进程一模一样,一个完全相同的进程映像,除了pid标识信息不同),所以子进程也会从父进程执行到的fork函数之后开始执行,两个进程pid不同,其余都是一样的,从fork函数复制进程这一点, 下面来看一下fork函数复制进程的案例
#include <unistd.h>
#include <stdio.h>
int main ()
{
pid_t fpid; //fpid表示fork函数返回的值
int count=0;
printf("执行fork前fpid %d\r\n",fpid);
fpid=fork();
printf("当前fpid %d\r\n",fpid);
printf("当前count %d\r\n",count);
if (fpid < 0)
printf("error in fork!");
else if (fpid == 0) {
printf("子进程, my process id is %d\r\n",getpid());
count++;
printf("子进程当前count %d\r\n",count);
}
else {
printf("父进程, my process id is %d\r\n",getpid());
count++;
printf("父进程当前count %d\r\n",count);
}
printf("%d最后结果是: %d\r\n",getpid(),count);
return 0;
}
运行结果:
执行fork前fpid 0
当前fpid 4493
当前count 0
父进程, my process id is 4492
父进程当前count 1
4492最后结果是: 1
当前fpid 0
当前count 0
子进程, my process id is 4493
子进程当前count 1
4493最后结果是: 1
可以看出,这里复制了一个新的进程4493
其实fork函数的特点就是,一次函数调用可能会得到两种不同的返回值.如何来理解这句话呢?
首先fork函数的返回值有下面三种:
- 大于0的正整数 (成功生成子进程)
- 0
- 负数
我们可以着重了解下面的问题:
三. 为什么fork函数复制子进程后,在子进程中fork函数为什么返回值是0?
fork函数最早出现在main.c的方法中
void main(void)
{
...
sched_init();
...
if (!fork()) {
init(); // 在新建的子进程(任务1)中执行。
}
...
}
fork函数在头文件unistd.h中定义
sys_fork:
call find_empty_process # 获取一个可用空的pid
testl %eax,%eax # %eax寄存器中存储前面找到的pid。若返回负数则退出。
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call copy_process #开始复制进程 生成子进程
addl $20,%esp # 丢弃这里所有压栈内容。
1: ret
fork函数会返回_res,_res绑定了%eax寄存器,上面执行的代码可以看出,%eax寄存器存储的是子进程的pid,所以fork函数(父进程中调用的就会返回子进程的pid) 因为copy_process函数父进程执行过程中将%eax寄存器的值置为0了,所以子进程执行的fork()函数也是返回%eax寄存器中的值,所以返回的值就是0
下面即为copy_process函数
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;
// 首先为新任务数据结构分配内存。如果内存分配出错,则返回出错码并退出。
// 然后将新任务结构指针放入任务数组的nr项中。其中nr为任务号,由前面
// find_empty_process()返回。接着把当前进程任务结构内容复制到刚申请到
// 的内存页面p开始处。
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
// 随后对复制来的进程结构内容进行一些修改,作为新进程的任务结构。先将
// 进程的状态置为不可中断等待状态,以防止内核调度其执行。然后设置新进程
// 的进程号pid和父进程号father,并初始化进程运行时间片值等于其priority值
// 接着复位新进程的信号位图、报警定时值、会话(session)领导标志leader、进程
// 及其子进程在内核和用户态运行时间统计值,还设置进程开始运行的系统时间start_time.
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid; // 新进程号。也由find_empty_process()得到。
p->father = current->pid; // 设置父进程
p->counter = p->priority; // 运行时间片值
p->signal = 0; // 信号位图置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; // 进程开始运行时间(当前时间滴答数)
// 再修改任务状态段TSS数据,由于系统给任务结构p分配了1页新内存,所以(PAGE_SIZE+
// (long)p)让esp0正好指向该页顶端。ss0:esp0用作程序在内核态执行时的栈。另外,
// 每个任务在GDT表中都有两个段描述符,一个是任务的TSS段描述符,另一个是任务的LDT
// 表描述符。下面语句就是把GDT中本任务LDT段描述符和选择符保存在本任务的TSS段中。
// 当CPU执行切换任务时,会自动从TSS中把LDT段描述符的选择符加载到ldtr寄存器中。
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; // 这是当fork()返回时新进程会返回0的原因所在,在父进程复制子流程后将%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; // 段寄存器仅16位有效
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); // 任务局部表描述符的选择符(LDT描述符在GDT中)
p->tss.trace_bitmap = 0x80000000; // 高16位有效
// 如果当前任务使用了协处理器,就保存其上下文。汇编指令clts用于清除控制寄存器CRO中
// 的任务已交换(TS)标志。每当发生任务切换,CPU都会设置该标志。该标志用于管理数学协
// 处理器:如果该标志置位,那么每个ESC指令都会被捕获(异常7)。如果协处理器存在标志MP
// 也同时置位的话,那么WAIT指令也会捕获。因此,如果任务切换发生在一个ESC指令开始执行
// 之后,则协处理器中的内容就可能需要在执行新的ESC指令之前保存起来。捕获处理句柄会
// 保存协处理器的内容并复位TS标志。指令fnsave用于把协处理器的所有状态保存到目的操作数
// 指定的内存区域中。
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
// 接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址和限长,
// 并复制页表。如果出错(返回值不是0),则复位任务数组中相应项并释放为该新任务分配的用于
// 任务结构的内存页。
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
// 如果父进程中有文件是打开的,则将对应文件的打开次数增1,因为这里创建的子进程会与父
// 进程共享这些打开的文件。将当前进程(父进程)的pwd,root和executable引用次数均增1.
// 与上面同样的道理,子进程也引用了这些i节点。
for (i=0; i<NR_OPEN;i++)
if ((f=p->filp[i]))
f->f_count++;
if (current->pwd)
current->pwd->i_count++;
if (current->root)
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
// 随后GDT表中设置新任务TSS段和LDT段描述符项。这两个段的限长均被设置成104字节。
// set_tss_desc()和set_ldt_desc()在system.h中定义。"gdt+(nr<<1)+FIRST_TSS_ENTRY"是
// 任务nr的TSS描述符项在全局表中的地址。因为每个任务占用GDT表中2项,因此上式中
// 要包括'(nr<<1)'.程序然后把新进程设置成就绪态。另外在任务切换时,任务寄存器tr由
// CPU自动加载。最后返回新进程号。
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;
}
四.fork函数中体现的写时复制
写时复制涉及操作系统中的虚拟地址(逻辑地址)
cpu 内存 磁盘
首先我们在linux上执行free命令可以得到下列信息
[root@01_12_146 cfile]# free -h
total used free shared buff/cache available
Mem: 15G 5.8G 510M 830M 9.3G 8.7G
Swap: 8.0G 409M 7.6G
- Mem指的就是我这台机器上的物理内存(主存储器,主存)
- Swap在linux上称为交换分区,也叫做虚拟内存
- 虚拟内存,不是某一个操作系统独有的,这是操作系统内存管理中的规范,通俗的说,就是在主存不够用的时候就会从硬盘中抽出一部分空间 来哄骗cpu作为内存使用,这个空间就叫做虚拟内存(但是在windows上及时当前内存不紧张,也会使用到虚拟内存),这是一种逻辑上扩充物理内存的技术。基本思想是用软、硬件技术把内存与外存这两级存储器当做一级存储器来用。虚拟内存技术的实现利用了自动覆盖和交换技术。简单的说就是将硬盘的一部分作为内存来使用。
- 虚拟地址,原始的内存分配,是直接为进程分配物理地址,这样多个进程都是可以直接操作计算机的主存,即可以直接操作计算机的物理地址,所以可能会导致进程间的读写相互影响,并且对于一些 恶意程序,便可以修改其他程序的物理地址造成混乱,也就是说这样的进程内存分配是没有隔离性的.而虚拟地址,是让每个进程都有了自己的一块内存地址,在32位操作系统上,这个自己的一块内存 的大小是4G,进程中的程序只需要和自己虚拟地址交互即可,数据到底写到了物理地址的什么位置不需要关心,因为虚拟地址和物理地址之间由MMU(内存管理单元)管理了映射关系,好了,这样的话,进程中的程序需要使用数据的时候,首先需要 看数据是否存在映射关系中的物理地址上,所以这里涉及到虚拟地址中的另一个概念,页表,页表也是保存在进程中,页表就是进程获取虚拟地址和物理地址映射情况的数据结构.当进程中的程序访问虚拟地址的时候,从页表中 得出数据不在物理地址上,这个时候就会发生缺页异常,此时会将磁盘上的数据拷贝到物理地址上,如果发现数据存在物理地址上,那么同时页表中也会有数据虚拟地址对应的在物理内存上的位置.
参考: 现代操作系统普遍采用虚拟内存管理(Virtual Memory Management)机制, 这需要MMU(Memory Management Unit)的支持。MMU通常是CPU的一部分,如果处理器没有MMU, 或者有MMU但没有启用,CPU执行单元发出的内存地址将直接传到芯片引脚上,被 内存芯片 (物理内存)接收,这称为物理地址(Physical Address),如果处理器启用了MMU, CPU执行单元发出的内存地址将被MMU截获,从CPU到MMU的地址称为虚拟地址(Virtual Address), 而MMU将这个地址翻译成另一个地址发到CPU芯片的外部地址引脚上,也就是将虚拟地址映射 成物理地址。 Linux中,进程的4GB(虚拟)内存分为用户空间、内核空间。用户空间分布为0~3GB( 即PAGE_OFFSET,在0X86中它等于 0xC0000000) ,剩下的1G为内核空间。程序员只能使用虚拟地址。系统中每个进程有各自的私有用 户空间(0~3G),这个空间对系统中的其他进程是不可见的。 CPU发出取指令请求时的地址是当前上下文的虚拟地址,MMU再从页表中找到这个虚拟地址 的物理地址,完成取指。同样读取数据的也是虚拟地址,比如mov ax, var. 编译时var就 是一个虚拟地址,也是通过MMU从也表中来找到物理地址,再产生总线时序,完成取数据的。
事实上,每个进程创建后都不是立刻拷贝数据到了物理内存上,而只是建立了虚拟地址的映射,只有当运行到对应的程序后才会通过虚拟地址获取数据,此时发生缺页异常,之后才会拷贝数据;
现在看下fork中一个重要的概念-写时复制
父进程调用fork复制子进程之后,会复制了父进程的虚拟地址空间到自己的进程中,所以子进程的代码段,数据段堆栈段信息都是指向父进程的物理内存地址的,所以我们说子进程和父进程几乎一样,
但是如果父子进程需要更改数据时,并且又不是exec调用,那么就会为子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。这就是写时复制。
ps: 这里说明一下exec调用,是fork之后如果希望子进程可以执行另外的进程业务逻辑,不继续执行父进程的逻辑就可以使用exec函数来实现.
某个进程执行exec后,系统把代码段替换成新的程序的代码(exec函数的参数中指定),废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一保留的就是进程ID。所以
对于系统而言,该进程的是没有变的,没有新的进程产生,但是该进程执行的逻辑已经变了,并且整个代码段和内存栈空间都变了,就是说,这个进程灵魂已经改变了,只是还是披着之前的pid这个外壳
下面即为子进程中调用exec函数,让子进程可以运行完完全全的另一个事情
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
#include<stdlib.h>
int main(int argc, char *argv[], char ** environ)
{
pid_t pid;
int status;
printf("Exec example!\n");
pid = fork();
if(pid < 0){
perror("Process creation failed\n");
exit(1);
}
else if(0 == pid){
printf("child process is running\n");
printf("My pid = %d ,parentpid = %d\n",getpid(),getpid());
printf("uid = %d,gid = %d\n",getuid(),getgid());
execve("processimage",argv,environ); //子进程中调用了exec系列函数,所以会直接切换执行processimage方法了
printf("process never go to here!\n");
exit(0);
}
else {
printf("Parent process is runnig\n");
}
wait(&status);
exit(0);
}
参考文章
文章1
文章2
文章3
文章4
文章5
文章6
文章7
文章8
文章9
文章10
文章11
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名,转载请标明出处
最后编辑时间为:
2021/04/28 23:26