Trying to use SDL2 with own binding. Inconsistency when building on windows vs linux

Hey, I have written my own partial binding for SDL2 in zig 0.16.0. I am able to build it and use it fine on windows, this is my build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const sdlLibPath = b.option([]const u8, "libsdl", "Specify SDL2 lib path.");

    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const sdlLib = b.createModule(
        .{
            .root_source_file = b.path("src/sdl.zig"),
            .target = target,
            .optimize = optimize,
        },
    );

    if (sdlLibPath) |path| {
        sdlLib.addLibraryPath(.{ .cwd_relative = path });
    }

    const exe = b.addExecutable(.{
        .name = "ziggin",
        .root_module = b.createModule(
            .{
                .root_source_file = b.path("src/main.zig"),
                .target = target,
                .optimize = optimize,
                .imports = &.{
                    .{
                        .name = "sdl",
                        .module = sdlLib,
                    },
                    .{
                        .name = "gl",
                        .module = b.createModule(
                            .{
                                .root_source_file = b.path("src/gl.zig"),
                                .target = target,
                                .optimize = optimize,
                            },
                        ),
                    },
                },
            },
        ),
    });

    // Linking OpenGL differs between Windows and Linux.
    switch (target.result.os.tag) {
        .linux => {
            exe.root_module.linkSystemLibrary("GL", .{});
        },
        .windows => {
            exe.root_module.linkSystemLibrary("opengl32", .{});
            // Use -mwindows when building in release mode.
            if (optimize != .Debug) {
                exe.subsystem = .windows;
            }
        },
        else => {},
    }

    b.installArtifact(exe);

    const run_step = b.step("run", "Run the app");

    const run_cmd = b.addRunArtifact(exe);
    run_step.dependOn(&run_cmd.step);
    run_cmd.step.dependOn(b.getInstallStep());
}

I run zig build -Dlibsdl=[PATH TO SDL2.lib] and it works fine on windows. Wierdly I don’t have to do a .linkSystemLibrary or .linkLibrary in order to make it work. Interestingly when I build this way the resulting binary doesn’t link libc at all, only SDL2.dll.

My issue is when I try to build this on my linux laptop. First off the linker gives me a bunch of warnings about missing symbols for everything SDL2. So then i added sdlLib.linkSystemLibrary("SDL2", .{}) and the linker could now find the symbols.

But now whenever i try to run the binary I get segfaults on SDL_Init(), I then tried setting .link_libc = true and now I can run the binary as expected.

But why is it inconsistent between the platforms? Ideally I want to not have to link libc so if there is a way I can dynamically link SDL2 on linux without also pulling in libc in my binary I want to do it. Why can I build the binary on windows without linking libc but on linux I segfault when I try to do it? Also why am I able to not specify linking SDL2 on windows but on linux I have to do it?

What is the correct way I should handle the SDL2 dependency in my build.zig in order to make building on windows and linux the same?

The binding:

pub const SDL_Window = opaque {};
pub const SDL_Renderer = opaque {};
pub const SDL_GLContext = ?*anyopaque;
pub const SDL_Scancode = u32;
pub const SDL_Keycode = i32;
pub const SDL_GLattr = u32;

pub const SDL_INIT_VIDEO: u32 = 0x00000020;

pub const SDL_WINDOWPOS_CENTERED: u32 = 0x2FFF0000;
pub const SDL_WINDOW_OPENGL: u32 = 0x00000002;
pub const SDL_WINDOW_RESIZABLE: u32 = 0x00000020;
pub const SDL_RENDERER_ACCELERATED: u32 = 0x00000002;
pub const SDL_RENDERER_PRESENTVSYNC: u32 = 0x00000004;

pub const SDL_GL_CONTEXT_MAJOR_VERSION: u32 = 17;
pub const SDL_GL_CONTEXT_MINOR_VERSION: u32 = 18;
pub const SDL_GL_CONTEXT_PROFILE_MASK: u32 = 21;
pub const SDL_GL_CONTEXT_PROFILE_CORE: u32 = 1;

pub const SDL_QUIT: u32 = 256;
pub const SDL_WINDOWEVENT: u32 = 512;
pub const SDL_WINDOWEVENT_SIZE_CHANGED: u32 = 6;
pub const SDL_KEYDOWN: u32 = 0x300;
pub const SDL_KEYUP: u32 = 769;
pub const SDL_MOUSEMOTION: u32 = 1024;
pub const SDL_MOUSEBUTTONDOWN: u32 = 1025;
pub const SDL_MOUSEBUTTONUP: u32 = 1026;
pub const SDL_MOUSEWHEEL: u32 = 1027;

pub const SDL_SCANCODE_A: u32 = 4;
pub const SDL_SCANCODE_B: u32 = 5;
pub const SDL_SCANCODE_C: u32 = 6;
pub const SDL_SCANCODE_D: u32 = 7;
pub const SDL_SCANCODE_E: u32 = 8;
pub const SDL_SCANCODE_F: u32 = 9;
pub const SDL_SCANCODE_G: u32 = 10;
pub const SDL_SCANCODE_H: u32 = 11;
pub const SDL_SCANCODE_I: u32 = 12;
pub const SDL_SCANCODE_J: u32 = 13;
pub const SDL_SCANCODE_K: u32 = 14;
pub const SDL_SCANCODE_L: u32 = 15;
pub const SDL_SCANCODE_M: u32 = 16;
pub const SDL_SCANCODE_N: u32 = 17;
pub const SDL_SCANCODE_O: u32 = 18;
pub const SDL_SCANCODE_P: u32 = 19;
pub const SDL_SCANCODE_Q: u32 = 20;
pub const SDL_SCANCODE_R: u32 = 21;
pub const SDL_SCANCODE_S: u32 = 22;
pub const SDL_SCANCODE_T: u32 = 23;
pub const SDL_SCANCODE_U: u32 = 24;
pub const SDL_SCANCODE_V: u32 = 25;
pub const SDL_SCANCODE_W: u32 = 26;
pub const SDL_SCANCODE_X: u32 = 27;
pub const SDL_SCANCODE_Y: u32 = 28;
pub const SDL_SCANCODE_Z: u32 = 29;
pub const SDL_SCANCODE_1: u32 = 30;
pub const SDL_SCANCODE_2: u32 = 31;
pub const SDL_SCANCODE_3: u32 = 32;
pub const SDL_SCANCODE_4: u32 = 33;
pub const SDL_SCANCODE_5: u32 = 34;
pub const SDL_SCANCODE_6: u32 = 35;
pub const SDL_SCANCODE_7: u32 = 36;
pub const SDL_SCANCODE_8: u32 = 37;
pub const SDL_SCANCODE_9: u32 = 38;
pub const SDL_SCANCODE_0: u32 = 39;
pub const SDL_SCANCODE_RETURN: u32 = 40;
pub const SDL_SCANCODE_ESCAPE: u32 = 41;
pub const SDL_SCANCODE_BACKSPACE: u32 = 42;
pub const SDL_SCANCODE_SPACE: u32 = 44;
pub const SDL_SCANCODE_TAB: u32 = 43;
pub const SDL_SCANCODE_LCTRL: u32 = 224;
pub const SDL_SCANCODE_LSHIFT: u32 = 225;
pub const SDL_SCANCODE_LALT: u32 = 226;
pub const SDL_SCANCODE_RCTRL: u32 = 228;
pub const SDL_SCANCODE_RSHIFT: u32 = 229;
pub const SDL_SCANCODE_RALT: u32 = 230;
pub const SDL_NUM_SCANCODES: u32 = 512;

pub const SDL_Keysym = extern struct {
    scancode: SDL_Scancode = 0,
    sym: SDL_Keycode = 0,
    mod: u16 = 0,
    unused: u32 = 0,
};

pub const SDL_WindowEvent = extern struct {
    type: u32 = 0,
    timestamp: u32 = 0,
    windowID: u32 = 0,
    event: u8 = 0,
    padding1: u8 = 0,
    padding2: u8 = 0,
    padding3: u8 = 0,
    data1: i32 = 0,
    data2: i32 = 0,
};

pub const SDL_KeyboardEvent = extern struct {
    type: u32 = 0,
    timestamp: u32 = 0,
    windowID: u32 = 0,
    state: u8 = 0,
    repeat: u8 = 0,
    padding2: u8 = 0,
    padding3: u8 = 0,
    keysym: SDL_Keysym = .{},
};

pub const SDL_MouseMotionEvent = extern struct {
    type: u32 = 0,
    timestamp: u32 = 0,
    windowID: u32 = 0,
    which: u32 = 0,
    state: u32 = 0,
    x: i32 = 0,
    y: i32 = 0,
    xrel: i32 = 0,
    yrel: i32 = 0,
};

pub const SDL_MouseButtonEvent = extern struct {
    type: u32 = 0,
    timestamp: u32 = 0,
    windowID: u32 = 0,
    which: u32 = 0,
    button: u8 = 0,
    state: u8 = 0,
    clicks: u8 = 0,
    padding1: u8 = 0,
    x: i32 = 0,
    y: i32 = 0,
};

