Displaying numbers with DOS

It’s true that DOS doesn’t offer us a function to output a number directly.
You’ll have to first convert the number yourself and then have DOS display it
using one of the text output functions.

Displaying the unsigned 16-bit number held in AX

When tackling the problem of converting a number, it helps to see how the
digits that make up a number relate to each other.
Let’s consider the number 65535 and its decomposition:

(6 * 10000) + (5 * 1000) + (5 * 100) + (3 * 10) + (5 * 1)

Method 1 : division by decreasing powers of 10

Processing the number going from the left to the right is convenient because it
allows us to display an individual digit as soon as we’ve extracted it.

  • By dividing the number (65535) by 10000, we obtain a single digit quotient
    (6) that we can output as a character straight away. We also get a remainder
    (5535) that will become the dividend in the next step.

  • By dividing the remainder from the previous step (5535) by 1000, we obtain
    a single digit quotient (5) that we can output as a character straight away.
    We also get a remainder (535) that will become the dividend in the next step.

  • By dividing the remainder from the previous step (535) by 100, we obtain
    a single digit quotient (5) that we can output as a character straight away.
    We also get a remainder (35) that will become the dividend in the next step.

  • By dividing the remainder from the previous step (35) by 10, we obtain
    a single digit quotient (3) that we can output as a character straight away.
    We also get a remainder (5) that will become the dividend in the next step.

  • By dividing the remainder from the previous step (5) by 1, we obtain
    a single digit quotient (5) that we can output as a character straight away.
    Here the remainder will always be 0. (Avoiding this silly division by 1
    requires some extra code)

    mov     bx,.List
.a: xor     dx,dx
    div     word ptr [bx]  ; -> AX=[0,9] is Quotient, Remainder DX
    xchg    ax,dx
    add     dl,"0"         ;Turn into character [0,9] -> ["0","9"]
    push    ax             ;(1)
    mov     ah,02h         ;DOS.DisplayCharacter
    int     21h            ; -> AL
    pop     ax             ;(1) AX is next dividend
    add     bx,2
    cmp     bx,.List+10
    jb      .a
    ...
.List:
    dw      10000,1000,100,10,1

Although this method will of course produce the correct result, it has a few
drawbacks:

  • Consider the smaller number 255 and its decomposition:

    (0 * 10000) + (0 * 1000) + (2 * 100) + (5 * 10) + (5 * 1)
    

    If we were to use the same 5 step process we’d get “00255”. Those 2 leading
    zeroes are undesirable and we would have to include extra instructions to get
    rid of them.

  • The divider changes with each step. We had to store a list of dividers in
    memory. Dynamically calculating these dividers is possible but introduces a
    lot of extra divisions.

  • If we wanted to apply this method to displaying even larger numbers say
    32-bit, and we will want to eventually, the divisions involved would get
    really problematic.

So method 1 is impractical and therefore it is seldom used.

Method 2 : division by const 10

Processing the number going from the right to the left seems counter-intuitive
since our goal is to display the leftmost digit first. But as you’re about to
find out, it works beautifully.

  • By dividing the number (65535) by 10, we obtain a quotient (6553) that will
    become the dividend in the next step. We also get a remainder (5) that we
    can’t output just yet and so we’ll have to save in somewhere. The stack is a
    convenient place to do so.

  • By dividing the quotient from the previous step (6553) by 10, we obtain
    a quotient (655) that will become the dividend in the next step. We also get
    a remainder (3) that we can’t just yet output and so we’ll have to save it
    somewhere. The stack is a convenient place to do so.

  • By dividing the quotient from the previous step (655) by 10, we obtain
    a quotient (65) that will become the dividend in the next step. We also get
    a remainder (5) that we can’t just yet output and so we’ll have to save it
    somewhere. The stack is a convenient place to do so.

  • By dividing the quotient from the previous step (65) by 10, we obtain
    a quotient (6) that will become the dividend in the next step. We also get
    a remainder (5) that we can’t just yet output and so we’ll have to save it
    somewhere. The stack is a convenient place to do so.

  • By dividing the quotient from the previous step (6) by 10, we obtain
    a quotient (0) that signals that this was the last division. We also get
    a remainder (6) that we could output as a character straight away, but
    refraining from doing so turns out to be most effective and so as before we’ll
    save it on the stack.

At this point the stack holds our 5 remainders, each being a single digit
number in the range [0,9]. Since the stack is LIFO (Last In First Out), the
value that we’ll POP first is the first digit we want displayed. We use a
separate loop with 5 POP‘s to display the complete number. But in practice,
since we want this routine to be able to also deal with numbers that have
fewer than 5 digits, we’ll count the digits as they arrive and later do that
many POP‘s.

    mov     bx,10          ;CONST
    xor     cx,cx          ;Reset counter
.a: xor     dx,dx          ;Setup for division DX:AX / BX
    div     bx             ; -> AX is Quotient, Remainder DX=[0,9]
    push    dx             ;(1) Save remainder for now
    inc     cx             ;One more digit
    test    ax,ax          ;Is quotient zero?
    jnz     .a             ;No, use as next dividend
.b: pop     dx             ;(1)
    add     dl,"0"         ;Turn into character [0,9] -> ["0","9"]
    mov     ah,02h         ;DOS.DisplayCharacter
    int     21h            ; -> AL
    loop    .b

This second method has none of the drawbacks of the first method:

  • Because we stop when a quotient becomes zero, there’s never any problem
    with ugly leading zeroes.
  • The divider is fixed. That’s easy enough.
  • It’s real simple to apply this method to displaying larger numbers and
    that’s precisely what comes next.

Displaying the unsigned 32-bit number held in DX:AX

On a cascade of 2 divisions is needed to divide the 32-bit value in
DX:AX by 10.
The 1st division divides the high dividend (extended with 0) yielding a high
quotient. The 2nd division divides the low dividend (extended with the
remainder from the 1st division) yielding the low quotient. It’s the remainder
from the 2nd division that we save on the stack.

To check if the dword in DX:AX is zero, I’ve OR-ed both halves in a scratch
register.

Instead of counting the digits, requiring a register, I chose to put a sentinel
on the stack. Because this sentinel gets a value (10) that no digit can ever
have ([0,9]), it nicely allows to determine when the display loop has to stop.

Other than that this snippet is similar to method 2 above.

    mov     bx,10          ;CONST
    push    bx             ;Sentinel
.a: mov     cx,ax          ;Temporarily store LowDividend in CX
    mov     ax,dx          ;First divide the HighDividend
    xor     dx,dx          ;Setup for division DX:AX / BX
    div     bx             ; -> AX is HighQuotient, Remainder is re-used
    xchg    ax,cx          ;Temporarily move it to CX restoring LowDividend
    div     bx             ; -> AX is LowQuotient, Remainder DX=[0,9]
    push    dx             ;(1) Save remainder for now
    mov     dx,cx          ;Build true 32-bit quotient in DX:AX
    or      cx,ax          ;Is the true 32-bit quotient zero?
    jnz     .a             ;No, use as next dividend
    pop     dx             ;(1a) First pop (Is digit for sure)
.b: add     dl,"0"         ;Turn into character [0,9] -> ["0","9"]
    mov     ah,02h         ;DOS.DisplayCharacter
    int     21h            ; -> AL
    pop     dx             ;(1b) All remaining pops
    cmp     dx,bx          ;Was it the sentinel?
    jb      .b             ;Not yet

Displaying the signed 32-bit number held in DX:AX

The procedure is as follows:

First find out if the signed number is negative by testing the sign bit.
If it is, then negate the number and output a “-” character but beware to not
destroy the number in DX:AX in the process.

The rest of the snippet is the same as for an unsigned number.

    test    dx,dx          ;Sign bit is bit 15 of high word
    jns     .a             ;It's a positive number
    neg     dx             ;\
    neg     ax             ; | Negate DX:AX
    sbb     dx,0           ;/
    push    ax dx          ;(1)
    mov     dl,"-"
    mov     ah,02h         ;DOS.DisplayCharacter
    int     21h            ; -> AL
    pop     dx ax          ;(1)
.a: mov     bx,10          ;CONST
    push    bx             ;Sentinel
.b: mov     cx,ax          ;Temporarily store LowDividend in CX
    mov     ax,dx          ;First divide the HighDividend
    xor     dx,dx          ;Setup for division DX:AX / BX
    div     bx             ; -> AX is HighQuotient, Remainder is re-used
    xchg    ax,cx          ;Temporarily move it to CX restoring LowDividend
    div     bx             ; -> AX is LowQuotient, Remainder DX=[0,9]
    push    dx             ;(2) Save remainder for now
    mov     dx,cx          ;Build true 32-bit quotient in DX:AX
    or      cx,ax          ;Is the true 32-bit quotient zero?
    jnz     .b             ;No, use as next dividend
    pop     dx             ;(2a) First pop (Is digit for sure)
.c: add     dl,"0"         ;Turn into character [0,9] -> ["0","9"]
    mov     ah,02h         ;DOS.DisplayCharacter
    int     21h            ; -> AL
    pop     dx             ;(2b) All remaining pops
    cmp     dx,bx          ;Was it the sentinel?
    jb      .c             ;Not yet

Will I need separate routines for different number sizes?

In a program where you need to display on occasion AL, AX, or DX:AX, you could
just include the 32-bit version and use next little wrappers for the smaller
sizes:

; IN (al) OUT ()
DisplaySignedNumber8:
    push    ax
    cbw                    ;Promote AL to AX
    call    DisplaySignedNumber16
    pop     ax
    ret
; -------------------------
; IN (ax) OUT ()
DisplaySignedNumber16:
    push    dx
    cwd                    ;Promote AX to DX:AX
    call    DisplaySignedNumber32
    pop     dx
    ret
; -------------------------
; IN (dx:ax) OUT ()
DisplaySignedNumber32:
    push    ax bx cx dx
    ...

Alternatively, if you don’t mind the clobbering of the AX and DX registers use
this fall-through solution:

; IN (al) OUT () MOD (ax,dx)
DisplaySignedNumber8:
    cbw
; ---   ---   ---   ---   -
; IN (ax) OUT () MOD (ax,dx)
DisplaySignedNumber16:
    cwd
; ---   ---   ---   ---   -
; IN (dx:ax) OUT () MOD (ax,dx)
DisplaySignedNumber32:
    push    bx cx
    ...

Leave a Comment