본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

SwiftPM C 타깃

~14 min · swiftpm, c, modules, build, swift

Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

타깃 넷, 방향은 하나

이 트랙에 나온 건 전부 SwiftPM 패키지 하나, AriandelKit 안에 살아. 화살표는 한쪽으로만 가. CJSONC를 CMyPaint가 쓰고, 그걸 CAriandelSurface가 쓰고, 그걸 AriandelEngine이 쓰고, 그걸 실행 파일 ariandel-icon이랑 테스트가 써. (AriandelEngine은 mypaint_brush_*를 직접 부르니까 CMyPaint에도 바로 기대고, 가족 공용 키트의 CwkKitCore에도 기대.) 패키지가 내보내는 product는 딱 하나, AriandelEngine 라이브러리야. XcodeGen이 만드는 앱은 이 패키지에선 그 product 하나만 가져가. 나머지 링크는 가족 공용 키트의 CwkKitCore, CwkKitMedia, CwkKitWeb야. 앱의 Swift 파일 중 C 모듈을 import하는 것도 하나도 없고. C는 엔진이 가진 거지, 앱이 보는 게 아니야.

플랫폼은 iOS 18이랑 macOS 15야. macOS 최소 버전은 장식이 아니야. Mac에서 돌리는 swift test가 빠른 엔진 스위트고, 브러시, 타일, 실행 취소, 문서, 렌더러가 시뮬레이터 없이 돌아. Swift 타깃의 Swift 6 언어 모드랑 C 타깃의 C11은 매니페스트 맨 아래에서 한 번에 정해. 그래서 어떤 타깃도 몰래 다른 모드로 새지 못해.

include/가 공개된 얼굴이야

SwiftPM C 타깃은 .c 파일 디렉터리에 include/ 하위 디렉터리가 붙은 거야. include/에 있는 건 뭐든 그 타깃의 공개 인터페이스야. 거기 module.modulemap이 없으면(Ariandel엔 없어) SwiftPM이 대신 써 줘. 디렉터리 통째에 거는 umbrella 하나. 두 번째 블록에 있는 생성된 모듈 맵이 그거야. 그래서 Swift에서 import CAriandelSurface 한 줄이면 공개 헤더 넷이 다 보여. 경계를 넘으면 이렇게 바뀌어.

  • 함수는 C 타입을 단 Swift 함수로 들어와. int는 Int32, uint16_t *는 UnsafeMutablePointer<UInt16>.
  • nullability는 헤더가 정해. ariandel_surface.h는 자기를 #pragma clang assume_nonnull로 감싸. 그래서 ArdTileRelease의 버퍼는 옵셔널 아닌 채로 들어오고, _Nullable을 붙인 ArdTileAcquire의 반환값은 옵셔널로 들어와. 레슨 2의 Swift 콜백이 그 시그니처랑 정확히 맞아.
  • 단순한 매크로는 상수로 들어와. Tile.size가 Int(ARD_TILE_SIZE)야.
  • 선언만 하고 정의는 안 한 구조체는 OpaquePointer로 들어와. 그게 BrushSurface.handle이고.
  • 함수 포인터 typedef는 @convention(c) 함수 타입으로 들어와. 캡처 없는 func을 바로 넘길 수 있는 이유가 그거야.

공개 헤더는 의존하는 타깃의 공개 헤더를 include해도 돼. ariandel_surface.h가 mypaint-surface.h를 include하는데, CMyPaint가 의존성이라 찾아져. 그리고 순전히 배치 때문에 있는 헤더도 하나 있어. ariandel_shader_types.h는 Metal 버퍼 구조체를 C로 선언해서 Swift가 C 배치를 그대로 받게 해. 셰이더 소스는 그 필드를 하나하나 똑같이 되풀이하고. 패키지 스위트는 MemoryLayout으로 세 구조체의 C 쪽 stride(40, 112, 144바이트)랑 주요 필드 오프셋을 셰이더가 기대하는 숫자에 고정해 둬. 그걸로 잡히는 건 C 쪽 어긋남뿐이야. 테스트는 셰이더 소스를 안 읽어. 그건 실행할 때 컴파일하는 Swift 문자열이거든. 그래서 Metal 사본만 고치면 그대로 빠져나가고, 그 사본을 맞추는 건 고치는 사람 몫으로 남아.

cSettings, 그리고 비공개로 남아야 하는 헤더

