Using Zig to Unit Test a C Application

Michael Lynch

Zig로 C 애플리케이션 단위 테스트하기

Zig는 독자적으로 개발된 새로운 저수준 프로그래밍 언어입니다. C를 현대적으로 재해석한 언어로, C의 성능은 그대로 유지하면서 지난 30년간의 툴링과 언어 설계 발전을 적극적으로 받아들였습니다.

제가 써 본 어떤 언어보다 Zig는 C 코드를 호출하기가 쉽습니다. 또한 Zig는 단위 테스트를 일급 기능으로 다룹니다. C 언어는 전혀 그렇지 않죠.

Zig의 이 두 가지 특성이 흥미로운 가능성을 만듭니다. 기존 C 코드에 단위 테스트를 추가할 수 있다는 점입니다. C 코드를 다시 작성하거나 빌드 로직을 고칠 필요도 없습니다.

Zig로 기존 C 코드를 테스트하는 방법을 보여주기 위해, 제가 매일 사용하는 실제 C 애플리케이션에 단위 테스트를 추가해 보았습니다.

실제 C 애플리케이션: uStreamer

지난 3년간 저는 오픈소스 KVM over IP인 TinyPilot을 작업해 왔습니다. TinyPilot을 이용하면 Raspberry Pi를 어떤 컴퓨터에든 연결해 원격으로 제어할 수 있습니다.

대상 컴퓨터의 화면을 스트리밍하기 위해 TinyPilot은 Raspberry Pi 하드웨어에 최적화된 영상 스트리밍 유틸리티인 uStreamer를 사용합니다.

브라우저 창에 Dell 부팅 화면을 표시하고 있는 TinyPilot 스크린샷

TinyPilot은 C로 작성된 uStreamer 애플리케이션을 이용해 영상을 스트리밍합니다

저는 몇 년간 uStreamer를 다뤄 왔지만, 코드베이스에 접근하기가 어렵다고 느꼈습니다. C로 구현되어 있고 자동화된 테스트도 없기 때문입니다.

저는 코드를 직접 만져보면서 가장 잘 배우는 편이라, Zig를 통해 uStreamer의 C 코드를 실행해 보는 것이 uStreamer와 Zig 모두를 더 잘 이해하는 좋은 방법이 될 것 같았습니다.

uStreamer 소스 코드 가져오기

먼저 uStreamer 소스 코드를 가져오겠습니다. 이 글을 쓰는 시점의 최신 릴리스는 v5.45이므로 해당 버전을 가져오겠습니다:

USTREAMER_VERSION='v5.45'
git clone \
  --branch "${USTREAMER_VERSION}" \
  https://github.com/pikvm/ustreamer.git

uStreamer에서 가장 단순한 C 함수는 무엇일까?

이번 연습에서 어려운 부분은 Zig를 사용하는 것이므로, C 부분은 최대한 단순하게 유지하고 싶습니다.

uStreamer의 C 코드에서 아주 단순한 함수를 찾고 싶습니다. 입력을 넣으면 쉽게 확인할 수 있는 출력을 돌려주는 함수 말입니다.

파일 이름을 훑어보다가 base64.c를 발견했습니다. 가능성이 있어 보였습니다. base64는 임의의 데이터를 인쇄 가능한 문자열로 인코딩하는 방식이라는 걸 알고 있습니다.

예를 들어 /dev/random에서 10바이트를 읽어 터미널에 출력하면 인쇄 불가능한 바이트가 나옵니다:

$ head -c 10 /dev/random > /tmp/output && cat /tmp/output
V�1A�����b

데이터를 base64로 인코딩하면 깔끔하고 인쇄 가능한 문자를 얻을 수 있습니다:

$ base64 < /tmp/output
Vo8xQbWmnsLQYg==

uStreamer의 base64 함수 시그니처는 다음과 같습니다:

// src/libs/base64.h

void us_base64_encode(const uint8_t *data, size_t size, char **encoded, size_t *allocated);

