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

한 줄 요약

명령어 집합 구조(ISA)는 CPU 가 이해하는 명령어, 레지스터, 메모리 접근 방식, 비트 인코딩을 정한 계약서다. RISC 는 단순하고 고정된 명령어로 하드웨어를 빠르게 만들자는 철학, CISC 는 명령어 하나가 많은 일을 하게 하자는 철학이었고, 오늘날 둘의 경계는 상당히 흐려졌다.

왜 필요한가

같은 C 코드라도 x86-64 서버와 ARM 맥북, 라즈베리 파이에서 서로 다른 기계어로 컴파일된다. 컨테이너 이미지를 빌드할 때 linux/amd64, linux/arm64 를 따로 만드는 이유가 ISA 가 다르기 때문이다.

ISA 는 소프트웨어와 하드웨어가 만나는 경계다. 위쪽(컴파일러, OS)은 ISA 만 믿고 코드를 만들고, 아래쪽(마이크로아키텍처)은 ISA 를 지키는 한 내부를 마음대로 바꿀 수 있다. 같은 x86-64 프로그램이 여러 세대의 CPU 에서 다시 컴파일하지 않고 돌아가는 것이 이 계약 덕분이다.

핵심 개념

ISA 가 정하는 것

항목 예시
명령어 종류 산술·논리, 적재·저장, 분기, 시스템 호출
레지스터 개수, 폭(32/64비트), 특수 레지스터(PC, 스택 포인터, 플래그)
주소 지정 방식 레지스터, 즉시값, 베이스+오프셋, 인덱스 스케일
인코딩 명령어 길이, 비트 필드 배치
메모리 모델 바이트 순서, 정렬 요구, 메모리 순서 규칙
특권 수준 사용자 모드·커널 모드, 예외·인터럽트 처리

ISA 와 마이크로아키텍처를 구분해야 한다. ISA 는 “무엇을” 약속하고, 마이크로아키텍처는 그것을 “어떻게” 구현하는지(파이프라인 단수, 캐시 크기, 분기 예측기)다. 인텔과 AMD 는 같은 x86-64 ISA 를 서로 다른 마이크로아키텍처로 구현한다.

CISC 와 RISC 의 원래 주장

  CISC (예: x86) RISC (예: RISC-V, AArch64)
명령어 길이 가변 (x86 은 1~15바이트) 고정 (주로 4바이트)
메모리 접근 산술 명령이 메모리 피연산자를 직접 다룸 적재(load)·저장(store) 명령만 메모리 접근
레지스터 수 상대적으로 적음 (x86-64 범용 16개) 많음 (RISC-V, AArch64 모두 범용 31~32개)
명령어 수와 복잡도 많고 복잡한 명령 포함 적고 단순
해독 복잡 단순, 파이프라인에 유리

1970년대에는 메모리가 비싸고 컴파일러가 단순했다. 명령어 하나가 많은 일을 하면 프로그램이 작아지고 어셈블리 작성도 편했다. 이것이 CISC 의 배경이다.

1980년 패터슨과 디첼은 실제 컴파일러가 복잡한 명령어를 거의 쓰지 않는다는 관찰을 바탕으로, 자주 쓰는 단순한 명령어만 빠르게 만들자는 RISC 를 주장했다. 고정 길이와 적재/저장 구조는 해독을 단순하게 하고 파이프라이닝(#090)을 쉽게 만든다.

같은 일, 다른 기계어

p[3] += x; 를 생각해 보자. x86-64 는 메모리 피연산자를 직접 고치는 add 하나로 끝난다(아래 “직접 해 보기” 에서 실측). RISC-V 는 적재–연산–저장 세 명령이 필요하다. 아래는 RISC-V 64비트 기준으로 손으로 쓴 대응 코드다(실행 확인은 하지 않았다).

# RISC-V (a0 = p, a1 = x)
ld   t0, 24(a0)      # t0 = p[3]
add  t0, t0, a1      # t0 += x
sd   t0, 24(a0)      # p[3] = t0
ret

명령어 수는 RISC 가 많지만 각 명령어가 단순하고 모두 4바이트라 해독과 파이프라인이 쉽다. 반대로 CISC 는 코드 밀도가 높다.

경계가 흐려진 이유

  • x86 의 내부는 RISC 처럼 동작한다. 현대 x86 CPU 는 복잡한 명령어를 내부에서 단순한 마이크로 연산(micro-op)으로 쪼개 실행한다. CISC 의 겉모습을 유지하면서 RISC 식 파이프라인을 쓰는 셈이다.
  • RISC 도 복잡해졌다. SIMD, 암호화, 원자적 연산 같은 명령이 추가됐다. RISC-V 는 16비트 압축 명령(C 확장)으로 코드 밀도를 보완한다.
  • 결국 성능은 마이크로아키텍처가 정한다. 캐시, 분기 예측, 비순차 실행이 ISA 차이보다 성능에 더 큰 영향을 주는 경우가 많다. ISA 차이는 해독기 복잡도, 전력, 코드 크기 같은 곳에서 남는다.

RISC-V: 열린 ISA

RISC-V 는 공개 사양으로 누구나 구현할 수 있는 ISA 다. 작은 기본 정수 명령어 집합(RV32I, RV64I) 위에 곱셈(M), 원자 연산(A), 부동소수점(F, D), 압축(C), 벡터(V) 같은 확장을 골라 붙이는 모듈 구조가 특징이다. 교육용으로도 인기가 높아 최근 교과서 상당수가 RISC-V 로 설명한다.

인코딩 엿보기: RISC-V I 형식

 31            20 19   15 14  12 11    7 6       0
┌───────────────┬───────┬──────┬───────┬─────────┐
│   imm[11:0]   │  rs1  │funct3│  rd   │ opcode  │
└───────────────┴───────┴──────┴───────┴─────────┘
     12비트        5비트   3비트   5비트    7비트

필드 위치가 고정돼 있어서 해독기는 명령어를 다 읽기 전에도 레지스터 번호를 꺼낼 수 있다. 가변 길이인 x86 은 명령어의 시작과 끝을 찾는 것부터가 일이다.

직접 해 보기

1. x86-64 가변 길이 명령어 확인

x86-64 리눅스, gcc 13.3 에서 확인했다.

/* add.c */
void add_mem(long *p, long x) {
    p[3] += x;
}
gcc -O2 -fcf-protection=none -c add.c -o add.o
objdump -d add.o
0000000000000000 <add_mem>:
   0:	48 01 77 18          	add    %rsi,0x18(%rdi)
   4:	c3                   	ret

메모리 피연산자(0x18(%rdi), 곧 p + 24바이트)를 직접 고치는 add 가 4바이트, ret 은 1바이트다. 길이가 제각각이다. -fcf-protection=none 은 출력에 endbr64 명령이 끼지 않게 하려고 붙였다.

2. RISC-V 명령어를 손으로 인코딩

def rv_addi(rd, rs1, imm):
    # RISC-V I-type: imm[11:0] | rs1 | funct3 | rd | opcode
    return ((imm & 0xFFF) << 20) | (rs1 << 15) | (0b000 << 12) | (rd << 7) | 0b0010011

def rv_decode_itype(word):
    return {
        "opcode": word & 0x7F,
        "rd": (word >> 7) & 0x1F,
        "funct3": (word >> 12) & 0x7,
        "rs1": (word >> 15) & 0x1F,
        "imm": (word >> 20) if not (word >> 31) else (word >> 20) - 4096,
    }

w = rv_addi(5, 6, 10)        # addi x5, x6, 10
print(f"{w:08x}", rv_decode_itype(w))
w = rv_addi(5, 6, -1)        # addi x5, x6, -1
print(f"{w:08x}", rv_decode_itype(w))

출력(python3 로 확인):

00a30293 {'opcode': 19, 'rd': 5, 'funct3': 0, 'rs1': 6, 'imm': 10}
fff30293 {'opcode': 19, 'rd': 5, 'funct3': 0, 'rs1': 6, 'imm': -1}

즉시값 −1 은 12비트 2의 보수 0xFFF 로 들어간다(#082). 해독할 때는 최상위 비트로 부호를 확장한다.

현업에서는

  • 멀티 아키텍처 이미지: x86 노드와 ARM 노드가 섞인 쿠버네티스 클러스터에서는 이미지가 해당 ISA 용으로 빌드돼 있지 않으면 파드가 exec format error 로 죽는다. docker buildx 로 여러 플랫폼을 함께 빌드하고 매니페스트 리스트로 묶는다. 노드의 ISA 는 kubernetes.io/arch 레이블로 확인하고 스케줄링에 쓸 수 있다.
  • ABI: ISA 위에는 호출 규약(인자를 어느 레지스터로 넘기는지)을 정한 ABI 가 있다. x86-64 리눅스는 System V psABI 를 따른다. C 확장 모듈이나 FFI 가 깨질 때 이 문서를 본다.
  • 컴파일 대상 선택: -march=native 로 빌드한 바이너리는 빌드한 CPU 의 확장 명령(AVX2 등)을 쓸 수 있지만, 그 확장이 없는 다른 노드에서 illegal instruction 으로 죽을 수 있다. 배포 바이너리는 대상 범위를 명시해 빌드한다.

확인 문제

  1. ISA 와 마이크로아키텍처의 차이를 설명하라.
  2. 적재/저장 구조란 무엇인가.
  3. 고정 길이 명령어가 파이프라인에 유리한 이유는.
  4. 현대 x86 CPU 가 “겉은 CISC, 속은 RISC” 라고 불리는 이유는.

풀이

  1. ISA 는 소프트웨어가 볼 수 있는 명령어·레지스터·인코딩의 약속이고, 마이크로아키텍처는 그 약속을 구현하는 내부 설계(파이프라인, 캐시 등)다.
  2. 메모리에는 적재·저장 명령으로만 접근하고 산술 연산은 레지스터끼리만 하는 구조다.
  3. 다음 명령어의 시작 위치를 바로 알 수 있고 필드 위치가 고정돼 해독이 단순하고 빠르기 때문이다.
  4. 복잡한 x86 명령어를 내부에서 단순한 마이크로 연산으로 쪼개 RISC 식 파이프라인으로 실행하기 때문이다.

더 읽을거리 (References)