(2)基于OS虚拟内存的情况
基于OS运行程序时,常见有两种方式
· 在图形界面,双击快捷图标实现
· 在命令行,执行./a.out命令实现
我们前面说过,说每一个进程都是运行在自己的独立虚拟内存中的,命令行和图形界面本身也是一个程序(进程),所以也是运行在自己独立的虚拟内存上的。
1)程序的加载
当我们双击程序,或者执行./a.out命令时,就开始了程序的加载操作,具体步骤如下:
(a)首先从父进程复制出一个子进程
图形界面、命令行程序就是父进程,执行程序时会从父进程复制出子进程,复制的目的其实就是从父进程的“虚拟内存”复制出一个子进程的“虚拟内存”,准确讲应该是复制出“虚拟内存”的相关数据结构,用于建立子进程的虚拟内存。
有了子进程的虚拟内存,就可以将新的程序加载到虚拟内存中了。
虚拟内存空间被分为了两部分,一部分是内核空间,另一个部分是应用空间,应用程序的应该加载到应用空间。
在Linux下复制子进程时需要调用Linux OS所提供的fork函数,该函数在《Linux系统编程、网络编程》中有详细介绍。fork是由父进程调用的。
至于虚拟内存与真实物理内存之间的对应关系,这个事情就留给“虚拟内存机制”来操心。
(b)调用加载器,将自己程序(新程序)的“代码段”和“数据段”加载到子进程虚拟内存的应用空间中
基于Linux运行的话,gcc链接时重定位的运行地址是从0x08048000或者0x0000000000400000开始的,所以程序会被加载到虚拟内存中0x08048000或者0x0000000000400000地址往后的空间中。
至于虚拟内存0~0x08048000或者0~0x0000000000400000之间的虚拟空间,则未被使用。
基于Linux OS运行时,加载器是由Linux OS提供的,任何一个程序都可以通过execve这个系统API来调用加载器,为了方便称呼,我们就直接将“execve函数”称为加载器。
有关调用fork函数创建子进程,然后调用execve函数加载新程序到子进程的过程,在《Linux系统编程、网络编程》第8章—进程控制有详细的介绍,大家把这章学完之后,你就知道在有OS时,新程序是如何基于OS运行起来的。
2)运行
(a)cpu的pc指向_start(将第一条指令——start所在位置的虚拟地址存放到pc)
(b)从_start开始执行启动代码。
(c)启动代码调用_init等函数进行初始化。
其中很重要的就是弄出堆和栈这两个东西,这一点与前面裸机的情况时类似的,这里不再赘述。
不过与裸机不同的是,在栈和堆之间,还有一个“共享映射区”。
(d)启动代码调用main函数,main函数再调用子函数,我们自己写的代码就开始运行了。
(e)main函数调用return关键字,返回到启动代码。
有OS时,main函数将返回值return给启动代码后,启动代码会调用exit函数,接着将返回值返回给OS。
在裸机情况下,启动代码不存在调用exit函数这一说,只有基于OS时才存在这种情况。
疑问:将返回值返回给OS有什么用?
在《Linux系统编程、网络编程》第7章—进程控制有详细介绍,我们这里只是C语言相关的课程,所以这个知识点不属于本门课的课程范围,请看《Linux系统编程、网络编程》。
我们这里虽然讲的只是c的情况,实际上c++等其它语言的程序也是类似的。
3)加载执行新程序后——子进程从父进程复制而来的堆/栈去哪里了
(a)谁代表了堆和栈的存在
堆栈指针代表了“堆和栈”的存在,堆栈指针存储在了寄存器或者静态区。
堆栈指针在,堆栈就在,堆栈指针没有了,堆栈就没有了。
(b)向子进程中加载新程序后会怎么样
加载新程序之前,子进程中的所有内容(包括堆和栈),都是从父进程复制(继承)而来,子进程的.text、.data、...、堆栈与父进程的一模一样。
加载新程序后,子进程原来的.text、.rodata等都被覆盖了,那么子进程原来的堆栈指针也就无效了,无效的意思就是原来的堆栈被释放了,释放的意思就是让出来,别人可以去操作了,只是以前的堆栈被使用时,里面的数值还遗留在了空间中。
当exec加载新程序后,新程序的.text、.rodata、.data等会覆盖子进程原有的.text、.rodata、.data、.bss,然后开始执行新程序。
执行新程序时一定是从.text中的“启动代码”开始执行的,当执行启动代码中设置堆栈的代码时,会重新设置新程序自己的堆栈指针,此时所代表的就是新程序自己的堆栈,只不过堆栈空间还是以前那个堆栈空间。
这就好比上家公司搬走了,重新使用办公地点时,会从新布置办公空间的道理是一样的,只是重新布置,但是空间还是原来那个空间。
(c)重新设置堆栈时,堆栈空间会被清零吗?
子进程以前的堆栈被释放时,空间是没有清零的,设置新堆栈时清不清零,这个要看设置堆栈的代码是怎么做的,它可以清零,也可以不清零,实际上都是没有清零。
不清零也没关系,只要我们开辟变量空间时,记得初始化一个新的值,将以前的值给它覆盖掉就行。
就好搬进上家公司的地方时先不腾空空间,等用到哪些空间时,再搬新东西进去覆盖它即可。
正是由于新程序重新设置堆栈时不会清零,所以当新程序最开始运行时,
· 如果函数的“自动局部变量”没有赋初始值的话,就会是一个随机值,这个随机值就是子进程以前的栈所遗留的。
· malloc从堆中开辟变量空间时,如果没有初始化值的话,也是子进程以前的堆所遗留的值。
疑问:为什么以前遗留的值会被称为随机值?
答:因为对于我们来说,并不知道里面到底遗留的是什么值,所以它可能是任何值,因此就是随机的,自然就被称为了随机值。
就跟买彩票一样,号摇出来后肯定一个确定的数,但是对于不知道号是多少的我们来说,它可能是任何数,所以对于不知道号是多少的我们来说,它是一个随机值。
新程序自己在释放“堆栈”中的变量空间时,实际上也不会清零,
· 比如某函数运行结束,自动局部变量释放了,但是数据还遗留在了里面。
· 比如free释放了malloc所开辟的空间,同样的,以前所使用数据也遗留在了里面。
所以当新程序运行一段时间后,堆栈中的随机值已经不再是以前堆栈所遗留的数据了,而是自己所遗留的数据。
因此,我们从堆栈中开辟变量空间时,如果不初始化的话,里面就是一个随机值,总结起来,随机来自于两个地方。
· 新程序重新设置堆栈时没有清零,遗留了子进程的以前堆栈的数据
· 新程序在运行的过程中,在释放堆栈中的变量空间时,也不会清零,也会导致随机值
正是由于以上原因,在堆栈中开辟变量空间时,我们总是建议大家一定要初始化,否者这些随机值可能会带来一些麻烦,比如程序进行计算时不小心使用了随机值,从而导致程序计算结果不对,如果程序非常重要的话,这种错误的结果往往会带来很严重的大麻烦,不过好在于,一般性的程序都还无所谓。
疑问:初始化时,我不知道初始化为什么值,怎么办呢?
答:不知道初始化为多少,那就初始化为0(自己清零),比如。
栈:
int fun(void) { int a = 0; ... }
堆:
int *p = malloc(4); bzero(p); //调用bzero将空间清零,或则使用memset也行,memset(p, 0, 4); memset与bzero的不同之处在于,bzero只能将内容设置为0,但是memset还可以设置为其它值,比如 memset(p, 1, 4);,将4个字节全部设置1,bzero其实就是memset的特例。
d)请区分“释放 与 清零”,这是两回事
很多同学总以为释放就是清零,清零就是释放,其实释放是释放,清零是清零,并不不是一回事,千万不要混淆,这就好比上厕所,上完厕所把厕所让出来是释放,走时把大便冲干净就是清零,对于不讲究的人来说,完全可以只释放厕所但是不清零,估计大家都挺讨厌这种人的。
子进程以前的堆栈空间被释放时,其实只是把空间让出来,好让新程序重新设置自己的堆栈,但是空间并没有清零,以前的数据还遗留在了里面,而启动代码重新设置自己的堆栈时,往往也也不会清,所以还一直遗留着。
不过,只要程序员在开辟变量空间时,记得初始化新值覆盖它,其实也没什么大不了的,总不至于会像不冲大便那样,让人讨厌。