A Simple Example of Calling a C Library from Zig

Michael Lynch

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

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

Zig은 새롭게 독립적으로 개발된 로우레벨 프로그래밍 언어입니다. C를 현대적으로 재해석한 언어로, C의 성능상 이점은 그대로 유지하면서 지난 30년간 발전한 툴링과 언어 설계의 개선점을 활용하고자 합니다.

Zig은 C를 대체하기 위해 설계되었기 때문에, Zig 애플리케이션에서 C 라이브러리를 호출할 수 있다는 점이 핵심 기능 중 하나입니다. Zig의 C 상호 운용 기능을 보여주는 간단한 예제를 찾을 수 없어 직접 작성하기로 했습니다.

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

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

  • “C/C++/Zig” by 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 빌드 시스템으로 변환하는 방법을 배우고 싶지 않았습니다. 대신 전체 애플리케이션을 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을 설치하셔도 됩니다.

프로젝트에 다음 flake.nix 파일을 추가했는데, 이 파일이 제 환경에 Zig 0.14.0을 가져옵니다:

{
  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으로 업데이트해 준 것에 감사드립니다.

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

댓글