Using Zig to Call C Code: Strings

Michael Lynch

ZigでCコードを呼び出す:文字列

原文は Michael Lynch により に公開されました。 このブログを購読する

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

sizeofが2文字の文字列に対して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-terminated)されたバッファであり、番兵の値が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の通常の配列も他の言語と同様に振る舞いますが、Zigの番兵終端配列では、配列の終端とされている位置から1つ先のスロットまで読み取ることができます。.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ではヌル終端が強制されるため、ある程度の安全性が得られます。

この動作を示す完全な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

いい感じです。期待通りに動作しました。Cのstrlen関数を使って、Zigで作成した文字列の長さを計算しています。

ヌル終端されていない文字列を渡そうとするとどうなるでしょうか。

const notNullTerminated = [_]u8{ 'h', 'i' };
std.debug.print("strlen({s})={d}\n", .{ notNullTerminated, strlen(&notNullTerminated) });
$ 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(&notNullTerminated) });
                                                                      ^~~~~~~~~~~~~~~~~~
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を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は符号なしバイトを意味します。

[*: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)

出力はごく素直なものです。ssCopyは同じデータを含んでいても、変数の型は異なります。sCopyはCが割り当てたメモリを表すのに対し、sはZigが割り当てて管理しているメモリを表しているからです。

ZigがsCopyのサイズを8と報告しているのは、64ビットシステムにおけるポインタのサイズがそうだからです。メモリバッファはCが割り当てたものであり、そのサイズ情報をZigに伝えることができないため、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で標準的な方法で行うことができます。

この改訂版の欠点は、以前のバージョンより遅いことです。メモリの割り当てとコピーを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が実行時まで知らないスライスであることはわかっています。それでも、Zigはスライスの長さを知っているので、それが何バイトを占めるかもわかるはずですが、その情報をどう問い合わせればよいのかわかりません。

追記: Redditの/u/paulstelian97さんが説明してくれたところによると、スライスは「ファットポインタ」であり、メモリアドレスと長さを含んでいるとのことです。これで通常のポインタの2倍のサイズになる理由は理解できましたが、スライスがバイト単位で何バイトなのかをZigにどう問い合わせればよいのかは、まだわかりません。

まとめ

Zigでは、文字列をCのコードに渡したり、Cから文字列の出力を受けることが簡単にできます。

Zigの型システムはCよりも強力で、これにより開発者はCライブラリのZigネイティブなラッパーを書くことができ、Cで利用可能なものよりも堅牢なメモリ破壊に対するチェックが得られます。

さらに読む


efjimm氏に、提案をいただき、この解決策をシンプルにするのに役立ったことに感謝します。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント