csapp-Machine-Level Representation of Program-review

Machine-Level Representation of Program收获和思考

Basics

Machine-Level Programming可以看成是机器执行对于上层代码的一种翻译,即硬件是如何通过一个个的指令去解释每一行代码,然后操纵各种硬件执行出对应的结果。
Machine-Level Programming有2种表现形式,一种是text格式的汇编代码;一种是由字节序列构成的机器码,可以理解汇编是机器码的一种文本表示(毕竟机器码是一组字节序列,很难读懂),Machine-Level Program可以看成是C、Java等高级语言和机器执行之间的桥梁。对于一个程序员而言,我们对于Machine-Level Programming更多的是能够读懂各种代码的汇编级别,通过阅读大量的汇编代码去掌握低级别的优化手段以及对代码常见错误的原因有更深刻的理解。

历史

csapp是基于Intel-64指令集讲述的Machine-Level Representation of Program。Intel采用的是CISC(Complex Instruction Set Computer),与Intel对应的AMD采用的则是RISC(Reduced Instruction Set Computer);与X86-64相对应的是IA32,X86-64相对于IA32添加了以下功能:

  1. 提供更高效的条件操作指令
  2. 从32bit扩展到64bit
  3. 支持多核

C、汇编和机器码

汇编可以看成是机器码的文本表示。对于C语言,其执行过程就是首先编译成汇编文件,然后再翻译成对应的可执行文件,当然我们也可以使用反汇编工具对可执行文件进行反汇编。
csapp-Machine-Level Representation of Program-review_第1张图片

汇编构成

在Machine-Level Programming中,其主要操纵的是CPU和内存。
csapp-Machine-Level Representation of Program-review_第2张图片
对于Assemble Code,与C Code不同的是它的数据类型与操作指令,在Assemble Code中,整数只有1、2、4、8字节大小的数据,浮点数只有4、8、10字节大小的数据,没有C语言中诸如数组、结构体等数据结构;操作指令则只包含数据在Register和Memory中的传输和存储以及控制指令(if、switch、for、while、do-while等)。
在X86-64架构中,Register一共有16个,除了%rsp Register以外,其他的Register中均可以存储数据和地址,%rsp表示栈顶指针。
csapp-Machine-Level Representation of Program-review_第3张图片
与%rxx对应的%exx表示的是32bit的数据和地址,因此如果存储long型的数据,使用%rxx类型的Register,否则可以使用%exx类型的Register。
通常%rsi、%rdi用来存储函数形参中的第一个参数和第二个参数。

汇编运算

通常我们用%rxx表示该Register中存储的是数据,用(%rxx)表示该Register中存储的是地址。通过D(Rb,Rr,S)<---->Mem(Reg[Rb]+S*Reg[Rr]+D]来表示Register和Memory中的地址映射。
在汇编运算中,一个最重要的指令是movq,它表示将某个位置中的数据移动到另一个位置中,如:movq %rbx %rax 表示将%rbx中存储的数据移动到%rax中,注意在汇编代码中,参数的顺序和C语言中的参数顺序是相反的,即第一个表示source,第二个表示dest。在汇编中,数据一共有3种形式:Immediate(常量)、存储在Register中的数据,存储在Memory中的数据,三种数据的传输关系如下:csapp-Machine-Level Representation of Program-review_第4张图片
除了movq指令外,还有一个很重要的指令leaq(load effective address) src dest,其用来更高效的进行地址运算(注意:leaq指令是为了更方便的计算地址,而不是存储在其中的值),其中src通常是expression mode,如在编译t = 12 x s时,可以通过leaq (%rbx,%rbx,2) %rax和salq $2 %rax来表示。除此之外,还有一系列的汇编运算指令:

csapp-Machine-Level Representation of Program-review_第5张图片
csapp-Machine-Level Representation of Program-review_第6张图片

Control

Control描述的是通过一系列条件指令去控制代码进行非线性执行。

Condition Codes

Condition Codes中常用的4个为CF、ZF、SF、OF,4个条件码分别表示无符号整数运算是否溢出、运算结果是否为0,运算结果是否为负数、有符号整数运算是否溢出。
Condition Codes通常并不会直接去设置,而是作为某些指令的结果去设置对应的Condition Codes,如cmp指令。通过setx指令去实现Condition Codes的设置,setx修改的是对应寄存器的低位字节(%rxx对应的低位字节为%xl)
csapp-Machine-Level Representation of Program-review_第7张图片

Conditional Branch

