汇编的主要作用
严格来说,汇编语言变得越来越不重要了,一方面是高级语言的发展将计算机底层封闭的越来越彻底,除了极少数情况,coder几乎不用care机器在做什么以及怎么做的,相反,他们只需要知道大概得原理即可。
另一方面,汇编不适合用来编写复杂的代码。因为他难以阅读和调试,不够直观。现在能想到的直接需要使用汇编的场景几乎只有通过汇编编写特别的算法代码对其加速,但这只是一种工程化方法,对算法的优化最好是从算法本身入手。而就单从加速而言,指令库<intrins.h>可以让我们在C/C++ 直接使用向量指令,而不需要编写晦涩的汇编代码。
各种汇编器的区别
汇编器是将用助记符写的程序翻译成机器指令,并组装成可以执行的程序,常见的汇编器,包括NASM/YASM/MASM 以及 gcc的汇编器,他们的主要区别如下:
- NASM & YASM
- 支持 intel 语法(也是最常见的语法),拥有强大的宏编程系统,这个有点类似于C++的模板元编程,即在"编译"期间完成一些编程任务,从而能提高运行时的效率。当然更重要的是其提供了比汇编语言本身更强大的编程能力
- MASM
- 微软实现的汇编器,不跨平台
- gcc 内置汇编器
- 主要配合gcc 完成汇编任务,功能相对较弱
内存分段
简而言之,就是历史上出现过使用20bit的地址线,但是寄存器只有16bit,从而需要通过两个寄存器的组合来访问某个地址,比如CS:IP = CS << 4 | IP, 因此对于一个内存地址而言,选择不同的基地址,其表示方式不同
举个例子:
CS = 0x0012 <- 0x0120
IP = 0x3456, CS:IP = 0x0120 + 0x3456 = 0x3576
CS=0x0000
IP = 0x3576 CS:IP = 0x3576
归根结底是CS的低12bit, 与IP的高12bit 重叠,这个重叠的部分可以表示在CS中,也可以表示在IP中
现在绝大多数程序已经不使用分段模式了,但cpu仍然保留分段模式的特性以保持兼容性。 由这些代码可见,对于16bit cpu,一个段的size是64K。
保护模式和实模式
这部分其实不是重点,学到的时候再说。简单叙述就是保护模式提供了更大的内存段空间(4GB),更多的特权级和更好的中断支持
中断
- 外部中断
- 如鼠标、键盘等
- 内部中断
- 错误中断(陷阱),通常会导致程序运行终止
- 软件中断,通常用于实现操作系统API,用于和内核交互
可被处理的中断通常要制定中断处理函数(可以简单理解为事件回调函数),中断处理完成后恢复程序现场, 继续执行。
汇编语言
通过指令助记符+操作数写出的一行代码称为汇编指令,一个汇编指令通常会被汇编器翻译成一个机器指令(注意,某个机器指令的执行周期是不同的,某些情况下,某些汇编器会通过组合多个指令来虚拟一个指令)。(助记符可以理解为函数调用)
一条汇编指令通常会包含一些指令操作数(我们可以理解为函数的参数),常见的指令操作数有:
- 内存地址
- 寄存器
- 立即数
- 默认的操作数(类似于C++的默认参数)
基本指令
最基本的指令如MOV, 用于数据搬移,形如mov dst, src. dst src 不可同时为内存, 这些限制其实是和cpu的指令实现有关。参考intel的指令手册可以发现,很多指令都带有特殊的限制。
以下是一个计算1+1的例子
mov eax, 1; eax 存储1
add eax, eax; 计算1+1
汇编指示符
汇编指示符用于指导汇编器工作,不会被翻译成具体的指令。NASM有一个强大的预处理系统(比C的预处理器要强),这使得我们能在汇编层面编写复杂且高效的代码(将运行时的逻辑放在“预处理期间"解决)
常见的指示符功能是:
- 定义常量、宏、条件宏展开
- 定义内存结构
- 包含其他文件
equ 指示符
a equ 100; 定义一个常量a,值为100
用于定义一个常量,并且后续不可被再定义或者修改
define 指示符
类似于C语言的#define, 不过nasm使用%define 定义宏, %define 的宏常量,可以被重定义
%define size 100
mov eax, size; size 被替换成100
%define size 200
mov eax, size; size 此时被替换成200
数据指示符
有时候这种指示符也称为汇编器指令
数据位宽定义
|简写|中文|位宽|
|--|--|
|B|字节|8|
|W|字|16|
|D|双字|32|
|Q|四字|64|
这样,我们就可以在ASM中定义数据变量了
变量一般可分为两种:初始化的变量和未初始化的变量,初始化的变量通过dx 指示符定义,未初始化的变量通过resx定义
dx,(x=[b,w,d,q]) 定义变量并初始化
a db 0; 定义一个字节变量a, 初始化为0
bt dw 1000; 定义一个字变量bt,初始化为1000
c resb 1; 定义一个未初始化的字节变量c
顺序的指示符指向的内存是连续的,比如bt 和 a 就是连续的
定义数组
定义初始化的数组通过dx定义,如
a db 1,2,3,4; 定义一个4字节数组a,初始化1234
b times 100 db 0; 定义一个长度为100的数组,并且全为0
c resw 100; 定义一个100字的数组c, 未初始化
定义字符串, 字符串可以分开一个个字符定义,也可以直接定义。单引号双引号不区分
d db 'w', "o", "r", "d", 0 ;
e db "word", 0; d e效果等价
上面的代码中 a b c d e 引用的是变量的内存地址,我们可以理解为C里的指针,因此访问他时,需要通过[d] 这种形式引用。
mov这种指令通常要指定位宽,位宽可以通过寄存器隐士的表示出(类似于C++的自动推导),也可以手动指明,当无法推断时,则汇编器会报错。比如:
mov al, [L1]; al 暗指 操作位宽是1b, 即将[L1]表示的字节数据传输到al中
mov eax, L1; L1此处是一个内存地址,表示将L1的地址存储到eax中,eax 暗指 位宽是double word
mov [L1], ah; ah 暗指 位宽是1b,
mov [L1], 1; 会报错,这个不能推断操作位宽
mov dword [L1], 1; 在某些汇编器中,可能会通过movb,movw, movd, movq 来表示指令位宽。mov 指令不能直接操作于两个内存,这是硬件设计决定的
上面可见汇编程序一般不关心数据类型(数据是int short char 或者其他)
汇编的输入输出以及调试
通过汇编语言可以向屏幕中输出内容,或者从键盘读入数据,但是这些通常涉及到和os api交互,这部分交互需要单独的说明。
nasm汇编器提供了几个使用函数帮我们解决简单的输入输出内容:
print_int ; 输出eax中的值(as int32_t)
print_char; 输出al中的值(as char)
print_nl; 输出\n
print_string; 输出eax存储的内存地址指向的字符串
read_int; 从键盘读入int32 存储到eax
read_char; 从键盘读入char 存储到al
注:这些“函数”实际上是宏
通过%include指令可以包含具有这些函数实现的文件,
%include "asm_io.inc"
"asm_io.inc"中还包括一些调试宏,后面用到再说.
编写第一个汇编程序
核心命令
nasm -f elf32 asm_io.asm
nasm -f elf hello.asm
3. sudo apt install gcc-multilib g++-multilib libc6-dev-i386
g++ -m32 -o t main.cc hello.o asm_io.o
- 当前的汇编指令都是在32bit模式下工作的,后续根据需求改为64bit的指令
- 32bit 模式下,需要安装32bit的依赖库,参考3.
具体代码分析请参考hello.asm
具体的代码框架可见,xxx.inc 就像 C的header一样, xxx.asm 就像.c 一样提供实现,hello.asm 提供了一个基本的加法运算实现,我们在main.cc 中调用这个汇编的实现。
其实这个和正常的编译过程差不多,先将源文件生成obj文件(中间文件),然后链接器把这些文件链接,生成正式的程序。 只不过这里hello.o 是通过nasm 生成的。
这里还有个要注意的是在C++中使用汇编时,要注意C++引用函数的Mangling 问题(其实有时候实在是不懂当年要研究编译器如何Mangling, 哎。。。),一般情况下都是用extern "C" 包起来阻止其按照"mangling" 解析。不过有的编译器对C的符号也有mangling问题,比如printf 实际的符号是_printf,这个不在讨论之列。
数据表示
略(正常的原反补),大小端 略
数据的扩展和压缩
这个地方最主要要记忆的应该是数位扩展的两种情况
在C中,有时候可能会碰到这种情况:
数位扩展
int16_t a = -1;
int32_t b = (int32_t)(a); // 此时a 还是(-1), 即扩展出来的b >> 16 = 0xffff;
uint32_t c = (uint32_t) a; // 此时c 就是 这里b c 的二进制也没有区别
数位压缩
int32_t a = -2;
int16_t b = a; // b 此时也是-2
uint16_t c = a; // c 此时是一个较大的数 这里 b c 的二进制没有区别
如果上面的例子中,数据都是正数,则扩展时全部在高位补0, 压缩时直接对原数据阶段。这些目前来说还比较直观。
不过在汇编层面,这些事情可能不太好做,比如说:
mov ah, -8
mov eax, 0xf7000000
mov eax, ax; eax 里面的值是什么呢? mov怎么判断ah是一个有符号还是无符号数;呢? eax 整体的值是-8 还是别的什么值呢? f700fff8 eax 整体并不是-8
写代码判断之。。。
- 数位扩展是一个很麻烦的事情,就寄存器和立即数的算数运算而言,以寄存器的位宽为准,立即数表明了符号
- mov 只操作寄存器的部分,比如mov ax, -1 只会对低16bit 赋值
- movzx 是将源寄存器扩展后赋值,比如mov eax, ax, 将ax 以无符号数解释并扩展
- 比如 ax=0xffff时,会被解释称65535 并扩展,扩展后0x0000ffff
- movsx 是将源寄存器扩展后赋值,比如mov eax, ax, 将ax 以有符号数解释并扩展
- 扩展后 0xffffffff
真正和符号有关的是移位,对于有符号数,符号位为0的数右移高位补0, 符号位为1的数高位补1。对于无符号数,则高位一直补0,这两者的移位指令在某些平台上可能有所差别。比如x86上,其使用sar指令做算数右移,用shr指令做逻辑右移。算数右移适用于有符号数的右移,逻辑右移则不care符号信息,只对原始数据右移。
int a = 0xffff0000;
int b = a>>4; b= 0xfffff000 算数右移
int c = ((uint32_t)a) >> 4 ; c = 0x0ffff000 逻辑右移
注意:如果移位位数超过了数据位宽,则结果未定义
FLAGS 寄存器
某些指令如cmp 的操作结果会影响到FLAGS寄存器,常用的标志位是:
FLAGS的其他位暂不叙述
| 标志位 | 描述 | 发生时机 |
|---|---|---|
| OF | 溢出标志 | |
| CF | 借位标志 | 产生借位 |
| SF | 符号标志位 | |
| ZF | 零标志 | 结果为0 |
溢出标志位的上溢和下溢
int8_t a= 127;
a + 1 == -1; // ==> 上溢
int8_t b=-128;
b - 1 = 127;// ==> 下溢
考虑上面两个例子:
- (127) - (-1) = -128 (OF = 1 SF = 1)
- (127) - 1 = 126 (OF = 0, SF = 0)
- (1) - 127 = -128 (OF = 0 SF = 1)
- (-2) - 127 = (-129) = 127 (OF = 1 SF = 0)
现在比较搞的是SF 和 OF不清楚是怎么被置位的,比如(-129) 对于i8 产生了一个什么样的数呢
mov eax, 0
mov ax, -10
sub ax, 65535
eax = 65527
这个结果表明,下溢的时候,结果是一个正数,但是OF=1, SF=0, 一般SF=0,表示a > b, 但是OF=1 表示产生了下溢(比最小更小)从而是a <b
上溢和下溢只对有符号数而言,但是如果是无符号输的话,通常后续的跳转指令不会判断OF进位
对于指令cmp l, r, 相当于计算l - r其结果如下, 设l r是无符号数:
- l = r, ZF = 1 CF = 0, 结果为0,没有借位
- l > r, ZF = 0, CF = 0,结果不为0,没有借位
- l < r, ZF = 0, CF = 1, 因为需要借位
注意他没有判断SF、OF
浙公网安备 33010602011771号