26. 有问题的指令

优质
小牛编辑
125浏览
2023-12-01

26.1 XCHG (所有处理器)

XCHG register,[memory]指令是危险的。 缺省情况下,该指令隐含一个LOCK前缀阻止自己使用cache,因此该指令十分耗时,应该尽量避免。

26.2 大循环移位(所有处理器)

RCR和RCL指令,如果移位位数大于1的话是很慢的,应该避免。

26.3 串操作指令(所有处理器)

不带重复前缀的串指令是非常慢的,应该被简单的指令取代。
同样地,任何处理器上的LOOP指令,PPlain和PMMX上的JECXZ指令也很慢。

倘若重复次数不小的话,REP MOVSD和REP STOSD是相当快的。
尽可能地这些指令的DWORD版本,且保证源地址和目的地址都按8对齐。

在特定条件下,还存在一些更快的搬动数据的方法。 详情看27.8章。

注意,当REP MOVS指令向目的地址写入一个字的时候,在同一个时钟周期它会从源地址读出下一个字。 如果这两个地址的2-4位相同的话,会有cache行冲突。

换句话说,如果ESI+字长-EDI能被32整除的话,每次叠代都会有1个时钟的损失。 最简单的避免cache行冲突的方法是用DWORD版本的REP MOVS(即REP MOVSD)且使得源地址和目的地址都按8对齐。 想写出优化的代码的话,就不要用MOVSB和MOVSW,甚至在16位模式下也不要用它们。

在PPro,PII和PIII上,如果是一次性搬动整个cache行的话,REP MOVS和REP STOS运行得很快。
但还必须满足以下条件才会发生这样的好事:

*源地址和目的地址都按8对齐

*增量是向前的(清方向标志)

*计数器(ECX)大于等于64

*EDI和ESI的差在数值上大于等于32

*源内存和目的内存必须是写回或组合写模式(一般你可以假定是这样)

在这些条件下,REP MOVSD产生的微码大约是215+2*ECX条,REP STOSD产生的微码大约是185+1。 5*ECX条,它们的速度大约是5字节/时钟。

如果上面的条件不是都满足的,速度将减为1/3。

在“快速模式”下(即上述条件全部满足),byte版本和word版本的REP MOVS/STOS指令同样受益,但效率不如dword版本。

REP STOSD的优化条件与REP MOVSD相同。

机器对于REP LOADS, REP SCAS, 和REP CMPS没有优化过,最好用循环取代它们。
如何取代REPNE SCASB,参考示例 1.10, 2.8 和 2.9。 如果ESI和EDI的2-4位相同的话,REP CMPS可能会遭遇cache行冲突。

26.4 位测试(所有处理器)

在PPlain和PMMX上,BT, BTC, BTR, 和BTS最好被TEST, AND, OR,
XOR或移位取代;在PPro,PII和PIII上应该避免对内存操作数的位测试。

26.5 整型乘法(所有处理器)

在PPlain和PMMX上,整型乘法大约花9个周期;在PPro,PII和PIII上大约是3个周期。
因此最好用一个常量与其它指令(诸如SHL,ADD,SUB和LEA)结合的形式取代整型乘法,比如:

IMUL EAX,10

可以被替换为:

MOV EBX,EAX / ADD
EAX,EAX / SHL EBX,3 / ADD EAX,EBX

LEA EAX,[EAX+4*EAX] / ADD EAX,EAX

在PPlain和PMMX上,浮点乘法比整型乘法快,但花在整数转为浮点,计算结果再转回整数的时间往往比浮点乘法节省的时间要多,除非转化的次数相对乘法的次数而言很少。
MMX乘法很快,但只有16位操作数能用。

26.6 WAIT指令(所有处理器)

经常地,你可以通过省略WAIT指令来提高速度。 WAIT指令有3个功能:

a. 老式的8087处理器每次在浮点指令(伪指令)前都自动插入一个WAIT,以保证协处理器准备好接受它。

b. 被用来在浮点运算单元(FPU)和整型运算单元(IU)之间协调内存的访问(只有先浮点后整型情况下才有):

b.1. FISTP [mem32]

WAIT ; 在用IU读结果之前,先要等FPU写完

MOV
EAX,[mem32] ;

b.2. FILD [mem32]

WAIT ; 在用IU覆盖数据之前,先等FPU读完原来的数据

MOV
[mem32],EAX ;

b.3. FLD QWORD PTR [ESP]

WAIT ; 在堆栈上覆盖数据时阻止意外中断的发生

ADD ESP,8
;

c. WAIT还用来检测异常。
如果浮点指令设置的浮点状态字中有未屏蔽的异常位时,会产生一个中断。

关于a:

除了8087外,a功能再也不需要了。通过在程序中申明更高级的CPU,可以告诉汇编器不要插入WAIT,除非你想使代码兼容8087。8087的浮点伪指令经过汇编后也会插入WAIT指令,因此你得告诉汇编器不要插入伪指令,除非你需要它。

关于b:

在8087和80287上需要显式地用WAIT指令协调内存访问,但在Pentium上不需要。在80387和80487上是否需要不大清楚。对于协调内存访问,Intel手册上说除了在FNSTSW和FNSTCW之后,都需要WAIT指令。但我在这些Intel处理器上做了几次实验,发现在32位的Intel处理器上省去WAIT指令并没有出现任何错误。省去用于协调内存访问的WAIT指令不是100%安全的,甚至在写32位代码的时候。因为代码可能会在罕见的80386主处理器和287协处理器组合的机器上运行,而287是需要WAIT协调内存的。我也没有关于非Intel处理器的信息,也没有测试过硬件和软件所有可能的组合,因此可能存在一些其它的需要WAIT的情形。

如果你想保证代码可以在任何32位处理器上工作(包括非Intel处理器),那么如果是为了协调内存访问安全起见,推荐你写WAIT指令。

关于c:

基于c的目的,汇编器会自动在以下指令前面插入一条(F)WAIT:FCLEX, FINIT, FSAVE, FSTCW,
FSTENV,
FSTSW。你可以通过写成FNCLEX来省略WAIT指令。在80387上,我测试的结果是大多数情况下不需要WAIT指令,因为没有WAIT指令,这些指令(除了FNCLEX和FNINIT)在异常发生时也会产生中断(所不同的是有了WAIT,中断点的IRET是返回到FN。。指令,没有WAIT则是返回到下一条指令)。

对于几乎所有其它的浮点指令,如果前面有指令在浮点状态字上设置了未屏蔽的异常位的话,都会产生一个中断——而异常可能被立刻检测到,也可能在稍后检测到。你可以在最后一条浮点指令后面插入一条WAIT以保证捕获所有异常。

如果想知道异常究竟在哪里以便从异常情形恢复的话,你可能仍然需要一条WAIT。比如考虑上面b。3的代码:如果你想从FLD产生的异常中恢复,那么需要一条WAIT,因为在ADD
ESP,8之后的中断会覆盖要加载的值。FNOP可能比WAIT更快,而且能达到相同的目的。

26.7 FCOM + FSTSW AX(所有处理器)

FNSTSW指令在所有处理器上都很慢。
PPro,PII和PIII处理器有FCOMI指令来避免较慢的FNSTSW指令。 用FCOMI取代传统的FCOM/FNSTSW
AX/SAHF指令能够节省8个时钟周期。 因此你要尽可能地用FCOMI取代FNSTSW,哪怕在会引起额外代码的情况下。

在没有FCOMI的处理器上,通常用于做浮点比较的方法是:

FLD [a]

FCOMP [b]

FSTSW AX

SAHF

JB ASmallerThanB

你可以用FNSTSW AX取代FSTSW AX,直接用test
AH取代不可配对的SAHF来改进代码(但 TASM3.0 版本对于FNSTSW AX指令有一个bug):

FLD [a]

FCOMP [b]

FNSTSW AX

SHR AH,1

JC ASmallerThanB

测试st(0)是否=0。0:

FTST

FNSTSW AX

AND AH,40H

JNZ IsZero ;
(注意ZF的值与结果相反)

测试a是否大于b:

FLD [a]

FCOMP [b]

FNSTSW AX

AND AH,41H

JZ
AGreaterThanB

在PPlain和PMMX上,不要用TEST
AH,41H因为它不能配对。

在PPlain和PMMX上,FNSTSW指令花2个时钟,但在任何浮点指令之后它都必须先等上4个时钟,因为它要等浮点状态字从流水线中引退。
甚至在FNOP这种不改变状态字的指令后也如此,但在整型指令后没有延迟。 你可以在FCOM和FNSTSW之间插入4个时钟周期的整型指令来弥补这个延迟。
紧跟FCOM之后配对的FXCH指令不会使FNSTSW延迟,哪怕是有缺陷的配对:

FCOM ; clock 1

FXCH ; clock
1-2 (有缺陷的配对)

INC DWORD PTR [EBX] ; clock 3-5

FNSTSW AX ; clock 6-7

你可能想用FCOM取代FTST,因为FTST是不可配对的。
记住要用FNSTSW,因为FSTSW(没有N)将带有一个WAIT前缀导致以后的延迟。

