If you’ve ever found yourself copying and pasting the same five lines of assembly code in twenty different places, then changing one instruction in that block and having to update all twenty copies — you already understand why macros exist. But assembly macros are so much more than simple text substitution. They’re a compile-time programming language hiding inside your assembler, capable of generating complex code patterns, making platform-specific decisions, and even building entire data tables programmatically.
In this article, we’re going to explore the full power of macro assembly and conditional assembly. By the time we’re done, you’ll be writing macros that would make a C++ template metaprogrammer nod in respect.
What Is a Macro? (Text Substitution on Steroids — and Why It’s Different from a Function)
Let’s start with the most fundamental question: what exactly is a macro in assembly language?
A macro is a named block of code that gets textually expanded at the point where it’s invoked. When the assembler encounters a macro invocation, it substitutes the macro’s body into the code at that location — replacing any parameters with their actual values — and then assembles the expanded code.
This is fundamentally different from a function call. Let me illustrate:
Function Call: One Copy of Code, Multiple Call Sites
; Function definition (one copy exists in memory)
print_hex:
push ebp
mov ebp, esp
; ... implementation ...
ret
; Function calls (each call site jumps to the same code)
call print_hex ; Call site 1
; ...
call print_hex ; Call site 2
; ...
call print_hex ; Call site 3
With a function:
- There’s only ONE copy of the
print_hexcode in memory - Each
CALLinstruction pushes a return address and jumps to that one copy - At runtime, the CPU executes a
CALL(which involves stack operations) and later aRET(which pops the return address) - This has overhead: the
CALLandRETinstructions take time, and you pay the cost of branch misprediction
Macro: Code Duplication, Zero Runtime Overhead
; Macro definition
%macro PRINT_HEX 1
push eax
mov eax, %1
call print_hex_impl
pop eax
%endmacro
; Macro invocations (each expands to a copy of the macro body)
PRINT_HEX ebx ; Expands to: push eax; mov eax, ebx; call print_hex_impl; pop eax
PRINT_HEX ecx ; Expands to: push eax; mov eax, ecx; call print_hex_impl; pop eax
PRINT_HEX edx ; Expands to: push eax; mov eax, edx; call print_hex_impl; pop eax
With a macro:
- The macro body is copied at each invocation site
- There’s no
CALLorRET— the code is right there inline - Zero function call overhead at runtime
- But… the code is larger (you have multiple copies of the same logic)
The Fundamental Tradeoff
| Aspect | Function | Macro |
|---|---|---|
| Code size | Small (one copy) | Large (copy at each use) |
| Runtime overhead | CALL/RET overhead | Zero (inline expansion) |
| Parameter passing | Registers, stack | Text substitution (any assembly code) |
| Debugging | Easy (one place to set breakpoint) | Harder (code duplicated everywhere) |
| Recursion | Supported | Not supported (expands at compile time) |
Macros are essentially a metaprogramming tool. You’re writing code that writes code. The macro itself is processed at assembly time (not at runtime), and the result is injected into your source code before the assembler even sees it.
A Concrete Example: Why Macros Matter
Let’s say you’re writing a bootloader in 16-bit real mode, and you need to print a string using BIOS interrupt 0x10. The code looks like this:
; Print a character using BIOS teletype output
mov ah, 0x0E ; BIOS teletype function
mov al, 'H' ; Character to print
mov bh, 0 ; Page number
mov bl, 0x07 ; Text attribute (light gray on black)
int 0x10 ; Call BIOS
If you need to print the entire string “Hello”, you’d write this block five times, once for each character. That’s 20 lines of code for “Hello”.
With a macro:
%macro PRINT_CHAR 1
mov ah, 0x0E
mov al, %1
mov bh, 0
mov bl, 0x07
int 0x10
%endmacro
; Now printing "Hello" is easy:
PRINT_CHAR 'H'
PRINT_CHAR 'e'
PRINT_CHAR 'l'
PRINT_CHAR 'l'
PRINT_CHAR 'o'
But we can do even better:
%macro PRINT_STRING 1
jmp %%start ; Skip over the string data
%%str db %1, 0 ; String data (null-terminated)
%%start:
lea si, [%%str] ; Load address of string
%%loop:
mov al, [si] ; Get character
cmp al, 0 ; Check for null terminator
je %%done
mov ah, 0x0E ; BIOS teletype
mov bh, 0
int 0x10
inc si ; Next character
jmp %%loop
%%done:
%endmacro
; Usage:
PRINT_STRING "Hello"
This macro not only saves typing — it generates an entire loop with string data embedded right there in the code. Try doing that with a function in assembly!



