Pull to refresh

Непростая линковка Swift и C

Reading time10 min
Views2.4K

Все началось с того, что нашей команде прилетел жирный намек на покачаться в сторону системной разработки под яблочную платформу из за наклевывающихся контрактов. А мы все на виндофс пишем и вижуал студию одобряем который год - так что разнообразие не повредит.

Ну а чтобы покачаться в разработке под платформу самое лучше – написать какой-нить системный утиль, а тут Fugu14 выкатили поэтому я решил написать небольшую систему дампа фримвари для айфонов. И в качестве начала было решено переписать igetnonce на swift.

Почему swift? – ну неповторимый оригинал уже на Си, так что этот вариант отпадает, а красоту синтаксиса Objective-C  я чет так и не оценил.

Посмотрев как что нынче носят в swift  - я был крайне впечатлен концепцией пакетов и SPM – лаконичное описание для сборки проекта – это всегда приятно. По этой причине было решено реализовывать проект в виде пакета.

Чистый swift это конечно хорошо, однако igetnonce в качестве зависимостей тащит ряд Си-шных библиотек среди которых широко известная в кругах любителей jailbreak -ов libimobiledevice. С нее то и начались мои проблемы :-)

Swift и Си-библиотеки

Но для начала давайте обсудим как вообще связать swift и Си-шную библиотеку. Толковой информации об этом в интернете не то чтобы много – могу порекомендовать эту и эту, однако и они не достаточно полно описывают то как это правильно сделать.

Но тут (внезапно) на помощь приходит официальная документация – которая гуглится через коленку, и содержит пару ошибок… Так что думаю ничего страшного не будет от того, что я тут продублирую шаги документации с некоторыми исправлениями.

Для работы с Си-шными библиотеками в swift требуется создать специальный пакет-обертку.

mkdir Clibimobiledevice  # конвенция именований в формате Clibname 	описана в официальной доке так что не будем ее нарушать
cd Clibimobiledevice
swift package init --type system-module  

В результате получаем следующую структуру файлов:

Clibimobiledevice
    ├── Package.swift
    ├── README.md
    └── module.modulemap

Однако данная структура ошибочна, о чем расскажу чуть позже, сейчас давайте просто приведем ее к правильному виду.

Clibimobiledevice % mkdir -p ./Source/Clibimobiledevice
Clibimobiledevice % mv module.modulemap ./Source/Clibimobiledevice

В результате имеем следующую структуру:

Clibimobiledevice
    ├── Package.swift
    ├── README.md
    └── Source
        └── Clibimobiledevice
            └── module.modulemap

Так теперь следует отредактировать module.modulemap. Подробное описание формата modulemap приведено в официальной документации, но нам достаточно небольшого сабсета всех возможностей модулей, а именно нам нужно обьявить один системный модуль и выдернуть все содержимое заголовочных файлов libimobiledevice.

Получить путь до папки с заголовочными файлами можно с помощью команды brew --prefix libimobiledevice. В итоге module.modulemap будет иметь следующее содержимое:

Clibimobiledevice % cat > Source/Clibimobiledevice/module.modulemap
module Clibimobiledevice [system] {
  header "/usr/local/opt/libimobiledevice/include/libimobiledevice/libimobiledevice.h"
  export *
}
^D

Некоторые из озвученных далее проблем можно решить с помощью файла module.modulemap, однако это будут полумеры не до конца решающие проблему.

Теперь перейдем к файлу Package.swift. И отредактируем его следующим образом:

Clibimobiledevice % cat > Package.swift
// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Clibimobiledevice",
    products: [
        .library(name: "Clibimobiledevice", targets: ["Clibimobiledevice"]),
    ],
    targets: [
        .systemLibrary(
            name: "Clibimobiledevice",
	 // path:
            pkgConfig: "libimobiledevice-1.0",
            providers: [
                .brew(["libimobiledevice"])
            ]
        )
    ]
)
^D

Для взаимодействия с Си-шными библиотеками у SPM существует специальный таргет врапер – systemLibrary. Как можно увидеть из документации  - параметр path по умолчанию смотрит в [PackageRoot]/Sources/[TargetName] - как раз поэтому нам и пришлось изменить структуру каталогов проекта ранее.

Так же данный таргет опционально готов получить на вход источник пакетов – в нашем случае brew и имя (именно имя, без полного пути и без расширения) pkg-config-а используемой Си-шной библиотеки. Об этом конфиге и о том причем тут brew дальше и пойдет речь.

В целом наш врапер уже готов и теперь надо создать проект использующий его функциональность:

Clibimobiledevice % cd ..
% mkdir foo
% cd foo
% swift package init --type executable

В результате получаем следующую структуру файлов:

foo
├── Package.swift
├── README.md
├── Sources
│   └── foo
│       └── main.swift
└── Tests
    └── fooTests
        └── fooTests.swift

Отредактируем Package.swift, чтобы добавить в зависимости Clibimobiledevice:

foo % cat > Package.swift
// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "foo",
    dependencies: [
            .package(name: "Clibimobiledevice", path: "../Clibimobiledevice"),
        ],
    targets: [
        .executableTarget(
            name: "foo",
            dependencies: [
                .product(name: "Clibimobiledevice", package: "Clibimobiledevice")
            ]),
        .testTarget(
            name: "fooTests",
            dependencies: ["foo"]),
    ]
)
^D

И вызовем в main.swift какую-нибудь функцию libimobiledevice:

foo % cat > Sources/foo/main.swift
import Clibimobiledevice

idevice_set_debug_level(1)
^D

И попробуем собрать что получилось:

foo % swift build
warning: you may be able to install libimobiledevice-1.0 using your system-packager:
    brew install libimobiledevice

Undefined symbols for architecture x86_64:
  "_idevice_set_debug_level", referenced from:
      _foo_main in main.swift.o
ld: symbol(s) not found for architecture x86_64
[2/3] Linking foo

И так у нас на лицо проблема линковки, плюс странный варнинг о том, что мы не установили libimobiledevice. Что могло пойти не так? Может у нас какая-то проблема с версией библиотеки? Может у нас армовая версия? Проверим это:

foo % ARCH=x86_64 jtool2 -S /usr/local/opt/libimobiledevice/lib/libimobiledevice-1.0.dylib | grep idevice_set_debug_level
0000000000003e89 T _idevice_set_debug_level

Да нет – нужный символы на месте. Тогда попробуем руками указать линковщику где искать нужные символы

foo % swift build -Xlinker -L/usr/local/opt/libimobiledevice/lib -Xlinker -limobiledevice-1.0
warning: you may be able to install libimobiledevice-1.0 using your system-packager:
    brew install libimobiledevice

ld: warning: dylib (/usr/local/opt/libimobiledevice/lib/libimobiledevice-1.0.dylib) was built for newer macOS version (12.0) than being linked (10.10)
[1/1] Build complete!

Все собралось. Можно считать это победой – передать флаги через LinkerSetting.unsafeFlags и пойти пить чай, но это же не наши методы.

Помните функцию systemLibrary формирующую таргет для Си-шных библотек?

static func systemLibrary(
  name: String, 
  path: String? = nil, 
  pkgConfig: String? = nil, 
  providers: [SystemPackageProvider]? = nil
) -> Target

Нам интересен ее параметр pkg-config. Что такое pkg-config? если коротко – это утилита определяющая формат в котором библиотеки указывают необходимые для их сборки зависимости и флаги компиляции. Файлы для pkg-config имеют расширение .pc.

Мы в качестве такого файла указали libimobiledevice-1.0. Давайте взглянем на него чтобы немного освежить/познакомиться с форматом:

foo % cat /usr/local/opt/libimobiledevice/lib/pkgconfig/libimobiledevice-1.0.pc
# объвляются константы сокращающие запись
prefix=/usr/local/Cellar/libimobiledevice/1.3.0		
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include

Name: libimobiledevice
Description: A library to communicate with services running on Apple iOS devices.
Version: 1.3.0
Libs: -L${libdir} -limobiledevice-1.0			# флаги для ld
Cflags: -I${includedir}										# флаги для копилятора
Requires: libplist-2.0 >= 2.2.0						# зависимости
Requires.private: libusbmuxd-2.0 >= 2.0.2 openssl >= 0.9.8	

Как видим из конфига – флаги для ld аналогичны тем что использовали мы для успешной сборки и по замыслу лежащему в основе pkg-config SPM должен был сам вытащить эти флаги из конфига и подставить куда надо. А раз он этого не сделал то что-то пошло не так, да и варнинг выведенный при сборки только закрепляет мысль о том, что SPM не обработал наш .pc файл.

Таким образом возникает резонный вопрос где SPM ищет .pc файлы?

Для поиска ответа пришлось идти в исходники SPM. После некоторого времени, потраченного на поиски стало ясно что за обработку pkg-config-ов отвечает (ВНИМАНИЕ!) PkgConfig.swift, а за поиск – расположенная в нем структура PCFileFinder, в особенности функция locatePCFile.

