C指针原理(43)-helloworld的C程序汇编剖析

一、汇编基础

1、指令码与数据处理

当计算机处理应用程序运行指令码时,数据指针指示处理器如何在内存的数据区域寻找要处理的数据,这块区域也称为堆栈,指令码放在另外的指令区,此外,还有指令指针机制,当处理器完成一个指令码的处理后,指令指针指向下一条指令码。

IA-32指令码(INTEL、AMD公司的CPU使用)由一堆二进制码构成,其格式为:

指令前缀、操作码、可选修饰符、可选数据元素

指令前缀可包含1到4个修改操作码行为的1字节前缀,分为:

锁定前缀和重复前缀

段覆盖前缀和分支提示前缀

操作数长度覆盖前缀

地址长度覆盖前缀

操作码定义了处理器执行的功能

修饰符定义执行功能时涉及的寄存器和内存位置。

数据元素是完成功能需要使用的数据,这些数据可以是直接的数据值,也可以是数据在内存中的地址。

2、汇编语言

以LINUX/UNIX环境下的汇编语言AT&T汇编(WINDOWS下有一种常用的汇编格式Intel汇编)进行讲解,汇编语言允许程序员方便地创建指令码程序,但不是用那些二进制编码的格式,还是使用助记符,助记符使用不同的词表示不同的指令码,有了助记符,程序员可以用英语来书写在目标机器上执行的指令码,不用记忆那些无趣的二进制编码。

 通常, FreeBSD 的内核使用 C 语言的调用规范。 此外, 虽然我们使用 int  $0x80来访问内核, 但是我们常常通过调用一个函数来执行  int  $0x80, 而不是直接访问。这个规范是非常方便的, 比 Microsoft� 的 MS-DOS上使用的规范更加优越。 为什么呢? 因为 UNIX� 的规范允许任何语言所写的程序访0问内核。这意味着在freebsd下访问内核需要先将参数压入栈中,然后再执行 int  $0x80调用内核中断,执行内核函数。 

下面这段代码是经典的helloworld汇编代码:

dp@dp:~ % vim helloworld.s

#hello.s

.data # 数据段声明

    msg : .string "Hello, world!\n" # 要输出的字符串

    len = . - msg                   # 字串长度

.text # 代码段声明

.global _start # 指定入口函数

_start: # 在屏幕上显示一个字符串

    pushl $len  # 参数三:字符串长度

    pushl $msg  # 参数二:要显示的字符串

    pushl $1    # 参数一:文件描述符(stdout) 

    movl $4, %eax    # 系统调用号(sys_write) 

    pushl %eax

    int  $0x80       # 调用内核功能

                     # 退出程序

    movl $0,%ebx     # 参数一:退出代码

    movl $1,%eax     # 系统调用号(sys_exit) 

    int  $0x80       # 调用内核功能

在LINUX/UNIX(以freebsd为例)下,可以使用gs和ld软件汇编和链接。

dp@dp:~ % as -o helloworld.o helloworld.s

dp@dp:~ % ld -o helloworld helloworld.o

dp@dp:~ % ./helloworld

Hello, world!

dp@dp:~ %

也可以直接使用GCC命令编译,但用gcc编译时将入口函数名由_start改为main。

dp@dp:~ % vim helloworld.s

#hello.s

.data # 数据段声明

    msg : .string "Hello, world!\n" # 要输出的字符串

    len = . - msg                   # 字串长度

.text # 代码段声明

.global main # 指定入口函数

main: # 在屏幕上显示一个字符串

    pushl $len  # 参数三:字符串长度

    pushl $msg  # 参数二:要显示的字符串

    pushl $1    # 参数一:文件描述符(stdout) 

    movl $4, %eax    # 系统调用号(sys_write) 

    pushl %eax

    int  $0x80       # 调用内核功能

                     # 退出程序

    movl $0,%ebx     # 参数一:退出代码

    movl $1,%eax     # 系统调用号(sys_exit) 

    int  $0x80       # 调用内核功能

~

汇编后运行

dp@dp:~ % gcc -o helloworld helloworld.s

dp@dp:~ % ./helloworld

Hello, world!

dp@dp:~ %

对于ubuntu等Linux 是一个类 UNIX 操作系统。 但是, 它的内核在传递参数的时候, 使用和 MS-DOS 相同系统调用规范。 比如在 UNIX 的规范中, 代表内核函数的数字存放在 EAX 中。 但是在 Linux 中, 参数不进行压栈而是存放在 EBX, ECX, EDX, ESI, EDI, EBP。因此在ubuntu下这段代码需要这样编写(设使用GCC编译)

