A Simple Example of Calling a C Library from Zig

Michael Lynch

Zig에서 C 라이브러리를 호출하는 간단한 예제

Zig은 새로 독립적으로 개발된 로우레벨 프로그래밍 언어입니다. C의 모든 성능적 장점을 유지하면서도 지난 30년 동안 발전한 툴링과 언어 설계의 개선을 함께 활용하려는, C를 현대적으로 재해석한 언어입니다.

Zig은 C를 대체하기 위해 설계된 만큼, Zig 애플리케이션에서 C 라이브러리를 호출할 수 있다는 점이 일급 기능 중 하나입니다. Zig의 C 연동 기능을 보여주는 간단한 예제를 찾을 수 없어 직접 작성하기로 했습니다.

Zig에서 C를 호출하는 기존 자료

Zig에서 C 코드를 호출하는 방법을 설명한 글을 몇 개 찾았습니다. 모두 유용한 정보를 담고 있었지만, 내용이 너무 추상적이거나 제가 하려던 것보다 훨씬 복잡한 시나리오를 다루고 있었습니다:

  • “C/C++/Zig” - Loris Cro
    • 훌륭한 튜토리얼이지만 내용이 복잡합니다. 단순히 C 라이브러리를 호출하는 것이 아니라, 거대한 C 애플리케이션을 Zig로 빌드하는 방법을 알아낸 뒤 기존 C 코드를 호출하기도 하고 C 코드로부터 호출도 받는 새로운 함수를 작성하는 과정을 다룹니다.
    • 이 튜토리얼에서 많이 배웠지만, 이를 통해 더 단순한 상황에서 Zig가 C를 호출하는 방법을 파악하기는 어려웠습니다.
    • 또한 이 튜토리얼은 Zig 0.8.1 기준으로 작성되어 현재는 Zig 0.14.0에서 코드가 컴파일되지 않습니다.
  • “Extending a C Project with Zig” (2023)
    • 비교적 최근에 작성된 글이라 현재 버전의 Zig에서도 컴파일됩니다.
    • 위 튜토리얼과 마찬가지로 이 글 역시 크고 복잡한 C 애플리케이션을 컴파일하는 방법을 다루기 때문에, 그 내용을 더 간단한 상황에 어떻게 적용해야 할지 이해하기 어려웠습니다.
  • ziglearn Chapter 4 - Working with C
    • 이 글은 Zig와 C 연동을 위한 저수준 메커니즘을 설명하지만, 완전한 예제 코드는 보여주지 않습니다.

위에서 소개한 두 개의 ‘C 프로젝트 확장’ 튜토리얼이 가진 가장 큰 한계는 복잡한 Makefile을 Zig 빌드 시스템으로 포팅하는 방법을 이미 알고 있다고 가정한다는 점입니다. 두 튜토리얼 모두 “자, 이 복잡한 100줄짜리 Makefile을 보세요. 짜잔, 이제 복잡한 100줄짜리 build.zig 파일이 되었습니다!” 식으로 넘어가고, 그 과정을 제대로 설명하지 않습니다(90분짜리 영상을 보지 않는 한 말입니다).

Zig를 전혀 모르는 초보자로서 저는 거대한 Makefile을 Zig 빌드 시스템으로 변환하는 방법을 배우고 싶지 않았습니다. 대신 C 애플리케이션 전체를 Zig 네이티브 빌드 시스템으로 포팅하는 대신, Zig로 C 애플리케이션의 일부만 빌드하는 간단한 예제를 시도해 보고 싶었습니다.

간단한 C 애플리케이션 만들기

다른 Zig + C 예제들에서 저를 헷갈리게 했던 점은 C 코드 자체가 너무 복잡해서 Zig에서 C 코드를 호출하는 기본 원리가 가려진다는 것이었습니다.

Zig의 C 연동 기능을 더 단순하게 보여주기 위해, 간단한 C 애플리케이션과 라이브러리를 직접 만들어 보기로 했습니다.

먼저 C 헤더 파일입니다:

// arithmetic.h

int add(int x, int y);

그리고 구현부입니다:

// arithmetic.c

#include "arithmetic.h"

int add(int x, int y) {
  return x + y;
}

특별한 내용은 없습니다. 최대한 단순하게 유지하는 것이 목표입니다.

마지막으로 add 함수를 테스트할 애플리케이션을 만들겠습니다:

// main.c

#include <stdio.h>

#include "arithmetic.h"

int main(void)
{
  int x = 5;
  int y = 16;
  int z = add(x, y);
  printf("%d + %d = %d\n", x, y, z);
  return 0;
}

이제 모든 것이 정상이라면 표준 C 컴파일러인 gcc로 이 애플리케이션을 컴파일할 수 있어야 합니다:

$ gcc arithmetic.c main.c -o ./bin/example
$ ./bin/example
5 + 16 = 21

좋습니다, 모두 정상적으로 동작합니다!

이 단계까지의 전체 예제는 GitHub에서 확인할 수 있습니다.

컴파일러를 Zig로 바꾸기

지금까지는 순수한 C 프로젝트였고, Zig는 전혀 사용하지 않았습니다.

이제 Zig를 설치하겠습니다. Zig를 설치하는 방법은 여러 가지가 있지만, 저는 Nix를 사용하겠습니다. 제가 요즘 가장 좋아하는 패키지 매니저이기 때문입니다. 저는 설치에만 Nix를 사용할 뿐이므로, 아직 Nix 컬트에 입문하지 않았다면 다른 방법으로 Zig 0.14.0을 설치하셔도 좋습니다.

Zig 0.14.0을 환경에 가져오도록 프로젝트에 다음 flake.nix 파일을 추가했습니다:

{
  description = "Dev environment for zig-c-simple";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 0.14.0
    zig-nixpkgs.url = "github:NixOS/nixpkgs/f6db44a8daa59c40ae41ba6e5823ec77fe0d2124";
  };

  outputs = { self, flake-utils, zig-nixpkgs }@inputs :
    flake-utils.lib.eachDefaultSystem (system:
    let
      zig-nixpkgs = inputs.zig-nixpkgs.legacyPackages.${system};
    in
    {
      devShells.default = zig-nixpkgs.mkShell {
        packages = [
          zig-nixpkgs.zig
        ];

        shellHook = ''
          echo "zig" "$(zig version)";
        '';
      };
    });
}

이제 nix develop을 실행하면 프로젝트 환경에서 Zig 0.14.0을 사용할 수 있는 것을 확인할 수 있습니다:

$ nix develop
zig 0.14.0

Zig에는 gcc를 그대로 대체할 수 있는 C 컴파일러가 내장되어 있습니다. 이전 컴파일을 다시 시도하되, gcc 대신 zig cc를 호출해 보겠습니다:

$ zig cc arithmetic.c main.c -o ./bin/example
$ ./bin/example
5 + 16 = 21

좋습니다, 여전히 잘 동작하고 이제 컴파일에 Zig를 사용하고 있습니다. 아직 Zig 코드는 전혀 사용하지 않았으니, 다음 단계에서 해 보겠습니다.

이 단계까지의 전체 예제는 GitHub에서 확인할 수 있습니다.

동등한 Zig 앱 만들기

Zig 애플리케이션을 만들기 위해 보일러플레이트 Zig 실행 파일을 생성해 주는 zig init-exe를 사용하겠습니다:

$ zig init-exe
info: Created build.zig
info: Created src/main.zig

src/main.zig를 다음 내용으로 교체하겠습니다. 위의 main.c와 동등한 Zig 애플리케이션을 만드는 코드입니다.

// src/main.zig

const std = @import("std");

fn add(x: i32, y: i32) i32 {
    // TODO: Instead of reimplementing this in Zig, call the C version.
    return x + y;
}

pub fn main() !void {
    const x: i32 = 5;
    const y: i32 = 16;
    var z: i32 = add(x, y);

    const stdout_file = std.io.getStdOut().writer();
    var bw = std.io.bufferedWriter(stdout_file);
    const stdout = bw.writer();

    try stdout.print("{d} + {d} = {d}\n", .{ x, y, z });
    try bw.flush();
}

