Procedures Extended

Subroutines or procedures — we already talked about them in detail in the Control Flow Instructions section. Now we’ll use the knowledge we’ve gained so far — variables, arrays, and the stack. We’ll bring them all together with procedures and see how it works in the backend: how the values of SP and IP change in different parts of the code.

I’ll use a simple example program whose purpose is to find the minimum number between two values. We start with two variables that contain any two numbers of our choice. Our code will compare them and store the minimum/maximum number in another variable called Min/Max.

Problem:

Write a program in DOS assembly that takes two numbers from variables and determines which one is minimum and which one is maximum. In the end, the Min variable should contain the minimum number and the Max variable should contain the maximum.

Code:

; Main Program
; ------------
	[org 0x100]

	mov ax, [num1]           ; Store value of num1 into AX
	mov bx, [num2]           ; Store value of num2 into BX

	push ax                  ; Push value stored in AX onto the stack
	push bx                  ; Push value stored in BX onto the stack

	call min                 ; Call min subroutine

	mov [Min], ax
	mov [Max], bx

	pop bx
	pop ax

	mov ax, 0x4C00           ; Exit Prep
	int 0x21

; Subroutines
; -----------
	min:                     ; min subroutine label
		cmp ax, bx             ; Compare the value stored in AX and BX (AX - BX)
		jle return             ; JLE means (Jump if AX is less or equal to BX)
		mov dx, bx             ; Store value of BX in DX
		mov bx, ax             ; Store value of AX in BX
		mov ax, dx             ; Store value of DX in AX
		ret

; JUMPS
; -----
	return:                  ; Jump label
		ret

; Variables
; ---------
	num1: dw 10              ; Variable num1 with the value 10
	num2: dw 15              ; Variable num2 with the value 15
	Max: dw 0                ; Variable Max initialized with 0
	Min: dw 0                ; Variable Min initialized with 0

Above I’ve provided a complete code that solves the given problem. I’ve added comments to every statement to explain what it does, and I also partitioned the code into different sections such as Main Program, Subroutines, and Variables.

Now, if you recall, we already learned how to translate assembly code into machine code using nasm in our Warmup section.

C:\\> nasm.exe findMinMax.asm -o findMinMax.com

The above command generates a .com file that we can execute in DOSBox. We can also analyze it using the AFD debugger to understand what the code does internally.

C:\\> afd.exe findMinMax.com
Fig 5.0: Code view in AFD debugger

I already explained every screen window of the AFD debugger back in the Warmup section. If you need a refresher, you can go back and check it again. At that time, I’m sure most of it didn’t really click — you saw the windows, but you probably didn’t fully get what they were showing. But now, after everything we’ve learned, you not only recognize them but also understand their purpose.

Still, let’s be honest: even though the debugger makes more sense now, it’s a lot to take in when we don’t have much practical experience. That’s why, for now, I just want you to focus on the specific parts which are visible — especially the ones that are not highlighted in red.

Fig 5.1: Narrow The Vision

The figure above shows everything we need to focus on. Every single visible window there is important for understanding the inner workings of our code. We are mainly looking at the following windows:

  • Register Section — where the General Purpose Registers are highlighted with a yellow block.

  • Instruction Pointer (IP) — also highlighted with a yellow block.

  • Stack Section — highlighted with a yellow block.

  • Flags Section — where all the flags (OF, DF, IF, etc.) are highlighted with a yellow block.

  • Code Section — this shows our program instructions in listing format, with a yellow block highlighting [0126].

  • Memory Windows — there are two of them: one just after the code window and one below it. Both have a yellow block highlighting a cell containing 00.

Hopefully, I don’t need to re-explain the registers, IP, stack, and flags sections — we already studied them in detail. But the Code Window and Memory Windows need some explanation before we move on.

Code Window:

In the code window, we see our instructions in a slightly different format, called Listing format. For example:

MOV AX, [0126]

Here [0126] is the variable address, which we can understand. But before that, we also see something like this:

0100 A12601

Every line in the listing starts with numbers like that. Let’s break it down:

  • The first part, 0100, is the address of the instruction in the code segment. Why 0100? Because that’s what we specified in our very first instruction: [org 0x100].

  • The second part, A12601, is the opcode. Every assembly instruction is assigned a unique opcode. When we assemble our code, the assembler first generates a Listing file with opcodes and addresses, and then converts that listing file into pure machine code.

We can generate the listing file ourselves using the following command:

C:\\> nasm.exe -l findMinMax.asm -o findMinMax.lst

After running this command, we get a new .lst file that contains our code in listing format.

Fig 5.3: Three Files generation