pub const SDL_MouseWheelEvent = extern struct {
    type: u32 = 0,
    timestamp: u32 = 0,
    windowID: u32 = 0,
    which: u32 = 0,
    x: i32 = 0,
    y: i32 = 0,
    direction: u32 = 0,
    preciseX: f32 = 0,
    preciseY: f32 = 0,
    mouseX: i32 = 0,
    mouseY: i32 = 0,
};

pub const SDL_Event = extern union {
    type: u32,
    window: SDL_WindowEvent,
    key: SDL_KeyboardEvent,
    motion: SDL_MouseMotionEvent,
    button: SDL_MouseButtonEvent,
    wheel: SDL_MouseWheelEvent,
    padding: [56]u8,
};

pub extern "SDL2" fn SDL_Init(flags: u32) c_int;
pub extern "SDL2" fn SDL_Quit() void;
pub extern "SDL2" fn SDL_CreateWindow(title: [*:0]const u8, x: i32, y: i32, w: i32, h: i32, flags: u32) ?*SDL_Window;
pub extern "SDL2" fn SDL_DestroyWindow(window: *SDL_Window) void;
pub extern "SDL2" fn SDL_PollEvent(event: ?*SDL_Event) c_int;
pub extern "SDL2" fn SDL_GL_SetAttribute(attr: SDL_GLattr, value: i32) c_int;
pub extern "SDL2" fn SDL_GL_CreateContext(window: *SDL_Window) SDL_GLContext;
pub extern "SDL2" fn SDL_GL_DeleteContext(context: SDL_GLContext) void;
pub extern "SDL2" fn SDL_GL_GetProcAddress(proc: [*:0]const u8) ?*anyopaque;
pub extern "SDL2" fn SDL_GL_GetDrawableSize(window: *SDL_Window, w: *i32, h: *i32) void;
pub extern "SDL2" fn SDL_GL_SwapWindow(window: *SDL_Window) void;
pub extern "SDL2" fn SDL_GetTicks() c_uint;

Where I use the binding:

const std = @import("std");
const sdl = @import("sdl");
const gl = @import("gl");

