glibc syscallラッパーの解析

このページの目次

straceでopenatが見えても、ソースコードが必ずしもopenatを呼び出しているとは限りません。clock_gettimeを呼び出しても、トレースに同名のシステムコールが出現する保証はありません。libcが提供する公共インタフェースのセマンティクスは、内部で他のシステムコール、ユーザー空間の高速パス、または複数のステップの組み合わせを選択する可能性があります。

この境界を理解することは、2つの目的で役立ちます。逆アセンブリを読む際に、引数とエラー変換を探す場所を知るため、そして最適化を行う際に、元のAPIのセマンティクスを維持できているかを確認するためです。

1回のライブラリ関数呼び出しが、必ずしも1回のカーネル呼び出しに対応するわけではない アプリケーションが公共APIを呼び出す 例:read、open、clock_gettime 1 libcがインタフェースセマンティクスを実装 キャンセルポイント、互換処理、引数と構造体レイアウトの変換 2 実装パスの選択 利用可能なvDSOパス、または実際にカーネルに入る 3 結果を変換してアプリケーションに返す 公共インタフェースの規約に従って結果とエラーを返す 4 公共のsyscall()もerrnoを変換します。生アセンブリレイヤーのみが、カーネルの-errnoを直接見ます。

3層のインタフェース:同じ名前が異なるものを指すことがある

階層例ABIとエラーの処理
公共ライブラリ関数read(fd, buf, n)libcがその関数の完全な規約を実装
一般的なシステムコール関数syscall(SYS_read, fd, buf, n)libcがレジスタを配置しエラーを変換;readの全セマンティクスに等しくない
生命令ラッパーインラインアセンブリ syscall / svc呼び出し側がアーキテクチャABIに従って引数を渡し、生結果を解釈

x86-64およびAArch64 Linuxの生ABIでは、エラー符号は -errno です。公共の syscall() はすでにエラーを -1 に変換し、errnoに書き込んでいます。したがって、syscall(...) == -1 を見た後、errnoを読み取るべきであり、-1を反転させてエラー番号として扱うべきではありません。syscall(2)

errnoはスレッドローカル状態であり、成功時の戻り値でそれをゼロクリアする必要はありません。インタフェースの戻り値が失敗を示した場合のみ解釈し、その後にerrnoを上書きする可能性のある呼び出しの前に保存してください。すべてのCインタフェースがerrnoでエラーを報告するわけではありません。例えば、多くのpthread関数はエラー番号を直接返します。

失敗する呼び出しで変換を見る

#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(void)
{
    long result = syscall(SYS_write, -1, "x", (size_t)1);
    int saved = errno;
    printf("result=%ld, errno=%d, EBADF=%d\n", result, saved, EBADF);
    return !(result == -1 && saved == EBADF);
}
cc -std=c11 -Wall -Wextra -Werror wrapper.c -o wrapper
./wrapper
strace -e trace=write ./wrapper

無効なファイルディスクリプタは、カーネルにEBADFを返させます。アプリケーションが観察するのは -1 と errno=EBADF です。straceは生エラーを人間が読める形式にデコードします。printf自体もwriteを発生させる可能性があるため、トレースを分析する際には、fd=-1のターゲット呼び出しと区別してください。

x86-64編のraw_writeを使用した場合のみ、戻り値は直接 -EBADF になります。1つのアルゴリズム内でこれらの2つのエラー規約を混在させてはいけません。

エラー番号は、それを生成した戻り値と一緒に保存する

インタフェース規約判断方法誤解しやすい状況
read / write / syscall-1を返した後、errnoを読む成功時にerrnoに古いエラーが残っている
pthread_create など関数が直接返すエラー番号をチェック非ゼロを返すが、無関係なerrnoを出力している
getauxval など、0値に曖昧さがあるインタフェースドキュメントに従ってまずerrnoをクリアし、結果と組み合わせて判断0値のみで存在しないことを誤判断する
生syscallアセンブリアーキテクチャのエラー範囲を判断変換されていない-errnoを通常の-1と見なす

