14

I have been writing some small asm COM program using Netwide Assembler (nasm), but am having trouble getting it to run on 8088 emulators like PCjs. I could use some help translating the assembly "down" from 386 to 8088-compatible assembly instructions.

Unwittingly I had written the initial code for DOSBox, not realizing it emulates a 386/Pentium. After a few hours, the lightbulb turned on why my code was crashing the 8088 emulators, and I began to use the cpu 8086 line into my asm program so NASM would start outputting code for the 8088. That fixed some opcodes that were invalid. However.

I still have numerous errors like this when I try to run nasm now with cpu 8086 enabled:

error: no instruction for this cpu level

Here are the lines it's erroring on. (These are not together in the code. They are spread out. I have included them without context.)

    SAR AL, 4                   ; shift to be 4 bit number / integer

    SAR AL, 2                   ; shift right by 2 so it is an integer

    IMUL AX, 4                  ; each entry is 4 bytes long so offset = 4*index

    SAR DI, 2                   ; floor(colcursor / 4)

    SAL AL, 6                   ; shift the incoming 2-bit color over by 6

    SAL AL, 4                   ; shift the new 2-bit color to fit 

    SAL AL, 2

    IMUL DI, 80/2           ; see above, offset = rowcursor/2*80 (or *80/2)

Where can I learn more about this? Is there a 'pure 8088' reference for instructions that doesn't include the 80186 / 80286 / 80386 stuff ? (Other than Wikipedia which is kind of confusing me).

I'm guessing for imul I can just substitute with mov + mul, but I don't know what to do if I can't shift by 4 bits.

3
  • 6
    Most of the instructions in your snippet are 80186 instructions. 8088 could only shift by 1 directly or with a counter in CL. It was the 186 that introduced the shift by constant. NEC V20/V30 do implement these instructions it is therefore possible to equip an 8088 class machine with these instructions but requires to swap the cpu. Apr 24, 2023 at 5:50
  • 4
    My instruction set reference based on NASM's does include some new instructions but it also lists which forms are 8086 (and thus also 8088) compatible: pushbx.org/ecm/doc/insref.htm
    – ecm
    Apr 24, 2023 at 7:38
  • thanks. I was confused because the NASM PDF says "NASM has no means of being told what type of processor the code it is generating will be run on"... but apparently it kind of does for 8086?
    – don bright
    Apr 25, 2023 at 22:12

3 Answers 3

24

For sar and sal (and the other shift instructions), you need to use the cl register if you are shifting by more than one bit. So:

mov cl, 4
sar al, cl
1
15

8086/8088 has imul but only in the one-operand widening form, just like mul where it does DX:AX = AX * r/m. Note that's r/m, not imm16, so you will have to mov your constant into a register first (possibly AX).

If you know the result will fit in 16 bits and will ignore DX then it doesn't matter whether you use mul or imul, as they are mathematically equivalent in that case. (But do remember that DX is clobbered!)

For performance, though, keep an eye on the clock counts in the manual: 16-bit mul/imul is over 100 cycles! With a constant multiplier, you may do better with manual shifts and adds. For instance, imul ax, 4 should definitely be replaced by a pair of shl ax, 1. And for imul di, 40 you could do (untested):

mov ax, di
shl di, 1
shl di, 1
add di, ax
shl di, 1
shl di, 1
shl di, 1

Sadly we don't have the 386's fancy addressing modes so the lea edi, [edi+edi*4] trick is unavailable.

2
  • 1
    In an old 8086 assembly book of mine, they even coded an arbitrary 32 bit multiplication without a single (I)MUL instruction. Instead they use a combination of shifts and adds.
    – PMF
    Apr 24, 2023 at 19:28
  • 4
    The Watcom C(++) compiler did the same even when generating code for the 386, probably because a predetermined shift/add/subtract sequence was still faster in many cases. Apr 24, 2023 at 19:32
12

https://pushbx.org/ecm/doc/insref.htm is a corrected + improved version of the appendix of the NASM manual that documents the CPU required for each form of each instruction, along with English descriptions of them. e.g. that shl r/m8, imm8 was new in [186], with [8086] only having shift by 1 or by CL. Current versions of the NASM manual stripped out the English descriptions of instructions as it got too long with new SIMD instructions.


