Cのエラーコード設計
ある関数はbool型を返し、ある関数は-1を返し、ある関数は0を失敗として返す。プロジェクトが育つと、エラーの流儀がモジュールごとにばらばらになりがちです。C言語には例外機構がないため、エラー処理の品質は、戻り値(および出力引数)の設計に大きく左右されます。本記事では、エラーコードの体系の定石と、エラーをどの層で対処するかという伝搬のルール、組み込みならではの運用までを解説します。
- なぜ「体系」が必要なのか
- 定石:0が成功、負がエラーのenum一本
- エラー名を取得できるようにする
- 伝搬のルール:検知は下位、対処は上位
- モジュールが増えたら:レンジ分けと境界での変換
- アンチパターン
- まとめ
なぜ「体系」が必要なのか
エラー処理の最大の敵はばらつきです。関数ごとに成功・失敗の表現が違うと、呼び出し側の書き方が安定せず、チェック漏れや判定ミスが混入します。SEI CERT C Coding Standard(JPCERT/CCが日本語版を公開しているコーディング標準。以下、CERT C コーディングスタンダード)も、レコメンデーションとして「エラー処理には一貫性のある方針を採用する」(ERR00-C)ことを挙げています。体系をプロジェクト開始時に1つ決めておくことが、以後の全コードの品質を底上げします。
定石:0が成功、負がエラーのenum一本
typedef enum {
ERR_OK = 0, /* 成功 */
ERR_PARAM = -1, /* 引数不正 */
ERR_TIMEOUT = -2, /* 応答なし */
ERR_BUSY = -3, /* リソース使用中 */
ERR_CRC = -4, /* データ化け */
ERR_NOT_READY = -5, /* 初期化前に呼ばれた */
} err_t;
err_t sensor_read(uint16_t *out_value); /* データは出力引数で */
/* 呼び出し側はいつも同じ形になる */
uint16_t value;
err_t err = sensor_read(&value);
if (err != ERR_OK) {
log_error("sensor_read", (int)err); /* enumをintへキャスト。保存時はint32_t等に正規化 */
return err; /* 上位へそのまま伝搬 */
}
この体系の利点は、すべての関数で「ERR_OKと比較するだけ」という同じ形が成立することです。正の値を「成功+付加情報」(受信したバイト数など)に割り当てる流儀もあり、負=エラーの原則と両立します。POSIXでも read() のように、成功時は読み取ったバイト数(0はEOFなど)を返し、失敗時は-1を返してerrnoが設定される設計が広く使われています。errnoは、戻り値でエラーを検知した場合に限り参照します。この記事の例はerr_t固定(0または負の値のenum)を前提に説明しており、受信バイト数のように成功時に整数値を返す必要があるAPIは、戻り値をssize_t型などにした別の設計にします。なお、Cの列挙型(enum)の表現サイズ(格納サイズ)は処理系依存(実装定義)になり得ます。ABI(Application Binary Interface)境界をまたぐインターフェースや、不揮発性メモリにエラーログを保存する用途では、err_t をそのまま書き出さず、格納用に int32_t などサイズの決まった別型(err_store_t など)を定義し、変換時に範囲チェックをしたうえで、仕様上のサイズを明記しておくと事故を防げます。
重要なのが、データはエラーコードに混ぜず出力引数で返すことです。CERT C コーディングスタンダードのレコメンデーションにも「帯域外エラーインジケータと正常な戻り値を混同しない」(ERR02-C)があります。これは、同一の戻り値に正常値とエラー値を混在させる設計(インバンドエラー、in-band error)を避けるべきだという趣旨です。「-1はエラー、それ以外は測定値」のような設計は、測定値が-1になり得た瞬間に破綻します。値はエラーコードに混ぜず、出力引数や構造体で返すか、POSIXのread()のように成功時の戻り値を正の値(バイト数など)として使う設計にします。あわせて出力引数の扱いも仕様として決めておきます。出力引数はNULL不可とし、NULLが渡された場合はERR_PARAMを返す、という規約にしておくと呼び出し側の書き方が安定します。
エラー名を取得できるようにする
エラーコードは数値のままログに出すと、現場で「-3とはどのようなエラーか」と混乱を招きます。コードと名前の対応テーブルを1つ用意し、ログには名前を出します。出荷後の機器ではエラーログを不揮発性領域に残す設計と組み合わせると、返品解析の効率が大きく変わります。
const char *err_name(err_t e)
{
switch (e) {
case ERR_OK: return "OK";
case ERR_PARAM: return "PARAM";
case ERR_TIMEOUT: return "TIMEOUT";
case ERR_BUSY: return "BUSY";
case ERR_CRC: return "CRC";
case ERR_NOT_READY: return "NOT_READY";
default: return "UNKNOWN";
}
}
伝搬のルール:検知は下位、対処は上位
エラー処理の設計でもう1つ決めるべきは「どの層が何をするか」です。原則は、下位(ドライバ・ライブラリ層)はエラーを検知して返すだけ、リトライするか、諦めるか、安全停止するかの判断は、文脈を知っている上位(アプリ層)がおこなう、という分担です。下位が勝手にリトライや初期化を始めると、上位から見えない時間遅れや副作用が生まれます。あわせて守るべきは「握りつぶし禁止」です。CERT C コーディングスタンダードのERR33-C(標準ライブラリ関数のエラーを検出し対処する)で述べられる「戻り値の確認」の考え方は、自作APIやHAL(Hardware Abstraction Layer)の戻り値にも同様に適用すべきものです。戻り値を捨てている呼び出しは、静的解析やコンパイラ警告で機械的に洗い出せます。
- 回復可能なエラー(タイムアウト・BUSY):上位がリトライ方針を決める(「通信におけるI/Oの堅牢化とタイムアウト設計」参照)。
- 回復不能な状態(あり得ない引数・内部矛盾):エラーコードで返し続けるのではなく、アサートで開発中に即検出します。製品では、ログ保存→安全側出力→必要に応じてリセット(または縮退運転)など、プロダクト方針に従って安全状態へ移行します。
モジュールが増えたら:レンジ分けと境界での変換
規模が大きくなったら、エラーコードにモジュール別のレンジを割り当てると(例:-100番台はフラッシュメモリ、-200番台は通信)、コードを見ただけで発生源の見当がつきます。また、ベンダHALやミドルウェアは独自のエラー型を持っているので、自分のモジュールの境界でアプリ共通のerr_tへ変換し、外へは漏らさないようにします。層をまたいで他のモジュールのエラー型が貫通する設計は、依存関係を硬直させる典型です。
アンチパターン
- すべてbool型で返す:失敗の理由が消えるため、ログを見ても原因にたどり着けません。
- 「とりあえず-1」:異なる原因が同じコードに集約されてしまい、エラーコードの意味がなくなります。
- グローバルなerrno風自作変数:マルチタスク・割り込みと相性が悪く、上書き競合します。戻り値で返すのが基本です(標準のerrno(エラー原因を示すグローバルな診断情報)も、戻り値でエラーを検知した後に原因を特定する用途が本来の使い方です)。
まとめ
Cのエラー処理は「0成功・負エラーのenum+出力引数」「名前テーブルでログに残す」「検知は下位・対処は上位」「握りつぶし禁止」の4点をプロジェクト初日に決めるのが最大のコツです。体系は凝る必要はなく、全員が同じ形で書けることに価値があります。

