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コンパイラに伝えることができません。そこでCでは、文字列の終わりを「ヌル終端文字」で示します。これは文字列の最後の文字の直後に置かれる、値が0の1バイトです。
文字列の長さを返すstrlenを呼び出すと、ヌル終端文字を除いた文字数が返ってきます。
printf("strlen=%lu\n", strlen("hi"));
strlen=2
strlenでは長さが2と返ってくる一方で、sizeofでバイト単位のサイズを調べると3になります。
printf("sizeof=%lu\n", sizeof("hi"));
sizeof=3
2文字の文字列に対して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における1バイトの表現で、8ビットの符号なし整数を意味します。
:0は、番兵値が0の番兵終端バッファ(つまりヌル終端バッファ)であることを意味します。
constは定数であることを示し、一度値を代入したら変更できません。
*は、この値がメモリ上の位置を指すポインタであることを意味します。
ここでの重要なポイントは2つあります。
- Zigは文字列変数の長さを把握しています。
- Zigは文字列にヌル終端文字を付加します。
Zigのヌル終端はC互換のため
Cでは、ヌル終端文字が実質的に文字列の長さを決定します。5文字の文字列があるときに、3文字目をヌルバイトに置き換えると、すべてのCの文字列関数はその文字列を2文字として扱うようになります。
// 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番目を読もうとすると実行時エラーになるか、未定義動作となります。
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と定義することで、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を2文字("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は符号なしの1バイトを意味します。
[*:0]は、長さが不明なヌル終端スライスを意味します。これは[: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ビットシステムにおけるポインタのサイズが8バイトだからです。メモリバッファのサイズをZigが教えてくれないのは、Cが確保したもので、バッファサイズの情報をZigに伝えることができないためです。
Zig管理のバッファでラッパーを改善する
ZigからCのstrdup関数を呼び出すことはできましたが、この方法は少し雑多です。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で解放できます。
この改訂版の欠点は、以前のバージョンより遅くなることです。メモリの確保とコピーを2回行うことになります。1回はCで、もう1回は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 Raspberry Piで実行するとsize=8になります。
sはZigがコンパイル時にサイズを把握している配列であるのに対し、sCopyは実行時までサイズがわからないスライスであることは理解しています。それでもZigはスライスの長さを知っているのだから、占有するバイト数もわかるはずですが、その情報をどう問い合わせればよいのかわかりませんでした。
追記:Redditの/u/paulstelian97さんが説明によると、スライスはメモリアドレスと長さを含む「ファットポインタ」だそうです。これで通常のポインタの2倍のサイズになる理由は理解できましたが、スライスのバイト単位のサイズをZigにどう問い合わせればよいのかは、依然としてわかりません。
まとめ
Zigを使えば、文字列をCのコードに渡したり、Cから文字列の出力を受け取ったりすることが簡単にできます。
Zigの型システムはCよりも強力なので、開発者はCライブラリ用のZigネイティブなラッパーを書くことができ、Cでは得られないような、メモリ破壊に対するより堅牢なチェックを実現できます。
参考資料
記事をランダムに読む