例えば、最初のwriteが失敗し、その後コンテキストを記録するためにログファイルを開くと、このopenでもerrnoが変更される可能性があります。失敗の直後にsaved_errnoを保存し、保存した値を使用してレポートをフォーマットしてください。errno=0を設定することは、すべての呼び出し前の固定儀式ではありません。インタフェースの規約で古い値の曖昧さを解消する必要がある場合のみ、これを行います。

成功の後、古いerrnoはどこへ?

最初に失敗し、次に成功すると、戻り値が3に変わっている一方で、errnoがまだ9であることが同時に確認できます。これはカーネルによって自動的にリセットされるグローバルなエラーランプではありません。失敗を報告する必要がある場合は、この戻り値に対応するerrnoを直ちに保存し、後続の呼び出しによる上書きを避けてください。

図を準備しています
成功の後、古いerrnoはどこへ?

glibcのsyscall()は生-errnoを-1に変換し、errnoを設定します。成功時にerrnoをゼロクリアする必要はありません。エラーの解釈は、同じ戻り値とペアにする必要があります。

ソースコードのマクロが解決すること

glibcのアーキテクチャ層は、呼び出し番号、入出力レジスタ、およびクロバー(破壊されるレジスタ)を配置します。内部呼び出し層は生結果を保持し、公共ラッパー層はerrno変換を実行します。具体的なマクロ名と展開方法は、バージョン、アーキテクチャ、および引数の数に応じて変化するため、古いバージョンのマクロ展開をglibcの恒久的インタフェースとして見なしてはいけません。

インラインアセンブリを読む際には、出力レジスタが入力呼び出し番号と正しく関連付けられているか、第4引数がターゲットABIが要求するレジスタに配置されているか、ハードウェアクロバーレジスタがクロバーリストに含まれているか、そしてメモリの可視性が正確に記述されているかを重点的に確認してください。

asm volatile は、そのアセンブリが随意に削除できない副作用を持つことを示します。それ自体はメモリバリアではありません。"memory" クロバーは、アセンブリがリストされていないメモリにアクセスする可能性があることをコンパイラに通知し、対応するコンパイラの並べ替えを制限しますが、CPUメモリバリアを自動的に生成しません。これらの制約は、C/C++の原子メモリ順序とは異なる層の問題です。GCC Extended Asm

スレッドキャンセルは「遅い呼び出しの前にチェックする」ことではない

read、writeなどのインタフェースはキャンセルポイントセマンティクスを持っています。glibcのread実装はSYSCALL_CANCELを経由します。実装は、キャンセルリクエストの処理と、システムコールへの進入、ブロッキング、および退出間の競合ウィンドウを処理する必要があります。呼び出し前にフラグを1回だけチェックすると、チェックの後にキャンセルリクエストを受け取り、永久にブロッキングする可能性があります。glibc read実装

直接 syscall(SYS_read, ...) や生アセンブリに変更しても、同等の置換とはみなせません。libcのキャンセルメカニズムをバイパスし、スレッドの停止とリソースクリーンアップの動作を変更する可能性があります。逆に、公共のreadを使用しても、スレッドが任意の場所で安全に非同期キャンセルされることを意味しません。キャンセルタイプとクリーンアッププロセッサの規約に従ってリソースのライフサイクルを設計してください。

キャンセルリクエストはまず保留され、許可されたキャンセルポイントで処理される ワースレッド:キャンセル禁止 クリーンアップを登録し、準備完了 メインスレッド:キャンセルリクエスト リクエストの保留は、退出済みではないことを意味する ワースレッド:キャンセル再開 公共readのキャンセルポイントを実行 クリーンアッププロセッサが読み込みエンドを閉じる;pthread_joinがPTHREAD_CANCELEDを観察 メインスレッドはその後、自身が所有する書き込みエンドを閉じ、同期オブジェクトを破棄する この例ではデフォルトの遅延キャンセルを保持;2つの同期ゲートはイベント順序を決定するために使用され、sleepによる推測に依存しない。

