理解C语言(零) 导读(上):C程序的编译过程- 机器级表示


1 从Hello world说起

Hello world是初学者使用任何一项编程语言最基本最简单的程序。下面是一个C语言版的"Helloworld" :

#include <stdio.h>
int main(){
	printf("Hello wolrd\n");
    return 0;
}

这段程序被编译、链接后会生成一个可执行文件,在操作系统中运行这个程序,屏幕会输出"Hello world"。在输出结果的背后,它究竟做了怎样的工作,比如C程序是如何执行的、C程序是如何加载到内存中、它又是如何输出到屏幕上的?这里既涉及到了C语言程序的执行过程,又涉及到了与操作系统交互的行为。这篇文章,我们将主要从编译器GCC的角度看C语言程序的执行过程。

gcc编译器并不是一个单一庞大的编译器,通常它是由多达六七个稍小的程序所组成,这些程序是由一个叫编译器驱动程序来调用。从宏观角度讲编译器有以下几个可分离出来的单独程序,包括:预处理器(preprocessor)、编译器(compiler,又分为两个部分:前端,进行语法和语义解析,生成一抽象语法树;后端,进行代码生成和相关的代码优化)、汇编器(assembler)、链接器(linker)

假设源程序文件名为hello.c,图1是gcc编译器运行hello.c源程序的执行过程:

下面我们以讲解gcc基本用法Usage: gcc [options] [filename] 来简略描述hello.c的运行过程

1.选项 -E : 预编译过程,处理宏定义和include,并作语法检查

gcc -E hello.c -o hello.i              #将hello.c预处理输出为hello.i文件

2.选项 -S : 编译过程,生成通用的汇编代码

gcc -S hello.c                         #生成汇编代码hello.s

3.选项 -c : 汇编过程,生成ELF格式的可重定位目标文件,目标文件(机器代码,),用文本编辑器打开是乱码

gcc -c hello.c                         #生成目标代码hello.o(中间文件),不能执行,在Makefile中应用广泛

4.选项 -L : 链接过程,将.o文件与所需库文件链接合并成ELF格式的可执行目标文件,分静态链接和动态链接

gcc hello.o -L dir(如./lib)            #指定库搜索路径,有多个则从前往后搜索

5.选项 -l : 链接过程,指定链接库,库命名规则是libxxx.a,指定库名时使用的格式是-lxxx

gcc hello.c -o hello -lm              #链接数学库
ld -o hello hello.o -lxxx             #链接xxx库

6.选项 -o : 将源文件预处理、编译、汇编并链接形成可执行目标文件,-o选项指定可执行文件的文件名,加载到内存中即可执行

gcc hello.c -o hello                  #生成可执行文件hello

7.部分选项 :
选项 -Wall : 编译时打开警告信息开关
选项 -D : 在文件中定义宏INFO,编译时加上-D INFO使其生效
选项 -O : 后指定数字,使用编译优化级别1~3优化程序
选项 -g : 产生调试信息

8.选项 -static : 使用静态链接库,将使用的静态库对象嵌入至可执行映像文件中,加载时无需进一步的链接

gcc -c -Wall x1.c x2.c       #生成目标文件
ar -cru libxxx.a x1.o x2.o   #创建静态库
#定义静态库的应用接口xxx.h,里面显式引用上面的源文件函数和对象
gcc -O2 -c main.c            #测试用例调用静态库的函数
gcc -static -o p main.o ./libxxx.a  #链接静态库和目标文件生成可执行文件p

9.选项 -share : 使用共享库,在运行时动态加载目标程序所需要的信息
选项 -fPIC : 指示编译器生成与地址无关的目标文件(position-independent code)

gcc -shared -fPIC -o libxxx.so x1.c x2.c  #生成共享库libvector.so
gcc -o p1 main.c ./libvector.so           #共享库中的目标对象并未嵌入可执行文件中,执行时完成链接过程

最后由链接器ld合并到的可执行文件hello就可被加载到内存中,由系统执行。 下面就具体来介绍程序执行时经历的各个过程


2 编译阶段:C程序的机器级表示

2.1 数据的表示、C代码和机器级代码的联系

A. 数据表示
对于C语言来说,它支持整型数据、浮点数据等多种采取不同编码方式的数据类型。从机器角度看,他们又是一样的,均表示为一个连续的字节序列。

根据机器的不同,数据使用的字节顺序也有所不同:

  • 小端法:最低有效字节存储在所用字节中的最低地址。随着地址的增大,它在存储器中按照最低字节到最高字节的顺序进行存储。绝大部分Intel兼容机都是采用小端法,如Linux的IA32和x86-64机器,Windows的IA32机器

  • 大端法:最高有效字节存储在所用字节中的最低地址。随着地址的增大,它在存储器中按照最高字节到最低字节的顺序进行存储。大多数IBM和Sun机器采用大端法,如运行Solaris的Sun Sparc处理器

下面是一段C代码,它使用强制类型转换来访问和打印不同数据对象的字节表示,byte_p表示一个指向无符号字符对象的指针,即一个字节指针引用一个字节序列。可用它来测试你的机器中类型为int、float、void *的对象字节表示和字节顺序,程序如下:

#include <stdio.h>

typedef unsigned char *byte_p;

void show_bytes(byte_p start,int len){
	for(int i=0;i < len;i++)
		printf(" %.2x",start[i]);
	printf("\n");
}

void show_int(int x){
	show_bytes((byte_p) &x,sizeof(int));
}

void show_float(float x){
	show_bytes((byte_p) &x,sizeof(float));
}

void show_pointer(void *x){
	show_bytes((byte_p) &x,sizeof(void *));
}

