《PC Assembly Language》读书笔记
本书下载地址:pcasm-book。
前言
- 8086处理器只支持实模式(real mode),不能满足安全、多任务等需求。
Q:为什么实模式不安全、不支持多任务?为什么虚模式能解决这些问题?
A: 以下是根据网上搜索结果及自己的理解做出的解答,有待斟酌。(1) 安全:实模式下用户可以访问任意的物理内存,可以修改系统程序或重要数据的内容,因而不安全。虚模式下用户能够访问的内存是由Descriptor Table中的信息决定的,其基地址是事先不确定的,而长度、权限均有限制,因此相比实模式更安全。(2) 多任务:多任务意味着CPU可以在不同任务之间切换,这要求不同任务之间的地址空间要相互隔离,否则从A任务切换到B任务时,B任务可能会修改A任务使用的内存数据。实模式不支持数据隔离,而虚模式支持。
-
本书充分使用了两个开源软件:NASM汇编器和DJGPP C/C++编译器。
第1章 简介
1.1 数字系统
- 内存单位
内存单位 | 字节数 |
---|---|
word | 2 bytes |
double word | 4 bytes |
quad word | 8 bytes |
paragraph | 16 bytes |
- ASCII使用一个字节来对字符编码,而Unicode使用两个字节。
1.2 计算机组成
-
8086 16-bit寄存器
- AX/BX/CX/DX这四个16-bit的通用寄存器可以分解成两个8-bit的寄存器
- SI和DI:index registers,通常用于指针,也可用于大多数通用寄存器可以使用的场景,但它们不能分解成8-bit的寄存器。
- BP和SP寄存器用于指向机器语言栈里面的数据。
- CS/DS/SS/ES:段寄存器。CS:Code Segment. DS: Data Segment. SS: Stack Segment. ES: Extra Segment.
- IP寄存器和CS寄存器一起作为指示CPU将要执行的下一条指令的地址。每当执行完一条指令,IP将会指向下一条指令的地址。
- FLAGS寄存器存储上一条指令的执行结果的重要信息。
-
80386 32-bit寄存器
- 相比8086,通用寄存器扩展到32-bit。为了后向兼容,AX仍然代表16-bit寄存器,而用EAX来代表扩展的32-bit寄存器。
- 段寄存器仍然是16-bit,此外增加了两个备用的段寄存器FS和GS。
-
实模式
- 实模式的物理地址计算公式:physical address = 16 * selector + offset
- 为什么不直接存储物理地址,而将其分解成两部分?回答:8086的地址需要20-bit的数字来表示,而寄存器只有16-bit,因此需要用两个16-bit的数值来表示。
- 那为什么不是将20-bit的地址拆分成最高的4-bit和剩下的16-bit,而是用这种有点费解的表示方式? 回答:不知道...
- 实模式的缺点1:一个selector最多只能reference 64KB内存,当代码大于64KB时,需要分段存储,在不同段之间跳转时,CS的值也需要改变。(CS的值存储在selector中)
- 实模式的缺点2:同一个物理地址对应的段地址不唯一,可以有多种表示方式。
-
16-bit 保护模式
80286处理器使用16-bit保护模式。- 实模式下selector的值代表物理内存的paragraph数目,保护模式下selector的值代表descriptor table的索引。
- 16-bit 保护模式使用了虚拟内存的技术,其基本思想是:只保留程序正在使用的数据和代码在内存中,其他数据和代码临时存储在磁盘上。
- descriptor table记录有每个段的信息:是否在内存中、内存地址、访问权限等。
- 16-bit 保护模式的一大缺点是offset仍然是16-bit,导致segment大小仍然现在在64KB。
-
32-bit 保护模式
80386处理器使用32-bit保护模式,它与80286使用的16-bit保护模式的主要区别是:- offset被扩展到32-bits,因此offset的大小增大到40亿,从而段大小增大到4GB。
- 段可以划分成大小为4KB的页。虚拟内存系统基于页而非段来工作。
-
中断
- 每种中断都分配有一个数字,用于索引中断向量表,找到对应的中断handler来处理当前中断。
- 外部中断是指由鼠标、键盘、定时器等外围设备触发的中断。
- 内部中断是指有CPU内部触发的中断,内部中断可能来自运行error或中断指令。Error Interrupt也被称作trap,由中断指令触发的中断也被称为软件中断。
1.3 汇编语言
-
汇编语言
- 汇编器是一个将汇编语言转换为机器语言的程序。
- 汇编语言与高级语言的差异之一:每条汇编语言语句直接代表一条机器指令,而每条高级语言语句可能需要转换成多条机器指令。
- 汇编语言与高级语言的差异之二:不同类型的CPU使用的汇编语言不相同,而高级语言则可以相同。因此汇编语言的可移植性要低于高级语言。
- 本书使用NASM汇编器(Netwide Assembler),更通用的汇编器是微软汇编器MASM(Microsoft's Assembler)和Borland汇编器TASM。
-
操作数
操作数有4种类型:- register
- memory
- immediate
- implied:没直接表示出来的数,比如自加操作中的1.
-
driectives
汇编语言也有类似C语言的预处理语句,其语句是以%开头。SIZE equ 100
%define SIZE 100
L1 db 0
: byte labeled L1 with initial value 0L2 dw 1000
: word labeled L2 with initial value 1000L7 resb 1
: 1 uninitialized byte labeled L7- letters for RESX and DX Directives:
- B: byte
- W: word
- D: double word
- Q: quad word
- T: ten bytes
- label可以用来指向代码中的数据,label相当于指针,在label前后加上中括号([])则表示对指针所指向的数据,类似C语言的星号。
-
输入/输出:
- 作者在
asm_io.inc
文件中封装了C语言的I/O函数,包括print_int
,print_char
,print_string
,print_nl
,read_int
和read_char
。 - 疑问:1.3.6节说汇编语言可以使用C标准库的I/O函数,为什么底层语言可以调用高级语言的函数的?
- 汇编语言文件包含:
%include "asm_io.inc"
- 函数调用:使用CALL指令。
- 作者在
-
调试:通过打印问题现场中寄存器、内存、栈和数学处理器里面的数值来进行调试。作者在
asm_io.inc
文件中封装了调试相关函数,包括dump_regs
,dump_mem
,dump_stack
和dump_math
。
1.4 创建程序
- 使用NASM编译汇编程序
nasm -f elf hello.asm
gcc -o hello hello.o
-
解决编译错误
- "error: instruction not supported in 64-bit mode"的解决方法:nasm的格式选项改用elf代替elf64,gcc选项增加
-m32
- "relocation R_X86_64_32S against '.text' can not be used when making a PIE object; recompile with -fPIC"的解决方法:gcc选项增加
-no-pie
- "undefined reference to '_printf'"的解决方法:使用nasm编译asm_io.asm时,增加
-d ELF_TYPE
选项。
- "error: instruction not supported in 64-bit mode"的解决方法:nasm的格式选项改用elf代替elf64,gcc选项增加
-
段
- 已初始化的数据存储在
.data
段 - 未初始化的数据存储在
.bss
段 - 代码存储在
.text
段
- 已初始化的数据存储在
-
global
:汇编语言的label默认拥有internal scope,这意味着只有同一个模块的代码可以使用此label。globel指令使label拥有external scope,使得程序中任意模块都可使用此label。 -
enter
指令创建一个栈帧,leave
指令销毁一个栈帧。enter
指令的第一个参数用来指示需要为局部变量申请的内存大小。enter
和leave
指令的等价代码如下所示:
; enter
push ebp
mov esp, ebp
sub firstPram, esp
; leave
mov esp, ebp
pop ebp
-
windows的目标文件是coff(Common Object File Format)格式的,linux的目标文件是elf(Executable and Linkable Format)格式。
-
-l listing-file
选项可以让nasm生成一个包含汇编信息的list文件。该文件第2列是数据或代码在段中的偏移,注意这个偏移值不一定是最终形成整个程序时的真实偏移值,因为不同模块都可能在数据段定义了自己的label,在链接阶段,所有数据段的label定义汇总在一个数据段里,这时链接器需要重新计算各个label 的偏移。 -
大小端
- IBM框架、大部分RISC处理器和摩托罗拉处理器都是大端序,而Intel处理器是小端序。
- 需要关注字节序的场景:
- 当字节数据在不同主机间传输时
- 当字节数据作为多字节整数写到内存,然后逐个字节读取时(或者相反)
- 字节序对数组元素的顺序不影响,数组的第一个元素永远在最小的地址。但字节序对数组的每个单独元素还是会有影响(比如元素是多字节的整数时)。
第2章 汇编语言基础
整数
-
整数的表示
- 采用2的补码的形式来表示负数。2的补码是指对正数的二进制序列取反码后加1.阮一峰的一篇日志关于2的补码解释了取反码加1的原因:以-5为例,-5=0-5=100000000 - 00000101=11111111 - 00000101 + 1.
-
符号扩展
- 当多个数据之间进行运算时,往往需要增大或减少数据的size,这时需要扩展符号位。对unsigned型整数,符号位用0来扩展;对signed型整数,正数用0来扩展符号位,负数用1来扩展符号位。
- MOVZX用于无符号正数扩展,比如将al的值扩展到ax,将ax的值扩展到eax等。
- MOVSX用于有符号正数扩展。
- 数据类型转换
- CBW: Covert Byte to Word. extends AL to AX
- CWD: Covert Word to Double word. extends AX to DX:AX
- CWDE: Covert Word to Double word Extended. extends AX to EAX
- CDQ: Convert Double word to Quad word. extends EAX to EDX:EAX
- EOF是一个用来表示文件结束的宏(通常被定义为-1),而不是真实存在的字符。
-
2的补码的四则运算
- 加法:add
- 减法:sub
- 无符号乘法:
mul source
。其中source是寄存器或内存地址,不能是立即数。另一个操作数根据size大小存储在AL、AX、DX:AX或EDX:EAX中。 - 有符号乘法:
imul source1
imul dest, source1
imul dest, source1, source2
- 无符号除法:
div source
- 有符号除法:
idiv source
- 取反:
NEG
控制结构
-
比较
- 控制结构根据对数据的比较来决定执行流程,数据比较的结果存储在FLAGS寄存器中。
- 80x86使用CMP指令来实现比较,其原理是将两个操作数相减,根据差值设置FLAGS寄存器。
- 无符号整数的比较,需要关注两个寄存器:ZF(zero)和CF(carry)。
- 有符号整数的比较,需要关注三个寄存器:ZF(zero)、OF(overflow)和SF(sign)。
-
分支指令
- 无条件分支:
JMP
、SHORT
、NEAR
和FAR
。 - 有条件分支:
JE
、JZ
等。
- 无条件分支:
-
循环
- LOOP: ECX减1,如果ECX不为0,则跳到label
- LOOPE, LOOPZ: ECX减1,如果ECX不为0且ZF=1,则跳到label
- LOOPNE, LOOPNZ: ECX减1,如果ECX不为0且ZF=0,则跳到label
第3章 位操作
移位操作
-
逻辑移位
- 指令:
SHL
和SHR
- 移动的位数可以是常量或者是存储在CL寄存器的值
- 移走的那一位的值存储在CF寄存器中
- 指令:
-
算术移位
- 指令:
SAL
和SAR
SAL
和SHL
的行为完全一样。So,如果左移时改变了符号位怎么办?SAR
用符号位来填充最高位。
- 指令:
-
左移相当于乘以2,右移相当于除以2.
-
循环移位
- 指令:
ROL
和ROR
- 移走的那一位的值存储在另一端空出来的位上。
- 指令:
布尔位操作
AND
OR
XOR
NOT
TEST
:TEST指令执行一个AND操作,根据结果来设置FLAGS寄存器,而不保存结果。
计算整数中1的位数
- 逐位检查:常规方法
- 更快的检查:
data = data & (data - 1)
- 以空间换时间:事先存储0~255的1的数目,然后对int型整数每个字节查表再汇总。
- 很巧妙但实在不知道怎么想出来的方法:
x = (x & mask[i]) + ((x >> shift) && mask[i]);
第4章 子程序
调用者和被调用者必须统一约定好如何传递数据。关于如何传递数据的规则被称为calling conventions。
间接寻址
-
间接寻址允许寄存器像指针变量一样操作,但要注意寄存器没有数据类型的概念,寄存器目前是不是作为指针使用、指向什么类型的数据等完全取决于使用的指令。这是汇编语言比高级语言更容易出错的原因之一。
-
所有32位的通用寄存器和index寄存器(ESI、EDI)都可用于间接寻址,而16位或8位的寄存器则不能(伍注:我想应该是因为地址是32位的,少于32位的寄存器存不下)
子程序举例
-
伍注:call指令背后需要做存储返回地址等操作,留意一下。
-
$
操作符返回当前行对应的内存地址。
栈
-
栈相关寄存器
- SS(Stack Segment):存储栈的段地址
- SP(Stack Pointer):存储栈的偏移地址,也就是栈顶数据的地址
- BP(Base Pointer): 和SP联合使用,在寻找栈中的数据和使用个别的寻址方式时会用到。比如说,堆栈中压入了很多数据或者地址,你肯定想通过SP来访问这些数据或者地址,但SP是要指向栈顶的,是不能随便乱改的,这时候你就需要使用BP,把SP的值传递给BP,通过BP来寻找堆栈里数据或者地址。
-
PUSH指令在栈顶插入一个double word(4字节),并导致ESP的值减4;POP指令读取栈顶的一个double word,并导致ESP的值加4.
-
栈可以用来临时存储数据,以及在进行子程序调用时传递参数和局部变量。
-
80x86可以通过PUSHA指令来将EAX、EBX、ECX、EDX、ESI、EDI和EBP寄存器的值全部压栈,而通过POPA指令将它们的值全部出栈。
-
CALL指令会进行无条件跳转并且将下一条指令的地址压栈,RET指令会将下一条指令的地址出栈并且跳到那个地址。
调用约定(Calling Conventions)
-
存储在栈中的参数在子程序中不会出栈,而是通过指针来访问。
-
80386提供EBP寄存器来访问栈中的数据。C语言calling conventions要求子程序首先将EBP的值入栈,然后将ESP的值赋给EBP。在子程序运行过程中,ESP的值随着数据的入栈或出栈而改变,但EBP的值保持为ESP的原始值。在子程序结束时,EBP需要恢复为原始值(伍注:我想是因为在嵌套场景下EBP保存了父程序的ESP的原始值,若不能正常恢复EBP的话,父程序的数据访问就会乱套)。
subprogram_label:
push ebp ; save original EBP value on stack
mov ebp, esp ; new EBP = ESP
; subprogram code
pop ebp ; restore original EBP value
ret
- 子程序的开始和结束可以使用两条简单的指令:ENTER和LEAVE。ENTER指令需要两个输入参数,C语言调用约定中第二个参数永远为0,第一个参数则是局部变量需要使用的字节数。LEAVE指令不需要输入参数。
subprogram_label:
enter LOCAL_BYTES, 0 ; = # bytes needed by locals
; subprogram code
leave
ret
-
清除栈中的输入参数
- C calling Convention: 子程序结束后,由调用者负责清除栈中传递给子程序的参数。这样做的原因是C语言允许函数参数数目可变,这种情况下子程序不能判断输入参数的数目,因此由调用者来清除更容易。
- Pascal calling convention: 由子程序负责清除栈中的输入参数。这样相对C语言效率更高些,因为清栈的代码只需在子程序生成一次即可,而不需要每次调用时都生成一遍。Pascal不需要函数参数可变,因此不存在C语言的问题。
-
使用栈来存储局部变量的好处:
- 可重入:每次调用子程序时,会从栈中申请内存来存储局部变量,子程序结束后会释放内存。这样多次调用子程序也不会有影响。(伍注:试想如果使用固定内存来存储局部变量,多次调用子程序时就会相互影响,从而不能满足可重入的要求)
- 节省内存:存储在栈中的数据只会在子程序生命周期中使用内存,不存储在栈中的数据会在整个程序生命周期中都要使用内存。
多模块程序
-
多模块程序是指有多个目标文件组成的程序。
-
模块A如果想访问模块B的label1,需要在模块B中将label1声明为global,并且在模块A中将label1定义为extern。
汇编语言与C语言交互
-
可以在C语言程序中调用汇编子程序。
-
C语言假定子程序维持以下寄存器的值不变:EBX,ESI,EDI,EBP,CS,DS,SS,ES。具体地说,EBX、ESI和EDI的值必须保持不变,因为C语言使用这些寄存器来存储register变量。上述其他寄存器在子程序内部可以修改,但在子程序返回前需要恢复原始值。
-
大部分C编译器会在函数名、全局变量名或静态变量名的前面加上一个下划线,比如DJGPP的gcc编译器。而在Linux系统中则保持原名、不带下划线。
-
根据C调用约定,函数参数压栈的顺序与它们出现在函数调用的顺序相反。这样有个好处是:对参数数目可变的函数,比如printf函数,格式化字符串在栈中的位置永远是所有参数的最下面(EBP + 8),这样子程序通过查看[EBP + 8]的内容可以很方便地判断出参数数目。
-
LEA(Load Effective Address)指令用于计算地址。如
lea eax, [ebp - 8]
语句的作用是把寄存器EBP存储的内存地址减8后得到的地址赋给寄存器EAX。 -
根据C调用约定,函数返回值通过寄存器来返回。所有整数类型、指针类型的数据均通过EAX寄存器返回。浮点数通过ST0寄存器返回。
-
调用约定
- GCC编译器运行多种盗用约定。函数的调用约定可以通过
__attribute__
显式声明,比如void f(int) __attribute__((cdecl));
- stdcall和cdecl的区别是前者要求由子程序负责清除栈中的参数,因此前者智能用于参数数目固定的函数。
- GCC提供了regparm属性用来告诉编译器用寄存器来传递最多3个整数参数,而不是用栈来传递。
- Borland和Microsoft为C语言增加了
__cdecl
和__stdcall
关键字,这两个关键字作为函数修饰符出现在函数名的前面。比如:void __cdecl f(int);
- cdecl vs stdcall:
- cdecl的优点是简洁灵活,可以用于任意类型的C函数和C编译器,缺点主要是相对其他调用约定速度更慢、占用内存更多,因为每次对子函数的调用都需要生成清除栈中参数的代码。
- stdcall的优点是占用内存更少,缺点主要是不支持函数参数数目可变。
- 使用寄存器来传递整数参数的优点是速度更快,缺点主要是复杂,因为当参数较多时,部分参数在寄存器中、部分在栈中。
- GCC编译器运行多种盗用约定。函数的调用约定可以通过
-
dump_stack 1, 2, 4
解释:第一个参数“1”是一个数字label,第2、3个参数分别代表打印多少个位于EBP下面和上面的double word。
可重入和递归子程序
-
一个可重入的子程序必须满足以下性质:
- 必须不能修改任何代码指令
- 必须不能修改全局变量(比如存储在data和bss段的数据),所有变量存储在栈中。
-
编写可重入代码的优势:
- 可重入子程序可以被递归调用
- 可重入程序可以被多个进程共享
- 可重入子程序在多线程程序中工作得更好(why?)
-
C变量存储类型
- global: 在所有函数的外面定义。存储在data或bss段中,存在于程序的整个生命周期中。默认可以被程序中的任意函数访问,但如果声明为static,则只能被同一模块的程序访问。
- static: 函数中被声明为static的局部变量。存储在data或bss段中。只能在它们所在的函数内被访问。
- automatic: 函数中定义的局部变量的默认类型。当定义它们的函数被调用时,这些变量会在栈中分配到内存空间,而函数返回时这些内存空间会被释放。
- register: 该关键字请求编译器用寄存器来存储当前变量,但编译器没必要一定这样做。C编译器经常自动将普通的auto变量存储在寄存器中。但如果变量的地址会在代码中用到,则不能将其存储在寄存器中,因为寄存器没有地址。此外结构体类型也不能定义为register,因为寄存器存不下。
- volatile: 该关键字告诉编译器当前变量的值可能在任意时刻被修改,这意味着编译器不能对变量的修改时间做任何猜测。此关键字可以避免编译器将变量存储在寄存器中(编译器有时会将变量存储在寄存器中,接着使用寄存器的值来代替变量值),以及避免编译器自动对代码中的某些变量赋值进行优化(比如
x = 10; y = 20; z =x
这段代码,编译器往往自动将10复制给z)。
第5章 数组
-
定义数组
- 在data段中定义已初始化的数组:使用db, dw, dd等指令,可以使用TIMES指令来重复声明。
- 在bss段中定义未初始化的数组:使用resb, resw等指令。
- 在stack段中定义局部数组变量:计算出所有局部变量的总字节数,然后将ESP减去此数值。如果总字节数不是4的倍数,需要向上取整到4的倍数,以保证ESP的值以double word为单位。
-
访问数组元素
- C语言根据指针类型来判断指针运算中需要移动多少个字节,汇编语言中需要程序员来计算在不同元素间跳转时需要偏移多少个字节。
- 间接寻址公式:
[ base_reg + factor * index_reg + constant ]
-
方向标志
- CLD: 清除方向标志。此时基址寄存器以递增的方式工作。(记忆:清真(情增))
- STD: 设置方向标志。此时基址寄存器以递减的方式工作。
-
读写内存
- ESI(Source Index)用于读内存,EDI(Destination Index)用于写内存。
- 用于存储串操作指令的数据的寄存器是固定的,即AL、AX或EAX。
- 串存储指令使用ES而非DS来标志用于写内存的段地址,在使用时记得初始化ES的值(在虚模式下ES会自动初始化,在实模式下则不会)。
- 不能通过MOV指令直接将DS寄存器的值复制到ES,而需要使用通用寄存器中转。
-
串操作指令
- LODSx: 把由DS:SI指向的源串中的字节(或字)装入到AL(或AX)中,并根据DF自动修改指针SI,以指向下一个要装入的字节(或字)。
- STOSx: 把AL(或AX)中的内容存储到由ES:DI指向的目的串汇总,并根据DF自动修改指针DI,以指向下一个要写入的字节(或字)。
- MOVSx: 将DS:SI所指向的源串中的一个字节(或字)传送到ES:DI所指向的存储单元,并根据DF自动修改SI和DI的值。
- CMPSx: 比较DS:SI所指向的源串中的一个字节(或字)与ES:DI所指向的目的串的一个字节(或字),根据比较结果修改FLAGS寄存器。适用于比较或搜索数组。
- SCASx:比较寄存器AL(或AX)的内容与ES:DI所指向的目的串的一个字节(或字),根据比较结果修改FLAGS寄存器。适用于比较弧搜索数组。
-
REP指令前缀:
- 指令前缀不是指令,而是位于串指令前面、用来改变指令行为的一个特别的字节。
- REP指令前缀:重复执行某条指令,重复次数存储在ECX寄存器中。
- REPx指令前缀:重复执行某条指令,直到某个条件不再满足或重复次数达到ECX的值。注意:当重复操作由于比较结果不再满足要求而中止时,基址寄存器仍然会加1、ECX寄存器仍然会减1,而FLAGS寄存器仍然保持中止时的状态。因此,可以通过Z标志来判断操作中止是因为比较结果不满足要求还是ECX变为0(ECX变为0只能说明达到最大重复次数,但不能确认最后一次的比较结果,所以还是需要看FLAGS寄存器)。
第6章 浮点数
浮点数的表示
-
十进制小数转换成二进制小数:不断乘以2,取个位。
-
IEEE定义了两种精度不同的浮点数表示法:单精度(float)和双精度(double)。Intel的数学协处理器还使用了第三种精度更高的表示法,叫做extended precision。
-
注意某个数值在A进制下是有限小数,在B进制下却可能是无限循环小数。比如1/3在十进制下是无限循环小数0.3333···,在三进制下则是0.1.
-
IEEE单精度表示法:32位,一般能精确到7位十进制有效数字。
- s: 最高位是符号位,0为整数,1为负数
- e: 第30~23位是偏移后的指数,其数值为正确的指数值加上127.指数为0或0xFF时有特殊含义。
- f: 第22~0位是底数,其数值为紧接在个位的1后面的23位。
- 单精度的整数大小范围是1.0*2^(-126) ~ 1.1111...*2127,对应十进制的1.1755*10(-35) ~ 3.4028*10^35.
-
e和f的特殊值
- e = 0 and f = 0:表示0,注意存在+0和-0(视最高位的符号位而定)
- e = 0 and f != 0:表示非规范化的小数,用于数值非常小的小数。
- e = FF and f = 0: 表示无限大(符号位为正)或无限小(符号位为负)
- e = FF and f != 0: 表示不确定的结果,NaN(Not a Number)。比如计算负数的平方根、将两个无限的数相加时会返回NaN。
-
IEEE双精度表示法:64位。一般能精确到15位十进制有效数字。
- 指数的位数增加到11位,指数偏移量由127改为1023.
- 底数的位数增加到52位
- 单精度的整数大小范围为10(-308)至10(308).
浮点数的四则运算
-
由于计算机的位数有限,许多浮点数不能被精确表示。
-
程序员必须记住:计算机中的浮点数运算得到的永远是近似值。一个常见的编程错误是使用等号来比较两个浮点数是否相等,比如:
if ( f(x) == 0.0 ) // wrong
if ( fabs(f(x)) < EPS ) // true. Here EPS is a macro defined to be a very small positive value (like 1 * 10^(-10))
数字协处理器
-
Intel提供一个额外芯片(数学协处理器)来支持浮点数运算,8086、80286、80386处理器对应的协处理器分别是8087、80287、80387。自Pentium以后数学协处理器就做进CPU里面了。
-
数字协处理器的寄存器
- 数字协处理器有8个浮点寄存器,名字分别是ST0,ST1,...ST7。每个寄存器能存储80位的数据,并且是按照栈的LIFO方式来管理数据的。
- ST0永远存储栈顶数据的地址。
- 数字协处理器也有1个状态寄存器。
-
数字协处理器的指令
-
为区分正常CPU指令,数字协处理器的指令均以F开头。
-
加载和保存指令
-
FLD source
: 从内存中加载一个浮点数到栈顶,source可以是立即数或协处理器寄存器。 -
FILD source
: 从内存中加载一个整数,将其转化为浮点数并保存到栈顶。source是立即数。 -
FLD1
: 保存数字1到栈顶 -
FLDZ
: 保存数字0到栈顶 -
FST dest
: 保存栈顶到内存。dest是立即数或协处理器寄存器。 -
FSTP dest
: 保存栈顶到内存,并将栈顶的数字出栈。 -
FIST dest
: 将栈顶数字转换成整数,并保存到内存中。dest是单字或双字。 -
FISTP
:除了栈顶数字出栈和dest可以是四字外,与FIST相同。 -
FXCH STn
: 交换ST0和STn在栈中的数值 -
FFREE STn
: 释放栈中的一个寄存器(即将该寄存器标识为unused或empty)
-
-
加减法指令
FADD src
: ST0 += src。src可以是协处理器寄存器或立即数。FADD dest, ST0
: dest += ST0. dest是协处理器寄存器。FADDP dest
或FADDP dest ST0
: dest += ST0,然后出栈。FIADD src
: ST0 += (float) src. src是立即数(整数)。FSUB src
: ST0 -= src. src可以是协处理器寄存器或立即数。FSUBR src
: ST0 = src - ST0. src同上。FSUB dest, ST0
: dest -= ST0. dest是协处理器寄存器。FSUBR dest, ST0
: dest = ST0 - dest.FSUBP dest
或FSUBP dest, ST0
: dest -= ST0,然后出栈。FSUBRP dest
或FSUBRP dest, ST0
: dest = ST0 - dest,然后出栈。FISUB src
: ST0 -= (float) src.FISUBR src
: ST0 = (float) src - ST0.
-
乘除法指令
FMUL src
: ST0 *= src.FMUL dest, ST0
: dest *= ST0.FMULP dest
或FMULP dest, ST0
: dest *= ST0,然后出栈。FIMUL src
: ST0 *= (float) src.FDIV src
: ST0 /= src.FDIVR src
: ST0 = src / ST0.FDIV dest, ST0
: dest /= ST0.FDIVR dest, ST0
: dest = ST0 / dest.FDIVP dest
或FDIVP dest, ST0
: dest /= ST0,然后出栈。FDIVRP dest
或FDIVRP dest, ST0
: dest = ST0 / dest,然后出栈。FIDIV src
: ST0 /= (float)src.FIDIVR src
: ST0 = (float)src / ST0.
-
比较指令
FCOM src
: 比较ST0和src。FCOMP src
: 比较ST0和src,然后出栈。FCOMPP
: 比较ST0和ST1,然后出栈两次。FICOM src
: 比较ST0和(float)src。FICOMP src
: 比较ST0和(float)src,然后出栈。FTST
: 比较ST0和0.
-
宏指令
FCHS
: ST0 = - ST0FABS
: ST0 = |ST0|FSORT
: ST0 = sqrt(ST0)FSCALE
: ST0 = ST0 * 2[1].
-
第7章 结构体与C++
汇编与C++
-
重载和名字修饰
- C++允许多个函数名字相同,只要它们的参数类型不完全相同即可。注意:C++不允许两个函数名字和参数类型完全相同而只有返回值类型不同。
- C++使用“函数名字+函数参数类型缩写”的方式来修饰函数名。(注意名字修饰时没用到返回值类型,这也是为啥C++ 不允许两个函数只有返回值类型不同的原因。)
- C++对所有函数都添加函数参数类型信息来加以修饰,因为它无法判断特定函数是否被重载。
- C++对全局变量也都添加函数参数类型信息来加以修饰。
- 类型安全链接(typesafe linking):函数或全局变量在不同地方的类型不一致。
- 由于不同编译器使用不同的名字修饰规则,不同编译器编译的C++代码可能无法链接到一起。在使用已经编译好的C++库时需要注意其使用的编译器的名字修饰规则。
- 由于C和C++的名字修饰规则不同,在C++中调用C函数时可能会链接失败,为解决该问题,可以使用
extern "C"
语句来告诉编译器对应的函数或全局变量使用传统C的编译链接方式。
-
引用:C++相对C语言引入的新特性。它允许程序员无需使用指针就能传递参数地址给函数。
-
内联函数:不会发生函数调用,而是将函数体的代码替换到调用处。内联函数的主要缺点是内联代码不会链接,因此所有需要使用该内联函数的文件都必须能访问到该内联函数所在的文件;而且,当修改了内联函数的实现,所有调用该函数的文件需要重新编译。
ST1 ↩︎