9

I am trying to recreate the exact C source code from some 16bit DOS 8086 assembly generated by the MS C 5.0 compiler. After making some progress, I've hit a wall with the following code (annotated in IDA):

code:0100                 mov     timerCounter, 0
code:0105 waitForKey:
code:0105                 cmp     timerCounter, 78h    ; this is increased in a timer IRQ service routine elsewhere
code:010A                 jnb     short keyOrTimeout   ; timeout of 78h (120 ticks) exceeded, break out of loop
code:010C                 call    far ptr check_keybuf ; check for keypress
code:0111                 or      ax, ax               ; check for zero return value (faster than cmp reg,imm)
code:0113                 jnz     short continue       ; ax != 0: no keypress, continue spinning
code:0115                 call    far ptr getkey       ; else: fetch key
code:011A                 jmp     short keyOrTimeout   ; ...and break out of loop
code:011C continue:
code:011C                 jmp     short waitForKey     ; try again
code:011E keyOrTimeout:
code:011E                 cmp     timerCounter, 78h    ; out of the loop, check reason (timeout or not)

This seems quite trivial to rewrite into C:

static char volatile timerCounter;
for (timerCounter = 0; timerCounter < timeout;) {
    if (check_keybuf() == 0) {
        getkey();
        break;
    }
    // trying to force the extra jump step, doesn't make a difference
    // else continue;
}
if (timerCounter >= timeout) {
    // ...
}    

However, it generates the following assembly when compiled with the default compiler flags:

00FD  C6060C0400        mov byte [0x40c],0x0
0102  803E0C0478        cmp byte [0x40c],0x78
0107  730E              jnc 0x117             ; break on timeout
0109  9A0E026500        call 0x65:0x20e       ; check_keybuf()
010E  0BC0              or ax,ax
0110  75F0              jnz 0x102             ; continue directly to loop condition above
0112  9A13026500        call 0x65:0x213       ; getkey();
0117  803E0C0478        cmp byte [0x40c],0x78

The problem is that the instruction at 0x110 jumps directly to the loop condition, without the extra step like 0x113 in the original code. No matter how smart I try to be, the compiler optimizes my code into an identical sequence of instructions:

    timerCounter = 0;
waitForKey:
    if (timerCounter < timeout) {
        if (check_keybuf() == 0) {
            getkey();
            goto keyOrTimeout;
        }
        else { // this always gets eliminated
            ;
            (void*)NULL;
            goto continue;
        }
    }
    else goto keyOrTimeout;
continue: // try to force the extra step of an intermediate continue location
    goto waitForKey;
keyOrTimeout:
    if (timerCounter >= timeout) {
        // ...
    }    

This compiles to the exact same code as the for loop above.

The CL compiler supports some optimization options, until now I've used no explicit options which is equivalent to /Ot - "optimize for speed". Other supported options are as follows:

  • /Od - disable optimizations: it generates code where the condtions from the C code are not inverted (e.g >= -> jae), the or ax, ax optimization for the zero check is replaced by the slower cmp ax, 0, and conditional jumps are always followed by unconditional jumps for the else case. After inverting the conditions manually, it looks even worse, so this doesn't look like something I could make match the original output.
  • /Ol, /Ox - loop optimize/full optimizations: it completely messes up the code, moving the conditions to the end and doing a lot of other wild reordering, doesn't look like I could get it to emit what I want either.
  • /Os - optimize for size, /Or - disable inline return, /On - disable unsafe optimizations, /Oa - assume no pointer aliasing: same as the default.

The compiler installation also includes the QuickC compiler, which is a different, pared-down C compiler for non-professional use, which can be selected by using the /qc option to the CL frontend, but the code it generates looks even worse than that from CL /Od.

I've also tried using MS C 5.10 which is a slighly newer release with minor improvements, but the code it generates is exactly the same.

I have no idea how the compiler was made to emit this code, it looks like partly optimized (due to the or ax,ax use) and partly not (because of the intermediate jump that I cannot replicate).

Is there a way to force the compiler to emit this specific code? I'm out of ideas.

22
  • Try adding an empty else block to your if-statement. Apr 6 at 22:21
  • 5
    I've also tried using MS C 5.10 which is a slighly newer release with minor improvements, Unless I'm missing something, you want worse code. Is there an older, lousier compiler? Apr 6 at 22:29
  • 3
    @another-dave: I was able to identify the compiler by this string in the original binary: "MS Run-Time Library - Copyright (c) 1988, Microsoft Corp". This is specific to MSC 5.1, MSC 5.0 leaves "1987" in the executable, I imagine MSC 4.0 would be 1986 or 1985, so it was not the one used. Apr 7 at 9:16
  • 2
    @neuviemeporte Well, you mentioned a lot of things in your question. I would expect that if you can create something that will cause the creation of an empty block it should generate this. Are you certain that this is the compiler used originally? Apr 7 at 9:20
  • 1
    @neuviemeporte: Try wrapping the goto keyOrTimeout; statement in { and } braces. Some people prefer that as a coding style. and with these old compilers, it occasionally makes a difference. Apr 8 at 16:42

2 Answers 2

12