The screenshot above shows four windows. The first window contains my source file along with two directories I created to keep things organized: Executable File and Listing Files. The second and third windows show the contents of those two directories. The last window is our DOSBox, where I ran two commands:

  1. To generate the listing file (saved in the Listing Files directory, shown in the third window).

  2. To generate the executable file (saved in the Executable File directory, shown in the second window).

For now, we are only concerned with the listing file we just generated — what’s actually inside it. The following content is from that file:

Fig 5.4: Listing File Content

That’s our code with some subtle changes. Below I’ve explained what each change means:

1. Line Number

  • First column shows us the sequential number of the line as it appears in the listing file.

  • It is not part of our original source code; the assembler inserts it only for reference.

  • Its purpose is to help us quickly locate and cross-reference specific instructions within the listing output.

2. Address (Offset in Memory)

  • 2nd column contains the memory offset where the corresponding instruction or data will be placed when the program is loaded.

  • It reflects how the assembler maps our source instructions and variables into memory.

  • For example, if the program is assembled with org 0x100, then an address like 00000007 corresponds to memory location 0107h in real execution.

3. Machine Code (Opcode + Operands in Hex)

  • This column displays the actual machine code produced by the assembler in hexadecimal form.

  • Each assembly instruction corresponds to one or more bytes of opcodes and operands.

  • What appears here is the true binary representation that the processor will execute. For instance, 50 is the single-byte opcode for push ax, while 39D8 encodes cmp ax, bx.

4. Source Code

  • The final column reproduces our original source line exactly as we wrote it, including instructions, labels, and comments.

  • It exists to allow comparison between the human-readable assembly and the generated machine code.

  • This ensures that we can trace how every line in our program is translated and where it is stored in memory.

That’s everything we were missing. Now we can start debugging our code and see how things change step by step as we execute each instruction. Use F1 to run one instruction at a time, from here onward, we’ll step through each instruction with the debugger screenshots.

Fig 5.5: Updating Registers - mov ax, [num1]
  1. Above shows the result after executing the first instruction: mov ax, [num1]. In the debugger, we don’t actually see the label num1; instead, it has been converted to its real memory address. Still, from our source code we know it refers to num1. This updates the AX register, which now contains 000A (10 in decimal). The IP register has advanced to the address of the next instruction: mov bx, [num2]. Again, the debugger shows the address instead of the label, but we know it points to num2. Once executed, this will load num2 into BX (currently BX is still empty).

Fig 5.6: Stack push - push ax
  1. I’ve executed the instruction that loads the value into BX, which you can clearly see. If you look carefully, I also executed the first PUSH instruction. This pushes the value stored in AX onto the stack. As a result, the stack now contains the value 000A. Meanwhile, the IP register has advanced to the address of the next instruction waiting to be executed.

Fig 5.7: Notice the stack
  1. Now, not only did I push the value of AX onto the stack, but I also pushed the value stored in BX. Notice how the first value we pushed (000A) moves one step down, and the new value (000F) is placed above it. Why? Because of the rule we already discussed: LIFO (Last In, First Out) — the last value pushed is always the first one to be popped.

  • Also notice that the IP register now holds the address of the next instruction (0109), which is the CALL. Things are about to get spicy here.

Fig 5.8: Subroutine called - call min
  1. Here we’ve executed the call min instruction. First, look at the stack: you’ll notice a new entry has been pushed on top. That value is the address of the next instruction that comes after the call in our code.

    • Check Fig 5.7 and notice what the next instruction is after call 011A. It’s at address 010C, and that exact value has been pushed onto the stack.

    • This is exactly what we learned earlier about the call instruction (forgot already? go back and revisit it). The call pushes the return address (the next instruction’s address) onto the stack, and then updates the IP register to point to the subroutine’s first instruction. If you look at Fig 5.8, the IP now contains the address of cmp AX, BX, which is the first line of our subroutine in the code.

    Fig 5.9: Flags gets updated
  2. In this step, we executed the cmp AX, BX instruction. As we already know, in the backend this subtracts the value in BX from AX, but it doesn’t update the registers — instead, it only updates the flags.

    • Notice how the SF flag is now 1, and both the AF and CF flags are also set. Why? Because AX contains 10 (decimal) and BX contains 15.

    • Subtracting 10 - 15 gives us a negative result. Assembly works in binary, so if we convert both values into binary and subtract, we get this:

