memcpy と memmove

事の始まり

研究でプログラムを書いていたときの話。
ローカル(Mac OS)では問題なく動作していたプログラムが、サーバ(Linux)に移した途端なぜか Segmentation Fault でクラッシュするということがあった。

セグフォは許可されていないメモリ領域にアクセスした時に起こるエラーで、経験上 配列へのアクセスでミスをしているパターンが多い。
どうせ今回もそのパターンだろうと思ったけど、何度確認してもおかしいところは見つからない。
特にそのプログラムではきちんとテストを書いていたので、配列へのアクセスミス程度であればすぐに気づくはずだった。

埒が明かなかったので、今度は標準ライブラリの関数の使い方にミスがないかをチェックしていくことにした。
(そのプログラムでは memcpy を多用していたので、もしかすると memcpy の第1引数と第2引数の順番を間違えたのかも…と思ってのことだった)

愛用の Dash でドキュメントを読みまくっていると、memcpy の説明の一文が目に留まった。

If the objects overlap, the behavior is undefined.

まさにこれが原因だった。
つまり、メモリのコピー元とコピー先の領域が重なるようなケースがあり、この undefined な動作によってメモリの中身が滅茶苦茶になっていたのだった。
(具体的には、ポインタの配列が壊れたせいで変な領域にアクセスしてしまい、セグフォが起こっていた)

コピー元とコピー先の領域が重なっていると何が問題なのかは、だいたい想像がつく。
例えば、memcpy がコピー元の先頭から順に1バイトずつコピーしているのだとしたら、dest > src のときにコピー元の重複部分が上書きされてしまう。

じゃあ一体どうすればいいのかというと、実は memmove という関数が用意されているので、そっちを使う。
memmove ではまず src から一時的な領域にバイトがコピーされ、さらにそこから dest にバイトのコピーが行われたかのようにコピーが行われるので、srcdest の領域が重なっていても問題にならない。(参考)

そんなわけで、無事にプログラムのバグは解決したのだけど、memcpy の中で一体何が起こっていたのかが気になったので、実装を覗いてみることにした。

Mac OS の memcpy

http://www.opensource.apple.com/source/xnu/xnu-2050.18.24/libsyscall/wrappers/memcpy.c

予想通り、memcpy の中で dest < src かどうかで条件分岐をしていた。
dest < src なら前から順にコピー、そうでないなら後ろから順にコピーしているのだと思う。

if ((unsigned long)dst < (unsigned long)src) {
    /*
     * Copy forward.
     */

     // 省略
} else {
    /*
     * Copy backwards.  Otherwise essentially the same.
     * Alignment works as before, except that it takes
     * (t&wmask) bytes to align, not wsize-(t&wmask).
     */

     // 省略
}

さらに、memmove が引数そのままで memcpy を呼び出していることも分かった。
今回のプログラムが Mac OS で問題なく動作していたのはこのためだった。

__private_extern__ void *
memmove(void *s1, const void *s2, size_t n)
{
    return memcpy(s1, s2, n);
}

Linux の memcpy

https://github.com/torvalds/linux/blob/master/arch/alpha/lib/memcpy.c

void * memcpy(void * dest, const void *src, size_t n)
{
    if (!(((unsigned long) dest ^ (unsigned long) src) & 7)) {
        __memcpy_aligned_up ((unsigned long) dest, (unsigned long) src, n);
        return dest;
    }
    __memcpy_unaligned_up ((unsigned long) dest, (unsigned long) src, n);
    return dest;
}

destsrc の下位3ビットを見てアラインメントをチェックし、処理を分けているが、それ以上のことはしていないように見える。
ここで使われている __memcpy_aligned_up__memcpy_unaligned_up とは別に __memcpy_aligned_dn__memcpy_unaligned_dn というのも定義されてるけど、使われていない。
up downforward backward という意味ではないのかな?よくわからない。

まとめ

  • memcpy でコピー元とコピー先の領域が重なるような場合は memmove を使う
  • Mac OS ではmemcpy でも問題ない (けどもちろん memmove を使うほうが安全)

これまでいくつも C のプログラムを書いてきたし、memcpy も何度も使ってきたけど、今回の undefined な動作については全く知らなかった。
さらに memmove なんて関数があるのも今回はじめて知った。

後日このことを先生に話したら、先生はこの仕様のことを知っているようだった。
経験の差なのだろうか。
ただ、先生も memmove の存在は知らなかったらしい。

今回の件で一番感じたのは、やはりドキュメントはしっかり読むべきだということ。
特に『わかったつもり』になっている C の標準ライブラリの使い方とか、そういう基本的なところが実は怪しかったりする。

あと Dash 最高。

以上、memcpy の間違った使い方をしてハマったのと、それを解決する中でいろいろ勉強になった、という話でした。

2015-01-13 追記

ブコメで以下のようなご指摘をいただきました。
id:mnogu さん、ありがとうございます!

引用されているalphaマシン用のLinux kernelコードは関係ないのでは?
ただ、glibcのmemcpy.cには「Overlap is NOT handled correctly.」とあるので、結論は同じ。
https://sourceware.org/git/?p=glibc.git;a=blob;f=string/memcpy.c;hb=HEAD

memcpy の実装についてどこを見るべきなのかよく調べなかったため、見るべきソースコードを間違えていました。
申し訳ありません。

間違いを教えてもらうことでまたひとつ勉強になりました。
ブログに書いてよかった…