Conditional Branch可以狭义的理解为if else控制语句,通过一系列%jmp(无条件跳转)/%jx(有条件跳转)指令实现。
csapp-Machine-Level Representation of Program-review_第8张图片
这里涉及到Conditional move的优化:对于两个相对简单的分支运算,提前计算每个分支的结果再判断的效率要优于先判断再计算对应的分支结果。这里的本质我理解的其实编译器会去衡量提前预测的代价和预测失败的代价,当操作相对简单时,提前预测的代价(即提前计算每个分支的结果)要小于预测失败的代价(即在预测进入某个分支失败时重新进行判断),因此提前计算每个分支的效果更高;当操作比较复杂时,首先我们提前计算每个分支的代价会增大,其次我们也大概率不会对每个分支的结果进行计算。
因此Condition move的前提是每个分支的计算相对简单,当然Condition move可能还会涉及到一些异常错误,如空指针异常等(因为它会提前计算每个分支的结果,可能某个分支本身不会被执行到,但因为提前计算的原因触发了空指针异常)

Loop

常见的Loop有3种,分别为while、for、do-while。Loop总体来说是通过Condition Branch和Goto指令实现。三种Loop的差异在于:for对于while而言,需要提前初始化某些变量;do-while对于while而言,需要在条件判断前提前执行一次循环体。

Switch Statement

对于C Code而言,switch可以用一系列if else判断,但在汇编层面,switch并不是通过简单的if else堆积去实现。在switch的汇编实现中,编译器放弃了O(n)的if else代替,而是通过Jump Table和Conditional Tree实现。
Jump Table可以看成是一个索引数组,其中每一个索引对应switch statement中的case value。当然对于非法的case value(即负数or过大的value),可以通过对所有case value添加一个bias来将case value演绎成一个合理的值,当然也可以通过其他技巧去将不合理的case value等价替换掉。
由于数组是可以通过索引直接访问对应的值,因此Jump Table为O(1),但对于较为稀疏的case value而言,将其设置为一个0-n-1的jump table会浪费较大的空间,因此通过conditional tree(本质上是二分查找)来实现,其时间复杂度为O(log n)

Procedures

Procedures即为函数,其执行规则与栈规则一致:if p call q,then q return before q <—> last in, first out

Stack

对于Procedures而言,其重要特性都和Stack相关,如函数调用,参数传递、数据存储。因此理解Stack的结构对于Procedures的理解至关重要。
Stack是一个后进先出的数据结构,在汇编层面,Address从高到低对应Stack从栈底到栈顶,而数据增长的方向则是从栈底到栈顶,%rsp寄存器指向栈顶地址
csapp-Machine-Level Representation of Program-review_第9张图片

Calling Conventions

Passing Control

对于Procedures而言,其通过call label指令和ret指令实现控制权的传递,对应的call label即当p call q时将控制权从p传递给q,当q执行完后通过ret返回结果,将控制权还给p。

Passing Data

Passing Data主要是参数的传递,对于前6个参数,会存储在特定的Register中(%rsi、%rdi…),从第7个参数开始,其都会存储在stack的特定区域(stack frame)中。

Managing Local Data

在Procedure中,当调用其他函数时,如p call q,那么对于q执行过程中产生的一些中建信息,对于p而言其实是无用的,所以这部分信息不应该占用公共的空间,而是应该随着q的生命周期结束而释放,在stack中,通过设定stack frame区域来存储这一部分数据,包括返回信息、本地的存储信息、临时空间。当然返回信息式存储在caller stack frame中,而剩下两者则是存储在callee stack frame中。
当然,由于寄存器是公用的,即p和q使用的%rbx是同一个rbx。在某些场景下我们可能需要临时保存某些特殊值而不被改变,因此在16个Register中,使用特殊的几个Registers(%r10和%r11)来保存临时值
csapp-Machine-Level Representation of Program-review_第10张图片

Data

Data除了Integer和Floating Point这些基本数据类型外,还有Array、Structure、Union等组合数据。在内存中,诸如Array、Structure、Union这种类型的数据其实是一段连续的字节,他们是某些基本数据类型的集合。

Array

Array可以看成是同一种数据类型的集合,常见的有一维数组和二维数组两种类型。对于一个 A T[L],其在内存中所占用的空间大小为L * sizeof(A)。对于二维数组 A T[m][n],可以将其理解为每个元素均为一个长度为n的一维A类型数组。
csapp-Machine-Level Representation of Program-review_第11张图片
csapp-Machine-Level Representation of Program-review_第12张图片

数组和指针

数组和指针一直是一个容易被混淆的概念。但其实如果站在内存的角度去看就很容易区分这两种概念,在内存中,数组是一段连续的字节序列,存储的是同一种数据类型;而指针在内存中则是一个大小为8byte的地址空间。对于A *而言,其表示A作为一个pointer,指向内存中的某个存储的value为A类型的内存块,因此,A *既可以表示一个A value,也可以表示一个A array。当A *表示一个A类型的array时,其代表的是该array的首地址

