【CSAPP】程序编码

时间:2023-01-06 01:23:45

1. 引言

用高级语言(CJava等)编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定类型的机器密切相关的。
即使编译器承担了生成汇编代码的大部份工作,对于严谨的程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。

  • 通过阅读汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。
  • 高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为。如线程是如何共享数据和保持数据私有的。
  • 程序遭到攻击的许多方式中,都涉及程序存储运行时控制信息的方式和细节。了解这些漏洞是如何出现的,以及如何防御它们,需要具备程序机器级表示的知识。

精通细节是理解更深和更基本概念的先决条件。

我们常见的计算机体系结构有armx86IA32即“Intel 32位体系结构”;IA64即“Intel 64位体系结构”,也称x86_64
这些年来,许多公司生产出了与Intel处理器兼容的处理器,能够运行完全相同的机器级程序。其中领头的是AMD

假设一个c程序,有两个文件p1.cp2.c

linux> gcc -Og -o p p1.c p2.c

gccGCC C编译器,这是Linux上默认的编译器,也可以简单地用cc来启动它。
编译选项-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级,并带符号表。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。

-g表示编译产物带符号表,-O0表示不优化,-O1表示一级优化,-O2表示二级优化。优化等级越高、性能越好,但机器代码与源代码的对应关系越难以理解。

2. 机器级代码

计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对机器级编程来说,有两种抽象尤为重要:

  • 指令集体系结构指令集架构Instruction Set ArchitectureISA)定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。它将程序行为描述成按顺序依次执行指令。

  • 机器级程序使用的地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。

编译过程中,将用C语言描述的抽象的程序转换为处理器执行的基本的指令。汇编代码非常接近机器代码,只不过采用了可读性更好的文本格式表示。一条机器指令有其唯一对应的汇编指令,反之亦然。理解汇编代码及它与原始C代码的联系,是理解计算机如何执行程序的关键一步

x86-64的机器代码和原始的C代码差别非常大。在机器代码中,一些通常对C程序员隐藏的处理器状态都是可见的:

  • 程序计数器(PC%rip寄存器)给出将要执行的下一条指令的地址。
  • 整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址和整数。
  • 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。
  • 一组向量寄存器可以存放一个或多个整数或浮点数值。

机器代码把虚拟内存看成一个很大的、按字节寻址的数组。汇编代码不感知数据类型,任何类型的数据都不过是一组连续的字节。一条机器指令只执行一个非常基本的操作。

使用一张图描述各级抽象及其之间的关系,其中上层是对下层的抽象。

编译
执行
系统调用
C/C++,Java语言
汇编指令,机器指令
指令集,虚拟内存
硬件+系统软件

3. 代码示例

3.1. 编译

mstore.c的内容如下:

long mult2(long, long);

void multstore(long x, long y, long *dest)
{
    long t = mult2(x, y);
    *dest = t;
}

在命令行上使用-S选项,就能看到C语言编译器产生的汇编代码mstore.s

[liheng@localhost2 3]$ gcc -Og -S mstore.c
[liheng@localhost2 3]$ ls
mstore.c  mstore.s

汇编代码文件mstore.s中包含下面几行:

mulstore:
    pushq %rbx
    movq %rdx, %rbx
    call mult2
    movq %rax, (%rbx)
    popq %rbx
    ret

上面代码中每个缩进去的行都对应一条机器指令

3.2. 汇编

如果我们使用-c选项,gcc会编译并汇编该代码生成mulstore.o,它是二进制格式的文件,无法直接查看。

[liheng@localhost2 3]$ gcc -Og -c mstore.c
[liheng@localhost2 3]$ ll mstore.o
-rw-rw-r--. 1 liheng liheng 1368 331 18:51 mstore.o

1368字节的文件中有一段14字节的序列,它的十六进制表示为:

0000040 4853 d389 00e8 0000 4800 0389 c35b

这就是上面列出的汇编指令对应的目标代码。从中得到一个信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。

我们可以通过lldbgdb查看程序的字节表示。x/14bx multstore命令以16进制的形式显示从函数multstore所处地址开始的14个字节(使用-g编译出来的目标文件里才有符号表)。第一个x表示“显示”,14b表示14个字节,第二个x表示16进制。

(gdb) x/14bx multstore
0x0 <multstore>:	0x53	0x48	0x89	0xd3	0xe8	0x00	0x00	0x00
0x8 <multstore+8>:	0x00	0x48	0x89	0x03	0x5b	0xc3

3.3. 反汇编

反汇编器根据机器代码产生一种类似于汇编代码的格式,使用objdump -d multstore.o可以生成目标文件对应的汇编代码。

[liheng@localhost2 3]$ objdump -d mstore.o

mstore.o:     文件格式 elf64-x86-64


Disassembly of section .text:

0000000000000000 <multstore>:
   0:	53                   	push   %rbx
   1:	48 89 d3             	mov    %rdx,%rbx
   4:	e8 00 00 00 00       	callq  9 <multstore+0x9>
   9:	48 89 03             	mov    %rax,(%rbx)
   c:	5b                   	pop    %rbx
   d:	c3                   	retq

这里只反汇编了.text节,如果要反汇编所有节,需要把-d改成-D

我们看到.text14个字节的指令序列被分成了若干个组,每组有1 ~ 5个字节,每组都是一条指令,右边是等价的汇编语言。其中一些关于机器代码和它的反汇编表示的特性值得注意:

  • x86-64的指令长度是不等长的。有些指令(常用指令)需要的字节数少,有些指令(不常用指令)需要的字节数多。
  • 设计指令格式的方法是,从某个给定位置开始,可以将字节唯一地解码成机器指令
  • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。反汇编器的输入只有.o
  • 反汇编器使用的指令命名规则与gcc生成的汇编代码使用的有些细微差别。在我们的示例中,它省略了很多指令结尾的q,这些后缀是大小指示符,在大多数情况下可以忽略。而反汇编器给callret指令添加了q后缀,省略这些后缀也没有问题。

3.4. 链接

生成实际可执行的代码需要对一组目标代码文件运行链接器。而这一组目标代码文件中必须含有一个main函数。main.c的内容如下:

#include <stdio.h>

void multstore(long, long, long *);

int main()
{
    long d;
    multstore(2, 3, &d);
    printf("2 * 3 --> %ld\n", d);
    return 0;
}

long mult2(long a, long b)
{
    long s = a + b;
    return s;
}

使用如下命令生成可执行文件prog

[liheng@localhost2 3]$ gcc -Og -o prog main.c mstore.c
[liheng@localhost2 3]$ ll prog
-rwxrwxr-x. 1 liheng liheng 8496 331 19:28 prog

prog比较大,因为它不仅包含了两个过程的代码,还包含了用来启动和终止程序的代码。反汇编prog文件,部分内容如下:

0000000000400568 <multstore>:
  400568:	53                   	push   %rbx
  400569:	48 89 d3             	mov    %rdx,%rbx
  40056c:	e8 f2 ff ff ff       	callq  400563 <mult2>
  400571:	48 89 03             	mov    %rax,(%rbx)
  400574:	5b                   	pop    %rbx
  400575:	c3                   	retq
  400576:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  40057d:	00 00 00

multstore的定义这部分与mstore.o反汇编产生的代码几乎一样。但有如下区别:

  • 左边列出的地址不同:连接器将这段代码的地址移到了一段不同的地址范围中。
  • 链接器填上了callq指令调用函数mult2需要使用的地址。链接器的任务之一就是为函数调用找到跳转的位置。
  • 多了两行代码,多出来的两条指令对程序没有影响,因为它们出现在了retq指令后面。插入这些指令是为了满足对齐要求,使得就存储器系统性能而言,能更好地放置下一个代码块。

3.5. 关于格式的注释

gcc产生的汇编代码对程序员而言有点难读。如我们用如下命令生成文件mstore.s

[liheng@localhost2 3]$ gcc -Og -S mstore.c
[liheng@localhost2 3]$ cat mstore.s
	.file	"mstore.c"
	.text
	.globl	multstore
	.type	multstore, @function
multstore:
.LFB0:
	.cfi_startproc
	pushq	%rbx
	.cfi_def_cfa_offset 16
	.cfi_offset 3, -16
	movq	%rdx, %rbx
	call	mult2
	movq	%rax, (%rbx)
	popq	%rbx
	.cfi_def_cfa_offset 8
	ret
	.cfi_endproc
.LFE0:
	.size	multstore, .-multstore
	.ident	"GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
	.section	.note.GNU-stack,"",@progbits

所有以.开头的行都是指导汇编器和链接器工作的伪指令,可以忽略它们。
可以看到,汇编代码中没有关于指令的用途以及它们与源代码之间关系的解释说明。

本书中,为了更清楚地说明汇编代码,我们省略大部分伪指令,并包含解释性说明,对于上面的汇编代码,带解释的版本如下:

multstore:
	pushq	%rbx // save %rbx
	movq	%rdx, %rbx // copy dest to %rbx
	call	mult2 // call mult2(x, y)
	movq	%rax, (%rbx) // store result at *dest
	popq	%rbx // restore %rbx
	ret // return

4. ATT与Intel汇编代码格式

我们的表述是ATT格式的汇编代码,这是gccobjdump和其它一些我们使用的工具的默认格式。
其它编译工具,如MicrosoftIntel的工具,其汇编代码是Intel格式的。
这两种格式有些许不同,使用如下命令,gcc可以产生multstore函数的Intel格式的汇编代码。

[liheng@localhost2 3]$ gcc -Og -S -masm=intel mstore.c

mstore.s的部分内容如下:

multstore:
        push    rbx
        mov     rbx, rdx
        call    mult2
        mov     QWORD PTR [rbx], rax
        pop     rbx
        ret

可以看到IntelATT格式在如下方面有所不同:

  • Intel格式省略了指示大小的后缀q
  • Intel格式省略了寄存器名字前面的%符号。
  • Intel格式用不同的方式描述内存中的位置。
  • 对带有多个操作数的指令,两种格式列出的操作数相反

5. 把C程序和汇编代码结合起来

对于一些C程序访问不到的机器特性,如获取PF条件码标志的值,就需要在C程序中插入几条汇编指令来实现。
C程序中插入汇编代码有两种方法:

  • 用汇编语言编写完整的函数,放在一个独立的汇编代码文件中,让汇编器和链接器把它和用C语言书写的代码合并起来。
  • 使用编译器,如gcc的内联汇编特性,用asm伪指令可以在C程序中包含简短的汇编代码。

C程序中包含汇编代码使得这些代码与特定类型的机器相关,代码的移植性会降低。