计算机原理-程序编译及运行(五)

前面一篇我们已经知道CPU所需的指令和数据都在内存中的基本工作原理,现在再介绍下程序运行在内存的分布情况。

我们知道对于计算机系统来说,最底层的是硬件,硬件之上是操作系统,而我们的程序都是基于操作系统来运行的,而不是基于硬件,这样操作系统为我们提供了一层抽象,所以对于程序员来说,不需要特别的关注计算机硬件。所以正常来说介绍完硬件后,应该来介绍操作系统。但是我们知道在计算机在不断的发展,硬件,操作系统都在不断发展。而无论怎么发展,程序运行都离不开内存,所以我决定以内存和出发点,来看硬件,操作系统以及应用程序的发展过程。

一.操作系统历史

1.手工系统:独占处理,程序运行独占所有CPU,内存资源。

2.单道批处理系统:程序独占资源,自动化操作

3.多道批处理操作系统:程序独占CPU,共享内存(同时加载到内存)

4.分时操作系统:CPU时间分片,通过调度按权限让程序轮流执行。

5.现在操作系统:多CPU,多进程,多线程,虚拟内存等等,进化史基本就是为了更好的执行效率,更好的用户体验,基本都是为了克服硬件的IO问题。

二.程序编译和链接

一里面我们知道了操作系统如何调度让程序在机器上更加高效的运行起来,现在我们来看看程序是如何从一行行代码变成可运行的可执行程序的。

编译过程:

(源代码)–预处理–编译—汇编—链接–(可执行文件)。在Linux下一般使用GCC来编译C语言程序, 而VS中使用cl.exe。下图就是上面的代码在GCC中编译的过程。我们后面讨论的都以C语言为例。编译器

 

2.1.1 预处理

预处理是程序编译的第一步,以C语言为例, 预编译会把源文件预编译成一个 .I 文件。而C++则是编译成 .ii。 GCC中预编译命令如下

view plain copy

print?在CODE上查看代码片派生到我的代码片

  1. #gcc -E hello.c -o hello. I 

当我们打开hello.i 文件是会发现这个文件变的好大,因为其中包含的<stdio.h> 文件被插入到了hello.i 文件中,一下是截取的一部分内容

view plain copy

