Что не так с Hello World?
Казалось бы, современный С++ дает столько возможностей… Давайте попробуем начать постигать всю эту необъятную мощь с написания Hello World:
#include <iostream> int main(){ std::cout << "Hello World" << std::endl; }
Какой там сейчас последний компилятор… Давайте возьмем какой-нибудь GCC 15.2.0, запускаем компиляцию g++ -static -O2 hello.cpp -o hello.exe и… получили 2,30 МБ.
Что же пошло не так? Почему для отображения 11 символов понадобилось раздуть бинарник до >2МБ? Давайте разбираться.
Флаги компилятора?
Что же может влиять на размер бинарника? Первым на ум приходят флаги компилятора, давайте попробуем поочередно добавлять флаги:
-s-(-~1,25 МБ)-> Размер:1,05 МБ
Пояснение: Флаг
-sудаляет отладочную информацию из исполняемого файла.
И, собственно всё. Иные флаги оптимизаций не влияют на размер при текущей кодовой базе.
Интересно выходит, что для обычной компиляции даже Hello World компилятор почему-то пихает по умолчанию информацию, которая не нужна для вывода 11 символов.
А что, если?
А что там у нас в кодовой базе? #include <iostream> ага, iostream. Хм, а что если заменить на printf?
#include <stdio.h> int main(){ printf("Hello World"); }
И получаем… 42,5 КБ — уменьшение еще на ~1 МБ. Что же в этом iostream такого, что он просит 1 метр вашего бинарника? А по факту он тянет за собой инициализацию целой цепочки зависимостей, начиная с глобального std::cout -> std::stringstream и заканчивая локалями, виртуальными функциями и шаблонами, и всё это ради 11 символов. Для стандартной библиотеки языка, который строится вокруг эффективности как-то избыточно выходит.
Может конкретная версия компилятора виновата? Что ж, попробуем собрать на разных версиях GCC:
15.2.0:1,05 МБ13.1.0:1,03 МБ11.2.0:1,00 МБ10.3.0:930 КБ4.9.2:577 КБ3.4.2:260 КБ
Тенденция явно показывает, что чем выше версия компилятора, тем жирнее зависимости которые тянет iostream. Если также пройтись по версиям уже для printf?
15.2.0:42,5 КБ13.1.0:41,5 КБ10.3.0:86,0 КБ4.9.2:15,5 КБ3.4.2:5,50 КБ
Любопытно что тут версия 10.3.0 - выбивается из тенденции. Возможно есть отличия в портах между TDM и MinGW-W64.
Выходит, что Hello World может весить всего 5,50 КБ при выборе правильной версии компилятора.
Меняем тактику
Как же мы можем приблизиться к этому результату на более современных компиляторах? Если даже printf может тянуть лишнее, какой самый что ни на есть прямой способ вывода? Для Windows можем попробовать использовать системный вызов WriteFile для записи в stdout:
#include <string.h> #include <windows.h> void print(const char* cptr, DWORD len){ WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), cptr, (DWORD)len, NULL, NULL); } void print(const char* cstr){ print(cstr, strlen(cstr)); } int main(){ print("Hello World\n"); }
Сравнительная таблица для WriteFile будет уже такой:
15.2.0:14,0 КБ13.1.0:14,5 КБ10.3.0:14,0 КБ4.9.2:11,5 КБ3.4.2:5,50 КБ
Любопытно, что тенденция тут будто бы нелинейная по сравнению с printf: Если для новых версий отличие в несколько раз (42,5/14,0=303%), то для старых версий она уже ближе к району погрешности (15,5/11,5=34,7%), а для 3.4.2 размер вовсе идентичен.


Как выжать максимум?
Что если хочется еще меньше? Текущие ~11-15 кб это минимум чего можно добиться со стандартным рантаймом компилятора. Если мы хотим чтобы наш Hello World весил еще меньше, придется уже вручную инициализировать CRT:
// ====== Minimal CRT ====== #define NULL 0 #define STD_OUTPUT_HANDLE ((unsigned long)-11) int main(); typedef unsigned long long size_t; typedef unsigned int DWORD; extern "C" __declspec(dllimport) void __stdcall ExitProcess(DWORD uExitCode) __attribute__((noreturn)); extern "C" __declspec(dllimport) int __stdcall WriteFile(void* hFile, const void* lpBuffer, DWORD nNumberOfBytesToWrite, DWORD* lpNumberOfBytesWritten, void* lpOverlapped ); extern "C" __declspec(dllimport) void* __stdcall GetStdHandle(unsigned long nStdHandle); extern "C" void _start(){ ExitProcess(main()); } //Наша точка входа: только для 64 битной архитектуры extern "C" void __main(){} //Заглушка для компилятора // ====== User-Space Code ====== extern "C" size_t strlen(const char* c){ size_t len=0; while(*c!='\0'){ len++; c++; } return len; } void print(const char *cptr, size_t len){ WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), cptr, (DWORD)len, NULL, NULL); } void print(const char *cstr){ print(cstr, strlen(cstr)); } int main(){ print("Hello, min-CRT\n"); }
Для компиляции без стандартного CRT нужно использовать следующие флаги:
g++ -static -s -O2 hello.cpp -o hello.exe -nostdlib -Wl,--entry=_start -lkernel32
Пояснение: Флаг
-nostdlibобъединяет действия:-nostartfiles(отключает инициализациюCRT)-nodefaultlibs(не линковать либы автоматически)
-Wl,--entry=_start: опция линковщика, явно описывающая нашу точку входа_start()
Пример инициализации
CRTнесет ознакомительный характер.
Путь написания самопальногоCRTдля чего угодно, что хоть сколечки выходит за рамкиHello World- крайне тернист и полон сюрпризов. Дерзайте только в случае, если вы хотите чтобы ад вам показался раем.
В итоге для всех упомянутых версий 15.2.0, 13.1.0, 10.3.0, 4.9.2 получаем стабильные 3,50 КБ (3 584 байт)
«Не плати за то, что не используешь»
Тот самый золотой принцип о котором говорят на каждом углу оказывается не так уж и универсален, и нередко бывает так что компилятор за вас решает, что запихнуть в бинарник «на всякий случай» или же из‑за неэффективной линковки зависимостей.
Выходит чем меньше мы доверяем свои функции на откуп реализаций компилятора, тем стабильнее будет вес наших исполняемых файлов при обновлении компилятора. Или же проще использовать старый добрый 3.4.2? :)
