RV32I 명령어 둘러보기
RISC-V의 모든 프로그램은 결국 RV32I(또는 RV64I) 베이스의 40개 명령 위에 지어진다. 곱셈도, 부동소수점도, 벡터도 없는 이 작은 집합만으로 C 컴파일러가 돌아가고 운영체제가 부팅한다. 4장에서 비트 배치를 보았다면, 이 장에서는 각 명령이 무엇을 하고 왜 그렇게 만들어졌는지를 무리별로 살펴본다. 끝에서는 브라우저 안의 미니 IDE로 직접 프로그램을 짜서 한 명령씩 실행해 본다.
- RV32I의 40개 명령을 아홉 무리로 나누고, 규격이 그 수를 어떻게 세는지 안다.
- 즉시값 연산과 레지스터 연산, 시프트,
slt/sltu의 차이를 설명한다. - 플래그 없이 오버플로를 검사하는 규격의 명령 순서를 직접 써 본다.
jal/jalr와 여섯 분기 명령, 반환 주소 스택 힌트 규칙을 이해한다.- 적재 명령의 부호 확장과 0 확장, 리틀 엔디언, 정렬 규칙을 그림으로 확인한다.
- FENCE·ECALL·EBREAK, HINT 공간, 의사 명령이 각각 무엇인지 구별한다.
40개의 명령, 아홉 무리
규격의 RV32I 장은 첫머리에서 명령 수를 밝힌다. 레지스터 연산 10개, 즉시값 연산 9개, 상위 즉시값 2개, 점프 2개, 분기 6개, 적재 5개, 저장 3개, 그리고 fence·ecall·ebreak를 합하면 꼭 40개다. 규격은 여기서 한 걸음 더 나아가, 아주 단순한 구현이라면 ecall/ebreak를 “항상 트랩하는 SYSTEM 명령” 하나로 처리하고 fence를 아무것도 하지 않는 명령으로 구현해 38개까지 줄일 수 있다고 덧붙인다.
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 srai | 9 | I | 정수 연산 |
| 정수 연산 · 레지스터 | add sub sll slt sltu xor srl sra or and | 10 | R | 정수 연산 |
| 상위 즉시값 | lui auipc | 2 | U | 4장 |
| 무조건 점프 | jal jalr | 2 | J · I | 제어 흐름 |
| 조건 분기 | beq bne blt bge bltu bgeu | 6 | B | 제어 흐름 |
| 적재 | lb lh lw lbu lhu | 5 | I | 적재와 저장 |
| 저장 | sb sh sw | 3 | S | 적재와 저장 |
| 메모리 순서 | fence | 1 | I | FENCE·ECALL |
| 환경 호출 · 중단점 | ecall ebreak | 2 | I | FENCE·ECALL |
RV32I는 2019년에 버전 2.1로 비준되었고, 그 뒤로 명령의 뜻은 바뀌지 않았다. 원래 베이스에 있던 CSR 명령과 카운터, fence.i는 이 무렵 Zicsr·Zicntr·Zifencei라는 별도 확장으로 떨어져 나갔다. 그래서 오늘날의 RV32I는 정말로 “정수 계산과 메모리 접근, 제어 흐름”만 남은 최소 집합이다.
정수 연산: 즉시값과 레지스터
계산 명령 19개는 두 쌍둥이 무리로 나뉜다. 레지스터-즉시값 명령(I형)은 rs1과 12비트 즉시값을, 레지스터-레지스터 명령(R형)은 rs1과 rs2를 피연산자로 쓴다. 결과는 언제나 rd에 쓰고, 메모리는 건드리지 않는다. 메모리를 읽고 쓰는 일은 오직 적재·저장 명령만 하는 적재-저장 구조(load-store architecture)이기 때문이다.
add와 addi를 바꿔 보면 쓰지 않는 경로가 흐려진다. 즉시값 쪽에 subi가 없는 이유도 여기서 보인다. addi rd, rs1, -5처럼 음수 즉시값을 더하면 되기 때문이다.레지스터-즉시값 명령
| 명령 | 동작 | 알아 둘 점 |
|---|---|---|
| addi | rd ← rs1 + sext(imm) | 오버플로는 무시하고 하위 32비트만 남긴다. mv, nop, li(작은 값)의 바탕 |
| slti | rd ← (rs1 < sext(imm)) ? 1 : 0 | 부호 있는 비교 |
| sltiu | rd ← (rs1 <u sext(imm)) ? 1 : 0 | 즉시값을 먼저 부호 확장한 뒤 부호 없이 비교. sltiu rd, rs, 1 = seqz |
| andi · ori · xori | rd ← rs1 op sext(imm) | xori rd, rs, -1 = 비트 반전(not) |
| slli · srli · srai | rd ← rs1 시프트 shamt | shamt는 imm[4:0]. srai는 inst[30]=1로 구별한다 |
sltiu는 처음 보면 이상하다. 즉시값을 부호 확장한 다음에 부호 없는 수로 비교하니, sltiu rd, rs, -1은 “rs가 0xffffffff보다 작은가”가 된다. 이 규칙 덕분에 즉시값 범위의 양 끝, 즉 0 근처와 232 근처를 모두 하나의 명령으로 비교할 수 있다. 가장 쓸모 있는 경우가 sltiu rd, rs, 1이다. 부호 없이 1보다 작은 수는 0뿐이므로 “rs가 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는 필요할 때만 분기 명령으로 검사하라고 권한다.
규격은 세 가지 경우의 검사 순서를 그대로 제시한다. 부호 없는 덧셈은 “합이 한 피연산자보다 작으면 넘쳤다”는 성질을 이용해 분기 하나로 끝난다. 한쪽 피연산자의 부호를 미리 아는 부호 있는 덧셈(예: 양수 즉시값)도 분기 하나면 된다. 일반적인 부호 있는 덧셈은 “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 어디로든 점프할 수 있다.
jal은 점프하면서 바로 다음 주소(0x0010)를 ra에 적어 두고, 함수 끝의 ret은 그 값으로 돌아온다. 호출 명령 자체가 스택을 건드리지 않는다는 점이 x86의 call과 다르다. 스택에 ra를 저장할지는 호출 규약(7장)이 정한다.반환 주소 스택 힌트
고성능 코어는 ret의 목적지를 미리 맞히려고 반환 주소 스택(RAS, return-address stack)이라는 예측기를 둔다. 문제는 하드웨어가 어떤 jalr이 “호출”이고 어떤 것이 “복귀”인지 알 길이 없다는 점이다. 규격은 rd와 rs1이 링크 레지스터(x1 또는 x5)인지에 따라 힌트를 정해 두었다. 컴파일러가 이 약속을 지키면 예측이 잘 맞는다.
| rd가 x1/x5 | rs1이 x1/x5 | rd = rs1 | RAS 동작 | 전형적인 쓰임 |
|---|---|---|---|---|
| 아니오 | 아니오 | — | 없음 | 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은 의사 명령이다.
분기 판정기: 부호 있는 비교와 부호 없는 비교
| 명령 | 조건 (이번 값) | 결과 |
|---|
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는 위 24비트를 1로 채워 −100을, lbu는 0으로 채워 156을 만든다.| 명령 | 읽는/쓰는 크기 | rd를 채우는 방법 |
|---|---|---|
| lw | 32비트 | 그대로 (RV32에서는 확장할 비트가 없다) |
| lh · lhu | 16비트 | 부호 확장 · 0 확장 |
| lb · lbu | 8비트 | 부호 확장 · 0 확장 |
| sw · sh · sb | 32 · 16 · 8비트 | rs2의 하위 비트만 메모리에 쓴다 (저장에는 확장이 필요 없다) |
저장 명령에 부호 없는 판이 없는 이유는 간단하다. 쓰는 쪽은 하위 비트를 잘라 넣기만 하면 되므로 부호를 따질 일이 없다. 또 rd가 x0인 적재도 값만 버릴 뿐 실제 메모리 접근과 그에 따른 예외는 그대로 일어난다. 장치 레지스터를 읽는 일 자체가 부수 효과를 낼 수 있기 때문이다.
리틀 엔디언과 정렬
“… loads and stores whose effective addresses are naturally aligned shall not raise an address-misaligned exception.”
RV32I, Load and Store Instructions — 비정렬 접근은 실행 환경 인터페이스(EEI)에 따라 정상 완료될 수도, 예외를 낼 수도 있다. 완료되더라도 규격은 비정렬 접근의 원자성을 보장하지 않는다.
적재 확장 관찰기: lb · lbu · lh · lhu · lw
lb와 lbu를 번갈아 눌러 0x9c가 −100과 156으로 갈리는 것을 보자. 그다음 lh를 고르고 오프셋을 1이나 3으로 옮기면 비정렬 접근 경고가 뜬다. 바이트 칸의 순서와 레지스터 칸의 순서가 거꾸로인 것이 리틀 엔디언이다.FENCE, ECALL, EBREAK
FENCE: 메모리 접근의 순서 맞추기
하나의 하트 안에서는 명령이 프로그램 순서대로 실행된 것처럼 보이지만, 다른 하트나 장치가 볼 때는 메모리 접근의 순서가 뒤바뀔 수 있다(RVWMO 메모리 모델, 11장). fence는 “내 앞의 어떤 종류의 접근이 내 뒤의 어떤 종류의 접근보다 먼저 보이게 하라”고 순서를 못 박는다. 종류는 장치 입력(I), 장치 출력(O), 메모리 읽기(R), 메모리 쓰기(W) 네 가지다.
| 어셈블리 | 필드 | 기계어 | 뜻 |
|---|---|---|---|
fence (= fence iorw, iorw) | pred=IORW, succ=IORW | 0x0ff0000f | 모든 메모리·장치 접근의 순서를 맞춘다 |
fence rw, rw | pred=RW, succ=RW | 0x0330000f | 메모리 접근끼리만 순서를 맞춘다 |
fence.tso | fm=1000, pred=RW, succ=RW | 0x8330000f | TSO 순서(쓰기→읽기 재배치만 허용). 지원하지 않는 구현은 fence rw,rw로 처리해도 된다 |
pause | pred=W, succ=0 | 0x0100000f | Zihintpause. 스핀 루프에서 잠깐 쉬라는 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 |
|---|---|---|
| 1 | a0을 10진 정수로 출력 | — |
| 4 | a0이 가리키는 0으로 끝나는 문자열 출력 | — |
| 11 | a0의 하위 바이트를 문자로 출력 | — |
| 10 | 프로그램 종료 | — |
| 93 | a0을 종료 코드로 하여 종료 | exit (64번은 write) |
보드에 운영체제가 없을 때 디버거를 통해 호스트 PC의 파일 입출력을 빌려 쓰는 방식을 세미호스팅이라 한다. 규격은 slli x0, x0, 0x1f · ebreak · srai x0, x0, 7 세 줄을 세미호스팅 호출 표시로 정했다. 앞뒤 두 명령은 x0에 쓰는 HINT라 아무 효과가 없지만, 디버거는 이 앞뒤 무늬를 보고 일반 중단점과 세미호스팅 요청을 구별한다.
HINT: 아무것도 바꾸지 않는 명령의 쓸모
rd가 x0인 계산 명령은 결과가 버려지므로 아키텍처 상태를 바꾸지 않는다. 이런 인코딩이 수백만 개나 된다. RISC-V는 이 공간을 그냥 두지 않고 HINT(hint instruction)로 지정했다. HINT는 마이크로아키텍처에 성능 관련 귀띔을 전하는 용도이며, pc를 진행시키고 성능 카운터를 바꾸는 것 말고는 아무 효과가 없다. 그래서 HINT를 모르는 옛 구현은 그냥 NOP처럼 실행하면 된다. 새 기능을 추가해도 옛 하드웨어에서 같은 바이너리가 그대로 돈다는 뜻이다.
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, auipc | rd=x0 | 미래 표준 HINT |
addi | rd=x0, 그리고 rs1≠x0 또는 imm≠0 | 미래 표준 HINT |
add | rd=x0, rs1=x0, rs2=x2…x5 | NTL.P1 · NTL.PALL · NTL.S1 · NTL.ALL |
slli / srai | rd=rs1=x0, shamt=31 / 7 | 세미호스팅 시작 / 끝 표시 |
fence | rd=rs1=x0, fm=0, pred=W, succ=0 | PAUSE |
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이 표준 목록을 관리한다.
| 의사 명령 | 실제 명령 | 뜻 |
|---|---|---|
nop | addi x0, x0, 0 | 아무것도 안 함 |
li rd, imm | 값에 따라 여러 가지 (addi 또는 lui+addi) | 상수 싣기 |
la rd, symbol | auipc rd, hi + addi rd, rd, lo | 주소 싣기 (pc 기준) |
mv rd, rs | addi rd, rs, 0 | 레지스터 복사 |
not rd, rs | xori rd, rs, -1 | 1의 보수 |
neg rd, rs | sub rd, x0, rs | 2의 보수 (부호 바꾸기) |
seqz rd, rs | sltiu rd, rs, 1 | rs == 0 이면 1 |
snez rd, rs | sltu rd, x0, rs | rs ≠ 0 이면 1 |
beqz / bnez rs, off | beq / bne rs, x0, off | 0과 비교해 분기 |
bgt rs, rt, off | blt rt, rs, off | 크면 분기 (순서 바꾸기) |
ble rs, rt, off | bge rt, rs, off | 작거나 같으면 분기 |
j off | jal x0, off | 점프 |
jr rs | jalr x0, 0(rs) | 레지스터로 점프 |
ret | jalr x0, 0(x1) | 함수 복귀 |
call off | auipc x1, hi + jalr x1, lo(x1) | 멀리 있는 함수 호출 |
tail off | auipc x6, hi + jalr x0, lo(x6) | 꼬리 호출 (복귀 주소 없이 점프) |
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만 쓴다.
RV32I 미니 IDE
lw가 읽는 두 칸이 남색으로, 맞바꾸는 sw가 쓰는 칸이 금색으로 깜빡이며 배열이 정렬된다. 문자열 길이에서는 lbu가 한 바이트씩 읽어 가다 0을 만나면 멈춘다. 소스에서 lbu를 lb로 바꿔도 결과가 같은 이유는 무엇일까? (힌트: 이 문자열에는 0x80 이상인 바이트가 없다.)한 줄에 명령 하나, # 뒤는 주석, 이름:은 라벨이다. .data 구역에서는 .word, .half, .byte, .string, .space로 자료를 놓고(0x2000번지부터), .text 구역에 명령을 쓴다(0번지부터). 데이터 주소는 la로 얻는다. 시스템 호출은 a7에 번호, a0에 인자를 넣고 ecall.
핵심 정리
- RV32I는 40개 명령이다. 계산 21개(즉시값 9, 레지스터 10,
lui/auipc), 제어 8개, 적재 5개, 저장 3개,fence·ecall·ebreak. FENCE.TSO와 PAUSE는 FENCE의 특수 인코딩이다. - 계산 명령은 rs2 대신 부호 확장한 12비트 즉시값을 쓰느냐만 다르다.
sltiu도 즉시값을 먼저 부호 확장한다. NOP은addi x0, x0, 0이다. - 플래그가 없으므로 오버플로는 분기로 검사한다. 부호 없는 덧셈은
bltu하나, 일반 부호 있는 덧셈은slti·slt·bne세 개를 덧붙인다. jal(±1 MiB)과jalr는 복귀 주소 pc+4를 rd에 남긴다. rd/rs1이 x1·x5인지에 따라 반환 주소 스택 힌트(push/pop)가 정해진다.- 분기 6개(±4 KiB)는 두 레지스터를 직접 비교한다. 같은 비트라도
blt와bltu의 판정은 다를 수 있다. - 주소는 rs1 + 오프셋 한 가지 방식.
lb/lh는 부호 확장,lbu/lhu는 0 확장이며, 메모리는 리틀 엔디언이다. 비정렬 접근의 처리는 실행 환경(EEI)이 정한다. - rd=x0인 계산 명령은 HINT 공간이고(약 91% 표준용),
mv·li·ret·call같은 이름은 어셈블러가 펼치는 의사 명령이다.
확인 퀴즈
다음 중 RV32I의 40개 명령에 들어가지 않는 것은?
fence, ecall, ebreak는 베이스에 들어 있다.sltiu rd, rs, 1과 같은 일을 하는 의사 명령은?
snez는 sltu rd, x0, rs다.t0 = −1, t1 = 1일 때 분기가 성립하는 명령은?
blt가 성립한다. 부호 없이 보면 −1은 0xffffffff라 가장 큰 수이므로 bltu는 성립하지 않는다.메모리 0x2000번지의 바이트가 0x80일 때 lb t0, 0(a0) (a0 = 0x2000) 뒤의 t0은?
lbu였다면 0x00000080(128)이다.RISC-V의 공식 NOP 인코딩은?
addi x0, x0, 0(0x00000013)이다. 모든 비트가 0인 32비트 값은 정의되지 않은(illegal) 명령으로 남겨 두어, 빈 메모리로 뛰어든 오류를 바로 잡아낼 수 있게 했다.jalr x0, 0(ra), 즉 ret에 대해 반환 주소 스택이 받는 힌트는?
t1, t2를 부호 없이 더한 결과의 자리올림을 검사하는 규격의 방법은?
4바이트 정렬되지 않은 주소로의 lw는 어떻게 되는가?
더 읽을거리
- The RISC-V Instruction Set Manual, Volume I — RV32I Base Integer Instruction Set, Version 2.1 (v20260120)
- Volume I — RV32/64G Instruction Set Listings (opcode 지도와 명령어 목록표)
- Volume I — Zihintpause Extension, Zihintntl Extension
- RISC-V Assembly Manual (riscv-non-isa) — 표준 의사 명령 목록과 어셈블러 지시어
- Andrew Waterman, “Design of the RISC-V Instruction Set Architecture”, UC Berkeley 박사 논문 (2016) — 플래그를 없앤 이유, 분기 설계의 근거
- David Patterson, Andrew Waterman, The RISC-V Reader: An Open Architecture Atlas (2017), 2장 RV32I