2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita Engineer Festa 2024(キータ・エンジニア・フェスタ 2024) - Qiita
において、約1ヶ月で38記事という大量の記事の投稿を要求されることがわかった。
そこで、あまりコストをかけずに記事数を稼ぐ方法を考えた結果、「Welcome to AtCoder を様々な言語で解く」ことを思いついた。
単に解くだけでなく、使用する言語仕様の解説を入れれば、記事として一応成立するだろう。

Welcome to AtCoder

PracticeA - Welcome to AtCoder

Welcome to AtCoder では、以下の形式で整数 $a$, $b$, $c$ および文字列 $s$ が入力として与えられる。

a
b c
s

この入力をもとに、与えられた整数の和 $sum = a + b + c$ および文字列 $s$ を、以下の形式で出力することが求められる。

sum s

整数 $a$, $b$, $c$ は 1 以上 1,000 以下である。

今回用いる機能

AtCoder の Assembly x64 は、x86_64 向けのアセンブリ言語 (NASM) である。

レジスタ

x86_64 では、64ビットの整数を格納する以下のレジスタが使用できる。

  • RAX、RBX、RCX、RDX
  • RSI、RDI
  • RSP
  • RBP
  • R8 ~ R15

このうち、RAX、RBX、RCX、RDX については、以下のように32ビット、16ビット、8ビットの部分を用いることができる。

64ビット 下位32ビット 下位16ビット 下位8ビット 下位16ビット中の
上位8ビット
RAX EAX AX AL AH
RBX EBX BX BL BH
RCX ECX CX CL CH
RDX EDX DX DL DH

また、詳細は省略するが、今回挙げたこれ以外のレジスタについてもそれぞれ下位32ビット、下位16ビット、下位8ビットの部分を用いることができる。
これらのレジスタの「下位32ビット」に値を書き込むと、64ビットのうち残りの部分 (上位32ビット) はゼロクリアされる。
たとえば、EAX に値を書き込むと、RAX の上位32ビットはゼロクリアされる。
16ビットや8ビットの部分への書き込みでは、このようなゼロクリアは起こらない。

RSP レジスタは、通常スタックポインタとして用い、スタック上で最後のデータ (スタックトップ) があるアドレスを格納する。
RBP レジスタは、通常ベースポインタとして用い、スタック上のローカル変数の位置の基準となるアドレスを格納する。

対象とするCPUモードの明示

BITS を用いることで、記述するプログラムが想定するCPUモードを明示できる。
今回は、64ビットモードを想定するので、

bits 64

とする。
モードはアセンブル時の出力形式などから自動で推定されるので明示は不要であるが、明示しておいたほうがわかりやすいだろう。

スタック上のデータを実行不可にする

アセンブラを混ぜてコンパイルするとスタックが実行可になってしまう話 #Linux - Qiita

スタック上のデータは、セキュリティのため実行を許さないのがよいとされる。
.note.GNU-stack セクションを作ることで、スタック上のデータを実行させないことを示すことが出来る。

section .note.GNU-stack

と書くことで、このセクションを作ることができる。
さらに

section .text

と書くことで、この後書くコードを通常のプログラムを配置する .text セクションに配置できる。

命令の記述方法

NASM では、Intel記法で命令を記述する。
これは、基本的に

命令 デスティネーション, ソース

の形式で記述する記法である。
(命令によっては、引数の数が異なる場合もある)

たとえば、EAX レジスタに定数 72 を格納する命令は

mov eax, 72

と記述できる。

このように、レジスタや即値はそのまま記述すればよい。
メモリアクセスは、アクセスするアドレスを [] で囲って表現する。
たとえば、RSP レジスタが指す場所のデータを EAX レジスタにロードする命令は

mov eax, [rsp]

と記述できる。
[] の前に以下のキーワードを用いることで、アクセスするメモリのサイズを指定できる。
即値を書き込む場合など、他のオペランドなどからアクセスするサイズがわからない場合、このような指定が必要である。

mov byte  [rax], 72 ; 1 バイト
mov word  [rax], 72 ; 2 バイト
mov dword [rax], 72 ; 4 バイト
mov qword [rax], 72 ; 8 バイト

エスケープシーケンスを無視する文字列は、"" または '' で囲って表現できる。
エスケープシーケンスが有効な文字列は、`` で囲って表現できる。
(3.4.2 Character Strings)
短い (8バイト以下の) 文字列は、リトルエンディアンで解釈して数値として使用できる。
(3.4.3 Character Constants)

