GOAL P2: SPEC 에서 죽은 C 백엔드를 걷어낸다

파이프라인은 .fe → i386 asm → wasm → wlink → .exe 인데 명세는 아직 C 를
방출하는 컴파일러를 서술하고 있었다. 외부 감사가 그 문서를 충실히 읽고
존재하지 않는 문제(C 의 부호 있는 오버플로 UB)를 보고했다 -- 명세가 거짓말을
하면 그것을 읽는 사람이 틀린 답을 낸다.

걷어낸 것: .fei 심볼 파일과 그 위에 얹힌 증분 빌드 서술, fe_errors.h,
--emit-c 와 --error-table, '호스트 C 방출', 'C 방출 시 static inline',
'별도 C 표현'.

오류 코드 절은 실제대로 다시 썼다: 빌드 하나가 모든 유닛의 소스를 함께 읽고,
드라이버가 emit 전에 쓰인 이름을 모아 철자순으로 1부터 매긴다. 유닛 하나만
따로 코드 생성까지 밀고 갈 수 없다는 것도 그 결과로 적었다.

R7 이 컴파일러 소스 파일 이름(own.c)을 대고 있던 것도 언어의 말로 바꿨고,
이동은 변수 단위이고 대여는 place 단위라는 구분을 붙였다.

--target= 과 --model= 은 드라이버에서 없앴다. 타깃이 하나인데 받아들여서
무시하는 플래그는 안 받는 것보다 나쁘다.

SPEC 이 약속만 하고 구현이 없는 것 셋을 TODO 에 적었다: --strip-error-names,
fmt.fmt_error, 0b/0o 리터럴 값 계산.