確実な順序関係でキャンセルとクリーンアップを観察

以下では、glibc/Linux上でパイプを確立し、ワースレッドが最終的にreadを呼び出す準備をする。メインスレッドは書き込みエンドを保持するが書き込まないため、データはなく、EOFが事前に発生することもない。2つのbarrierは、まずクリーンアップを登録し、次にキャンセルリクエストを発行し、最後にキャンセルを再開することを保証する。デフォルトの遅延キャンセルでは、保留中のリクエストは公共readのキャンセルポイントで処理される。キャンセル状態

cancel-read.cとして保存:

#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static pthread_barrier_t ready, requested;
static int ends[2];
static int cleaned;
static void require(int code) { if (code != 0) abort(); }
static void meet(pthread_barrier_t *barrier) {
    int code = pthread_barrier_wait(barrier);
    if (code != 0 && code != PTHREAD_BARRIER_SERIAL_THREAD) abort();
}
static void cleanup(void *unused) {
    (void)unused;
    if (close(ends[0]) != 0) abort();
    cleaned = 1;
}
static void *worker(void *unused) {
    (void)unused;
    require(pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL));
    pthread_cleanup_push(cleanup, NULL);
    meet(&ready);
    meet(&requested);
    require(pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL));
    char byte;
    (void)read(ends[0], &byte, 1);
    pthread_cleanup_pop(1);
    return NULL;
}
int main(void) {
    if (pipe(ends) != 0) return 1;
    require(pthread_barrier_init(&ready, NULL, 2));
    require(pthread_barrier_init(&requested, NULL, 2));
    pthread_t thread;
    require(pthread_create(&thread, NULL, worker, NULL));
    meet(&ready);
    require(pthread_cancel(thread));
    meet(&requested);
    void *result;
    require(pthread_join(thread, &result));
    if (close(ends[1]) != 0) return 1;
    require(pthread_barrier_destroy(&ready));
    require(pthread_barrier_destroy(&requested));
    printf("canceled=%d cleaned=%d\n", result == PTHREAD_CANCELED, cleaned);
    return !(result == PTHREAD_CANCELED && cleaned == 1);
}
cc -std=c11 -Wall -Wextra -pthread cancel-read.c -o cancel-read
./cancel-read

canceled=1 cleaned=1 が期待される。cleanedはワースレッドによって書き込まれ、メインスレッドは成功したjoin後にそれを読む。この読み取り時点では、スレッドが終了していることが保証されている。joinを削除して、メインスレッドが同期なしで通常のintをポーリングすることはできない。

pthread_cleanup_push/popはglibcにおいてペアで使用される必要のあるマクロであり、スコープは一致している必要がある。この例では、クリーンアッププロセッサが読み込みエンドを所有し、メインスレッドが書き込みエンドを所有する。閉じる責任を分離することで、キャンセルパスと通常パスの両方で、同じ記述子を重複して閉じることがなくなる。プロトコルを強調するために、スレッドインフラストラクチャのエラーはabortでサンプルを終了させる。本番コードでは、作成失敗時のリソース回収も手配する必要がある。クリーンアッププロセッサ

この例のreadを「より高速な」バージョンにするために直接生システムコールに置き換えないでください。それはキャンセルポイントセマンティクスを変更し、保留中のリクエストがこの例のプロトコルに従って待機を終了できない可能性があります。停止を自分で管理することを選択した場合は、待機に対して明確なウェイクアップチャネルを提供し、クリーンアッププロトコルを再設計する必要があります。ループの外にブール値を追加するだけでなく。

vDSO:APIを維持し、実装パスを置き換える

カーネルはプロセスにvDSOをマップし、libcはバージョン付きのシンボルを解決できる。サポートされているクロックの場合、clock_gettimeパスはカーネル共有のタイミング情報を読み取り、ユーザー空間で結果を計算する。サポートされていない環境やリクエストの場合、パスはカーネルに入る可能性がある。

