memcpy と memmove の違い:メモリ重複コピーの落とし穴

背景

アプリケーションを別のプラットフォームに移植した際、受信バッファから処理済みデータを memcpy で移動させたところ、メモリ上のデータが破壊される問題が発生した。

環境情報:

$ gcc -v
Using built-in specs.
...
gcc version 4.9.2 (Linx 4.9.2-10-linx1)

$ uname -a
Linux localhost 4.9.0-0.bpo.1-linx-security-amd64 #1 SMP ...

原則

  • memcpymemmove はどちらもメモリブロックのコピーに使われる標準ライブラリ関数。
  • memcpy はコピー元とコピー先の領域が重なる場合の動作を保証しない(未定義動作)。コピー方向は実装依存。
  • 重なりがある場合は memmove を使う。これは重なりを正しく処理する。
  • 未定義動作に依存してはいけない。バージョンや実装によって結果が変わる。

メモリ配置と重複パターン

コピー元(src)とコピー先(dst)の関係で、次の4つのパターンが考えられる。

  1. dst が src より完全に前方(重なりなし)
  2. dst < src < dst+len(src の一部が dst より後方、前方からのコピーで上書きリスク)
  3. dst が src より完全に後方(重なりなし)
  4. src < dst < src+len(src の末尾部分が dst に重なる、後方からのコピーで安全)

パターン2と4が問題になる。特にパターン2では前方からコピーすると src の未コピー部分が上書きされる。

glibc 2.32 の memcpy 実装

void *
MEMCPY (void *dstpp, const void *srcpp, size_t len)
{
  unsigned long int dstp = (long int) dstpp;
  unsigned long int srcp = (long int) srcpp;

  /* 先頭から後方へコピー */
  if (len >= OP_T_THRES)
    {
      len -= (-dstp) % OPSIZ;
      BYTE_COPY_FWD (dstp, srcp, (-dstp) % OPSIZ);
      PAGE_COPY_FWD_MAYBE (dstp, srcp, len, len);
      WORD_COPY_FWD (dstp, srcp, len, len);
    }
  BYTE_COPY_FWD (dstp, srcp, len);
  return dstpp;
}

glibc の memcpy は常に低アドレスから高アドレスへコピーする。重なりチェックは一切行わない。そのためパターン2では問題が発生する。パターン4では先頭からのコピーが理論上安全だが、WORD_COPY_FWD のような最適化によって実際には未定義動作が引き起こされる場合がある。

例えば、同じパターン4でも glibc のバージョンによって結果が異なる。

  • Linx 6.0 / RHEL <6.5 / Ubuntu 20 では期待通り動作。
  • Linx 8.0 / RHEL >7 ではデータ破壊が発生。
  • バイト単位の単純コピーなら問題ないが、WORD_COPY_FWD が原因。

glibc 2.32 の memmove 実装

MEMMOVE (a1const void *a1, a2const void *a2, size_t len)
{
  unsigned long int dstp = (long int) dest;
  unsigned long int srcp = (long int) src;

  if (dstp - srcp >= len)   /* 符号なし比較で前方コピーが安全か判定 */
    {
      /* 前方コピー(memcpy と同じ処理) */
      ...
    }
  else
    {
      /* 後方コピー:末尾から先頭へ */
      srcp += len;
      dstp += len;
      BYTE_COPY_BWD (dstp, srcp, ...);
      WORD_COPY_BWD (dstp, srcp, ...);
      ...
    }
  RETURN (dest);
}

memmove ではまず dstp - srcp >= len の条件で前方コピーが安全かどうかを判定する。パターン2とパターン4の両方でこの条件が偽になるため、後方コピー(高アドレスから低アドレスへ)が選択され、重なりが正しく処理される。

テストコード(再構成版)

#include <iostream>
#include <cstring>

static constexpr int kCopyModeMemcpy = 1;
static constexpr int kCopyModeMemmove = 2;
static constexpr int kCopyModeByteByByte = 3;