벤더링한 두 라이브러리는 둘 다 config.h를 무조건 include해. libmypaint 쪽은 Ariandel이 직접 쓴 세 줄(glib 없음, 패키지 이름)이고, json-c 쪽은 업스트림 CMake 출력에 가드 하나 더한 거야. 각자 자기 타깃 루트, include/ 바깥에 앉아 있고, .headerSearchPath(".")로 닿아. 이 설정은 그 타깃 자기 파일을 컴파일할 때만 먹혀. 이 배치가 왜 규칙인지는 집 규칙의 함정 항목이 말해 줘. 둘은 다른 타깃의 다른 파일이고, 어느 쪽이든 공개 include/에 넣으면 모듈의 일부가 돼서 Swift가 엉뚱한 매크로를 import해. include/에 든 건 전부, import하는 모든 쪽에 거는 약속이야. 빌드 설정은 그런 약속이 아니고.

CMyPaint는 .define("HAVE_CONFIG_H")도 걸어. autotools 관례야. 그래도 믿기 전에 벤더 코드를 읽어 봐. 이 업스트림 커밋에선 두 라이브러리 어느 파일도 그 매크로를 검사 안 하고, config.h는 상관없이 include돼. 일하는 건 검색 경로고, 그 define은 해 없는 습관이야. (아래 스케치는 자기 include를 그걸로 감싸 뒀어. 소스가 진짜로 검사할 때 관례가 어떻게 도는지 보라고.)

exclude 목록은 소스 아닌 걸 타깃 밖에 둬. COPYING이랑 PROVENANCE.md는 자기가 설명하는 코드 옆에 남아. 업스트림의 fastapprox/Makefile.am.local은 Ariandel이 안 쓰는 빌드 시스템 몫이야. glib/mypaint-brush.c는 mypaint-brush.c가 #include로 끌어오는 조각이라 따로 컴파일하면 절대 안 되고.

생성된 것, 손으로 고치지 않는 것

벤더 타깃의 헤더 몇 개는 복사한 게 아니라 생성한 거야. libmypaint의 brushsettings-gen.h(비공개)랑 include/mypaint-brush-settings-gen.h(공개)는 같은 커밋의 brushsettings.json에 업스트림 generate.py를 돌려서 나와. json-c의 json.h랑 json_config.h는 iOS용으로 설정한 업스트림 CMake에서 나오고. 각 타깃의 PROVENANCE.md에 정확한 명령이 적혀 있어. 이것들도 자기가 설명하는 벤더 코드처럼 다뤄. 기록된 커밋에서 다시 생성하고, 손으로는 절대 안 고쳐. 벤더링 얘기 전체는 1트랙에 있어.

자기 아이콘을 직접 칠하는 실행 파일

ariandel-icon은 작은 명령줄 타깃이야. swift run ariandel-icon <brushes-dir> <out.png>. 넘겨받은 디렉터리(앱이 번들하는 CC0 세트)에서 MyPaint 브러시를 읽어. 그리고 앱이 쓰는 바로 그 Brush, BrushSurface, CanvasRenderer.flatten으로, 헤더 주석 표현대로라면 "A night of the Painted World, one copper-red stroke (Pippa's hair) and falling snow"를 칠해. 눈송이 자리는 시드 고정 생성기가 정하니까 아이콘은 돌릴 때마다 똑같아. 마지막엔 알파 채널 없이 다시 그려. App Store 아이콘엔 알파가 있으면 안 되거든(ITMS-90717).

누가 한 번 그린 에셋 대신 왜 타깃으로 만들었냐고? Mac에서 엔진 전체를 처음부터 끝까지 돌리니까, 브러시 경로가 망가지면 아이콘이 망가진 걸로 드러나. 아이콘을 소스에서 재현할 수 있게 되고. 그리고 자기가 만든 걸 자기가 정직하게 써 보는 일이기도 해. 획마다 첫 deltaTime을 PencilMapping.strokeStartInterval로 시작하는데, 진짜 펜이 닿을 때 앱이 libmypaint에 먹이는 그 상수야(4트랙). 매니페스트엔 라이브러리만 product로 올라 있는데도 swift run이 돼. SwiftPM은 루트 패키지의 실행 타깃이면 뭐든 돌릴 수 있거든.