test "test add" {
    try std.testing.expectEqual(@as(i32, 21), add(5, 16));
}

실행해 보면 C 버전과 동일한 출력이 나옵니다:

$ zig build run
5 + 16 = 21

좋습니다. 하지만 제 목표는 모든 것을 Zig로 다시 작성하는 것이 아니라 Zig에서 C 코드를 호출하는 것입니다. 다음으로 Zig로 구현한 add를 네이티브 C 구현으로 교체하는 방법을 알아보겠습니다.

이 단계까지의 전체 예제는 GitHub에서 확인할 수 있습니다.

Zig 애플리케이션을 네이티브 C 라이브러리에 링크하기

지금까지는 기본적인 “hello, world!” 수준의 내용이었습니다. 이제 이전에 제가 막혔던 부분, 즉 Zig에서 네이티브 C 코드를 호출하는 단계로 넘어가겠습니다.

먼저 Zig 코드와 C 코드를 분리하도록 파일을 정리하겠습니다. 새로운 폴더 구조는 다음과 같습니다:

c-src/
  arithmetic.c
  arithmetic.h
  main.c
src/
  main.zig
build.zig

다음으로 Zig 애플리케이션이 C 소스 파일에 접근할 수 있도록 build.zig를 수정합니다:

    const exe = b.addExecutable(.{
        .name = "zig-c-simple",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    exe.addIncludePath(b.path("c-src"));   // Look for C source files

Zig의 유닛 테스트 빌드 타깃에 대해서도 동일하게 설정합니다:

    const unit_tests = b.addTest(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    unit_tests.addIncludePath(b.path("c-src"));   // Look for C source files

이제 Zig 빌드가 C 연산 라이브러리에 접근할 수 있도록 설정했지만, 아직 라이브러리를 호출하지는 않았습니다. 예제를 완성하려면 src/main.zig 파일을 다음과 같이 수정해야 합니다:

// src/main.zig

const arithmetic = @cImport({
    @cInclude("arithmetic.c");
});

fn add(x: i32, y: i32) i32 {
    return arithmetic.add(x, y);
}

위 변경으로 add 함수의 Zig 네이티브 구현을 arithmetic.c 파일에 있는 네이티브 C add 함수를 호출하는 래퍼로 교체했습니다.

이제 진짜 시험대입니다. 모든 것이 예상대로 컴파일되고 실행될까요?

$ zig build run
5 + 16 = 21

좋습니다, 동작합니다!

유닛 테스트도 실행해 보겠습니다:

$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 1/1 tests passed
test success
└─ run test 1 passed 1ms MaxRSS:1M
   └─ zig test Debug native success 2s MaxRSS:201M

유닛 테스트도 통과합니다. 모든 것이 잘 되어 보입니다!

Zig가 정말 C를 호출하고 있을까요?

다른 프로그래밍 언어에서 C 코드를 호출해 본 적이 있는데, 이렇게 쉬운 적은 없었습니다. 혹시 제가 스스로를 속이고 있는 게 아닐까, Zig가 실제로는 제 C 코드를 호출하고 있지 않은 게 아닐까 걱정되어 일부러 C 코드에 버그를 넣어 보았습니다:

// arithemtic.c

int add(int x, int y) {
  return x + y - 1; // Intentionally return incorrect results.
}

제 Zig 애플리케이션이 정말로 C를 호출하고 있다면, 기반이 되는 C 코드가 이제 잘못되었으므로 Zig 유닛 테스트가 실패해야 합니다.

어떻게 되는지 확인하기 위해 유닛 테스트를 실행했습니다:

$ zig build test --summary all
test
└─ run test 0/1 passed, 1 failed
error: 'main.test.test add' failed: expected 21, found 20
/nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/lib/zig/std/testing.zig:103:17: 0x1048bad in expectEqualInner__anon_421 (test)
                return error.TestExpectedEqual;
                ^
/tmp/tmp.LCH7Soiq5V/src/main.zig:25:5: 0x1048c7f in test.test add (test)
    try std.testing.expectEqual(@as(i32, 21), add(5, 16));
    ^
error: while executing test 'main.test.test add', the following test command failed:
/tmp/tmp.LCH7Soiq5V/.zig-cache/o/ab02faa31a7c5067027f9f3a2e4ce1f9/test --seed=0x4b485ae6 --cache-dir=/tmp/tmp.LCH7Soiq5V/.zig-cache --listen=-
Build Summary: 1/3 steps succeeded; 1 failed; 0/1 tests passed; 1 failed
test transitive failure
└─ run test 0/1 passed, 1 failed
   └─ zig test Debug native success 1s MaxRSS:257M
error: the following build command failed with exit code 1:
/tmp/tmp.LCH7Soiq5V/.zig-cache/o/159f82a7dcc12c245254f0919e2ecdf2/build /nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/bin/zig /nix/store/dzdlr4lms4wgjvi02r1pcqh54iiq9pn5-zig-0.14.0/lib/zig /tmp/tmp.LCH7Soiq5V /tmp/tmp.LCH7Soiq5V/.zig-cache /home/mike/.cache/zig --seed 0x4b485ae6 -Zabdc51211068b123 test --summary all

좋습니다! 예상대로 expected 21, found 20 오류와 함께 테스트가 실패했습니다. 유닛 테스트가 제가 C add 함수에 넣은 버그를 정확히 찾아냈습니다.

Zig가 헤더 참조를 따라가고 있을까요?

이 솔루션에서 놀랍도록 잘 동작하는 또 다른 부분은 .h 파일을 통해 함수를 참조할 수 있다는 점입니다. C/C++ 프로그래밍을 한 지 오래되어 정확히는 기억나지 않지만, .c 파일로 임포트하는 것은 불가능했던 것으로 기억하는데, Zig에서는 이렇게 쉬운 것이 놀라울 따름입니다.

Zig가 어떤 꼼수를 쓰고 있는 건 아닌지 테스트하기 위해, arithmetic.h 헤더에 새로운 함수와 전처리기 매크로를 추가했습니다:

// arithmetic.h

#define INCREMENT_AMOUNT 1
int increment(int x);

그리고 이 새로운 함수 정의를 arithmetic.c에 추가합니다:

// arithemtic.c

int increment(int x) {
  return x + INCREMENT_AMOUNT;
}

마지막으로 이 새로운 함수에 대한 간단한 유닛 테스트를 src/main.zig 파일에 추가합니다:

test "test increment" {
    try std.testing.expectEqual(@as(i32, 6), arithmetic.increment(5));
}

만약 Zig가 C 헤더의 #include 지시문을 무시한다면 컴파일 오류가 나거나 테스트가 실패해야 합니다. 새로운 테스트를 실행해 보겠습니다:

$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 2/2 tests passed
test success
└─ run test 2 passed 830us MaxRSS:1M
   └─ zig test Debug native success 2s MaxRSS:201M

통과했습니다! 이는 Zig가 제 C 소스에 있는 #include 참조를 따라가는 편리한 기능을 가지고 있어, 제가 써 본 어떤 언어보다 C 코드를 더 쉽게 호출할 수 있게 해 준다는 것을 보여줍니다.

정리

이 글에서는 Zig에서 C 코드를 호출하는 방법을 보여주기 위해 제가 생각할 수 있는 가장 간단한 예제를 소개했습니다.

이 기법을 사용하면 C 라이브러리의 일부를 Zig 빌드 시스템으로 포팅한 뒤, Zig에서 해당 라이브러리를 호출할 수 있습니다.

소스 코드

전체 소스 코드는 GitHub에서 확인할 수 있습니다. 프로젝트의 단계별로 나누어 두었습니다:


Stéphane BortzmeyerIntegratedQuantum에게 제안을 해 주셔서 이 솔루션을 단순화하는 데 도움을 주셔서 감사드립니다. Daniel Bartley에게는 솔루션을 Zig 0.14.0으로 업데이트해 주셔서 감사드립니다.

원문은 Michael Lynch님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.