void test3(){
	int val=0x87654321;

	byte_p valp=(byte_p)&val;
	show_bytes(valp,4);
	show_int(val);

	float val1=0x4A564504; //3510593
	show_float(val);

    printf(" %p\n",&val);
	show_pointer(&val);

	/**
	 * 21 43 65 87
       * 21 43 65 87
       * 7a 35 f1 ce
       * 0x7fffb73c3d58
       * 58 3d 3c b7 ff 7f 00 00
	 */
}

int main(){
    test3();
	//小端法:21 43 65 87(低地址低字节序列)
	//大端法:87 65 43 21(低地址高字节序列)
	return 0;
}

B. C代码和机器级代码的联系
利用gcc编译一个C程序code.c以汇编代码的形式输出,如果使用-c选项它将会生成目标代码文件。

//code.c
int accum=0;
int sum(int x,int y){
	int t=x+y;
	accum+=t;
	return t;
}

利用Objdump反汇编器工具我们可以查看目标代码文件的内容,它产生的是一种类似于汇编代码的格式。OBJDUMP是所有二进制工具之母,它能够显示任何一个目标文件中的信息(三种形式的目标文件均可)- objdump -d code.o,如下:

可看出IA32的机器代码和原始C代码差别是很大的,但是我们还是需要学习C程序的机器级表示呢?因为

  • 相比二进制格式的机器级代码,汇编代码可读性更好,它是机器代码的文本表示,给出了程序中的每条指令。理解汇编代码和原始C代码的联系,是理解计算机如何执行程序的关键一步。

  • 阅读汇编代码,有助于我们理解编译器的优化能力,并分析代码中的低效率。
    注:编译器如果使用更高的优化级别优化程序,它可能会使产生的代码严重改变形式,比如快速操作代替慢速操作,递归计算变成迭代计算,对应关系就不太容易理解。

  • 理解汇编代码,有助于我们了解程序运行时行为的信息。
    我们会了解程序如何将数据存储在不同的存储器区域中,例如我们需要知道一个变量是否在运行时栈中,还是动态分配的堆中,还是全局区域中。
    知道程序是如何映射到机器上是很重要的;再例如从这些机器表示中我们就能理解存储器访问越界是如何产生的,为什么蠕虫和病毒能够利用这些漏洞信息获得程序的控制权,以及出现了这种问题我们该如何防御它

  • 高级语言的代码隐藏了程序的具体运行过程,而机器通过指令集体系结构和虚拟地址的实现屏蔽了程序的细节。它能在机器上运行实际上是一系列机器代码指令的执行序列。学习程序的机器级表示是连接高级语言与机器指令执行的桥梁。它有助于我们通过研究系统的逆向工程真正了解程序运行时的创建过程

再次强调,C语言提供了一种模型,可以在存储器中声明和分配各种数据类型的对象,但汇编代码中它只是简单地将存储器看成一个很大的、按字节寻址的序列,不区分有符号数和无符号数,不区分各种类型的指针。下面我们从汇编代码的角度描述C语言各种数据、结构等的表示,主要有以下几类:

  • 数据的汇编表示和处理
  • 机器级程序如何实现控制结构(if,else,while,switch语句)
  • 机器级程序如何维护一个运行时栈来控制过程间数据和控制的传递及存储
  • 机器级程序如何表示像数据、结构和联合这样的数据结构

C. AT&T汇编代码格式
这里所采用的是AT&T格式的汇编代码,它也是GCC、OBJDUMP和其他一些我们常用工具的默认格式。其他的诸如Microsoft的工具采用的是Intel的形式。它们之间所有所不同,一般地:

  • AT&T代码不能省略指示大小的后缀,比如指令movl(在利用反汇编器它省略了后缀指示符)
  • AT&T代码不能省略寄存器前面的'%'符号,比如%esp
  • AT&T代码用诸如8(%ebp)描述存储器中位置,并且在涉及多个操作数的指令情况下,源操作数在前面,目的操作数在后面

2.2 数据访问、传送和算术运算

我们第一个要关心的就是C语言数据类型对应的IA32是如何表示的。假定16位表示一个字,32位数成为双字,64位称为四字。下面是C语言数据类型在IA32中的大小:

C声明 汇编代码后缀 大小(字节)
char b- 字节 1
short w- 字 2
int l- 双字 4
long l- 双字 4
long long - 8
void * l- 双字 4
float s- 单精度 4
double l- 双精度 8
long double t- 扩展精度 10/12

C语言对于机器来说它们则是统一连续的字节序列。我们关注C语言的数据类型,如何区分不同的数据类型,实际上就是关注机器如何存储字节的方式-寻址模式

2.2.1 寻址模式与数据访问

通常为了快速访问和存储数据,IA32 CPU中央处理单元会提供一组8个存储32位的整数寄存器。名字以%e开头,一般来说前6个寄存器都可堪称通用寄存器,它的使用没有限制。

注意:

  • 这组IA32整数寄存器,有些可以存储C语言中的指针和整数数据,有些用来记录某些重要的程序状态,而有些用来保存临时数据如局部变量和函数的返回值
  • 所有的8个寄存器都可以作为一个字(16位)或32个字(双字)来访问。并且可以独立访问前4个寄存器的两个低位字节
  • 过程处理中%eax %ecx %edx的保存和恢复不同于接下来的三个寄存器%ebx %esi %edi
  • 堆栈管理中%ebp表示帧指针,%esp表示栈指针。 运行时,栈指针可以移动,因而信息的访问都是相对于帧指针的

在机器指令中这些存储的数据通常称为操作数,它指出执行一个操作中要引用的源数据值,以及放置结果的目标位置。源数据值可以以立即数或从寄存器或存储器中读出,结果可以存放在寄存器或存储器中。