C 타깃은 공개된 얼굴이 달린 디렉터리야. 인터페이스는 include/에 두고, 나머지(설정, 비공개 생성 헤더, 조각, 라이선스)는 전부 바깥에 둬. 그리고 C랑 나머지 모두 사이엔 Swift 타깃 딱 하나만 세워. 그러면 나머지 코드는 Swift만 import하고, 밑에 C가 있었다는 것도 몰라.

Code

AriandelKit의 Package.swift(원문 그대로)·swift
// swift-tools-version:6.0
//
// Ariandel's engine: libmypaint (vendored, unmodified) behind a Swift engine
// that owns tiles, layers, undo, documents and Metal rendering.
// `swift test` here is the fast engine suite and runs on macOS.
import PackageDescription

let package = Package(
    name: "AriandelKit",
    platforms: [
        .iOS(.v18),
        .macOS(.v15),
    ],
    products: [
        .library(name: "AriandelEngine", targets: ["AriandelEngine"]),
    ],
    dependencies: [
        // The family kit: the vendored-file drift check runs in this package's suite.
        .package(name: "CwkKit", path: "../../../cwkSiblingKit/CwkKit"),
    ],
    targets: [
        // json-c, vendored byte-for-byte (see Sources/CJSONC/PROVENANCE.md).
        .target(
            name: "CJSONC",
            path: "Sources/CJSONC",
            exclude: ["COPYING", "PROVENANCE.md"],
            cSettings: [.headerSearchPath(".")]
        ),
        // libmypaint, vendored byte-for-byte (see Sources/CMyPaint/PROVENANCE.md).
        .target(
            name: "CMyPaint",
            dependencies: ["CJSONC"],
            path: "Sources/CMyPaint",
            exclude: [
                "COPYING",
                "PROVENANCE.md",
                // Compiled only through mypaint-brush.c's #include.
                "glib/mypaint-brush.c",
                "fastapprox/Makefile.am.local",
            ],
            cSettings: [
                .define("HAVE_CONFIG_H"),
                .headerSearchPath("."),
            ]
        ),
        // Ariandel's own C: the tiled surface libmypaint paints into, pixel kernels.
        .target(
            name: "CAriandelSurface",
            dependencies: ["CMyPaint"],
            path: "Sources/CAriandelSurface"
        ),
        .target(
            name: "AriandelEngine",
            dependencies: ["CAriandelSurface", "CMyPaint", .product(name: "CwkKitCore", package: "CwkKit")],
            path: "Sources/AriandelEngine"
        ),
        // Paints the app icon with the engine itself (`swift run ariandel-icon`).
        .executableTarget(
            name: "ariandel-icon",
            dependencies: ["AriandelEngine"],
            path: "Sources/ariandel-icon"
        ),
        .testTarget(
            name: "AriandelEngineTests",
            dependencies: [
                "AriandelEngine",
                .product(name: "CwkKitTesting", package: "CwkKit"),
            ],
            path: "Tests/AriandelEngineTests",
            resources: [.copy("Fixtures")]
        ),
    ],
    swiftLanguageModes: [.v6],
    cLanguageStandard: .c11
)
패키지 배치(일부 생략, 주석 추가)와 SwiftPM이 스케치의 C 타깃에 생성한 모듈 맵·text
AriandelKit's layout (trimmed; comments added)

Packages/AriandelKit/
  Package.swift
  Sources/
    CJSONC/                  json-c, vendored byte for byte
      config.h               private: CMake output, reached by .headerSearchPath(".")
      include/               public: json.h and json_config.h (generated by CMake), json_object.h, ...
      arraylist.c ...        12 sources
    CMyPaint/                libmypaint, vendored byte for byte
      config.h               private: three defines
      brushsettings-gen.h    private, generated by upstream generate.py
      include/               public: mypaint-surface.h, mypaint-tiled-surface.h,
                             mypaint-brush-settings-gen.h (generated), ...
      glib/mypaint-brush.c   excluded: compiled only through mypaint-brush.c's #include
      fastapprox/            headers (its Makefile.am.local is excluded)
      mypaint-brush.c ...    16 sources
    CAriandelSurface/        Ariandel's own C
      include/               public: ariandel_surface.h, ariandel_kernels.h,
                             ariandel_pixels.h, ariandel_shader_types.h
      ariandel_surface.c  ariandel_kernels.c  ariandel_pixels.c
    AriandelEngine/          Swift: brushes, tiles, layers, documents, undo, files, Metal
    ariandel-icon/main.swift paints the app icon with the engine itself
  Tests/AriandelEngineTests/