数组指针和指针数组

很多同学分不清这两个概念:数组指针和指针数组。同样的,从内存方面去考虑二者能够很轻松的区分。如int * A[3],它表示的是一个长度为3,每个元素为一个指针,每个指针指向一个int类型元素的数组,int (* A)[3],它表示的是一个指向长度为3的int数组的指针。
csapp-Machine-Level Representation of Program-review_第13张图片
还有一点需要注意的是在使用指针时,对于 T val[L]而言,val+2表示val数组的第三个元素的地址(0表示首地址),但是2+val则无法通过编译,这是因为在汇编层面规定了参数的顺序,第一个为 void *,第二个为常数,因此2+val无法被有效处理。

Structure

Structure可以看成是一组不同类型元素的集合。

struct{
	char a;
	int b[2];
	long c;
} S;

Structure中需要注意的是一个Structure所占用的内存空间并不总是等于其内部所有元素占用大小的和,因为这里还涉及到一个对其的问题。
对于X86-64而言,在从内存中取数据时会一次性取出64byte大小的数据,因此对于一个Structure而言,如果其所占用的内存地址横跨两个内存块时,还需要硬件和os去采取额外的操作去弥补这种横跨问题,因此会造成性能的下降。
而对齐则是为了解决这种横跨内存块造成的性能下降。所谓对齐就是尽可能让变量的首地址是该变量占用大小的k倍,通常是按照一个Structure中占用空间最大的变量对齐,拿上述例子来说,占用空间最大的是long类型的c(占用8个byte),因此需要按照8对齐(注意:对齐只针对基本数据类型,即使是一个array,也只需要看array中元素的大小,这一点很好理解)。为了尽可能减少对齐造成的内存消耗,这里涉及到一个变量重排序的过程,通常将占用空间最大的元素放在地址首部对齐占用的内存空间最小(这一点大家可以实际实践下)

Union

Union和Structure是非常类似的一种数据类型,都是一组不同类型数据的集合,但Union与Structure不同的是,Union中所有数组共享同一份内存空间,也就是说Structure的大小等于其内所有元素占用空间之和(不考虑对齐问题),而Union的大小等于其内最大元素占用空间。
csapp-Machine-Level Representation of Program-review_第14张图片
因此在使用Union时,不能在同一时刻同时使用Union中的多个数据,而只能使用一个数据

Memory Layout

对于X86-64机器来说,其可用的地址空间有48位,对应即为256TB,在其中又只有128TB可以供用户使用,所以寻址空间一共有128TB(0->0x0007FFFFFFFFFFFF)
csapp-Machine-Level Representation of Program-review_第15张图片

在内存区域中,Stack负责存储一些局部变量以及运行时数据;Heap则是动态产生,通过malloc()、calloc()、new()等方式创建;Data则是负责存储一些静态变量、全局变量;Text/Shared Libraries存储可执行的机器指令,并且是只可读的。

Buffer Overflow

Buffer Overflow指的是输入的内容由于没有做限制而导致其覆盖了内存中其他指令的地址从而对其他指令造成破坏,同样,这也是hacker攻击程序的主要手段。
在C语言中,诸如gets()这样没有对输入内容作大小限制的函数则存在Buffer Overflow的风险。

避免手段

  1. 禁止使用有Buffer Overflow风险的函数,使用等价的fgets代替gets,fgets通过提供最大长度限制参数来避免Buffer Overflow
  2. OS提供的避免Buffer Overflow的保护手段
    1. ASL:每次运行时将栈地址随机化,Buffer Overflow生效的前提是你明确知道栈地址的位置,然后通过覆盖的形式破坏程序执行,而ASL通过随机栈地址可以有效避免Buffer Overflow
    2. 提供可执行标记,通过标记栈不可执行来避免Buffer Overflow
    3. 金丝雀:在Stack中添加测试代码,从代码执行前从指定寄存器A中取出8byte的value存储到另一个寄存器B中,然后执行栈上代码,随后比较寄存器B中的value和寄存器A中的value是否相等,若不相等,说明栈上地址被破坏、

总结

通过学习Machine-Level Representation这一章节,我觉得最大的收获就是学会了去阅读汇编代码,能够从汇编级别上去调试代码,掌握汇编层面的一些优化手段(Condition Move、对齐等等),同时也进一步加深了我对指针的理解。最后,我想用自己总结的一句话来收尾:自己从汇编层面、内存存储层面去理解代码原理、分析代码问题要比Google更加有效,cs is easy,if not easy,then try back to the source.

你可能感兴趣的:(csapp,csapp,Machine-Level)