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를 사용합니다.

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.gituStreamer에서 가장 단순한 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 인코딩 방식으로 인코딩할 입력 데이터입니다.size는data버퍼의 길이(바이트 단위)입니다.encoded는us_base64_encode가 base64로 인코딩된 문자열을 저장하는 출력 버퍼에 대한 포인터입니다.us_base64_encode가 출력용 메모리를 할당하며, 호출자가 사용을 마치면 메모리를 해제해야 합니다.- 엄밀히 말하면
us_base64_encode는 호출자가encoded용 버퍼를 직접 할당하도록 허용하지만, 단순화를 위해 여기서는 그 기능은 무시하겠습니다.
allocated는us_base64_encode가encoded에 할당한 바이트 수를 채워 넣는 포인터입니다.
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.0Zig 실행 파일 만들기
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 코드가 변환된 헤더 파일을 직접 활용할 수 있도록 하는 절충안입니다.
문서를 이해할 수 없었습니다. C 포인터가 무엇인지 설명하기보다는 사용하지 말라는 경고처럼 보였기 때문입니다.
더 검색한 끝에 Kagi에서 찾은 reddit의 다음 설명이 훨씬 이해하기 쉬웠습니다:
[*c]T는 그저 타입 T에 대한 C 포인터일 뿐이며, 해당 포인터에 여러 원소가 있는지 아닌지 알 수 없다는 뜻입니다. 있을 수도 있고 없을 수도 있습니다. 길이도 알 수 없습니다(포인터+길이로 이루어진 슬라이스가 아니라 그냥 포인터일 뿐입니다). 그리고 여러 원소가 있더라도 예를 들어 null로 끝나는지 아닌지도 알 수 없습니다.
이제 이해가 됩니다.
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 u8Zig가 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_encode가 cEncoded에 문자열을 채워 넣을 것이고, [*: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: 21cEncoded를 다시 출력 가능한 문자열로 변환하려면, 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: 21Zig에서 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_encode가 cEncoded 포인터에 메모리를 할당했습니다. 호출자는 해당 메모리를 해제하거나 그 책임을 호출자에게 넘길 책임이 있습니다.
일반적으로 함수가 호출자에게 출력 값의 해제를 맡긴다고 선언하는 것은 괜찮지만, 이번 경우는 조금 더 까다롭습니다. 일반적인 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 문자열로 변환했습니다:
std.mem.span을 사용해 C 문자열의 Zig 슬라이스를 만듭니다.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 라이선스에 따라 사용되었습니다.
글을 무작위로 읽기