有时候把整型指令用于比较浮点值,可以更快,如27.6节描述。

26.8 FPREM(所有处理器)

FPREM和FPREM1指令在所有处理器上都很慢。 你可以用以下算法取代它:
把被除数与除数的倒数相乘,乘积减去其整数部分得到其小数部分,小数部分再与除数相乘(见27.5章,如何得到小数部分)。

一些文档上说有时候这2条指令给出的结果是不能完全还原的,因此需要不断重复地做FPREM或FPREM1直到余数能够完全还原。
从老式的8087开始我已经在很多处理器上测试过这件事了,没有发现需要重复做FPREM或FPREM1的情况。

26.9 FRNDINT(所有处理器)

这个指令在所有处理器上都很慢。 将它替换为:

FISTP QWORD PTR [TEMP]

FILD
QWORD PTR [TEMP]

尽管在写操作完成之前就试图去读[TEMP]会有惩罚,但这段代码仍然比FRNDINT快。
推荐你在中间插入一些其它指令以避免惩罚,见27.5章,如何进行浮点数取整。

26.10 FSCALE和指数函数(所有处理器)

FSCALE指令在所有处理器上都很慢。计算2的整数次幂,可通过向浮点数的阶码部分插入希望的幂次来快速完成。为了计算2^N,这里N是一个带符号的整数,对于不同范围的N,你可以选择以下方法之一:

对于|N| < 2^7-1你可以用单精度浮点数:

MOV EAX, [N]

SHL EAX, 23

ADD EAX,
3F800000H    ;IEEE754标准

MOV DWORD PTR [TEMP], EAX

FLD DWORD PTR [TEMP]

对于 |N| < 2^10-1 你可以用双精度浮点数:

MOV EAX, [N]

SHL EAX, 20

ADD EAX,
3FF00000H    ;IEEE754标准

MOV DWORD PTR [TEMP], 0

MOV DWORD PTR [TEMP+4], EAX

FLD QWORD PTR [TEMP]

对于 |N| < 2^14-1 你可以用扩展双精度浮点数:

MOV EAX, [N]

ADD EAX, 00003FFFH    ;IEEE754标准

MOV DWORD PTR [TEMP], 0

MOV
DWORD PTR [TEMP+4], 80000000H

MOV DWORD PTR
[TEMP+8], EAX

FLD TBYTE PTR [TEMP]

FSCALE 经常用来计算指数函数。下面的例子是一个不用慢指令
FRNDINT 和 FSCALE 的指数函数:

; extern "C" long double _cdecl
exp (double x);

_exp PROC NEAR

PUBLIC _exp

FLDL2E

FLD QWORD PTR [ESP+4] ; x

FMUL ; z = x*log2(e)

FIST
DWORD PTR [ESP+4] ; round(z)

SUB ESP, 12

MOV DWORD PTR [ESP], 0

MOV
DWORD PTR [ESP+4], 80000000H

FISUB DWORD PTR
[ESP+16] ; z - round(z)

MOV EAX, [ESP+16]

ADD EAX,3FFFH

MOV
[ESP+8],EAX

JLE SHORT UNDERFLOW

CMP EAX,8000H

JGE SHORT
OVERFLOW

F2XM1

FLD1

FADD ; 2^(z-round(z))

FLD
TBYTE PTR [ESP] ; 2^(round(z))

ADD ESP,12

FMUL ; 2^z = e^x

RET

UNDERFLOW:

FSTP ST

FLDZ ; return 0

ADD ESP,12

RET

OVERFLOW:

PUSH 07F800000H ; 正无穷大

FSTP ST

FLD DWORD PTR [ESP]
; 返回无穷大

ADD ESP,16

RET

_exp ENDP

26.11 FPTAN (所有处理器)

根据手册,FPTAN返回两个结果X和Y,留给程序员用Y/X得到结果。
但事实上,在一般情况下X总是1,因此除法不需要了。
我的实验结果显示所有32位Intel处理器(不管用浮点单元FPU的还是用协处理器的),FPTAN返回的X总是1。
如果想绝对保证你的代码在所有处理器上运行正确,你可以先看一下X是否是1,这比做除法要快得多了。
Y的值可能会很大,但不可能是正负无穷大,因此只要参数是合法的你就用不着测试Y是否是有效的了。

26.12 FSQRT (PIII)

在PIII上,计算x的近似平方根的较快的方法是x乘以其倒数平方根:

SQRT(x) = x * RSQRT(x)