pub const App = struct {
    windowWidth: i32,
    windowHeight: i32,
    window: *sdl.SDL_Window,
    context: sdl.SDL_GLContext,

    pub fn init(width: i32, height: i32, procs: *gl.DispatchTable) !App {
        if (sdl.SDL_Init(sdl.SDL_INIT_VIDEO) != 0) {
            std.log.err("ERROR: Failed to initialize SDL2.\n", .{});
            return error.SDLInitFail;
        }
        errdefer sdl.SDL_Quit();

        _ = sdl.SDL_GL_SetAttribute(sdl.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
        _ = sdl.SDL_GL_SetAttribute(sdl.SDL_GL_CONTEXT_MINOR_VERSION, 3);
        _ = sdl.SDL_GL_SetAttribute(sdl.SDL_GL_CONTEXT_PROFILE_MASK, sdl.SDL_GL_CONTEXT_PROFILE_CORE);

        const window = sdl.SDL_CreateWindow("Window", sdl.SDL_WINDOWPOS_CENTERED, sdl.SDL_WINDOWPOS_CENTERED, width, height, sdl.SDL_WINDOW_OPENGL | sdl.SDL_WINDOW_RESIZABLE) orelse {
            std.log.err("ERROR: Failed to create window.\n", .{});
            return error.SDLWindowCreationFail;
        };
        errdefer sdl.SDL_DestroyWindow(window);

        const context = sdl.SDL_GL_CreateContext(window) orelse {
            std.log.err("ERROR: Failed to create Gl context.\n", .{});
            return error.SDLGLContextCreationFail;
        };
        errdefer sdl.SDL_GL_DeleteContext(context);

        if (!procs.init(sdl.SDL_GL_GetProcAddress)) return error.GLProcsInitFail;

        gl.makeDispatchTableCurrent(procs);
        errdefer gl.makeDispatchTableCurrent(null);

        gl.viewport(0, 0, width, height);

        return .{
            .windowWidth = width,
            .windowHeight = height,
            .window = window,
            .context = context,
        };
    }

    pub fn deinit(self: *App) void {
        gl.makeDispatchTableCurrent(null);
        sdl.SDL_GL_DeleteContext(self.context);
        sdl.SDL_DestroyWindow(self.window);
        sdl.SDL_Quit();
    }

    pub fn swapBuffer(self: *App) void {
        sdl.SDL_GL_SwapWindow(self.window);
    }

    pub fn resizeBuffer(self: *App) void {
        sdl.SDL_GL_GetDrawableSize(self.window, &self.windowWidth, &self.windowHeight);
        gl.viewport(0, 0, self.windowWidth, self.windowHeight);
    }
};
1 Like

tl;dr: zig sets up thread-local storage itself when not linking libc, but lets libc do it otherwise. libc needs thread-local storage to be set up the way it wants.

if there is a way I can dynamically link SDL2 on linux without also pulling in libc in my binary I want to do it.

SDL2 depends on libc, so the dynamic loader should pull in libc at runtime, and indeed it does. my binary does link libc, and it has the correct program interpreter:

$ ldd zig-out/bin/ziggin
        linux-vdso.so.1 (0x00007f2a56069000)
        libSDL2-2.0.so.0 => /lib64/libSDL2-2.0.so.0 (0x00007f2a55fd6000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f2a55ddd000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f2a5606b000)

Except for the addition of libm and library order, the link_libc binary is identical:

$ ldd zig-out/bin/ziggin
        linux-vdso.so.1 (0x00007fcb7eb82000)
        libm.so.6 => /lib64/libm.so.6 (0x00007fcb7ea3c000)
        libc.so.6 => /lib64/libc.so.6 (0x00007fcb7e843000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fcb7eb84000)
        libSDL2-2.0.so.0 => /lib64/libSDL2-2.0.so.0 (0x00007fcb7e7df000)

So, why does it behave differently?

For me, this instruction is the one that crashes:

mov rsi,[fs:rsi]

This is a load from an address relative to the fs register, with the offset in rsi. On Linux on x86_64, the fs register is used to store thread-local data. But who puts the data there?

In Zig, if you’re not linking libc, the Zig standard library sets thread-local storage up for you. If you’re interested in the details, take a look at std.os.linux.tls.initStatic and the documentation in that file.

If you are linking libc, however, libc will set up thread-local storage. glibc stores different data there, from the looks of it, that is a struct pthread with tons of internal data there.

So, libc expects there to be data there, but Zig’s startup code cannot put it there, leading to a crash.

2 Likes

Okay so it sounds like SDL2 requires the c runtime to function and on Linux the .so cant handle that on its own, so you need to link libc in order to set up the c runtime?

Out of curiosity, is there any c library you can link to that doesn’t expect the c runtime to be set up? I like the concept of zig not needing libc to function but if 99% of the c libraries you might interface with need you to link libc it feels like that might negate that feature.

I might, for example, try to write my own shallow window handling logic interfacing with X11 or wayland directly instead of using SDL2, but those are surely c libraries and might need you to link libc anyway?

Okay so it sounds like SDL2 requires the c runtime to function and on Linux the .so cant handle that on its own, so you need to link libc in order to set up the c runtime?

Yes, SDL2 links libc, and libc requires thread-local storage to be set up a certain way.

Out of curiosity, is there any c library you can link to that doesn’t expect the c runtime to be set up?

Some C libraries don’t depend on libc. “freestanding” might be a good search term.

I might, for example, try to write my own shallow window handling logic interfacing with X11 or wayland directly instead of using SDL2, but those are surely c libraries and might need you to link libc anyway?

That’s right.

There is work being done to implement a dynamic loader in Zig, which would make linking libc at compile time unnecessary, see this thread:

That seems to set up thread-local storage differently, I am unfortunately unfamiliar with the details. Check the source if you’re interested:

2 Likes

Out of curiosity, is there any c library you can link to that doesn’t expect the c runtime to be set up?

Only very simple ones :slight_smile:

As @cancername said, thread local storage is one of the incompatibilities you’ll encounter when the “normal” libc initialization is missing, but there are others as well, generally related to libc initialization (notably malloc and I/O).

I made the custom dynamic loader that @cancername linked exactly for this kind of use case. It lets you build a static executable that is not linked against libc while still being able to load dynamic libraries at runtime.

1 Like

I would think that any C library that’s entirely self contained and use any C stdlib functions (or at most ‘quasi builtins’ like memset, memcpy) should work. Usually that’s hard to figure out from the outside without actually reading the code though.

try to write my own shallow window handling logic interfacing with X11 or wayland directly instead of using SDL2

I might be wrong, but linking with any ‘system dll’ on Linux will load glibc into the process anyway. There is this thread though which discusses the topic so maybe there’s a solution (but if there is, it doesn’t seem to be trivial):