본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Register: CPU 내부의 가장 빠른 저장 공간, 1클럭 사이클 접근

레지스터는 CPU 내부에 물리적으로 존재하는 초고속 저장 장치로, 산술논리연산장치(ALU)가 연산을 수행하기 위해 데이터를 직접 읽고 쓰는 유일한 장소다. 메모리 계층 구조에서 가장 상위에 위치하며 접근 속도는 1클럭 사이클, 용량은 수십~수백 바이트에 불과하다. 컴파일러의 레지스터 할당(register allocation) 전략과 함수 호출 규약(call

송민성7분 읽기

1. 개념

레지스터(register)는 CPU 내부에 물리적으로 내장된 저장 회로다. 플립플롭(flip-flop)이나 래치(latch) 회로로 구성되며, CPU 다이 위에 직접 배치되어 있어 메모리 버스를 거치지 않고 즉시 접근할 수 있다.

x86-64 아키텍처 기준으로 범용 레지스터(general purpose register)는 16개(RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8~R15)가 있고, 각각 64비트(8바이트) 폭을 가진다. 이 외에도 프로그램 카운터(PC, x86에서는 RIP), 스택 포인터(SP), 플래그 레지스터(FLAGS) 같은 특수 목적 레지스터가 존재한다.

2. 왜 사용하는가

ALU는 메모리를 직접 연산할 수 없다. 덧셈 하나를 수행하려 해도 피연산자가 레지스터에 로드되어 있어야 한다. 이것이 로드-스토어 아키텍처(load-store architecture, ARM/RISC-V 계열)의 핵심 원칙이며, x86 같은 CISC 계열도 내부적으로는 마이크로op 단위에서 레지스터를 거쳐 연산한다.

메모리 계층 구조를 놓고 보면 레지스터가 필요한 이유가 명확해진다.

| 저장 장치 | 접근 시간(대략) | 용량(대략) | |---|---|---| | 레지스터 | 1클럭 사이클 (약 0.3ns @ 3GHz) | 16개 × 8바이트 = 128바이트 | | L1 캐시 | 4클럭 사이클 (약 1ns) | 32~64KB | | L2 캐시 | 10~20클럭 사이클 (약 3~7ns) | 256KB~1MB | | 메인 메모리(DRAM) | 100ns 내외 | 수GB~수백GB |

레지스터가 없다면 모든 연산마다 메모리 접근(수백 배 느림)이 발생해 CPU 클럭 속도 자체가 의미 없어진다.

3. 동작 원리

컴파일러는 소스 코드의 변수를 가능한 한 레지스터에 배치하려 시도한다. 이 과정을 레지스터 할당(register allocation)이라 하며, 그래프 컬러링(graph coloring) 알고리즘을 주로 사용한다. 레지스터 개수는 물리적으로 한정되어 있어서(x86-64 정수 레지스터 16개), 동시에 살아있는 변수(live variable)가 레지스터 수보다 많으면 일부를 메모리(스택)로 밀어내는 레지스터 스필(register spill)이 발생한다.

현대 CPU는 물리적 레지스터보다 더 많은 내부 레지스터를 두고 이름을 동적으로 매핑하는 레지스터 리네이밍(register renaming) 기법을 쓴다. 예를 들어 인텔 Skylake는 아키텍처상 보이는 레지스터는 16개뿐이지만 내부적으로는 180개 이상의 물리 레지스터를 두어 거짓 의존성(false dependency)을 제거하고 비순차 실행(out-of-order execution)을 돕는다.

함수 호출 시에는 호출 규약(calling convention)에 따라 레지스터 사용 방식이 정해진다. System V AMD64 ABI(리눅스/macOS x86-64 표준)는 정수 인자 첫 6개를 RDI, RSI, RDX, RCX, R8, R9 순서로 전달하고, 반환값은 RAX에 담는다.

4. 코드 예제

두 정수를 더하는 함수가 어셈블리 레벨에서 레지스터를 어떻게 쓰는지 확인한다.

c
// add.c int add(int a, int b) { return a + b; }
shell
# x86-64 어셈블리로 컴파일해서 레지스터 사용 확인 gcc -O2 -S add.c -o add.s cat add.s

-O2 최적화 시 생성되는 핵심 코드는 다음과 같다.

text
add:
        lea     eax, [rdi+rsi]   ; 인자 a(edi), b(esi)를 더해 eax에 저장
        ret                      ; eax(반환값)를 그대로 반환

인자 a는 RDI, b는 RSI로 전달되고, 반환값은 관례대로 EAX(RAX의 하위 32비트)에 담긴다. 메모리 접근이 단 한 번도 없다는 점이 핵심이다.

레지스터가 부족해 스필이 발생하는 상황을 파이썬으로 시뮬레이션하면 개념을 직관적으로 이해할 수 있다.

python
# 레지스터 4개뿐인 가상 CPU를 시뮬레이션 # 변수가 레지스터 수를 초과하면 스택(메모리)으로 스필된다 class TinyCPU: def __init__(self, num_registers: int = 4): self.registers = {f"R{i}": None for i in range(num_registers)} self.stack = {} # 스필된 값 저장 (메모리 역할) self.spill_count = 0 def allocate(self, var_name: str, value: int) -> str: for reg, content in self.registers.items(): if content is None: self.registers[reg] = (var_name, value) return reg # 빈 레지스터가 없으면 스필 발생 self.spill_count += 1 self.stack[var_name] = value return f"SPILLED->stack[{var_name}]" def status(self): return {"registers": self.registers, "stack": self.stack, "spill_count": self.spill_count} cpu = TinyCPU(num_registers=4) variables = {"a": 1, "b": 2, "c": 3, "d": 4, "e": 5} # 변수 5개, 레지스터 4개 for name, val in variables.items(): location = cpu.allocate(name, val) print(f"{name} -> {location}") print(cpu.status())
text
a -> R0
b -> R1
c -> R2
d -> R3
e -> SPILLED->stack[e]
{'registers': {...}, 'stack': {'e': 5}, 'spill_count': 1}

동시에 살아있는 변수가 레지스터 수(4개)를 초과하는 순간 마지막 변수가 스택으로 밀려나는 것을 확인할 수 있다. 실제 컴파일러도 이와 유사한 방식으로 스필 여부를 결정한다.

5. 시간 복잡도 또는 성능 특성

레지스터 접근은 O(1)이며 실질적으로 1클럭 사이클 내에 완료된다. 3GHz CPU 기준 약 0.33ns다. L1 캐시 대비 3~4배, 메인 메모리 대비 200~300배 빠르다.

다만 레지스터는 개수가 절대적으로 부족하다(x86-64 정수 레지스터 16개, 부동소수점/SIMD용 XMM/YMM/ZMM 레지스터 16~32개). 레지스터 스필이 발생하면 해당 변수는 스택 메모리(L1 캐시 적중 시 약 1ns, 캐시 미스 시 수십~수백 ns)로 접근해야 하므로 성능이 급격히 저하될 수 있다.

6. 실무 사용 사례

컴파일러 최적화: GCC, LLVM/Clang은 레지스터 할당 단계에서 그래프 컬러링 또는 선형 스캔(linear scan) 알고리즘을 사용해 어떤 변수를 레지스터에 남기고 어떤 변수를 스필할지 결정한다. -O2, -O3 최적화 레벨이 높아질수록 레지스터 활용도가 증가한다.

함수 호출 규약: System V AMD64 ABI, Windows x64 calling convention 등은 어떤 레지스터로 인자를 전달하고 어떤 레지스터를 호출자/피호출자가 보존해야 하는지(caller-saved / callee-saved) 명시한다. RBX, RBP, R12~R15는 callee-saved로, 함수가 이 값을 변경했다면 복원 후 반환해야 한다.

JIT 컴파일러: V8(자바스크립트 엔진)의 TurboFan, JVM의 HotSpot 같은 JIT 컴파일러는 런타임에 핫 패스(hot path)를 분석해 자주 쓰이는 변수를 레지스터에 고정 배치하는 최적화를 수행한다.

임베디드/저수준 프로그래밍: 인터럽트 서비스 루틴(ISR)에서는 레지스터 상태를 스택에 저장(push)하고 복원(pop)하는 컨텍스트 스위칭 코드를 직접 작성하는 경우가 있다.

7. 주의할 점

C 언어의 register 키워드는 컴파일러에게 "이 변수를 레지스터에 두라"고 요청하는 힌트였지만, 현대 컴파일러(GCC, Clang)는 이를 사실상 무시하고 자체 레지스터 할당 알고리즘으로 판단한다. C++17부터는 이 키워드가 아예 예약어에서 의미를 잃어 사용해도 경고만 발생한다.

레지스터 개수를 초과하는 지역 변수를 가진 함수는 컴파일러가 자동으로 스필/필(fill) 코드를 삽입하므로, 성능이 민감한 반복문 내부에서는 살아있는 변수 개수를 의식적으로 줄이는 것이 도움이 될 수 있다. 다만 이는 컴파일러 최적화에 맡기고 프로파일링 결과를 보고 판단해야 할 문제이며, 섣부른 수동 최적화는 가독성만 해칠 수 있다.

8. 핵심 정리

레지스터는 CPU 내부에 위치한 가장 빠르지만 가장 작은 저장 공간이며, 모든 연산은 반드시 레지스터를 거쳐야 한다. 접근 속도는 1클럭 사이클로 메인 메모리보다 수백 배 빠르지만 개수가 극히 제한적이어서(x86-64 기준 16개) 컴파일러의 레지스터 할당과 스필 관리가 성능에 직접 영향을 미친다. 함수 호출 규약, 레지스터 리네이밍, JIT 최적화 등 저수준 성능 최적화의 근간이 되는 개념이다.

© 2026 Tyler Song