mov eax, '\n' ; eax に 0x6e5c を格納する
mov eax, `\n` ; eax に 0x0a を格納する

関数の作り方

System V Application Binary Interface (3.2 Function Calling Sequence)

ラベルと関数の公開

関数は、ラベルを用いてその関数が呼ばれた時に実行を開始する場所を指定することで作成する。
global ラベル を用いると、指定したラベルを標準ライブラリを含む他のファイル (プログラム) から参照できるようになる。
実行開始時に標準ライブラリから呼び出される main 関数は、この global を用いて参照できるようにしておかなければならない。

global func ; func 関数を外部から参照できるようにする
func: ; func 関数の実行開始点
	; func 関数の処理内容
	ret ; func関数から呼び出し元に戻る

レジスタの使い方

caller-save と callee-save

RAX、RCX、RDX、RSI、RDI、R8~R11 レジスタは、関数内で自由に使用し、呼び出された時の値を復元せずに関数から戻ってもよい。(caller-save)
逆に、関数内で関数を呼び出すと、これらのレジスタの値は変化する可能性がある。

RBX、RSP、RBP、R12~R15 レジスタは、関数内で使用しないか、使用した場合は関数から戻る際には関数が呼び出されたときと同じ値が格納された状態に復元しなければならない。(callee-save)
逆に、関数内から関数を呼び出しても、これらのレジスタの値は変化しないことになっている。

引数と戻り値

1個のレジスタに収まるサイズの整数である引数や返り値は、以下のレジスタに格納する。

データ レジスタ
第1引数 RDI
第2引数 RSI
第3引数 RDX
第4引数 RCX
第5引数 R8
第6引数 R9
戻り値 RAX

1個のレジスタに収まらないサイズ (構造体など)・整数以外・第7引数以降の渡し方も定義されているが、今回は使わないのでここでは省略する。

中で関数を呼び出す関数

中で関数を呼び出す関数では、呼び出された後最初に行う処理であるプロローグスタックフレームを作成し、呼び出し元に戻る直前に行う処理であるエピローグで作成したスタックフレームを破棄するのが基本である。

func:
	; プロローグ
	push rbp     ; スタックに現在の RBP の値をプッシュする
	mov rbp, rsp ; RBP にスタックポインタの値 (前の RBP の値を格納したアドレス) を格納する
	sub rsp, 16  ; スタック上にローカル変数用の領域を確保する

	; 関数の処理本体

	; エピローグ
	leave        ; スタックポインタを RBP の値に設定し、スタックから RBP に値をポップする
	ret          ; 呼び出し元に戻る

関数を呼び出す際、スタックポインタの値は 16 の倍数でなければならないことになっている。
関数を呼び出すと、戻り先のアドレスがスタックにプッシュされ、スタックポインタの値が 8 減る。
この状態でさらに RBP をプッシュすると、スタックポインタの値がさらに 8 減り、再び 16 の倍数になる。
よって、その後関数を呼び出す前にスタックポインタから減算する量 (の合計) は 16 の倍数でなければならない。
上のコード例では例として 16 を減算しているが、処理内容に応じてより大きい 16 の倍数を減算したり、逆に 0 を減算したり (または、この sub 命令を省略したり) してもいい。

このプロローグの実行後、スタックは以下の状態になる。
callee-save レジスタの値も、復元用に「ローカル変数」の領域に保存することがある。

プロローグ実行後のスタックの様子

leave 命令を用いると、このプロローグの操作の逆を行い、RBP およびスタックポインタ (RSP) の値を関数が呼び出された時の値に戻すことで、関数から戻る準備を行うことができる。
エピローグでは、この命令を用いて関数から戻る準備をした後、ret 命令により呼び出し元に戻る。

プロローグに相当する操作ができる enter 命令も存在するが、効率が悪いとされ、あまり用いられない。

中で関数を呼び出さない関数

中で関数を呼び出さない関数 (leaf functions) では、スタックフレームを作らず、直接 RSP を基準にしてローカル変数などを扱ってもよい。
このとき、RSP から 128 を引いたアドレス以上 RSP 未満の部分を red zone といい、この部分は割り込みハンドラなどに破壊されることなく用いることができることになっている。

スタックフレームを作らない関数でのスタックフレームの様子

これより大きい領域をローカル変数で用いる場合など、スタックフレームを作ってもよい。

中で関数を呼び出す関数の実行中にも red zone は存在するが、関数を呼び出す際にスタックにプッシュされる戻り先アドレスや呼び出す関数のローカル変数などによりデータが破壊されることがあるため、一般にデータの保存には適さない。