The module map SwiftPM generated for the sketch's C target (no module.modulemap
in the source tree, so it writes one: the whole include/ is the module):

module CEngine {
umbrella "<package>/Sources/CEngine/include"
export *
}
직접 재현하는 최소 스케치: 공개 include/를 단 C 타깃, 비공개 config.h, Swift 래퍼, 실행 파일(빈 디렉터리에서 실행, Swift 6.4로 빌드하고 실행함)·bash
# A minimal sketch of AriandelKit's shape: a C target whose include/ is its
# public face, a private config.h, a Swift target over it, and an executable.
# Run in an empty directory.
mkdir -p Sources/CEngine/include Sources/Engine Sources/mini-icon
cat > Package.swift <<'EOF'
// swift-tools-version:6.0
import PackageDescription

let package = Package(
    name: "MiniKit",
    platforms: [.macOS(.v15)],
    products: [.library(name: "Engine", targets: ["Engine"])],
    targets: [
        .target(
            name: "CEngine",
            path: "Sources/CEngine",
            exclude: ["NOTES.md"],
            cSettings: [
                .define("HAVE_CONFIG_H"),
                .headerSearchPath("."),      // reaches config.h; it is never public
            ]
        ),
        .target(name: "Engine", dependencies: ["CEngine"], path: "Sources/Engine"),
        .executableTarget(name: "mini-icon", dependencies: ["Engine"], path: "Sources/mini-icon"),
    ],
    swiftLanguageModes: [.v6],
    cLanguageStandard: .c11
)
EOF
echo "Not a source file; excluded." > Sources/CEngine/NOTES.md
echo '#define ENGINE_NAME "mini"' > Sources/CEngine/config.h
cat > Sources/CEngine/include/ce_tiles.h <<'EOF'
#ifndef CE_TILES_H
#define CE_TILES_H
#include <stdint.h>
#define CE_TILE_SIZE 64
int ce_fill(uint16_t *values, int count, uint16_t value);
const char *ce_name(void);
#endif
EOF
cat > Sources/CEngine/ce_tiles.c <<'EOF'
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "ce_tiles.h"
int ce_fill(uint16_t *values, int count, uint16_t value) {
    for (int i = 0; i < count; i++) values[i] = value;
    return count;
}
const char *ce_name(void) { return ENGINE_NAME; }
EOF
cat > Sources/Engine/Engine.swift <<'EOF'
import CEngine

/// The Swift face of the C target; callers import Engine, never CEngine.
public enum Engine {
    public static let tileSize = Int(CE_TILE_SIZE)          // a C macro, imported as a constant
    public static var name: String { String(cString: ce_name()) }

    public static func filledTile(_ value: UInt16) -> [UInt16] {
        var tile = [UInt16](repeating: 0, count: tileSize * tileSize)
        let written = tile.withUnsafeMutableBufferPointer { ce_fill($0.baseAddress!, Int32($0.count), value) }
        precondition(written == tile.count)
        return tile
    }
}
EOF
cat > Sources/mini-icon/main.swift <<'EOF'
import Engine
let tile = Engine.filledTile(1 << 15)
print("\(Engine.name): \(Engine.tileSize)x\(Engine.tileSize) tile, first value \(tile[0])")
EOF
swift run mini-icon

External links

Exercise

네가 쓰는 작은 C 라이브러리 하나, 아니면 네 프로젝트의 C 파일 하나를 골라서 AriandelKit 모양으로 만들어 봐. 공개 헤더는 include/에 두고, 설정 헤더는 타깃 루트에 둬서 .headerSearchPath(".")로 닿게 해. 소스 아닌 파일은 전부 exclude하고, 그걸 감싸는 Swift 타깃을 하나 둬서 아무도 C 모듈을 직접 import 안 하게 해. 빌드한 다음 SwiftPM이 생성한 모듈 맵을 찾아서, 그게 공개로 만든 헤더를 전부 적어 봐.
Hint
목록을 보고 놀랐다면 include/에 비공개여야 할 게 앉아 있는 거야. 타깃 루트로 옮기고 검색 경로를 더해. 그리고 HAVE_CONFIG_H 같은 define을 어느 빌드 시스템에서 베껴 오기 전에 소스를 grep해 봐. 검사하는 데가 없으면 아무 일도 안 하는 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.