0000 1010           (10)
0000 1111           (15)
-----------
1111 1011           (-5 in 2's Complement)
-----------
  • If you’re not sure how the above subtraction produces 1111 1011, please review binary subtraction. The key point here is that the most significant bit (MSB) tells us whether the number is negative. Since the MSB is 1 (look the first one on left side), the result is negative, which is why the SF flag is set.

  • The CF flag is set because the subtraction required a borrow (carry) during calculation. Anytime there’s a borrow in calculation, the CF flag is set.

  • The AF flag is set because the rightmost nibble (1010, i.e., the lower 4 bits) generated a carry into the higher nibble. If a carry occurs from the lower nibble to the higher one, the AF flag is set.

That’s what you need to understand about how the cmp instruction affects the flags. Also, notice that the IP register has now moved on to the address of the next instruction (a jump).

Fig 5.0.1: Jump (Jump If less or equal)
  1. See how in the code window the JUMP instruction is shown as JNG instead of JLE. Don’t worry—they mean the same thing. JNG means Jump if Not Greater, while JLE means Jump if Less or Equal.

    • Since this is a conditional jump, it depends on the result of the previous cmp instruction. The comparison was between the registers AX and BX, so the jump instruction checks their relationship to decide whether to jump or not.

    • Put simply, the instruction now means: Jump if AX is less than or equal to BX, or equivalently, Jump if AX is not greater than BX.

    • Here, AX = 10 and BX = 15, so the condition is true because AX is indeed less than BX.

Note: This is a simplified analogy. In reality, JLE (or JNG) checks the flags directly:

  • If ZF = 1, it jumps.

  • If SF ≠ OF, it jumps

  • Because the condition is satisfied, notice the IP register now contains the jump target address 0125 instead of the usual next instruction address 011E.

  • That new address points to the ret instruction

Fig 5.0.2: Return Instruction
  1. In this step, we have executed the return (ret) instruction. As we learned before, ret removes the top value from the stack and updates the IP register with it. Notice how the address 010C that was in the stack is now placed into the IP register.

  • This means the subroutine has finished, and execution has returned to the usual flow right after the call instruction.

  • The instruction at address 010C is mov [012C], AX. In our source code, we wrote it as mov [Min], AX, but the assembler replaces labels with their actual addresses. The [] means “the value at that address,” which we’ve already covered.

  • Now focus: AX contains 000A, while memory at [012C] currently contains 00. You can confirm this in the memory window below, where the highlighted yellow block at offset C shows 00.

  • Notice there are two highlighted blocks: one at offset A (value 00) and another at offset C (value 00).

  • Our target address is [012C]. The memory window shows DS:0120. Here DS means Data Segment, which is the section of memory reserved for data (variables, arrays, strings, etc.).

  • The base address is 0120, and we need to reach offset C. In hexadecimal, C equals 12 in decimal. If you look closely, the memory window also labels columns in hexadecimal, helping us locate the correct cell at [012C].

Fig 5.0.2 (b): Data segment offset
  • These numbers are offsets (or indexes). Since we need to look at [012C], we focus on the yellow-highlighted block at C. Later, for the next instruction, we’ll be checking the yellow block at A.

  • The instruction tells us to mov [012C], AX, which means move the value stored in AX into that memory address. The next instruction will work the same way.

  • Once we execute these instructions, the yellow blocks that currently contain 00 will be updated with the values stored in AX and BX.

Fig 5.0.3: Min And Max Gets Updated
  1. Take a look — after executing both mov instructions, the memory locations now contain the values stored in AX and BX. We did it… Yay! 🎉

But hold on, we’re not done yet. Notice the stack still contains 000F and 000A, the values we pushed earlier. The reason is simple: if we ever need those values back, they’re safely stored in the stack. In this specific case, the subroutine didn’t modify AX or BX, so they remain unchanged.

Now, here’s the twist: if you change the source code so that num1 contains the bigger number and num2 the smaller one, you’ll see that both registers get updated inside the subroutine. In that case, if you didn’t save the original values in the stack, you’d lose them forever.

That’s the whole point of the stack: it allows us to save data temporarily and restore it later when needed. For demonstration, let’s assume AX and BX held different values — we’ll now pull them back from the stack.

Fig 5.0.4: POP instruction
  1. Observe that, we executed the first POP instruction: POP BX. Notice how the current top stack value is now 000A instead of 000F. This means the value from the stack has been restored into the BX register. If we execute the next POP instruction, the 000A will also be popped out, updating the AX register.

  • After that, there’s nothing new left for the remaining instructions. If we keep pressing F1 to step through one by one, eventually the program will finish and terminate.

Fig 5.0.5: Program Terminated…

Phew… that was a lot to digest, right? 😅 But trust me, you just picked up so many practical insights that probably felt like magic before. We’ve come a long way—be proud of ourselves. By now, we don’t just “know” assembly — we understand the intricacies behind the scenes that make it all work.

Now we have seen why procedures depend so much on the stack — for arguments, saving registers values, and returning to the caller. This isn’t just about finding min and max — this is the blueprint for every function we’ll ever write in assembly

Last updated

Was this helpful?