システムコール

Linux System Call Table for x86 64 · Ryan A. Chapman
System V Application Binary Interface (A.2 AMD64 Linux Kernel Conventions)

システムコールを用いることで、入出力などのシステム (OS) が用意する機能を用いることができる。
x86_64 の Linux におけるシステムコールは、以下の値を各レジスタに設定し、syscall 命令を実行することで呼び出すことができる。

レジスタ 設定する値
RAX システムコール番号
RDI 第1引数
RSI 第2引数
RDX 第3引数
R10 第4引数
R8 第5引数
R9 第6引数

レジスタに収まらないデータ・整数以外・第7引数以降は用いない。

システムコールを実行すると、RAX レジスタに結果を表す数値が格納される。
また、RCX レジスタおよび R11 レジスタの値が破壊される。

read

read(2): read from file descriptor - Linux man page

以下の内容をレジスタに格納して呼び出す。

レジスタ 設定する内容
RAX 0
RDI 読み込み元のファイルディスクリプタ
RSI 読み込んだデータを格納するメモリアドレス
RDX 読み込む最大のバイト数

指定したファイルディスクリプタに対応するファイルからデータを読み込み、メモリに格納する。
通常、標準入力に対応するファイルディスクリプタは 0 である。

成功した場合は、RAX に読み込んだバイト数が格納される。
失敗した場合は、RAX に負の値が格納される。

write

write(2): to file descriptor - Linux man page

以下の内容をレジスタに格納して呼び出す。

レジスタ 設定する内容
RAX 1
RDI 書き込み先のファイルディスクリプタ
RSI 書き込むデータが格納されたメモリアドレス
RDX 書き込む (最大の) バイト数

指定したファイルディスクリプタに対応するファイルにデータを書き込む。
通常、標準出力に対応するファイルディスクリプタは 1 である。

成功した場合は、RAX に書き込んだバイト数が格納される。
失敗した場合は、RAX に負の値が格納される。

exit

exit(2): terminate calling process - Linux man page

以下の内容をレジスタに格納して呼び出す。

レジスタ 設定する内容
RAX 60
RDI 終了ステータス

プログラム (プロセス) の実行を終了する。
終了ステータスは、0 で正常終了を、0 以外で異常終了を表す。

今回使用した命令

add

ADD — Add

add 加算先, 加算する値

「加算先」(レジスタ/メモリ) の値に「加算する値」(レジスタ/メモリ/即値) の値を加算し、和を「加算先」に格納する。
ただし、メモリ上の値に直接メモリ上の値を加算することはできない。

call

CALL — Call Procedure

call 飛び先のラベル

関数を呼び出す。
すなわち、次の命令のアドレスをスタックにプッシュし、「飛び先のラベル」に制御を移す。

cdq

CWD/CDQ/CQO — Convert Word to Doubleword/Convert Doubleword to Quadword

cdq

EAX レジスタの値を、EDX:EAX に符号拡張する。
すなわち、EAX レジスタの最上位ビットを、EDX レジスタの全ビットにコピーする。

cmp

CMP — Compare Two Operands

cmp 引かれる数, 引く数

「引かれる数」(レジスタ/メモリ) の値から「引く数」(レジスタ/メモリ/即値) の値を減算し、差は保存せずに捨てる。
主に条件分岐命令を実行する直前に実行し、数値を比較するために用いる。
メモリ上の値同士を直接減算することはできない。

dec

DEC — Decrement by 1

dec 対象

「対象」(レジスタ/メモリ) の値を 1 だけ小さくする。(デクリメントする)

idiv

IDIV — Signed Divide

idiv 割る数

(割る数が32ビットの場合) EDX:EAX を「割る数」で割り、商を EAX レジスタに、余りを EDX レジスタに格納する。
割られる数と割る数はともに符号付きの数として扱う。
「割る数」がゼロの場合、および商が格納先のレジスタに収まらない (オーバーフローを起こす) 場合、例外が発生する。

特に、割られる数を EAX レジスタのみに書き込み、EDX レジスタの設定を忘れてしまうと、エラーが発生する原因となる。
割られる数のサイズも割る数のサイズも32ビットの場合は、割られる数を EAX レジスタに書き込んだ後 cdq 命令を用いて符号拡張するとよいだろう。

imul

IMUL — Signed Multiply

imul 格納先, 掛けられる数, 掛ける数

「掛けられる数」(レジスタ/メモリ) と「掛ける数」(即値) の積を計算し、結果を「格納先」(レジスタ) に格納する。
掛けられる数と掛ける数はともに符号付きの数として扱う。

imul 命令にはオペランドが 1 個の形式および 2 個の形式も存在するが、今回は使用していないためここでは省略する。

jg / jl / jne / jnz

Jcc — Jump if Condition Is Met

jg 飛び先のラベル
jl 飛び先のラベル
jne 飛び先のラベル
jnz 飛び先のラベル

条件分岐を行う。
sub 命令や cmp 命令で $a - b$ の減算を行った直後に実行した場合、以下の条件を満たすならば「飛び先のラベル」に制御を移し、満たさないならばそのまま次の命令の実行に移る。

命令 条件
jg $a > b$
jl $a < b$
jne / jnz $a \neq b$

これらの命令は他の命令の実行結果により設定される「フラグ」に基づいて条件を満たすかを判定する。
たとえば、jne / jnz は計算結果がゼロであることを表すフラグが立っていないとき条件を満たすと判定し、立っているとき条件を満たさないと判定する。
フラグの詳細は、ここでは省略する。

jmp

JMP — Jump

jmp 飛び先のラベル

無条件で、「飛び先のラベル」に制御を移す。

lea

LEA — Load Effective Address

lea 格納先, [メモリアドレス]

「メモリアドレス」の値を「格納先」(レジスタ) に格納する。
(「メモリアドレス」が指すメモリの中身を格納するわけではない)

add 命令では加算する2個の値のうち1個は必ず書き込み先になるが、lea 命令では加算の結果を関係ないレジスタに書き込めるので、効率が良くなることがある。

leave

LEAVE — High Level Procedure Exit

leave

関数のスタックフレームを破棄する。
すなわち、以下の操作を行う。

  1. RBP レジスタの値を RSP レジスタ (スタックポインタ) に書き込む
  2. スタックから RBP レジスタに値をポップする
    1. スタックポインタが指すアドレスから RBP レジスタに値をロードする
    2. スタックポインタの値を 8 増やす

mov

MOV — Move

mov 書き込み先, 書き込む値

「書き込む値」(レジスタ/メモリ/即値) を「書き込み先」(レジスタ/メモリ) に書き込む。
メモリ上の値を直接メモリに書き込むことはできない。

push

PUSH — Push Word, Doubleword, or Quadword Onto the Stack

push 値

「値」をスタックにプッシュする。
すなわち、以下の操作を行う。

  1. RSP レジスタ (スタックポインタ) の値を 8 減らす
  2. スタックポインタが指すアドレスに「値」を書き込む

ret

RET — Return From Procedure

ret

関数から呼び出し元に戻る。
すなわち、以下の操作を行う。

  1. RSP レジスタ (スタックポインタ) が指す値から飛び先のアドレスをロードする
  2. スタックポインタの値を 8 増やす
  3. ロードした飛び先のアドレスに制御を移す

sub

SUB — Subtract

sub 減算先, 減算する値

「減算先」(レジスタ/メモリ) の値から「減算する値」(レジスタ/メモリ/即値) の値を減算し、差を「減算先」に格納する。
ただし、メモリ上の値から直接メモリ上の値を減算することはできない。

syscall

SYSCALL — Fast System Call

syscall

対応した環境において、システムコールを実行する。

test

TEST — Logical Compare

test 値1, 値2

「値1」(レジスタ/メモリ) と「値2」(レジスタ/即値) のビットAND演算を行い、結果は保存せずに捨てる。
ビットAND演算とは、入力の対応する位置のビットが両方1のとき結果のビットを1、そうでないとき結果のビットを0とする演算である。
今回は、値1と値2に同じレジスタを指定し、そのレジスタに格納されている値がゼロかどうかを判定するために用いた。

xor

XOR — Logical Exclusive OR

xor デスティネーション, ソース

「デスティネーション」(レジスタ/メモリ) と「ソース」(レジスタ/メモリ/即値) のビットXOR演算を行い、結果を「デスティネーション」に格納する。
ただし、メモリ上の値同士を直接演算することはできない。
ビットXOR演算とは、入力の対応する位置のビットのうち1であるビットが奇数個のとき結果のビットを1、そうでないとき結果のビットを0とする演算である。
今回は、デスティネーションとソースに同じレジスタを指定し、そのレジスタにゼロを格納するために用いた。

提出コード

bits 64

section .note.GNU-stack
section .text

