Chapter 05

RV32I 명령어 둘러보기

RISC-V의 모든 프로그램은 결국 RV32I(또는 RV64I) 베이스의 40개 명령 위에 지어진다. 곱셈도, 부동소수점도, 벡터도 없는 이 작은 집합만으로 C 컴파일러가 돌아가고 운영체제가 부팅한다. 4장에서 비트 배치를 보았다면, 이 장에서는 각 명령이 무엇을 하고 왜 그렇게 만들어졌는지를 무리별로 살펴본다. 끝에서는 브라우저 안의 미니 IDE로 직접 프로그램을 짜서 한 명령씩 실행해 본다.

40개의 명령, 아홉 무리

규격의 RV32I 장은 첫머리에서 명령 수를 밝힌다. 레지스터 연산 10개, 즉시값 연산 9개, 상위 즉시값 2개, 점프 2개, 분기 6개, 적재 5개, 저장 3개, 그리고 fence·ecall·ebreak를 합하면 꼭 40개다. 규격은 여기서 한 걸음 더 나아가, 아주 단순한 구현이라면 ecall/ebreak를 “항상 트랩하는 SYSTEM 명령” 하나로 처리하고 fence를 아무것도 하지 않는 명령으로 구현해 38개까지 줄일 수 있다고 덧붙인다.

정수 연산 · 즉시값9개 · I형 addi slti sltiu xori ori andi slli srli srai 정수 연산 · 레지스터10개 · R형 add sub sll slt sltu xor srl sra or and 상위 즉시값2개 · U형 lui auipc 32비트 상수와 pc 기준 주소를 만드는 짝 (4장) 무조건 점프2개 · J/I형 jal jalr 함수 호출 · 복귀 · 간접 점프 복귀 주소(pc+4)를 rd에 남긴다 조건 분기6개 · B형 beq bne blt bge bltu bgeu 두 레지스터를 비교해 바로 분기 적재5개 · I형 lb lh lw lbu lhu u가 붙으면 0 확장, 없으면 부호 확장 저장3개 · S형 sb sh sw rs2의 하위 8·16·32비트를 rs1 + 오프셋 주소에 쓴다 메모리 순서1개 fence fence.tso · pause 는 fence의 특수 인코딩이라 따로 세지 않는다 환경 호출 · 중단점2개 ecall ebreak 실행 환경(OS·펌웨어·디버거)으로 제어를 넘기는 트랩 9 + 10 + 2 + 2 + 6 + 5 + 3 + 1 + 2 = 40
그림 5-1. RV32I의 40개 명령. 남색은 계산, 금색은 제어 흐름, 청록은 메모리 접근, 보라는 순서·환경 명령이다. CSR 명령(Zicsr, 16장)과 fence.i(Zifencei)는 베이스가 아니라 별도 확장에 속한다.
규격 원문에서
“RV32I contains 40 unique instructions, though a simple implementation might cover the ECALL/EBREAK instructions with a single SYSTEM hardware instruction that always traps …”

Volume I, RV32I Base Integer Instruction Set, Version 2.1 — 규격의 명령어 목록표(RV32/64G Listings)에는 FENCE.TSO와 PAUSE도 따로 한 줄씩 적혀 있지만, 둘 다 FENCE의 특정 인코딩이므로 40개 셈에는 들어가지 않는다.

무리명령개수형식이 장의 절
정수 연산 · 즉시값addi slti sltiu xori ori andi slli srli srai9I정수 연산
정수 연산 · 레지스터add sub sll slt sltu xor srl sra or and10R정수 연산
상위 즉시값lui auipc2U4장
무조건 점프jal jalr2J · I제어 흐름
조건 분기beq bne blt bge bltu bgeu6B제어 흐름
적재lb lh lw lbu lhu5I적재와 저장
저장sb sh sw3S적재와 저장
메모리 순서fence1IFENCE·ECALL
환경 호출 · 중단점ecall ebreak2IFENCE·ECALL
RV32I 버전 2.1

RV32I는 2019년에 버전 2.1로 비준되었고, 그 뒤로 명령의 뜻은 바뀌지 않았다. 원래 베이스에 있던 CSR 명령과 카운터, fence.i는 이 무렵 Zicsr·Zicntr·Zifencei라는 별도 확장으로 떨어져 나갔다. 그래서 오늘날의 RV32I는 정말로 “정수 계산과 메모리 접근, 제어 흐름”만 남은 최소 집합이다.

정수 연산: 즉시값과 레지스터

