레지스터와 프로그래머 모델
어셈블리 프로그래머나 컴파일러가 보는 CPU는 놀랄 만큼 단순하다. 32개의 정수 레지스터와 pc 하나, 그리고 바이트 단위로 번지가 매겨진 메모리가 전부다. 조건 플래그도, 전용 스택 레지스터도, 누산기도 없다. 이 장에서는 RISC-V 베이스 ISA의 프로그래머 모델을 하나씩 뜯어보고, 그중 가장 독특한 레지스터 x0가 어떻게 수많은 명령을 “공짜로” 만들어 내는지 살펴본다.
- x0–x31, pc, XLEN으로 이루어진 비특권 상태를 그림으로 설명한다.
- x0가 항상 0인 이유와, x0 덕분에 생기는 의사 명령(nop·mv·neg·j·ret …)을 안다.
- 조건 플래그 레지스터가 없을 때의 장단점을 x86·Arm과 비교한다.
- zero·ra·sp·gp·tp·t·s·a 같은 ABI 이름과 역할을 구별한다.
- x1·x5 링크 레지스터와 반환 주소 스택(RAS) 힌트 규칙을 읽는다.
- f·v 레지스터와 CSR이 언제 생기는지, RV32E가 왜 16개만 두는지 안다.
- 바이트 주소, 리틀 엔디언, 정렬, 주소 공간 순환을 직접 실험한다.
프로그래머가 보는 상태: x0–x31과 pc
규격은 베이스 정수 ISA의 비특권 상태를 표 하나로 정리한다. x0부터 x31까지 32개의 정수 레지스터는 모두 폭이 XLEN 비트다(RV32I는 32, RV64I는 64). 여기에 현재 명령의 주소를 담는 pc가 하나 더 있다. 그것이 전부다.
“There is no dedicated stack pointer or subroutine return address link register in the Base Integer ISA; the instruction encoding allows any x register to be used for these purposes. However, the standard software calling convention uses register x1 to hold the return address for a call, with register x5 available as an alternate link register.”
왜 32개인가, 왜 5비트 칸이 고정되어 있나
레지스터가 32개이니 레지스터를 가리키는 칸은 5비트다. 규격 해설은 16개로도 컴파일된 정수 코드에는 충분하다고 인정하면서도, 32개를 고른 이유를 이렇게 설명한다. 16비트 명령 안에 3-주소 형식(rd, rs1, rs2)을 넣으면 16개 레지스터로도 완전한 ISA를 만들 수 없고, 24비트 같은 어중간한 길이는 피하고 싶었다. 일단 32비트 명령을 택하자 32개 레지스터를 지원하기는 쉬웠고, 레지스터가 많으면 루프 펼치기나 소프트웨어 파이프라이닝 같은 고성능 코드에도 유리하다.
더 중요한 것은 칸의 위치다. 모든 형식에서 rs1·rs2·rd가 같은 비트 위치에 있다. 덕분에 하드웨어는 명령의 종류를 다 해독하기 전에 레지스터 파일부터 읽기 시작할 수 있다. 명령 형식은 4장에서 자세히 다룬다.
x0: 언제나 0인 레지스터
규격의 정의는 한 줄이다. “레지스터 x0는 모든 비트가 0으로 고정되어 있다.” x0를 읽으면 항상 0이 나오고, x0에 쓰는 결과는 그냥 버려진다. 레지스터 하나를 통째로 상수 0에 바친 셈인데, 이 덕분에 ISA는 훨씬 작아진다. 0이 필요한 곳에 언제든 x0를 쓰면 되니, “레지스터 복사”, “부호 반전”, “무조건 점프”, “0과 비교해 분기” 같은 명령을 따로 만들 필요가 없다. 어셈블러가 의사 명령(pseudoinstruction)으로 이름만 붙여 주면 된다.
레지스터와 ALU 사이의 데이터 흐름
add zero, a1, a2를 눈여겨보자. ALU는 12를 계산해 x0로 보내지만, x0는 그대로 0이다. 결과를 버리고 싶을 때(예: 반환 주소가 필요 없는 점프) 목적지를 x0로 두는 이유다.x0를 이용하는 대표적인 의사 명령을 정리하면 다음과 같다. 모두 규격의 명령 설명이나 어셈블리 매뉴얼에 나오는 표준 확장이다.
| 의사 명령 | 실제 명령 | x0의 역할 |
|---|---|---|
| nop | addi x0, x0, 0 | 읽기도 쓰기도 x0 → 아무 효과 없음 |
| li rd, imm | addi rd, x0, imm | 0 + 즉시값 (12비트 범위일 때) |
| mv rd, rs | addi rd, rs, 0 | (x0 없이 즉시값 0으로 만든다) |
| neg rd, rs | sub rd, x0, rs | 0 − rs |
| snez rd, rs | sltu rd, x0, rs | 0 <u rs ⇔ rs ≠ 0 |
| beqz rs, L | beq rs, x0, L | 0과 비교 |
| bgtz rs, L | blt x0, rs, L | 0 < rs |
| j L | jal x0, L | 복귀 주소를 버림 |
| jr rs | jalr x0, 0(rs) | 복귀 주소를 버림 |
| ret | jalr x0, 0(x1) | 복귀 주소를 버리고 ra로 점프 |
x0 마법: 의사 명령 펼쳐 보기
li a0, 5와 li a0, 0x12345678을 비교해 보자. 작은 상수는 x0에 더해 한 명령으로 끝나지만, 큰 상수는 lui가 상위 비트를 직접 만들기 때문에 x0가 필요 없다.x0에 쓰는 명령은 결과가 버려지므로 아키텍처 상태를 바꾸지 않는다. 규격은 이런 인코딩 가운데 정식 nop(addi x0, x0, 0)을 뺀 나머지 상당수를 HINT로 지정해, 성능 힌트용으로 쓸 수 있게 남겨 두었다. 예를 들어 Zihintpause의 pause는 후행 집합이 비어 있는(succ=0) fence 인코딩을, Zicbop의 프리페치는 ori의 rd=x0 변형을 쓴다. 힌트를 모르는 옛 코어에서는 그냥 nop처럼 동작하므로 호환성이 깨지지 않는다.
플래그 레지스터가 없다
x86과 Arm에는 비교나 산술 결과의 성질(0인지, 음수인지, 올림수가 났는지, 넘쳤는지)을 기록하는 조건 코드(condition codes, flags) 레지스터가 있다. 분기 명령은 그 플래그를 읽는다. RISC-V에는 이런 레지스터가 없다. 대신 분기 명령이 두 레지스터를 직접 비교한다.
slt로 일반 레지스터에 0/1을 쓴다.규격 해설은 이 결정의 이유를 여러 가지로 든다. 비교-분기를 합친 명령은 일반 파이프라인에 잘 들어맞고, 추가 조건 코드 상태나 임시 레지스터가 필요 없으며, 정적 코드 크기와 명령어 가져오기 트래픽을 줄인다. 분기가 명령어 흐름 앞쪽에서 일찍 보이므로 예측도 빨리 할 수 있다. 플래그는 비순차 실행 코어에서 특히 골칫거리다. 거의 모든 명령이 플래그를 쓰면 그 플래그도 레지스터처럼 이름 바꾸기(renaming)를 해야 하기 때문이다.
대가도 있다. 올림수(carry)나 넘침(overflow)을 알고 싶으면 명령을 더 써야 한다. 규격은 그 비용이 크지 않다며 직접 예를 든다.
“This design was motivated by the observation that a combined compare-and-branch instruction fits into a regular pipeline, avoids additional condition code state or use of a temporary register, and reduces static code size and dynamic instruction fetch traffic.”
ABI 이름과 역할
하드웨어에게 x10과 x18은 똑같은 레지스터다. 그러나 따로 컴파일된 함수끼리 서로를 부르려면 “인자는 어디에 넣고, 어떤 레지스터는 호출 뒤에도 값이 남아 있어야 하는가”를 약속해야 한다. 이 약속이 호출 규약이고, RISC-V psABI 규격이 정한다. 어셈블리에서는 번호 대신 역할이 드러나는 ABI 이름을 주로 쓴다. 자세한 규칙과 스택 프레임은 7장에서 다룬다.
레지스터 파일 탐색기
| 레지스터 | ABI 이름 | 뜻 (psABI) | 호출 뒤 보존? |
|---|---|---|---|
x0 | zero | 항상 0 | — (바뀌지 않음) |
x1 | ra | 복귀 주소 | 아니오 |
x2 | sp | 스택 포인터 | 예 |
x3 | gp | 전역 포인터 | — (할당 대상 아님) |
x4 | tp | 스레드 포인터 | — (할당 대상 아님) |
x5–x7 | t0–t2 | 임시 | 아니오 |
x8–x9 | s0–s1 | 피호출자 저장 (s0 = 프레임 포인터 fp) | 예 |
x10–x17 | a0–a7 | 인자 (a0·a1은 반환값도) | 아니오 |
x18–x27 | s2–s11 | 피호출자 저장 | 예 |
x28–x31 | t3–t6 | 임시 | 아니오 |
psABI는 “표준 ABI에서 프로시저는 정수 레지스터 tp와 gp를 수정해서는 안 된다. 시그널 처리기가 그 값에 의존할 수 있기 때문이다”라고 적는다. gp는 링커가 전역 변수 접근을 짧게 만드는 데(7장의 링커 완화), tp는 스레드 지역 저장소(TLS)를 가리키는 데 쓴다.
x1·x5 링크 레지스터와 반환 주소 스택 힌트
베이스 ISA에는 “함수 호출 명령”이나 “복귀 명령”이 따로 없다. jal과 jalr은 아무 레지스터에나 복귀 주소(pc+4)를 남길 수 있다. 그러나 고성능 코어는 복귀 주소를 미리 맞히는 반환 주소 스택(RAS, return-address stack)을 갖고 있고, 그러려면 어떤 점프가 호출이고 어떤 점프가 복귀인지 알아야 한다. RISC-V는 별도의 힌트 비트 대신 레지스터 번호로 그 뜻을 전한다. 기준은 x1(ra)과 x5(t0), 두 링크 레지스터다.
| rd가 x1/x5? | rs1이 x1/x5? | rd = rs1? | RAS 동작 (JALR) | 대표 예 |
|---|---|---|---|---|
| 아니오 | 아니오 | — | 없음 | jr a5 (switch 점프 테이블) |
| 아니오 | 예 | — | Pop | ret = jalr x0, 0(ra) |
| 예 | 아니오 | — | Push | jalr ra, 0(a5) (함수 포인터 호출) |
| 예 | 예 | 아니오 | Pop 한 뒤 Push | jalr x5, 0(x1) (코루틴 전환) |
| 예 | 예 | 예 | Push | auipc ra, …; jalr ra, …(ra) |
jal은 rd가 x1이나 x5일 때만 RAS에 push한다. 마지막 줄의 규칙이 재미있다. rs1과 rd가 같은 링크 레지스터면 pop 없이 push만 하는데, 이는 auipc ra, imm20; jalr ra, imm12(ra) 같은 먼 거리 호출 두 명령을 하나로 묶어(macro-op fusion) 처리할 수 있게 하려는 것이다. x5를 대체 링크 레지스터로 고른 이유도 규격에 있다. 호출 규약에서 임시 레지스터라 자유롭게 쓸 수 있고, 인코딩이 x1과 한 비트만 다르다(00001 ↔ 00101). 압축 코드에서 레지스터를 저장·복원하는 “밀리코드” 루틴을 부를 때 ra를 보존하는 용도로 쓴다.
jal ra, …는 복귀 주소(pc+4)를 RAS에 넣고, ret(rs1 = x1, rd = x0)은 RAS에서 꺼내 목적지를 미리 예측한다. 힌트일 뿐이므로 예측이 틀려도 결과는 정확하다. 실제 목적지는 언제나 ra 레지스터의 값이다.확장이 더하는 레지스터: f·v·CSR
베이스 ISA의 상태는 x 레지스터와 pc뿐이지만, 확장을 얹으면 상태가 늘어난다. 각각 뒤의 장에서 자세히 다루니 여기서는 지도만 그려 두자.
fcsr이, V를 얹으면 VLEN 비트짜리 벡터 레지스터 32개와 설정 CSR이 생긴다. CSR은 x 레지스터와 전혀 다른 4096칸짜리 주소 공간으로, Zicsr의 전용 명령으로만 읽고 쓴다.Zfinx 계열 확장은 f 레지스터 없이 x 레지스터로 부동소수점 연산을 한다. 레지스터 파일을 통째로 아끼므로 작은 코어와 문맥 전환 비용에 유리하다. 대신 F와는 소프트웨어 호환이 되지 않는다(12장).
RV32E: 레지스터를 16개로 줄이면
RV32E와 RV64E는 RV32I·RV64I에서 단 하나, 정수 레지스터 수만 16개(x0–x15)로 줄였다. 명령 인코딩은 그대로이고, x16–x31을 가리키는 인코딩은 예약된다. 왜 이런 변형이 필요할까? 규격 해설이 숫자로 답한다.
레지스터가 16개면 호출 규약도 달라져야 한다. psABI의 ILP32E 호출 규약은 인자 레지스터를 a0–a5 여섯 개, 피호출자 저장을 s0–s1 두 개, 임시를 t0–t2 세 개만 쓰고, 스택 정렬도 16바이트가 아니라 4바이트로 완화한다(psABI 1.0에서 ILP32E의 상태는 Draft다). 위 탐색기에서 “RV32E”를 눌러 무엇이 남는지 확인해 보자.
메모리: 바이트 주소, 리틀 엔디언, 정렬
하트(hart)는 2XLEN 바이트짜리 하나의 주소 공간을 본다. 주소 하나가 바이트 하나를 가리킨다. 규격은 워드(word)를 32비트(4바이트)로 정하고, 하프워드는 16비트, 더블워드는 64비트, 쿼드워드는 128비트다. x86의 “워드 = 16비트”와 다르니 주의하자.
리틀 엔디언
여러 바이트로 된 값을 메모리에 놓을 때, 가장 낮은 자리 바이트를 가장 낮은 주소에 두면 리틀 엔디언(little-endian), 반대면 빅 엔디언(big-endian)이다. RISC-V는 리틀 엔디언으로 시작했다. 규격 해설은 그 이유로 상업적으로 리틀 엔디언이 지배적이라는 점(x86 전부, Arm 위의 iOS·Android·Windows)과 하드웨어 설계자에게 더 자연스럽다는 점을 든다.
엄밀히 말해 규격은 “RISC-V 베이스 ISA의 메모리 시스템은 리틀 엔디언이거나 빅 엔디언이며, 특권 아키텍처가 바이 엔디언 동작을 추가로 정의한다”고 적는다. 특권 규격의 mstatus에는 모드별 데이터 엔디언을 고르는 MBE·SBE·UBE 비트가 있다(RV64 mstatus의 비트 37·36·6, RV32에서는 MBE·SBE가 mstatush의 비트 5·4). 다만 RVI20·RVA 프로파일은 리틀 엔디언을 요구하고, psABI도 빅 엔디언 호출 규약을 아직 정의하지 않았으므로 현실의 RISC-V 소프트웨어는 거의 모두 리틀 엔디언이다. 명령어는 메모리 엔디언과 상관없이 언제나 16비트 리틀 엔디언 파슬로 저장된다(2장).
정렬과 비정렬 접근
크기가 N 바이트인 접근의 주소가 N의 배수면 자연 정렬되었다고 한다. 규격은 “EEI와 관계없이 자연 정렬된 적재·저장은 주소 비정렬 예외를 일으키지 않는다”고 보장한다. 정렬되지 않은 접근은 실행 환경(EEI)에 따라 다르다.
| EEI의 선택 | 비정렬 적재·저장은 |
|---|---|
| 완전 지원을 보장 | 하드웨어가 처리하거나, 보이지 않는 트랩(invisible trap)으로 소프트웨어가 대신 처리한다. 프로그램은 예외를 보지 않는다. 느릴 수는 있다. |
| 보장하지 않음 | 성공할 수도, address-misaligned 예외가 날 수도 있다. 부작용이 있는 영역처럼 흉내 내면 안 되는 곳이라면 access-fault 예외가 대신 날 수도 있다. |
비정렬 접근이 성공하더라도 원자성은 보장되지 않는다. 자연 정렬된 접근만 원자적으로 실행됨이 보장된다. 참고로 RVA20U64부터의 응용 프로세서 프로파일은 주 메모리에 대한 비정렬 적재·저장 지원을 요구(Zicclsm)하지만, “아주 느릴 수 있으니 정확성을 위해서만 기대하라”고 덧붙인다.
엔디언과 부호 확장 실험실 (RV32)
0x8badf00d를 0x1000에 sw로 저장한 뒤, 같은 주소에서 lb와 lbu를 차례로 해 보자. 맨 아래 바이트 0x0d는 양수라 둘이 같지만, 주소를 0x1003으로 옮겨 0x8b를 읽으면 lb는 위쪽 24비트를 1로 채운다(금색). 엔디언을 빅으로 바꾸어 다시 저장하면 바이트 순서가 뒤집힌다.핵심 정리
- 베이스 정수 ISA의 비특권 상태는 XLEN 비트짜리 x0–x31과 pc뿐이다. 전용 스택 포인터나 링크 레지스터는 하드웨어에 없고, 역할은 ABI가 정한다.
- 레지스터 칸(rs1·rs2·rd)은 5비트이고 모든 형식에서 같은 위치에 있어, 해독 전에 레지스터를 읽을 수 있다.
- x0는 항상 0이고 쓰기는 버려진다. 덕분에 nop·li·neg·j·jr·ret·beqz 같은 의사 명령이 새 인코딩 없이 생긴다.
- RISC-V에는 조건 플래그가 없다. 분기가 두 레지스터를 직접 비교하고, 비교 결과가 필요하면 slt로 레지스터에 남긴다.
- ABI 이름: zero, ra, sp, gp, tp, t0–t6, s0–s11, a0–a7. sp·s 레지스터는 호출 뒤 보존, ra·t·a는 보존되지 않는다.
- x1과 x5는 링크 레지스터로, jal/jalr의 rd·rs1 번호가 반환 주소 스택의 push/pop 힌트가 된다.
- F는 f0–f31과 fcsr을, V는 v0–v31과 벡터 CSR을 더한다. CSR은 12비트 주소의 별도 공간이다. RV32E는 레지스터를 16개로 줄여 코어 면적을 약 25% 아낀다.
- 메모리는 바이트 주소, 2XLEN 크기의 순환 공간이며, 사실상 리틀 엔디언이다. 자연 정렬된 접근은 비정렬 예외를 내지 않는다.
확인 퀴즈
add x0, a1, a2를 실행하면?
ret 의사 명령의 실제 인코딩은?
RISC-V에 조건 플래그 레지스터가 없어서 생기는 장점이 아닌 것은?
psABI에서 함수 호출 뒤에도 값이 보존되어야 하는 레지스터 묶음은?
jalr x1, 0(x5)는 반환 주소 스택에 어떤 힌트를 주는가?
리틀 엔디언 RV32에서 0x1000 번지에 sw로 0x8BADF00D를 저장한 뒤 lb a0, 0x1003을 실행하면 a0는?
RV32E가 RV32I와 다른 점은?
더 읽을거리
- Volume I — RV32I Base Integer Instruction Set, Version 2.1 (프로그래머 모델, JAL/JALR의 RAS 힌트 표, 적재·저장과 비정렬)
- Volume I — Introduction: Memory, Base Instruction-Length Encoding
- Volume I — RV32E and RV64E Base Integer Instruction Sets
- Volume II — Machine-Level ISA: mstatus의 MBE·SBE·UBE
- RISC-V ABIs Specification v1.0 — Register Convention, ILP32E