Linux系统学习笔记:程序的机器级表示

来源:互联网 发布:kali linux攻击服务器 编辑:程序博客网 时间:2024/06/04 19:44

汇编语言编写的代码和机器代码接近,和机器密切相关,现在通常我们已经不会用它来编写程序了。但它仍然很重要,特别是希望能够了解程序是如何转换成机器代码来运行的时候。本篇总结汇编语言的部分知识和C程序如何编译成机器代码。

Contents

  • 程序编码
  • GAS指令
    • 数据传送
    • 算术和逻辑
    • 控制
    • 过程
    • 数组
    • 结构和联合
    • 内嵌汇编
  • 对齐
  • 缓冲区溢出
  • GDB调试器

程序编码

汇编语言中不区分数据类型,将存储器看作一个很大的按字节寻址的数组。程序存储器包含程序的目标代码、操作系统需要的一些信息、运行时栈和用户分配的存储器块。程序存储器用虚拟地址寻址,由操作系统负责管理虚拟地址空间,将虚拟地址转换为物理地址。

gcc 命令使用 -S 选项可以产生汇编代码,使用 -c 选项会编译代码产生目标文件, -O2 选项为优化级别。

例:

/* a.c */int accum = 0;int sum(int x, int y){    int t = x + y;    accum += t;    return t;}
$ gcc -O2 -S a.c    # 生成a.s汇编文件$ gcc -O2 -c a.c    # 生成a.o目标文件

生成的汇编文件中 sum 函数为:

sum:    pushl   %ebp    movl    %esp, %ebp    movl    12(%ebp), %eax    addl    8(%ebp), %eax    addl    %eax, accum    popl    %ebp    ret

可以用反汇编器来根据目标代码来生成类似于汇编代码的格式:

$ objdump -d a.o...00000000 <sum>:   0:   55                      push   %ebp   1:   89 e5                   mov    %esp,%ebp   3:   8b 45 0c                mov    0xc(%ebp),%eax   6:   03 45 08                add    0x8(%ebp),%eax   9:   01 05 00 00 00 00       add    %eax,0x0   f:   5d                      pop    %ebp  10:   c3                      ret

sum 函数有17个字节,还可以用 gdb 查看它的字节表示:

$ gdb a.o(gdb) x/17xb sum0x0 <sum>:      0x55    0x89    0xe5    0x8b    0x45    0x0c    0x03    0x450x8 <sum+8>:    0x08    0x01    0x05    0x00    0x00    0x00    0x00    0x5d0x10 <sum+16>:  0xc3

x/17xb 表示“检查17个十六进制字节”。得到的结果和 objdump 的相同。

在主文件中调用 sum 函数:

/* m.c */int main(){    return sum(1, 3);}

编译生成可执行文件:

$ gcc -O2 -o m a.o m.c

反编译 m 文件,可以看到函数 sum 和 a.o 目标文件中的有两个不同:一是虚拟空间地址不同,二是 accum全局变量现在已由具体的地址代替。

$ objdump -d m...080483a0 <sum>: 80483a0:       55                      push   %ebp 80483a1:       89 e5                   mov    %esp,%ebp 80483a3:       8b 45 0c                mov    0xc(%ebp),%eax 80483a6:       03 45 08                add    0x8(%ebp),%eax 80483a9:       01 05 b0 95 04 08       add    %eax,0x80495b0 80483af:       5d                      pop    %ebp 80483b0:       c3                      ret

GAS指令

Intel用字表示16位数据类型,32位数称为双字,64位数称为四字。见前一篇中C数据类型的大小的表。GAS中每个操作都有一个字符后缀,表示操作数的大小。

IA32 CPU中包含一组8个存储32位值的寄存器,它们用来存储整数数据和指针。所有8个寄存器可以以16位和32位来访问,前四个寄存器还可以独立访问两个低位字节。

/media/note/2012/03/15/linux-machine-level/fig1.png

整数寄存器

大多数指令具有操作数,指示操作要引用的源数据值和放置结果的目的位置。操作数有三种:

  • 立即数,即常数值,写为 $ 加一个整数。
  • 寄存器,表示某个寄存器的内容,用E_a表示寄存器a,引用R[E_a]表示它的值。
  • 存储器引用,根据地址访问某个存储器位置,用M[Addr]表示存储在存储器中地址Addr开始的值的引用。

伸缩因子s必须是1、2、4、8。

类型格式操作数值名称立即数$ImmImm立即数寻址寄存器E_aR[E_a]寄存器寻址寄存器ImmM[Imm]绝对寻址寄存器(E_a)M[R[E_a]]间接寻址寄存器Imm(E_b)M[Imm+R[E_b]](基址+偏移量)寻址寄存器(E_b,E_i)M[R[E_b]+R[E_i]]变址寄存器Imm(E_b,E_i)M[Imm+R[E_b]+R[E_i]]寻址寄存器(,E_i,s)M[R[E_i]*s]伸缩化的变址寻址寄存器Imm(,E_i,s)M[Imm+R[E_i]*s]伸缩化的变址寻址寄存器(E_b,E_i,s)M[R[E_b]+R[E_i]*s]伸缩化的变址寻址寄存器Imm(E_b,E_i,s)M[Imm+R[E_b]+R[E_i]*s]伸缩化的变址寻址

数据传送

最常用的指令是数据传送指令。IA32限制传送指令的两个操作数不能都指向存储器位置。

指令效果描述movl   S,DD <- S传送双字movw   S,DD <- S传送字movb   S,DD <- S传送字节movsbl S,DD <- S(符号扩展)传送符号扩展的字节movzbl S,DD <- S(零扩展)传送零扩展的字节pushl  S

R[%esp] <- R[%esp] - 4

M[R[%esp]] <- S

压栈popl   D

D <-M[R[%esp]]

R[%esp] <- R[%esp] + 4

出栈

movsbl 和 movzbl 复制字节,并扩展为32位。

IA32的栈向低地址方向增长,压栈减小栈指针的值并存内容到存储器中,出栈相反。按惯例,将栈倒着画,栈顶放在下面。

pushl    %ebp相当于:subl     $4,%espmovl     %ebp,(%esp)popl     %eax相当于:movl     (%esp),%eaxaddl     $4,%esp

算术和逻辑

下表中包含了四类整数操作:加载有效地址、一元操作、二元操作和移位操作。

指令效果描述leal  S,DD <- &S加载有效地址incl  DD <- D + 1加1decl  DD <- D - 1减1negl  DD <- -D取负notl  DD <- ~D取补addl  S,DD <- D + S加subl  S,DD <- D - S减imull S,DD <- D * S乘xorl  S,DD <- D ^ S异或orl   S,DD <- D | S或andl  S,DD <- D & S与sall  k,DD <- D << k左移shll  k,DD <- D << k左移(等价于sall)sarl  k,DD <- D >> k算术右移shrl  k,DD <- D >> k逻辑右移

除 leal 外,每条指令都有以 w 和 b 结尾的对字和字节操作的版本。

加载有效地址指令 leal 并不引用存储器,而是将有效地址写到目的操作数,目的操作数必须是寄存器。

下表中是一些特殊的算术操作:64位的乘积和整数除法。

指令效果描述imull SR[%edx]:R[%eax] <- S * R[%eax]有符号64位乘法mull  SR[%edx]:R[%eax] <- S * R[%eax]无符号64位乘法cltd  SR[%edx]:R[%eax] <- R[%eax](符号扩展)转换为四字idivl S

R[%edx] <- R[%edx]:R[%eax] mod S

R[%eax] <- R[%edx]:R[%eax] / S

有符号除法divl  S

R[%edx] <- R[%edx]:R[%eax] mod S

R[%eax] <- R[%edx]:R[%eax] / S

无符号除法

控制

程序执行通常还需要控制操作执行的顺序。汇编语言提供了非顺序控制流的机制,基本操作是跳转到程序的另一部分。

CPU中有一组单个位的条件码寄存器,描述最近的算术或逻辑操作的属性。主要包括:

  • CF :进位标志,最近的操作使最高位产生了进位,可用来检查无符号操作数的溢出。
  • ZF :零标志,最近的操作得到的结果为0。
  • SF :符号标志,最近的操作得到的结果为负数。
  • OF :溢出标志,最近的操作导致一个二进制补码溢出。

leal 指令不改变条件码,其他的算术和逻辑操作都会设置条件码。此外,下面的操作只设置条件码:

指令基于描述cmpl  S2,S1S1 - S2比较双字testl S2,S1S1 & S2测试双字

cmpl 和 testl 指令都有对应的 w 和 b 结尾的字和字节版本。

可以根据条件码的某种组合来设置整数寄存器或执行条件分支指令。

下面的指令根据条件码的组合将一个字节设置为0或1,可以用 movzbl 指令对高位字节清零来得到32位结果。

指令同义名效果设置条件sete  DsetzD <- ZF相等/零setne DsetnzD <- ~ZF不等/非零sets  D D <- SF负数setns D D <- ~SF非负数setg  DsetnleD <- ~(SF^|OF)&~ZF大于(有符号>)setge DsetnlD <- ~(SF^|OF)大于等于(有符号>=)setl  DsetngeD <- SF^|OF小于(有符号<)setle DsetngD <- (SF^OF)|ZF小于等于(有符号<=)seta  DsetnbeD <- ~CF&~ZF超过(无符号>)setae DsetnbD <- ~CF超过或相等(无符号>=)setb  DsetnaeD <- CF低于(无符号<)setbe DsetnaD <- CF|ZF低于或相等(无符号<=)

跳转指令使执行切换到程序中的一个新位置,跳转的目的地通常用标号指明。当跳转条件满足时,指令会跳转到一条带标号的目的地。

指令同义名跳转条件描述jmp Label 1直接跳转jmp *Operand 1间接跳转je  LabeljzZF相等/零jne Labeljnz~ZF不等/非零js  Label SF负数jns Label ~SF非负数jg  Labeljnle~(SF^OF)&~ZF大于(有符号>)jge Labeljnl~(SF^OF)大于等于(有符号>=)jl  LabeljngeSF^OF小于(有符号<)jle Labeljng(SF^OF)|ZF小于等于(有符号<=)ja  Labeljnbe~CF&~ZF超过(无符号>)jae Labeljnb~CF超过或相等(无符号>=)jb  LabeljnaeCF低于(无符号<)jbe LabeljnaCF|ZF低于或相等(无符号<=)

jmp 指令是无条件跳转。可以是直接跳转,以一个标号作为跳转目标,如 .L1 ;也可以是间接跳转,跳转目标从寄存器或存储器中读出,如 *(%eax) 。

条件跳转只能是直接跳转。

汇编代码中跳转目标以符号标号书写,汇编器和链接器会产生跳转目标的适当编码。最常用的编码是PC相关的,即以目标指令的地址和紧跟在跳转指令后面的那条指令的地址的差作为编码,这样的好处是指令编码简洁,且目标代码存储位置变化时不必改变。另一种编码方法是给出绝对地址。

C语言控制流翻译成汇编代码的结构,通常都是以跳转实现。不同的编译器或同一编译器的不同优化级别产生的汇编代码的结构往往都是不同的。下面只就一些典型的处理方式做分析,实际当中会有一定不同。

C语言的条件语句是用有条件跳转和无条件跳转结合起来实现的。

C语言if-else语句:                      汇编实现的结构:if (test-expr)                              t = test-expr;    then-statement                          if (t)else                                            goto true;    else-statement                          else-statement                                            goto done;                                        true:                                            then-statement                                        done:

C语言中的三种循环也是以条件测试和跳转的组合来实现,大多数会根据循环的 do-while 形式来产生循环代码。

