There was a point early in my Assembly journey where I was copy-pasting the same five-instruction sequence for “print a string” over and over across a project, and it finally dawned on me: this is exactly the kind of repetition macros exist to eliminate. Once I actually understood how the assembler processes macros — not at runtime, but entirely before assembly even happens — a lot of “why does my code look different after building” confusion went away. Let’s dig into exactly how macro expansion works.
What Is a Macro in Assembly?
A macro is a named template of instructions that you define once and then “call” by name throughout your source code. Unlike a subroutine (which is called at runtime via CALL/BL and involves an actual jump and return), a macro is expanded entirely by the assembler before the program is ever assembled into machine code. Every place you invoke the macro, the assembler literally substitutes the macro’s body text in place of the invocation, then assembles the resulting expanded source as if you’d typed it all out by hand.
This is a crucial distinction: macros don’t cost you a function call at runtime — there’s no CALL instruction, no stack frame, no return address. The tradeoff is code size: each macro invocation duplicates the entire instruction sequence in the final binary, whereas a subroutine call reuses the same code.
Defining a Macro in NASM
Here’s a simple macro that prints a null-terminated string, wrapping the syscall boilerplate we saw in the I/O section into something reusable:
%macro print_string 2
mov rax, 1 ; sys_write
mov rdi, 1 ; stdout
mov rsi, %1 ; string argument (parameter 1)
mov rdx, %2 ; length argument (parameter 2)
syscall
%endmacro
section .data
hello db "Hello, Macro!", 0xA
hellolen equ $ - hello
section .text
global _start
_start:
print_string hello, hellolen ; macro invocation
mov rax, 60
xor rdi, rdi
syscall
%1 and %2 refer to the first and second parameters passed at the call site. When NASM processes this file, it doesn’t generate a function call — it literally copies the five instructions inside %macro/%endmacro, substituting hello for %1 and hellolen for %2, directly into the _start block before assembling anything into machine code.
The Macro Expansion Process, Step by Step
Here’s what actually happens inside the assembler when it encounters a macro invocation:
flowchart TD
A[Assembler reads source line by line] --> B{Is this line a macro invocation?}
B -- No --> C[Pass line through to assembly pass unchanged]
B -- Yes --> D[Look up macro definition by name and arg count]
D --> E[Substitute actual arguments for formal parameters %1 %2 etc]
E --> F[Insert expanded instruction sequence into token stream]
F --> G[Continue scanning expanded text for nested macro calls]
G --> H[Feed fully expanded source into the real assembly pass]
H --> I[Generate machine code / object file]
This happens during what’s often called the preprocessing pass (analogous to the C preprocessor handling #define before the compiler proper ever sees the code). By the time the actual instruction encoder runs, macros no longer exist as a concept — they’ve been fully “flattened” into ordinary instructions.
Macro Parameters and Local Labels
One subtlety that trips people up: if a macro contains a label and you invoke that macro more than once, you’ll get a “duplicate label” error unless the assembler gives each expansion its own unique labels. NASM solves this with %% prefixed local labels:
%macro loop_n_times 1
mov rcx, %1
%%repeat_loop:
; ... body of loop ...
dec rcx
jnz %%repeat_loop
%endmacro
Every time loop_n_times is invoked, NASM automatically generates a unique internal label for %%repeat_loop (something like ..@1.repeat_loop, ..@2.repeat_loop, etc.), so multiple invocations in the same file never collide.
Macros in GNU Assembler (GAS) for ARM
GAS uses a slightly different syntax but the underlying expansion mechanism is identical — pure textual substitution before assembly:
.macro print_char char
mov w0, #1 // stdout
ldr x1, =char_buf
mov w2, \char
strb w2, [x1]
mov x2, #1
mov x8, #64 // sys_write on ARM64
svc #0
.endm
.data
char_buf:
.space 1
.text
.global _start
_start:
print_char 65 // prints 'A'
mov x0, #0
mov x8, #93
svc #0
Note the backslash (\char) syntax GAS uses to reference macro parameters, versus NASM’s %1 positional syntax — different assemblers, same underlying textual-substitution concept.
Macros vs Procedures: A Critical Comparison
This is probably the single most important comparison to internalize, because choosing wrong has real performance and maintainability consequences:
| Aspect | Macro | Procedure/Subroutine |
|---|---|---|
| Expansion timing | At assembly time (compile-time text substitution) | At runtime (via CALL/BL and RET) |
| Code size | Grows with each invocation (code duplicated inline) | Stays constant regardless of call count |
| Execution speed | Faster — no call/return overhead, no stack frame | Slightly slower — call/return overhead, stack manipulation |
| Flexibility | Parameters substituted as raw text, can even generate different instructions per call | Parameters passed via registers/stack, same code executes every time |
| Debugging | Harder to set breakpoints on a “logical” call site, since it’s inlined everywhere | Easier — one place to breakpoint, one call stack frame per invocation |
| Best suited for | Small, frequently used, performance-critical sequences | Larger, reusable logic where code size matters more than call overhead |
Nested and Recursive Macros
Macros can invoke other macros, and the assembler simply keeps expanding until no macro invocations remain:
%macro print_newline 0
print_string newline_char, 1
%endmacro
Here, print_newline expands into a call to print_string, which itself then expands into the full syscall sequence — this is called nested macro expansion, and NASM (and most assemblers) impose a maximum nesting depth specifically to catch accidental infinite recursion, which is a real risk if a macro accidentally invokes itself.
Conditional Assembly Inside Macros
Macros often combine with conditional assembly directives (%if, %ifdef, %else, %endif in NASM) to generate different code depending on build-time conditions:
%macro debug_print 1
%ifdef DEBUG_BUILD
print_string %1, debug_msg_len
%endif
%endmacro
If DEBUG_BUILD isn’t defined when you assemble, this macro expands to literally nothing — zero instructions, zero overhead in the release build — which is a very common pattern for instrumentation code you want to strip out of production binaries entirely.
Practical Use Cases
- Boilerplate elimination: syscall wrappers, register save/restore sequences, common loop patterns.
- Portable code: writing a macro like
SYSCALL_WRITEthat expands differently depending on the target OS/architecture via conditional assembly, letting the same source file target multiple platforms. - Performance-critical inlining: avoiding call/return overhead in tight loops where every cycle counts (audio processing, cryptographic primitives, etc.).
- Debug instrumentation: conditionally compiled logging/tracing that vanishes entirely in release builds.
Debugging Macro-Expanded Code
Because the debugger and disassembler only ever see the expanded instructions (macros don’t exist post-assembly), a few practical tips help:
- Use your assembler’s listing file option (
nasm -l output.lst) to see the fully expanded source alongside line numbers, which is invaluable for figuring out exactly what a macro invocation turned into. - In GDB, when you set a breakpoint at a macro invocation’s source line, you’re really breaking at the first instruction of that particular expansion — remember there’s no single “the macro” location if it was invoked multiple times.
- Watch out for macros that expand to different sizes depending on their arguments (e.g., conditional assembly inside the macro) — this can make matching source lines to disassembly slightly less predictable.
Common Mistakes
- Forgetting
%%for local labels, causing “symbol already defined” errors the moment a macro is invoked more than once. - Overusing macros for large code blocks, bloating binary size unnecessarily — a subroutine would have been more appropriate.
- Assuming macro parameters are type-checked — they’re not; NASM/GAS macros perform pure textual substitution, so passing the wrong kind of argument (e.g., a register name where a constant was expected) can produce confusing assembler errors or, worse, silently valid-but-wrong code.
- Infinite or excessively deep nested macro expansion, usually from an accidental self-referencing macro name.
Best Practices
- Use macros for small, frequently repeated, performance-sensitive sequences; use subroutines for larger reusable logic.
- Always use local label syntax (
%%labelin NASM) inside any macro containing a label. - Keep macro bodies short and well-documented — since they get inlined everywhere, an unclear macro creates unclear disassembly at every single call site.
- Leverage conditional assembly inside macros for debug-only code paths that should vanish entirely in release builds.
- Generate a listing file during development to verify macros expand exactly as you expect.
Variadic Macros: Handling a Variable Number of Arguments
Sometimes you don’t know in advance how many arguments a macro invocation will need — a logging macro that accepts any number of values to print, for instance. NASM supports this through the special %0 symbol (which expands to the total argument count inside the macro body) combined with %rotate, which shifts the parameter list so %1 always refers to a different actual argument on each iteration:
%macro sum_registers 1-*
xor rax, rax
%rep %0
add rax, %1
%rotate 1
%endrep
%endmacro
; usage:
sum_registers rbx, rcx, rdx ; expands to three ADD instructions, however many were passed
The 1-* in the macro declaration tells NASM “accept one or more arguments,” and %rep %0 repeats the enclosed block exactly %0 times, rotating through the argument list on each pass. This is conceptually identical to a C variadic macro, except everything happens as pure text substitution at assembly time rather than through any runtime variadic argument mechanism.
Macros vs the C Preprocessor: Where the Analogy Breaks Down
I mentioned earlier that Assembly macros are similar in spirit to C’s #define macros, and that comparison is genuinely useful for building intuition — but it’s worth being precise about where the two diverge:
| Aspect | Assembly Macro (NASM/GAS) | C Preprocessor Macro |
|---|---|---|
| Scope of substitution | Whole instruction sequences, including labels | Typically single expressions or short statements |
| Parameter handling | Positional (%1, %2) or named (\param) | Named parameters only |
| Local symbol generation | Built-in (%%label in NASM) to avoid collisions | Not built-in; requires manual unique naming tricks |
| Conditional logic | %if/%ifdef operate at assembly time, aware of defined symbols | #if/#ifdef operate at compile time, aware of defined macros |
| Type awareness | None whatsoever — pure text substitution | None whatsoever — pure text substitution, same as Assembly |
| Recursive self-reference | Explicitly guarded against with nesting limits | Explicitly disallowed by the C standard |
The big practical difference in day-to-day use is that Assembly macros routinely generate multiple full instructions including internally-scoped labels, something C macros can technically do too but rarely need to, since C already has real functions, loops, and blocks as first-class language constructs — Assembly doesn’t, so macros end up carrying more of that structural weight.
A Realistic Multi-Macro Example: A Small “Standard Library”
To show how macros compose in a real project, here’s a small NASM header I might genuinely build up over time for simple programs:
%macro exit_program 1
mov rax, 60
mov rdi, %1
syscall
%endmacro
%macro write_stdout 2
mov rax, 1
mov rdi, 1
mov rsi, %1
mov rdx, %2
syscall
%endmacro
%macro read_stdin 2
mov rax, 0
mov rdi, 0
mov rsi, %1
mov rdx, %2
syscall
%endmacro
Once these are defined (often in a separate .inc file included with %include "stdlib.inc"), everyday program logic reads almost like a higher-level language:
%include "stdlib.inc"
section .data
msg db "Enter your name: ", 0
msglen equ $ - msg
section .bss
namebuf resb 64
section .text
global _start
_start:
write_stdout msg, msglen
read_stdin namebuf, 64
exit_program 0
This is exactly the kind of readability improvement that makes macros worth the small binary-size cost for frequently repeated boilerplate.
MASM Macros: A Third Syntax Worth Knowing
Beyond NASM and GAS, Microsoft’s MASM (Macro Assembler) uses yet another syntax convention, which is worth a quick look if you ever work on Windows-targeted Assembly projects:
print_string MACRO buffer, length
mov rax, 1
mov rdi, 1
mov rsi, buffer
mov rdx, length
syscall
ENDM
.code
main PROC
print_string offset hello, hellolen
main ENDP
MASM uses named parameters directly (no %1 or \param prefix needed) and the keywords MACRO/ENDM instead of NASM’s %macro/%endmacro or GAS’s .macro/.endm. The underlying expansion mechanism — pure textual substitution before assembly — remains identical across all three tools; only the surface syntax for declaring parameters and delimiting the macro body differs.
Why Excessive Macro Use Can Hurt Debuggability
I want to be honest about a real downside I’ve run into personally: heavily macro-based code can become genuinely harder to read in a disassembler or crash dump, precisely because the macro’s identity disappears entirely after expansion. If a bug manifests inside the fifteenth invocation of a ten-instruction macro, the disassembly just shows ten ordinary instructions with no indication they originated from a shared template — you have to cross-reference back to the source and count invocations manually, or rely on debug symbols and listing files if they’re available. This is a genuine, non-hypothetical tradeoff against macros’ code-size and clarity benefits at the source level, and it’s part of why performance-critical inner loops are often the right place for macros, while larger, less frequently invoked logic is usually better served by an actual subroutine, where the disassembly retains an unambiguous CALL instruction pointing to one canonical, easily identified location.
Macro Redefinition and Include Guards
Just like C header files can accidentally get included twice, causing “already defined” errors, Assembly include files defining macros can suffer the same problem if included more than once across a multi-file project. NASM supports the same include-guard pattern C programmers already know:
%ifndef STDLIB_INC
%define STDLIB_INC
%macro exit_program 1
mov rax, 60
mov rdi, %1
syscall
%endmacro
%endif
This ensures that even if %include "stdlib.inc" accidentally appears twice across a project’s source files (directly or indirectly through nested includes), the macro definitions inside only get processed once, avoiding a “macro already defined” assembler error that could otherwise be genuinely confusing to track down in a larger, multi-file Assembly project.
Frequently Asked Questions
Q: Do macros exist in the final compiled binary? No. By the time machine code is generated, all macros have been fully expanded into ordinary instructions — there’s no runtime trace of “macro-ness” left in the binary whatsoever.
Q: Can a macro take a variable number of arguments? Yes — NASM supports this via %0 (which holds the argument count inside a macro body) combined with %rotate to shift through a variable argument list, similar in spirit to C’s variadic macros.
Q: Is macro expansion the same thing as inline functions in high-level languages? Conceptually very similar — both aim to eliminate call overhead by duplicating code at each call site — but Assembly macros are pure textual substitution with no type checking whatsoever, while inline functions in languages like C++ still go through full type checking before the compiler decides whether to actually inline them.
Summary and Key Takeaways
Macro expansion in Assembly is a compile-time (technically, assembly-time) textual substitution process: every macro invocation in your source is replaced with the macro’s full instruction body, with parameters substituted in, before the real assembly pass ever generates machine code. This trades increased code size for zero call/return overhead, making macros ideal for small, frequently used, performance-sensitive sequences.
Key points to remember:
- Macro expansion happens entirely before machine code generation — no runtime cost, no
CALL/REToverhead. - NASM uses
%1,%2, etc. for parameters and%%labelfor locally unique labels; GAS uses\paramsyntax. - Macros trade binary size for execution speed — the opposite trade-off from subroutines.
- Conditional assembly combined with macros is a powerful pattern for debug-only or platform-specific code paths.
References
- NASM (Netwide Assembler) Official Documentation — Macro-processing chapter
- GNU Assembler (
as) Manual — Macros section, Free Software Foundation - Intel® 64 and IA-32 Architectures Software Developer’s Manuals — Intel Corporation
- ARM Architecture Reference Manual for A-profile architecture — Arm Ltd.