Zig 与 C 语言互操作实战

Zig 内置了 C 语言翻译器,可以直接导入 C 头文件并生成 Zig 绑定。本文详解 @cImport/@cInclude、类型映射、指针转换、回调函数处理以及将 Zig 库暴露给 C 调用方的完整流程。

1. 为什么 Zig 能直接导入 C 头文件?

Zig 项目从早期就内置了一套 C 头文件解析和翻译机制,这意味着:

  • 零手动绑定:不需要写 .h 文件后再手写 Zig 包装代码
  • 保持同步:C API 更新后重新编译即可自动同步
  • 类型安全:Zig 编译器在翻译阶段进行类型检查
  • 零运行时开销:翻译生成的 Zig 代码直接调用 C ABI
// 直接导入标准 C 头文件
const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("stdlib.h");
    @cInclude("string.h");
});

pub fn main() void {
    _ = c.printf("Hello from Zig, via C printf\n");
}

2. @cImport 与 @cInclude 基本用法

2.1 导入单个头文件

const sqlite3 = @cImport({
    @cInclude("sqlite3.h");
});

pub fn main() !void {
    var db: ?*sqlite3.sqlite3 = null;
    const rc = sqlite3.sqlite3_open(":memory:", &db);
    if (rc != sqlite3.SQLITE_OK) {
        std.debug.print("打开数据库失败\n", .{});
        return;
    }
    defer _ = sqlite3.sqlite3_close(db);
}

2.2 导入多个头文件

const glfw = @cImport({
    @cDefine("GLFW_INCLUDE_NONE", {});  // 定义宏
    @cInclude("GLFW/glfw3.h");
});

const glad = @cImport({
    @cInclude("glad/gl.h");
});

2.3 定义 C 宏

const c = @cImport({
    @cDefine("NDEBUG", {});           // #define NDEBUG
    @cDefine("BUFFER_SIZE", "1024");  // #define BUFFER_SIZE 1024
    @cInclude("mylib.h");
});

3. C 类型到 Zig 类型映射

Zig 翻译后的类型映射遵循以下规则:

C 类型Zig 翻译结果说明
intc_int平台相关大小
unsigned longc_ulong平台相关大小
char*[*c]u8C 风格任意指针
void*?*anyopaque不透明指针
const char*[*c]const u8常量 C 指针
struct Foo**Foo自动转换为 Zig 指针
enum Barc_intC 枚举翻译为底层整数

3.1 C 指针的特殊处理

// C 指针 [*c] 可以指向单个元素或数组,隐含任意转换
const c_str: [*c]const u8 = c.get_string();

// 安全的做法是立即转换为 Zig 切片
const len = c.strlen(c_str);
const zig_slice: []const u8 = c_str[0..len];

[*c] 指针是 Zig 中最危险的指针类型——它不保证长度,允许任意算数运算,仅在 C 互操作边界使用。

4. 实战:调用 SQLite 数据库

const std = @import("std");
const c = @cImport({
    @cInclude("sqlite3.h");
});

const SqliteError = error {
    OpenFailed,
    ExecFailed,
    PrepareFailed,
    BindFailed,
    StepFailed,
};

const Database = struct {
    db: *c.sqlite3,

    pub fn open(path: []const u8) !Database {
        var db: ?*c.sqlite3 = null;
        const rc = c.sqlite3_open(path.ptr, &db);
        if (rc != c.SQLITE_OK or db == null) {
            return SqliteError.OpenFailed;
        }
        return Database{ .db = db.? };
    }

    pub fn close(self: Database) void {
        _ = c.sqlite3_close(self.db);
    }

    pub fn exec(self: Database, sql: []const u8) !void {
        const rc = c.sqlite3_exec(
            self.db,
            sql.ptr,
            null, null, null
        );
        if (rc != c.SQLITE_OK) return SqliteError.ExecFailed;
    }

    pub fn prepare(self: Database, sql: []const u8) !Statement {
        var stmt: ?*c.sqlite3_stmt = null;
        const rc = c.sqlite3_prepare_v2(
            self.db, sql.ptr, @intCast(sql.len), &stmt, null
        );
        if (rc != c.SQLITE_OK or stmt == null) {
            return SqliteError.PrepareFailed;
        }
        return Statement{ .stmt = stmt.? };
    }
};

const Statement = struct {
    stmt: *c.sqlite3_stmt,

    pub fn bind_int(self: Statement, idx: i32, value: i32) !void {
        const rc = c.sqlite3_bind_int(self.stmt, idx, value);
        if (rc != c.SQLITE_OK) return SqliteError.BindFailed;
    }

    pub fn step(self: Statement) !bool {
        const rc = c.sqlite3_step(self.stmt);
        return switch (rc) {
            c.SQLITE_ROW => true,
            c.SQLITE_DONE => false,
            else => SqliteError.StepFailed,
        };
    }

    pub fn column_int(self: Statement, idx: i32) i32 {
        return c.sqlite3_column_int(self.stmt, idx);
    }

    pub fn deinit(self: Statement) void {
        _ = c.sqlite3_finalize(self.stmt);
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    const db = try Database.open(":memory:");
    defer db.close();

    try db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
    try db.exec("INSERT INTO users (name) VALUES ('Alice'), ('Bob')");

    var stmt = try db.prepare("SELECT id, name FROM users WHERE id > ?");
    defer stmt.deinit();
    try stmt.bind_int(1, 0);

    while (try stmt.step()) {
        const id = stmt.column_int(0);
        std.debug.print("用户 ID: {d}\n", .{id});
        _ = id;
    }

    _ = allocator;
}

5. 从 Zig 暴露 API 给 C

Zig 可以编译为 C 兼容的动态或静态库:

5.1 导出函数

// src/math.zig
const std = @import("std");

// 使用 "C" 调用约定导出函数
export fn zig_add(a: i32, b: i32) i32 {
    return a + b;
}

export fn zig_factorial(n: u32) u64 {
    if (n <= 1) return 1;
    var result: u64 = 1;
    var i: u32 = 2;
    while (i <= n) : (i += 1) {
        result *= i;
    }
    return result;
}

// 导出全局变量
export var zig_version: [*:0]const u8 = "1.0.0";

5.2 生成 C 头文件

# 编译为静态库
zig build-lib src/math.zig -O ReleaseFast -target x86_64-linux-gnu

# Zig 自动生成 .h 头文件(需要额外工具)
# 目前最常用的是 zigtranslate 或手动编写头文件

5.3 手动编写 C 头文件

// include/zig_math.h
#ifndef ZIG_MATH_H
#define ZIG_MATH_H
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

int32_t zig_add(int32_t a, int32_t b);
uint64_t zig_factorial(uint32_t n);
extern const char* zig_version;

#ifdef __cplusplus
}
#endif

#endif

5.4 C 侧调用

#include "zig_math.h"
#include <stdio.h>

int main() {
    int result = zig_add(10, 20);
    printf("10 + 20 = %d\n", result);

    unsigned long fact = zig_factorial(10);
    printf("10! = %lu\n", fact);

    printf("版本: %s\n", zig_version);
    return 0;
}

6. 回调函数与函数指针

6.1 将 Zig 函数作为 C 回调

// 定义 Zig 回调
fn my_callback(user_data: ?*anyopaque, value: i32) callconv(.C) void {
    const ctx: *MyContext = @ptrCast(@alignCast(user_data.?));
    ctx.sum += value;
}

// 注册到 C 库
const CCallback = fn (?*anyopaque, i32) callconv(.C) void;
extern fn c_register_callback(cb: CCallback, user_data: ?*anyopaque) void;

// 调用
var ctx = MyContext{ .sum = 0 };
c_register_callback(my_callback, &ctx);

callconv(.C) 显式指定使用 C ABI 调用约定,确保函数指针可以被 C 代码正确调用。

6.2 处理 C 传递的函数指针

const CompareFn = fn (a: ?*const anyopaque, b: ?*const anyopaque) callconv(.C) c_int;

extern fn c_qsort(
    base: ?*anyopaque,
    nmemb: usize,
    size: usize,
    compar: CompareFn,
) void;

7. 不透明类型

C 的前向声明结构体(如 typedef struct Foo Foo;)在 Zig 中翻译为不透明类型:

// 翻译后的结果
const FILE = opaque {};  // FILE* 的前向声明

// 使用
extern fn fopen(path: [*:0]const u8, mode: [*:0]const u8) ?*FILE;
extern fn fclose(stream: *FILE) c_int;

