この記事を書くためのインスピレーションは、x86アーキテクチャに関する同様の出版物を読んだ後に得られました[1]。
この資料は、プログラムが内部からどのように配置されているか、メインに入る前に何が起こっているのか、なぜこれがすべて行われているのかを理解したい人に役立ちます。 また、glibcライブラリの機能のいくつかを使用する方法も示します。 そして最終的に、元の記事[1]のように、移動した経路が視覚的に表示されます。 ほとんどの場合、この記事はglibcライブラリの解析です。
それでは、旅を始めましょう。 Linux x86-64、およびlldbをデバッグツールとして使用します。 また、時々objdumpを使用してプログラムを逆アセンブルします。
ソースコードは通常のHello、world( hello.cpp )です。
#include <iostream> int main() { std::cout << "Hello, world!" << std::endl; }
* Clang -- 4.0.1 * lldb -- 4.0.1 * glibc -- 2.25 * `uname -r` -- 4.12.10-1-ARCH
コードをコンパイルしてデバッグを開始します。
clang++ -stdlib=libc++ hello1.cpp -g -o hello1.out lldb hello1.out
プログラムで考慮されるコードのほとんどは、選択されたコンパイラとc ++ライブラリからほとんど独立しています。 たまたまllvmインフラストラクチャがgccよりも私に少し近づいているので、libc ++ライブラリを備えたclangコンパイラが検討されますが、問題のコードのほとんどがglibcライブラリから解析されるため、大きな違いはありません。
bash (だけでなく)を使用する場合のプログラムは、 fork関数を呼び出し、 execveを使用してコマンドライン引数を渡して新しいプロセスを作成することによって生成されます。 また、実行可能ファイルの最初の命令に制御を移す前に、入力および出力記述子(STDIN、STDOUT、STDERR)が設定されます。その後、動的リンクの場合、プログラムに必要なライブラリがロードおよび初期化され、「. preinit_array 」セクションの関数が呼び出されます このすべての後、最初の関数が呼び出されます。これは実行可能ファイル(「 .preinit_array 」セクションをカウントしない)にあり、従来はプログラムの開始と見なされる_startと呼ばれます。 静的リンクの場合、たとえば、「. preinit_array 」セクションの初期化など、リンカーの作業は実行可能ファイル内にあり、関数自体は動的にリンクされたプログラムとは少し異なります。 動的にリンクされたプログラムを検討します。
実行可能ファイルのエントリポイントは、ヘッダーに示されています。
readelf -h hello1.out | grep Entry
次に、 objdump -d hello1.out
を使用して、このアドレスにある関数を確認します。 これは既に説明した_start関数であり、ブレークポイントを設定してデバッグを開始します。
b _start r
ウィキペディアの定義:
ABI(アプリケーションバイナリインターフェイス)-互換性のあるABIを搭載したマシン間で実行可能コードを移植できるように設計された、オペレーティングシステムおよびその他の低レベルサービスにアプリケーションにアクセスするための一連の契約 ソースコードレベルで互換性を規制するAPIとは異なります。 ABIは、バイナリインターフェイスを定義しながら、すべてのコードを再コンパイルせずに、コンパイルされたコンポーネントモジュールをリンカーが結合できるようにする一連のルールと考えることができます。
ABIレベルはc / c ++プログラマーには隠されており、このレベルのすべての作業はコンパイラーと標準libcライブラリーによって実装されます。 私の場合、 clangコンパイラとglibcライブラリはすべてのABIルールに従います。 Linux x86-64のABIルールは、 System V AMD64 ABI [2]で指定されています。 Solaris、Linux、FreeBSD、OS Xは、このドキュメントの規則に従います。 マイクロソフトには独自のABIがあり、それらは慎重に隠されています。 このドキュメントの最初の章[2]では、このアーキテクチャは32ビットプロセッサ[3]のABIルールにも準拠していると述べています。 したがって、これらはglibcなどの低レベルライブラリの開発者が依存する2つの基本的なドキュメントです。
ABIによると、プログラムの開始時に、以下を除くすべてのレジスタが定義されていません。
- %rdx:プログラムが終了する前に呼び出す必要がある関数へのポインター。
- %rsp:スタックは16バイト境界に整列され、引数の数、引数自体、および環境が含まれます。
0(%rsp)argc
8(%rsp)argv [0]
...
8 argc(%rsp)NULL
8 (argc + 1)(%rsp)envp [0]
...
8 *(argc + k + 1)(%rsp)envp [k]
ヌル
補助ベクトル
...
ヌル
ヌル
補助ベクトル(補助ベクトル)には、現在のマシンに関する情報が含まれています。 LD_SHOW_AUXV=1 ./hello1.out
を使用して、それらの値を表示できます。 得られた値は、[4]でかなり詳しく説明されています。
x `$rsp` -s8 -fu -c1
プログラム引数の数
p *(char**)($rsp+8)
はプログラムの名前です。 スタックの次は、プログラム引数、ゼロ区切り文字、環境引数、および補助ベクトルです。
さらに、フラグレジスタが設定され、SSEとx87が構成されます(§3.4.1[2])。
メインユーザー関数の引数の準備がほぼ整っていることに気付くかもしれませんが、残っているのは正しいポインターを設定することだけです。 ただし、ポインタを設定する以外に、メイン手順を入力する前にまだ多くの作業が必要です。 将来的には、説明内の関数には、ソースの場所と、ツールチップの形式のバイナリ形式の関数自体が付随します。例: main
_start関数を見てみましょう。これは小さく、その主なタスクは__libc_start_main関数に制御を移すことです。
di
を使用して現在の関数を逆アセンブルします(明確にするために、出力はここと以下でフォーマットされます)。
_start: xor %ebp, %ebp mov %rdx, %r9 pop %rsi mov %rsp, %rdx and $-0x10, %rsp push %rax push %rsp lea 0x1aa(%rip), %r8 ; __libc_csu_fini lea 0x133(%rip), %rcx ; __libc_csu_init lea 0xec(%rip), %rdi ; main call *0x200796(%rip) ; __libc_start_main hlt
_start関数は 、リンカーによってオブジェクトファイルScrt1.oとしてプログラムに接続されます。 同様の機能を実行するオブジェクトファイルcrt1(gcrt1、Srct1、Mcrt1)にはいくつかの種類がありますが、さまざまな場合に使用されます。 たとえば、 Scrt1.oは PICコードの生成に使用されます[5]。 オブジェクトファイルの選択を確認するには、 -v
スイッチを使用してプログラムをコンパイルします。 これらの関数のオフセットはリンク段階でのみ認識されるため 、オブジェクトオフセット__libc_csu_fini 、 __libc_csu_init、およびmainは示されていないことに注意してください。
ABI要件に従って、 フレームを初期フレームとしてマークするには、 %ebpをゼロに設定する必要があります。これは、 xor%ebp、%ebpが正確に行うことです。
次は、 __ libc_start_main関数を呼び出すための準備です。 この関数の署名は次の形式です。
int __libc_start_main(int (*main) (int, char **, char **), int argc, char **argv, __typeof (main) init, void (*fini) (void), void (*rtld_fini) (void), void *stack_end)
ABIによると、関数の引数は適切な場所に配置する必要があります。
引数 | 関数呼び出しの位置 | 説明 |
---|---|---|
メイン | %rdi | プログラムの主な機能 |
argc | %rsi | プログラム引数の数 |
argv | %rdx | 引数の配列。 引数が環境変数であり、その後が補助ベクトルである後 |
初期化 | %rcx | mainの前に呼び出されるグローバルオブジェクトコンストラクター。 この関数のタイプは、メイン関数と同じです。 |
フィニ | %r8 | メインの後に呼び出されるグローバルオブジェクトデストラクタ |
rtld_fini | %r9 | 動的リンカーのデストラクタ。 動的に割り当てられたライブラリを解放します |
stack_end | %rsp | 整列されたスタックの現在の位置 |
ABIでは、関数が呼び出されたときに、スタックが16バイト(引数のタイプに応じて32、場合によっては64)境界に整列される必要があります。 要求は、 and $ -0x10、%rsp (?) 命令の実行後に満たされます。 このアライメントの意味は、SIMD命令(SSE、MMX)はアライメントされたデータでのみ動作し、スカラー命令はアライメントされたデータでより速く読み書きされることです。
16バイトのアライメントを保持するには、 __libc_start_mainを呼び出す前に、%raxレジスターがスタックに配置され、未定義の値が格納されます。 このスタックセルは読み取られません。
プログラムはlibc_start_main関数から返されるべきではなく、不正な動作を示すためにhlt命令が使用されます。 この命令の特徴は、保護されたプロセッサモードでは保護リング0でのみ実行できることです。つまり、オペレーティングシステムのみがこの命令を呼び出すことができます。 リング3にあります。つまり、プログラムに権限がないコマンドを実行しようとすると、セグメンテーションエラーが発生します。
hlt命令の後には、 nopl 0x0命令(%rax、%rax、1)もあります 。これは、次の関数を16バイト境界に揃えるために必要です。 ABIはこれを必要としませんが、コンパイラーは関数の先頭を揃えてパフォーマンスを改善します( 1、2 )。
それでは先に進みましょう
b __libc_start_main c
__libc_start_main関数のソースコードは、静的および動的にリンクされたライブラリに対して異なるコードが生成されることを示しています。 gdbまたはlldbを使用して、 libc.so.6ライブラリで関数コードがどのように見えるかを確認できます。
lldb libc.so.6 -b -o 'di -n __libc_start_main'
glibcライブラリコードには、__ glibc_likelyおよび__glibc_unlikelyの多くのオカレンスが含まれています。 多数の条件付き操作がこのマクロに置き換えられています。 マクロは、最終的に次の組み込み関数に変換されます。
# define __glibc_unlikely(cond) __builtin_expect ((cond), 0) # define __glibc_likely(cond) __builtin_expect ((cond), 1)
__builtin_expectは、コンパイラがメモリ内のコードのセクションを正しく配置するのに役立つ一種の最適化です。 どのブランチが実行される可能性が最も高いかをコンパイラーに通知し、コンパイラーはこのメモリー領域を比較命令の直後に配置することで、命令のキャッシュ可能性を向上させ、コンパイラーは関数の最後に残りのブランチがあればそれを隠します。
__libc_start_main関数は少し面倒で、主なアクションを簡単に説明します。
- rtld_finiを__cxa_atexitに登録します
- __libc_csu_initを呼び出す
- キャンセルポイントを作成
- メイン
- 出る
__cxa_atexit
__cxa_atexit関数は、最初のラッパーのラッパーであるatexitとは異なり、登録済み関数のパラメーターを取ることができますが、ユーザー空間から直接呼び出すことはできません。 関数はDSO識別子を使用するため、呼び出さないでください。DSO識別子はコンパイラのみが知っています。 __cxa_atexit(f、p、d)が呼び出されると、DSO dがアンロードされるときにf(p)関数が呼び出されるようにする必要があります[8]。
__cxa_atexitの使用例:
#include <cstdio> extern "C" int __cxa_atexit (void (*func) (void *), void *arg, void *d); extern void* __dso_handle; void printArg(void *a) { int arg = *static_cast<int*>(a); printf("%d\n",arg); delete (int*)a; } int main() { int *k = new int(17); __cxa_atexit(printArg, k, __dso_handle); }
このトリックは、爽快感のためにのみ使用することをお勧めします。 プログラムを終了するときに、同様の方法を使用してデストラクタを呼び出す方が安全です。
rtld_finiは、リンカ関数_dl_finiへのポインタです 。 はい、リンカーはglibcライブラリの一部です。 _dl_fini関数は、ロードされたすべてのライブラリーの初期化を解除してアンロードします。
__libc_csu_init
前のものと同じ方法で__libc_csu_init関数にアクセスできます。 __libc_csu_initは、 _initおよび.init_arrayセクションにある関数ポインターを呼び出します。
_init
_init関数は、すべて.initセクションにあります。 そのコードは2つの部分に分かれています: 紹介とエピローグ 。 概要は、プロローグと__gmon_start__関数の呼び出しの試行で構成されます。
_init subq $0x8, %rsp leaq 0x105(%rip), %rax ; __gmon_start__ testq %rax, %rax je 0x5555555548a2 ; je to addq instruction callq *%rax addq $0x8, %rsp retq
_init関数の主な目的は、 gprofプロファイラーを初期化することです。 命令「 leaq 0x105(%rip), %rax
」は、関数__gmon_start__-プロファイラーを初期化する関数のアドレスを取ります。 プロファイラーが存在しない場合、%raxには値0が含まれ、遷移jeは機能します。 subq $ 0x8、%rspおよびaddq $ 0x8、%rsp命令は、スタックを整列し、元の状態に戻します。 このアライメントが必要なのは、関数を呼び出すときに、x86-64アーキテクチャでのサイズが8バイトの戻りアドレスをスタックに配置するためです。
コードの独自のセクションを.initセクションに追加できます。 hello2.cppの例を考えてみましょう。
#include <cstdio> extern "C" void my_init() { puts("Hello from init"); } __asm__( ".section .init\n" "call my_init" ); int main(){}
_initが次のようになっていることを考慮してください。
subq $0x8, %rsp movq 0x200835(%rip), %rax testq %rax, %rax je 0x5555555547ba callq *%rax callq 0x555555554990 ; ::my_init() addq $0x8, %rsp retq
リストからcallq 0x555555554990
ように、 callq 0x555555554990
命令callq 0x555555554990
エントリと関数のエピローグの間callq 0x555555554990
追加され、 my_initを呼び出すだけです。 _init関数は、プログラムの一部の独自の初期化を簡単に追加できるように実装されているようです。
興味深い事実 :気配りのある読者は、hello2.cppの出力がputs関数を介して出力されていることに気付いているはずです。 cout
を介して出力する場合、 libstdc ++ライブラリでコンパイルすると、セグメンテーションエラーが発生し、 libc ++ライブラリを使用すると、メッセージが正常に表示されます。 これは何のために起こっているのですか? 実際、 libstdc ++では、 cout
通常のグローバルオブジェクトとして初期化され、グローバルオブジェクトは少し後に初期化されます。 libc ++の場合、 ld-linux-x86-64.so.2ライブラリから_dl_init関数のライブラリをロードするときに初期化が行われます 。 この関数は、 _start関数に制御を渡す直前に、 _dl_start_userから呼び出されます。
各方法には長所と短所があります。 libc ++ライブラリが接続されている場合、 cout
ような標準のc ++出力ツールcout
使用されcout
いなくても、コンストラクタはとにかく呼び出されます。 libstdc ++ライブラリの場合、最適化フラグを有効にしても、 iostreamヘッダーファイルが接続されている回数だけコンストラクターが呼び出されます。 当然、コンストラクターは、複数回呼び出すことができ、再初期化がスキップされるという事実を考慮します。 もちろん、これはプログラムの初期化をそれほど遅くしませんが、それでも不快です。 この理由から、多くの高性能プロジェクトは、iostreamヘッダーファイルの使用を使用せず、推奨せず、さらには禁止し、その結果、入出力用の独自のインターフェイスを作成します。
.init_array
次に、ポインターが.init_arrayセクションにある関数が呼び出されます。
セクションの内容を確認します。
objdump hello1.out -s -j .init_array
私の場合、 .init_arrayの内容は次の意味を持っています: a00f0000 00000000
、これはリトルエンディアンのバイト順の 64ビットシステム上のアドレス0x0fa0を意味します。 このアドレスにはframe_dummy関数があります。
frame_dummy
興味深いことに、 frame_dummyはgccライブラリの一部です。
gccプロジェクトは非常に大きく、すでにLinuxオペレーティングシステムに根を張っていることを忘れないでください。 gccプロジェクトには、コンパイラだけでなく、コンパイルに必要なファイルも含まれています。 したがって、リンクはcrtbeginS.oやcrtendS.oなどのcrtファイルを使用します。
したがって、gccプロジェクトを完全に削除することはできません。少なくとも、補助crtファイルを残す必要があります。 メインのようにgccコンパイラを使用しないUnixオペレーティングシステム。
frame_dummyは次のようになります。
pushq %rbp movq %rsp, %rbp popq %rbp jmp 0x555555554cc0 ; register_tm_clones nopw (%rax,%rax)
frame_dummyのタスクは、引数を設定し、 register_tm_clones関数を実行することです。 この層は、引数を公開するためにのみ必要です。 この場合、引数は設定されていませんが、ソースコードからわかるように、アーキテクチャによっては常にそうとは限りません。 興味深いことに、最初の2つの指示はプロローグであり、3番目の指示はエピローグです。 jmp命令は、関数呼び出しのテール最適化です。 そして、いつものように、アライメントの最後に。
register_tm_clones関数は、 トランザクション メモリをアクティブにするために必要です。
グローバルオブジェクトの初期化
グローバルオブジェクトがある場合、ここで初期化されます。
グローバルオブジェクトがある場合、関数アドレス_GLOBAL__sub_I_< >
.init_arrayセクションに追加_GLOBAL__sub_I_< >
。
グローバル変数を初期化する例を考えてみましょう。
global1.cpp :
int k = printf("Hello from .init_array");
変数は次のように初期化されます。
push %rbp mov %rsp, %rbp lea 0xf59(%rip), %rdi ; + 4 mov $0x0, %al call 0x555555554e80 ; symbol stub for: printf mov %eax, 0x202130(%rip) ; k pop %rbp ret
最初の2つの指示はプロローグです。 次に、 %rdi
行にポインターを置き、 %al
をゼロに設定して、 printf関数を呼び出す準備をします。 ABI [2]によると、可変数の引数を持つ関数には、ベクトルレジスターに含まれる可変引数の数を意味する%al
に格納された隠しパラメーターが含まれています。 ほとんどの場合、いくつかの関数を最適化する必要がありますが、 printfはベクトルレジスタからスタックにデータを移動するためにこの情報を使用します。
printfを呼び出した後、関数の結果が変数kのメモリ領域に配置され、エピローグが呼び出されます。
global2.cpp :
デフォルトではないコンストラクタとデストラクタを持つ特定のクラスGlobal
があるとしましょう:
Global g;
次に、初期化は次のようになります。
push %rbp mov %rsp, %rbp sub $0x10, %rsp lea 0x202175(%rip), %rdi ; g call 0x5555555550e0 ; Global::Global() lea 0x1c5(%rip), %rdi ; Global::~Global() lea 0x202162(%rip), %rsi ; g lea 0x202147(%rip), %rdx ; __dso_handle call 0x555555554f10 ; symbol stub for: __cxa_atexit mov %eax, -0x4(%rbp) add $0x10, %rsp pop %rbp ret
ここでは、グローバルコンストラクターを呼び出した後、デストラクタが__cxa_atexitを使用して登録される方法を確認します。 Itanium ABI [8]に従って実装されています。
関数呼び出しの初期化
glibcから、初期化は次のように呼び出されます: (*__init_array_start [i]) (argc, argv, envp);
mainに似たパラメーターが初期化関数に渡されるため、使用できることに注意してください。 gccおよびclangコンパイラにはconstructor
属性があり、オブジェクトの初期化ステップの前に関数が呼び出されます。
これらの引数をそれに渡すことができます。 次のグローバル関数を使用して、プログラムの出力を確認します。
void __attribute__((constructor)) hello(int argc, char **argv, char **env) { printf("#args = %d\n", argc); printf("filename = %s\n", argv[0]); }
これは、より実用的な目的に使用できます(hello3.cpp):
#include <cstdio> class C { public: C(int i) { printf("Program has %d argument(s)\n", i); } }; int constructorArg; const C c(constructorArg); void __attribute__((constructor (65535))) hello(int argc, char ** argv, char **env){ constructorArg = argc; } int main(){}
constructor
属性パラメーターは、呼び出しの優先順位を示します。
おそらく既に推測したように、プログラムは正しい数の引数を出力します。最も興味深いことに、 c
オブジェクトは定数です。 このアプローチの主な欠点は、標準のサポートの欠如であり、結果として、クロスプラットフォームの欠如です。 また、このようなコードは、使用されるlibcライブラリに大きく依存しています。
int x = 1 + 2 * 3;
という形式のグローバル変数を追加したいと思いint x = 1 + 2 * 3;
まったく初期化されず、それらの値は最初にコンパイラによってメモリに書き込まれます。 int s = sum(4, 5)
などの単純な関数によって初期化される変数も初期化する場合は、C ++ 11標準の識別子constexprをsum
関数に追加します。
キャンセルポイントを作成
キャンセルポイントは、 setjmpを呼び出してグローバル変数を設定することにより作成されます。
メインストリームがキャンセルされたときに、 元に戻すことができるように、元に戻すバッファを設定するには、 setjmpコンテキストを保存する必要があります。
ファイルcancel.cpp
#include <pthread.h> pthread_t g_thr = pthread_self(); void * thread_start(void *) { pthread_cancel(g_thr); return 0; } int main() { pthread_t thr; pthread_create(&thr, NULL, thread_start, NULL); pthread_detach(thr); while (1) { pthread_testcancel(); } }
cancel.cpp , , , exit . , , , , .
, , setjmp :
br set -n __libc_start_main -R 162
: , — .
setjmp __GI__setjmp . , . [7]. , , PLT .
main
.
std::cout << "Hello, world!" << std::endl;
, :
operator<<(std::cout, "Hello, World!").operator<<(std::endl);
または
operator<<(std::cout, "Hello, World!"); std::cout.operator<<(std::endl);
endl
libc++, libstdc++ : ostream& endl(ostream&);
ostream
, <<
, visitor .
. IFUNC-, __strlen_avx2 _strlen_sse2 . strlen .
stdout _IO_file_doallocate malloc , 1 . , setvbuf .
stdout , . flush
, stdout
.
, flush
, fwrite , __libc_write , syscall ( , ):
ssize_t __libc_write (int fd, const void *buf, size_t nbytes) { return ({ unsigned long int resultvar = ({ unsigned long int resultvar; long int __arg3 = (long int) (nbytes); long int __arg2 = (long int) (buf); long int __arg1 = (long int) (fd); register long int _a3 asm ("rdx") = __arg3; register long int _a2 asm ("rsi") = __arg2; register long int _a1 asm ("rdi") = __arg1; asm volatile ( "syscall\n\t" : "=a" (resultvar) : "0" (1) , "r" (_a1), "r" (_a2), "r" (_a3) : "memory", "cc", "r11", "cx"); (long int) resultvar; }); resultvar; }); }
statement expressions , gcc:
int l = ({int b = 4; int c = 8; c += b});
, c += b
l == 12
.
__libc_write ( __GI___libc_write , _setjmp ) syscall , syscall , C . rax
. =a
, rax , "0" (1)
, rax
1 ( sys_write ).
, , sys_write , .
, ABI [2], . : %rdi, %rsi, %rdx, %r10, %r8, %r9.
exit
exit :
- __call_tls_dtors — thread local storage , .
- , atexit
- _dl_fini — ,
_start
r9
, . - ( ).
- _dl_fini — ,
- __libc_atexit
- _IO_cleanup — .
- _exit — .
_exit 231 ( sys_exit_group ), %rdi . .
Linux sys_exit . , , sys_exit_group . , , , sys_exit , [6].
, , "Hello, World!!!", C/C++, glibc . : , , setjmp, atexit...
, dot
[1] — http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html
[2] — https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-r252.pdf
[3] — https://github.com/hjl-tools/x86-psABI/wiki/intel386-psABI-1.1.pdf
[4] — https://habrahabr.ru/post/128111/
[5] — https://dev.gentoo.org/~vapier/crt.txt
[6] — http://syprog.blogspot.ru/2012/03/linux-threads-through-magnifier-local.html
[7] — https://sourceware.org/glibc/wiki/Style_and_Conventions#Double-underscore_names_for_public_API_functions
[8] — https://itanium-cxx-abi.github.io/cxx-abi/abi.html#dso-dtor