From c0b338eaa427d424be74daa9fef774fb5c498b98 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 25 May 2026 22:52:34 +0300 Subject: [PATCH] ffi M1.2 A.4b.ii: emit C-ABI IMP trampolines (dead code pending class_addMethod) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For each bodied instance method on a sx-defined #objc_class, emit a C-callconv trampoline function '____imp': void __SxFoo_bump_imp(ptr obj, ptr _cmd, ...user_args) { ivar = load @__SxFoo_state_ivar state = object_getIvar(obj, ivar) call @SxFoo.bump(__sx_default_context, state, ...user_args) ret } The trampoline bridges the Obj-C runtime's IMP calling convention ('id self, SEL _cmd, ...args' as C ABI) to the sx body's default-callconv shape ('__sx_ctx ptr, state ptr, ...user_args'). Implicit context comes from '&__sx_default_context'; the body keeps its sx-side personality intact and can use 'self.field' through the substituted state-struct pointer (M1.2 A.2b + A.3). New helpers in lower.zig: - 'getObjcObjectGetIvarFid' lazily declares object_getIvar. - 'emitObjcDefinedClassImps' + 'emitObjcDefinedClassImp' walk the cache and synthesise each trampoline. - 'lookupGlobalIdByName' for finding the per-class ivar handle global. Linear scan — same N-is-small rationale as the other Obj-C caches. Dead code at this commit: the trampolines exist in the module but no class_addMethod call registers them with the runtime. 'objc_msgSend(obj, sel_bump)' would still fall through to the parent class (NSObject 'doesNotRecognizeSelector:') today. A.4b.iii wires up class_addMethod in emit_llvm's class-pair-init constructor — that's when the trampolines come alive. 142's IR snapshot refreshed to show the trampoline. 173 example tests pass. zig build test green. --- src/ir/lower.zig | 182 +++++++++++++++++- .../142-objc-class-method-lowering.ir | 12 ++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 4c8c10b..84bf05c 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -108,6 +108,7 @@ pub const Lowering = struct { implicit_ctx_enabled: bool = false, current_ctx_ref: Ref = Ref.none, sel_register_name_fid: ?FuncId = null, // lazily-declared `sel_registerName` extern (non-literal selector fallback) + objc_object_get_ivar_fid: ?FuncId = null, // lazily-declared `object_getIvar` extern (M1.2 A.4b IMP trampoline body) jni_env_stack: std.ArrayList(Ref) = std.ArrayList(Ref).empty, // lexical `#jni_env(env)` Ref stack — top is current scope's env for omitted-env `#jni_call` jni_env_stack_base: usize = 0, // index above which the currently-lowering fn's `#jni_env` scopes live; outer-fn Refs aren't valid in this fn's instruction stream jni_env_tl_get_fid: ?FuncId = null, // extern `sx_jni_env_tl_get` (from library/vendors/sx_jni_runtime/sx_jni_env_tl.c) @@ -11442,7 +11443,9 @@ pub const Lowering = struct { /// lazy lowering, so we walk the cache and force-lower here. /// `lowerFunction` sets `current_foreign_class` automatically based /// on the qualified name, so `*Self` substitutions in the body - /// resolve correctly (M1.2 A.2b). + /// resolve correctly (M1.2 A.2b). After the bodies are lowered, + /// `emitObjcDefinedClassImps` wraps each with a C-ABI trampoline + /// (M1.2 A.4b.ii). fn lowerObjcDefinedClassMethods(self: *Lowering) void { for (self.module.objc_defined_class_cache.items) |entry| { const fcd = entry.decl; @@ -11456,6 +11459,183 @@ pub const Lowering = struct { self.lazyLowerFunction(qualified); } } + // Now the bodies are lowered — emit the C-ABI IMP trampolines + // that bridge `objc_msgSend` invocations to them. + self.emitObjcDefinedClassImps(); + } + + /// Lazily declare `object_getIvar(obj: *void, ivar: *void) -> *void` + /// as an extern. Cached so multiple IMP trampolines share one decl. + fn getObjcObjectGetIvarFid(self: *Lowering) FuncId { + if (self.objc_object_get_ivar_fid) |fid| return fid; + const ptr_void = self.module.types.ptrTo(.void); + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("obj"), .ty = ptr_void }) catch unreachable; + params.append(self.alloc, .{ .name = self.module.types.internString("ivar"), .ty = ptr_void }) catch unreachable; + const fn_name = self.module.types.internString("object_getIvar"); + const fid = self.builder.declareExtern(fn_name, params.toOwnedSlice(self.alloc) catch unreachable, ptr_void); + const func = self.module.getFunctionMut(fid); + func.call_conv = .c; + self.objc_object_get_ivar_fid = fid; + return fid; + } + + /// For each bodied instance method on a sx-defined `#objc_class`, + /// emit a C-ABI IMP trampoline that the Obj-C runtime calls (after + /// the dispatch path from `objc_msgSend`). The trampoline: + /// 1. Loads the cached ivar handle from `@___state_ivar`. + /// 2. Calls `object_getIvar(obj, ivar)` to get the `*State` + /// state pointer. + /// 3. Calls the sx body `@.(__sx_default_context, + /// state, ...user_args)` (default sx-callconv). + /// 4. Returns the result (or `ret void`). + /// + /// IMP name: `____imp`. emit_llvm's + /// constructor (A.4b.ii companion) registers this via + /// `class_addMethod` with a derived selector + type encoding. + fn emitObjcDefinedClassImps(self: *Lowering) void { + for (self.module.objc_defined_class_cache.items) |entry| { + const fcd = entry.decl; + for (fcd.members) |m| { + const method = switch (m) { + .method => |md| md, + else => continue, + }; + if (method.body == null) continue; + self.emitObjcDefinedClassImp(fcd, method); + } + } + } + + fn emitObjcDefinedClassImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { + // Save+restore builder state — we're switching into a new fn + // mid-pass and need to restore for the next emit_llvm steps. + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + defer { + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + } + + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, md.name }) catch return; + const name_id = self.module.types.internString(imp_name); + const ptr_void = self.module.types.ptrTo(.void); + + // C-ABI signature: (obj: *void, _cmd: *void, ...user_args) -> ret. + // User params skip index 0 (which is *Self). + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("obj"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; + + // Set current_foreign_class so *Self in user-param resolution + // resolves to *State (M1.2 A.2b). Save+restore. + const saved_fc = self.current_foreign_class; + self.current_foreign_class = fcd; + defer self.current_foreign_class = saved_fc; + + const param_start: usize = 1; + for (md.params[param_start..], 0..) |p_node, i| { + // User params are reflected at the C-ABI boundary AS-IS — + // the runtime trampoline forwards them through to the body. + // *Self here would be a programming error (only the implicit + // self at index 0 is *Self), but we use resolveType to handle + // pointer types correctly. + const pty = self.resolveType(p_node); + params.append(self.alloc, .{ + .name = self.module.types.internString(md.param_names[param_start + i]), + .ty = pty, + }) catch return; + } + + const ret_ty: TypeId = if (md.return_type) |rt| self.resolveType(rt) else .void; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, ret_ty); + const func = self.builder.currentFunc(); + func.linkage = .external; + func.call_conv = .c; + func.has_implicit_ctx = false; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + // (1) Load ivar handle from per-class global. + const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return; + defer self.alloc.free(ivar_global_name); + const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return; + const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); + const ivar_handle = self.builder.load(ivar_addr, ptr_void); + + // (2) state = object_getIvar(obj, ivar_handle). + const get_ivar_fid = self.getObjcObjectGetIvarFid(); + const obj_ref = Ref.fromIndex(0); + const get_ivar_args = self.alloc.alloc(Ref, 2) catch return; + get_ivar_args[0] = obj_ref; + get_ivar_args[1] = ivar_handle; + const state_ptr = self.builder.emit(.{ .call = .{ + .callee = get_ivar_fid, + .args = get_ivar_args, + } }, ptr_void); + + // (3) Call sx body `@.(default_ctx, state, ...user_args)`. + const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return; + defer self.alloc.free(body_name); + const body_fid = self.resolveFuncByName(body_name) orelse return; + + // Locate __sx_default_context global. When implicit_ctx is off + // (no std.sx imported), the body has no __sx_ctx param either — + // skip the ctx prepend. + const ctx_ref: ?Ref = blk: { + if (!self.implicit_ctx_enabled) break :blk null; + const dctx_gi = self.global_names.get("__sx_default_context") orelse break :blk null; + break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void); + }; + + // Build arg list: [ctx?] + state + user_args. + const num_user_args = params_slice.len - 2; // minus obj + _cmd + const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + 1 + num_user_args; + const call_args = self.alloc.alloc(Ref, num_call_args) catch return; + var idx: usize = 0; + if (ctx_ref) |c_ref| { + call_args[idx] = c_ref; + idx += 1; + } + call_args[idx] = state_ptr; + idx += 1; + // User args come from imp params slots 2..N. + var ip: usize = 2; + while (ip < params_slice.len) : (ip += 1) { + call_args[idx] = Ref.fromIndex(@intCast(ip)); + idx += 1; + } + + const call_ref = self.builder.emit(.{ .call = .{ + .callee = body_fid, + .args = call_args, + } }, ret_ty); + + // (4) Return. + if (ret_ty == .void) { + self.builder.retVoid(); + } else { + self.builder.ret(call_ref, ret_ty); + } + + self.builder.finalize(); + } + + /// Linear scan over module globals for a given name. Used for + /// looking up the per-class ivar handle global from inside IMP + /// trampoline emission. + fn lookupGlobalIdByName(self: *Lowering, name: []const u8) ?inst_mod.GlobalId { + const name_id = self.module.types.internString(name); + for (self.module.globals.items, 0..) |g, i| { + if (g.name == name_id) return inst_mod.GlobalId.fromIndex(@intCast(i)); + } + return null; } fn synthesizeJniMainStubs(self: *Lowering) void { diff --git a/tests/expected/142-objc-class-method-lowering.ir b/tests/expected/142-objc-class-method-lowering.ir index 3d9f4ae..15750bc 100644 --- a/tests/expected/142-objc-class-method-lowering.ir +++ b/tests/expected/142-objc-class-method-lowering.ir @@ -784,6 +784,18 @@ entry: ret { ptr, i64 } %call } +; Function Attrs: nounwind +define void @__SxFoo_bump_imp(ptr %0, ptr %1) #0 { +entry: + %load = load ptr, ptr @__SxFoo_state_ivar, align 8 + %call = call ptr @object_getIvar(ptr %0, ptr %load) + call void @SxFoo.bump(ptr @__sx_default_context, ptr %call) + ret void +} + +; Function Attrs: nounwind +declare ptr @object_getIvar(ptr, ptr) #0 + declare i64 @write(i32, ptr, i64) declare ptr @objc_getClass(ptr)