작지만 중요한 확장들
M·A·F·V처럼 한 글자 이름을 가진 큰 확장 말고도, RISC-V에는 이름이 Z로 시작하는 작은 확장이 수십 개 있다. 명령어가 두 개뿐인 것도 있고, 아예 새 명령 없이 기존 인코딩의 빈자리에 뜻만 얹은 것도 있다. 하지만 이 작은 확장들이 없으면 운영체제는 CSR을 못 건드리고, JIT 컴파일러는 새 코드를 못 돌리고, 공격자는 반환 주소를 마음대로 바꾼다. 이 장은 Z 확장 하나하나가 “왜” 필요했는지를 따라가며, 작은 인코딩 결정이 어떤 큰 문제를 푸는지 본다.
- Zicsr의 CSR 명령 6개가 하나의 CSR을 원자적으로 읽고·고치고·쓰는 방식과 의사 명령을 안다.
- Zicntr·Zihpm 카운터의 CSR 주소와, RV32에서 64비트 카운터를 안전하게 읽는 3회 읽기 루프를 이해한다.
- fence.i, pause, ntl.* 처럼 기존 인코딩을 빌려 쓴 명령과 힌트의 인코딩을 확인한다.
- Zimop의 “may-be 연산”이 새 확장을 옛 하드웨어에서도 돌게 해 주는 원리를 안다.
- Zicond로 분기 없는 조건부 선택을 만들고, 분기 예측 실패 비용과 비교한다.
- CMO의 clean·flush·inval·zero·prefetch가 캐시와 메모리에 미치는 효과를 구별한다.
- Zicfilp 착지 패드와 Zicfiss 그림자 스택이 각각 어떤 공격을 막는지, 위반 시 어떤 예외가 나는지 안다.
- Zilsd가 RV32에서 레지스터 짝으로 64비트를 한 번에 옮기는 규칙을 안다.
Z 확장 지도: 작게 쪼갠 이유
RISC-V의 확장 이름 규칙(2장)에서 Z 다음 글자는 그 확장이 기대는 한 글자 확장을 가리킨다. Zi*는 베이스 정수 ISA(I) 쪽, Zb*는 비트 조작(B) 쪽이다. 이 장에서 다루는 확장은 대부분 Zi로 시작한다. 즉 정수 베이스에 붙는 작은 기능이다.
왜 이렇게 잘게 나눌까? 이유는 두 가지다. 첫째, 필요 없는 구현은 안 만들어도 되게 하려는 것이다. 운영체제 없는 마이크로컨트롤러에는 shadow stack이 필요 없고, 캐시가 없는 칩에는 캐시 관리 명령이 의미가 없다. 둘째, 원래 베이스에 있던 것을 떼어 내기 위해서다. 카운터 CSR과 fence.i는 예전에 RV32I 안에 있었지만, 모든 구현에 강요하기엔 부담이 커서 Zicntr와 Zifencei로 분리되었다.
| 확장 | 규격 버전 | 새 명령·CSR | 한 줄 요약 |
|---|---|---|---|
| Zicsr | 2.0 | csrrw·csrrs·csrrc·csrrwi·csrrsi·csrrci | CSR 하나를 원자적으로 읽고 고쳐 쓴다 |
| Zicntr / Zihpm | 2.0 | cycle·time·instret / hpmcounter3–31 | 읽기 전용 64비트 카운터 |
| Zifencei | 2.0 | fence.i | 자기 하트의 store를 명령 인출에 보이게 |
| Zihintntl | 1.0 | ntl.p1·ntl.pall·ntl.s1·ntl.all | 다음 메모리 접근은 다시 안 쓴다는 힌트 |
| Zihintpause | 2.0 | pause | 스핀 대기 중 잠깐 쉬어 가라는 힌트 |
| Zimop / Zcmop | 1.0 | mop.r.n·mop.rr.n / c.mop.n | 나중 확장이 뜻을 붙일 자리, 기본은 rd ← 0 |
| Zicond | 1.0.0 | czero.eqz·czero.nez | 조건이 0이면(아니면) 0을 쓴다 |
| CMO (Zicbom·Zicboz·Zicbop) | 1.0.0 | cbo.clean·flush·inval·zero, prefetch.i·r·w | 캐시 블록 단위 관리 |
| Zicfilp / Zicfiss | CFI 장 (비준됨) | lpad / sspush·sspopchk·ssrdp·ssamoswap, ssp CSR | 간접 점프·반환 주소 보호 |
| Zilsd / Zclsd | 1.0 | RV32용 ld·sd / c.ld·c.sd·c.ldsp·c.sdsp | 짝수·홀수 레지스터 짝으로 64비트 접근 |
Zicsr: CSR을 원자적으로 읽고 고쳐 쓰기
각 하트에는 제어·상태 레지스터(CSR, Control and Status Register)를 위한 별도 주소 공간이 4096칸 있다. 일반 레지스터 x0–x31과 달리 CSR은 하드웨어 동작을 조절하거나(예: 인터럽트 허용) 상태를 알려 준다(예: 부동소수점 플래그, 카운터). 특권 아키텍처(17장)가 대부분의 CSR을 정의하지만, CSR에 접근하는 명령은 비특권 규격의 Zicsr 확장이 정한다.
Zicsr의 명령은 여섯 개뿐이고 모두 같은 일을 한다. CSR의 옛 값을 읽어 rd에 넣고, 동시에 새 값을 CSR에 쓴다. 새 값을 만드는 방법이 세 가지(그대로 쓰기·비트 켜기·비트 끄기)이고, 피연산자가 레지스터인지 5비트 즉시값인지에 따라 두 벌이 있다.
| 명령 | 동작 | 자주 쓰는 의사 명령 |
|---|---|---|
| csrrw rd, csr, rs1 | rd ← CSR, CSR ← rs1 (맞바꾸기) | csrw csr, rs1 = csrrw x0, csr, rs1 |
| csrrs rd, csr, rs1 | rd ← CSR, CSR ← CSR | rs1 | csrr rd, csr = csrrs rd, csr, x0 · csrs csr, rs1 |
| csrrc rd, csr, rs1 | rd ← CSR, CSR ← CSR & ~rs1 | csrc csr, rs1 |
| csrrwi rd, csr, uimm | rd ← CSR, CSR ← uimm | csrwi csr, uimm |
| csrrsi rd, csr, uimm | rd ← CSR, CSR ← CSR | uimm | csrsi csr, uimm |
| csrrci rd, csr, uimm | rd ← CSR, CSR ← CSR & ~uimm | csrci csr, uimm |
읽지 않거나 쓰지 않는 경우
CSR 중에는 읽거나 쓰는 것만으로 부수 효과가 생기는 것이 있다. 그래서 규격은 언제 CSR을 실제로 읽고 쓰는지를 정확히 정해 두었다. 핵심은 x0과 0이다. csrrw의 rd가 x0이면 CSR을 아예 읽지 않고, csrrs/csrrc의 rs1이 x0이면(즉시값 형식은 uimm=0이면) CSR에 아예 쓰지 않는다. 덕분에 csrr은 읽기 전용 CSR에 써도 불법 명령 예외가 나지 않는다.
| 명령 | 조건 | CSR 읽기 | CSR 쓰기 |
|---|---|---|---|
| csrrw / csrrwi | rd = x0 | 안 함 | 함 |
| csrrw / csrrwi | rd ≠ x0 | 함 | 함 |
| csrrs / csrrc | rs1 = x0 | 함 | 안 함 |
| csrrsi / csrrci | uimm = 0 | 함 | 안 함 |
| csrrs / csrrc / csrrsi / csrrci | 그 밖 | 함 | 함 |
csrrs의 rs1이 x0이 아닌 레지스터인데 값이 우연히 0이라면, 비트는 하나도 안 바뀌지만 규격상 쓰기는 일어난 것이다. 읽기 전용 CSR이었다면 불법 명령 예외가 날 수 있다. 쓰기를 피하려면 인코딩 자체가 x0(또는 uimm=0)이어야 한다.
“All CSR instructions atomically read-modify-write a single CSR, whose CSR specifier is encoded in the 12-bit csr field of the instruction held in bits 31-20.”
Zicsr, CSR Instructions — “원자적”이라는 말은 읽기와 쓰기 사이에 다른 명령이 끼어들 수 없다는 뜻이다. 인터럽트 허용 비트를 끄면서 옛 값을 저장하는 일이 한 명령으로 끝난다.
예를 들어 운영체제가 잠깐 인터럽트를 막았다가 원래대로 되돌릴 때 다음처럼 쓴다. csrrci 한 명령이 “옛 mstatus 저장”과 “MIE 비트(3번) 끄기”를 동시에 한다.
Zicntr · Zihpm: 카운터와 타이머
성능을 재려면 “몇 사이클 걸렸나”, “명령 몇 개를 실행했나”를 알아야 한다. RISC-V는 사용자 모드에서도 읽을 수 있는 읽기 전용 64비트 카운터를 최대 32개 둔다. 주소는 0xC00–0xC1F이고, RV32에서는 위쪽 32비트를 0xC80–0xC9F로 따로 읽는다. 이 중 처음 세 개가 Zicntr, 나머지 29개가 Zihpm이다.
| 의사 명령 | CSR (주소) | 세는 것 | RV32 상위 절반 |
|---|---|---|---|
rdcycle rd | cycle (0xC00) | 하트가 돌고 있는 코어의 클럭 사이클 수 | rdcycleh · cycleh (0xC80) |
rdtime rd | time (0xC01) | 실시간 시계 틱 (벽시계 시간) | rdtimeh · timeh (0xC81) |
rdinstret rd | instret (0xC02) | 이 하트가 완료(retire)한 명령 수 | rdinstreth · instreth (0xC82) |
csrr rd, hpmcounter3 … | hpmcounter3–31 (0xC03–0xC1F) | 플랫폼이 정하는 사건 (캐시 미스 등) | hpmcounter3h–31h (0xC83–0xC9F) |
세 의사 명령은 모두 csrrs rd, counter, x0으로 바뀐다. 몇 가지 눈여겨볼 규칙이 있다. ecall·ebreak처럼 동기 예외를 일으킨 명령은 완료된 것으로 치지 않아 instret이 늘지 않는다. 모든 하트의 실시간 시계는 서로 한 틱 안으로 맞아야 한다. 그리고 Zihpm 카운터가 어떤 사건을 셀지는 플랫폼이 정하며, 특권 소프트웨어가 mcounteren/scounteren으로 사용자 모드의 접근을 막을 수도 있다(17장). 타이밍 부채널 공격을 막기 위해 카운터 접근을 막는 실행 환경도 있다고 규격이 적고 있다.
RV32에서 64비트 카운터 읽기
RV32에서 cycle을 읽으려면 두 번 나눠 읽어야 한다. 문제는 그 사이에도 카운터가 계속 증가한다는 점이다. 아래쪽 32비트가 0xFFFFFFFF에서 0으로 넘어가는 순간 위쪽이 1 늘어나는데, 하필 두 읽기 사이에 그 일이 일어나면 위·아래가 서로 다른 시점의 값이 되어 약 43억(232)만큼 틀린 값을 얻는다. 규격은 이를 피하는 짧은 루프를 제시한다.
위쪽을 앞뒤로 두 번 읽어 같으면, 그 사이에 자리올림이 없었다는 뜻이므로 x3:x2가 한 시점의 올바른 값이다. 다르면 처음부터 다시 읽는다. 아래 애니메이션으로 “자리올림까지 남은 사이클”을 바꿔 가며 직접 확인해 보자.
RV32에서 64비트 cycle 읽기
“We required the counters be 64 bits wide, even when XLEN=32, as otherwise it is very difficult for software to determine if values have overflowed.”
Zicntr and Zihpm — 32비트 카운터는 수 GHz 클럭에서 몇 초 만에 한 바퀴를 돈다. 64비트로 정해 두면 현실적으로 넘칠 일이 없고, 대신 RV32에는 위의 짧은 루프라는 비용만 남는다.
fence.i와 힌트: Zifencei · Zihintpause · Zihintntl
Zifencei: 내가 쓴 코드를 내가 실행하려면
JIT 컴파일러는 실행 중에 기계어를 만들어 메모리에 store로 쓴 뒤 그 주소로 점프한다. 그런데 많은 코어는 명령어 캐시(I-캐시)와 데이터 캐시(D-캐시)가 따로 있고, 둘이 서로를 자동으로 맞춰 주지 않는다. store는 D-캐시에 들어갔는데 인출 장치는 I-캐시에 남은 옛 명령을 가져올 수 있다. RISC-V는 store가 명령 인출에 보이는 것을 fence.i를 실행하기 전까지 보장하지 않는다.
fence.i는 그 앞의 store가 그 뒤의 명령 인출에 보이도록 보장한다. 단순한 구현은 I-캐시와 파이프라인을 통째로 비우는 식으로 이를 지킨다.fence.i의 인코딩은 MISC-MEM opcode(0001111)에 funct3=001이고, 나머지 칸(imm, rs1, rd)은 앞으로 더 정밀한 펜스를 위해 남겨 두었다. 표준 소프트웨어는 이 칸을 0으로 두고, 구현은 무시해야 한다.
fence.i는 실행한 하트 자신의 인출만 동기화한다. 다른 하트도 새 코드를 실행하려면, 쓴 쪽이 데이터 fence를 한 뒤 다른 하트들에게 각자 fence.i를 하라고 요청해야 한다. 게다가 운영체제는 사용자 스레드를 언제든 다른 하트로 옮길 수 있다. 그래서 리눅스는 사용자 모드의 fence.i에 기대지 않고, 명령 캐시를 맞추는 시스템 호출(riscv_flush_icache)을 두었다. 이것이 fence.i가 베이스 ISA에서 빠져 Zifencei로 분리된 이유 중 하나다.
Zihintpause: 스핀 대기 중 잠깐 쉬기
잠금(lock)이 풀리기를 기다리며 같은 메모리를 계속 읽는 스핀 대기 루프는 아무 일도 안 하면서 에너지와 실행 자원을 쓴다. pause는 “명령 완료 속도를 잠깐 늦춰도 된다”는 힌트다. SMT 코어라면 그동안 다른 하트에게 자원을 양보할 수 있다. 효과가 지속되는 시간은 유한해야 하고, 0이어도 된다.
재미있는 점은 인코딩이다. pause는 새 opcode가 아니라 FENCE 명령의 쓸모없는 조합이다. pred=W(앞의 쓰기), succ=0(뒤의 아무것도 아님)인 FENCE는 순서를 강제할 대상이 없으므로 아무 의미가 없다. 그 자리를 힌트로 쓴다.
pause는 pred=W(0001), succ=0인 FENCE다. 아래: 네 가지 NTL 힌트는 rd=x0, rs1=x0인 ADD에서 rs2만 x2–x5로 바꾼 것이다. 둘 다 원래는 “아무 일도 안 하는” 인코딩이라 힌트를 모르는 코어에서도 안전하다.스핀 루프마다 pause는 하나만 넣으라고 규격은 권한다. 여러 개를 넣으면 어떤 구현에서는 필요 이상으로 오래 멈출 수 있다. 메모리 값이 바뀔 때까지 아예 저전력으로 잠들고 싶다면 Zawrs 확장의 lr.w + wrs.nto를 쓴다(10장).
Zihintntl: “이 데이터는 다시 안 쓴다”
큰 배열을 한 번 훑고 지나가는 스트리밍 코드는 캐시를 쓸모없는 데이터로 채워, 정작 자주 쓰는 데이터를 밀어낸다. NTL(Non-Temporal Locality) 힌트는 바로 다음 명령의 메모리 접근이 시간 지역성이 낮다고 알려 준다. 구현은 이 힌트로 캐시에 아예 넣지 않거나, 교체 우선순위를 낮출 수 있다. 네 가지 변형은 “어느 캐시 수준까지 지역성을 기대할 수 없는가”를 나타낸다.
ntl.all, 경쟁이 심한 동기화 변수면 ntl.pall)에 따라 고르라고 규격은 권한다.HINT는 아키텍처 상태를 바꾸지 않는 인코딩(대개 rd=x0)에 성능 정보를 실은 것이다. 힌트를 모르는 옛 코어는 그냥 아무 일도 안 하는 명령으로 실행하므로, 같은 바이너리가 어디서나 돈다. NTL 힌트는 ADD로 인코딩되어 있어서 LR/SC 루프 안에 넣어도 전진 보장을 깨지 않는다는 장점도 있다. 반면 pause는 FENCE이므로 LR/SC 루프 안에 넣으면 전진 보장이 사라진다.
Zimop: “어쩌면 연산”이라는 예약 자리
새 보안 확장을 만들었다고 하자. 컴파일러가 그 명령을 함수마다 넣으면, 그 확장이 없는 옛 칩에서는 불법 명령 예외로 프로그램이 죽는다. 그렇다고 칩마다 바이너리를 따로 만들 수는 없다. Zimop은 이 문제를 미리 풀어 둔 확장이다. MOP(May-Be-Operation)은 “지금은 rd에 0을 쓰는 것 말고 아무 일도 안 하지만, 나중 확장이 뜻을 붙일 수 있는” 명령이다.
- mop.r.n (n = 0–31, 32개): 나중에 rs1을 읽고 rd를 쓰도록 재정의될 수 있다.
- mop.rr.n (n = 0–7, 8개): 나중에 rs1·rs2를 읽고 rd를 쓰도록 재정의될 수 있다.
- c.mop.n (Zcmop, n = 1, 3, …, 15의 홀수 8개): 16비트판.
c.lui xn, 0에 해당하는 예약 공간에 있고, 레지스터를 쓰지 않는다.
모두 합쳐 32비트 MOP 40개다. MOP는 SYSTEM opcode에 들어 있는데, 나중에 특권 CSR 상태(예: “이 모드에서 기능이 켜졌는가”)에 따라 동작이 달라질 것으로 예상했기 때문이다.
mop.r.n의 번호 n(5비트)은 30·27·26·21·20번 비트에 흩어져 있다. 아래: Zicfiss의 sspush는 mop.rr.7 자리를 재정의한 것이어서, 확장이 없거나 꺼진 하트에서는 rd=x0에 0을 쓰는 무해한 명령이 된다.“It is sometimes desirable to define instruction-set extensions whose instructions, rather than raising illegal-instruction exceptions when the extension is not implemented, take no useful action (beyond writing x[rd]).”
Zimop — 규격은 바로 이어서 제어 흐름 무결성 검사를 예로 든다. 검사를 무시해도 프로그램은 올바르게 돈다면, MOP로 만들어 두는 것이 옳다.
| MOP 자리 | 재정의한 명령 (Zicfiss) | 확장이 없거나 꺼졌을 때 |
|---|---|---|
mop.rr.7 | sspush x1, sspush x5 | x0 ← 0 (아무 일 없음) |
mop.r.28 | sspopchk x1, sspopchk x5, ssrdp rd | rd ← 0 |
c.mop.1 | c.sspush x1 | 아무 레지스터도 안 씀 |
c.mop.5 | c.sspopchk x5 | 아무 레지스터도 안 씀 |
MOP가 “아무 일도 안 함”이 아니라 rd에 0을 쓰는 것으로 정의된 데에도 이유가 있다. 해독이 단순해지고, 결과가 0인지로 기능이 켜졌는지 알아낼 수 있기 때문이다. 예를 들어 ssrdp t0은 그림자 스택이 켜져 있으면 ssp 값을, 아니면 0을 준다. 그래서 운영체제는 그림자 스택을 0번지에 두지 않기로 약속한다.
Zicond: 분기 없는 조건부 연산
x = c ? a : b 같은 코드는 보통 분기로 번역된다. 분기 예측기가 맞히면 빠르지만, c가 무작위에 가까운 데이터라면 예측은 자주 틀리고, 틀릴 때마다 파이프라인에 들어온 명령을 버리는 벌점을 낸다(8장). 다른 ISA에는 조건부 이동(cmov) 명령이 있지만, 그 명령은 레지스터를 세 개 읽어야 해서 RISC-V의 “읽기 포트 두 개” 원칙과 맞지 않는다. Zicond는 두 개만 읽는 더 작은 부품을 준다.
| 명령 | 동작 | 인코딩 (R형, opcode OP=0110011) |
|---|---|---|
| czero.eqz rd, rs1, rs2 | rs2 == 0 이면 rd ← 0, 아니면 rd ← rs1 | funct7=0000111, funct3=101 |
| czero.nez rd, rs1, rs2 | rs2 ≠ 0 이면 rd ← 0, 아니면 rd ← rs1 | funct7=0000111, funct3=111 |
“조건에 따라 0으로 만들기” 하나만 있으면 나머지는 조합으로 만든다. 한쪽을 0으로 지운 두 값을 더하면(또는 OR하면) 선택이 되고, 0으로 지운 값을 더하면 조건부 덧셈이 된다. 두 명령 모두 Zkt가 있으면 데이터 값과 무관한 시간에 실행되어야 하므로, 비밀 데이터로 분기하면 안 되는 암호 코드에도 쓸 수 있다.
| 연산 (규격 예시) | 명령 순서 |
|---|---|
| rd = (rc ≠ 0) ? rs1 + rs2 : rs1 | czero.eqz rd, rs2, rc → add rd, rs1, rd |
| rd = (rc == 0) ? rs1 + rs2 : rs1 | czero.nez rd, rs2, rc → add rd, rs1, rd |
| rd = (rc ≠ 0) ? rs1 | rs2 : rs1 | czero.eqz rd, rs2, rc → or rd, rs1, rd |
| rd = (rc ≠ 0) ? rs1 : rs2 | czero.eqz rd, rs1, rc → czero.nez rtmp, rs2, rc → add rd, rd, rtmp |
| rd = (rc == 0) ? rs1 : rs2 | czero.nez rd, rs1, rc → czero.eqz rtmp, rs2, rc → add rd, rd, rtmp |
분기 없는 선택 vs 분기 코드
CMO: 캐시 블록 다루기
캐시는 보통 소프트웨어 눈에 보이지 않는다. 하지만 캐시 일관성 밖에 있는 장치와 데이터를 주고받을 때는 이야기가 다르다. 예를 들어 DMA 장치가 하트의 캐시를 보지 못하고 메모리만 읽는다면, 하트가 쓴 데이터가 아직 캐시에만 있을 때 장치는 옛 값을 읽는다. 반대로 장치가 메모리에 새 데이터를 쓰면, 하트의 캐시에 남은 옛 사본이 그 값을 가린다. CMO(Cache-Management Operation) 확장은 이런 상황을 위해 캐시 블록 단위로 동작하는 명령을 정의한다.
| 확장 | 명령 | 하는 일 | 인코딩 |
|---|---|---|---|
| Zicbom (관리) | cbo.clean (rs1) | 수정된 사본이 있으면 메모리로 써 보낸다. 사본은 남는다. | MISC-MEM, funct3=010, rd=0, imm=1 |
cbo.flush (rs1) | clean을 한 뒤 사본을 없앤다 (원자적으로). | … imm=2 | |
cbo.inval (rs1) | 사본을 없앤다. 다음 load는 메모리에서 읽는다. | … imm=0 | |
| Zicboz | cbo.zero (rs1) | 블록 전체 바이트에 0을 store한다. | … imm=4 |
| Zicbop (프리페치 힌트) | prefetch.i off(rs1) | 곧 명령 인출할 블록이라는 힌트 | ORI, rd=0, rs2 칸=00000 |
prefetch.r off(rs1) | 곧 읽을 블록이라는 힌트 | ORI, rd=0, rs2 칸=00001 | |
prefetch.w off(rs1) | 곧 쓸 블록이라는 힌트 | ORI, rd=0, rs2 칸=00011 |
캐시 블록은 메모리를 2의 거듭제곱 크기로 자연 정렬한(NAPOT) 조각이다. 블록 크기는 구현마다 다르고, 소프트웨어는 실행 환경(예: 장치 트리)을 통해 알아내야 한다. 첫 CMO 확장들에서는 시스템 전체의 블록 크기가 같아야 한다. cbo.*는 rs1이 가리키는 주소가 블록 안 어디든 그 블록 전체에 작용하며, 정렬되지 않았다고 예외를 내지 않는다. 프리페치는 ORI의 rd=x0 힌트 자리를 빌렸으므로 오프셋의 하위 5비트(imm[4:0])는 0이어야 한다.
캐시 블록 연산 실험실
cbo.clean 뒤에 다시 읽어 보자. 그다음 dirty 상태에서 cbo.inval을 누르면 하트가 쓴 값이 메모리에 가지 못한 채 사라진다. 그래서 규격은 낮은 특권 모드에 inval을 허락하기 전에 높은 특권 소프트웨어가 clean이나 flush를 해 두거나, xenvcfg.CBIE로 inval을 flush로 바꾸라고 권한다.사용자·감독자 모드에서 이 명령들을 쓸 수 있는지는 menvcfg·senvcfg·henvcfg의 CBIE(inval), CBCFE(clean·flush), CBZE(zero) 필드가 정한다. 허락되지 않으면 불법 명령 예외(가상화 중이면 가상 명령 예외)가 난다. CBIE가 01이면 inval이 flush로 바뀌어 실행된다. 프리페치는 힌트이므로 어떤 경우에도 예외를 내지 않는다.
CFI: 착지 패드와 그림자 스택
메모리 오류(버퍼 넘침, use-after-free)를 이용하는 공격자는 새 코드를 넣는 대신, 이미 있는 코드 조각(가젯)을 이어 붙여 원하는 일을 시킨다. 반환 주소를 바꿔 가젯을 이어 가는 ROP와, 함수 포인터를 바꿔 엉뚱한 곳으로 점프하는 JOP/COP가 대표적이다. 직접 분기(beq, jal)는 목적지가 명령 안에 박혀 있어 바꿀 수 없으므로, 노리는 것은 레지스터 값으로 점프하는 jalr뿐이다. RISC-V의 CFI(Control-Flow Integrity)는 이 jalr을 두 방향에서 지킨다.
| Zicfilp — 앞 방향(forward-edge) | Zicfiss — 뒤 방향(backward-edge) | |
|---|---|---|
| 지키는 것 | 간접 호출·간접 점프의 목적지 | 함수 반환(ret)의 목적지 |
| 방법 | 목적지 첫 명령이 lpad여야 한다 (라벨 비교 선택) | 반환 주소를 그림자 스택에 한 벌 더 두고 비교 |
| 새 상태 | ELP(예상 착지 패드) 1비트 | ssp CSR (0x011), 그림자 스택 페이지 |
| 인코딩 | lpad = rd=x0인 AUIPC (HINT 자리) | sspush·sspopchk·ssrdp = MOP 재정의, ssamoswap = AMO |
| 위반 시 | software-check 예외(cause 18), xtval = 2 | software-check 예외(cause 18), xtval = 3 |
Zicfilp: 착지 패드
Zicfilp가 켜지면 하트는 ELP(Expected Landing Pad) 상태를 추적한다. jalr·c.jr·c.jalr로 간접 점프하는데 rs1이 x1·x5(링크 레지스터)도 x7도 아니면 ELP가 LP_EXPECTED가 된다. 그러면 도착한 곳의 첫 명령은 반드시 lpad여야 하고 4바이트 정렬되어 있어야 한다. lpad에는 20비트 라벨(LPL)이 있어서, 0이 아니면 호출하는 쪽이 x7의 비트 31–12에 넣어 둔 기대 라벨과 같아야 한다. 하나라도 어기면 software-check 예외가 나고 xtval에는 “landing pad fault”를 뜻하는 2가 기록된다.
lpad일 때만 실행이 이어진다. 함수 포인터를 조작해 함수 중간으로 뛰어들게 하면 첫 명령이 lpad가 아니므로 즉시 예외가 난다. lpad는 AUIPC의 HINT 자리라 Zicfilp가 없거나 꺼진 하트에서는 아무 일도 하지 않는다.x7로 점프하는 경우가 예외인 이유도 있다. 컴파일러가 범위를 검사한 읽기 전용 점프 테이블(switch 문)처럼 목적지를 완전히 통제하는 경우를 위해 “소프트웨어가 지키는 분기”로 남겨 둔 것이다. 또 rs1이 링크 레지스터인 jalr은 반환이거나, auipc ra와 짝을 이룬 사실상 직접 호출이라 착지 패드가 필요 없다.
Zicfiss: 그림자 스택
함수를 호출하면 반환 주소(ra)가 일반 스택에 저장된다. 일반 스택은 보통 메모리라서 버퍼 넘침으로 덮어쓸 수 있다. Zicfiss는 반환 주소를 보호된 두 번째 스택에도 저장해 두고, 반환하기 직전에 둘을 비교한다. 그림자 스택은 페이지 테이블에서 R=0, W=1, X=0인 특별한 페이지(원래 예약된 조합)로 표시되며, sspush·ssamoswap 말고 일반 store로는 쓸 수 없다. 일반 store가 그 페이지에 쓰려 하면 store/AMO access-fault가 난다.
sw ra) 그림자 스택에도 저장한다(sspush ra). 꼬리에서는 일반 스택에서 ra를 되읽은 뒤 sspopchk ra로 그림자 사본과 비교한다. 버퍼 넘침이 일반 스택의 ra를 바꿔도 그림자 사본은 바꿀 수 없다.“A mismatch of the two values is indicative of a subversion of the return address control variable and causes a software-check exception.”
Control-flow Integrity (CFI), Shadow Stack — 예외 번호 18(software check)과 xtval 값 2·3은 특권 규격의 mcause 표에 정의되어 있다.
공격과 방어: 그림자 스택과 착지 패드
sspush/sspopchk는 MOP로서 아무 일도 하지 않고, ret은 공격자가 심은 0x4000으로 간다. 착지 패드 장면에서도 마찬가지로 CFI를 끄면 lpad는 그냥 지나가는 명령이 된다.Zicfilp·Zicfiss는 모드마다 따로 켤 수 있다. 켜는 스위치(menvcfg·senvcfg의 LPE·SSE 비트, M 모드는 mseccfg.MLPE)와 트랩 때 ELP를 보존하는 방법은 특권 규격이 정한다. Zicfiss는 Zicsr·Zimop·Zaamo에 의존하고, 사용자 모드에서 쓰려면 S 모드가 있어야 하며, M 모드에서는 지원되지 않는다. 동적 로더는 실행 파일과 모든 공유 라이브러리가 CFI로 컴파일되었을 때만 기능을 켜야 한다.
Zilsd: RV32의 레지스터 짝 load/store
RV32에서 64비트 값(예: long long, double 값을 정수 레지스터로 옮기는 경우)을 옮기려면 lw 두 번, sw 두 번이 필요하다. Zilsd는 RV64에만 있던 ld·sd 인코딩을 RV32에서 재사용해, 짝수 번호 레지스터와 그다음 홀수 레지스터를 한 쌍으로 묶어 64비트를 한 명령에 옮긴다. 엔디안과 상관없이 낮은 번호 레지스터가 하위 32비트를 갖는다.
ld. RV32에서 ld x14, 0(a0)은 8바이트를 읽어 x14(하위)와 x15(상위)에 나눠 넣는다. sd는 반대로 레지스터 짝을 8바이트로 쓴다.| 유효 주소 정렬 | 각 워드 접근이 원자적인가 | 정렬 예외가 날 수 있나 |
|---|---|---|
| 8의 배수 | 예 | 아니요 |
| 4의 배수지만 8의 배수는 아님 | 예 | 예 |
| 그 밖 | 아니요 | 예 |
8바이트 정렬이어도 64비트 전체가 원자적으로 접근된다는 보장은 없다. 4바이트 정렬이면 두 개의 4바이트 접근으로 나뉠 수 있고, 각 워드만 원자적이다. load 도중 트랩이 나면 베이스 레지스터는 원래 값을 유지해야 다시 실행할 수 있다(짝의 다른 레지스터는 이미 바뀌었을 수 있다). 압축판 Zclsd는 c.ld·c.sd·c.ldsp·c.sdsp를 주는데, RV32의 Zcf(c.flw 등)와 같은 인코딩을 쓰므로 둘은 함께 쓸 수 없다(13장).
Zilsd는 새 기능이라기보다 코드 크기와 성능을 위한 확장이다. 함수 머리·꼬리에서 레지스터를 저장·복원할 때, 64비트 값을 복사할 때 명령 수가 절반으로 준다. 레지스터 파일이 짝 단위로 쓰기를 지원하는 구현이라면 원자성 조건도 자연스럽게 만족한다.
핵심 정리
- Zicsr의 6개 명령은 CSR 하나를 원자적으로 읽고(rd ← 옛 값) 고쳐 쓴다. rd=x0이면 읽지 않고, rs1=x0(uimm=0)이면 쓰지 않는다.
csrr은csrrs rd, csr, x0이다. - Zicntr는 cycle·time·instret(0xC00–0xC02), Zihpm은 hpmcounter3–31(0xC03–0xC1F)이다. 모두 64비트이며 RV32는 0xC80–0xC9F로 위쪽을 읽고, 자리올림 때문에 상위-하위-상위 3회 읽기 루프가 필요하다.
fence.i는 자기 하트의 store를 이후 명령 인출에 보이게 한다.pause는 pred=W·succ=0인 FENCE,ntl.*은add x0, x0, x2–x5로 인코딩된 힌트다.- Zimop의 MOP는 기본으로 rd에 0만 쓰는 예약 자리다. Zicfiss의
sspush(mop.rr.7)·sspopchk(mop.r.28)처럼 나중 확장이 재정의해도, 옛 하트에서는 무해하게 돈다. - Zicond의
czero.eqz·czero.nez두 명령과 add/or를 조합하면 분기 없는 조건부 선택·연산을 만든다. 예측하기 어려운 조건에서 분기 예측 실패 벌점을 없앤다. - CMO: clean(써 보내기), inval(사본 버리기), flush(둘 다), zero(블록에 0), prefetch(힌트). 블록 크기는 구현마다 다르며 실행 환경에서 알아낸다.
- CFI: Zicfilp는 간접 점프 목적지에
lpad(라벨 비교)를, Zicfiss는 그림자 스택(ssp CSR 0x011, xwr=010 페이지)을 요구한다. 위반하면 software-check 예외(cause 18)가 나고 xtval은 착지 패드 2, 그림자 스택 3이다. - Zilsd는 RV32에서
ld·sd로 짝수·홀수 레지스터 짝에 64비트를 옮긴다. 낮은 번호가 하위 절반이다.
확인 퀴즈
의사 명령 csrr a0, mstatus는 어떤 실제 명령으로 바뀌는가?
csrrw는 rs1=x0이어도 0을 써 버린다. csrrs는 rs1=x0이면 CSR에 아예 쓰지 않으므로 순수한 읽기가 된다.RV32에서 64비트 cycle을 읽을 때 rdcycleh를 두 번 읽어 비교하는 이유는?
pause 명령의 인코딩으로 옳은 것은?
Zicfiss의 sspush가 Zimop의 MOP 자리를 쓰는 가장 큰 이유는?
czero.nez a3, a0, a2에서 a2 = 5, a0 = 9이면 a3은?
DMA 장치가 메모리에 새 데이터를 쓴 뒤, 하트가 그 데이터를 읽기 전에 해야 할 CMO는?
sspopchk ra에서 그림자 사본과 ra가 다를 때 일어나는 일은?
RV32 + Zilsd에서 ld x14, 0(a0)의 결과로 옳은 것은?
더 읽을거리
- “Zicsr” Extension for CSR Instructions, Version 2.0 (ISA Manual v20260120, Vol. I)
- “Zicntr” and “Zihpm” Extensions for Counters, Version 2.0
- “Zifencei” Extension for Instruction-Fetch Fence, Version 2.0
- “Zihintntl” Version 1.0 · “Zihintpause” Version 2.0
- “Zimop” and “Zcmop” Extensions, Version 1.0
- “Zicond” Extension for Integer Conditional Operations, Version 1.0.0
- “CMO” Extensions for Base Cache Management Operation ISA, Version 1.0.0
- Control-flow Integrity (CFI) — Zicfilp · Zicfiss · 특권 쪽: Privileged CFI
- “Zilsd”, “Zclsd” Extensions for Load/Store pair for RV32, Version 1.0
- “Zawrs” Extension, Version 1.01 — 스핀 대기 대신 저전력 대기 (10장)