base64.c에 있는 함수의 구현을 살펴보고 us_base64_encode의 의미를 추론한 내용은 다음과 같습니다:

  • data는 base64 인코딩 방식으로 인코딩할 입력 데이터입니다.
  • sizedata 버퍼의 길이(바이트 단위)입니다.
  • encodedus_base64_encode가 base64로 인코딩된 문자열을 저장하는 출력 버퍼에 대한 포인터입니다.
    • us_base64_encode가 출력용 메모리를 할당하며, 호출자가 사용을 마치면 메모리를 해제해야 합니다.
    • 엄밀히 말하면 us_base64_encode는 호출자가 encoded용 버퍼를 직접 할당하도록 허용하지만, 단순화를 위해 여기서는 그 기능은 무시하겠습니다.
  • allocatedus_base64_encodeencoded에 할당한 바이트 수를 채워 넣는 포인터입니다.

C에서 이 함수를 호출하는 간단한 테스트 프로그램은 다음과 같습니다:

// src/test.c

#include <stdio.h>

#include "libs/base64.h"

void main(void) {
  char *input = "hello, world!";
  char *encoded = NULL;
  size_t encoded_bytes = 0;
  us_base64_encode((uint8_t *)input, strlen(input), &encoded, &encoded_bytes);
  printf("input:        %s\n", input);
  printf("output:       %s\n", encoded);
  printf("output bytes: %lu\n", encoded_bytes);
  free(encoded);
}

많이 쓰이는 C 컴파일러인 gcc로 컴파일해 보겠습니다:

$ gcc src/test.c src/libs/base64.c -o /tmp/b64test
In file included from src/libs/base64.h:31,
                 from src/test.c:3:
src/libs/tools.h: In function ‘us_signum_to_string’:
src/libs/tools.h:194:34: warning: implicit declaration of function ‘sigabbrev_np’ [-Wimplicit-function-declaration]
  194 |         const char *const name = sigabbrev_np(signum);

흠, 컴파일은 되지만 uStreamer 코드가 포함하는 tools.h 헤더 때문에 컴파일러 경고가 많이 발생합니다.

src/libs/tools.h를 살펴보니 모든 오류가 하나의 함수, 즉 us_signum_to_string 주변에서 발생하고 있었습니다. 관련 없는 경고를 없애기 위해 해당 함수를 주석 처리해 보겠습니다.

/*
DEBUG: Temporarily delete this function to get the build working again.
INLINE char *us_signum_to_string(int signum) {
...
  return buf;
}
*/

성가신 us_signum_to_string 함수를 제거했으니 다시 빌드를 시도해 보겠습니다:

$ gcc src/test.c src/libs/base64.c -o /tmp/b64test && /tmp/b64test
input:        hello, world!
output:       aGVsbG8sIHdvcmxkIQ==
output bytes: 21

좋습니다, 이제 컴파일러 경고가 더 이상 나오지 않습니다.

uStreamer 전체를 컴파일하려면 us_signum_to_string이 컴파일되도록 문제를 해결해야 할 겁니다. 하지만 이번 연습에서는 Zig에서 us_base64_encode만 호출하면 되므로 us_signum_to_string은 필요하지 않습니다.

test.c 프로그램의 출력을 시스템에 내장된 base64 유틸리티와 비교하면 올바른 결과를 내고 있음을 확인할 수 있습니다:

$ printf 'hello, world!' | base64
aGVsbG8sIHdvcmxkIQ==

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

uStreamer 프로젝트 환경에 Zig 추가하기

제가 가장 선호하는 Zig 설치 방법은 Nix를 이용하는 방법으로, 이를 통해 Zig 버전을 쉽게 전환할 수 있습니다. 원하시는 방법으로 Zig를 설치하셔도 됩니다.

다음 flake.nix 파일을 프로젝트에 추가했는데, 이 파일은 Zig 0.11.0을 환경으로 불러옵니다:

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

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

    # 0.11.0
    zig-nixpkgs.url = "github:NixOS/nixpkgs/46688f8eb5cd6f1298d873d4d2b9cf245e09e88e";
  };

  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.11.0을 사용할 수 있음을 확인할 수 있습니다:

# There's a weird quirk of Nix flakes that they have to be added to your git
# repo.
$ git add flake.nix
$ nix develop
zig 0.11.0

Zig 실행 파일 만들기

Zig 컴파일러의 init-exe는 상용구 Zig 애플리케이션을 생성하므로, 이를 이용해 uStreamer 소스 트리 안에 간단한 Zig 앱을 만들어 보겠습니다:

$ zig init-exe
info: Created build.zig
info: Created src/main.zig
info: Next, try `zig build --help` or `zig build run`

상용구 Zig 애플리케이션을 컴파일하고 실행해 보면 모든 것이 정상적으로 동작함을 알 수 있습니다:

$ zig build run
All your codebase are belong to us.
Run `zig build test` to run the tests.

호출하려는 uStreamer C 파일이 C 표준 라이브러리에 의존하므로, 해당 라이브러리에 링크하도록 build.zig 파일을 약간 수정해야 합니다. 수정하는 김에 상용구 바이너리 이름도 base64-encoder로 바꾸겠습니다:

    const exe = b.addExecutable(.{
        .name = "base64-encoder", // Change binary name.
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    exe.linkLibC();               // Link against C standard library.
    exe.addIncludePath(.{ .path = "src" });

Zig에서 uStreamer 코드 호출하기

이제 Zig에서 us_base64_encode C 함수를 호출해 보겠습니다.

다시 한번, Zig에서 호출하려는 C 함수는 위에서 설명한 다음과 같은 함수입니다:

// src/libs/base64.h

void us_base64_encode(const uint8_t *data, size_t size, char **encoded, size_t *allocated);

C 타입과 Zig 타입 사이를 변환하는 방법을 알아내는 것이 Zig 초보자인 저에게는 이 과정에서 가장 어려운 부분이었습니다.

다음은 첫 번째 시도였습니다:

// src/main.zig

const ustreamer = @cImport({
    @cInclude("libs/base64.c");
});

pub fn main() !void {
    const input = "hello, world!";
    var cEncoded: *u8 = undefined;
    var allocatedSize: usize = 0;
    // WRONG: This doesn't compile.
    ustreamer.us_base64_encode(&input, input.len, &cEncoded, &allocatedSize);
}

그 결과 다음과 같은 컴파일러 오류가 발생했습니다:

$ zig build run
zig build-exe b64 Debug native: error: the following command failed with 1 compilation errors:
...
src/main.zig:17:32: error: expected type '[*c]const u8', found '*const *const [13:0]u8'
    ustreamer.us_base64_encode(&input, input.len, &cEncoded, &allocatedSize);
                               ^~~~~~
src/main.zig:17:32: note: pointer type child '*const [13:0]u8' cannot cast into pointer type child 'u8'
/home/mike/ustreamer/zig-cache/o/9599bf4c636d23e50eddd1a55dd088ff/cimport.zig:1796:43: note: parameter type declared here
pub export fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {

이 오류는 상당 부분이 낯설어서 처음에는 이해하기 어려웠습니다.

위 컴파일러 오류에서 중요한 부분은 error: expected type '[*c]const u8', found '*const *const [13:0]u8'입니다. *const *const [13:0]u8 타입을 전달하려 했지만, Zig에서는 [*c]const u8을 전달해야 한다는 뜻입니다.

이게 무슨 뜻일까요?

사용한 타입 이해하기

Zig 컴파일러에 따르면 저는 '*const *const [13:0]u8 타입의 매개변수를 전달했습니다. 이 의미를 이해하기 위해 오른쪽에서 왼쪽으로 살펴보겠습니다:

u8은 부호 없는 바이트로, Zig에서 문자열의 문자를 표현하는 방식입니다.

[13:0]은 null로 끝나는 배열을 의미합니다. 13은 배열의 길이로, Zig가 컴파일 시점에 계산합니다. :0은 문자열의 끝을 나타내기 위해 값이 0인 바이트 하나가 추가로 있음을 의미합니다. Zig에서 null 종료 문자열의 동작 방식에 대한 자세한 내용은 이전 글을 참고하세요.

*const는 상수 포인터를 의미합니다. 포인터는 메모리의 주소이며, const는 이후 코드에서 변수를 재할당할 수 없음을 뜻합니다.

*const *const는 상수 포인터에 대한 상수 포인터를 의미합니다. 즉, input은 문자열에 대한 상수 포인터이므로 &input은 상수 포인터에 대한 상수 포인터가 됩니다.

Zig 타입을 C 타입으로 변환하기

좋습니다, 이제 제가 전달한 문자열을 Zig가 어떻게 보는지 이해했습니다. 그렇다면 Zig가 input 타입으로 전달하길 원했던 것은 무엇일까요?

expected type '[*c]const u8'

도대체 [*c]는 무슨 뜻일까요?

이게 무슨 뜻인지 알아내는 게 놀랍게도 어려웠습니다. 결국 여러 출처에서 정보를 조각모아 이해할 수 있었습니다.

다음은 공식 Zig 문서의 내용입니다:

C 포인터

이 타입은 가능한 한 사용하지 않는 것이 좋습니다. C 포인터를 사용하는 유일한 타당한 이유는 C 코드를 변환하여 자동 생성된 코드에서뿐입니다.

C 헤더 파일을 가져올 때, 포인터를 단일 항목 포인터(*T)로 변환해야 할지 다중 항목 포인터([*]T)로 변환해야 할지 모호합니다. C 포인터는 Zig 코드가 변환된 헤더 파일을 직접 활용할 수 있도록 하는 절충안입니다.

https://ziglang.org/documentation/0.11.0/#C-Pointers

문서를 이해할 수 없었습니다. C 포인터가 무엇인지 설명하기보다는 사용하지 말라는 경고처럼 보였기 때문입니다.

더 검색한 끝에 Kagi에서 찾은 reddit의 다음 설명이 훨씬 이해하기 쉬웠습니다:

[*c]T는 그저 타입 T에 대한 C 포인터일 뿐이며, 해당 포인터에 여러 원소가 있는지 아닌지 알 수 없다는 뜻입니다. 있을 수도 있고 없을 수도 있습니다. 길이도 알 수 없습니다(포인터+길이로 이루어진 슬라이스가 아니라 그냥 포인터일 뿐입니다). 그리고 여러 원소가 있더라도 예를 들어 null로 끝나는지 아닌지도 알 수 없습니다.

-/u/slimsag on reddit

이제 이해가 됩니다.

C에서 포인터는 단순히 메모리 주소와 데이터 타입입니다. char* 같은 C 타입은 'A' 같은 단일 문자를 가리킬 수도 있고, "ABCD" 같은 연속된 문자열의 첫 문자를 가리킬 수도 있습니다.

Zig에서는 배열에 대한 포인터와 단일 원소에 대한 포인터가 다른 타입입니다. Zig가 C 코드에서 데이터 타입을 추론해야 할 때, C 코드가 단일 원소를 가리키는지 배열을 가리키는지 알 수 없으므로, C 포인터 타입([*c]T)은 Zig가 “모르겠다, C에서 가져온 것이다”라고 말하는 방식입니다.

시행착오 끝에 Zig가 원하는 것은 주소 연산자 &를 사용하는 대신 input.ptr을 참조해 input에 대한 포인터를 얻는 것임을 알아냈습니다.

다음 Zig 스니펫은 .ptr&의 차이를 보여줍니다:

const input = "hello, world!";
std.debug.print("input     is type {s}\n", .{@typeName(@TypeOf(input))});
std.debug.print("&input    is type {s}\n", .{@typeName(@TypeOf(&input))});
std.debug.print("input.ptr is type {s}\n", .{@typeName(@TypeOf(input.ptr))});
input     is type *const [13:0]u8
&input    is type *const *const [13:0]u8
input.ptr is type [*]const u8

Zig가 us_base64_encode[*c]const u8 타입의 매개변수를 전달하길 원한다는 점을 기억하면, [*]const u8을 해당 타입으로 변환할 수 있어 보입니다.

좋습니다, 다시 us_base64_encode를 호출해 보겠습니다:

const input = "hello, world!";
var cEncoded: *u8 = undefined;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

그 결과는 다음과 같습니다:

$ zig build run
zig build-exe b64 Debug native: error: the following command failed with 1 compilation errors:
...
src/main.zig:12:54: error: expected type '[*c][*c]u8', found '**u8'
    ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
                                                     ^~~~~~~~~

진전입니다!

코드는 여전히 컴파일되지 않지만, 이제 Zig는 첫 번째 매개변수가 아니라 세 번째 매개변수에 대해 불평하고 있습니다. 적어도 처음 두 매개변수에 대해서는 예상한 타입을 전달했다는 뜻입니다.

출력 매개변수를 Zig로 변환하기

컴파일러 오류에는 C로 구현된 us_base64_encode를 호출하는 데 도움이 되는 정보도 담겨 있습니다:

pub export fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {

이는 Zig로 변환된 C 함수의 시그니처이므로, Zig는 함수를 호출하기 위해 정확히 어떤 타입을 전달해야 하는지 알려주고 있는 셈입니다.

또는 zig translate-c 유틸리티를 사용해 이 C 함수 시그니처를 Zig로 변환할 수도 있습니다. 이 방법은 위의 컴파일러 오류와 사실상 동일한 결과를 제공하지만, 원래 매개변수 이름을 그대로 유지한다는 차이가 있습니다. 컴파일러 오류에서는 매개변수 이름 앞에 arg_가 붙기 때문입니다.

# We add --library c to let Zig know the code depends on libc.
$ zig translate-c src/libs/base64.h --library c | grep us_base64
pub extern fn us_base64_encode(data: [*c]const u8, size: usize, encoded: [*c][*c]u8, allocated: [*c]usize) void;

더 많은 시행착오 끝에 마침내 Zig에서 us_base64_encode를 호출하는 방법을 추론해 냈습니다:

const input = "hello, world!";
var cEncoded: [*c]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

그리고 성공적으로 컴파일됩니다!

C 포인터보다 더 나은 방법은 없을까?

Zig 문서에서 C 포인터에 대해 뭐라고 했는지 기억하시나요:

자동 생성된 코드에서만 C 포인터를 사용하는 것이 유일한 타당한 이유입니다…

이 코드는 제가 직접 작성하고 있으므로, 자동 생성된 코드용으로 예약된 타입을 사용해서는 안 될 것 같습니다.

세 번째 매개변수가 null로 끝나는 문자열에 대한 포인터라는 것을 알고 있습니다. 이를 Zig에서는 어떻게 표현할 수 있을까요?

처음에는 이렇게 해야겠다고 생각했습니다:

var cEncoded: [*:0]u8 = undefined;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

이게 합리적으로 보였습니다. us_base64_encodecEncoded에 문자열을 채워 넣을 것이고, [*:0]u8은 길이를 알 수 없는 null 종료 문자열을 나타낸다는 걸 알고 있습니다. 하지만 컴파일하면 Zig는 거부했습니다:

error: expected type '[*c][*c]u8', found '*[*:0]u8'

막막해서 Zig 토론 포럼인 Ziggit에 도움을 요청했습니다. 한 시간도 안 되어 다른 사용자가 해결책을 알려주었습니다:

var cEncoded: ?[*:0]u8 = null;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

문제는 C에서 char** 타입은 null이 될 수 있지만, Zig의 [*:0]u8 타입은 null이 될 수 없다는 점이었습니다. 그래서 Zig가 이전 시도를 거부했던 것입니다.

올바른 타입인 ?[*:0]u8을 분해해 보면 다음과 같습니다:

  • null로 끝나는 바이트 슬라이스(:0]u8)
  • 길이를 알 수 없는 슬라이스([*)
  • null일 수 있는 값(?)

새로운 타입 덕분에 코드는 컴파일되지만, cEncoded 값을 출력해 보면 문자열이 아니라 메모리 주소처럼 보이는 값이 나옵니다:

$ zig build run
input:       hello, world!
output:      u8@2b12a0      # << whoops, not what I expected
output size: 21

cEncoded를 다시 출력 가능한 문자열로 변환하려면, optional 변수에서 값을 꺼내 그 값이 null이 아님을 코드로 검증해야 합니다:

var cEncoded: ?[*:0]u8 = null;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

...

std.debug.print("output:      {s}\n", .{output});

그러면 올바른 결과가 출력됩니다:

$ zig build run
input: hello, world!
output: aGVsbG8sIHdvcmxkIQ==
output size: 21

Zig에서 C 호출 완성하기

이제 Zig에서 C 함수 us_base64_encode를 호출하는 완전한 코드를 갖추었습니다. 전체 src/main.zig 파일은 다음과 같습니다:

// src/main.zig

const std = @import("std");

// Import the base64 implementation from uStreamer's C source file.
const ustreamer = @cImport({
    @cInclude("libs/base64.c");
});

pub fn main() !void {
    // Create a standard Zig string.
    const input = "hello, world!";

    // Create variables to store the ouput parameters of us_base64_encode.
    var cEncoded: ?[*:0]u8 = null;
    var allocatedSize: usize = 0;

    // Call the uStreamer C function from Zig.
    ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

    // Get the output as a non-optional type.
    const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

    // Free the memory that the C function allocated when this function exits.
    defer std.c.free(cEncoded);

    // Print the input and output of the base64 encode operation.
    std.debug.print("input:       {s}\n", .{input});
    std.debug.print("output:      {s}\n", .{output});
    std.debug.print("output size: {d}\n", .{allocatedSize});
}
$ zig build run
input:       hello, world!
output:      aGVsbG8sIHdvcmxkIQ==
output size: 21

좋습니다! 잘 동작합니다. 그리고 결과는 위의 C 구현과 동일합니다.

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

네이티브 C 구현을 위한 Zig 래퍼 만들기

이제 Zig에서 C 함수 us_base64_encode를 성공적으로 호출할 수 있지만, 코드가 다소 지저분합니다. main() 함수의 대부분이 값을 C 코드와 주고받는 변환을 처리하고 있기 때문입니다.

코드를 개선하는 한 가지 방법은 us_base64_encode용 Zig 래퍼 함수를 추가하는 것입니다. 그러면 모든 Zig-C 상호 운용 로직을 캡슐화할 수 있고, 래퍼의 호출자는 C를 호출하고 있다는 사실을 알거나 신경 쓸 필요가 없습니다.

래퍼 함수는 어떤 모습이어야 할까요?

임의의 바이트를 받아 null로 끝나는 문자열을 반환해야 하므로, 함수 시그니처는 다음과 같이 생겨야 합니다:

fn base64Encode(data: []const u8) [:0]u8 {...}

위의 main() 함수를 기반으로 이미 구현의 첫 몇 줄을 갖추고 있습니다:

fn base64Encode(data: []const u8) [:0]u8 {
  var cEncoded: ?[*:0]u8 = null;
  var allocatedSize: usize = 0;

  ustreamer.us_base64_encode(data.ptr, data.len, &cEncoded, &allocatedSize);

  // TODO: Complete the implementation.

C가 할당한 메모리는 누가 해제해야 할까?

아직 다루지 않은 문제가 하나 있습니다. us_base64_encodecEncoded 포인터에 메모리를 할당했습니다. 호출자는 해당 메모리를 해제하거나 그 책임을 호출자에게 넘길 책임이 있습니다.

일반적으로 함수가 호출자에게 출력 값의 해제를 맡긴다고 선언하는 것은 괜찮지만, 이번 경우는 조금 더 까다롭습니다. 일반적인 Zig 할당 버퍼가 아니라, 특별한 해제 함수(std.c.free)가 필요한 C 할당 버퍼이기 때문입니다.

C 구현 세부 사항을 추상화하고 싶으므로, 호출자가 C 전용 메모리 해제 함수를 사용할 필요 없도록 하고 싶습니다.

이것이 Zig 래퍼 구현을 완성하기 위해 해야 할 일을 알려줍니다. defer std.c.free로 C가 할당한 메모리 버퍼를 해제하고, 그 내용을 Zig가 관리하는 슬라이스로 복사해야 합니다:

fn base64Encode(data: []const u8) ![:0]u8 {
  var cEncodedOptional: ?[*:0]u8 = null;
  var allocatedSize: usize = 0;

  ustreamer.us_base64_encode(data.ptr, data.len, &cEncodedOptional, &allocatedSize);
  const cEncoded: [*:0]u8 = cEncodedOptional orelse return error.UnexpectedNull;

  // Get the output as a non-optional type.
  const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

  // Free the C-allocated memory buffer before exiting the function.
  defer std.c.free(cEncoded);

  // TODO: Copy the contents of cEncoded into a [:0]u8 buffer.
}

C 문자열을 Zig 문자열로 변환하기

이제 문자열을 [*:0]u8(길이를 알 수 없는, 0으로 끝나는 Zig 슬라이스)로 가지고 있지만, [:0]u8(길이를 아는, null 종료 Zig 슬라이스)로 반환하고 싶습니다. C 스타일 문자열을 Zig 슬라이스로 어떻게 변환할 수 있을까요?

이전 글에서는 다음 과정으로 C 문자열을 Zig 문자열로 변환했습니다:

  1. std.mem.span을 사용해 C 문자열의 Zig 슬라이스를 만듭니다.
  2. allocator.dupeZ를 사용해 슬라이스의 내용을 새로 할당된 Zig 슬라이스로 복사합니다.

이 과정은 여기에서도 동작하겠지만, (1) 단계에서 쓸데없는 작업을 하게 됩니다. std.mem.span은 null 종결자를 찾기 위해 문자열을 순회해야 합니다. 이 코드에서는 이미 null 종결자의 위치를 알고 있습니다. us_base64_encode가 그 정보를 allocatedSize 매개변수에 저장하기 때문입니다.

대신 다음과 같이 cEncoded 슬라이스의 길이를 아는 Zig 슬라이스를 만듭니다:

// The allocatedSize includes the null terminator, so subtract 1 to get the
// number of non-null characters in the string.
const cEncodedLength = allocatedSize - 1;

// Convert cEncoded (unknown length slice) to a length-aware slice.
const outputLengthAware: [:0] = cEncoded[0..cEncodedLength :0];

이제 래퍼 함수의 구현을 완성할 수 있습니다:

fn base64Encode(allocator: std.mem.Allocator, data: []const u8) ![:0]u8 {
    var cEncoded: [*c]u8 = null;
    var allocatedSize: usize = 0;

    ustreamer.us_base64_encode(data.ptr, data.len, &cEncoded, &allocatedSize);

    // Get the output as a non-optional type.
    const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

    // Free the C-allocated memory buffer before exiting the function.
    defer std.c.free(cEncoded);

    // The allocatedSize includes the null terminator, so subtract 1 to get the
    // number of non-null characters in the string.
    const cEncodedLength = allocatedSize - 1;
    return allocator.dupeZ(u8, cEncoded[0..cEncodedLength :0]);
}

dupeZ를 호출하려면 Zig 할당자가 필요하므로, base64Encode 래퍼가 std.mem.Allocator 타입을 받도록 의미를 조정했습니다.

모두 하나로 묶기

Zig 래퍼를 마련했으니, 이제 Zig에서 C 함수 us_base64_encode를 실행하는 것은 매우 간단합니다.

이전 코드가 다음과 같았음을 기억하세요:

const input = "hello, world!";
var cEncoded: ?[*:0]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;
defer std.c.free(cEncoded);

Zig 래퍼를 사용하면 의미가 두 줄로 단순화됩니다:

const output = try base64Encode(allocator, "hello, world!");
defer allocator.free(output);

전체 예제는 다음과 같습니다:

const std = @import("std");

// Import the base64 implementation from uStreamer's C source file.
const ustreamer = @cImport({
    @cInclude("libs/base64.c");
});

fn base64Encode(allocator: std.mem.Allocator, data: []const u8) ![:0]u8 {
    var cEncodedOptional: ?[*:0]u8 = null;
    var allocatedSize: usize = 0;

    ustreamer.us_base64_encode(data.ptr, data.len, &cEncodedOptional, &allocatedSize);
    const cEncoded: [*:0]u8 = cEncodedOptional orelse return error.UnexpectedNull;
    defer std.c.free(cEncodedOptional);

    const cEncodedLength = allocatedSize - 1;
    return allocator.dupeZ(u8, cEncoded[0..cEncodedLength :0]);
}

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

    const input = "hello, world!";
    const output = try base64Encode(allocator, input);
    defer allocator.free(output);

    // Print the input and output of the base64 encode operation.
    std.debug.print("input:       {s}\n", .{input});
    std.debug.print("output:      {s}\n", .{output});
    std.debug.print("output size: {d}\n", .{output.len});
}
$ zig build run
input:       hello, world!
output:      aGVsbG8sIHdvcmxkIQ==
output size: 20

이제 출력 크기가 21이 아니라 20인 이유는 기본 데이터 타입이 바뀌었기 때문입니다. 이전에는 us_base64_encode가 채운 출력 크기 매개변수를 출력했는데, 여기에는 null 종결자가 포함되어 있었습니다. 이제는 출력 문자열의 .len 속성을 사용하고 있으며, 여기에는 null 종결자가 포함되지 않습니다.

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

첫 번째 단위 테스트 만들기

이제 편리한 Zig 래퍼를 통해 C 함수 us_base64_encode를 호출할 수 있게 되었으니, C 구현이 올바른지 검증하기 위한 단위 테스트 작성을 시작할 준비가 되었습니다.

먼저 해야 할 일은 단위 테스트가 libc와 uStreamer의 C 소스 파일에 접근할 수 있도록 build.zig 파일을 약간 수정하는 것입니다:

// build.zig

    const unit_tests = b.addTest(.{
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    unit_tests.linkLibC();                          // Link against libc.
    unit_tests.addIncludePath(.{ .path = "src" });  // Search src path for includes.

이미 Zig 래퍼 함수를 작성하면서 어려운 작업을 마쳤으므로, 첫 번째 단위 테스트를 작성하는 것은 간단합니다:

// src/main.zig

test "encode simple string as base64" {
    const allocator = std.testing.allocator;
    const actual = try base64Encode(allocator, "hello, world!");
    defer allocator.free(actual);
    try std.testing.expectEqualStrings("aGVsbG8sIHdvcmxkIQ==", actual);
}

zig build test 명령은 제 단위 테스트를 실행합니다:

$ 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:211M

성공입니다! 첫 번째 단위 테스트가 동작하고 C 코드를 실행하고 있습니다.

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

오탐 테스트 결과 확인하기

단위 테스트가 성공하고 있지만, 테스트가 실제로 C 코드를 실행하고 있고 단순히 오탐을 반환하는 것이 아님을 확인하고 싶습니다. C 코드에 의도적으로 버그를 넣어 이를 검증할 수 있습니다.

다음은 base64.c 구현의 일부 발췌입니다:

#    define OCTET(_name) unsigned _name = (data_index < size ? (uint8_t)data[data_index++] : 0)
    OCTET(octet_a);
    OCTET(octet_b);
    OCTET(octet_c);
#    undef OCTET

이 두 줄의 순서를 바꿔 보겠습니다:

    OCTET(octet_a);
    OCTET(octet_c); // I've swapped these
    OCTET(octet_b); // two lines.

그리고 변조한 뒤 C 함수에 대해 단위 테스트를 다시 실행하면 다음과 같은 결과가 나옵니다:

$ zig build test --summary all
run test: error: 'test.encode simple string as base64' failed: ====== expected this output: =========
aGVsbG8sIHdvcmxkIQ==␃

======== instead found this: =========
aGxlbCxvIG93cmRsIQ==␃

멋집니다, 테스트가 동작합니다!

us_base64_encode에 버그를 넣자 테스트가 실패하며 버그를 드러냈습니다.

여러 단위 테스트 추가하기

단일 테스트 케이스를 여러 테스트 케이스로 확장해 C 함수 로직을 더 많이 실행하고 있다는 확신을 높이고 싶습니다.

첫 번째 단위 테스트의 절반은 메모리 관리에 관한 상용구였으므로, 각 테스트마다 이를 반복하고 싶지 않습니다. 상용구를 담는 유틸리티 함수를 작성했습니다:

fn testBase64Encode(
    input: []const u8,
    expected: [:0]const u8,
) !void {
    const allocator = std.testing.allocator;
    const actual = try base64Encode(allocator, input);
    defer allocator.free(actual);
    try std.testing.expectEqualStrings(expected, actual);
}

테스트 유틸리티 함수 덕분에 새로운 테스트를 쉽게 추가할 수 있습니다:

test "encode strings as base64" {
    try testBase64Encode("", "");
    try testBase64Encode("h", "aA==");
    try testBase64Encode("he", "aGU=");
    try testBase64Encode("hel", "aGVs");
    try testBase64Encode("hell", "aGVsbA==");
    try testBase64Encode("hello, world!", "aGVsbG8sIHdvcmxkIQ==");
}

test "encode raw bytes as base64" {
    try testBase64Encode(&[_]u8{0}, "AA==");
    try testBase64Encode(&[_]u8{ 0, 0 }, "AAA=");
    try testBase64Encode(&[_]u8{ 0, 0, 0 }, "AAAA");
    try testBase64Encode(&[_]u8{255}, "/w==");
    try testBase64Encode(&[_]u8{ 255, 255 }, "//8=");
    try testBase64Encode(&[_]u8{ 255, 255, 255 }, "////");
}
$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 2/2 tests passed
test success
└─ run test 2 passed 2ms MaxRSS:2M
   └─ zig test Debug native success 2s MaxRSS:195M

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

마무리

Zig가 C와 훌륭하게 상호 운용되기 때문에 기존 C 애플리케이션에 C 코드나 빌드 과정을 수정하지 않고도 단위 테스트를 추가할 수 있습니다.

제가 보여준 예제에서 C 코드는 Zig를 전혀 알지 못하며, 기존 Makefile에 아무런 변경 없이 그대로 동작합니다.

이 연습이 Zig 언어와 테스트 중인 C 코드 모두를 더 깊이 배우는 유용한 방법이 되었다고 느꼈습니다.


Ziggit 커뮤니티의 이번 블로그 글에 대한 도움에 감사드립니다. uStreamer 발췌 부분은 GPLv3 라이선스에 따라 사용되었습니다.

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

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