アーキテクチャ
一般的なコンピュータの構成要素
コンピュータの基本構造
-
コンピュータでは上図のように,CPU,メインメモリ(以後,単にメモリ), 入出力装置が,バス(bus)と呼ばれる信号線でつながっています.
-
CPUは制御装置,ALU(演算装置),レジスタから構成されています
-
バスはデジタルの信号線です. アドレスバス,データバス,制御バスがあります(図にはこの区別は書いていません).
-
上図にはキャッシュやMMUなどがありませんが, 本書の範囲ではこの図の知識で十分です.
CPUの基本構成
- 制御装置 = フェッチ実行サイクルをひたすら繰り返します
- ALU = 四則演算や論理演算などを計算します
- レジスタ
- 高速・小容量・固定長のメモリです
- 特定の役割を持つ専用レジスタ(例: プログラムカウンタ
%rip
)と, 様々な用途に使える汎用レジスタ(例:%rax
)に概ね分かれています.
メモリ
- メモリはRAMです.揮発性があります(電源が切れると記憶内容は失われます).
- メモリは巨大なバイトの配列です
- メモリのアドレスを指定して,メモリの内容を読み書きします
- バイト単位だけでなく,4バイトや8バイトなどの連続するメモリ領域も読み書きできます
- 通常,メモリには1バイトごとに連番のアドレスがつきます これをバイトアドレッシングといいます.
実際の物理アドレスには,RAMだけでなく,ROMや memory-mapped I/O (メモリのアドレスを使ってアクセスする入出力装置,例えばVRAM)も マップされています.ただし,これらは通常はユーザプロセスからは見えないので気にしなくて良いです.
フェッチ実行サイクル
-
CPUは次の動作をひたすら繰り返します
- フェッチ(fetch)
- プログラムカウンタ(
%rip
)が指す機械語命令を メモリからCPUに読み込みます - 次の機械語命令を指すように,プログラムカウンタの値を増やします
- デコード(decode)
- 読み込んだ命令を解析して,実行の準備をします
- 例えば,必要ならメモリからオペランドの値をCPUに読み込みます
- 実行(execute)
- 読み込んだ機械語命令を実行します
x86-64のレジスタ
汎用レジスタ
- 上記16個のレジスタが汎用レジスタ(general-purpose register)です. 原則として,プログラマが自由に使えます.
- ただし,
%rsp
はスタックポインタ,%rbp
はベースポインタと呼び, 一番上のスタックフレームの上下を指す という役割があります. (ただし,-fomit-frame-pointer オプションでコンパイルされたa.out
中では,%rbp
はベースポインタとしてではなく, 汎用レジスタとして使われています).
caller-save/callee-saveレジスタ
汎用レジスタ | |
---|---|
caller-saveレジスタ | %rax , %rcx , %rdx , %rsi , %rdi , %r8 〜%r11 |
callee-saveレジスタ | %rbx , %rbp , %rsp , %r12 〜%r15 |
引数
引数 | レジスタ |
---|---|
第1引数 | %rdi |
第2引数 | %rsi |
第3引数 | %rdx |
第4引数 | %rcx |
第5引数 | %r8 |
第6引数 | %r9 |
プログラムカウンタ(命令ポインタ)
ステータスレジスタ(フラグレジスタ)
本書で扱うフラグ
ステータスレジスタのうち,本書は以下の6つのフラグを扱います. フラグの値が1になることを「フラグがセットされる」「フラグが立つ」と表現します. またフラグの値が0になることを「フラグがクリアされる」「フラグが消える」と表現します.
フラグ | 名前 | 説明 |
---|---|---|
CF | キャリーフラグ | 算術演算で結果の最上位ビットにキャリーかボローが生じるとセット.それ以外はクリア.符号なし整数演算でのオーバーフロー状態を表す. |
OF | オーバーフローフラグ | 符号ビット(MSB)を除いて,整数の演算結果が大きすぎるか小さすぎるかするとセット.それ以外はクリア.2の補数表現での符号あり整数演算のオーバーフロー状態を表す. |
ZF | ゼロフラグ | 結果がゼロの時にセット.それ以外はクリア. |
SF | 符号フラグ | 符号あり整数の符号ビット(MSB)と同じ値をセット.(0は0以上の正の数,1は負の数であることを表す) |
PF | パリティフラグ | 結果の最下位バイトの値1のビットが偶数個あればセット,奇数個であればクリア. |
AF | 調整フラグ | 算術演算で,結果のビット3にキャリーかボローが生じるとセット.それ以外はクリア.BCD演算で使用する(本書ではほとんど使いません). |
CFフラグが立つ例
# asm/cf.s
.text
.globl main
.type main, @function
main:
movb $0xFF, %al
addb $1, %al # オーバーフローでCFが立つ
ret
.size main, .-main
$ gcc -g cf.s
$ gdb ./a.out
(gdb) b 8
Breakpoint 1 at 0x112d: file cf.s, line 8.
(gdb) r
Breakpoint 1, main () at cf.s:8
8 ret
(gdb) p $al
$1 = ❶ 0
(gdb) p $eflags
$2 = [ ❷ CF PF AF ZF IF ]
(gdb) quit
movb $0xFF, %al
とaddb $1, %al
で,1バイト符号なし整数の加算0xFF+1
をすると,オーバーフローが起きて%al
の値は❶ 0になります. (1バイト符号なし整数の範囲は0〜255です.0xFF+1
は255+1=256となり (1バイト符号なし整数として)オーバーフローが起きています).p $eflags
でステータスフラグを調べると,❷ CF フラグが立っています.
OFフラグが立つ例
# asm/of.s
.text
.globl main
.type main, @function
main:
movb $0x7F, %al
addb $1, %al # オーバーフローでOFが立つ
ret
.size main, .-main
$ gcc -g of.s
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1129: file of.s, line 6.
(gdb) r
Breakpoint 1, main () at of.s:6
6 movb $0x7F, %al
(gdb) si
7 addb $1, %al # オーバーフローでOFが立つ
(gdb) si
main () at of.s:8
8 ret
(gdb) p $al
$1 = ❶ -128
(gdb) p $eflags
$1 = [ AF SF IF ❷ OF ]
(gdb) ❸ p/u $al
$2 = ❹ 128
(gdb) quit
movb $0x7F, %al
とaddb $1, %al
で,1バイト符号あり整数の加算0x7F+1
をすると,オーバーフローが起きて%al
の値は❶ -128になります. (1バイト符号あり整数の範囲は-128〜127です.0x7F+1
は127+1=128となり (1バイト符号あり整数として)オーバーフローが起きています).p $eflags
でステータスフラグを調べると,❷ OF フラグが立っています.- なお,符号なし(❸
u
)オプションをつけて%al
レジスタの値を表示させると, 符号なしの結果として正しい❹ 128という結果になりました. (x86-64は符号なし・符号ありを区別せず,どちらに対しても正しい結果を 計算します). - ここで説明する通り, 符号あり整数のオーバーフローは未定義動作になるので, 符号あり整数のオーバーフローを起こすプログラムは書いてはいけません.
レジスタの別名
%rax
レジスタの別名 (%rbx
, %rcx
, %rdx
も同様)
%rax
の下位32ビットは%eax
としてアクセス可能%eax
の下位16ビットは%ax
としてアクセス可能%ax
の上位8ビットは%ah
としてアクセス可能%ax
の下位8ビットは%al
としてアクセス可能
%raxに値を入れて,%eax, %ax, %ah, %alにアクセスする例
# asm/reg.s
.text
.globl main
.type main, @function
main:
movq $0x1122334455667788, %rax # ❶
ret
.size main, .-main
$ gcc -g reg.s
$ gdb ./a.out
(gdb) b main
Breakpoint 1 at 0x1129: file reg.s, line 6.
(gdb) r
Breakpoint 1, main () at reg.s:6
6 movq $0x1122334455667788, %rax
(gdb) si
main () at reg.s:7
7 ret
(gdb) p/x $rax
$1 = ❷ 0x1122334455667788
(gdb) p/x $eax
$2 = ❸ 0x55667788
(gdb) p/x $ax
$3 = ❹ 0x7788
(gdb) p/x $ah
$4 = ❺ 0x77
(gdb) p/x $al
$5 = ❻ 0x88
(gdb) q
%rax
レジスタに❶0x1122334455667788
を入れると, 当たり前ですが%rax
レジスタには❷0x1122334455667788
が入っています.%eax
には(%rax
の下位4バイトなので)❸0x55667788
が入っています.%ax
には(%rax
の下位2バイトなので)❹0x7788
が入っています.%ah
には(%ax
の上位1バイトなので)❺0x77
が入っています.%al
には(%ax
の下位1バイトなので)❻0x88
が入っています.
%rbp
レジスタの別名 (%rsp
, %rdi
, %rsi
も同様)
%rbp
の下位32ビットは%ebp
としてアクセス可能%ebp
の下位16ビットは%bp
としてアクセス可能%bp
の下位8ビットは%bpl
としてアクセス可能
%r8
レジスタの別名 (%r9
〜%r15
も同様)
%r8
の下位32ビットは%r8d
としてアクセス可能%r8d
の下位16ビットは%r8w
としてアクセス可能%r8w
の下位8ビットは%r8b
としてアクセス可能
同時に使えない制限
- 一部のレジスタは
%ah
,%bh
,%ch
,%dh
と一緒には使えない. - 例:
movb %ah, (%r8)
やmovb %ah, %bpl
はエラーになる. - 正確には
REX
プリフィクス付きの命令では,%ah
,%bh
,%ch
,%dh
を使えない.
32ビットレジスタ上の演算は64ビットレジスタの上位32ビットをゼロにする
- 例:
movl $0xAABBCCDD, %eax
を実行すると%rax
の上位32ビットが全てゼロになる - 例:
movw $0x1122, %ax
やmovb $0x11, %al
では上位をゼロにすることはない
上位32ビットをゼロにする実行例
# asm/zero-upper32.s
.text
.globl main
.type main, @function
main:
movq $0x1122334455667788, %rax
movl $0xAABBCCDD, %eax
movq $0x1122334455667788, %rax
movw $0x1122, %ax
movq $0x1122334455667788, %rax
movb $0x11, %al
ret
.size main, .-main
# zero-upper32.txt
b 7
r
list 6,7
p/z $rax
si
p/z $rax
echo # 以下が出力されれば成功\n
echo # $1 = 0x1122334455667788 (%raxは8バイトの値を保持)\n
echo # $2 = 0x00000000aabbccdd (%raxの上位4バイトがゼロになった)\n
quit
$ gcc -g zero-upper32.s
$ gdb ./a.out -x zero-upper32.txt
Breakpoint 1, main () at zero-upper32.s:7
7 movl $0xAABBCCDD, %eax
6 movq $0x1122334455667788, %rax
7 movl $0xAABBCCDD, %eax
$1 = 0x1122334455667788
8 movq $0x1122334455667788, %rax
$2 = 0x00000000aabbccdd
# 以下が出力されれば成功
# $1 = 0x1122334455667788 (%raxは8バイトの値を保持)
# $2 = 0x00000000aabbccdd (%raxの上位4バイトがゼロになった)
オペレーティングシステムの存在
OSは邪魔!?
- アセンブリ言語の利点はCPUや 周辺機器のハードウェア(入出力装置)がよく見えることです
- でもユーザプロセス(皆さんが普通に実行しているプログラム)は
OS上で動作するので,OSはCPUやハードウェアの詳細を見せない働きをします
- この抽象化のおかげで,通常のアプリケーションを開発する際に, CPUやハードウェアの詳細を気にすること無くプログラミングできるわけですが.
- 例えば,OSは以下を隠しています.以下でもう少し詳しく説明します.
- OSによるマルチタスク処理: ユーザプロセスはCPUを専有しているように見えます.
- ハードウェアの詳細(例: ハードディスク): ユーザプロセスはシステムコール経由でハードウェア等にアクセスします.
- 物理メモリ: ユーザプロセスは仮想メモリのみアクセス可能で, 物理メモリへの直接アクセスはできません.
- 割り込み (interrupt): ハードウェアがCPUに非同期的に(asyncronously)送る信号が割り込みです. ユーザプロセスが割り込みを直接受け取ることはありません.
OSによるマルチタスク処理
- ユーザプロセスから見ると「ずっとCPUやレジスタを専有している」ように見えます.
- メモリはユーザプロセスごとに割り当てられますが,
CPUの数は少ないので,OSはCPU上で実行するユーザプロセスを定期的に切り替えています.
- このユーザプロセスの切り替えにはタイマー割り込みを使っています. タイマー割り込みが発生すると,OSがブート時に設定した割り込みハンドラを CPUが自動的に起動します.
- その際,ユーザプロセスが使っていたCPUのレジスタ値はメモリに退避します. 実行再開時にはレジスタ値をメモリから回復します.
- ユーザプロセスのプログラムを書く時は, マルチタスクのことを気にする必要はありません(ただしリアルタイムシステムなどは除く).
システムコール
- ユーザプロセスは直接,周辺機器のハードウェアを操作できません.
ユーザプロセスが直接操作するのは面倒だし危険なので,
通常,OSが「ユーザプロセスによるハードウェア操作」を禁止しているからです.
- (Linuxではシステムコール
ioperm
やiopl
を使って, この禁止を解除できますが,本書では説明しません).
- (Linuxではシステムコール
- そのため,ユーザプロセスはシステムコールを使って,
ハードウェア操作をカーネル(OS本体)に依頼します.
- ユーザプロセスが動作するアドレス空間をユーザ空間, カーネルが動作するアドレス空間をカーネル空間と呼びます. カーネル空間ではCPUの特権命令の実行やハードウェア操作が可能です.
printf
などのライブラリ関数の呼び出しにはcall
命令とret
命令を使います. 一方,write
などのシステムコールの呼び出しは トラップ命令(ソフトウェア割り込み命令)である,syscall
/sysret
,sysenter
/sysexit
,int
/iret
などを使います. システムコールの呼び出しにはユーザ空間からカーネル空間への切り替えが 必要だからです.
- システムコール内では入出力命令(
in
,out
,mov
)を実行することで ハードウェアの操作を行います. ハードウェア側から来る割り込みは,予めOSが設定した割り込みハンドラが対処します. ユーザ空間ではCPUの特権命令を実行できないので, ユーザプロセス内では(ioperm
やiopl
を使わない限り)これらの操作をできません.
仮想メモリ
- OSはx86-64のページング機能などを使って仮想メモリを有効化しています.
- 上図でプロセスAとプロセスBは物理メモリの一部を仮想メモリとして (仮想アドレスを使って)アクセスできる状態です.
printf
はプロセスAとプロセスBで共有しています.- 共有している
printf
以外は,プロセスはお互いに他のプロセスのメモリの中身にアクセスできません.
-
仮想メモリが有効な状態では, CPUが扱うアドレス(
%rip
やメモリ参照でアクセスするアドレス) はすべて仮想アドレスです. CPU上で動作するプログラム(a.out
)は 物理メモリのアドレスを使ってのアクセスはできません. -
仮想アドレスから物理アドレスへの変換:
- OSは「仮想アドレスと物理アドレスの対応表」であるページテーブルを 物理メモリ上で管理します.
- 実際の変換はCPUとバスの間にあるMMU(memory management unit)が高速に行います. MMUはCPUから仮想アドレスを受け取り,それを物理アドレスに変換してから, バス上で物理アドレスを使って物理メモリにアクセスします.
割り込みとシグナル
- CPUはバスを介して周辺機器とつながってます. 例えば,キーボードのキーを押すと,キーを押すたび,離すたびに, CPUに割り込みを伝えます.
- 割り込み(interrupt)は周辺機器側からCPUに非同期的(asynchronously)に 送る信号です.
- CPUは割り込みを受け取ると,(ブート時にOSが設定した)割り込みハンドラを自動的に 起動して,その割り込みに対処します.
- 一部の割り込みはユーザプロセスにUNIXシグナルとして配送されます.
例えば,ユーザプロセスはタイマー割り込みを直接,受け取ることはできませんが,
(
alarm
やsetitimer
などのシステムコールを使えば)SIGALRM
というUNIXシグナルを受け取ることができます.