初探 zig-lang 开发鸿蒙应用

404 阅读8分钟

我们在之前的文章中已经简单讲过 zig-lang 与鸿蒙之间的可能出现的一些新的设想,比如使用 zig-lang 作为鸿蒙原生模块的编译器和链接器。那么在本文我们将继续探索 zig 在鸿蒙中的使用。

  1. 本文使用的 zig 版本为 0.13.0,且必须是0.13.0 之后的版本,否则不存在 OpenHarmony 的构建目标
  2. 本文可能不会涉及到过多的 zig 语法的讲解,主要是能够演示和跑通整个流程即可,相关语法和使用可以参考 zig 的官方文档。
  3. 文本涉及的代码仓库地址为:zig-addon

初始 zig-lang

zig 是一门由 Andrew Kelley 设计并且开发的新兴系统级编程语言,旨在解决 C 中存在的一些问题,比如内存安全问题、隐藏控制流、错误处理等问题。语言本身的好坏这里不做过多评价,我们仅以使用者的角度来尝试和现有的社区或者生态结合,从而产生更多的想象空间和思考。

一个由 zig 实现的 hello world:

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.print("Hello, {s}!\n", .{"world"});
}

更多入门和示例教程可以参考官方文档:zig-lang

简单谈谈我个人对于 zig 这门语言的感受,他相较于其他语言有如下几个比较亮眼的点:

  1. 良好的 C ABI 交互能力。
    与其他语言不同,zig 和 C ABI 之间交互调用无需任何额外的 FFI 胶水代码,我们可以像使用一个正常的 zig 模块一样使用所有的 C ABI。
  2. 良好的交叉编译能力。
    得益于其构建系统的设计,我们能够体验到无与伦比的交叉编译能力。我们不需要任何额外的配置即可完成交叉编译。

开发

我们这里使用 zig 实现一个最简单的两数之和的函数给 ArkTS 调用,正如我们之前讲的那样,我们可以在 zig 中非常轻松的使用 C ABI,官网给出了比较详细的参考示例:Import from C Header File。我们只需要使用 @cImport@cInclude 内置函数即可完成 ABI 的引入,那么在鸿蒙中我们可以直接如下即可:

const napi = @cImport({
    @cInclude("napi/native_api.h");
});

现在 napi 就是我们在 C/C++ 编码中可以使用的内容的全集对象,使用时只需要从中取出所需的类型和函数即可,比如:

  1. 获取 napi_env
    var env = napi.napi_env;
    
  2. 获取创建 double 数据的函数
    napi.napi_create_double();
    

基本上与在 C/C++ 中使用毫无差异(有点像使用 C++ 提供了一个 namespace 的感觉)。

那么这里直接给出具体的实现,如下所示:

const std = @import("std");
const napi = @cImport({
    @cInclude("napi/native_api.h");
});

// Add 函数实现
fn add(env: napi.napi_env, info: napi.napi_callback_info) callconv(.C) napi.napi_value {
    var argc: usize = 2;
    var args: [2]napi.napi_value = undefined;

    // 获取参数
    _ = napi.napi_get_cb_info(env, info, &argc, &args, null, null);

    // 检查参数类型
    var value_type0: napi.napi_valuetype = undefined;
    var value_type1: napi.napi_valuetype = undefined;
    _ = napi.napi_typeof(env, args[0], &value_type0);
    _ = napi.napi_typeof(env, args[1], &value_type1);

    // 获取参数值
    var value0: f64 = undefined;
    var value1: f64 = undefined;
    _ = napi.napi_get_value_double(env, args[0], &value0);
    _ = napi.napi_get_value_double(env, args[1], &value1);

    // 创建返回值
    var result: napi.napi_value = undefined;
    _ = napi.napi_create_double(env, value0 + value1, &result);

    return result;
}

// 初始化函数
fn init(env: napi.napi_env, exports: napi.napi_value) callconv(.C) napi.napi_value {
    const desc = [_]napi.napi_property_descriptor{
        .{
            .utf8name = "add",
            .method = add,
            .getter = null,
            .setter = null,
            .value = null,
            .attributes = napi.napi_default,
            .data = null,
        },
    };

    _ = napi.napi_define_properties(env, exports, 1, &desc);

    return exports;
}

// 模块定义
var module = napi.napi_module{
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = null,
    .nm_register_func = init,
    .nm_modname = "add",
    .nm_priv = null,
    .reserved = .{ null, null, null, null },
};

fn module_init() callconv(.C) void {
    napi.napi_module_register(&module);
}

comptime {
    const init_array = [1]*const fn () callconv(.C) void{&module_init};
    @export(init_array, .{ .linkage = .strong, .name = "init_array", .section = ".init_array" });
}

这里有几个小的细节简单说明下:

  1. 函数基本上都有 callconv(.C) 这个标记,简单理解就是该标记用于表述函数遵守 C ABI 调用,与 Rust 中的 repr(C) 比较类似。
  2. 在使用 C/C++ 编写对应的模块的时候,我们通常会使用 __attribute__((constructor)) 用来实现模块加载时的函数自动执行,在 Rust 中我们通常会使用 ctor 包来实现类似的功能,在 zig 中我们目前并没有类似的能力,但是我们可以向固定的内存段中写入函数来实现类似的功能。
    export const init_array: [1]*const fn () callconv(.C) void linksection(".init_array") = .{&module_init};
    
    这里我们向 .init_array 段中放入了函数指针数组,该函数会在程序启动/加载时自动执行。

这样我们就使用 zig 完成了原来使用 C/C++ 实现的两数之和的函数。当然这里只完成了编写,还没有编译出最终的结果。

编译

我们在之前的部分讲过,zig 最出彩的部分在于其编译系统,不过据我自己简单的编写体验来看,这部分也是相对来说比较复杂的,涉及内容比较多。

与 Rust 类似,zig 的编译主入口可以通过 build.zig 文件来独立定义,当然对于某些程序来说没有这个文件也是可能的。该文件的固定格式一般如下所示:

const std = @import("std");

pub fn build(b: *std.Build) !void {
}

暴露一个公共的 build 方法,接受一个标准库的 Build 参数以及最终返回一个空结果。

我们基本上所有的逻辑都在 build 函数中完成,那么现在我们思考下如果要编译成一个鸿蒙可执行的文件需要哪些编译过程。

  1. 需要编译成对应架构下的动态链接库,也就是.so文件
    //  创建一个动态链接库构建产物
    const t = b.addSharedLibrary(.{});
    
  2. 需要链接 libace_napi.z.so 文件,所有 NAPI 相关的实现都在该动态库中
    t.linkSystemLibrary("ace_napi.z");
    
  3. 由于我们还有很多其他的系统库以及能力,并且在 zig 的编译过程中并没有鸿蒙的头文件因此我们还需要告诉编译工具我们的头文件路径以及动态/静态库地址。
    // 添加绝对路径的 lib 查询路径
    t.addLibraryPath(.{ .cwd_relative = libPath });
    // 添加绝对路径的头文件查询路径
    t.addIncludePath(.{ .cwd_relative = includePath });
    

因为我们需要构建三个架构下的鸿蒙产物,因此这里不再详细赘述,直接给出相关的实现:

const std = @import("std");

fn getEnvVarOptional(allocator: std.mem.Allocator, name: []const u8) !?[]const u8 {
    return std.process.getEnvVarOwned(allocator, name) catch |err| {
        if (err == error.EnvironmentVariableNotFound) {
            return null;
        }
        return err;
    };
}

fn cloneSharedOptions(option: std.Build.SharedLibraryOptions) std.Build.SharedLibraryOptions {
    return std.Build.SharedLibraryOptions{
        .code_model = option.code_model,
        .name = option.name,
        .pic = option.pic,
        .error_tracing = option.error_tracing,
        .root_source_file = option.root_source_file,
        .optimize = option.optimize,
        .target = option.target,
        .link_libc = option.link_libc,
        .max_rss = option.max_rss,
        .omit_frame_pointer = option.omit_frame_pointer,
        .sanitize_thread = option.sanitize_thread,
        .single_threaded = option.single_threaded,
        .strip = option.strip,
        .zig_lib_dir = option.zig_lib_dir,
        .win32_manifest = option.win32_manifest,
        .version = option.version,
        .use_llvm = option.use_llvm,
        .use_lld = option.use_lld,
        .unwind_tables = option.unwind_tables,
    };
}

const targets: []const std.Target.Query = &.{
    .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .ohos },
    .{ .cpu_arch = .arm, .os_tag = .linux, .abi = .ohos },
    .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .ohos },
};

