x86_64 Backend Reference
The x86_64 backend produces a Linux ELF64 executable that makes raw Linux syscalls using the System V AMD64 ABI. The output runs natively on Linux x86_64 or via qemu-x86_64 user-mode emulation on macOS and ARM hosts.
Source: yogc/src/backends/x86_64.js
Output Format
| Property | Value |
|---|---|
| File name | app.elf |
| Format | ELF64 executable (ET_EXEC) |
| Architecture | EM_X86_64 (0x3E) |
| Load address | 0x400000 |
| Code virtual address | 0x400078 |
| Instruction width | Variable (1–15 bytes) |
| Byte order | Little-endian |
Unlike the ARM64 and ESP32 backends, x86_64 instructions are variable-width. This backend uses a byte-oriented emitter (X86_64Emitter) rather than a fixed-word slot array. Patches record byte offsets into the flat byte buffer.
ELF64 Structure
The output binary has exactly three sections laid out sequentially:
Offset 0x00 [ ELF64 header : 64 bytes ]
Offset 0x40 [ PT_LOAD phdr : 56 bytes ]
Offset 0x78 [ _start + code : variable ]
[ string pool : variable ]| Constant | Value | Meaning |
|---|---|---|
ELF_HEADER_SIZE | 64 | Standard ELF64 header size |
PHDR_SIZE | 56 | PT_LOAD program header entry size |
CODE_FILE_OFFSET | 120 (0x78) | File offset where code begins |
LOAD_VADDR | 0x400000 | Virtual address of the PT_LOAD segment |
CODE_VADDR | 0x400078 | Virtual address of the first instruction |
PT_LOAD program header
A single PT_LOAD segment maps the entire file (header + phdr + code + strings) at 0x400000 with PF_R | PF_X (read + execute) permissions. There are no section headers — they are not required for execution.
p_offset = 0, p_vaddr = p_paddr = 0x400000, p_filesz = p_memsz = fileSize, p_align = 0x200000 (2 MiB, standard for Linux ET_EXEC).
Virtual address mapping
file offset 0 → vaddr 0x400000 (ELF magic bytes)
file offset 0x78 → vaddr 0x400078 (code: _start)
code byte index i → vaddr 0x400078 + i
string at code-rel j → vaddr 0x400078 + jX86_64Emitter
X86_64Emitter is a byte-level emitter. Key members:
| Member | Type | Purpose |
|---|---|---|
buf | number[] | Flat byte array of emitted instruction bytes |
labels | object | name → byte offset in buf |
patches | object[] | { offset, labelName, type } — pending 32-bit field fixups |
strPool | object[] | { label, str, bytes } — string literal deduplication |
_strIdx | number | Counter for generating unique string labels |
Key methods:
emitByte(b)— append one byteemitBytes(...bytes)— append multiple bytesemitI32LE(n)— append a signed 32-bit integer as 4 LE bytesplaceholder32(labelName, type)— emit 4 zero bytes and register a patchlabel(name)— define a label at the current byte positionhere()— return current byte positionaddString(str)— add a UTF-8 string to the pool; returns its label (deduplicates by content)
Instruction Emitters
CALL rel32 — Near Call
E8 [rel32 LE] (5 bytes)Pushes the return address and jumps to the target. rel32 = target_byte_offset − (patch_offset + 4). The patch placeholder is at patch_offset; RIP at execution = patch_offset + 4.
emitCALL(e, 'main')
// → 0xE8, [placeholder32 'main' 'rel32']JMP rel32 — Unconditional Near Jump
E9 [rel32 LE] (5 bytes)Same displacement formula as CALL. Currently unused in the generated code but available for future conditional branching.
RET — Near Return
C3 (1 byte)Pops the return address and jumps to it. Emitted at the end of every compiled function (after POP RBP).
PUSH RBP — Save Frame Pointer
55 (1 byte)Saves the caller's RBP and re-aligns RSP. A CALL pushes 8 bytes (misaligning from 16-byte boundary), and PUSH RBP pushes another 8 bytes, restoring 16-byte alignment required by the System V ABI for any subsequent CALL.
POP RBP — Restore Frame Pointer
5D (1 byte)Restores the caller's frame pointer in the function epilogue, before RET.
MOV RBP, RSP — Set Frame Pointer
48 89 E5 (3 bytes)REX.W (48) MOV r/m64,r64 (89) ModRM(RBP←RSP) (E5). Sets RBP to the current stack pointer, establishing the function frame.
MOV EAX, imm32 — Load Syscall Number
B8 [imm32 LE] (5 bytes)Loads a 32-bit immediate into EAX (zero-extended to RAX). Used for syscall numbers: SYS_write = 1, SYS_exit = 60.
MOV EDI, imm32 — First Syscall Argument
BF [imm32 LE] (5 bytes)Loads into EDI (zero-extended to RDI). Used for: file descriptor (1 = stdout), exit code (0).
MOV EDX, imm32 — Third Syscall Argument
BA [imm32 LE] (5 bytes)Loads into EDX (zero-extended to RDX). Used for byte count in write(2).
LEA RSI, [RIP + disp32] — RIP-relative Address Load
48 8D 35 [disp32 LE] (7 bytes)REX.W (48) LEA r64,m (8D) ModRM(RIP-relative,RSI) (35). Loads the address of a string literal into RSI (second syscall argument = buf pointer). The patch type is 'rip_rel32' with the same displacement formula as rel32.
SYSCALL — Linux System Call
0F 05 (2 bytes)Transfers control to the Linux kernel. On return, RAX holds the result. Registers: RAX = syscall number, RDI/RSI/RDX = first three arguments.
Bootstrap (_start)
The Linux kernel jumps to _start with RSP pointing at argc and RSP 16-byte aligned.
_start:
call main ; E8 [rel32] — invoke user's main()
mov edi, 0 ; BF 00 00 00 00 — exit code 0
mov eax, 60 ; B8 3C 00 00 00 — SYS_exit
syscall ; 0F 05 — exit(0)Total size: 5 + 5 + 5 + 2 = 17 bytes. Code for main (and other functions) follows immediately.
Function Frame Layout
Every compiled function gets a minimal System V-compliant stack frame:
<name>:
push rbp ; 55 — save caller frame pointer (1 byte)
mov rbp, rsp ; 48 89 E5 — establish frame (3 bytes)
<body> ; — compiled function body
pop rbp ; 5D — restore frame pointer (1 byte)
ret ; C3 — return to caller (1 byte)The PUSH RBP + MOV RBP, RSP prologue and POP RBP + RET epilogue together cost 6 bytes per function. The body emits instructions between them.
String Pool
All string literals used in uart_print calls are collected into a string pool appended immediately after the compiled code bytes. There are no alignment gaps between the code section and the pool.
Deduplication
addString(str) scans e.strPool linearly. If the same string is already present, it returns the existing label instead of adding a duplicate. The same string referenced in two uart_print calls costs only one pool entry.
RIP-relative addressing
Because the string pool follows the code at a fixed offset, each string's virtual address is CODE_VADDR + codeSize + poolOffset. The LEA RSI, [RIP+disp32] instruction addresses it as a displacement from the byte after the 4-byte disp32 field:
disp32 = (CODE_VADDR + strCodeRelativeOffset) − (CODE_VADDR + patch_offset + 4)
= strCodeRelativeOffset − (patch_offset + 4)This is identical to the rel32 formula: target − (field_offset + 4), where target is the string's code-relative byte offset (registered in e.labels during step 1 of resolveAndSerialize).
uart_print — write(1, ...) Syscall Sequence
On Linux, uart_print maps to write(1, buf, len) (stdout). No UART hardware is involved.
; For uart_print("Hello\n") — 6 bytes, label __str_0
lea rsi, [rip + <disp to __str_0>] ; 48 8D 35 [rip_rel32] — buf pointer
mov edx, 6 ; BA 06 00 00 00 — len
mov edi, 1 ; BF 01 00 00 00 — fd = stdout
mov eax, 1 ; B8 01 00 00 00 — SYS_write
syscall ; 0F 05Total: 7 + 5 + 5 + 5 + 2 = 24 bytes per uart_print call. The string is stored once in the pool regardless of how many times it is printed.
uart_init — No-op
uart_init emits no instructions. stdout (fd 1) is already open when the process starts; no initialisation is needed.
Patch Types
| Type | Usage | Displacement formula |
|---|---|---|
'rel32' | CALL, JMP | target − (patch_offset + 4) |
'rip_rel32' | LEA RSI, [RIP+disp32] | target − (patch_offset + 4) |
Both types use the same formula. The distinction exists for clarity and to allow future backends to handle them differently if needed.
Patches are resolved in resolveAndSerialize step 2, after string pool labels are registered in step 1. The signed 32-bit displacement is written as 4 LE bytes at p.offset in e.buf.
resolveAndSerialize — Output Assembly
1. Register string pool labels:
e.labels[entry.label] = codeSize + strCursor (code-relative byte offset)
2. Resolve patches:
disp32 = target − (p.offset + 4)
write 4 LE bytes at p.offset in e.buf
3. Assemble code + string pool:
codePlusData = Buffer.concat([e.buf, ...strBufs])
4. Build ELF64 header (64 bytes) with e_entry = CODE_VADDR + _start offset
5. Build PT_LOAD phdr (56 bytes)
6. Return Buffer.concat([elf, phdr, codePlusData])Running the Output
# Linux x86_64 (native)
chmod +x app.elf
./app.elf
# macOS or ARM host (user-mode QEMU — no full VM needed)
brew install qemu # if not already installed
qemu-x86_64 app.elf
# Windows WSL
# Run natively inside the WSL environment
./app.elfqemu-x86_64 is user-mode emulation: it translates x86_64 syscalls to the host OS kernel. Only the instruction set is emulated — no virtual hardware, no full VM overhead.