进程地址空间和进程控制

程序地址空间

C/C++地址空间[stack heap 静态区 代码段等] 不是 内存,是虚拟内存(虚拟地址空间)。创建子进程时,要拷贝父进程的内核数据结构。

接下来先看一个现象

#include <stdio.h>
#include <unistd.h>

int global_value = 10;

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        printf("fork error\n");
        return 1;
    }
    else if(id == 0)
    {
        int cnt = 0;
        while(1)
        {
            printf("child[%d]:%d: %p\n", getpid(), global_value, &global_value);
            sleep(1);
            cnt++;
            if(cnt == 10)
            {
                global_value = 99;
                printf("子进程修改全局变量\n");
            }
        }
    }
    else
    {
        while(1)
        {
            printf("parent[%d]:%d: %p\n", getpid(), global_value, &global_value);
            sleep(2);
        }
    }
    sleep(1);
}

代码运行结果

child[12148]:10:0x60105c
parent[12147]:10:0x60105c
子进程修改全局变量
child[12148]:99:0x60105c
parent[12147]:10:0x60105c

为什么同一个地址,数值却不一样?因为进程是具有独立性的,每个进程有各自对应的地址表。

首先要意识到,程序打印出来的地址,绝对不是物理地址,可以想到指针表示的肯定不是物理地址,是虚拟地址(逻辑地址、线性地址)【因为这片虚拟地址是连续的,也称为线性地址】。

原因:父进程PCB通过它的地址空间通过页表映射找到物理地址,&global_value得到的是虚拟地址空间的地址,子进程也同理,因为子进程是由父进程创建的,相当于父进程的PCB、地址空间都被拷贝给子进程,所以大部分数据是一样的,故&global_value得到的值是一样的,经过页表映射得到的值也是一样的。【这也就是程序刚运行时,global_value都是10,&global_value都是0x60105c的原因。】当子进程去修改global_value,我们都知道进程具有独立性,子进程对数据的改变是不会影响父进程的。所以当两个进程对共享数据做修改时,这里子进程对global_value进行修改,此时OS会在物理内存重新开辟一块空间存放拷贝的global_value,而后再修改为99,不修改子进程地址空间到页表的映射关系,而是修改子进程的页表到物理内存的映射关系。【因此我们看到的子进程的global_value变成99,父进程的global_value依然是10,但是两者的地址空间仍然不变。】

写时拷贝–当有多个进程对同一块物理空间进行修改时,OS会先进行数据拷贝,更改页表映射,然后再让进程修改数据.

什么是进程地址空间

进程会跟操作系统要内存或者对象空间,那操作系统会给进程画个饼,这个饼是进程地址空间和页表。进程地址空间和页表都要被管理。

地址空间的本质是内核的一种数据结构mm_struct,以32位计算机为例:

注意:1、地址空间描述的基本空间大小是字节;2、32位下,最多有2^32个地址,每个地址标识1个字节,即最多表示4GB空间范围;3、每1个字节都要有唯一的地址,一共需要表示2^32个地址。4、地址最大的意义只要保证唯一性,2进制下用32比特位就可以完全表示–>unsigned int->uint32_t。

struct mm_struct
{
	uint32_t code_start, code_end;
	uint32_t data_start, data_end;
	uint32_t heap_start, heap_end;
	uint32_t stack_start, stack_end;
	//...还有其他的数据区域
}

struct mm_struct* mm = (struct mm_struct*)malloc(struct mm_struct);
//区域划分--虚拟地址(因为这片空间是malloc出来的,里面的刻度是我们自己定的,但是能根据这些地址找到数据)
mm->code_start = 0x1111 1111;//这个地址是随便写的
mm->code_end = 0x1211 1111;
...
mm->heap_start = 0x1400 0000;//定义局部变量
mm->heap_end = 0x1500 0000;//堆栈之间要做区域调整
mm->stack_start = 0x7fff ffff;//malloc new
mm->stack_end = 0x8fff ffff;

区域调整本质就是修改heap和stack的start或者end。进程地址空间又涉及到区域划分、区域调整、区域扩大。举例:定义局部变量、malloc new堆空间,实际上就是区域扩大,扩大栈区或者扩大堆区;当函数调用完毕、free时,就是在缩小栈区或堆区。