// 这里是通过模块导出的方法,因此这里的参数可以任意定义
pub fn nativeAddonBuild(build: *std.Build, option: std.Build.SharedLibraryOptions) !void {
    var arm64Option = cloneSharedOptions(option);
    arm64Option.target = build.resolveTargetQuery(targets[0]);

    var armOption = cloneSharedOptions(option);
    armOption.target = build.resolveTargetQuery(targets[1]);

    var x64Option = cloneSharedOptions(option);
    x64Option.target = build.resolveTargetQuery(targets[2]);

    const arm64 = build.addSharedLibrary(arm64Option);
    const arm = build.addSharedLibrary(armOption);
    const x64 = build.addSharedLibrary(x64Option);

    // link N-API
    arm64.linkSystemLibrary("ace_napi.z");
    arm.linkSystemLibrary("ace_napi.z");
    x64.linkSystemLibrary("ace_napi.z");

    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    var ndkRoot: ?[]const u8 = null;

    const home = try getEnvVarOptional(allocator, "OHOS_NDK_HOME");
    if (home) |v| {
        ndkRoot = try std.fs.path.join(allocator, &[_][]const u8{ v, "native" });
    } else {
        const ohos_sdk_native = try getEnvVarOptional(allocator, "ohos_sdk_native");
        if (ohos_sdk_native) |v| {
            ndkRoot = v;
        }
    }

    if (ndkRoot) |rootPath| {
        const includePath = try std.fs.path.join(allocator, &[_][]const u8{ rootPath, "sysroot", "usr", "include" });
        const libPath = try std.fs.path.join(allocator, &[_][]const u8{ rootPath, "sysroot", "usr", "lib" });

        arm64.addLibraryPath(.{ .cwd_relative = libPath });
        arm.addLibraryPath(.{ .cwd_relative = libPath });
        x64.addLibraryPath(.{ .cwd_relative = libPath });

        arm64.addIncludePath(.{ .cwd_relative = includePath });
        arm.addIncludePath(.{ .cwd_relative = includePath });
        x64.addIncludePath(.{ .cwd_relative = includePath });

        const arm64IncludePath = try std.fs.path.join(allocator, &[_][]const u8{ includePath, "aarch64-linux-ohos" });
        const armIncludePath = try std.fs.path.join(allocator, &[_][]const u8{ includePath, "arm-linux-ohos" });
        const x64IncludePath = try std.fs.path.join(allocator, &[_][]const u8{ includePath, "x86_64-linux-ohos" });

        const arm64LibPath = try std.fs.path.join(allocator, &[_][]const u8{ libPath, "aarch64-linux-ohos" });
        const armLibPath = try std.fs.path.join(allocator, &[_][]const u8{ libPath, "arm-linux-ohos" });
        const x64LibPath = try std.fs.path.join(allocator, &[_][]const u8{ libPath, "x86_64-linux-ohos" });

        arm64.addLibraryPath(.{ .cwd_relative = arm64LibPath });
        arm.addLibraryPath(.{ .cwd_relative = armLibPath });
        x64.addLibraryPath(.{ .cwd_relative = x64LibPath });

        arm64.addIncludePath(.{ .cwd_relative = arm64IncludePath });
        arm.addIncludePath(.{ .cwd_relative = armIncludePath });
        x64.addIncludePath(.{ .cwd_relative = x64IncludePath });

        const arm64DistDir: []const u8 = build.dupePath("dist/arm64-v8a");
        const armDistDir: []const u8 = build.dupePath("dist/armeabi-v7a");
        const x64DistDir: []const u8 = build.dupePath("dist/x86_64");

        const arm64Step = build.addInstallArtifact(arm64, .{
            .dest_dir = .{
                .override = .{
                    .custom = arm64DistDir,
                },
            },
        });
        const armStep = build.addInstallArtifact(arm, .{
            .dest_dir = .{
                .override = .{
                    .custom = armDistDir,
                },
            },
        });
        const x64Step = build.addInstallArtifact(x64, .{
            .dest_dir = .{
                .override = .{
                    .custom = x64DistDir,
                },
            },
        });

        build.getInstallStep().dependOn(&arm64Step.step);
        build.getInstallStep().dependOn(&armStep.step);
        build.getInstallStep().dependOn(&x64Step.step);
    } else {
        @panic("Environment OHOS_NDK_HOME or ohos_sdk_native not found, please set it as first.");
    }
}

PS: 这里有点小偷懒了,直接给出了最终的通过模块管理实现的代码。zig 的模块依赖管理可以参考 zig package manager

到这里基本上就完成了使用 zig 开发鸿蒙模块的全部开发和构建配置工作,接下来我们只需要使用 zig 执行构建即可。

zig build

最终会在项目下生成一个 zig-out 目录其中就有我们所需的动态链接库。然后我们只需要在我们的鸿蒙应用中调用即可。

17385069780893.jpg

最终能够完美的执行。

相较于使用 Rust 开发来说,其产物体积明显小一些,基本上跟使用 C/C++ 体积大小接近,部分场景可能更小。

17385071092144.jpg

相较于使用 C/C++ 开发来说,语法更加现代化,同时又具备了内存安全和比较现代化的模块化管理等能力。

不过本文仅仅只是浅尝辄止并未深入研究内部可能存在的一些问题和相关语言在鸿蒙更深一层的封装能力,仅用于抛砖引玉之用,欢迎大佬们一起探讨~

文章内容如有错误,还请海涵并期待斧正~