1. NASM编译器介绍:
1) Netwide Assembler,是目前唯一开源且免费的汇编器;
2) 该汇编器只提供编译的功能,但不提供连接的功能,在Linux下编译器产生.o文件后还需要使用ld链接器和操作系统的库链接才能形成可执行文件,而在Windows下需要使用MASM的ml链接器连接形成.exe文件;
3) 这里我们先介绍实模式编程,由于Linux以及Windows都是运行在保护模式下的,因此我们会将编译好的程序写进虚拟硬盘的主引导扇区中,然后由虚拟机启动来观察程序运行效果,在Windows中使用Oracle Virtual Box以及VHD,在Linux中使用Bochs和dd磁盘工具;
4) NASM和MASM一样都是遵从Intel汇编语法,因此指令集(即指令的名称相同)相同、语法相同,仅仅就是内存寻址形式以及其它的一些语法的细微处有不同地方;
2. 符号命名规则以及立即数的表示形式、越界问题:
1) 对于指令、寄存器名称以及立即数中的字母,都对大小写不敏感;
2) 但是对于标号是大小写敏感的!比如Tag:和tag:就是两个完全不同的标号;
3) 标号必须以字母开头,或者是以'_'、'.'、'@'、'?'开头,当然标号当中也可以包含这些符号,并且标号也可以只包含这些符号,比如'@@'等,但是如果以特殊符号开头而不是以字母开头,对于NASM有特殊含义(之后会详细讲解);
4) 立即数的表示:
i. 规则基本和MASM一样,可以用H、没有后缀、B来区分十六进制、十进制、二进制,并且后缀的大小写随意;
ii. 后缀法表示十六进制时,如果是以字母开头的就必须加一个0前缀,否则编译器会认为这是一个标号而不是一个立即数而报错!(注意:标号基本上是以字母开头的)
iii. 但是NASM还支持更加传统的十六进制表示形式,就是C语言的0x前缀表示法,用这种方式十六进制如果是以字母开头就无需加0前缀了,并且无需任何后缀,x的大小写随意;
iv. NASM允许使用下划线来使较长的立即数更加清晰,并且对于任意一种表示法都有效,比如100_0_11_11B、0F_E_3H、33_3_33、0x89_EF等,但是下划线不能放在开头,否则汇编器会将其误认为标号而报错;
!注意:任何汇编语言都不允许立即数作为目的操作数,因为目的操作数具有存放运算结果的功能,而立即数没有自己的空间,因此不能作为目的操作数使用!
5) 立即数越界问题:就是指立即数的范围超出目的操作数的范围大小,比如mov cl, 0623EFA823H、mov byte [bx], 3242342342325等,这种情况编译器不会报错,但是会警告提示范围过大,如果执意运行而忽视警告,则在运行时会做截断处理而损失精度,极大可能会导致程序错误或异常崩溃!所以不能忽视立即数越界问题;
6) 良好的符号命名习惯:
i. 所有指令和寄存器名称都小写;
ii. 立即数的后缀都大写;
iii. 立即数中的字母都大写;
iv. 0x中的x小写;
v. 全局标号以大写字母开头,后面跟小写字母(和Java的类名相似);
vi. 局部标号全小写;(关于全局标号和局部标号的概念后面会详细介绍)
7) 字符串的表示:
i. 字符串也是一种立即数;
ii. 既可以使用' '也可以使用" "来表示字符串立即数;
iii. 如果字符串中有"则必须使用' '将字符串括起来,相反,如果字符串中有'则必须使用" "将字符串括起来,这样就可以是字符串中的"和'符号转义;
iv. ‘ ’和" "的匹配规则:用' '括起来就不能在字符串中有'符号了,对于" "情况也相同,字符串中不能再含有",因为这是由NASM识别字符串的规则决定的,当汇编器读取起始的'或"时,就会将其看做字符串的起始/结束,因此一旦扫描到第二个'或"时就会认为是字符串的结束,如果后面还有其它字符则汇编器会变得非常苦恼因而干脆直接报错!
3. 声明数据:
1) 除了db、dw、dd之外还多了一个dq,即define quad word来声明四字数据(64位);
2) 声明的数据越界问题:比如db 0xFFFF,该立即数明显超出一字节的范围,对于这种情况编译器会警告但不会报错,如果执意运行同样是做阶段处理而损失精度,可能会导致程序错误或异常崩溃,因此不容忽视;
3) NASM没有了“d? n dup(...)”的重复定义数据的语法了,取而代之的是伪指令times,使用形式是"times 重复次数 指令(可以是汇编指令也可以是伪指令)",times就表示重复的意思,可以将后面指定的指令重复执行指定次数,比如"times 128 db 1",即表示重复定义128个连续的字节1;
4. 汇编地址以及标号的本质:
1) 汇编地址的概念在所有汇编编译器里都是统一的,但不过之前在[Intel汇编-MASM]中没有提到过,因此在这里就需要强调一下;
2) 所谓汇编地址,就是编译器给源程序中每条指令定义的地址,由于编译后的程序可以在内存中浮动(即可以装载在内存中的任意位置),因此直接用绝对地址(20位的湿模式下的物理内存地址)来给源程序中的指令定位的话将不利于程序在内存中的浮动;
3) 汇编地址定位规则:
一般规则:
i. 如果在没有使用特殊指令的一般情况下(特别是vstart指令),整个源程序中第一条指令的汇编地址为0,之后所有指令的汇编地址都是相对于整个源程序第一条指令的偏移地址,即使程序中分了很多段也是如此,在这种情况下,如果将整个源程序看做一个段的话则汇编地址就是段内偏移地址;
ii. 在NASM中,所有的标号实质上就是其所在处指令的汇编地址(相对于整个源程序第一条指令的偏移地址),在编译后会将所有标号都替换成该汇编地址值(即立即数);
特殊规则:
i. 如果在定义段的时候使用了vstart指令,比如"section my_segment vstart=15",则会提醒汇编器,该段起始指令的汇编地址是15,段内的其它指令的汇编地址都是和该段起始指令地址的偏移量加上15;
!NASM使用关键字section来定义段,后面跟一个段的名称(用户自取),接下来跟一些段的属性定义;
!因此,vstart伪指令就是规定段的其实汇编地址的指令;
!如果vstart=0,则段内的汇编指令就是段内的偏移地址了!!!这种手法经常使用!!
ii. 使用NASM规则的标准段,是指section .data、section .text、section .bss,这三种标准段都默认包含有vstart=0的含义,因此段内的指令以及标号的汇编地址都是段内偏移地址,并且在加载程序的时候会自动使cs执行.text、ds指向.bss、es指向.data而无需人手工来执行对段寄存器赋值的步骤,而对于i.中的人全权定义段的方式则没有这种自动的步骤,需要亲手对段寄存器进行赋值!
4) 引用标号:
i. 和MASM不一样的是NASM大大简化了对标号的引用,不需要再用seg和offset对标号取段地址和偏移地址了;
ii. 在NASM中,标号就是一个立即数,而这个立即数就是汇编地址而已,仅此而已!!
iii. 在NASM中不再有MASM中数据标号的概念了,也就不存在什么arr[5]之类的内存寻址形式了!!!!
iv. 在NASM中所有出现标号的地方都会用标号的汇编地址替换,因此诸如mov ax, tag之类的指令,仅仅就是将一个立即数(tag的汇编地址)传送至ax而已,而不是取tag地址内的数据了!!!如果要取标号处内存中的数据就必须使用[ ](类似C语言的指针运算符*),比如[tag]就代表取tag地址内存中的数据;
!!!只有段寄存器中存放的才是真实的物理内存段地址(即16的整数倍),标号全部都是源程序的汇编地址!
!这种地址模式有利于程序在内存中浮动,只需要在装载程序(程序装入内存的过程)的时候定义一下段寄存器中的内容即可,其余的偏移地址都能由标号(汇编地址)正确表示);
5) 定义标号:
i. NASM定义标号和MASM定义标号的规则略有不同;
ii. NASM在定义标号是可以不使用冒号也可以不使用;
iii. 如果使用了冒号则会是汇编器将其强制视为标号,如果没使用冒号则汇编器会自行推断;
iv. 汇编器推断是否是标号的规则,首先读入单词,如果单词后跟着一个冒号则将其视为标号,如果没有跟冒号则会先核对其是否是某个指令的名字,如果不是则将其视为标号,因此当标号名和指令名冲突的时候要加冒号,否则可以不加;
v. NASM规则中,如果标号指示的是一段数据的定义也可以使用冒号定义(当然也可以不使用冒号),这点和MASM不同(MASM指示数据定义的标号在定义时一定不能加冒号),在NASM中可以这样来:
data1 db 1, 2, 3
string1: db 'abcd'
!因此NASM在语法上还是相当自由的!
vi. 标号定义的规范:定义数据时不要使用冒号,在指令行中定义标号时使用冒号,这样以示区分指令地址和数据区域的入口;
5. 寻址方式:
1) 寻址方式就是指应该到哪里找操作符,比如操作数放在寄存器中、指令中、内存中等;
2) 寄存器寻址:
i. 操作数位于寄存器中,比如mov ax, cx;
ii. 这种寻址方式是最最快的,比立即数寻址还快,因为寄存器是CPU中最快的部件;
iii. CPU不支持两种不同尺寸的寄存器相互作用,比如mov ax, bl等,都是不对的,会直接编译报错,即使ax比bl宽也不行!这个是CPU硬件层面上就已经决定了的;
3) 立即寻址:
i. 操作数以立即数的形式出现在指令中,此时操作数就存放在指令中,而指令存放在指令寄存器IR中(Instruction Register),在CPU内部,但是对于程序员不可见;
ii. 从IR中取数据没有直接从寄存器中取数据来的快,因此寄存器寻址仍然是最快的方式;
iii. 比如:mov ax, 17、mov bx, tag等,注意引用标号也是一种立即寻址方式,因为标号在编译后会被替换成一个汇编地址,而汇编地址就是一个立即数;
4) 内存寻址:即操作数在内存中,内存寻址的方式有很多种,我们放到下一小节具体分解;
6. 内存寻址:
1) 内存寻址最主要的问题就是提供偏移地址(因为段地址有默认的段寄存器给出或者直接使用段前缀表示法来确定段地址),因此这里全部讨论偏移地址的确定方式,而偏移地址也称为有效地址,因此内存寻址主要解决如何确定偏移地址的问题;
2) 偏移地址由中括号[ ]给出,因此问题就转化为[ ]中的内容该如何确定;
3) 直接寻址:有效地址直接由立即数给出,比如mov ax, [0x5C00]等,当然标号也是立即数,因此也可以填标号,比如mov ax, [data]等,即偏移地址直接由立即数给出;
4) 基址寻址:
i. 有效地址由基址寄存器给出,[ ]中只包含bx或bp(两者之一),可以带一个立即数;
ii. 基址寄存器是指bx和bp,bx默认段是ds,而bp默认段是ss;
iii. 例如:mov [bx + 3], ax、mov byte [bp], 0x55等;
!!两个操作数中做多只能有一个可以是内存,不能两个都是,这是当代CPU的固有限制,我们不得不接受这种规定,内存内部没有单元之间相互通信的通道,信息交换必须通过CPU进行,即使是内存和立即数作用也是如此,因为立即数保存在CPU的IR中,也是CPU的一部分,如上例iii.中的第二个例子;
!NASM不需要再使用ptr关键在来指定内存的大小了,可以直接使用byte、word、dword、qword来指定内存的大小(qword只能用在保护模式中,是模式下不能使用);
5) 关于立即数偏移量的问题:在基址寻址以及后面很多寻址方式中都可以跟一个立即数偏移量,比如mov bx, [bx + 2],该偏移量可正可负,但是建议使用减号和一个整数来表示负的偏移量,比如mov al, [bx - 2];
6) 变址寻址:
i. 有效地址由变址寄存器si或di给出,[ ]中只能包含si和di的其中一个,可以带一个立即数偏移量;
ii. 变址寄存器也称为索引寄存器,在数组之间批量传送数据时经常使用;
iii. 例如:mov ax, [si + 5]、mov [di], 100111B等;
iv. 基址寄存器的默认段都是ds;
7) 基址变址寻址:
i. 同时使用基址寄存器和变址寄存器来确定有效地址,[ ]只能包含一个基址寄存器和一个变址寄存器,可以带一个立即数偏移量;
ii. 默认的段由基址寄存器决定,如果基址寄存器是bx则默认为ds,如果是bp则默认是ss;
iii. 例如:mov ax, [bx + si + 3], 2、xor [bp + di + 9], 77等;
8) 数组访问:
i. 将数据标号作为数组的首地址,可以实现和MASM一样的利用数据标号实现和C语言一样的数组访问功能;
ii. 使用格式是:[标号 + 之前讲过的所有访问方式]
iii. 例如:
arr db 1, 2, 3, 4, 5, 6, 7
mov ax, [arr + bx] ; arr[bx]
mov ax, [arr + si] ; arr[bx][si]
mov ax, [arr + bp + di] ; arr[bp][di]
mov ax, [arr + 3] ; arr[3]
!这样就使标号具有和MASM数据标号一样的功能了!
!因此可以在NASM中轻松实现诸如直接定址表、查表等功能了!
!!注意:虽然标号也是立即数,但是在内存寻址的[ ]中最多只能出现一个标号,但是可以带多个普通立即数(非标号)!!
!!因为在内存寻址中标号是有特殊含义的,即一个数组的首地址,因此标号在内存寻址中专门用于表示数组首地址的而不是用来表示立即数偏移量的!!
!!如果出现多于一个标号会直接报错!!请看示例:
mov ax, [data1 + tag + bx] ; 错!最多只能有一个标号
mov [data1 + bx + si + 3 - 2 + 0F9H]; 对!立即数可以带多个
9) 段前缀表示法:
i. MASM也有段前缀表示法,即不使用默认的段寄存器而人为指定一个段寄存器,在MASM中段前缀是写在[ ]外面的,比如es:[bx + di + 6],但是在NASM中段前缀是写在里[ ]里面的;
ii. NASM的段前缀表示方法:[段寄存器:之前讲过的所有有效地址确定方式]
iii. 例如:mov ax, [es: arr + bx + di + 9H],这就表示段是es段,而有效地址是arr+bx+di+9H,是一种带段前缀的数组访问,并且是基址变址寻址;
7. 定义数据常量:
1) 数据常量的定义方式就是使用equ伪指令(即等置语句)将一个值和一个标号相关联,比如:CONST_VALUE equ 10,既然是和标号关联,则定义该标号的时候同样可以加冒号,比如:CONST_VALUE: equ 10,这两者效果一样,但是推荐使用没有冒号的那种定义方式,这样可以使源程序更加规范;
2) 等值伪指令的背后实现方式就是类似C语言中的宏,此时这里的标号不再指代一个汇编地址了,而仅仅是一个宏符号而已,以后在代码中所有引用该宏的地方都会被(文本)替换成其所代表的数值;
3) 注意,该种定义数据常量的方式并不占用汇编地址也不会产生占用任何内存单元,仅仅就是一种文本层面上的替换,这种替换工作会在预处理阶段完成,原理和C语言一模一样,就是个#define语句而已;
4) 这种宏定义不等于C语言中的普通变量的定义,比如a equ 10之后再来个a equ 20,这种是错误的,这不是常量,而是宏,因此汇编器会认为你重复定义了两个同名的宏而报错!
5) 可以用一个宏来定义另一个宏,比如a equ 5; b equ a; 则最后b的宏值就是5,就是一个传递的宏替换而已;
!!!注意和标号的区别:
!如果用标号来定义数据,则定义的数据中可以出现标号,只不过这里的标号实质上是一个汇编地址的数值,当然也可以出现宏(数据常量),只不过会被替换成其宏值;
!这个道理对宏值同样有效:
data1 db 1, 2, 3
value1 equ 20
data2 dw 1, data1, value1 ; data2数组中包括元素1、汇编地址data1和宏值20
value2 equ data1 ; value2的宏值是汇编地址data1
value3 equ value1 ; 宏替换20