memcpy・memset・memcmpの正しい使い方

memcpy・memset・memcmpの3関数は、Cでメモリを扱うときの基本道具です。速くて便利な一方、この3つは型の意味を一切知らず、メモリをただのバイトの並びとして処理します。この性質を理解せずに使うと、構造体の比較やコピーで再現性の低いバグを生みます。本記事では、現場で実際に踏まれている4つの罠と、組み込みならではの注意点を整理します。

大前提:3関数は「型」を知らない

3関数の仕様は「指定されたバイト数を機械的に処理する」だけです。コピー先の型が何か、その値がどんな意味を持つかは関知しません。この性質は強力ですが、構造体のパディング(詰め物)・オーバーラップ(領域の重なり)・オブジェクト表現(ビット表現)という、普段は意識しないレイヤの問題をすべて呼び込みます。以下の罠は、どれもこの一点から派生しています。なお、この記事のコード例はC99以降を前提としており、<stdbool.h>・<stdint.h>のインクルードが必要です。

罠1:memcmpで構造体を比較してはいけない

構造体には、メンバを適切なアドレスにそろえるためのパディングが挿入されることがあり、その中身は規格上規定されていません。memcmpはパディングまで含めてバイト比較するため、全メンバが等しい構造体同士が「不一致」と判定されることがあります。SEI CERT C Coding StandardのEXP04-C「構造体を含むバイト単位の比較を行わない」およびEXP42-C「パディングデータを比較しない」でも明確に禁止されています。EXP04-CはJPCERT/CCが日本語訳を公開しており(https://www.jpcert.or.jp/sc-rules/c-exp04-c.html)、EXP42-Cは日本語訳が公開されていないため、SEI CERTの原文(https://wiki.sei.cmu.edu/confluence/display/c/EXP42-C.+Do+not+compare+padding+data)を参照してください。

typedef struct {
    char     type;     /* この直後にパディングが入り得る */
    uint32_t size;
} item_t;
/* NG:パディングの不定値まで比較してしまう */
if (memcmp(&a, &b, sizeof(a)) == 0) { ... }
/* OK:メンバごとに比較する */
bool item_equal(const item_t *a, const item_t *b)
{
    return (a->type == b->type) && (a->size == b->size);
}

「memsetで0埋めしてから使えば大丈夫」という運用も見かけますが、代入、関数の引数渡し、最適化などでパディングが再び不定になり得るため、運用で縛るのは難しく、結局すべての書き込み経路を管理することになります。比較関数を1つ書くのが最も安全で確実です。

罠2:領域が重なるならmemcpyではなくmemmove

memcpyは、コピー元とコピー先の領域が重なっていない前提の関数で、重なったまま使った場合は未定義動作(undefined behavior)です。未定義動作であるため、たまたま動作しているように見えるだけで、実装や最適化の変更によって突然壊れることがあります。ここでいう重なりとは、コピー元とコピー先のバイト範囲が1バイトでも共有される状態を指します。受信バッファの先頭詰めのように同じ配列内でデータをずらす処理では、重なりを許容するmemmoveを使います。「うちの環境では動いている」コードが、コンパイラの最適化レベル変更・バージョンアップ・アーキテクチャ変更・ライブラリ更新など、あらゆる変化をきっかけに壊れる時限爆弾の典型です。

罠3:サイズ指定のミス(sizeofの相手を間違える)

void save_config(const config_t *cfg)
{
    config_t dest_cfg;
 
    /* NG:ポインタのサイズ(4や8)しかコピーされない */
    memcpy(&dest_cfg, cfg, sizeof(cfg));
 
    /* OK:指している先の型のサイズを使う */
    memcpy(&dest_cfg, cfg, sizeof(*cfg));
}

sizeof(ポインタ)とsizeof(*ポインタ)の取り違えは、警告も出にくい定番ミスです。また、単純な構造体の複製なら、そもそもmemcpyを使わず代入(dst = src;)で済むことも覚えておくとよいでしょう。代入ならサイズ指定そのものが不要で、コンパイラが型に応じた最適なコピーを生成します。

罠4:memsetは「バイト単位」で埋める

memset(buf, 1, sizeof(buf)) と書いても、int配列の各要素が1になるわけではありません。各バイトが0x01になるため、32ビットintなら要素は0x01010101です。memsetが確実に機能するのは、各バイトに同じ値を書き込む用途(ゼロ埋め・0xFF埋めなど)に限られます。整数型・浮動小数点型・ポインタ型の配列に対して0以外の値で「型の意味どおりの値」を設定する目的には使えません。なお、C規格(ISO C)では、ポインタのNULL表現や浮動小数点の0.0のビット表現が「全ビット0」である保証はありません。したがって移植性が問われるコードでは、memset(..., 0, ...)を「NULL / 0.0に初期化した」根拠にせず、メンバへ明示的に代入して初期化します。ただし多くの実装(x86/x86-64などの一般的なアーキテクチャ)では、慣習的に全ビット0がNULLポインタを表します。

組み込みならではの注意点

  • ペリフェラルレジスタ領域に使わない:memcpyやmemsetはアクセス幅や順序を最適化の都合で自由に選びます。レジスタはvolatileポインタ経由で、規定された幅で個別にアクセスします。
  • アライメントの回避策として使う:通信バッファの途中アドレスから多バイト値を取り出すときは、ポインタのキャストで直接読まず、memcpyでいったんローカル変数へコピーするのが定石です。キャスト読みはアライメント違反でフォールトが発生するコアがあります。
  • 大きな転送はDMAと使い分ける:CPUを長時間占有する大量コピーは、DMA(「DMA転送の実務とキャッシュ整合性」参照)への置き換えを検討します。
  • C++で使う場合は型に注意する:ユーザ定義のコンストラクタ・デストラクタを持つ型や仮想関数を持つ型(C++用語:trivially copyableでない型)へのmemcpy使用は、C++では未定義動作です。このような型のコピーには代入演算子かコピーコンストラクタを使います。

まとめ

3関数の事故は「型を知らない関数に、型の意味を期待した」ときに起きます。構造体の比較はメンバごと、重なるコピーはmemmove、sizeofは指す先、memsetはバイト単位。この4行をチームのレビュー観点に加えるだけで、再現性の低いメモリ系バグをかなりの割合で未然に防げます。

組み込みソフトの世界 トップへ戻る