The simple way is to just put the count in cl and use shl al, cl. Or for counts of 2, just repeat shift-by-1 twice. Maybe even for a count of 3, although 3x shl al,1 (2 bytes each) costs more code-size than mov/shl (4 bytes total) so could be slower depending on surrounding instructions. (Variable-count shifts are very slow on 8086, though, so slow that the prefetch buffer will fill up.)

Emulating shl al, 6 has room for being clever, like maybe rotate right by 2 and mask, instead of shifting left by 6. (SAL and SHL are the same instruction. Many people prefer to always write SHL regardless of the signedness of the operand.)

Generally avoid mul and imul for constants. They're very slow on 8086/8088, and a constant can be broken down into its set bits like x*40 = x*32 + x*8 = (x*4 + x)*8, which you do with shifts and adds. Or x*15 = x*16 - x. Never use imul for small power-of-2 constants like 4, that's what shifts are for. (Even if you need to widen the result to 16 or 32-bit, prefer shifts for powers of 2.) If you do use a hardware multiply, use mul r8 unless you need the high half of the result to be signed. imul is slower on 8086.

Shift-and-add is what compilers (like GCC) will do with -m32 -mtune=i386 even when it takes much more than 2 instructions, although you'd need to stop GCC from using 32-bit LEA addressing modes for its shifts and adds if you want it to help you figure out a good sequence of shifts and adds. Perhaps AVR GCC, since AVR also only has 1-bit shifts. For example on Godbolt multiplying by 40 using ((x<<2)+x) << 3. Or by 14 using ((x<<3)-x) << 1. (For 8086, you can decide when it's worthwhile to put a count into cl instead of repeating shift instructions, since that's an option AVR doesn't have.)

Fun fact: 8086 does have an immediate multiply, AAD imm8 which does AX = (AL + AH*imm8) & 0xFF. But it's not fast (60 cycles), and some non-Intel CPUs ignore the immediate and assume it's 10.


If you're optimizing for performance, that's often the same thing as optimizing for code-size on 8088, since code-fetch is the major bottleneck. (1 byte per 4 cycles, half the bus-width of 8086.) 8088's prefetch queue is 4 bytes, vs. 8086's being 6 bytes.

