アーキテクチャ

一般的なコンピュータの構成要素

コンピュータの基本構造

  • コンピュータでは上図のように,CPUメインメモリ(以後,単にメモリ), 入出力装置が,バス(bus)と呼ばれる信号線でつながっています.

  • CPUは制御装置ALU(演算装置),レジスタから構成されています

  • バスはデジタルの信号線です. アドレスバス,データバス,制御バスがあります(図にはこの区別は書いていません).

  • 上図にはキャッシュMMUなどがありませんが, 本書の範囲ではこの図の知識で十分です.

CPUの基本構成

  • 制御装置 = フェッチ実行サイクルをひたすら繰り返します
  • ALU = 四則演算や論理演算などを計算します
  • レジスタ
    • 高速・小容量・固定長のメモリです
    • 特定の役割を持つ専用レジスタ(例: プログラムカウンタ%rip)と, 様々な用途に使える汎用レジスタ(例: %rax)に概ね分かれています.

メモリ

  • メモリはRAMです.揮発性があります(電源が切れると記憶内容は失われます).
  • メモリは巨大なバイトの配列です
    • メモリのアドレスを指定して,メモリの内容を読み書きします
    • バイト単位だけでなく,4バイトや8バイトなどの連続するメモリ領域も読み書きできます
  • 通常,メモリには1バイトごとに連番のアドレスがつきます これをバイトアドレッシングといいます.

実際の物理アドレスには,RAMだけでなく,ROMや memory-mapped I/O (メモリのアドレスを使ってアクセスする入出力装置,例えばVRAM)も マップされています.ただし,これらは通常はユーザプロセスからは見えないので気にしなくて良いです.

フェッチ実行サイクル

  • CPUは次の動作をひたすら繰り返します

    1. フェッチ(fetch)
    • プログラムカウンタ(%rip)が指す機械語命令を メモリからCPUに読み込みます
    • 次の機械語命令を指すように,プログラムカウンタの値を増やします
    1. デコード(decode)
    • 読み込んだ命令を解析して,実行の準備をします
    • 例えば,必要ならメモリからオペランドの値をCPUに読み込みます
    1. 実行(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, %aladdb $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, %aladdb $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, %axmovb $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ではシステムコールiopermioplを使って, この禁止を解除できますが,本書では説明しません).
  • そのため,ユーザプロセスはシステムコールを使って, ハードウェア操作をカーネル(OS本体)に依頼します.
    • ユーザプロセスが動作するアドレス空間をユーザ空間, カーネルが動作するアドレス空間をカーネル空間と呼びます. カーネル空間ではCPUの特権命令の実行やハードウェア操作が可能です.
    • printfなどのライブラリ関数の呼び出しにはcall命令とret命令を使います. 一方,writeなどのシステムコールの呼び出しは トラップ命令(ソフトウェア割り込み命令)である, syscall/sysretsysenter/sysexitint/iretなどを使います. システムコールの呼び出しにはユーザ空間からカーネル空間への切り替えが 必要だからです.
  • システムコール内では入出力命令(in, out, mov)を実行することで ハードウェアの操作を行います. ハードウェア側から来る割り込みは,予めOSが設定した割り込みハンドラが対処します. ユーザ空間ではCPUの特権命令を実行できないので, ユーザプロセス内では(iopermioplを使わない限り)これらの操作をできません.

仮想メモリ

  • 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シグナルとして配送されます. 例えば,ユーザプロセスはタイマー割り込みを直接,受け取ることはできませんが, (alarmsetitimerなどのシステムコールを使えば) SIGALRMというUNIXシグナルを受け取ることができます.