C编译器剖析_1.5 结合C语言来学汇编

1.5  结合C语言来学汇编

     汇编语言的语法和语义都不复杂,如果你会C语言,那你就一定能在很短时间内看懂本节介绍的汇编语言。但是要很熟练地阅读和编写汇编程序,则需要长期的训练。上世纪80年代末,求伯君闭关一年多,单枪匹马用洋洋洒洒数万行汇编代码开发出DOS版WPS,由此奠定江湖大佬地位,但时至今日,这样的个人英雄主义时代已经一去不复还。除了信息安全和软件逆向分析等少数领域外,需要使用汇编语言进行研发的场合已经不多了,用高级语言进行程序开发已经是主旋律,即便是Linux内核和嵌入式驱动开发,也几乎是C语言的天下。不论杰出的程序员能用汇编语言写出多么精巧的代码,在这个由市场和孔方兄共同推动的时代,花最少的钱获得最大的回报,永远是资本和资本家所追逐的。随着编译优化技术的发展,由编译器自动生成的汇编代码的执行效率,已经不输于大部分汇编程序员手工编写的汇编代码。而汇编代码的可读性差,开发效率相对较低,由此导致的开发和维护成本高,注定了高级语言取而代之成为编程的主流。毕竟,在大多数场合,还是孔方兄说话算数。

    CPU的指令集实际上是软件和硬件的接口,而汇编语言就以助记符的形式描述了这个指令集。严格说来,CPU并不认识汇编语言,CPU只认识这些助记符对应的0/1机器码,但因为汇编语言与机器语言几乎是一对一的关系,所以习惯上把汇编语言和机器语言统称为低级语言。学习汇编语言的最主要目的是熟悉软件与硬件的接口,毕竟指令集是计算机体系结构的一部分。当然,我们要剖析的是C编译器,而C编译器最终需要把C源代码翻译成汇编代码,在开始C编译器剖析的万里长征时,我们有必要熟悉一下我们要到达的目的地。学习任何语言的不二法宝是”先模仿然后主动地去使用这门语言来表达”,学英语、学C语言和学汇编都是如此。为了能用尽量少的篇幅介绍UCC编译器用到的汇编语言,这里我们采取的是结合C语言代码来熟悉汇编的办法。我们先简单介绍一些基本概念,然后以1.4节中给出的一个简单的C程序及由UCC编译器生成的汇编代码。通过C源代码,我们可以知道程序要完成的功能,然后去阅读汇编代码,就会容易许多。此处,我们要达到的目标是“能基本看懂UCC编译器用到的汇编代码”。确切地说,是能基本看懂ucc\ucl\x86Linux.tpl中的汇编代码,而Windows平台对应的ucc\ucl\x86win32.tpl只是用到的汇编代码语法格式与Linux平台的有所不同,两者没有本质区别。X86Linux.tpl文件名中的”.tpl”是模板template的缩写,其含义是文件x86Linux.tpl中包含的汇编代码是不完整的,需要指定具体的操作数后才能构成完整的汇编指令。如下所示的汇编代码中,%0、%1和%2是占位符,这和前一节图1.17中的$1、$2和$3的作用类似。以下两条汇编指令用于比较%2和%1对应的两个有符号整数,如果两者不相等,则跳转到%0对应的目标地址处;若相等,则执行下一条汇编指令。助记符cmp是compare的缩写,cmpl中的后缀”l”表示进行32位的比较;而jne是Jump if Not Equal的缩写。

         TEMPLATE(X86_JNEI4,    "cmpl %2, %1;jne %0")

    出于系统安全方面的考虑,就如网站的管理员和一般用户具有不同的权限一样,CPU的指令集也被划分为特权指令和非特权指令。CPU处于可以使用任何指令的状态时,被称之为“管态或核心态”,管态是处于核心的管理状态的意思;而CPU处于只能使用非特权指令时,被称为“目态或用户态”,目态是表面上看到的外层的非核心的状态。操作系统内核要管理所有的软硬件资源,就要有权使用CPU的所有指令,所以要处于管态。而操作系统内核之外的应用程序只使用非特权指令,处于目态。当然,实际上x86 CPU是有四个不同的运行级别,从ring0一直到ring3,但Linux内核实际用到的只有ring0和ring3。所以概念上,我们仍可用管态和目态的概念来理解CPU所处的运行级别。像开中断sti、关中断cli、输入in和输出out这样的指令在x86中都是特权指令,只有OS内核才能使用。如果一个应用程序要读写文件,而读写文件显然需要做输入输出,这就得向操作系统内核提出申请,因为真正读写文件的权限是在操作系统内核中,应用程序通过触发中断来申请操作系统内核提供的各种服务,这就是所谓的系统调用。

    这里,你的问题可能会是“为什么需要绕这么个圈子,为什么不让应用程序有权使用所有指令,这样应用程序就不用麻烦操作系统内核了,且消除了管态和目态的切换开销,运行效率还可以大大提高”。答案就是前文中提及的“系统安全”,可以想象如果网站的一般用户和管理员拥有一样的权力,那带来的只能是混乱。但在标准C语言里,并没有任何语法结构与开关中断这样的汇编指令相对应,而我们知道,Linux内核主要是用C语言来开发的。这就意味着,我们需要直接用汇编语言来编写这部分代码,或者由C编译器提供在C语言中嵌入汇编代码的机制。而我们要剖析的UCC编译器暂时还未提供在C代码中嵌入汇编的机制。

    当然,你的问题可能还有“在C语言编写的应用程序中,如何触发一个中断interrupt来向内核发出系统调用请求”。答案是,这需要通过”int或sysenter”这样的汇编指令。例如在Linux中,C程序员可调用read()和write()这样的库函数来向内核发出读文件和写文件的系统调用请求,但是这些库函数最终还是需要去调用汇编指令”int或sysenter”,只不过这些工作由库函数的实现者帮我们代劳了而已。

    站在C编译器的角度来看,我们需要从CPU的指令集中选取一些汇编指令来支持C语言的语义,ucc\ucl\x86Linux.tpl中的汇编代码就是UCC编译器原作者的选择。这个选择并非最优,也不唯一,但非常清晰易懂。这些汇编代码只是CPU指令集中的一部分,但已足够支撑起C语言的语义。无论多复杂的C语言代码,只要是用UCC编译器来编译,我们最终得到的汇编程序都是由ucc\ucl\x86Linux.tpl中的汇编指令组合而来。所谓大道至简,浩瀚的宇宙,可能其组合规则也不复杂,只是我们还没发现而已。

    页式存储管理是许多现代CPU普遍支持的内存管理机制,而Intel的x86 CPU因为要兼容早期8086芯片的分段机制,最终形成的是”先分段再分页”的段页式存储管理。如何利用或者干脆绕开x86的分段机制,及如何开启请求分页机制的工作已经由操作系统内核完成,操作系统可在x86 CPU上,为每个运行的程序(进程)营造一片相对独立的一维地址空间。我们知道,平面是个二维空间,要定位二维空间中的一个位置我们需要坐标(x,y);而要定位一条线段中的某个点,我们只需要一个坐标x,这个坐标x也就是平时我们所说的地址。这里,我们不去关注操作系统如何为每个进程构建一段独立的一维虚地址空间,我们更关注对每个进程而言,这段地址空间是如何组织的。概念上,我们写的C程序里有代码,也有数据,因此,进程的地址空间逻辑上可被看成由“代码区”和“数据区”这两部分构成。全局变量、静态变量和常量在编译时,就可知道它们要占多大空间,这部分空间被称为静态数据区。静态数据区中“静态”的含义是指这部分数据区在“编译时”可确定,不需要等到“程序运行时”才进行分配。对应的,就有“动态数据区”。函数调用时,我们需要借助一个“栈”来进行,在栈中我们会存放函数对应的局部变量、临时变量和返回地址等信息,而如果一个C语言函数从未被调用,我们就不需要在栈中为该函数分配空间,这意味着C函数中的局部变量是在栈中动态分配的。

    这块动态数据区被称为“栈Stack”的原因在于,函数的调用正好满足先进后出FILO的关系,即最先被调用的函数却最后一个返回;另一个动态分配的例子是C语言中的malloc()函数,或C++中的new操作,这部分动态数据区被称为堆Heap。我们平时会讲“这一堆乱七八糟的东东”,“堆”反映的是无序。通过malloc或者new分配得到的各数据区,其回收操作是无序的,在C和C++中需要由程序员手工完成。所谓的“内存泄露”往往指的也是C程序员调用了malloc,却忘了free;或者C++程序员调用了new,却忘了对应的delete。既然手工来管理堆空间要看程序员的人品,那Java等语言就引入了垃圾自动回收机制。总之,我们可把一个进程的地址空间分为以下几部分:

         (1)代码区

         (2)数据区           动态数据区(由堆和栈构成,运行时分配)

                               静态数据区(全局变量、静态变量和常量,编译时确定)

    需要由C编译器管理的数据区主要是栈,而堆空间的管理则通过相应的C库函数malloc和free来实现,由库函数实现者去花心思,不用劳烦C编译器。C编译器对栈的管理,主要是通过生成函数调用和函数返回的相应汇编指令来实现,这些指令在运行时,就会自动实现“栈”空间的分配和回收,所以栈空间的管理不需要C程序员操心。需要另外说明的是,数据区一般是用来存放数据,但是黑客也可以在栈或堆中注入一些人为构建的机器代码。下面我们以图1.19中的C语言代码来举例说明动态和静态数据区的概念。

 C编译器剖析_1.5 结合C语言来学汇编_第1张图片

图1.19    数据区示例

    按C语言的语法,图1.19中的str1是全局变量,而str2是静态变量,而第12行的”Hello World”则是常量,这部分数据就保存在我们前文中所述的“静态数据区”;而函数h中的变量str3是个局部变量,在函数h被调用时,才会在栈中动态为str3分配内存,在第9行,我们让str3指向了由malloc分配的16字节的堆空间。图1.20是由”ucc  -S hello.c”命令生成的部分汇编代码。图1.20第8行的str1对应前述C代码中的str1,而第9行的str2.0则对应上述C代码中的静态变量str2,为了避免与可能存在的全局变量str2重名,UCC为静态变量str2加上了”.0”的后缀。第8行中的” .comm         str1,4”告诉汇编器,str1是个全局变量,要占4个字节,在32位机器中,指针一般就占4个字节; 而第9行的” .lcomm       str2.0,4”告诉汇编器,str2.0是个只在当前文件中使用的静态变量,要占4个字节。我们还在第5行看到了C代码中的字符串常量"Hello World",UCC编译器还为之起了一个名为”.str0”的名字,因为合法的C语言中不存在以”.”为前缀的变量名,所以我们可以不用担心”.str0”会跟C代码中的变量重名,而第5行的”.string”则指明了紧随其后的是以’\0’结束的字符串"HelloWorld"。UCC编译器对静态数据区的管理只要做到这一层即可,为这些变量分配具体存储位置的工作就由后续的汇编器和连接器来完成。而图1.20中的第3行”.data”则告诉汇编器接下来的是静态数据区,而第11行的”.text”则告诉汇编器接下来的又切换成代码区了,text是正文的意思,实际上代表的就是代码区。你可能也发现了,在图1.20中我们没有发现形参str和局部变量str3,不过,对比图1.19的C代码,应可以猜出图1.20第15至35行就是C函数h()所对应的汇编代码,在后面的分析中,我们会看到第23行的20(%ebp)即为形参str,而第28行的-4(%ebp)是局部变量str3。

