はじめまして。株式会社インサイトテクノロジーのプロダクト開発本部でデータベースセキュリティ監査ツールPISOの開発に携わっている 趙 明春(チョウ メイシュン) です。
C++でコードを書いていると、この書き方で大丈夫か、予想外の問題が起きないか、など不安に駆られる場合があります。本ブログは、デバッガーを用いてC++コードの内部動きを解析し、モヤモヤから解放されることを目指しています。
キャストのモヤモヤ
今回はC++のキャスト(型変換)がテーマとなります。C++のキャストは、static_cast, dynamic_cast, reinterpret_castなど複数演算子が存在し、本質をつかむまで使い分けが難しいです。追い打ちをかけるように、一部のマニュアルや書籍には「xxxキャストは危険です」と注意書きまであり、できることなら避けたい気持ちになってしまいます。
サンプルコード
dynamic_cast演算子を例にデバッグと解析を行ってみます。サンプルコードは、基底クラスB1, B2とその派生クラスDのポインタ間で、dynamic_castを用いて5種類の型変換を行っています。
- 変換1: 派生クラスDのポインタpdを基底クラスB1のポインタpb1に変換します(アップキャストと呼びます)
- 変換2: 派生クラスDのポインタpdを基底クラスB2のポインタpb2に変換します(アップキャストですが、変換1と何かが違います)
- 変換3: 基底クラスB1のポインタpb1を派生クラスDのポインタpdに変換します(ダウンキャストと呼びます)
- 変換4: 基底クラスB2のポインタpb2を派生クラスDのポインタpdに変換します(ダウンキャストです)
- 変換5: 基底クラスB1のポインタpb1を基底クラスB2のポインタpb2に変換します(クロスキャストと呼び、継承関係のないクラス間の変換を指します)
#include <iostream>
using namespace std;
class B1 {
int int_of_B1;
public:
virtual void func_of_B1() {}
};
class B2 {
int int_of_B2;
public:
virtual void func_of_B2() {}
};
class D : public B1, public B2 {
int int_of_D;
public:
void func_of_B1() {}
void func_of_B2() {}
};
void dynamic_cast_test() {
cout << "変換1. アップキャスト(D -> B1)\n";
D d; // クラスB1, クラスB2を継承
D *pd = &d;
B1 *pb1 = dynamic_cast<B1*>(pd);
cout << " D :" << pd << " -> "
<< " B1:" << pb1 << endl;
cout << "変換2. アップキャスト(D -> B2)\n";
B2 *pb2 = dynamic_cast<B2*>(pd);
cout << " D :" << pd << " -> "
<< " B2:" << pb2 << endl;
cout << "変換3. ダウンキャスト(B1 -> D)\n";
B1 b1;
pb1 = &b1;
pd = dynamic_cast<D*>(pb1);
cout << " B1:" << pb1 << " -> "
<< " D :" << pd << endl;
cout << "変換4. ダウンキャスト(B2 -> D)\n";
B2 b2;
pb2 = &b2;
pd = dynamic_cast<D*>(pb2);
cout << " B2:" << pb2 << " -> "
<< " D :" << pd << endl;
cout << "変換5. クロスキャスト(B1 -> B2)\n";
pb2 = dynamic_cast<B2*>(pb1);
cout << " B1:" << pb1 << " -> "
<< " B2:" << pb2 << endl;
}
int main() {
dynamic_cast_test();
cin.ignore();
return 0;
}
早速サンプルコードの実行結果です:
変換1. アップキャスト(D -> B1)
0000000AB8BEF9A8 -> 0000000AB8BEF9A8
変換2. アップキャスト(D -> B2)
0000000AB8BEF9A8 -> 0000000AB8BEF9B8
変換3. ダウンキャスト(B1 -> D)
0000000AB8BEFA48 -> 0000000000000000
変換4. ダウンキャスト(B2 -> D)
0000000AB8BEFA78 -> 0000000000000000
変換5. クロスキャスト(B1 -> B2)
0000000AB8BEFA48 -> 0000000000000000
以下2点に着目しながらデバッグを進めて行きます。
- 変換1はキャスト前後でアドレスが変わりません、変換2はキャストによりアドレスが16バイト(0000000AB8BEF9B8 – 0000000AB8BEF9A8)ずらされています。なぜでしょうか。
- 変換3, 変換4, 変換5はキャスト結果、アドレスがNULL(0000000000000000)になっています。これまたなぜでしょうか。
検証環境とツール
- OS: Windows 10
- Visual Studio: Microsoft Visual Studio Community 2019
- Platformはx64を指定(64bitプラットフォーム向け)。
Visual Studioのデバッグ方法
Visual Studioを用いたC++コードのデバッグ方法
ブレークポイントの設定
図のように、プロジェクトを開き、コードウィンドウで5回のdynamic_cast関数呼び出し直後に、ブレークポイントを設定します。
注意:画面のメニュー部でPlatformが「x64」に指定されていることをご確認ください。64bitプラットフォームで、アドレス(ポインタ)のサイズは8バイトとなります。
デバッグ開始
F5ボタンを押しデバッグ開始します。図では解析に必要なウィンドウをいくつか開いています。
デバッグ画面を構成した各ウィンドウの機能と見方をご紹介します。ウィンドウの順序や位置は好みに合わせて調整できます。
- コード:ソースコードにブレークポイントを設定できます。
- ウォッチ:一時停止中のブレークポイントにおける変数の値が表示されます。クラスのオブジェクトの場合、メンバー変数とメンバー関数のメモリ上の並びや値を確認できます。
- 逆アセンブル:コンパイラによって作成された命令に対応するアセンブリコードと該当するソースコードが表示されます。
- レジスタ:デバッグ中のレジスタの内容が表示されます。
- メモリ:プロセスで使用されているメモリ空間が表示されます。
モヤモヤ解消作戦
変換1のデバッグと解析
コードウィンドウ
変換1のdynamic_cast演算子を実行した後のブレークポイントで一時停止している状態です。変換前のpd及び変換後のpb1が、メモリ領域のどこを指しているかが気になるところです。
ウォッチウィンドウ
まず、変換前のpdが指す派生クラスDのメモリ領域を展開し確認します。すると、仮想関数テーブルを指すポインタやメンバー変数が順を追って並んでいます。
1. 基底クラスB1の仮想関数テーブルを指すポインタ__vfptr(8バイト)
2. 基底クラスB1のメンバー変数int_of_B1(4バイト)
3. 基底クラスB2の仮想関数テーブルを指すポインタ__vfptr(8バイト)
4. 基底クラスB2のメンバー変数int_of_B2(4バイト)
5. 派生クラスDのメンバー変数int_of_D(4バイト)
つぎに、変換後のpb1が指す基底クラスB1のメモリ領域を展開し確認します。
1. 基底クラスB1の仮想関数テーブルを指すポインタ__vfptr(8バイト)
2. 基底クラスB1のメンバー変数int_of_B1(4バイト)
dynamic_castによるアップキャストに成功し、変換前のpdと変換後のpb1は同じメモリ領域を指しています。
逆アセンブルウィンドウ
逆アセンブルウィンドウでも、コードウィンドウと同じブレークポイントで止まっています。
変換1で、dynamic_cast関数の逆アセンブルコードは二つのmov命令で構成されています。pdの値をRAXレジスタを経由し、値を変えずpb1に付与しているようです。
mov rax,qword ptr [pd]
mov qword ptr [pb1],rax
レジスタウィンドウ
ブレークポイントで各種レジスタの値を確認できます。RAXレジスタの値を確認すると、変換後のpb1と一致します。
メモリウィンドウ
「アドレス」フィールドにpdを指定すると、pdが指すメモリアドレスから始まるメモリ内容が表示されます。ウォッチウィンドウで確認したpdのメモリ領域と同じ内容となっています。
変換2のデバッグと解析
コードウィンドウ
デバッグを続行(F5)し、変換2(派生クラスDのポインタから基底クラスB2のポインタにアップキャスト)のdynamic_cast実行後のブレークポイントで実行を一時停止します。
変換1と比べると、変換先が基底クラスB1から基底クラスB2に変わっただけです。結果はどうなるでしょうか。ここでも、変換前のpdと、変換後のpb2にフォーカスを当てます。
ウォッチウィンドウ
まず目に入るのは、pdとpb2の値が異なる点です。何があったでしょうか。
pb2とpdの差分を計算すると、16バイトです(0x0000000ab8bef9b8 – 0x0000000ab8bef9a8)。
pdが指す派生クラスDのメモリ領域を展開し確認します。派生クラスDは二つの基底クラスB1,B2を継承しているので、クラスDのメモリ領域には、まず基底クラスB1のデータ、つぎに基底クラスB2のデータが含まれます。
1. 基底クラスB1の仮想関数テーブルを指すポインタ__vfptr(8バイト)
2. 基底クラスB1のメンバー変数int_of_B1(4バイト)
3. 基底クラスB2の仮想関数テーブルを指すポインタ__vfptr(8バイト)
4. 基底クラスB2のメンバー変数int_of_B2(4バイト)
5. 派生クラスDのメンバー変数int_of_D(4バイト)
上記3.から始まるB2の領域はDの先頭から16バイト離れています。変換2は基底クラスB2へのポインタpb2を返すため、派生クラスD領域の先頭を指すpdを16バイトずらし、B2の先頭を指すように調整していることがうかがえます。
待ってください、上記1.の8バイトと2.の4バイトを足すと12バイトです、16バイトではなく、と疑問の声が聞こえてきます。これはアラインメントによりメモリ内のオブジェクトのアドレスがサイズに合わせて調整されるためです。
逆アセンブルウィンドウ
逆アセンブルコードからも、ポインタpdを16バイトずらしている様子を確認できます。
RAXレジスタを経由し、add命令でpdの値(0x0000000ab8bef9a8)に16バイト(10h)を足したアドレスをRAXレジスタにセットしています(0x0000000ab8bef9b8)。
mov rax,qword ptr [pd]
add rax,10h
レジスタウィンドウ
RAXレジスタの値が0000000AB8BEF9B8となっています、ウォッチウィンドウのpb2の値と一致します。
メモリウィンドウ
「アドレス」フィールドにpdを指定すると、pdが指すメモリアドレスから始まるメモリ内容が表示されます。先頭から16バイト離れたアドレス0x0000000AB8BEF9B8(変換2の結果のpb2)の領域を確認します。
アドレス0x0000000AB8BEF9B8の領域に値00007ff61639bd88が格納されています。この値は、ウォッチウィンドウで確認したクラスB2の仮想関数テーブルを指すポインタ__vfptrと一致します、ここは基底クラスB2の領域で間違いなさそうです。
変換3のデバッグと解析
コードウィンドウ
さらにデバッグを続行(F5)し、変換3(基底クラスB1のポインタから派生クラスDのポインタにダウンキャスト)のdynamic_cast実行後のブレークポイントで実行を一時停止します。
ウォッチウィンドウ
マニュアルなどで「dynamic_castは実行時に型チェックしてくれるので安全にダウンキャストできる」との説明を見かけますが、果たして結果はどうなるでしょうか。
おっと、変換後のpdがNULL(0x0000000000000000)になっています。dynamic_castが実行時型情報(RTTI: Run-Time Type Information)による型チェックに失敗したようです。なぜ失敗したか気になります。
pb1が指す基底クラスB1のメモリ領域を展開すると、クラスB1の仮想関数テーブルを指すポインタ__vfptrとメンバー変数int_of_B1のみ存在します。それもそのはず、基底クラスB1の領域に派生クラスDのメンバー変数int_of_Dの領域なんか含まれないからです。
逆アセンブルウィンドウ
dynamic_cast 演算子のランタイム実装__RTDynamicCast がコールされることを確認できます。
docs.microsoft.comから __RTDynamicCast の構文を確認します:
PVOID __RTDynamicCast (
PVOID inptr,
LONG VfDelta,
PVOID SrcType,
PVOID TargetType,
BOOL isReference
) throw(...)
パラメーター
inptr
ポリモーフィック型オブジェクトへのポインター。
VfDelta
オブジェクト内の仮想関数ポインターのオフセット。
Desttype
inptr パラメーターでポイントするオブジェクトのスタティック型。
TargetType
キャストの意図した結果。
isReference
true 入力が参照である場合は。false 入力がポインターである場合は。
戻り値
成功した場合は、適切なサブオブジェクトへのポインター。それ以外の場合はNULL。
pb1の値がRCXレジスタ経由で__RTDynamicCast関数に渡され、__RTDynamicCast関数の戻り値はRAXレジスタ経由でpdに代入されています。
mov rcx,qword ptr [pb1]
call __RTDynamicCast
mov qword ptr [pd],rax
レジスタウィンドウ
RAXレジスタの値がNULL(0000000000000000)になっています。上記__RTDynamicCastのマニュアルから、__RTDynamicCastの戻り値NULLは、キャスト失敗を意味します。
メモリウィンドウ
「アドレス」フィールドにpd1を指定すると、pd1が指すメモリアドレスから始まるメモリ内容が表示されます。確かに、基底クラスB1のデータのみ存在し、その派生クラスDのデータは見当たりません。従いまして、基底クラスB1のポインタから派生クラスDのポインタへのdynamic_castは失敗して正解です。
仮に、無理やり基底クラスB1のポインタpb1を派生クラスDのポインタpdに変換させると、何が起きるでしょうか(reinterpret_cast演算子を使って強制変換できます)。pdを使ってクラスDのメンバー変数int_of_Dにアクセスした途端、Access Violation 例外が発生するかもしれません。なぜなら、pdが指すメモリ領域の実態はクラスB1であり、そこにはクラスDのメンバー変数int_of_Dの領域など存在せず、よその領域を不正アクセスする羽目になるからです。
変換4、変換5 について
ご興味のある方は、同じ要領でデバッガーを用いて解析してみてください。机上ではなく実際やってみると、新しい景色が見えたり発見があったりし、わくわくすること間違いないです。
まとめ
Visual Studioのデバッグ機能を駆使し、ブラックボックス化されたキャストの内部処理をのぞき込むことができました。キャストの演算子や操作(アップキャスト/ダウンキャストなど)による内部実装と挙動の違いも見えてきました。これで、少しは気持ちよくキャストを使えるようになっていただけると幸いです。
参考文献
- Microsoftマニュアル
- IBM Knowledge Center
- en.cppreference.com
- Scott Meyers『Effective C++: 55 Specific Ways to Improve Your Programs and Designs』(Addison-Wesley Professional,2005)