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

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

程序编码

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

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 sum
0x0 <sum>:      0x55    0x89    0xe5    0x8b    0x45    0x0c    0x03    0x45
0x8 <sum+8>:    0x08    0x01    0x05    0x00    0x00    0x00    0x00    0x5d
0x10 <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。

类型 格式 操作数值 名称
立即数 $Imm Imm 立即数寻址
寄存器 E_a R[E_a] 寄存器寻址
寄存器 Imm M[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,D D <- S 传送双字
movw   S,D D <- S 传送字
movb   S,D D <- S 传送字节
movsbl S,D D <- S(符号扩展) 传送符号扩展的字节
movzbl S,D D <- 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,%esp
movl     %ebp,(%esp)

popl     %eax
相当于:
movl     (%esp),%eax
addl     $4,%esp

算术和逻辑

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

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

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

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

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

指令 效果 描述
imull S R[%edx]:R[%eax] <- S * R[%eax] 有符号64位乘法
mull  S R[%edx]:R[%eax] <- S * R[%eax] 无符号64位乘法
cltd  S R[%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,S1 S1 - S2 比较双字
testl S2,S1 S1 & S2 测试双字

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

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

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

指令 同义名 效果 设置条件
sete  D setz D <- ZF 相等/零
setne D setnz D <- ~ZF 不等/非零
sets  D   D <- SF 负数
setns D   D <- ~SF 非负数
setg  D setnle D <- ~(SF^|OF)&~ZF 大于(有符号>)
setge D setnl D <- ~(SF^|OF) 大于等于(有符号>=)
setl  D setnge D <- SF^|OF 小于(有符号<)
setle D setng D <- (SF^OF)|ZF 小于等于(有符号<=)
seta  D setnbe D <- ~CF&~ZF 超过(无符号>)
setae D setnb D <- ~CF 超过或相等(无符号>=)
setb  D setnae D <- CF 低于(无符号<)
setbe D setna D <- CF|ZF 低于或相等(无符号<=)

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

指令 同义名 跳转条件 描述
jmp Label   1 直接跳转
jmp *Operand   1 间接跳转
je  Label jz ZF 相等/零
jne Label jnz ~ZF 不等/非零
js  Label   SF 负数
jns Label   ~SF 非负数
jg  Label jnle ~(SF^OF)&~ZF 大于(有符号>)
jge Label jnl ~(SF^OF) 大于等于(有符号>=)
jl  Label jnge SF^OF 小于(有符号<)
jle Label jng (SF^OF)|ZF 小于等于(有符号<=)
ja  Label jnbe ~CF&~ZF 超过(无符号>)
jae Label jnb ~CF 超过或相等(无符号>=)
jb  Label jnae CF 低于(无符号<)
jbe Label jna CF|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-statement
while (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, %esp
popl    %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, @function
swap:
        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, @function
func:
        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->j
addl    (%eax), %edx        加上r->i
leal    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/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值