bool OverlapCopyTest(int mode)
{
    using u8 = unsigned char;

    // 模擬データパケット(2個のサブパケットを含む)
    u8 rawData[] = {
        0x68, 0x2f, 0x0c, 0x00, 0x06, 0x00, 0x74, 0x01, 0x05, 0x00,
        0x01, 0x00, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x16, 0x44,
        0x50, 0x44, 0x38, 0x30, 0x30, 0x32, 0x30, 0x31, 0x39, 0x30,
        0x35, 0x30, 0x38, 0x31, 0x36, 0x35, 0x30, 0x2e, 0x64, 0x61,
        0x74, 0xf7, 0x00, 0x00, 0x00, 0xf7, 0x00, 0x00, 0x00,
        0x68, 0xe1, 0x0e, 0x00, 0x06, 0x00, 0x74, 0x01, 0x05, 0x00,
        0x01, 0x00, 0x00, 0x00, 0x00, 0x02, 0x06, 0xf7, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x69, 0x6e, 0x63, 0x6c,
        0x75, 0x64, 0x65, 0x09, 0x28, 0x24, 0x28, 0x49, 0x43, 0x43,
        0x53, 0x44, 0x45, 0x56, 0x48, 0x4f, 0x4d, 0x45, 0x29, 0x2f,
        0x73, 0x72, 0x63, 0x2f, 0x68, 0x6d, 0x69, 0x2f, 0x70, 0x6c,
        0x75, 0x67, 0x69, 0x6e, 0x73, 0x61, 0x75, 0x78, 0x2f, 0x70,
        0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x61, 0x75, 0x78, 0x2e,
        0x70, 0x72, 0x69, 0x29, 0x31, 0x0d, 0x0a, 0x69, 0x6e, 0x63,
        0x6c, 0x75, 0x64, 0x65, 0x09, 0x28, 0x24, 0x28, 0x49, 0x43,
        0x43, 0x53, 0x44, 0x45, 0x56, 0x48, 0x4f, 0x4d, 0x45, 0x29,
        0x2f, 0x73, 0x72, 0x63, 0x2f, 0x68, 0x6d, 0x69, 0x2f, 0x70,
        0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x61, 0x75, 0x78, 0x2f,
        0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x61, 0x75, 0x78,
        0x2e, 0x70, 0x72, 0x69, 0x29, 0x32, 0x0d, 0x0a, 0x69, 0x6e,
        0x63, 0x6c, 0x75, 0x64, 0x65, 0x09, 0x28, 0x24, 0x28, 0x49,
        0x43, 0x43, 0x53, 0x44, 0x45, 0x56, 0x48, 0x4f, 0x4d, 0x45,
        0x29, 0x2f, 0x73, 0x72, 0x63, 0x2f, 0x68, 0x6d, 0x69, 0x2f,
        0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x61, 0x75, 0x78,
        0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x61, 0x75,
        0x78, 0x2e, 0x70, 0x72, 0x69, 0x29, 0x33,
    };
    const size_t totalLen = sizeof(rawData);

    // 受信バッファ(生データをコピー)
    u8 recvBuf[1024] = {};
    memcpy(recvBuf, rawData, totalLen);
    size_t remaining = totalLen;

    // 最初の49バイトを処理済みとする
    const size_t processedSize = 49;
    remaining -= processedSize;

    // 正しい残りデータを保存(比較用)
    u8 expectedRemaining[1024] = {};
    memcpy(expectedRemaining, recvBuf + processedSize, remaining);

    // 実際のコピー操作
    switch (mode)
    {
    case kCopyModeMemcpy:
        std::memcpy(recvBuf, recvBuf + processedSize, remaining);
        break;
    case kCopyModeMemmove:
        std::memmove(recvBuf, recvBuf + processedSize, remaining);
        break;
    case kCopyModeByteByByte:
        for (size_t i = 0; i < remaining; ++i)
            recvBuf[i] = recvBuf[processedSize + i];
        break;
    default:
        return false;
    }

    // 結果検証
    bool ok = true;
    for (size_t i = 0; i < remaining; ++i)
    {
        if (recvBuf[i] != expectedRemaining[i])
        {
            std::cout << "memory corrupted at offset " << i << std::endl;
            ok = false;
        }
    }
    std::cout << (ok ? "Copy success" : "Copy failed") << std::endl;
    return ok;
}

int main()
{
    OverlapCopyTest(kCopyModeMemcpy);
    // 必要に応じて他のモードもテスト
    return 0;
}

なぜ memmove で統一しないのか?

  • memcpy は標準仕様上、重なりがない場合の最速を目指して実装される。多くのアプリケーションでは重なりが発生しないケースが大半であり、余分な分岐を嫌う。
  • しかし、実際には memmove の性能が memcpy と同等、あるいは上回る計測結果も存在する。
  • 歴史的な経緯として、glibc の memcpy 実装が変更されたことで、memcpy を使っていたアプリケーション(Adobe Flash Player など)でバグが表面化した事例がある(Linus Torvalds の発言が有名)。
  • 安全側に倒すなら、重なりの可能性が少しでもある場合は memmove を選ぶべき。重なりがないことが確定している狭い範囲に限定して memcpy を使う。

最終的に、仕様を守るという立場からは「重なりがある領域へのコピーには memmove を使う」が正しい。実装の挙動を推測して memcpy に依存するのは危険である。

タグ: memcpy memmove glibc memory-overlap undefined-behavior

6月14日 16:15 投稿