これはclock_idの数値サイズに基づいて一般的なホワイトリストを作成することではない。異なるアーキテクチャ、time64互換要件、vDSOシンボル、およびその返すエラーは、実装に影響を与える。vDSOエラーを無条件に無視して再度呼び出すこともできない。特定のエラー伝播とフォールバックはインタフェースに準拠する必要がある。glibc clock_gettime、vdso(7)

以下では、ネイティブ64ビットLinuxで2つのパスを比較する:

#define _GNU_SOURCE
#include <stdio.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>

int main(void)
{
    struct timespec a, b;
    if (clock_gettime(CLOCK_MONOTONIC, &a) == -1) {
        perror("clock_gettime");
        return 1;
    }
    if (syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &b) == -1) {
        perror("syscall clock_gettime");
        return 1;
    }
    printf("libc=%lld.%09ld, syscall=%lld.%09ld\n",
           (long long)a.tv_sec, a.tv_nsec,
           (long long)b.tv_sec, b.tv_nsec);
    return 0;
}

コンパイルして strace -e trace=clock_gettime ./clock-paths を実行する。2回目の呼び出しは明確にカーネルに入り、1回目の呼び出しは利用可能なvDSOパスではこのsyscall記録を生成しない可能性がある。2回の呼び出しは同じ単調クロックを読み取り、2番目は1番目より早くならないが、クロックの分解能により2つの結果が等しいことが許容される。スケジューリングにより間隔が大きくなる可能性もあり、非常に小さい固定差値を必須の条件として使用することはできない。syscall記録がないからといって、関数が実行されていないわけではない。clock_gettime(3)

ラッパーには歴史的互換性も含まれる

glibcはopenでopenatを実装でき、32ビットプラットフォームの時間インタフェースはtime64互換ブランチを必要とするかもしれない。ソースコードの関数名、ダイナミックシンボル名、およびカーネルsyscall名は1対1で対応する必要はない。seccompポリシーとトレースフィルタ条件は、ターゲット環境の実際の呼び出しパスに基づいて決定する必要がある。open(2)

glibcは2.25でgetpidのPIDキャッシュを削除した。これ以上「現在のgetpidは常にキャッシュから直接返される」ということでベンチマーク結果を説明してはならない。getpid(2)

muslもアーキテクチャ引数規約、スレッドローカルerrno、キャンセルポイント、および適用可能なvDSOパスを必要とする。2つのlibcを比較する際には、バージョン、ターゲットアーキテクチャ、および具体的な関数を選択し、ビルド成果物と動作を確認する。「各syscallが5行、50行」ということでパフォーマンスを導き出すのは、信頼できる意味を持たない。

ラッパーコストをどのように測定するか

まず、測定対象が生進入/退出、公共API呼び出し、またはスケジューリングとデバイス待機を含むエンドツーエンド操作のいずれであるかを明確にする。カーネル、libc、CPU、コンパイルオプション、および仮想化環境を記録し、バッチループオーバーヘッドとターゲット呼び出しを区別する。

straceはパスとエラーを確認するのに適しているが、トレース自体はレイテンシに顕著な影響を与え、トレースされていないプログラムのマイクロベンチマークを直接代表するものではない。クロックAPIの場合、vDSOが使用されているかを確認する必要がある。futexの場合、待機値、実際にスリープしているか、およびウェイクアップ元を説明する必要があり、「競合のないFUTEX_WAIT」を固定の消費操作として扱うことはできない。

測定によってラッパーコストが重要であり、キャンセルと互換性セマンティクスを明確に引き受けることができる場合にのみ、公共インタフェースをバイパスすることを検討する。不要なシステムコールの削減やI/Oの結合は、通常、アーキテクチャ固有のアセンブリのコピーを作成するよりも、まず確認する価値がある。