Zig로 C 코드 호출하기: 문자열
원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기
Zig는 C를 대체하기 위해 설계된 새로운 오픈소스 프로그래밍 언어다. 나는 아직 Zig 초보자라서 기존 C 애플리케이션의 일부를 Zig로 다시 작성해 보며 언어를 익히고 있다.
Zig를 다루면서 처음 마주한 난관 중 하나는 문자열을 이해하는 것이었다. Zig 문자열이 C 코드를 호출할 때 어떻게 동작하는지에 대한 자세한 문서를 찾지 못해, 나와 같이 Zig로 C를 호출하려는 다른 사람들에게 도움이 되길 바라며 내가 알아낸 내용을 공유한다.
C 문자열 간단 정리
Zig 문자열을 C 코드에 넘기는 방법을 설명하기 전에, 먼저 C에서 문자열이 어떻게 동작하는지 배경을 짚어보자.
C에서 문자열의 타입은 char*이다.
char* example = "Hi, I'm a C string!";
C 문자열은 그저 문자의 연속이다. char* 타입은 변수가 그 연속된 문자 중 첫 번째 문자의 메모리 주소를 저장한다는 뜻이다.
답답하게도 char* 타입은 C 컴파일러에게 문자열이 어디서 끝나는지 알려주지 않는다. 대신 C 애플리케이션은 문자열의 끝을 “널 종단 문자(null terminator)”로 표시하는데, 이는 문자열의 마지막 문자 뒤에 오는 값이 0인 바이트다.
문자열의 길이를 반환하는 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은 센티넬 값이 0인 센티넬 종료 버퍼(sentinel-terminated buffer), 즉 널 종료 버퍼(null-terminated buffer)임을 의미한다.
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의 센티넬 종료 배열은 배열의 supposed end 너머 한 슬롯을 더 읽을 수 있게 해준다. 이는 .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로 정의해, Zig 호출자가 널 종료된 문자열을 전달하도록 보장한다. 이는 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에서 Zig가 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]은 길이를 알 수 없는 널 종료 슬라이스를 의미한다. 이는 [:0]과 다르다. 후자는 Zig가 슬라이스의 길이를 알고 .len 속성을 통해 접근할 수 있다는 뜻이다. [*: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비트 시스템에서 포인터의 크기이기 때문이다. Zig는 C가 할당한 메모리 버퍼의 크기를 알려줄 수 없는데, C가 버퍼 크기 정보를 Zig에 전달할 수 없기 때문이다.
Zig가 관리하는 버퍼로 래퍼 개선하기
C의 strdup 함수를 Zig에서 호출하는 데 성공했지만, 내 해결책은 다소 깔끔하지 못하다. Zig 래퍼가 길이를 알 수 없는 슬라이스를 반환하며 구현 세부사항을 노출하고, 호출자가 std.c.free로 버퍼를 해제해야 하는데 Zig 네이티브 메모리 할당자를 사용하지 못하기 때문이다.
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는 슬라이스의 길이를 알고 있으므로 몇 바이트를 차지하는지 알아야 할 것 같은데, 그 정보를 어떻게 조회해야 할지 모르겠다.
업데이트: reddit의 /u/paulstelian97님이 설명해주길 슬라이스는 “팻 포인터(fat pointer)”이며, 메모리 주소와 길이를 담고 있다고 한다. 이제 왜 일반 포인터 크기의 두 배인지 이해했지만, 여전히 Zig에 슬라이스의 바이트 크기를 어떻게 물어봐야 할지 모르겠다.
마무리
Zig는 문자열을 C 코드에 전달하고 C로부터 문자열 출력을 받는 것을 쉽게 만든다.
Zig의 타입 시스템은 C보다 강력하여, 개발자가 C 라이브러리를 위한 Zig 네이티브 래퍼를 작성해 C에서 가능한 것보다 메모리 손상에 대한 더 견고한 검사를 제공할 수 있게 한다.
더 읽어보기
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기