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

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

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

IR.md 가 설명이고 ir.h/ir.c 가 그 형태다. 아직 아무도 만들지 않는다.
2026-08-17 05:44:56 +09:00

6.0 KiB

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).

booli8, chari8, usizei32 다.


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>           중단

trapfe_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 재설계가 아니다.