The Challenge: Code Reuse vs. Binary Size in Assembly

When developing low-level applications in x86/x86_64 assembly using NASM, creating reusable helper routines (like strlen, println, and strcat) is a rite of passage. Using Git submodules is an excellent way to maintain a single source of truth for these routines across multiple projects.

However, an issue quickly arises: binary bloat. If you bundle all your functions into a single .asm file and assemble it into an object file, the linker incorporates every single byte into your final executable—even routines your program never calls. In constrained environments or bare-metal development, this dead code is unacceptable.

Fortunately, you do not need to resort to copy-pasting code or relying on high-level runtimes. Here are the standard, industry-tested methods to keep your NASM libraries modular, maintainable, and lean.

Solution 1: The Canonical Unix Approach (Separate Files and Static Archives)

The standard way C standard libraries (and low-level tools) prevent dead code is by utilizing the traditional static archive (.a file) behavior of the GNU linker (ld).

How it works:

When the linker encounters a static library created with ar, it examines individual object files (.o) inside the archive. It only extracts and links object files that resolve currently undefined symbols.

1. Split functions into individual source files

Organize your repository so each independent function lives in its own assembly file:

my-nasm-lib/
├── Makefile
├── src/
│   ├── strlen.asm
│   ├── println.asm
│   └── strcat.asm
└── tests/
    └── test_runner.asm

2. Declare functions as global

Inside strlen.asm:

global strlen
section .text
strlen:
    xor eax, eax
.loop:
    cmp byte [rdi + rax], 0
    je .done
    inc rax
    jmp .loop
.done:
    ret

3. Build a static library (archive)

In your library's Makefile, assemble each file into its own object file, then bundle them into a .a archive using ar:

SRCS = $(wildcard src/*.asm)
OBJS = $(SRCS:src/%.asm=build/%.o)

build/%.o: src/%.asm
	nasm -f elf64 $< -o $@

libstrings.a: $(OBJS)
	ar rcs $@ $(OBJS)

4. Link against the library

In your main project, compile your main file and link against your static library:

nasm -f elf64 main.asm -o main.o
ld main.o -L./lib/string-x86 -lstrings -o my_app

If main.asm only references println, ld will only pull in println.o. The strcat.o and strlen.o code will never make it into the final executable.

Solution 2: Using Section-Level Garbage Collection (--gc-sections)

If you prefer keeping related functions within fewer .asm files, you can instruct the linker to perform dead-code stripping automatically.

1. Assign each function to its own section

Modern ELF linkers can discard unused sections. Instead of placing all code into a generic section .text, place each function into a unique sub-section:

global strlen
global strcat

section .text.strlen
strlen:
    ; implementation...
    ret

section .text.strcat
strcat:
    ; implementation...
    ret

2. Instruct ld to garbage-collect unused sections

When linking your application, pass the --gc-sections flag to the linker:

ld --gc-sections main.o string_lib.o -o my_app

The linker marks referenced sections starting from your entry point (usually _start) and sweeps away any unreferenced .text.* sections. This achieves zero-overhead inclusion without needing dozen of separate .o files.

Best Practices for Managing Submodule Libraries

  • Unit Testing: Keep tests in a dedicated folder (e.g., tests/) inside the submodule repository. Run tests by building a dedicated test binary linking against libstrings.a. This keeps tests out of downstream builds entirely.
  • Header/Include Files: Create an include/strings.inc file containing extern declarations and shared constants/macros so consumers only need to %include "strings.inc".
  • Automate with Makefiles: Set up recursive builds so that running make in your parent project automatically builds the static archive inside the Git submodule before linking.

Conclusion

You do not have to sacrifice modularity or write monolithic code to optimize your binary size. By compiling routines into individual object files and bundling them with ar, or by utilizing unique ELF sections with ld --gc-sections, you maintain clean code, retain centralized bug fixes through Git submodules, and ensure your binaries stay as light as possible.