计算机体系结构
冯诺依曼体系
当前计算机主要是基于冯诺依曼体系结构设计的,主要由五大部件组成:
-
存储器用来存放数据和程序
-
运算器主要运行算数运算和逻辑运算,并将中间结果暂存到运算器中
-
控制器主要用来控制和指挥程序和数据的输入运行,以及处理运算结果
-
输入设备用来将人们熟悉的信息形式转换为机器能够识别的信息形式,常见的有键盘,鼠标等
-
输出设备可以将机器运算结果转换为人们熟悉的信息形式,如打印机输出,显示器输出等
CPU构造
CPU主要由以下几个单元构成:
- 程序计数器
- 告诉计算机从哪里提取下一条指令
- 保存即将执行的下一条指令的内存地址
- 指令解码器
- CPU先查看程序计数器,然后提取存放在指定内存地址的数字,接着传递给指令解码器,由它来解释指令
- 指令解码器给出解释包括:需要进行何种处理,以及处理过程中将会涉及到哪些内存单元
- 数据总线
- 是CPU和内存间的物理连线
- 通用寄存器
- 算法逻辑单元
寄存器
CPU 本身只负责运算,不负责储存数据,为了提高CPU读取内存速度,CPU都会内置缓存。按照层级依次分为L1 Cache, L2 Cache, L3 Cache,其读写延迟依次增加,实现成本依次降低。但只有CPU缓存还不够,在CPU缓存之上还有CPU 寄存器。CPU 优先读写寄存器,再由寄存器跟内存交换数据。
寄存器不依靠地址区分数据,而依靠名称。每一个寄存器都有自己的名称。寄存器按照种类分为通用寄存器和控制寄存器。其中通用寄存器有可细分为数据寄存器,指针寄存器,以及变址寄存器。
早期计算机处理器都是是16位的,后来处理器开始支持32位以及64位,为了兼容并保留了旧名称,16位处理器的AX寄存器拓展成EAX(E表示拓展的意思)。对于64位处理器的寄存器相应的就是RAX。其他指令也类似。
几个特殊寄存器:
寄存器 | 功能 |
---|---|
ESP(Stack Pointer)-栈指针寄存器 | 存放执行函数对应栈帧的栈顶地址(也是系统栈的顶部),且始终指向栈顶 |
EBP(Base Pointer)-栈帧基址指针寄存器 | 存放执行函数对应栈帧的栈底地址,用于C运行库访问栈中的局部变量和参数 |
EIP(Instruction Pointer)-指令寄存器 | 指向处理器下条等待执行的指令地址(代码段内的偏移量),每次执行完相应汇编指令EIP值就会增加;EIP是个特殊寄存器,不能像访问通用寄存器那样访问它。EIP可被jmp、call和ret等指令隐含地改变 |
寄存器的最低有效字
对于32位机器,寄存器大小是4个字节。每两个字节称为字。最后两个字节称为最低有效字。最后两个字节继续划分可以分为最低有效半字,和最高有效半字。比如%eax的最低有效字是%ax, %ax的最低有效半字是%al和%ah
寄存器使用约定
寄存器是唯一能被所有函数共享的资源。虽然某一时刻只有一个函数在执行,但需保证当某个函数调用其它函数时,被调函数不会修改或覆盖主调函数稍后会使用到的寄存器值。因此,IA32采用一套统一的寄存器使用约定,所有函数调用都必须遵守该约定。
- 寄存器%eax、%edx和%ecx为主调函数保存寄存器(caller-saved registers),当函数调用时,若主调函数希望保持这些寄存器的值,则必须在调用前显式地将其保存在栈中;被调函数可以覆盖这些寄存器,而不会破坏主调函数所需的数据。
- 寄存器%ebx、%esi和%edi为被调函数保存寄存器(callee-saved registers),即被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存起来,并在函数返回前从栈中恢复其原值,因为主调函数可能也在使用这些寄存器
- 被调函数必须保持寄存器%ebp和%esp,并在函数返回后将其恢复到调用前的值,亦即必须恢复主调函数的栈帧
汇编语言
CPU执行的最小单元是指令(instruction),它就运行一次,然后停下来,等待下一条指令。这些指令都是二进制的,称为操作码(opcode),比如加法指令就是00000011。汇编语言是二进制指令的文本形式,与指令是一一对应的关系。比如,加法指令00000011写成汇编语言就是 ADD。只要还原成二进制,汇编语言就可以被 CPU 直接执行,所以它是最底层的低级语言。
要把这些文字指令翻译成二进制,这个步骤就称为 assembling,完成这个步骤的程序就叫做 assembler。它处理的文本,自然就叫做 aseembly code。标准化以后,称为 assembly language,缩写为 asm,中文译为汇编语言。
第一个汇编程序
1 | # exit.s |
汇编、链接、执行
- 汇编
汇编是将汇编程序转换成机器指令的过程
1 | as exit.s -o exit.o |
- as是运行汇编的命令
- exit.s是源文件
- -o exit.o 是输出目标文件。目标文件是机器语言写成的代码。目标文件的内容通常不完全放在一起。大型程序有多个源文件,通常将每个源文件转换成一个目标文件。
- 链接
链接是将多个目标文件合二为一,并且向其中添加信息,以使内核知道如何加装和运行该目标文件
1 | ld exit.o -o exit |
- 执行
1 | ./exit |
程序解释
1 | # 以小数点开始的指令不会翻译成机器指令,是针对汇编程序本身的指令。它也被称为汇编指令或伪操作 |
查找最大值
1 | # 变量 |
汇编并链接程序
1 | as maximum.s -o maximum.0 |
上面程序解释:
1 | # 变量 |
内存布局
汇编语言中.section指令将程序划分几部分不是任意划分的,都是需要对应程序的内存布局。应用程序的内存布局分为以下几大块
- Stack - 栈
- Heap - 堆
- BSS - 未初始化数据区,对应的汇编是(.section .bss)
- DS - 初始化化数据区, 对应的汇编是(.section .data)
- Text - 文本区,程序代码, 对应的汇编是(.section .text)
1 | High Addresses ---> .----------------------. |
寻址方式
寻址方式就是处理器根据指令中给出的地址信息来寻找有效地址的方式,是确定本条指令的数据地址以及下一条要执行的指令地址的方法
内存地址引用通用格式:
1 | 地址或偏移 (%基址寄存器, %索引寄存器, 比例因子) |
所有字段都是可选的,最终地址计算方式
结果地址 = 地址或偏移 + %基址或偏移寄存器 + 比例因子 * %索引寄存器
地址或偏移,以及比例因子都必须是常量,其余两个必须是寄存器。省略项都使用0替代
我们看看上面date_items的例子
data_items(,%edi, 4)的最终地址 = date_items的地址 + 0 + 4 * %edi
寻址方式与指令
寻址方式 | 寻址指令 | 说明 |
---|---|---|
立即寻址 | mov $number, %eax | 将number直接加载到寄存器或存储位置 |
直接寻址 | mov 0x123, %eax | 将内存地址0x123存储的值加载到%eax |
变址寻址 | mov string_start(%ebx, %ecx, 5), %eax | 将string_start分别与%ebx,5 * %ecx相加,并将所得地址存储的值加载到%eax中。格式为: 地址或偏移 (%基址寄存器, %索引寄存器, 比例因子); 计算方式:结果地址 = 地址或偏移 + %基址或偏移寄存器 + 比例因子 * %索引寄存器 |
间接寻址 | mov (%eax), %ebx | 从寄存器eax指定的内存地址加载值到ebx |
基址寻址 | mov 4(%eax), %ebx | 将寄存器eax指定的内存地址加上4之后得到新内存地址, 然后从新内存地址加载值到ebx |
函数
函数的构成
-
函数名
函数名是一个符号,代表函数代码的起始地址。
-
函数参数
函数参数是传递给函数进行的处理的数据项
-
局部变量
局部变量是函数处理时使用的数据存储区,在函数返回是即被废弃
-
静态变量
静态变量是函数进行处理时用到的数据存储区,但使用后不会被废弃,每当函数代码被激活时候都重复使用。
-
全局变量
全局变量是函数进行处理时用到的,在函数之外管理的数据存储区
-
返回地址
返回值是一个“看不见”的参数,因为它不能再函数中使用。返回地址这一参数告诉函数当其执行完毕后应该再从哪里开始执行。返回地址必不可少,因为程序中许多不同的部分都会调用函数进行处理,因此函数必须能够返回调用它的地方。在大多数编程语言中,调用函数时会自动传递这个参数。汇编语言中,call指令会处理返回地址,ret指令负责按照该地址返回到调用函数的地方。
-
返回值
返回值返回数据到主程序
汇编执行函数的流程
了解函数执行流程,首先要先理解栈和栈帧的概念
栈
每个运行的计算机程序都是用叫做栈的内存区来使函数正常工作。计算机的栈处于内存地址的最顶端,可以通过pushl指令将值压入栈顶。指令popl将值从栈顶弹出。
每当值进行入栈或出栈时候,栈在内存中是向下增长的,栈寄存器%esp总是包含一个指向当前栈顶的指针。当push1数据入栈时,%esp所包含的指针值会减去4(对于32位操作系统,栈的粒度是4字节),从而指向新的栈顶,同理popl则会使%esp的值增加4
若想要访问栈顶元素,则需要使用间接寻址方式,将栈顶的内容复制到%eax:
movl (%esp), %eax
将%esp置于括号中是复制%esp所含指针执行的值,而不是指针(没有括号的情况是复制指针), 若要访问栈顶下一个值,只需要:
movl 4(%esp), %eax
栈帧结构
函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。
栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。
函数调用栈的典型内存布局如下图:
函数执行流程
-
参数入栈
执行函数之前,先将函数所有参数按照逆序压入栈中。然后调用call指令表明开始执行某个函数。
call指令会完成两件事情:- 将下一条指令的地址及返回地址压入栈中
- 接着修改指令指针(%eip)以指向函数起始处。
在函数开始执行时,栈看起来如下:
1
2
3
4
5参数 #N
...
参数2
参数1
返回地址 <--- (%esp) -
执行前准备工作
- 函数通过push1 %ebp指令保存当前基址计算器%ebp。
- 接着使用
movl %esp, %ebp
指令将栈指针%esp复制到%ebp, 之后可以通过%ebp寄存器来访问函数参数。
为啥不通过%esp来直接访问函数参数,因为在程序中还有可能压入其他函数的参数等对栈的操作。
此时栈看起来如下:
1
2
3
4
5
6参数 #N <--- N*4 +4(%ebp)
...
参数 2 <--- 12(%ebp)
参数 1 <--- 8(%ebp)
返回地址 <--- 4(%ebp)
旧%ebp <--- (%esp)和(%ebp)接下来函数为其所需的所有局部变量保留栈空间, 加入局部变量需要2个字的内存,只需:
sub1 $8, %esp
sub1指令将%esp减去8,一个字长度是4个字节,此时栈看起来如下:
1
2
3
4
5
6
7
8参数 #N <--- N*4 + 4(%ebp)
...
参数2 <--- 12(%ebp)
参数1 <--- 8(%ebp)
返回地址 <--- 4(%ebp)
旧%ebp <--- (%ebp)
局部变量1 <--- -4(%ebp)
局部变量2 <--- -8(%ebp) / %esp -
函数执行
-
函数执行完毕后,收尾工作
一个函数执行完毕之后,会做三件事:
- 将其返回值存储到%eax
- 将栈恢复到调用函数的状态(移除当前栈帧,并使调用代码的栈帧重新生效, 恢复前一个栈帧)
- 通过ret指令将控制权交还给调用它的程序,ret指令将栈顶的值弹出,并将指令指针寄存器%eip设置为该弹出值
相应的指令如下:
1
2
3
4movl -4(%ebp), %eax
movl %ebp, %esp // 注意此处没有括号,用于恢复前一个栈帧
popl %ebp // 弹出栈顶元素即旧的%ebp, 并保存到%ebp寄存器中
ret // 返回到返回地址(4(%ebp),交出控制权给函数调用方,相当于popl %eip
至此控制权转到调用代码出,调用代码可以检查%eax中的返回值,并弹出入栈的参数。
上面执行流程可以概况四步骤:
- 压栈: 函数参数和返回地址压栈
- 跳转: 跳转到函数所在代码处执行
- 执行: 执行函数代码
- 返回: 堆栈平衡,找出之前的返回地址,跳转回之前的调用点之后,完成函数调用
堆栈平衡
主调函数将参数压栈后调用被调函数体,返回时需将被压栈的参数全部弹出,以便将栈恢复到调用前的状态,这个过程就叫堆栈平衡。清栈过程可由主调函数负责完成堆栈平衡,也可由被调函数负责完成堆栈平衡。
函数示例
简单数学运算
下面示例将计算2 ^ 3 + 5 ^ 2
1 | // 本程序将计算 2 ^ 3 + 5 ^ 2 |
递归函数计算N的阶乘
1 | # 计算N的阶乘 |
汇编、链接并运行:
1 | as factorial.s -o factorial.o |
文件处理
UNIX文件的概念
无论UNIX文件是什么程序创建的,都可以作为连续的字节流进行访问。当访问一个文件时,通过文件打开它,操作系统都会分配一个对应的编号,这个编号称为文件描述符。接下来可以使用文件描述负对该文件进行读取和写入。关闭文件后,文件描述符即失效。
文件处理流程
- 通过Open系统调用,告诉Linux要打开的文件名,读还是写模式,还有权限。%eax保存系统调用号(Open操作的是5), 文件名地址存放在%ebx, 读写模式存在在%ecx, 文件操作权限存放在%edx
- Open系统调用之后,Linux返回文件描述符号到%eax
- 接下来对文件进行读操作。read的调用号是3,为了进行该调用,必须将文件描述符存入%ebx, 将存储数据的缓存去地址存入%ecx,将缓存去大小放入%edx。read操作将返回从文件中读取的字符数或一个负数的错误码。write的系统调用4,需要的参数与read系统调用相同,唯一的区别是缓冲区已经填满了要写入的数据。Write系统调用将把写入的字节数或错误代码存入%eax
- 文件使用完毕,可以使用close关闭文件描述符。文件描述应该存入在%ebx中。
缓冲区
要创建缓冲区,需要保留静态或动态存储。静态存储就是.data段里面的.long或.byte等指令声明的存储。不过通过.long或.byte指令声明缓存区需要完全键入所有字符。一来过于麻烦,特别需要缓存空间很大的情况下,而来声明几百字节可能最终没有用到就会造成浪费。
这时候可以使用.bss端,可以保留存储位置,却不进行初始化
1 | .secion .bss |
文件处理程序
1 | # 目的: 将输入文件的所有字母都转换为大写字母,然后输出到输出文件 |
汇编、链接并执行
1 | as touuper.s -o toupper.o |
C语言转汇编
1 |
|
查看内存布局
使用GCC将上面C代码编译成二进制文件,并用size命令查看内存布局:
1 | // 编译 |
C代码转换成汇编语言
gcc -fno-asynchronous-unwind-tables -S test.c -o test.s
-S选项用于指示GCC将C语言转换成汇编语言,-fno-asynchronous-unwind-tables
用于去掉.cfi_startproc
等汇编标签。
test.s文件内容如下:
1 | .file "test.c" |
注意test.c里面的main函数并不是汇编程序真正的入口(_start),要查看完整汇编信息,可以使用objdump命令
1 | objdump -S ./test # 列出test二进制文件详细的汇编信息 |
输出内容摘录如下:
1 | ... |