ir: 중간 표현을 정의한다

명령 12개와 종결자 4개. 함수 단위 기본 블록이고 임시값은 블록을 넘지 않아
phi 노드가 없다 -- 블록을 넘겨야 하는 값은 지역을 경유한다. 코드가 조금 더
생기지만 레지스터 할당기를 블록 단위로 유지해 준다.

Ferro 타입은 여기서 사라진다. 구조체·슬라이스·옵셔널·에러 유니온이 전부
mem<N> 이고 필드는 lowering 이 계산한 바이트 오프셋이다. 모노모피제이션이
프론트엔드에서 끝나므로 IR 에 제네릭이라는 개념도 없다.

덩어리는 언제나 주소로 오간다. 크기 임계값이 없어서 ISA 마다 다른 구조체 전달
규칙을 통째로 피해간다. trap 은 이유와 줄 번호만 남기고 파일 이름 문자열은
유닛당 하나를 공유한다.

IR.md 가 설명이고 ir.h/ir.c 가 그 형태다. 아직 아무도 만들지 않는다.
This commit is contained in:
2026-08-17 05:44:56 +09:00
parent b0e55e072e
commit ddea962d14
5 changed files with 805 additions and 73 deletions
+180
View File
@@ -0,0 +1,180 @@
# Ferro IR
`fec` 의 중간 표현. 이 문서는 IR 자체만 다룬다. 언어 규범은 `SPEC.md` 이고,
Ferro 의미론이 어떻게 이 형태로 펴지는지는 lowering 이 담당한다.
IR 을 두는 이유는 하나다. **검사가 끝난 AST 와 기계 사이의 거리가 너무 멀다.**
`try` 하나가 분기 두 개와 임시값 하나로 펴지고, `defer` 는 함수의 모든 이탈
경로에 복제되며, 배열 인덱스는 비교와 트랩을 낳는다. 그 전개를 명령어 선택과
같은 자리에서 하면 둘 다 읽을 수 없게 된다.
---
## 1. 형태
함수 단위다. 함수는 **기본 블록**의 목록이고, 블록은 **명령**의 목록과 하나의
**종결자**로 끝난다. 블록 중간에서 분기하지 않고, 종결자 뒤에 명령이 없다.
```
fn @okid.test() -> i32 {
$0: i32 ; 지역
b0:
%0 = const i32 7
store $0, %0
%1 = load i32 $0
ret %1
}
```
### 슬롯
| | | |
|---|---|---|
| `%n` | 임시값 | 한 번 정의되고 여러 번 쓰인다. 블록을 넘지 않는다 |
| `$n` | 지역 | 주소를 가진 스택 자리. 함수 진입 시 전부 잡는다 |
| `@name` | 전역·함수 | 링커가 보는 이름 |
임시값이 블록을 넘지 않으므로 φ 노드가 없다. 블록을 넘겨야 하는 값은 지역에
`store` 하고 다시 `load` 한다. 이것이 SSA 보다 코드를 조금 더 만들지만,
**레지스터 할당기를 블록 단위로 유지**해 준다. v0.1 에서는 그 교환이 맞다.
### 기계 타입
```
i8 i16 i32 ptr mem<N>
```
Ferro 타입은 여기서 사라진다. 구조체·배열·슬라이스·옵셔널·에러 유니온은 전부
`mem<N>` 이고, 필드 접근은 lowering 이 계산한 **바이트 오프셋**이다. IR 은
`Box(i32)` 라는 것을 모른다 — 크기 N 바이트짜리 메모리만 안다.
`ptr` 은 4바이트다. 세그먼트가 없으므로 포인터 종류도 하나뿐이다 (`SPEC.md` §2).
`bool``i8`, `char``i8`, `usize``i32` 다.
---
## 2. 명령
12개다.
```
%d = const <ty> <imm> 상수
%d = load <ty> <place> 메모리에서 읽는다
store <place>, %v 메모리에 쓴다
%d = addr <place> 주소를 뜬다
%d = <op> <ty> %a, %b 산술·비트 연산
%d = <cmp> <ty> %a, %b 비교. 결과는 i8
%d = cast <from> <to> %a 정수 폭 변환
%d = call @f(%a, ...) 호출
copy <place>, <place>, N N바이트 복사
```
`<op>``add sub mul div mod and or xor shl shr`
`<cmp>``eq ne lt le gt ge` (부호 있음/없음은 `<ty>` 가 정한다)
### place
`load`·`store`·`addr`·`copy` 의 피연산자다.
```
$n 지역
@name 전역
%p 포인터 임시값이 가리키는 곳
%p + <imm> 상수 오프셋. 필드 접근이 여기로 온다
```
인덱스처럼 오프셋이 상수가 아니면 lowering 이 주소를 먼저 계산한다.
```
; a[i] 는
%0 = load i32 $i
%1 = const i32 4 ; 원소 크기
%2 = mul i32 %0, %1
%3 = addr $a
%4 = add ptr %3, %2
%5 = load i32 %4
```
### 덩어리
**덩어리는 언제나 주소로 오간다.** 크기 임계값이 없다.
```
call @f(%p) ; f 가 mem<N> 을 받으면 %p 는 그 주소다
```
반환도 같다. `mem<N>` 을 반환하는 함수는 **첫 인자로 결과를 쓸 주소를 받는다**.
호출자가 자리를 잡고 넘긴다.
이 규약을 고른 이유는 단순해서만이 아니다. ISA 마다 다른 "구조체를 언제
레지스터로 넘기는가" 규칙을 통째로 피해간다. `extern "c"` 경계에서는 C ABI 로
변환해야 하며 그것은 백엔드의 일이다.
---
## 3. 종결자
4개다.
```
jmp b<n> 무조건 분기
br %c, b<t>, b<f> %c 가 0이 아니면 b<t>
ret [%v] 반환
trap <reason> <line> 중단
```
`trap``fe_trap(reason, UNIT_FILE, line)` 이 된다. `reason` 은 작은 정수이고
`UNIT_FILE` 은 **유닛당 하나뿐인 파일 이름 문자열**이다. 트랩 지점마다 문자열을
두면 실행 파일이 부풀기 때문이다.
`line` 은 컴파일 시점에 상수로 박힌다. 주소가 아니라 줄 번호를 남기는 방식은
당대에 흔했고, 심볼 테이블 없이 실패 지점을 말할 수 있는 가장 싼 방법이다.
reason 값:
| | |
|---|---|
| 0 | 배열·슬라이스 경계 |
| 1 | 정수 오버플로 |
| 2 | 0으로 나눔 |
| 3 | 도달할 수 없는 곳에 도달 (`@unreachable`) |
| 4 | 명시적 `@trap()` |
`--no-checks` 는 0·1·2 를 만드는 검사를 lowering 단계에서 생략한다. 3·4 는
소스에 쓰인 것이므로 남는다.
---
## 4. 함수와 전역
```
fn @unit.name(<ty>, ...) -> <ty> { ... }
extern fn @name(<ty>, ...) -> <ty>
global @unit.name : mem<N> = <초기값 바이트>
```
이름은 `유닛.이름` 이다. 제네릭 인스턴스도 여기서는 그냥 함수 하나다 —
모노모피제이션이 프론트엔드에서 끝나므로 **IR 에 제네릭이라는 개념이 없다.**
---
## 5. 이 IR 이 하지 않는 것
- **최적화 없음.** 상수 접기도, 죽은 코드 제거도 없다. 나중에 붙일 자리는 있다
- **φ 노드 없음.** 블록 간 값은 지역을 경유한다
- **타입 검사 없음.** 검사는 프론트엔드에서 끝났다. IR 이 잘못되었다면 lowering 의 버그다
- **예외·언와인딩 없음.** 에러는 값이고, `try` 는 분기다
---
## 6. 왜 이 크기인가
명령 12개와 종결자 4개는 **i386 으로 직접 번역할 수 있는 최소 집합**이다.
각각이 몇 개의 x86 명령으로 내려가고, 그 대응이 눈으로 확인된다. 이보다 높으면
백엔드가 IR 을 다시 해석해야 하고, 이보다 낮으면 lowering 이 기계에 가까워져서
다른 ISA 로 옮길 때 다시 써야 한다.
같은 이유로 이 집합은 m68k·ARM·MIPS·RV32 에도 그대로 내려간다. 평평한 주소
공간과 정수 연산만 쓰기 때문이다. 백엔드를 하나 더 만드는 비용은 명령 선택과
레지스터 할당이지 IR 재설계가 아니다.