由于是不透明类型,Zig 代码不能直接访问其内部字段,只允许传递指针和调用外部函数。

8. build.zig 中的 C 集成配置

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

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

    // 链接 C 标准库
    exe.linkLibC();

    // 添加系统库搜索路径
    exe.addLibraryPath(.{ .cwd_relative = "/usr/local/lib" });

    // 链接 sqlite3
    exe.linkSystemLibrary("sqlite3");

    // 添加头文件搜索路径
    exe.addIncludePath(.{ .cwd_relative = "/usr/local/include" });

    // 添加 C 源码(如果需要编译 C 文件)
    exe.addCSourceFile(.{
        .file = b.path("deps/third_party.c"),
        .flags = &.{"-std=c11", "-O2"},
    });

    b.installArtifact(exe);
}

9. 常见陷阱与解决方案

9.1 字符串处理

// ❌ 错误:直接传递 Zig 切片给 C
const zig_str = "Hello";
_c_function(zig_str);  // 类型不匹配

// ✅ 正确:确保以 null 结尾
const c_str: [*:0]const u8 = "Hello".ptr;
_c_function(c_str);

// ✅ 或手动添加 null 终止
const buf = try allocator.allocSentinel(u8, 10, 0);
defer allocator.free(buf);
@memcpy(buf[0..zig_str.len], zig_str);
_c_function(buf.ptr);

9.2 可变参数函数

extern fn c_printf(fmt: [*:0]const u8, ...) c_int;

// Zig 目前对变参 C 函数支持有限
// 通常需要写 C 包装函数

9.3 位域(Bit Fields)

Zig 不直接支持 C 的位域,需要手动换算:

struct Flags {
    unsigned int enabled: 1;
    unsigned int level: 3;
    unsigned int padding: 4;
};
const Flags = packed struct {
    enabled: u1,
    level: u3,
    padding: u4,
};

9.4 联合体类型映射

C 的 union 在 Zig 中映射为无标签联合,使用时需要格外小心,因为 Zig 无法知道当前是哪个成员处于活跃状态:

// C: union { int i; float f; }
const CUnion = extern union {
    i: c_int,
    f: f32,
};

// 必须配合上下文判断当前活跃成员
var val: CUnion = undefined;
val.i = 42;
const as_int = val.i;  // 安全
// const as_float = val.f;  // 未定义行为!

对于需要类型安全的场景,建议在 Zig 侧包装为有标签联合(tagged union),中间层负责正确赋值和读取。

9.5 内存布局差异

C 结构体的默认布局不一定与 Zig 的默认布局一致,需要显式指定对齐方式:

// 确保与 C 结构体完全相同的内存布局
const CPoint = extern struct {
    x: f64,
    y: f64,
};

// 使用 @sizeOf 验证
comptime {
    assert(@sizeOf(CPoint) == @sizeOf(c.struct_Point));
}

10. 从 Zig 调用 C++ 代码的特殊考量

虽然 Zig 原生支持 C 互操作,但调用 C++ 代码需要额外的桥接层,因为 C++ 的 ABI 涉及名称修饰、异常处理和类布局等复杂因素:

// bridge.h — 使用 extern "C" 暴露 C 接口
extern "C" {
    int add_cpp(int a, int b);
    void process_data_cpp(const char* data);
}
// 通过 C 桥接头文件调用 C++ 实现
const bridge = @cImport(@cInclude("bridge.h"));
const result = bridge.add_cpp(10, 20);

这种桥接模式也意味着,与大型 C++ 代码库的互操作需要维护一个中间层,可能增加项目的复杂度。

11. 总结

Zig 的 C 互操作能力是其作为"更好 C"定位的关键支撑:

能力Zig 实现对比传统方式
头文件导入@cImport/@cInclude 自动翻译手写 FFI 绑定
类型映射编译时自动转换手动对应类型
暴露 Zig APIexport + callconv(.C)标记导出符号
链接 C 库linkSystemLibrary + linkLibC手写 Makefile
交叉编译自动下载目标 libc配置交叉工具链

这一整套机制让 Zig 可以成为现有 C/C++ 项目的渐进式替代方案:从单个模块开始用 Zig 重写,其余部分保持 C 不变,两者无缝协作。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 系统编程实战
  2. Zig 内存管理与分配器模式