操作数有三种类型:

  • 立即数: 书写方式为$0xFF
  • 寄存器: 用来表示寄存器的内容。用\(E_a\)表示任意寄存器\(a\),用引用\(R[E_a]\)表示其内容。
  • 存储器引用:根据计算出来的有效存储器地址,访问某存储器位置。用\(M[Addr]\)表示对存储在存储器地址Addr开始的b个字节值的引用

下面是不同的数据寻址模式,它允许不同形式的存储器引用。\(Imm(E_b,E_i,s)\)是最常见的组成部分,它表示一个立即数偏移\(Imm\),一个基址寄存器\(E_b\),一个变址寄存器\(E_i\)和一个比例因子\(s\),它的有效地址则表示为$Imm+R[E_b]+R[E_i ] * s $。如下图

寻址模式

2.2.2 数据传送命令

数据传送指令主要是由MOV类指定完成,根据操作数的大小不同,把它分成: movb、movw、movl

MOV类的指令是将源操作数的值复制到目的操作数中。其中源操作数指定的值要么是一个立即数,要么是存储在寄存器或存储器中,目的操作数指定一个位置,要么是一个寄存器,要么是一个存储器地址。

注意传送指令的两个操作数不能都指向存储器位置,将一个值从一个存储器位置复制到另一个存储器位置需要两条指令-第一条指令由源值加载到寄存器中,第二条将该寄存器写入目的存储器位置中。

如下几个实例(第一个是源操作数,第二个是目的操作数):

movl $0x4050 %eax     // 立即数的内容存储到寄存器%eax中,4个字节
movl $-17,(%esp)      // 立即数的内容到存储器中,4个字节
movw %bp,%sp          // 寄存器的内容复制到另一个寄存器中,2个字节
movl %eax,-12(%ebp)   // 寄存器的内容到存储器中,4个字节
movb (%edi,%ecx),%ah // 存储器的内容到寄存器中,1个字节

有两个数据传送操作需要重点说明下- 一将数据压入程序栈中,二从程序栈中弹出数据。

在IA32中,程序栈放在存储器某区域一端插入和删除元素,栈向下增长,因而栈顶元素是所有栈中元素地址最低的,栈指针%esp保存着栈顶元素的地址。其中pushl指令的功能是把数据压入到栈中,而popl指令是弹出数据,均只有一个操作数-压入的数据源和弹出的数据目的。

  • 压栈:栈指针-4,再将值写到新的栈顶地址。
    pushl %ebp的行为等价于以下两条指令。它们区别在于pushl指令编码为1个字节,而上面两条指令一共6个字节
subl $4,%esp         栈指针减去4,压栈
movl %ebp,(%esp)     把帧指针的内容存在栈%esp的位置上
  • 出栈:从栈顶位置读出数据,并将指针值加4。
    popl %eax等价于以下两条指令。
movl (%esp),%eax  从存储器中读出数据再写到寄存器%eax中
addl $4,%esp      寄存器%esp的值增加4

总之无论如何,%esp指向的地址总是指向栈顶,任何存储在栈顶之外的数据都被认为是无效的。

下面举一个例子:元素交换操作

在理解目标文件的代码段之前首先需要了解几点:

  • 注意区分代码中数据传送命令的源操作数和目的操作数。寄存器还是存储器引用还是立即数
  • 过程调用的汇编代码格式通常比较固定:保存旧的帧指针,再把栈指针当作帧指针来使用,相对于栈指针位置配一定大小的内存,执行函数的代码操作,栈做好返回的准备最后退出当前栈
  • 过程调用中不建议直接使用相对于%ebp的参数和临时变量做数据运算,而是利用寄存器%eax %edx %ecx保存参数和临时变量来操作
  • C语言中的指针其实就是地址,间接引用指针实际上是将指针放在存储器中,然后在存储器引用使用这个寄存器(所以必须掌握数据传送的寻址模式)。再次看出C语言中各种数据类型表示在汇编代码中是没有区别的,不同的是数据的寻址模式不一样决定了它具体的表示意义,是读还是写,是指针(地址引用)还是基本数据类型。

如下是对这段汇编代码的注释:


push %ebp            //保存旧帧指针
mov  %esp,%ebp       //旧的栈指针作为新的帧指针
sub  $0x10,%esp     //在栈上分配16(0x10)字节内存

mov 0x8(%ebp),%eax   //从存储器(%ebp+8)中读出参数x(实际为地址),存在寄存器%eax中
mov (%eax),%eax      //存储器引用该寄存器,取出该地址的内容*x,存在%eax中
mov %eax,-0x4(%ebp)  //将寄存器%eax的内容写入到存储器(%ebp-4)的位置中 temp=*x

mov 0xc(%ebp),%eax
mov (%eax),%edx      //这两步和前面类似,将第二个参数y(%ebp+12)存到%eax,并把其内容加载到寄存器%edx中
mov 0x8(%ebp),%eax
mov %edx,(%eax)      //将地址y的内容%edx赋值给地址x对应的存储器(%eax)中 *x=*y

mov 0xc(%ebp),%eax
mov -0x4(%ebp),%edx
mov %edx,(%eax)      //这三步分别取出y,temp然后再赋值 *y=temp

leave
ret 

2.2.3 算术和逻辑操作

如下图为一些整数和逻辑操作,给出的每个指令类都有对字节、字和双字数据进行操作的指令,如ADD类指令有addb addw addl 。通常这些操作分为四组:加载有效地址、一元和二元操作、移位。

A. 加载有效地址
加载有效地址指令leal的形式是从存储器读取数据到寄存器,实际上它并没有引用存储器,它并不从指定的位置读取数据,而是将有效地址写入目的操作数中,同时它也能简洁描述普通的算术操作。

例如:

leal 7(%edx,%edx,4),%eax   //设置存储器%eax的值为5x+7,%edx的值为x
leal 6(%edx),%eax          //设置存储器%eax的值为x+6

B. 一元和二元操作
一元操作,只有一个操作数,既是源又是目的。该操作数可以是一个寄存器,也可以是一个存储器位置。有自增、自减、取负、取补运算,类似于C语言中的++和--运算符

二元操作,第二个操作数既是源又是目的。第一个操作数可以是立即数、寄存器或存储器位置,第二个操作数可以是寄存器或者存储器位置,不过两个操作数不能同时是存储器位置(需要两条指令)。例如:

subl %eax,%edx     //从寄存器%edx中减去%eax中
addl %ecx,4(%eax) //从寄存器%eax+4中加上%ecx中

C. 移位操作
这一组是移位操作,第一项是给出移位量,可以是立即数或者单字节寄存器,第二项给出的是要移位的数值,可以是寄存器或者存储器位置。

左移指令有两个名字SAL SHL,效果都一样,都是将右边填上0。右移指令不同,SAR执行算术移位(填上符号位),SHR执行逻辑移位(填上0)。例如:

sall $2,%eax   // x << =n
sarl %c1,%eax  // x >> =n
xorl %edx,%edx //等价于movl $0,%edx,但它只需要2个字节,movl需要5个字节编码

2.3 C语言控制结构的汇编表示

在C语言中有提供这样的结构:条件语句、循环语句和分支语句。那么在机器级指令中提供怎样的机制来实现C语言控制结构的行为。它的主要思路是借助条件码寄存器和跳转指令来实现有条件的行为。

现代计算机中实现条件操作有两种方法:利用控制的条件转移和数据的条件转移。数据的条件转移更好地匹配了现代处理器的性能特性,但这里主要讲述控制的条件转移这种传统方法。

2.3.1 条件码和跳转指令

CPU除了提供上面的几个整数寄存器外,还维护着一组单个比特位的条件码,描述最近的算术或逻辑操作特性,用于执行条件分支指令。

  • CF: 进位标志,表示最近的操作使最高位产生了进位。用于检查无符号操作数的溢出
  • ZF: 零标志,表示最近的操作得出的结果为0
  • SF: 符号标志,表示最近的操作得出的结果为负数
  • OF: 溢出标志,表示最近的操作使补码溢出-正溢出或负溢出

有几种设置条件码的情形
A. 比较和测试指令:它们只设置条件码而不改变任何其他寄存器

cmp S2,S1 通过S1-S2的结果,比较两者的大小
test S2,S1 通过S1&S2的结果(按位与),比如testl %eax,%eax用来检查%eax是正数,负数还是0或者其中一个操作数是掩码,用来指示哪些位应该被测试

B. 根据条件码的组合,使用set指令,不同后缀名表示不同条件

set指令的目的操作数是8个单字节寄存器或者存储一个字节的存储器位置,把该字节位置设置成0或1。它的基本思路是执行比较或测试指令,根据set指令的类型决定计算结果t=a-b:操作数的大小,是有符号的还是无符号的,程序值的数据类型。如图所示为set指令的常见情形

例如
sete表示相等时设置(set when equal)指令, 因而a=b,t=0,则置ZF位就表示相等。

setl表示当小于时设置(set when less)指令,测试一个有符号比较。假设没有发生溢出,OF位设置为0,即a < b时t为负数,SF位置为1;发生溢出,什么情形下满足a < b呢,则只有产生负溢出时,即t > 0此时OF位为1,SF为0。结合溢出位和符号位,使用异或操作SF^OF才能提供a < b是否为真的测试

seta表示当大于时设置指令测试一个无符号比较。当t=a-b>0时CF位置为0(无符号的),ZF位为0,使用~CF &~ZF

通过cmp和test比较的操作数长度决定是何种类型,再结合set指令,判断是无符号还是有符号的。例如

cmpl %eax,%edx
setne %al #由1得知是32位数的比较,由2得知是比较两个数是否不等。判断要么是无符号int或者int比较

testw %ax,%ax
sete %al #一个16位数是否为0,两个字节的类型可用short或者unsigned short

C. 跳转指令:无条件的和有条件的
所谓跳转指令是指程序执行时切换到程序中的一个带有标号的地址。跳转指令又分无条件跳转(直接跳转、间接跳转)、有条件跳转

直接跳转中,直接选择一个跳转目标,写法为".L1";间接跳转中,跳转目标是从寄存器或者存储器位置中读出的,写法为"\*+操作数指示符",如jmp *eax,用寄存器%eax的值作为跳转目标。有条件的跳转指令通常是根据条件码的某个组合,或者跳转或者继续执行代码中的下一条指令,与set指令向匹配。注意:条件跳转只能是直接跳转!

我们并不是要取掌握这些跳转指令,而是要理解跳转指令的目标如何编码,特别是要掌握PC相关的编码方式。它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码,地址偏移量一般为1/2/4个字节。

如下是一个与PC相关的寻址例子,它含两个跳转:第1行的jle指令指向更高的地址,第8行的jg指令指向更低的地址。如图所示:

分析:由Objdump得到的反汇编版本。可知第1行的跳转目标指明为0x17,对应的目标编码为0xd;第7行的跳转指标指明为0xa,对应的目标编码为0xf3。并且通过计算观察到第1行跳转指令的下一个指令地址0x17=0xd+0xa,第7行跳转指令的下一个指令地址0xa=0xf3+0x17。

可以看出来执行PC相对的寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址(缘由:当时的处理器以更新计数器作为执行一条指令第一步)。

并且如果对目标文件链接后进行程序反汇编,依然可以看到1和7行跳转目标的编码始终没有变。优点显而易见,使用与PC相关的跳转目标编码,指令编码很简洁,目标代码无需做任何改变直接移到存储器不同位置。如图:

接下来几个小节,我们将使用各种类型的跳转指令来实现C语言的控制结构。从最基础的分支语句,再到循环和switch语句。

2.3.2 条件语句的翻译-if-else

C语言的if-else语句的通用形式模板:

if(test-expr)
	then-statement
else
	else-statement

对于这种通用形式,汇编实现一般采用如下插入条件(else-statement)和无条件分支(if-statement)的形式,保证执行正确的代码块。用C语法来描述控制流:

t=test-expr;
if(!t)
	goto false;
then-statment;
goto done;
false:
	else-statement
done:

因而对于C语言的if-else语句,需要创建一个goto版本的紧密遵循汇编代码控制流的等价语句(为了方便构造汇编代码)。基本思路是:首先比较了两个操作数(第3行),设置条件码。如果比较的结果表明x大于等于y,则它会跳转到计算x-y的代码块,否则继续执行计算y-x的代码。如下图所示一个计算两数之差的绝对值函数的C代码和汇编代码。

2.3.3 循环语句的翻译-do-while、while、for

C语言中提供了3种循环结构,如do-while、while、for。在汇编代码中,绝大多数都可以用条件测试和跳转组合结合起来实现循环的效果。并且大多数汇编器都是根据一个循环的do-while形式来产生循环代码(尽管实际中用的很少),其他的循环都是借助do-while形式再编译成机器代码。下面就从do-while循环开始研究

  • do-while循环

do-while语句的通用形式模板:

do
	body-statement
while (test-expr);

对于这种通用形式,用C语法的goto语句来描述控制流:

loop:
	body-statement
	t=test-expr;
	if(t)
		goto loop;

也就是说每次循环,程序都会先执行body-statement,然后再对test-expr表达式求值,如果测试为真,回去继续执行循环,可看到body-statement至少执行一次。

如下图所示用do-while循环来计算函数参数的阶乘:

思路比较明确,寄存器%edx保存参数n,%eax保存result。程序开始执行循环(.L2),先执行循环的主体,然后再测试n > 1,如果为真,条件跳转重复执行循环(第7行的跳转指令是实现循环的关键),否则退出循环返回结果。我们可以看到寄存器%eax通常用来返回函数的值,所以常常选它存放要返回的程序值。

通过描述fact_do的过程,我们学习到一个逆向工程循环的基本策略:如何由汇编代码找到和原始代码的对应关系,核心是找到程序值和寄存器之间的映射关系。看看在循环前如何初始化寄存器,在循环中如何更新和测试寄存器以及在循环后又如何使用寄存器,把它组合起来就能打开C语言隐秘bug后的大门了。(注:GCC常会做一些寄存器优化的变换,更不易观察出来)

  • while循环

while语句的通用形式模板:

while (test-expr)
	body-statement

对于这种通用形式,用C语法的goto语句来描述控制流:

t=test-expr;
if(!t)
	goto done;
loop:
	body-statement
	t=test-expr;
	if(t)
		goto loop;
done:

和do-while不同的是它首先要对test-expr求值,第一次执行body时,循环就有可能终止。因而GCC常用的策略是使用条件分支,并且在需要时优化最开始的测试,省略循环的第一次执行,从而转换成do-while循环。

如下图所示用while循环来实现阶乘函数:

比较fact_while和fact_do的代码,它们几乎是相同的。唯一的不同点就是初始的测试和循环的跳转(第3/4行)

  • for循环

for循环语句的通用形式模板:

for(init-expr;test-expr;update-expr)
	body-statement

对于这种通用形式,用C语法的goto语句来描述控制流:

init-expr;
t=test-expr;
if(!t)
	goto done;
loop:
	body-statement
	update-texpr;
	t=test-expr;
	if(t)
		goto loop;
done:

它的基本步骤是首先对初始表达式init-expr求值,然后进入循环;在循环中先对测试条件test-expr求值,为假则退出,否则执行循环体body-statement,最后再更新表达式。

如下所示为for循环写的阶乘函数:

int fact_for(int n){
	int i;
	int result=1;
	for(i=2;i < =n;i++)
		result *=i;
	return result;
}

它对应的汇编代码如下所示。

2.3.4 多重分支语句的翻译-switch

switch语句是一个具有多种可能结果的分支语句,它不仅提高了C代码的可读性,而且通过使用跳转表(jump table)数据结构使得实现更为高效。

所谓跳转表实际上是一个指针数组,每个元素都是一个指向代码段的指针。通过表项的索引值i来执行一个跳转表内的数组引用,从而确定跳转指令的目标,并且如果有重复情况的引用即简单的使用相同的代码标号。

跟使用一组很长的if-else语句相比,优点是很明显的,使用跳转表执行switch语句的时间与开关情况的数量无关。甚至switch语句出现上百种情况,也只用一次跳转表去访问处理。

如下图所示为一个switch语句的示例,分别是:switch语句、翻译到扩展的C语言(使用跳转表)。汇编代码略。

根据汇编代码中index的值,有五个不同的跳转位置:loc_A(.L3)、loc_B(.L4)、loc_C、loc_D、loc_def。观察到jmp指令的操作数有前缀*,表明这是一个间接跳转,操作数指定一个存储器位(数组引用某索引元素)。通过分析switch语句确定指针数组的每个表项具体对应哪个代码块(不同的跳转位置)

2.4 C语言函数调用的栈帧结构

IA32程序用程序栈来支持过程调用。它包括将数据(参数和返回值)和控制从代码的一部分传到另一部分,另外还包括进入时为过程的局部变量分配空间,并在退出时释放空间。一般地,机器只提供转移控制到过程和从过程中转移出控制的简单指令,数据传递、局部变量的分配和释放必然通过程序栈实现

