DEV Community

Cover image for ⚑ZigπŸ’žRustπŸ¦€ DLL InterOP
mkpoli
mkpoli

Posted on • Edited on

⚑ZigπŸ’žRustπŸ¦€ DLL InterOP

This is a translated and recompiled version of two of my Japanese articles on this topic:
https://zenn.dev/mkpoli/articles/591756f1af6ca8
https://zenn.dev/mkpoli/articles/4d8c1e28bdd05e

This article demonstrates how to use compiled Windows shared library (*.DLL s) between Zig and Rust through C ABI. Although the focus is primarily on Windows, there would not be much difference on Linux and other platforms.

Prerequisite

Installation

Rust

See the official guide of Rust

Zig

See the official guide of Zig

TL;DR

# Install scoop
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression

# Install Rust
scoop install rustup
rustup update

# Install Zig
scoop bucket add versions
scoop install versions/zig-dev
Enter fullscreen mode Exit fullscreen mode

Development Environment

I'm using VSCode. Install the extensions rust-lang.rust-analyzer and ziglang.vscode-zig (Ctrl+Shift+P, Install Extension). In the Zig confirmation window that appears, set it to the one in the PATH, and install ZLS.

Load Zig DLL in Rust Application

Initialize Environment

Execute the following commands to make a folder and open it with VSCode:

mkdir zig-rust-interop
cd zig-rust-interop
code .
Enter fullscreen mode Exit fullscreen mode

Open the Terminal in VSCode and execute to initialize the projects:

mkdir zig-lib
cd zig-lib
zig init
cd ..
mkdir rust-exe
cd rust-exe
cargo init
cd ..
Enter fullscreen mode Exit fullscreen mode

Then the following files should be generated:

.
β”œβ”€β”€ rust-exe
β”‚   β”œβ”€β”€ Cargo.toml
β”‚   └── src
β”‚       └── main.rs
└── zig-lib
    β”œβ”€β”€ build.zig
    β”œβ”€β”€ build.zig.zon
    └── src
        β”œβ”€β”€ main.zig
        └── root.zig
Enter fullscreen mode Exit fullscreen mode

Develop Zig library

zig-lib/src/main.zig is for generating executables, so we remove it first. We are going to use the generated zig-lib/src/root.zig. Let's take a look on it. We can see a simple implementation of add() function is exported.

const std = @import("std");
const testing = std.testing;

export fn add(a: i32, b: i32) i32 {
    return a + b;
}

test "basic add functionality" {
    try testing.expect(add(3, 7) == 10);
}
Enter fullscreen mode Exit fullscreen mode

Open zig-lib/build.zig and remove the part for executable genration and testing, change addStaticLibraryγ‚’addSharedLibrary as the following:

const std = @import("std");

pub fn build(b: *std.Build) !void {
    const target = b.standardTargetOptions(.{});

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

    const dll = b.addSharedLibrary(.{
        .name = "zig-lib",
        .root_source_file = .{ .path = "src/root.zig" },
        .target = target,
        .optimize = optimize,
    });

    b.installArtifact(dll);

    const lib_unit_tests = b.addTest(.{
        .root_source_file = .{ .path = "src/root.zig" },
        .target = target,
        .optimize = optimize,
    });

    const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);

    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_lib_unit_tests.step);
}
Enter fullscreen mode Exit fullscreen mode

Execute the following command to build the library:

cd zig-lib
zig build
cd ..
Enter fullscreen mode Exit fullscreen mode

Then inside zig-lib/zig-out/, there should be zig-lib.dll, zig-lib.lib and zig-lib.pdb generated. These files have the aforementioned add() function embbeded in.

Develop Rust Application

Use extern "C" directly

The easiest way to call functions from a dynamic library is to link dynamically in compile time.

Firstly, we add extern "C" block in rust-exe/src/main.rs.

extern "C" {
    fn add(a: i32, b: i32) -> i32;
}

fn main() {
    let a = 1;
    let b = 2;
    let c = unsafe { add(a, b) };
    println!("zig-lib.dll: add({}, {}) = {}", a, b, c);
}
Enter fullscreen mode Exit fullscreen mode

The, create a new file called rust-exe/build.rs, and inform the Rust compiler the path to search and library name by println!() to stdout, then we will copy the generated DLL file to the built target folder (I'm not sure if this is the recommended way, but at least it would be better to let Zig's build script to copy the target to Rust folder because it is too coupled).

use std::{env, fs, path::Path};

const LIB_NAME: &str = "zig-lib";
const ZIG_OUT_DIR: &str = "../zig-lib/zig-out/lib";

fn main() {
    println!("cargo:rustc-link-search=native={}", ZIG_OUT_DIR);
    println!("cargo:rustc-link-lib=dylib={}", LIB_NAME);

    let rust_root = env::var("CARGO_MANIFEST_DIR").unwrap();
    let profile = env::var("PROFILE").unwrap();
    let dll_name = format!("{}.dll", LIB_NAME);
    let out_dir = Path::new(&rust_root).join("target").join(&profile);

    let src_path = Path::new(ZIG_OUT_DIR).join(&dll_name);
    let dst_path = Path::new(&out_dir).join(&dll_name);

    if !src_path.exists() {
        panic!(
            "{} not found. Run `cd ../zig-lib && zig build` first.",
            src_path.display()
        );
    }

    fs::copy(&src_path, &dst_path).unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Then, if we execute the following command, the Rust application should be compiled and run.

cd rust-exe
cargo clean && cargo run
Enter fullscreen mode Exit fullscreen mode

However, this methods will not error when the dll file does not exist for some reason (please let me know why), and it is difficult to specify the path dynamically.

Use crates such as libloading

On the other hand, there is a method to load the DLLs in runtime. Firstly, execute the following command to add libloading to our dependencies.

cargo add libloading
Enter fullscreen mode Exit fullscreen mode

Then let's modify rust-exe/src/main.rs.

@@ -1,10 +1,14 @@
-extern "C" {
-    fn add(a: i32, b: i32) -> i32;
+fn add(a: i32, b: i32) -> Result<i32, Box<dyn std::error::Error>> {
+    unsafe {
+        let lib = libloading::Library::new("zig-lib.dll")?;
+        let add: libloading::Symbol<unsafe extern "C" fn(i32, i32) -> i32> = lib.get(b"add")?;
+        Ok(add(a, b))
+    }
 }

 fn main() {
     let a = 1;
     let b = 2;
-    let c = unsafe { add(a, b) };
+    let c = add(a, b).unwrap();
     println!("zig-lib.dll: add({}, {}) = {}", a, b, c);
 }
Enter fullscreen mode Exit fullscreen mode

πŸŽ‰After that, execute the following command to see the same result of zig-lib.dll: add(1, 2) = 3 showing.

cargo clean && cargo run
Enter fullscreen mode Exit fullscreen mode

If we delete zig-lib.dll, an error message is shown as below.

thread 'main' panicked at src\main.rs:12:23:
called `Result::unwrap()` on an `Err` value: LoadLibraryExW { source: Os { code: 126, kind: Uncategorized, message: "Cannot find specified module" } }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Enter fullscreen mode Exit fullscreen mode

Load Rust DLL in Zig Application

Initialize Environment

Execute the following commands to make a folder and open it with VSCode

mkdir zig-rust-interop
cd zig-rust-interop
code .
Enter fullscreen mode Exit fullscreen mode

Open the Terminal in VSCode and execute to initialize the projects:

mkdir rust-lib
cd rust-lib
cargo init
cd ..
mkdir zig-exe
cd zig-exe
zig init
cd ..
Enter fullscreen mode Exit fullscreen mode

Then the following files should be generated:

.
β”œβ”€β”€ rust-lib
β”‚   β”œβ”€β”€ Cargo.toml
β”‚   └── src
β”‚       └── lib.rs
└── zig-exe
    β”œβ”€β”€ build.zig
    β”œβ”€β”€ build.zig.zon
    └── src
        β”œβ”€β”€ main.zig
        └── root.zig
Enter fullscreen mode Exit fullscreen mode

Develop Rust library

Add cdylib to lib.crate-type in Cargo.toml.

@@ -3,4 +3,7 @@
 version = "0.1.0"
 edition = "2021"

+[lib]
+crate-type = ["cdylib"]
+
 [dependencies]
Enter fullscreen mode Exit fullscreen mode

We will use the automatically generated lib.rs file. Let's take a look of the content. We can see a simple add() function is exported.

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
Enter fullscreen mode Exit fullscreen mode

Add #[no_mangle] to the top just before add() so that Rust will not mangle with the name with some random characters.

@@ -1,3 +1,4 @@
+#[no_mangle]
 pub fn add(left: usize, right: usize) -> usize {
     left + right
 }
Enter fullscreen mode Exit fullscreen mode

Then, after executing the following command, a compiled Rust DLL will be generated.

cd rust-lib
cargo build
cd ..
Enter fullscreen mode Exit fullscreen mode
β”œβ”€β”€ debug
β”‚   β”œβ”€β”€ build
β”‚   β”œβ”€β”€ deps
β”‚   β”‚   β”œβ”€β”€ rust_lib.d
β”‚   β”‚   β”œβ”€β”€ rust_lib.dll
β”‚   β”‚   β”œβ”€β”€ rust_lib.dll.exp
β”‚   β”‚   β”œβ”€β”€ rust_lib.dll.lib
β”‚   β”‚   └── rust_lib.pdb
β”‚   β”œβ”€β”€ examples
β”‚   β”œβ”€β”€ incremental
β”‚   β”‚   └── ...
β”‚   β”œβ”€β”€ rust_lib.d
β”‚   β”œβ”€β”€ rust_lib.dll
β”‚   β”œβ”€β”€ rust_lib.dll.exp
β”‚   β”œβ”€β”€ rust_lib.dll.lib
β”‚   └── rust_lib.pdb
Enter fullscreen mode Exit fullscreen mode

Develop Zig Application

Firstly, rewrite the entry point main.zig file as the following:

const std = @import("std");

pub fn main() !void {
    var dll = try std.DynLib.open("rust_lib.dll");
    const add = dll.lookup(*fn (i32, i32) i32, "add").?;

    std.debug.print("rust_lib.dll: add({}, {}) = {}\n", .{
        1,
        2,
        add(1, 2),
    });
}
Enter fullscreen mode Exit fullscreen mode

Then let's remove the part of generating static lib which is not needed for now, and add code to compile Rust in release mode while copying the files back to the build artifact dir zig-out/bin as the following:

const std = @import("std");
const fs = std.fs;

const RUST_DIR = "../rust-lib";
const RUST_RELEASE_DIR = RUST_DIR ++ "/target/release";
const DLL_NAME = "rust_lib.dll";

const RUST_DLL_RELEASE_PATH = RUST_RELEASE_DIR ++ "/" ++ DLL_NAME;
const ZIG_BIN_OUT_DIR = "zig-out/bin";

pub fn build(b: *std.Build) !void {
    _ = b.run(&[_][]const u8{ "cargo", "build", "--manifest-path", RUST_DIR ++ "/Cargo.toml", "--release" });
    const cwd = fs.cwd();
    std.debug.print("Copying {s} to {s}\n", .{ RUST_DLL_RELEASE_PATH, ZIG_BIN_OUT_DIR });
    try fs.Dir.copyFile(cwd, RUST_DLL_RELEASE_PATH, cwd, ZIG_BIN_OUT_DIR ++ "/" ++ DLL_NAME, .{});
    std.debug.print("Copied rust-lib.dll to {s}\n", .{ZIG_BIN_OUT_DIR});

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

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

    const exe = b.addExecutable(.{
        .name = "zig-exe",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });

    b.installArtifact(exe);
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}
Enter fullscreen mode Exit fullscreen mode

After that, execute the following command:

cd zig-exe
zig build run
Enter fullscreen mode Exit fullscreen mode

πŸŽ‰Then rust_lib.dll: add(1, 2) = 3 should be printed.

Conclusion

How was the article? Please let me know if you have any more ideas. The working example is published at Github:

https://github.com/mkpoli/zig-rust-interop/tree/master

The reason for this article is that I was trying to make an IME (Input Method Editor) of the Ainu language and I decided to use Zig and Rust for that. It still has a long way to go.

Top comments (0)