C编译器剖析_1.5 结合C语言来学汇编_第2张图片

图1.20     函数h()

    而要比较好的理解函数h对应的汇编代码,我们需要再介绍点关于寄存器的概念。X86汇编语言中描述的寄存器物理上存在于CPU中,而静态数据区则存在于内存中。因为寄存器的访问速度远远快于内存,出于速度的考虑,我们当然期望汇编指令的操作数能尽量地使用寄存器。直观上,CPU提供的寄存器数量似乎越多越好,但这有硬件成本的约束,而且计算机研究人员还发现当寄存器数量到一定程度时,再增加寄存器并没有带来性能上的明显改进,性能的改进在软件上相当程度上取决于编译器对寄存器的分配和指派算法,还有优化,在硬件上更多的着眼点是放在CPU和内存之间的Cache上。UCC编译器使用的x86寄存器有eax、ebx、 ecx、 edx、 esp、 ebp、esi和 edi等共8个寄存器。以”e”为前缀命名的32位版本的寄存器,去掉前缀得到的ax,bx,cx,dx,sp,bp,si和di,就是16位版本的寄存器。ax还可分为ah和al这两个8位的寄存器,其中的h表示高字节,而l表示低字节,bx、cx和dx寄存器也存在类似的8位寄存器。Intel公司早期的8086CPU就是16位的CPU。“如何给变量和函数取名”估计是一直困扰程序员的难题。Intel后来在64位CPU中新加的寄存器干脆就叫R8,R9,…, R15,这有点Arm 芯片的风格了,从R0一直到R15。不过,实事求是,这些通用的寄存器Register,确实也想不出什么更好的名字。图1.19中的main()函数需要使用寄存器,而被调函数h()也需要使用寄存器,因此,要在主调函数和被调函数之间定个规矩,哪些寄存器是要由主调函数进行保存,哪些寄存器要由被调函数进行保存。

     IT大佬们开会讨论后的结果是:eax,ecx,edx被划入易失寄存器scratch register,需要由主调函数caller来保存,此处“保存”指的是把寄存器的数据写到(store)栈中的临时变量,当然如果主调函数在调用“被调函数”时,压根儿就没使用易失寄存器,或者易失寄存器中的数据不需要保存(比如这些数据从内存载入(Load)寄存器后,重来没有被修改过),那就没必要做保存动作了,因为写内存是有一定的时间开销的。而ebx,esi和edi被划入保值preserve registers,需要由被调函数callee来保存,这意味着如果主调函数在保值寄存器中存放了有用的数据,在函数调用返回后,保值寄存器中的数据仍然会和函数调用前是一样的,这也是“保值”一词的由来。问题是,如果被调函数发现寄存器不太够用,想借用一下保值寄存器那怎么办?办法也很简单,被调函数要先保存一下“保值寄存器”的值,使用后再恢复成原样即可。而UCC编译器使用了最简单的策略来管理“保值寄存器”,即在函数入口处不管本函数是否需要使用这些寄存器,对保值寄存器统统先压入push到栈中,函数返回时,再从栈中逆序弹出pop。这就是我们在图1.20中第17至19行看到的三条push指令,及第30至32行看到的三条pop指令。这当然不是最优的策略,但应是最简单最好理解的。而每个函数都可能成为被调函数,所以由UCC编译器产生的汇编代码中,每个函数的开头部分和结束部分的汇编代码都是一样的,相关代码在x86Linux.c的第177和186行,如下所示,英文单词prologue是序言的意思,而epilogue则是尾声的意思。

    TEMPLATE(X86_PROLOGUE, "pushl %%ebp;pushl %%ebx;pushl%%esi;pushl %%edi;movl %%esp, %%ebp")

    TEMPLATE(X86_EPILOGUE, "movl %%ebp, %%esp;popl %%edi;popl%%esi;popl %%ebx;popl %%ebp;ret")

    当一个C函数被调用时,我们需要在栈中为该函数分配一段内存空间,我们需要记录这段内存的两端,x86提供的寄存器ebp和esp正是用于此目的。x86的入栈方向一般是从高地址往低地址方向入栈,如图1.21所示。

C编译器剖析_1.5 结合C语言来学汇编_第3张图片

图1.21  栈示意图

    我们再来看一下main()函数对应的汇编代码,如图1.22所示。图1.22中第39至43行的四条push指令把ebp,ebx,esi和edi入栈,对应图1.21中的ebp0,ebx0,esi0和edi0。第43行的movl指令可用于“内存与寄存器”或“寄存器与寄存器”之间的数据传递,mov是move的缩写,后缀”l”表示进行的32位的操作,后面我们还会看到表示16位操作的后缀”w”和表示8位操作的后缀”b”, 分别是long,word和byte的缩写。严格说来,32位CPU的字长是32位,但因为word这个词语已在16位时代就广泛使用,所以很多地方还是把一个word当作2个字节。第43行用于把寄存器esp中的数据传送到寄存器ebp中,相当于我们为main函数在栈中的数据确立了基址,要查找栈中的其他数据,都会以ebp为基准点,例如前文中所述的20(%ebp)和-4(%ebp),寄存器ebp正是base pointer的意思。而esp则始终指向栈顶,stack pointer之意。第44行的sub指令是“substract”的缩写,相当于作“esp -= 4;”的计算,这形成了图1.21的ebp1所指向的位置下方的一处空白,这位置对应一个临时变量,用来存放”Hello World”的地址。UCC编译器经过优化后,直接在第46行把”Hello World”的地址存到寄存器eax中了。第46行中的”lea”是Load Effective Addrss的缩写,我们知道一个内存单元相当于一个房间,其地址相当于房间门牌号,该内存单元的数据相当于房间中的物品,lea指令就用于取”.str0”对应静态数据区的首地址,并存放到寄存器eax中。第47行的mov指令则把寄存器eax的数据传送到全局变量str1中。这正是图1.19中第12行C语句str1 = "Hello World";所要完成的功能。第48至50行正好对应的是C代码函数调用”h(str1);”,第48行的push指令完成了实参给形参传值的操作,图1.21中的 str来标记了函数h形参对应的位置。而第49行的call指令会先把返回地址压入栈中,这样当h(str1)返回后,我们才能回到图1.22的第50行处。第51行对应图1.19中C语句”return 0;”,用于把返回值存放到寄存器eax中。是的,IT大佬们还会对函数返回值如何存放作个规定,请参见ucc\doc\UCC Internal.pdf的第9.3节” Calling Convention”。Calling Convention即调用约定的意思,主要是要对函数的参数如何传递,返回值放哪,函数调用结束后,由主调函数还是被调函数进行退栈做一个约定。有许多不同的函数调用约定,UCC编译器使用的是C调用约定。第50行的汇编指令就相当于一个pop操作,这个退栈动作在主调函数main()中完成。

    有了这样的基础后,我们再回头去看图1.20中的h()函数对应的汇编代码,就应很有感觉了。当h()函数成为当前正在执行的函数时,寄存器ebp会被设置到图1.21的ebp2处,而ebp+20则正好是第一个形参str的地址,指令” movl 20(%ebp), %eax”的含义是把ebp+20处的内容传递到寄存器eax中。而ebp-4则对应h()中的局部变量str3,汇编指令”movl %eax, -4(%ebp)”会把寄存器eax的内容存到(ebp-4)处,即存到局部变量str3。图1.20第33行弹出栈中存放的ebp1,这会使ebp再次指到图1.21中main()对应的ebp1处。对应第34行的ret指令用于从当前栈顶弹出返回地址,存放到程序计数器寄存器eip中,对CPU而言,寄存器eip的内容指明了下一条要执行指令的位置,这样h()函数执行完后,就会返回到图1.22的第50行。因为图1.20第33行已经把ebp指针恢复到ebp1处,所以从h()函数返回后,在main()函数对应的汇编代码中,通过ebp作基址查找的局部变量就是main()函数对应的局部变量了。

     纸面上的表达永远很难达到动态的效果,最好的办法是对照着图1.19,图1.20和1.22,然后拿起笔,在纸上画出每一条汇编指令执行完后的内存示意图,如图1.21所示。需要注意的是esp始终指向顶栈,因为入栈方向是由高到低,所以pushl会使栈顶指针esp作减4操作,popl则使栈顶指针esp加4。Call指令会把其下一条指令的地址(即函数返回地址)入栈,这相当于作了esp减4的操作,而ret指令相当于做了esp加4的出栈操作。下一节,我们再将写几个简单的C程序,来解释ucc\ucl\x86Linux.tpl中的其他汇编代码。

C编译器剖析_1.5 结合C语言来学汇编_第4张图片

                                   图1.22 main()汇编代码



 

你可能感兴趣的:(C编译器剖析,c语言,编译器,汇编语言)