It turns out that in MS C 5.1, in addition to all the /O... options there is one seemingly unrelated one that influences the optimizations for this compiler: /Zi. In the output of CL /help it is described as:

/Zi symbolic debugging information

I have been ignoring it for a long time because the executable does not contain any symbols. However, enabling it and building the source with CL /Gs /Zi /c src.c caused the compiler to generate the exact code I was looking for from a simple for loop:

for (timerCounter = 0; timerCounter < 0x78;) {
    if (check_keybuf() == 0) {
        getkey();
        break;
    }
}

The fine manual offers some explanation to what this option does exactly:

/Zi: Creates object file for use with Microsoft Code View debugger

The /Zi option produces an object file containing full symbolic-debugging information for use with the CodeView debugger. This object file includes full symbol-table information and line numbers. If the /Zi option is given with no explicit /O options, all optimizations involving code motion and rearrangement are suppressed, although simple optimizations are still performed. If any explicit /O options are given, all requested optimizations are performed.

This explains why the code seems partially optimized; it only gets rid of optimizations that could interfere with debugging. Also, there are no symbols in the executable because the symbols were put in the object files instead for the debugger to use.

Anyway, with this option enabled, I'm getting exactly matching opcodes here and elsewhere.

4
  • That was a plot twist. That must have been very frustrating until you discovered it, and a great relief after. Sep 5 at 15:45
  • 2
    @WayneConrad indeed it was, spent 5 months on it and tried every Borland and MS compiler that fit the period. Detailed writeup at neuviemeporte.github.io/f15-se2/2023/09/02/compiler3.html Sep 5 at 19:59
  • That's a nice write-up on a not-nice problem. Good job! BTW, the answer you deleted seems useful to me. Sometimes not-quite-right answers are still very useful to someone with a similar problem. Sep 5 at 20:13
  • 1
    Thank you, also restored the deleted answer. Sep 5 at 20:26
3

TL;DR: It does not appear to be possible with the MS compiler, but I managed to get Borland Turbo C to emit the required code.

Inspired by user JeremyP's suggestion that a different compiler might have been used than what I assumed based on the C runtime copyright string in the game's binary, I did some experiments with both older versions of Microsoft C (4.0 and 3.0), and Turbo C (2.01, 2.0, 1.5 and 1.0). I was able to get the Turbo C compiler to emit the desired instructions with the following code:

timerCounter = 0;
waitForKey:
    if (timerCounter < TIMEOUT) {
        if (check_keybuf() == 0) {
            getkey();
            goto keyOrTimeout;
        }
        goto waitForKey;
    }
keyOrTimeout:
if (timerCounter < TIMEOUT) {
    // ...
}

I compiled this code with version 2.0 of the tcc compiler into object code with the default flags, then linked the binary with the link linker from Microsoft C 5.1. The only extra step was to explicitly give it the appropriate C runtime library (slibce.lib). The following instructions were emitted, which is exactly what I need:

000000FA  C606450200        mov byte [0x245],0x0
000000FF  803E450279        cmp byte [0x245],0x78
00000104  7312              jnc 0x118             ; break
00000106  9AD8011700        call 0x17:0x1d8
0000010B  0BC0              or ax,ax
0000010D  7507              jnz 0x116             ; continue trampoline
0000010F  9ADD011700        call 0x17:0x1dd
00000114  EB02              jmp short 0x118       ; break
00000116  EBE7              jmp short 0xff        ; continue
00000118  803E450278        cmp byte [0x245],0x78

I was unable to get any variant of a loop (for/while/do-while as opposed to a goto-based construct) to do the same thing, the compiler seems to treat every conditional and/or branch as a separate jump target, which leads to code that looks like this:

00000124  C606450200        mov byte [0x245],0x0
00000129  803E45027A        cmp byte [0x245],0x7a
0000012E  7312              jnc 0x142             ; break trampoline
00000130  9AD8011700        call 0x17:0x1d8
00000135  0BC0              or ax,ax
00000137  7507              jnz 0x140             ; continue trampoline 1
00000139  9ADD011700        call 0x17:0x1dd
0000013E  EB06              jmp short 0x146       ; break
00000140  EB02              jmp short 0x144       ; continue trampoline 2
00000142  EB02              jmp short 0x146       ; break
00000144  EBE3              jmp short 0x129       ; continue
00000146  803E450278        cmp byte [0x245],0x78

With the /O option to tcc, which enables loop optimizations, every variant of the loop (either goto-based, or loop-based) is optimized to the following:

000000FA  C606470200        mov byte [0x247],0x0
000000FF  803E470279        cmp byte [0x247],0x79
00000104  730E              jnc 0x114             ; break
00000106  9ADA011600        call 0x16:0x1da
0000010B  0BC0              or ax,ax
0000010D  75F0              jnz 0xff              ; continue
0000010F  9ADF011600        call 0x16:0x1df
00000114  803E470278        cmp byte [0x247],0x78

I am not exactly in a happy place, because switching to Turbo C made stuff diverge elsewhere (in particular, taking parameters off the stack after a function call that is done with add sp,2 in the original binary is done with pop cx instead), but that concludes the question of that loop in my book.

You must log in to answer this question.

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