RSQRTSS或RSQRTPS指令得到的倒数平方根的精度是12位。
通过Intel应用手册AP-803描述的牛顿-拉弗森公式,可以得到23位的精度:

x0 = RSQRTSS(a)

x1 = 0.5 * x0 * (3 - (a * x0)) * x0)

x0是a的倒数平方根的首次逼近,x1是更好的逼近。
计算的顺序不能搞错,必须先用这个公式,然后再与a乘得到其平方根。

26.13 MOV [MEM],累加器 (PPlain和PMMX)

MOV [mem],AL/MOV [mem],AX/MOV [mem],EAX指令被对称电路看作如同向累加器(AL/AX/EAX)写一样。
因此下面的指令不能配对:

MOV [mydata], EAX

MOV EBX, EAX

只有在短形的MOV指令,没有基址或变址寄存器,原操作数为累加器的情况下才会发生这种问题。
你可以通过用另外的寄存器,或改变指令顺序,或用指针寄存器,或把MOV指令硬性编码为更长形式来避免这个问题。

32位模式下,你可以把MOV [mem],EAX指令写成:

DB 89H, 05H

DD OFFSET DS:mem

16位模式下,你可以把MOV [mem],AX指令写成:

DB 89H, 06H

DW OFFSET
DS:mem

要用AL而不用(E)AX,把88H改成89H即可。

MOV [MEM],累加器 的缺点在PMMX上仍然没被改正。

26.14 TEST指令(PPlain和PMMX)

带立即操作数的TEST指令,只有当目的操作数是AL,AX或EAX时才能配对。

TEST register,register 和 TEST
register,memory总是能配对的。

比如:

TEST ECX,ECX ; 可配对

TEST [mem],EBX ; 可配对

TEST
EDX,256 ; 不可配对

TEST DWORD PTR [EBX],8000H ; 不可配对

用了以下方法的任何一种,都能使它配对:

MOV EAX,[EBX] / TEST
EAX,8000H

MOV EDX,[EBX] / AND EDX,8000H

MOV AL,[EBX+1] / TEST AL,80H

MOV AL,[EBX+1] / TEST AL,AL ; (测试符号位SF)

(导致不能配对的原因可能是该2-字节指令的头一个字节与某条不能配对的指令相同,而当决定是否能配对的时候,处理器无法提供对第二个字节的检查)。

26.15 位扫描(PPlain和PMMX)

在PPlain和PMMX上,BSF和BSR指令的优化是很差的,花大约11+2*n个时钟,n是要跳过的0的个数。

以下代码模拟了BSR ECX,EAX的功能:

TEST EAX,EAX

JZ SHORT BS1

MOV DWORD PTR
[TEMP],EAX

MOV DWORD PTR [TEMP+4],0

FILD QWORD PTR [TEMP]

FSTP
QWORD PTR [TEMP]

WAIT ; WAIT仅仅是为了和老的8087处理器兼容

MOV ECX, DWORD PTR [TEMP+4]

SHR ECX,20 ; 把指数隔离

SUB
ECX,3FFH ; 调整

TEST EAX,EAX ; 清ZF标志

BS1:

以下代码模拟了BSF ECX,EAX的功能:

TEST EAX,EAX

JZ SHORT
BS2

XOR ECX,ECX

MOV
DWORD PTR [TEMP+4],ECX

SUB ECX,EAX

AND EAX,ECX

MOV DWORD PTR
[TEMP],EAX

FILD QWORD PTR [TEMP]

FSTP QWORD PTR [TEMP]

WAIT
; WAIT仅仅是为了和老的8087处理器兼容

MOV ECX, DWORD PTR
[TEMP+4]

SHR ECX,20

SUB
ECX,3FFH

TEST EAX,EAX ; 清ZF标志

BS2:

这些模拟的代码不要在PPro,PII和PIII上用,因为在这些机器上,位扫描指令只用1或2个时钟周期,而上面这些代码有2个部分内存延迟。

26.16 FLDCW (PPro, PII 和 PIII)

在PPro,PII和PIII上,如果在FLDSW指令后面有需要读控制字的浮点指令(几乎所有浮点指令都会读浮点控制字),那么会有严重的延迟。

在编译C/C++代码的时候,如果把浮点数转为整数的截断操作完成的同时其它浮点指令需要进行舍入操作,那么会产生大量FLDCW指令。
在用汇编写代码的时候,可能的话你可以通过使用舍入操作取代截断操作来改进代码,或者在循环内部需要截断操作的时候,把FLDCW指令移到循环外。

见27.5章,如何不改变控制字把浮点数转化为整数。