わらばんし仄聞記

南の国で引きこもってるWeb屋さん

原書で学ぶ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の最大値を取得する場合、下図のようになります。

f:id:warabanshi21:20140522012330p:plain

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した場合はこんな感じ

f:id:warabanshi21:20140520230747p:plain

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の倍数となるよう、間を適切に空けてくれます。

f:id:warabanshi21:20140520233552p:plain

こんな手間をしなくていいのが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つの場合になってます)

f:id:warabanshi21:20140521000640p:plain

後半に続きます

11章は長いので、一旦ここまで。

OSvをビルドしてみる

OSvについては下のリンクを参照。

OSv - the operating system designed for the cloud

これをビルドして動かしてみました。 コードについてはgithub上のコチラにあり、ビルド方法もこのページ下部にREADME.mdが表示されています。
ちなみに手っ取り早くOSvを触るだけ触りたいという人は、capstanというのがあるのでそちらを使った方がいいでしょう。

前提と今回試した環境

前提

さて、READMEを読んでみると、とりあえず手順の例として載っているのはFedoraDebianです。
また、アーキテクチャ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).

今回試した環境

見事に前提としている環境に逆らってますね。
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やらがインストールされ、特にgcjJAVA_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領域上ではなく、スタック上の変な位置を指していることがわかります。

f:id:warabanshi21:20140417003656p:plain

これまた、悪意あるコードからの防御策としてこうなってるんじゃないかとのこと。

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実行によってスタックへ既に戻り先アドレスが入っている為、この処理の完了時点ではスタックは下図のようになっている。

f:id:warabanshi21:20140417021544p:plain

スタックに入っているrbxの値はこの関数が呼ばれた直後に保持していた値であり、これらの2命令が終わった時にはrbpに現在のrspのアドレスが入っている。
さて、この関数内から更に別の関数(同じのでもいいが)が呼ばれた場合にはまた下図のようになる。

f:id:warabanshi21:20140417022540p:plain

戻り先アドレスがスタックへ積まれ、その上にrbxがpushされる。ここでpushされるrbxの値は、先の状態でのrbx(rbx1)がある位置のアドレス(前にmov rbp, rspで保持していた、以前のrspの値)である。つまり、pushされるrbxは代々、一つ前の関数へのリンクを繋げる役割を果たしている。

というのがスタックフレームの簡単な例であるが、他にもスタックには関数内のローカル変数が入れられたりする。
スタックフレームへローカル変数用の領域を設ける場合は16の倍数バイトを確保する様にし、

push     rbp
mov      rbp, rsp
sub      rsp, 32

という感じで確保する。図にするとこんな感じ。

f:id:warabanshi21:20140417024126p:plain

この後に関数が呼ばれると、これらのローカル変数の上に次の戻り先アドレスと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命令のパターンなんかを合わせて調べれば理解できる内容かと思われます。