Zig NEWS

Govind
Govind

Posted on

But, will this blow up ?

The source for this post can be found here
Say you see something like this:

pub fn initStack(ptr: *anyopaque, comptime addI: addProto, comptime subI: subProto, comptime mulI: mulProto, comptime divI: divProto) Calculator {
const vtable = VTable{
        .add = addI,
        .sub = subI,
        .mul = mulI,
        .div = divI,
    };

    return .{ .ptr = ptr, .vtable = &vtable };
Enter fullscreen mode Exit fullscreen mode

Anyone with C/C++ experience will immediately panic on seeing something like this, as you are returning a pointer (&vtable) to something that is on the stack and just praying for a SegmentationFault .

But is that true ? What happens when we actually run this ?


pub fn main() void {
    var i = Implementation.init(@as(i32, 45));
    var imi = i.myInterface();
    print("{}\n", .{imi.add(@as(i32, 10), @as(i32, 20))});
    print("{}\n", .{imi.div(@as(f32, 12.0), @as(f32, 2.10))});

    var imiS = i.myInterfaceStack();
    print("{} \n", .{imiS.add(@as(i32, 10), @as(i32, 20))});
    print("{} \n", .{imiS.div(@as(f32, 12.0), @as(f32, 2.10))});
}
Enter fullscreen mode Exit fullscreen mode
Output:
my add implementation 30
my div implementation 5.71428585e+00
my add implementation 30
my div implementation 5.71428585e+00
Enter fullscreen mode Exit fullscreen mode

And it worked without a SegFault ! How is that happening ?
Curious, I tried to disassembly the .exe file and look at the assembly code. You can do so simply with

zig build-exe -target x86_64-linux-gnu calculator_implementation.zig

objdump -cpu=intel   --x86-asm-syntax=intel --disassemble-all calculator_implementation > calc_out.txt
Enter fullscreen mode Exit fullscreen mode

If you look at the assembly listing for the symbol calculator_interface.initStack (notice that fns inside structs are nothing but namespaced fns in the assembly), you will find some interesting things:


  239a10: 55                            push    rbp
  239a11: 48 89 e5                      mov rbp, rsp
  239a14: 50                            push    rax
  239a15: 48 89 f8                      mov rax, rdi
  239a18: 48 89 75 f8                   mov qword ptr [rbp - 8], rsi
  239a1c: 48 8b 4d f8                   mov rcx, qword ptr [rbp - 8]
  239a20: 48 89 0f                      mov qword ptr [rdi], rcx
  239a23: 48 b9 20 e9 20 00 00 00 00 00 movabs  rcx, 2156832
  239a2d: 48 89 4f 08                   mov qword ptr [rdi + 8], rcx
  239a31: 48 83 c4 08                   add rsp, 8
  239a35: 5d                            pop rbp
  239a36: c3                            ret
  239a37: 66 0f 1f 84 00 00 00 00 00    nop word ptr [rax + rax]
Enter fullscreen mode Exit fullscreen mode

checking against the declaration of initStack : pub fn initStack(ptr: *anyopaque, comptime addI: addProto, ...) Calculator {
It looks like ptr is copied to address at rdi and rdi + 8 is set to the value 2156832 (decimal)

Where is 2156832 (hex: 20e920) ?
This is in the .rodata section of the assembly (Note that our addresses in initStack here have a prefix 23...)
Lets look at the data in 20e920:

  20e920: 00 e2                         add dl, ah
  20e922: 23 00                         and eax, dword ptr [rax]
  20e924: 00 00                         add byte ptr [rax], al
  20e926: 00 00                         add byte ptr [rax], al
  20e928: 00 e3                         add bl, ah
  20e92a: 23 00                         and eax, dword ptr [rax]
  20e92c: 00 00                         add byte ptr [rax], al
  20e92e: 00 00                         add byte ptr [rax], al
  20e930: 00 e4                         add ah, ah
  20e932: 23 00                         and eax, dword ptr [rax]
  20e934: 00 00                         add byte ptr [rax], al
  20e936: 00 00                         add byte ptr [rax], al
  20e938: 00 e5                         add ch, ah
  20e93a: 23 00                         and eax, dword ptr [rax]
Enter fullscreen mode Exit fullscreen mode

(Ignore the instructions, for they are false. This is .rodata section, so it contains only data)
Intel assembly is Little Endian, so we read values from higher address -> lower address. Therefore,
The first 8 byte value is : 00 00 00 00 00 23 e2 00
The second 8 byte value is 00 00 00 00 00 23 e3 00
3rd: 00 00 00 00 00 23 e4 00
4rd: 00 00 00 00 00 23 e4 00

These 23... address. seem familiar. Let us look at the instructions at 23 e2 00

000000000023e200 <Implementation.add>:
  23e200: 55                            push    rbp
  23e201: 48 89 e5                      mov rbp, rsp
  23e204: 48 83 ec 20                   sub rsp, 32
  23e208: 48 89 7d f0                   mov qword ptr [rbp - 16], rdi
  23e20c: 89 75 ec                      mov dword ptr [rbp - 20], esi
  23e20f: 89 55 e8                      mov dword ptr [rbp - 24], edx
  23e212: e8 39 00 00 00                call    0x23e250 <std.debug.print.153>
  23e217: 8b 45 ec                      mov eax, dword ptr [rbp - 20]
  23e21a: 03 45 e8                      add eax, dword ptr [rbp - 24]
  23e21d: 89 45 e4                      mov dword ptr [rbp - 28], eax
  23e220: 0f 90 c0                      seto    al
  23e223: 70 02                         jo  0x23e227 <Implementation.add+0x27>
  23e225: eb 13                         jmp 0x23e23a <Implementation.add+0x3a>
  23e227: 48 bf 60 d5 20 00 00 00 00 00 movabs  rdi, 2151776
  23e231: 31 c0                         xor eax, eax
Enter fullscreen mode Exit fullscreen mode

And boom ! These are the addresses of our implementing functions !!

Here is the strange thing : We were expecting our &vtable to point to addresses in the Stack, but it turned out to be addresses in the actual assembly itself ? How is that ?

The secret lies in the declaration of our fn and the magic of comptime

pub fn initStack(ptr: *anyopaque, comptime addI: addProto, comptime subI: subProto, comptime mulI: mulProto, comptime divI: divProto) Calculator {
Enter fullscreen mode Exit fullscreen mode

Since our vtable is a const and its values are comptime known, Zig is smart enough to create this const as a part of the assembly itself. Therefore, when we return a pointer to vtable, it points to an address inside .rodata and not into the stack of initStack

This pattern is however unusual.
Most interface implementation (such as Allocator) in Zig, create a intermediate struct with namespaced fn's to call the fns passed as args

//comptime is the key, as it lets us know
//the signature of the implementing function at compile time

    // A clever trick. addI or subI will have a type-signature of fn(c: *ConcreteType, ..args)
    // our interface has a type-erased `ptr` that we need to send to addI or subI
    // we sort of `wrap` addI or subI to allow passing this type erased pointer without a
    // compile error (of type mismatch)
    const gen = struct {
        pub fn addProtoImpl(ptr: *anyopaque, x: i32, y: i32) i32 {
            return @call(.{}, addI, .{ ptr, x, y });
        }
        pub fn subProtoImpl(ptr: *anyopaque, x: i32, y: i32) i32 {
            return @call(.{}, subI, .{ ptr, x, y });
        }
        pub fn mulProtoImpl(ptr: *anyopaque, x: i32, y: i32) i32 {
            return @call(.{}, mulI, .{ ptr, x, y });
        }
        pub fn divProtoImpl(ptr: *anyopaque, x: f32, y: f32) f32 {
            return @call(.{}, divI, .{ ptr, x, y });
        }
        // All `fns` are part of the `.text` section of the binary
        // so for each implementation , we know where exactly to `jmp`
        // for each implementation
        // vtable is not allocated on the heap, but is part of `.rodata`
        // (as it is a const inside the struct)
        // we can therefore safely return pointers to this struct from within any fn
        const vtable = VTable{
            .add = addProtoImpl,
            .sub = subProtoImpl,
            .mul = mulProtoImpl,
            .div = divProtoImpl,
        };
    };
    return .{ .ptr = optr, .vtable = &gen.vtable };
}
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
rabbit profile image
pylang

Determining something at comptime can reduce a lot of stress at runtime

Collapse
 
thewawar profile image
LinFeng

I think it should explicitly add a comptime keyword before const vtable = ... so that the compiler can put the value in .rodata, otherwise it should locate in stack. My point is user should have the option to choose.