これは「ドワンゴ Advent Calendar 2018」6日目の記事です。
flat assembler とは
flat assembler (fasm) は Tomasz Grysztar さんが開発している x86 向けのアセンブラです。flat assembler はマクロ機能がとても充実しているアセンブラです。公式や非公式でさまざまなマクロが開発されていて独特な文化を形成しています。
例を挙げると Fresh IDE とよばれる flat assembler で開発された IDE があります。Fresh IDE には FreshLib と呼ばれるマクロライブラリが付属しており、マルチプラットフォームのGUIライブラリや、オブジェクト指向プログラミングを実現しています。一般的なアセンブラのイメージであるプラットフォーム依存で低レイヤーという概念を覆しています。
今回はこ flat assembler を利用して 64bit の windows プログラムの作成に挑戦します。
flat assembler を動かしてみる
今回は 64bit の Window 10 上で flat assembler を動かします。flat assembler のダウンロードサイト から Windows版のzipファイルをダウンロードして、適当なディレクトリに展開します。 fasm.exe
のある場所にパスを通して、INCLUDE
ディレクトリのパスを環境変数 INCLUDE
に設定します。PowerShell を開いて fasm
コマンドを実行すると以下のようにバージョンとオプションのヘルプが表示されます。
> fasm
flat assembler version 1.73.04
usage: fasm <source> [output]
optional settings:
-m <limit> set the limit in kilobytes for the available memory
-p <limit> set the maximum allowed number of passes
-d <name>=<value> define symbolic variable
-s <file> dump symbolic information for debugging
今回利用したバージョンは2018年8月18日に更新された、バージョン1.73.04です。
hello, world.
まずは hello, world.
とメッセージボックスに表示するプログラムを作ります。fasm には windows プログラミングのためのマクロが付属しており、まるで普通のプログラミング言語のようにコードを記述できます。以下は付属マクロを利用したコード例です。
include 'win64wx.inc'
.code
start:
invoke MessageBox, HWND_DESKTOP, 'hello, world.', 'Message', MB_OK
invoke ExitProcess, 0
.end start
上のファイルを hello01.asm
という名前で保存します。以下のように fasm
コマンドの引数に指定して実行するとビルドされ hello01.exe
が作られます。
> fasm .\hello01.asm
flat assembler version 1.73.04 (944650 kilobytes memory)
4 passes, 0.2 seconds, 1536 bytes.
hello01.exe
を実行すると以下のようにメッセージボックスが表示されます。
> .\hello01.exe
日本語を表示する
Unicode版のWindowsでは文字列をUTF16で扱います。そのため以下のようにそのまま日本語の文字列を書き込んでUTF8で保存してビルドすると文字化けします。
include 'win64wx.inc'
.code
start:
invoke MessageBox, HWND_DESKTOP, 'こんにちは、世界。', 'メッセージ', MB_OK
invoke ExitProcess, 0
.end start
文字列の内容を16進数の数値で書き込むこともできますが、fasmに用意されているマクロを利用すると、UTF8の文字列をUTF16に変換できます。マクロを利用するには以下のように include 'encoding\utf8.inc'
を追加します。
include 'win64wx.inc'
include 'encoding\utf8.inc'
.code
start:
invoke MessageBox, HWND_DESKTOP, 'こんにちは、世界。', 'メッセージ', MB_OK
invoke ExitProcess, 0
.end start
ビルドすると以下のように正しく表示されます。
マクロを使わずに書いてみる
前章までのコードで invoke MessageBox
の引数に文字列を直接書いています。このように書くと、マクロの機能で文字列内容を適当な場所に置き、MessageBox には文字列のアドレスを渡すようになります。この機能を使わないように書くと、以下のようになります。
.code
start:
invoke MessageBox, HWND_DESKTOP, message, title, MB_OK
invoke ExitProcess, 0
message du 'こんにちは、世界。', 0
title du 'メッセージ', 0
du
は16bitのデータを配置する命令です。'encoding\utf8.inc'
は du
以降の文字列をUTF8だと仮定してUTF16に変換します。
.code
は以降のコードをテキストセクションに配置するマクロ、.end
はコードの最後で import テーブルを生成し、指定したラベルをエントリポイントとして指定するマクロです。どうやら同じ構文が MASM (Microsoft Macro Assembler) に用意されているらしく、その構文を再現しているようです。これらは fasm のプリミティブな section
命令で書き換えることができます。
invoke
の引数に文字列を渡す機能や、.code
や .end
は win64wx.inc
で定義されています。そして、win64wx.inc
はより低レイヤーな win64w.inc
マクロで実現されています。以下のコードは win64w.inc
の機能を利用してメッセージボックスを表示するコードを書き換えたものです。
format PE64 GUI 5.0
entry start
include 'win64w.inc'
include 'encoding\utf8.inc'
section '.text' code readable executable
start:
sub rsp, 8
invoke MessageBox, HWND_DESKTOP, message, title, MB_OK
invoke ExitProcess, 0
section '.data' data readable writeable
message du 'こんにちは、世界。', 0
title du 'メッセージ', 0
section '.idata' import data readable writeable
library kernel32,'kernel32',\
user32,'user32'
include 'api\kernel32.inc'
include 'api\user32.inc'
今回は文字列を書き換えることがないので message
や title
はデータセクションではなくテキストセクションに置くこともできます。start:
の直後で sub rsp, 8
と書いています。これは今回の記事で初めて出てきた機械語の命令です。sub
は引き算の命令で、スタックのトップを指している rsp
から 8 引いています。これは 64bit の windows の呼び出し規約で、API呼び出し時にスタックが16バイト境界に並んだ状態で呼び出す必要があるためです。call
した直後に8バイトのリターンアドレスが積まれているので8引いて調整します。
コンソール画面に出力する
プログラミングの教科書に出てくるようなコンソール画面に hello, world. と出力するコードを書いてみます。ここでは Windows API の WriteFile
関数を利用して出力します。
format PE64 console
entry start
include 'win64w.inc'
section '.text' code readable executable
start:
sub rsp, 8
invoke GetStdHandle, STD_OUTPUT_HANDLE
invoke WriteFile, rax, msg, msg.len, output, 0
invoke ExitProcess, 0
section '.data' data readable writeable
output dd ?
msg db 'hello, world.', 0x0D, 0x0A
.len = $ - msg
section '.idata' import data readable writeable
library kernel32, 'kernel32'
include 'api\kernel32.inc'
$
は現在のアドレスを表します。$ - msg
は現在のアドレスから msg
のアドレスの差を求めることで文字列の長さを求めています。API呼び出しの戻り値は rax
に格納されているので、WriteFile
の呼び出しでは直前の GetStdHandle
の戻り値を rax
で渡しています。今回は kernel32.dll
のAPIのみ利用しているので user32.dll
は不要です。db
や dd
はデータを配置する命令です。db
では後続の値をバイトサイズ(8bit)だと解釈して出力、dw
はワードサイズ (16bit)、dd
はダブルワード (32bit)、dq
はクアッドワード (64bit) で出力します。以下は実行結果です。
> fasm .\hello05.asm
flat assembler version 1.73.04 (1048576 kilobytes memory)
3 passes, 2048 bytes.
> ./hello05
hello, world.
invokeを使わずに書いてみる
これまで利用していた invoke
は fasm のマクロです。展開すると機械語の call
に変換されます。以下のコードはメッセージボックスを表示するプログラムの invoke
を call
の呼び出しに書き換えたものです。
format PE64 GUI 5.0
entry start
include 'win64w.inc'
include 'encoding\utf8.inc'
HWND_DESKTOP = 0
MB_OK = 0
section '.text' code readable executable
start:
sub rsp, 0x28
mov rcx, HWND_DESKTOP
lea rdx, [message]
lea r8, [title]
mov r9, MB_OK
call [MessageBoxW]
mov rcx, 0
call [ExitProcess]
section '.data' data readable writeable
message du 'こんにちは、世界。', 0
title du 'メッセージ', 0
section '.idata' import data readable writeable
library kernel32, 'kernel32', \
user32, 'user32'
include 'api\kernel32.inc'
include 'api\user32.inc'
これまでのコードと異なり、start
直後が sub rsp, 0x28
になっています。これは Windows 64bit の ABI で引数分のスタック領域を開けておく必要があるためです。このあたりの話は以下の資料に書かれています。
.idata セクションを分解する
.idata
セクションにはインポートする関数の名前を並べます。Windows のローダはこのセクションを見てDLLを読み込み、必要なAPIのアドレスをメモリに書き込みます。こちらもマクロを利用せずに記述できます。具体的なフォーマットは以下の資料に書かれています。
format PE64 GUI 5.0
entry start
include 'encoding\utf8.inc'
HWND_DESKTOP = 0
MB_OK = 0
section '.text' code readable executable
start:
sub rsp, 0x28
mov rcx, HWND_DESKTOP
lea rdx, [message]
lea r8, [title]
mov r9, MB_OK
call [MessageBoxW]
mov rcx, 0
call [ExitProcess]
section '.data' data readable writeable
message du 'こんにちは、世界。', 0
title du 'メッセージ', 0
section '.idata' import data readable writeable
dd 0, 0, 0, rva kernel_name, rva kernel_table
dd 0, 0, 0, rva user_name, rva user_table
dd 0, 0, 0, 0, 0
kernel_table:
ExitProcess dq rva _ExitProcess
dq 0
user_table:
MessageBoxW dq rva _MessageBoxW
dq 0
kernel_name db 'kernel32', 0
user_name db 'user32', 0
_ExitProcess dw 0
db 'ExitProcess',0
_MessageBoxW dw 0
db 'MessageBoxW',0
include 'win64w.inc'
が不要になり、fasm付属のマクロを利用しない形に到達しました。ここで rva
はEXEファイルがメモリに展開されたアドレス(BaseImageAddress)からの相対位置を計算するfasmの演算子です。
バイナリファイルを出力する
これまでファイルの先頭で format PE64
と書いて、64bitのPEフォーマットのファイルを出力するように指示ていましたが、ここを format binary
と書くとバイナリ形式で値を直接出力できます。以下はこの機能を使って ABC
と書かれたテキストファイルを出力する例です。as 'txt'
と書くと出力の拡張子が .txt
になります。
format binary as 'txt'
db 65, 66, 67
65, 66, 67 は ABC
それぞれの文字コードです。文字列リテラルで以下のように書くことも出来ます。
format binary as 'txt'
db 'ABC'
この方法を利用するとプログラムに限らず、テキストや画像を出力することもできます。
EXEファイル全体を自力で書いてみる
バイナリファイルを出力する機能を利用すると、EXEファイルのすべてのバイトを指定して生成できそうです。ということで実際にやってみました。ここでは話を簡単にするために、以下のコードを生成します。
format PE64 console
entry start
include 'win64w.inc'
section '.text' code readable executable
start:
sub rsp, 28h
mov rcx, 123
call [ExitProcess]
section '.idata' import data readable writeable
dd 0, 0, 0, rva kernel_name, rva kernel_table
dd 0, 0, 0, 0, 0
kernel_table:
ExitProcess dq rva _ExitProcess
dq 0
kernel_name db 'kernel32',0
_ExitProcess dw 0
db 'ExitProcess',0
起動すると即終了するプログラムです。分かりやすくするために戻り値を 123
にしています。そしてこのコードをひたすら db
, dw
, dd
, dq
を使って書いたコードが以下になります。作成中のメモをコメントとして残しているので再現される方は参考にして下さい。
;
; 64bit EXE 生成プログラム
;
format binary as "exe"
;------------------------------
; DOS ヘッダ
;------------------------------
; 全部定数とみなして問題ない
dw 0x5A4D ; e_magic
dw 0 ; e_cblp
dw 0 ; e_cp
dw 0 ; e_crlc
dw 0 ; e_cparhdr
dw 0 ; e_minalloc
dw 0 ; e_maxalloc
dw 0 ; e_ss
dw 0 ; e_sp
dw 0 ; e_csum
dw 0 ; e_ip
dw 0 ; e_csum
dw 0 ; e_lfarlc
dw 0 ; e_ovno
dw 0, 0, 0, 0 ; e_res
dw 0 ; e_oemid
dw 0 ; e_oeminfo
dw 0, 0, 0, 0 ; e_res2
dw 0, 0, 0, 0
dw 0, 0
dd 0x0080 ; e_lfanew
dq 0, 0, 0, 0, 0, 0, 0, 0
;------------------------------
; EXE ヘッダ
;------------------------------
; NumberOfSections はセクション数。必要に応じて書き換え
; 他はそのままで良い
dw 0x4550, 0x0000 ; Signature
dw 0x8664 ; Machine
dw 0x0002 ; NumberOfSections
dd 0 ; TimeDateStamp
dd 0 ; PointerToSymbolTable
dd 0 ; NumberOfSymbols
dw 0x00F0 ; SizeOfOptionalHeader
dw 0x002F ; Characteristics
;------------------------------
; オプションヘッダ
;------------------------------
; 重要なのは AddressOfEntryPoint 他は定数
; ImageBase + AddressOfEntryPoint がエントリーポイントの実アドレス
dw 0x020B ; Magic
db 0 ; MajorLinkerVersion
db 0 ; MinorLinkerVersion
dd 0 ; SizeOfCode
dd 0 ; SizeOfInitializedData
dd 0 ; SizeOfUniinitializedData
dd 0x1000 ; AddressOfEntryPoint
dd 0 ; BaseOfCode
;------------------------------
; オプションヘッダ (Windows)
;------------------------------
; 以下は全部定数
dq 0x00400000 ; ImageBase
dd 0x1000 ; SectionAlignment
dd 0x0200 ; FileAlignment
dw 0x0005 ; MajorOperatingSystemVersion
dw 0x0000 ; MinorOperatingSystemVersion
dw 0 ; MajorImageVersion
dw 0 ; MinorImageVersion
dw 0x0005 ; MajorSubsystemVersion
dw 0x0000 ; MinorSubsystemVersion
dd 0 ; Win32VersionValue
; 以下を設定
; SizeOfImage (ロードに必要なサイズ, SectionAlignment 揃え)
; SizeOfHeaders (ヘッダのサイズ, セクションテーブル含む, FileAlignment 揃え)
; スタック・ヒープサイズは適宜設定
dd 0x3000 ; SizeOfImage
dd 0x0200 ; SizeOfHeaders
dd 0 ; CheckSum
dw 0x0003 ; Subsystem
dw 0 ; DllCharacteristics
dq 0x1000 ; SizeOfStackReserve
dq 0x1000 ; SizeOfStackCommit
dq 0x00010000 ; SizeOfHeapReserve
dq 0x0000 ; SizeOfHeapCommit
; 以下は定数
dd 0 ; LoaderFlags
dd 0x0010 ; NumberOfRvaAndSizes
;------------------------------
; オプションヘッダ (Data)
;------------------------------
dd 0, 0 ; ExportTable
dd 0x2000, 0x004F ; ImportTable
dd 0, 0 ; ResourceTable
dd 0, 0 ; ExceptionTable
dd 0, 0 ; Certificate Table
dd 0, 0 ; Base Relocation Table
dd 0, 0 ; Debug
dd 0, 0 ; Architecture
dd 0, 0 ; Global Ptr
dd 0, 0 ; TLS Table
dd 0, 0 ; Load Config Table
dd 0, 0 ; Bound Import
dd 0, 0 ; IAT
dd 0, 0 ; Delay Import Descriptor
dd 0, 0 ; CLR Runtime Header
dd 0, 0 ; Reserved
;------------------------------
; .text
;------------------------------
db ".text", 0, 0, 0 ; Name
dd 0x0011 ; VirtualSize
dd 0x1000 ; VirtualAddress
dd 0x0200 ; SizeOfRawData
dd 0x0200 ; PointerToRawData
dd 0 ; PointerToRelocations
dd 0 ; PointerToLinenumbers
dw 0 ; NumberOfRelocations
dw 0 ; NumberOfLinenumbers
dd 0x60000020 ; Characteristics
;------------------------------
; .idata
;------------------------------
db ".idata", 0, 0 ; Name
dd 0x004F ; VirtualSize
dd 0x2000 ; VirtualAddress
dd 0x0200 ; SizeOfRawData
dd 0x0400 ; PointerToRawData
dd 0 ; PointerToRelocations
dd 0 ; PointerToLinenumbers
dw 0 ; NumberOfRelocations
dw 0 ; NumberOfLinenumbers
dd 0xC0000040 ; Characteristics
;------------------------------
; ゼロ埋め
;------------------------------
dq 0, 0, 0, 0, 0
;------------------------------
; text セクション
;------------------------------
; sub rsp, 28h
db 0x48 ; REX.W PREFIX 0100WR0B W = 1, R = 0, B = 0
db 0x83 ; SUB r/m64, imm8
db 0xEC ; RSP (Mod = 11, R/M = 100, Value = /5)
db 0x28 ; VALUE
; mov rcx, 123
db 0x48 ; REX.W
db 0xC7 ; MOV r/m64, imm32
db 0xC1 ; RCX (Mod = 11, R/M = 01, Value = /0)
db 0x7B, 0x00, 0x00, 0x00 ; VALUE
; call [ExitProcess]
db 0xFF ; CALL m16:32
db 0x15 ; mod = 00 reg = 10 r/m = 101
db 0x17, 0x10 ; (EIP = 0x1011) + 0x1017 = 0x2028
db 0x00, 0x00
db 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
;------------------------------
; idata セクション
;------------------------------
dd 0, 0, 0, 0x2038, 0x2028
dd 0, 0, 0, 0, 0
dq 0x2041, 0
db "kernel32", 0
db 0, 0, "ExitProcess", 0, 0
dq 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
dq 0, 0, 0, 0, 0, 0, 0, 0
コードを hello10.asm
という名前で保存して以下のように PowerShell で実行すると 123 が返ってくることが確認できます。
> fasm .\hello10.asm
flat assembler version 1.73.04 (1048576 kilobytes memory)
1 passes, 1536 bytes.
> .\hello10
> $LASTEXITCODE
123
書いてみた感想なのですが、これは人間が書くものではありません。特にRIP相対アドレッシングは凶悪です。想定ではこれまでの流れに合わせて hello, world. を出すはずだったのですが、時間切れで断念しました。
おわりに
今回は flat assembler を触りいろいろなスタイルで hello, world. を書いてみました。flat assembler には他にもリソースを構築するマクロや、if や while といった制御構文を実現するマクロなどいろいろな機能がありとても面白いので、それはまた別の機会に紹介できればと考えています。
また flat assembler の次期バージョンである flat assembler g の開発も進んでいるようでこちらも注目しています。flat assembler g は、flat assembler の考え方をさらに推し進めたもので、x86 の機械語命令自体もマクロで表現してしまうようです。flat assembler 1 (初代 flat assembler の呼び方) では x86 の命令は組み込みで用意されていましたが、 flat assembler g では特定のプラットーフォーム向けの命令は搭載されておらず、すべてをマクロで表現されています。汎用アセンブラもしくはアセンブラを作るための言語が falt assembler g のようです。
私は日ごろはサーバサイドやフロントエンドで動く TypeScript や JavaScript を書いています。おそらく業務で flat assembler を使うような機会は来ないと思いますが、日頃自分が触っていたり耳にしたりする言語・環境・ツールがこの世の中のすべてではないということを、肝に銘じてコードを書いていこうと、今年も思いを新たにしました。