#hello.s

.data # 数据段声明

    msg : .string "Hello, world!\n" # 要输出的字符串

    len = . - msg                   # 字串长度

ext # 代码段声明

global main # 指定入口函数

main: # 在屏幕上显示一个字符串

    movl $len, %edx  # 参数三:字符串长度

    movl $msg, %ecx  # 参数二:要显示的字符串

    movl $1, %ebx    # 参数一:文件描述符(stdout) 

    movl $4, %eax    # 系统调用号(sys_write) 

    int  $0x80       # 调用内核功能

                     # 退出程序

    movl $0,%ebx     # 参数一:退出代码

    movl $1,%eax     # 系统调用号(sys_exit) 

    int  $0x80       # 调用内核功能

2、C语言编译

   C语言属于高级语言,对汇编语言程序员来说也许是一种解脱,完成同样的任务,程序编码量减少很多,这只是把很多编码生成转移除到了编译器上来而已,让机器承担这部分编码生成工作。

以freebsd系统、intel 的Intel® Pentium®CPU为例,编写以下hello,world代码

dp@dp:~ % cat hello.c

include

int main(){

printf(“hello,world\n”);

return 0;

}
dp@dp:~ %

编译后,运行

dp@dp:~ % gcc -o hello hello.c

dp@dp:~ % ./hello

hello,world

使用GCC的 -S选项生成C语言对应的汇编代码

dp@dp:~ % gcc -S hello.c

下面是刚才生成的汇编语言代码

dp@dp:~ % cat hello.s

.file “hello.c”

.section .rodata

.LC0:

.string “hello,world”

.text

.p2align 4,15

.globl main

.type main, @function

main:

leal 4(%esp), %ecx

andl $-16, %esp

pushl -4(%ecx)

pushl %ebp

movl %esp, %ebp

pushl %ecx

subl $4, %esp

movl $.LC0, (%esp)

call puts

movl $0, %eax

addl $4, %esp

popl %ecx

popl %ebp

leal -4(%ecx), %esp

ret

.size main, .-main

.ident “GCC: (GNU) 4.2.1 20070831 patched [FreeBSD]”

.section .note.GNU-stack,"",@progbits
dp@dp:~ %

分析通过GCC编译C语言程序生成的汇编代码,能清楚得了解C语句运行机制、内存分配机制等隐藏在C语言代码下的内部工作原理。下面将对helloworld程序生成的汇编进行分析。

(1)寄存器基础知识

寄存器是中央处理器内的组成部份。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和地址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器。在中央处理器的算术及逻辑部件中,包含的寄存器有累加器。

虽然计算机都拥有内存,但由于CPU的运行速度一般比主内存的读取速度快,访问内存所需要的时间为数个时钟周期,要访问内存的话,就必须等待数个CPU周期从而造成浪费,因此内存并不是数据存取最快的装置,后来在现代计算机上使用的AMD或Intel微处理器在芯片内部集成了大小不等的数据高速缓存和指令高速缓存,统称为cache(高速缓存),cache让数据访问的速度适应CPU的处理速度,其原理是内存中程序执行与数据访问的局域性行为,即一定程序执行时间和空间内,被访问的代码集中于一部分,但是这些仍不是访问数据最快的途径。

寄存器是存储器层次结构中的最顶端,也是系统操作数据的最快速途径,但它数量少能存储的空间有限,它直接安放在中央处理器内,是有限存贮容量的高速存贮部件,可用来暂存指令、数据和地址。

IA-32处理器有8个通用寄存器,分别为:
EAX 一般用作累加器
EBX 一般用作基址寄存器(Base)
ECX 一般用来计数(Count)
EDX 一般用来存放数据(Data)
EBP 一般用作堆栈指针(Stack Pointer)
EBP 一般用作基址指针(Base Pointer)
ESI 一般用作源变址(Source Index)
EDI 一般用作目标变址(Destinatin Index)

IA-32处理器有6个常用的段寄存器,分别为 :
CS 代码段寄存器
DS 数据段寄存器
SS 堆栈段寄存器
ES、FS及GS 附加数据段寄存器

它还有标志寄存器EFLAGS,用来存放有关处理器的控制标志,此外还有控制寄存器.还拥有调试寄存器和测试寄存器以及系统地址寄存器。

这些寄存器,使用的最多的是通用寄存器,在AT&T汇编中,使用%寄存器名的方式表示通用寄存器,比如:

%ebx表示ebx寄存器

%ecx表示ecx寄存器

