编译得到可执行目标文件后,就可以将“可执行目标文件”加载“运行地址”所指的内存位置,然后就运行了
不过这里还是要分两种情况来看

  • 第一种是裸机运行的情况
  • 第二种是基于OS虚拟内存运行的情况。

一、裸机的情况

使用专门针对裸机的编译器来编译程序,最后得到的就是可以在裸机上运行的可执行程序

加载裸机程序时,由专门的加载程序(加载软件)来实现的,
如果你无法想象裸机程序是如何加载的话,你就想一想单片机程序的加载,因为单片机其实就是裸机的情况

1. 加载

其实加载的过程就是将“代码段”和“数据段”复制到内存上

裸机时,链接器重定位后的“运行地址”是真实的物理地址,
加载时直接将“代码段”和“数据段”复制到物理内存中“运行地址”所指定的位置

裸机运行地址是多少,可以由我们程序员自己来定

注意:裸机时就不是ELF格式头了,而是bin格式头


2. 运行

  • (a)CPUPC(程序计数器)存放第一条指令_start的地址,也就是将PC指向第一条指令_start
    PCCPU的寄存器之一
  • (b)从_start开始执行启动代码
  • (c)启动代码调用_init等函数进行初始化
    初始化有一件非常重要的事情就是,从内存划出一片空间出来用作堆和栈,
    因为空间是以堆和栈的方式来管理的,因此就称为堆 和 栈
  • (d)启动代码调用main函数,main函数再调用各个子函数,我们自己写的代码就开始运行了
  • (e)main函数调用return关键字,返回到启动代码。
    对于裸机的来说,返回到启动代码就结束了

至于return的返回值,有没有返回值,对于裸机来说都没有什么影响,
就算有返回值,将返回值返回给启动代码后,这个返回值对启动代码来说也没有什么意义

所以说,对于裸机来说,其实main函数的返回值没有什么意义,
所以大家在学习单片机时候,以前的main函数的返回值都是void

void main(void)
{
    return;
}

不过现在都规范化了,单片机等裸机里面,也要求main函数的返回值类型为int

int main(void)
{
    return 0;
}

尽管在这里要求返回int型的返回值,但是我们自己应该清楚,在裸机下,main函数的返回值并没有什么大的意义

3. 栈、堆

前面说过,程序运行起来后,初始化代码会从内存中划出一片空间,用来作为程序运行所需要的栈和堆。

栈(stack)

栈的意思是,表示内存空间以栈这种数据结构来进行管理,所谓管理就是管理空间的开辟和释放,
栈的特点是,只能在栈顶进行操作,不能够在栈的中间和栈底操作

栈是向下生长的

所谓向下生长就是,栈底在最高地址处,当栈中没有任何空间被使用时,栈顶指针就指向栈底
每当栈顶被占用一个字节的空间,栈顶指针就向低地址方向移动一个字节

从高地址向低地址方向移动,就是向下生长,栈顶指针所指的那个字节是没被用的,
栈顶和栈底之间的栈空间,就是被占用的空间

怎么理解栈顶指针?就是某个寄存器或则指针变量,专门用于存放栈顶字节的地址

栈的作用

函数自动局部变量、形参等就开辟于栈。

int fun(int a)
{
    int b;
    ...
}

不过这里有一点需要强调下,对于ARM来说,由于ARM CPU内部寄存器比较多,
所以如果形参在4个以内的,实际上形参是在寄存器中,而并不在栈中,
如果超过4个的话,第4个往后的形参才会存在栈中

不过在intelCPU上又不一样,因为Intel CPU的寄存器比较紧俏,所以形参基本都是存在栈中的

我们这里为了讲课的方便,我们一律认为形参都是在栈中的

从栈中开辟和释放自动局部变量、形参空间的过程,由函数被调用时,在运行的过程中自动完成的,
无需程序员关心,开辟空间和释放空间的本质,其实就是栈顶指针移动的过程


堆(heap)

堆空间和栈空间的管理方式是有区别的:

  • 栈的话只能在栈顶才能进行操作,但是堆不是,堆的话可以在中间任何位置操作
  • 堆的空间是向上生长的,也就是说在堆中开辟空间时与栈相反,是从低地址往高地址方向延伸的

栈的空间是自动开辟和释放的,但是堆的空间不是的,堆只能手动开辟和释放

  • 从堆里面开辟空间
    程序需要调用malloc函数来手动开辟。

所谓手动开辟,就是程序员需要在程序中亲自调用某个函数来实现,至于说在堆中什么位置开辟空间,
这个由malloc函数的算法来决定。

释放在堆中开辟的空间:在程序中调用free函数,手动释放释放的意思,也是将空间让出来,让别人可以使用


二、基于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)CPUPC指向_start
  • (b)从_start开始执行启动代码
  • (c)启动代码调用_init等函数进行初始化
    其中很重要的就是弄出堆和栈这两个东西,这一点与前面裸机的情况时类似的,这里不再赘述
    不过与裸机不同的是,在栈和堆之间,还有一个“共享映射区”
  • (d)启动代码调用main函数,main函数再调用子函数,我们自己写的代码就开始运行了
  • (e)main函数调用return关键字,返回到启动代码

桟、堆、共享映射区没有明确的界限,堆栈之间的空间间隔非常大,正常情况下是不会产生溢出和冲突

OS时,main函数将返回值return给启动代码后,启动代码会调用exit函数,接着将返回值返回给OS
在裸机情况下,启动代码不存在调用exit函数这一说,只有基于OS时才存在这种情况

疑问:将返回值返回给 OS 有什么用?

在《Linux系统编程、网络编程》第7章—进程控制有详细介绍,我们这里只是C语言相关的课程,
所以这个知识点不属于本门课的课程范围,请看《Linux系统编程、网络编程》

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);

memsetbzero的不同之处在于,bzero只能将内容设置为0,但是memset还可以设置为其它值,
比如memset(p, 1, 4);,将4个字节全部设置1

(d)请区分“释放 与 清零”,这是两回事

很多同学总以为释放就是清零,清零就是释放,其实释放是释放,清零是清零,并不不是一回事,千万不要混淆,

子进程以前的堆栈空间被释放时,其实只是把空间让出来,好让新程序重新设置自己的堆栈,但是空间并没有清零,
以前的数据还遗留在了里面,而启动代码重新设置自己的堆栈时,往往也也不会清,所以还一直遗留着,
不过,只要程序员在开辟变量空间时,记得初始化新值覆盖它,其实也没什么大不了的,总不至于会像不冲大便那样,让人讨厌