Строчка 417 показывает все источники путей используемые SPM для поиска .pc файлов. А именно:

  • PCFileFinder.searchPaths – константа заданная в структуре

    • /usr/local/lib/pkgconfig

    • /usr/local/share/pkgconfig

    • /usr/lib/pkgconfig

    • /usr/share/pkgconfig

  • PCFileFinder.pkgConfigPaths – является результатом выполнения команды pkg-config --variable pc_path pkg-config и на моей системе имело следующее содержимое:

    • /usr/local/lib/pkgconfig:/usr/local/share/pkgconfig

    • /usr/lib/pkgconfig

    • /usr/local/Homebrew/Library/Homebrew/os/mac/pkgconfig/10.15

  • customSearchPaths – складывается из содержимого переменной окружения PKG_CONFIG_PATH и внешнего аргумента additionalSearchPaths который в случай использования brew будет содержать /usr/local/opt/(NAME)/lib/pkgconfig см тут и тут

    • PKG_CONFIG_PATH

    • /usr/local/opt/(NAME)/lib/pkgconfig

Исходя из собранных путей становиться ясно, что libimobiledevice-1.0.pc должен был быть найден еще на по пути /usr/local/lib/pkgconfig

foo % file /usr/local/lib/pkgconfig/libimobiledevice-1.0.pc
/usr/local/lib/pkgconfig/libimobiledevice-1.0.pc: ASCII text

Но что же тогда пошло не так? Для того чтобы разобраться в этом было решено создать небольшой проект, который создаст экземпляр PkgConfig напрямую.

foo % cd ..
% mkdir spm_test
% cd spm_test
spm_test % swift package init --type executable
spm_test % cat > Package.swift
// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "spm_test",
    platforms: [
        .macOS("10.15.4")
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        .package(name: "SwiftPM", url: "https://github.com/apple/swift-package-manager.git", .revision("658654765f5a7dfb3456c37dafd3ed8cd8b363b4"))
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .executableTarget(
            name: "spm_test",
            dependencies: [
                "SwiftPM"
            ])
    ]
)
^D

spm_test %cat > Sources/spm_test/main.swift
import Basics
import PackageLoading
import PackageModel
import TSCBasic

typealias Diagnostic = Basics.Diagnostic

// Подспер из тестов spm )))
struct Collector: ObservabilityHandlerProvider, DiagnosticsHandler {
    private let _diagnostics = ThreadSafeArrayStore<Diagnostic>()

    var diagnosticsHandler: DiagnosticsHandler { self }

    var diagnostics: [Diagnostic] {
        self._diagnostics.get()
    }

    func clear() {
        self._diagnostics.clear()
    }

    func handleDiagnostic(scope: ObservabilityScope, diagnostic: Diagnostic) {
        self._diagnostics.append(diagnostic)
    }
}

let collector = Collector()
let observabilitySystem = ObservabilitySystem(collector)

let observability = observabilitySystem.topScope.makeChildScope(description: "test")
let result = try PkgConfig(name: "libimobiledevice-1.0", additionalSearchPaths: [], fileSystem: localFileSystem, observabilityScope: observability)
print(result)

^D

Собираем и запускаем:

spm_test % ./.build/x86_64-apple-macosx/debug/spm_test
...
[1086/1086] Build complete!
spm_test % ./.build/x86_64-apple-macosx/debug/spm_test
Swift/ErrorType.swift:200: Fatal error: 
Error raised at top level: couldn't find pc file for openssl
zsh: illegal hardware instruction  ./.build/x86_64-apple-macosx/debug/spm_test

Иииии вот она ошибка! Проблема в том что мы не можем найти .pc для openssl. Openssl действительно находился в списке зависимостей для libimobiledevice. Получается что PkgConfig рекурсивно ищет и разбирает .pc файлы для всех зависимостей и если с одной из них произойдет какая-то проблема то никаких внятных сообщений об ошибках в консоли не появиться, а только бесполезный варнинг о том что исходный пакет не установлен.

Попробуем установить openssl через brew:

spm_test % brew install openssl
Running `brew update --preinstall`...
…

openssl@3 is keg-only, which means it was not symlinked into /usr/local,
because macOS provides LibreSSL.

If you need to have openssl@3 first in your PATH, run:
  echo 'export PATH="/usr/local/opt/openssl@3/bin:$PATH"' >> ~/.zshrc

For compilers to find openssl@3 you may need to set:
  export LDFLAGS="-L/usr/local/opt/openssl@3/lib"
  export CPPFLAGS="-I/usr/local/opt/openssl@3/include"

