Zig로 C 코드 호출하기: 문자열
Zig는 C를 대체하기 위해 설계된 새로운 오픈소스 프로그래밍 언어입니다. 저는 아직 Zig 초보자라 기존 C 애플리케이션의 일부를 Zig로 다시 작성하면서 언어를 배우고 있습니다.
Zig를 다루면서 처음 마주한 난관 중 하나는 문자열을 이해하는 것이었습니다. C 코드를 호출할 때 Zig 문자열이 어떻게 동작하는지에 대한 자세한 문서를 찾을 수 없어, Zig로 C를 호출하려는 다른 분들에게 도움이 되길 바라며 제가 알게 된 내용을 공유합니다.
C 문자열 간단 정리
Zig 문자열을 C 코드에 넘기는 방법을 설명하기 전에, C에서 문자열이 어떻게 동작하는지 배경부터 짚어보겠습니다.
C에서 문자열의 타입은 char*입니다.
char* example = "Hi, I'm a C string!";
C 문자열은 그저 문자의 연속일 뿐입니다. 타입 char*는 해당 변수가 그 연속된 문자 중 첫 번째 문자의 메모리 주소를 저장한다는 의미입니다.
답답하게도 char* 타입은 컴파일러에게 문자열이 어디서 끝나는지 알려주지 않습니다. 대신 C 애플리케이션은 문자열의 마지막 문자 뒤에 값이 0인 바이트, 즉 “널 종료 문자(null terminator)”를 두어 문자열의 끝을 표시합니다.
strlen을 호출하면 문자열의 길이를 반환하는데, 이때 널 종료 문자를 제외한 문자 수를 알려줍니다:
printf("strlen=%lu\n", strlen("hi"));
strlen=2
strlen이 문자열 길이를 2라고 해도, sizeof 함수로 확인한 문자열의 바이트 크기는 3입니다:
printf("sizeof=%lu\n", sizeof("hi"));
sizeof=3
두 글자짜리 문자열에 대해 sizeof가 3을 반환하는 이유는 C가 문자열 끝에 암시적으로 널 종료 문자를 추가했기 때문입니다.
문자열의 각 문자 값을 출력해보면 널 종료 문자가 실제로 존재함을 증명할 수 있습니다:
char s[] = "hi";
printf("%s=[%c, %c, %d]\n", s, s[0], s[1], s[2]);
hi=[h, i, 0]
C와 C++의 문자열은 매우 취약해서 보안 취약점과 애플리케이션 충돌을 자주 일으킵니다. 컴파일러가 문자열 길이를 알지 못하기 때문에 C 코드가 애플리케이션의 다른 변수가 사용하는 메모리를 실수로 덮어쓰기 쉽습니다.
기본적인 Zig 문자열
Zig는 C를 대체하는 현대적인 언어로 설계됐기 때문에 어려운 과제를 안고 있습니다. C의 실수를 바로잡으면서도 기존 C 코드와 쉽게 상호 운용할 수 있어야 합니다.
문자열이 어떻게 동작하는지 보여주는 간단한 Zig 애플리케이션 예제입니다:
const std = @import("std");
pub fn main() !void {
const s = "hello";
std.debug.print("s is type {s}\n", .{@typeName(@TypeOf(s))});
}
$ zig run src/strings.zig
s is type *const [5:0]u8
Zig에서 문자열 "hello"의 타입은 *const [5:0]u8입니다.
이 변수 타입에는 많은 정보가 담겨 있어 오른쪽에서 왼쪽 순으로 풀어보겠습니다.
u8은 Zig에서 바이트를 표현하는 방식으로, 8비트 부호 없는 값입니다.
:0은 센티널 값(sentinel value)이 0인 센티널 종료 버퍼, 즉 널 종료 버퍼임을 뜻합니다.
const는 상수 변수이므로 값을 할당한 뒤에는 변경할 수 없다는 의미입니다.
*는 이 값이 메모리 위치를 가리키는 포인터라는 뜻입니다.
여기서 중요한 점은 두 가지입니다:
- Zig는 문자열 변수의 길이를 알고 있다.
- Zig는 문자열에 널 종료 문자를 추가한다.
Zig의 널 종료 문자는 C 호환성을 위한 것이다
C에서 널 종료 문자는 사실상 문자열의 길이를 결정합니다. 다섯 글자짜리 문자열이 있을 때 세 번째 문자를 널 바이트로 바꾸면, 모든 C 문자열 함수는 이제 그 문자열을 두 글자짜리로 인식합니다.
// Truncating a string in C.
char s[] = "hello";
s[2] = '\0';
printf("s=[%s]\n", s);
printf("len=%lu\n", strlen(s));
s=[he]
len=2
Zig에서도 문자열에는 여전히 널 종료 문자가 있지만, 제가 확인한 바로는 널 종료 문자는 Zig API에 아무 의미가 없습니다. 문자열을 출력하거나 길이를 확인할 때 널 문자는 아무런 영향을 주지 않습니다. Zig는 널 문자가 어디에 있든 문자열의 실제 길이를 정확히 알고 있습니다.
// Trying to null-terminate a string in the middle in Zig.
const s = [_:0]u8{ 'h', 'e', 0, 'l', 'o' };
std.debug.print("s={s}\n", .{s});
std.debug.print("s.len={d}\n", .{s.len});
s=[helo]
s.len=5
Zig의 널 종료 이해하기
len 필드는 널 문자를 제외한다
Zig에서 문자열을 선언하면 문자열 길이를 알려주는 len 속성이 있습니다. 흥미롭게도 이 길이는 널 종료 문자를 제외합니다:
const s = "hi";
std.debug.print("s.len={d}\n", .{s.len});
s.len=2
문자열의 실제 길이는 널 바이트까지 포함해야 하므로 3이라는 것을 알고 있습니다. 문자열의 바이트 크기를 확인해보면 이를 증명할 수 있습니다:
const s = "hi";
// Dereference the string s with s.*. Otherwise, Zig would report the size of
// the pointer.
std.debug.print("@sizeOf={d}\n", .{@sizeOf(@TypeOf(s.*))});
@sizeOf=3
진짜 배열 경계는 어디인가?
대부분의 언어에서 배열 크기가 N이면 N - 1번 슬롯까지만 읽을 수 있습니다. 예를 들어 크기가 2인 배열에서는 슬롯 0과 슬롯 1은 읽을 수 있지만, 슬롯 2를 읽으면 런타임 충돌이 발생하거나 정의되지 않은 동작(undefined behavior)이 일어납니다.
Zig의 일반 배열도 다른 언어와 비슷하게 동작하지만, Zig의 센티널 종료 배열은 배열의 끝이라고 알려진 지점보다 한 칸 더 읽을 수 있습니다. .len이 주장하는 바와 달리 센티널 값을 위한 추가 슬롯이 있기 때문입니다:
var s = "hi";
std.debug.print("s[{d}]={d}\n", .{ s.len, s[s.len] });
s[2]=0
처음에는 제가 동작을 제대로 이해한 건지 확신이 서지 않았습니다. 혹시 배열 경계를 넘어 읽고 있는데 다음 메모리의 바이트가 마침 0인 것은 아닐까 하는 생각도 들었습니다.
아닙니다. Zig 문서는 길이 N인 센티널 종료 배열에서 N번 슬롯을 읽을 수 있다고 명시하고 있습니다:
센티널 종료 슬라이스는
len인덱스에 대한 요소 접근을 허용합니다.https://ziglang.org/documentation/0.11.0/#Sentinel-Terminated-Slices
슬롯 N + 1을 읽어보며 Zig가 코드 컴파일을 거부하는지도 확인해 볼 수 있습니다:
var s = "hi";
std.debug.print("s[{d}]={d}\n", .{ s.len + 1, s[s.len + 1] });
src/corrupt-string.zig:5:59: error: index 3 outside array of length 2 +1 (sentinel)
std.debug.print("s[{d}]={d}\n", .{ s.len + 1, s[s.len + 1] });
~~~~~~^~~
컴파일러 메시지는 변수의 길이가 2 + 1이라고 명시적으로 말해주므로, 널 종료 문자를 위해 추가 슬롯을 예약하고 있음을 알 수 있습니다.
널 종료 문자를 덮어쓸 수 없다
Zig 문서는 [:0] 구문이 Zig가 “길이 인덱스에 있는 센티널 값을 보장한다”는 의미라고 설명합니다.
널 종료 버퍼에서 널 문자를 덮어써 보며 이를 테스트해보기로 했습니다:
var s = [_:0]u8{ 'h', 'e', 'l', 'l', 'o' };
s[s.len] = 'A';
위 코드는 오류 없이 컴파일되고 실행됩니다.
놀랍습니다. Zig가 널 종료 속성을 보장해야 하는 것 아닌가요?
알고 보니 Zig는 널 종료 문자를 덮어쓰는 할당을 무시함으로써 널 종료를 보장합니다. 그래서 제가 널 종료 문자를 덮어쓰라고 지시했음에도 Zig는 그 할당을 가볍게 무시하고 널 종료 속성을 유지하는 것입니다.
var s = [_:0]u8{ 'h', 'e', 'l', 'l', 'o' };
s[s.len] = 'A'; // This line has no effect.
std.debug.print("s[{d}]={d}\n", .{ s.len, s[s.len] });
s[5]=0
Zig 문자열을 C 코드에 넘기기
이제 Zig 문자열이 어떻게 동작하는지 이해했으니, Zig의 널 종료 보장을 활용해 C 코드를 더 안전하게 호출하는 방법을 보여드리겠습니다.
Zig 코드에서 문자열 파라미터를 받는 C 함수를 호출하고 싶다고 상상해 보겠습니다. 예시로 C 표준 라이브러리의 strlen 함수를 어떻게 호출하는지 보여드리겠습니다:
// Returns the length of the C string str.
size_t strlen(const char* str);
strlen은 단순한 함수입니다. 문자열을 순회하며 첫 번째 0 바이트를 찾고, 0이 아닌 바이트 수를 반환합니다.
C 표준 라이브러리가 string.h 헤더 파일에 선언한 함수들을 호출하려면 Zig에서 이렇게 헤더를 가져옵니다:
const cString = @cImport({
@cInclude("string.h");
});
이렇게 하면 Zig에서 strlen 함수를 다음과 같이 호출할 수 있습니다:
cString.strlen("hello!");
이를 염두에 두고 C의 strlen 함수에 대한 Zig 네이티브 래퍼를 만들어 보겠습니다:
fn strlen(str: [:0]const u8) usize {
return cString.strlen(str);
}
파라미터를 [:0]const u8로 정의해 호출하는 쪽에서 반드시 널 종료된 문자열을 넘기도록 했습니다. 이는 C에서는 불가능한 수준의 안전성을 제공합니다. Zig는 널 종료를 강제하지만 C는 어떤 문자열이 널 종료되어 있음을 보장할 수 없기 때문입니다.
전체 Zig 프로그램 예제는 다음과 같습니다:
// src/wrap-strlen.zig
const std = @import("std");
const cString = @cImport({
@cInclude("string.h");
});
fn strlen(str: [:0]const u8) usize {
return cString.strlen(str);
}
pub fn main() !void {
const s = "hi";
std.debug.print("strlen({s})={d}\n", .{ s, strlen(s) });
}
zig run으로 이 예제를 컴파일하고 실행할 수 있습니다. C 표준 라이브러리의 헤더인 string.h를 가져오고 있으므로, Zig 컴파일러에게 libc와 링크하라고 알려주기 위해 --library c를 전달해야 합니다.
$ zig run src/wrap-strlen.zig --library c
strlen(hi)=2
좋습니다, 예상대로 동작하네요. Zig에서 만든 문자열의 길이를 계산하기 위해 C의 strlen 함수를 사용하고 있습니다.
널 종료되지 않은 문자열을 넘기려고 하면 어떻게 될까요?
const notNullTerminated = [_]u8{ 'h', 'i' };
std.debug.print("strlen({s})={d}\n", .{ notNullTerminated, strlen(¬NullTerminated) });
$ zig run src/wrap-strlen-badcall.zig --library c
src/wrap-strlen-badcall.zig:13:71: error: expected type '[:0]const u8', found '*const [2]u8'
std.debug.print("strlen({s})={d}\n", .{ notNullTerminated, strlen(¬NullTerminated) });
^~~~~~~~~~~~~~~~~~
src/wrap-strlen-badcall.zig:13:71: note: destination pointer requires '0' sentinel
src/wrap-strlen-badcall.zig:7:16: note: parameter type declared here
fn strlen(str: [:0]const u8) usize {
^~~~~~~~~~~~
좋습니다!
Zig는 컴파일 타임에 오류를 잡아냅니다. strlen이 널 종료 문자열([:0]u8)을 요구하지만 notNullTerminated의 타입은 const [2]u8이라 널 종료 속성이 없기 때문입니다.
널 종료는 엄격한 보장이 아니다
Zig 컴파일러가 C 문자열 함수를 호출하는 것을 더 안전하게 만들어주긴 하지만, [:0]u8 타입이 실제로 널 종료되어 있음을 확실히 보장할 수는 없습니다.
기존 배열에서 슬라이스를 만들고 이를 잘못되게 널 종료된 것으로 선언함으로써 Zig를 속일 수 있습니다:
var a = [_]u8{ 'h', 'e', 'l', 'l', 'o' };
// This is a lie, as this slice isn't really null-terminated.
const s = a[0..2 :0];
std.debug.print("strlen({s})={d}\n", .{ s, strlen(s) });
이 코드를 기본 설정으로 컴파일하면 Zig가 디버그 검사를 추가해 런타임에 패닉을 일으킵니다:
$ zig run src/wrap-strlen-evil.zig --library c
thread 43926 panic: sentinel mismatch: expected 0, found 108
/home/mike/ustreamer/src/wrap-strlen-evil.zig:13:16: 0x21fefc in main (wrap-strlen-evil)
const s = a[0..2 :0];
^
/nix/store/bg6hyfzr1wzk795ii48mc1v15bswcvp3-zig-0.11.0/lib/zig/std/start.zig:574:37: 0x220477 in main (wrap-strlen-evil)
const result = root.main() catch |err| {
^
???:?:?: 0x7ffff7dffacd in ??? (libc.so.6)
Unwind information for `libc.so.6:0x7ffff7dffacd` was not available, trace may be incomplete
오류 메시지 sentinel mismatch: expected 0, found 108은 슬라이스의 2번 슬롯에서 0 바이트(널 종료 문자)가 있어야 하는데 'l' 문자(108로 표시됨)가 발견됐다는 뜻입니다.
코드를 릴리스 모드로 컴파일하면 프로그램은 정의되지 않은 동작을 보입니다:
$ zig run src/wrap-strlen-evil.zig --library c -O ReleaseFast
strlen(he)=5
Zig는 슬라이스의 길이를 알고 있으므로 문자열 s를 두 글자("he")로 인식합니다. 반면 C는 길이 정보가 없으므로 첫 번째 널 바이트를 찾을 때까지 탐색하고, 그 과정에서 a 배열에 할당된 메모리를 넘어서까지 읽게 됩니다. 환경에 따라 이 프로그램은 할당된 메모리를 벗어난 영역을 읽어 접근 위반으로 충돌할 수도 있습니다.
Zig에서 C 문자열 받기
자, 지금까지 문자열을 입력 파라미터로 받는 C 함수에 대한 Zig 래퍼를 작성하는 방법을 보여드렸습니다.
그렇다면 출력으로 C 문자열을 생성하는 C 함수는 어떻게 처리할까요?
예시로 C의 strdup 함수에 대한 Zig 래퍼를 만드는 방법을 보여드리겠습니다:
// Returns a pointer to a null-terminated byte string, which is a duplicate of
// the string pointed to by str1. The returned pointer must be passed to free to
// avoid a memory leak.
char* strdup(const char* str1);
주석에서 설명하듯 strdup은 문자열을 받아 그 문자열의 복사본을 담을 메모리를 할당하고, 문자열 내용을 새로 할당된 버퍼에 복사합니다.
C의 strdup 함수에 대한 Zig 래퍼는 다음과 같이 생길 수 있습니다:
fn strdup(str: [:0]const u8) ![* :0]u8 { ... }
이 함수는 입력으로 널 종료 문자열을 받습니다. 반환 값은 ![*:0]u8이며, 각각의 의미는 다음과 같습니다:
u8은 부호 없는 바이트를 의미합니다.
[*:0]은 길이를 알 수 없는 널 종료 슬라이스를 의미합니다. 이는 Zig가 슬라이스의 길이를 알고 .len 속성으로 접근할 수 있게 하는 [:0]과 다릅니다. [*:0] 타입의 경우 Zig는 길이를 모르고 단지 널 바이트로 끝난다는 사실만 압니다.
!는 이 함수가 오류를 반환할 수 있음을 의미합니다. 기저의 C strdup 함수가 실패할 수 있기 때문에 오류가 발생할 수 있습니다.
이제 이 함수의 입력과 출력을 설명했으니 구현을 시도해 보겠습니다:
fn strdup(str: [:0]const u8) ![* :0]u8 {
return cString.strdup(str) orelse error.OutOfMemory;
}
그리고 Zig에서 strdup 래퍼 함수를 호출하는 전체 예제는 다음과 같습니다:
const std = @import("std");
const cString = @cImport({
@cInclude("string.h");
});
fn strdup(str: [:0]const u8) ![* :0]u8 {
return cString.strdup(str) orelse error.OutOfMemory;
}
pub fn main() !void {
const s = "hi";
std.debug.print("s = [{s}] (type={}, size={d}, len={d})\n", .{ s, @TypeOf(s), @sizeOf(@TypeOf(s.*)), s.len });
const sCopy = try strdup(s);
defer std.c.free(sCopy);
std.debug.print("sCopy= [{s}] (type={}, size={d}, len={d})\n", .{ sCopy, @TypeOf(sCopy), @sizeOf(@TypeOf(sCopy)), std.mem.len(sCopy) });
}
실행 결과는 다음과 같습니다:
$ zig run src/wrap-strdup.zig --library c
s = [hi] (type=*const [2:0]u8, size=3, len=2)
sCopy= [hi] (type=[*:0]u8, size=8, len=2)
출력은 꽤 직관적입니다. s와 sCopy는 같은 데이터를 담고 있지만 변수 타입이 다릅니다. sCopy는 C가 할당한 메모리를 나타내고, s는 Zig가 할당하고 관리하는 메모리를 나타내기 때문입니다.
Zig가 sCopy의 크기를 8이라고 보고하는 이유는 64비트 시스템에서 포인터의 크기이기 때문입니다. C가 메모리를 할당했고 버퍼 크기 정보를 Zig에 전달할 수 없으므로, Zig는 메모리 버퍼의 크기를 알 수 없습니다.
Zig 관리 버퍼로 래퍼 개선하기
C의 strdup 함수를 Zig에서 호출하는 데는 성공했지만, 제 해결책은 다소 깔끔하지 못합니다. Zig 래퍼가 길이를 알 수 없는 슬라이스를 반환하며 구현 세부사항을 노출하고, 호출자가 Zig 네이티브 메모리 할당자가 아니라 std.c.free로 버퍼를 해제해야 하기 때문입니다.
C 구현 세부사항을 추상화하고 이를 일반적인 네이티브 Zig 함수처럼 보이게 하려면 어떻게 해야 할까요?
결과 문자열을 복사해 넣을 Zig 네이티브 버퍼를 할당하는 방식으로 수정한 strdup 래퍼는 다음과 같습니다:
fn strdup(allocator: std.mem.Allocator, str: [:0]const u8) ![:0]u8 {
const cCopy: [* :0]u8 = cString.strdup(str) orelse return error.OutOfMemory;
defer std.c.free(cCopy);
// Create a Zig slice of the C buffer that's length-aware.
const zCopy: [:0]u8 = std.mem.span(cCopy);
// Allocate a new null-terminated Zig-managed slice and copy in the contents
// of zCopy.
return allocator.dupeZ(u8, zCopy);
}
원래 구현과 이번 수정본 사이에는 몇 가지 변경점이 있습니다.
첫째, 이 함수는 메모리를 할당하는 Zig 객체인 std.mem.Allocator를 받습니다. Zig에서는 C처럼 전역 메모리 할당 함수를 호출해 원할 때마다 메모리를 할당하지 않습니다. 숨겨진 메모리 할당을 피하기 위해 함수들은 std.mem.Allocator 타입을 받으며, 이를 통해 호출자에게 함수가 메모리를 할당할 수 있음을 명확히 알립니다.
다음으로, C 메모리 버퍼를 반환하고 비표준적인 방식으로 해제하도록 호출자에게 책임을 떠넘기는 대신, 새로운 strdup 구현은 일반적인 Zig 슬라이스를 반환합니다. strdup의 호출자는 여전히 슬라이스를 해제해야 할 책임이 있지만, 이제는 allocator.free로 표준적인 방식으로 해제할 수 있습니다.
이 수정본의 단점은 이전 버전보다 느리다는 점입니다. 메모리를 두 번 할당하고 복사합니다. 한 번은 C에서, 또 한 번은 Zig에서입니다. 성능이 중요한 코드라면 호출자가 C 배열을 직접 다루게 하여 속도를 얻을 수도 있겠지만, 일반적으로는 Zig가 메모리를 관리하도록 하는 편을 선호합니다.
새로운 래퍼 구현을 사용한 전체 예제는 다음과 같습니다:
const std = @import("std");
const cString = @cImport({
@cInclude("string.h");
});
fn strdup(allocator: std.mem.Allocator, str: [:0]const u8) ![:0]u8 {
const cCopy: [* :0]u8 = cString.strdup(str) orelse return error.OutOfMemory;
defer std.c.free(cCopy);
const zCopy: [:0]u8 = std.mem.span(cCopy);
return allocator.dupeZ(u8, zCopy);
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
defer _ = gpa.deinit();
const s = "hi";
std.debug.print("s = [{s}] (type={}, size={d}, len={d})\n", .{ s, @TypeOf(s), @sizeOf(@TypeOf(s.*)), s.len });
const sCopy = try strdup(allocator, s);
defer allocator.free(sCopy);
std.debug.print("sCopy= [{s}] (type={}, size={d}, len={d})\n", .{ sCopy, @TypeOf(sCopy), @sizeOf(@TypeOf(sCopy)), sCopy.len });
}
$ zig run src/wrap-strdup2.zig --library c
s = [hi] (type=*const [2:0]u8, size=3, len=2)
sCopy= [hi] (type=[:0]u8, size=16, len=2)
sCopy의 크기가 왜 16인지 이해할 수 없습니다. 슬라이스에 몇 글자를 저장하든 크기는 그대로 유지되지만, 같은 코드를 32비트 ARM 라즈베리 파이에서 실행하면 size=8로 줄어듭니다.
s는 Zig가 컴파일 타임에 크기를 아는 배열이고, sCopy는 Zig가 런타임이 되어서야 크기를 알 수 있는 슬라이스라는 것은 알고 있습니다. 그래도 Zig는 슬라이스의 길이를 알고 있으므로 몇 바이트를 차지하는지 알아야 할 텐데, 그 정보를 어떻게 조회해야 할지 모르겠습니다.
업데이트: 레딧의 /u/paulstelian97 님이 설명해주신 바에 따르면 슬라이스는 “팻 포인터(fat pointer)”로서 메모리 주소와 길이를 함께 담고 있다고 합니다. 이제 왜 일반 포인터보다 두 배 큰지는 이해했지만, 슬라이스가 차지하는 바이트 크기를 Zig에게 어떻게 물어봐야 하는지는 여전히 모르겠습니다.
마무리
Zig는 문자열을 C 코드에 넘기거나 C로부터 문자열 출력을 받는 작업을 쉽게 만들어줍니다.
Zig의 타입 시스템은 C보다 강력하여, 개발자가 C 라이브러리에 대한 Zig 네이티브 래퍼를 작성할 수 있게 하고, 이를 통해 C에서 가능한 것보다 메모리 손상에 대해 더 견고한 검사를 제공합니다.
더 읽어보기
글을 무작위로 읽기