hello world漫游

来源:互联网 发布:浙江大学知乎 编辑:程序博客网 时间:2024/05/17 23:40

Hello world漫游

在进入我们今天的主题之前,我想回顾一下冯诺依曼体系结构以及存储程序思想。太经典了!
1计算机是由运算器,控制器,存储器,输入输出设备五部分组成
2采用存储程序的方式,要执行的程序和数据先放到存储器中
3采用二进制编码数据
4程序是指令的集合,指令在存储器中按执行顺序存放

回到我们今天的hello world漫游,下面是我们要重点讨论的部分,如果你确实看不下去,那么你也可以跳跃式阅读。
1操作系统的启动
2信息的表示
3程序的编写
4 I/O设备的控制
5程序性能优化
6程序的编译与链接
7存储器层次结构以及虚拟存储技术
8进程与异常控制流
9指令系统
10指令在硬件上执行
1:操作系统的启动
首先在我们漫游之前,让我们开启我们的计算机,启动操作系统,为hello world提供生存环境。操作系统的启动过程主要是下面几步,至于为什么我要介绍操作系统的启动,接着往下看就明白了。
这里写图片描述

网上有很多详细的介绍,具体可以参考下面链接,我不再详述。
http://blog.chinaunix.net/uid-23069658-id-3142047.html

2信息的表示
Hello world 的生命周期可以说是从源程序的产生开始,说到源程序我们必须先对源程序有一个基本概念—信息。信息就是位+上下文(深入理解计算机系统),在计算机系统中信息是用位和上下文来表示的。以文件形式存在于磁盘上,包括.txt .cpp .asm .obj…。其中文件主要包括文本文件和二进制文件,可以这么理解,和计算机打交道的是二进制文件,和我们打交道的是文本文件。二进制文件就是由一串二进制位0和1来表示的。对于这样的0和1我们是不怎么喜欢看的了。为了让我们更简单明了的看到计算机中的信息,计算机大牛们就想出了一个很巧妙的办法来实现人机交互的问题。那就是编码,对一串二进制位0和1进行编码,常见的有ascii,unicode,gb2312…。也就是通过一串0和1来表示成我们能看的懂的信息。例如ascii中,用二进制的65D 0010 0001B表示大写字母A,unicode中用\u6b22\u8fce表示中文 ”欢迎”。

编码是很神奇的一步,也是实现人机交互很重要的一步,下面有兴趣的同学可以继续深入了解其他信息的表示方式和处理,如整数,浮点数,运算。说到数的表示,在计算机中同样也可以理解为是一个编码的过程。就如我们常说的,原码,反码,补码一样。想想我们平时使用的十进制原来也是一个编码的过程啊,只是我们没有过多想过这个问题,只是欣然的接收了而已,哈哈!

下面就简单介绍下计算机中整数,浮点数,以及他们的运算问题。常见的数进制有二进制,八进制,十进制,十六进制,当然他们之间的转换我就不作介绍了。我们生活中通常使用十进制,而计算机中使用二进制,但是考虑到十进制和二进制之间转换的不易性。我们程序员在计算机表示中常用十六进制,如地址的表示我们不会用一长串0和1来表示,也不会用十进制表示,我们通常会用0XFFFF来表示。下面是C语言中常见的数据类型以及他的表示范围。当然,不同计算机系统,对数据类型所占的字节数有所不同,但总是二进制表示的形式。
这里写图片描述
说到这里,对于一个称职的程序员,我想有必要了解的就是,数据类型之间的转换问题,
关于这个问题,我想最根本的解释就是回到他们的二进制表示形式以及定义上来理解。下面就介绍强制类型转化和截断数字的准则。
1对于位不同的类型之间的转换,就是对数据的位进行扩充或者截断,当然扩充的方式需要根据数据的定义,有的是前补0,有的是前补符号位,截断就是保留数据的低有效位。

2对于位相同的类型之间的转换,就是根据数据类型的定义来重新解释该数据(无符号数)。

数据的机器级表示中有大端对其和小端对其,这里需要注意的就是,小端法(高有效位放在地地址)大端法(高有效位放在地址),下面是1234H在不同机器下的表示。
这里写图片描述

s为符号位,表示正负
f为尾数,f是二进制小数,表示为0.的形式
e为阶码,e采用无符号数表示
IEEE浮点数表示有几种情况:规格化,非规格化,特殊值
1规格化:e不全为0,也不全为1
S=s,M=1+f,E=e-Bias(单精度Bias为127,双精度为1023)
那么 就等于
2非规格化:e全为0
S=s,M=f,E=1-Bias,换种理解,M=1+f,E=e-Bias也是一样,问题的关键是保证数的连续性
那么 就等于
3特殊值:e全为1,表示值为无穷大,NAN

关于数的运算(算术运算,逻辑运算,关系运算,位运算),当然和我们生活中数的运算一样,只是在计算机中同样是采用二进制数运算,作为程序员,我们更关心的是如何安全,高效的进行运算。
1:有符号数和无符号数进行运算时,隐式转换为无符号数对待
浮点数与整数进行运算时,隐式转换为浮点数对待
2:乘法和除法运算,为了简化运算时间,常转换为位运算来代替
3:有符号数采用的是算术右移和无符号数采用的是逻辑右移

1有符号数和无符号数比较求值
这里写图片描述
2整数乘除转位运算
如x*14表示为(x<<3)+(x<<2)+(x<<1)
3带符号数的算术右移操作

(x<0? (x+(1<<k)-1):x)>>k

好了,有了上面的编码基础,我们可以开始进行hello world源文件的编辑了。

3:hello world编辑
说到编写源程序,我想大家一定会第一时间打开一个熟悉的编辑器,然后用不到几十秒的时间写出hello world代码,接着编译运行。然而很可惜的就是现在确实有那么些“程序员”只是在做这一部分工作,真是名副其实的“码农”。今天,我想简单从硬件角度介绍编辑源文件的问题,这也就是我为什么已开启要介绍操作系统启动的原因,因为只有操作系统启动后,我们才能更好的通过操作系统来管理我们的硬件资源以及我们的应用程序。当我们打开一个编辑器时,实际上是在执行一个应用程序,操作系统会为这个程序创建进程,通过进程来管理我们的编辑文本文件时执行的操作。也就是说进程在等待I/O设备。当我敲击键盘时很快会在编辑器行显示如下源文件。但是硬件上,我们是怎么实现源文件的编辑的呢,接着往下看。
这里写图片描述

4:I/O控制方式
为了理解进程与I/O设备的数据交流,我们有必要先了解 I/O接口(适配器)。对于I/O接口,我们可以采用内存一样的对其进行编址,编址方式可以采用I/O独立编址,也可以与主存一起进行统一编址。编址之后,我们就通过I/O接口来访问控制I/O设备。我们就能像访问地址一样的来访问I/O设备,讲到后面的文件系统之后,我们也可以把I/O设备看作设备文件。对I/O设备的操作就好像是对文件操作一样。
这里写图片描述

1 程序查询式
通过程序指令不断的去查询设备是否准备输入/输出数据,这对cpu来说是极大的浪费时间。多说无用!

2中断

中断是控制I/O设备的一个很重要的方式,同样是一个非常值得借鉴的控制机制。讲到中断,我们先来了解下8088的中断系统
这里写图片描述

其中,中断的实现是通过8259A芯片硬件上来实现的,但中断的管理也需要操作系统来实现。至于中断嵌套可以自行看微机原理书。中断包括硬件中断和软中断,可屏蔽中断和不可屏蔽中断。需要注意的是中断是一种机制,而异常,更非错误。当外设向cpu发出请求时,会在8259A芯片上产生一个中断信号。cpu通过中断逻辑得到中断向量号,然后去访问中断向量表得到中断处理程序的入口地址,在系统启动后,中断向量表存放在主存的从0000H—03FEH地址(8088),共255个中断。由于中断向量地址占4个字节,而中断向量号是按序存放的,所以中断向量地址也等于4*中断向量号。
这里写图片描述

中断响应过程(同jmp指令执行过程比较类似,至于保护现场和恢复现场只是一种简单的描述,不过就是些寄存器状态的保存,当然还是挺麻烦的),中断处理过程的示意图如下:
这里写图片描述
有了中断控制方式,cpu就不用不断的去查询等待外设是否处于准备状态,而是外设通过中断来请求cpu了,但是考虑到外设每一次请求cpu就中断一次的话,cpu需要的代价很大,不仅要停下来而且要保存现场,然后去执行中断,最后恢复现场,才能继续工作。所以又有了下面的DMA控制方式。

