//! RinneScript -> no-libc Linux ELF lowering (the `.rgx` backend). //! //! This is the "Direct native ELF (no libc)" path for `rinne build`. It lowers //! a deliberately small, well-defined subset of RinneScript: //! //! * `fn main() int` with no parameters is the program entry point. //! * Statements: `return ;` and top-level `@stdout("literal")` calls. //! (The `.{}` tuple argument of `@stdout` must be empty for now.) //! * Expressions: integer literals, `+`, `-`, unary `-`, and parentheses. //! //! `@stdout("literal")` writes its literal bytes to fd 1 via the `write` //! syscall. The program's `main` return value becomes the exit status via the //! `exit` syscall. //! //! Text is emitted for the *build host architecture*: x86-64 and AArch64 //! Linux are supported (`elf_emit.zig` selects the ELF machine type). The //! `--native` (linked) path additionally emits GNU assembly for the same two //! architectures and hands it to the system linker. //! //! Everything outside this subset is rejected up front with a clear diagnostic //! (`error.UnsupportedRinne`); `rinne build` then falls back to the existing //! C11 + system `cc` pipeline. Integer `{}` formatting and string args are //! planned follow-ups, deliberately not silently miscompiled. //! //! The emitted binary is a static ET_EXEC assembled by `elf_emit.zig`. const std = @import("std"); const builtin = @import("builtin"); const parser = @import("../script/parser.zig"); const elf_emit = @import("elf_emit.zig"); pub const Error = error{ OutOfMemory, UnsupportedArch, UnsupportedRinne, InvalidProgram }; /// Native instruction set selected at build time from the host CPU. pub const Arch = enum { x86_64, aarch64 }; fn hostArch() Error!Arch { return switch (builtin.cpu.arch) { .x86_64 => .x86_64, .aarch64 => .aarch64, else => return error.UnsupportedArch, }; } const SYS_WRITE: u32 = 1; const SYS_EXIT: u32 = 60; const STDOUT_FD: u32 = 1; const ARM64_SYS_WRITE: u32 = 64; const ARM64_SYS_EXIT: u32 = 93; /// A little assembler: appends instruction bytes for the selected `arch` and /// records the references that must be patched once the final text/rodata /// layout is known. const Asm = struct { alloc: std.mem.Allocator, arch: Arch, bytes: std.ArrayList(u8) = .empty, relocs: std.ArrayList(Reloc) = .empty, const Reloc = union(enum) { // ---- x86-64 (RIP-relative) ---- /// RIP-relative disp32 of a `lea rsi,[rip+disp]` pointing at rodata. rip_rodata: struct { field_off: usize, rip_off: usize, rodata: usize }, /// rel32 of an `E8` call pointing at a text offset. call_text: struct { field_off: usize, rip_off: usize, text: usize }, // ---- AArch64 (PC-relative) ---- /// imm26 of a `bl` pointing at a text offset. arm64_bl_text: struct { field_off: usize, pc_off: usize, text: usize }, /// immhi:immlo (page delta) of an `adrp x1, #0` pointing at rodata. arm64_adrp_rodata: struct { field_off: usize, pc_off: usize, rodata: usize }, /// imm12 (page low 12 bits) of an `add x1, x1, #0` pointing at rodata. arm64_add_lo12: struct { field_off: usize, rodata: usize }, }; fn init(alloc: std.mem.Allocator, arch: Arch) Asm { return .{ .alloc = alloc, .arch = arch }; } fn deinit(self: *Asm) void { self.bytes.deinit(self.alloc); self.relocs.deinit(self.alloc); } fn len(self: *const Asm) usize { return self.bytes.items.len; } fn byte(self: *Asm, b: u8) !void { try self.bytes.append(self.alloc, b); } fn u32le(self: *Asm, v: u32) !void { try self.bytes.appendSlice(self.alloc, &std.mem.toBytes(v)); } fn u64le(self: *Asm, v: u64) !void { try self.bytes.appendSlice(self.alloc, &std.mem.toBytes(v)); } // ---- x86-64 instruction helpers (64-bit operand sizes) ---- /// mov rax, imm64 fn movRaxImm64(self: *Asm, imm: u64) !void { try self.byte(0x48); try self.byte(0xB8); try self.u64le(imm); } /// mov eax, imm32 (zero-extends into rax) fn movEaxImm32(self: *Asm, imm: u32) !void { try self.byte(0xB8); try self.u32le(imm); } /// mov edi, imm32 (zero-extends into rdi) fn movEdiImm32(self: *Asm, imm: u32) !void { try self.byte(0xBF); try self.u32le(imm); } /// mov edx, imm32 (zero-extends into rdx) fn movEdxImm32(self: *Asm, imm: u32) !void { try self.byte(0xBA); try self.u32le(imm); } /// mov rdi, rax fn movRdiRax(self: *Asm) !void { try self.appendSlice(&.{ 0x48, 0x89, 0xC7 }); } /// mov rcx, rax fn movRcxRax(self: *Asm) !void { try self.appendSlice(&.{ 0x48, 0x89, 0xC1 }); } /// add rax, rcx fn addRaxRcx(self: *Asm) !void { try self.appendSlice(&.{ 0x48, 0x01, 0xC8 }); } /// sub rax, rcx fn subRaxRcx(self: *Asm) !void { try self.appendSlice(&.{ 0x48, 0x29, 0xC8 }); } /// neg rax fn negRax(self: *Asm) !void { try self.appendSlice(&.{ 0x48, 0xF7, 0xD8 }); } fn pushRax(self: *Asm) !void { try self.byte(0x50); } fn popRax(self: *Asm) !void { try self.byte(0x58); } fn syscall(self: *Asm) !void { try self.appendSlice(&.{ 0x0F, 0x05 }); } fn ret(self: *Asm) !void { try self.byte(0xC3); } /// lea rsi, [rip + disp32]; points at rodata[`rodata`]. fn leaRsiRip(self: *Asm, rodata: usize) !void { try self.appendSlice(&.{ 0x48, 0x8D, 0x35 }); const field_off = self.len(); try self.u32le(0); try self.relocs.append(self.alloc, .{ .rip_rodata = .{ .field_off = field_off, .rip_off = self.len(), .rodata = rodata } }); } /// call as `E8 rel32`. fn callText(self: *Asm, text: usize) !void { try self.byte(0xE8); const field_off = self.len(); try self.u32le(0); try self.relocs.append(self.alloc, .{ .call_text = .{ .field_off = field_off, .rip_off = self.len(), .text = text } }); } fn appendSlice(self: *Asm, s: []const u8) !void { try self.bytes.appendSlice(self.alloc, s); } // ---- AArch64 instruction helpers (64-bit fixed-width instructions) ---- /// `movz rd, #imm16, lsl #(hw*16)` (zeroes the other lanes). fn arm64Movz(self: *Asm, rd: u5, imm: u16, hw: u32) !void { try self.u32le(0xD2800000 | (hw << 21) | (@as(u32, imm) << 5) | rd); } /// `movk rd, #imm16, lsl #(hw*16)` (keeps the other lanes). fn arm64Movk(self: *Asm, rd: u5, imm: u16, hw: u32) !void { try self.u32le(0xF2800000 | (hw << 21) | (@as(u32, imm) << 5) | rd); } /// `mov rd, #imm` as a movz/movk chain (handles arbitrary 64-bit values). fn arm64MovImm(self: *Asm, rd: u5, imm: u64) !void { var hw: u6 = 0; var first = true; while (!first or (imm >> (16 * hw)) != 0) { const chunk: u16 = @intCast((imm >> (16 * hw)) & 0xffff); if (chunk != 0 or first) { if (first) try self.arm64Movz(rd, chunk, hw) else try self.arm64Movk(rd, chunk, hw); first = false; } if (hw == 3) break; // all 64 bits visited; shifting by 64 is UB hw += 1; } } /// `svc #0` (kernel syscall entry). fn arm64Svc(self: *Asm) !void { try self.u32le(0xD4000001); } /// `ret` (x30). fn arm64Ret(self: *Asm) !void { try self.u32le(0xD65F03C0); } /// `str x0, [sp, #-16]!` (pre-index; keeps sp 16-byte aligned). fn arm64PushX0(self: *Asm) !void { try self.u32le(0xF81F0FE0); } /// `ldr x1, [sp], #16` (post-index; unwinds the push). fn arm64PopToX1(self: *Asm) !void { try self.u32le(0xF84107E1); } /// `str x30, [sp, #-16]!` (callee-save of the link register). fn arm64PushLR(self: *Asm) !void { try self.u32le(0xF81F0FFE); } /// `ldr x30, [sp], #16` (restore of the link register). fn arm64PopLR(self: *Asm) !void { try self.u32le(0xF84107FE); } /// `add x0, x1, x0`. fn arm64AddX1X0(self: *Asm) !void { try self.u32le(0x8B000020); } /// `sub x0, x1, x0`. fn arm64SubX1X0(self: *Asm) !void { try self.u32le(0xCB000020); } /// `neg x0, x0` (sub x0, xzr, x0). fn arm64NegX0(self: *Asm) !void { try self.u32le(0xCB0003E0); } /// `bl ` as `bl #0` + record the arm64 imm26 reloc. fn arm64BlText(self: *Asm, text: usize) !void { try self.u32le(0x94000000); const field_off = self.len() - 4; try self.relocs.append(self.alloc, .{ .arm64_bl_text = .{ .field_off = field_off, .pc_off = field_off, .text = text } }); } /// `adrp x1, #0`; points at the page containing rodata[`rodata`]. fn arm64AdrpX1(self: *Asm, rodata: usize) !void { try self.u32le(0x90000001); const field_off = self.len() - 4; try self.relocs.append(self.alloc, .{ .arm64_adrp_rodata = .{ .field_off = field_off, .pc_off = field_off, .rodata = rodata } }); } /// `add x1, x1, #0`; patched with the rodata address's low 12 bits. fn arm64AddX1Lo12(self: *Asm, rodata: usize) !void { try self.u32le(0x91000021); try self.relocs.append(self.alloc, .{ .arm64_add_lo12 = .{ .field_off = self.len() - 4, .rodata = rodata } }); } }; /// Deduplicating string table forming the `.rodata` region (starts with a /// reserved scratch tail only used by future formatting; currently empty). const StrTab = struct { alloc: std.mem.Allocator, strings: std.ArrayList([]const u8) = .empty, fn init(alloc: std.mem.Allocator) StrTab { return .{ .alloc = alloc }; } fn deinit(self: *StrTab) void { for (self.strings.items) |s| self.alloc.free(s); self.strings.deinit(self.alloc); } /// Offset within the rodata buffer of the string interned (added if new). fn intern(self: *StrTab, s: []const u8) !usize { for (self.strings.items, 0..) |existing, i| { if (std.mem.eql(u8, existing, s)) return offsetOf(self, i); } const copy = try self.alloc.dupe(u8, s); const idx = self.strings.items.len; try self.strings.append(self.alloc, copy); return offsetOf(self, idx); } fn offsetOf(self: *const StrTab, idx: usize) usize { var off: usize = 0; for (self.strings.items[0..idx]) |s| { off += s.len + 1; // NUL-terminated } return off; } /// Serialize as a NUL-terminated byte set. fn build(self: *const StrTab, out_alloc: std.mem.Allocator) ![]u8 { var buf: std.ArrayList(u8) = .empty; for (self.strings.items) |s| { try buf.appendSlice(out_alloc, s); try buf.append(out_alloc, 0); } return buf.toOwnedSlice(out_alloc); } }; /// `_start` fixed-length preamble (x86-64): /// call rg_main ; 48 ... 5 bytes /// mov rdi, rax ; 3 bytes /// mov eax, 60 ; 5 bytes /// syscall ; 2 bytes const START_LEN = 5 + 3 + 5 + 2; /// AArch64 `_start` fixed-length preamble: /// bl rg_main ; 4 bytes /// movz x8, #93 ; 4 bytes /// svc #0 ; 4 bytes const START_LEN_ARM64 = 12; fn startLen(arch: Arch) usize { return switch (arch) { .x86_64 => START_LEN, .aarch64 => START_LEN_ARM64, }; } fn emitStart(a: *Asm, main_off: usize) Error!void { switch (a.arch) { .x86_64 => { try a.callText(main_off); try a.movRdiRax(); try a.movEaxImm32(SYS_EXIT); try a.syscall(); }, .aarch64 => { // rg_main returns the exit status in x0 (AArch64 return register), // so `_start` only needs the exit syscall after the call. try a.arm64BlText(main_off); try a.arm64MovImm(8, ARM64_SYS_EXIT); try a.arm64Svc(); }, } } fn findMain(prog: *const parser.Program) ?*const parser.FnDecl { for (prog.fn_decls) |*fd| { if (std.mem.eql(u8, fd.name, "main")) return fd; } return null; } fn failCtx(err_buf: ?ErrBuf, msg: []const u8) Error { if (err_buf) |eb| eb.write("{s}\n", .{msg}); return error.UnsupportedRinne; } /// Raise an `UnsupportedRinne` and record a reason into `err_buf`. fn unsupported(err_buf: ?ErrBuf, comptime fmt: []const u8, args: anytype) Error { if (err_buf) |eb| eb.write(fmt, args); return error.UnsupportedRinne; } /// A write-through reporter for `UnsupportedRinne` diagnostics. pub const ErrBuf = struct { alloc: std.mem.Allocator, out: *std.ArrayList(u8), fn write(self: ErrBuf, comptime fmt: []const u8, args: anytype) void { const s = std.fmt.allocPrint(self.alloc, fmt, args) catch return; defer self.alloc.free(s); self.out.appendSlice(self.alloc, s) catch {}; } }; /// Lower `prog` into complete `.rgx` executable bytes. On `error.UnsupportedRinne`, /// `err_buf` (if non-null) receives a human-readable reason. pub fn lower(alloc: std.mem.Allocator, prog: *const parser.Program, err_buf: ?ErrBuf) Error![]u8 { const main_fn = findMain(prog) orelse return failCtx(err_buf, "native .rgx build needs a 'fn main() int' entry point"); const arch = try hostArch(); var a = Asm.init(alloc, arch); defer a.deinit(); var strs = StrTab.init(alloc); defer strs.deinit(); // Text layout: [ _start ][ rg_main ]. `_start` is fixed-length so rg_main // begins immediately after it. const main_off = startLen(arch); try emitStart(&a, main_off); const main_len = try emitMain(&a, &strs, main_fn, err_buf); _ = main_len; const rodata = try strs.build(alloc); defer alloc.free(rodata); var img = elf_emit.Image{ .text = a.bytes.items, .rodata = rodata, }; _ = elf_emit.layout(&img); try patchRelocs(&a, &img); return elf_emit.assemble(alloc, &img); } /// Emit the body of `fn main`. Returns its length in bytes. fn emitMain(a: *Asm, strs: *StrTab, main_fn: *const parser.FnDecl, err_buf: ?ErrBuf) Error!usize { const start = a.len(); // Entry point must be `fn main() int` with no parameters. if (main_fn.params.len != 0) return unsupported(err_buf, "native .rgx build needs 'fn main()' with no parameters\n", .{}); // AArch64 `bl`/`ret` go through the link register (x30), which nested // `bl` calls inside the body would clobber, so rg_main saves/restores it // around the body. switch (a.arch) { .x86_64 => {}, .aarch64 => try a.arm64PushLR(), } for (main_fn.body.stmts) |*stmt| switch (stmt.*) { .expr_stmt => |e| { if (e != .call_directive or !std.mem.eql(u8, e.call_directive.name, "stdout")) return unsupported(err_buf, "only '@stdout' calls are supported in native build\n", .{}); try emitStdout(a, strs, e.call_directive.args, err_buf); }, .return_stmt => |r| { if (r) |e| { if (!isIntExpr(e)) return unsupported(err_buf, "only integer expressions are supported in 'fn main'\n", .{}); try lowerInt(a, e); } }, else => return unsupported(err_buf, "only 'return' and '@stdout' are supported in 'fn main'\n", .{}), }; // Fall-through path must still produce a value; the return register holds // whatever the last statement left, plus an explicit ret to unwind. switch (a.arch) { .x86_64 => try a.ret(), .aarch64 => { try a.arm64PopLR(); try a.arm64Ret(); }, } return a.len() - start; } /// Emit `write(fd=1, "content", n)`. fn emitStdout(a: *Asm, strs: *StrTab, args: []const parser.Expr, err_buf: ?ErrBuf) Error!void { // Expected shape: @stdout("literal", .{}) if (args.len != 2) return unsupported(err_buf, "@stdout expects a format string and a tuple\n", .{}); const fmt = args[0]; const tuple = args[1]; if (fmt != .string_lit) return unsupported(err_buf, "@stdout expects a string literal format\n", .{}); if (tuple != .array_lit) return unsupported(err_buf, "@stdout expects a tuple argument\n", .{}); if (tuple.array_lit.len != 0) return unsupported(err_buf, "integer '{{}}' formatting is not yet supported in native build\n", .{}); const s = fmt.string_lit; const ro = try strs.intern(s); try emitWriteLiteral(a, ro, s.len); } /// `write(fd=1, buf=rodata[ro], len)` for the host arch. fn emitWriteLiteral(a: *Asm, rodata: usize, len: usize) Error!void { switch (a.arch) { .x86_64 => { try a.movEaxImm32(SYS_WRITE); try a.movEdiImm32(STDOUT_FD); try a.leaRsiRip(rodata); try a.movEdxImm32(@intCast(len)); try a.syscall(); }, .aarch64 => { try a.arm64MovImm(8, ARM64_SYS_WRITE); try a.arm64MovImm(0, STDOUT_FD); try a.arm64AdrpX1(rodata); try a.arm64AddX1Lo12(rodata); try a.arm64MovImm(2, len); try a.arm64Svc(); }, } } /// Lower an integer expression; result in the arch return register (rax/x0). fn lowerInt(a: *Asm, e: parser.Expr) Error!void { switch (a.arch) { .x86_64 => try lowerIntX64(a, e), .aarch64 => try lowerIntArm64(a, e), } } fn lowerIntX64(a: *Asm, e: parser.Expr) Error!void { switch (e) { .int_lit => |v| try a.movRaxImm64(@bitCast(v)), .unary => |u| { try lowerIntX64(a, u.operand.*); if (u.op == .minus) try a.negRax(); }, .binary => |b| { try lowerIntX64(a, b.lhs.*); try a.pushRax(); try lowerIntX64(a, b.rhs.*); try a.movRcxRax(); try a.popRax(); switch (b.op) { .plus => try a.addRaxRcx(), .minus => try a.subRaxRcx(), else => return error.InvalidProgram, } }, else => return error.InvalidProgram, } } fn lowerIntArm64(a: *Asm, e: parser.Expr) Error!void { switch (e) { .int_lit => |v| try a.arm64MovImm(0, @bitCast(v)), .unary => |u| { try lowerIntArm64(a, u.operand.*); if (u.op == .minus) try a.arm64NegX0(); }, .binary => |b| { try lowerIntArm64(a, b.lhs.*); try a.arm64PushX0(); try lowerIntArm64(a, b.rhs.*); try a.arm64PopToX1(); switch (b.op) { .plus => try a.arm64AddX1X0(), .minus => try a.arm64SubX1X0(), else => return error.InvalidProgram, } }, else => return error.InvalidProgram, } } fn isIntExpr(e: parser.Expr) bool { switch (e) { .int_lit => return true, .unary => |u| return u.op == .minus and isIntExpr(u.operand.*), .binary => |b| { if (b.op == .plus or b.op == .minus) return isIntExpr(b.lhs.*) and isIntExpr(b.rhs.*); return false; }, else => return false, } } /// Patch recorded relocations using the final layout. All x86-64 arithmetic /// is done in offsets; `LOAD_BASE` cancels out for both text and rodata /// targets. AArch64 uses the absolute virtual addresses (LOAD_BASE is /// page-aligned, so page deltas are just bare offset differences). fn patchRelocs(a: *Asm, img: *elf_emit.Image) Error!void { const text_off = elf_emit.ELF_HEADER_SIZE + elf_emit.PHDR_COUNT * elf_emit.PHDR_SIZE; switch (a.arch) { .x86_64 => { // rodata begins at text_off + (rounded text length); recompute same as layout. for (a.relocs.items) |rel| switch (rel) { .call_text => |c| { // disp = text[c.text] - text[c.rip_off] const disp = @as(i64, @intCast(c.text)) - @as(i64, @intCast(c.rip_off)); writeI32(a.bytes.items, c.field_off, @intCast(disp)); }, .rip_rodata => |r| { // target = LOAD + text_off + (rodata_off - text_off) + r.rodata // rip = LOAD + text_off + r.rip_off const rodata_off = alignA(text_off + img.text.len, 16); const target = rodata_off - text_off + r.rodata; const disp = @as(i64, @intCast(target)) - @as(i64, @intCast(r.rip_off)); writeI32(a.bytes.items, r.field_off, @intCast(disp)); }, else => unreachable, }; }, .aarch64 => { for (a.relocs.items) |rel| switch (rel) { .arm64_bl_text => |c| { // imm26 = (target - pc) >> 2, within ±128 MiB. const pc = elf_emit.LOAD_BASE + @as(u64, text_off + c.pc_off); const target = elf_emit.LOAD_BASE + @as(u64, text_off + c.text); const imm26: u32 = @intCast((target - pc) >> 2); const instr = readU32(a.bytes.items, c.field_off); writeU32(a.bytes.items, c.field_off, (instr & 0xFC000000) | (imm26 & 0x03FFFFFF)); }, .arm64_adrp_rodata => |r| { // imm21 = (page(rodata) - page(pc)) >> 12 -> immhi:immlo. const rodata_off = alignA(text_off + img.text.len, 16); const pc = elf_emit.LOAD_BASE + @as(u64, text_off + r.pc_off); const target = elf_emit.LOAD_BASE + @as(u64, rodata_off + r.rodata); const dp: i64 = @as(i64, @intCast(target & ~@as(u64, 0xfff))) - @as(i64, @intCast(pc & ~@as(u64, 0xfff))); const imm21: u32 = @bitCast(@as(i32, @intCast(dp >> 12))); const immlo = imm21 & 0x3; const immhi = (imm21 >> 2) & 0x7FFFF; const instr = readU32(a.bytes.items, r.field_off); writeU32(a.bytes.items, r.field_off, (instr & 0x9E00001F) | (immlo << 29) | (immhi << 5)); }, .arm64_add_lo12 => |r| { // imm12 = rodata & 0xfff (page low bits of the target). const rodata_off = alignA(text_off + img.text.len, 16); const target = elf_emit.LOAD_BASE + @as(u64, rodata_off + r.rodata); const lo12: u32 = @intCast(target & 0xfff); const instr = readU32(a.bytes.items, r.field_off); writeU32(a.bytes.items, r.field_off, (instr & ~@as(u32, 0x003FFC00)) | (lo12 << 10)); }, else => unreachable, }; }, } } fn alignA(v: usize, a: usize) usize { return (v + a - 1) & ~(a - 1); } fn writeI32(bytes: []u8, off: usize, val: i32) void { const b = std.mem.toBytes(val); @memcpy(bytes[off .. off + 4], &b); } fn readU32(bytes: []const u8, off: usize) u32 { var b: [4]u8 = undefined; @memcpy(&b, bytes[off .. off + 4]); return std.mem.readInt(u32, &b, .little); } fn writeU32(bytes: []u8, off: usize, val: u32) void { const b = std.mem.toBytes(val); @memcpy(bytes[off .. off + 4], &b); } const testing = std.testing; /// Parse, lower, write+chmod, run, and check the resulting `.rgx` binary. fn runCompiled(alloc: std.mem.Allocator, src: []const u8, expect_exit: u8, expect_out: []const u8) !void { var p = parser.Parser.init(alloc, src); defer p.deinit(); var prog = try p.parseProgram(); defer prog.deinit(alloc); var err_buf: std.ArrayList(u8) = .empty; defer err_buf.deinit(alloc); const blob = try lower(alloc, &prog, .{ .alloc = alloc, .out = &err_buf }); defer alloc.free(blob); const io = std.testing.io; const path = "/tmp/rg_rinnescript_native"; const cwd = std.Io.Dir.cwd(); cwd.writeFile(io, .{ .sub_path = path, .data = blob }) catch return error.TestUnexpectedResult; defer { cwd.deleteFile(io, path) catch {}; } const path_z = try alloc.dupeZ(u8, path); defer alloc.free(path_z); try testing.expect(std.os.linux.chmod(path_z, 0o755) == 0); var child = std.process.spawn(io, .{ .argv = &.{path}, .stdout = .pipe, .stderr = .ignore, }) catch return error.TestUnexpectedResult; // Read the (tiny) stdout while the child runs, then wait. var rbuf: [1024]u8 = undefined; const out_file = child.stdout orelse return error.TestUnexpectedResult; var rr = out_file.reader(io, &rbuf); const got = rr.interface.readSliceShort(rbuf[0..]) catch return error.TestUnexpectedResult; _ = &out_file; const term = child.wait(io) catch return error.TestUnexpectedResult; switch (term) { .exited => |code| { if (expect_exit != 0) try testing.expectEqual(expect_exit, code); if (expect_out.len > 0) try testing.expect(std.mem.eql(u8, rbuf[0..got], expect_out)); }, else => return error.TestUnexpectedResult, } } test "rgx: hello world writes via syscall and exits 0" { const src = \\fn main() int { \\ @stdout("hello native\n", .{}); \\ @stdout("world\n", .{}); \\ return 0; \\} ; try runCompiled(testing.allocator, src, 0, "hello native\nworld\n"); } test "rgx: integer expression becomes exit status" { const src = \\fn main() int { \\ return 1 + 2 + 3; \\} ; try runCompiled(testing.allocator, src, 6, ""); } test "rgx: integer args to @stdout are rejected (not miscompiled)" { const src = \\fn main() int { \\ @stdout("x={}\n", .{4}); \\ return 0; \\} ; var p = parser.Parser.init(testing.allocator, src); defer p.deinit(); var prog = try p.parseProgram(); defer prog.deinit(testing.allocator); var err_buf: std.ArrayList(u8) = .empty; defer err_buf.deinit(testing.allocator); const res = lower(testing.allocator, &prog, .{ .alloc = testing.allocator, .out = &err_buf }); try testing.expectError(error.UnsupportedRinne, res); } // --------------------------------------------------------------------------- // Linked assembly backend (`--native`, links against libc & other .so files). // // Rather than hand-assembling a relocation-free ELF, this emits GNU assembly // for the host architecture (x86-64 AT&T syntax, or AArch64 syntax) and lets // the system linker (`cc -nostartfiles`) assemble + link it against libc and // any `-l`/`-L` the user passes. `_start` calls the Rinne `main`, then hands // the result to libc `exit`; `@stdout` goes through libc `write`. This is the // path that supports "link with libc and other libs". // --------------------------------------------------------------------------- /// Lower `prog` into GNU assembly source for the build-host architecture. On /// `error.UnsupportedRinne`, `err_buf` (if non-null) receives a human-readable /// reason. pub fn lowerAssembly(alloc: std.mem.Allocator, prog: *const parser.Program, err_buf: ?ErrBuf) Error![]u8 { switch (try hostArch()) { .x86_64 => return lowerAssemblyX64(alloc, prog, err_buf), .aarch64 => return lowerAssemblyArm64(alloc, prog, err_buf), } } /// x86-64 AT&T assembly emitter. fn lowerAssemblyX64(alloc: std.mem.Allocator, prog: *const parser.Program, err_buf: ?ErrBuf) Error![]u8 { const main_fn = findMain(prog) orelse return failCtx(err_buf, "native build needs a 'fn main() int' entry point"); var buf: std.ArrayList(u8) = .empty; defer buf.deinit(alloc); // Externs resolved against libc / linked libs by the system linker. try wline(&buf, alloc, " .extern write"); try wline(&buf, alloc, " .extern exit"); var seen: std.ArrayList([]const u8) = .empty; defer seen.deinit(alloc); try collectLibcallSyms(&seen, alloc, main_fn); for (seen.items) |sym| { try wprint(&buf, alloc, " .extern {s}", .{sym}); } try buf.appendSlice(alloc, \\ .text \\ .globl _start \\_start: \\ call rg_main \\ mov %rax, %rdi \\ call exit \\ ); try emitMainAsm(&buf, alloc, main_fn, err_buf); try buf.appendSlice(alloc, \\ .section .rodata \\ ); try appendRosym(&buf, alloc, main_fn); // rodata labels + bytes return buf.toOwnedSlice(alloc); } /// Emit `rg_main` body (statements), leaving the result in rax, then `ret`. fn emitMainAsm(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, main_fn: *const parser.FnDecl, err_buf: ?ErrBuf) Error!void { try buf.appendSlice(alloc, "rg_main:\n"); var str_i: usize = 0; for (main_fn.body.stmts) |*stmt| switch (stmt.*) { .expr_stmt => |e| { if (e != .call_directive) return unsupported(err_buf, "only '@stdout' and '@libcall' calls are supported in 'fn main'\n", .{}); if (std.mem.eql(u8, e.call_directive.name, "stdout")) { emitStdoutAsm(buf, alloc, &str_i, e.call_directive.args, err_buf) catch |er| switch (er) { error.UnsupportedRinne => return er, else => return er, }; } else if (std.mem.eql(u8, e.call_directive.name, "libcall")) { try emitLibcallAsm(buf, alloc, e.call_directive.args, err_buf); } else { return unsupported(err_buf, "only '@stdout' and '@libcall' calls are supported in 'fn main'\n", .{}); } }, .return_stmt => |r| { if (r) |ex| { if (ex == .call_directive and std.mem.eql(u8, ex.call_directive.name, "libcall")) { try emitLibcallAsm(buf, alloc, ex.call_directive.args, err_buf); } else { if (!isIntExpr(ex)) return unsupported(err_buf, "only integer expressions (or @libcall) are supported in 'return'\n", .{}); try lowerIntAsm(buf, alloc, ex); } } }, else => return unsupported(err_buf, "only 'return', '@stdout' and '@libcall' are supported in 'fn main'\n", .{}), }; try buf.appendSlice(alloc, " ret\n"); } /// Collect the unique `.extern` symbol names referenced via `@libcall`. fn collectLibcallSyms(seen: *std.ArrayList([]const u8), alloc: std.mem.Allocator, main_fn: *const parser.FnDecl) Error!void { for (main_fn.body.stmts) |*stmt| switch (stmt.*) { .expr_stmt => |e| { if (e == .call_directive and std.mem.eql(u8, e.call_directive.name, "libcall")) { if (e.call_directive.args.len == 2 and e.call_directive.args[0] == .string_lit) try addSym(seen, alloc, e.call_directive.args[0].string_lit); } }, .return_stmt => |r| { if (r) |ex| { if (ex == .call_directive and std.mem.eql(u8, ex.call_directive.name, "libcall")) { if (ex.call_directive.args.len == 2 and ex.call_directive.args[0] == .string_lit) try addSym(seen, alloc, ex.call_directive.args[0].string_lit); } } }, else => {}, }; } fn addSym(seen: *std.ArrayList([]const u8), alloc: std.mem.Allocator, sym: []const u8) Error!void { for (seen.items) |s| if (std.mem.eql(u8, s, sym)) return; try seen.append(alloc, sym); } /// Emit `call ` with the given integer args in System V registers. fn emitLibcallAsm(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, args: []const parser.Expr, err_buf: ?ErrBuf) Error!void { // Expected shape: @libcall("symbol", .{arg1, arg2, ...}). if (args.len != 2) return unsupported(err_buf, "@libcall expects a symbol name and a tuple of integer args (e.g. a call like @libcall(\"getpid\", tuple) )\n", .{}); if (args[0] != .string_lit) return unsupported(err_buf, "@libcall expects a string symbol name as its first argument\n", .{}); if (args[1] != .array_lit) return unsupported(err_buf, "@libcall expects a tuple of arguments as its second argument\n", .{}); const sym = args[0].string_lit; const tuple = args[1].array_lit; const max_args = 6; // rdi, rsi, rdx, rcx, r8, r9 if (tuple.len > max_args) return unsupported(err_buf, "@libcall supports at most 6 integer arguments in native build\n", .{}); for (tuple, 0..) |arg_expr, i| { if (!isIntExpr(arg_expr)) return unsupported(err_buf, "@libcall arguments must be integer expressions in native build\n", .{}); const reg = svereg(alloc, i) catch return error.OutOfMemory; defer alloc.free(reg); try lowerIntAsm(buf, alloc, arg_expr); try wprint(buf, alloc, " mov %rax, %{s}", .{reg}); } // Result lands in rax (System V). The caller may use or discard it. try wprint(buf, alloc, " call {s}", .{sym}); } /// The i-th System V integer argument register (0-based). fn svereg(alloc: std.mem.Allocator, i: usize) ![]const u8 { const reg = switch (i) { 0 => "rdi", 1 => "rsi", 2 => "rdx", 3 => "rcx", 4 => "r8", 5 => "r9", else => return error.InvalidProgram, }; return alloc.dupe(u8, reg); } /// Emit a `write(fd=1, "literal", len)` through libc's `write`. fn emitStdoutAsm(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, str_i: *usize, args: []const parser.Expr, err_buf: ?ErrBuf) Error!void { if (args.len != 2) return unsupported(err_buf, "@stdout expects a format string and a tuple\n", .{}); const fmt = args[0]; const tuple = args[1]; if (fmt != .string_lit) return unsupported(err_buf, "@stdout expects a string literal format\n", .{}); if (tuple != .array_lit) return unsupported(err_buf, "@stdout expects a tuple argument\n", .{}); if (tuple.array_lit.len != 0) return unsupported(err_buf, "integer '{{}}' formatting is not yet supported in native build\n", .{}); const s = fmt.string_lit; const label = str_i.*; try wline(buf, alloc, " mov $1, %eax"); try wline(buf, alloc, " mov $1, %edi"); try wprint(buf, alloc, " lea .Ls{d}(%rip), %rsi", .{label}); try wprint(buf, alloc, " mov ${d}, %edx", .{s.len}); try wline(buf, alloc, " call write"); str_i.* += 1; } /// Lower an integer expression to assembly; result in rax. fn lowerIntAsm(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, e: parser.Expr) Error!void { switch (e) { .int_lit => |v| try wprint(buf, alloc, " mov ${d}, %rax", .{v}), .unary => |u| { try lowerIntAsm(buf, alloc, u.operand.*); if (u.op == .minus) try wline(buf, alloc, " neg %rax"); }, .binary => |b| { try lowerIntAsm(buf, alloc, b.lhs.*); try wline(buf, alloc, " push %rax"); try lowerIntAsm(buf, alloc, b.rhs.*); try wline(buf, alloc, " mov %rax, %rcx"); try wline(buf, alloc, " pop %rax"); switch (b.op) { .plus => try wline(buf, alloc, " add %rcx, %rax"), .minus => try wline(buf, alloc, " sub %rcx, %rax"), else => return error.InvalidProgram, } }, else => return error.InvalidProgram, } } /// AArch64 assembly emitter: `_start` calls `rg_main` (result in x0) and /// forwards it to libc `exit`; statements and integer expressions follow the /// AArch64 calling convention (x0..x7 for args, x0 for the result). fn lowerAssemblyArm64(alloc: std.mem.Allocator, prog: *const parser.Program, err_buf: ?ErrBuf) Error![]u8 { const main_fn = findMain(prog) orelse return failCtx(err_buf, "native build needs a 'fn main() int' entry point"); var buf: std.ArrayList(u8) = .empty; defer buf.deinit(alloc); // Externs resolved against libc / linked libs by the system linker. try wline(&buf, alloc, " .extern write"); try wline(&buf, alloc, " .extern exit"); var seen: std.ArrayList([]const u8) = .empty; defer seen.deinit(alloc); try collectLibcallSyms(&seen, alloc, main_fn); for (seen.items) |sym| { try wprint(&buf, alloc, " .extern {s}", .{sym}); } try buf.appendSlice(alloc, \\ .text \\ .globl _start \\_start: \\ bl rg_main \\ bl exit \\ ); try emitMainAsmArm64(&buf, alloc, main_fn, err_buf); try buf.appendSlice(alloc, \\ .section .rodata \\ ); try appendRosym(&buf, alloc, main_fn); // rodata labels + bytes return buf.toOwnedSlice(alloc); } /// Emit `rg_main` body for AArch64 (statements), leaving the result in x0, /// then `ret`. fn emitMainAsmArm64(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, main_fn: *const parser.FnDecl, err_buf: ?ErrBuf) Error!void { try buf.appendSlice(alloc, "rg_main:\n"); // Save the link register: `bl write`/`bl ` inside the body would // otherwise clobber it and break the final `ret`. try wline(buf, alloc, " str x30, [sp, #-16]!"); var str_i: usize = 0; for (main_fn.body.stmts) |*stmt| switch (stmt.*) { .expr_stmt => |e| { if (e != .call_directive) return unsupported(err_buf, "only '@stdout' and '@libcall' calls are supported in 'fn main'\n", .{}); if (std.mem.eql(u8, e.call_directive.name, "stdout")) { try emitStdoutAsmArm64(buf, alloc, &str_i, e.call_directive.args, err_buf); } else if (std.mem.eql(u8, e.call_directive.name, "libcall")) { try emitLibcallAsmArm64(buf, alloc, e.call_directive.args, err_buf); } else { return unsupported(err_buf, "only '@stdout' and '@libcall' calls are supported in 'fn main'\n", .{}); } }, .return_stmt => |r| { if (r) |ex| { if (ex == .call_directive and std.mem.eql(u8, ex.call_directive.name, "libcall")) { try emitLibcallAsmArm64(buf, alloc, ex.call_directive.args, err_buf); } else { if (!isIntExpr(ex)) return unsupported(err_buf, "only integer expressions (or @libcall) are supported in 'return'\n", .{}); try lowerIntAsmArm64(buf, alloc, ex); } } }, else => return unsupported(err_buf, "only 'return', '@stdout' and '@libcall' are supported in 'fn main'\n", .{}), }; try wline(buf, alloc, " ldr x30, [sp], #16"); try buf.appendSlice(alloc, " ret\n"); } /// Emit `mov x, #imm` as a movz/movk sequence the assembler will accept /// for any 64-bit constant. fn arm64MovImmAsm(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, rd: usize, imm: u64) Error!void { var hw: u6 = 0; var first = true; while (!first or (imm >> (16 * hw)) != 0) { const chunk: u16 = @intCast((imm >> (16 * hw)) & 0xffff); if (chunk != 0 or first) { if (first) { try wprint(buf, alloc, " movz x{d}, #0x{x}", .{ rd, chunk }); } else { try wprint(buf, alloc, " movk x{d}, #0x{x}, lsl #{d}", .{ rd, chunk, 16 * hw }); } first = false; } if (hw == 3) break; // all 64 bits visited; shifting by 64 is UB hw += 1; } } /// Emit `call ` with the given integer args in AArch64 registers x0..x7. fn emitLibcallAsmArm64(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, args: []const parser.Expr, err_buf: ?ErrBuf) Error!void { // Expected shape: @libcall("symbol", .{arg1, arg2, ...}). if (args.len != 2) return unsupported(err_buf, "@libcall expects a symbol name and a tuple of integer args (e.g. a call like @libcall(\"getpid\", tuple) )\n", .{}); if (args[0] != .string_lit) return unsupported(err_buf, "@libcall expects a string symbol name as its first argument\n", .{}); if (args[1] != .array_lit) return unsupported(err_buf, "@libcall expects a tuple of arguments as its second argument\n", .{}); const sym = args[0].string_lit; const tuple = args[1].array_lit; const max_args = 8; // x0..x7 if (tuple.len > max_args) return unsupported(err_buf, "@libcall supports at most 8 integer arguments in native build\n", .{}); // x0 is both the evaluation scratch and arg0, so park arg0 in x16 (the // caller-saved IP0 scratch register) until every argument is materialized. for (tuple, 0..) |arg_expr, i| { if (!isIntExpr(arg_expr)) return unsupported(err_buf, "@libcall arguments must be integer expressions in native build\n", .{}); try lowerIntAsmArm64(buf, alloc, arg_expr); if (i == 0) { if (tuple.len > 1) try wline(buf, alloc, " mov x16, x0"); } else { try wprint(buf, alloc, " mov x{d}, x0", .{i}); } } if (tuple.len > 1) try wline(buf, alloc, " mov x0, x16"); // Result lands in x0 (AArch64). The caller may use or discard it. try wprint(buf, alloc, " bl {s}", .{sym}); } /// Emit a `write(fd=1, "literal", len)` through libc's `write` (AArch64). fn emitStdoutAsmArm64(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, str_i: *usize, args: []const parser.Expr, err_buf: ?ErrBuf) Error!void { if (args.len != 2) return unsupported(err_buf, "@stdout expects a format string and a tuple\n", .{}); const fmt = args[0]; const tuple = args[1]; if (fmt != .string_lit) return unsupported(err_buf, "@stdout expects a string literal format\n", .{}); if (tuple != .array_lit) return unsupported(err_buf, "@stdout expects a tuple argument\n", .{}); if (tuple.array_lit.len != 0) return unsupported(err_buf, "integer '{{}}' formatting is not yet supported in native build\n", .{}); const s = fmt.string_lit; const label = str_i.*; try wline(buf, alloc, " mov w0, #1"); try wprint(buf, alloc, " adrp x1, .Ls{d}", .{label}); try wprint(buf, alloc, " add x1, x1, :lo12:.Ls{d}", .{label}); try arm64MovImmAsm(buf, alloc, 2, @intCast(s.len)); try wline(buf, alloc, " bl write"); str_i.* += 1; } /// Lower an integer expression to AArch64 assembly; result in x0. fn lowerIntAsmArm64(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, e: parser.Expr) Error!void { switch (e) { .int_lit => |v| try arm64MovImmAsm(buf, alloc, 0, @bitCast(v)), .unary => |u| { try lowerIntAsmArm64(buf, alloc, u.operand.*); if (u.op == .minus) try wline(buf, alloc, " neg x0, x0"); }, .binary => |b| { try lowerIntAsmArm64(buf, alloc, b.lhs.*); try wline(buf, alloc, " str x0, [sp, #-16]!"); try lowerIntAsmArm64(buf, alloc, b.rhs.*); try wline(buf, alloc, " ldr x1, [sp], #16"); switch (b.op) { .plus => try wline(buf, alloc, " add x0, x1, x0"), .minus => try wline(buf, alloc, " sub x0, x1, x0"), else => return error.InvalidProgram, } }, else => return error.InvalidProgram, } } /// Append `.rodata` string labels/bytes collected by `emitStdoutAsm`. Emits /// byte values explicitly to avoid escaping/quoting surprises. fn appendRosym(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, main_fn: *const parser.FnDecl) Error!void { var str_i: usize = 0; for (main_fn.body.stmts) |*stmt| switch (stmt.*) { .expr_stmt => |e| { if (e == .call_directive and std.mem.eql(u8, e.call_directive.name, "stdout")) { const fmt = e.call_directive.args[0]; if (fmt == .string_lit) { try appendLabel(buf, alloc, str_i); try buf.appendSlice(alloc, " .byte "); const s = fmt.string_lit; for (s, 0..) |c, i| { if (i > 0) try buf.appendSlice(alloc, ","); const b = std.fmt.allocPrint(alloc, "{d}", .{c}) catch return error.OutOfMemory; defer alloc.free(b); try buf.appendSlice(alloc, b); } try buf.appendSlice(alloc, "\n"); str_i += 1; } } }, else => {}, }; } /// Append a `.rodata` string label line (`.Ls{N}:`). fn appendLabel(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, i: usize) Error!void { const s = std.fmt.allocPrint(alloc, ".Ls{d}:\n", .{i}) catch return error.OutOfMemory; defer alloc.free(s); try buf.appendSlice(alloc, s); } /// Append a literal line (already newline-terminated) to the assembly buffer. fn wline(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, s: []const u8) Error!void { try wprint(buf, alloc, "{s}\n", .{s}); } /// Append a formatted string (newline appended) to the assembly buffer. fn wprint(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, comptime fmt: []const u8, args: anytype) Error!void { const s = std.fmt.allocPrint(alloc, fmt ++ "\n", args) catch return error.OutOfMemory; defer alloc.free(s); try buf.appendSlice(alloc, s); } test "rgx assembly backend: emits linked libc skeleton + rodata bytes" { const src = \\fn main() int { \\ @stdout("hi\n", .{}); \\ return 3; \\} ; var p = parser.Parser.init(testing.allocator, src); defer p.deinit(); var prog = try p.parseProgram(); defer prog.deinit(testing.allocator); var err_buf: std.ArrayList(u8) = .empty; defer err_buf.deinit(testing.allocator); const asm_src = try lowerAssembly(testing.allocator, &prog, .{ .alloc = testing.allocator, .out = &err_buf }); defer testing.allocator.free(asm_src); try testing.expect(std.mem.indexOf(u8, asm_src, ".extern write") != null); try testing.expect(std.mem.indexOf(u8, asm_src, ".extern exit") != null); switch (builtin.cpu.arch) { .x86_64 => { try testing.expect(std.mem.indexOf(u8, asm_src, "call rg_main") != null); try testing.expect(std.mem.indexOf(u8, asm_src, "call write") != null); }, .aarch64 => { try testing.expect(std.mem.indexOf(u8, asm_src, "bl rg_main") != null); try testing.expect(std.mem.indexOf(u8, asm_src, "bl write") != null); }, else => return error.SkipZigTest, } try testing.expect(std.mem.indexOf(u8, asm_src, ".byte 104,105,10") != null); // "hi\n" } test "rgx assembly backend: @libcall emits extern + sysv arg registers + call" { const src = \\fn main() int { \\ return @libcall("addtwo", .{3, 4}); \\} ; var p = parser.Parser.init(testing.allocator, src); defer p.deinit(); var prog = try p.parseProgram(); defer prog.deinit(testing.allocator); var err_buf: std.ArrayList(u8) = .empty; defer err_buf.deinit(testing.allocator); const asm_src = try lowerAssembly(testing.allocator, &prog, .{ .alloc = testing.allocator, .out = &err_buf }); defer testing.allocator.free(asm_src); try testing.expect(std.mem.indexOf(u8, asm_src, ".extern addtwo") != null); switch (builtin.cpu.arch) { .x86_64 => { try testing.expect(std.mem.indexOf(u8, asm_src, "mov %rax, %rdi") != null); // arg0 try testing.expect(std.mem.indexOf(u8, asm_src, "mov %rax, %rsi") != null); // arg1 try testing.expect(std.mem.indexOf(u8, asm_src, "call addtwo") != null); }, .aarch64 => { try testing.expect(std.mem.indexOf(u8, asm_src, "mov x16, x0") != null); // arg0 parked try testing.expect(std.mem.indexOf(u8, asm_src, "mov x1, x0") != null); // arg1 try testing.expect(std.mem.indexOf(u8, asm_src, "bl addtwo") != null); }, else => return error.SkipZigTest, } }