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

한 줄 요약

데이터패스는 명령어가 실행될 때 데이터가 흘러가는 부품들(PC, 명령어 메모리, 레지스터 파일, ALU, 데이터 메모리, 멀티플렉서)의 배선이고, 제어 장치는 명령어의 연산 코드를 보고 그 배선의 스위치(제어 신호)를 켜고 끄는 회로다. CPU 는 “길” 과 “신호등” 으로 나뉜다.

왜 필요한가

앞 글들에서 게이트, 덧셈기, 레지스터(#084~#085), 저장 프로그램(#086), 명령어 형식(#087)을 따로 봤다. 이 글은 그것들을 하나로 조립한다. 명령어 하나가 실제로 어떤 부품을 어떤 순서로 거치는지 알면 다음이 이해된다.

  • 왜 메모리 적재 명령이 덧셈보다 느린가
  • 파이프라이닝(#090)이 “어디를” 자르는 것인가
  • 해저드(#091)가 왜 생기는가

핵심 개념

구성 부품

부품 역할 종류
PC 다음 명령어 주소 순차(레지스터)
명령어 메모리 PC 주소의 명령어를 내줌 메모리
레지스터 파일 32개 레지스터. 읽기 포트 2개, 쓰기 포트 1개 순차
즉시값 생성기 명령어에서 상수를 꺼내 부호 확장 조합
ALU 덧셈·뺄셈·논리·비교 조합
데이터 메모리 적재·저장 대상 메모리
멀티플렉서(MUX) 여러 값 중 하나를 고름 조합

단일 사이클 데이터패스

명령어 하나를 한 클록 사이클에 끝내는 가장 단순한 설계다. RISC-V 의 add, addi, lw, sw, beq 를 처리하는 데이터패스를 단순화하면 다음과 같다.

          ┌────────────────── +4 ──────────────────┐
          │                                         ▼
 ┌────┐   │  ┌────────┐   rs1 ┌──────────┐ A ┌─────┐     ┌────────┐
 │ PC │───┴─►│명령어  │──────►│ 레지스터  │──►│     │ 주소 │ 데이터  │
 └────┘      │메모리  │   rs2 │  파일    │ B │ ALU │────►│ 메모리  │
   ▲         └───┬────┘──────►│          │─┐ │     │     └───┬────┘
   │             │        rd  │          │ ▼ └──┬──┘         │
   │             │      ─────►│  쓰기 ◄──┼─MUX   │ 결과        │ 읽은 값
   │             ▼            └──────────┘ ▲     ▼            ▼
   │        즉시값 생성 ───────────────────┘   MemToReg MUX ◄──┘
   │             │                              │
   └── PC MUX ◄──┴── (PC + 즉시값, 분기 시) ◄────┘ (ALU 결과 = 0 ?)

그림의 MUX 세 개가 핵심이다.

  1. ALUSrc MUX: ALU 의 두 번째 입력을 레지스터(rs2)로 할지 즉시값으로 할지.
  2. MemToReg MUX: 레지스터에 쓸 값을 ALU 결과로 할지 메모리에서 읽은 값으로 할지.
  3. PC MUX: 다음 PC 를 PC+4 로 할지 분기 목적지로 할지.

제어 장치: 연산 코드 → 제어 신호

제어 장치는 명령어의 opcode(와 funct 필드)를 받아 MUX 선택 신호와 쓰기 허용 신호를 내는 조합 회로다. 단일 사이클에서는 사실상 진리표 하나다.

명령 RegWrite ALUSrc MemRead MemWrite MemToReg Branch
add 1 0 0 0 0 0
addi 1 1 0 0 0 0
lw 1 1 1 0 1 0
sw 0 1 0 1 x 0
beq 0 0 0 0 x 1

x 는 “아무 값이나 상관없음” 이다. sw, beq 는 레지스터에 쓰지 않으므로 MemToReg 가 무엇이든 결과에 영향이 없다.

명령어별 경로

  • add x1, x2, x3: 인출 → 레지스터 2개 읽기 → ALU 덧셈 → 레지스터 쓰기. 데이터 메모리는 쓰지 않는다.
  • lw x4, 0(x2): 인출 → x2 읽기 → ALU 로 주소 계산 → 데이터 메모리 읽기 → 레지스터 쓰기. 부품을 가장 많이 거친다.
  • sw x1, 100(x0): 주소 계산 → 메모리 쓰기. 레지스터 쓰기 없음.
  • beq x3, x0, 8: 두 레지스터를 ALU 로 빼서 0 인지 확인 → PC MUX 선택.

단일 사이클의 문제

클록 주기는 가장 느린 명령어(lw)의 경로에 맞춰야 한다. add 는 데이터 메모리를 거치지 않는데도 lw 만큼 기다린다. 해결책은 두 가지다.

  • 다중 사이클: 명령어를 여러 단계로 나눠 단계마다 한 사이클씩 쓰고, 명령어마다 필요한 단계 수만 쓴다. 제어 장치가 유한 상태 기계(#085)가 된다.
  • 파이프라인: 단계를 나누고 여러 명령어를 겹쳐 실행한다(#090). 현대 CPU 의 표준이다.

하드와이어드 제어와 마이크로프로그램 제어

방식 구현 특징
하드와이어드 게이트로 직접 만든 조합 회로·FSM 빠름, 바꾸기 어려움. RISC 계열에 일반적
마이크로프로그램 제어 신호 묶음(마이크로 명령)을 작은 ROM 에 저장해 순서대로 꺼냄 복잡한 명령어를 다루기 쉬움. 전통적 CISC 에서 쓰임

현대 x86 CPU 는 흔한 명령어는 하드웨어 해독기로 바로 마이크로 연산으로 바꾸고, 드물고 복잡한 명령어만 마이크로코드 ROM 을 거친다. CPU 제조사가 배포하는 “마이크로코드 업데이트” 가 바로 이 부분을 고치는 것이다.

직접 해 보기

단일 사이클 데이터패스를 파이썬으로 흉내 낸다. 제어 장치는 표 하나이고, 데이터패스는 그 신호로 MUX 를 고른다. 메모리 0~12 번지의 네 수를 더해 100 번지에 저장한다. python3 로 실행해 확인했다.

CONTROL = {  # 제어 장치 = 연산 코드 -> 제어 신호 표
    #        RegWrite ALUSrc MemRead MemWrite MemToReg Branch
    "add":  (1,       0,     0,      0,       0,       0),
    "addi": (1,       1,     0,      0,       0,       0),
    "lw":   (1,       1,     1,      0,       1,       0),
    "sw":   (0,       1,     0,      1,       0,       0),
    "beq":  (0,       0,     0,      0,       0,       1),
}

def step(pc, imem, reg, dmem):
    op, rd, rs1, rs2, imm = imem[pc // 4]                 # 1. 명령어 인출
    rw, alusrc, mr, mw, m2r, br = CONTROL[op]             # 2. 해독 -> 제어 신호
    a, b = reg[rs1], reg[rs2]                             #    레지스터 파일 읽기
    alu_b = imm if alusrc else b                          # 3. MUX: 즉시값 vs 레지스터
    alu = a - alu_b if br else a + alu_b                  #    ALU
    mem = dmem.get(alu, 0) if mr else None                # 4. 데이터 메모리
    if mw: dmem[alu] = b
    if rw and rd != 0:                                    # 5. 레지스터 쓰기(x0 은 항상 0)
        reg[rd] = mem if m2r else alu
    return pc + imm if (br and alu == 0) else pc + 4      #    다음 PC MUX

imem = [
    ("addi", 2, 0, 0, 0),      # 0:  x2 = 0      (주소)
    ("addi", 3, 0, 0, 4),      # 4:  x3 = 4      (남은 개수)
    ("lw",   4, 2, 0, 0),      # 8:  x4 = mem[x2]
    ("add",  1, 1, 4, 0),      # 12: x1 += x4
    ("addi", 2, 2, 0, 4),      # 16: x2 += 4
    ("addi", 3, 3, 0, -1),     # 20: x3 -= 1
    ("beq",  0, 3, 0, 8),      # 24: x3 == 0 이면 32 로
    ("beq",  0, 0, 0, -20),    # 28: 무조건 8 로 (x0 == x0)
    ("sw",   0, 0, 1, 100),    # 32: mem[100] = x1
]
reg = [0] * 8
dmem = {0: 10, 4: 20, 8: 30, 12: 40}
pc, cycles = 0, 0
while pc // 4 < len(imem):
    pc = step(pc, imem, reg, dmem); cycles += 1
print("x1 =", reg[1], "mem[100] =", dmem[100], "cycles =", cycles)

출력:

x1 = 100 mem[100] = 100 cycles = 26

명령어 26개를 실행했고 단일 사이클이므로 26 사이클이다. 실제 RISC-V 의 beq 는 ALU 와 별도의 비교기와 주소 덧셈기를 쓰지만, 여기서는 단순화를 위해 ALU 뺄셈 결과로 판정했다. step() 의 다섯 주석이 다음 글에서 볼 5단계 파이프라인의 다섯 단계(IF, ID, EX, MEM, WB)와 그대로 대응한다는 점을 기억해 두자.

현업에서는

  • 명령어 비용 감각: 레지스터끼리의 연산은 싸고, 메모리를 거치는 적재는 비싸다. 캐시에 없으면 훨씬 더 비싸다. 핫 루프에서 메모리 접근을 줄이는 최적화가 효과적인 이유다.
  • 마이크로코드 업데이트: CPU 보안 취약점(Spectre 계열 등) 대응으로 OS 나 펌웨어가 마이크로코드를 갱신하는 일이 있다. 리눅스에서는 부팅 시 로드되며 /proc/cpuinfo 의 microcode 항목으로 현재 버전을 볼 수 있다. 홈랩 노드마다 이 값이 다르면 같은 워크로드의 성능이 달라질 수 있다.
  • 하드웨어 설계 교육: Nand to Tetris 나 MIT 6.004 같은 과정은 이 데이터패스를 직접 만들어 보게 한다. 소프트웨어 개발자가 “CPU 가 실제로 무엇을 하는지” 를 몸으로 익히는 가장 빠른 길이다.

확인 문제

  1. 단일 사이클 데이터패스에서 클록 주기를 정하는 명령어는 보통 무엇이고 왜 그런가.
  2. lw 와 add 에서 MemToReg 신호는 각각 무엇이어야 하는가.
  3. ALUSrc 가 1 인 명령어를 위 표에서 모두 고르라.
  4. 마이크로프로그램 제어 방식의 장단점은.

풀이

  1. lw. 명령어 메모리, 레지스터 읽기, ALU, 데이터 메모리, 레지스터 쓰기까지 가장 긴 경로를 거치기 때문이다.
  2. lw 는 1(메모리 값), add 는 0(ALU 결과).
  3. addi, lw, sw.
  4. 복잡한 명령어를 ROM 의 마이크로 명령으로 쉽게 구현·수정할 수 있지만, ROM 을 거치는 만큼 하드와이어드보다 느릴 수 있다.

더 읽을거리 (References)