The prefetch queue is discarded on a taken branch (there's no branch prediction), so that's probably part of why they made it shorter on 8088. (But it still takes 4 fetches to fill it, vs. 3 on 8086.)

See also

  • Supercat's point that counting memory accesses (including code fetch) is usually the best way to predict performance on 8088, except around very slow instructions like mul that would let the prefetch buffer fill and leave memory untouched for many cycles.

    This is what makes x86's 1-byte xchg ax, reg useful sometimes instead of mov ax, reg or mov reg, ax, if you just need a value somewhere else and don't need to keep a copy. And optimizing to have values in AX or AL for the short-form encodings like 2-byte add al, imm8. And inc cx is 1 byte, vs. 2 bytes for inc cl, so use 16-bit inc when possible even if you only care about the low 8 bits.

  • https://www2.math.uni-wuppertal.de/~fpf/Uebungen/GdR-SS02/opcode_i.html instruction timings for 8088 through Pentium, but not including code-fetch costs. i.e. cycles when the instruction is already prefetched.

    • mov reg, imm8 - 4 cycles (But it's a 2 byte instruction so it costs 8 cycles of code-fetch.)
    • shl reg, 1 - 2 cycles (code size = 2 bytes)
    • shl reg, cl - 8 + 4*count cycles. (code size 2 bytes)
    • mul r8 - 70 to 77 vs. imul r8 80-98
    • mul r16 - 118-133 vs. imul r16 128-154
    • (So if you don't need the high-half result, use mul not imul. The product bits out to the width of the inputs, not overlapping with any sign or zero-extension bits, is the same for mul and imul.)
  • Increasing Efficiency of binary -> gray code for 8086 on Stack Overflow, putting some of this into practice for the int->hex part of the question.


Shifting by more than half a register

mov cl, 6 and a slow shl al, cl will give the prefetch buffer time to fill (up to 4 bytes) while the shift is running. But the shift costs 8+4*6 = 32 cycles, time for 8 bytes of memory fetches. After refilling the prefetch buffer (4 fetches taking 16 clock cycles), that leaves another 4 fetch cycles wasted. So the prefetch buffer was only able to hide half the cost of the shift.

If you're counting in memory bytes, count that shl al,cl as 2 bytes (to fetch itself) plus 4 bytes of lost/wasted fetch time, assuming later instructions are fast and not a branch, so they consume those 4 bytes of prefetch before more cycles are wasted. So the total cost of mov cl,6 / shl al,cl is equivalent to at least 8 bytes of instruction fetch, if it started with the buffer empty and usefully ended with it full.

; emulate shl al, 6
  and  al, 3             ; 2 bytes, 4 cycles
  ror  al, 1             ; 2 bytes, 2 cycles
  ror  al, 1             ; 2 bytes, 2 cycles

(Or rotate first and end with and al, 0xc0, i.e. (0xff<<6) & 0xff. I don't think this will matter, since even the slower instruction is still as fast as a memory access. Even if this is preceded by slow instructions so it starts with the prefetch buffer full, it will leave the prefetch buffer empty when it finishes. Unless AND-immediate takes more cycles before it frees space in the prefetch buffer, in case it was full when this started decoding.)

This is more actual code size than mov cl,6 / shl al, cl but faster even considering the prefetch buffer: only its actual 6 code-fetch bytes, no lost code-fetch cycles of prefetch-buffer overflow.

I also considered mov ah, al + mov al, 0 / 2x shr ax, 1. Or maybe xchg ah, al if AH had already been zero. But rotate is better if you don't have a use for the x >> 2 value in AH.

The same analysis method applies for mov cl, 40 / mul cl vs. multiple mov / shl / add instructions, but mul cl is so slow you have a lot more room for more instructions while still coming out ahead. (70-16)/4 is 13.5, and that's not counting the 4 bytes for mov+mul themselves. Even moreso for 16-bit multiplies which take a minimum of 118 cycles. (118-16)/4 is 25.5, and you'd need 3-byte mov dx, 40 to set up for it. DX is a good temporary since mul r16 produces a result in DX:AX.)


For 16-bit shifts by 8 or more, start with mov ah, al / mov al, 0 for left shifts.

Xor-zeroing isn't particularly useful for 8-bit registers: same code size unlike for 16-bit registers. mov al, 0 is 4 cycles vs. 3 for xor al,al on 8086, though, so it could matter if the prefetch buffer might overflow due to slow instructions before and after.

But xor ah,ah is slower than mov ah,0 on some modern CPUs (like Intel Haswell and later). Of course, xor ax, ax is better than mov ax, 0, saving a byte of code-size, so always use it for 16-bit registers. (Modern CPUs don't special-case it as a zeroing idiom though, for registers narrower than 32-bit.)

Intel P6 family (Pentium Pro through Nehalem) did handle xor ax,ax as a 16-bit zeroing idiom. uops.info throughput test results for Core 2 show xor r8w,r8w averaging 0.33 cycles per instruction, vs. 1 cycle for xor r8w,r9w (bottlenecked on latency; later tests with multiple independent XORs in the loop show it can run them at 3/clock as well). (Despite Core 2's front-end being 4 uops wide, it still needed a back-end uop to actually write a zero to a register, unlike on Sandbybridge-family where zeroing idioms are eliminated in the issue/rename stage.)

P6 renamed the low 16-bit register separately from the full register. Unlike first-gen Sandybridge which renamed 8-bit (but not 16-bit) partial registers, and later Sandybridge which only renames AH/BH/CH/DH separately from the full registers.

If you want a shifted copy of a register, you might start with xor dx,dx / mov dh, al / shl dx,1 to do dx = ax<<9. In that case, xor-zeroing does indeed work well, at least as good as mov dl, 0 / mov dh, al on most CPUs, and better on P6-family. (Writing DH and then shifting DX still causes a partial-register stall on P6-family CPUs, though.)

If all else is equal in terms of 8086, you might want to consider modern CPUs that might also run your code when choosing instructions.

1
  • thank you very much, that is awesome. this program only targets 8088 MSDOS, nothing else.
    – don bright
    Apr 25, 2023 at 22:13

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .