How Maru Compiles Standalone ELF Binaries: A Complete Technical Guide
Maru compiles standalone ELF binaries by using its own integrated assembler to emit raw ELF headers, program headers, and machine code directly to disk, eliminating the need for external toolchains like GCC or LD.
Maru, the Minimal Advanced Runtime Utility, is a self-hosting compiler developed by Attila Lendvai that can produce complete ELF executables without relying on traditional build tools. This article explains how Maru compiles standalone ELF binaries by examining the three-layer architecture that handles target configuration, code emission, and binary production directly from the source code in the attila-lendvai/maru repository.
The Three-Layer Compilation Architecture
Maru’s compilation pipeline consists of three distinct layers that work together to produce standalone binaries:
- Target configuration – Defines architecture-specific constants and calling conventions in
source/compiler/target.l - Code emission – Transforms intermediate representation into raw machine code via
source/compiler/emit-x86-64.landsource/assembler/x86-traditional.l - Binary production – Writes ELF headers and segments directly to disk or passes assembly to an external linker using
tools-for-build/linker-script.ld
Configuring the Target Architecture
The compilation process begins with target configuration. The file source/compiler/target.l runs at bootstrap time to establish compile-time constants based on the host architecture.
(let (arch word-size)
(case target/arch
("x86_64" (set arch 'x86-64) (set word-size 64))
(("i686" "i386") (set arch 'IA-32) (set word-size 32))
(("aarch64") (set arch 'aarch64) (set word-size 64))
(else (error "Couldn't identify architecture …")))
(eval-in-slave
`(let ()
(define-constant +architecture+ ',arch)
(define-constant +operating-system+ ',(string->symbol target/os))
(define-constant +little‑endian?+ true)
(define-constant +target‑triple+ ,(concat‑strings target/arch "-" target/vendor "-" target/os
(if (equal "" target/abi) "" (concat‑string "-" target/abi))))
(define-constant +word‑size‑in‑bits+ ,word-size)
(define-constant +word‑size‑in‑bytes+ (/ ,word-size 8)))))
This code defines constants such as +architecture+, +word-size-in-bytes+, and +target-triple+ that the emitter uses to select instruction sets and calculate ELF layout offsets.
Emitting Machine Code and Assembly
Once the target is configured, the compiler transforms high-level expressions into machine code through the emission layer.
Register Allocation and Calling Conventions
The file source/compiler/emit-x86-64.l implements the System V AMD64 ABI calling convention. The first six function arguments are passed in registers, while additional arguments spill onto the stack.
(define‑constant +param‑registers+ ''("%rdi" "%rsi" "%rdx" "%rcx" "%r8" "%r9"))
(define‑function new‑arg (c)
(with‑instance‑accessors c <x86‑64‑compiler>
(let ((reg (pop c.arg‑registers)))
(if reg (R64 reg) ; argument goes in a register
(let ((off c.arg‑offset)) ; otherwise spill onto the stack
(set c.arg‑offset (+ off +word‑size‑in‑bytes+))
(TEMP64 off))))))
Function Prologue and Epilogue
The emitter generates standard stack frames using ENTER and LEAVE macros that map to x86-64 frame setup instructions.
(emit ENTER frame‑body‑size) ; push %rbp, mov %rsp,%rbp, sub $frame‑size,%rsp
...
(awhen (<x86‑compiler>-fn‑epilogue‑label -c-)
(emit DEFLABEL it))
(emit LEAVE frame‑body‑size) ; restore %rsp, pop %rbp
Raw Byte Directives
The assembler in source/assembler/x86-traditional.l provides pseudo-operations that write raw bytes into the output buffer. These directives enable manual construction of ELF headers without external tools.
.i8,.i16,.i32,.i64– Emit integers of specific widths.ascii– Emit ASCII strings.align– Pad to alignment boundaries.base– Set the origin address for absolute addressing
Generating Standalone ELF Binaries Without External Linkers
Maru can produce completely self-contained ELF executables by combining the assembler’s raw byte directives with the save-assembler-buffer function.
The ELF Test Case
The file tests/test-elf.x86-64.l demonstrates this capability by constructing a valid 64-bit ELF executable from scratch. The test defines ELF constants, writes the header and program headers, emits machine code for a write/exit program, and adds data segments.
(define elf/PF_R 0x4)
(define elf/PF_W 0x2)
(define elf/PF_X 0x1)
(define elf/page‑size 0x1000)
(define base‑address 0x00400000)
(assemble
'((.base base‑address) ; set the default origin for the binary
file‑start
(.i8 0x7f) ; 0x7f “ELF” magic bytes
(.ascii "ELF")
(.i8 2 1 1 0) ; ELFCLASS64, ELFDATA2LSB, version, OSABI
(.i8 0 0 0 0 0 0 0 0) ; padding to 16‑byte ident
(.i16 2) ; e_type = ET_EXEC
(.i16 62) ; e_machine = EM_X86_64
(.i32 1) ; e_version
(.i64 code‑segment‑start) ; e_entry (entry point)
(.i64 (- program‑headers file‑start)) ; e_phoff
(.i64 0) ; e_shoff (no section headers)
(.i32 0) ; e_flags
(.i16 elf‑header‑size) ; e_ehsize = 64
(.i16 program‑header‑size) ; e_phentsize = 56
(.i16 3) ; e_phnum = three program headers
(.i16 0) (.i16 0) (.i16 0) ; e_shentsize, e_shnum, e_shstrndx
(.def elf‑header‑size (- $ file‑start))
program‑headers
;; CODE segment – readable & executable
(.i32 1) ; PT_LOAD
(.i32 (+ elf/PF_R elf/PF_X)) ; p_flags
(.i64 0) ; p_offset
(.i64 base‑address) ; p_vaddr
(.i64 0) ; p_paddr (unused)
(.i64 code‑segment‑size) ; p_filesz
(.i64 code‑segment‑size) ; p_memsz
(.i64 elf/page‑size) ; p_align
(.def program‑header‑size (- $ program‑headers))
;; RO‑DATA segment – readable only
(.i32 1) (.i32 elf/PF_R)
(.i64 (- ro‑data‑segment‑start file‑start))
(.i64 ro‑data‑segment‑start)
(.i64 0)
(.i64 ro‑data‑segment‑size)
(.i64 ro‑data‑segment‑size)
(.i64 4)
;; DATA segment – readable & writable
(.i32 1) (.i32 (+ elf/PF_R elf/PF_W))
(.i64 (- data‑segment‑start file‑start))
(.i64 data‑segment‑start)
(.i64 0)
(.i64 data‑segment‑size)
(.i64 data‑segment‑size)
(.i64 4)
code‑segment‑start
;; write(1, msg, msglen)
(MOVQir 1 _RAX) (MOVQir 1 _RDI) (MOVQir msg _RSI) (MOVQir msglen _RDX) (SYSCALL)
;; exit(42)
(XORQrr _RAX _RAX) (MOVQir exit‑code _RBX) (ADDQim -42 0 _RBX _RAX 1)
(MOVQir exit‑code _RDI) (MOVQir 60 _RAX) (SYSCALL)
(.align elf/page‑size) ; make next segment page‑aligned
(.def code‑segment‑size (- $ file‑start))
ro‑data‑segment‑start
msg (.ascii "Hello, world! From an ELF64 binary!\n")
(.def msglen (- $ msg))
(.def ro‑data‑segment‑size (- $ ro‑data‑segment‑start))
data‑segment‑start
exit‑code (.i64 42)
(.def data‑segment‑size (- $ data‑segment‑start))))
Writing the Binary to Disk
After assembling the buffer, Maru writes the executable directly using save-assembler-buffer:
(let ((filename "./build/hello-elf"))
(assemble <your-program-ast>)
(save-assembler-buffer *assembler* filename)
(println (concat-strings "*** Run " filename " to see the greeting")))
Executing the resulting file on Linux x86-64 prints the greeting and exits with status 42, demonstrating a complete ELF binary built entirely within Maru’s ecosystem.
Using External Linkers for Standard Builds
While Maru can generate raw ELF files, the standard compilation pipeline often produces assembly text (*.s) that requires external linking. For these cases, Maru includes a minimal linker script at tools-for-build/linker-script.ld:
/* tools-for-build/linker-script.ld */
ENTRY(_start)
SECTIONS {
. = 0x08048000; // classic Linux default base
.text : { *(.text) } :text
.rodata : { *(.rodata) } :text
.data : { *(.data) } :data
.bss : { *(.bss COMMON) } :data
}
PHDRS {
text PT_LOAD FLAGS(0x7); // RWX (required for the initial bootstrapping)
data PT_LOAD FLAGS(0x6); // RW-
}
This script forces the .text section to be writable (RWX) during the initial bootstrapping phase, allowing Maru to relocate its own code when Address Space Layout Randomization (ASLR) is enabled. Once the compiler generates position-independent code, this requirement can be relaxed.
Summary
- Maru compiles standalone ELF binaries through a three-layer architecture involving target configuration, code emission, and binary production.
- The
source/compiler/target.lfile establishes architecture-specific constants like+word-size-in-bytes+and+architecture+at bootstrap time. - Code generation in
source/compiler/emit-x86-64.lhandles System V AMD64 calling conventions, allocating the first six arguments to registers%rdithrough%r9before spilling to the stack. - The integrated assembler in
source/assembler/x86-traditional.lsupports raw byte directives (.i8,.i64,.ascii) that enable manual construction of ELF headers without external tools. - The test file
tests/test-elf.x86-64.ldemonstrates emitting a complete 64-bit ELF executable usingassembleandsave-assembler-buffer, producing a working binary that executeswriteandexitsystem calls. - For standard builds, Maru can emit assembly text and link using
tools-for-build/linker-script.ldwith external GCC or LD, using RWX permissions during bootstrapping to support self-relocation under ASLR.
Frequently Asked Questions
What is Maru?
Maru is a minimal, self-hosting compiler and runtime environment written in Lisp that can bootstrap itself from a small core of assembly and C. It targets multiple architectures including x86-64, i686, and AArch64, and is capable of generating standalone ELF binaries without depending on traditional Unix toolchains.
Does Maru require GCC or LD to produce binaries?
No. While Maru can emit assembly text that is subsequently linked by GCC or LD using the provided tools-for-build/linker-script.ld, it is not required. The tests/test-elf.x86-64.l example demonstrates generating a complete, executable ELF file purely through Maru’s internal assembler and the save-assembler-buffer function, with no external linker involvement.
What architectures does Maru support?
According to the target configuration in source/compiler/target.l, Maru supports x86_64 (64-bit), i686/i386 (32-bit x86), and AArch64 (64-bit ARM). The compiler uses these target definitions to set constants such as +word-size-in-bytes+ and +architecture+, which determine instruction selection and ELF header generation.
How does Maru handle system calls in standalone binaries?
In standalone ELF mode, Maru emits raw x86-64 instructions using the SYSCALL instruction directly. The tests/test-elf.x86-64.l example loads the system call number into %rax (1 for write, 60 for exit), sets arguments in %rdi, %rsi, and %rdx following the System V AMD64 ABI, and executes the SYSCALL instruction to invoke the Linux kernel directly without linking to libc.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →