Episode 3: Structure Your Code

Episode 3: Structure Your Code

In a modern language we'd structure our code into methods and classes and organize it into different files to keep it from becoming overwhelming.  We can do a little of that in ASM.  The instructions CALL and RET support constructs called "procedures" - a labeled block of instructions that can automatically return to where it was called from.  Let's make our int-to-string code into a procedure:

; Converts the signed integer in AX to a string and puts it in [DI]
int_to_string:
  mov bx, 10          ; Load divisor and clear counter
  mov cx, 0
  test ax, ax
  jns .peelOffDigits  ; Is AX signed? If not, go to 'continue'
  mov byte [di], '-'  ; Put a negative sign on the string and increment our pointer
  inc di
  neg ax              ; Negate to make positive
  .peelOffDigits:
    xor dx, dx        ; Zero the high-word of the divisor
    div bx            ; This will give us the remainder in DX
    push dx           ; Push it onto the stack
    inc cl            ; Increment our counter
    test ax, ax       ; If no remainder, we're done
    jnz .peelOffDigits
  .buildString:
    pop ax            ; Pop out the next digit
    add al, '0'       ; Add 48 to get the ascii value
    stosb             ; Store the char in AL into [DI], then increment DI
    loop .buildString
  mov byte [di], '$'  ; Terminate the string
  ret

All we have to do is label it and put a RET at the bottom. Now we can invoke the procedure from anywhere with call int_to_string (after loading up AX and DI of course).

You may notice a bit of extra code near the top: Lines 5-9 allow the code to handle negative values. We test AX, and if it's negative (the 'signed' flag is set), we add a '-' to our buffer then negate the number to make it positive. Now our code handles signed 16-bit values in the range -32768 to 32767.

While we're organizing, let's move this procedure out of the main file altogether and into its own file named formatting.asm. No here's how our test2.asm looks:

[cpu 8086]
[org 100h]

jmp main

%include 'formatting.asm'

main:

mov ax, -1234
mov di, buf16

call int_to_string

mov dx, buf16
mov ah, 9
int 21h

mov ax, 4c00h
int 21h

buf16: times 16 db 0

A lot cleaner, right? We pull in our int_to_string procedure with %include 'formatting.asm'. One thing to note is the jmp main which is now the very first instruction: The %include directive literally inserts the code form formatting.asm at that point in the file, so without the jump that procedure would get executed immediately on program startup. So we'll jump over it, then call it when necessary. The rest of the code is so much nicer now: We load up the number we want to format and a buffer to put it in, call int_to_string to format it, do an INT 21h call to print it out, and do another INT 21h to exit the app.

We can clean the code even further. Printing strings is a common function and we will be using it a lot in the future, so we would to make that call as short as possible. NASM allows macros:

; Prints the given '$'-terminated string.
%macro print 1
  mov dx, %1
  mov ah, 9h
  int 21h
%endmacro

"print" is the name of the macro, "1" is the number of arguments it takes, and "%1" is a substitution for the first argument. So now our

mov dx, buf16
mov ah, 9
int 21h

can be reduced to print buf16! You can put the macro wherever you want; I put mine in a separate file and include it after %include 'formatting.asm'.

I got tired of typing in the commands to build and run the project, so I installed GNU Make for Win32 and created a Makefile:

#
# Makefile for PCjr ASM Game project
#
ifeq ($(OS),Windows_NT)
  NASM="$(USERPROFILE)\AppData\Local\NASM\nasm"
  DOSBOX="$(ProgramFiles)\DOSBox-0.74\dosbox"
  RM=cmd \/C del
else
  NASM=nasm
  RM=rm
  UNAME_S := $(shell uname -s)
  ifeq ($(UNAME_S),Darwin) # Mac OS X
    DOSBOX=/Applications/DOSBox.app/Contents/MacOS/DOSBox
  else # assume Linux
    DOSBOX=DISPLAY=:0 dosbox
  endif
endif

TARGET=test
TARGET.COM=$(TARGET).com

MACROS=stdio.mac
SRCS=formatting.asm
DEPS=$(MACROS) $(SRCS)

NASM_OPTS=-f bin
DOSBOX_OPTS=-conf dosbox.conf

$(TARGET.COM): $(TARGET).asm $(DEPS)
 $(NASM) $(NASM_OPTS) -o $@ $<

run: $(TARGET.COM)
 $(DOSBOX) $(DOSBOX_OPTS) $^

clean:
 $(RM) $(TARGET.COM)

I won't bother to explain it here. If you're unfamiliar with Makefiles there are plenty of good tutorials on the web (like this one). In a nutshell, it allows us to build our executable from the console with make test.com or just make and run it in DOSBox with make run. Our Makefile tracks dependencies; it knows that the executable depends on the .ASM files and the macro files, so if any of those change a rebuild is needed.

Full source for this episode can be downloaded at:
https://github.com/josh2112/pcjr-asm-game/tree/episode-3