Describe the process of macro expansion in Assembly language

Describe the process of macro expansion in Assembly language

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:

AspectMacroProcedure/Subroutine
Expansion timingAt assembly time (compile-time text substitution)At runtime (via CALL/BL and RET)
Code sizeGrows with each invocation (code duplicated inline)Stays constant regardless of call count
Execution speedFaster — no call/return overhead, no stack frameSlightly slower — call/return overhead, stack manipulation
FlexibilityParameters substituted as raw text, can even generate different instructions per callParameters passed via registers/stack, same code executes every time
DebuggingHarder to set breakpoints on a “logical” call site, since it’s inlined everywhereEasier — one place to breakpoint, one call stack frame per invocation
Best suited forSmall, frequently used, performance-critical sequencesLarger, 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

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:

Common Mistakes

  1. Forgetting %% for local labels, causing “symbol already defined” errors the moment a macro is invoked more than once.
  2. Overusing macros for large code blocks, bloating binary size unnecessarily — a subroutine would have been more appropriate.
  3. 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.
  4. Infinite or excessively deep nested macro expansion, usually from an accidental self-referencing macro name.

Best Practices

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:

AspectAssembly Macro (NASM/GAS)C Preprocessor Macro
Scope of substitutionWhole instruction sequences, including labelsTypically single expressions or short statements
Parameter handlingPositional (%1, %2) or named (\param)Named parameters only
Local symbol generationBuilt-in (%%label in NASM) to avoid collisionsNot 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 awarenessNone whatsoever — pure text substitutionNone whatsoever — pure text substitution, same as Assembly
Recursive self-referenceExplicitly guarded against with nesting limitsExplicitly 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:

References

Exit mobile version