C语言do-while语句:                     汇编实现的结构:do                                      loop:    body-statement                          body-statementwhile (test-expr);                          t = test-expr;                                            if (t)                                                goto loop;
C语言while语句:                        汇编实现的结构:while (test-expr)                           t = test-expr;    body-statement                          if (!t)                                                goto done;                                        loop:                                            body-statement                                            t = test-expr;                                            if (t)                                                goto loop;                                        done:
C语言for语句:                          汇编实现的结构:for (init-expr; test-expr; update-expr)     init-expr;    body-statement                          t = test-expr;                                            if (!t)                                                goto done;                                        loop:                                            body-statement                                            update-expr;                                            t = test-expr;                                            if (t)                                                goto loop;                                        done:

C语言的 switch 语句的实现在分支较多和值的跨度较小时会使用跳转表。跳转表是一个数组,表项i是代码段的地址,使用跳转表使代码可以快速跳转到要执行的分支,和分支数无关。

跳转表类似下面这样声明:

        .section        .rodata    只读数据        .align 4        .align 4.L8:                               下面是不同的case        .long   .L2        .long   .L3        .long   .L4        .long   .L5        .long   .L2        .long   .L6        .long   .L2        .long   .L7

过程

过程调用包括数据和控制的传递,还在进入时为过程的局部变量分配空间,退出时释放空间。IA32提供了转移控制的指令,数据传递和局部变量的分配释放则通过操纵程序栈实现。

栈用来传递过程参数、存储返回信息、保存寄存器和用于本地存储。为单个过程分配的部分栈称为栈帧。寄存器 %ebp 作为帧指针,寄存器 %esp 作为栈指针,栈帧以这两个指针定界。程序执行时,栈指针可以移动。

/media/note/2012/03/15/linux-machine-level/fig2.png

栈帧结构

若P调用Q,Q的参数放在P的栈帧中。调用时,P的返回地址压入栈中形成P的栈帧的末尾,返回地址是程序从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针开始,后面是其他寄存器的值和不能放在寄存器中的局部变量。栈向低地址方向增长。

下面是过程调用和返回的指令:

指令描述call  Label过程调用call  *Operand过程调用leave为返回准备栈ret从过程调用中返回

调用可以是直接的和间接的, call 指令将返回地址入栈,并跳转到被调用过程的起始处,返回地址是call 后面的指令的地址。 leave 指令使栈指针指向 call 存储的返回地址。 ret 指令从栈中弹出地址,并跳转到那里。寄存器 %eax 可以用来返回值。

leave等价于:movl    %ebp, %esppopl    %ebp

程序寄存器组被所有过程共享。寄存器 %eax 、 %edx 、 %ecx 为调用者保存寄存器,由P保存,Q可以覆盖这些寄存器而不破坏P的数据。寄存器 %ebx 、 %esi 、 %edi 为被调用者保存寄存器,Q需要在覆盖它们之前将值保存到栈中,并在返回前恢复它们。

int swap(int *x, int *y){    int t = *x;    *x = *y;    *y = t;    return *x + *y;}int func(){    int a = 1234;    int b = 4321;    int s = swap(&a, &b);    return s;}
        .p2align 4,,15.globl swap        .type   swap, @functionswap:        pushl   %ebp                压入帧指针        movl    %esp, %ebp          将帧指针设为栈指针指向位置        movl    8(%ebp), %edx       读取x        movl    12(%ebp), %ecx      读取y        pushl   %ebx                压入%ebx(被调用者保存寄存器)        movl    (%edx), %eax        读取*x        movl    (%ecx), %ebx        读取*y        movl    %ebx, (%edx)        *y写入x指向存储器位置        movl    %eax, (%ecx)        *x写入y指向存储器位置        addl    (%edx), %eax        *x+*y        popl    %ebx                恢复%ebx        popl    %ebp                弹出帧指针(%esp等于原值)        ret                         返回        .size   swap, .-swap        .p2align 4,,15.globl func        .type   func, @functionfunc:        pushl   %ebp                压入帧指针        movl    $5555, %eax         保存计算结果(编译器-O2优化)        movl    %esp, %ebp          将帧指针设为栈指针指向位置        popl    %ebp                弹出帧指针(%esp未变)        ret                         返回        .size   func, .-func

