(此答案是根据问题的第一个版本编写的,其中有一些尚未优化的十六进制打印代码,它是 .exe 程序的完整源代码。问题的更新已删除除了与 8086 无关的 ILP 之外,只有有优化空间的部分,所以我不打算删除答案的那些部分。)
代码大小优化(与 8086 尤其是 8088 的速度相关,请参阅 this retrocomputing answer):
- bin2Gray 部分:没有变化,除非您计算从内存重新加载或使
a 保持不变。只需重新订购 Pentium 及更高版本上 ILP 的说明。或者可能是xlat 的表格。
-
字节->十六进制数字:21 个字节,低于 32 个(代码获取的字节更少)
- 退出:从 4 (mov ah / int) 下降 1 字节 (
ret)。至少适用于 .com 可执行文件,它们也更小。
我可能应该将代码大小计算为需要获取的总字节数(即执行的指令的字节数),而不是静态代码大小,尽管这对于优化也很有用。
从 21 字节 bin2hex 部分中删除循环将花费几个字节的静态代码大小,但将动态字节数减少约 5,IIRC。并避免在采用的分支上丢弃任何预取缓冲区,当然int 10h 除外。
int 20h 也可以退出(无需任何 AH 设置),但只能从 .com 可执行文件中退出。对我来说有趣的部分是用紧凑的代码计算所需寄存器中的 ASCII 数字,但如果你想要一个小的整体程序,.com 是要走的路。这也避免了 DS 的设置。 (尽管如果您将 a 和 EQU 或 = 设为常量,则无需设置 DS。)
未尝试:利用初始寄存器值,这在某些 DOS 版本中显然是可靠的。如果您假装自己正在编写一个可能对更大程序有用的块,那是不可行的。
您的程序基本上有两个独立的部分;计算格雷码,并为寄存器中的 1 字节值计算 bin->hex。单独提取半字节并不能有效地向后优化格雷码计算,所以我认为我们可以将它们完全分开。
有multiple different ways to make a Gray code(在连续值之间只有一位翻转)。 AFAIK,x ^ (x>>1) 是从二进制计算中最便宜的,但在给定寄存器中的输入的情况下,仅用 2 条指令就可以完成某些事情并非不可能。
还相关:Gray code algorithm (32 bit or less) 表示 gray->binary 指出标准 x ^ (x>>1) 是 GF(2k) 中的乘法。所以在最近的带有 Galois-Field 指令的 CPU 上,我认为你可以使用 gf2p8affineqb 一次处理 16 个字节。 (gf2p8mulb 使用固定多项式,我认为这不是我们想要的。)
8088 性能主要是关于内存访问(包括代码获取)
https://www2.math.uni-wuppertal.de/~fpf/Uebungen/GdR-SS02/opcode_i.html 显示指令时序,但这些时序是only execution,而不是代码获取。 8088 有一个 4 字节的预取缓冲区(并且只有一个 8 位数据总线),8086 上有 6 字节的 16 位总线。 Supercat 在那里的答案中建议:
在最初的 8088 处理器上,估计执行速度的最简单方法通常是忽略循环计数,而是计算内存访问次数,包括取指令,然后乘以四。
我认为 8086 的情况大致相同,只是每次访问都可以是一个完整的 2 字节字。所以直线代码(没有分支)一次可以获取 2 个字节。
为简单起见,我只是用表中的指令大小和循环计数对 asm 源进行了注释,而没有尝试对预取缓冲区的行为进行建模。
xlat(如al = ds:[bx+al])只有 1 个字节,如果您不介意拥有 256 字节的表,则值得使用。执行需要 11 个字节,但这包括它进行的数据访问。不计取代码,mov bl,al/shr al,1/xor al,bl 是 2+2+3 个周期,但是 3 个字的代码大小将花费 12 个周期来获取。 xlat 几乎需要这么长的时间,但是当它完成时,预取缓冲区将有一些时间来获取以后的指令,所以我认为它更胜一筹。
不过,它确实要求该表来自某个地方,或者在加载可执行文件时从磁盘中获取,或者您必须预先计算它。而且您需要获得指向 BX 的指针,因此只有在循环中执行此操作才可能是胜利。
但是如果您使用的是表格,您可以结合问题的两个部分并查找两个 ASCII 十六进制数字字符以获得给定的格雷码二进制,例如mov dx, [bx + si] 表指针在 SI 中,二进制字节在 BL 中,BH=0。 (DX 设置您使用 DOS 调用输出 DL。)这当然需要您的表为 256 个字(512 个字节)。拥有一个很小的可执行文件可能比在这里节省几个周期更有价值;屏幕或文件的实际 I/O 可能足够慢,因此无关紧要。但是,如果您对多个字节执行此操作,则将 ASCII 字节对复制到缓冲区中可能会很好。
有一个优化将有助于更现代的 CPU(从 Pentium 开始)可以并行运行多于 1 条指令:复制寄存器,然后移动原始的,这样就可以在同一循环作为副本。
; optimized for Instruction-level Parallelism
;; input: AL output: AL = bin_to_gray(AL)
;; clobbers: DL
mov dl, al ; 2B 2 cycles (not counting code-fetch bottlenecks)
shr al, 1 ; 2B 2c
xor al, dl ; 2B 3c
(有关现代 CPU 的更多信息,请参阅 https://agner.org/optimize/。还有 Can x86's MOV really be "free"? Why can't I reproduce this at all? - mov-elimination 不适用于字节或字寄存器,因为它合并到 EDX 的低部分。所以即使在带有 mov- 的 CPU 上一般来说,它不能应用在这里,所以这种优化可以节省延迟。)
我很确定 bin -> gray 没有进一步改进的余地。即使是现代 x86 也没有复制和右移(除了另一个寄存器中的计数,BMI2 shrx 或带有 AVX 的 SIMD 寄存器,但仅适用于 word/dword/qword 元素大小)。也没有右移和异或,所以没有避免mov,shr 和异或显然也是必要的。 XOR 是无进位加法,但我认为这没有帮助。除非您有无进位乘法 (pclmulqdq) 和一个乘数常数,以便在正确的偏移量处将输入的两个副本获取到乘法结果的高半部分,否则您将需要分别执行这些操作。或者使用 Galois-Field 新指令 (GFNI):What are the AVX-512 Galois-field-related instructions for?
不过,如果您想彻底检查,https://en.wikipedia.org/wiki/Superoptimization - 要求超级优化器寻找与 mov/shr/xor 序列产生相同 AL 结果的序列。
在实际用例中,您通常需要在寄存器中获取数据的代码,因为这就是您应该将数据传递给函数的方式。在mov al, a 之后,这就是你的代码正在做的事情。
但如果它是内存中的全局变量,您可以通过加载两次而不是复制带有mov 的寄存器来节省一个字节的代码大小,但会牺牲速度。 或者甚至更好,将其设为汇编时常数。(虽然如果这样做,下一步是 mov al, a ^ (a>>1) 在汇编时进行计算。)
; a equ 0ACh ; makes the following instructions 2 bytes each
;;; otherwise, with a being static storage, loading from memory twice sucks
mov al, a
shr al, 1 ; 2B, 2 cycles
xor al, a ; reg,imm: 2B, 4 cycles on 8088. reg,mem: 3B, 13+6 cycles
字节 -> 2 个 ASCII 十六进制数字
这是更有趣的部分。
有时只循环 2 次迭代是不值得的,尤其是当您对每一半做单独的事情时可以节省一些工作。 (例如,低半字节是 x & 0xf,高半字节是 x >> 4。使用 rol/mov/ 并不是最优的。)
技巧:
-
首选insn al, imm - x86 有short-form special cases for immediate operands with AL。 (也是 AX,imm16)。
-
想要在 AL 中做一些事情意味着使用BIOS int 10h / AH=0Eh 电传输出打印更有效,该输出在 AL 中输入,并且不会破坏任何其他寄存器。我认为 BIOS 输出会忽略像 foo > outfile.txt 这样的 DOS I/O 重定向并始终打印到屏幕上。
-
有一个邪恶的 hack 滥用 DAS 将 0..15 整数转换为 ASCII 十六进制数字 '0'..'9' 或 'A'..'F' 而不进行分支。在 8086(与现代 x86 不同)上,DAS 与典型的整数指令一样快。请参阅this codegolf.SE answer,详细了解它的工作原理;这是非常不明显的,但它避免了分支,所以它实际上是 8086 的一个很大的加速。
-
BIOS/DOS 调用一般不会修改 AH,所以可以在循环外进行设置。
-
然后对于代码大小,而不是仅仅展开,使用cl=4 作为循环计数器来循环并重新运行一些较早的代码一次(不包括移位)。 sub cl, 2 / jnz 会起作用,但使用奇偶校验标志是一种使用 dec cx (1B) / jpe 向后跳一次,然后在下一次跌倒的方法。
-
DOS 程序(或至少.com 程序)的 SP 指向一些干净退出的代码的地址。所以你可以通过ret退出。
(我没有考虑在保持整体策略的同时改进你的循环。使用AL 尽可能多的指令可能是值得的,但是运行rol 两次而不是移动一次会花费很多周期在 8086 上:8 + 4*n 用于按 CL 移位。)
;; input: byte in AL. output: print 2 ASCII hex digits with BIOS int 10h
;; clobbers: CX, DX
hexprint_byte:
mov ah, 0Eh ; BIOS teletype call #
; push ax ; 1B 15c
mov dx, ax ; 2B 2c ; save number, and AH=call number
mov cl, 4 ; 2B 4c
shr al, cl ; 2B 8+4*4 cycles isolate the high nibble
.loop:
cmp al, 10 ; 2B 4c set CF according to digit <= 9
sbb al, 69h ; 2B 4c read CF, set CF and conditionally set AF
das ; 1B 4c magic, which happens to work
int 10h ; 2B BIOS teletype output (AL), no return value
; pop ax ; 1B 12c ; would do one extra pop if you used this instead of mov/xchg, so you'd need jmp ax instead of ret. But AND destroys AX
xchg ax, dx ; 1B 3c ; retrieve the original again (with AH=0Eh call number)
and al, 0Fh ; 2B 4c ; isolate the low nibble this time
dec cx ; 1B 3c ; PF is set from the low byte only, CH garbage isn't a problem.
jpe .loop ; 2B 4c-not-taken, 16c-taken
; 4-1 = 3, (0b11) which has even parity
; so JPE is taken the first time, falls through the 2nd
;; size = 21 bytes
然后您可以使用ret 或int 20h 退出程序。
这是 NASM 语法;如果您的汇编程序不喜欢.loop,则将其更改为其他内容。 (NASM 不允许 2: 作为本地标签,所以无论如何我都必须选择不同的名称。)我在 Linux 上测试了这个单步执行,以确保循环分支被执行一次,并且我在 AH 中得到了正确的值/AL 当达到int 10h 时。 (我用 NOP 替换它,因为我实际上将它构建到一个 32 位静态可执行文件中,因此我可以轻松地在 GDB 中单步执行它,而不会弄乱过时的 16 位开发设置。字节数来自组装为 16-一点,当然。)
为了速度,复制 cmp/sbb/das/int 10h 只需要多花费几个字节,节省 dec/jpe。 (就像 7 个字节而不是 dec/jpe 的 3 个字节)。无论哪种方式,第一次打印后的 xchg / AND 都是必需的。
采用的分支需要 16 个周期,这样可以避免第二次冗余/无用执行 xchg/and(3 个字节/7 个周期)以及循环开销。
您要求小(在 8086 上速度快),所以这就是我所做的。这牺牲了其他一切,包括可读性,以节省字节。但这就是在汇编中打代码的乐趣!
不幸的是,它也绝对不像您在标题中要求的那样更简单。更简单的可能会使用查找表,也许使用 xlatb。这在 8086 上也可能更快,尤其是如果您想避免 DAS 黑客攻击。
另一个可能有助于代码大小(但对性能非常不利)的技巧是aam 16 设置 AH= 商 = 前导数字,AL = 余数 = 尾随数字(低)。 (注意这与div bl 相反)Displaying Time in Assembly 显示了一个将其与 BIOS int 10h 输出一起用于 2 位 十进制 数字的示例。 (通常 AAM 与立即数 10 一起使用,显然 NEC V20 CPU 会忽略立即数并始终除以 10。英特尔 CPU 只对 AL 进行立即除法)。在 8088/8086 上,AAM 需要 83 个周期,类似于div,这基本上就是它的作用。使用 2 次方的硬件除法通常很糟糕。
使用 AAM 16 的版本有 23 个字节,没有使用任何循环(我在寄存器中没有任何常量可以利用,所以 mov cx, 1 / loop 将是 5 个字节, 而 cmp/sbb/das/int 10h 总共是 7)
比循环版本更慢、更大,但“更简单”
aam 16 ; 83 cycles, 2 bytes AH= quotient = leading digit AL = remainder = trailing digit (low)
; normally never use div or aam by a power of 2, only for code-size over speed.
cmp al, 10 ; 2B 4c set CF according to digit <= 9
sbb al, 69h ; 2B 4c read CF, set CF and conditionally set AF
das ; 1B 4c magic, which happens to work
xchg dx, ax ; 1B 3c stash low digit in DL
mov al, dh ; 2B 2c get leading digit
cmp al, 10 ; 2B 4c
sbb al, 69h ; 2B 4c most significant (high) nibble as ASCII hex
das ; 1B 4c
mov ah, 0Eh ; 2B 3c BIOS teletype output (of AL), advancing cursor
int 10h ; 2B ?
mov al, dl ; 2B 2c ; get low digit back from DL xchg ax, dx breaks AH callnum
int 10h ; 2B
; size=23B
我想知道我是否可以将int 21h / AH=2 与来自 DL 的输入一起用于其中一个输出?这将需要更改 AH,但也许可以为第二个输出完成。不幸的是,DOS 调用在 AL 上进行,将其设置为打印的字符。 (例如,使用这个 int 10h 调用)。
相关: