计算机组成结构—高级语言程序与机器级代码

目录

一、x86汇编指令基础

1.相关寄存器

(1)通用寄存器

(2)指令指针寄存器

(3)标志寄存器

2.汇编指令格式

3.常用汇编指令

(1)数据传输指令

(2)算术和逻辑运算指令

(3)转移指令

二、从 C 语言程序到汇编程序

1.编译器、汇编器、链接器

2.进程的地址空间

3.利用栈实现函数原理

三、选择结构语句的机器级表示

四、循环结构语句的机器级表示

1.do-while

2.while

3.for循环

五、过程(函数)调用对应的机器级表示


一、x86汇编指令基础

1.相关寄存器

(1)通用寄存器

        x86 架构的 CPU 中会设置一组 通用寄存器,用来存储整数数据和指针(地址)。

        最初的 8086 有 8 个 16 位的寄存器,分别叫做 ax、bx、cx、dx、si、di、bp、sp,每个寄存器都有各自特殊的用途,这都体现在它们的名字中。当扩展到 32 位架构(标准名称为 IA32)时,这些寄存器也都扩展为 32 位,名称前加上了 ’‘e“ 表示扩展(extended)。

        每个 32 位的通用寄存器,都可以将低 16 位当作一个 16 位寄存器独立使用,最低 8 位当作一个 8 位寄存器使用;而 ax、bx、cx、dx 的高低字节都可以分别作为两个 8 位寄存器,称为 ah、bh、ch、dh 和 al、bl、cl、dl。

        这里用途最为特殊的,就是堆栈指针 ebp 和 esp,它们配合可以很容易地实现子过程的调用和返回;另外,一般也经常用 ebp + 偏移量 的形式来定位存放在栈中的局部变量。

        扩展为 64 位的 x86-64 架构后,原先的 8 个 32 位寄存器全部扩展到 64 位,标号以 r 开头;此外还新增了 8 个通用寄存器,标号为 r8 ~ r15。

(2)指令指针寄存器

        除通用寄存器外,x86 架构的 CPU 还会设置一系列特殊功能的寄存器。其中最为重要的就是 指令指针寄存器 IP(Instruction Pointer),它存放了下一条要执行的指令的地址;很明显,这其实就是我们之前介绍的 程序计数器 PC

        最初的 ip 也是 16 位的;到了 IA32 架构下,指令指针寄存器也扩展为 32 位,称为 eip;而到了 x86-64 时代,对应也扩展成了 64 位的指令寄存器,称为 rip。

(3)标志寄存器

        标志寄存器 flags 里面有众多标志位,记录了 CPU 执行指令过程中的一系列状态,大都由 CPU 自动设置和修改。

  • ZF 零标志

  • CF 进位标志

  • SF 符号标志

  • OF 溢出标志

  • PF 奇偶标志

  • TF 跟踪标志

  • IF 中断标志

  • ...

        很显然,标志寄存器其实就是之前提到的 程序状态字 PSW。IA32 架构下标志寄存器为 32 位,称为 efl(eflags),除去一些不使用的保留位外,每一位都对应着一个状态标志;x86-64 架构下扩展为 64 位,不过扩展的高位都没有使用,相当于还是 32 位。

2.汇编指令格式

        对于 x86 指令集的汇编代码,也有两种不同的指令格式。

  • ATT 格式:由 AT&T 公司而得名,这是 GCC 等常用工具的默认格式。

  • Intel 格式:Intel 文档中和 Microsoft 编程工具采用的汇编格式。

        这两种格式整体风格相似,但也有很多不同:

        x86 是复杂指令集架构,支持多种寻址方式,两种格式的各种寻址方式如下:  

        下面是 mov 指令的一些示例。mov 指令用于移动数据,可以将立即数、寄存器和内存中的操作数,移动到寄存器或者内存中。需要注意,ATT 格式的数据传输方向是从左向右,而 Intel 格式恰好相反。

        mov 指令不能将一个内存中的操作数,移动到另一个内存地址。

        下面我们主要以 Intel 格式为例,来详细介绍 C 语言和汇编指令的对应关系。

3.常用汇编指令

(1)数据传输指令

  • mov:在寄存器和内存之间移动数据;

  • lea:load effective address,加载有效地址,将一个内存地址加载到目的寄存器;

  • push:将数据压入栈,同时 esp 减去数据长度;

  • pop:将栈顶数据弹出栈,同时 esp 加上数据长度;

(2)算术和逻辑运算指令

        主要可以按照操作数的个数分为两类:

  • 双操作数 —— add(加)、sub(减)、mul(无符号乘)、imul(有符号乘)、and(逻辑与)、or(或)、xor(异或)、sal/shl(左移)、sar(算术右移)、shl(逻辑右移)

        格式为: op D, S

        表示计算 D (op) S 的值,结果存入 D 中。

  • 单操作数 —— inc(自增)、dec(自减)、neg(取负)、not(取反)

        计算的结果仍然存入操作数所在位置。

        比较特别的是除法指令,它们都只有一个操作数,表示除数,被除数则放在 edx : eax 中;得到的结果商放在 eax 中,余数放在 edx 中:

  • div:无符号除

  • idiv:有符号除

(3)转移指令

  • 无条件转移:jmp

        jmp 指令后面一般跟一个 “标签”(label),用来指明可以直接跳转到的目的地。它的底层编码一般都是 PC 相对的,也就是相对寻址。

  • 有条件转移

        标志寄存器 efl 中有很多标志状态,也称为 “条件码”,它们记录了最近的算术逻辑操作的结果属性。通过检测这些寄存器中的条件码,就可以执行条件转移指令了。最常用的条件码有:ZF(零标志)、CF(进位标志)、SF(符号标志)、OF(溢出标志);一般会对它们进行组合,用来表示更加容易理解的控制条件。

        上面的条件跳转指令,需要先做一个算术或逻辑运算、更改条件码;而很多时候我们只需要做一个简单比较即可,并不需要将运算结果保存。有两类特殊指令可以只改变条件码、而不改变其它任何的寄存器:  

        这两种指令,特别是 cmp 经常和条件转移指令配合使用,用来实现条件分支(选择)和循环结构的程序。

  • 调用和返回

    进行子过程(函数)调用时,使用 call 指令;返回原函数时使用 ret 指令。

二、从 C 语言程序到汇编程序

1.编译器、汇编器、链接器

        用高级语言编写好一段程序之后,需要经过一系列“翻译“过程,才能得到计算机能够执行的机器代码。比如,我们用 C 语言写了一个简单的 hello world 程序,源程序文件命名为 hello.c,用 GCC 编译器可以将它翻译成一个可执行目标程序 hello。具体的过程如下图所示:

        第二步编译的结果,生成了汇编程序 hello.s,这就是汇编语言描述的机器指令;汇编程序再经过汇编就可以得到二进制的机器语言程序。

        在一些集成开发环境(比如 Visual Studio)中,可以在调试(Debug)模式下对机器码进行 ”反汇编“,得到相应的汇编语言代码。

2.进程的地址空间

        C 语言程序运行之后,对应的进程都会有自己独立的地址空间;这就是操作系统为每个进程提供的 虚拟地址空间。对于 32 位系统,进程虚拟地址空间的大小就是 2^32^ B = 4 GB。

        整个虚拟空间需要操作系统统一管理,因此进程的虚拟地址空间中必须保留一部分给操作系统内核使用。对于 Linux 系统(32 位),内核区大小为 1GB,地址从 0xc0000000 ~ 0xffffffff;而 Windows 系统默认情况下内核区大小为 2GB,地址从 0x80000000 ~ 0xffffffff。其余低地址部分则为用户区。

        下面是一个 x86 Linux 进程的虚拟地址空间。

        用户区主要包括这样几部分:

  • 程序代码和数据:主要包括 只读代码段读写段(.data 和 .bss)。对所有进程来说,代码都是从固定地址开始,紧接着就是 C 语言中的全局和静态数据。其中 .data 中是已初始化的全局和静态 C 变量,而 .bss 中是未初始化的全局和静态变量。

  • 堆(Heap):用于运行时的动态内存分配,向上(高地址)生长。代码和数据区,在进程开始运行时就被指定了大小;而通过调用 malloc 和 free 这样的 C 标准库函数,可以让堆区动态地扩展和收缩。

  • 共享库的内存映射区:用户区的中间部分是一块内存映射区域,用来存放像 C 标准库这样的共享库。

  • 用户栈(Stack):位于虚拟地址空间用户区顶部,向下(低地址)生长。一般用来存储局部变量和函数参数,结合堆栈指针可以方便地实现函数的调用和返回。

3.利用栈实现函数原理

         C 语言中的函数是一种重要的抽象,它将代码按功能封装起来,让程序结构更加清晰、可重用性更高。

        每个函数内部可以定义局部变量,这些变量只具有局部作用域。所以,嵌套函数调用时(例如,在函数 P 中调用函数 Q),就可以利用栈数据结构 ” 后进先出 “(LIFO)的特点,在栈内依次保存函数 P 和 Q 的相关内容。

        这样,进程中的每一个函数,都会在栈上有一块自己的空间,就叫做 ” 栈帧 “(stack frame);当前正在执行的函数的栈帧总是在栈顶。这样 esp 的内容就是栈顶地址,而 ebp 的内容就保存当前栈顶栈帧的 “底部” 地址。

        于是,在调用一个函数 Q(子过程)时,可以在栈上继续给 Q 中的局部变量分配内存空间(入栈)。当 Q 调用结束,就将 Q 的所有局部变量释放(出栈)。

        想要用机器级代码实现函数调用,还需要考虑下面几个问题:

① 参数传递:函数 Q 应该能获取到 P 传入的参数;这可以通过指定参数存放的位置(写入寄存器或者入栈)来实现。

② 转移控制:调用 Q 时,需要跳转到函数 Q 入口处执行指令,这可以用 call 指令实现;调用结束,还应返回到 P 中的调用点继续执行,这需要保存之前调用点的信息,将下一条指令地址入栈。  

 

        等到调用结束时,执行 ret 指令返回,就执行出栈操作,将栈中保存的地址交给 eip,继续执行 P 中的下一条指令。  

③ 保存上下文:原函数 P 使用的寄存器的内容,应该进行保存;调用结束后,还应该进行恢复。

        调用 Q 后,可以先将原函数 P 的上下文(寄存器值)做一个入栈保存;然后再分配内存给 Q 的局部变量。待 Q 调用结束后,先释放 Q 的局部变量,然后继续弹栈恢复 P 的上下文。

        比较特殊的是栈基指针 ebp,在调用 Q 之后,它应该指向 Q 的栈帧的底部;所以应该先将之前的 ebp(P 的栈帧底部)入栈,然后将 ebp 移向 esp 的位置。之后再保存 P 其它寄存器的值、分配空间给 Q 的局部变量。  

push  ebp
mov   ebp, esp

        当 Q 调用结束返回时,只要反向执行,让 esp 移向 ebp 的位置;再将原先保存的 ebp 的值弹出,并放入 ebp 中就可以了:

mov   esp, ebp
pop   ebp

         对于函数调用开始时(call 之后)两条对 ebp 的处理指令,可以用一条 enter 指令来代替;函数结束时(ret 之前)的两条指令,则可以用 leave 来代替。

④ 返回值传递:函数 Q 调用结束,执行 ret 指令,此时应该能将返回值传回原函数 P;可以通过指定某个寄存器(eax)接收返回值来实现。

        这样,我们可以将完整的栈帧结果表示如下:

        栈帧中主要由 4 部分构成:上一层函数的上下文(主要是寄存器的值)、当前函数的局部变量、调用下一层函数所需的参数,以及返回地址。对于当前执行的函数 Q,没有参数构造区和返回地址两部分。

三、选择结构语句的机器级表示

        除顺序结构外,高级语言程序中一般还会有选择结构和循环结构。

        选择结构 又称为 分支(branch)结构。C 语言中的选择语句主要有 if ... else 和 switch ... case,此外三目运算符 ? : 也可以实现选择结构。

        很显然,通过设置条件码(标志位)、结合各类转移指令,就可以很容易地实现程序中的选择语句。

        下面一段 C 语言代码使用 if - else 语句实现了选择结构:

int a = 23;
int b = 31;
if (a > b)
{
    a++;
}
else {
    a--;
}
printf(" a = %d\n", a);

        可以发现它等效于使用 2 个 goto,分别跳过 if 后面的分支和 else 后面的分支:

if (a <= b)
        goto L1;
    a++;
    goto L2;
L1:
    a--;
L2:
    printf(" a = %d\n", a);

         这样,很容易得到对应的汇编代码:

四、循环结构语句的机器级表示

        C 语言中的循环语句有 do - while、while 和 for 三种。汇编语言中,同样可以用条件测试和跳转指令的组合来实现循环的效果。在循环结构中,通常使用条件转移指令来判断循环的结束。

1.do-while

        下面是一段使用了 do - while 循环的 C 语言代码:

int a = 0;
do {
    a++;
} while (a < 5);
printf(" a = %d \n ", a);

        很明显,循环部分可以利用 if 判断和 goto 语言来实现:  

int a = 0;
L1:
    a++;
    if (a < 5)
        goto L1;
    printf(" a = %d \n ", a);

        这样,类比之前选择结构,可以得到对应的汇编表示如下:  

2.while

        while 循环与 do - while 类似,区别在于第一次执行循环体之前就要先做条件判断。下面是一段 while 循环的 C 语言代码:

int a = 0;
while (a < 5) {
    a++;
}
printf(" a = %d \n ", a);

        如果用 goto 进行改写,可以参考 if - else 的实现,使用两个 goto 分别进行满足、不满足循环条件时的跳转:

int a = 0;
L1:
    if (a >= 5)
        goto L2;
    a++;
    goto L1;
L2:
    printf(" a = %d \n ", a);

         于是对应的汇编程序如下:

3.for循环

        for 循环可以将循环变量和循环条件统一列出,因此对程序员来说是最友好的;但它直接转换成汇编语言会有一定的难度。for 循环的基本形式如下:  

for( 初始化循环变量; 判断循环条件; 更新循环变量)
    循环体

        可以把它先改写成 while 循环的形式:  

初始化循环变量;
while( 判断循环条件 )
{
    循环体;
    更新循环变量;
}

        这样,就可以用 goto 改写如下:  

初始化循环变量;
L1:
    if(循环条件不成立)
        goto L2;
    循环体;
    更新循环变量;
    goto L1;
L2:
    循环外语句;

        当然,如果不改写成 while、完全按照 for 循环的执行顺序来处理,就会麻烦很多。比如下面是一段 for 循环的 C 语言代码:

int a = 10;
for (int i = 0; i < 5; i++)
{
    a--;
}
printf(" a = %d \n ", a);

        对应的汇编代码如下:

        很明显,本质上三种循环都是等效的,而比较之下 do-while 循环的汇编指令最简洁。因此,大多数编译器会进行优化,将另两种循环语句转换为 do-while 语句形式来生成机器代码。

        另外,x86 架构指令集还提供了一个 loop 指令专门用于循环的实现。它默认使用 ecx 寄存器作为循环计数器,每次执行到 loop 指令都会先对 ecx 做减 1 操作;然后判断 ecx 是否为 0,如果不为 0 则跳转到 loop 后面标号对应的位置,如果为 0 则循环结束继续向下执行。

mov ecx, 10
.L1:
mov eax, dword ptr [a]
sub eax, 1
mov dword ptr [a], eax
loop .L1

        上面的 loop 指令相当于:  

dec ecx
cmp ecx, 0
jne .L1

        这样减少了指令数,汇编代码的可读性更高了。除 loop 外,还有类似的 loopz 和 loopnz 指令,它们判断循环继续的条件除了 ecx != 0 外,还有对 ZF 的要求。  

五、过程(函数)调用对应的机器级表示

        下面是一段 C 语言的函数调用过程。我们在 main 函数中调用了 add 函数,进行两个整数的求和。

int add(int x, int y);

int main() {
    int a = 23;
    int b = 31;

    int sum = add(a, b);

    printf(" sum = %d\n", sum);
}

int add(int x, int y) {
    int sum = x + y;
    return sum;
}

        函数调用时,首先应该跳转之前,将需要的参数进行保存;然后执行 call 指令,同时将下一条指令地址入栈。主程序 main 对应的汇编代码如下:

 

        调用函数 add 后,首先应该将原来的 ebp 入栈,并将 ebp 指向当前 esp 的位置(add 函数栈帧的底部);然后保存之前其它寄存器的值(入栈)。之后分配局部变量的空间,执行 add 内部指令。执行完毕后,做反向操作,弹栈恢复所有寄存器的值,返回 main 函数的调用处继续执行。

        add 函数对应的汇编指令如下:  

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值