数组

设有数据类型 T , L 为 T 的字节大小。

C中的数组声明 T a[N] 在存储器中分配了 L*N 字节的连续区域,设 x 为起始位置,声明还引入了标识符 a,作为指向数组开头的指针,指针的值为 x 。数组元素 i 的存放地址为 x + L*i 。

C中指针运算的值会根据指针引用的数据类型的大小进行调整。若 p 指针指向类型 T 的数据, p 的值为 x,则 p+i 的值为 x + L*i 。

对于循环中数组的引用,编译器优化通常会用指针运算代替循环变量来遍历数组,用最后的数组元素的地址和指针的比较作为测试条件。

对于固定大小的数组,编译器会进行多种优化,如使用指针变量来访问,用移位和加法代替乘法指令。动态分配的数组在编译时不能确定大小,需要用 calloc 等函数在堆中创建,必须使用乘法指令。

typedef int *matrix;int matrix_e(matrix a, int i, int j, int n){    return a[(i*n) + j];}
matrix_e:        pushl   %ebp        movl    %esp, %ebp        movl    20(%ebp), %eax          读取i        imull   12(%ebp), %eax          计算i*n        movl    8(%ebp), %edx           读取a        addl    16(%ebp), %eax          计算i*n+j        popl    %ebp        movl    (%edx,%eax,4), %eax     读取a[i*n+j]        ret

结构和联合

结构的实现类似于数组,组成部分存储在连续区域,指向结构的指针即结构的第一个字节的地址。编译器保存关于每个结构类型的信息,指示每个域的字节偏移。

struct rec {    int i;    int j;    int a[3];    int *p;} *r;r->p = &r->a[r->i + r->j];
movl    4(%eax), %edx       读取r->jaddl    (%eax), %edx        加上r->ileal    8(,%edx,4), %edx    计算&r->a[r->i+r->j]movl    %edx, 20(%eax)      存入r->p

联合的语法和结构一样,但它的不同的域引用相同的存储器块。

内嵌汇编

现在已经很少使用汇编代码写程序了,但是在一些如访问寄存器或获取条件码等的场合,仍然需要汇编代码。

可以编写独立的汇编代码文件,然后编译它并和C代码链接起来。GCC也支持将汇编和C代码混合起来,即内嵌汇编。

内嵌汇编像过程调用一样写代码:

asm(code-string);

code-string 为字符串形式的汇编代码序列,编译器将它插入到生成的汇编代码中,错误检查会由汇编器来执行。

asm 有一个扩展版本,它可以指定汇编代码序列的操作数和要被覆盖的寄存器。

asm(code-string[ : output-list[ : input-list[ : overwrite-list]]]);

code-string 由用 ; 分隔的汇编代码指令序列组成,输入输出操作数用引用 %0 、...、 %9 表示,操作数根据它们第一次在 input-list 和 output-list 中出现的顺序编号。输入输出列表由 , 分隔的操作数对组成,每个操作数对由空格分隔的操作数类型的字符串和括号包含的操作数组成。 = 表示赋值, r 表示整数寄存器。操作数是C表达式。寄存器要在前面加 % 。

int umul(unsigned x, unsigned y, unsigned *dst){    int res;    /*     * movl     x,%eax              读取x     * mull     y                   无符号乘法乘以y     * movl     %eax,*dst           存储结果的低4字节到*dst     * setae    %dl                 设置低位字节     * movzbl   %dl,res             零扩展设为res     */    asm("movl %2,%%eax; mull %3; movl %%eax,%0; setae %%dl; movzbl %%dl,%1"        : "=r" (*dst), "=r" (res)   /* 输出 */        : "r" (x), "r" (y)          /* 输入 */        : "%eax", "%edx"            /* 覆盖 */        );    return res;}
umul:        pushl   %ebp        movl    %esp, %ebp        movl    8(%ebp), %ecx       读取x        pushl   %ebx        movl    12(%ebp), %ebx      读取y#APP# 4 "asm.c" 1        movl %ecx,%eax; mull %ebx; movl %eax,%ebx; setae %dl; movzbl %dl,%ecx# 0 "" 2#NO_APP        movl    16(%ebp), %eax      读取dst        movl    %ebx, (%eax)        保存*dst        movl    %ecx, %eax          res为结果        popl    %ebx        popl    %ebp        ret

