X(旧Twitter)で話題になったC言語の未定義動作に関する疑問を、デバッガを用いてメモリアドレスの観点から詳細に解説する動画である。MSVCの32ビットビルドと64ビットビルドにおける振る舞いの違いを、callocやsnprintfの仕様とメモリレイアウトの差異から解き明かしていく。

C言語の未定義動作とレイの疑問
最近Xで、ラモン・サンタマリアがこのコードスニペットを投稿し、GCCやMSVCの64ビットターゲット版でコンパイルして実行したときと、MSVCの32ビットターゲット版でコンパイルして実行したときで、なぜ出力が異なるのかと質問していました。多くの人が返信し、そこでやっていることはC言語では未定義動作だから、何が起きてもおかしくないよと答えました。技術的にはその通りです。未定義動作なので、何が起きてもおかしくありません。しかし実際には、未定義動作というのは、仕様書の作成者が特定の言語で何か特別なことをしたときに責任を負いたくないと言うために使う言葉にすぎません。ですから、この違いについてC言語の仕様作成者に責任を問うつもりはありません。でも、もし彼が誰かご存じない方のために説明しておくと、彼は実はRayと呼ばれるこの素晴らしいライブラリを作った人なのです。彼が作成し、現在もメンテナンスしています。これを使うと、Cのような低水準言語でゲームを素早く作るようなことができるようになります。入力を取得するといった、素早く開発を始めるために使える素晴らしいマルチメディアユーティリティのようなものです。本当に素晴らしいです。たくさんの人が使っていて、教育プラットフォームとしても優れています。ですから正直なところ、彼がここでの違いは何なのか、なぜこんなことが起きているのかと質問したのなら、未定義動作だからという以上のきちんとした答えを返す義務があると思います。ですから、なぜこの違いが実際に起きているのか、これから見ていきましょう。未定義動作だからという理由以上に、はるかに興味深い答えがあることがわかっているからです。
デバッガでのビルド比較
このようなビルドの違いを調査する一番簡単な方法は、デバッガで2つを並べて実行することです。まさにここでそれをやっています。左側にはMSVCのx86ビルドがあります。右側にはMSVCのx64ビルドがあります。ここではGCCのものは表示していません。後でわかるように、それはあまり重要ではないからです。さて、YouTubeで見やすくするためにもう少しフォントを大きくしないのはなぜかと疑問に思っている方のために説明すると、実はここで使っているWindbgというデバッガではフォントをこれ以上大きくできないからなのです。メモリビューのサイズがハードコードされているからです。そのため、フォントを大きくするとすべてのバイトが見えなくなってしまいます。そういうものなのです。Windbgは少し特殊なプログラムです。そして、rad debuggerのようなもっとモダンなものではなくWindbgを使っている理由ですが、プログラムのx86バージョンを見る必要があるからです。最近のほとんどのデバッガはx64しか対応していません。もうx86向けにコンパイルする人はほとんどいないからです。
メモリ割り当てとcallocの働き
注意点はこれくらいにして、ここの最初の2行を見てみましょう。この2行はどちらも同じ calloc というCライブラリの関数を呼び出しています。C言語に馴染みがない方のために説明すると、標準ランタイムライブラリというものがあり、みんな大抵それをリンクします。これは他の言語とそれほど違いはなく、標準化されたライブラリ、あるいは標準化されていないライブラリによって、こうしたユーティリティ関数が提供されていると期待するようなものです。C言語は比較的低水準な言語なので、自分でメモリの割り当てと解放を行う必要があります。ですので、何かを保存したい場合、そしてそれが8バイトを必要とする場合、割り当て関数を呼び出して8バイト必要ですと伝える必要があります。それが calloc です。予約したいスペースのサイズを伝えるので、ライブラリ側で何らかの方法でそれを行ってくれるメモリアロケータを持っていることを期待しているわけです。具体的な仕組みはわかりません。さて、calloc はおそらく皆さんよくご存じでしょう。calloc は割り当てた後にメモリをクリアするように定義されています。そのため、メモリの塊を受け取って、何かを書き込むまでその中身が何かわからないという状態の代わりに、calloc は要求したサイズの、あらかじめゼロでクリアされたメモリブロックを返すことが保証されています。つまり、この2行のコードがやろうとしているのはそれだけです。その関数を2回呼び出し、最終的に str01 と str02 に入るメモリの場所を記憶します。これらは星印で示されているようにC言語におけるポインタです。しかし、ポインタに馴染みがなくても大したことではありません。少しメモリをお願いして、受け取ったメモリがどこにあるか覚えておきたい、ここがメモリの場所ですと言っているだけです。さて、calloc が行うのはサイズを割り当ててクリアすることだけですが、2つのパラメータを取ります。2つのパラメータはサイズを指定するための方法にすぎません。配列の構文で要求するようなものです。配列の要素数を指定し、次に配列の要素の大きさを指定します。そして、サイズを計算するためにこれら2つを掛け合わせます。さて、charのサイズです。charはわずか1バイトです。事実上ASCII文字です。そのように考えていいでしょう。なので1バイトになります。つまりここは1です。そして max_string_size はここでは8です。つまり、これは8バイトのメモリを要求することになります。では、この2行を実行してみましょう。まず、x86バージョンで実行します。1、2。そしてx64バージョンで実行します。1、2。これで両方のバージョンのプログラムで、Cランタイムライブラリに8バイトを2回要求し、私たちの知る限りそれは応じてくれました。
メモリアドレスとレイアウトの違い
x86バージョンで私たちが受け取ったとされる8バイトのセクションのメモリ内の実際のアドレスを見てみましょう。ここに表示されているのがわかります。これら2つのアドレスです。これらは文字通り、このメモリの最初のバイトが欲しい場合にプロセッサに要求する仮想アドレスです。つまり文字列1はここで、文字列2はそこです。x64側でも同じように、文字列1と文字列2があります。さて、すぐに、これら2つのプログラムの動作にはわずかな違いがあることが実際にわかります。x86の場合、メモリ内の2つのアドレスの差を取ると、x86ビルドでは文字列1と文字列2が16バイト離れていることがわかりますが、x64ビルドでは実際には32バイト離れています。つまり、32ビットビルドと64ビットビルドの間に少し違いがあることがすでにわかります。さらに一歩進んで、メモリ内のそれらの場所での実際のメモリレイアウトがどのようになっているかを見てみましょう。ここで両側にメモリウィンドウがあり、両方の場合において文字列1のアドレスから始まるメモリの内容を見るように設定しています。ここが強調表示されているバイトであることがわかります。これが実際に、x86バージョンの場合の文字列1の最初のバイトです。つまり、ここから私たちが確認していくメモリになります。そしてx64バージョンでは、これが強調表示されている最初のバイトであることがわかります。ですから、ここのメモリを見ていくことになります。そして、ここでもすぐにかなりの違いがあることがわかります。間隔が異なることに加えて、これが何を意味するか考えてみましょう。もしここが文字列1のアドレスであり、私たちが8バイトのメモリを要求したことがわかっているので、1、2、3、4、5、6、7、8となります。私たちが予約したとされるメモリのバイトの次のバイトはこれらのバイトになりますが、これらが何を意味するのかはまだ本当にわかりません。そして、文字列2に到達するまでに16バイトあることがわかっています。ですから、これらは実際には文字列2のために取得したものの一部ではありません。クリアされているはずなのにクリアされていないので、これは良いことです。これが文字列2の最初のバイトですよね。それはアドレスを見るだけで確認できます。13072E8ですね。ええと、ここに13072E0があります。ですから、1、2、3、4、5、6、7、8。そこが文字列2の始まりです。そしてもちろん、文字列1から始めて16バイト進むことでも見つけることができます。その分だけ離れていることがわかっているからです。いずれにせよ、ここが文字列2のある場所です。ご覧の通り、期待通りにクリアされた8バイトのメモリがあり、その後にまだ何かわからないものがたくさんあります。繰り返しますが、これは非常に理解しやすい動作をしています。きれいでシンプルです。これはただの偶然です。C言語の仕様などには、そのように2つの割り当てを行ったときに、それらが互いに近くになるという保証はありません。非常に遠く離れた2つの割り当てを返してきた可能性も十分にあります。
x64ビルドにおけるメモリパディング
さて、x64側はそれほど違いはありませんが、意味のある違いがあります。ご覧のように、ゼロクリアされた8バイトを割り当てたとき、私たちは実際にそれを取得しました。しかし奇妙なことに、私たちはそれよりもずっと多くのものを取得しましたよね。ここにクリアされたメモリの別の8バイトがあります。さらに別のクリアされたメモリの8バイトがあります。つまり、私たちが取得した余分なクリアされた16バイトになります。そして、まだ何かわからないものがいくつかあります。さて、まだ何かわからないものは、どちらのケースでもいくらか似ていますよね。どちらのケースでも、私たちが認識できない8バイトです。では文字列2はどうなるのでしょうか。繰り返しになりますが、これがここ800、つまり32バイト後から始まることがわかっています。ここの800の行を見るだけで、そこが文字列2のある場所だとわかります。私たちが要求したクリアされたメモリの8バイトがあります。そして再び、要求していない余分なクリアされたメモリの16バイトがあります。それからまた、よくわからない別の8バイトのセットが続きます。さて、これらのバイトはおそらく偶然のものではないこともわかります。ある種のパターンがあることがわかります。4Bがあります。7170があります。この2つは両方とも36とE3です。こちら側に行って同じものを見ると、ああ、これらは不快なほど似ているなと思うでしょう。だからおそらくわかると思いますが、単なるランダムなものがそこにあるわけではありません。それも実際にある程度構造化されているわけです。とにかく、これまでのところ、これら2つのビルドの間の本当に大きな違いの1つがわかりました。それは、8バイトの割り当てと一緒に得ているこれらの余分な16バイトのゼロです。では次に何が起きるでしょうか。
memcpyによるメモリコピーの挙動
次は memcpy です。さて、memcpy はある場所から別の場所へメモリをコピーしろと指示するだけです。これも calloc と同じようにCライブラリ関数です。つまり、ライブラリのメンテナが書いたもので、私たちが単に呼び出しているものです。さて、memcpyの仕様は、2番目のパラメータから1番目のパラメータにコピーし、3番目のパラメータで要求したバイト数だけコピーするというものです。つまり、この文字列を保持することになるあるメモリから8バイトをコピーしようとしており、私たちがコピーできるようにその文字列をメモリのどこかに配置しておくのはCコンパイラの仕事です。これは、定数文字列がある場合にC言語がやってくれることの1つにすぎません。そのため、これをすべてコピーするわけではありません。私たちがコピーするように伝えている数なので、最初の8バイトだけをコピーします。それらを str02 にコピーします。ご存知の通り、str02 はクリアされた8バイトのメモリですが、ここにある8バイトすべてを上書きしようとしているので、実際にはクリアされている必要はありませんでした。しかし、いずれにせよそれが私たちがやろうとしていることです。では、この行をステップオーバーし、メモリ表示を見ていれば、実際にこれをコピーするのを見ることができます。実際、ここのメモリ表示では、これらのバイトが実際に何であるかのASCIIの読み出しがあります。このメモリにあった以前の文字列のようなものが、ただそこに置かれているのをたくさん見ることができます。たとえば、おそらく使用されてその後解放され、再利用されている環境変数ブロックのようなものに見えます。しかし、私たちが実際にいくらかのメモリを割り当てたセクションをここを見ると、今度はこの memcpy が表示されるはずで、str02 の場所に表示されるはずです。これはここのE8であることがわかっています。ですから、ここから始まることになります。この行を実行すると、私たちがやろうとしていたことを正確に実行したことがわかります。1、2、3、4、5、6、7、8のASCIIエンコーディングをそれらのバイトに書き込みました。そして、ここでもASCIIビューでそれが表現されていることがわかります。つまり、私たちが期待した通りのことを正確に行いました。すべて完璧に機能しています。何も言うことはありません。しかし、8バイトすべてを埋めたため、今度はそこで埋めたものと、メモリブロックの後に続く、実際には何かわからなかったランダムなバイトとの間に、事実上スペースがないことに気づくでしょう。つまり、私たちが最後に埋めたバイトと、その間にはゼロクリアされたバイトなどは何もありません。意味がよくわからない他のバイトと直接隣接しています。
x64でのmemcpy実行後のレイアウト
対照的にx64バージョンに行ってみると、memcpy は指定された分だけある場所から別の場所へコピーするように指定されているため、まったく同じことを行おうとしますが、64ビットバージョンでもそれを行います。まったく同じ動作です。そしてここでわかるように、それを行い、私たちが予想した通り、str02 のある場所に直接書き込みました。そこのASCIIバージョンもあります。しかし今回は、割り当てを行ったときに取得した余分な16バイトのゼロがあるためです。なぜそうなっているのかについてはこれまで説明はありませんでしたが、取得しました。私たちが埋め込んだものと、まだ意味がよくわからないそれらの余分なバイトとの間には、言ってみればたくさんのスペースがあります。つまり、再び、それらの16バイトがここで違いを生んでいます。これらの操作を行った後のメモリのレイアウトのようなものは、まだ理由がわからない何らかの理由で発生していたそのより大きな割り当てられたクリアサイズのために、64ビットバージョンでわずかに異なります。さて、ここまでは順調です。
文字列の終端とsnprintfの仕様
今度は、物事を出力する前の最後の行の時間です。これは snprintf と呼ばれる関数への呼び出しです。snprintf は文字列フォーマット関数です。これが何をするかというと、書き込み先となるメモリとしてここで使われる文字列1を受け取ります。そして、超えてはならないサイズを受け取ります。つまり、max_string_size は基本的にこれ以上は決して書き込まないでくださいという意味です。この文字列に8バイト以上決して書き込まないでください。フォーマット文字列は、Cランタイムライブラリの標準IO関数に固有の文字列の規約で、何をしたいかをエンコードするだけのものです。この場合、パーセントはエスケープ文字のようなものです。Sは文字列です。そして、これが何をするかというと、これを読み込んで、ああなるほど、これをフォーマット文字列の後の次のパラメータが何であれそれに置き換えなければならないんだなと言うのです。そしてこの場合、それは str02 です。つまり、ここでやろうとしているのは、str02 から文字を読み取って文字列1に入れることです。そしてそれで本当にフォーマット文字列の終わりです。それがこの関数のやろうとしていることのすべてです。そして完了したとみなします。さて、この時点で、より高水準な言語でのプログラミングに慣れている方は、よしこれでうまくいくはずだと思うかもしれません。しかしC言語は超高水準言語ではないため、文字列のようなものは実際には完全に形成された意味的構造ではありません。文字列ライブラリを作ったり、既存の文字列ライブラリを使ったりして自分で作ることはできます。しかし基本的なレベルでは、Cランタイムライブラリでさえ、文字列が何であるかという概念は、メモリ内のどこから始まるかというポインタ以外には本当にありません。それは私たちが calloc を行ったときにここで求めたものです。私たちはいくらかのメモリを取得し、それがどこから始まるかを知っています。そして今私たちはそれを使って、これが出力したい文字列ですと言っているのです。しかし問題があります。文字列の長さを指定していません。str02 は私たちが割り当ててからいくつかのものをコピーした単なるメモリですが、str02 に関連付けられたそれ以上の情報はありません。下でわかるように、その値は単なるメモリ内のアドレスです。長さを記録していません。snprintf は、ただ str02 を与えられただけでは何文字コピーすればいいかわかりません。なぜならそれは単なる開始場所だからです。では、どうやって終わる場所を知るのでしょうか。Cランタイムライブラリは、主に歴史的な理由から、非常に特殊な規約を使用しています。文字列がいつ終わるかを知る方法は、文字列の中にゼロバイトがあることだ、としています。ですから、ゼロを見るまでは自分たちが見るものすべてが有効な文字であると仮定し続け、ゼロが来たらそこで終了だと教えてくれるのです。C言語の話をするときに、ASCIIZ文字列とかヌル終端文字列などという言葉を聞いたことがあるなら、人々はそういうことを話しているのです。長さが決して明示的に指定されないという事実について話しているのです。それは、ゼロを文字列が終わったことを示す特別なマーカーとして扱うことによって、文字列の中にただ暗黙的に存在しているのです。さて、なぜ私がその点について長々と話したのか不思議に思うかもしれません。その理由は、それが後ですぐに非常に重要になるからです。しかしその前に、もう一つ話さなければならないことがあります。それはここの部分です。
snprintfの戻り値に関する罠
snprintf はここで値を返しています。その値は何になることになっているのでしょうか。Cランタイムライブラリが snprintf を指定している方法は、文字列フォーマットをすべて行うのに十分なスペースが実際にあったかどうかに関係なく、文字列フォーマットの結果全体を収めるためにバッファがどれだけの大きさである必要があったかを返すようになっているということです。つまりこの場合、私たちはメモリアドレスを渡し、8バイトである max_string_size を使用できると伝えています。もし8バイトに収まるなら、実際に必要だったサイズが返ってきます。たとえば3バイトか4バイトかかったとすると、コピー長としてここに3または4が返ってきます。しかし、もし実際に収まらなかった場合、実際に返されるのはバッファがどれくらいの大きさである必要があったかという値です。さて、私のようにいまだにC言語でのプログラミングが好きな人間が、それがCランタイムライブラリの標準的なやり方であったにもかかわらず、先ほど話したようなゼロ終端文字列を使わない理由はたくさんあります。そして、まさにここですぐに1つの本当に大きな理由を見ることになります。しかし、この行だけでも、それが少しエラーを起こしやすい規約である理由が何となくわかるような、ちょっとした理由があります。具体的に言うと、これはバッファの大きさがどれくらいであるべきかを返すことになっています。snprintf はCの規約に従おうとします。どこで終わったかがわかるように、この文字列をヌル終端しなければなりません。なぜなら、ここにどれだけのものを出力するかわからないからです。ですから、後でその文字列を他の関数に渡すために、文字列を適切に終端して、誰もが文字列の終わりをわかるように、そこにゼロを入れなければならないのです。そこで問題となるのは、この関数の返す長さは、そのゼロのヌル終端子を含めて使用したであろうバイト数なのか、それともそれを含めないバイト数なのかということです。そしてドキュメントをチェックしに行かない限り、本当に知る方法はありません。ですから、なぜこれらの文字列が素晴らしいものではないのかというさらに悪い例を見る前であっても、少しのことであってもそれらが多くの問題を引き起こす理由を理解していただければと思います。なぜなら突然、文字列の長さが異なる意味を持つようになるからです。概念的なバージョンの文字列に実際の文字がいくつあるかを意味していたのでしょうか。それとも、最後にゼロを置かなければならないため1つ多くなるので、保存するのに必要なバイト数を意味していたのでしょうか。とにかく、snprintf の場合、最後のゼロを除いたカウントを返すように定義されています。ですから、これは str02 を文字列1にコピーした完全な結果を保存するのに必要な実際のサイズより1つ少ないと予想されます。
メモリビューでのsnprintfの確認
さて、この行を実行したい場合は、もう一度メモリビューに下がって、実際に機能するのを見ることができます。文字列1に書き込むことになりますが、これはここの8バイトです。この行を実行してみると、私たちが期待する7バイトがコピーされることがわかります。しかし、その最後のバイトはコピーされません。ただの1、2、3、4、5、6、7になります。何が起きているのでしょうか。なぜ8文字すべての1、2、3、4、5、6、7、8が得られなかったのでしょうか。繰り返しになりますが、それはゼロ終端に関係しています。なぜなら、snprintf は文字列がどこで終わるかを示すためにゼロを入れなければならないからです。つまり、文字列を保存するために私たちが提供しているバイトのうち、7つしか使用できないということです。そしてその8番目のバイトはゼロに使用されなければなりません。つまりそれが関数が正確に行ったことです。7バイトプラスゼロです。今度はx86側に移ります。同じことです。ここに文字列1の8バイトがあります。この行を実行してみると、まったく同じことを行うことがわかります。最初の7バイトをコピーし、ヌル終端のためのゼロを入れます。さて、ここでも大量の他のメモリが変化しました。しかし繰り返しますが、それほど驚くことではありません。printf が一時メモリか何かを使用する場合、割り当てと解放を行う可能性があり、それが近くから来ている可能性があるからです。snprintf のようなかなり重量級の関数を呼び出すとき、そこで何が起きているかは誰にもわかりませんが、それは私たちが予約して何かを復元しているメモリではありません。ですから、自分自身の一時的な使用のために何かを割り当てる可能性が十分にある関数への呼び出し中に、それが保存されるとは期待していません。
32ビットと64ビットの決定的な違い
さて、コピー長は保存されている変数であるため、技術的にはすでにこの時点で、これら2つのプログラムの間に相違がある証拠を実際に確認できます。実際、これをここのウォッチウィンドウに入れて、コピー長の値が何であるか教えてよと言ってみます。x86側では14であることがわかります。一方、x64側にこれを入れるとわずか8です。さて、snprintf について私が話したことに基づけば、ここでの正解は8であることもわかるでしょう。この結果を適切に保存するには9文字必要でした。1、2、3、4、5、6、7、8のASCII値と、文字列を終了するためのリテラル値のゼロです。これで9文字です。しかしもちろん、snprintf は終端子を除いたカウントを返すように定義されています。ですから結果は8になるはずで、x64バージョンでは8になっています。しかしx86バージョンでは、すでに問題があることがわかります。長さは14である必要があったと言っており、これはここで結果を保存するのに15バイトのメモリが必要だと考えていることを意味します。文字に14、ゼロ終端子に1つです。ですからすでにいくらかの相違を見ることができますが、もちろん今からの出力で、レイの元の投稿とまったく同じように、さらに多くの相違を見ることになります。
標準出力とstrlenの振る舞い
では、実際に何が標準出力に書き出されるかを見ることができるように、出力ウィンドウを表示しましょう。x64側から始めます。printf は snprintf とまったく同じです。ご覧のように、snprintf と同じようにフォーマット文字列を取ります。そのフォーマット文字列によって要求されたフォーマットを満たすために使用されるいくつかの引数を取ります。そして本当の違いは、snprintf は後で使用するメモリに書き込むことになっているためです。最大サイズとメモリへのポインタを取ります。一方、printf はコンソールに出力するだけとして定義されているため、すべてを標準出力に書き込むと想定しています。ですから、フォーマット文字列の前にパラメータはありません。これらのほとんどは実際のフォーマット情報ではありません。str01 というラベルや、括弧やコロンのようなものにすぎません。しかし、いくつかのフォーマット要素があります。数値を出力するパーセントiがあります。文字列を出力するパーセントsがあります。そしてもちろん、次の行に行くためのリターンになるバックスラッシュnがあります。では、これらは何を出力するように求められているのでしょうか。最初の行の str01 では、snprintf によって返された長さを出力することになりますが、この場合それが8であることはすでにわかっています。そして、snprintf によって生成された文字列が何であれ、それを出力します。この行を実行すると、驚くようなことは何も起こりません。私たちが得られると思っていた通りの文字列、そしてメモリ内で見た通りの文字列が得られます。そして、私たちが得られると思っていた通りのカウントも得られます。すべてほとんど教本通りです。
str02の出力と偶然の産物
さて、次の行では少し違うことを行います。str02 を出力します。これはもちろん memcpy によって生成されたもので、単なるバイトの生のコピーです。繰り返しますが、特定の文字列関数ではなく、単なる生のメモリ操作関数です。そして長さについては、Cランタイムライブラリに strlen という関数を使って文字列の長さが何であるかを調べるように頼みます。さて、strlen は皆さんが考える通りの理由で必要な関数です。これらのヌル終端文字列、つまり止まる場所を示すゼロを持つことで終わる文字列は、実際には文字列の長さをどこにも保存していないからです。もし文字列の実際の長さを計算したいなら、誰かが見に行かなければなりません。つまり strlen は、文字通りあなたが指定したメモリ上のポイントから開始する関数です。この場合、str02 から開始するように頼みます。そしてゼロを見つけるまで順番にバイトを見ていくだけです。最初のバイトでゼロを見つけたら、文字列の長さはゼロであると教えてくれます。10文字後に見つけたら、10と言い、そうやって続きます。ですから再び、少なくともこれはいくらか一貫していることがわかります。strlen はそのゼロを含めずに、実際の文字がいくつあるかを教えてくれます。ですから、少なくとも snprintf からの結果とは一貫しています。どちらも文字列を保存するのにかかる実際のサイズより1つ少ない値を返します。終端するゼロではなく実際の文字だけを数えているからです。ですから、たとえば str01 で strlen を呼び出したとすると、8ではなく7を生成すると期待されます。なぜなら実際の文字が7つあるからです。8は snprintf が必要だったと言っていた数プラス、ヌル終端子のための1つだということを思い出してください。つまり、入れたかった文字をすべて入れ終わるには合計9つ必要だったわけですが、実際にはそれはできませんでした。覚えていると思いますが1つ足りませんでした。とにかく、これがどうなると思いますか。自分たちでやってみることができます。str02。それがどこにあるかわかっていますよね。800の行です。ここから始めてゼロを見つけるまで数え始めたとすると、よし、1、2、3、4、5、6、7、8があるとします。よし、ゼロがありました。ゼロに当たる前に8つのものがあります。ですから、strlen が str02 に対して8を返すと期待します。そして同様に、ここで printf がどのように機能するか想像してみると、文字列を出力しなければなりません。文字列の何文字を出力すればいいかどうやって知るのでしょうか。私たちが指定したところから開始し、ゼロに当たるまで進み続けます。ですからまったく同じことを行います。ここから開始し、1番目、2番目、3番目、4番目、5番目、6番目、7番目、8番目を出力し、ゼロに当たったときに停止します。ですから再び、これを実行すると、異常なことは何も見られません。まさに私たちが起こるだろうと期待していた通りのことです。さて、ここで理解することが非常に重要ですが、もちろん今手順を追って説明したように、これらが両方とも8を生成するという事実はある種の偶然です。それらは両方とも同じ種類の8ではありません。一方のケースで見ているのは、ゼロが見られる前に現在 str02 に保存されている8文字の正確なカウントです。しかし str01 の出力のケースで実際に見ているのは、8つコピーしたかったけれどできなかった、実際には7つしかコピーしなかった、と伝えているということです。そしてそれがここで起きていることがわかります。実際に出力されるのは7つだけです。そしてそれはそれが収まるすべてだったからです。文字列を終了するゼロのためにその最後のスペースを使用する必要がありました。クレージーなメモリの話がたくさんあるのはわかっていますが、これらすべてが理にかなっていることを願っています。
x86ビルドでの問題発生の瞬間
x86ビルドでまったく同じ一連の手順を実行したときに何が起きるか見てみましょう。まず、コピー長を指定して printf を行いますが、ここでは何らかの理由で14であることがわかっていますよね。少し奇妙です。その14が出力されるのを見ることになります。そして printf は str01 から開始し、ゼロに当たるまで進みます。それはどうなるでしょうか。str01 はここから始まります。ですから、これらの7バイトを出力してゼロで終わります。ですから、これが基本的には64ビットバージョンと同じように見えると期待しますが、8ではなくここに14があります。そしてなぜかはまだよくわかっていませんよね。この行を実行してみましょう。はい、出ました。これが私たちが予想していた出力です。さて、レイがそのスクリーンショットで異常として指摘していた行に来ました。私たちは str02 の長さを知らないことを思い出してください。それを取得するには strlen を呼び出さなければなりません。そして strlen は何をするのでしょうか。その最初のアドレスから開始し、ゼロを見つけるまで進みます。そこで何が起こると思いますか。ええと、str02 はここにあります。そこが開始点です。そしてもちろん、1、2、3、4、5、6、7、8が入っています。それが私たちが memcpy で中に入れたものだからです。ASCIIでエンコードされた1、2、3、4、5、6、7、そして8があります。ですから、ここから開始してゼロを見つけるまで進むように strlen に頼むと、それらを通過してゼロは見つかりません。そしてそのまま探し続けます。そして、ラップアラウンドして次のメモリアドレスに行ってもゼロではないことがわかります。そしてそれもゼロではありません。これもこれもこれも、ここが偶然現れた最初のゼロです。さて、なぜ偶然現れたと言うのでしょうか。文字列を保存するためにメモリを割り当てたとき、私たちは8バイトしか要求しなかったことを思い出してください。それから8バイトをコピーし、私たちが実際に使えるスペースをすべて使い切ってしまいました。文字列には単純に残りのスペースはありません。私たちは実際に8文字をそこに入れました。ですから、最初から開始してゼロに当たるまで進むことになっている strlen という関数を呼び出しているので。それがこの文脈における文字列の定義です。私たちは決してそこにゼロを入れませんでした。見つけるためのゼロを持っていません。ですから再び、これらのゼロ終端文字列と一緒に持ち歩かれる長さがないためです。strlen は自分が何か間違ったことをしているとは決して知りません。この文字列を保持するために元々割り当てられたメモリの範囲外に出ていることを知りません。そのメモリが8バイトの長さであるべきだとは一度も言われていません。ですから、この8番目の文字に到達したとき、これがまだ同じ有効な文字列であると考えてそのまま9番目の文字に進み、私たちが用意した文字列とは何の関係もない、たまたまそこにあるゼロに偶然当たるまで止まらないのです。さて、printf が実際に出力にその文字列を入れようとするときにも、まったく同じことが起こります。その文字列の長さがどれくらいであるべきかまったくわかりません。strlen が指示されたのと同じことをするように指示されているだけです。アドレス str02 から開始し、ゼロに当たるまで進め、と。ですから、私たちが実際に入れた8文字すべてを出力します。そしてその後、それらの一部が私たちが文字列に入れたであろう文字では絶対になかったとしても、8、AF、3F、37、69、そしてE2をASCIIとして出力しようとするだけです。いいですか。この行を実行してみると、ほぼ予想通りのものが得られます。しかし、ここでもう一つ変化球が来ます。そうです。14は予想通りです。str02 はここから始まり、私たちが中に入れた8文字分進み、そして9、10、11、12、13、14、そしてヌル終端子に当たりました。ですからその14は完全に理にかなっており、メモリの中身を見た今となっては、そこにあるべきだと私たちが考える通りのものです。このコードを見ただけでは非常に混乱するかもしれませんが、一度メモリの中身を見てしまえば、完全に理にかなっています。
なぜ出力文字が消えたのか
そして同様に、ここで snprintf に戻ると、なぜ14を返していたのでしょうか。まったく同じ状況が理由です。無制限のサイズがあった場合に、出力バッファに入れたい文字数を返すように定義されています。つまり、str02 から開始し、ゼロに当たるまで進むということです。そしてそれが14文字になることがわかっています。もちろん、最後に置くことになっているその余分なゼロのためのスペースは返しません。ですから、事実上 strlen と同じものを返すだけです。14を返します。つまり、それは説明するのがとても簡単です。しかしここで何が起きているのでしょうか。メモリ内にあるとわかっている8が完全に欠落しているように見えますが、それは意味がわかりません。さらに、1、2、3、4、5、6、7、8、9、10、11、12文字の後、他には何も出力されていません。それがどうして理にかなっているのでしょうか。14と言っているのに、14文字も見えません。さて、これもまたメモリを見るだけで解決できます。これはASCII文字列なのでASCIIでエンコードされていることがわかっています。UTF8はASCIIのスーパーセットのようなものなので、UTF8として考えることもできます。ですから、これら2つのエンコーディングのどちらかを知っていれば、これらの数字が何を表しているか理解できます。知らなければいつでもASCIIテーブルを調べることができます。しかし31は1のコードで、32は2のコードで、という具合です。ですからこれらの数字はまさに私たちがそうであると期待するものです。1、2、3、4、5、6、7、8です。そして printf はこれらの文字を出力します。そしておそらく概念的に出力し、私たちには見えませんでしたが、おそらくある時点で実際に8をバッファにコピーしたのでしょう。しかし、次の文字を出力しようとしたとき、それはASCIIです。そしてASCIIの08はたまたまバックスペースのASCIIコードなのです。ですから何が起きるかというと、printf はバッファ内で1文字戻り、ああ、カーソルを戻さなきゃとなるのです。そして次に出力するものは、そこにあるはずだった文字の上に上書きされます。ですから、08は実際に文字として出力されないだけでなく、それによって14から13に減るのですが、1つ戻り、そして次の文字が、今やその上にあったもの、つまり8を何であれ置き換え、私たちが文字を出力する数を13から12にさらに減らします。そこからは残りの5文字を出力します。そしていくつかはこの場合ある種の記号で、他のものはクエスチョンマーク、7、Iのような実際の通常のASCII文字であることがわかります。
根本原因と解決策
さて、2つのビルドが分岐する理由の完全な病理を通って歩いてきたので、一度巻き戻してx86バージョンを手短に見直し、なぜ誤動作しているのかをはっきりと確認しましょう。問題はここのこの memcpy にあります。calloc を呼び出して8バイトのメモリを取得し、アドレスを str02 に記憶しました。その後、コンパイラが私たちの実行可能ファイルに確実に入れ、この実行可能ファイルを実行したときにオペレーティングシステムがメモリにロードしたはずのこの定数文字列から、str02 に memcpy を行います。つまりそれはどこかにあります。そのメモリから私たちが割り当てた8バイトにコピーし、最大サイズとして8を渡しているので8バイトすべてに書き込みます。これを行うと、ヌル終端子のための余地は明らかに残っていませんし、入れようとさえしていません。memcpy はここで私たちが文字列について話していることを知りません。まったくわかりません。単なる生のコピー関数です。ですから、8バイトすべてを埋めます。そしてここにヌル終端子を置きませんが、ここは置く必要がある場所です。バッファの最後の文字です。ヌル終端子を置くこともできますが、使えるのは8バイトしかありません。その結果、プログラムの残りの部分のすべてが、ここから開始してこれを文字列として扱うように指示したとき、ゼロを見つけるまでただ読み続けるだけになります。ですから、もしこれを修正し、C言語の仕様標準により適合した正しいプログラムにしたいのであれば、ここでヌル終端子を追加する何かを入れる必要があります。str02 のブラケット max_string_size マイナス1イコールゼロというコード行を文字通り書いて、その場所にゼロをハードコーディングすることもできました。それで私たちの問題は解決するでしょう。これを1つ減らすこともできました。最大サイズマイナス1と言って、8バイトのゼロを取得したことがわかっています。7つだけコピーすれば、最後にはまだ1つ残っているでしょう。ですからそのようなものであれば何でも機能し、正しく実行されるプログラムになります。
strncpyを使えば解決するのか?
さて、レイの元の投稿への返信の中で注目すべきなのは、ある人たちが、ああ、文字列をコピーするには strncpy か strcpy を使うべきだよと言っていたことです。そしてそれは本当です。ある意味ではそれが文字列をコピーするために使うべきものです。文字列コピーのルーチンのようなものですから。しかし注目すべきは、この特定のケースにおいて strncpy の仕様は memcpy の仕様より決して優れていないということです。実際、C言語には、snprintf のようなものよりも、限られたスペースにこの文字列をコピーし、それが確実にヌル終端されるようにする、優れたコピー関数はありません。ここのC標準ライブラリはそれほどうまく指定されていなかっただけなのです。つまり strncpy は文字列をヌル終端しますが、それはそうする余地がある場合のみです。ですからもし8というサイズを与えられたら、ヌル終端子は入れません。なぜなら、うーん、最初の文字のために8つすべてを使う必要があったんだ、ごめん、そこにヌル終端子は入れられなかったよ、となるからです。もし信じられないなら、自分で実行してみてください。ここのこの strncpy の行でブレークしてみると、ここが strncpy で書き込もうとしている場所であることがわかります。この行を実行してみると、私の運勢が好転していないことがわかります。そこに8バイトあります。コピーされた数字の8があります。私たちが望んでいたはずのヌル終端子ではありません。つまり本当にそれだけのことです。このプログラムの32ビットビルドと64ビットビルドの違いは、本当にすべて calloc の振る舞いの違いから来ています。他のすべてのことはそこから派生しています。そしてもしプログラムがどのように動いているかを理解し、calloc から戻ってくるその違いを見れば、ああ、なるほど、他のすべてのことが今起きているような形で起きている理由がわかったとすぐになるでしょう。64ビットビルドは、そこに余分なパディングのゼロがあるためにたまたま機能しているだけです。ですから、コードが str02 にヌル終端子を入れていなくても、calloc の振る舞いのために8バイトの後に密かにヌル終端子が存在しているのです。
64ビット版のcallocの謎とさらなる探求
さて、これらすべてのことが、追加の疑問を投げかけることになります。2つの異なるビルドが分岐した理由を示し、それは calloc のこの振る舞いのせいだと言いましたが、なぜ calloc がこのような特定の振る舞いをするのかについては決して調べませんでした。なぜ64ビットビルドではこれらのゼロですべてパディングしていたのに、32ビットビルドではまったくパディングなしで、私たちが要求した正確な8バイトだけを返しているように見えたのでしょうか。私がそこに踏み込まなかったのには良い理由があります。それは、その部分を探索するためにはアセンブリ言語を読むことができ、calloc が裏で何をしているのかを解明するために少しリバースエンジニアリングができなければならないからです。ですから、それは明らかにあまり一般的な視聴者向けの動画にはなりませんが、かなり面白いと私は思います。なので、私のウェブサイト computerenhanced.com にアップするつもりです。もし皆さんがこれをもっと深く掘り下げて、calloc で実際に何が起きているのかを見るためにたくさんの時間を費やしたいのであれば、編集が終わったらそこにアップします。でもそれまでは、興味のある方のためにちょっとした予告を残しておきましょう。このコードを適切に修正してヌル終端子を入れるよりも、もう少しこれをつついてみて、もしあなたが calloc をリバースエンジニアリングしてそれが何をするか知っているなら、未定義動作の部分を決して修正することなく、レイが見ていたこの振る舞いを制御するためにCコードに変更を加えることができるか、見る方が面白いと思うからです。では、ここで何ができるか見てみましょう。私がやりたいのは、32ビットバージョンを強制的に機能させ、32ビットバージョンと64ビットバージョンの両方が正しく機能しているように見せることだと仮定しましょう。やるべきことは max_string_size を7に変更することだけです。これでうまく設定できるはずです。これで32ビットビルドを実行すると、完璧です。1、2、3、4、5、6、1、2、3、4、5、6、7、どちらの答えも7です。64ビットバージョンを実行してみると、まったく同じ変更で、8を7に変更しただけです。ドーン。同じ結果です。どちらの場合も7です。1、2、3、4、5、6、1、2、3、4、5、6、7。さて、もし代わりに私が暴走して、両方のバージョンを成功させる代わりに両方のバージョンを失敗させたいと思ったとしても、問題ありません。サイズを24に変更するだけです。最初から24文字はなかったので、ここのソース文字列を少し長くする必要はありますが、それ以外は手品のような仕掛けは何もありません。32ビットバージョンを実行してみます。私たちが望んでいた通り、ここにいくつかの素敵な失敗文字が表示されます。64ビットバージョンを実行すると、それも失敗します。はい、予告はここまでです。calloc のアセンブリやそれ以上のことを掘り下げて楽しみたい方は、computerenhanced.com でお会いしましょう。また、この動画にはいくつかのアウトテイクもあります。この特定のコードでできる、この特定の動画にはあまり合わないように思えた他の面白いこともいくつかあります。それらもそこにアップします。でもこの動画はこれで終わりです。楽しんでいただけたなら幸いです。そして覚えておいていただきたいのですが、私たちが見たものはすべて基盤となるヒープアロケータの振る舞に依存しているためです。皆さんのマシンでこれを実行すると、実際にはまったく異なる結果が得られるかもしれません。ですから、もしよろしければ、これを少しローグライクゲームのように扱って、皆さんのマシンで同じプログラムをステップ実行した場合に、同じようなメモリレイアウトになるのか、それとも微妙な違いが生じるのかを確認してみることもできます。もちろん、さらに楽しむために別のオペレーティングシステムで試すこともできます。何か特に面白いことを見つけたらコメントで教えてください。この動画は以上です。次回まで、皆さんプログラミングを楽しんでください。それではインターネット上でお会いしましょう。


コメント