ncpp: Как создать самодостаточную экосистему на С++98 в 2026 году, которая запустится даже на железе со свалки

от автора

Современный С++ стал значительно удобнее по сравнению с прошлыми стандартами. STL и стандартная библиотека существенно облегчают жизнь программиста. С каждой новой версией стандарта добавляют что-то новое, что помогает упростить (или усложнить) очередной аспект разработки.

Однако со временем при разработке на С++ начинают появляться вопросики:

  • Везде ли скомпилируется ли моя программа?

    • Поддерживается ли текущая версия компилятора для моей ОС?

    • Поддерживается ли текущий стандарт в моей версии компилятора?

  • Везде ли запуститься моя программа?

    • Какие зависимости тянет моя программа?

      • Есть ли эти зависимости в минимальной комплектации моей ОС?

      • Или может проще все статически намертво слинковать?

  • На сколько эффективны стандартные реализации?

    • На сколько эффективно они линкуются?

    • Какой будет итоговый размер исполняемого файла?

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

Выбор стандарта и окружения

Как же можно запуститься на максимально возможном количестве конфигураций металлолома, сохраняя минимальный комфорт который дает C++?

Самый ранний из доступных стандартов — C++98, под него больше всего поддерживаемых компиляторов (а следовательно и архитектур и ОС для них) было выпущено.

От каких минимальных версий ОС стоит отталкиваться? В ходе изучения доступных системных вызовов на разных версиях ОС, системных вызовов из Win XP в большинстве своем достаточно для комфортной работы и на Win 10+, а некоторые улучшения в новых версиях можно уже добавить в ходе Progressive Enhancement.

Что на счет Linux, версия ядра 2.6 уже содержит большинство вызовов, которые используются сегодня, например epoll(), pthread_* и прочие.

По компиляторам я пришел к выводу, что более-менее возможно поддерживать компиляторы начиная с gcc 3.4 (с натяжкой при Graceful Degradation), а более полноценно поддерживать начиная с gcc ~4.9, собирая тесты на этой версии, а далее уже следовать Progressive Enhancement. Так как это минимальная версия, в которые включены минимально необходимые определения системных вызовов для написания всех модулей проекта.

Примечание: Подразумевается, что вы сможете одновременно и скомпилировать, и запустить на целевой ОС. Я знаю что вы умеете С++23 запускать и на Win95, но собрать C++23 на Win95, если у вас на руках только металлолом без доступа к современной системе, у вас вряд-ли получится. (Или у кого-то таки получилось?)

Проект нацелен на то, что он должен собраться, даже если ваш металлолом это последний в мире ЭВМ.

Отказ от STL

Как я упоминал в прошлой статье, STL — непозволительная роскошь. Чтобы не зависеть от того, как реализован std::string/std::vector/etc в libstdc++, были написаны свои варианты реализаций ncpp::Array/ncpp::String/etc, которые содержат STL подобный интерфейс.

В стандартных контейнерах реализован метод .stack(), чтобы вы могли передать под управление заранее выделенную память и не дергать лишний раз malloc()/free().

В STL принято использовать snake_case, проект же предлагает использовать CamelCase для более удобного восприятия кода, где классы в конце-концов именуются с большой буквы.

Unity Build

Для упрощения и ускорения сборки используется Unity Build стиль, где собирается единая единица трансляции (мастер-файл) из .cpp файлов в необходимой последовательности, что может помочь с оптимизациями, однако может потреблять больше памяти в сравнении классической модульной сборкой.

Как я бодался с линковщиком?

Если взять простейший пример Hello World:

#include "src/ncpp.cpp"using namespace ncpp;int main(){ print("Hello World\n"); }

И скомпилировать его на gcc 4.9.2 просто с флагами g++ -O2 -std=c++98 -static hello.cpp -o hello.exe -lws2_32 -lpsapi мы получим… 702 КБ. Непорядок. Просчитался, но где?

Что не пошло не так с линковкой?

По умолчанию линковщик рассчитан на модульную структуру, где должен включать только те объектные файлы (.o) в которых находятся необходимые символы (функции) для включения в бинарник. Однако для линковщика .o является минимальной включаемой единицей трансляции, и если из него нужен даже один символ, он включит весь .o целиком.

Если же импортируемые статические библиотеки неэффективно собраны, то линковщик может тянуть значительно больше мертвого кода. Для решения этой проблемы придумали LTO (Link Time Optimization).

Однако LTO в основном рассчитан именно на модульную сборку, где на этапе линковки он по идее должен более агрессивно выбирать только нужные символы, видя весь проект “целиком”.

У GCC есть свой специальный флаг -fwhole-program который лучше приспособлен для этой задачи: Он указывает, что текущая единица трансляция и есть вся программа, что позволяет оптимизировать еще агрессивнее. Сравнительная таблица применения флагов:

702 КБ -(без доп. флагов)
415 КБ -s
370 КБ -s -m32
302 КБ -fno-exceptions -fno-rtti -s -m32

Как мы видим, вырезание отладочной информации, архитектура и выпиливание исключений явно помогают уменьшить вес. Для Hello World это нам явно не нужно. Продолжаем навешивать флаги уже с позиции:

g++ -O2 -std=c++98 -static hello.cpp -o hello.exe -lws2_32 -lpsapi -fno-exceptions -fno-rtti -s -m32

302 КБ -fvisibility=hidden
303 КБ -ffunction-sections -fdata-sections -Wl,–gc-sections
138 КБ -flto
138 КБ -flto=thin
138 КБ -flto=thin -fvisibility=hidden -ffunction-sections -fdata-sections -Wl,–gc-sections
138 КБ -flto -fvisibility=hidden -Dexternally_visible=‘visibility(“default”)’
16 КБ -fwhole-program

Как мы видим, не все флаги оптимизации могут помогать в конкретных случаях. Что заметно, флаг -fwhole-program лидирует и может даже немного ускорить компиляцию. Однако в Clang посчитали что он не нужен и LTO справится лучше.

Как мы видим правильные флаги линковки помогают добиться 16 КБ размера исполняемого файла. Однако даже тут оптимизации не смогли вырезать все. Чтобы помочь линковщику, для сравнения я ввел в проект макрос NCPP_BASE_ONLY, который еще на уровне препроцессинга выпиливает большую часть лишнего.

Сравнительная таблица применения флагов с макросом NCPP_BASE_ONLY начиная с g++ -O2 -std=c++98 -static hello.cpp -o hello.exe:

405 КБ -(без доп. флагов)
215 КБ -s
153 КБ -s -m32
129 КБ -s -m32 -flto
129 КБ -s -m32 -flto=thin
16 КБ -s -fwhole-program
12 КБ -s -m32 -fwhole-program

В итоге для кода:

#define NCPP_BASE_ONLY#include "src/ncpp.cpp"using namespace ncpp;int main(){ print("Hello World\n"); }

При использовании таких флагов:

g++ -O2 -std=c++98 -fno-exceptions -fno-rtti -fwhole-program -static -s -m32 hello.cpp -o hello.exe

Удалось добиться размера в 12 кб — размер, сравнимый использованию системного вызова WriteFile. В итоге именно такой вариант параметров я и вшил по умолчанию в сборочный скрипт make.bat

Системщики скажут что это много — и вы будете правы. По оптимизации еще есть над чем работать.

Что с линковкой на Linux?

Тут ситуация усложняется, так как мы наталкиваемся на существенное препятствие: glibc. Его монолитная структура не позволяет линковщику выдернуть только нужное, и он тащится целиком.

  • При использовании iostream которая тянет libstdc++ на gcc 4.8.5 — вы получите 1.3 Mb.

  • И даже при использовании примера выше (без -m32) вы сможете ужаться лишь до 769 Kb.

Как же это можно решить? Отказаться от libc и написать свои syscalls на ASM? Это неоправданно, если только вы не хотите познать ад.

Наиболее компромиссное на текущий момент решение — статическая линковка с musl заместо glibc. В таком случае при использовании:

musl-gcc -O2 -std=c++98 -fno-exceptions -fno-rtti -fwhole-program -static -s hello.cpp -o hello

Вы сможете получить размер около 14 Kb. Этот вариант сборки также можно вызвать из сборочного скрипта make.sh MUSL

Что по зависимостям?

В идеале конечно цель добиться полного Zero Deps, и большая часть компонентов не требуют внешних зависимостей.

Однако с текущей архитектурой современных ОС, некоторые вещи просто невозможно написать без динамической линковки. Например GUI или работу с графикой OpenGL/Vulkan. Нельзя просто взять и вшить ту тонкую прослойку GLX/EGL/libGLdispatch.so чтобы ваш бинарник мог независимо обращаться к видеокарте и дергать из нее API. Ядро-то диспатчить не умеет, а нужных либ в системе может попросту не быть.

Хотя на Windows это работает хитрее. Даже при флаге -static на самом деле у вас все еще остается динамическая зависимость от opengl32.dll, которая содержит реализации OpenGL 1.1 и содержит необходимый wglGetProcAddress() чтобы подгрузить недостающее.

Для любителей ASM

Многие из вас крайне крутые специалисты, которые могут так заоптимизировать Hello World на ASM что оно будет легче планковского веса — я верю что вы это можете и что вы очень круты.

Однако ASM — совершенно непереносим, да и мы тут пытаемся писать именно на С++, пытаясь выжать все соки из доступного компилятора на доступной платформе.

Если же вы действительно готовы писать свой оптимизированный CRT с блэкджеком для большинства существующих архитектур — я был бы рад вашему вкладу в проект.

ссылка на оригинал статьи https://habr.com/ru/articles/1033350/