2.4.1 栈帧结构基础

机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复和本地存储。我们称为单个过程分配的那部分栈为栈帧。按前面的介绍,栈指针%esp可以移动,帧指针%esp不变化,因而大多数信息的访问都是相对于帧指针的。如下图所示:

  • 支持过程调用和返回的指令

call指令:目标是指明被调用过程起始的指令地址。同跳转一样,调用可是直接的(一个标号),也可是间接的(*后加一个操作数指示符)。其效果是:将返回地址入栈,并跳转到被调用过程(P调用Q,P调用者,Q被调用者)的第一条指令处。 所谓返回地址是指在程序中紧跟call后面的那条指令的地址,这样当被调用过程返回时,执行会从此处继续。

leave指令:为返回准备栈,它等价于如下代码:

movl %ebp,%esp
popl %ebp //恢复已保存的%ebp寄存器

ret指令:从过程调用中返回。指的是从栈中弹出地址,并跳转到这个位置

如图所示为sum和main函数的反汇编代码节选。

可以看到:main函数中,call指令调用函数sum,它的效果是将返回地址0x080483e1压入栈中,并跳到sum函数的第一条指令中。sum继续执行直到遇到ret指令,它的效果是从栈中弹出值0x080483e1,然后跳到这个地址(调用sum的call函数之后),继续main函数的执行

  • 寄存器使用惯例

调用者调用被调用者时,要求被调用者不能覆盖某个调用者稍后会使用的寄存器值。根据惯例,寄存器%eax、%edx、%ecx称为调用者保存寄存器。P调用Q时,Q可以覆盖这些寄存器而不会破坏任何P需要的数据(因为会恢复)。另一方面,寄存器%ebx、%esi等被划分为被调用者保存寄存器。要求Q必须在覆盖这些寄存器值之前,先把它们保存到栈中,并在返回前恢复它们。

2.4.2 普通函数、递归函数的调用过程

A. 普通函数的调用实例

考虑如下图定义的普通C函数调用,函数caller包括一个对函数swap_add的调用,并且给出了调用swap_add函数前和正在运行时的栈帧结构。注:访问的栈位置有些是相对于栈指针%esp的,有些是相对于基地址指针%ebp的,偏移量都是由相对于这两个指针的栈表示!

a. caller栈帧的汇编代码-调用swap-add前
如图所示,caller的栈帧包括存储局部变量arg1和arg2(相对于帧指针位置-4和-8,只能存在栈中,所以必须要为它们生成地址)。

第2、3、4行表示保存旧的%ebp,并把%ebp当做%esp的开始位置.然后栈指针减去24字节,从而在栈上分配24个字节(栈向低地址方向生长)。第5、6行初始化两个局部变量(arg1,arg2)

第7-10行开始计算&arg2和&arg1的值并存储到栈中,leal指令加载有效地址(将有效地址写入目的操作数中),形成swap_add的参数。并且将这些参数存储到相对于栈指针偏移量为0和+4的地方
注意:

  • 调用swap_add的栈帧caller,分配了24个字节,8个用于局部变量,8个用于向swap_add传递参数,还有8个未使用。(调用swap_add前) GCC坚持一个访问数据的严格对齐原则,函数使用的所有栈空间必须是16字节的整数倍。(栈分配存在一些从不使用的空间)

  • C语言中,所有的参数都是传值调用。注意这与显示地产生一个指向某值的指针作为参数,值发生改变并无冲突。传递给函数的是指针值的一个副本,对函数内指针参数的改变并不会影响作为实参的指针变量,但它可以改变作为副本的指针指向的内容,从而达到类似于引用参数的效果。

b. swap_add的汇编代码-初始化栈帧,执行主体,过程返回恢复栈的状态

  • 初始化栈帧:在这之前,call指令已经讲返回地址压入栈中。它执行的操作就是保存旧的%ebp,%esp指针指向%ebp,并且保存旧的%ebx。如下:

可以看到%ebx作为被调用者保存寄存器,它必须将旧值压入栈中。注意到%ebp已经移动了,作为swap_add的帧指针

  • swap_add主体代码:从caller的栈帧取出它的参数并执行swap_add操作。如下:

由于初始化栈帧中帧指针%esp已经移动,参数的位置因而也从%esp的旧值+4和0的位置变成了相对于%ebp的新值+12和+8的位置。 寄存器%eax是作为返回值传递的

  • 过程返回恢复栈的状态:恢复%ebx和%ebp的值

恢复这几个寄存器值后,重新设定栈指针式它指向存储的返回值。ret指令将控制转移到caller,再执行caller指令的下一条指令(返回地址)

B. 递归函数的调用实例
如图为递归的阶乘函数的C代码和汇编代码。我们主要检查当参数n来调用时,机器代码如何操作。

和普通函数的过程调用类似,第2-5行创建一个栈帧,其中包括%ebp的旧值、保存的被调用者寄存器%ebx的值及当递归调用自身的时候保存参数的4个字节。第6-7行用寄存器%ebx来保存参数n的值,并将%eax的返回值置为1。判断n < =1的结果,成立的话会跳转到完成代码(终止条件);否则,处于递归的情形下,n-1的值存在栈上,并调用函数本身,此时后寄存器%eax保存着(n-1)!的值,被调用者保存寄存器%ebx保存参数n。

从这两个示例我们可以总结出一些要点:

  • 栈规则提供了一种机制,每次函数调用都有存储它自己的私有状态信息(保存的返回位置、栈指针、被调用者保存寄存器-很重要,必须先保存)
  • 如果需要,还需要根据分配和释放的栈规则存储局部变量。过程调用中在栈上分配局部变量,返回时自动释放。每个调用在各自栈中私有空间,局部变量都不会相互影响。
  • 过程调用中访问信息均是相对于帧指针%ebp而言,$0x4(%ebp)表示的是返回地址,往地址增大的方向$0x8(%ebp)表示的是函数第一个参数,函数如果有多个参数,依次以4递增。本地变量和临时变量则是往地址变小的方向存储
  • 在返回前,函数必须将栈恢复到原始条件,可以恢复所有的被调用者保存寄存器和%ebp,并重置%esp使其指向返回地址。
  • 如果在调用过程中,使用了malloc函数,需要说明的是:
     一、指针变量是分配在栈上的局部变量,调用结束,该变量自动释放。但由malloc分配在堆的内存-该指针指向的堆内存却并未释放,如果不作处理,就会造成内存泄露
     二、为了防止内存泄露,有两种处理情形:作为返回值,返回那段堆内存的指针,从而不会丢失对那段内存的控制;在栈调用结束前使用free操作手动释放那段内存;
     三、指针变量的内存随调用结束自动释放,指针指向的那段内存必须使用free或delete操作释放。因而明确一点的是free之后再解引用那个指针是非法的,因为访问已释放的内存地址是无效的,一般建议释放操作后主动置指针为NULL指针就不会造成误解。

2.5 C语言中的结构:数组、指针、结构、联合

2.5.1 数组和指针

对于数据类型T和整型常数N,声明如下: T a[N]。它具有两个效果:
在存储器分配L*N个字节的连续区域,L是数据类型T的大小;xa表示起始位置,则访问数组元素a[i]的位置在xa+ L*i的地方。

数组元素的访问一般借助存储器引用指令。如计算整型的E[i]: E的地址存放在寄存器%edx中,而i存放在寄存器%ecx中,访问指令如下:

movl (%edx,%ecx,4),%eax (计算地址xe+4i,并读取这个存储器位置的值,将结果放到寄存器%eax中)

指针,实际上是地址的一种表示方式,它指向某一个类型的对象。指针类型不是机器代码中的一部分,而是C语言提供的一种抽象。

产生指针可用&运算符,它适合于变量、结构、联合和数组的元素,我们常常用leal指令来生成存储器引用的地址。*用于指针的间接引用,它表示一个值,类型与指针的类型相关,它是通过存储器引用来实现的,要么写入数据,要么读取数据。

如下图为与整型数组E有关的表达式。它的起始地址和整数索引i分别存在寄存器%edx、%ecx中

可以看出:

  • leal指令用来创建地址,movl用来引用存储器(除了第一种和最后一种情况,前者表示复制地址,后者表示复制索引)
  • 数组与指针是有紧密联系的。一个数组的名字既可以直接数组引用又可以像一个指针变量引用(但不能修改)。如a[3]与*(a+3)有一样的效果,它均需要用对象类型的大小对偏移量进行伸缩。我们写表达式p+i,指针p的指针为p,则得到的地址计算为p+L*i,L是与p相关联的数据类型大小!
  • 指针能够从某种类型强制转换到另一种类型,只改变它的类型,而不改变它的值。所起的效果是改变指针运算的伸缩。如p是一个char*类型的指针,那么表达式(int*)p+7为p+28,(int*)(p+7)为p+7
  • 对于二维数组,对应的元素的地址的汇编代码表示可以借助移位、加法和伸缩组合来避免直接的乘法工作

2.5.2 结构和联合、数据对齐

C语言提供两种结合不同类型的对象来创建数据类型的机制:

  • 结构: 多个对象组合到一个单位中
  • 联合: 允许用几种不同的类型来引用一个对象

与此同时,计算机系统对数据类型的对象地址必须是某值K(2、4、8)的倍数,这种对齐限制简化了处理器和存储器系统间的硬件设计。Intel建议对齐数据以提高存储器系统的性能。在Linux中采用如下的对齐策略:

  • 数组作为同样大小的块来分配,结构体作为变长的块来分配,保存着不同大小的结构元素;联合作为一个单独的块来分配,这个块必须足够大,能够装下联合中最大的元素。
  • 对于IA32系统,2字节数据类型(如short)的地址必须是2的倍数,而较大的数据类型(如int、float、int *)的地址必须是4的倍数。
  • x86-64系统对齐要求更为严格,对于任何需要K字节的标量数据类型的起始地址必须是K的倍数。如long、double和指针必须在8 字节边界上对齐,long double使用16字节对齐
  • 结构体的对齐除了要满足每个字段的对齐要求,还需要考虑整体的结构满足怎样的对齐要求,例如32位的结构体元素里最大只有2个字节,它就无需满足4字节对齐,如果含有一个4字节的元素,则整体必须满足4字节对齐。即要求所有元素都满足自身的对齐要求(一定要了解是32还是64位系统,因为它们对齐方式有很大的不同)

下面就来具体介绍结构体和联合体:

A. 结构体
考虑下面的代码:

#include<stdio.h>
 /**

  */
struct test {   
    int i; 
    short c;
    int j;
    char *p;
    char s[0];
}; 

int main(){
    struct test *pt=NULL;
    printf("pt的地址: %p\n",pt);
    /*输出pt->i/c/j/p/s都会core dump,因为不能访问内存地址0x0-0x20,但可以输出这些内存地址*/
    printf("i的地址: %p\n",&pt->i); 
    printf("c的地址: %p\n",&pt->c);
    printf("j的地址: %p\n",&pt->j);
    printf("p的地址: %p\n",&pt->p);
    printf("s的地址: %p\n",pt->s);
    return 0;
}

在结构体中,编译器记录了每个字段的字节偏移,汇编代码通过以结构体的地址加上适当的偏移放访问结构的字段。记test的起始地址为xp,,结合数据对齐的策略:

  • 在64位机器要保证每个元素的K字节数据对齐,各元素的地址为xp、xp+4、xp+8、xp+16、xp+24
  • 在32位机器要保证较大数据类型的4字节数据对齐,各元素的地址为xp、xp+4、xp+8、xp+12、xp+16

以32为例,记结构体类型的指针变量在寄存器%edx中,获取每个字段元素的汇编代码如下(每行代码独立):

movl (%edx),%eax    获得pt->i
movl 4(%edx),%eax   获得pt->c
movl 8(%edx),%eax     获得pt->j
leal 4(%edx),%eax + movl %eax,12(%edx) 获得&pt->c存储在pt->p
movl 16(%edx),%eax    获得pt->s

也就是说结构体中的元素存储的都是相对于结构体首地址的偏移。回到上面的实例中,分别在64位和32位系统中测试,如下

64位系统:
pt的地址: (nil)
i的地址: (nil)
c的地址: 0x4
j的地址: 0x8
p的地址: 0x10
s的地址: 0x18

32位系统:
pt的地址: (nil)
i的地址: (nil)
c的地址: 0x4
j的地址: 0x8
p的地址: 0xc
s的地址: 0x10

我们可以看到输出指针p和数组s名字的不同形式,这是因为指针和数组还是有区别的。访问结构体中的指针字段实际上是指针本身的值(同其他非指针或数组的变量是一样),访问结构体中的数组名其实就是数组的相对地址(即char s[10]中数组名s和&s是一样的)

B. 联合类型
联合类型提供了一种方式,绕过了C语言类型系统,允许以多种类型来引用一个对象,并且它的总大小为它最大字段的大小。在某些情况下,联合十分有用

  • 事先知道一个数据结构中的两个不同的字段是互斥的,可将这两个字段声明为联合的一部分,而不是结构的一部分,以减小分配空间的总量。

例如实现一个二叉树的数据结构,每个叶子节点都有一个double的数据,而每个内部节点都有指向两个孩子节点的指针但无数据。
简单的声明如下:

struct node {
	struct node *left;
	struct node *right;
	double data;
};

每个节点需要16字节,浪费一半的字节;不妨定义如下,并引入枚举类型,区分联合中可能的不同选择,共需要12个字节。

typedef enum {LEAF,INTERNAL} nodetype_t;
struct node {
	nodetype_t type;
	union {
		struct {
			struct node *left;
			struct node *right;
		} internal;
		double data;
	} info;
}
  • 访问不同数据类型的位模式。如我们以一种数据类型来存储联合中的参数,又以另一种数据类型来访问它(注意此时的字节顺序)

例如以两个4字节的无符号位的形式创建一个8字节的形式:

double bit2double(unsigned w0,unsigned w1){
	union {
		double d;
		unsigned u[2];
	} temp;
	
	temp.u[0]=w0;
	temp.u[1]=w1;
	return temp.d;
}

在小端法机器上,参数w0是d的低4位字节;在大端法机器中,参数w0是d的高4位字节。

C. 长度为0的数组
刚才上面有个例子有一个char s[0]-长度为4的数组。默认地零数组在标准C和C++是不允许的,如果使用的话编译时会产生错误。但在GNU C99中,这种用法是合法的,它最典型的用法是置于结构体中的最后一个字段,并且在前面至少有一个其他字段,因而GCC编译时不会产生任何警告或错误。我们称这个数组为柔性数组。

例如我们想定义一个可变长度的结构体,这时候我们就可以用到零数组。下面是使用指针和零数组构建变长数组的代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/*零数组方式*/
typedef struct {
	int len;
	char body[0];
} data_t1;

/*指针方式*/
typedef struct {
	int len;
	char *body;
} data_t2;

int main(){
	printf("零数组方式的结构体大小为: %d\n",sizeof(data_t1));
	printf("指针方式的结构体大小为: %d\n",sizeof(data_t2));

	int len=10;
	data_t1 *d1=(data_t1 *)malloc(sizeof(data_t1)+len);//len个数据类型为T的元素,柔性数组申请空间时采用一次性分配一次性释放原则
	d1->len=len;
	strcpy(d1->body,"success!");
	int i,size=strlen(d1->body);
	

	data_t2 *d2=(data_t2 *)malloc(sizeof(data_t2));
	d2->len=len;
	d2->body=(char *)malloc(sizeof(char)*len);//指针方式还需要分配指针指向的内容,释放也要分别释放
	memset(d2->body,'a',len);

	for(i=0;i<size;i++)
		printf("%c",d1->body[i]);
	printf("\n");
	free(d1);

	for(i=0;i<len;i++)
		printf("%c",d2->body[i]);
	printf("\n");
	free(d2->body);
	free(d2);
	return 0;
}

对应的输出结果如下(64位系统):

零数组方式的结构体大小为: 4
指针方式的结构体大小为: 16
success!
aaaaaaaaaa

我们再用gcc -g test.c -o test & gdb test查看,有如下结果:

从上面的结果可以看出:

  • 零长度数组定义在结构体内,但并不占用结构体的空间(可用sizeof(某结构体或者char[0])测试,可比较指针方式是否占用空间)。可理解为这是一个没有内容的占位符标识,分配了实质内容后变成了一个有长度的数组
  • 它能够为结构体内的数据分配一段连续的内存,并可以一次性讲内存释放。对比指针,既需要释放指针指向的内存块,又需要释放结构体指针。
  • 分配连续的内存是有利于提高访问速度,并减少了一定量的内存碎片,指针则不可。
posted @ 2015-03-06 23:03  charlesxiong  阅读(3433)  评论(0编辑  收藏  举报