原書で学ぶ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命令のパターンなんかを合わせて調べれば理解できる内容かと思われます。
原書で学ぶ64bitアセンブラ入門(2)
前回に引き続き、タイトルの勉強会でやった内容についてです。
勉強会毎の投稿ではなく、区切りと記事を書く労力の関係上、本書の1章ごとに書いていこうと思います。
chapter2 Numbers
二章は数値についてです。
コンピュータでは全ての情報をビット列として扱いますが、そのなかでも数値をビット列でどう保存しているかについて見ていきます。
この辺りはまだ序の口なので、まだまだ64bit特有な事は出てきません。ですので大体わかってる人は飛ばしてしまってもいいんじゃないかなとは思います。正直自分でも記事を書いていて、今更な事柄なんでとばしちゃいたい気持ちが少し…。
Binary numbers(2進数)
日頃使ってる10進数の数値は各桁の重みが10のn乗(n=0,1,2...)で表されるのと同様、2進数では各桁の重みが2のn乗となります。(位取り記数法)
- 10進数の例
1024 = 10^3 * 1 + 10^2 * 0 + 10^1 * 2 + 10^0 * 4 = 1000 + 0 + 20 + 4 = 1024
- 2進数の例
10101111 = 2^7 * 1 + 2^6 * 0 + 2^5 * 1 + 2^4 * 0 + 2^3 * 1 + 2^2 * 1 + 2^1 * 1 + 2^0 * 1 = 128 + 16 + 8 + 4 + 2 + 1 = 159
10進数から2進数への変換方法として、簡単なやり方は2での除算を繰り返して余りを最下位桁から順に当てはめていくやり方があります。
手順を逐一書いてみるとこんな感じ。
- 741を2進数に変換
除算 | 商 | 剰余 | ビット |
---|---|---|---|
741/2 | 370 | 1 | 1 |
370/2 | 185 | 0 | 01 |
185/2 | 92 | 1 | 101 |
92/2 | 46 | 0 | 0101 |
46/2 | 23 | 0 | 00101 |
23/2 | 11 | 1 | 100101 |
11/2 | 5 | 1 | 1100101 |
5/2 | 2 | 1 | 11100101 |
2/2 | 1 | 0 | 011100101 |
1/2 | 0 | 1 | 1011100101 |
というわけで、741は2進数で1011100101となります。このアルゴリズムは簡単なループや再帰で書けるのでお手軽ですね。
yasmアセンブラ用のコードを書く場合、定数の表現に2進数を使うことができます。
その際に10進数と見分ける為、末尾に"b"を付けて表す事になっています。
mov rax, 1001 ; 1001をraxへ入れる mov rax, 1001b ; 9をraxへ入れる
Hexadecimal numbers(16進数)
2進数では各桁の重みが2のn乗だったのと同様、16進数では16のn乗になります。つまり1つの桁で0~15までの数値を表さなければならないですが、当然普段使っている10進数の数字で10以上の数値を一文字で表すものはありません。なので、10以上にはアルファベット1文字を対応させることになり、その対応は以下のようになります。
10進数 | 2進数 | 16進数 |
---|---|---|
0 | 0 | 0 |
1 | 1 | 1 |
2 | 10 | 2 |
3 | 11 | 3 |
4 | 100 | 4 |
5 | 101 | 5 |
6 | 110 | 6 |
7 | 111 | 7 |
8 | 1000 | 8 |
9 | 1001 | 9 |
10 | 1010 | a |
11 | 1011 | b |
12 | 1100 | c |
13 | 1101 | d |
14 | 1110 | e |
15 | 1111 | f |
yasm上で16進数の定数を表現するには"0x"を接頭辞として付けます。この書き方は他の言語やらでも大体一緒ですね。
さて、先程の2進数と同様、16進数を10進数に直してみます
- 0xa1aを10進数にする
0xa1a = 16^2 * 0xa + 16^1 * 1 + 16^0 * 0xa = 256 * 10 + 16 * 1 + 16 = 2586
その他、10進数から16進数を作る方法は2進数と同様にして出来ますので、この辺りは省略。
さて、コンピューター上では実際のデータは0か1の2進数で表現できるビット列で表されている事は先に述べた通りです。ただ、だからといって全ての数値などを2進数で書いていると、書くのも読むのも大変です。そこで16進数の出番となってきます。
2進数の4桁は丁度16進数の1桁として表せるため(16=2^4だから当然ですが)、相互に変換が容易になります。10進数を使おうとすると、桁が綺麗に合ってくれないので余計面倒になりますしね。
で、よく8ビット(=16進数2桁)の事を1バイトと呼びますが、この4ビットについても別名があり、「ニブル(nibble)」と呼ぶそうです。あんま聞かないですけどね。
Integer(整数)
z86-64での整数は1,2,4,8バイトの長さがあり、それらのとる値は以下の通りです
型 | ビット数 | バイト数 | 最小値 | 最大値 |
---|---|---|---|---|
unsigned | 8 | 1 | 0 | 255 |
signed | 8 | 1 | -128 | 127 |
unsigned | 16 | 2 | 0 | 65535 |
signed | 16 | 2 | -32768 | 32767 |
unsigned | 32 | 4 | 0 | 4294967295 |
signed | 32 | 4 | -2147483648 | 2147483647 |
unsigned | 64 | 8 | 0 | 18446744073709551615 |
signed | 64 | 8 | -92233720368547750808 | 9223372036854775807 |
符号付き整数の負の数は2の補数表現で表されます。よく聞く2の補数の作り方は、全ビットを反転させて1を足したものという様に説明されているかと思います。さて、この「2の補数」。大体2進数を習うときに言葉と作り方だけ出てきて、一体どうしてこうなった的な説明が書いてない事が多いです。自分も習ったときはそれくらいで、根拠はスルーだったおかげで長いことちゃんと理解してなかったのでその辺りをここで捕捉しておこうかと。本書には別にこんな事書いてないんですけどね。
そもそも補数とは
n進法において、ある数aに対して足したときにn進法での桁が1つ上がる最小の数です。つまりある数aの桁数がmとすると
(補数) = n^m - a
となります。本当はもうちょっと細かい事があるんですが、その辺りはwikipedia先生(補数)を見てください。
- 実際に例を挙げてみると
10進数で61の補数は
10^2 - 61 = 39
よって39ですね。さて、同様にして2進数について見てみますと、10101100(=172)の補数は
2^8 - 172 = 84 = 01010100
となります。実際、01010100は10101100を反転して1足した数になってますね。しかしこの1を足すという挙動が腑に落ちない。なんなのコレ?
10101100(=172)について見てみると、この数値で使っている桁数で表現出来る最大値は11111111(=255)となる。で、先程補数を作るときに使った値は2^8(=256)。ということは、先程の式はこのよう変形できる
2^8 - 172 = 256 - 172 = 255 - 172 + 1
ここでやっている255 - 172という行為。ちょっと考えてみればわかりますが、これは2進数上で考えると172のビットを反転していることと同じですね。こうして呪文のように言われている「反転して1を足せ!」という行為に裏付けがとれました。まー、この辺りは一通りwikipedia先生(2の補数)に書いてあるんですけどね。
- なぜ補数を使うと負数の表現が出来るのか?
いやまぁ、計算したら実際にそうなるし、そういう風に考えられたとか言ってしまえばそれまでなんですが、これについても昔は気持ち悪かったので同じ境遇の人が居るかも知れないことを考えて書いておきます。
(ちゃんとした人が書いた書物で読んだ訳でなく、自分でそういうことかと思っただけなので間違いがあったら指摘お願いします)
話を簡単にするため、8ビットの場合について考えます。
有効桁を8ビットの符号無しとして見ると、表現出来る最大の数は255。256になると有効桁数を超えた所に桁が上がる事になりますね。有効桁が8ビットなので9ビット目以上はオーバーフローしてしてしまい、つまり、有効桁数に着目している限りでは256は0に戻ってしまいます。これは256を法とした剰余系とみなせます。
さて、整数において任意の数mは
m = qn + r q = 商(quotient), r = 剰余(residue)
で表せます。先程の256を法とする剰余系を踏まえると、nを256とすればqにいかなる整数を入れてもmとrは合同になります。例としてm = 85、m = 183という2パターンについて考えると
85 ≡ 0 * 256 + 85 (=85) ≡ 1 * 256 + 85 (=341) ≡ 2 * 256 + 85 (=597) 183 ≡ 0 * 256 + 183 (=183) ≡ 1 * 256 + 183 (=439) ≡ 2 * 256 + 183 (=695)
てな具合ですね。
ここで唐突に符号付きの状態に移行してみましょう。8ビットの場合、最上位ビットを符号ビットとして1の場合は負の値として扱いますが、これは符号無しの場合での0~127を正の数、128~255を負の値として扱うという区分けをしたことに他なりません。よって先程出した85と183という2値は、符号付き状態にしたとたん、正の数と負の数に分かたれてしまいました…。
まぁ、85の方はいいでしょう。元から正の数で分断後も正の数、つまり何も変わりありません。
一方183はいきなり負の値だと言われてしまいました。いきなりそんな事いわれたって…って感じですよね。
しかしここでいい方法があります。183は先程の256を法とした剰余系にいると考えると、負の値としての振る舞いも出来ることになります。任意の数mを表す式において、183という値は先程
185 ≡ 0 * 256 + 185
となるように表しました。そこで、さっき述べましたよね?この剰余系なら「qはどんな整数であっても剰余の値と合同になります」と。
ならばということで、こういう風にしちゃいます
186 ≡ 1 * 256 + (-71)
義務教育で習う商と余りの概念でいくと余りは正の数と勝手に思いこみがちですが、そもそも余りとは先程の m = qn + r のrの事に他なりません。なので、もちろんrが負であっても構いません。よって
186 ≡ -71
であるから、256を法とする剰余系では185と-71は合同です。
以上より、この剰余系においてはある数に185を足すことと71を引くことは同じ事になります。
さー これでスッキリしましたね。なぜ11111111なんてのが-1として扱われるか。
11111111は255であり、256を法とする剰余系では 255 ≡ -1 なのです。つまり有効桁数8bitの環境下においては255を足すことと1を引くことは同義なのです。よって、2の補数表現を使うことで加算で減算を表現でます。
かなり話が逸れました。
閑話休題。でもってIntegerの節はあと足し算したり掛け算したりしてるだけですのでこんなもので。
Floating point numbers(浮動小数点数)
浮動小数点数とはどう扱われるべきか?そのあたりはIEEE754に書いてあります。
x86-64ではfloat、double、long doubleというパターンが使えますが(long doubleはIEEE754に載ってない)、いずれも基本的には指数部や仮数部やらに使うビット数が変わるだけなので、ビット数控えめのfloatについて見ていきます。
floatでは32ビットの内訳は以下のようになってます
位置 | 役割 |
---|---|
32bit目 | 符号ビット |
24~31bit目 | 指数部 |
1~23bit目 | 仮数部 |
符号ビットはまぁいいですね。1ならその値は負の値であることを示します。
指数部には指数バイアスというものがあり、指数部8ビットに指定された値から127を引いた値が実際に指数として使われる値になります。つまり、指数部が10000011であるなら、131 - 127 = 4ということで、仮数部で示された値に2^4が掛けられる事になります。同様に01111101であるなら、125 = 127 = -2ということで、2^-2が掛けられます。
仮数部は小数点以下の値を表します。但し、この仮数部は整数部分を示す値はありませんが、1が暗黙の値として存在するものとして扱われます。つまり、仮数部が01100000000000000000000ならば、これはつまり1.01100000000000000000000であるとして処理されます。
これらを踏まえて考えると、このままでは0という値の表現ができないことに気づくと思います。指数部は2の指数を表すので、つまりここで表現できるいかなる値を指定しても0より大きくなります。仮数部もまた、暗黙の整数部が存在する以上、最小値は1になります。
そのため、これもまたルールとして、「すべてのビットが0ならば0として扱う」ということになっています。
他にもこのようなルールがあり、指数部の0x00と0xFFには特別な意味があります。0x00は仮数部も0の時は0となりますが、仮数部に値がある場合は非正規化数を表します。また、指数部、仮数部ともに0の時でも符号ビットが1の場合は-0を表現します。
0xFFの場合は無限大を表現し、これもまた符号ビットにより正の無限大と負の無限大を表します。
簡単な例
さて、実際に10進数から浮動小数点数を導く実例を見てみます。
- 1.0の場合
この場合、仮数部には暗黙の1が存在するので、値はであれば1.0として見なされます。正の数だから符号ビットは0となり、あとは指数部。指数部から導かれる値、これをnとすると2のn乗が仮数部で求められた値に掛けられるので、つまり 2^n * 1.0 = 1.0 となればよい。つまり n = 0 であればいいわけで、指数部はそのビット列で示された値から127が引かれる事になるということは、127 - 127 = 0 を作れば良い。よって、指数部は127。
これをビット列で表すと
0 01111111 00000000000000000000000 ↑符号ビット ↑指数部 ↑仮数部
見やすくすると
0011 1111 1000 0000 0000 0000 0000 0000 = 0x3F800000
となり、つまり浮動小数点数での1.0は0x3F800000となる。更にはintel系CPUではバイトオーダーはリトルエンディアンのため、この値はメモリ上に
00 00 80 3F
というように配置されます。リトルエンディアン=アドレスがリトルな位置に、バイト単位でのエンド(=末尾)を配置するパターンですね。
あと、本書には10進数から浮動小数点数の作り方なんてのも載ってますが、そこら辺は他の書籍でも沢山載ってるでしょうし割愛ということで。
原書で学ぶ64bitアセンブラ入門(1)
タイトルの通り、洋書の64bitアセンブラ入門書をよむ勉強会を開催しています。
参考書:Introduction to 64 Bit Intel Assembly Language Programming for Linux
ターゲットとしては洋書の技術書はいつも物怖じしてたけど、読んでみよう!という人と、
アセンブラの本は古いのばっかで最新(=64bit)のモノがないなーと思ってた人。
参加してもらってるのは後者が多い感じがしますがね。
そもそも自分が英語あまりできないし(TOEIC400点代)。
そんな自分ではありますが、興味ある人には当会に途中からでも参加していただけるよう、
今までやってた内容を要約して書いておこうと思った次第であります。
chapter1 introduction
第一章は導入部分です。
何故このご時世にアセンブリ言語を学ぶ価値があるのかなどについて。
まー この辺りは自分もよく聞かれます。そんなもの習得して今更何の価値があるの?って。
本書ではこれに対する回答として以下を挙げています
- 卓越したアセンブリ言語のコーダーが書いたプログラムは、とても効率的である
- 高レベル言語では成し得ないことを操作できる
- 高級言語の実装がどのようになっているか(低レベルなレイヤーで)を知ることで、よりよい選択肢を見極められる
- 高級言語で不可解なバグに遭遇した際、その原因を追うことができる
他にもアセンブルや機械語等の話が少々ありますが、この章についてはひとまず先述の分で十分でしょう。
個人的見解としては、プログラミング言語の根源を知ろうとする際にはこれらの知識が必須のものかと思います。
とりあえずアドレスやリンクと言った事についての把握がまだ曖昧な人については、
先立ってその辺りの情報を得ることが今後の内容については必要でしょう。
而して、それらはそこまで難解な概念ではありませんので、一瞥してみれば十分でしょう。
不明瞭ならば、当会に参加して聞いてみるのもアリです。
ともあれ、長々と書きましたが第一章についてはこの程度です。
今後も続けて会で取り上げた章についた書いていければと思います。