명령 12개와 종결자 4개. 함수 단위 기본 블록이고 임시값은 블록을 넘지 않아 phi 노드가 없다 -- 블록을 넘겨야 하는 값은 지역을 경유한다. 코드가 조금 더 생기지만 레지스터 할당기를 블록 단위로 유지해 준다. Ferro 타입은 여기서 사라진다. 구조체·슬라이스·옵셔널·에러 유니온이 전부 mem<N> 이고 필드는 lowering 이 계산한 바이트 오프셋이다. 모노모피제이션이 프론트엔드에서 끝나므로 IR 에 제네릭이라는 개념도 없다. 덩어리는 언제나 주소로 오간다. 크기 임계값이 없어서 ISA 마다 다른 구조체 전달 규칙을 통째로 피해간다. trap 은 이유와 줄 번호만 남기고 파일 이름 문자열은 유닛당 하나를 공유한다. IR.md 가 설명이고 ir.h/ir.c 가 그 형태다. 아직 아무도 만들지 않는다.
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).
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 재설계가 아니다.