명령어 집합과 프로그램 실행
소프트웨어가 보는 명령어 집합 구조와 그것을 만드는 구현을 구분하고, 명령어의 구성과 주소 개수에 따른 형식, RISC와 CISC의 설계 방향을 비교합니다. LOAD·ADDI·STORE·분기 명령어로 된 작은 CPU를 실행해 반복과 종료, 끝나지 않는 입력을 확인합니다.
같은 프로그램 파일이 서로 다른 회사의 CPU에서 똑같이 실행될 수 있는 것은, 명령어가 무엇을 뜻하는지에 대한 약속이 같기 때문입니다. CPU 안의 회로가 어떻게 생겼든, 소프트웨어가 보기에 같은 명령어가 같은 일을 하면 프로그램은 그대로 돌아갑니다. 이 글에서는 이 약속인 명령어 집합 구조를 살펴보고, CPU 데이터 경로에서 본 실행 과정을 분기가 있는 작은 CPU로 확장해 봅니다.
명령어 집합 구조와 구현
명령어 집합 구조(instruction set architecture, ISA)는 소프트웨어가 볼 수 있는 CPU의 모습을 정한 약속입니다. 어떤 명령어가 있고 각 명령어가 무엇을 하는지, 레지스터가 몇 개이고 몇 비트인지, 데이터와 주소를 어떤 형식으로 다루는지가 여기에 들어갑니다. 반면 파이프라인을 몇 단으로 나눌지, 캐시를 얼마나 둘지, 명령어를 내부에서 어떻게 해독할지는 같은 ISA를 만드는 방법의 문제로, 이것을 마이크로아키텍처(microarchitecture) 또는 구현이라고 합니다.
Intel과 AMD의 CPU가 내부 설계는 서로 달라도 같은 x86-64 프로그램을 실행할 수 있는 것은 ISA가 같기 때문입니다. 반대로 ISA가 다른 ARM CPU에서는 같은 실행 파일이 바로 돌아가지 않고, 다시 컴파일하거나 번역해야 합니다. ISA가 소프트웨어와 하드웨어 사이의 약속이라서, 하드웨어 설계자는 그 약속만 지키면 내부를 자유롭게 바꿀 수 있습니다.
명령어는 연산과 피연산자로 이루어진다
명령어는 보통 어떤 연산을 할지 정하는 연산 코드(opcode)와, 그 연산에 쓸 값을 어디서 가져오고 결과를 어디에 둘지 정하는 피연산자(operand) 정보로 나뉩니다. 피연산자는 레지스터 번호일 수도 있고, 명령어 안에 직접 적힌 수인 즉시값(immediate)일 수도 있으며, 메모리 주소나 분기할 위치일 수도 있습니다. 명령어는 하는 일에 따라 데이터를 옮기는 명령어, 산술과 논리 계산을 하는 명령어, 실행 순서를 바꾸는 분기 명령어, 운영체제와 관련된 시스템 명령어로 나눌 수 있습니다.
명령어에 주소를 몇 개 적느냐에 따라 형식이 달라집니다. A = B + C를 계산한다고 해 봅시다. 3주소 형식은 ADD A, B, C처럼 입력 둘과 결과 하나를 모두 적습니다. 2주소 형식은 ADD B, C처럼 결과를 첫 입력 자리에 덮어쓰므로, A에 담으려면 먼저 B를 A로 옮겨야 합니다. 1주소 형식은 누산기(accumulator)라는 정해진 레지스터 하나를 늘 쓰므로 LOAD B, ADD C, STORE A로 나누어 적고, 0주소 형식은 스택의 맨 위 값들을 쓰므로 PUSH B, PUSH C, ADD, POP A로 적습니다. 주소를 적지 않은 명령어도 정보가 없는 것이 아니라, 정해진 레지스터나 스택이 그 자리를 대신하는 것입니다.
RISC와 CISC
RISC(reduced instruction set computer)는 명령어를 단순하고 크기가 고른 형식으로 만들고, 계산은 레지스터끼리만 하며 메모리는 LOAD와 STORE로만 접근하는 설계 방향입니다. 명령어가 단순하면 해독이 쉽고 파이프라인으로 겹쳐 실행하기 좋습니다. CISC(complex instruction set computer)는 메모리의 값으로 바로 계산하는 명령어나 길이가 제각각인 명령어처럼 다양한 명령어를 두는 방향으로, x86이 대표적입니다.
그렇다고 RISC가 언제나 빠르고 CISC가 언제나 느린 것은 아닙니다. 오늘날의 x86 CPU는 복잡한 명령어를 내부에서 단순한 마이크로연산(micro-operation)으로 쪼개 실행하므로, 내부는 RISC와 비슷하게 동작합니다. 실제 성능은 ISA보다 캐시, 파이프라인, 분기 예측 같은 구현에 더 크게 달려 있습니다. 실제 RISC ISA의 예로 RISC-V RV32I 공식 규격은 32비트 정수 레지스터 32개와 PC, 명령어 형식을 정의합니다. 아래의 작은 CPU는 이 규격을 구현한 것이 아니라 개념을 익히기 위한 모델입니다.
더 깊이: RISC-V에도 길이가 다른 명령어가 있다
RISC의 명령어는 크기가 고르다는 설명에도 예외가 있습니다. RISC-V의 기본 정수 명령어는 모두 32비트이지만, 압축 명령어 확장 Zca를 더하면 자주 쓰는 32비트 명령어의 16비트 버전을 쓸 수 있습니다. 즉시값이나 주소 변위가 작을 때, 레지스터 가운데 하나가 x0이나 복귀 주소 레지스터 x1, 스택 포인터 x2일 때, 목적 레지스터와 첫 원본 레지스터가 같을 때, 가장 많이 쓰는 레지스터 8개만 쓸 때가 그런 경우입니다. 이 16비트 명령어는 하나하나가 기본 ISA의 32비트 명령어 하나로 그대로 펼쳐지도록 설계되었습니다.
그 대신 명령어의 정렬 조건이 느슨해집니다. 16비트 명령어와 32비트 명령어를 자유롭게 섞어 쓸 수 있어야 하므로, 32비트 명령어도 2바이트 단위의 아무 위치에서나 시작할 수 있게 됩니다. 그래서 PC는 늘 같은 크기만큼 늘어나지 않고, 방금 실행한 명령어의 길이에 따라 2나 4만큼 늘어납니다. 규격은 32비트 명령어의 정렬 조건을 없앤 덕분에 코드 밀도가 크게 좋아진다고 설명합니다.
작은 CPU로 반복 실행하기
명령어 여섯 가지를 가진 CPU를 만들어 보겠습니다. 레지스터는 R 하나이고, LOAD는 메모리의 값을 R로 가져오고, ADDI는 즉시값을 R에 더하며, STORE는 R을 메모리에 씁니다. JNZ는 R이 0이 아니면, JZ는 R이 0이면 지정한 명령어 번호로 분기하고, HALT는 실행을 멈춥니다. 메모리는 딕셔너리, 프로그램은 (연산, 피연산자) 튜플의 목록입니다.
def run(program, memory, limit=100):
memory = dict(memory)
pc, r, trace = 0, 0, []
for _ in range(limit):
if not 0 <= pc < len(program):
raise ValueError(f"PC {pc}가 명령어 범위 밖입니다")
op, arg = program[pc]
current = pc
pc += 1
if op == "LOAD":
r = memory[arg]
elif op == "ADDI":
r += arg
elif op == "STORE":
memory[arg] = r
elif op == "JNZ":
if r != 0:
pc = arg
elif op == "JZ":
if r == 0:
pc = arg
elif op == "HALT":
trace.append((current, op, r))
return memory, trace
else:
raise ValueError(f"알 수 없는 명령어 {op}")
trace.append((current, op, r))
raise RuntimeError(f"{limit}단계 안에 끝나지 않았습니다")
countdown = [("LOAD", 20), ("ADDI", -1), ("JNZ", 1), ("STORE", 21), ("HALT", None)]
result, trace = run(countdown, {20: 3})
print([r for _, _, r in trace], result[21], len(trace))
try:
run(countdown, {20: 0})
except RuntimeError as error:
print("오류:", error)
guarded = [("LOAD", 20), ("JZ", 4), ("ADDI", -1), ("JNZ", 2), ("STORE", 21), ("HALT", None)]
for start in (3, 0):
result, trace = run(guarded, {20: start})
print(start, [pc for pc, _, _ in trace], result[21])[3, 2, 2, 1, 1, 0, 0, 0, 0] 0 9
오류: 100단계 안에 끝나지 않았습니다
3 [0, 1, 2, 3, 2, 3, 2, 3, 4, 5] 0
0 [0, 1, 4, 5] 0run은 앞 글처럼 명령어를 인출한 뒤 PC를 1 늘리고 실행하며, 분기 명령어는 조건이 맞으면 PC를 지정한 번호로 바꿉니다. trace에는 명령어마다 실행한 번호, 연산, 실행 뒤의 R을 기록합니다. PC가 프로그램 밖을 가리키거나 모르는 연산이 나오면 오류를 내고, limit번 안에 HALT에 닿지 않으면 끝나지 않는 프로그램으로 보고 멈춥니다.
countdown은 M[20]의 값을 R로 가져와 0이 될 때까지 1씩 빼고, 결과를 M[21]에 씁니다. 첫 줄의 R은 3, 2, 2, 1, 1, 0, 0, 0, 0인데, 값이 두 번씩 나오는 것은 JNZ가 R을 바꾸지 않기 때문입니다. 9단계 만에 HALT에 닿고 M[21]은 0입니다. 그런데 M[20]이 0이면 먼저 1을 빼서 −1이 되고, 그다음부터는 −2, −3으로 내려가 0에 다시 닿지 않습니다. 조건을 검사하기 전에 빼는 순서 때문에 생긴 문제이며, 둘째 줄처럼 단계 제한에 걸립니다.
guarded는 LOAD 바로 다음에 JZ로 0인지 먼저 확인해, 0이면 반복을 건너뛰고 STORE로 갑니다. 마지막 두 줄은 실행한 명령어 번호의 순서인데, 3에서 시작하면 2번과 3번 명령어를 세 번 돈 뒤 4번과 5번으로 끝나고, 0에서 시작하면 1번에서 곧바로 4번으로 건너뛰어 끝납니다. 프로그램이 맞는지 확인할 때는 이처럼 평범한 입력과 함께 0 같은 끝값과, 끝나지 않을 수 있는 입력도 넣어 보아야 합니다.
정리
- 명령어 집합 구조는 명령어, 레지스터, 데이터 형식처럼 소프트웨어가 보는 CPU의 약속이고, 마이크로아키텍처는 그 약속을 만드는 구현입니다.
- 명령어는 연산 코드와 피연산자로 이루어지며, 피연산자는 레지스터, 즉시값, 메모리 주소, 분기 위치일 수 있습니다.
- 3주소, 2주소, 1주소, 0주소 형식은 주소를 명령어에 몇 개 적느냐의 차이이고, 적지 않은 주소는 정해진 레지스터나 스택이 대신합니다.
- RISC는 단순하고 고른 명령어와 LOAD·STORE 구조를, CISC는 다양한 명령어를 지향하지만, 성능은 구현에 더 크게 달려 있습니다.
- 분기 명령어는 PC를 바꾸어 반복을 만들며, 조건을 검사하는 순서에 따라 어떤 입력에서는 끝나지 않을 수 있습니다.
연습 문제
1. 실행 기록 따라가기
countdown을 M[20] = 2로 실행하면 trace의 R 값과 단계 수는 어떻게 되나요?
해설 보기
LOAD로 R = 2, ADDI로 R = 1, JNZ는 R이 0이 아니므로 1번으로 돌아가고 R은 1 그대로입니다. 다시 ADDI로 R = 0, JNZ는 R이 0이라 분기하지 않고, STORE와 HALT가 R = 0으로 기록됩니다. 그래서 R은 2, 1, 1, 0, 0, 0, 0이고 7단계입니다. 입력이 3일 때보다 반복이 한 번 적어 ADDI와 JNZ 두 단계가 줄었습니다.
2. 0주소 형식으로 쓰기
A = (B + C) × D를 0주소 형식, 곧 스택 명령어 PUSH, POP, ADD, MUL로 쓰세요. ADD와 MUL은 스택 맨 위의 두 값을 꺼내 계산한 결과를 다시 넣습니다.
해설 보기
PUSH B, PUSH C, ADD, PUSH D, MUL, POP A입니다. B와 C를 넣고 ADD를 하면 스택에 B + C가 남고, D를 넣은 뒤 MUL을 하면 (B + C) × D가 남으며, POP A가 그 값을 A에 씁니다. 이 순서는 수식 트리를 후위 순회한 B C + D ×와 같습니다. 이산수학 교재의 트리 순회와 수식의 표현에서 본 후위 표기의 스택 계산이 바로 0주소 명령어의 실행 방식입니다.
3. 32비트 레지스터였다면
countdown을 M[20] = 0으로 실행하면 이 모델에서는 끝나지 않았습니다. R이 32비트 부호 없는 레지스터라서 0에서 1을 빼면 이 된다면 어떻게 될까요?
해설 보기
R은 0에서 1을 빼 이 되고, 그다음부터 1씩 줄어 언젠가는 0에 닿습니다. 0에서 출발해 다시 0이 되기까지 ADDI를 번, 약 43억 번 실행하므로, 프로그램은 끝나기는 하지만 아주 오래 걸립니다. 이 모델에서 끝나지 않은 것은 Python의 정수에 폭 제한이 없어 음수로 계속 내려가기 때문입니다. 같은 프로그램이라도 레지스터의 폭과 넘침 규칙에 따라 동작이 달라지므로, 프로그램이 다루는 값의 범위를 먼저 정해야 합니다.
프로그램을 검사할 때는 결과 값만 보지 말고, 끝나는지와 주소와 값이 허용된 범위 안에 있는지도 함께 확인해 보세요. 다음 글 주소지정·스택·호출에서는 명령어가 피연산자의 값을 찾는 여러 방법과, 함수 호출에 쓰이는 스택을 다룹니다.