컴퓨터공학 300 주제 시리즈의 086번째 글이다. 전체 지도는 여기.

한 줄 요약

폰 노이만 구조는 명령어와 데이터를 같은 메모리에 두고, CPU 가 프로그램 카운터가 가리키는 명령어를 하나씩 꺼내 실행하는 “저장 프로그램” 컴퓨터의 설계다. 오늘날 거의 모든 범용 컴퓨터가 이 모델 위에 서 있고, 그 장점과 한계(메모리 병목)를 함께 물려받았다.

왜 필요한가

초기 전자 계산기 중에는 프로그램을 바꾸려면 배선을 다시 꽂아야 하는 기계도 있었다. 1945년 존 폰 노이만이 쓴 EDVAC 보고서 초안은 프로그램을 숫자로 부호화해 데이터와 같은 메모리에 넣자는 구조를 정리했다. 이 발상 덕분에 다음이 가능해졌다.

  • 프로그램을 바꾸는 일이 메모리에 쓰는 일이 된다. 하드웨어를 건드릴 필요가 없다.
  • 프로그램이 다른 프로그램을 데이터로 다룰 수 있다. 컴파일러, 로더, 운영체제, 인터프리터, JIT 가 모두 여기서 나온다.

반대로 이 구조의 한계도 매일 마주친다. CPU 는 빨라졌는데 메모리에서 명령어와 데이터를 가져오는 통로는 하나라서, 실제 성능은 자주 메모리가 정한다. 캐시와 메모리 계층(#092~#093)은 이 문제를 완화하려는 장치다.

핵심 개념

다섯 구성 요소

EDVAC 보고서는 기계를 몇 개의 기관으로 나눠 설명했다. 현대 교과서는 이를 보통 다섯으로 정리한다.

          ┌───────────────── CPU ─────────────────┐
          │  ┌───────────┐       ┌─────────────┐  │
 입력 ───►│  │ 제어 장치  │◄─────►│ 연산 장치    │  │───► 출력
          │  │ (PC, IR)  │       │ (ALU, 레지스터)│ │
          │  └─────┬─────┘       └──────┬──────┘  │
          └────────┼────────────────────┼─────────┘
                   │      버스(주소·데이터·제어)
          ┌────────▼────────────────────▼─────────┐
          │      메모리: 명령어와 데이터가 함께      │
          └───────────────────────────────────────┘
구성 요소 역할
연산 장치(ALU) 덧셈, 비교, 논리 연산
제어 장치 명령어를 해독하고 각 부품에 제어 신호를 보냄
메모리 명령어와 데이터를 주소로 구분 없이 저장
입력 장치 외부에서 데이터를 받음
출력 장치 결과를 내보냄

핵심 아이디어 세 가지

  1. 저장 프로그램: 명령어도 비트열이다. 메모리의 어느 칸이 명령어이고 어느 칸이 데이터인지는 비트만 봐서는 알 수 없고, PC 가 그 칸을 가리키느냐로 정해진다.
  2. 순차 실행: 프로그램 카운터(PC)가 다음에 실행할 명령어 주소를 갖고 있다. 기본은 PC 를 하나씩 올리고, 분기 명령만 PC 를 다른 값으로 바꾼다.
  3. 주소로 접근하는 단일 메모리: 모든 칸은 번호(주소)로 읽고 쓴다.

인출–해독–실행 주기

CPU 는 전원이 꺼질 때까지 이 루프를 돈다.

loop:
    IR ← Memory[PC]      ; 인출(fetch)
    PC ← PC + 명령어 길이
    해독(decode): IR 의 연산 코드·피연산자 해석
    실행(execute): ALU 연산, 메모리 읽기/쓰기, 또는 PC 변경
    (인터럽트가 있으면 처리)

IR(명령어 레지스터)은 방금 가져온 명령어를 담는다. 이 단순한 루프를 여러 단계로 겹쳐 돌리는 것이 파이프라이닝이다.

폰 노이만 병목

명령어와 데이터가 같은 통로로 오가므로 CPU 는 한 번에 둘 중 하나밖에 가져오지 못한다. 그리고 메모리 접근은 CPU 연산보다 훨씬 느리다. 존 배커스는 1977년 튜링상 강연에서 이 통로를 “폰 노이만 병목” 이라고 불렀다. 대응책은 다음과 같다.

  • 캐시: 자주 쓰는 명령어·데이터를 CPU 가까이에 복사해 둔다.
  • 명령어 캐시와 데이터 캐시 분리: 아래 수정 하버드 구조.
  • 프리페치, 넓은 버스, 메모리 채널 다중화.

하버드 구조와 수정 하버드 구조

하버드 구조는 명령어 메모리와 데이터 메모리를 물리적으로 나눈다. 둘을 동시에 가져올 수 있어 빠르지만, 프로그램을 데이터처럼 다루기는 어렵다. 일부 마이크로컨트롤러와 DSP 가 이 구조를 쓴다.

현대 범용 CPU 는 절충형이다. 주기억 장치와 주소 공간은 하나(폰 노이만)이고, CPU 안쪽 L1 캐시만 명령어용과 데이터용으로 나눈다(하버드). 이것을 흔히 수정 하버드 구조라고 부른다.

“프로그램도 데이터” 의 그림자

명령어와 데이터가 같은 메모리에 있다는 것은 공격자가 데이터로 넣은 바이트를 CPU 가 명령어로 실행할 수도 있다는 뜻이다. 버퍼 오버플로로 셸코드를 실행하는 고전적인 공격이 그렇다. 그래서 현대 운영체제는 페이지 단위로 “쓰기 가능” 과 “실행 가능” 을 동시에 허용하지 않는 정책(W^X, NX 비트)을 쓴다. JIT 컴파일러는 이 제약 아래에서 코드를 쓰고 나서 권한을 바꾸는 방식으로 동작한다.

직접 해 보기

명령어와 데이터가 같은 리스트(메모리)에 있는 누산기 기계를 만든다. 1 부터 n 까지 더한다. python3 로 실행해 확인했다.

mem = [
    ("LOAD", 21),   # 0: acc = sum
    ("ADD", 20),    # 1: acc += n
    ("STORE", 21),  # 2: sum = acc
    ("LOAD", 20),   # 3: acc = n
    ("SUB", 22),    # 4: acc -= 1
    ("STORE", 20),  # 5: n = acc
    ("JNZ", 0),     # 6: acc != 0 이면 0 번지로
    ("PRINT", 21),  # 7
    ("HALT", 0),    # 8
] + [None] * 11 + [
    5,              # 20: n
    0,              # 21: sum
    1,              # 22: 상수 1
]

def run(mem, limit=200):
    pc, acc, steps = 0, 0, 0
    while steps < limit:
        op, addr = mem[pc]      # 인출: PC 가 가리키는 메모리에서 명령어를 읽는다
        pc += 1                 # PC 증가
        steps += 1
        if op == "LOAD":    acc = mem[addr]     # 해독 후 실행
        elif op == "ADD":   acc += mem[addr]
        elif op == "SUB":   acc -= mem[addr]
        elif op == "STORE": mem[addr] = acc
        elif op == "JNZ":   pc = addr if acc != 0 else pc
        elif op == "PRINT": print("out:", mem[addr])
        elif op == "HALT":  break
    return steps

print("steps:", run(list(mem)))

m2 = list(mem)
m2[1] = ("SUB", 20)        # 더하기를 빼기로 바꿔 넣는다
print("steps:", run(m2))
print(mem[1], "->", m2[1])

출력:

out: 15
steps: 37
out: -15
steps: 37
('ADD', 20) -> ('SUB', 20)

0~8 번지는 명령어, 20~22 번지는 데이터지만 둘 다 같은 mem 에 있다. 메모리 한 칸을 바꾸자 다른 프로그램이 됐다. 로더가 실행 파일을 메모리에 올리는 일도 본질적으로 이것이다. steps 37 중 절반 가까이가 LOAD/STORE 라는 점도 눈여겨볼 만하다. 연산보다 메모리 왕복이 많다. 병목의 축소판이다.

현업에서는

  • 성능 분석의 출발점: 프로파일러에서 CPU 사용률이 높은데 실제로는 메모리를 기다리는 시간(stall)이 대부분인 경우가 흔하다. “CPU 바운드” 라고 판단하기 전에 메모리 바운드인지 확인한다.
  • 인터프리터와 JIT: 파이썬 바이트코드, JVM 바이트코드는 소프트웨어로 구현한 인출–해독–실행 루프다. JIT 는 그 바이트코드를 실제 기계어로 바꿔 메모리에 쓰고 PC 를 그곳으로 옮긴다. 저장 프로그램 개념이 없으면 불가능한 일이다.
  • 보안 설정: 컨테이너 런타임과 커널의 메모리 보호(NX, ASLR)는 “데이터를 코드로 실행하지 못하게” 하는 장치다. 일부 JIT 기반 런타임이 seccomp·SELinux 정책과 충돌하는 이유도 쓰기 가능한 메모리를 실행해야 하기 때문이다.

확인 문제

  1. 저장 프로그램 개념을 한 문장으로 설명하라.
  2. 메모리 칸의 내용이 명령어인지 데이터인지는 무엇으로 정해지는가.
  3. 폰 노이만 병목이란 무엇이고 대표적인 완화책은.
  4. 현대 CPU 가 “수정 하버드 구조” 라고 불리는 이유는.

풀이

  1. 프로그램을 숫자로 부호화해 데이터와 같은 메모리에 저장하고 실행한다는 개념이다.
  2. 비트 자체가 아니라 CPU 가 그 주소를 PC 로 인출하느냐로 정해진다.
  3. CPU 와 메모리 사이의 단일 통로가 처리 속도를 제한하는 현상이다. 캐시, 명령어·데이터 캐시 분리, 프리페치가 완화책이다.
  4. 주기억 장치와 주소 공간은 하나지만 L1 캐시를 명령어용과 데이터용으로 나눠 동시에 접근하기 때문이다.

더 읽을거리 (References)