240/240, 35/35.
This commit is contained in:
2026-08-17 16:12:53 +09:00
parent d9bced830c
commit ef7939f609
3 changed files with 20 additions and 22 deletions
+13 -21
View File
@@ -1,7 +1,7 @@
# Ferro 언어 명세 v0.1.8 # Ferro 언어 명세 v0.1.8
DOS용 시스템 프로그래밍 언어. C만큼 빠르고, 메모리 안전성을 함수 단위 지역 검사만으로 보장한다. DOS용 시스템 프로그래밍 언어. C만큼 빠르고, 메모리 안전성을 함수 단위 지역 검사만으로 보장한다.
파일 확장자 `.fe`, 컴파일러 이름 `fec`, 심볼 파일 `.fei`. 파일 확장자 `.fe`, 컴파일러 이름 `fec`. 별도의 인터페이스 파일은 없다 — 빌드 하나가 모든 유닛의 소스를 함께 읽는다.
이 문서는 Ferro 언어 명세만 다룬다. 컴파일러 구현 지시서와 표준 라이브러리 상세 명세는 이 문서는 Ferro 언어 명세만 다룬다. 컴파일러 구현 지시서와 표준 라이브러리 상세 명세는
별도 문서에서 다룬다. 명세 판단이 애매한 부분은 §1 철학과 §5 소유권 규칙을 기준으로 결정한다. 별도 문서에서 다룬다. 명세 판단이 애매한 부분은 §1 철학과 §5 소유권 규칙을 기준으로 결정한다.
@@ -112,7 +112,7 @@ and or not orelse
- `[]mut T``[]T`로, `&mut T``&T`**호출 인자 위치에서만** 암묵 재대여할 수 있다. 이것은 호출 동안의 read-only view이며 원래 배타 대여는 원래 마지막 사용까지 유지된다. 일반 `let`/대입에는 이 암묵 약화를 적용하지 않는다. 장기 shared borrow가 필요하면 root/place에서 명시적으로 새 `&` 또는 shared slice를 만들고 R6 검사를 받는다. - `[]mut T``[]T`로, `&mut T``&T`**호출 인자 위치에서만** 암묵 재대여할 수 있다. 이것은 호출 동안의 read-only view이며 원래 배타 대여는 원래 마지막 사용까지 유지된다. 일반 `let`/대입에는 이 암묵 약화를 적용하지 않는다. 장기 shared borrow가 필요하면 root/place에서 명시적으로 새 `&` 또는 shared slice를 만들고 R6 검사를 받는다.
- **배타 대여를 호출에 넘기는 것은 이동이 아니라 그 호출 동안의 재대여다.** `&mut T``&mut T` 파라미터에, `[]mut T``[]mut T` 파라미터에 넘기면 호출이 끝날 때 돌려받는다. 호출이 도는 동안 호출자는 그 값에 손댈 수 없으므로 별칭이 생기지 않는다. 이것이 없으면 배타 파라미터를 다시 넘기는 일이 함수당 한 번만 가능해져서 `&mut`가 사실상 쓸 수 없게 된다. - **배타 대여를 호출에 넘기는 것은 이동이 아니라 그 호출 동안의 재대여다.** `&mut T``&mut T` 파라미터에, `[]mut T``[]mut T` 파라미터에 넘기면 호출이 끝날 때 돌려받는다. 호출이 도는 동안 호출자는 그 값에 손댈 수 없으므로 별칭이 생기지 않는다. 이것이 없으면 배타 파라미터를 다시 넘기는 일이 함수당 한 번만 가능해져서 `&mut`가 사실상 쓸 수 없게 된다.
- `^[]T`는 "슬라이스를 가리키는 포인터"가 아니라 길이를 함께 소유하는 독립 타입이다. R4의 일반 `^T` 대상 제한의 예외이며 `?^[]T`도 허용한다. `*[]T`/`*[]mut T`는 계속 금지한다. `mem.alloc_slice(T, n)`가 반환하고 drop 시 버퍼를 해제한다. - `^[]T`는 "슬라이스를 가리키는 포인터"가 아니라 길이를 함께 소유하는 독립 타입이다. R4의 일반 `^T` 대상 제한의 예외이며 `?^[]T`도 허용한다. `*[]T`/`*[]mut T`는 계속 금지한다. `mem.alloc_slice(T, n)`가 반환하고 drop 시 버퍼를 해제한다.
- `str`은 nominal 타입이 아니라 미리 선언된 `const str = []u8;` type alias다. UTF-8 검증을 보장하지 않으며 문자열 리터럴은 정적 읽기 전용 `[]u8`이다. 따라서 별도 변환 규칙이나 별도 C 표현은 없다. - `str`은 nominal 타입이 아니라 미리 선언된 `const str = []u8;` type alias다. UTF-8 검증을 보장하지 않으며 문자열 리터럴은 정적 읽기 전용 `[]u8`이다. 따라서 별도 변환 규칙이나 별도 표현은 없다.
### 4.3 구조체 ### 4.3 구조체
@@ -196,25 +196,18 @@ fn read_all(path: str) -> !^[]u8 { // 표준 io는 core.Error로 통일
- 실패를 복구하지 않고 트랩으로 바꾸려면 `expr catch @trap()`을 쓴다. v0.1에는 별도 `must` 키워드를 두지 않는다. - 실패를 복구하지 않고 트랩으로 바꾸려면 `expr catch @trap()`을 쓴다. v0.1에는 별도 `must` 키워드를 두지 않는다.
`error.Name`은 선언된 error 타입을 만들지 않고 기본 `core.Error`의 이름 있는 `error.Name`은 선언된 error 타입을 만들지 않고 기본 `core.Error`의 이름 있는
멤버를 참조하는 익명 에러 값이다. 각 유닛은 사용한 이름을 `.fei`에 기록한다. 멤버를 참조하는 익명 에러 값이다. **드라이버가 emit 단계 이전에** 빌드에 든 모든
최종 빌드에서 **드라이버가 emit 단계 이전에** 모든 유닛의 `.fei`에서 사용된 이름을 유닛에서 쓰인 이름을 모아 중복을 제거하고 이름의 바이트순으로 정렬하여 1부터
합치고 중복을 제거한 뒤 이름의 바이트순으로 정렬하여 1부터 `u16` 코드를 부여한다. `u16` 코드를 부여한다. 따라서 서로 다른 유닛의 `error.Name`은 같은 값이고,
드라이버는 이 표를 단일 생성 헤더 `fe_errors.h``#define` 정수 상수로 방출한다. 빌드 순서와 무관하게 결과가 결정적이다.
따라서 서로 다른 유닛의 `error.Name`은 같은 값이고 빌드 순서와 병렬 컴파일에도
결과가 결정적이며 `switch` case 라벨로 쓸 수 있다.
이름 집합이 바뀌면 `fe_errors.h`와 그 헤더에 의존하는 오브젝트를 무효화하지만,
각 유닛의 `.c`는 재방출하지 않는다. 각 `.fei`는 그 unit이 사용하는 이름 집합만
기록하므로 global 번호 재배정 때문에 다시 쓰지 않으며, source에서 이름 사용 자체가
바뀐 unit의 `.fei` interface만 갱신한다.
빌드 디렉터리 이력에 따라 번호가 달라지는 append-only 표는 금지한다. 빌드 디렉터리 이력에 따라 번호가 달라지는 append-only 표는 금지한다.
유닛 단위 `--emit-c`는 전체 이름 집합을 알 수 없으므로 `--error-table=<파일>` 번호는 빌드 전체를 봐야 정해지므로 유닛 하나만 따로 코드 생성까지 밀고 갈 수는
확정된 표를 받아야 하며, 없으면 컴파일 에러다. 없다. 이름이 65,535개를 넘으면 컴파일 에러다. 명시적인 `error` 선언은 여전히 nominal
이름이 65,535개를 넘으면 컴파일 에러다. 명시적인 `error` 선언은 여전히 nominal
타입이며, 같은 멤버 이름이나 숫자 코드를 가진 다른 선언 및 `core.Error`와 자동 타입이며, 같은 멤버 이름이나 숫자 코드를 가진 다른 선언 및 `core.Error`와 자동
변환되지 않는다. `error.Name`의 타입은 `core.Error`이며 `core.Error!T` 또는 변환되지 않는다. `error.Name`의 타입은 `core.Error`이며 `core.Error!T` 또는
축약형 `!T`를 반환하는 함수에서만 직접 반환할 수 있다. 축약형 `!T`를 반환하는 함수에서만 직접 반환할 수 있다.
`--strip-error-names`를 사용하면 실행 파일과 런타임 오류 문자열에서 이름을 `--strip-error-names`를 사용하면 실행 파일과 런타임 오류 문자열에서 이름을
제거하지만 숫자 코드`.fei`의 타입/코드 일관성 정보는 유지한다. 제거하지만 숫자 코드는 유지한다.
`fmt.fmt_error`는 이 정책에 따라 `core.Error` 값을 이름 또는 코드로 포맷한다. `fmt.fmt_error`는 이 정책에 따라 `core.Error` 값을 이름 또는 코드로 포맷한다.
### 4.7 타입 동등성과 alias ### 4.7 타입 동등성과 alias
@@ -280,7 +273,7 @@ r.^ = 1; // r의 마지막 사용
x += 1; // OK — 여기서 r의 대여는 이미 끝났다 x += 1; // OK — 여기서 r의 대여는 이미 끝났다
``` ```
**R7 (참조 무효화와 부분 이동).** 참조 대상이 이동되거나 재대입되면 그 참조는 이후 사용 시 에러. own.c는 변수 단위 상태만 추적하므로 field/index/`.?` projection에서 비-Copy 소유값을 이동해 꺼내는 것은 금지한다. `mem.replace(&mut place, replacement)`로 유효한 대체값을 남기면서 꺼내야 한다. 배열의 선택적 소유 원소는 `?^T`로 두고 `mem.replace(&mut arr[i], null).?`로 꺼낸다. projection chain 자체(`p.?.^`, `s.field.x`)는 값을 소비하지 않는다. **R7 (참조 무효화와 부분 이동).** 참조 대상이 이동되거나 재대입되면 그 참조는 이후 사용 시 에러. 이동 상태는 변수 단위만 추적하므로 field/index/`.?` projection에서 비-Copy 소유값을 이동해 꺼내는 것은 금지한다(대여는 §5 R6대로 place 단위로 갈라지지만, 이동은 그렇지 않다). `mem.replace(&mut place, replacement)`로 유효한 대체값을 남기면서 꺼내야 한다. 배열의 선택적 소유 원소는 `?^T`로 두고 `mem.replace(&mut arr[i], null).?`로 꺼낸다. projection chain 자체(`p.?.^`, `s.field.x`)는 값을 소비하지 않는다.
이 금지에는 예외가 하나 있다. **타입 자신의 `drop` 안에서는 `self`의 projection에서 값을 꺼낼 수 있다.** 그 객체는 사라지는 중이고 `drop`이 돌아간 뒤에 그것을 읽을 수 있는 코드가 없으므로, R7이 막으려는 "뒤에 남은 반쪽짜리 값"이 생기지 않는다. 다른 함수에서는 예외가 없다. 이 금지에는 예외가 하나 있다. **타입 자신의 `drop` 안에서는 `self`의 projection에서 값을 꺼낼 수 있다.** 그 객체는 사라지는 중이고 `drop`이 돌아간 뒤에 그것을 읽을 수 있는 코드가 없으므로, R7이 막으려는 "뒤에 남은 반쪽짜리 값"이 생기지 않는다. 다른 함수에서는 예외가 없다.
@@ -295,7 +288,7 @@ x += 1; // OK — 여기서 r의 대여는 이미 끝났다
- `Static`: 문자열 리터럴 또는 `static`에서 파생되어 caller local borrow를 만들지 않는다. - `Static`: 문자열 리터럴 또는 `static`에서 파생되어 caller local borrow를 만들지 않는다.
- `Param(N)`: 시그니처로 정해진 하나의 참조성 parameter에서 파생된다. 메서드는 `Param(self)`만 허용하며 다른 참조성 인자에서 파생되면 에러다. 자유 함수는 기존 규칙대로 참조성 parameter가 정확히 하나여야 한다. - `Param(N)`: 시그니처로 정해진 하나의 참조성 parameter에서 파생된다. 메서드는 `Param(self)`만 허용하며 다른 참조성 인자에서 파생되면 에러다. 자유 함수는 기존 규칙대로 참조성 parameter가 정확히 하나여야 한다.
control-flow 합류는 `Static + Static → Static`, `Static + Param(N) → Param(N)`, `Param(N) + Param(N) → Param(N)`이다. 서로 다른 `Param` provenance가 합류하면 컴파일 에러다. `?&T`/`?[]T``null` 반환 경로는 caller borrow를 만들지 않는 경로이므로 static/null 경로와 `Param(N)` 경로가 합쳐지면 전체를 보수적으로 `Param(N)`으로 본다. 이 provenance는 함수 시그니처와 lowered `.fei` interface metadata에 기록할 수 있어야 한다. control-flow 합류는 `Static + Static → Static`, `Static + Param(N) → Param(N)`, `Param(N) + Param(N) → Param(N)`이다. 서로 다른 `Param` provenance가 합류하면 컴파일 에러다. `?&T`/`?[]T``null` 반환 경로는 caller borrow를 만들지 않는 경로이므로 static/null 경로와 `Param(N)` 경로가 합쳐지면 전체를 보수적으로 `Param(N)`으로 본다. 이 provenance는 함수 시그니처만 보고 결정할 수 있어야 한다.
호출 지점에서 `Param(N)` 결과는 **정해진 파생 원본을 대여한 것으로 취급**한다. 즉 결과를 지역 변수에 바인딩할 수 있으며, 그 대여가 사는 동안 원본에 R6·R7이 그대로 적용된다. `Static` 결과는 caller local borrow를 만들지 않는다. 호출 지점에서 `Param(N)` 결과는 **정해진 파생 원본을 대여한 것으로 취급**한다. 즉 결과를 지역 변수에 바인딩할 수 있으며, 그 대여가 사는 동안 원본에 R6·R7이 그대로 적용된다. `Static` 결과는 caller local borrow를 만들지 않는다.
@@ -422,8 +415,7 @@ orelse_expr := expr 'orelse' expr
12 기본: literal, ident, '(' expr ')', struct_literal, @builtin(...) 12 기본: literal, ident, '(' expr ')', struct_literal, @builtin(...)
``` ```
- `and`, `or`는 단축 평가한다. 호스트 C 방출에서는 각각 `&&`, `||` - `and`, `or`는 단축 평가한다. 좌변이 답을 정하면 우변을 평가하지 않는다.
매핑하며, 평가 순서와 단락 규칙은 Ferro 의미론을 그대로 유지한다.
- `orelse``catch`도 lazy다. 좌변이 각각 `Some`/success이면 우변 또는 handler를 - `orelse``catch`도 lazy다. 좌변이 각각 `Some`/success이면 우변 또는 handler를
평가하지 않는다(§4.5·§4.6). 평가하지 않는다(§4.5·§4.6).
- `as`는 후위 우선순위(단항보다 강함)지만 단항 연산자 바로 뒤에 `as`가 나타나면 모호한 비용을 숨기지 않도록 괄호를 강제한다. `(-x) as u32``-(x as u32)`는 허용하고 `-x as u32`는 컴파일 에러다. - `as`는 후위 우선순위(단항보다 강함)지만 단항 연산자 바로 뒤에 `as`가 나타나면 모호한 비용을 숨기지 않도록 괄호를 강제한다. `(-x) as u32``-(x as u32)`는 허용하고 `-x as u32`는 컴파일 에러다.
@@ -789,7 +781,7 @@ binding은 마지막 segment라 `io.write`, `mem.replace` 형태로 사용한다
| 튜플 / 다중 반환 | 편의 | 이름 없는 필드는 가독성 손해 | struct | | 튜플 / 다중 반환 | 편의 | 이름 없는 필드는 가독성 손해 | struct |
| 레이블 있는 break | 편의 | — | 플래그 변수 | | 레이블 있는 break | 편의 | — | 플래그 변수 |
| 슬라이스 패턴 매칭 | 편의 | — | 인덱스 비교 | | 슬라이스 패턴 매칭 | 편의 | — | 인덱스 비교 |
| `inline fn` | 편의 | — | C 방출 시 `static inline` | | `inline fn` | 편의 | — | 인라인 여부는 백엔드가 정한다 |
| `must` 키워드 | 편의 | 실패를 트랩으로 바꾸는 문법 설탕일 뿐 핵심 의미론이 아님 | `expr catch @trap()` | | `must` 키워드 | 편의 | 실패를 트랩으로 바꾸는 문법 설탕일 뿐 핵심 의미론이 아님 | `expr catch @trap()` |
| 블록 표현식 | 편의 | 값을 만드는 블록이 없으면 `catch`가 짧은 형태로 충분하고, 문법 표면이 작아진다 (§4.6) | `catch <식>`, `return`으로 탈출 | | 블록 표현식 | 편의 | 값을 만드는 블록이 없으면 `catch`가 짧은 형태로 충분하고, 문법 표면이 작아진다 (§4.6) | `catch <식>`, `return`으로 탈출 |
| 라이프타임 표기 (`'a`) | **구조적 불가** | 전역 분석 필요, R4를 풀어야 함 | R4 (2급 참조), R8 파생 반환, 인덱스 핸들 | | 라이프타임 표기 (`'a`) | **구조적 불가** | 전역 분석 필요, R4를 풀어야 함 | R4 (2급 참조), R8 파생 반환, 인덱스 핸들 |
+3
View File
@@ -53,6 +53,9 @@ uv run python tests/exec.py 31/31 컴파일된 프로그램이 실제로
| `interrupt` `shared` `atomic` `critical` | 파싱만 되고 의미 없음. SPEC §11 에서 v0.2 | | `interrupt` `shared` `atomic` `critical` | 파싱만 되고 의미 없음. SPEC §11 에서 v0.2 |
| lowering 미구현 진단 | `internal: cannot lower X`. 사용자 오류처럼 보이지 않는다 | | lowering 미구현 진단 | `internal: cannot lower X`. 사용자 오류처럼 보이지 않는다 |
| 배열·enum 페이로드의 자동 해제 | `release_at` 은 구조체 필드까지만 내려간다 | | 배열·enum 페이로드의 자동 해제 | `release_at` 은 구조체 필드까지만 내려간다 |
| `--strip-error-names` | 받아들이지만 아무것도 하지 않는다 (SPEC §4.6) |
| `fmt.fmt_error` | 없다. SPEC §4.6 이 약속만 하고 있다 |
| `0b` / `0o` 리터럴 | 렉서는 받지만 값 계산이 10진과 16진만 안다 |
--- ---
+4 -1
View File
@@ -60,7 +60,10 @@ int main(int argc, char **argv)
else if(strcmp(argv[i],"-o")==0 && i+1<argc) out_path=argv[++i]; else if(strcmp(argv[i],"-o")==0 && i+1<argc) out_path=argv[++i];
else if(strncmp(argv[i],"--std=",6)==0) std_root=argv[i]+6; else if(strncmp(argv[i],"--std=",6)==0) std_root=argv[i]+6;
else if(strcmp(argv[i],"--no-checks")==0) no_checks=1; else if(strcmp(argv[i],"--no-checks")==0) no_checks=1;
else if(strncmp(argv[i],"--target=",9)==0 || strncmp(argv[i],"--model=",8)==0 || strcmp(argv[i],"--strip-error-names")==0) { } /* One target (SPEC 2), so --target= and --model= are gone: a flag
that is accepted and does nothing is worse than one that is not
accepted at all. */
else if(strcmp(argv[i],"--strip-error-names")==0) { }
else if(argv[i][0]!='-') file=argv[i]; else if(argv[i][0]!='-') file=argv[i];
else if(strcmp(argv[i],"--help")==0){usage();return 0;} else if(strcmp(argv[i],"--help")==0){usage();return 0;}
else {fprintf(fe_diag_stream(),"fec: unknown option %s\n",argv[i]);return 2;} else {fprintf(fe_diag_stream(),"fec: unknown option %s\n",argv[i]);return 2;}