(2)C变量内存分配

在C语言中,变量在内存中拥有自己的位置,这个位置就是变量的地址,可用指针来保存这个地址。而汇编语言中变量包括标记、数据类型、默认值三个部分,标记指示了变量的内存位置,存储的数据类型决定了变量在内存占有多少字节的空间,默认值决定了变量的初始值。观察上面C语言版的helloworld生成的汇编代码中的一段(如下所示),输出的helloworld字符串被放置在由“.LC0”标记的内存中,类型为string型,默认值为"hello,world"。

.LC0:

.string “hello,world”

“.LC0”标记的内存位于应用程序的静态分配区域,这个区域在程序运行后即被分配,即“hello,world”作为一个C语言字符串常量被安排在静态分配区域。

还有一个非常重要内存分配区域就是堆栈,,堆栈是特殊的内存区域,用于程序中函数传递参数、数据的临时存取,通常是应用程序内存范围的结尾位置的内存区域,为了方便堆栈数据的存取,有一个堆栈指针(栈顶指针)指向堆栈中的下个内存位置,这意味着如何仅依靠栈顶指针不采用任意偏移地址机制(偏移地址可以以基地址为中心进行调整,比如说访问某个变量,该变量的基地址为0x400,偏移地址为0x16,则该变量的最终地址为0x416),则只能按照先进后出的顺序来访问堆栈内部存储的变量。

在汇编语言中将数据放入堆栈中,使用pushl助记符,而将数据从堆栈中弹出,使用popl助记符,每次对堆栈数据的放入与弹出都会导致栈顶指针的变化,因为栈顶指针永远指向堆栈中下一个可用的地址。下面这段汇编完成了将10压入堆栈,然后将10弹出到ebx寄存器中的过程。

pushl $10

popl %ebx

(2)C程序执行

C语言的源代码被翻译成若干行汇编代码,由几个简单的指令组成的汇编代码生成二进制文件,执行这个二进制文件,完成了helloworld的执行。

汇编语言中用的较多助记符是movl、addl、subl

movl完成数据的复制,而addl完成数据的加法,subl完成数据的减法。

这3个助记符的语法格式是:

助记符 源数据 目标数据

比如,对于这段静态分配变量的汇编代码:

myvalue:

.long 190

mess:

.ascii “hello”

通过addl与movl可以完成将myvalue指示的long类型变量190加100,然后减20的功能。

movl myvalue,%ebx

addl $100,%ebx

subl $20,%ebx

movl %ebx,myvalue

汇编语言的代码放在了.text段中,分析上面的helloworld的反汇编代码中一段:

.text

.p2align 4,15

.globl main

.type main, @function

globl 命令指定了main函数为入口函数(程序启动时执行的函数),然后接着在后面定义了main函数的组成:

main:

leal 4(%esp), %ecx

andl $-16, %esp

pushl -4(%ecx)

pushl %ebp

movl %esp, %ebp

pushl %ecx

subl $4, %esp

movl $.LC0, (%esp)

call puts

movl $0, %eax

addl $4, %esp

popl %ecx

popl %ebp

leal -4(%ecx), %esp

ret

.size main, .-main

.ident "GCC: (GNU) 4.2.1 20070831 patched [FreeBSD]"

.section .note.GNU-stack,"",@progbits

观察这些汇编代码,里面充斥着pushl、popl、movl、subl与addl等助记符,C程序最终就是通过复制、入栈、出栈、加法、减法等简单操作来完成执行的。注意观察这些代码中的如下几行:

leal 4(%esp), %ecx

andl $-16, %esp

pushl -4(%ecx)

pushl %ebp

movl %esp, %ebp

pushl %ecx

subl $4, %esp

movl $.LC0, (%esp)

call puts

C语句的print(“helloworld”)输出字符串就是通过上述几行实现的,除开最后一行call puts(call指令完成调用C语言的puts函数输出字符串的功能,puts函数向终端输出一个字符串,其唯一的参数是char *str,str表示需要输出的字符串)外,其它行做的所有工作就是将调用puts函数的唯一参数(指向字符串”helloworld”地址的标示“.LC0”)的放入堆栈中,以供puts函数调用,倒数第二行将.LC0标记的地址复制到当前堆栈的栈顶,前面几行分配堆栈,调整栈顶指针,将需要保存的寄存器入栈(因为调用puts函数会破坏现有寄存器的值,称之为保存现场),当puts函数完成后,会将入栈的寄存器值弹回各自的寄存器中(称之为恢复现场)。

你可能感兴趣的:(c,设计与架构)