29 C 와 만나는 자리
먼저 알아야 할 것
cap c 는 시작점이 받을 수 없는 권한이다requires 는 진입에서 검사된다export 한 op 만 C 에서 부를 수 있는 심볼이 된다돌아보기
16장에서 cap c 는 왜 시작점이 요구할 수 없는 권한이라고 했는가?
답. 실행하는 쪽(운영체제)이 건넬 수 있는 권한이 아니기 때문이다. C 로 들어가는 문은 그것을 줄 자격이 있는 자리에서 만들어져 인자로 흘러야 한다. 이 장은 그 문 — extern — 을 다룬다. 들어가는 방향과 나가는 방향 모두다.
이 장의 필요성과 맥락
이 장이 끝나면
extern op 이 갖춰야 할 세 가지(unsafe 표시·cap c·효과 줄)와 link 절을 익힌다. 경계를 건널 수 있는 타입이 C ABI 가 표현할 수 있는 것으로 한정되고 슬라이스는 포인터와 길이 둘이 된다는 것을 알게 된다. export 한 op 을 --emit-h·--no-main 으로 C 프로그램에 넣고, C 가 계약을 어기면 문에서 멈추는 모습을 확인한다. 되부름과 소유를 넘기는 규칙도 보게 된다.이 장에서 답할 질문
unsafe가 붙은 op 안에서는 아무 일이나 해도 되는가?
29.1 C 를 부른다 — 표시·권리·효과 줄#
examples/ch29/area.low
module area_ffi .
rem 몸이 C 에 있다 — 표시·권리·효과 줄 셋을 모두 갖춘다
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
unsafe proc area_twice input k cap c . input w i64 . input h i64 . output i64 .
effects unsafe .
do
return add (c_area k w h) (c_area k w h) .
end
실행 결과
$ lowentc --check area.low
== check: ok ==
C 함수를 부르는 op 은 셋을 모두 갖춘다.
| 갖출 것 | 없으면 | 누구에게 말하나 |
|---|---|---|
unsafe 표시 | E-FFI-NOUNSAFE | 사람에게 — 여기서부터는 언어가 아니라 사람이 책임진다 |
input k cap c . | E-FFI-NOCAP | 처리기에게 — C 로 들어가는 것은 건네받은 권리다 |
효과 줄(적어도 effects unsafe) | E-FFI-NOEFFECT | 부르는 쪽에게 — 무엇을 떠안는지 머리에서 배운다 |
표 29.1 — C 를 부르는 op 이 갖추어야 하는 것
extern op 은 몸이 C 에 있으므로 이쪽에는 몸이 없다. 대신 struct 가 칸을 담듯 절을 do … end 에 담고, 그 가운데 link "lw_c_area" 가 C 쪽 이름을 댄다. 블록에 문장을 적으면 몸이 둘이 되어 E-FFI-BODY 다. 이름은 처리기가 op 이름에서 지어내지 않는다. 다른 언어에 하는 약속이므로 약속한 사람이 적는다 — 빠뜨리면 E-FFI-LINK 다. area_twice 는 그 op 을 부르므로 스스로도 unsafe 이고 cap c 를 받는다. 표시와 권리가 호출 사슬을 따라 올라간다.
examples/ch29/nounsafe.low
module nounsafe .
rem expect: E-FFI-NOUNSAFE
extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
실행 결과
$ lowentc --check nounsafe.low
nounsafe.low:4:0 E-UNSAFE-UNDECLARED: this op declares the `unsafe` EFFECT but is not marked `unsafe`. The effect says WHAT it does; the modifier says WHO takes responsibility. Write `unsafe proc …` — an unsafe op that nobody signed for is exactly the hole the discipline exists to close
nounsafe.low:4:0 E-FFI-NOUNSAFE: calling C without `unsafe`. Inside that C function every invariant this language enforces can be broken, and the tool cannot see it. What cannot be checked must at least be MARKED (RFC-0063 D1)
examples/ch29/nocap.low
module nocap_ffi .
rem expect: E-FFI-NOCAP
unsafe extern proc c_area do
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
실행 결과
$ lowentc --check nocap.low
nocap.low:4:0 E-FFI-NOCAP: this op calls C but receives NO right to do so. Calling into C is a capability you are HANDED — `input k cap c .` — exactly like the heap (RFC-0043), the device bus (RFC-0042) and raw machine instructions (RFC-0041). An ambient door into C is one every caller silently inherits
셋 중 하나만 있어도 “부를 수는 있다”. 그러나 그러면 이 언어는 자기가 무엇을 잃었는지 말하지 못한다.
문. unsafe 가 붙은 op 안에서는 아무 일이나 해도 되는가?
답. 아니다. unsafe 는 “아무렇게나 해도 된다” 가 아니라 “이 자리의 일부를 처리기가 검사할 수 없다” 는 표시다. unsafe op 의 본문에서도 경계 검사·소유·차용 규칙은 그대로 적용된다. 검사할 수 없는 것은 C 함수의 안이다. 그 표시가 소스에 남아 있어서 나중에 감사할 자리를 찾을 수 있다.
29.2 경계를 건너는 타입#
경계를 건널 수 있는 것은 C ABI 가 표현할 수 있는 것뿐이다. option·result·벡터·그릇은 C 에 없고, 처리기는 있는 척하지 않는다.
examples/ch29/fftype.low
module fftype .
rem expect: E-FFI-TYPE
unsafe extern proc c_find do
input k cap c .
input key u64 .
output option u64 .
effects unsafe .
link "lw_c_find" .
end
실행 결과
$ lowentc --check fftype.low
fftype.low:4:0 E-FFI-TYPE: this signature carries a type the C ABI cannot express (option / result / vector / container). C has no such thing, and the tool will not PRETEND it does: pass integers, f64, or a byte slice (which becomes a POINTER and a LENGTH — two C arguments, because that is what a slice honestly IS in C)
저절로 사상되는 것은 슬라이스뿐이다. slice τ 는 “τ 를 가리키는 포인터” 와 “개수” 두 인자가 된다. 폭도 안다 — slice u32 는 uint32_t * 이지 바이트 포인터가 아니다. 권한은 값이 아니므로 C 로 건너가지 않는다. ABI 이름은 c 하나다. “이 기계에서 C 가 무엇인가” 는 짓는 기계가 이미 정한다.
29.3 C 가 Lowent 를 부른다#
반대 방향이다. export 한 op 은 C 에서 부를 수 있는 심볼이 된다.
examples/ch29/exported.low
module exported .
rem run: clamp_add 100 50
export fn clamp_add input a u32 . input b u32 . output u32 .
requires le a 1000 .
requires le b 1000 .
ensures le ret 2000 .
do
return add a b .
end
export fn sum_bytes input xs slice u8 . output u64 .
do
var s u64 be 0 .
for x xs do
set s (add s (widen u64 x)) .
end
return s .
end
실행 결과
$ lowentc --run clamp_add exported.low 100 50
clamp_add(100, 50) = 150
--emit-h 가 헤더를 낸다. 헤더를 손으로 적으면 서명이 두 곳에 살고, 언젠가 갈린다.
long long clamp_add(long long, long long);
long long sum_bytes(const unsigned char *, size_t);sum_bytes 의 slice u8 이 포인터와 길이 둘로 나뉜 것을 볼 수 있다.
Lowent
input xs slice u8 .
C (--emit-h 가 낸 머리)
sum_bytes(const unsigned char *, size_t)
└──────┬────────────┘ └─┬──┘
│ 개수 (len xs)
포인터 (xs 의 첫 칸)--no-main 은 main 과 명령 줄 디스패처 없이 내보낸 op 의 진입점만 담은 C 를 낸다. 남의 빌드에 그대로 넣으면 된다. 다음 C 프로그램이 그 둘을 쓴다.
// host-for: exported.low
#include <stdio.h>
#include "lowent.h"
int main(void) {
const unsigned char bytes[] = {1, 2, 3, 4};
printf("clamp_add(100, 50) = %lld\n", clamp_add(100, 50));
printf("sum_bytes = %lld\n", sum_bytes(bytes, 4));
fflush(stdout);
printf("clamp_add(5000, 1) = %lld\n", clamp_add(5000, 1));
return 0;
}
$ cc host.c <exported.low 을 --no-main 으로 낸 C> && ./host
clamp_add(100, 50) = 150
sum_bytes = 10
panic: requires violated at entry
(종료 코드 70)
앞의 두 호출은 답을 받는다. 셋째 호출은 requires le a 1000 . 을 어긴다. C 는 계약을 모르지만, 불려 들어오는 자리는 이쪽 문이므로 op 의 계약이 인자에 강제되고 진입에서 멈춘다. 문 안은 Lowent 가, 문 밖은 C 가 책임진다.
두 방향을 한 장에 그리면 이렇다. 나가는 쪽은 머리에 적고, 들어오는 쪽은 문에서 잰다.
나가는 쪽 --- Lowent 가 C 를 부른다
area_twice ──▶ c_area ══ link "lw_c_area" ══▶ lw_c_area (C)
unsafe · cap c · effects unsafe 안은 검사 밖, 사람이 책임진다
들어오는 쪽 --- C 가 Lowent 를 부른다
host.c ── clamp_add(5000, 1) ══▶ ┃ requires le a 1000 .
┃ 5000 은 약속을 어긴다
┗━▶ 진입에서 멈춘다 (종료 코드 70)흔한 오해. FFI 경계에서는 언어의 보장이 모두 사라진다
unsafe·cap c·효과 줄로 머리에 적힌다. C 가 들어오는 쪽에서는 보장이 그대로 선다. 들어오는 인자가 계약을 어기면 진입에서 멈춘다. 경계는 구멍이 아니라 문이고, 문에는 계약이 서 있다.29.4 되부름과 소유#
C 에게 Lowent 함수를 넘겨 되부르게 하려면 export extern op 의 주소를 unsafe_fn <op> 으로 얻어 값으로 넘긴다. 새 규약은 없다. 규칙이 둘 붙는다.
unsafe_fn이 가리킬 수 있는 것은export externop 뿐이다(E-FN-NOTEXPORT). C 가 부를 수 없는 문의 주소는 주소가 아니다.- 되부름으로 쓸 op 은 권한을 요구할 수 없다(
E-FN-CAP). 되부름에 들어서는 것은 C 이고, C 는 건넬 권한이 없다. 권한이 필요한 일은 되부름 밖에서 하고, 되부름 안은 셈만 한다.
owned τ 를 extern 에 넘기면 없앨 책임이 C 에게 옮겨간다. 이쪽의 의무는 그 지점에서 끝나고, 그 뒤의 반납이 지켜졌는지는 검증되지 않는다. 고정된 인자 뒤에 개수가 정해지지 않은 인자를 받는 C 함수는 variadic . 절로 부를 수 있지만, 그 인자에는 계약이 닿지 않으며 반대 방향(C 가 우리 가변인자를 부르기)은 없다.
examples/ch29/variadic.low
module variadic .
rem C 의 char* — unsafe_ptr 는 타입 앞에 붙는 한정자다
newtype cstr unsafe_ptr u8 .
unsafe extern proc c_printf do
input k cap c .
input fmt cstr .
output i32 .
effects unsafe .
rem 고정 인자 뒤에 개수가 정해지지 않은 인자를 받는다
variadic .
link printf .
end
rem 고정 인자(서식) 뒤에 42 를 하나 더 건넨다 — 그 값에는 타입 검사도 계약도 닿지 않는다
unsafe proc report input k cap c . output i32 . effects unsafe .
do
return c_printf k (cstr_of "sum=%d\n\0") 42 .
end
실행 결과
$ lowentc --check variadic.low
== check: ok ==
newtype cstr unsafe_ptr u8 .는 C 의char*를 이름 붙인 타입으로 만든다.unsafe_ptr는 홀로 쓰는 타입이 아니라 타입 앞에 붙는 한정자다.variadic .절이 “고정 인자 뒤에 더 받는다” 를 적고,link printf .가 C 이름을 적는다. 부르는 자리의42는 고정 인자 뒤에 붙는 값이라 타입 검사도 계약도 닿지 않는다. 서식과 인자가 어긋나는 C 의 고전적 결함이 여기서는 막히지 않는다는 뜻이다. 그래서unsafe가 붙는다.cstr_of "…\0"은 끝에 영 바이트를 둔 문자열 리터럴을 C 문자열로 본다. VM 은 C 를 부를 수 없으므로 이 예제는 검사만 하고, 네이티브로 지으면sum=42를 찍는다.
실제 사례. 두 수를 더하는 프로그램이 지는 짐
main 과 명령 줄 디스패처가 함께 나오고, 디스패처는 태그 경로와 그 풀들을 붙잡는다. 개발 저장소의 측정에서 두 수를 더하는 op 하나를 기본으로 내면 읽기 전용 데이터가 약 197 KB, 초기화되지 않은 데이터가 약 1.7 MB 였고, --no-main 으로 내면 둘 다 50 바이트 이하가 되었다. 라이브러리로 넣을 때 --no-main 을 쓰는 이유다. 운영체제 없는 대상(--target cortex_m)은 처음부터 디스패처를 내지 않는다.29.5 흔한 실수#
반례. extern op 에 효과 줄을 적지 않는다
examples/ch29/mistake_noeffect.low
module mistake_noeffect .
rem expect: E-FFI-NOEFFECT
rem ✘ 효과 줄이 없다 --- 부르는 쪽이 무엇을 떠안는지 머리에서 알 수 없다
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
link "lw_c_area" .
end
실행 결과
$ lowentc --check mistake_noeffect.low
mistake_noeffect.low:5:0 E-UNSAFE-UNUSED: this op is marked `unsafe` but declares no `unsafe` effect. An `unsafe` that buys nothing is a FALSE ALARM — and false alarms are how real ones stop being read. Drop the modifier
mistake_noeffect.low:5:0 E-FFI-NOEFFECT: this op calls C and declares no effect. The effect row is how a CALLER learns what it is taking on; C that hides in a clean signature is the hidden cost this language exists to remove (declare at least `effects unsafe`)
진단이 둘 나온다. E-FFI-NOEFFECT 는 “C 를 부르는데 효과 줄이 없다” 이고, E-UNSAFE-UNUSED 는 “unsafe 표시를 했는데 unsafe 효과가 없으니 거짓 경보다” 이다. 둘은 같은 뿌리에서 나온다. 표시(unsafe)는 누가 책임지는가, 효과 줄(effects unsafe)은 무엇을 하는가 를 말하고, 한쪽만 있으면 짝이 맞지 않는다. effects unsafe . 한 줄로 둘 다 사라진다.
반례. extern op 에 link 절을 적지 않는다
examples/ch29/mistake_nolink.low
module mistake_nolink .
rem expect: E-FFI-LINK
rem ✘ `link` 절이 없다 --- 어느 C 심볼을 부르는지 아무 데도 안 적혀 있다
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
end
unsafe proc area_twice input k cap c . input w i64 . input h i64 . output i64 .
effects unsafe .
do
return add (c_area k w h) (c_area k w h) .
end
실행 결과
$ lowentc --check mistake_nolink.low
mistake_nolink.low:5:0 E-FFI-LINK: an `extern` op does not say which C symbol it calls. Write `link "strlen" .` (and `link "sin" from "m" .` when it lives in a library). The C name is a promise made to another language, so the author writes it: derived from the op name it would change the moment the op is renamed, with nothing in the source saying so (RFC-0063)
link 가 없으면 어느 C 심볼을 부르는지 소스 어디에도 없다. 처리기가 op 이름에서 지어내면 op 의 이름을 바꾸는 순간 다른 C 함수를 부르게 되고 그 사실이 적힌 데가 없다. 그래서 E-FFI-LINK 로 거절한다 — 약속은 적은 사람의 것이다.
반례. C 를 부르는 op 을 부르면서 unsafe 표시를 빠뜨린다
examples/ch29/mistake_callerunsafe.low
module mistake_callerunsafe .
rem expect: E-UNSAFE-UNDECLARED
unsafe extern proc c_area do
input k cap c .
input w i64 .
input h i64 .
output i64 .
effects unsafe .
link "lw_c_area" .
end
rem ✘ C 를 부르는 op 을 부르면서 `unsafe` 표시를 빠뜨렸다
proc area_twice input k cap c . input w i64 . input h i64 . output i64 .
effects unsafe .
do
return add (c_area k w h) (c_area k w h) .
end
실행 결과
$ lowentc --check mistake_callerunsafe.low
mistake_callerunsafe.low:14:0 E-UNSAFE-UNDECLARED: this op declares the `unsafe` EFFECT but is not marked `unsafe`. The effect says WHAT it does; the modifier says WHO takes responsibility. Write `unsafe proc …` — an unsafe op that nobody signed for is exactly the hole the discipline exists to close
area_twice 는 effects unsafe 를 적었지만 머리에 unsafe 가 없다. 효과는 호출을 따라 올라가므로 부르는 op 도 unsafe 효과를 내고, 그 효과를 낸다면 누군가 서명해야 한다. E-UNSAFE-UNDECLARED 의 말대로 “아무도 서명하지 않은 unsafe op 이 바로 이 언어가 막으려는 구멍” 이다. unsafe proc area_twice … 로 적는다.
반례. 보통 op 의 주소를 되부름으로 넘긴다
examples/ch29/mistake_cbplain.low
module mistake_cbplain .
rem expect: E-FN-NOTEXPORT
fn by_value input a i64 . input b i64 . output i64 .
requires ge a -1000000 .
requires le a 1000000 .
requires ge b -1000000 .
requires le b 1000000 .
do
return sub a b .
end
unsafe proc callback_addr input k cap c . output u64 . effects unsafe .
do
rem ✘ 보통 op 의 주소를 C 에게 넘기려 한다 --- C 가 부를 수 있는 문이 없다
let f u64 be unsafe_fn by_value .
return f .
end
실행 결과
$ lowentc --check mistake_cbplain.low
mistake_cbplain.low:14:1 W-EFFECT-OVER: this op DECLARES `unsafe` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
mistake_cbplain.low:16:0 E-FN-NOTEXPORT: `unsafe_fn <op>` needs an `export extern` op — only those have a C-callable symbol whose entry checks the contract (RFC-0066 §2). A plain op has no address C can call
C 가 부를 수 있는 것은 export extern op 이 만든 심볼뿐이다. 그 입구에서 계약이 인자에 강제된다. 보통 op 에는 그런 문이 없으므로 E-FN-NOTEXPORT 다. 머리를 export extern fn by_value … 로 바꾼다. 첫 줄의 W-EFFECT-OVER 는 주소를 얻는 것만으로는 unsafe 일을 한 것이 아니라는 알림이다. 그 주소로 C 를 부르는 op 에서 unsafe 가 선다.
반례. 되부름으로 쓸 op 이 권한을 받는다
examples/ch29/mistake_cbcap.low
module mistake_cbcap .
rem expect: E-FN-CAP
rem 되부름으로 쓸 op 이 권한을 입력으로 받는다
export extern fn cmp_logged input out cap io . input a i64 . input b i64 . output i64 .
requires ge a -1000000 .
requires le a 1000000 .
requires ge b -1000000 .
requires le b 1000000 .
do
return sub a b .
end
unsafe proc callback_addr input k cap c . output u64 . effects unsafe .
do
rem ✘ C 가 되부를 때 건넬 권한이 없다
let f u64 be unsafe_fn cmp_logged .
return f .
end
실행 결과
$ lowentc --check mistake_cbcap.low
mistake_cbcap.low:15:1 W-EFFECT-OVER: this op DECLARES `unsafe` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
mistake_cbcap.low:17:0 E-FN-CAP: `unsafe_fn <op>` names an op that requires a CAPABILITY (a `cap` parameter) — but a callback is entered BY C, and C has no capability to hand it. Do the capability-needing work OUTSIDE the callback and keep the callback pure or limited-effect (RFC-0066)
되부름에 들어서는 것은 C 이고 C 에게는 건넬 권한이 없다. 그러니 권한을 받는 op 은 되부름이 될 수 없다(E-FN-CAP). 출력이나 파일처럼 권한이 필요한 일은 되부름 밖에서, C 가 돌아온 뒤에 한다. 되부름 안은 셈만 한다.
29.6 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
unsafe extern proc c_area do input k cap c . … effects unsafe . link "lw_c_area" . end | C 에 몸이 있는 op — 절을 do … end 에 담는다 | 표시·권리·효과 줄 셋이 모두 있어야 한다 |
unsafe proc area_twice input k cap c . … effects unsafe . | C 를 부르는 op 을 부르는 op | 표시와 권리가 호출 사슬을 따라 올라간다 |
input xs slice u8 .(경계) | C 에서는 포인터와 길이 두 인자 | 저절로 사상되는 것은 슬라이스뿐 |
option·result·벡터를 경계에 | 거절(E-FFI-TYPE) | C ABI 에 없는 것을 있는 척하지 않는다 |
export fn clamp_add … | C 에서 부를 수 있는 심볼 | 들어오는 인자에 계약이 강제된다 |
lowentc --emit-h · --no-main | 헤더를 낸다 · main 없이 라이브러리로 낸다 | 서명이 한 곳에만 산다 |
unsafe_fn cmp | export extern op 의 주소(되부름) | 보통 op 은 E-FN-NOTEXPORT · 권한을 받으면 E-FN-CAP |
input h owned τ .(extern 에) | 없앨 책임이 C 로 넘어간다 | 그 뒤의 반납은 검증되지 않는다 |
variadic . | C 의 가변 인자 함수를 부른다 | 가변 인자에는 계약이 닿지 않는다 |
newtype cstr unsafe_ptr u8 . · cstr_of "…\0" | C 포인터 한정자 · 영 바이트로 끝나는 C 문자열로 보기 | 포인터는 이름 붙인 타입으로만 다룬다 |
표 29.2 — C 경계의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
extern op 은 unsafe·cap c·효과 줄을 모두 갖추고 link 로 C 이름을 댄다. 경계를 건너는 타입은 C ABI 가 표현할 수 있는 것뿐이고 슬라이스는 포인터와 길이가 된다. export 한 op 은 --emit-h·--no-main 으로 C 에 넣으며, C 가 계약을 어기면 문에서 멈춘다. 되부름은 권한 없는 export extern op 의 주소로 하고, owned 를 넘기면 책임이 C 로 간다.