Skip to content

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 ​

PropertyValue
File nameapp.elf
FormatELF64 executable (ET_EXEC)
ArchitectureEM_X86_64 (0x3E)
Load address0x400000
Code virtual address0x400078
Instruction widthVariable (1–15 bytes)
Byte orderLittle-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  ]
ConstantValueMeaning
ELF_HEADER_SIZE64Standard ELF64 header size
PHDR_SIZE56PT_LOAD program header entry size
CODE_FILE_OFFSET120 (0x78)File offset where code begins
LOAD_VADDR0x400000Virtual address of the PT_LOAD segment
CODE_VADDR0x400078Virtual 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 + j

X86_64Emitter ​

X86_64Emitter is a byte-level emitter. Key members:

MemberTypePurpose
bufnumber[]Flat byte array of emitted instruction bytes
labelsobjectname → byte offset in buf
patchesobject[]{ offset, labelName, type } — pending 32-bit field fixups
strPoolobject[]{ label, str, bytes } — string literal deduplication
_strIdxnumberCounter for generating unique string labels

Key methods:

  • emitByte(b) — append one byte
  • emitBytes(...bytes) — append multiple bytes
  • emitI32LE(n) — append a signed 32-bit integer as 4 LE bytes
  • placeholder32(labelName, type) — emit 4 zero bytes and register a patch
  • label(name) — define a label at the current byte position
  • here() — return current byte position
  • addString(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.

js
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.

asm
_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:

asm
<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.

asm
; 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 05

Total: 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 ​

TypeUsageDisplacement formula
'rel32'CALL, JMPtarget − (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 ​

sh
# 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.elf

qemu-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.

Yog — union of JS syntax and silicon.