每个进程都有自己的虚拟地址空间/进程地址空间和页表。PCB里有个指针struct mm_struct* mm指向这个进程地址空间。

我们知道每个可执行程序都是保存在磁盘上的,当运行时会被加载到内存里,内存也是硬件,那么如何将内存(硬件)和进程地址空间(软件)联系起来呢?

可以将内存当作1个大数组struct page mm[4GB/4KB];,即划分为4KB大小的一块一块空间,数量是4GB/4KB个,一个4KB称为Page,这里的就是物理地址。虚拟地址和物理地址是通过页表建立映射关系的。

举例:&a,取到的是虚拟地址空间的地址,OS会自动帮我们去页表索引到对应的物理地址,拿到a的值。

为什么要有进程地址空间

1、如果让进程直接访问物理内存,万一进程越界非法操作,那其他数据非常不安全;当有进程地址空间时,如果发生越界访问,页表在映射时会直接拦截该操作,可以起到保护作用,禁止非法访问;

2、进程地址空间的存在,可以更方便地进行进程和进程数据代码的解耦,保证了进程独立性;进程的独立性体现在进程有自己独立的内核数据结构(PCB,地址空间,页表),写时拷贝的方式可以保证数据的独立性,代码是独立的;

3、让进程以统一的视角来看待进程对应的代码和数据各个区域,方便编译器也以统一的视角来编译代码,方便使用,因为两者规则是一样的,编译完就可以直接使用。

**补充:**1、可执行程序里面,但没有被加载到内存时有没有地址呢?有地址,是逻辑地址。举例:汇编的时候能看到指令的地址。编译器在编译我们的代码时,就会按照虚拟地址空间的规则和样式对代码和数据进行编址。这个逻辑地址是我们的程序内部使用的地址,当程序被加载到物理内存的时候,该程序对应的指令和数据就天然有一个外部的物理地址,不过程序内部如函数跳转等还是按编译器编址走。不过外部要访问该程序时,还是要通过内存的物理地址来访问该程序。那cpu读取指令时,读取的是虚拟地址,通过页表映射,找到物理地址,就能拿到该程序的代码和数据。

从磁盘加载程序到内存这个过程,有两套地址:1、物理地址,标识物理内存中代码和数据的地址;2、逻辑地址,在程序内部互相跳转的时候使用。

OS肯定也能读取程序内部的逻辑地址,所以就可以根据物理地址和逻辑地址创建映射关系,即填充页表,那么进程PCB就可以通过mm_struct(地址空间)来访问物理地址(因为可执行程序在编译的时候就是按照虚拟地址规则来划分的)。

当进程被调度时,会把程序的地址空间的值load到CPU里,CPU就可以用这个值取访问地址空间,通过查询页表,找到物理地址。【CPU读进来的都是指令,指令内部的是逻辑地址!】

可执行程序的编码格式是ELF,可以后续自己查看。

进程控制

fork是从已存在进程中创建一个新进程,新进程为子进程,原进程为父进程。进程调用fork,当控制转移到内核中的fork代码后,内核做:1、分配新的内存块和内核数据结构给子进程,即创建PCB和创建子进程的地址空间;2、将父进程部分数据结构内容拷贝至子进程,即给PCB和进程空间赋值;3、创建并设置页表;4、添加子进程到系统进程列表当中;5、fork返回,开始调度器调度。

关于fork

使用fork,父子进程的数据是采用写时拷贝的方式,各自私有一份。创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略

1、如何理解fork可以有2个返回值?

因为当执行fork语句时,实际在fork函数内部将子进程添加到系统进程list后,已经有2个执行流了【通俗一点,return之前核心代码已经执行完毕】,所以父进程和子进程都会执行return pid;这条语句。

2、如何理解fork返回之后,给父进程返回子进程PID,给子进程返回0?

实际上是因为子进程的PID返回给父进程,可以让父进程找到子进程,而子进程现在没有孩子进程,故返回0,相当于表示子进程没有孩子。

3、如何理解同一个变量可以存储两个值,在同一段代码又能进入if也能进入else?

