Как собирать кроссплатформенные приложения. На C++. В браузере

TLDR; Нельзя просто так взять и запихнуть реальную систему сборки в браузер. Но если обойти некоторые тонкие технические и юридические моменты - вполне можно, что мы показываем наглядно. Статья о том, как это сделано, и немного о том, где можно применить.
Идея запихнуть clang в браузер и собирать приложения не нова: это уже делали, как минимум, в Emscripten, и ещё пара исследовательских проектов. Проблема в том, как это использовать в практических сценариях: программирование это не про работающий компилятор, это про целую кучу прикладных библиотек, про инфраструктуру
Проблема встаёт в момент, когда человек узнаёт, сколько в реальности нужно всего собрать, чтобы получить готовый sysroot для, например, QT: объём работы для подготовки сборочной площадки убивает всю выгоду от идеи. Но что если этой работы делать просто… не нужно.
Часть 1: Не sysroot, а host и target
Вся концепция кросс-компиляции крутится вокруг триплета (в котором на самом деле может быть и четыре, и пять компонентов, но принято звать триплетом).
Что такое триплет для кросс-компиляции
Триплет — это строка, которая однозначно идентифицирует целевую платформу для компиляции. Классический формат: `архитектура-вендор-ос` (например, `aarch64-apple-macosx`, `x86_64-unknown-linux-gnu`). На практике компонентов бывает четыре или пять: `x86_64-pc-windows-msvc` — здесь `pc` вендор, `windows` ОС, `msvc` среда линковки. Для wasm-мишеней это `wasm32-unknown-unknown`: ни вендора, ни ОС в привычном смысле.
Кросс-набор GCC выглядит так: один тарбол на триплет, внутри — полная автономная единица. Конвенция именования: gcc-<triplet>-<version>-binaries-<host-arch>.tar.xz (например, gcc-arm-linux-gnueabihf-12.2.0-binaries-x86_64-linux.tar.xz). Распаковывается в дерево, где корневая папка — сам триплет:
arm-linux-gnueabihf/
├── bin/ # gcc, g++, ld, as, ar — все привязаны к этому target
├── lib/ # libc, libm, libgcc, startup-файлы (crt0, crti, crtn)
├── include/ # заголовки libc, stdlib, platform
└── libexec/ # gcc internals (cc1, collect2)
Почему именно так: --target в GCC — это компиляционная опция configure, а не рантайм-флаг. На выходе получается бинарник cc1, который намертво знает, какой код эмитить, какие регистры использовать, какой ABI. Он не может переключиться на другую платформу без пересборки. Sysroot (заголовки + библиотеки) тоже жёстко привязан к target: другой триплет — другие заголовки, другие .a, другие crt*.o. Поэтому всё это лежит в одной папке с именем триплета: ты знаешь, что берёшь, и что оно самодостаточное.
Побочный эффект: если нужен и aarch64-apple-macosx, и x86_64-unknown-linux-gnu, и riscv64-linux-gnu — это три полностью независимых тарбола, каждый со своим cc1, своим линкером, своим sysroot. Комбинаторика растёт линейно с числом target’ов, а каждый тарбол весит 200–500 МБ и собирается полчаса-час.
В таком мире живёт большинство, поскольку именно так работает GCC: под каждый триплет своя сборка компилятора. Clang же правила меняет, триплет в нём становится параметром --target, а не частью артефакта. Вот здесь можно провести весьма хитрый разрез: разделить артефакты для запуска (host) и артефакты для сборки (target).
Парадокс в том, что в самой инфраструктуре clang такой разрез не проведён, она всё ещё живёт в логике GCC триплетов. Итак, что куда идёт.
Host
В группу Host идёт то, что реально нужно запускать, и что наглухо привязано к этим запускаемым артефактам. Это:
Компилятор
Линкер
Драйвер сборки
Binutils (llvm-версия)
Ресурсные заголовки компилятора
Обратите внимание на последний элемент, он важный. Ресурсы компилятора делятся на две логичных части: заголовки и библиотеки. Они, в совокупности, обеспечивают работу интринсиков компилятора и реализуют некоторые функции более оптимально (libclang_rt.builtins-riscv64.a). Сюда же относятся startup файлы платформы (clang_rt.crtbegin-riscv64.o). Так вот, ресурсные библиотеки это часть для сборки приложений (target), поскольку они в него включаются напрямую. Но заголовки связаны с фактическим бинарником компилятора по версии и набору функций, потому относятся к host.
Target
Это то, что обычно понимают под sysroot: заголовки и библиотеки, которые позволяют компилятору собрать конечное приложение. Сюда же кладутся ключевые зависимости и startup-объекты.
Что такое startup-объекты crtX.o
Startup-объекты — это маленькие `.o`-файлы, которые линкер кладёт в начало и конец итогового бинарника. Они содержат код, который выполняется **до** и **после** `main()`, и который компилятор не генерирует сам.
Классический набор (GCC, ELF):
Файл | Что делает |
|---|---|
| Точка входа |
| Пролог секций |
| Эпилог тех же секций |
| C++: регистрация конструкторов/деструкторов статических объектов ( |
| C++: завершение тех же массивов |
Порядок линковки: crt1.o + crti.o + crtbegin.o → объекты приложения → crtend.o + crtn.o.
В LLVM/Clang имена другие: clang_rt.crtbegin-<arch>.o, clang_rt.crtend-<arch>.o, clang_rt.crtstartup-<arch>.o. Суть та же.
Почему они target-специфичны: внутри — ассемблер, привязанный к архитектуре (выравнивание стека, порядок аргументов, имя entry point). Под aarch64 это одно, под riscv64 — другое. Поэтому они лежат в target, а не в host.
Компилятору, у которого --target это опция, a не часть его самого, здесь попросту не место.
При сборке, в первую очередь собирается компилятор для host, соответствующий текущей машине (обычно она называется build в терминах кросс-компиляции). Затем, target для той же локальной архитектуры. Затем, hosts для всех остальных архитектур, с использованием уже подготовленных target.
То есть, target нужна не только при сборке чего-то пользовательского, но и для сборки самой инфраструктуры, это неотъемлемая её часть.
Что в совокупности
Предположим, мы собираем компиляторы для 10 платформ, и поддерживаем для компиляции 20 платформ. Легко подсчитать, что в мире GCC это 200 условных тарболов, в которых лежит компилятор и sysroot. Неподъёмно для одного энтузиаста просто по факту того, что одна сборка компилятора на хорошем домашнем ПК - около получаса. Час, если хочется LTO.
Если же target и host разделены, это 30 тарболов, из которых только 10 - тяжёлая сборка. В итоге, можно собрать весь набор это 12 часов: вполне подъёмно для одного человека.
Сборка получается, в целом, воспроизводимой и не зависит от предыдущей версии того же компилятора (привет Кену Томпсону и Rust): любой, что может собрать llvm - сойдёт. Более того, она вся целиком может быть сделана на одной машине в одной ОС, правда, только если эта ОС - Linux.
Но побочный итог такого деления: вы получаете target наборы, которые не привязаны к host платформе, только к версии компилятора. А значит, вы можете запускать компиляцию с этим target одинаково, хоть на Linux, хоть на Windows, хоть… в браузере?
Часть 2: Платформы и юристы
Собрать sysroot для Linux может любой, исходные коды открыты, лицензия позволяет. А что, если хочется собрать target для Windows и использовать его на Linux? Или macOS?
Важно, что стандартный, штатный llvm clang сейчас может собирать код для всего этого. Да, это будет код без специфических apple фиксов и оптимизаций, но он будет работать. Вся наша история не о том, как сделать код оптимальным, а как отдельный человек без тысячной компании за спиной может взять и собрать приложение с абсолютного нуля.
Windows
Хотя, технически, вы можете использовать проекты вроде xwin для загрузки Windows SDK, на практике - это юридическая серая зона. Потому проект просит от вас явно подтвердить согласие на лицензию Microsoft. По этой лицензии, у SDK есть две части: распространимая (redistributable) и нераспространимая (non-redistributable). К первой относятся модули DLL и… всё. Статические библоитеки и, главное, библиотеки реализации, которые позволяют вам слинковаться с системной DLL и несут в том числе startup-код (и то, и другое *.lib), вы не имеете право распространять в своих пакетах. Не имеете и вы и права распространять заголовки SDK за исключением тех, что имеют открытую лицензию явно (WIL например).
То есть, просто взять и собрать tarball с target для Windows на основе Windows SDK вы можете. Для себя. Но не можете это распространять.
Проблема усугубляется тем, что значительные части стандартных библиотек C и C++ находятся в статических частях, и они тоже non-redistributable. То есть, если упрощать, вы не можете использовать UCRT и MS C++ в своём target.
CRT на Windows: варианты, отладка, версии, DLL
Windows-CRT — это не одна библиотека, а матрица вариантов, которые нельзя смешивать.
Варианты линковки (флаги MSVC):
Флаг | Что линкуется | Отладочный аналог |
|---|---|---|
|
|
|
|
|
|
Отладочные и релизные варианты не совместимы на уровне линковки: объект, скомпилированный с /MD, не пролинкуется с /MDd-версией CRT (разные имена импортов, разная укладка _malloc/_free). Перемешал — heap corruption в рантайме, не на линковке.
Привязка к версии компилятора:
Каждый major MSVC (2015/2017/2019/2022) — это свой _MSC_VER (1900, 1911, 192x, 193x) и свой набор заголовков STL. Критично:
Заголовки
<xstring>,<xmemory>,<yvals.h>— внутренние, их укладка меняется между версиями. VS2019 заголовки + VS2022msvcp140d.dll= UB, потому чтоstd::stringв debug-режиме несёт дополнительные поля (SSO buffer size, debug heap cookie), и их размер/положение зависят от версии заголовков.<yvals_core.h>содержит#error-проверки: если_MSC_VERне совпадает с ожидаемым для данного набора заголовков, компиляция падает с сообщением «you are using headers from a different version».__cplusplusмакрос: до VS2019 16.10 всегда199711L(даже с/std:c++17), потом201703L, в VS2022202002L. Код, который ветвится по__cplusplus, ведёт себя по-разному.
Итог: target для Windows SDK — это не «sysroot + libstdc++». Это конкретная версия заголовков + конкретная версия .lib/.dll, и они должны быть из одного релиза. Дла воспроизведения вручную это чудовищная точечная работа.
Статическая инициализация в DLL — отдельная боль:
Входная точка DLL — не _start, а DllMain. Цепочка:
loader → DllMain(DLL_PROCESS_ATTACH)
→ __DllMainCRTStartup
→ _initterm(&__xi_a, &__xi_z) // C-конструкторы
→ _initterm_e(&__imp_a, &__imp_z) // C++-конструкторы (с try/catch)
→ пользовательский DllMain
Проблемы:
Loader lock. Во время
DllMainзагрузчик держит глобальную блокировку. Если статический конструктор в DLL вызываетLoadLibrary(прямо или косвенно — черезstd::coutпервый раз, черезnew, через RTTI), происходит deadlock. Это не баг, это спецификация: вDLL_PROCESS_ATTACHзапрещено вызывать большинство Win32 API.Порядок инициализации между DLL. Если DLL A зависит от DLL B, статические объекты B инициализируются до A. Но если зависимость циклическая (A → B → A), порядок не определён. Статический объект в A, который в конструкторе обращается к статическому объекту в B, может получить неинициализированное значение.
Порядок внутри одной DLL. Между объектами разных translation units порядок не гарантирован (static initialization order fiasco). MSVC использует массивы указателей
__xi_a..__xi_z/__imp_a..__imp_z, которые линкер заполняет в порядке, зависящем от порядка.objна линковочной строке.Thread attach/detach.
DllMainвызывается и приDLL_THREAD_ATTACH/DLL_THREAD_DETACHдля каждого нового потока. Если в статическом конструкторе (process attach) вы создаёте поток,DllMainбудет вызван для него пока загрузчик ещё не отпустил lock → deadlock.
Поэтому в серьёзных Windows-кодах статические конструкторы в DLL минимизируют до абсурда: либо inline инициализация (C++11 magic statics), либо явный Init()-вызов из EXE после загрузки всех DLL.
Потому нельзя просто взять и слинковаться против ucrtbase.dll в коде, который специально для этого не написан. А если он будет написан специально для этого - это сломает всю идею универсальности.
Остаётся собрать это всё без Windows SDK. Что вполне возможно, как ни странно. Лицензия Microsoft покрывает конкретный текст заголовков, но не имена функций в DLL, не константы ABI, не укладку структур и имена полей (конкретнее это объясняется в разборах Google vs. Oracle по поводу Java API). То есть, это всё можно просто написать заново, используя официальную документацию и собственные исследования, как это уже делали Wine и ReactOS (к слову, если брать их наработки, то вы попадаете уже под условия GNU GPL).
Google vs Oracle: что покрывает лицензия в коде
**Суть дела (2010–2021).** Oracle подала на Google: Android скопировал 11 500 Java API-пакетов (декларации методов, структура классов, организация пакетов). Вопрос: можно ли перенести API-декларации в новую платформу?
Решения по инстанциям:
Суд | Итог |
|---|---|
District court (2012) | API не подлежат копирайту (merger doctrine, scenes à faire). Google выиграл. |
Federal Circuit, en banc (2014) | Отменил: декларация API — это творческое литературное произведение, копирайт есть. Но вопрос fair use оставил на remand. |
Supreme Court (2021, 5-8) | Копирование было fair use. Google выиграл. |
Ключевые выводы SC для нашего случая:
Копирайт покрывает выражение, не идею. Конкретный текст (комментарии, форматирование, имена переменных, где был творческий выбор) — защищён. Но:
сигнатуры функций, поределяемые нуждами взаимоействия с ними,
укладка структур, заданная ABI,
значения констант, зафиксированные спецификацией,
семантика («что функция делает»)
— это идея/функция, не выражение. Копировать их, написав текст заново, законно.
Thin copyright. Для высокофункциональных произведений (API-заголовки) защита «тонкая»: покрывает только точное выражение, не лежащую в основе методологию. Нельзя зарегистрировать копирайт на идею функции, которая принимает
HANDLEи возвращаетBOOL.Merger doctrine. Если существует только один разумный способ выразить что-то (единственное логичное имя для функции, единственный порядок полей для бинарной совместимости), выражение «сливается» с идеей и не защищается.
Scenes à faire. Стандартные для жанра элементы (
WINAPI,#pragma pack, типичные guard-макросы) не являются оригинальным выражением.Fair use — факто-зависимый. Суд не сказал «API копировать можно всегда». Он сказал: в этих конкретных обстоятельствах (новая платформа, трансформативное использование, скопированы только декларации без реализаций, новый рынок) — это fair use. Другой набор фактов мог бы дать другой ответ.
Практический вывод для сборки target без SDK:
Можно написать собственные заголовки, которые:
объявляют те же функции с теми же сигнатурами (ABI-совместимо),
используют те же имена структур и полей (бинарная совместимость),
содержат те же константы и их значения (спецификация),
но написаны вашим текстом: ваши комментарии, ваш формат, ваши имена там, где был выбор.
Нельзя: скопировать текст заголовка 1-в-1. Именно поэтому Wine и ReactOS пишут всё с нуля по документации. И именно поэтому git-история, где каждый коммит — «добавил декларацию CreateWindowExW по MSDN», является юридическим доказательством: вы показываете, что текст создан вами, а не извлечён из чужого файла.
Однако, UCRT и MS C++ усугубляют проблему: весь startup и ABI код нужно написать самостоятельно. Да и libc в целом, общедоступных функций в kernel32.dll довольно мало, а математические блоки и вовсе строго проприетарные. К счастью, есть musl libc, откуда можно взять большую часть необходимого кода.
Главный момент в такой работе: ваша история версий становится юридическим документом и вашим доказательством, что вы не крали ничего из чужих текстов, и сделали всё это вручную.
MacOS
Условия лицензии Apple значительно более жёсткие: вам разрешено использовать их SDK только на платформах с железом Apple. К счастью, на это железо можно поставить Linux и через это работать. Однако, проблема всё та же: нам нужно не просто запустить, но распространять наш target, в который не могут быть включены официальные SDK.
Здесь прелесть в том, что MacOS это наследник BSD и значительная часть кода (включая startup) открыта. Можно взять открытый код ядра XNU и получить из него минимальную открытую версию SDK, которую можно распространять. Недостающие функции дописываются ручным оверлеем по тем же правилам, что и для Microsoft.
В сущности, основа MacOS SDK это те же заголовки XNU, обработанные unifdef или каким-то другим скриптом. Многие проекты Apple и вовсе сейчас открыты под пермиссивными лицензиями и так ведутся: например, фреймворк Foundation это часть Swift Runtime, откуда его и можно взять для target. Сложности составляют закрытые системные фреймворки, но концепция селекторов в Objective C делает их весьма простыми для переписывания, намного проще и лаконичнее, чем с Windows SDK.
Apple использует для линковки приложений не чистые библиотеки, а их текстовые описания, TBD. Для своего target нам тоже нужны TBD, но взять их неоткуда, потому мы применили трюк из арсенала реверс-инженерии. Собрали библиотеки с помощью законно полученного SDK, а затем из них, из наших собственных библиотек, извлекли список используемых функций и добавили в список для генерации TBD. То есть, код под лицензией Apple не трогали вовсе.
Linux
Хотя система открытая, если вы разрабатываете приложение, и не хотите публиковать его под GNU GPL - она всё равно подкладывает вам свинью. Главный аспект: вы не можете статически линковаться с glibc, да и для приложения это будет в целом плохая идея. Слишком много систем в Linux рассчитаны на динамическую линковку: например, драйвера Vulkan, которые loader тянет через dlopen. Проблему пытается решить формат контейнеров (AppImage, Flatpak, snap), но это отдельный этап в сборке, отдельный набор инструментов и… ну, вы помните, мы хотим, чтобы сборка была простой и единообразной.
Потому, самое простое решение: собрать руками достаточно старую версию glibc (2.33 в нашем случае) и расчитывать на обратную совместимость. Приложение линкуется против старой версии glibc, но работает с системной. При качественно и тщательно написанных скриптах сборки это достижимо даже без контейнеров (отдельный этап и набор инструментов в сборке, опять…), просто правильно указать флаги.
Что на выходе
Набор target для всех основных платформ, который просто работает. Без нужды тащить дополнительные SDK и инструменты.
Простота гарантирует переносимость. Как только мы начинаем усложнять систему, добавлять инструменты, добавлять этапы - появляется больше точек, где всё может сломаться, и, в итоге, сломается сам человек, который будет пытаться такое собрать. Переусложнённые тулчейны это ровно то, чем корпоарции сдерживают нас. Не бери make, бери meson + ninja (две тулзы вместо одной), просто скачай SDK (+1 этап, валидация, зависимость), просто собери в контейнере (+этап, валидация, зависимость, ограничение платформы). Видимая “простота” инструментов на деле создаёт лишь больше работы, с которой человек уже не способен справляться, не может удержать всё в уме. Появляются отдельные профессии, задача которых - возиться в этом сборочном зоопарке вместо программиста, хотя, на самом деле, это не так уж нужно, не найди кто-то фатальный недостаток в предыдущем инструменте за большие деньги от нужного фонда.
Часть 3. Идём в браузер
Сейчас у нас есть нужные hosts и targets, но как заставить их работать в браузере? Что позволит нам так просто взять и собрать clang?
Можно было бы взять Emscripten или WASI SDK но… вспоминаем предыдущую часть - это усложнение тулчейна.
Вместо этого, сделаем шаг к упрощению системы. У нас есть glibc, есть MacOS libc из SDK, есть наша собственная libc на Windows, унаследованная от musl. Если мы добавим в эту кашу, скажем, bionic libc, или что-то от RTOS… Каша из ifdef в пользовательском коде станет неподдерживаемой. И причём здесь браузер? Следите за руками.
Umbrella libc
Вместо того, чтобы отдавать пользователю нашего SDK (теперь это уже логично так назвать) нативные заголовки платформы, мы можем просто взять и сделать свои. Заголовки стандартной библиотеки C - шутка стандартная, и много работы для сведения API к общему знаменателю не требуется. Зато из кода пользователя исчезают тысячи ifdef по платформам, потому, что теперь у вас все pdup, wcslen и прочие называются одинаково, и нет всем известного Microsoft shim в каждом проекте.
Послойная схема runtime (xenolith-engine/runtime)
┌─────────────────────────────────────────────────────────────────────┐
│ Пользовательский код (Xenolith / Stappler / приложение) │
└──────────────────────────────┬──────────────────────────────────────┘
│ #include <stdio.h>, <vector>, …
▼
┌─────────────────────────────────────────────────────────────────────┐
│ include/sprt/ — зонтичные заголовки (публичный API) │
│ ├── c/ __sprt_stdio.h, __sprt_string.h, … (C) │
│ ├── cxx/ algorithm, vector, string, … (C++) │
│ ├── compat/ мосты для не-POSIX платформ │
│ └── runtime/ внутренние объявления runtime │
└──────────────────────────────┬──────────────────────────────────────┘
│ #include_next → платформенные заголовки
▼
┌─────────────────────────────────────────────────────────────────────┐
│ include_libc/ — платформенные заголовки libc (linux/, darwin/, │
│ android/, sys/, arpa/, netinet/, cxx/) │
│ Подключаются ПОСЛЕ sprt через -idirafter: #include_next находит │
│ именно их, а не системные. │
└──────────────────────────────┬──────────────────────────────────────┘
│ вызовы функций
▼
┌─────────────────────────────────────────────────────────────────────┐
│ libc_wrapper/ — диспетчер: C → платформенная libc или musl, │
│ C++ → libc++, sys/ → syscall-обёртки │
└──────────┬──────────────────────────────────────────┬───────────────┘
│ │
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ libc_impl/ │ │ libcxx/ │
│ ├── mimalloc/ (аллокатор) │ │ Порт libc++ (C++ STL) │
│ ├── wasm_malloc/ │ │ + include_libc/cxx/ │
│ └── src/, asm/ │ │ (внутренние __-заголовки) │
└─────────────────────────────┘ └──────────────────────────────┘
│
│ (для wasm / windows — вместо системной libc)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ musl-libc/ + musl-adapters/ │
│ Полная C-библиотека (musl) + адаптеры под архитектуру/платформу. │
│ На Linux/macOS не используется — там вызовы идут в системную libc. │
└──────────────────────────────┬──────────────────────────────────────┘
│ syscalls / platform API
▼
┌─────────────────────────────────────────────────────────────────────┐
│ core/ — платформенное ядро (per-OS подкаталоги): │
│ linux/ darwin/ windows/ android/ wasm/ embox/ nuttx/ │
│ runtime_core_*.cpp: memory, pthread, time, dso, filepath, log… │
└──────────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ window/ — оконная система (per-OS): │
│ macos/ (.mm, AppKit) windows/ linux/ android/ ios/ wasm/ │
└──────────────────────────────┬──────────────────────────────────────┘
▼
ОС / ядро
Ключевые принципы:
Один
libsprt.a— всё выше (отinclude/sprtдоcore/) компилируется в один архив. Для wasm-хоста это и есть весь «host»: libc + libc++ + runtime в одном файле.#include_nextкак механизм зонтика. Рантайм через видит#include_nextвидит платформу, а пользовательский код видит только рантайм, но не видит платформу.musl где нет системной libc. На Linux и macOS вызовы идут напрямую в glibc/libSystem. На Windows и wasm — через musl.
Под капотом система направляет вызов снаружи в платформенную libc или реализует то, чего на платформе по какой-то причине нет. Работа по отслеживанию дыр специфическая, но посильная.
Бонусом - единый интерфейс для локалей (у glibc и BSD он разный, но сводимый, мы даём оба на обеих), возможность подтягивать функции из musl там, где их забыли реализовать, возможность отслеживать при отладке вызовы libc (особенно важно для MacOS).
Собирая всё в кучу, получаем прецедент - у нас уже есть собственная libc для Windows взамен UCRT. Так почему бы не взять и не написать такую же для WebAssembly.
WebAssembly
Теперь он объявлен как платформа, а, значит, для него должны быть свои host и target (wasm32-unknown-unknown). Сперва нужен target. Но если остальные target требовали готовой libc… Нет, стоп, Windows не требовал.
Первоначально, библиотеки и host для Windows строились из SDK, но это не дело, и в какой-то момент мы решили использовать нашу же libc в качестве основы для сборки. Заодно, тесты llvm довольно сильно помогли в ловле блох. В каких-то местах даже оказалось, что llvm может включить функции, которые Windows SDK включать не давал, и они даже будут работать (это касается специфики окна консоли, мы отдаём правильно реализованные Linux-аналоги, а llvm без них просто забивает на функцию). Проблемы создаёт libc++, но об этом позже.
Теперь мы знаем, что наши единые внешние заголовки вполне пригодны для сборки больших проектов вроде llvm. Значит, мы уже можем собирать target для своей, а не для внешней libc и нам не нужен никакой WASI SDK.
Реально нужна лишь обёртка на стороне браузера, которая реализует базовые функции для работы по протоколу WASI. А дальше, большая часть вопросов уже закрывается общей реализацией libc на основе musl, которую мы использовали для Windows.
После, мы собираем target, смотрим на результат в браузере и прикидываем, а может ли наша libc просто так взять и собрать clang для host. И выясняем, что, нет, не может, нужна libc++.
libc++
Вот, где начинаются подставы. Казалось бы, libc++ это часть llvm и есть везде. Но на MacOS, например, используется системная libc++, которая, благодаря странному устройству линкера, будет конфликтовать с собственной, если вы её соберёте.
Держать несколько активных версий libc++ это сложность, опять ifdef, от которых мы так настойчиво избавлялись, нет единобразия интерфейсов, нет контроля над кодом, и, кроме того, мы не можем просто взять и собрать libc++ так, чтобы это повторяло macOS SDK - мы пытались. поверьте.
На помощь нам приходит механизм ABI изоляции в libc++, который уже есть - нужно просто указать, в какой namespace отгружать ABI символы. Однако, в практическом тесте мы видим, что не все символы получают нужный namespace.
Другая беда - с нашей новой umbrella libc оно просто не заводится. libc++ это уже лапша из ifdef, которая предусматривает только варианты заранее известных libc, без возможности подключить generic систему с полными функциями. То, что есть, серьёзно урезает функциональность: теряются локали, потоки, исключения, а за ними и контейнеры.
Остаётся одно: форкнуть. Механизм форка можно выбрать проще, чем набор патчей: мы подменяем конфигурационные файлы через оверлей, заменяя блоки ifdef на то, что нужно для работы с нашей libc. Потом прогоняем сверху штатный набор тестов, и оно работает.
Теперь у нас есть достаточный набор, чтобы собрать clang.
Собрать clang
Мало просто собрать, нужно протестировать, а делать это в браузере крайне неудобно. Потому, можно взять и собрать под Windows. Почему под Windows? Потому, что там работает наша же libc, у которой 98% идентичны WASM-версии, и там есть нормальный дебаггер (кстати, он у нас пропатченый, чтобы можно было поднимать на Linux под wine, без забегов на Windows машину).
Работа предельно тупая и единообразная: собрать набор тестов, прогнать набор тестов, исправить ошибки, собрать набор тестов, прогнать набор тестов, исправить ошибки, собрать набор тестов, прогнать набор тестов, исправить ошибки, собрать набор тестов, прогнать набор тестов, исправить ошибки, собрать набор тестов, прогнать набор тестов, исправить ошибки… Ровно та работа, для которой придуманы нейросети, не правда ли? На 16 день итераций оно наконец сказало, что все тесты, которые мы можем пройти, пройдены. Потом то же самое сказало на 24 день. И на 31. И где-то на сороковой день это уже можно было признать за правду.
После того, как версия для Windows, наконец, завелась, запустить это же в браузере - дело техники.
Общая проблема одна: в браузере нет fork и концепции приложений как таковых. Идея придумана до нас - эмулируем запуск приложения как WebWorker и читаем его stdout как голые байты. Именно так вызывается драйвер компиляции.
Часть 4. А чем собирать?
И вот у нас есть clang и даже lld в браузере. Компилируй - не хочу. Но что компилировать, а, главное, как?
Нет ни cmake, ни meson, ни даже make. Даже если их собрать, нет pkgconfig, нет uname, нет mkdir, rm, ln, cp, mv…
Внезапно, до реальной работы проект разрастается в чудовище, непосильное малой команде, славься UNIX-way.
Или…
А что если взять достаточно простой GNU Make синтаксис и подменить в нём внешние вызовы всяких mkdir на виртуальные из нашей libc? На 100% это не заработает, но какую-то практическую нишу создаст.
К счастью (многовато в этом пути случайностей, не находите?), у нас уже был готовый парсер и инспектор формата, нужно только дописать его до исполнителя. Вместо сложной системы jobserver мы сделали простой однопоточный реактор, и уже это, предварительно проверив на настольных системах, запустили в браузер. fork нет, exec нет, но ведь это наш собственный реактор, потому мы логику подменяем как хотим.
Вся наша исходная сборка сделана на GNU Make изначально (помните, как мы говорили про разусложнение, вот оно и сыграло), и переезд на новый build driver мы протестировали попросту на себе.
Ещё пара недель и у нас уже не просто clang в браузере, а система, которая может собирать настольные приложения в браузере. Настольные приложения на нативном C++. В браузере.
Часть 5. Что показывать?
Итак, у нас своя libc и свой сборщик. Казалось бы, можно начать собирать софт, что уже в наличии. Э, нет, сказал современный подход к разработке. Все настолько привыкли к классическому разделению платформ через ifdef, что в большинстве случаев код для Windows просто не соберётся. И, скорее всего, будет вставлять палки в колёса, ведь автор сделал собственную обёртку, сводящую UCRT к нормальным libc-функциям. Эта обёртка попросту конфликтует с нашей библиотекой. А копировать поведение UCRT мало того, что противоречит исходной цели одинаковости, так ещё и юридически сомнительно (бонусом к остальным сомнениям - лучше их не накапливать).
К счастью, у нас как раз завалялся собственный 2D-движок (Совпадение? Думаю, да!). Он основан на Vulkan на всех платформах (ну, сейчас уже имеет бэкэнды для Metal, OpenGL и собственный растеризатор на CPU), и работает, как и всё остальное, единообразно. Кроме того, имеет WebGPU бэкэнд. И собирается из нашего же кода.
То есть, можно не просто собрать из браузера приложение под конечную платформу, но и работать с самим приложением. В браузере. С тем же самым. Как тебе такой подход к разработке, Илон Маск?
Прелесть плафтормы в том, что бэкэнд в ней выбирается динамически на многих уровнях. Это не QT, где вы выбираете почти всегда заранее. Здесь выбор категории графического API это опция командной строки, а выбор конкретной формы (например, XCB, Wayland или вообще прямое рисование в DRM) - эвристика. И всё это связано одной абстракцией сцены, которая в большинстве случаев о конечной форме соединения с окном не знает ничего. То есть, код, работающий для WebGPU, это тот же код, который будет работать для Vulkan на Linux. Или для прямого рисования на фреймбуфере в embox (но это уже совсем другая история).
Не хватает только интеграции отладчика. Мы, конечно, ушибленные, но не настолько. Или… ненененене, пока лучше остановиться здесь.
Часть 6. Зачем?
Как будто вы не знаете этот ответ. Книжка такая была. Just for fun.
Но практические применения всё же есть.
Во-первых, это гарантированно изолированная среда сборки. В ней просто нет и быть не может того, что в неё не завезли. И эта среда будет одинаковой на какой бы хостовой системе вы её не запустили. Контейнер без контейнера.
Во-вторых, это браузер, который может себе установить любой человек, без технических знаний. Скажем, менеджер среднего звена. Он знает, как управлять процессами, но понятия не имеет, как ему поставить тулчейн для сборки себе на комп. Здесь мы можем дать ему кнопки “собрать всё”, и он может ими воспользоваться.
В-третьих, полная локализация тулчейнов внутри организации без необходимости разворачивать весь стек CI. Да, медленнее. Но работает. Плюс скриптуемо на javascript до посинения. Можно сделать сколько угодно красивых и удобных кнопочек.
В-четвёртых, это фундамент для того, чтобы впихивать более интересные проекты в ту же оболочку. Сборка под платформы становится javascipt-вызовом, а не аццким набором CI-скриптов, без боли, подписок и ошибок API (пользуясь случаем передаю привет Github).
Ограничения
Безусловно, это WebAssembly. То есть, ниже скорость работы, выше издержки, раздувается память под браузер.
Максимум 2гб рабочей памяти компилятора (не всего процесса, отдельного вызова). Решается в wasm64, который почти готов на момент написания статьи.
Собранный host и targets нужно где-то хранить и откуда-то скачивать. Весит host от 70мб, target (один) - 7-30мб. Плюс сам движок/SDK.
Исполнять без браузера в принципе можно, то такой процесс не тестировался, да и зачем он, если есть нативный host.
Нейрослоп
Ну не может статья такого свойства обойтись без раздела про нейрорабов (определение раба - “говорящее орудие”, и нейросети слишком точно в него попадают, не находите?).
К фундаменту системы нейросети отношения не имеют, всё было тщательно выпилено ручным трудом. Где-то за годы, где-то чуть быстрее. Да и не способны они с нуля создать нечто подобное, не переоценивайте инструмент. Нужно много человеческой смекалки, чтобы найти правильные места для разрезов, позволивших это всё создать.
Нейросети, тем не менее, полезны в вопросе нанизывания на этот фундамент новых элементов при известных вводных. Репозиторий написан так, чтобы они имели быстрый доступ к курируемой базе знаний, где что искать, и при каких случаях куда смотреть. Многие задачи, вроде добавления нового виджета для UX, решаются нейронкой просто потому, что это - мартышкин труд для человека, мелкая механическая работа по композиции известных деталей. Незаменимы они и при тестировании: пока человек ест и спит, могут работать.
Однако же, за каждую строку кода своего раба отвечает хозяин, как и положено в рабовладельческом обществе. Потому, в целом, глухой и бессмысленный нейрослоп мы не приветствуем.
В проекте много механического кода, созданного нейросетями. Но это возможно лишь потому, что человеки изначально создали те механизмы, в которых рабы будут трудиться.
К сожалению, и это важно усвоить, современная правовая доктрина не может оперировать понятием “смысл”, для него нет формального определения. Потому, если вы создали описательный механизм, он содержит суть проекта, и его доля составляет 10% от всего кода, а 90% это генерация поверх механизма (Не боитесь, у нас цифры будут в пользу человека. Пока ещё.) - это код хозяина раба, а не ваш. Стоит задуматься над вопросом, а кто же вам сдал этого раба в аренду, кто его хозяин?
Где же это всё?
Playground: https://playground.xenolith.studio/
Код: https://github.com/XenolithEngine/xenolith-engine/tree/nightly
Сайт: https://engine.xenolith.studio
Телеграм: https://t.me/StapplerSDKBlog
Дальше
Вопросы, пожелания, темы для следующих статей - почитаем, посмотрим, может быть, ответим и напишем.
Всё добро сделано безвозмездно, ни один корпоративный рубль не пострадал. Проще говоря - подайте на пропитание.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.