淘先锋技术网

首页 1 2 3 4 5 6 7

汇编语言是一种什么语言?

结合本人从清华学习经验,说说本人的深切体会吧,初识它时,认为汇编语言是一种助记符,一种低级语言,直接面对指令,将二进制指令替换成人类便于记忆的字符串,并冠以特殊的格式。每一条汇编指令对应一条二进制指令。根据内核架构的不同,不同的指令有不同的长度和格式。

大多数人一开始都以为汇编语言本身很简单,常用指令没几个,语法规则也不多,看几个小时资料似乎就明白了,但其实不然。汇编的背后是体系结构,是程序设计抛开各种高层形态的最根本,最本质的解释。本人从业多年,除了跟我一样搞过很久安全的同学,其余的没有一个我认为算是精通汇编。而我是怎么掌握汇编的呢 ?

1 早年用汇编手写病毒。比如处理指令重定位,是真的用汇编计算指令地址,push push call 实现函数调用。

2 长期病毒木马二进制分析。分析明白各种 malware 的原理,实现查杀防。个别病毒,需要实现修复。

3 漏洞挖掘。fuzzer 发现漏洞,汇编级详细分析,exploit 编写,武器化利用,一条龙。

4 各种逆向分析。好的东西没有代码,IDA 里看就是了。

5 跟debuger 做朋友。从来看不上print 方式的bug定位。所有问题在调试器里分析明白,绝不靠猜。

6 编译器后端研究。什么指令选择,指令调度,寄存器分配,全都研究一遍。

7 底层开发,操作系统,设备驱动,虚拟化都走上一遍。X86很熟? ARM学一遍做对比。历经这么多,终于敢说学明白汇编了。推荐如下的文档,很基础又相对全面的介绍了很多计算机常见问题在汇编层面的解释。《Introduction to Computer Organization with x86-64 Assembly Language & GNU/Linux》

学汇编不是说一定要用这它做多牛鼻的事情, 问题的关键在于, 学透了汇编会使你真正理解计算机另外一方面, 如上面所说, 在工作中你迟早会在某个阴暗的角落遇到汇编. 不管你承认不承认, 现在的CPU没有直接跑高级语言的, 哪怕是虚拟机也都是类似汇编的指令集.当遇到崩溃分析, 性能优化甚至编译器抽风等等的时候, 汇编是你最后一根救命稻草.

下面再讲讲汇编语言的基本内容吧:

目前国内的汇编语言教材大多都是上来先讲一大堆CPU、总线、寄存器、标志位……再讲汇编语言程序设计。这种字典式的编写方法对入门是很不利的,因为在不知道这些东西都是用来干什么的情况下,全部记忆往往很难。然而这些概念在编程中还不得不用到,于是又得重新往前翻书,这就陷入了一个循环。

实际上,汇编语言的学习完全可以和高级语言一样。只不过因为汇编语言是根据CPU的工作原理进行操作,所以一切代码都要从CPU和内存的角度考虑问题。理解了指令在内存层面的执行过程,编程就水到渠成了。

先从最简单的开始:给定两个数a和b,让CPU做一次加法,结果储存在c中。输出c。

用C语言编写这个程序:

int a=3;

int b=4;

int c;

int main()

{

c=a+b;

printf("%d",c);

return 0;

}

(注意:如果写int c=3+4,一行就可以搞定。但是这里没这样做,而是先统一声明所有的变量,然后再在进行运算的主函数中执行相加操作。后面可以看到,这种编程习惯是符合二进制数据在内存中的存放规律的。)

如果用汇编语言编写,该怎样写呢?

再重复一下题目:给定两个数a和b,让CPU做一次加法,结果储存在c中。输出c。

要从原理上写这个过程,就要解决以下问题:

①数据a和b怎么存储 ②怎么做加法 ③怎么储存结果 ④怎么输出结果

下面将分别解决这四个问题。

1.汇编语言程序的结构

首先,我们要知道二进制信号在内存中的存放规律。众所周知,计算机能直接处理的只能是二进制信号,这些信号以高低电平的方式存放在内存中,既可以作为指令,也可以作为程序使用的数据。一块内存区域所存放的二进制信号到底是指令还是数据,是由相应的命令说了算的。

CPU在读取指令/数据时,每读取一条指令/数据,内存位置指针就加1,指向下一条指令/数据的内存地址。这样就产生了一个问题:数据和指令在内存中应该分块,并且要连续存放。否则如果内存位置指针不知道下一个位置是数据还是代码,将会给内存位置指针的寻址带来极大的不便。所以,在汇编程序中,要人工将内存分为数据段(Data Segment),代码段(Code Segment),堆栈段(Stack Segment)和附加段(Extra Segment)。

