20260608_2


メモリ操作の基礎

$sp(スタックポインタ)

スタックポインタは「今スタックのどこにいるか」を示すレジスタ。

スタックはアドレスが小さい方向に伸びる:

アドレス大的  ┌─────────────┐
              │  まだ使えない  │
              ├─────────────┤ ← $sp(現在のトップ)
              │  空き領域     │
              ├─────────────┤
              │  データ3      │ ← 以前pushしたもの
              ├─────────────┤
              │  データ2      │
              ├─────────────┤
              │  データ1      │
              └─────────────┘
アドレス小的

push/pop の動き:

【push 5】                 【pop】
$sp -= 4                  $t0 = 0($sp)  ← 5を読み取り
0($sp) = 5                $sp += 4      ← $spを戻す

sw と lw(メモリ書き込み・読み出し)

命令意味
swStore Word:メモリに書き込むsw $ra, 0($sp)
lwLoad Word:メモリから読み出すlw $ra, 0($sp)

構文:sw レジスタ, オフセット(ベースレジスタ)

sw $ra, 0($sp)    # $raの中身を、$spが指すアドレスに書き込む
lw $ra, 0($sp)    # $spが指すアドレスの中身を、$raに読み込む

1ワード = 4バイトなので、次のデータは +4 の位置:

sw $ra, 0($sp)    # アドレス $sp+0 に $ra を保存
sw $a0, 4($sp)    # アドレス $sp+4 に $a0 を保存
lw $ra, 0($sp)    # $sp+0 から $ra に復元
lw $a0, 4($sp)    # $sp+4 から $a0 に復元

フレームとは

フレーム = 1つの関数が使うスタックの領域。

mainのフレーム              fact(4)のフレーム
┌─────────────┐            ┌─────────────┐
│  $ra        │ ← mainの   │  $ra        │ ← fact(4)の
│             │   リターン先 │             │   リターン先
│  局所変数   │            │  引数n=4    │
└─────────────┘            └─────────────┘
       ↓                           ↓
   $sp(現在地)             $sp(現在地)

関数呼び出しのたびにフレームが積み重なる:

main() → fact(4) → fact(3) → fact(2) → fact(1)

スタックの状態:
┌─────────────┐
│ fact(1)     │ ← $sp(一番上)
├─────────────┤
│ fact(2)     │
├─────────────┤
│ fact(3)     │
├─────────────┤
│ fact(4)     │
├─────────────┤
│ main        │ ← 最初に積まれたもの
└─────────────┘

リターンするたびにフレームが消える。fact(1)がリターン→fact(1)のフレーム削除、fact(2)がリターン→fact(2)のフレーム削除、…と順番に元に戻る。

なぜ退避が必要か

レジスタは関数ごとに使い回される。呼び出し先で $a0 が書き換えられたら、元の値がわからなくなる。

main:  li $a0, 4       # $a0 = 4(factの引数)
       jal fact         # fact(4)を呼ぶ
       # ここで $a0 は fact の中で壊されている

退避 = 一旦メモリに保存しておいて、後で戻す:

sw $a0, 0($sp)    # 保存(push)
... 中で $a0 が壊されてもOK ...
lw $a0, 0($sp)    # 復元(pop)

レジスタ退避のルール

callee-save レジスタ($s0〜$s7$ra

関数の入口で退避 → 出口で復元(1回ずつ)。

┌─ 関数入口 ─┐         ┌─ 関数出口 ─┐
│ sw $s0,..  │         │ lw $s0,..  │
│ sw $ra,..  │ ←対→   │ lw $ra,..  │
└────────────┘         └────────────┘

呼び出し元がこのレジスタを使っている可能性があるため、元の値を壊さないように守る。

caller-save レジスタ($t0〜$t9$a0〜$a3$v0〜$v1

jal のたびに退避・復元する。

main:
    sw   $t0, 0($sp)     # 保存(jal前)
    li   $a0, 1
    jal  fun             # $t0 が壊れる
    lw   $t0, 0($sp)     # 復元(jal後)

呼び出し先がこのレジスタを自由に使ってもいいルール。

まとめ

レジスタタイプ退避タイミング
$s0〜$s7callee関数入口/出口(1回ずつ)
$racallee関数入口/出口(1回ずつ)
$t0〜$t9callerjal のたびに
$a0〜$a3callerjal のたびに
$v0〜$v1callerjal のたびに

関数呼び出しの基本

引数と返値の受け渡し

  • 引数:$a0〜$a3 に順に入れる(5引数目以降はスタックにpush)
  • 返値:$v0 に入れて返す

関数の入口・出口テンプレート

関数名:
    # === 入口(フレーム作成 + 退避) ===
    addi $sp, $sp, -8      # フレーム確保
    sw   $ra, 4($sp)       # $ra を退避(jalを使うから)
    sw   $a0, 0($sp)       # 引数nを退避(後で使うから)
 
    # === 関数本体 ===
    ...
    jal  他の関数
    ...
 
    # === 出口(復元 + フレーム解放) ===
    lw   $a0, 0($sp)       # 引数nを復元
    lw   $ra, 4($sp)       # $ra を復元
    addi $sp, $sp, 8       # フレーム解放
    jr   $ra               # リターン

絶対に守るルール:

  • 入口で退避したら、必ず出口で復元
  • $sp-N したら、出口では +N して戻す
  • 入口と出口の $sp の操作は必ず対になる

関数呼び出しによる文字列コピー

C言語の元のコード

void my_strcpy(char *q, char *p) {
    while ((*q++ = *p++) != '\0');
}
 
char from[4], to[4];
 
int main(void) {
    fgets(from, 4, stdin);
    my_strcpy(to, from);
    printf("%s\n", to);
}

関数定義(呼び出し側とは独立)

my_strcpy:              # q=$a0(コピー先), p=$a1(コピー元)
loop:
    lb   $t0, 0($a1)    # pから1バイト読み込み → $t0
    sb   $t0, 0($a0)    # $t0 を q に書き込み
    addi $a1, $a1, 1    # p++
    addi $a0, $a0, 1    # q++
    bne  $t0, 0, loop   # \0 じゃなければループ
    jr   $ra

my_strcopyはフレームが不要。関数内で他の関数を呼ばないため $ra の保護が不要で、$s も使わないため退避も不要。

呼び出し側(main)

.text
.globl main
main:
    # 入口($ra退避)
    addi $sp, $sp, -4
    sw   $ra, 0($sp)
 
    # fgets相当:fromの先頭アドレスを$a0に
    la   $a0, from
    li   $a1, 4
    jal  read_str
 
    # my_strcpy相当:to と from を渡す
    la   $a0, to
    la   $a1, from
    jal  my_strcpy
 
    # printf相当:to を渡す
    la   $a0, to
    jal  print_str
 
    # 出口($ra復元)
    lw   $ra, 0($sp)
    addi $sp, $sp, 4
    jr   $ra
 
.data
from: .space 4
to:   .space 4

mainのフレーム:

┌─────────────┐
│ $ra         │ ← $sp+0(リターン先を保存)
└─────────────┘
    $sp ← -4 して確保

fromto.data にある大域変数なので la でアドレスを取得。局所配列にする場合はスタック上に確保する(add $a0, $sp, $zero でアドレスを計算)。

局所配列版のフレーム

main のフレーム(局所配列版):
┌─────────────┐
│ $ra         │ ← $sp+8
├─────────────┤
│ to[0-3]     │ ← $sp+4
├─────────────┤
│ from[0-3]   │ ← $sp+0
└─────────────┘
    $sp ← -12 して確保
main:
    addi $sp, $sp, -12      # 3ワード分確保(4+4+4)
    sw   $ra, 8($sp)        # $ra を退避
    add  $a0, $sp, $zero    # from の先頭アドレス
    li   $a1, 4
    jal  read_str
    addi $a0, $sp, 4        # to の先頭アドレス
    add  $a1, $sp, $zero    # from の先頭アドレス
    jal  my_strcpy
    addi $a0, $sp, 4        # to の先頭アドレス
    jal  print_str
    lw   $ra, 8($sp)
    addi $sp, $sp, 12
    jr   $ra

再帰呼び出し

階乗 n! の再帰版

int fact(int n) {
    if (n == 1) return 1;
    else return n * fact(n - 1);
}

再帰も普通の関数呼び出しと同じ。特別なことは何もない。

fact:
    # 入口
    addi $sp, $sp, -8       # 2ワード確保
    sw   $ra, 4($sp)        # リターンアドレス退避
    li   $t0, 1
    bne  $a0, $t0, else     # n != 1 なら分岐
 
    # n == 1 の場合
    move $v0, $t0           # 返値 = 1
    j    exit
 
else:
    sw   $a0, 0($sp)        # 引数nを退避
    addi $a0, $a0, -1       # n-1 を引数に設定
    jal  fact               # fact(n-1) を呼ぶ
    lw   $a0, 0($sp)        # n を復元
    mul  $v0, $a0, $v0      # 返値 = n * fact(n-1)
 
exit:
    # 出口
    lw   $ra, 4($sp)        # リターンアドレス復元
    addi $sp, $sp, 8        # フレーム解放
    jr   $ra

なぜ $a0 を退避するのか

fact(3) を呼んだ場合の流れ:

fact(3) 実行中
├── $a0 = 3(今の引数)
├── sw $a0, 0($sp)  ← 3 を退避
├── $a0 = 2 にして jal fact
│   ├── fact(2) 実行中
│   │   ├── $a0 = 2(今の引数)
│   │   ├── sw $a0, 0($sp)  ← 2 を退避
│   │   ├── $a0 = 1 にして jal fact
│   │   │   ├── fact(1) → 返値1
│   │   │   └── return 1
│   │   ├── lw $a0, 0($sp)  ← 2 を復元!
│   │   └── mul $v0, 2, 1 = 2
│   └── return 2
├── lw $a0, 0($sp)  ← 3 を復元!
└── mul $v0, 3, 2 = 6

退避しなかった場合:jal fact の後 $a0 は fact の中で 1 に変わってしまう。n * fact(n-1)n がわからなくなる。

fact(3) 実行中のスタック

┌─────────────┐
│ fact(1)     │ $ra と $a0=1
├─────────────┤
│ fact(2)     │ $ra と $a0=2
├─────────────┤
│ fact(3)     │ $ra と $a0=3
├─────────────┤
│ main        │ $ra
└─────────────┘
    $sp

各フレームに別々の $a0 が保存されている。これが再帰の仕組み。

再帰の核心

再帰 = 関数が自分自身を呼ぶ
  → 普通の関数呼び出しと同じ
  → フレームが積み重なる
  → 各フレームに引数の退避値が残る
  → リターンするたびにフレームが消えて、前の引数に戻る

偶奇判定(再帰版)

C言語のコード

int even(int n) {
    if (n == 0) return 1;    // 0は偶数
    else return odd(n - 1);   // n-1が偶数ならnも偶数
}
 
int odd(int n) {
    if (n == 0) return 0;    // 0は奇数じゃない
    else return even(n - 1);  // n-1が奇数ならnも奇数
}

evenodd を呼び、oddeven を呼ぶ。お互いに呼び合いながら 1 ずつ減っていく。

アセンブリ(通常版)

even:
    bne  $a0, $zero, else1    # n != 0 なら分岐
    li   $v0, 1               # n == 0 → return 1(偶数)
    jr   $ra
else1:
    addi $sp, $sp, -4         # フレーム確保
    sw   $ra, 0($sp)          # $ra 退避
    addi $a0, $a0, -1         # n-1 を引数に
    jal  odd                  # odd(n-1) を呼ぶ
    lw   $ra, 0($sp)          # $ra 復元
    addi $sp, $sp, 4          # フレーム解放
    jr   $ra                  # odd の返値をそのまま返す
 
odd:
    bne  $a0, $zero, else2
    li   $v0, 0               # n == 0 → return 0(奇数じゃない)
    jr   $ra
else2:
    addi $sp, $sp, -4
    sw   $ra, 0($sp)
    addi $a0, $a0, -1
    jal  even
    lw   $ra, 0($sp)
    addi $sp, $sp, 4
    jr   $ra

odd(3) の呼び出しと帰還の流れ

odd(3) を呼ぶ
├── $a0 = 3, 3 ≠ 0 なので else2 へ
├── $a0-- → 2, jal even
│   even(2)
│   ├── $a0 = 2, 2 ≠ 0 なので else1 へ
│   ├── $a0-- → 1, jal odd
│   │   odd(1)
│   │   ├── $a0 = 1, 1 ≠ 0 なので else2 へ
│   │   ├── $a0-- → 0, jal even
│   │   │   even(0)
│   │   │   └── return 1  ← 基底ケース(0は偶数)
│   │   │       odd(1) は even の返値をそのまま返す → return 1
│   │   └── return 1
│   │       even(2) は odd の返値をそのまま返す → return 1
│   └── return 1
│       odd(3) は even の返値をそのまま返す → return 1
└── odd(3) の返値 = 1(= 3は奇数)

odd(2) の場合は最終的に even(0) → return 1 → odd(1) で return 0 → even(2) で return 0 → odd(2) の返値 = 0(= 2は奇数じゃない)。

返値がそのまま伝わる理由

odd も even も、呼び出し先の返値をいじらずにそのまま返している
「3は奇数?」→「2は偶数?」→「1は奇数?」→「0は偶数?」→「はい」
その「はい」が一番上まで伝わっていく。比喩的に言えば、問い合わせが一番下まで届き、答えがそのまま戻ってくる。

入力even の返値odd の返値意味
0100は偶数、奇数じゃない
1011は奇数、偶数じゃない
2102は偶数、奇数じゃない
3013は奇数、偶数じゃない

fact(3) 実行中のスタック

通常版では各呼び出しのたびにフレームが積み重なる:

┌──────────────┐
│ odd(1) のframe │ ← $sp
├──────────────┤
│ even(2)のframe │
├──────────────┤
│ odd(3) のframe │
├──────────────┤
│  main の frame │
└──────────────┘

末尾呼び出しの除去

通常版の問題点

jal + jr $ra のペアが毎回発生 → スタックが n に比例して伸びる。

除去版:jalj に置き換える

even:
    bne  $a0, $zero, else1
    li   $v0, 1
    jr   $ra
else1:
    addi $a0, $a0, -1
    j    odd              # jal → j に変更
 
odd:
    bne  $a0, $zero, else2
    li   $v0, 0
    jr   $ra
else2:
    addi $a0, $a0, -1
    j    even             # jal → j に変更

なぜ jalj で OK か

通常版では jal で飛び、jr $ra で戻ってからすぐに jr $ra する。つまり戻った直後にリターンするだけのパターン。この場合、jalj(一飛び)に置き換え、jr $ra を省略できる。

除去版では even と odd の間を j でループする。実質的にスタックを使わないループと同じ。

通常版除去版
命令jal + jr $raj のみ
スタック消費n に比例なし
フレーム数n 個0 個

odd(3) の除去版の流れ:

odd(3) → j even(2) → j odd(1) → j even(0) → return 1
    odd(1) は even の返値をそのまま返す → 1
    even(2) は odd の返値をそのまま返す → 1
odd(3) は even の返値をそのまま返す → 1

スタックが一切増えない。even と odd の間をループしているだけ。


局所配列とスタック

大域変数 vs 局所配列

配列をメモリに置く方法は2種類ある:

大域変数(.data局所配列(スタック)
定義場所.data セグメント関数のフレーム内
アドレス取得la $a0, fromadd $a0, $sp, $zero
ライフサイクルプログラム全体関数呼び出し中のみ
再帰で使える?使えない(上書きされる)使える(各フレームに個別)

大域変数版

.data
from: .space 4
to:   .space 4
 
main:
    addi $sp, $sp, -4
    sw   $ra, 0($sp)
    la   $a0, from       # from の先頭アドレスを取得
    li   $a1, 4
    jal  read_str
    la   $a0, to
    la   $a1, from
    jal  my_strcpy
    la   $a0, to
    jal  print_str
    lw   $ra, 0($sp)
    addi $sp, $sp, 4
    jr   $ra

局所配列版

main:
    addi $sp, $sp, -12      # 3ワード分確保(from + to + $ra)
    sw   $ra, 8($sp)        # $ra を退避
    add  $a0, $sp, $zero    # from の先頭アドレス($sp+0)
    li   $a1, 4
    jal  read_str
    addi $a0, $sp, 4        # to の先頭アドレス($sp+4)
    add  $a1, $sp, $zero    # from の先頭アドレス($sp+0)
    jal  my_strcpy
    addi $a0, $sp, 4        # to の先頭アドレス($sp+4)
    jal  print_str
    lw   $ra, 8($sp)        # $ra 復元
    addi $sp, $sp, 12       # フレーム解放
    jr   $ra

フレームの構造

局所配列版の main のフレーム:

$sp+8  ┌─────────────┐
       │   $ra        │ ← リターンアドレス
$sp+4  ├─────────────┤
       │  to[0-3]     │ ← コピー先配列(4バイト)
$sp+0  ├─────────────┤
       │ from[0-3]    │ ← コピー元配列(4バイト)
       └─────────────┘
          $sp

ポイント:

  • la ではなく add でアドレスを計算する($sp からオフセットを足す)
  • 局所配列はデータセグメントではなくスタックメモリに配置
  • もし配列の範囲を超えて書き込むと、$ra が壊れてリターン先がわからなくなる

再帰で局所配列が重要的な理由

大域変数は程序全体で1つだけ。再帰呼び出しで上書きされてしまう:

fact(3) が from を使う → fact(2) も from を使う → 上書き!

局所配列なら各フレームに個別に存在する:

fact(3) のフレーム → from が $sp+0 にある
fact(2) のフレーム → from が別の $sp+0 にある(別物)

このため、再帰的な関数では局所変数をスタック上に配置する必要がある。


ここからがペーパーテストの範囲

ノイマン型計算機

  • プログラム内蔵方式
  • 逐次実行方式
  • 1つのプロセッサとメモリ
  • 逐次実行の関係でほとんどの回路が休むことになってしまう

古典的なRISC型CPUのステージ

  1. 命令フェッチ(IFステージ)
    • メインメモリ/キャッシュメモリから命令を読み出す
  2. 命令デコード/レジスタフェッチ(IDステージ)
    • 命令でコードとソースオペランドレジスタの読み出しを並行して行う
  3. 実行(EXステージ)
    • 演算の実行もしくは実行アドレス/分岐アドレスの計算、分岐条件の判定のいずれか、プログラムカウンタの設定も行う
  4. メモリアクセス(MAステージ)
    • road/storeにおける実行アドレスのオペランドデータにアクセスする
  5. レジスタへのライトバック(WBステージ)
    • 結果をレジスタへ格納する。次のクロックでレジスタフェッチ可能

lw $t0,4($sp)の場合

  1. 命令を読み出す
  2. lwを認識、$spをフェッチ
  3. $sp+4を計算
  4. メモリから値を読み出す
  5. $t0に値を格納

sw $t0,4($sp)の場合

  1. 命令を読み出す
  2. swを認識、$sp$t0をフェッチ
  3. $sp+4を計算
  4. メモリに書き込む
  5. 未使用

add $t0,$t1,$t2の場合

  1. 命令を読み出す
  2. addを認識、$t1$t2をフェッチ
  3. $t1+$t2を計算(加算実行)
  4. 未使用(演算結果をWBへ素通り)
  5. $t0に格納

beq $t0,$t1,loopの場合

  1. 命令を読み出す
  2. beqを認識、$t0$t1をフェッチ
  3. $t0$t1を比較し、条件成立ならPCに分岐先アドレスをセット
  4. 未使用
  5. 未使用

シングルクロック方式では最も実行時間の長い命令に合わせてクロックサイクルを決定する

マルチサイクル方式の場合は最も長いステージに合わせてクロックサイクルを決定する