3 DMA控制方式
DMA全称direct memory access也就是是直接在内存与I/O直接建立联系,每次DMA先缓存一定量的数据块,然后当数据块满了之后,向cpu申请占用总线,这时DMA直接读写内存,控制I/O与内存的操作,这样就大大减少中断的次数,提高了cpu的利用率。

4通道控制方式
通道控制方式同样是采用硬件来实现的,采用I/O通道控制器,相对于DMA来说,通道控制器是一个简易的处理器,能执行有限的指令集,能控制数据传送的方式,如字节多路通道,选择通道,数组多路通道。

5 思考
关于I/O设备基本概念就介绍这些,相信大家都有一定的理解。下面回想我们hello world的编辑,如果从根本出发,只要我们认为我们敲进去的不是键盘上的26个英文字母,而是敲进去一个中断信号,然后程序调用中断程序执行相应的中断程序,我们的一切行为都是在为程序服务,是程序为我们在磁盘上创建文件,编辑文件,保存文件就ok了,同样需要注意的我们的源文件是以ascii码的形式保存的。好了,编辑完hello world源文件之后,我们就可以开始编译运行了,相信很多C教材都是这么说的,但是作为程序员很有必要了解这个过程,因为在以后的工作中,我们经常会遇到类似的问题,我们常常会认为程序没有问题,但是却编译不成功,或者是编译不报错,但运行不起来,类似的问题会让我们头痛不以,下面我们就慢慢的理解编译链接的整个过程。

5:hello world的编译与链接
这里写图片描述
1 预处理,把C语言中的一些预处理语句解释出来,如#include,#define ifdef,也就是宏定义,文件包含,条件编译。执行cpp命令,会在当前目录下产生test.i文件,查看文件可以看出预处理所做的工作。我们会发现test.i文件里面引入了很多我们没有编辑过的代码。
这里写图片描述
这里写图片描述

2 编译
说到编译,就是把C语言程序编译成汇编代码。那么我们还是要有一定的汇编语言基础,至少能看懂汇编代码。当然,我们所学的汇编是inter汇编,与编译器汇编出来的汇编指令语法不同,但是还是可以相通的,如果你还是很遗憾的说,汇编我都几年没用了,差不多都忘了,那也没关系,下面一张常用汇编程序格式能给你简单的回忆(这里我省去了宏,条件编译等)。

data segment commom;不同模块下同名段连接,即在同一模块下    dataname    dw  20 dup(1,?)    array       dw  1,2,$+2,4,$+8 ;$表示当前地址计数器sp的值                org 0020h   ;使下一偏移地址从0020h开始                even    ;下一地址从偶地址开始data ends;-------------------------------------------------------code segment        main proc far        assume cs:code,ds:datastart:        push ds        sub ax,ax        push ax        mov ax,data;把data的数据段地址给ax        mov dx,ax        mov ax,dataname ;把dataname数据给ax        mov ax,offset dataname;把data的地址给ax        mov ax,es:[di]  ;默认es和di连用        mov ax,ds:table[si][bx] ;ds默认和si连用,相对基址变址寻址        call sun        call far prt extern_sun        jmp start        mov dl,1h        mov ah,display        int 21h        ret    main endp    ;--------------------------------------------------------    sun proc near        .        .        ret    sun endpcode ends    end start

看完上面的汇编程序的一般格式后,默认大家对汇编有一定的理解,至少能看明白。毕竟我们确实很少会再去写汇编代码了。然而不得不说的是汇编语言确实很强大,而且很危险,然后我只想对早期的程序员说,你们真的辛苦了,汇编代码真难调试!!!