因为进程空间是独立的,当父子进程都执行return pid;这条语句时,不确定哪一方先返回,谁先返回谁就先写入id,谁就会以写时拷贝的方式拷贝一份副本。相当于父子进程各自有1个id,父进程的返回值id!=0,子进程的返回值id==0。又因为代码是共享的,实际是子进程或父先走一遍if判断流程,父进程或子再走一遍if判断流程,才会看到有2个if和else结果。虽然是同一段代码,但是是两个进程在做动作。

进程创建

用户能创建的进程是有上限的,如果系统有太多的进程,或者实际用户的进程数超过了限制,fork会调用失败。

#include <stdio.h>
#include <unistd.h>

int main()
{
	int cnt = 0;
	while(1)
	{
		pid_t ret = fork();
		if(ret < 0)
		{
			//创建失败
			printf("fork error! cnt:%d\n", cnt);
			break;
		}
		else if(ret == 0)
		{
			//子进程
			while(1)
			{
				sleep(1);
			}
		}
		//父进程
		//死循环创建子进程
		cnt++;
	}
	return 0;
}

进程等待

通过进程等待来解决僵尸进程的问题。让进程状态从Z变成X。

僵尸进程的代码和数据会被释放,但退出码和信号会被保存在它的PCB里面,供父进程读取

父进程通过进程等待的方式,目的:1、回收子进程资源;2、获取子进程退出信息。在等待期间,子进程没有退出的时候,父进程只能阻塞等待。

系统调用wait/waitpid

wait/waitpid是操作系统做的,所以操作系统有资格也有能力去读取子进程的PCB,拿到它的退出信息。

//wait()方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
返回值:
	成功返回被等待进程pid,失败返回-1。
参数:
	输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
//waitpid()方法
pid_ t waitpid(pid_t pid, int* status, int options);
返回值:
	当正常返回的时候waitpid返回收集到的子进程的进程ID;
	如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
	如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
	pid:
		Pid=-1,等待任一个子进程。与wait等效。
		Pid>0.等待其进程ID与pid相等的子进程。
	status:
        WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
		WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
	options:
		WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
//int status = 0;
//waitpid(id, &status, 0);
//第2个参数设置是指针,通过waitpid()函数,可以直接修改这个status
//第3个参数,0表示阻塞式等待,WNOHANG表示非阻塞

子进程跑10s退出,父进程第15秒才去wait/waitpid,所以有5s的时间会看到子进程的Z状态,然后子进程被父进程回收,僵尸进程就消失了,只剩下父进程的状态。

参数status 退出码+退出信号

wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。

问题:命令行输出wait success:16238, ret:2560,为什么status的值是2560呢?[子进程的退出码为10,exit(10);]

因为status不是整体使用的,有自己的位图结构。(只研究status低16比特位)。

当正常终止时,低8位(0-7)为0[终止信号],8-15位为退出状态;当被信号所杀时,低7位为终止信号,第8位为core dump标志。

即本程序的status为00001010 00000000,也就是我们所看到的2560。

//所以对status的使用应该如下
printf("exit_signal:%d, child_exit_code:%d\n", (status & 0x7F), ((status>>8) & 0xFF));

status & 0x7F 拿到低7位数据,core dump不拿。非0表示出错了,可以通过kill -l自行查看信号值所代表的信息
(status>>8) & 0xFF 拿到8-15位数据。子进程的退出码可以自己设置

宏WIFEXITED/WEXITSTATUS

这是系统提供的方便对status进行位操作的宏。

WIFEXITED(status)正常退出,则返回真。

WEXITSTATUS(status)提取进程退出码。

非阻塞式等待

阻塞式等待:如果子进程一直死循环不退出,那么父进程只能一直卡着等子进程结束。【之前写的test_exit、test_wait、mychild程序均是阻塞式等待】

非阻塞式等待:本质是状态检测,如果没有就绪,直接返回。多次非阻塞式等待叫做轮询

pid_t ret = waitpid(id, &status, WNOHANG);//WNOHANG表示非阻塞式等待,0表示阻塞

这里,ret == id表示等待成功,已获取子进程的退出信息;ret == 0表示检测到子进程没有退出,watpid也没有等待失败;ret < 0表示 可能是因为id传错了导致函数调用失败。

