FENCE.I와 자기 수정 코드: 명령어 쪽에서 지켜야 할 것

Sunhong··12 min read·0 views

이 글의 핵심 3줄

  • RISC-V는 저장한 값이 같은 하트의 명령어 페치에 보이는 것을 FENCE.I 전에는 보장하지 않는다. 쓴 코드가 안 보이는 자리는 셋이다. 쓰기 지연 데이터 캐시, 명령어 캐시, 그리고 이미 가져온 명령어(파이프라인 앞단과 미리 가져오기 버퍼).
  • 같은 자기 수정 코드 ELF를 Python 명령어 모델의 16가지 조합(코어 구성 4 × FENCE.I 구현 4)에서 돌렸다. FENCE.I를 NOP으로 둬도 되는 것은 캐시도 미리 가져오기도 없는 코어뿐이었고, 캐시가 없어도 미리 가져오기가 있으면 플러시가, I가있으면무효화가,쓰기지연D가 있으면 무효화가, 쓰기 지연 D가 있으면 내리기까지 필요했다.
  • QEMU 11.1.3은 fence.i 없이도 다른 곳에 쓴 코드는 새 것(2)을 실행했지만, 바로 다음 명령어를 고친 경우는 옛 것(11)을 실행했다. TCG가 변환한 페이지에 쓰기를 감지하되, 지금 실행 중인 변환 블록은 끝까지 옛 번역으로 돌기 때문이다. 블록을 명령어 하나로 줄이자 둘 다 새 코드가 됐다.

19~21편에서 RV32I 5단 파이프라인 코어에 M·A·C를 붙였다. 2부의 마지막인 이번 편은 명령어 쪽의 남은 규칙 하나, FENCE.I다. 20편의 FENCE가 하트 사이에서 데이터 접근의 순서를 정했다면, FENCE.I는 한 하트 안에서 "데이터로 쓴 것"과 "명령어로 읽는 것"을 맞춘다. JIT 컴파일러, 부트로더가 코드를 RAM에 복사한 뒤 뛰어드는 경우, 디버거의 소프트웨어 중단점이 모두 이 규칙에 기댄다.

1. 쓴 코드가 왜 안 보이나

폰 노이만 구조에서 명령어와 데이터는 같은 메모리에 있다. 그런데 파이프라인 코어는 명령어와 데이터를 서로 다른 길로 읽는다. IF 단계는 명령어 캐시(I)나명령어버스로,MEM단계는데이터캐시(D)나 명령어 버스로, MEM 단계는 데이터 캐시(D)나 데이터 버스로 간다. 둘을 나눠야 한 사이클에 명령어 하나와 데이터 하나를 동시에 읽을 수 있기 때문이다. 이렇게 나뉜 길 사이에는 기본적으로 아무 연결이 없다.

sw로 새 명령어를 썼을 때 명령어 쪽이 옛 비트를 들고 있을 수 있는 자리는 셋이다.

자리 옛 비트가 남는 이유
① 쓰기 지연(write-back) D$ 새 코드가 D의더러운줄에만있고메모리에는아직없다.I의 더러운 줄에만 있고 메모리에는 아직 없다. I는 메모리에서 채운다
② I$ 예전에 그 주소를 실행해 줄이 남아 있다
③ 이미 가져온 명령어 5단 파이프라인의 IF·ID에 있는 명령어, 미리 가져오기(prefetch) 버퍼, 21편의 페치 버퍼

sw로 쓴 새 코드는 D와 이미 가져온 명령어에 옛 비트를 들고 있을 수 있다. FENCE.I는 D 무효화, 플러시를 순서대로 한다

③은 캐시가 없는 가장 단순한 코어에도 있다. 바로 다음 명령어를 고치는 경우를 손으로 따라가 보자. 실습 코드의 시험 2는 이렇다.

80000110: sw   t1,0(t0)    # t0 = 0x80000114
80000114: addi a0,x0,11    # 이 명령어를 addi a0,x0,22로

5단 파이프라인에서 사이클별 위치는 다음과 같다.

사이클   IF          ID      EX      MEM
  1      sw
  2      addi(114)   sw
  3      다음        addi    sw
  4                  다음    addi    sw → 0x114에 씀

sw가 MEM에서 0x80000114에 쓰는 사이클 4에는, 그 주소의 옛 addi가 이미 EX에서 실행되고 있다. 옛 명령어는 사이클 2에 페치됐다. 미리 가져오기 버퍼가 있으면 더 멀리 떨어진 명령어도 이미 들어와 있을 수 있다.

2. FENCE.I의 의미: 같은 하트만

FENCE.I(Zifencei 확장, 비특권 명세 6장)는 I 형식이고 opcode MISC-MEM(0001111), funct3 001이다. rs1·rd·imm 칸은 미래의 세밀한 펜스를 위해 남겨 뒀고, 구현은 이 칸을 무시해야 한다. 명세의 규칙은 두 문장으로 요약된다.

  • RISC-V는 명령어 메모리에 한 저장이 그 하트의 명령어 페치에 보인다는 것을, 그 하트가 FENCE.I를 실행하기 전에는 보장하지 않는다.
  • FENCE.I는 그 뒤의 명령어 페치가 "같은 하트에 이미 보이는" 앞선 데이터 저장을 모두 보게 한다. 다른 하트의 페치는 보장하지 않는다.

"같은 하트에 이미 보이는"이 ①의 근거다. D에만있는새코드는그하트의로드에는이미보이니,FENCE.I뒤에는페치에도보여야한다.그래서쓰기지연D에만 있는 새 코드는 그 하트의 로드에는 이미 보이니, FENCE.I 뒤에는 페치에도 보여야 한다. 그래서 쓰기 지연 D와 메모리에서 채우는 I를가진코어는FENCE.I에서D를 가진 코어는 FENCE.I에서 D를 내리거나(write back), I채우기가D 채우기가 D를 엿보게(snoop) 해야 한다.

20편의 FENCE와 이름이 비슷하지만 하는 일은 다르다.

항목 FENCE FENCE.I
맞추는 것 데이터 접근(R·W·I·O)끼리의 순서 데이터 저장 → 명령어 페치
누구 기준 다른 하트·장치가 관찰하는 순서 같은 하트만
단순 in-order 코어 순서를 바꾸는 구조가 없으면 NOP 5단 파이프라인이면 최소한 플러시
확장 기본 RV32I Zifencei (원래 기본 ISA에 있다가 분리됨)

FENCE.I가 기본 ISA에서 빠진 이유도 명세 해설에 있다. I와D와 D가 서로 일관되지 않은 설계에서는 FENCE.I가 두 캐시를 통째로 비워야 해서 비싸고, 다하트 환경의 사용자 모드에서는 그것만으로 충분하지도 않기 때문이다(아래에서 설명). 그래서 FENCE를 NOP으로 처리할 수 있는 단순한 코어라도 FENCE.I는 따로 생각해야 한다.

명령어 캐시가 있는 하트 여럿에 새 코드를 보이게 하려면, 명세는 쓴 하트가 먼저 데이터 FENCE를 실행하고 다른 모든 하트에 FENCE.I 실행을 요청하라고 한다. 요청 방법은 명세 밖(실행 환경)이다. 흔한 방법은 둘이다.

방법 하는 일
IPI + FENCE.I 쓴 하트가 다른 하트에 소프트웨어 인터럽트를 보내고, 받은 하트가 처리기에서 FENCE.I
SBI sbi_remote_fence_i S 모드 OS가 M 모드 펌웨어(OpenSBI 등)에 요청. SBI RFENCE 확장

리눅스는 같은 이유로 FENCE.I를 사용자 모드 ABI에서 뺐다. 스레드가 FENCE.I 직후 다른 하트로 옮겨지면 소용이 없기 때문이다. 대신 시스템 호출(riscv_flush_icache)로 OS에 맡긴다. 명세 6장의 해설이 이 사정을 그대로 적어 두었다.

3. 코어 구조별 FENCE.I 구현

명세는 구현 방법을 정하지 않고 결과만 요구한다. 해설은 "간단한 구현은 로컬 명령어 캐시와 명령어 파이프라인을 비우면 된다", "I와D와 D가 일관되게 유지되거나 캐시 없는 RAM뿐이면 페치 파이프라인만 비우면 된다"고 적는다. 구조에 따라 정리하면 다음과 같다.

코어 구조 FENCE.I가 할 일
캐시 없음, 가져온 명령어 없음 없음 (NOP)
캐시 없음, 파이프라인·미리 가져오기 있음 뒤 명령어 플러시, pc+4부터 다시 페치
I(쓰기즉시D(쓰기 즉시 D 또는 D$ 없음) 플러시 + I$ 무효화
I+쓰기지연D + 쓰기 지연 D 플러시 + I무효화+D 무효화 + D 더러운 줄 내리기
I$가 저장을 엿봐 스스로 지움 플러시만

여기서 "캐시 없음, 가져온 명령어 없음"은 사실 5단 파이프라인에는 해당하지 않는다. 1절의 표처럼 5단 파이프라인은 그 자체로 명령어 두세 개를 앞서 가져오기 때문이다. 그래서 이 시리즈의 5단 코어에서 FENCE.I의 최소 구현은 "분기 예측 실패처럼 뒤를 비우고 pc+4로 다시 가기"다. 이미 있는 플러시 경로(18편의 분기 처리)를 그대로 쓰면 되니, 디코더에 FENCE.I를 "목적지 pc+4인 무조건 점프, 항상 예측 실패"로 표시하는 방법이 간단하다. 저장 버퍼가 있다면 앞선 저장이 메모리(또는 일관된 캐시)에 닿을 때까지 기다리는 것도 함께 해야 한다.

4. 실습 1: QEMU에서 자기 수정 코드

시험 코드(smc.c)는 두 가지를 한다. 장비는 맥 M1 Max, QEMU 11.1.3, riscv64-elf-gcc 16.2(-march=rv32i_zifencei)다.

  • 시험 1 (다른 곳에 쓴 코드): 데이터 배열 code[]에 addi a0,x0,1; ret을 쓰고 FENCE.I 뒤 호출한다. 같은 자리에 addi a0,x0,2; ret을 쓰고 FENCE.I 없이 호출한다. 이어 FENCE.I 뒤 다시 호출한다.
  • 시험 2 (바로 다음 명령어): 1절의 sw + addi 쌍을 FENCE.I 없이 한 번, sw와 addi 사이에 FENCE.I를 넣어 한 번 실행한다.
/* addi a0, x0, k */
#define LI_A0(k) ((uint32_t)(k) << 20 | 10u << 7 | 0x13u)
#define RET 0x00008067u   /* jalr x0, 0(ra) */
write_code(1); __asm__ volatile("fence.i" ::: "memory");
put("t1 first      ", ((fn)code)());
write_code(2);            /* fence.i 없음 */
put("t1 no fence.i ", ((fn)code)());
시험 QEMU 기본 QEMU 명령어 하나씩
시험 1, 처음 1 1
시험 1, FENCE.I 없이 2 2
시험 1, FENCE.I 뒤 2 2
시험 2, FENCE.I 없이 11 22
시험 2, FENCE.I 뒤 22 22

QEMU는 FENCE.I 없이도 시험 1에서 새 코드를 실행했다. 실제 I가있는하드웨어라면옛코드(1)가나올수있는자리다.이유는QEMUTCG의동작방식에있다.TCG는게스트명령어를변환블록(기본블록단위)단위로호스트코드로번역해두고,번역한블록이있는게스트페이지에쓰기가일어나면그페이지의번역을버린다(추정:QEMU의자기수정코드감지).하드웨어로치면"모든저장을엿보는완전히일관된I가 있는 하드웨어라면 옛 코드(1)가 나올 수 있는 자리다. 이유는 QEMU TCG의 동작 방식에 있다. TCG는 게스트 명령어를 변환 블록(기본 블록 단위) 단위로 호스트 코드로 번역해 두고, 번역한 블록이 있는 게스트 페이지에 쓰기가 일어나면 그 페이지의 번역을 버린다(추정: QEMU의 자기 수정 코드 감지). 하드웨어로 치면 "모든 저장을 엿보는 완전히 일관된 I"처럼 행동하는 셈이다.

반면 시험 2는 FENCE.I 없이 옛 코드(11)를 실행했다. 고친 명령어가 지금 실행 중인 변환 블록 안에 있었기 때문이다. 블록은 이미 번역됐으니 끝까지 옛 번역대로 돈다. 블록을 명령어 하나로 쪼개는 옵션(-accel tcg,one-insn-per-tb=on)으로 다시 돌리자 22가 나왔다. FENCE.I는 QEMU에서 블록을 끝내고 다음 블록을 새로 번역하게 하므로 두 경우 모두 22다.

정리하면 QEMU는 명세가 허용하는 행동 중 하나를 보일 뿐, 하드웨어와 같지 않다. FENCE.I를 빠뜨린 소프트웨어 버그가 QEMU에서는 드러나지 않을 수 있고, 반대로 RTL 검증의 기준 모델로 QEMU를 쓸 때 FENCE.I 없는 자기 수정 코드는 비교 대상에서 빼야 한다.

5. 실습 2: 명령어 모델로 16가지 조합

같은 smc.elf를 직접 짠 작은 RV32I 명령어 모델(iss.py)에서 실행했다. ELF의 적재 세그먼트를 메모리에 올리고, UART 주소 저장을 글자로 모으고, 종료 장치 쓰기에서 멈춘다. 명령어 쪽에는 세 가지를 넣을 수 있다.

  • 미리 가져오기 큐: 실행할 명령어보다 최대 3개 앞까지 순서대로 가져와 둔다(5단 파이프라인의 IF·ID와 버퍼 하나에 해당한다고 본 모델 선택). 분기가 일어나면 비운다.
  • I$: 직접 사상, 16줄 × 16바이트. 미스면 메모리에서 채운다.
  • 쓰기 지연 D$: 저장을 "더러운 워드"로 들고 있다가 FENCE.I가 내리라고 할 때만 메모리에 쓴다. 로드는 이것을 먼저 본다.
def fence_i(self):
    if self.fencei == 'nop': return
    if self.fencei == 'full' and self.wb:   # ① D$ 내리기
        for a, v in self.dirty.items():
            self.write_mem(a, v)
        self.dirty = {}
    if self.fencei in ('flush+inval', 'full'):
        self.itag = [None] * len(self.itag) # ② I$ 무효화
    self.redirect(self.pc + 4)              # ③ 플러시

먼저 정확성 검사로, 캐시도 미리 가져오기도 없는 구성 A의 출력이 QEMU를 명령어 하나씩 돌린 결과와 다섯 줄 모두 같은지 확인했다(같았다). 그다음 코어 구성 4가지와 FENCE.I 구현 4가지(NOP, 플러시만, 플러시 + I무효화,여기에D 무효화, 여기에 D 내리기)를 모두 돌렸다.

코어 구성 4가지 × FENCE.I 구현 4가지 중 명세를 지킨 조합: 캐시와 미리 가져오기가 없으면 NOP도 되고, 구조가 늘 때마다 플러시·무효화·내리기가 하나씩 더 필요했다

코어 구성 FENCE.I 없이 시험 1 / 2 명세를 지키는 최소 구현
A 캐시 없음, 미리 가져오기 0 2 / 22 NOP
B 캐시 없음, 미리 가져오기 3 2 / 11 플러시
C I$ + 미리 가져오기 3 1 / 11 플러시 + I$ 무효화
D I+쓰기지연D + 쓰기 지연 D + 미리 가져오기 3 첫 호출부터 실패 + D$ 내리기

구성 D에서 FENCE.I가 D를내리지않으면첫호출부터실패했다.‘code[]‘는처음에0으로채워진영역(.bss)이고,새로쓴‘addia0,x0,1‘은D를 내리지 않으면 첫 호출부터 실패했다. `code[]`는 처음에 0으로 채워진 영역(.bss)이고, 새로 쓴 `addi a0,x0,1`은 D에만 있었다. I$는 메모리의 0을 가져왔고, 0x80000200에서 0x00000000(불법 명령어)을 실행하게 됐다. 21편에서 본 대로 전부 0인 명령어를 명세가 불법으로 정해 둔 덕분에, 이런 버그는 엉뚱한 실행 대신 즉시 트랩으로 드러난다.

FENCE.I 없이의 결과를 보면 QEMU 기본 실행(2 / 11)은 구성 B, 즉 "캐시는 없고 앞서 가져온 명령어만 있는 코어"와 같은 모양이었다.

비용도 하나 쟀다. 구성 C에서 프로그램 전체(693개 명령어, FENCE.I 3번)의 I미스는FENCE.I가무효화를하지않을때37번,할때55번이었다.FENCE.I한번에I 미스는 FENCE.I가 무효화를 하지 않을 때 37번, 할 때 55번이었다. FENCE.I 한 번에 I 전체를 버리므로, 실제 코드에서는 무효화 뒤에 실행하는 코드의 크기만큼 미스가 생긴다. 명세 해설이 JIT 컴파일러의 예로 "번역한 명령어를 길게 만든 뒤 FENCE.I를 한 번만 부르는" 방식을 드는 이유다.

6. 5단 코어에서의 구현 세부

RTL로 옮길 때 정해야 할 것이 몇 가지 있다. 명세가 정하지 않는 부분이라 흔한 선택을 적는다.

어느 단계에서 처리하나. FENCE.I는 앞선 저장이 끝난 뒤에 효과를 내야 한다. 5단 코어에서 가장 단순한 방법은 FENCE.I를 MEM 단계까지 보낸 뒤(앞선 sw는 이미 WB 쪽으로 넘어감), 그 시점에 I무효화를걸고IF⋅ID⋅EX를비운다음pc+4로다시페치하는것이다.EX에서처리하면바로앞‘sw‘가아직MEM에있어순서가뒤집힐수있다.플러시경로는분기예측실패와같으니,손해는비운단계수(이방식이면3사이클)에I 무효화를 걸고 IF·ID·EX를 비운 다음 pc+4로 다시 페치하는 것이다. EX에서 처리하면 바로 앞 `sw`가 아직 MEM에 있어 순서가 뒤집힐 수 있다. 플러시 경로는 분기 예측 실패와 같으니, 손해는 비운 단계 수(이 방식이면 3사이클)에 I 미스 비용이 더해진 만큼이다(추정, 구현마다 다름).

**I무효화를몇사이클에.∗∗유효비트를플립플롭으로두면한사이클에모두지울수있다.태그와함께SRAM에두면줄마다한번씩써야하므로줄수만큼사이클이걸리고,그동안페치를멈춰야한다.줄수가많지않은작은코어는앞쪽을,큰I 무효화를 몇 사이클에.** 유효 비트를 플립플롭으로 두면 한 사이클에 모두 지울 수 있다. 태그와 함께 SRAM에 두면 줄마다 한 번씩 써야 하므로 줄 수만큼 사이클이 걸리고, 그동안 페치를 멈춰야 한다. 줄 수가 많지 않은 작은 코어는 앞쪽을, 큰 I는 뒤쪽을 고르는 경우가 많다(추정).

**D내리기.∗∗쓰기지연D 내리기.** 쓰기 지연 D의 더러운 줄을 모두 내리는 것은 비싸다. 그래서 I채우기가D 채우기가 D를 먼저 엿보게 하거나, 둘 아래에 통합 L2를 두고 L1 D를쓰기즉시(write−through)로만들어문제를없애는구조가쓰인다.명세해설도I를 쓰기 즉시(write-through)로 만들어 문제를 없애는 구조가 쓰인다. 명세 해설도 I·D$를 일관되게 유지하는 여러 방법을 예로 든다.

실제로 언제 쓰이나. 자기 수정 코드라고 하면 특이한 기법 같지만, 흔한 곳에 숨어 있다. 부트로더가 플래시의 다음 단계 코드를 RAM에 복사하고 뛰어들 때, OS가 프로그램을 메모리에 올릴 때, 디버거가 소프트웨어 중단점(ebreak)을 써 넣을 때, JIT가 코드를 만들 때다. 5절 구성 D가 첫 호출부터 실패한 모습이 바로 "복사하고 FENCE.I 없이 뛰어든 부트로더"의 증상이다.

7. 설계 체크리스트: FENCE.I

항목 규칙
인코딩 MISC-MEM, funct3 001. rs1·rd·imm은 무시(소프트웨어는 0으로)
최소 구현 뒤 명령어 플러시 + pc+4 재페치. 5단 파이프라인이면 언제나 필요
I$ 무효화하거나, 저장을 엿봐 지우는 일관성 구조
쓰기 지연 D$ 더러운 줄을 내리거나, I채우기가D 채우기가 D를 엿봄
저장 버퍼 앞선 저장이 I$가 보는 곳까지 닿은 뒤 플러시
페치 버퍼(C 확장) 비운다. 남은 16비트 칸도 옛 비트다
분기 예측기·BTB 옛 코드 기준 예측이 남아도 정확성 문제는 없다(예측 실패로 고쳐짐). 비우는 것은 선택
범위 같은 하트만. 다른 하트는 IPI 또는 SBI sbi_remote_fence_i
사용자 모드 명령어는 실행 가능하지만 리눅스 ABI는 시스템 호출 사용
트랩 FENCE.I 자체는 트랩 없음. Zifencei를 구현하지 않으면 불법 명령어로 처리하는 것이 흔한 선택
디버그 중단점 디버거가 ebreak를 써 넣는 것도 자기 수정 코드. 디버그 모드 진입·복귀 때 같은 처리가 필요(37편)

정리

  • 저장한 코드가 명령어 쪽에 안 보이는 자리는 쓰기 지연 D,I, I, 이미 가져온 명령어 셋이다. 캐시가 없는 5단 파이프라인에도 셋째는 있다.
  • FENCE.I는 같은 하트의 이후 페치가 그 하트에 보이는 앞선 저장을 모두 보게 한다. 다른 하트에는 IPI나 SBI sbi_remote_fence_i로 각자 FENCE.I를 실행시켜야 한다.
  • 명령어 모델 16가지 조합에서, FENCE.I가 할 일은 구조에 따라 NOP → 플러시 → 무효화 → D$ 내리기로 늘었다. 하나라도 빠지면 옛 코드(1, 11)를 실행하거나 첫 호출부터 불법 명령어를 만났다.
  • QEMU 11.1.3은 FENCE.I 없이도 다른 곳에 쓴 코드는 새 것을, 실행 중인 블록 안의 바로 다음 명령어는 옛 것을 실행했다. 변환 블록 단위 번역 때문이라 하드웨어와 다르다.

다음 글: 23편부터 3부 특권 아키텍처로 넘어간다. 첫 편은 특권 모드와 CSR이다. M·S·U 모드가 왜 나뉘었는지, CSR 주소 12비트에 권한과 읽기 전용 여부가 어떻게 들어 있는지, WARL·WLRL 필드가 구현에 무엇을 맡기는지, 그리고 앞으로 다룰 주요 CSR의 지도를 그린다.

참고 문헌

  • The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. 6장 "Zifencei" 확장, 2.7절 메모리 순서 명령어, 17장 RVWMO. https://github.com/riscv/riscv-isa-manual
  • RISC-V Supervisor Binary Interface Specification, RFENCE 확장(sbi_remote_fence_i). https://github.com/riscv-non-isa/riscv-sbi-doc
  • D. Harris 외, RISC-V System-on-Chip Design, 2023. 캐시와 FENCE.I 구현(CORE-V Wally)
  • D. Patterson, J. Hennessy, Computer Organization and Design: RISC-V Edition. 4장 파이프라인, 5장 메모리 계층
  • QEMU TCG 문서(변환 블록과 자기 수정 코드). https://www.qemu.org/docs/master/devel/tcg.html

비슷한 글 추천