Dart FFI에서 구조체를 값으로 전달하기
Dart FFI에서 구조체를 값으로 전달하기 (structs by value)
API 설계와 네이티브 호출 규약에 대한 깊이 있는 분석이에요.
Dart 2.12 릴리스에서 우리는 C-interop 기능인 Dart FFI를 확장해 구조체를 값으로 전달하는 기능을 추가했어요. 이 글은 이 기능을 Dart SDK에 추가하기 위해 무엇이 필요한지 다뤄요. 저수준 언어 구현 세부사항이나 구조체를 값으로 전달하는 플랫폼 규약에 관심이 있다면 계속 읽어 보세요. 이 글은 struct-by-value 기능을 위해 API 개발과 ABI(응용 프로그램 바이너리 인터페이스) 규명을 모두 다뤄요.
이 기능(그리고 다른 Dart FFI 기능)을 작업한 2년 동안 우리는 API를 바꿔야 할 많은 제약을 발견했어요. ABI 탐험도 그에 못지않게 흥미로웠는데, 어려운 문제의 세부사항을 확정하는 데 여러 가지 접근 방식을 취할 수 있음을 보여줬어요.
본문
C/C++에서 값으로 전달 vs 참조로 전달
매일 C 코드를 작성하지 않는다면 빠르게 다시 짚고 넘어갈게요. C에 다음과 같은 구조체와 함수가 있다고 가정해 보아요.
struct Coord {
double x;
double y;
Coord* next;
};
Coord TranslateByValue(Coord coord) {
coord.x = coord.x + 10.0;
coord.y = coord.y + 10.0;
return coord;
}
void TranslateByPointer(Coord* coord) {
coord->x = coord->x + 10.0;
coord->y = coord->y + 10.0;
}
그럼 이 함수들을 간단한 C 코드에서 사용할 수 있어요. 로컬 변수 c1이 있다고 해 보아요.
Coord c1 = {10.0, 10.0, nullptr};
c1을 TranslateByValue에 전달하면 인자가 값으로 전달돼서, 호출받는 쪽이 사실상 구조체의 복사본을 대상으로 동작해요.
Coord c2 = TranslateByValue(c1);
즉 c1은 변경되지 않아요. 하지만 c1을 담고 있는 메모리를 가리키는 포인터로 c1을 참조로 전달하면 c1이 제자리에서(in-place) 변경돼요.
TranslateByPointer(&c1);
이제 c1.x에는 20.0이 들어 있어요.
API 설계 여정
원래 Dart FFI 프로토타입은 이미 구조체에 대한 포인터 전달을 지원했어요. 하지만 우리는 다양한 사용 사례와 제약을 수용하기 위해 여러 번 API를 재설계했어요.
초기 설계
우리의 초기 설계는 메모리에 구조체를 할당하고, 그 포인터를 C에 전달하며, 구조체의 필드를 수정할 수 있게 했어요. 그 접근 방식에서 Struct 클래스는 Pointer 클래스를 확장했어요.
@struct
class Coordinate extends Pointer<Void> {
@Double()
double x;
@Double()
double y;
@Pointer()
Coordinate next;
/// generated by @ffi.struct annotation
external static int sizeOf();
static Coordinate allocate({int count: 1}) =>
allocate<Uint8>(count: count * sizeOf()).cast();
}
final c = Coordinate.allocate()
..x = 10.0
..y = 10.0;
Dart FFI 사용자들은 앞선 스니펫처럼 작성했고, Dart FFI 내부에서는 sizeOf의 구현과 x, y, next의 getter/setter 구현을 생성했어요. 하지만 2년 전에 우리는 이 설계에 문제가 있다는 것을 깨달았어요. Coordinate가 Pointer를 확장하게 함으로써 Coordinate와 Coordinate*를 구분할 수 없었거든요.
Coordinate와 Coordinate* 구분하기
우리는 Dart FFI에 Struct를 도입했고 구조체들이 이 클래스를 확장하도록 만들었어요.
abstract class Struct<S extends NativeType> extends NativeType {
final Pointer<S> addressOf;
}
이제 Dart의 Pointer<Coordinate>는 C의 Coordinate*를 나타내고, Dart의 Coordinate는 C의 Coordinate를 나타내요. 이는 next 필드가 Pointer<Coordinate> 타입을 갖게 되어 @Pointer 주석이 중복이 됐어요. 그래서 우리는 Pointer 주석을 제거했어요.
class Coordinate extends Struct<Coordinate> {
@Double()
double x;
@Double()
double y;
Pointer<Coordinate> next;
}
이제 구조체에 대한 포인터를 Pointer 객체로 나타내므로, Pointer의 allocate 팩토리를 쓰기 시작했어요.
final c = Pointer<Coordinate>.allocate();
Pointer<Coordinate>의 필드에 접근하려면 Coordinate 타입의 객체가 필요해요. 그 객체에 x, y, next 필드가 있으니까요. 이를 위해 Pointer에 load 메서드가 이미 있었어요.
c.load<Coordinate>().x = 10.0;
물론 load를 호출할 때마다 <Coordinate>를 써야 하는 것은 장황해요. (Pointer<Uint8>에서 Dart int를 로드할 때도 타입 인자를 써야 했어요.) load에 이 타입 인자가 필요한 이유는 이 메서드의 반환 타입을 Dart 타입 시스템에 지정하기 위해서예요.
확장 메서드의 등장
Dart 2.7이 확장 메서드(extension methods)를 도입했어요. 확장 메서드를 사용하면 Pointer<T>의 타입 인자 T에 대해 패턴 매칭을 할 수 있었어요.
extension StructPointer<T extends Struct> on Pointer<T> {
external T get ref;
}
타입 인자에 대한 패턴 매칭 덕분에 호출 지점의 장황함을 제거할 수 있었어요.
c.ref.y = 10.0; // ref is pattern matched to be of type Coordinate.
확장 메서드 패턴 매칭을 사용해 Struct<S>의 타입 인자를 중복으로 만들 수도 있었고, 사용자 구조체의 정의를 다음과 같이 바꿨어요.
class Coordinate extends Struct {
@Double()
double x;
@Double()
double y;
Pointer<Coordinate> next;
}
이전에는 타입 인자 <S>가 Struct 필드 Pointer<S> addressOf를 제약했어요. 대신 우리는 그 필드를 확장 getter로 바꿨어요.
extension StructPointer<T extends Struct> on Pointer<T> {
external Pointer<T> get addressOf;
}
백킹 스토리지 누수 막기
C에서 Dart로 구조체를 값으로 반환할 때, 구조체를 저장하기 위해 C 메모리를 malloc 하고 싶지 않아요. 느리기 때문이기도 하고 사용자에게 해제 부담을 지우기 때문이기도 해요. 그래서 대신 구조체는 TypedData로 복사되고, Coordinate는 백킹 스토리지로 Pointer 또는 TypedData를 가질 수 있어요. 하지만 첫 재설계에서 도입한 addressOf는 Pointer 타입이었어요. 이 타입은 항상 C 메모리로 백업된다는 뜻을 전달했는데, 더 이상 사실이 아니었어요. 그래서 우리는 addressOf를 deprecated 처리했어요.
최적화를 위해
마지막 단계는 구조체 관련 메서드를 포함한 다양한 Dart FFI 메서드 호출이 컴파일 타임 상수 타입 인자를 갖도록 요구하는 것이었어요.
extension StructPointer<T extends Struct> on Pointer<T> {
/// Must invoke with a constant [T].
external T get ref;
}
메서드 호출이 이렇게 되면 코드를 더 잘 최적화할 수 있고 C 의미론과 더 잘 맞아요. 이 마지막 변경은 Dart 2.12에서 deprecation 경고를 촉발하며, 그 변경은 Dart 2.13에서 강제돼요.
ABI 발견 여정
이제 API가 갖춰졌으니 다음 질문은 이렇습니다: C는 이런 구조체를 값으로 전달하거나 반환할 때 어디에 두길 기대할까? 이것을 응용 프로그램 바이너리 인터페이스(ABI)라고 불러요.
문서
가장 자연스러운 것은 문서를 찾는 거예요. ARM은 Arm 아키텍처용 프로시저 호출 표준 — ABI 2019Q1과 ARM 64비트 아키텍처(AArch64)용 프로시저 호출 표준을 제공해요. 하지만 x86과 x64 공식 문서는 인터넷에서 사라졌고, 사람들은 이 정보를 찾다가 비공식 미러나 리버스 엔지니어링에 의존하게 됐어요. 문서를 잠깐만 봐도 구조체를 값으로 전달하는 여러 위치가 보여요.
- 여러 CPU 및 FPU 레지스터에.
- 스택에.
- 복사본에 대한 포인터로. (복사본은 호출자의 스택 프레임에 있어요.)
- 부분적으로 CPU 레지스터에, 부분적으로 스택에.
스택으로 전달될 때 요구되는 정렬(alignment)이 무엇인지, 사용되지 않는 모든 CPU 및 FPU 레지스터가 막혀 있는지 아니면 되채워지는지(backfilled)에 대한 추가 질문이 있어요. 구조체를 값으로 반환할 때, 구조체는 두 위치로 반환될 수 있어요.
- 여러 CPU 및 FPU 레지스터에.
- 호출받는 쪽이 메모리 위치에 기록 — 이 경우 호출자가 그 메모리 위치에 대한 포인터를 전달해요. (이 예약된 메모리도 호출자의 스택 프레임에 있어요.)
결과 위치에 대한 포인터가 전달될 때, 이것이 일반적인 CPU 인자 레지스터와 충돌하는지에 대한 추가 질문이 있어요.
Dart FFI 컴파일 리팩터링
이 초기 조사만으로도 Dart FFI 컴파일러 파이프라인의 일부를 재설계해야 한다는 것을 깨닫기에 충분했어요. 우리는 원래 Dart 코드를 어셈블리로 컴파일하는 데 쓰였던 Location 타입을 재사용하고 있었어요. 하지만 Dart ABI에서는 워드 단위로 정렬되지 않은 스택 위치나 동시에 두 개 이상의 레지스터를 사용하지 않아요. Location 타입을 이런 추가 위치들을 지원하도록 확장하는 실험은, Location이 Dart 가상 머신에서 많이 사용되기 때문에 거대하고 복잡한 diff로 끝났어요. 그래서 대신 우리는 Dart FFI의 컴파일 파이프라인을 교체했어요.
네이티브 ABI 탐구
ABI를 조금 탐구해 보아요. 다음과 같은 구조체와 C 함수 시그니처가 있다고 가정해 보아요.
struct Struct3Bytes {
uint8_t a0;
uint8_t a1;
uint8_t a2;
};
Struct3Bytes MyFunction(Struct3Bytes, Struct3Bytes, Struct3Bytes,
Struct3Bytes, Struct3Bytes, Struct3Bytes,
Struct3Bytes, Struct3Bytes);
다양한 ABI는 MyFunction에서 이 구조체들을 어떻게 전달할까요? Linux x64에는 CPU 인자 레지스터가 6개 있어요. 구조체는 단일 레지스터에 들어갈 만큼 작아서 처음 6개 인자는 6개 CPU 인자 레지스터에 들어가고, 마지막 2개는 스택에 들어가요. 스택 인자는 8바이트로 정렬돼요. 반환값도 CPU 레지스터에 들어가요(더 큰 예시).
rdi int64 Compound(size: 3)
rsi int64 Compound(size: 3)
rdx int64 Compound(size: 3)
rcx int64 Compound(size: 3)
r8 int64 Compound(size: 3)
r9 int64 Compound(size: 3)
S+0 Compound(size: 3)
S+8 Compound(size: 3)
=>
rax int64 Compound(size: 3)
그럼 Windows에서는 어떻게 될까요? 완전히 달라요. Windows에는 인자 레지스터가 4개뿐이에요. 하지만 첫 번째 레지스터는 반환값을 기록할 메모리 위치의 포인터를 전달하는 데 사용돼요. 그리고 모든 인자는 복사본에 대한 포인터로 전달돼요. 구조체의 크기가 3바이트로 2의 거듭제곱이 아니기 때문이에요.
Locations on Windows
Pointer(rdx int64) Compound(size: 3)
Pointer(r8 int64) Compound(size: 3)
Pointer(r9 int64) Compound(size: 3)
Pointer(S+0 int64) Compound(size: 3)
Pointer(S+8 int64) Compound(size: 3)
Pointer(S+16 int64) Compound(size: 3)
Pointer(S+24 int64) Compound(size: 3)
Pointer(S+32 int64) Compound(size: 3)
=>
Pointer(rcx int64, ret:rax int64) Compound(size: 3)
다른 예시를 살펴보아요. Linux와 Android의 ARM32예요. 다음과 같은 구조체와 C 함수 시그니처가 있다고 가정해 보아요.
struct Struct16Bytes {
float a0;
float a1;
float a2;
float a3;
};
Struct16Bytes MyFunction2(Struct16Bytes, float, Struct16Bytes);
이런 특정 타입의 구조체는 동일한 요소만 포함하므로 **동종 복합체(homogeneous composite)**라고 불러요. 그리고 최대 4개의 멤버를 가진 동종 float는 일반 구조체와 다르게 취급돼요. 이 경우 Linux는 구조체 안의 개별 부동 소수점에 부동 소수점 레지스터를 사용해요.
Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
s4 float
Multiple(s5 float, s6 float, s7 float, s8 float) Compound(size: 16)
=>
Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
Android에서는 HardFP 대신 SoftFP가 사용돼요. 이는 float가 부동 소수점 레지스터가 아니라 정수 레지스터로 전달된다는 뜻이에요. 게다가 결과를 위한 Pointer를 전달하고 있어요. 이는 첫 번째 인자가 부분적으로 정수 레지스터로, 부분적으로 스택으로 전달되는 흥미로운 상황을 초래해요.
M(r1 int32, r2 int32, r3 int32, S+0 int32) Compound(size: 16)
S+4 float
M(S+8 int32, S+12 int32, S+16 int32, S+20 int32) Compound(size: 16)
=>
P(r0 uint32) Compound(size: 16)
이 중 무엇이라도 잘못 처리하면 런타임에 세그멘테이션 폴트가 발생할 가능성이 높아요. 그래서 모든 하드웨어와 OS 조합에서 ABI의 모든 엣지 케이스를 정확히 처리하는 것이 가장 중요해요.
godbolt.org로 탐구하기
문서가 매우 간결하기 때문에 우리는 컴파일러 익스플로러 godbolt.org를 통해 많은 엣지 케이스를 알아냈어요. 컴파일러 익스플로러는 C 코드와 컴파일된 어셈블리를 나란히 보여줘요. 앞선 스크린샷은 Windows x86에서 sizeof(Struct3Bytes)가 3바이트임을 보여줘요. 3이 반환 레지스터 eax로 옮겨지기 때문이에요. 구조체를 조금 변경하면 크기가 여전히 3인지 검사할 수 있어요.
typedef struct {
int16_t a0;
int8_t a1;
} Struct3Bytes;
크기는 3이 아니에요: mov eax, 4. int16은 2바이트 정렬이어야 하므로 구조체는 2바이트 정렬이어야 해요. 즉 이 구조체들의 배열을 할당할 때 다음 구조체가 2바이트 정렬이 되도록 각 구조체 뒤에 1바이트 패딩이 있어요. 따라서 이 구조체는 네이티브 ABI에서 4바이트예요.
생성된 테스트로 탐구하기
안타깝게도 컴파일러 익스플로러는 MacOS와 iOS를 지원하지 않아요. 그래서 수동 탐구를 더 효율적으로 만들기 위해(그리고 이 기능을 위한 훌륭하고 방대한 테스트 스위트를 갖기 위해) 테스트 생성기를 작성했어요. 핵심 아이디어는 크래시가 나면 GDB로 무엇이 잘못됐는지 볼 수 있도록 테스트를 생성하는 거예요. 세그멘테이션 폴트가 발생했을 때 무엇이 잘못됐는지 쉽게 볼 수 있는 방법 중 하나는 모든 인자가 예측 가능하고 알아보기 쉬운 값을 갖게 하는 거예요. 예를 들어 다음 테스트는 연속 정수를 사용해서, 레지스터와 스택에서 이 정수 값들을 쉽게 알아볼 수 있게 해요.
void testPassStruct3BytesHomogeneousUint8x10() {
final a0Pointer = calloc<Struct3BytesHomogeneousUint8>();
final Struct3BytesHomogeneousUint8 a0 = a0Pointer.ref;
final a1Pointer = calloc<Struct3BytesHomogeneousUint8>();
// ...
a0.a0 = 1;
a0.a1 = 2;
a0.a2 = 3;
a1.a0 = 4;
// ...
final result = passStruct3BytesHomogeneousUint8x10(
a0, a1, a2, a3, a4, a5, a6, a7, a8, a9);
print("result = $result");
Expect.equals(465, result);
calloc.free(a0Pointer);
calloc.free(a1Pointer);
// ...
}
문제를 더 쉽게 찾는 또 다른 방법은 어디에나 print를 넣는 거예요. 예를 들어 Dart에서 C로 전환하는 동안 세그멘테이션 폴트가 나지 않았지만 모든 인자를 엉망으로 만들어 버렸다면, 인자를 출력하는 것이 도움이 돼요.
int64_t
PassStruct3BytesHomogeneousUint8x10(Struct3BytesHomogeneousUint8 a0,
Struct3BytesHomogeneousUint8 a1,
// ...
) {
std::cout << "PassStruct3BytesHomogeneousUint8x10"
<< "((" << static_cast<int>(a0.a0) << ", "
<< static_cast<int>(a0.a1) << ", " << static_cast<int>(a0.a2)
<< "), (" << static_cast<int>(a1.a0) << ", ") << // ...
int64_t result = 0;
result += a0.a0;
result += a0.a1;
result += a0.a2;
result += a1.a0;
// ...
std::cout << "result = " << result << "\n";
return result;
}
테스트 추가는 구성 파일에 함수 타입을 추가하는 것만큼 쉬워요. 테스트를 빠르게 추가할 수 있는 덕분에 방대한 테스트 스위트가 생겼어요. 예상대로 이 테스트 스위트는 네이티브 ABI에서 또 하나의 흥미로운 케이스를 잡아냈어요 — 이번에는 iOS-ARM64에서요. iOS ARM64에서 스택의 비구조체 인자는 워드 크기가 아니라 자기 자신의 크기로 정렬돼요. 구조체는 워드 크기로 정렬되는데, 구조체가 float만 있는 동종 구조체라면 float의 크기로 정렬돼요.
요약
이것으로 API 설계와 ABI 발견의 여정을 마칩니다. 좋은 테스트 스위트와 철저한 코드 리뷰 덕분에 우리는 2020년 12월 마스터 브랜치에서 Dart FFI에 구조체를 값으로 전달하는 지원을 탑재했고, Dart 2.12에서 사용할 수 있어요! Dart FFI를 사용하는 데 관심이 있다면 dart.dev의 C interop 문서에서 시작할 수 있어요. API 설계와 ABI 발견에 대한 질문이나 의견이 있다면 아래에 댓글을 남겨 주세요. 여러분의 의견을 듣고 싶어요!
이 Dart FFI 기능에 기여한 Dart 언어 팀과 (나머지) Dart 가상 머신 팀, 그리고 이 블로그 글을 다듬어 준 Kathy Walrath와 Michael Thomsen에게 감사드립니다.
더 알아보기
- C interop (Dart FFI) — Dart에서 C 언어와 상호운용하는 FFI 가이드
- Announcing Dart 2.12 — struct-by-value 지원이 포함된 릴리스
- Dart 2.7 발표 — 확장 메서드 도입