【実務・中級編】未定義動作(UB)を徹底駆逐!UBSan(UndefinedBehaviorSanitizer)による実行時監視の現場 – 実行環境・ランタイム・コンパイラ生産性向上バイブル

はじめに:なぜあなたの書いたCコードは、テストをすり抜けて本番で爆発するのか

テックリードとしてコードレビューを行っていると、次のようなコードにしばしば遭遇する。

int calculate_offset(int base, int index) {
return base + (index sizeof(int));
}

「一見して問題ない美しいコード」に見えるかもしれない。しかし、このコードはC言語の仕様において時限爆弾だ。`index` が十分に大きく、`base` との足し算で `INT_MAX` を超えた場合、C言語の規格(ISO/IEC 9899)において、その挙動は未定義動作(Undefined Behavior: UB)と定義される。

UBに陥ったプログラムがどうなるか?
コンパイラ(GCC/Clang)は「開発者が書いたコードにUBは存在しない」という強烈な前提(Strict Aliasingやオーバーフローのラップアラウンドが起きないという仮定)のもとで最適化を行う。その結果、「オーバーフローした後に値が負になるからエラーチェックしよう」という防衛コードそのものが、コンパイラによって綺麗に消し去られる。テスト環境(最適化なし・O0)では正常に動き、本番環境(最適化あり・O2/O3)で突然セグメンテーション違反やデータ破壊を引き起こす——これが、C/C++エンジニアを長年苦しめてきた悪名高い「論理バグの正体」である。

AddressSanitizer (ASan) はバッファオーバーランやuse-after-freeといったメモリ破壊の検出には神がかった威力を発揮するが、「計算の論理破綻」「不正なポインタ演算」「シフト演算の範囲外」といった論理的UBの検知は苦手だ。

ここで登場するのが、GCCおよびClangにネイティブ統合された UBSan(UndefinedBehaviorSanitizer) である。本記事では、CI/CDパイプラインと開発者の手元環境にUBSanを完全に組み込み、未定義動作をビルドパイプラインの段階で完全駆逐するための実践的アプローチを解説する。

—

1. UBSanとは何か? 内部で何が起きているのか

UBSanは、コンパイル時にコードの各所に「実行時チェック(インストルメンテーション)」を埋め込むコンパイラ・サニタイザである。

例えば、前述の加算処理に対してUBSan有効でコンパイルを行うと、コンパイラは内部で次のようなアセンブリ(概念的なCコード)に相当するチェックを挿入する。

// UBSanが内部で行っているチェックの概念
int safe_add(int a, int b) {
if (__builtin_add_overflow(a, b, &result)) {
__ubsan_handle_add_overflow(…); // 実行時エラーを吐いてアボート
}
return result;
}

このオーバーヘッドは、CPUの分岐予測が極めて効率的に働くため、通常のベンチマークでは数パーセント程度に収まる。つまり、「速度を犠牲にせずに安全性を買う」ための極めてコストパフォーマンスの高い投資なのだ。

—

2. 開発環境の極限最適化:手元(Local)での即時検知フロー

テックリードとしてチーム全体の生産性を底上げするためには、「CIで怒られてから気づく」のではなく、「開発者のエディタを保存した瞬間、あるいはローカルテストを実行した瞬間にUBを検知する」フィードバックループを作る必要がある。

推奨ビルド構成(CMake)

手元の開発マシン(Linux / macOS)でClangまたはGCCを使い、UBSanを有効化するCMakeのツールチェーン設定またはビルドプロファイルのベストプラクティスを示す。

CMakeLists.txt のサニタイザ統合スニペット
option(ENABLE_UBSAN “Enable UndefinedBehaviorSanitizer” ON)

if(ENABLE_UBSAN)
# デバッグ情報(-g3)を必ず付与し、クラッシュ時に正確なファイル名・行数を特定できるようにする
set(CMAKE_C_FLAGS “${CMAKE_C_FLAGS} -g3 -O1”)
set(CMAKE_CXX_FLAGS “${CMAKE_CXX_FLAGS} -g3 -O1”)

# UBSanを有効化するフラグ群
set(UBSAN_FLAGS
“-fsanitize=undefined” # 基本的なUBチェッカー群を網羅的に有効化
“-fsanitize=integer” # 整数オーバーフロー(加算・減算・乗算)の厳格検知
“-fno-sanitize-recover=all” # UB検知時に即座にプロセスをアボートさせ、見落としを防ぐ
“-fno-omit-frame-pointer” # スタックトレースの精度を100%にするためにフレームポインターを維持
)

string(REPLACE “;” ” ” UBSAN_FLAGS_STR “${UBSAN_FLAGS}”)
set(CMAKE_C_FLAGS “${CMAKE_C_FLAGS} ${UBSAN_FLAGS_STR}”)
set(CMAKE_CXX_FLAGS “${CMAKE_CXX_FLAGS} ${UBSAN_FLAGS_STR}”)

message(STATUS “UBSan is enabled. Building with strict runtime checks.”)
endif()

> アーキテクトの知見:
> `-fno-sanitize-recover=all` は極めて重要だ。デフォルトではUBSanは警告を出して実行を継続しようとするが、これでは巨大なテストスイートのログの海にエラーが埋もれてしまう。「UBを踏んだら即座に異常終了(Aborted)」させることで、テストスイートを Fail させ、開発者に直ちに対処を強いることができる。

—

3. CI/CDパイプライン統合:見えないバグを自動であぶり出す

プルリクエストが作成された際、GitHub Actions等のCI環境でUBSanをビルド・実行マトリクスに組み込む。これにより、レビュアーの負担を劇的に軽減する。

実用的な GitHub Actions ワークフロー構成例 (`.github/workflows/ubsan.yml`)

name: UBSan CI Pipeline

on:
push:
branches: [ “main”, “develop” ]
pull_request:
branches: [ “main”, “develop” ]

jobs:
ubsan-check:
name: Build and Test with Clang & UBSan
runs-on: ubuntu-22.04

steps:
# リポジトリのソースコードをチェックアウト(サブモジュールも含む)

  • name: Checkout repository

uses: actions/checkout@v4
with:
submodules: recursive

# 最新のClangとNinjaビルドツールのセットアップ

  • name: Install Dependencies

run: |
sudo apt-get update
sudo apt-get install -y clang ninja-build cmake

# UBSan専用の環境変数設定
# print_stacktrace=1 でエラー発生時の詳細なコールスタックを出力
# halt_on_error=1 で最初の検知で即停止

  • name: Configure CMake with UBSan

env:
CC: clang
CXX: clang++
UBSAN_OPTIONS: “print_stacktrace=1:halt_on_error=1”
run: |
cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DENABLE_UBSAN=ON

# ビルドの実行

  • name: Build Project

run: cmake –build build –parallel $(nproc)

# テストスイートの実行(ここでUBSanがランタイム監視を行う)

  • name: Run Unit Tests

env:
UBSAN_OPTIONS: “print_stacktrace=1:halt_on_error=1”
run: |
cd build
ctest –output-on-failure –timeout 300

—

4. 警告(エラー)が出た際のデバッグアプローチと実例

CIや手元でテストを実行した際、次のようなエラーログに遭遇することがある。これがUBSanからのシグナルだ。

エラーログの読み方と実例

/workspace/src/protocol.c:112:25: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type ‘int’
#0 0x403212 in parse_header /workspace/src/protocol.c:112:25
#1 0x4045a0 in process_packet /workspace/src/net.c:45:10
#2 0x4012ff in main /workspace/src/main.c:20:5

このログが示している情報は極めて明確である。
1. 発生場所: `/workspace/src/protocol.c` の 112行目 25文字目
2. 原因: 符号付き整数 (`int`) のオーバーフロー (`2147483647 + 1`)
3. コールスタック: `main` -> `process_packet` -> `parse_header` の順で呼び出されている。

デバッグのステップ

1. 該当箇所の特定と修正:
問題のコードが `int total_len = header->len + payload->len;` であった場合、オーバーフローする前に型を昇格させるか、範囲チェックを行う必要がある。

2. 修正コードの実装(安全な算術演算への置換):
C23以降であれば標準機能やコンパイラのビルトイン関数 (`__builtin_add_overflow`) を活用する。

#include #include

bool compute_total_length(int len_a, int len_b, int out_result) {
// コンパイラのビルトインを使い、オーバーフローを安全に検知する
if (__builtin_add_overflow(len_a, len_b, out_result)) {
// オーバーフロー発生時のハンドリング
return false;
}
return true;
}

3. サプレッション(抑制)ファイルの活用(最終手段):
どうしても修正できないサードパーティライブラリや、仕様上意図的なラップアラウンド(ビット演算等)がある場合は、サプレッションファイルを用いて特定の関数やファイルのチェックをバイパスできる。

`ubsan-suppression.txt`:

# サードパーティ製レガシーライブラリの整数オーバーフローを一時的に無視
signed-integer-overflow:third_party_lib.c

コンパイル時に `-fsanitize-blacklist=ubsan-suppression.txt` (Clangの場合は `-fsanitize-recover=` 等と併用) を渡すことで制御可能である。ただし、プロダクトコード本体でこれを使用することは「技術的負債の放置」と同義であるため、原則禁止とすべきだ。

—

5. チーム開発で事故らないための統制ルール(テックリードの提言)

最後に、チーム全体にこの仕組みを定着させ、形骸化させないための運用ルールを提示する。

1. 「ASan + UBSan」のコンボをデフォルトデバッグビルドにする
ローカル開発時の `cmake -DCMAKE_BUILD_TYPE=Debug` では、デフォルトで ASan と UBSan が両方有効になるようにプロジェクトテンプレートを設計する。開発者が意識せずとも、コードを書いた時点でサニタイザの網にかかる状態を作るのが、最強のインフラストラクチャである。

2. CIでの警告ゼロをマージガード(Branch Protection)に組み込む
GitHub のプルリクエスト設定で、UBSanによるテストジョブの成功をマージの必須条件(Required status checks)に指定する。「警告が出ているけれど、動いているからマージする」という文化をシステム的に排除する。

3. 「未定義動作はバグではなく、仕様違反のコンパイル時・実行時エラーである」という共通認識の醸成
チームメンバーに対して、「動けば正義」ではなく、「規格に準拠しており、どのコンパイラ・どの最適化レベルでも安全に動作する」ことの重要性をコードレビューや勉強会で浸透させる。

UBSanを使いこなすことは、C/C++という現代においては「扱いづらい猛獣」を完全に手懐けることに等しい。ぜひ今日のビルドスクリプトに `-fsanitize=undefined` を追加し、あなたのプロジェクトに潜む「見えない時限爆弾」をすべてあぶり出してほしい。

タイトルとURLをコピーしました