print?在CODE上查看代码片派生到我的代码片

  1. # 1 “hello.c”
  2. # 1 “<built-in>”
  3. # 1 “<命令行>”
  4. # 1 “hello.c”
  5. # 1 “/usr/include/stdio.h” 1 3 4
  6. # 28 “/usr/include/stdio.h” 3 4
  7. # 1 “/usr/include/features.h” 1 3 4
  8. # 324 “/usr/include/features.h” 3 4
  9. # 1 “/usr/include/i386-linux-gnu/bits/predefs.h” 1 3 4
  10. # 325 “/usr/include/features.h” 2 3 4
  11. # 357 “/usr/include/features.h” 3 4
  12. # 1 “/usr/include/i386-linux-gnu/sys/cdefs.h” 1 3 4
  13. # 378 “/usr/include/i386-linux-gnu/sys/cdefs.h” 3 4
  14. # 1 “/usr/include/i386-linux-gnu/bits/wordsize.h” 1 3 4
  15. # 379 “/usr/include/i386-linux-gnu/sys/cdefs.h” 2 3 4
  16. # 358 “/usr/include/features.h” 2 3 4
  17. # 389 “/usr/include/features.h” 3 4
  18. # 1 “/usr/include/i386-linux-gnu/gnu/stubs.h” 1 3 4# 940 “/usr/include/stdio.h” 3 4
  19. # 2 “hello.c” 2
  20. int main()  
  21. {  
  22.     printf(“Hello, world.\n”);  
  23. return 0;  

总结下来预处理有一下作用:

  • 所有的#define删除,并且展开所有的宏定义
  • 处理所有的条件预编译指令,比如我们经常使用#if #ifdef #elif #else #endif等来控制程序
  • 处理#include 预编译指令,将被包含的文件插入到该预编译指令的位置。这也就是为什么我们要防止头文件被多次包含。
  • 删除所有注释 “//”和”/* */”.
  • 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。比如上面的 # 2 “hello.c” 2
  • 保留所有的#pragma编译器指令,因为编译器需要使用它们。
2.1.2 编译

编译是一个比较复杂的过程。编译后产生的是汇编文件,其中经过了词法分析、语法分析、语义分析、中间代码生成、目标代码生成、目标代码优化等六个步骤。如要要详细深入研究,就要去看下《编译原理》。当我们语法有错误、变量没有定义等问题是,就会出现编译错误。

view plain copy

print?在CODE上查看代码片派生到我的代码片

  1. #gcc -S hello.i -o hello.s 

通过上面的命令,可以从预编译文件生成汇编文件,当然也可以之际从源文件编译成汇编文件。实际上是通过一个叫做ccl的编译程序来完成的。

view plain copy

print?在CODE上查看代码片派生到我的代码片

  1.     .file   “hello.c”  
  2.     .section    .rodata  
  3. .LC0:  
  4.     .string “Hello, world.”  
  5.     .text  
  6.     .globl  main  
  7.     .type   main, @function  
  8. main:  
  9. .LFB0:  
  10.     .cfi_startproc  
  11.     pushl   %ebp  
  12.     .cfi_def_cfa_offset 8  
  13.     .cfi_offset 5, -8  
  14.     movl    %esp, %ebp  
  15.     .cfi_def_cfa_register 5  
  16.     andl    $-16, %esp  
  17.     subl    $16, %esp  
  18.     movl    $.LC0, (%esp)  
  19.     call    puts  
  20.     movl    $0, %eax  
  21.     leave  
  22.     .cfi_restore 5  
  23.     .cfi_def_cfa 4, 4  
  24.     ret  
  25.     .cfi_endproc  
  26. .LFE0:  
  27.     .size   main, .-main  
  28.     .ident  “GCC: (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3″  
  29.     .section    .note.GNU-stack,””,@progbits 

上面就是生成的汇编文件。我们看出其中分了好几个部分。我们只需要关注,LFB0这个段中保存的就是C语言的代码对于的汇编代码.。

2.1.3 汇编

汇编的过程比较简单,就是把汇编代码转换为机器可执行的机器码,每一个汇编语句机会都对应一条机器指令。它只需要根据汇编指令和机器指令的对照表进行翻译就可以了。汇编实际是通过汇编器as来完成的,gcc只不过是这些命令的包装。

view plain copy

print?在CODE上查看代码片派生到我的代码片

  1. #gcc -c hello.s -o hello.o  
  2. //或者  
  3. #as hello.s -o hello.o 

汇编之后生成的文件是二进制文件,所以用文本打开是无法查看准确的内容的,用二进制文件查看器打开里面也全是二进制,我们可以用objdump工具来查看:

view plain copy

print?在CODE上查看代码片派生到我的代码片

  1. cc@cheng_chao-nb-vm:~$ objdump -S hello.o  
  2. hello.o:     file format elf32-i386  
  3. Disassembly of section .text:  
  4. 00000000 <main>:  
  5.    0:   55                      push   %ebp  
  6.    1:   89 e5                   mov    %esp,%ebp  
  7.    3:   83 e4 f0                and    $0xfffffff0,%esp  
  8.    6:   83 ec 10                sub    $0x10,%esp  
  9.    9:   c7 04 24 00 00 00 00    movl   $0x0,(%esp)  
  10.   10:   e8 fc ff ff ff          call   11 <main+0x11>  
  11.   15:   b8 00 00 00 00          mov    $0x0,%eax  
  12.   1a:   c9                      leave    
  13.   1b:   c3                      ret     

上面我们看到了Main函数汇编代码和机器码对应的关系。关于objdump工具后面会介绍。这里生成的.o文件我们一般称为目标文件,此时它已经和目标机器相关了。

2.1.4 链接

链接是一个比较复杂的过程,其实链接的存在是因为库文件的存在。我们知道为了代码的复用,我们可以把一些常用的代码放到一个库文件中提供给其他人使用。而我们在使用C,C++等高级语言编程时,这些高级语言也提供了一些列这样的功能库,比如我们这里调用的printf 函数就是C标准库提供的。 为了让我们程序正常运行,我们就需要把我们的程序和库文件链接起来,这样在运行时就知道printf函数到底要执行什么样的机器码。

view plain copy

print?在CODE上查看代码片派生到我的代码片

  1. #ld -static crt1.o crti.o crtbeginT.o hello.o -start-group -lgcc -lgcc_eh -lc-end-group crtend.o crtn.o 

我们看到我们使用了链接器ld程序来操作,但是为了得到最终的a.out可执行文件(默认生成a.out),我们加入了很多目标文件,而这些就是一个printf正常运行所需要依赖的库文件。

2.1.5 托管代码的编译过程

对于C#和Java这种运行在虚拟机上的语言,编译过程有所不同。 对于C,C++的程序,生成的可执行文件,可以在兼容的计算机上直接运行。但是C#和JAVA这些语言则不同。他们编译过程是相似的,但是他们最终生成的并不是机器码,而是中间代码,对于C#而言叫IL代码,对于JAVA是字节码。所以C#,JAVA编译出来的文件并不能被执行。

我们在使用.NET或JAVA时都需要安装.NET CLR或者JAVA虚拟机,以.NET为例,CLR实际是一个COM组件,当你点击一个.NET的EXE文件时,它和C++等不一样,不能直接被执行,而是有一个垫片程序来启动一个进程,并且初始化CLR组件。当CLR运行后,一个叫做JIT的编译器会吧EXE中的IL代码编译成对应平台的机器码,然后如同其他C++程序一样被执行。

计算机原理-内存(四)

前面讲了些CPU及CPU与其他部件之间的交互等,我们也知道,对于计算机来说,CPU跟内存是衡量性能的很重要的指标,同时也跟编程性能有莫大关系,所以现在就专门来看看内存里的一些道道~

内存组成

不多说,先上图,好对内存有个直观的印象

图中内存大小为128M,其他是它是由多个内存颗粒组成,而并非单个颗粒就是128M,内存颗粒学名 DRAM芯片。一般的内存都是由多个DRAM芯片组成,进行字长及容量的扩展。比如128M,32位内存,可能是有 8个 16M*4位的单个DRAM芯片组成!这样做应该是工艺成本的制约。篇幅所限,DRAM芯片张啥样及如何进行字长及容量的扩展在其他篇幅中再介绍!

内存地址

字长

计算机在同一时间内处理的一组二进制数称为一个计算机的“字”,而这组二进制数的位数就是“字长”。通常称处理字长为8位数据的CPU叫8位CPU,32位CPU就是在同一时间内处理字长为32位的二进制数据。 所以这里的字并不是我们理解的双字节(Word)而是和硬件相关的一个概念。一般来说计算机的数据线的位数和字长是相同的。这样从内存获取数据后,只需要一次就能把数据全部传送给CPU。

地址总线

前面我们已经介绍过地址总线的功能。地址总线的数量决定了他最大的寻址范围。就目前来说一般地址总线先字长相同。比如32位计算机拥有32为数据线和32为地线,最大寻址范围是4G(0x00000000 ~ 0xFFFFFFFF)。当然也有例外,Intel的8086是16为字长的CPU,采用了16位数据线和20位数据线。

内存编址

内存的大小和它芯片扩展方式有关。比如我们内存模块是采用 16M*8bit的内存颗粒,那么我们使用4个颗粒进行位扩展,成为16M*32bit,使用4个颗粒进行字容量扩展变为64M*32bit。那么我们内存模块使用了8个内存颗粒,实际大小是128MB。

我们需要对这个128M的内存进行编址以便CPU能够使用它,通常我们多种编址方式:

  1. 按字编址:    对于这个128M内存来说,它的寻址范围是32M,而每个内存地址可以存储32bit数据。
  2. 按半字编址:对于这个128M内存来说,它的寻址范围是64M,而每个内存地址可以存储16bit数据。
  3. 按字节编址:对于这个128M内存来说,它的寻址范围是128M,而每个内存地址可以存储8bit数据。

目前的计算机,主要都是采用按字节编址的方式这也是为什么对于32位计算机来说,能使用的最多容量的内存为4GB如果我们按字编地址,能使用的最大内存容量就是16GB了。

内存数据存放

前面我们知道了,内存是按字节编址,每个地址的存储单元可以存放8bit的数据。我们也知道CPU通过内存地址获取一条指令和数据,而他们存在存储单元中。现在就有一个问题。我们的数据和指令不可能刚好是8bit,如果小于8位,没什么问题,顶多是浪费几位(或许按字节编址是为了节省内存空间考虑)。但是当数据或指令的长度大于8bit呢?因为这种情况是很容易出现的,比如一个16bit的Int数据在内存是如何存储的呢?

其实一个简单的办法就是使用多个存储单元来存放数据或指令。比如Int16使用2个内存单元,而Int32使用4个内存单元。当读取数据时,一次读取多个内存单元。于是这里又出现2个问题:

  1. 多个存储单元存储的顺序?
  2. 如何确定要读几个内存单元?
  3. 如何快速寻址?
大端和小端存储
  1. Little-Endian 就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
  2. Big-Endian 就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。

需要说明的是,计算机采用大端还是小端存储是CPU来决定的, 我们常用的X86体系的CPU采用小端,一下ARM体系的CPU也是用小端,但有一些CPU却采用大端比如PowerPC、Sun。

存储顺序解决了,那么再来看看如何确定大小,而这也是cpu决定的所以我们先来看看cpu指令。

cpu指令

首先我们来看看CPU指令的格式,我们知道CPU质量主要就是告诉CPU做什么事情,所以一条CPU指令一般包含操作码(OP)和操作

操作码字段
   地址码字段

根据一条指令中有几个操作数地址,可将该指令称为几操作数指令或几地址指令。

 操作码
 A1
 A2
 A3

三地址指令: (A1) OP (A2) –> A3

 操作码
 A1
 A2

二地址指令: (A1) OP (A2) –> A1

 操作码
  A1

一地址指令: (AC) OP (A) –> AC   

 操作码

零地址指令

A1为被操作数地址,也称源操作数地址; A2为操作数地址,也称终点操作数地址; A3为存放结果的地址。 同样,A1,A2,A3以是内存中的单元地址,也可以是运算器中通用寄存器的地址。所以就有一个寻址的问题。关于指令寻址后面会介绍。

CPU指令设计是十分复杂的,因为在计算机中都是0和1保存,那计算机如何区分一条指令中的操作数和操作码呢?如何保证指令不会重复呢?这个不是我们讨论的重点,有兴趣的可以看看计算机体系结构的书,里面都会有介绍。从上图来看我们知道CPU的指令长度是变长的。所以CPU并不能确定一条指令需要占用几个内存单元,那么CPU又是如何确定一条指令是否读取完了呢?

3.1.2.2 指令的获取

现在的CPU多数采用可变长指令系统。关键是指令的第一字节。 当CPU读指令时,并不是一下把整个指令读近来,而是先读入指令的第一个字节。指令译码器分析这个字节,就知道这是几字节指令。接着顺序读入后面的字节。每读一个字节,程序计数器PC加一。整个指令读入后,PC就指向下一指令(等于为读下一指令做好了准备)。

Sample1:

view plaincopyprint?在CODE上查看代码片派生到我的代码片

  1. MOV AL,00  机器码是1011 0000 0000 0000 

机器码是16位在内存中占用2个字节:

【00000000】 <- 0x0002

【10110000】 <- 0x0001

比如上面这条MOV汇编指令,把立即数00存入AL寄存器。而CPU获取指令过程如下:

  1. 从程序计数器获取当前指令的地址0x0001。
  2. 存储控制器从0x0001中读出整个字节,发送给CPU。PC+1 = 0X0002.
  3. CPU识别出【10110000】表示:操作是MOV AL,并且A2是一个立即数长度为一个字节,所以整个指令的字长为2字节。
  4. CPU从地址0x0002取出指令的最后一个字节
  5. CPU将立即数00存入AL寄存器。

这里的疑问应该是在第3步,CPU是怎么知道是MOV AL 立即数的操作呢?我们在看下面一个列子。

Sample2:

view plaincopyprint?在CODE上查看代码片派生到我的代码片

  1. MOV AL,[0000] 机器码是1010 0000 0000 0000 0000 0000 

这里同样是一条MOV的汇编指令,整个指令需要占用3个字节。

【00000000】 <-0x0003

【00000000】 <- 0x0002

【10100000】 <- 0x0001

我们可以比较一下2条指令第一个字节的区别,发现这里的MOV  AL是1010 0000,而不是Sample1中的1011 000。CPU读取了第一个字节后识别出,操作是MOV AL [D16],表示是一个寄存器间接寻址,A3操作是存放的是一个16位就是地址偏移量(为什么是16位,后面文章会介绍),CPU就判定这条指令长度3个字节。于是从内存0x0002~0x0003读出指令的后2个字节,进行寻址找到真正的数据内存地址,再次通过CPU读入,并完成操作。

从上面我们可以看出一个指令会根据不同的寻址格式,有不同的机器码与之对应。而每个机器码对应的指令的长度都是在CPU设计时就规定好了。8086采用变长指令,指令长度是1-6个字节,后面可以添加8位或16位的偏移量或立即数。 下面的指令格式相比上面2个就更加复杂。

  • 第一个字节的高6位是操作码,W表示传说的数据是字(W=1)还是字节(W=0),D表示数据传输方向D=0数据从寄存器传出,D=1数据传入寄存器。
  • 第二个字节中REG表示寄存器号,3位可以表示8种寄存器,根据第一字节的W,可以表示是8位还是16位寄存器。表3-1中列出了8086寄存器编码表
  • 第二个字节中的MOD和R/M指定了操作数的寻址方式,表3-2列出了8086的编码

这里没必要也无法更详细介绍CPU指令的,只需要知道,CPU指令中已经定义了指令的长度,不会出现混乱读取内存单元的现象。有兴趣的可以查看引用中的连接。

那么我们如何进行快速寻址呢?

内存对齐

存储器一个cell是8bit,进行位扩展使他和字长还有数据线位数是相同,那么一次就能传送CPU可以处理最多的数据。而前面我们说过目前是按字节编址可能是因为一个cell是8bit,所以一次内存操作读取的数据就是和字长相同。

也正是因为和存储器扩展有关(参考1.2.1的图),每个DRAM位扩展芯片使用相同RAS。如果需要跨行访问,那么需要传递2次RAS。所以以32位CPU为例,CPU只能对0,4,8,16这样的地址进行寻址。而很多32位CPU禁掉了地址线中的低2位A0,A1,这样他们的地址必须是4的倍数,否则会发送错误。

如上图,当计算机数据线为32位时,一次读入4个地址范围的数据。当一个int变量存放在0-3的地址中时,CPU一次就内存操作就可以取得int变量的值。但是如果int变量存放在1-4的地址中呢? 根据上面条件2的解释,这个时候CPU需要进行2次内存访问,第一次读取0-4的数据,并且只保存1-3的内容,第二次访问读取4-7的数据并且只保存4的数据,然后将1-4组合起来。如下图:

所以内存对齐不但可以解决不同CPU的兼容性问题,还能减少内存访问次数,提高效率。

如何内存对齐

http://blog.csdn.net/cc_net/article/details/2908600

计算机原理-IO(三)

CPU和存储器之间是如何相互之间通信的呢,IO性能的瓶颈又是在哪里,希望看了这节可以给你解答。

总线

目前软件架构都流行ESB企业总线来处理消息的相互流转,这个架构灵感就参考了计算机中的总线。所谓总线是各种功能部件之间传送信息的公共通信干线,它是由导线组成的传输线束。我们知道计算机有运算器,控制器,存储器,输入输出设备这五大组件,所以总线就是用来连接这些组件的导线。

按照计算机所传输的信息种类,计算机的总线可以划分为

  • 数据总线: 数据总线DB是双向三态形式的总线,即它既可以把CPU的数据传送到存储器或输入输出接口等其它部件,也可以将其它部件的数据传送到CPU。数据总线的位数是微型计算机的一个重要指标,通常与微处理的字长相一致。我们说的32位,64位计算机指的就是数据总线。
  • 地址总线: 地址总线AB是专门用来传送地址的,由于地址只能从CPU传向外部存储器或I/O端口,所以地址总线总是单向三态的,这与数据总线不同。地址总线的位数决定了CPU可直接寻址的内存空间大小
  • 控制总线:控制总线主要用来传送控制信号和时序信号。控制总线的传送方向由具体控制信号而定,一般是双向的,控制总线的位数要根据系统的实际控制需要而定。其实数据总线和控制总线可以共用。

总线也可以按照CPU内外来分类:

  • 内部总线:在CPU内部,寄存器之间和算术逻辑部件ALU与控制部件之间传输数据所用的总线称为片内部总线。
  • 外部总线:通常所说的总线指片外部总线,是CPU与内存RAM、ROM和输入/输出设备接口之间进行通讯的通路,也称系统总线。

控制芯片

主要控制CPU和存储器,I/O设备等进行交互。一般都集成在主板上,所以攒机也不能忽略主板的。

对于目前的计算机结构来说,控制芯片集成在主板上,典型的有南北桥结构和单芯片结构。与芯片相连接的总线可以分为前端总线(FSB)、存储总线、IQ总线,扩展总线等。

  • 南北桥芯片结构
    • 北桥芯片,它控制着CPU的类型,主板的总线频率,内存控制器,显示核心等。它直接与CPU、内存、显卡、南桥相连,所以它数据量非常大;
      • 前端总线:是将CPU连接到北桥芯片的总线。FSB的频率是指CPU和北桥之间的数据交换速度。速度越快,数据带宽越高,计算机性能越好;
      • 内存总线:是将内存连接到北桥芯片的总线。用于和北桥之间的通信;
      • 显卡总线:是将显卡连接到北桥芯片的总新。目前有AGP,PCI-E等接口。其实并没有显卡总线一说,一般认为属于I/O总线;
    • 南桥芯片,它主要负责外部接口和内部CPU的联系;
      • I/O总线:连接外部I/O设备连接到南桥的总线, 比如USB设备,ATA,SATA设备,以及一些扩展接口;
      • 扩展总线:主要是主板上提供的一些PCI,ISA等插槽;
  • 单芯片结构: 单芯片组主要是是取消了北桥,因为现在CPU中内置了内存控制器,不需要再通过北桥来控制,这样就能提高内存控制器的频率,减少延迟。而现在一些CPU还集成了显示单元。也使得显示芯片的频率更高,延迟更低。

频率=性能

数据带宽 = (总线频率*数据位宽)/ 8

(每秒传送次数*每次传送大小)/单位大小转换

外频:系统总线的工作频率,一般的电脑这个是连接其他部件工作的基础频率。

分频:外部IO频率比系统总线低,采用2/3或1/3分频的方式就能使得CPU和外设同步的工作了。

倍频:CPU比外频高,所以需要一个倍频来协调工作。

CPU工作频率=外频*倍频

内存总线频率:

因为内存总线频率不同,所以内存和CPU之间存在同步和异步两种工作方式。

  • 同步方式:内存总线频率和CPU外频相同。比如以前的PC133和P3处理器,他们以同步的方式工作在133MHZ下。而当你超频时就需要拥有更高总线频率的内存。当然也需要北桥芯片的支持。
  • 异步方式:内存总线频率和CPU外频不同。睡着CPU外频的提高,内存也必须更新,所以出现了异步的方式。比如P4 CPU外频为200MHz,而内存却可以使用DDR333来运行。同时异步方式也在超频时经常使用。一般来说会有一个内存异步比率。在BIOS中会有相应的选项。

从性能上来讲,同步方式的延迟要好于异步方式,这也是为什么以前会说P4 200外频的CPU要使用DDR400才能发挥最大功效。但这也不是绝对的。比如我的I5处理器CPU外频工作在100MHz,而我使用的DDR3-1600的总线频率在200MHz,虽然不同步,但是拥有更高的传输速率。所以不能一概而论。

外部IO:

前面主要介绍了系统总线和CPU与内存之间的通信,最后一部分简单介绍一下CPU和I/O设备是如何通信的。对于计算机来说输入输出设备也是五大组件。我们知道相对于CPU,I/O设备的工作频率要慢的很多。比如早期的PCI接口工作频率只有33MHz,硬盘的IDE-ATA6的传输速率也只有133MB/s。而现在的 SATA3接口速率能达到600MB/s。

I/O设备一般由机械部件和电子部件两部分组成。电子设备一般称为设备控制器,在计算机上一般以芯片的形式出现,比如我们前面介绍的南桥芯片。不同的控制器可以控制不同的设备。所以南桥芯片中包含了多种设备的控制器,比如硬盘控制器,USB控制器,网卡、声卡控制器等等。而通过总线以及卡槽提供和设备本身的连接。比如PCI,PCI-E,SATA,USB等。

每个控制器都有几个寄存器和CPU进行通信。通过写入这些寄存器,可以命令设备发送或接受数据,开启或关闭。而通过读这些寄存器就能知道设备的状态。因为寄存器数量和大小是有限的,所以设备一般会有一个RAM的缓冲区,来存放一些数据。比如硬盘的读写缓存,显卡的显存等。一方面提供数据存放,一方面也是提高I/O操作的速度。

现在的问题是CPU如何和这些设备的寄存器或数据缓冲区进行通信呢?存在两个可选方案:

  1. 为每个控制器分配一个I/O端口号,所有的控制器可以形成一个I/O端口空间。存放在内存中。一般程序不能访问,而OS通过特殊的指令和端口号来从设备读取或是写入数据。早期计算机基本都是这种方式。
  2. 将所有控制器的寄存器映射到内存空间,于是每个设备的寄存器都有一个唯一的地址。这种称为内存映射I/O。

    另一种方式是两种的结合,寄存器拥有I/O端口,而数据缓冲区则映射到内存空间。Pentinum就是使用这种方式,所以在IBM-PC兼容机中,内存的0-640K是I/O端口地址,640K-1M的地址是保留给设备数据缓冲区的。(关于内存分布后面文章会介绍)

    对于我们程序员来说这两种方案有所不同

    1. 对于第一种方式需要使用汇编语言来操作,而第2种方式则可以使用C语言来编程,因为他不需要特殊的指令控制,对待I/O设备和其他普通数据访问方式是相同的。
    2. 对于I/O映射方式,不需要特殊的保护机制来组织对I/O的访问,因为OS已经完成了这部分工作,不会把这一段内存地址分配给其他程序。
    3. 对于内存可用的指令,也能使用在设备的寄存器上。

    任何技术有有点就会有缺点,I/O内存映射也一样:

    1. 前面提到过Cache可以对内存进行缓存,但是如果对I/O映射的地址空间进行缓存就会有问题。所以必须有机制来禁用I/O映射空间缓存,这就增大了OS的复杂性。
    2. 另一个问题是,因为发送指令后需要判断是内存还是I/O操作,所以它们需要能够检查全部的内存空间。以前CPU,内存和I/O设备在同一个总线上,所以检查很方便。但是后来为了提高CPU和内存效率,CPU和内存之间有一条高速的总线(比如QPI)。这样I/O设备就无法查看内存地址,因为内存地址总线旁落到了内存和CPU的高速总线上,所以需要一个额外的芯片来处理(北桥芯片,内存控制器的作用),增大了系统的复杂度。

 

瓶颈:1.寄存器-》内存(倍频-外频-内存频率)(普通应用可以忽略)

            2.IO瓶颈    IO速度实在太低(高性能程序都使用预先加载到内存的方式或者内存映射的方式来处理IO瓶颈:以时间,空间,金钱换取速度)

计算机原理(二)

不知道大家是否有过攒机的经历,攒机的时候,我们经常说不能只管 CPU 频率多高,性能多好,也要兼顾其他设备的性能,经常听到的词汇就是内存要大,总线频率也要匹配,硬盘转数也要跟上,这些其实都是涉及到不同设备部件之间的IO,一个不匹配就会拖累其他的。前面我们已经看了些CPU的皮毛,那么接下来我们就看下这些可能拖后腿的部件吧!

存储器

存储系统除了内存,硬盘这些,大概分为:寄存器、Cache、内部存储器、外部存储。请看如下图:

金字塔顶端的性能最好,既然这样,为啥还要分层呢,直接用L0就好了!

性价比。没有性价比,这些东西就不可能飞入寻常百姓家!我们知道,CPU是很快的,所以我们需要通过分层来平衡IO瓶颈,越接近CPU用越快的存储介质:各种晶体管,磁盘!

寄存器

寄存器基本与CPU是同频的,说明其高性能,但同时也意味着高能耗,高成本!所以我们只能少量的把寄存器集成到CPU中!

CASHE

SRAM,静态RAM,通常用于高速缓存,一般CPU中就集成了2级缓存就是这个!

DRAM ,这个其实就是我们熟悉的内存,这个容量大,DDR表示双倍的速率,而现在又有了DDR2,DDR3,他们的带宽也是越来越大。

上面的介质在断电后数据是会消失的,所以不能用来永久 存储数据,而ROM ,FLASH以及磁盘设备就是用来永久存储数据的,当然ROM 比磁盘速度快!

上面讲了这么多的存储设备,那么下一回,我们就来讲讲他们之间的通信,借此也可以了解看看以后如果写代码可能的瓶颈在哪里。

计算机原理(七)-进程内存管理分段

我们知道,进程都有独立的虚拟内存空间,跟物理内存一样,因为物理内存大小有限定,存在被覆盖的风险,所以现代CPU一般通过虚拟内存通过MMU进行页表的映射管理。而进程内的空间也一样,因为编译程序的时候,进程内也需要生成各个虚拟地址表,而每个表的大小不固定,可能就会覆盖其他表的地址,而这时,采用的解决方案是早期A-16位内存采用的分段方式,防止被覆盖。

分段原因

在编译过程中会建立许多的表,来确定代码和变量的虚拟地址:

  • 被保存起来供打印清单的源程序正文;
  • 符号表,包含变量的名字和属性;
  • 包含所有用到的整形和浮点型数据的表;
  • 语法分析树,包括程序语法分析的结果;
  • 编译器内部过程调用的堆栈。

前面4张表会随着编译的进行不断增大,而堆栈的数据也会变化,现在的问题就是,每一张表的大小都不确定,那么如何指定每一张表在虚拟内存空间的地址呢?

 

分段作用

分页实际是一个纯粹逻辑上的概念,因为实际的程序和内存并没有被真正的分为了不同的页面。而分段则不同,他是一个逻辑实体。一个段中可以是变量,源代码或者堆栈。一般来说每个段中不会包含不同类型的内容。而分段主要有以下几个作用:

  1. 解决编译问题: 前面提到过在编译时地址覆盖的问题,可以通过分段来解决,从而简化编译程序。
  2. 重新编译: 因为不同类型的数据在不同的段中,但其中一个段进行修改后,就不需要所有的段都重新进行编译。
  3. 内存共享: 对内存分段,可以很容易把其中的代码段或数据段共享给其他程序,分页中因为数据代码混合在一个页面中,所以不便于共享。
  4. 安全性: 将内存分为不同的段之后,因为不同段的内容类型不同,所以他们能进行的操作也不同,比如代码段的内容被加载后就不应该允许写的操作,因为这样会改变程序的行为。而在分页系统中,因为一个页不是一个逻辑实体,代码和数据可能混合在一起,无法进行安全上的控制。
  5. 动态链接: 动态链接是指在作业运行之前,并不把几个目标程序段链接起来。要运行时,先将主程序所对应的目标程序装入内存并启动运行,当运行过程中又需要调用某段时,才将该段(目标程序)调入内存并进行链接。可见,动态链接也要求以段作为管理的单位。
  6. 保持兼容性

所以在现在的x86的体系结构中分段内存管理是必选的,而分页管理则是可选的。

计算机原理-X86 CPU和内存管理(六)

这里主要简单介绍下32位处理器的内存访问,内存管理以及应用程序的内存布局。而32位CPU主要就是从intel 的80386架构CUP开始的,这也是我们最开始熟悉的CPU。而CPU最直接的作用就是 取指令、指令译码、执行指令、访存取数和结果写回。说白了就是对指令进行操作。而前面的可执行程序在CPU看来就是编译好的指令集合及数据集合。

其总图可以抽象为如下:

image

我们分别看看CPU及内存的一些操作细节:

一.32CPU
CPU架构

从80386开始,地址线变为了32位,和CPU寄存器以及运算单元位数一样,最大寻址范围增加到4G。另外80386处理器都可以支持虚拟存储器,支持多任务。 而之后的CPU,主要的改进就在于:

  1. CPU内部集成DMA,计数器,定时器等;
  2. 制造工艺的提示,更多的晶体管,更快的速度
  3. 加入更多的指令集,如MMX,SSE,SSE2等
  4. 集成L1,L2,L3高速缓存,减少外部总线的访问
  5. 超线程,多核心提高CPU效率
CPU内存访问方式

80386引入多任务后,内存管理方式有如下方式:

  1. 地址空间:这个是对物理内存的一个抽象,就好像进程是对CPU的一个抽象。一个进程可用于寻址的一套地址的集合,每个进程都有自己的地址空间,相互独立,这就解决了安全问题。
  2. 交换:把程序全部加载到内存,当进程处于空闲时,把他移除内存,存放到硬盘上,然后载入其他程序。这样使得每个进程可以使用更多的内存。
  3. 虚拟内存:利用到计算机系统的局部性和存储分层,我们可以只加载一部分需要使用的代码和数据到内存,当访问的内容不在内存时,和已经使用完的部分进行交换,这样就能在小内存的机器上运行大的程序了。对于程序来说这是透明的,看起来自己好像使用了全部内存。而多个应用完全可以使用相同的虚拟地址。
  4.  

    由于上面内存管理方式,产生了如下的内存访问方式:

虚拟寻址

CPU在内部增加了一个MMU(Memory Management Unit)单元来管理内存地址的转换。除了可以转换地址,还能提供内存访问控制。如操作系统固定使用一段内存地址,当进程请求访问这一段地址时,MMU可以拒绝访问以保证系统的稳定性。而MMU的翻译过程则需要操作系统的支持。所以可见硬件和软件在计算机发展过程中是密不可分的。

CPU寄存器

通用寄存器 ,指令指针寄存器。。。

二.虚拟内存

他就是现在保护模式下的内存获得方式,它的基本思想是:

  • 每个进程都有自己的地址空间;
  • 每个地址空间被分为多个块,每个块称为页,每个页有连续的地址空间;
  • 这些页被映射到物理内存,但不是所有也都在内存中程序才能运行;
  • 当使用的页不在物理内存中时,由操作系统负责载入相应的页;//运行时映射

虚拟地址(线性地址),虚拟地址就是同上CPU的MMU将虚拟地址映射为物理地址,然后送到总线,进行内存访问。这里最关键的就是虚拟地址的映射。

内存分页

对于虚拟内存来说,是对物理内存的抽象,整个虚拟内存空间被划分成了多个大小固定的页(page),每个页连续的虚拟地址,组合成了一个完整的虚拟地址空间。同样,操作系统也会把物理内存划分为多个大小固定的块,我们称为页框(page frame),它只是操作系统为了方便管理在逻辑上的划分的一个数据结构,并不存放实际内存数据,但是我们可以简单的认为它就是内存。这样一个虚拟内存的page就可以和一个物理内存的page frame对应起来,产生映射关系。

关于一个虚拟页的大小,现在的操作系统中一般是512B-64K(准确的说是地址范围大小,而非容纳数据的大小)。但是内存页的大小会对系统性能产生影响,内存页设得太小,内存页会很多,管理内存页的数组会比较大,耗内存。内存页设大了,因为一个进程拥有的内存是内存页大小的整数倍,会导致碎片,即申请了很多内存,真正用到的只有一点。目前Windows和Linux上默认的内存页面大小都是4K。

从上图我们也可以看出,虚拟内存的页和物理内存的页框并不一定是一一对应的,虚拟内存的大小和系统的寻址能力相关,也就是地址线的位数,而物理内存的页框数取决于实际的内存大小。所以可能只有一部分页有对应的页框,而当访问的数据不在物理内存中时就会出现缺页,这个时候操作系统会负责调入新的页,也就是建立新的映射。这样就不需要一次把程序全部加载到内存。

存储分页

加载应用程序到内存时,因为和虚拟地址有关,我们需要把应用程序文件和虚拟内存关联起来,在Linux中称为存储器映射,同时提供了一个mmap的系统调用来实现次功能。文件被分成页面大小的片,每一片包含一个虚拟页面的内容,因为页面调度程序是按需求调度,所以在这些虚拟页面并没有实际的进入内存,而是在CPU发出访问请求时,会创建一个虚拟页并加载到内存。我们在启动一个进程时,加载器为我们完成了文件映射的功能,所以此时我们的执行文件又可以称为映像文件。实际上加载器并不真正负责从磁盘加载数据到内存,因为和虚拟内存建立了映射关系,所以这个操作是虚拟内存自动进行的。 正是有了存储器映射的存在,使得我们可以很方便的将程序和数据加载到内存中。

MMU映射方式

如何将虚拟页映射到物理页中,MMU通过页表映射,页表是实时在内存中的,而虚拟页是运行时通过页表映射到实际的物理内存页上的。

页表分级

为了解决页表占用内存过多的问题,引入了分级页表

页表缓冲区

虚拟地址转换时,一次内存访问查找页表的过程。我们知道内存速度比CPU慢很多,每次从内存取数据都要访问2次内存,会使得系统性能下降。为了解决这个问题,在MMU中包含了一个关于PTE的缓冲区TLB(Translation Lookaside Buffer ),TLB是一个寄存器,所以它运行的速度和CPU速度相同。每次在去页表中查找之前,可以先在TLB中进行查找,如果命中则直接拿到物理页地址,如果不命中则再次访问内存。

进程调度和虚拟内存

我们知道在系统中,每个进程都有自己独立的虚拟空间,于是每个进程都有一张属于自己的内页表。 而我们翻译地址时,从cr3中取出页表目录的首地址。对于不同的进程,他们都使用同一个寄存器。于是在CPU调度进程的时候,虚拟的地址空间也需要切换。于是对于普通用户程序需要做下面几件事情:

  1. 保存当前进程的页表目录地址,也就是保存cr3中存放的地址到进程上下文中
  2. 清空TLB中缓存的数据
  3. 调度新的进程到CPU,并设置cr3寄存器的值为页表目录的首地址

但是内存中除了用户程序之外还存在操作系统自身占用的内存。我们可以简单的把操作系统看成一个超大的进程,他和其他普通进程一样需要使用虚拟内存,需要使用到页表。当然作为内核程序,它必须是有一些特权的,下一篇我们将会介绍虚拟内存的布局。而对于内核而言不是存在进程调度的。因为所有的内核进程都是共享内核的虚拟地址空间,而我们一般都称之为内核线程,而非进程。 当然对于Linux而言,没有线程的概念,线程和进程唯一不同就是是否共享虚拟地址空间。一般来说内核代码是常驻在内存的,那么内核会不会缺页呢?