原書で学ぶ64bitアセンブラ入門(8)
http://connpass.com/event/6463/原書で学ぶ64bitアセンブラ入門(6) - connpass での内容です。 この会も6回を数え、全19章の内14章まで終わってだいぶ佳境に入ってきた感じです。
今回は12章から14章までを読んでいきましたので、まずは12章をば。
chapter12 System calls
12章はシステムコールについての話です。
システムコールとは、大雑把に言ってしまえばカーネルのみが実行できる関数といった感じで、通常のユーザーモードのプログラムが好きなように実行出来る様にするには危険であり、そのような命令をカーネルに任せる為のものです。
システムコールが発生すると、CPUのオペレーションモードがユーザーモードからカーネルモードへ変わり、諸々の処理が実行できるようになります。尚、Linuxのシステムコールインターフェースは32ビットモードと64ビットモードで異なります。
32bit system calls
32bitシステムコールの定義は/usr/include/asm/unistd_32.h
で定義されています。このファイル内で定義されているシステムコール番号をeaxに入れ、ソフトウェア割り込み命令を実行するint 0x80
事でシステムコールを実行します。システムコールへのパラメーターはebx
ecs
edx
esi
edi
ebp
のレジスタを使って渡され、返値はeax
に設定されます。
64bit system call
64bitシステムコールの定義は/usr/include/asm/unistd_64.h
で定義されています。パラメーターはrdi
rsi
rdx
r10
r8
r9
を通じて渡され、返値はrax
に渡されます。これはCの関数コールで使われたレジスタと、r10
の箇所がrcx
であった事を除いて変わりありません。また、32bitではint 0x80
を使っていた代わりにsyscall命令を使います。例として、64bit版の"Hello world"はこんな感じになります。
C wrapper functions
いずれのシステムコールもCのラッパー関数を通じて使うことができます。例としてCライブラリのwrite関数は、書き込みリクエストをするsyscall命令を使う以外はほとんど何もしません。このラッパー関数を使った方がシステムコール番号を探したりする必要が無いため、システムコールの使い方としては好まれている様です。
先ほどの"Hello world"プログラムは下記のように書き換えることが出来ます。
ここではexternを使ってwriteとexitが他の場所で定義されていることをリンカに伝えています。
open system call
ファイルの読み書きには、その対象がオープンされている必要があります。これはopenシステムコールによって為されます。
int open ( char *pathname, int flags [, int mode] );
pathname
には開く対象のファイル名、flags
にはファイルがどのように開かれるかを定義したビットパターン、もしflags
でファイルが作られるパターンである場合はmode
で新規作成されるファイルの権限割り当てを定義します。flags
の定義は以下の通り
ビット | 意味 |
---|---|
0x000 | 読み込み専用 |
0x001 | 書き込み専用 |
0x002 | 読み書き |
0x040 | 必要なら新規作成 |
0x200 | ファイルのtruncate |
0x400 | 追記 |
基本的な権限設定は読み書き実行の指定です。ファイルへの実行権限付与はプログラムやスクリプトを実際に実行することが出来るということですが、ディレクトリへの実行権限の有無はそのディレクトリをたどることが出来るかどうかに関係します。
例として、./tmp/testfile
というディレクトリとファイルがあった場合について。
$ ls -al tmp/
total 8
drwxr-xr-x 2 warabanshi users 4096 Jun 15 09:41 .
drwxr-xr-x 3 warabanshi users 4096 Jun 15 09:41 ..
-rw-r--r-- 1 warabanshi users 0 Jun 15 09:41 testfile
通常、作成したばかりならこの様にディレクトリ内を閲覧する事が出来る。ここで、tmpディレクトリの実行権限を消してみると
$ chmod 655 tmp/
$ ls -al tmp
ls: cannot access tmp/..: Permission denied
ls: cannot access tmp/testfile: Permission denied
ls: cannot access tmp/.: Permission denied
total 0
d????????? ? ? ? ? ? .
d????????? ? ? ? ? ? ..
-????????? ? ? ? ? ? testfile
この様になり、tmp/ディレクトリには読み込み権限はある為、このtmp/ディレクトリエントリにある内容(配下にあるファイル名など)は閲覧出来るがその詳細については取得できないため、?マークが並んでいる。
権限については「所有者」「グループ」「その他」に対して権限を付与できるわけですが、この辺りは今更ですしいくらでもドキュメントが転がってるので割愛。
read and write system calls
ファイルへのデータ読み書きはreadとwriteシステムコールで行われ、プロトタイプは下記のようになっています
int read ( int fd, void *data, long count );
int write ( int fd, void *data, long count );
dataはいかなる型でも問題ありませんが、countには読み書きするバイト数を指定します。エラー時は返値が-1となり、extern変数のerrnoにエラータイプを示す整数がセットされる事で示されます。また、エラーのテキスト版をperror関数の呼び出しで出力することが出来ます。
lseek system call
ファイルの読み書きをする際、特定の位置だけにアクセスできれば良いならば、取り得る方法は2つあります。ファイル全体を読み込んで対象の箇所を操作するか、lseekを使って対象箇所の始点へ移動して必要な分だけ操作するかです。無論、後者の方が速いです。lseekのプロトタイプは
long lseek ( int fd, long offset, int whence );
となっています。offsetでどの位置に移動するかを指定できますが、その基準はwhenceの値によって変わります。
whence | 基準 |
---|---|
0 | 対象fdの最初の位置からのバイト数 |
1 | 現在の位置からの相対位置 |
2 | ファイル末尾からの相対位置 |
offsetを0、whenceを2とすると、これによりファイルサイズを容易に取得することが出来ます。
close system call
ファイルをopenした後、そのまま放っておいてもプログラム終了時にはOSによって閉じられるので、そういう意味では絶対に何がなんでもcloseしなければならないということはありません。ファイルディスクリプタを使ってのデータ読み書きはバッファも使っていないので、closeを明示的にしないとデータが失われるということもありません。
closeする意義は、カーネルのオーバーヘッドを減らすことと、実行している1プロセスで開けるファイル数の限度に達するのを避けるためです。
原書で学ぶ64bitアセンブラ入門(7)
原書で学ぶ64bitアセンブラ入門(6) - わらばんし仄聞記
上のリンクに続いて、11章、浮動小数点数についての続きです。
Conversion
ある整数長から別の整数長や、ある浮動小数点数長から別の浮動小数点数長。はたまた整数長から浮動小数点数長やその逆といった、つまりはword
からquad-word
への変換やdouble-word
からdouble
への変換など、そういった変換についてです。
整数長から整数長への変換は今までに出てきたmov命令を使って成される為、ここでは浮動小数点数を絡めた内容について扱います。
Converting to a different length floating point
ざっと挙げると以下のパターンがあります
- 1つの
float
からdouble
への変換 - 2つのpackされた
float
からdouble
への変換 - 1つの
double
からfloat
への変換 - 2つのpackされた
double
からfloat
への変換
packされた値の場合、doubleは2つしか入らないのでそれにつられてfloatも2つまでであることはどちらにも共通ですね。
また、これらはいずれもソースにはSSEレジスタかメモリのどちらかが使える一方、デスティネーションにはSSEレジスタしか使用できません。
命令の対応表については下記の通りです。
float -> double | double -> float | |
---|---|---|
scalar | cvtss2sd | cvtsd2ss |
packed | cvtps2pd | cvtpd2ps |
実例はこんな感じになります
cvtss2sd xmm0, [a] ; get a into xmm0 as a double
cvtsd2ss xmm0, xmm0 ; convert to float
Converting floating point to/from integer
浮動小数点数から整数への変換、またその逆についてです。
これについてはpackedでの扱いは無いようです。命令は以下の感じ。
float | double | |
---|---|---|
xmm -> int | cvtss2si | cvtsd2si |
int -> xmm | cvtsi2ss | cvtsi2sd |
表にはxmmとは書きましたが、整数から浮動小数点数への変換の場合、ソースには汎用レジスタかメモリが使えます。
実際の使い方はこんな感じになります
cvtss2si eax, xmm0 ; convert to dword integer
cvtsi2sd xmm0, rax ; convert qword to double
cvtsi2sd xmm0, dword [x] ; convert dword integer
Floating point comparison
浮動小数点数の比較についてです。
IEEE754の仕様では"Not a Number"、つまりNaNについては2つの型があります。quiet NaN(QNaN)とsignaling NaN(SNaN)です。
SNaNは発生すると常に例外を起こし、QNaNは例外を起こさずにその値が安全に伝播していきます。
さて、浮動小数点数での比較の場合、その比較がorderedであるかunorderedであるかによって挙動が変わります。orderedでの比較の場合、オペランドにQNaNかSNaNがあると例外を起こし、unorderedでの比較の場合はSNaNの場合のみ例外を起こします。表にするとこんな感じに。
ordered | unordered | |
---|---|---|
QNaN | 例外 | |
SNaN | 例外 | 例外 |
尚、gccコンパイラはunorderedを使うので、QNaNがオペランドに渡された場合でも例外を起こしません。gccも使ってるということでunorderedについて見てみると、floatを比較するにはucomiss
命令を使い、doubleを比較するにはucomisd
を使います。
これらの比較命令を使うにあたって、第一オペランドはXMMレジスタでなければなりませんが、第二オペランドはXMMレジスタかメモリが使えます。また、これらの比較をする事で各種フラグがセットされることになります。
比較後の条件ジャンプについても、整数での比較後に使っていたjge
などとは異なる命令を使うことになります。ここで使う命令は jb
jbe
ja
jae
となります。aとbはそれぞれ、aboveとbelowの意味のようです。詳細はググれば出てくるでしょうからそちらにお任せ。
Mathematical functions
SSE命令は最小と最大の計算、四捨五入、平方根の計算、逆数平方根についての浮動小数点数関数を持ちます。この節はそれらについて扱います。
Minimum and maximum
最小、最大を計算する命令についてです。一見、命令の名前からしてpackedデータとして複数持つ値の内、最小や最大を計算してくれるのかと自分は思ってしまいましたが、実際には2値の比較しかしてくれません。packedデータの扱いについては後ほど。
とりあえずスカラー用命令については下記のパターンがあります。
float | double | |
---|---|---|
最小値 | minss | maxss |
最大値 | minsd | maxsd |
これについてもデスティネーションはXMMレジスタのみで、ソースはXMMレジスタかメモリが使えるという点は相変わらずです。
packedデータについて、命令語のパターンは今までと同様の変化で使えます。scalarの意味のsであったところをpに変えるだけですね。
さて、packedの場合、packされている2要素間で、それぞれのpack値間で比較をして、それらの大きい方または小さい方の値をデスティネーションのpack値へ入れることになります。packedなfloatの最大値を取得する場合、下図のようになります。
rounding
丸めの対象としてはここまでと同様、floatかdouble、scalarかpackedかの組み合わせに拠る4通りの命令があります。
命令はそれぞれ、roundss
roundps
roundsd
roundpd
となります。
これらの命令は3つのオペランドを取り、この3つめは丸めのモード指定です。モードのパターンは下記のようになります。
mode | 丸め方法 |
---|---|
0 | 最近接遇数丸め |
1 | 正の無限大への丸め |
2 | 負の無限大への丸め |
3 | 0への丸め |
丸め方法の日本語については 端数処理 - Wikipedia を元にしました。
正直この日本語での記述を見たときは1,2の意味がわからなかったですが、これの英語版を見ると
日本語版wiki | 英語版wiki |
---|---|
正の無限大への丸め | toward +∞ |
負の無限大への丸め | toward −∞ |
となっており、それぞれ、正の無限大、負の無限大へ近づく方向へ丸めるという意図がわかります。
最近接遇数丸めは
端数が0.5より小さいなら切り捨て、端数が0.5より大きいならは切り上げ、端数がちょうど0.5なら切り捨てと切り上げのうち結果が偶数となる方へ丸める。
というもので、銀行家がよく使った丸め方でもあるそうで、「銀行家の丸め(banker's rounding)」とも呼ばれるそうです。説明の実例を挙げてみると
- 5.9 -> 6
- 5.2 -> 5
- 5.5 -> 6
- 4.5 -> 4
てな感じで、0.5の値について挙動が分かれるパターンの様です。
Square roots
square rootを求める命令があるってだけの話です。ソースとデスティネーションに使えるレジスタ、メモリの制約やscalar、packedについての変化など、今までの命令と同じです。
命令語は sqrtss
sqrtps
sqrtsd
sqrtpd
があります。
この後の節については単なる実例というかサンプルなので省略。
原書で学ぶ64bitアセンブラ入門(6)
11章、浮動小数点数について。
原書で学ぶ64bitアセンブラ入門(5) - connpassでの内容です。
chapter11 Floating point instructions
浮動小数点数の演算は、8088世代のCPUでは8087というコプロセッサ(補助プロセッサ)を使っていました。486世代になるとコプロセッサを内包するようになり、現在のCPUでもそれらの為の命令は使えるようです。
ですが、現在では基本的に、完全に分離された浮動小数点数用の機構があり、そちらを使います。その機構では16個の128bitレジスタ(Core iシリーズでは256bit)を使い、これらのレジスタはSSEレジスタ(Streaming SIMD Extensionsレジスタ。SIMD=Single Instruction - Multiple Data)と呼ばれます。
この章では主にこのレジスタを使用した命令を見ていきます
Flaoting point registers
先ほど述べたSSEレジスタはxmm0
というように、xmm
という接頭辞に数字が付いたものになります。16個存在するので、それぞれ
xmm0, xmm1, ... , xmm15
となります。core iシリーズでは256bit長のものになるとも述べましたが、それらはymm
という接頭辞になり
ymm0, ymm1, ... , ymm15
となります。また、以前に64bitレジスタ、たとえばrax
は対応する32bitレジスタeax
と下位32bitを共有しているという話がありましたが、それと同様、ymm
レジスタは対応するxmm
レジスタと下位128bitを共有しています。
加えてcore iシリーズではAVX命令(Advanced Vector Extension命令)が追加されています。
以下、基本的にxmm
のレジスタについて述べていきます。内容はbit長を2倍すればそのままymm
レジスタにも当てはまります。
さて、このxmmレジスタは、一つの値を持つか、もしくは4つのfloatか2つのdoubleという複数の値を持つ事ができます。また、複数の整数値を持つようにもできますが、それについてはこの本では触れないそうです。
Moving data to/from floating point registers
これまた先述した通り、SSEレジスタでは1つの値(scalar値)を扱うか、複数の値を持つデータ(= packed data)として扱う事ができます。これらについてのmov命令がどのようになるか見てみます。
Moving scalars
mov命令のSSE版ということで、movss
,movsd
という命令があります。それぞれfloat用とdouble用です。これらの命令では
- メモリ -> SSE
- SSE -> メモリ
- SSE -> SSE
というパターンで値を移すことができ、SSEレジスタはそれぞれで必要なbit長分の下位bitを使って当該値のやりとりをする事になります。 たとえばmovssした場合はこんな感じ
Moving packed data
さて、今度はpack値でのmov命令です。
今回は命令が4つあり、float用、double用それぞれにalignedとunaligned用の命令があります。ここで言うalignについては後述するとして、表にするとこんな感じに。
aligned | unaligned | |
---|---|---|
float | movaps | movups |
double | movapd | movupd |
命令の命名規則はmov
にalignedの場合はa
、unalingedの場合はu
。続けてpackedのp
、floatならsingleのs
でdoubleならdoubleのd
を続けたパターンになっています。
alignについてですが、これはメモリ上の位置が16バイト境界で整列されていることを意味します。つまり、mov対象の値があるメモリ上のアドレスは、16進数表記の場合に末尾が0となっているということですね。そのような配置になっていないメモリ上の値をmovaps
などすると、segmentation faultを起こします。
適切にalignするためにはalign
命令を使ってalign 16
なんてのを対象となる配列定義直前に入れるか、もしくはmalloc関数で取得したメモリは16バイト境界になっています。
実際に書いてみるとこんな感じに。
align 16
によって配列cの始まる位置が16バイト境界に整えられます。もしこれがalign 16
を入れないなら、cの位置がたまたま16バイト境界に位置しない限りはsegmentation faultを引き起こします。
こんな感じでcのアドレスが16の倍数となるよう、間を適切に空けてくれます。
こんな手間をしなくていいのがunaligned用の命令なんですが、やはりお手軽な分、処理は重いようです。しかし、この本曰くcore i世代のCPUではunalignedでもalignedと同じくらい早くなっているそうです・・・。
まぁ、極限の早さを求める人向きって事ですかね。
加減乗除
本ではそれぞれ節が分かれてますが、内容はほとんど同じなのでまとめて。
命令の命名規則はadd
sub
mul
div
それぞれに、ss
sd
ps
pd
のいずれかが接尾辞として付いた形になります。接尾辞は1文字目がscalarかpackかを表し、二文字目がsingle(=float)かdoubleを表します。addを例に挙げて表にするとこんな感じ
scalar | packed | |
---|---|---|
single | addss | addps |
double | addsd | addpd |
他のsub
mul
div
についても同様です。
レジスタとメモリについて、オペランドに使える組み合わせは以下のようになります
- SSE -> SSE
- メモリ -> SSE
デスティネーションは常にSSEレジスタである必要があります。
整数値の計算を行うとフラグレジスタに値がセットされることがありますが、浮動小数点数での計算では一切のフラグはセットされません。なので、テストをするには後で比較命令を使う必要があります。
packedでの演算は、2つのSSEレジスタ、またはSSEレジスタと同じだけのバイト長のメモリ領域にある、それぞれ対応する位置のpackされた値と演算をします。
コードで表すとこんな感じに。
movapd xmm0, [a] ; load 2 doubles from a
addpd xmm0, [b] ; add a[0] + b[0] and a[1] + b[1]
図示するとこんな感じのイメージ(コードと違ってfloat値4つの場合になってます)
後半に続きます
11章は長いので、一旦ここまで。
OSvをビルドしてみる
OSvについては下のリンクを参照。
OSv - the operating system designed for the cloud
これをビルドして動かしてみました。
コードについてはgithub上のコチラにあり、ビルド方法もこのページ下部にREADME.mdが表示されています。
ちなみに手っ取り早くOSvを触るだけ触りたいという人は、capstanというのがあるのでそちらを使った方がいいでしょう。
前提と今回試した環境
前提
さて、READMEを読んでみると、とりあえず手順の例として載っているのはFedoraとDebianです。
また、アーキテクチャはx86_64を前提としていることが書かれており、更に開発者メンバーは基本的にCore i世代のマシンを使って開発をしているそうで、現時点ではそれに合わせて実行してみるのが問題も少ない様です。
あとはビルド済んでからshellとか起動するとき、デフォルトでは2GBのメモリを想定している様です
By default, this runs OSv under KVM, with 4 VCPUs and 2GB of memory, and runs the default management application (containing a shell, Web server, and SSH server).
今回試した環境
- opensuse13.1
- Intel Core 2 Duo SP7500
- 2GB memory
見事に前提としている環境に逆らってますね。
x86_64である事と、linuxであることくらいは合ってますけど。
build
※以下には前提とした環境を逸れている為に起きる問題を回避する操作がありますが、これらはalpha版である現時点でbuildしたときに必要だったものです。
さて、githubからOSvのコードをcloneしてきて、submoduleについても取ってくるのはREADMEにある通り。この辺りは特に問題無いですね。
続いて必要なパッケージをzypperでインストールします。Fedoraのインストールパッケージを真似てインストール。対応するものが無かったのはboost-staticとmaven周りだったかな。そこら辺は適当に調整して、mavenについては後で確実に必要になるので http://software.opensuse.org/package/maven からrpmを取得してインストール。
ちなみにこれらのパッケージを入れる前、自分のこのマシンにはoracleJDKをインストールしてあり、JAVA_HOMEもそちらを向けていたのですがいつの間にかgcjやらopenJDKやらがインストールされ、特にgcjにJAVA_HOMEが設定されていた事が原因で思いっきりハマりました。イミフなエラーが出まくり。
javaにはoracleJDKを使うように設定しておきましょう。openJDKでもmake時にエラーが出ました。
準備は整ったと思いきや、前提から逸れているが故にやらなければならない事があります。
makeの実行時、実はOSvを起動することになるそうです。core2世代のCPUでは仮想化対応のレベルが低い事により、メモリを1024MB以下で割り当ててOSvを起動すると問題が起きるそうです。確かそんな感じ。間違ってたらアレなので、とりあえずcore2なら1024MB以下の割り当てでは動かないと思っておいてください。
この問題を回避するため、以下の2ファイルを編集してmake中のOSv起動時に割り当てるメモリ設定を1025以上にしておく必要があります。
- scripts/upload_manifest.py
- scripts/mkfsz.py
初期状態では512MB指定になってるので、該当する箇所を編集してやればOKです。
これらを済ませてmakeコマンドを実行すれば、無事にmake完了してくれる・・・ハズ。
起動
起動時についてもREADMEでは
./scripts/run.py
ってなってますが、先に述べたようにデフォでは2GBのメモリを使おうとします。全体で2GBしかないのにそんなの無理です。だがしかし、1024MB以下だと問題を起こします。
というわけで、現実的な解としてこんな感じで起動。
./scripts/run.py -m 1025
これで無事に動作してくれました。
あとはエンジョイしちゃってください!
原書で学ぶ64bitアセンブラ入門(5)
10章の配列について。
原書で学ぶ64bitアセンブラ入門(5)でやった内容になります。
chapter10 Arrays
Array address computation
配列とは、つまりが、ある特定の型(byte, words, double-words, quad-wordsなどのいずれか)のメモリ領域の連なりです。つまり、各要素の占めるバイト数は同じであり、各要素のアドレスは等間隔となるので容易に計算できます。
たとえば、アドレスbaseから始まる1要素mバイトの配列aについて考えてみると、要素a[i]
はbase+i*m
に位置します。
以下のコードを使った場合、aは1要素がbyte長で100要素の配列先頭アドレス、bは1要素がdouble word長で100要素の配列先頭アドレスといった具合になります。
General pattern for memory refereces
メモリ参照の一般的なパターンについて。
よくあるパターンとしては以下のものがあります。
パターン | 説明 |
---|---|
[label] | labelの位置に含まれる値 |
[label+2*ind] | ラベルにインデックスレジスタの2倍を加えた値のメモリアドレスから得られる値 |
[label+4*ind] | ラベルにインデックスレジスタの4倍を加えた値のメモリアドレスから得られる値 |
[label+8*ind] | ラベルにインデックスレジスタの8倍を加えた値のメモリアドレスから得られる値 |
[reg] | レジスタにあるメモリアドレスの位置にある値 |
[reg+k*ind] | レジスタにインデックスレジスタのk倍を加えた値のメモリアドレスから得られる値 |
[label+reg+k*ind] | ラベル、レジスタ、インデックスレジスタのk倍を加えた値のメモリアドレスから得られる値 |
[number+reg+k*ind] | 数値、レジスタ、インデックスレジスタのk倍を加えた値のメモリアドレスから得られる値 |
[label]
や[reg]
についてはそのままなので特に問題ないでしょう。
[label+2*idx]
といった形式を使うと、この場合ならidxが増える毎にアドレスが2つ進み、1要素がword長の配列に対してこれを使うならidxの値が配列のインデックスとしての意味を持つことになります。
レジスタを使っている[reg]
は、配列を関数へ渡す場合、そのアドレスをレジスタへ配置しなければならない為、そういった用途の場合にはこちらを使うことになります。
実際にこの挙動を試しているコードを本書から引用したものが以下になります。
2行目でdataセグメントに1要素がdouble-word長として5要素もつ配列aを定義し、4行目でdouble-word長で10要素分の領域をbとして確保しています。
10,11行目でcopy_array関数へ配列a,bを渡すための準備としてレジスタへ各配列のアドレスをセットしています。edxにはコピーする要素数の数値をセットしてcopy_array関数をcall。この関数内では配列の1要素ずつをループしてコピーするため、18行目でカウンタとして使うrcxレジスタを0クリア。
20,21行目が肝となる配列要素のコピー箇所で、先に0クリアしたカウンタとして働くrcxレジスタが先に述べたインデックスレジスタとして機能しているのが見て取れます。
表にあった[number+reg+k*ind]
なんかは構造体の配列を操作するのに使えます。reg,k,indは今までと同様、配列要素を示すのに使い、更にnumberが配列要素である構造体の何処を示すかのオフセットとして使います。
Allocating arrays
アセンブリでも動的にメモリを割り当てる単純な方法としては、malloc
関数を使います。malloc
で返されるメモリ領域は、16バイトでalignされた境界を持ちます。
使用例はこんな感じ
extern malloc … mov rdi, 1000000000 call malloc mov [pointer], rax
動的にメモリ領域を取得する動機としては、静的に確保する場合は予め領域のサイズを指定しておかなければならないため、通常、十分に余裕のあるだけの領域を確保しておくことになります。なので、大抵の場合、余裕の分だけ未使用の領域が出てくるので非効率です。
また、dataセグメントで確保するように定義されていると、実行ファイル自体にもその配列分の領域を確保する必要があるため、サイズが無駄に大きくなってしまいます。
原書で学ぶ64bitアセンブラ入門(4)
続いて9章。
これまたついこの前の原書で学ぶ64bitアセンブラ入門(4)でやった内容になります。
この章は関数呼び出しの挙動やスタックフレームなんかを扱います。
chapter9 Functions
The stack
まずはスタックについて。
ちょっとまだエントリには書いていない、以前やった3章ではスタックのアドレスは最高値が0x7fffffffffffになるとの記述があったんですが、この章でいきなりこの件をちゃぶ台返しして、「いやすまん。アレは嘘だ」と述べてくれます。
んじゃ実際どうなってるのよ?ということで、適当にターミナルを開いてシェルのプロセスの様子を見てみます。
$ cat /proc/$$/maps 00400000-004d4000 r-xp 00000000 fd:00 354 /bin/bash 006d3000-006dd000 rw-p 000d3000 fd:00 354 /bin/bas .....(中略) 7fff5b107000-7fff5b11c000 rw-p 00000000 00:00 0 [stack] 7fff5b1ff000-7fff5b200000 r-xp 00000000 00:00 0 [vdso] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
という具合に、プロセスがどのようにメモリ上へマップされているかを見てみると、最後の方に [stack] なんて書かれてますね。見ての通り、この出力結果からするとstackの最高値は7fff5b11c000です。
ちなみにこの値、プロセス毎に異なってはいるものの、常に7fffXXXXX000というパターンになってます。何でこんな事になってるかについての正確な理由については述べられてないんですが、著者曰く、いつも決まったアドレスから始まっていると、悪意あるコードがスタックの保持している値を書き換えるのが容易になってしまうので、それを防ぐための"stack randomization"によるものだろう。とのことです。
あとはstackへのpushとpopは普通、8バイト単位で行われるべきであると。それより小さいバイト数でのpushも可能だが、それをやるとデータの境界が曖昧になってしまうので面倒なことになるからやめておいた方がイイよと記されています。
Call instraction
関数呼び出しのcall命令について。
call命令の実行方法は
call my_function
てな具合。
さて、このcall命令は一体何をしてくれてるのか?要はmy_fucntionラベルのある位置へインストラクションポインタを書き換えて、callでの呼び出しが終わった後に処理が返ってくるべきアドレス(return address)をスタックへpushしてくれます。
ちなみにこの場合、関数へ処理が移った直後のスタックを見れば呼び出し元の命令がどの辺りのアドレスに位置していたのかがわかることになりますね。というわけで、main関数を呼び出す__libc_start_mainがどこにあるのかをmain実行直後のスタックの様子から探る事ができます。
この時の値を見ると、アドレスはtext領域上ではなく、スタック上の変な位置を指していることがわかります。
これまた、悪意あるコードからの防御策としてこうなってるんじゃないかとのこと。
Return instruction
続いて関数から呼び出し元へ戻るときに使われる、ret命令について。
ret命令が何をしてくれるかというと、スタックのトップにある値をアドレスとしてポップし、制御をそのアドレスへ移すといったことをしてくれます。
というわけで、こんなコードについて考えてみます。
本書ではebeという著者自作のデバッグツールを使ってますが、ここでは一般的なものを使おうということでgdbにしておきます。
手元の環境がCentOSのminimal-installだったのでデバッグ用環境が入ってなかった為、そこら辺の設定もついでにメモ。
# yum -y install nasm gdb yum-utils # vi /etc/yum.repos.d/CentOS-Debuginfo.repo -> enable=0 を enable=1 に編集 # dubuginfo-install glibc
こんな感じで環境作成はOK。では挙動を見ていきましょう。
まずはデバッグ情報を付けてのコンパイル。
$ nasm -f elf64 -g IALPL-9.3.s $ gcc -g -o IALPL-9.3 IALPL-9.3.o $ gdb IALPL-9.3
gdbでの操作内容はこんな感じに。
(gdb) b main Breakpoint 1 at 0x400486: file IALPL-9.3.s, line 7. (gdb) r Starting program: /home/warabanshi/sandbox/IALPL-9.3 Breakpoint 1, main () at IALPL-9.3.s:7 7 main: call doit (gdb) p $rip $1 = (void (*)()) 0x400486 <main> (gdb) step doit () at IALPL-9.3.s:4 4 doit: mov eax, 1 (gdb) p $rip $2 = (void (*)()) 0x400480 <doit> (gdb) info stack #0 doit () at IALPL-9.3.s:4 #1 0x000000000040048b in main () at IALPL-9.3.s:7 (gdb) step doit () at IALPL-9.3.s:5 5 ret (gdb) step main () at IALPL-9.3.s:8 8 xor eax, eax (gdb) p $rip $3 = (void (*)()) 0x40048b <main+5>
mainの最初にbreakpointを仕込み、一旦そこで停止。
ここで次に実行される命令は
call doit
であり、まだこの命令は実行されていない為、ripが示す0x400486はこのcall命令がある場所である。
次に、step inしてcallされた先へ飛ぶと、処理はdoitの1行目
mov eax, 1
へ移る。この時点でのripは0x400480と、先ほどより6byte少ない所を指している。元々のコードの通り、mainよりdoitの方が前にあるという構造がここから見て取れる。
更にstackの内容を見ると、0x40048bというアドレスがあるが、これがdoit関数が終わったときに戻るべき位置。つまり、call命令から返ってきた後、次に実行されるべき命令のあるアドレスを指している。
このようにスタックを利用して関数の挙動が実装されている事がわかる。
Function parameter and return value
関数呼び出しの時のパラメーターについてです。
x86-64 LinuxではSystem V ABIに従う為、パラーメーターの引き渡しには基本的に以下のレジスタを使います。
- 関数呼び出し時
整数パラメーター | rdi, rsi, rdx, rcx, r8, r9 |
浮動小数点数パラメーター | xmm0 - xmm7 |
浮動小数点数パラメーター数 | rax |
上記レジスタを超過したパラメーター | スタックへ逆順に積まれる |
- 関数からの戻り時
関数の返値(整数) | rax |
関数の返値(浮動小数点数) | xmm0 |
スタックへ積まれる場合は、たとえば
printf("%d%d%d%d%d%d%d%d%d", a, b, c, d, e, f, g, h, i)
という関数呼び出しがあったとすると
rdi | a |
rsi | b |
rdx | c |
rcx | d |
r8 | e |
r9 | f |
と、レジスタに入る分はそれぞれ割り当てられ、超過している3要素についてはスタックへ
| | |----------| | g | |----------| | h | |----------| | i | ------------
といった感じで格納される。レジスタの後の要素はpopしていけば順に取得できるような並びになっている。
Stack frames
ここまでで何度か出てきた、関数が始まる時の記述
push rbp mov rbp, rsp
これと共に、関数呼び出し時にはcall実行によってスタックへ既に戻り先アドレスが入っている為、この処理の完了時点ではスタックは下図のようになっている。
スタックに入っているrbxの値はこの関数が呼ばれた直後に保持していた値であり、これらの2命令が終わった時にはrbpに現在のrspのアドレスが入っている。
さて、この関数内から更に別の関数(同じのでもいいが)が呼ばれた場合にはまた下図のようになる。
戻り先アドレスがスタックへ積まれ、その上にrbxがpushされる。ここでpushされるrbxの値は、先の状態でのrbx(rbx1)がある位置のアドレス(前にmov rbp, rspで保持していた、以前のrspの値)である。つまり、pushされるrbxは代々、一つ前の関数へのリンクを繋げる役割を果たしている。
というのがスタックフレームの簡単な例であるが、他にもスタックには関数内のローカル変数が入れられたりする。
スタックフレームへローカル変数用の領域を設ける場合は16の倍数バイトを確保する様にし、
push rbp mov rbp, rsp sub rsp, 32
という感じで確保する。図にするとこんな感じ。
この後に関数が呼ばれると、これらのローカル変数の上に次の戻り先アドレスとrbxの値がpushされることになる。なお、rbpにはローカル変数用にrspを減算する前のrspの位置をmovしているため、関数呼び出し間でのrbpによるリンクへ影響は無い。
原書で学ぶ64bitアセンブラ入門(3)
さて、間が空いてしまいましたが続きをば。
本当なら前回に続いてchapter3について書くのが筋なんでしょうが、つい先日にやった勉強会での範囲に当たるところを先に書いてしまおうかと。
chapter8 Branching and looping
ということで、一気に飛んで8章で、分岐とループについてです。
分岐といってもif文のような明確な構造があるわけでもなく、ほかの言語で言うところのGOTO文のようなものを駆使して実現する事になります。
unconditional jump
無条件ジャンプとの名の通り、この命令が実行されると条件無く対象のラベルの位置へとばされる命令です。
書式としては
jmp label
という具合にjmpというニーモニックに飛び先のラベルを指定するだけです。
この節についてはこれだけで終わってしまうこともできますが、せっかくなのでもうちょっと掘り下げてみることに。
この飛び先のラベルですが、機械語になる場合は今の位置から前後どちらに何バイト移動した所かという差分値が入ることになります。つまり、命令とアドレスの差が即値として入るわけですが、これが127バイト以内の場合は機械語で2バイトとして表されます。それ以上の場合は差分値用の値が4バイト分割り当てられ、合計5バイトとなります。
無条件ジャンプではないですが、jz命令の場合はこんな感じ
0x74 0x0e # 差分が127バイト以内の場合 0x0f 0x00 0x01 0x00 0x00 # 差分が128以上となる場合
また、jmpだけしか使えなくてもswitch文に相当する構文を実装することができます。コードは本書から引用。
Conditional jump
これについてはアセンブラをやると頻出ですね。なのであまりそこまで改まって書くほどではないと思うので、軽めに。
基本的にはcmp命令やmov命令を実行する事によってフラグレジスタの値を操作し、そのフラグの値に応じてジャンプするか否かを判別して挙動を変える命令を実行される流れとなります。
注意点として、C言語などで記述されたif文と同様の内容をアセンブラで似せて書こうとすると、分岐条件の条件式を反対にする必要があります。
具体的には
if (a < b) { a = b; }
とあったとるすると、アセンブラでは
mov rax, [a] mov rbx, [b] cmp rax, rbx jge skip mov [b], rax skip:
となります。jgeに当てはまる場合ジャンプしてるので、rax >= rbx である場合にskipへジャンプさせる事になりますね。
必ずこう書かないといけないわけでは無いが、if文と同様に条件式の内容がtrueの場合に直下の処理が入る方が直感的にわかりやすいかと思われる。
Looping with conditional jumps
続いてループのお話。
単純に考えて、あるところで無条件に一定の箇所へ戻るジャンプ命令があれば、それでまずは無限ループができあがる。あとはその無限ループを脱出する処理があれば良いことになる。
本書の内容では、以下のような内容のコードについて考えてみている。
dataという何らかの値が入った変数に対して、この変数に1のビットが何個あるかをカウントしているコードである。
これをアセンブリに直してみると
こんな感じに。
rcxがカウンタの役割を果たし、以下の3命令でビットが1ならば結果をインクリメントするようになってます。
bt rax, 0 setc bl add edx, ebx
ビットテスト命令(bt)でraxの0番目のビットが1であるか確かめます。1の場合はキャリーフラグが立つこととなり、setc命令でblへキャリーフラグの中身が入れられることとなる。結局ビット単位なら1か0なので、それをsumの値に加算する事と同義となります。
さて、上記のアセンブラコードは手動で元のCコードをアセンブラへ変換したもので、実際にコンパイラへ任せて実行してみるとどうなるか。
こんな感じで実際にC言語での省略されていた前後を整えて実行してみると
これが、下記のようなコマンドを通してコンパイルすると
$ gcc -O3 -S -masm=intel countbits.c
以下のように変換される
ループのテストが最後に移動し、ループしている箇所の命令数も先述のアセンブラコードよりも短くなっていますね・・・。というわけで、下手に自分でアセンブラコードを書くより、コンパイラの最適化に任せた方が効率がいいようです。
ちなみにこれ、コンパイル元のC言語でsumをreturnすらしない様にして試したら、結局このコードで得るべき結果は無いということで、そもそも何もしないようなアセンブラコードになりました。
repeat string(array) instruction
この章の最後はrep命令(リピート命令)について。
とりあえずこの命令で重要視されるレジスタは以下の通り
rcx | 繰り返し回数のカウント |
rax | 特定の値の保持 |
rsi | 反復処理のソース |
rdi | 反復処理のディスティネーション |
これらのレジスタとは別に、ディレクションフラグ(DF)も挙動に影響します。また、rep命令での挙動を指示する際に、何バイトずつ処理するかでb,w,d,qの接尾辞を使用します。
例:movs命令
lea rsi, [source] lea rdi, [destination] omv rcx, 10000 rep movsb
このように記述すると、rsiレジスタの内容をrdiレジスタへmovするという処理を10000回繰り返す事になります。つまりは、配列sourceから配列destinationへ10000バイトをコピーするというような挙動になります。
例:stos命令
この命令はstosXのXに入る何バイトを処理するかを示す文字によって、rax, eax, ax, alのいずれかから、データをrdiが示すアドレスへコピーします。なお、rdiが示すアドレスは自動的に更新されます(増えるか減るかはDFに依る)。
mov eax, 1 mov ecx, 1000000 lea rdi, [destination] rep stosd
上記の例ではdouble wordずつ、1という値が入った要素でrdiに1000000回渡すという処理を行っています。
その他
loads命令やscas命令なんかがありますが、上記を踏まえておけばあとはrep命令のパターンなんかを合わせて調べれば理解できる内容かと思われます。