; int read_char(void)
; 1文字(バイト)読み込み、文字コードを返す
read_char:
	xor eax, eax
	xor edi, edi
	lea rsi, [rsp - 8]
	mov edx, 1
	syscall
	cmp eax, 1
	jne read_char_error
	mov al, [rsp - 8]
	ret
read_char_error:
	mov eax, -1
	ret

; void print_char(int c)
; 1文字(バイト)出力する
print_char:
	mov [rsp - 8], edi
	mov eax, 1
	mov edi, eax
	lea rsi, [rsp - 8]
	mov edx, eax
	syscall
	ret

; int read_int(void)
; 十進数の非負整数を1個読み込む
read_int:
	push rbp
	mov rbp, rsp
	sub rsp, 16
	mov [rbp - 8], ebx
	xor ebx, ebx
read_int_loop:
	call read_char
	cmp eax, '0'
	jl read_int_end
	cmp eax, '9'
	jg read_int_end
	imul ebx, ebx, 10
	sub eax, '0'
	add ebx, eax
	jmp read_int_loop
read_int_end:
	mov eax, ebx
	mov ebx, [rbp - 8]
	leave
	ret

; void print_int(int value)
; 非負整数を十進数で出力する
print_int:
	mov eax, edi
	mov rsi, rsp
	mov ecx, 10
print_int_convert_loop:
	cdq
	idiv ecx
	add dl, '0'
	dec rsi
	mov [rsi], dl
	test eax, eax
	jnz print_int_convert_loop
	mov eax, 1
	mov edi, eax
	mov rdx, rsp
	sub rdx, rsi
	syscall
	ret

global main
main:
	push rbp
	mov rbp, rsp
	sub rsp, 16
	; 3個の数値の和を出力する
	call read_int
	mov [rbp - 8], eax
	call read_int
	add [rbp - 8], eax
	call read_int
	add eax, [rbp - 8]
	mov edi, eax
	call print_int
	; 区切りとなる空白を出力する
	mov edi, ' '
	call print_char
	; 文字列を1行出力する
main_loop:
	call read_char
	mov [rbp - 16], al
	mov edi, eax
	call print_char
	cmp byte [rbp - 16], `\n`
	jne main_loop
	; 終了する
	mov eax, 60
	xor edi, edi
	syscall

提出 #54766528 - AtCoder Beginners Selection

提出コードの各関数の解説

read_char

read システムコールを用いて標準入力から1バイト読み込む。
システムコールを実行した後、結果が格納された EAX レジスタの値をチェックする。
値が 1 ならば、期待通り1バイト読み込めたということなので、読み込んだ値を EAX レジスタに格納して返す。
(EAX レジスタの値はこの時点で 1 であることがわかっているので、下位8ビットの AL に書き込むだけで EAX 全体の値が読み込んだ値になる)
そうでなければ、エラーまたはファイルの終端に達しているので、-1 を EAX レジスタに格納して返す。

print_char

write システムコールを用いて標準出力に1バイト出力する。
レジスタから直接出力することはできないので、出力する値を一旦メモリに格納し、それを出力している。

read_int

標準入力から数値を1個読み込む。
読み込む数値は十進数であることを仮定し、半角の 0~9 以外の文字が来たら読み込みを終了する。
数字が来たら、これまでに読み込んだ結果の数値に10を掛け、新しい数字が表す数値を加算する。
文字の読み込みには read_char 関数を用いるので、数値は callee-save レジスタである EBX を用いて管理する。

print_int

数値を1個標準出力に出力する。
まず、割り算を用いて、出力する数値を文字列に変換する。
このとき、下位を表す数字から順に決まるので、決まった数字を後ろ向きにメモリに格納していく。
変換が完了したら、変換結果の文字列を write システムコールを用いて出力する。
出力する文字数は、変換結果を格納する位置を表すポインタの初期値と最終値の差を計算することで求められる。

main

まず、数値を3個読み込み、和を出力する。
このとき、読み込んだ数値の扱いは、以下のようにそれぞれ変えている。

  1. 読み込んだ数値をメモリに格納する
  2. 読み込んだ数値をメモリに格納された前回読み込んだ数値に加算する
  3. メモリに格納された前2回で読み込んだ数値の和を、レジスタ上の読み込んだ数値に加算する

次に、出力において数値と文字列を区切る空白を出力する。

そして、3行目の文字列を出力する。
読み込んだ文字を出力した後、それが改行文字であるかをチェックし、改行文字でなければ次の文字の読み込みと出力を行う。

最後に、exit システムコールを用いてプログラムの実行を終了する。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?