非阻塞等待的好处:不会占用父进程所有精力,可以在轮询期间做别的事情

进程程序替换

首先思考一下为什么要创建子进程?想让子进程执行父进程代码的一部分,即父进程对应的磁盘代码的一部分;想让子进程执行1个全新的程序,即加载磁盘上指定的新程序并执行。

让子进程加载指定程序及执行其代码和数据就是进程的程序替换。进程替换时没有创建新的进程。只是将指定程序的代码和数据覆盖原进程的代码和数据。如果指定程序不存在(调用失败),就没有替换成功,会执行原进程的代码和数据。

进程替换函数

所有exec*系列函数,参数以NULL结尾,失败返回-1,成功无返回值。进程替换指将指定的程序加载到内存中,让指定进程进行执行。所以要知道程序在哪,以及给出程序要怎么执行[带哪些参数]

#include <unistd.h>
int execl(const char *path, const char *arg, ...);
//l--list,将参数1个1个传入exec()函数
//...是指可变参数列表,表示可以传多个参数
//execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
//必须要以NULL结尾
-----------------------------
int execlp(const char *file, const char *arg, ...);
//p--path,不用告诉我程序的路径,只需要告诉我程序的名称,我会自动在环境变量PATH里查找该可执行程序
//execl("ls", "ls", "-a", "-l", NULL);
//第一个是告诉系统我想执行的程序,第2个是告诉系统我要怎么执行这个程序
------------------------------
int execv(const char *path, char *const argv[]);
//v--vector,可以将所有的执行参数放入数组中统一传递,不用可变参数列表
//char* const argv_[] ={"ls", "-a", "-l", "--color=auto", NULL};//指针数组
//execv("/usr/bin/ls", argv_); 
-----------------------------
int execvp(const char *file, char *const argv[]);
//v--vector, p--path
//char* const argv_[] ={"ls", "-a", "-l", "--color=auto", NULL};//指针数组
//execvp("ls", argv_);
-----------------------------
int execle(const char *path, const char *arg, ..., char *const envp[]);
//l--list, e--自定义环境变量

//调用系统环境变量 -- getenv(自定义环境变量)->NULL
//extern char** environ;
//execle("./mybin", "mybin", NULL, environ);

//调用自定义环境变量 -- getenv(系统环境变量)->NULL
//char* const envp_[] = { (char*)"MYVAL=11223344" };
//execle("./mybin", "mybin", NULL, envp_);

//如果既想使用系统的,又想使用自定义的,那就要putenv,把指定的自定义环境变量导入到environ指向的环境变量表
//extern char** environ;
//putenv((char*)"MYVAL=22446688");
//execle("./mybin", "mybin", NULL, environ);
-----------------------------
int execvpe(const char *file, char *const argv[], char *const envp[]);
-----------------------------
int execve(const char *file, char *const argv[], char *const envp[]);//真正执行函数替换的系统调用接口
-----------------------------------------------
虽然前4个exec*函数没有传环境变量,但是子进程能拿到默认的环境变量,environ,通过空间地址的方式让子进程拿到的。
前6个是在3号手册[是经过封装的函数],execve在2号手册[是系统接口函数]

**为什么成功没有返回值呢?**因为成功了的话,原进程的代码和数据都被替换了,就算返回了也用不上。所以只要返回了,一定是函数调用错误了。

如何理解子进程的进程替换后,不会影响父进程?

因为进程独立性,子进程有自己独立的PCB、进程空间和页表,当子进程要替换代码和数据的时候(对物理内存进行写入),就会发生代码和数据的写时拷贝,OS会在物理内存重新开辟一块空间给子进程,重新构建子进程的页表映射关系。

先执行exec*函数还是main函数

exec* 系列函数是加载器,能帮我们把程序加载到内存中,所以是先执行exec*系列函数。

所以main函数的参数int main(int argc, char* argv[], char* env[]),和int execle(const char *path, const char *arg, ...,char *const envp[])对比一下,可以得出main的参数实际是由exec*系列函数传给main的

进程终止

进程退出后会变成僵尸状态,会把自己的退出信息写入到PCB里,等待操作系统用wait/waitpid读取,给父进程。故僵尸状态的进程其代码和数据可以被回收,但是PCBtask_struct必须保留。

