プログラムとデータはなぜ同じメモリに置けるのか

前回はフォン・ノイマンアーキテクチャの全体像——五大コンポーネントと3本のバスを見てきました。

今回は、その中でも最も革新的な考え方に焦点を当てます:プログラム命令を特別なデータとして扱い、データと一緒に同じメモリに格納すること。


身近な例え:楽譜と音楽プレーヤー

2つの異なる音楽機器を想像してみてください:

機器A:オルゴール

ゼンマイ仕掛けのオルゴールには、内部に凸点がびっしりと並んだ金属製のシリンダーがあります。各凸点がスチールの櫛歯を弾いて1つの音符を鳴らします。

曲を変えたい?オルゴールを分解して、凸点の並びが異なる別のシリンダーに交換する必要があります。シリンダー自体が「プログラム」と「演奏器」を物理的に融合させたものです。

機器B:スマートフォンの音楽アプリ

スマホには何百ものMP3ファイルが保存されています。タップした曲がそのまま再生されます。

MP3ファイル(プログラム/データ)とプレーヤーアプリ(実行器)は分離しています——曲を変えるにはファイルを変えるだけでよく、スマホを替える必要はありません。

オルゴールはフォン・ノイマン以前のコンピュータです:プログラムとハードウェアが結びついており、タスクを変えることはマシンを変えることと同じです。

スマートフォンはフォン・ノイマン以後のコンピュータです:プログラムはメモリ内の交換可能な内容であり、ハードウェアは汎用の実行プラットフォームです。

比較の観点オルゴール(外部プログラム制御)スマートフォン(プログラム内蔵方式)
プログラムの格納方法機械構造に固定されているデータとしてメモリ内に存在する
曲の変更分解してシリンダーを交換画面をタップするだけ
ハードウェアは汎用かオルゴール1台 = 曲1曲スマホ1台 = 無限の曲
柔軟性ほぼゼロ極めて高い

フォン・ノイマンがやったのはこの1つのことです:「楽譜」をハードウェアから取り出し、自由に置き換えられる「データファイル」に変えたのです。これ以降、ハードウェアはハードウェア、ソフトウェアはソフトウェアとなりました。


歴史的背景:「外部プログラム制御」から「プログラム内蔵方式」へ

2.1 フォン・ノイマン以前のコンピュータ

1940年代前半には、すでにいくつかの電子コンピュータが実用化されていましたが、それらには共通の問題がありました:

ENIAC(1946年)——世界初の汎用電子コンピュータで、演算速度は驚異的でしたが、計算タスクを変えるたびに、エンジニアは数日かけて数百本のケーブルを手動で抜き差しし、数千のスイッチを設定することで「再プログラミング」を行う必要がありました。

Harvard Mark I(1944年)——プログラムの入力にはパンチテープを使用しましたが、プログラムメモリ(テープリーダー)とデータメモリは物理的に分離された2つの装置であり、CPUはデータにアクセスするようにプログラム命令にアクセスできませんでした。

これらのマシンに共通する欠点:

  • プログラムの変更に非常に時間がかかる——ハードウェアの配線を手動で調整したり、物理メディアを交換したりする必要がある
  • プログラム命令をプログラム自身が変更できない——これにより、コンパイラやリンカなどの高度なプログラミング技術の出現が制限された
  • ハードウェア設計が複雑——タスクごとに異なるハードウェア構成が必要になる可能性がある

2.2 フォン・ノイマンの革新

1945年、フォン・ノイマンは『EDVACに関する報告書草案』の中で大胆なアイデアを提案しました:

プログラム命令を2進数にエンコードし、データと一緒に同じアドレス指定可能なメモリに格納するというものです。

このアイデアには、3つの決定的な利点がありました:

  1. ハードウェアの統一:CPUは、あるメモリ位置に命令が格納されているのかデータが格納されているのかを知る必要がありません——アドレスに従って読み書きするだけです。これによりハードウェア設計が大幅に簡素化されました。
  2. ソフトウェアの独立した存在:プログラムは、保存・複製・転送が可能な「ファイル」になりました。これが「ソフトウェア」という概念の誕生です。
  3. プログラムの自己修正可能性:プログラムはデータを操作するように、自分自身や他のプログラムの命令を修正できます。これがアセンブラ、コンパイラ、オペレーティングシステムなど、時代を画す技術の基盤となりました。

原理の詳細:メモリ内での命令とデータの共存

3.1 メモリモジュールの模式図:どれが命令?どれがデータ?

次の図は、あるメモリ領域のスナップショットを示しています。各マスは1つの記憶ユニットであり、一意のアドレス番号を持っています。

観察してみてください——命令とデータは物理的にはまったく同じで、どちらも0と1です。それらを区別する唯一の方法は、誰がいつアクセスしたかです。

命令の色のグラデーション データの色のグラデーション メモリモジュールの背景 主記憶装置(RAM) テーブルヘッダー アドレス 格納内容 種類 実際の意味 データ行 アドレス0: 命令 LOAD A 5 0x0000 10110001 00000101 [ 命令 ] LOAD A, 5 アドレス2: データ 5(Aの値) 0x0001 00000000 00000101 [ データ ] 即値5 アドレス3: 命令 LOAD B 3 0x0002 10110010 00000011 [ 命令 ] LOAD B, 3 アドレス5: データ 3(Bの値) 0x0003 00000000 00000011 [ データ ] 即値3 アドレス4: 命令 ADD A B 0x0004 00100000 00000001 [ 命令 ] ADD A, B アドレス6: 命令 STORE A 0x0005 00110000 01100100 [ 命令 ] STORE 100 アドレス100: データ 格納結果 0x0064 00000000 00001000 [ データ ] 計算結果: 8 凡例エリア 凡例の説明: 青色のハイライト = 命令ユニット(オペコード + オペランド) オレンジ色のハイライト = データユニット(即値、変数の値、計算結果) 説明:命令とデータは物理的な格納において完全に同じで、どちらもビット列です。CPUが命令をフェッチする際のPCポインタだけが、それが命令かデータかを決定します。

3.2 重要な疑問:CPUはどのように命令とデータを区別するのか?

上の模式図から重要な事実がわかります:命令とデータは物理的な記憶レベルではまったく区別できません——それらはどちらも2進数のビット列です。

では、CPUはどうやってメモリユニットを命令として扱うべきかデータとして扱うべきかを知るのでしょうか?

答えは簡単です:アクセスのタイミングとアクセス主体を見るのです。

  • 命令フェッチ段階:CPUのプログラムカウンタ(PC)がアドレスを提供し、コントローラが「命令読み出し」制御信号を発行します。このときメモリから読み出された内容は「命令」として命令レジスタに送られ、デコードされます。
  • 実行段階:CPUはデコード結果に基づいてオペランドのアドレス(レジスタまたは命令内の即値から取得される場合があります)を計算し、コントローラが「データ読み出し」制御信号を発行します。このときメモリから読み出された内容は「データ」としてALUに送られ、演算に参加します。

一言でまとめると:同じメモリアドレスでも、PCがそれを指しているときは「命令」であり、オペランドアドレスがそれを指しているときは「データ」です。これが「プログラム内蔵方式」の真髄です——命令とデータは物理的には区別できず、論理的にはアクセスコンテキストによって区別されます。


インタラクティブデモ:自分でメモリ内の命令とデータを確認する

以下のPythonコードは、インタラクティブに探索できるメモリシステムを作成します——命令やデータを書き込み、メモリ内の任意のアドレスの内容を表示して、それが命令かデータかを判断できます。

例

"""
メモリ内での命令とデータの混在格納デモ(サンプルデモ)
「プログラム内蔵方式」の考え方を示す:命令とデータは同じメモリ内では格納内容から区別できない
"""


class MemoryInspector:
    """メモリチェッカー——メモリ内での命令とデータの共存を直感的に表示"""

    def __init__(self, size=256):
        # メモリの初期化:各ユニットは整数値を1つ格納(8ビットを模擬)
        self.memory = [0] * size
        # マーク配列:各アドレスの種類を記録——'instruction' または 'data'
        # 注意:実際のハードウェアにはこのマークはありません!ここではデモの便宜のためだけです
        self.type_tags = ['empty'] * size
        # 各アドレスのニーモニック解釈を記録(これもデモの便宜のため)
        self.annotations = [''] * size

    def write_instruction(self, address, opcode, operand, annotation=""):
        """メモリに1つの命令を書き込む(2つのユニットを占有:オペコード + オペランド)"""
        self.memory[address] = opcode
        self.type_tags[address] = 'instruction'
        self.annotations[address] = annotation

        self.memory[address + 1] = operand
        self.type_tags[address + 1] = 'instruction'
        self.annotations[address + 1] = f" └ オペランド: {operand}"

    def write_data(self, address, value, annotation=""):
        """メモリにデータユニットを書き込む"""
        self.memory[address] = value
        self.type_tags[address] = 'data'
        self.annotations[address] = annotation

    def inspect(self, start=0, end=16):
        """指定された範囲のメモリ内容を表示する"""
        print("=" * 68)
        print(f"{'アドレス':>6} | {'格納値':>8} | {'2進数':>10} | {'タイプ':>12} | 意味")
        print("-" * 68)
        for addr in range(start, min(end, len(self.memory))):
            val = self.memory[addr]
            binary = format(val, '08b')  # 8ビット2進数表現
            tag = self.type_tags[addr]
            anno = self.annotations[addr]

            } (オペコード)"
            marker = "[I]" if tag == 'instruction' else \
                     "[D]" if tag == 'data' else \
                     "[ ]"

            if val != 0 or tag != 'empty':
                print(f"  {addr:4d} | {marker} {val:5d} |  {binary}  | {tag:>12} | {anno}")

        print("=" * 68)
        print(} (オペランドが変更されました)"\n")

    def compare_bytes(self, addr1, addr2):
        """
2つのアドレスの生のバイトを比較——バイナリ上では区別できないことがわかります。
        """

        v1 = self.memory[addr1]
        v2 = self.memory[addr2]
        b1 = format(v1, '08b')
        b2 = format(v2, '08b')

        print(f"\n"--- アドレス {addr1} とアドレス {addr2} の比較 ---")
        print(f"アドレス {addr1}: 2進数 {b1} (タイプ: {self.type_tags[addr1]})")
        print(f"アドレス {addr2}: 2進数 {b2} (タイプ: {self.type_tags[addr2]})")
        if b1 == b2 and self.type_tags[addr1] != self.type_tags[addr2]:
            print(f">>> 重要な発見:この2つのアドレスのバイナリ内容は完全に同じです ({b1})!")
            print(f">>> しかしそれらのタイプは異なります:アドレス {addr1} は {self.type_tags[addr1]}、")
            print(f">>> アドレス {addr2} は {self.type_tags[addr2]} です。")
            print(f">>> つまり——バイナリ内容だけでは、命令とデータを区別できないのです!")
        else:
            print(f"この2つのアドレスの内容は異なります。")

    def simulate_instruction_data_ambiguity(self, addr):
        """
同じバイナリ値が異なるコンテキストで異なる意味に解釈されることをデモする
        """

        val = self.memory[addr]
        binary = format(val, '08b')
        print(f"\n"--- アドレス {addr} の曖昧性デモ ---")
        print(f"格納されたバイナリ値: {binary} (10進数: {val})")
        print(f"")
        print(f"CPU の PC がここを指している場合(命令フェッチ段階):")
        print(f" -> このバイトは「命令オペコード」として解釈されます")
        print(f" -> 例えば {binary} は 'LOAD' 命令を表す可能性があります")
        print(f"")
        print(f"ALU がオペランドアドレスを介してここを参照する場合(実行段階):")
        print(f" -> このバイトは「データ」として解釈されます")
        print(f" -> 例えば10進数の {val} は単なる普通の数値です")
        print(f"")
        print(f"同じビット列でも、異なる役割が読み取ると、意味はまったく異なります!")


# ================================================================
# デモ1:メモリ内の混在ストレージの構築
# ================================================================
print(>>> デモ1:メモリ内に命令とデータを混在して格納\n")
mem = MemoryInspector()

# 命令シーケンスを書き込む
mem.write_instruction(0, 0xB1, 5, "LOAD A, 5")
mem.write_instruction(2, 0xB2, 3, "LOAD B, 3")
mem.write_instruction(4, 0x20, 1, "ADD A, B")
mem.write_instruction(6, 0x30, 100, "STORE 100")

# データを書き込む
mem.write_data(100, 8, "計算結果: 5 + 3 = 8")

# メモリ全体を表示
mem.inspect(0, 8)
mem.inspect(100, 102)

# ================================================================
# デモ2:同一数値の命令/データ曖昧性
# ================================================================
print(">>> デモ2:同じバイナリ値は命令の場合もあれば、データの場合もある\n")

# 意図的にアドレス (命令領域) に値5を書き、アドレス200 (データ領域) にも5を書く
mem.write_instruction(10, 5, 0, "この 5 はオペコードです")
mem.write_data(200, 5, "この 5 はデータ値です")

mem.compare_bytes(10, 200)

# 曖昧性デモ
mem.simulate_instruction_data_ambiguity(10)

# ================================================================
# デモ3:自己変更コードの概念(高度なトピック)
# ================================================================
print(">>> デモ3:プログラムによる自己変更の概念\n")
print("命令とデータが同じメモリ内にあるため、プログラムは自身の命令を変更できます!")
print("これこそがプログラム内蔵方式アーキテクチャがもたらす能力です。")
print("")
print("例えば、プログラムはアドレス6の命令 STORE 100 を STORE 200 に変更できます:")
print(f"変更前 アドレス 6: {mem.memory)
print(f"変更前 アドレス 7: {mem.memory)

# プログラムが自身を変更:STORE のオペランドを 100 から 200 に変更
mem.memory[7] = 200
mem.type_tags[7] = 'data'
mem.annotations[7] = "プログラム自身による変更:オペランド 100 -> 200"

print(f"変更後 アドレス 7: {mem.memory)
print("")
mem.inspect(6, 8)
print(">>> この能力は、コンパイラ、動的リンカ、仮想マシンなどの技術の基盤です。")

このプログラムを実行すると、命令とデータが同じメモリ内の異なる「役割」であることがはっきりとわかります。それらは同じストレージとアドレッシング機構を共有しています——これこそフォン・ノイマンアーキテクチャの中核となる巧妙さです。

3.3 インタラクティブデモ:メモリのセルをクリックして内容を確認(サンプル)

以下は64バイトのメモリ領域のシミュレーションです。青いセルは命令を表し、,オレンジ色のセルはデータを表し、"、灰色は空き領域です。任意のセルにホバーすると、格納されている生の値を表示し、"セルをクリックして"詳細な説明を表示します——そのアドレスに格納されているのが「命令」か「データ」か、そしてその実際の意味を含みます。"

命令 (Instruction) データ (Data) 空き (Empty)
セルはJSによって動的に生成されます
上部のメモリセルをクリックして、そのアドレスに格納されている内容のタイプと意味を確認します。

ハーバードアーキテクチャとの比較

フォン・ノイマンアーキテクチャは唯一のコンピュータアーキテクチャではありません。その主な「競争相手」はハーバードアーキテクチャ(Harvard Architecture)。

比較項目フォン・ノイマンアーキテクチャハーバードアーキテクチャ
メモリ単一メモリ、命令とデータで共用2つの独立したメモリ、命令とデータが分離されている
バス1組のアドレス/データバスで、命令とデータを時分割で共用2組の独立したバスで、命令とデータを同時にアクセスできる
利点ハードウェア設計が簡単で、メモリの利用率が高く、ソフトウェアが柔軟命令とデータを同時に取得でき、速度が速い(パイプラインに適している)
欠点命令とデータの取得が衝突する可能性がある(フォン・ノイマンボトルネック)ハードウェアがより複雑で、命令ストレージとデータストレージを動的に割り当てられない
代表的な用途PC、サーバーなどの汎用コンピュータ組み込みMCU、DSPデジタル信号プロセッサ

現代のCPUは実際の実装において、これら2つのアーキテクチャを融合させることがよくあります。外部にはフォン・ノイマンアーキテクチャの統一メモリモデルを提供し(プログラミングを容易にする)、内部ではL1キャッシュレベルで命令キャッシュとデータキャッシュを分離して使用します(ハーバードアーキテクチャの速度上の利点を活かす)。これが「改良型ハーバードアーキテクチャ」と呼ばれるものです。

その他の拡張