feat(asm): Phase C.0 — add inline_asm IR op (lock, no behavior change)

Adds the `inline_asm: InlineAsm` opcode to the IR Op union (inst.zig): interned
template + operand list (role/name/constraint/operand) + interned clobber names
+ has_side_effects; the result rides on Inst.ty (void / scalar / tuple).

The new variant forces coverage in the exhaustive Op switches:
- interp.zig: loud bailDetail — inline asm is never comptime-evaluable.
- print.zig: an IR-dump arm.
- emit_llvm.zig: a @panic TRIPWIRE — emit lands in Phase D, and until then
  lowerAsmExpr still bails, so no inline_asm op is ever created. Reaching emit
  would mean lowering switched over before emit was ready; crash loudly rather
  than miscompile.

No behavior change: lowering still bails, the op is constructed only in the new
`inline_asm op shape` unit test (inst.test.zig).

zig build test green (652 corpus, 446 unit).
This commit is contained in:
agra
2026-06-15 21:00:12 +03:00
parent 5f444aae26
commit 6c08de8ec1
6 changed files with 120 additions and 14 deletions

View File

@@ -1563,6 +1563,11 @@ pub const LLVMEmitter = struct {
// ── Calls ─────────────────────────────────────────────
.objc_msg_send => |msg| self.ops().emitObjcMsgSend(instruction, msg),
.jni_msg_send => |msg| self.ops().emitJniMsgSend(instruction, msg),
// Tripwire (ASM stream): the IR op exists (Phase C.0) but emit lands
// in Phase D. Until then `lowerAsmExpr` still bails, so no inline_asm
// op is ever created — reaching here means lowering switched over
// before emit was ready. Crash loudly rather than miscompile.
.inline_asm => @panic("inline_asm reached LLVM emit before Phase D — lowering must still bail until emitInlineAsm lands"),
.call => |call_op| self.ops().emitCall(instruction, call_op),
.call_indirect => |call_op| self.ops().emitCallIndirect(instruction, call_op),

View File

@@ -10,6 +10,8 @@ const FuncId = inst_mod.FuncId;
const Inst = inst_mod.Inst;
const Block = inst_mod.Block;
const Function = inst_mod.Function;
const InlineAsm = inst_mod.InlineAsm;
const StringId = types.StringId;
test "Ref none sentinel" {
try std.testing.expect(Ref.none.isNone());
@@ -48,6 +50,40 @@ test "block creation" {
try std.testing.expectEqual(@as(usize, 2), block.insts.items.len);
}
test "inline_asm op shape (ASM stream Phase C.0)" {
// out_value (yields the value, operand = .none) + a named-less input,
// plus two clobbers; result rides on Inst.ty.
const operands = [_]InlineAsm.AsmOperand{
.{ .role = .out_value, .name = @enumFromInt(1), .constraint = @enumFromInt(2), .operand = Ref.none },
.{ .role = .input, .name = .empty, .constraint = @enumFromInt(3), .operand = Ref.fromIndex(5) },
};
const clobbers = [_]StringId{ @enumFromInt(4), @enumFromInt(6) };
const inst = Inst{
.op = .{ .inline_asm = .{
.template = @enumFromInt(10),
.operands = &operands,
.clobbers = &clobbers,
.has_side_effects = true,
} },
.ty = .i64,
};
switch (inst.op) {
.inline_asm => |a| {
try std.testing.expect(a.has_side_effects);
try std.testing.expectEqual(@as(usize, 2), a.operands.len);
try std.testing.expectEqual(@as(usize, 2), a.clobbers.len);
try std.testing.expectEqual(InlineAsm.AsmOperand.Role.out_value, a.operands[0].role);
// an out_value operand carries no input Ref — the asm yields it
try std.testing.expect(a.operands[0].operand.isNone());
try std.testing.expectEqual(InlineAsm.AsmOperand.Role.input, a.operands[1].role);
try std.testing.expectEqual(Ref.fromIndex(5), a.operands[1].operand);
// an anonymous operand uses the `.empty` StringId sentinel
try std.testing.expectEqual(StringId.empty, a.operands[1].name);
},
else => unreachable,
}
}
test "function creation" {
const alloc = std.testing.allocator;
const params = &[_]Function.Param{

View File

@@ -226,6 +226,13 @@ pub const Op = union(enum) {
/// Method-ID caching across call sites is added in step 1.17.
jni_msg_send: JniMsgSend,
/// `asm volatile? { "tmpl", operands…, clobbers(.…) }` — inline assembly
/// (ASM stream, design §II.6). emit_llvm.zig assembles the LLVM constraint
/// string + rewrites the `%[name]` template, then `LLVMGetInlineAsm` +
/// `LLVMBuildCall2`. The result rides on `Inst.ty` (void / a scalar / a tuple
/// of the `out_value` types). Never comptime-evaluable — the interp bails.
inline_asm: InlineAsm,
// ── Closure creation ────────────────────────────────────────────
closure_create: ClosureCreate,
@@ -339,6 +346,35 @@ pub const ObjcMsgSend = struct {
args: []const Ref, // additional args after recv + sel
};
/// Inline assembly payload (design §II.6). All strings interned; operands in
/// SOURCE ORDER (= the `%N` index space and the LLVM constraint order). The
/// result type rides on `Inst.ty`: void (no value outputs), a scalar (one), or
/// a tuple (N). emit_llvm.zig owns the constraint-string assembly + `%[name]`
/// template rewrite.
pub const InlineAsm = struct {
/// Interned template, RAW — the `%[name]`→`${N}` rewrite happens at emit.
template: StringId,
/// Declaration order preserved (keys `%N` and the LLVM operand order).
operands: []const AsmOperand,
/// Interned dot-names from `clobbers(.…)`: "rcx", "cc", "memory", …
clobbers: []const StringId,
/// `volatile` — passed as LLVM `HasSideEffects`.
has_side_effects: bool,
pub const AsmOperand = struct {
role: Role,
/// Effective operand name (explicit `[name]` or auto-derived register);
/// `.empty` when anonymous.
name: StringId,
/// Verbatim constraint, e.g. "={rax}", "=r", "+r", "{rdi}", "r".
constraint: StringId,
/// `input` → the value `Ref`; `out_value` → `.none` (the asm yields it).
operand: Ref,
pub const Role = enum { out_value, out_place, input };
};
};
/// JNI dispatch payload. `env` is `JNIEnv*` (typed as ptr); `target`
/// is a `jobject` for instance calls and a `jclass` for static calls.
/// `name` and `sig` are pointers to NUL-terminated bytes (typically

View File

@@ -1015,6 +1015,8 @@ pub const Interpreter = struct {
.objc_msg_send => return bailDetail("#objc_call not available at comptime (no Obj-C runtime)"),
// Same story for JNI — no JVM at compile time.
.jni_msg_send => return bailDetail("#jni_call not available at comptime (no JVM)"),
// Inline asm executes target machine code — never comptime-evaluable.
.inline_asm => return bailDetail("inline assembly requires native execution; not available at comptime"),
// ── Block params ────────────────────────────────────
.block_param => {

View File

@@ -328,6 +328,14 @@ fn printInst(instruction: *const Inst, ref_idx: u32, tt: *const TypeTable, write
try writeArgs(c.args, writer);
try writer.writeAll(") : ");
},
.inline_asm => |a| {
try writer.print("inline_asm{s} tmpl=#{d} ops={d} clobbers={d} : ", .{
if (a.has_side_effects) " volatile" else "",
a.template.index(),
a.operands.len,
a.clobbers.len,
});
},
.compiler_call => |cc| {
const name = tt.getString(@enumFromInt(cc.name));
try writer.print("compiler_call \"{s}\"(", .{name});