For pkg-config to find openssl@3 you may need to set:
  export PKG_CONFIG_PATH="/usr/local/opt/openssl@3/lib/pkgconfig"

…

Как видно из логов brew установка нам не сильно поможет, так как brew не создает линков на необходимые нам .pc файлы. Тут есть два варианта:

  • создать линки самому – но это потребует аналогичных манипуляций при использовании пакета на другой машине, что гемор

  • использовать PKG_CONFIG_PATH – этот вариант очевидно более гуманный если мы сможем прописать это в коде

Изменим наш тестовый проект:

spm_test %cat > Sources/spm_test/main.swift
import Basics
import Basics
import PackageLoading
import PackageModel
import TSCBasic
import Foundation

typealias Diagnostic = Basics.Diagnostic

// Подспер из тестов spm )))
struct Collector: ObservabilityHandlerProvider, DiagnosticsHandler {
    private let _diagnostics = ThreadSafeArrayStore<Diagnostic>()

    var diagnosticsHandler: DiagnosticsHandler { self }

    var diagnostics: [Diagnostic] {
        self._diagnostics.get()
    }

    func clear() {
        self._diagnostics.clear()
    }

    func handleDiagnostic(scope: ObservabilityScope, diagnostic: Diagnostic) {
        self._diagnostics.append(diagnostic)
    }
}

let collector = Collector()
let observabilitySystem = ObservabilitySystem(collector)

let observability = observabilitySystem.topScope.makeChildScope(description: "test")

let pkg_config_path_env = "PKG_CONFIG_PATH"

var pkg_config_path = "/usr/local/opt/openssl@3/lib/pkgconfig"
if let current_pkg_config_path = ProcessInfo.processInfo.environment[pkg_config_path_env] {
    pkg_config_path = current_pkg_config_path + ":" + pkg_config_path
}

setenv(pkg_config_path_env, pkg_config_path, 1)

let result = try PkgConfig(name: "libimobiledevice-1.0", additionalSearchPaths: [], fileSystem: localFileSystem, observabilityScope: observability)
print(result)

^D

Соберем и запустим:

spm_test % swift build
[3/3] Build complete!
spm_test % ./.build/x86_64-apple-macosx/debug/spm_test
PkgConfig(name: "libimobiledevice-1.0", pcFile: <AbsolutePath:"/usr/local/lib/pkgconfig/libimobiledevice-1.0.pc">, cFlags: ["-I/usr/local/Cellar/libimobiledevice/1.3.0/include", "-I/usr/local/Cellar/libplist/2.2.0/include", "-I/usr/local/Cellar/libusbmuxd/2.0.2/include", "-I/usr/local/Cellar/libplist/2.2.0/include", "-I/usr/local/Cellar/openssl@3/3.0.1/include", "-I/usr/local/Cellar/openssl@3/3.0.1/include", "-I/usr/local/Cellar/openssl@3/3.0.1/include"], libs: ["-L/usr/local/Cellar/libimobiledevice/1.3.0/lib", "-limobiledevice-1.0", "-L/usr/local/Cellar/libplist/2.2.0/lib", "-lplist-2.0"])

БИНГО!!! Осталось реализовать аналогичную логику для пакета. Ииии это оказалось невозможно. Нет, правильнее сказать – я так и не понял как можно в пакете установить переменную окружения. Если кто-то знает как – буду рад такой информации.

Таким образом у нас остается только один путь:

spm_test % cat /usr/local/Cellar/openssl@3/3.0.1/lib/pkgconfig/openssl.pc
prefix=/usr/local/Cellar/openssl@3/3.0.1
exec_prefix=${prefix}
libdir=/usr/local/Cellar/openssl@3/3.0.1/lib
includedir=${prefix}/include

Name: OpenSSL
Description: Secure Sockets Layer and cryptography libraries and tools
Version: 3.0.1
Requires: libssl libcrypto
spm_test % ln  /usr/local/Cellar/openssl@3/3.0.1/lib/pkgconfig/openssl.pc /usr/local/lib/pkgconfig/openssl.pc
spm_test % ln  /usr/local/Cellar/openssl@3/3.0.1/lib/pkgconfig/libcrypto.pc /usr/local/lib/pkgconfig/libcrypto.pc
spm_test % ln  /usr/local/Cellar/openssl@3/3.0.1/lib/pkgconfig/libssl.pc /usr/local/lib/pkgconfig/libssl.pc
spm_test % cd ../foo
foo % swift build
[0/0] Build complete!

Надеюсь данный материал поможет другим быстрее разобраться с проблемами линковки Си и Swift.

Tags:
Hubs:
+7
Comments2

Articles