对齐

通常计算机系统对基本数据类型的可允许的地址做了一些限制,要求地址必须是某个值k(常为2、4、8)的倍数。对齐简化了处理器和存储器系统之间接口的硬件设计。

Linux要求2字节数据类型的地址必须是2的倍数,更大的数据类型的地址必须是4的倍数。Microsoft Windows要求任何k字节数据类型的地址必须是k的倍数。

编译器在汇编代码中指明全局数据所需的对齐,如跳转表中的 .align 4 ,它使该指令后面的数据从4的倍数的地址开始。

malloc 等分配存储器的库函数必须设计为返回的指针能满足最糟情况的对齐限制。

对于结构,它的起始地址和域都有一些对齐要求。如:

struct s {          /* 地址示例:x为间隙           */    char c;         /* bf9483e0: cx-------------- */    short s[2];     /* bf9483e2: --s0s1xx-------- */    int i;          /* bf9483e8: --------iiii---- */    char d;         /* bf9483ec: ------------dxxx */};

缓冲区溢出

C对数组引用不做边界检查,同时局部变量和状态信息(寄存器值和返回指针等)都存放在栈中,这使得越界的数组写操作会破坏存储在栈中的状态信息,程序使用被破坏的状态时就会出现严重的错误。

常见的状态破坏称为缓冲区溢出,就是实际保存内容的大小超过了缓冲区大小,导致写越界。缓冲区溢出能被用来让程序执行非本意的函数,这是最常见的通过计算机网络攻击系统安全的方法。

GDB调试器

GDB支持对机器级程序的运行时评估和分析。一般先运行 objdump 来获得程序的反汇编版本,以帮助确定断点等。断点可以设置在函数入口后面,或某个地址。使用 gdb 执行程序,遇到一个断点时,程序会停下来,将控制返回给用户。在断点处,可以查看各个寄存器和存储器。还可以单步跟踪程序,一次执行几条命令,或前进到下一个断点。

下面是一些常用的命令:

开始和停止  quit                      退出gdb  run                       运行程序(设置命令行参数)  kill                      停止程序断点  break func                设置断点在func函数入口  break *0x80483c7          设置断点在地址0x80483c7  delete 1                  删除断点1  delete                    删除全部断点执行  stepi                     执行一条指令  stepi n                   执行n条指令  nexti                     执行一条指令(可以通过子例程调用)  continue                  恢复执行  finish                    运行直到当前函数返回检查代码  disas                     反汇编当前函数  disas func                反汇编func函数  disas 0x80483b7           反汇编在地址0x80483b7附近的函数  disas 0x80483b7 0x80483c7 反汇编在指定地址范围的代码  print /x $eip             十六进制打印程序计数器检查数据  print $eax                十进制打印%eax的内容  print /x $eax             十六进制打印%eax的内容  print /t $eax             二进制打印%eax的内容  print 0x100               打印0x100的二进制表示  print /x 100              打印100的十六进制表示  print /x ($ebp+8)         十六进制打印%ebp+8的内容  print *(int *) 0xbffff760 打印地址0xbffff760的整数  print *(int *) ($ebp+8)   打印地址%ebp+8的整数  x/2w 0xbffff760           检查地址0xbffff760开始的双字(4字节)  x/20xb func               检查func函数20字节的十六进制表示有用的信息  info frame                当前栈帧的信息  info registers            全部寄存器的值  help                      gdb帮助
链接: http://www.yeolar.com/note/2012/03/15/linux-machine-level/