举例:平时写代码的return 0;为什么返回的是0呢?给谁返回?答:返回给invoke_main,这个返回值为进程的退出码,用于标定进程执行的结果是否正确。0表示正常返回,即场景1。

[yyq@VM-8-13-centos exit]$ ./test_exit 
[yyq@VM-8-13-centos exit]$ echo $?
1 #test_exit的退出码
[yyq@VM-8-13-centos exit]$ echo $?
0 #echo进程的退出码
[yyq@VM-8-13-centos exit]$ echo $?
0 #echo进程的退出码
// 为什么第一次是1,后面都是0?因为echo也是一个进程,第1次打印1打印的是test_exit的退出码,第2、3次打印的是echo的退出码。

$?:变量名是?,取变量名就是$?。永远记录最近一个进程在命令行执行完毕时对应的退出码,就是main函数内的返回值。

进程退出码

如何设定main函数返回值?如果不关心进程退出码,return 0就行。如果需要从进程退出码中知道信息,那就要返回特定的数据表示特定的错误,一般用0表示成功,非0表示失败。一般而言,退出码都必须有对应的退出码文字描述,是可以自定义的,也可以使用系统的库函数strerror

进程常见的退出原因

  1. 代码运行完毕,结果正确;
  2. 代码运行完毕,结果不正确;//退出码的意义就在于此
  3. 代码异常终止。

1和2对应的情况有从main返回;调用exit;_exit,可以通过echo $?查看退出码

3对应的情况是ctrl+c;信号终止,此时退出码是无意义的。

#include <stdlib.h>
void exit(int status); #参数status是让用户填退出码
//exit是库函数,当执行到exit()时,进程就会被直接终止

#include <unistd.h>
void _exit(int status);
//_exit是系统接口函数
------------------
两者的关系是库函数会调用系统接口函数,即exit会调用_exit。
两者的区别是 exit终止进程会主动刷新缓冲区,_exit终止进程不会刷新缓冲区

由此可以推断出 缓冲区在用户空间,一定不会在操作系统里。[试想一下,如果缓冲区在操作系统里,那_exit就可以刷新缓冲区,又因为exit调用_exit,那两个函数都能刷新。但是_exit不能刷新,所以缓冲区一定不在操作系统]

一个简易shell脚本

当我们使用如下execvp函数,再结合main函数的参数,我们可以通过命令行输入 ./myexec ls -a -l或者./myexec pwd等等,相当于我的程序就在执行系统指令。

int execvp(const char *file, char *const argv[]);
int main(int argc, char* argv[])
{
	execvp(argv[1], &argv[1]);//命令行输入./myexec ls -a -l
}

现在如果在命令行输入处能去掉./myexec,就成了一个简单的shell脚本。具体代码见下

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>

#define NUM 1024
#define OPT_NUM 64 //一个指令最多64个选项

char LineCommand[NUM];
char* myargv[OPT_NUM];

int main()
{
    while(1)
    { 
        //打印输出提示符
        printf("用户名@主机名 当前路径# ");
        fflush(stdout);
        
        //获取用户输入,输入时就自带\n
        char* s = fgets(LineCommand, sizeof(LineCommand)-1, stdin);
        assert(s != NULL);
        (void)s;
        //我们要把末尾的\n去掉,才是合法字符串
        LineCommand[strlen(LineCommand)-1] = 0;

        //"ls -a -l"->"ls" "-a" "-l" 字符串切割
        myargv[0] = strtok(LineCommand, " ");
        int i = 1;//下标
        //如果strtok无法继续切割会返回NULL,恰好myargv要以NULL结尾
        while(myargv[i++] = strtok(NULL, " "));

        //测试是否成功 条件编译
        #ifdef DEBUG
        for(int i = 0; myargv[i]; i++)
        {
            printf("argv[%d]:%s\n", i, myargv[i]);
        }
        #endif
        
        //执行命令
        pid_t id = fork();
        assert(id != -1);
        if(id == 0)
        {
            execvp(myargv[0], myargv);
            exit(1);
        }
        waitpid(id, NULL, 0);
    }
    return 0;
}
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值