这样划分好以后,我们只需要告诉内存位置指针每个段在内存中的起始地址,内存位置指针就可以顺利寻址了。怎样告诉呢?在CPU中,有一组专门的段寄存器用来存放各个段的起始地址。它们是:DS(用来存放数据段的起始地址),CS(用来存放代码段的起始地址),SS(用来存放堆栈段的起始地址),ES(用来存放附加段的起始地址)。程序员在编程时,需要人工指定这些段寄存器对应于程序中的哪个段。

有了段的概念,我们就可以写出一个汇编程序的基本框架如下:

DATA SEGMENT ;定义一个叫DATA的段。DATA既是这个段的名称,也指代这个段的地址。但这里并未规定这个段是数据段、代码段还是其他段

……

SEGMENT ENDS ;表示段结束。ENDS是END SEGMENT的缩写。

STACK SEGMENT ;定义一个叫STACK的段,这个段的地址用STACK表示。

……

SEGMENT ENDS ;段结束

CODE SEGMENT ;定义一个叫CODE的段,,这个段的地址用CODE表示。

ASSUME:CS:CODE,DS:DATA,SS:SEGMENT ;告诉编译器,将代码中写的各段分别对应上各个段寄存器。这句话要放在准备用作代码段的段开头

……

SEGMENT ENDS ;段结束

好了。回到我们的问题:怎样存储a和b呢?在数据段中声明变量如下:

DATA SEGMENT

A DW 03H ;定义一个名为A的双字节(即1个字)的数据,DW是Define Word 的缩写。末尾加H表示十六进制。

这相当于C语言中的int A=3,只不过int表示的范围远大于DW而 已。

B DW 04H ;定义一个名为B的双字节数据。由于B是紧挨着A之后定义的,根据 数据段的连续性,B在数据段的偏移地址就是A在数据段的偏移地 址 + A的长度。由于 A是双字节数据,所以A的长度是2个字。

SEGMENT ENDS

2.CPU的运算方式及运算结果的判定

第二个问题:怎样做一次加法?

CPU只能处理电平信号。学过模电的都知道,有一种东西叫“加法器”,输入2个电压信号,经过运算放大器后,就会得到这两个信号的和。所以CPU做加法的方式就是:把输入的两个二进制信号输入加法器,得到结果。

问题似乎解决了。但是我们突然发现,这样的结果几乎没有任何意义,因为我们无法知道结果的性质。比如,如果结果超出了能容许的最大位数(溢出),会怎么样?CPU没有任何提示。又或者,我们要比较两个数的大小,这就要将两个数相减。然而结果是正是负?我们无从知晓。

为了获知运算结果的性质,在CPU中设置了一个“标志寄存器”,专门用于存放运算结果的各种标志。它们都是用电路实现的。比如:

CF(Carry Flag)就是用来标志无符号数运算是否产生进位。产生进位时,CF=1,反之CF=0。特别指出,CF标志位的值对有符号数的运算没有意义。

OF(Overflow Flag)则是用来标志有符号数运算是否产生溢出。产生溢出时,OF=1,反之OF=0。同理,OF标志位的值对无符号数的运算没有意义。

SF(Sign Flag)用来标志结果的正负。当结果是负(SF)时,SF=1。反之SF=0。

回到我们的问题:怎么做一次加法?或者更一般地,怎样做一次运算?

我们不必关心具体的电路实现细节,只需要执行相应的运算指令,运算完成后,不仅会得到结果,各个标志位的值也可能发生相应的改变,从而有利于我们对结果的判断。例如:

ADD AX,BX ;把AX和BX中的内容相加,结果存放在AX中。若AX,BX为有符号数,当 产生溢出时,OF=1.CF的值不确定。当结果为负时,SF=1。

3.内存与寄存器的关系

内存(RAM)是存放各种数据、指令的地方。根据用途的不同,又可以把它分成不同的段。而寄存器(Register)则是CPU内部临时存放运算结果的地方。与容量较大的内存相比,寄存器的容量极小(每个寄存器只有16位),数量有限(只有少数几个),用途专一(各个寄存器有不同的用途,用来存放不同方面的结果)。例如,前面所述的段寄存器(DS,CS,SS,ES)就是用来存放段的起始地址的。除了段寄存器之外,CPU中还设有通用寄存器(AX,BX,CX,DX……)。它们各自有其专门的用途,在不致于产生冲突的情况下,也可以用来存放数据或运算结果。

