80x86 Assembler, Part 4

Atrevida Game Programming Tutorial #15
Copyright 1997, Kevin Matz, All Rights Reserved.

"Prerequisites:"

  • Chapter 14: 80x86 Assembler, Part 3

       
In this chapter, we'll learn how to write and call procedures (equivalent to functions or procedures or subroutines in other languages). We'll learn how to pass parameters to, and receive return values from, these procedures. We'll also learn how to use local variables in procedures.

Procedures

Procedures are very similar to functions in C. Let's convert this simple C function to an assembler procedure:

void DoSomething ()
{
    _AX += 3;           /* Add 3 to register AX */
    return;
}

(Okay, the return is optional for a void function, but in assembler the equivalent to the return is necessary.)

To call the function in a C program, we would say:

DoSomething ();

In (Ideal-mode) assembler, we would start the procedure definition with "PROC DoSomething", and we would end the procedure definition with "ENDP DoSomething". In between these two lines we put the assembler instructions that we want to make up the procedure. The last instruction in the procedure should be "RET", which returns execution to the code that called the procedure. (You can have more than one RET instruction in a subroutine, just as you can have multiple return's in a C function. Whenever a RET is encountered, the subroutine is ended and execution returns to the caller.)

Here's the equivalent to the above C function:

PROC DoSomething
    ADD AX, 3                          ; Add 3 to register AX
    RET
ENDP DoSomething

And to call this procedure, we would say:

    CALL DoSomething                   ; Call procedure DoSomething

Try out the following example program, which uses the DoSomething procedure:

------- TEST7.ASM begins -------

%TITLE "Assembler Test Program #7 -- Using simple procedures"

    IDEAL

    MODEL small
    STACK 256

    DATASEG

; No variables

    CODESEG

Start:
    ; Uncomment these lines if variables are added:
    ; MOV AX, @data
    ; MOV DS, AX

    MOV AX, 5                          ; Put some initial value in AX

    CALL DoSomething                   ; Call the DoSomething procedure

    ; Terminate program:
    MOV AX, 04C00h
    INT 21h

; ---------------------------------------------------------------------
; DoSomething
; ---------------------------------------------------------------------
; Desc: Adds 3 to AX
;  Pre: (none)
; Post: 3 has been added to AX.  No other registers or flags modified.
; ---------------------------------------------------------------------
PROC DoSomething
   ADD AX, 3                           ; Add 3 to register AX
   RET                                 ; Return to caller
ENDP DoSomething
; ---------------------------------------------------------------------

END Start

------- TEST7.ASM ends -------

If you have a debugger, you can use it to watch the subroutine being called and AX being modified. (In Turbo Debugger, use F7 to trace through a subroutine; if you use F8, the subroutine gets called, but you don't get to step through it instruction by instruction.) If you don't have a debugger, you might want to replace the "ADD AX, 3" instruction with some instructions that write something to the screen. If you put "checkpoints" throughout the program (eg. use interrupt calls to write out an "A" before the CALL instruction, a "B" within the procedure, and a "C" after the CALL instruction), then you can examine the program's output to see if it ran the way you expected it to.

You'll notice that I put some comments above the procedure. It's always a good idea to write in some notes briefly describing what the procedure does, what inputs or intial conditions it requires, if any, and what outputs the procedure produces, if any. If the procedure modifies any registers or flags, perhaps to pass a value back to the calling routine, that should be noted. You don't have to use the commenting "style" I used in the sample (the "Pre" and "Post" I stole from a mediocre Pascal book).

One more note: be sure to put your "terminate program" code (INT 21h Service 4Ch) before any procedures. If you don't, execution won't stop upon reaching a procedure definition -- execution will continue right into the procedure. What happens when execution reaches the RET instruction? Well, we'll see later, when we find out how the stack is used when procedures are called.

Saving and restoring registers

Your can modify any registers you like within a procedure.

If your procedure uses any registers, it's always courteous to PUSH and POP them, because the part of the program that is calling your procedure might depend on those registers:

PROC DoAnotherThing
    ; Save affected registers:
    PUSH AX
    PUSH BX
    PUSH DI

    ; Do something here that uses AX, BX, and DI...

    ; Restore affected registers:
    POP DI
    POP BX
    POP AX

    RET
ENDP DoAnotherThing

Of course, if you're trying to return values in certain registers (like AX in the DoSomething procedure), you don't want to PUSH or POP those registers.

But what if the calling routine doesn't care about certain registers? Then we're wasting time pushing and popping values that don't need to be saved. Normally this isn't a big deal. PUSH and POP are pretty fast, especially when registers are used (it takes slightly longer when memory locations are used instead). But if we are in a desperate situation where we need more speed, you could take all the PUSH and POP instructions out of a procedure. Then, when you call the procedure, you PUSH only those registers that need to be saved, then you CALL the procedure, and then you restore those registers with POP. It makes your code messier, but because you're eliminating unneeded steps, it makes the code slightly more efficient. (Just make sure the comments above the procedure are accurate!)

Saving and restoring flags

If you want to save and restore the FLAGS register, you can do so using two instructions: PUSHF, which will save the flags on the stack, and POPF, which will pop them off and restore them. For example:

     ; Save registers and flags:
     PUSH AX
     PUSH CX
     PUSHF

     ; Modify the registers and flags here.
     ; ...
     
     ; Restore registers and flags:
     POPF
     POP CX
     POP AX

It usually doesn't hurt (except for a tiny time penalty) to save the flags when saving and restoring registers in a procedure. After all, the main program might depend on some flag for later decision-making.

Using global variables in procedures

You have access to any variables in the DATASEG data segment (or in the code segment or elsewhere, provided you use the right segment overrides). These are global variables -- they're like variables in C that are declared outside of any functions.

Sometimes this is what you want. Often, however, you want variables that are local to your procedure, so that modifying them would not affect any other parts of the program. And frequently you'll want to pass parameters to your procedures. To get these features, we'll need to use the stack.

Near and far procedures and the stack

There are actually two types of procedures: near and far.

Near procedures exist in the same code segment as the "main program" where the procedure is to be called from. For such "near calls":

When a CALL instruction is encountered during execution, the offset address (the IP part of the CS:IP address) of the next instruction is pushed onto the stack. (The CS part does not need to be saved as it does not change.) IP is then loaded with the address of the first instruction in the procedure. (CS remains the same.) Execution continues until the RET instruction is reached. When the RET is executed, the value on the top of the stack is popped back into IP. Execution then continues with this new CS:IP address, which is the address of the instruction following the CALL instruction.

Far procedures exist in code segments different from the main code segment (CODESEG) where the procedures are called from. For such "far calls":

When the CALL instruction is encountered, the IP part of the address of the next instruction is pushed on the stack, and then the CS part of that address is pushed on the stack. CS:IP is loaded with the address of the first instruction in the procedure. The subroutine executes. Upon encountering the RET instruction, the value on the top of the stack is popped back into IP, and then another value is popped from the stack and placed back into CS.

Actually, far procedures may often be placed in the CODESEG code segment by the assembler. We really don't have any reason to care where in memory they are placed. But no matter where far procedures are placed, both the CS and IP registers are pushed and popped, not just IP.

To declare a near procedure, add "NEAR" to the procedure definition like this:

PROC MyNearProcedure NEAR
    .
    .
    .
ENDP MyNearProcedure

In both near and far procedures, we may use RET to return to the caller. There are actually two types of return instructions, Return Far, or "RETF", and Return Near, or "RETN". In a near procedure, you can use RETN, or you can use RET. The RET is automatically assembled as an RETN instruction by the assembler. It is safe to use RET for both near and far procedures; the assembler will choose the correct return instruction for you.

To declare a far procedure, add "FAR" to the procedure definition, like this:

PROC MyFarProcedure FAR
    .
    .
    .
ENDP MyFarProcedure

You can end a far procedure with either RET or RETF. The RET will be interpreted as a RETF by the assembler. Again, it's convenient just to use RET for both near and far procedures; the assembler will choose the appropriate return instruction.

To call a far procedure, you must use "CALL FAR" instead of "CALL". If you forget, the assembler should give an error.

    CALL FAR MyFarProcedure

(Near procedures can be called with just a simple "CALL".)

Most of the time we don't care whether procedures are near or far. If you leave out the NEAR or FAR in the procedure definition, as in the procedure in the example program TEST7.ASM, the assembler will choose one of the two for you, based on the memory model. In the small memory model, procedures will be defaulted to near. But this is not necessarily the case in other memory models (this table is adapted from the TASM manual; other assemblers might have different memory model names or configurations):

Memory Model     Default Procedure Type
-----------------------------------------
tiny             near
small            near
medium           far
compact          near
large            far
huge             far

So, for medium, large, and huge memory models, be prepared to use "CALL FAR" for procedures without a NEAR or FAR "override". (Actually, if you don't want to bother with this "CALL FAR" business, there is an alternative: use the "-m" command-line switch with TASM. This forces TASM to operate as a two-pass assembler -- it reads through your source file twice. When it operates as the default one-pass assembler, and it reaches a CALL instruction and it has not yet come across the procedure in question, it does not know if that procedure is near or far, so it generates code for the default procedure type. When it reads into the procedure in question, it may discover that its default "guess" was wrong, and error messages are generated. If it operates in the two-pass mode, it can determine the types of each procedure in the first pass, and then write the correct code in the second pass. (In case you were wondering.) Summary: using "-m" lets you omit the "FAR" in "CALL FAR".)

A quick demonstration of near and far procedures

Here is a short program that briefly demonstrates near and far procedures:

------- TEST8.ASM begins -------

%TITLE "Assembler Test Program #8 -- Using near and far procedures"

    IDEAL

    MODEL small
    STACK 256

    DATASEG

NearMessage                       DB   "Near procedure", 13, 10, "$"
FarMessage                        DB   "Far procedure", 13, 10, "$"

    CODESEG

Start:
    ; Make the DATASEG variables addressable:
    MOV AX, @data
    MOV DS, AX

    CALL DisplayNearMessage
    CALL DisplayNearMessage
    CALL DisplayNearMessage

    CALL FAR DisplayFarMessage
    CALL FAR DisplayFarMessage
    CALL FAR DisplayFarMessage
    
    ; Terminate program:
EndProgram:
    MOV AX, 04C00h
    INT 21h

; -------------------------------------------------------------------
; DisplayNearMessage
; -------------------------------------------------------------------
; Desc: Displays a message on the screen.  This is a NEAR procedure.
;  Pre: Ensure DS points to the start of DATASEG (@data).
; Post: A string is printed.  No registers or flags are modified.
; -------------------------------------------------------------------
PROC DisplayNearMessage NEAR
    ; Save affected registers:
    PUSH AX
    PUSH DX

    ; Use the DOS String Print service (INT 21h, Service 9h):
    MOV AH, 9
    MOV DX, OFFSET NearMessage
    ; (DS already points to the start of the DATASEG data segment)
    INT 21h

    ; Restore affected registers:
    POP DX
    POP AX

    RETN                               ; RETN = Near Return.  RET is
                                       ;  okay too; TASM will
                                       ;  assemble the RET as if it
                                       ;  were a RETN
ENDP DisplayNearMessage
; -------------------------------------------------------------------


; -------------------------------------------------------------------
; DisplayFarMessage
; -------------------------------------------------------------------
; Desc: Displays a message on the screen.  This is a FAR procedure.
;  Pre: Ensure DS points to the start of DATASEG (@data).
; Post: A string is printed.  No registers or flags are modified.
; -------------------------------------------------------------------
PROC DisplayFarMessage FAR
    ; Save affected registers:
    PUSH AX
    PUSH DX

    ; Use the DOS String Print service (INT 21h, Service 9h):
    MOV AH, 9
    MOV DX, OFFSET FarMessage
    ; (DS already points to the start of the DATASEG data segment)
    INT 21h

    ; Restore affected registers:
    POP DX
    POP AX

    RETF                               ; RETF = Near Return.  RET is
                                       ;  okay too; TASM will
                                       ;  assemble the RET as if it
                                       ;  were a RETF
ENDP DisplayFarMessage
; -------------------------------------------------------------------

END Start

------- TEST8.ASM ends -------

Just a note: I didn't use PUSHF and POPF because none of the instructions in the two procedures modify the flags. INT 21h Service 9 doesn't modify the flags (although some interrupt services do use flags to send status or error signals back to your program).

Passing parameters using the stack -- the "simple" method

Here's one method for passing parameters using the stack: push your parameters onto the stack. Call your procedure. Within the procedure, as you need the parameters, pop them off the stack into registers or variables. Then return to the calling code.

There's just one little problem though. Let's find out what it is.

Let's say we have a procedure which takes two parameters. It expects you to push one word-sized argument onto the stack, and then to push another word-sized argument onto the stack. Let's push 1234 hex and ABCD hex onto the stack:

    PUSH 01234h
    PUSH 0ABCDh

And so our stack looks like this:

       One word     One byte
      |<------->|   |<-->|
                                               Current
   SS:0000                                      SS:SP
      |                                           |
     \|/                                         \|/
      *----+----*...*----+----*----+----*----+----*----+----*----+----*...
      |    |    |   |    |    |    |    |    |    | CD | AB | 34 | 12 |
Off-  *----+----*...*----+----*----+----*----+----*----+----*----+----*...
sets:  0000 0001     n    n+1  n+2  n+3  n+4  n+5  n+6  n+7  n+8  n+9

                    -------------------------------->         Bottom of
                          Increasing addresses                stack is
                                                             somewhere to
                    <--------------------------------         the right
                     Stack grows downward (this way)           ------>

Now, we'll call our procedure:

    CALL ProcedureThatAcceptsParameters

What does CALL do? Well, let's assume ProceduceThatAcceptsParameters is a near procedure. CALL will push the contents of IP on the stack. Let's say IP happens to be 5678 hex. Then our stack will be:

       One word     One byte
      |<------->|   |<-->|
                                     Current
   SS:0000                            SS:SP
      |                                 |
     \|/                               \|/
      *----+----*...*----+----*----+----*----+----*----+----*----+----*...
      |    |    |   |    |    |    |    | 78 | 56 | CD | AB | 34 | 12 |
Off-  *----+----*...*----+----*----+----*----+----*----+----*----+----*...
sets:  0000 0001     n    n+1  n+2  n+3  n+4  n+5  n+6  n+7  n+8  n+9

                    -------------------------------->         Bottom of
                          Increasing addresses                stack is
                                                             somewhere to
                    <--------------------------------         the right
                     Stack grows downward (this way)           ------>

Okay, now that the CALL has been executed, we're inside the procedure. We want to get our parameters now, so let's pop off the word at the top of the stack... Oops, there goes our return address! Then let's get the other parameter. We pop off the next word and we get the ABCD hex parameter. Then our stack would incorrectly be:

       One word     One byte
      |<------->|   |<-->|
                                                         Current
   SS:0000                                                SS:SP
      |                                                     |
     \|/                                                   \|/
      *----+----*...*----+----*----+----*----+----*----+----*----+----*...
      |    |    |   |    |    |    |    | 78 | 56 | CD | AB | 34 | 12 |
Off-  *----+----*...*----+----*----+----*----+----*----+----*----+----*...
sets:  0000 0001     n    n+1  n+2  n+3  n+4  n+5  n+6  n+7  n+8  n+9

                    -------------------------------->         Bottom of
                          Increasing addresses                stack is
                                                             somewhere to
                    <--------------------------------         the right
                     Stack grows downward (this way)           ------>

And when the procedure's RET gets executed, it will pop off the next word from the top of the stack and put it into IP. So, before the call, we had a CS:IP of CS:5678, and after the call, execution will re-start at CS:1234. Not good at all. A crash is pretty much guaranteed.

The solution, then, is to pop off the return address and save it somewhere. You can't save it by pushing it on the stack, of course, but you can save it in some unused register, or perhaps in a variable if all of the registers are being used.

So, if our old procedure was...

PROC ProcedureThatAcceptsParameters         ; Does not work!
    ; Add the parameters and store the result in AX:
    POP AX
    POP BX
    ADD AX, BX

    RET
ENDP ProcedureThatAcceptsParameters

...then we could correct it to...

PROC ProcedureThatAcceptsParameters
    POP DX                             ; Save the return address in DX

    ; Add the parameters and store the result in AX:
    POP AX
    POP BX
    ADD AX, BX

    PUSH DX                            ; Put the return address back on
                                       ;  the stack
    RET
ENDP ProcedureThatAcceptsParameters

...assuming that DX was not being used for anything. (These procedures also modify AX and BX without saving and restoring them.)

That was for a near procedure. For far procedures, the only difference is that a CALL will push IP and then CS on the stack, so we would need to pop both words off of the stack and save them. This time, let's assume we declared global variables called SaveIP and SaveCS:

PROC ProcedureThatAcceptsParameters FAR
    ; Save the return address:
    POP [SaveCS]
    POP [SaveIP]

    ; Add the parameters and store the result in AX:
    POP AX
    POP BX
    ADD AX, BX

    ; Put the return address back on the stack:
    PUSH [SaveIP]
    PUSH [SaveCS]

    RET
ENDP ProcedureThatAcceptsParameters

It's not terribly difficult. It's just inconvenient. We'll see a more refined version of this parameter-passing method soon, but before we can use it, we must find out how the BP register works.

The BP register

I've been silent up until now on the role of the BP register. We haven't actually used it for anything yet. BP stands for "base pointer", and it points to a particular address on the stack. If we use the BP register correctly, we can use parameters (and local variables) on the stack without actually popping them off the stack!

For now, let's assume we're using near procedures. If we have some procedure that takes two word-sized parameters, 1234 hex as the first to be pushed on the stack and ABCD hex as the second, and we execute the CALL to the procedure, the stack will look like this when the procedure starts executing:

       One word     One byte
      |<------->|   |<-->|
                                SS:SP upon entry of
   SS:0000                         a procedure
      |                                 |
     \|/                               \|/
      *----+----*...*----+----*----+----*----+----*----+----*----+----*...
      |    |    |   |    |    |    |    |(old IP) | CD | AB | 34 | 12 |
Off-  *----+----*...*----+----*----+----*----+----*----+----*----+----*...
sets:  0000 0001     n    n+1  n+2  n+3  n+4  n+5  n+6  n+7  n+8  n+9

                    -------------------------------->         Bottom of
                          Increasing addresses                stack is
                                                             somewhere to
                    <--------------------------------         the right
                     Stack grows downward (this way)           ------>

Now, notice how the parameters are sitting in memory on the stack. Do you remember the Base + Displacement (or Base + Index + Displacement) addressing modes from Part 2 (Chapter 13)? We could access the second parameter (the ABCD hex parameter) by using [SP + 2], as in "MOV AX, [SP + 2]", couldn't we? Well, the idea is right, but actually we can't use SP for two reasons:

  1. The Base + Displacement and Base + Index + Displacement modes don't work with SP!
  2. SP changes every time we push or pop values on and off the stack. "MOV AX, [SP + 2]" would access the ABCD hex parameter at first, but if we pushed one word onto the stack, then that same "MOV AX, [SP + 2]" would reference the word immediately before the ABCD hex parameter, which would actually be the old IP value, the return address.

Hmmm, perhaps we could make a copy of the initial value of SP. What registers does the Base + Displacement mode allow? BX and BP. Because we want to leave BX available for other things, we can use BP:

MOV BP, SP                             ; Make a copy of SP and store it
                                       ;  in BP

There is always the possibility that the calling code is using BP, so we should save a copy of it first. So, an outline of a procedure that uses BP for stack addressing would be:

PROC MyProcedure
    PUSH BP                            ; Save BP
    MOV BP, SP                         ; BP = SP, for stack addressing

    ; Do the procedure's work here

    POP BP                             ; Restore BP
    RET
ENDP MyProcedure

The fact that BP is pushed on first changes slightly the layout of the contents of the stack. We now get:

       One word     One byte
      |<------->|   |<-->|
                           Both SP  (Initial SP)
   SS:0000                  and BP      .
      |                       |         .
     \|/                     \|/        .
      *----+----*...*----+----*----+----*----+----*----+----*----+----*...
      |    |    |   |    |    |(old BP) |(old IP) | CD | AB | 34 | 12 |
Off-  *----+----*...*----+----*----+----*----+----*----+----*----+----*...
sets:  0000 0001     n    n+1  n+2  n+3  n+4  n+5  n+6  n+7  n+8  n+9

                    -------------------------------->         Bottom of
                          Increasing addresses                stack is
                                                             somewhere to
                    <--------------------------------         the right
                     Stack grows downward (this way)           ------>

Now, whenever we want to access the second parameter (the ABCD hex parameter), we can use [BP + 4], as in "MOV AX, [BP + 4]" or "MOV WORD [BP + 4], 34". If want to access the first parameter (the 1234 hex parameter), we can use [BP + 6], as in "MOV AX, [BP + 6]" or "MOV WORD [BP + 6], 56", etc.

That was for near procedures. When you're using far procedures, the only difference is that IP and CS exist on the stack, not just IP. The parameters being equal, if our procedure was a far procedure, the stack would look like:

       One word     One byte
      |<------->|   |<-->|
                 Both SP  (Initial SP)
   SS:0000        and BP      .
      |             |         .
     \|/           \|/        .
      *----+----*...*----+----*----+----*----+----*----+----*----+----*...
      |    |    |   |(old BP) |(old CS) |(old IP) | CD | AB | 34 | 12 |
Off-  *----+----*...*----+----*----+----*----+----*----+----*----+----*...
sets:  0000 0001     n    n+1  n+2  n+3  n+4  n+5  n+6  n+7  n+8  n+9

                    -------------------------------->         Bottom of
                          Increasing addresses                stack is
                                                             somewhere to
                    <--------------------------------         the right
                     Stack grows downward (this way)           ------>

What would the displacement need to be to access the ABCD hex parameter? Well, count from the BP pointer. It's 6, so you would use [BP + 6], as in "MOV AX, [BP + 6]". And the 1234 hex parameter would be [BP + 8], as in "MOV AX, [BP + 8]". So with far procedures, the offsets are always two greater than they would be with near procedures, because of the extra word (the CS part of the return address) sitting on the stack.

If you ask me, something like [BP + 6] is not particularly descriptive. But that's what we'd use to address a parameter. Wouldn't it be nice to give names to these parameters? Actually, we can...

Passing parameters using the stack -- the "fancy" method

Turbo Assembler has a useful feature, the ARG directive, that lets us use descriptive variable names to refer to parameters sitting on the stack.

Let's look at an example:

PROC MyProcedure
    ARG SecondParameter:WORD, FirstParameter:WORD = BytesUsed

    PUSH BP                            ; Save BP
    MOV BP, SP

    ; Do the procedure's work here
    ; ...

    POP BP                             ; Restore BP
    RET BytesUsed
ENDP MyProcedure

Let's continue using our two example parameters, where the first to be pushed on the stack is 1234 hex, and the second to be pushed on the stack is ABCD hex.

If we were to write this instruction:

    MOV AX, [SecondParameter]

Then the assembler would convert it to...

    MOV AX, [BP + 4]

...assuming we were using a near procedure. The result would be that ABCD hex, the second parameter to be pushed on the stack, would be copied to AX. If we were using a far procedure, then the same instruction would be replaced with:

    MOV AX, [BP + 6]

Likewise, "MOV AX, [FirstParameter]" would be converted to "MOV AX, [BP + 6]" in the case of a near procedure, and "MOV AX, [BP + 8]" in the case of a far procedure. When we use ARG, we no longer have to worry about whether the procedure is near or far and then adding appropriate displacements to BP.

These "variables" are considered global in scope. But normally, in high-level languages, we expect them to be local. TASM allows us to use the "@@" prefix on variable names if we first use the LOCALS directive. This can go at the top of the program, near the MODEL and STACK directives. Then, if we specify variable names within a procedure, they are local to that procedure, and they can be used elsewhere without conflict.

.
.
.
    LOCALS
.
.
.

PROC MyProcedure
    ARG @@SecondParameter:WORD, @@FirstParameter:WORD = @@BytesUsed

    PUSH BP                            ; Save BP
    MOV BP, SP

    ; Do the procedure's work here
    .
    .
    .
    MOV AX, [@@FirstParameter]
    .
    .
    .
    MOV AX, [@@SecondParameter]
    .
    .
    .
    
    POP BP                             ; Restore BP
    RET @@BytesUsed
ENDP MyProcedure

What do the "= BytesUsed" or "= @@BytesUsed" do? Well, if you put an equals sign and a variable name at the end of the ARG line, TASM will count the total number of bytes used by all of the parameters listed and store that value in the variable (BytesUsed or @@BytesUsed, in this case). Why is this necessary? Check out the RET instructions. When RET is given a value as an operand, it adds the number to SP after returning. We're no longer popping the parameters off the stack, so we need some way to restore the stack to its previous state, and this is the way we do it. (We can't add a value from SP before the return, because then we'd be preventing the return address from being popped off the stack.)

Note also the usage of :WORD in the ARG directive. :WORD is the most common option, although :BYTE and :DWORD are permitted too. If you use :BYTE, you still need to push a word-sized parameter onto the stack (recall, only words can be pushed onto the stack), but ARG will treat that parameter as a byte, using only the least-significant byte of the word.

An example program using the ARG directive for parameter passing

Here's a quick program that demonstates the use of ARG for parameter passing:

------- TEST9.ASM begins -------

%TITLE "Assembler Test Program #9 -- Passing parameters using ARG"

    IDEAL

    MODEL small
    STACK 256
    LOCALS

    DATASEG

; No (global) variables used

    CODESEG
Start:
    ; Initialize DS to DATASEG:
    MOV AX, @data
    MOV DS, AX

    ; Add two values using the AddValues procedure:
    PUSH 1000h
    PUSH 2000h
    CALL AddValues

    NOP                                ; If you're using a debugger, this
                                       ;  gives you a chance to look at
                                       ;  the registers and flags

    ; Add two more values:
    PUSH 0AAAAh
    PUSH 0BBBBh
    CALL AddValues

    NOP                                ; (same)

    ; Terminate program:
EndProgram:
    MOV AX, 04C00h
    INT 21h

; ---------------------------------------------------------------------
; AddValues
; ---------------------------------------------------------------------
; Desc: Adds two word-sized values, placing the value in AX.
;  Pre: Push the first word-sized parameter on the stack, then push
;       the second word-sized parameter on the stack, then CALL this
;       procedure.
; Post: The sum is returned in AX.  Wrap-around is not considered;
;       check the carry flag (CF) to determine if a carry occurred.
; ---------------------------------------------------------------------
PROC AddValues NEAR
    ARG @@SecondParameter:WORD, @@FirstParameter:WORD = @@ArgStackBytes

    ; Allow the parameters to be addressed:
    PUSH BP
    MOV BP, SP

    ; Do the work (adding the two parameters):
    MOV AX, [@@FirstParameter]
    ADD AX, [@@SecondParameter]
    
    ; Restore BP:
    POP BP

    RET @@ArgStackBytes                ; Clean up stack and return
ENDP AddValues
; ---------------------------------------------------------------------

END Start

------- TEST9.ASM ends -------

(Hopefully, in a real program, one wouldn't use a procedure to do something as simple and fast as adding two numbers.)

I mentioned the carry flag, CF. If two numbers are added and an overflow occurs (ie. if two bytes are added and the result exceeds 255, or two words are added and the result exceeds 65535), then the carry flag is set to 1; otherwise, CF is cleared. You can test the CF flag with the JC and JNC conditional-jump instructions.

Sometimes you might want a procedure to signal whether or not an error occurred. It is common for programmers to use the carry flag as an error indicator. If you use the STC (set carry flag) instruction...

    STC

...the carry flag will be set to 1. If you use the CLC (clear carry flag) instruction...

    CLC

...the carry flag will be cleared. If your procedure needs to signal an error, you might set the carry flag, and if everything works correctly, you might clear the carry flag (or vice versa, according to your tastes). Then you can use JC or JNC in your main program to jump to an error-handing routine, perhaps to display a message on the screen and quit.

If you're going to use the carry flag to signal errors or status codes, remember not to use the PUSHF and POPF instructions in that procedure!

The NOP instructions in the program are unnecessary, of course. When I'm using the debugger, sometimes I hit the trace or step keys a bit too quickly when stepping through familiar sections of code. I put the NOP instructions in just so I know where to stop and inspect the registers and flags.

Clock cycles -- an aside

Unnecessary instructions such as NOP's should normally be removed after you've finished debugging. They do take up one byte and they do take a tiny amount of time to execute. How much time? If you've looked through an instruction set reference, you've probably seen clock cycle timings. (Not all instruction set references have this information. Tom Swan's "Mastering Turbo Assembler, Second Edition", for example, leaves out timing information.) You can compare the relative performance of different instructions by comparing the number of clock cycles they take to execute. NOP takes three clock cycles to execute on 386 and older processors, and only one clock cycle to execute on the 486 and higher processors. CLC takes two clock cycles on all of the processors. Slower instructions include the notorious MUL and DIV, which we'll encounter later. On the 486, multiplying AL with another byte-sized register takes 13 clock cycles -- not bad, actually. Multiplying AX with a word-sized memory operand takes 26 cycles. On the 8088 and 8086, the former could take up to 77 clock cycles to execute, and the latter could take 139 or more clock cycles -- ouch, especially since clock cycles take longer on slower processors!

Exactly how long is a clock cycle? It depends on the speed of the processor. I believe there is a formula for determining the clock cycle duration, but the few assembler and hardware-oriented books I have checked are remarkably silent on this issue. (But no, "100 MHz" doesn't mean 100 million clock cycles per second.)

We should be very careful when trying to use clock cycles to determine the performance of a piece of assembler code. The clock cycle figures listed in references are normally Intel's best-case figures. And there are many factors that can make clock cycle counting very unreliable -- the processor's instruction prefetch cache, word-alignment in memory (words that start at even addresses can be accessed slightly faster than words that start at odd addresses), "background" timing interrupts (your programs actually get interrupted roughly 18 times per second, but that's another topic entirely), and so on. In his book "Zen of Assembly Language", Michael Abrash calls these factors "cycle eaters". If you try to determine the performance of a portion of code by adding up the clock cycles for all of the instructions, realize that the cycle eaters will make that sum a very rough estimate. But it doesn't hurt to look up clock cycles in many cases: it's helpful to know that shift instructions only take between two and four cycles on 486 and higher processors, compared to the longer multiply and divide instructions.

Back to procedures...

Local variables, using the LOCAL directive

In high-level languages, it's very convenient to be able to use local variables in functions/procedures/subroutines. We can create temporary local variables in assembler functions by reserving space on the stack.

When we passed parameters to procedures and we used ARG to let us reference them easily, the parameters sat happily on the stack and were never popped off (until the procedure ended, and the RET instruction modified the SP register). We'll do a very similar thing with local variables, and we'll use a directive called LOCAL (not to be confused with the directive "LOCALS") to let us reference them with variable names.

The following diagram will give you an impression of the layout of items on the stack. I put both IP and CS on the stack, so this must be for a far procedure:

       One word     One byte                         Top of stack
      |<------->|   |<-->|                        <-----------------
   SS:0000         stack keeps         SP (after subtraction)
      |         <----------------       |
     \|/         growing this way      \|/
      *----+----*...*----+----*----+----*----+----*----+----*----+----*
      |    |    |   |         |         |(Local 3)|(Local 2)|(Local 1)|<--\
Off-  *----+----*...*----+----*----+----*----+----*----+----*----+----*   |
sets:  0000 0001     n    n+1  n+2  n+3  n+4  n+5  n+6  n+7  n+8  n+9     |
                                                                          |
   /----------------------------------------------------------------------/
   |
   |   *----+----*----+----*----+----*----+----*----+----*----+----*...
   \-->|(Old BP) |(Old CS) |(Old IP) |(Param 3)|(Param 2)|(Param 1)|
       *----+----*----+----*----+----*----+----*----+----*----+----*...
      /|\ n+ n+11 n+12 n+13 n+14 n+15 n+16 n+17 n+18 n+19 n+20 n+21
       |  10
       |                        ------------------------->
      BP and                       Increasing addresses
    initial SP

My apologies for the messy diagram. I think the best way to approach this is to give an example outline first:

; Use the LOCALS directive at the top of the program

PROC MyProcedure
    ARG @@Param3:WORD, @@Param2:WORD, @@Param1:WORD = @@ArgBytesUsed
    LOCAL @@Local3:WORD, @@Local2:WORD, @@Local1:WORD = @@LocalBytesUsed

    PUSH BP                            ; Save BP
    MOV BP, SP                         ; Let BP = SP
    SUB SP, @@LocalBytesUsed           ; Subtract @@LocalBytesUsed from
                                       ;  SP, reserving that number of
                                       ;  bytes on the stack for the
                                       ;  local variables

    ; Do the procedure's work here
    ; ...

    ADD SP, @@LocalBytesUsed           ; "MOV SP, BP" works too, and is
                                       ;  a bit faster
    POP BP                             ; Restore BP
    RET @@ArgBytesUsed
ENDP MyProcedure

The LOCAL directive does not actually reserve any space on the stack. It does count the number of bytes needed for the variables that you specify, and it stores that number in the variable that you specify after the equals sign -- in the example above, that variable is @@LocalBytesUsed. To reserve the space, you actually have to subtract that value from SP. That's what the "SUB SP, @@LocalBytesUsed" line does. After that, you can use the variable names specified in the LOCAL directive in your procedure. The variable names get translated into displacements from BP, as they did with the parameters handled using ARG.

Note that after the value is subtracted from SP, then the stack can be used normally for pushing and popping without disturbing any local variables. See the diagram above: six bytes are subtracted from the initial SP, leaving SP at the point just below the last local variable.

After the work of your procedure is done, then you need to restore SP to its original position. You can take the value that you subtracted from SP and add it back to SP, or, since BP and SP were initially equal, you can let SP equal BP.

An example program that uses local variables

Here's a program with a procedure that uses local variables:

------- TEST10.ASM begins -------

%TITLE "Assembler Test Program #10 -- Using LOCAL Variables"

    IDEAL

    MODEL small
    STACK 256
    LOCALS                             ; Permits the use of @@ with
                                       ;  variable names

    DATASEG

; No (global) variables


    CODESEG

Start:
    ; Let DS point to the start of the data segment:
    MOV AX, @data
    MOV DS, AX

    PUSH '*'
    PUSH 40
    PUSH 6
    CALL DrawBlockOfCharacters

    PUSH '.'
    PUSH 20
    PUSH 4
    CALL DrawBlockOfCharacters

    PUSH '/'
    PUSH 65
    PUSH 8
    CALL DrawBlockOfCharacters
    
    ; Terminate program:
    MOV AX, 4C00h
    INT 21h


; ---------------------------------------------------------------------
; DrawBlockOfCharacters
; ---------------------------------------------------------------------
; Desc: Displays a rectangular block of characters.
;  Pre: Before calling this procedure, push onto the stack the
;       character to use, the width of the block (in characters), and
;       the height of the block (in characters), in that order.
;       For the block to be displayed correctly, the cursor should
;       be in the first column, and the width and height should
;       not exceed that of the screen display.
; Post: The block is drawn.  The cursor is left in the first column
;       of the line following the last line of the block.  No flags
;       or registers are affected.
; NOTE: This procedure has not optimized.  It was written with
;       understandability in mind.
; ---------------------------------------------------------------------
PROC DrawBlockOfCharacters
    ARG @@Height:WORD, @@Width:WORD, @@Character:BYTE = @@ArgBytesUsed
    LOCAL @@x:WORD, @@y:WORD = @@LocalBytesUsed

    PUSH BP                            ; Save BP
    MOV BP, SP                         ; Allow params. to be addressed
    SUB SP, @@LocalBytesUsed           ; Reserve space for local vars.

    ; Save affected registers and flags:
    PUSH AX
    PUSH DX
    PUSHF

    ; Let [@@y] = [@@Height]  (@@y is the outer loop counter):
    MOV AX, [@@Height]
    MOV [@@y], AX

@@RowLoop:

    ; Let [@@x] = [@@Width]  (@@x is the inner loop counter):
    MOV AX, [@@Width]
    MOV [@@x], AX

    @@ColumnLoop:

        ; Use INT 21h, Service 2 to output the character:
        MOV AH, 2
        MOV DL, [@@Character]
        INT 21h

        ; Decrement the inner loop counter:
        DEC [@@x]

        ; Has the inner loop counter reached zero yet?
        CMP [@@x], 0
        JNE @@ColumnLoop               ; ...if not, continue the loop.
        ; If it has reached zero, then the inner loop ends here.

    ; Using INT 21h, Service 2, output a CR and LF to move the cursor
    ; to the start of the next line:
    MOV AH, 2
    MOV DL, 13                         ; ASCII 13 dec = carriage return
    INT 21h
    MOV DL, 10                         ; ASCII 10 dec = line feed
    INT 21h
    
    ; Decrement the outer loop counter:
    DEC [@@y]

    ; Has the outer loop counter reached zero yet?
    CMP [@@y], 0
    JNE @@RowLoop                      ; ...if not, continue the loop.
    ; If it has reached zero, then the outer loop ends here.
    
    ; Restore the affected registers and flags:
    POPF
    POP DX
    POP AX

    ADD SP, @@LocalBytesUsed           ; "De-allocate" local variables'
                                       ;  space
    POP BP                             ; Restore BP
    RET @@ArgBytesUsed
ENDP DrawBlockOfCharacters
; ---------------------------------------------------------------------

END Start

------- TEST10.ASM ends -------

Admittedly, it's not the most efficient procedure -- registers could actually be used in place of the local variables.

Summary

In this chapter, we've learned how to use assembler procedures. We've learned about near and far procedures. We've learned how the stack works, and how we can use parameters and local variables using the ARG and LOCAL directives.

Some of the remaining assembler topics include string instructions, macros, equates, port input and output using IN and OUT, multiplying and dividing using MUL and DIV, shift instructions, and combining assembler procedures with C/C++ code (but we might not necessarily meet these topics in this order).

  

Copyright 1997, Kevin Matz, All Rights Reserved. Last revision date: Mon, Aug. 25, 1997.

Go back

A project