下面我们来对比一下,汇编语言写的hello world,和C语言经过编译后的hello world有什么不一样
这里写图片描述
C语言版
这里写图片描述
汇编语言版
这里写图片描述
对比C语言汇编得到的汇编代码和直接用汇编语言写的代码,我们发现两者之间非常相似,但又有不同。其中我们注意到的是,C语言汇编后的代码有很多.file .def类的标识,这些我们可以暂时理解为是汇编器产生的一种标识,方便后面进行链接用的。还有一个就是_call _printf的调用,这个我们暂时理解为是对函数库的一个调用,在链接过程中继续讨论。可以看得出他们之间很类似,虽然目前看他们输出字符串执行的命令不同。


既然谈到C语言和汇编,那就不可避免的得说一下栈的问题。因为C语言中的控制,函数调用同汇编中的过程控制都需要运用到栈。栈的一个基本思想就是先进后出。为了更好的理解C语言中函数的递归调用,我们这里用汇编语言来描述它对栈操作过程。
这里写图片描述

C语言实现代码:

#include <stdio.h>int fu(int x){    if(x<1)        return 1;    else        return fu(x-2)+fu(x-1);}int main(){    int x;    fu(x);    return 0;}

机器反汇编代码:(每一步的执行都对应一条汇编指令,真正用汇编语言实现不用如此麻烦,因为,汇编语言也支持选择,循环,调用)

00401020    push   %ebp00401021    mov    %esp,%ebp00401023    push   %ebx00401024    sub    $0x34,%esp00401027    movl   $0x401150,(%esp)0040102E    call   0x4019b4 <SetUnhandledExceptionFilter@4>00401033    sub    $0x4,%esp00401036    call   0x401360 <__cpu_features_init>0040103B    call   0x401770 <fpreset>00401040    lea    -0x10(%ebp),%eax00401043    movl   $0x0,-0x10(%ebp)0040104A    mov    %eax,0x10(%esp)0040104E    mov    0x402000,%eax00401053    movl   $0x404004,0x4(%esp)0040105B    movl   $0x404000,(%esp)00401062    mov    %eax,0xc(%esp)00401066    lea    -0xc(%ebp),%eax00401069    mov    %eax,0x8(%esp)0040106D    call   0x40195c <__getmainargs>00401072    mov    0x404008,%eax00401077    test   %eax,%eax00401079    jne    0x4010c5 <__mingw_CRTStartup+165>0040107B    call   0x401964 <__p__fmode>00401080    mov    0x402004,%edx00401086    mov    %edx,(%eax)00401088    call   0x401520 <_pei386_runtime_relocator>0040108D    and    $0xfffffff0,%esp00401090    call   0x401750 <__main>00401095    call   0x40196c <__p__environ>0040109A    mov    (%eax),%eax0040109C    mov    %eax,0x8(%esp)004010A0    mov    0x404004,%eax004010A5    mov    %eax,0x4(%esp)004010A9    mov    0x404000,%eax004010AE    mov    %eax,(%esp)004010B1    call   0x40134d <main>004010B6    mov    %eax,%ebx004010B8    call   0x401974 <_cexit>004010BD    mov    %ebx,(%esp)004010C0    call   0x4019bc <ExitProcess@4>004010C5    mov    0x4050f4,%ebx004010CB    mov    %eax,0x402004004010D0    mov    %eax,0x4(%esp)004010D4    mov    0x10(%ebx),%eax004010D7    mov    %eax,(%esp)004010DA    call   0x40197c <_setmode>004010DF    mov    0x404008,%eax004010E4    mov    %eax,0x4(%esp)004010E8    mov    0x30(%ebx),%eax004010EB    mov    %eax,(%esp)004010EE    call   0x40197c <_setmode>004010F3    mov    0x404008,%eax004010F8    mov    %eax,0x4(%esp)004010FC    mov    0x50(%ebx),%eax004010FF    mov    %eax,(%esp)00401102    call   0x40197c <_setmode>00401107    jmp    0x40107b <__mingw_CRTStartup+91>0040110C    lea    0x0(%esi,%eiz,1),%esi

汇编语言实现代码:

```code segment    ;主程序    main proc far        assume cs:codestart:        mov ax,0    ;ax存储函数值,清零        mov bx,8    ;设置函数x        push bx     ;参数压栈        call  digui ;ip压栈           ret    main endp    ;递归子程序    digui proc near         push bp         mov bp,sp        push dx     ;保存和的后一个加数        push bx        sub sp,2        mov bx,(bp+4)        cmp bx,2        je out1        cmp bx,1        je out1        sub bx,1        mov (bp-6),bx        call digui        mov dx,ax   ;将和的第一个加数保存到dx,去计算后一个加数        dec bx      ;求f(n-2)        push bx        call digui        pop bx ;清空n        add ax,dx   ;将第二个加数加到第一个加数上,即求和        jmp out2        ;作为第二个加数的参数压栈out1:        mov ax,1out2:        add sp,2        pop bx        pop dx        pop bp        ret    digui   endpcode ends    end main

这里写图片描述
由此,我们可以看出函数的递归调用就是一个压栈的过程,先把一些需要保存的寄存器压栈,再把参数压栈,压栈顺序为至右向左,最后是是返回地址压栈,直到函数执行return返回,返回即对应一次出栈操作。同样,我们可以看出汇编语言实现的麻烦,我们必须清楚的知道栈里面的情况,想想要是程序员一不下心搞错了怎么办?哈哈,这就引出我们编程上的数组越界和缓冲区溢出问题了。当程序的返回地址CS:IP被恶意修改,那么程序就会转而去执行一段我们并不知道的程序,当这段程序是攻击性程序的话,那么我们就麻烦了。当然现在的操作系统已经对此类问题又了很强的保护。所以说汇编语言是很强大,但也很危险的!

3 汇编
汇编是一个将汇编语言文件汇编成二进制文件的一个过程。这里我们再来看看汇编后的二进制文件,由于二进制文件不能直接打开,但是我们可以看到下面的东西:先汇编产生二进制文件,然后反汇编得到一个类似于.Lst的文件,其中左边是对应的二进制机器码,右边为汇编代码。
这里写图片描述
下面是汇编语言产生的.Lst文件,左边为机器码,右边为汇编代码
这里写图片描述
从上面二个文件中可以看出,C语言汇编后的二进制代码和汇编语言的代码很相似,唯一不同还是C语言的汇编代码时通过_printf输出hello world,而汇编语言是通过dos中断 int 21来实现输出hello world。_printf调用来哦什么呢,下面就让链接的解释问题吧。

4链接
想想,计算机能识别就是二进制文件,既然有了二进制文件,怎么还不能执行呢。对的,我们还不能执行,因为在此之前,我们还有很重要,很关键的事要做–链接。通常我们的程序都是分模块的,就好像我们的.c中需要.h头文件一样。我们把各个功能模块分开来,把一些大部分程序要用到的功能段都放到.h文件中,这样,其他的c文件想用的时候都可以把他include进来。然而对于可执行文件也是一样,我们也可以把一些大部分可执行文件需要用到的功能放到一个静态库,动态库中,就好像一个模块一样,即插即用。我们常用的有静态链接库.a,和动态链接库.dll。
这里写图片描述

例如,我们在编程过程中常遇到需要使用库函数的问题,我们通常是采用include把需要的库函数加载到我们的源文件中。那么编译,链接后得到的可执行文件中就包括了我们需要的库函数。但是有个问题我们得想清楚,include加载到源程序中不仅加载了我们需要的函数,还包括很多其他功能实现,尽管这样不会增加我们.c文件的大小,但是它会大大增加编译链接后得到的可执行文件的大小,这样不仅浪费了磁盘存储,更可怕的是他加载到内存中后,对内存是一种极大的浪费。考虑到上面的两个问题,链接部分完美的解决了这两个问题。
静态链接.a,它是可重定位目标文件,采用下面的命令,我们同样可以得到我们想要的可执行文件,而不需要把不需要的功能实现加载到可执行文件中,这样就解决了磁盘存储的浪费。

动态链接.dll,他是共享目标文件,他可以加载到任意的内存地址,并和一个在内存中运行的程序链接起来,也就是说多个程序在运行时可以共享这个功能实现。这样它在内存中就不需要拷贝多份而浪费内存了,如printf。

那么,就会有同学会说,那为什么我们还是要用include stdio.h,下面是我的一个尝试,把它去掉后,程序会出现警告,但还是可以正常运行,说明在我的系统路径下一定有一个包含printf的动态链接.dll文件。
这里写图片描述
但是我们别喜过了,链接是给我们带来了很大的方便,但要真的实现即插即用真的就这么简单吗,多个目标文件是怎么链接的,怎么插入的,并非是简单的插入进去就行了。而是需要对文件的重整。下面我们介绍一下我们的目标文件。

通常我们的目标文件分为三类

1 可重定位目标文件
2可执行目标文件
3共享目标文件

对于上面的目标文件,都有一个典型的格式
这里写图片描述

我只简单介绍几个节的含义

.text: 已编译的机器代码
.rodata:只读数据
.data: 已初始化的全局变量
.bss:未初始化的全局变量
.symtab: 符号表
.rel.text:链接器把这个目标文件和其他目标文件结合时,需要修改这些位置。
.rel.data:

有了上面的一些节之后,我们就能把多个目标文件链接到一个可执行文件中去。为什么我们要这么做呢,其中最重要的就是实现指令的执行顺序的安排。这里我们提下后面要讲的一个概念,就是虚拟地址。简单的理解虚拟地址对应的是我们的磁盘地址。有了这个虚拟地址之后,我们就能合理的,有序的安排指令执行顺序。为了看看链接后的虚拟地址,我们采用反汇编来看看链接之后得到的文件和前面的汇编文件有什么不一样。

反汇编链接后的可执行文件得到的
这里写图片描述
直接反汇编二进制文件得到的
这里写图片描述
对比上面的,我们能够看到的就是他们的指令和机器代码完全一样,但是最左边的地址不一样,这也就是链接过程所做的事了,当然,链接的代码我只截取了一部分。

好了,终于讲完了程序是怎么变成可执行文件的了,看到这里,相信你对源程序的编辑,编译,链接已经有了自己的理解,一定也很累了。但是我们的漫游还没结束,我们还需要深入计算机硬件,了解程序是怎么运行的。不过在此之前,我们先了解下程序运行的性能问题,相信对自己严格要求的程序员一定会买一本代码规范化编程的书籍好好的研究程序优化问题,但这里我只是简单说下,缓解一下疲劳!

6:程序性能优化

下面来看一看一个简单的数组求和问题(下面是伪代码)
这里写图片描述
下面是一个对结构体数据求和的过程
这里写图片描述
消除循环的低效率
这里写图片描述
减少过程调用
这里写图片描述
消除不必要的存储器的引用如函数传递的参数是放在存储器的栈中的,而局部变量可以放在寄存器中,访问寄存器的速度明显优于存储器
循环展开
这里写图片描述
提高并行性等…下面我们进入计算机硬件,了解计算机体系结构。我们先看一张图,大致了解一下计算机有哪些硬件组成。
这里写图片描述
7:存储器层器结构以及虚拟存储技术

同样在我们运行可执行文件之前,我们必须要清楚一个概念,因为前面我们说到过指令的执行顺序是很关键的,而我们使用的是虚拟地址,而不是真正的物理地址,执行前必须虚拟地址转换为物理地址之,那么或许这时你会想为什么我们不直接使用物理地址呢?我们不妨来看下那就是虚拟存储器。下面就来介绍一下我们的存储器层次结构以及其中最重要虚拟存储器。
这里写图片描述

关于各种类型的存储详细介绍,可以百度!
下面介绍两种DRAM的扩展方式,位扩展和字扩展。
这里写图片描述
有了上面的这些存储器之后,下面继续介绍我们的存储器层次结构
这里写图片描述
为什存储器采用如此多的层次结构,为什么采用缓存和虚拟存储技术,最根本的问题就是解决cpu与存储器之间速度不匹配的问题,当然还有扩展存储器大小的用途。也就是说,每一次cpu根据地址去访问存储器时,都是先访问更快但又容量极小的存储器。如果不存在,则继续往下找。那么关键的问题就是高速缓存中能否找到cpu想要的指令或者数据,而我们的操作系统会尽最大努力的提高命中率。但是很可惜的是,我们不能未卜先知,预测cpu下一个需要访问的地址。我们能做的就是先把最大可能访问的东西放到高速缓存中,然后提高cpu随机访问命中的概率。但是幸运的是我们的程序具有时间和空间上的局部性,这就大大提高了我们cpu访问缓存命中的概率。为了提高命中率,合理利用高速缓存空间就显的十分重要了。硬件我们采用了各种存储器映射方式,软件上采用了各种的页面置换算法。

存储器映射方式
1全相连映射(不赘述)
这里写图片描述
2直接映射
这里写图片描述
3组相连映射
这里写图片描述
页面置换算法(这里不过多介绍)
1先进先出
2最近最久未使用
3clock置换

如果说缓存是缓存主存到高速缓存的话,那么虚拟存储器就是缓存磁盘到主存,前者主要加快了访存的速度,那么后者就增大了主存的容量(虚拟内存)。这就是虚拟存储技术,也就是为什么我们能运行比我们内存大的程序的原因,也是我们使用虚拟地址(逻辑地址)的原因。而且程序在很长一段时间使用的都是虚拟地址,只有真正执行的时候才会将虚拟地址转变为实际的物理地址,简单说吧,虚拟地址就是映射到磁盘的地址,物理地址是映射到内存的地址。那么让我们的可执行文件运行起来,还有很重要的一步就是虚拟地址到物理地址的转换。
下面就介绍虚拟存储器,解决虚拟地址到物理地址的映射问题,当然高速缓存的地址转换也是类似。虚拟地址翻译,在进行虚拟地址翻译过程中需要用到一个页表,该页表是存放在内存中的(物理存储器),为了图示,我把他单独列出物理内存。
这里写图片描述
这里写图片描述

从上面可以看出,页表就是一个定位指令或数据地址的作用,有效位表示是否在物理存储器中,为0表示不在,为null表示该页表项未被使用,为1表示能在物理存储器上找到。

考虑到虚拟存储器很大的时候,假如64位,那么如果我们采用上面的映射方式一个页表项对应磁盘上4K大小。那么我们的页表就需 s
可见这是不可能的。所以引出了多级页表的方法来解决我们不断扩大的虚拟存储器问题。
这里写图片描述

也就是说一级页表负责映射虚拟存储器上的4M地址空间,二级页表继续划分这4M空间,负责映射4K地址空间。这样大大减少来哦页表的地址空间,但是不得不说的是,增加了地址映射的复杂性。但这对高速的cpu来说不是问题,问题是需要多访问一次物理内存。好了,讲完地址映射之后,我们终于可以把我们的程序装载到内存上运行了。

8:进程与异常控制流

程序需要运行起来,没有操作系统是不行,因为操作系统管理着我们的硬件资源。在计算机系统下,提高系统的吞吐量是操作系统的一大职责,早期的操作系统没有过多的要求,只需要管理计算机硬件资源,保证有效性(提高资源利用率和系统吞吐量)即可,当然操作系统的目标有:有效性,方便性,可扩充性,开放性。所以早期的操作系统有单道批处理系统,多道批处理系统,分时系统,实时系统。无论如何,操作系统的目标就是上面几个。由于程序不具备并发性,不能有效的利用计算机硬件资源,所以操作系统提出了进程和线程的概念。进程是一个抽象的概念,进程是程序运行的一个实例,也就是说,操作系统为了提高资源利用率,通过进程来抽象的执行程序。也就是说,我们的程序需要以进程的形式才能运行起来!
这里简单介绍一下进程,每一个进程都有唯一一个进程标识号PID。操作系统在初始化的时候,有一个pid为0号的进程,它是所有进程的父进程。
操作系统会在下面几种情况下创建进程。

1用户登入:合法用户登入后,操作系统为该用户终端创建一个进程。
2作业调度:系统用来完成进程调度的一种机制
3提供服务:系统提供的一套为用户进程的服务
4应用请求:应用进程申请创建一个进程
前面3个都是系统创建新的进程(内核态),第4个是用户创建进程(用户态)

下面我们开始运行我们的hello world程序。
这里写图片描述
windows下我们执行可执行文件test.exe,当然linux下是./test,但这不是问题的关键。问题的关键是运行hello world时操作系统做了些什么?这直接关系到我们继续了解程序运行的实质。当我们在命令窗口执行test时,因为test不是内置的外壳命令,所以他会认为test是一个可执行文件,当外壳运行一个程序时,父外壳进程会调用fork()函数生成一个子进程,内核为新的子进程创建各种数据结构,并分配唯一的PID,为了给子进程创建虚拟存储器(就是虚拟地址到物理地址的映射表)它创建与当前进程一样的mm_struct,区域结构, 页表的原样拷贝。它是父进程的一个复制品。子进程通过调用execve系统调用启动加载器。加载器会删除子进程现有的虚拟存储器,并创建一组新的代码,数据,堆,和栈。通过虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件的内容,最后加载器跳到_strat地址,最终调用main函数。在加载过程中不会把程序的数据拷贝到存储器,直到cpu引用一个虚拟页面才进行拷贝,此时利用的就是操作系统的页面调度机制。关于fork和execve函数就不做过多介绍,有兴趣同学可以自己查阅资料,看看这两个函数的实现和作用。当然,进程运行的过程并非总是一帆风顺的,操作系统也不会让一个进程一直占用cpu直到进程结束。进程运行过程总会发生一些异常,注意异常不是错误,可以理解为是一种机制。

异常的类别有下面几种
1中断
2陷阱和系统调用
3故障
4终止
异常的机制和中断类似,也就是进程执行过程中,有其他更重要的事要做,cpu转而做其他事情,实现机制也类似,中断有中断向量表和中断处理程序,异常有异常表和异常处理程序,寻址方式和中断也类似。所以进程的执行过程也可以用下面示意图表示。
这里写图片描述

操作系统终于组织好我们的程序了,并以进程的方式开始运行了。离我们真正的在硬件上分析指令的运行就差最后一步了。

9指令系统

别忘了,我们的计算机可不认识什么程序,进程之类,计算机硬件只能识别0和1,不过幸运的是我们的前面通过lst文件也看到汇编后的二进制文件,左边为机器码,右边为机器指令。所以这又是一个编码的过程,也就是用一串0和1来编码机器指令,我们只要知道指令系统的编码规则就好,至于指令功能上的实现,那是硬件上的问题了。为了让大家能记起我们的汇编代码和机器码,我再把之前的lst文件贴上,也方便介绍后面的指令系统和硬件执行指令的过程。
这里写图片描述
下面是深入理解计算机系统书中定义的一套Y86指令集,就是用一串0和1组成的二进制数代表一条指令。其实如果我们是搞硬件的话,我们也可以自己设计电路,定义一套简单的指令系统集。
这里写图片描述
当然指令系统集的定义也必须合理安排,否则对硬件是一种极大的浪费和损害上面定的Y86指令集是由1-6个字节进行编码的,也就是说不是固定长度编码。不定长编码将会导致执行不同的指令使用的总线数量不一致。下面是我们熟知的CISC和RISC指令系统集
这里写图片描述

10指令在硬件上执行

有了指令系统之后,我们就有了和硬件打交道的语言了。但是我们必须在硬件上实现指令系统的功能。要实现数字系统在硬件上需要有三个主要部分支持:1计算对位进行操作的函数的组合逻辑,2存储位的存储器单元,3以及控制存储器元素更新的时钟信号。幸运的是我们都有。
1逻辑门
这里写图片描述

下面是TTL电路实现上面逻辑门的硬件原理图,具体怎么实现,我也看不明白了!
这里写图片描述
2存储器:如我们的虚拟存储器系统,寄存器(通用,专用,段)
3时钟信号:存储单个位或字节,时钟控制寄存器加载输入信号
如我们常用的运算操作指令,传送指令。
这里写图片描述

计算机硬件翻译指令过程
这里写图片描述
看完上面的图,我们再回过头来看看冯诺依曼体系结构以及存储程序思想。
1计算机是由运算器,控制器,存储器,输入输出设备五部分组成
2采用存储程序的方式,要执行的程序和数据先放到存储器中
3采用二进制编码数据
4程序是指令的集合,指令在存储器中按执行顺序存放

好了,这一次hello world漫游就到这里,如果你能看到这里,并能理解我今天介绍的这些东西,那么相信你一定对计算机系统有一个系统的理解,一定会觉得阅读的几个小时有收获!

参考资料
1深入理解计算机系统
2汇编语言程序设计
3C/C++程序设计语言
4计算机组织与结构
5计算机操作系统
616/32位微机原理

注:文章中图部分来源于百度

0 0