계산 명령 19개는 두 쌍둥이 무리로 나뉜다. 레지스터-즉시값 명령(I형)은 rs1과 12비트 즉시값을, 레지스터-레지스터 명령(R형)은 rs1과 rs2를 피연산자로 쓴다. 결과는 언제나 rd에 쓰고, 메모리는 건드리지 않는다. 메모리를 읽고 쓰는 일은 오직 적재·저장 명령만 하는 적재-저장 구조(load-store architecture)이기 때문이다.

add a0, a1, a2 레지스터 파일 rs1 → a1 rs2 → a2 즉시값 생성 imm[11:0] 부호 확장 I형 rs1 값 R형: rs2 값 선택 ALU funct3=000 · funct7=0000000 → 덧셈 rd ← 결과 a0 = a1 + a2
그림 5-2. 같은 ALU가 두 무리를 모두 처리한다. 다른 점은 두 번째 피연산자가 rs2 값이냐, 부호 확장한 12비트 즉시값이냐 하나뿐이다. 위 단추로 add와 addi를 바꿔 보면 쓰지 않는 경로가 흐려진다. 즉시값 쪽에 subi가 없는 이유도 여기서 보인다. addi rd, rs1, -5처럼 음수 즉시값을 더하면 되기 때문이다.

레지스터-즉시값 명령

명령동작알아 둘 점
addird ← rs1 + sext(imm)오버플로는 무시하고 하위 32비트만 남긴다. mv, nop, li(작은 값)의 바탕
sltird ← (rs1 < sext(imm)) ? 1 : 0부호 있는 비교
sltiurd ← (rs1 <u sext(imm)) ? 1 : 0즉시값을 먼저 부호 확장한 뒤 부호 없이 비교. sltiu rd, rs, 1 = seqz
andi · ori · xorird ← rs1 op sext(imm)xori rd, rs, -1 = 비트 반전(not)
slli · srli · sraird ← rs1 시프트 shamtshamt는 imm[4:0]. srai는 inst[30]=1로 구별한다

sltiu는 처음 보면 이상하다. 즉시값을 부호 확장한 다음에 부호 없는 수로 비교하니, sltiu rd, rs, -1은 “rs가 0xffffffff보다 작은가”가 된다. 이 규칙 덕분에 즉시값 범위의 양 끝, 즉 0 근처와 232 근처를 모두 하나의 명령으로 비교할 수 있다. 가장 쓸모 있는 경우가 sltiu rd, rs, 1이다. 부호 없이 1보다 작은 수는 0뿐이므로 “rs가 0인가”를 한 명령으로 계산한다.

시프트

원래 값 rs1 1 0 1 1 0 0 1 0 맨 왼쪽 = 부호 비트 sll · slli 2칸 1 1 0 0 1 0 0 0 오른쪽에서 0이 들어옴 srl · srli 2칸 0 0 1 0 1 1 0 0 왼쪽에서 0이 들어옴 sra · srai 2칸 1 1 1 0 1 1 0 0 부호 비트를 복사
그림 5-3. 세 가지 시프트를 8비트로 줄여 그렸다(실제는 32비트). 금색 칸이 새로 들어온 0, 보라 칸이 복사된 부호 비트다. 왼쪽 산술 시프트는 논리 시프트와 결과가 같으므로 따로 두지 않았다. 레지스터 시프트(sll srl sra)는 rs2의 하위 5비트만 시프트 양으로 쓴다.

레지스터-레지스터 명령과 NOP

R형 10개는 즉시값 무리와 거의 짝을 이룬다. 다른 점은 sub가 있다는 것뿐이다(즉시값 쪽은 음수를 더하면 되므로 subi가 필요 없다). add와 sub, srl과 sra는 funct3가 같고 inst[30] 한 비트로만 갈린다. slt/sltu는 비교 결과를 0 또는 1로 레지스터에 남겨, 플래그 레지스터 없이도 조건을 값으로 다룰 수 있게 한다. sltu rd, x0, rs2는 “rs2가 0이 아닌가”(snez)가 된다.

아무 일도 하지 않는 명령도 필요하다. 정렬용 빈칸, 타이밍 맞추기, 나중에 고쳐 쓸 자리 등에 쓰인다. RISC-V는 이것을 따로 만들지 않고 addi x0, x0, 0으로 정했다(기계어 0x00000013). x0에 쓰는 값은 버려지므로 결과적으로 아무 상태도 바뀌지 않는다.

규격 원문에서
“NOP is encoded as ADDI x0, x0, 0.”

RV32I, NOP Instruction — 규격 비고는 ADDI를 고른 이유로, 레지스터를 하나만 읽어 자원이 가장 적게 들고, 덧셈기는 어느 구현에나 가장 흔하며 주소 계산기에서도 실행할 수 있다는 점을 든다.

오버플로는 소프트웨어가 검사한다

x86이나 Arm은 덧셈 결과에 따라 자리올림(carry)·오버플로(overflow) 플래그를 세운다. RISC-V에는 그런 플래그가 없다. add는 결과의 하위 32비트만 남기고 넘친 사실은 조용히 버린다. 플래그는 모든 산술 명령이 “숨은 출력”을 하나씩 더 갖는 셈이라, 비순차 실행 코어에서 이름 바꾸기와 의존성 추적을 어렵게 한다. 대신 RISC-V는 필요할 때만 분기 명령으로 검사하라고 권한다.

0000 0 0 0001 1 +1 0010 2 +2 0011 3 +3 0100 4 +4 0101 5 +5 0110 6 +6 0111 7 +7 1000 8 -8 1001 9 -7 1010 10 -6 1011 11 -5 1100 12 -4 1101 13 -3 1110 14 -2 1111 15 -1 같은 비트, 두 가지 읽기 안쪽 숫자: 부호 없는 값 (0 … 15) 바깥 숫자: 부호 있는 값 (−8 … +7) 빨간 선 = 부호 없는 자리올림 1111 + 0010 = 0001 (15 + 2 → 1) 한 바퀴 돌면 합이 피연산자보다 작아진다 → bltu 한 번으로 검사 금색 선 = 부호 있는 오버플로 0111 + 0001 = 1000 (+7 + 1 → −8) 양수 + 양수가 음수, 음수 + 음수가 양수 → 비교 두 번과 분기 한 번으로 검사
그림 5-4. 4비트로 줄인 숫자 바퀴. 덧셈은 바퀴를 시계 방향으로 도는 일이다. 부호 없는 해석에서는 빨간 선(1111→0000)을, 부호 있는 해석에서는 금색 선(0111→1000)을 넘을 때 결과가 틀린다. 32비트에서도 똑같다. 경계가 0xffffffff→0과 0x7fffffff→0x80000000으로 바뀔 뿐이다.

규격은 세 가지 경우의 검사 순서를 그대로 제시한다. 부호 없는 덧셈은 “합이 한 피연산자보다 작으면 넘쳤다”는 성질을 이용해 분기 하나로 끝난다. 한쪽 피연산자의 부호를 미리 아는 부호 있는 덧셈(예: 양수 즉시값)도 분기 하나면 된다. 일반적인 부호 있는 덧셈은 “rs2가 음수인가”와 “합이 rs1보다 작은가”가 서로 다르면 오버플로라는 성질을 써서 명령 세 개를 더한다.

규격 원문에서
“We did not include special instruction-set support for overflow checks on integer arithmetic operations in the base instruction set, as many overflow checks can be cheaply implemented using RISC-V branches.”

RV32I, Integer Computational Instructions — 오버플로 검사가 필요한 언어(Rust의 디버그 빌드, 일부 런타임)는 이 순서를 컴파일러가 끼워 넣는다.

검사할 것덧붙는 명령원리
부호 없는 덧셈의 자리올림1합 <u 피연산자 ⇔ 한 바퀴 넘음
부호 있는 덧셈, 한쪽 부호를 앎1양수를 더했는데 합이 작아졌으면 넘침
부호 있는 덧셈, 일반3(rs2 < 0) ≠ (합 < rs1) 이면 넘침
직접 해 보기

이 장 끝의 미니 IDE에서 오버플로 예제를 고르면 0x7fffffff + 1을 위 네 명령으로 검사하고, 0xffffffff + 2의 자리올림을 bltu로 잡아낸다.

제어 흐름: 점프와 분기

jal과 jalr

jal rd, offset은 pc에 20비트 부호 있는 오프셋(2바이트 단위)을 더한 곳으로 점프하면서 다음 명령 주소 pc+4를 rd에 남긴다. 오프셋 범위는 ±1 MiB다. rd를 x0으로 주면 복귀 주소를 버리는 단순 점프가 되고, 어셈블러는 이것을 j라는 의사 명령으로 보여 준다. 호출 규약은 복귀 주소 레지스터로 ra(x1)를, 대체 링크 레지스터로 t0(x5)를 쓴다.

jalr rd, offset(rs1)은 I형이다. 목표 주소는 (rs1 + sext(imm)) & ~1, 즉 맨 아래 비트를 0으로 만든 값이다. 레지스터에 담긴 주소로 가므로 함수 복귀(ret = jalr x0, 0(ra)), 함수 포인터·switch 표 같은 간접 점프에 쓰인다. auipc와 짝지으면 pc 기준 ±2 GiB 어디로든 점프할 수 있다.

main 0x0008li a0, 21 0x000cjal ra, double 0x0010mv s0, a0 0x0014… double: 0x0040add a0, a0, a0 0x0044ret ret = jalr zero, 0(ra) jal: ra ← 0x0010 ret: pc ← ra ra = 0x00000010
그림 5-5. 함수 호출과 복귀. jal은 점프하면서 바로 다음 주소(0x0010)를 ra에 적어 두고, 함수 끝의 ret은 그 값으로 돌아온다. 호출 명령 자체가 스택을 건드리지 않는다는 점이 x86의 call과 다르다. 스택에 ra를 저장할지는 호출 규약(7장)이 정한다.

반환 주소 스택 힌트

고성능 코어는 ret의 목적지를 미리 맞히려고 반환 주소 스택(RAS, return-address stack)이라는 예측기를 둔다. 문제는 하드웨어가 어떤 jalr이 “호출”이고 어떤 것이 “복귀”인지 알 길이 없다는 점이다. 규격은 rd와 rs1이 링크 레지스터(x1 또는 x5)인지에 따라 힌트를 정해 두었다. 컴파일러가 이 약속을 지키면 예측이 잘 맞는다.

rd가 x1/x5rs1이 x1/x5rd = rs1RAS 동작전형적인 쓰임
아니오아니오—없음jr t1 간접 점프
아니오예—꺼냄(pop)ret = jalr x0, 0(ra)
예아니오—넣음(push)jalr ra, 0(t1) 함수 포인터 호출
예예아니오꺼낸 뒤 넣음코루틴 전환 (jalr ra, 0(t0))
예예예넣음auipc ra, … + jalr ra, …(ra) 먼 호출

jal도 rd가 x1 또는 x5이면 넣음(push) 힌트로 본다. 이 힌트는 성능에만 영향을 줄 뿐 결과는 바꾸지 않는다. 규칙을 어긴 코드도 올바르게 실행되며 다만 예측이 빗나가 느려질 뿐이다.

여섯 개의 조건 분기

분기 명령은 두 레지스터를 직접 비교해 성립하면 pc에 12비트 오프셋(2바이트 단위)을 더한다. 범위는 ±4 KiB다. 비교와 분기를 한 명령에 넣었으므로 플래그가 필요 없고, 같음(beq/bne), 부호 있는 크기(blt/bge), 부호 없는 크기(bltu/bgeu) 세 쌍이면 모든 비교를 표현할 수 있다. “크다”와 “작거나 같다”는 피연산자 순서만 바꾸면 되므로 bgt/ble은 의사 명령이다.

SIMULATOR

분기 판정기: 부호 있는 비교와 부호 없는 비교

명령조건 (이번 값)결과
골라 보기
t0 (16진수)—
t1 (16진수)—
성립하는 분기—
−1 vs 1을 골라 보자. 부호 있는 수직선에서 −1은 1의 왼쪽에 있어 blt가 성립하지만, 부호 없는 수직선에서는 0xffffffff가 맨 오른쪽 끝으로 날아가 bltu는 불성립이다. 같은 비트 패턴이라도 어떤 명령으로 비교하느냐가 뜻을 정한다.

목표 주소의 정렬

C 확장이 없는 RV32I에서는 명령이 4바이트 경계에 있어야 한다(IALIGN=32). 점프나 성립한 분기의 목표가 4의 배수가 아니면 명령어 주소 정렬 예외(instruction-address-misaligned)가 생기며, 예외는 목표 명령이 아니라 점프·분기 명령 자신에게서 보고된다. 성립하지 않은 분기는 목표가 잘못되어 있어도 예외를 내지 않는다. 오프셋이 2바이트 단위인 이유는 C 확장(13장)의 16비트 명령을 위해서다. C 확장이 있으면 IALIGN=16이 되어 2바이트 경계로도 점프할 수 있다.

규격 원문에서
“Plain unconditional jumps (assembler pseudoinstruction J) are encoded as a JAL with rd=x0.”

RV32I, Unconditional Jumps — 같은 절은 JAL의 범위를 “±1 MiB”, 조건 분기 절은 범위를 “±4 KiB”로 적고 있다.

적재와 저장

메모리 주소는 언제나 rs1 + 부호 확장한 12비트 오프셋 한 가지 방식으로 만든다. 인덱스 레지스터나 자동 증가 같은 복잡한 주소 지정 방식은 없다. 필요하면 add로 미리 주소를 계산한다. 이렇게 하면 주소 계산이 늘 같은 덧셈기 하나로 끝나고, 명령마다 레지스터 쓰기가 최대 하나로 제한되어 파이프라인이 단순해진다.

