计算机是怎么样工作的?

计算机是怎么样工作的?

 实验环境:Ubuntu12.04

下面我们通过 example.c 代码分别生成.cpp   .s   .o  和ELF可执行文件,并加载运行来分析   程序 example在CPU上执行的整个过程,并由此分析单任务计算机和多任务计算机的工作原理:

复制代码
 example.c
2 int g(int x) 3 { 4 return x + 3; 5 } 6 7 int f(int x) 8 { 9 return g(x); 10 } 11 int main() 12 { 13 return f(8) + 1;16 }
复制代码

一:

为了在系统上运行example.c 程序,每条C语句都必须转化为低级机器语言指令,然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制磁盘文件的形式存放起来。

 

在Linux系统上,从源文件到目标文件的转化是由编译器驱动程序完成的:

>gcc - o example example.cd

在这里,GCC编译器,读取源程序文件example.c 并把它翻译成一个可执行目标文件 example。

这个翻译过程分为四个阶段:

1:预处理阶段

预处理器根据以字符#开头的命令,修改原始的C程序,如果这里的example.c中的最前面加上#include<stdio.h>,告诉预处理器,读取系统文件stdio.h的内容,并把它直接插入到程序文本中,结果就得到另外一个C程序。

这里可以通过命令:gcc –E –o  example.cpp  example.c 来查看预处理之后的程序example.cpp,打开example.cpp文件,可以看到经过预处理的程序。

2:编译阶段

编译器将文本文件example.cpp 翻译成文本文件 example.s,它包含了对应的汇编程序:

可以根据第一阶段预处理后的 example.cpp 来生成汇编程序  命令:> gcc –x cpp-output –S –o example.s example.cpp

也可以直接通过原程序生成:> gcc –S –o example.s example.c

3:汇编阶段

汇编器(as)将example.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件example.o中,它是个二进制文件,字节编码是机器语言指令。

这阶段可以通过 第二阶段生成的example.s 来生成:>gcc –x assembler –c  example.s -o example.o
也可以直接通过原程序生成:        >  gcc –c example.c -o example.o
                                                         >  as –o example.o example.s
4:链接阶段

在example程序中,最后调用了printf函数,它是标准C库中的一个函数。printf函数存在于一个名为printf.o的单独预编译好了的目标文件中,而这个文件必须以某种方式合并到example.o程序中,链接器就是负责这些工作的。结果就得到了example文件,是一个可执行目标文件,可以被加载进内存执行。

生成可执行目标程序:可以接着第三阶段生成:   >gcc -o example  example.o

                                        也可以从原程序生成:  >gcc -o  example  example.c

此刻,example.c源程序已经被编译系统翻译成了可执行目标文件example,并存放在磁盘上,要想在linux系统上运行,我们将example文件名输入到外壳(shell)应用程序中:

> ./example

初始,shell执行它的指令,等待我们输入命令,当我们输入 ./example 后,shell 应用程序将字符读入寄存器,再存放到存储器中,然后shell执行一系列指令来加载可执行的example文件,将example目标文件中的代码和数据从磁盘复制到内存,然后处理器从main程序中的机器语言指令开始顺序执行。

 

二:

 下面再从汇编代码的角度来具体分析程序执行过程:

复制代码
 1 080483b4 <g>:
 2  80483b4:    55                       push   %ebp
 3  80483b5:    89 e5                    mov    %esp,%ebp
 4  80483b7:    8b 45 08                 mov    0x8(%ebp),%eax
 5  80483ba:    83 c0 03                 add    $0x3,%eax
 6  80483bd:    5d                       pop    %ebp
 7  80483be:    c3                       ret    
 8 
 9 080483bf <f>:
10  80483bf:    55                       push   %ebp
11  80483c0:    89 e5                    mov    %esp,%ebp
12  80483c2:    83 ec 04                 sub    $0x4,%esp
13  80483c5:    8b 45 08                 mov    0x8(%ebp),%eax
14  80483c8:    89 04 24                 mov    %eax,(%esp)
15  80483cb:    e8 e4 ff ff ff           call   80483b4 <g>
16  80483d0:    c9                       leave  
17  80483d1:    c3                       ret    
18 
19 080483d2 <main>:
20  80483d2:    55                       push   %ebp
21  80483d3:    89 e5                    mov    %esp,%ebp
22  80483d5:    83 ec 04                 sub    $0x4,%esp
23  80483d8:    c7 04 24 08 00 00 00     movl   $0x8,(%esp)
24  80483df:    e8 db ff ff ff           call   80483bf <f>
25  80483e4:    83 c0 01                 add    $0x1,%eax
26  80483e7:    c9                       leave  
复制代码

   

分析之前,先介绍3个寄存器,及3个特殊指令所执行的动作:

1:esp:栈指针,总是指向栈顶    ebp:栈基址指针,指向栈底     eip:总是指向下一条要执行指令的地址

2:call 指令:执行call 指令时:会把当前eip的值压栈保存,并使得eip等于被调用函数的起始地址。

3:leave指令:执行leave指令,等于下面两条指令:

movl  %ebp , %esp    //使栈顶指针指向栈基指针

pop  %ebp       //使得栈基指针恢复为前一次保存的ebp的值

4:ret指令:等于 pop  %ebp   即恢复ebp的值。

三:

下面分析指令从main函数开始执行过程中,函数栈的变换:

假设系统刚开始为该进程分配的栈状况如图1:

 

                                                      图  1

从main函数中第一条指令开始运行,知道运行到g函数中的add $0x3,%eax指令后,栈的状态如图2所示:

                            图2


x+3的计算结果11存放到%eax 中,下面进行函数返回操作: pop %ebp ret 执行完上面两条指令后的栈如图3:


                                                  图3

当前eip= 80483d0,即跳转到f函数中的leave指令处开始执行:
leave
ret

执行完上面两条指令后,栈如图4:


                                                 图4

当前eip=80483e4即回到了main函数中,下面执行
f(8)+1对应的操作: add $0x1,%eax
最后计算结果保存在eax中,等于12

执行leave操作后,栈如图5下:


                                                图5

从图5中可以看出,最后栈恢复之前的状态,跟图1中的原始栈一样。

下面我们根据example汇编代码的执行流程来分析单任务计算机和多任务计算机是怎么工作的:

      如果把main函数  f函数  g函数看成是3个不同的任务,那么从上面的汇编代码分析可以发现 对于单任务,计算机是按顺序从起始地址一条指令接着一条指令执行的,但是这里实现了main,f,g  多任务的执行,那是通过什么机制来实现的呢?

      通过上面的分析,我们不难发现是通过修改堆栈,保存任务流程断点信息(上下文:例如栈基址%ebp,和原下一条将要执行指令的地址%eip),并在将来某个时间恢复该上下文(通过pop,ret,leave操作恢复%ebp %eip的值),然后继续该任务流程”的方式,就是多任务的核心机制。

     从上面的汇编代码分析中也可以看出每个任务都设有一个私有堆栈,用于保存任务流被折断(任务切换)时的堆栈内容,方便返回之前的任务继续执行。

    由此可见,单任务的执行就是简单的从上到下按控制流执行。

                        多任务的执行是通过修改堆栈来改变任务的控制流方向实现多任务的并发执行。

  关于内存,最直观的理解可以将其想象成一个个格子,每个格子由一个地址标记出来并且存了一个字节的数据,对于32位的机器,可以有2^32个地址,也就是可以存4GB的数据。的确,对于程序员而言这样的理解已经足以满足我们编写程序的要求了,而内存实际的物理模型也是这个样子的。但是,对于系统而言,这样简单的模型是不够的,因为正常情况下系统中都会运行着多个程序,如果这些程序都可以直接对任意一个内存地址进行操作,那么一个程序就很有可能直接的修改了另外一个程序保存在内存中的数据,这种情况下会发生什么,不好说,但肯定会悲剧。所以操作系统必须实现一些机制,来保证各个进程可以和谐友爱地使用这有限的4G内存,同时又要保证内存的使用效率,这些就是我本文要讲的主要内容了。

1、基本概念

为了解决前面提到的问题,操作系统对物理内存做了抽象,得到一个重要的概念叫做地址空间,指可以用来访问内存的地址集合,也就是0X000000000xFFFFFFFF,大小跟物理内存一样是4。每个进程都有自己的地址空间,且在每个进程自己看来这4G就相当于物理内存,它可以使用任意地址去访问他们,而不需要担心影响到其他进程。因为这里的地址并不是实际的物理地址,而是虚拟地址,它需要经过系统转化成物理地址后再去访问内存,且系统保证了不同的进程中的同一个虚拟地址会映射到不同的物理地址,也就不会操作到同一块内存(除非那一块内存是共享的)。又因为通常一个程序也不会使用到4G的内存,所以4G的物理内存可以同时存放多个程序的数据而不会重叠,即使4G都已经放满了,也可以通过将一部分暂时没用的数据保存到磁盘的方式来腾出空间放其它的数据,具体如何操作,我们之后再讲,这里只要知道,我们的程序是通过虚拟地址来访问内存的,而系统保证了每个进程通过地址空间访问到的都会是自己的数据就可以了。

有了地址空间的概念后,在讨论程序如何使用内存的时候,我们就可以将物理内存的概念抛到一边了,接下来我们就看看Linux里的进程是如何使用地址空间的:

Linux中,虽然每个进程有4G地址空间,但是其中只有3G是属于它自己的,也就是所谓的用户空间,剩下的1G则是所有进程共享的,也就是内核空间,这1G的内核空间里保存了重要的内核数据比如用于分页查询的页表,还有之前提到的进程描述符等,这些内容在系统运行过程中将一直保存在内存当中,且对于运行在用户模式下的进程是不可见的,只有当进程切换到内核模式后,才能够对内核空间的资源进行访问(以及进行系统调用的权限),又因为内核空间是所有进程共享的,所以利用内核空间进行进程间通信就是一件理所当然的事情了,所有IPC对象如消息队列,共享内存和信号量都存在于内核空间中。

而用户空间又根据逻辑功能分成了3个段:TextData 和 Stack,如下图所示。

其中,Text段的内容是只读的且整个段的大小不会改变,它保存了程序的执行指令,来源于可执行文件,我们知道程序经过编译之后会得到一个可执行文件,这个可执行文件里就保存了程序执行的机器指令,在运行时,就将这些指令拷贝到Text段里然后CPU从这里读取指令并执行。

data段顾名思义是保存了程序中的数据,包括各种类型的变量,数组,字符串等,它包括两个部分,一个是有初始化的数据区,保存了程序中有初始值的数据,一个是无初始化的数据区(通常叫做BSS),保存了程序中没有初始值的数据,且BSS区的数据在程序加载时会自动初始化为0。注意这里的数据不包括函数内的局部变量,因为那是在stack段中的。举个例子,熟悉C/C++的人知道如果我们程序中的全局变量没有设置初始值的话,会自动初始化为0,而局部变量没有设置初始值的话,则他们的值是不确定的,其原因就在这里,当全局量不设初始值时,会保存在BSS区里,这里自动为0,若有初始值,则在有初始化的区,而局部变量在Stack段则是没有初始化。跟Text段不同,data段里的数据可以被修改,而且data段的大小也可能在程序运行过程中改变,比如说当调用malloc时,data段的地址会往上扩展,而这些动态分配的内存就称为堆。

stack段位于用户空间的最顶部,可以向下增长,它被用来存放进行函数调用的栈。当程序执行时,main函数的栈最先创建,伴随着传进来的环境变量和执行参数,并压入系统栈中(指stack段),当在main函数中调用另一个函数A时,系统会先在main的栈中压入函数A的参数和返回地址,并为A创建一个新的栈并压入系统栈中,而当A返回时,则A的栈被弹出,这样就使得当前执行的函数总是在系统栈的顶部(这里的顶部在上图中是在下方,因为stack段是往下增长的),这就是函数调用的一个粗略过程。

2、地址空间的应用

前面已经提到了地址空间的概念,进程只管使用地址空间里的地址去读写数据,而不管实际的数据是放在什么地方,接下来我们就看看系统利用这点可以干些什么。

1)共享text段:我们已经知道了text段是存放程序运行的机器指令的,那么当多个进程运行同一个程序的时候,它们的text段肯定也是一样的,在这个时候,为了节省物理内存,系统是不会把每个进程的text段内容都放到物理内存的,而是只保存了一份,然后让各个进程的地址空间的text都映射到这一区域,这样做对于每个进程的运行不会有任何影响,同时又节省了宝贵的物理内存。实际上系统还保证了同一份指令在内存中只会存在一份,一个实际例子就是DLL的使用。

2)内存映射文件:因为进程使用地址空间的地址读写数据时不用管实际的数据在哪,那就意味着这些数据甚至可以不在内存中,内存映射文件就是利用了这一点,通过保留进程地址空间的一个区域,并将这块区域映射到磁盘上的一个文件,进程就可以像操作内存一样来访问这个文件(即像访问数组一样可以使用指针,偏移量等),而不用使用到文件的IO操作,当然这其中肯定需要操作系统提供相应的机制来去实现逻辑地址到实际文件存放位置的转换,但这就不是我们所关心的了。使用内存映射文件还有一个好处就是,多个进程可以同时映射到同一个文件,又因为此时的这一份文件在进程看来就是内存,也就是说可以将其视为一块共享内存,这以为着每个进程对这块区域的修改对于其他进程都是实时可见的当然这里的效率会比将数据实际放在内存时要低,但是却带来了另一个好处就是磁盘空间相对于内存来讲是无限的,因此可以实现大数据量的数据共享。

  好了,到这里我们对内存就有了一个比较细致的理解,其中地址空间是一个值得细细体味的概念,下一篇文章我们再看看系统是通过怎样的机制来使得我们的程序可以如此方便地访问内存的。

 
 
分类: 操作系统
标签: 操作系统Linux 
原文地址:https://www.cnblogs.com/Leo_wl/p/3077216.html