Zig로 C 애플리케이션 유닛 테스트하기
원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기
Zig는 새롭게 독립적으로 개발된 저수준 프로그래밍 언어다. C를 현대적으로 재해석한 언어로, C의 성능은 유지하면서 지난 30년간의 툴링과 언어 설계의 발전을 받아들이려는 시도다.
Zig는 내가 써 본 어떤 언어보다 C 코드를 호출하기 쉽게 만든다. 또한 Zig는 유닛 테스트를 일급 기능으로 다루는데, C 언어는 확실히 그렇지 않다.
Zig의 이 두 가지 특성은 흥미로운 기회를 만든다. Zig를 이용하면 기존 C 코드에 유닛 테스트를 추가할 수 있다. C 코드나 빌드 로직을 다시 작성할 필요도 없다.
기존 C 코드를 테스트하는 데 Zig를 어떻게 활용할 수 있는지 보여주기 위해, 내가 매일 사용하는 실제 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로 변환하기
컴파일러 오류에는 us_base64_encode의 C 구현을 호출하는 데 도움이 되는 정보도 포함되어 있다:
pub export fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {이는 C 함수를 Zig로 변환한 시그니처로, 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 포인터를 사용하는 유일한 타당한 이유는 자동 생성된 코드에서뿐이다…
나는 이 코드를 직접 손으로 작성하고 있으므로, 자동 생성된 코드를 위해 예약된 타입을 사용해서는 안 될 것 같다.
us_base64_encode의 세 번째 파라미터가 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를 다시 출력 가능한 문자열로 변환하려면, 코드에서 값이 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 종료자를 찾기 위해 문자열을 순회해야 한다. 이 코드에서는 us_base64_encode가 그 정보를 allocatedSize 파라미터에 저장하므로 이미 null 종료자가 어디 있는지 알고 있다.
대신 다음과 같이 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 라이선스에 따라 사용되었습니다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기