计算机体系结构(翻译)
本文翻译自《Programming from the Ground Up》一书第二章 "Computer Architecture".
该书是讲x86汇编语言编程的, 可从 http://savannah.nongnu.org/projects/pgubook/ 下载(英文版).
我出于兴趣看过前面部分章节, 发现第二章是很好的计算机入门读物, 并不涉及汇编. 我未见该书有中文版, 因此尝试翻译, 以期帮助人们了解计算机, 揭开并破除计算机的神秘面纱.
现代计算机是基于一种叫做冯诺依曼结构的体系结构, 该结构根据其创建者的名字命名. 冯诺依曼结构把计算机分成两个主要部分:CPU(中央处理器)和主存(译注:即通常所说的内存). 所有的现代计算机, 包括个人电脑(PC), 超级计算机, 大型机, 甚至手机, 全部都采用这种结构.
计算机主存的结构
要理解计算机如何看待内存, 请想象一下当地的邮局. 他们通常有一间屋子, 里边放满了邮政信箱. 这些信箱和计算机内存有些类似, 他们都是有编号的连续的固定大小的存储单元. 例如, 如果你有256M的计算机内存, 那等于是你的计算机有大约2亿5千6百万个固定的存储单元. 如果用刚才的比喻, 就是有大约2亿5千6百万个信箱. 每一个存储单元都有一个编号, 而且每个存储单元都有相同的固定的大小. 一个邮政信箱和一个存储单元的区别在于, 你在一个邮政信箱里可以放各种不同的东西, 而在计算机内存的一个存储单元里就只能放一个数.
你可能想知道为什么计算机要被设计成这样. 这是因为这样容易生产制造. 假如计算机是由许多大小不同的存储单元构成, 或者你可以在其中放各种东西, 那要制作起来就既困难又昂贵.
计算机的内存被用来做许多不同的事情. 任何计算的结果全都存在里边. 实际上, 任何被"存储"了的东西, 都是存储在内存里. 考虑一下你家的电脑, 想象一下你的电脑内存里都存了些什么.
- 你的光标(鼠标指针)在屏幕上的位置
- 屏幕上每个窗口的位置
- 正在使用的每个字体中每个字符的形状
- 每个窗口中的所有控件(按钮,列表框,文本框等各种元素,译注)的布局
- 所有的工具栏图标的图像
- 每个错误消息和对话框的文本内容
- 等等等等
除了以上这些, 冯诺依曼体系还规定不仅计算机的数据要放在内存里, 而且控制计算机操作的程序也要在内存里. 事实上, 在计算机里, 程序和它的数据是一样的, 其差别仅在于如何被计算机使用. 它们都以相同的方式被存储和访问.
CPU
那么计算机是怎么工作的呢? 显然, 简单存储数据没有多大用处, 你得能访问, 修改和移动它. 这就是CPU的用武之地. CPU从主存每次一条地读取指令, 然后执行. 这一过程被称为 指令周期. CPU包含如下组成部分以完成这一功能:
- 程序计数器
- 指令译码器
- 数据总线
- 通用寄存器
- 算数逻辑单元
程序计数器 用来告诉计算机下一个指令从哪里获取. 我们前面提到数据和程序的存储方式是一样的, 它们只是被CPU拿来做了不同的解释. 程序计数器保存着下一条要被执行的指令的内存地址. CPU一开始就查看程序计数器, 并按照其指定的位置, 在内存中读取那个数, 无论是几. 之后那个数会被送到 指令译码器, 以便明确它表示什么指令. 这包括需要执行什么过程(加,减,乘,移动数据,等等)以及该过程涉及到那些存储单元. 计算机指令通常包含实际的操作以及执行该操作所涉及的一系列存储单元这两部分.
这时计算机使用 数据总线 来获取计算中用到的存储单元. 数据总线是CPU和内存之间的桥梁. 它是连接它们的真实的电线. 如果你看一下电脑主板, 那么从内存出来的线路就是你的数据总线.
除了处理器外边的主存, 处理器自己也有一些特殊的, 高速的存储单元, 称为寄存器. 寄存器分为两类, 通用寄存器 和 专用寄存器. 通用寄存器是完成主要工作的地方. 加,减,乘,比较,和其他操作通常使用通用寄存器来进行操作. 但是, 计算机只有很少的通用寄存器. 大部分信息都存储在主存, 拿到寄存器里进行处理, 处理完毕之后再放回主存. 专用寄存器 是一些有特殊用途的寄存器. 我们以后遇到的时候再讨论它们.
既然CPU已经取得了需要的全部数据, 它就把这些数据连同已经解码的指令一起传给 算数逻辑单元 做进一步处理. 这里是指令真正被执行的地方. 在计算完成得出结果后, 按照指令指定的, 结果将被放到 数据总线 并送到合适的存储单元或者放到寄存器.
这是一个非常简化的解释. 处理器在近年发展很快, 而且也更复杂得多了. 虽然基本的操作还是一样, 但是多级缓存, 超标量结构处理器, 流水线, 分支预测, 乱序执行, 微代码翻译, 协处理器, 以及其他的优化等使之变得复杂. 如果你不理解这些词语那也没什么可担心的, 如果你想多了解一些关于CPU的信息, 可以上网搜索这些词汇.
一些概念
计算机内存是有编号的连续的固定大小的存储单元. 每个存储单元所附带的编号被称为它的 地址. 单独的存储单元的大小称为一个 字节. 在x86处理器上, 一个字节是取值在0-255之间的一个数字.
你可能想知道, 既然计算机只能存储0到255之间的数字, 那么它是怎么显示和使用文本, 图像, 甚至是更大的数字的. 首先, 专门的硬件, 如显卡, 对每一个数字有特定的解释. 当要显示到屏幕时, 计算机根据 ACSII 码表格把你发出的数字翻译成在屏幕上显示的字母, 每一个数字准确翻译成一个字母或者数字. [1] 例如, 大写字母 A 用数字65表示. 字符 1 用数字49表示. 因此, 要打印出"HELLO", 你实际上给计算机的是72, 69, 76, 76, 79 这一串数字. 要打印出数字 100, 你要给计算机 49, 48, 48 这一串数字. 附录D包含ASCII码字符和其对应的数字的表格.
除了用数字来表示ASCII字符, 作为程序员, 你也可以用数字来表示任何你想让它表示的东西. 例如, 如果我开了家商店, 我会用一个数字来表示我出售的每一种商品. 每一个数字会关联到一系列其他的数字, 那些是ASCII字符, 用来表示在扫描的时候要显示的文字. 我还需要更多的数字来表示价格, 库存, 等等.
那么比255大的数怎么办呢? 我们可以简单的组合字节来表示更大的数字. 两个字节表示的数字范围是0到65535. 4个字节能表示的数字范围是0到4294967295. 现在, 写程序把字节组合起来增加数字的范围是很难的, 那需要一定的数学功底. 幸运的是, 计算机会替我们做4个字节以内的组合. 事实上, 我们默认会用到的就是4字节的数字. (译注: 这里4字节是指32位的x86处理器; 原书成于2004年, 讲解x86汇编编程, 当时PC处理器主要是32位的; 目前常见的64位处理器支持8个字节以内的组合.)
我们前面提到计算机除了有常规的内存, 还有被称为 寄存器 的特殊用途的存储单元. 寄存器是计算机用来进行计算的. 把寄存器想象成你桌子上的一个地方, 那里放的是你正在用着的东西. 你的文件夹和抽屉里可能放着许多资料, 但你现在工作正用的东西在桌面上. 寄存器存储的就是你正在操作的数字的内容.
在我们用着的计算机里, 寄存器都是4字节的. 典型的寄存器长度被称为计算机的 字 长. x86处理器的字有4字节. 这意味着在这些计算机上一次操作4个字节的是最自然的. 这个数值是大约40亿.
地址同样是4字节(1个字)的长度, 因此也能放进寄存器. 如果安装足够的内存, x86处理器能最多访问4294967296个字节. 注意, 这意味着我们可以像存储其他数字那样来存储地址. 事实上, 计算机无法分辨一个数值到底是地址, 数字, ASCII码, 或者是你存的别的什么东西. 一个数, 当你要显示它的时候, 它就是ASCII码, 当你查询它指向的字节的时候, 它就是地址. 请花点时间思考一下这一点, 它对于理解计算机如何工作是至关重要的.
存储在内存里的地址也被称为 指针, 因为它并不包含一个通常的数值, 而是指引你到内存里的另一个地方去.
如前所述, 计算机的指令也是存储在内存里的. 事实上他们和其他数据存储的方式完全一样. 计算机知道一个存储单元里是指令的唯一方法, 就是一个叫做 程序计数器 的专用寄存器在某一点或另一点指向了它. 如果程序计数器指向了内存里的一个字, 那个字就被作为指令加载. 除此之外, 计算机没有办法分辨程序和其他数据的区别. [2]
解释内存
计算机是非常精确的. 因为它们精确, 所以程序员也不得不同样精确. 一台电脑不知道你的程序打算要干嘛. 因此, 它只能精确的做你告诉它要做的事情. 如果你意外地打印了一个数字, 而不是那个数字对应一串的ASCII码, 计算机会照做不误, 而你会因为屏幕上的乱码而气愤(它会在ASCII表中找查那个数字对应的字符并打印出来). 如果你让计算机开始执行内存中某处的指令, 而那里其实存的是数据, 天知道计算机会怎么解释, 但它肯定会去试的. 计算机会严格按照你提供的顺序来执行指令, 即使那是没有意义的.
重点在于, 计算机会严格按照你的命令来做, 不管多么没有意义. 因此, 作为程序员, 你需要精确的知道你怎样在内存中组织你的数据. 记住, 计算机只能存储数字, 所以字母, 图片, 音乐, 网页, 文档, 以及任何其他的东西, 在计算机里都只是一长串的数字, 而某些特定的程序知道怎么解释它们.
比如说, 你想在内存里存储客户的信息. 一个方法是设置客户的姓名和地址的最大长度, 算每个有50个ASCII字符, 那就是每项50个字节. 然后, 有一个数字存用户的年龄和他们的客户id号. 这样, 你会有如下分布的内存状况:
记录起始:
客户的姓名 (50字节) - 记录起始
客户的地址 (50字节) - 记录起始 + 50字节
客户的年龄 (1字 - 4字节) - 记录起始 + 100字节
客户的id号 (1字 - 4字节) - 记录起始 + 104字节
这样, 给出了客户记录的地址的话, 你知道怎么找其他的数据. 但是它毕竟限制了客户的姓名和地址的最大长度分别是50个ASCII码.
如果我们不做这个限制会怎么样呢? 另一种方法是只记录中存放信息的指针. 比如, 我们不存姓名, 而是存姓名的一个指针. 这样我们会得到如下的内存分布:
记录起始:
客户姓名的指针 (1字) - 记录起始
客户地址的指针 (1字) - 记录起始 + 4
客户的年龄 (1字) - 记录起始 + 8
客户的id号 (1字) - 记录起始 + 12
实际的姓名和地址会存到内存的其他地方. 这种方式, 我们可以容易的知道哪一部分信息离记录的开始有多远, 同时又不限制姓名的地址的大小. 如果我们的记录中的一条信息的长度会变化, 我们就不知道下一条信息从哪开始. 因为记录的长度会变化, 所以找到下一条记录也同样困难. 因此, 几乎所有的记录都是固定大小的. 变成的数据通常和记录的其余部分分开存储.
数据访问方式
处理器(指令)访问数据有几个不同的方式, 称之为寻址模式. 最简单的一种是 立即寻址模式, 此时数据就包含字指令里头. 例如, 如果我们想吧一个寄存器地值初始化设置为0, 我们可以使用立即模式, 给它个数值0, 而不用给它打地址, 然后从那里再读一个0.
在 寄存器寻址模式, 指令包含要访问的寄存器, 而不是内存地址. 剩下的模式将是处理地址的.
在 直接寻址模式, 指令包含要访问的内存地址. 比如, 我可能说, 请把地址2002位置的数据加载的这个寄存器. 计算机就会直接到2002编号的存储单元, 并把其中的内容拷贝到寄存器.
在 变址寻址模式, 指令包含要访问的内存地址, 同时指定一个 变址寄存器 来做地址偏移. 例如, 我们可以指定地址2002和一个变址寄存器, 如果变址寄存器里的数是4, 实际的数据地址将是2006. 用这种方式, 如果你有从2002地址开始的一系列的数, 你可以用变址寄存器来循环操作它们. 在x86处理器中, 你还可以指定一个乘法系数来计算偏移. 这能允许你一次访问一个字节或者一个字(4字节). 如果你要访问一个字, 对于某个元素, 你的偏移量需要乘以4才能得到准确的位置. 例如, 如果你要访问2002开始的第4个字节, 那么你要设置变址寄存器为3(记住, 我们从0开始计数), 乘法系数为1, 因为我们是单个字节访问的. 这样我们得到2005的位置. 但是, 如果你要访问从2002开始的第4个字, 那么你要设置变址寄存器为3, 并设置乘法系数为4, 这样得到的位置上2014, 第4个字. 花点时间自己计算一下, 确保你理解如何计算.
在 间接寻址模式, 指令包含一个寄存器, 而其中是个指针, 指向了数据所在位置. 例如, 我们使用间接寻址并制定 %eax 寄存器, 而%eax里的值是4, 那么内存中编号为4的存储单元, 用的就是那里的数据, 不论里边是什么. 在直接寻址中, 我们将只是加载数值4, 但在间接寻址中, 我们用4作为地址去找我们要的数据.
最后, 还有 基址寻址模式. 这个和间接寻址类似, 但你同时使用一个称为 偏移 的数来加到寄存器里的数值上, 然后再查询. 本书中将大量使用这种模式.
在 解释内存 的章节中, 我们讨论了用一个内存中的结构来放置客户信息. 现在假定我们要获取客户的年龄, 那是数据的第8个字节, 而我们在寄存器中存储了结构开始的地址. 我们可以用基址寻址, 指定那个寄存器作为基址, 以8为偏移. 这和变址寻址很像, 区别在于偏移量是常数, 而地址放在寄存器里, 在变址寻址中, 偏移量在寄存器里而地址是常数.
还有其他一些寻址方式, 但前面这些是最重要的.
2013-08-16