lb t2, 3(a0) rs1 (a0) 기준 주소 0x00002000 imm 오프셋 +3 + 유효 주소 0x00002003 메모리 0x200012 0x200134 0x200256 0x20039c 0x200400 0x2005ff 0x20067f 0x200780 확장 lb: 부호 lbu: 0 rd lb → 0xffffff9c (−100) lbu → 0x0000009c (156)
그림 5-6. 적재 명령의 데이터 경로. 기준 주소와 오프셋을 더해 유효 주소를 만들고, 메모리에서 읽은 바이트를 32비트로 넓혀 rd에 쓴다. 0x9c의 최상위 비트는 1이므로 lb는 위 24비트를 1로 채워 −100을, lbu는 0으로 채워 156을 만든다.
명령읽는/쓰는 크기rd를 채우는 방법
lw32비트그대로 (RV32에서는 확장할 비트가 없다)
lh · lhu16비트부호 확장 · 0 확장
lb · lbu8비트부호 확장 · 0 확장
sw · sh · sb32 · 16 · 8비트rs2의 하위 비트만 메모리에 쓴다 (저장에는 확장이 필요 없다)

저장 명령에 부호 없는 판이 없는 이유는 간단하다. 쓰는 쪽은 하위 비트를 잘라 넣기만 하면 되므로 부호를 따질 일이 없다. 또 rd가 x0인 적재도 값만 버릴 뿐 실제 메모리 접근과 그에 따른 예외는 그대로 일어난다. 장치 레지스터를 읽는 일 자체가 부수 효과를 낼 수 있기 때문이다.

리틀 엔디언과 정렬

sw t0, 0(a0) t0 = 0x12345678 [31:24][23:16][15:8][7:0] 12 34 56 78 78 56 34 12 0x20000x20010x20020x2003 가장 낮은 바이트(0x78)가 가장 낮은 주소에 → lb 0(a0) 은 0x78, lw 0(a0) 는 0x12345678 자연 정렬: 주소가 크기의 배수 +0 +1 +2 +3 +4 +5 +6 +7 lw +0 ✓ lw +4 ✓ lh +2 ✓ lh +6 ✓ lw +2 ✗ lh +3 ✗ lb ✓ ✓ 자연 정렬: 예외를 내면 안 된다 ✗ 정렬 안 됨: 실행 환경(EEI)이 정한다 하드웨어 처리 · 트랩 후 에뮬레이션 · 예외
그림 5-7. 왼쪽: RISC-V의 기본 메모리 체계는 리틀 엔디언이다. 32비트 값의 가장 낮은 바이트가 가장 낮은 주소에 들어간다(데이터 접근을 빅 엔디언으로 바꾸는 선택 기능은 특권 규격에 있다). 오른쪽: 크기의 배수인 주소에 접근하는 자연 정렬 접근과, 경계를 걸친 비정렬 접근. 바이트 접근은 언제나 정렬되어 있다.
규격 원문에서
“… loads and stores whose effective addresses are naturally aligned shall not raise an address-misaligned exception.”

RV32I, Load and Store Instructions — 비정렬 접근은 실행 환경 인터페이스(EEI)에 따라 정상 완료될 수도, 예외를 낼 수도 있다. 완료되더라도 규격은 비정렬 접근의 원자성을 보장하지 않는다.

SIMULATOR

적재 확장 관찰기: lb · lbu · lh · lhu · lw

↓
메모리에서 읽은 비트확장으로 채운 비트
명령
rd (16진수)—
부호 있는 값—
부호 없는 값—
정렬—
오프셋 0에서 lb와 lbu를 번갈아 눌러 0x9c가 −100과 156으로 갈리는 것을 보자. 그다음 lh를 고르고 오프셋을 1이나 3으로 옮기면 비정렬 접근 경고가 뜬다. 바이트 칸의 순서와 레지스터 칸의 순서가 거꾸로인 것이 리틀 엔디언이다.

FENCE, ECALL, EBREAK

FENCE: 메모리 접근의 순서 맞추기

하나의 하트 안에서는 명령이 프로그램 순서대로 실행된 것처럼 보이지만, 다른 하트나 장치가 볼 때는 메모리 접근의 순서가 뒤바뀔 수 있다(RVWMO 메모리 모델, 11장). fence는 “내 앞의 어떤 종류의 접근이 내 뒤의 어떤 종류의 접근보다 먼저 보이게 하라”고 순서를 못 박는다. 종류는 장치 입력(I), 장치 출력(O), 메모리 읽기(R), 메모리 쓰기(W) 네 가지다.

3128 2724 2320 1915 1412 117 60 fm0000·1000 predPI PO PR PW succSI SO SR SW rs100000 funct3000 rd00000 opcode0001111 (MISC-MEM) fence 앞의 접근 중 pred에 켠 종류 (예: W) fence fence 뒤의 접근 중 succ에 켠 종류 (예: R, W) 먼저 보임 그다음
그림 5-8. FENCE의 비트 필드. pred(앞 집합)와 succ(뒤 집합)에서 I·O·R·W 비트를 골라 어떤 접근끼리 순서를 지킬지 정한다. fm은 펜스 모드로, 0000이면 보통 펜스, 1000이면 FENCE.TSO다. rs1과 rd는 앞으로 더 세밀한 펜스를 위해 남겨 둔 칸이며 지금은 0으로 둔다.
어셈블리필드기계어뜻
fence (= fence iorw, iorw)pred=IORW, succ=IORW0x0ff0000f모든 메모리·장치 접근의 순서를 맞춘다
fence rw, rwpred=RW, succ=RW0x0330000f메모리 접근끼리만 순서를 맞춘다
fence.tsofm=1000, pred=RW, succ=RW0x8330000fTSO 순서(쓰기→읽기 재배치만 허용). 지원하지 않는 구현은 fence rw,rw로 처리해도 된다
pausepred=W, succ=00x0100000fZihintpause. 스핀 루프에서 잠깐 쉬라는 HINT

단일 하트만 쓰는 프로그램에서는 FENCE가 사실상 할 일이 없다. 그래서 규격은 단순한 구현이 FENCE를 NOP처럼 처리해도 된다고 말한다(메모리 접근을 늘 순서대로 하는 구현이라면 그것만으로 순서가 지켜진다). 이 장의 에뮬레이터도 그렇게 한다. 명령어 캐시와의 동기화를 위한 fence.i는 Zifencei 확장(16장)의 몫이다.

ECALL과 EBREAK

두 명령은 SYSTEM opcode(1110011)를 쓰며 나머지 필드가 모두 0이고, imm[11:0]만 다르다. ecall(imm=0, 0x00000073)은 실행 환경에 서비스를 요청하고, ebreak(imm=1, 0x00100073)은 디버거에게 제어를 넘긴다. 둘 다 예외(트랩)를 일으키며, 어떤 레지스터에 무엇을 넣어 요청하는지는 ISA가 아니라 실행 환경이 정한다. 사용자 프로그램의 ecall은 운영체제가, 운영체제의 ecall은 SBI 펌웨어가 받는다(18·22장).

a7 (호출 번호)이 책 에뮬레이터의 약속참고: 리눅스 RISC-V
1a0을 10진 정수로 출력—
4a0이 가리키는 0으로 끝나는 문자열 출력—
11a0의 하위 바이트를 문자로 출력—
10프로그램 종료—
93a0을 종료 코드로 하여 종료exit (64번은 write)
세미호스팅: ebreak를 감싼 특별한 세 줄

보드에 운영체제가 없을 때 디버거를 통해 호스트 PC의 파일 입출력을 빌려 쓰는 방식을 세미호스팅이라 한다. 규격은 slli x0, x0, 0x1f · ebreak · srai x0, x0, 7 세 줄을 세미호스팅 호출 표시로 정했다. 앞뒤 두 명령은 x0에 쓰는 HINT라 아무 효과가 없지만, 디버거는 이 앞뒤 무늬를 보고 일반 중단점과 세미호스팅 요청을 구별한다.

HINT: 아무것도 바꾸지 않는 명령의 쓸모

rd가 x0인 계산 명령은 결과가 버려지므로 아키텍처 상태를 바꾸지 않는다. 이런 인코딩이 수백만 개나 된다. RISC-V는 이 공간을 그냥 두지 않고 HINT(hint instruction)로 지정했다. HINT는 마이크로아키텍처에 성능 관련 귀띔을 전하는 용도이며, pc를 진행시키고 성능 카운터를 바꾸는 것 말고는 아무 효과가 없다. 그래서 HINT를 모르는 옛 구현은 그냥 NOP처럼 실행하면 된다. 새 기능을 추가해도 옛 하드웨어에서 같은 바이너리가 그대로 돈다는 뜻이다.

rd = x0 → 결과가 버려지는 명령의 쓰임 addi x0, x0, 0공식 NOP (HINT 공간에서 제외) add x0, x0, x2 … x5NTL.P1 · PALL · S1 · ALL (Zihintntl) fence w, 0PAUSE (Zihintpause) slli x0,x0,31 · srai x0,x0,7세미호스팅 시작·끝 표시 lui x0, … · sub x0, …미래 표준 HINT용으로 예약 slti x0, … · slt x0, …사용자 정의 HINT (표준은 영영 안 씀) 표준용 ≈ 91% 사용자 9%
그림 5-9. RV32I의 HINT 공간. rd=x0인 즉시값·레지스터 연산과 일부 FENCE 인코딩이 HINT로 지정되어 있고, 그중 약 91%는 표준 HINT용, 나머지는 사용자 정의용이다. addi x0, x0, 0은 NOP 전용이라 HINT 표에서 빠져 있다.
규격 원문에서
“RV32I reserves a large encoding space for HINT instructions, which are usually used to communicate performance hints to the microarchitecture.”

RV32I, HINT Instructions — 같은 절의 표 4가 명령별 조건과 코드 포인트 수를 모두 나열한다.

명령조건용도
lui, auipcrd=x0미래 표준 HINT
addird=x0, 그리고 rs1≠x0 또는 imm≠0미래 표준 HINT
addrd=x0, rs1=x0, rs2=x2…x5NTL.P1 · NTL.PALL · NTL.S1 · NTL.ALL
slli / sraird=rs1=x0, shamt=31 / 7세미호스팅 시작 / 끝 표시
fencerd=rs1=x0, fm=0, pred=W, succ=0PAUSE
slti sltiu srli slt sltu 등rd=x0사용자 정의 HINT

의사 명령: 어셈블러가 주는 편의

RV32I에는 mv도, ret도, not도 없다. 모두 기존 명령에 x0이나 특정 즉시값을 넣어 만든 의사 명령(pseudoinstruction)이다. 하드웨어는 명령 수를 줄이고, 사람은 읽기 쉬운 이름을 쓴다. 의사 명령 표는 예전에는 ISA 매뉴얼 1권의 부록에 있었지만, 지금 판(v20260120)에서는 빠졌다. 현재는 RISC-V International의 비ISA 문서인 RISC-V Assembly Manual이 표준 목록을 관리한다.

의사 명령실제 명령뜻
nopaddi x0, x0, 0아무것도 안 함
li rd, imm값에 따라 여러 가지 (addi 또는 lui+addi)상수 싣기
la rd, symbolauipc rd, hi + addi rd, rd, lo주소 싣기 (pc 기준)
mv rd, rsaddi rd, rs, 0레지스터 복사
not rd, rsxori rd, rs, -11의 보수
neg rd, rssub rd, x0, rs2의 보수 (부호 바꾸기)
seqz rd, rssltiu rd, rs, 1rs == 0 이면 1
snez rd, rssltu rd, x0, rsrs ≠ 0 이면 1
beqz / bnez rs, offbeq / bne rs, x0, off0과 비교해 분기
bgt rs, rt, offblt rt, rs, off크면 분기 (순서 바꾸기)
ble rs, rt, offbge rt, rs, off작거나 같으면 분기
j offjal x0, off점프
jr rsjalr x0, 0(rs)레지스터로 점프
retjalr x0, 0(x1)함수 복귀
call offauipc x1, hi + jalr x1, lo(x1)멀리 있는 함수 호출
tail offauipc x6, hi + jalr x0, lo(x6)꼬리 호출 (복귀 주소 없이 점프)
소스 (의사 명령) li a0, 0x12345678 call square ret not t0, t1 실제 명령 (기계어로 바뀜) lui a0, 0x12345 addi a0, a0, 0x678 auipc ra, %pcrel_hi(square) jalr ra, %pcrel_lo(…)(ra) jalr zero, 0(ra) xori t0, t1, -1 어셈블러 펼치기 · 라벨 계산
그림 5-10. 어셈블러가 의사 명령을 펼치는 모습. li와 call은 값이나 거리에 따라 명령 두 개가 된다. 0x678은 양수라 lui에 0x12345를 그대로 넣었지만, 하위 12비트가 0x800 이상이면 addi가 음수를 더하게 되므로 상위값에 1을 더해야 한다(4장).
이 책 에뮬레이터의 단순화

아래 미니 IDE의 어셈블러는 프로그램이 64 KiB 안에 들어가므로 call/tail을 jal ra/jal x0 하나로 바꾼다(±1 MiB 안이면 이것으로 충분하다). 실제 GNU·LLVM 어셈블러는 거리를 모르는 시점에 auipc+jalr 두 명령을 내놓고, 링커가 가까우면 jal로 줄이는 링커 완화(relaxation)를 한다. 또 이 IDE의 li는 숫자 상수만 받는다.

직접 짜 보기: RV32I 미니 IDE

이제 배운 명령으로 프로그램을 직접 짜고 돌려 보자. 예제를 고르거나 직접 고친 뒤 어셈블을 누르면 기계어 목록이 만들어진다. 한 명령으로 한 줄씩 따라가며 금색으로 바뀌는 레지스터와, 메모리 칸의 읽기(남색)·쓰기(금색) 표시를 보자. 곱셈·나눗셈(M 확장, 9장)도 실행되지만 이 장의 예제는 RV32I만 쓴다.

SIMULATOR

RV32I 미니 IDE

예제 프로그램
레지스터 (금색 = 방금 바뀜)
.data 메모리 (0x2000부터) 읽기쓰기
실행
pc—
실행한 명령 수0
상태—
거품 정렬을 고르고 자동 실행을 눌러 보자. lw가 읽는 두 칸이 남색으로, 맞바꾸는 sw가 쓰는 칸이 금색으로 깜빡이며 배열이 정렬된다. 문자열 길이에서는 lbu가 한 바이트씩 읽어 가다 0을 만나면 멈춘다. 소스에서 lbu를 lb로 바꿔도 결과가 같은 이유는 무엇일까? (힌트: 이 문자열에는 0x80 이상인 바이트가 없다.)
어셈블리 문법 요약

한 줄에 명령 하나, # 뒤는 주석, 이름:은 라벨이다. .data 구역에서는 .word, .half, .byte, .string, .space로 자료를 놓고(0x2000번지부터), .text 구역에 명령을 쓴다(0번지부터). 데이터 주소는 la로 얻는다. 시스템 호출은 a7에 번호, a0에 인자를 넣고 ecall.

핵심 정리

  1. RV32I는 40개 명령이다. 계산 21개(즉시값 9, 레지스터 10, lui/auipc), 제어 8개, 적재 5개, 저장 3개, fence·ecall·ebreak. FENCE.TSO와 PAUSE는 FENCE의 특수 인코딩이다.
  2. 계산 명령은 rs2 대신 부호 확장한 12비트 즉시값을 쓰느냐만 다르다. sltiu도 즉시값을 먼저 부호 확장한다. NOP은 addi x0, x0, 0이다.
  3. 플래그가 없으므로 오버플로는 분기로 검사한다. 부호 없는 덧셈은 bltu 하나, 일반 부호 있는 덧셈은 slti·slt·bne 세 개를 덧붙인다.
  4. jal(±1 MiB)과 jalr는 복귀 주소 pc+4를 rd에 남긴다. rd/rs1이 x1·x5인지에 따라 반환 주소 스택 힌트(push/pop)가 정해진다.
  5. 분기 6개(±4 KiB)는 두 레지스터를 직접 비교한다. 같은 비트라도 blt와 bltu의 판정은 다를 수 있다.
  6. 주소는 rs1 + 오프셋 한 가지 방식. lb/lh는 부호 확장, lbu/lhu는 0 확장이며, 메모리는 리틀 엔디언이다. 비정렬 접근의 처리는 실행 환경(EEI)이 정한다.
  7. rd=x0인 계산 명령은 HINT 공간이고(약 91% 표준용), mv·li·ret·call 같은 이름은 어셈블러가 펼치는 의사 명령이다.

확인 퀴즈

다음 중 RV32I의 40개 명령에 들어가지 않는 것은?

CSR 명령은 Zicsr 확장에 속한다. fence, ecall, ebreak는 베이스에 들어 있다.

sltiu rd, rs, 1과 같은 일을 하는 의사 명령은?

부호 없이 1보다 작은 값은 0뿐이므로 “rs가 0이면 1”이다. snez는 sltu rd, x0, rs다.

t0 = −1, t1 = 1일 때 분기가 성립하는 명령은?

부호 있는 비교에서 −1 < 1이므로 blt가 성립한다. 부호 없이 보면 −1은 0xffffffff라 가장 큰 수이므로 bltu는 성립하지 않는다.

메모리 0x2000번지의 바이트가 0x80일 때 lb t0, 0(a0) (a0 = 0x2000) 뒤의 t0은?

0x80의 최상위 비트가 1이므로 부호 확장되어 위 24비트가 모두 1이 된다(−128). lbu였다면 0x00000080(128)이다.

RISC-V의 공식 NOP 인코딩은?

NOP은 addi x0, x0, 0(0x00000013)이다. 모든 비트가 0인 32비트 값은 정의되지 않은(illegal) 명령으로 남겨 두어, 빈 메모리로 뛰어든 오류를 바로 잡아낼 수 있게 했다.

jalr x0, 0(ra), 즉 ret에 대해 반환 주소 스택이 받는 힌트는?

rd(x0)는 링크 레지스터가 아니고 rs1(x1)은 링크 레지스터이므로 꺼냄이다. 그래서 예측기는 가장 최근 호출의 복귀 주소를 목적지로 예측한다.

t1, t2를 부호 없이 더한 결과의 자리올림을 검사하는 규격의 방법은?

부호 없는 합이 피연산자보다 작아졌다면 한 바퀴 넘어간 것이다. 분기 하나면 충분하다.

4바이트 정렬되지 않은 주소로의 lw는 어떻게 되는가?

규격은 자연 정렬된 접근이 정렬 예외를 내지 않는다는 것만 못 박고, 비정렬 접근은 EEI에 맡긴다. 완료되는 경우에도 원자성은 보장하지 않는다.

더 읽을거리