通用寄存器的用途简述如下:(通用寄存器容量都是16位的)

AX:①用来存放数据或运算结果

②AX的高8位AH用于与DOS操作系统通信。向AH中装入DOS系统的指令码并执行,可以利用DOS系统完成一些操作,如在屏幕上输出字符。

DX:①用来存放数据或运算结果

②与AH的DOS屏幕输出指令码配合使用,存放准备输出到屏幕上的数据

CX:在有循环的程序中,用来存放循环次数。相当于for循环中的计数变量i。

BX、SI、DI:①用来存放数据或运算结果

②用来存放数据段中的数据在段中的偏移地址

一般而言,需要运算的数据存放在内存中。CPU在程序的指令下,通过指针确定它们的位置,将它们读入寄存器。进行运算后,再将结果返回到内存预留的结果位置中。

回到我们的问题,在内存的DATA SEGMENT中存放有两个双字节数据A=3和B=4。要将它们读入寄存器进行相加运算,再将结果写入到内存中。为了读入寄存器,首先需要获取A和B在内存数据段中的偏移地址。确定它们的地址后,按地址将它们读入寄存器(这里可以任选两个寄存器),然后执行运算指令。运算完成后,将储存在寄存器中的结果写入到内存DATA SEGMENT中事先预留的位置。使用"MOV 目标,源"指令完成源对目标的赋值。代码如下:

DATA SEGMENT

A DW 03H

B DW 04H

C DW ? ;?表示声明时不赋值。相当于int c;

SEGMENT ENDS

STACK SEGMENT

SEGMENT ENDS

CODE SEGMENT

ASSUME:CS:CODE,DS:DATA,SS:SEGMENT

START: ;指定程序的入口位置(这个位置当然要在代码段中啦),并命名为START。

MOV AX,DATA

MOV DS,AX ;这两行的意思是,以通用寄存器AX为中介,将数据段DATA的起始地址(用句柄DATA表示)送入数据段寄存器DS中。在需要使用数据段的程序中,这一步是必须的,否则CPU无法确定数据段的位置。注意:ASSUME是一个伪代码,它只是告诉了编译器各个段与段寄存器的对应关系,并未存入各段的地址。(由于电路结构的原因,AX不能直接对DS赋值。不能直接写MOV DS,DATA。)

LEA SI,A ; 注意:与DATA SEGMENT中DATA的含义不同,数据段中A DW 03H中的A仅表示变量名,不表示变量的地址。类似于int a=3,a只是名称,取地址要用&a。在汇编中,取地址用LEA BX/SI/DI,A的格式。注:通常用BX、SI、DI这三个寄存器存数据的偏移地址。

MOV AX,[SI] ;将 [内存数据段中以SI为偏移地址的内容] 送入通用寄存器 AX中。这里AX也可以换成BX,CX等。

INC SI ;SI的值加1。即SI++。

INC SI ;因为A是双字节数据,所以SI要加2才能指向下一个数据B的偏移地址。当 然,这两条也可用 LEA SI,B替代。

MOV BX,[SI] ;将 [内存数据段中以SI为偏移地址的内容] ——就是B,送入通用寄存器 BX中。

ADD BX,AX ;BX与AX相加,结果存放在BX中。当然也可以写ADD AX,BX。但之所以不 存放在AX中,是因为一会输出要用到AH,避免冲突。

LEA DI,C

MOV [DI],BX ;这两条是将存放在BX中的结果写回数据段中C所在位置

MOV DX,BX ;准备在屏幕上输出结果。屏幕输出的是寄存器DX中的内容。

ADD DX,30H ;由于屏幕上输出的是文字,因此必须将数字+30H转换为对应的ASCII码

MOV AH,02H ;将控制DOS系统输出数值的代码02H装入AH。这步可理解为printf()中的"%d"

INT 21H ;INT=Interrupt,中断,执行DOS命令。执行后返回程序。

MOV AH,4CH ;4CH是程序结束,返回DOS系统的命令。将此命令装入AH,等待执行。

INT 21H ;中断,DOS系统执行4CH命令。程序结束。这两步类似于 "return 0"

SEGMENT ENDS

END START ;在程序的尾部,告诉MASM(宏汇编)编译器程序的入口位置在标号START处运行结果如下:

输出结果为7。当然,你可以不用MASM编译工具,而用其它的汇编IDE集成开发环境.

最后,希望通过这篇短文,能够帮助你能更好的认识,理解,学习好这门语言。

java字符串逆向输出,汇编语言是一种什么语言