Что не так с 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()
В итоге для всех упомянутых версий 15.2.0, 13.1.0, 10.3.0, 4.9.2 получаем стабильные 3,50 КБ (3 584 байт)
«Не плати за то, что не используешь»
Тот самый золотой принцип о котором говорят на каждом углу оказывается не так уж и универсален, и нередко бывает так что компилятор за вас решает, что запихнуть в бинарник «на всякий случай» или же из‑за неэффективной линковки зависимостей.
Выходит чем меньше мы доверяем свои функции на откуп реализаций компилятора, тем стабильнее будет вес наших исполняемых файлов при обновлении компилятора. Или же проще использовать старый добрый 3.4.2? :)
Комментарии (3)

feelamee
08.05.2026 20:41«Не плати за то, что не используешь»
очевидно вы платите за удобство и функционал, который предоставляет . Было бы интереснее посмотреть на проектирование и реализацию всего что позволяет . И сравнение по итогу.
А так… тут напрашивается хотя бы сравнение с {fmt} (в официальном readme есть пара бенчмарков)

AndreyDmitriev
08.05.2026 20:41Отчего ж вы не заглянули внутрь исполняемых файлов и детально не разобрались, откуда берутся эти самые (мега)байты? Там же относительно несложно всё. Очевидно, что для того, чтобы вывести что-то на экран, таки придётся позвать из ядра GetStdHandle, WriteConsole, и так далее до выхода через ExitProcess.
Вот если на асме:
EUROASM hello PROGRAM Format=PE, Entry=Start, IconFile= INCLUDE winapi.htm Start: nop StdOutput =B"Hello, Habr" TerminateProgram ENDPROGRAMИ StdOutput и TerminateProgram тут макросы, и если всё дизассемблировать, то будет 80 строк на ассебмлере, вот как на самом деле выглядит вывод в консоль:
Привет, Хабр!
401000 public start 401000 start proc near 401000 nop 401001 push 0 401003 push offset aHelloHabr ; "Hello, Habr" 401008 push 0FFFFFFFFh 40100A push 0FFFFFFF5h 40100C call sub_401018 401011 push 0 401013 call ExitProcess 401013 start endp 401013 401018 ; =============== S U B R O U T I N E ==================== 401018 sub_401018 proc near ; CODE XREF: start 401018 var_28 = dword ptr -28h 401018 401018 pusha 401019 mov ebp, esp 40101B sub esp, 8 40101E mov [esp+28h+var_28], esp 401021 push dword ptr [ebp+24h] 401024 call GetStdHandle 401029 mov ebx, eax 40102B inc eax 40102C stc 40102D jz short loc_401099 40102F mov ecx, [ebp+28h] 401032 mov edi, [ebp+2Ch] 401035 mov eax, [ebp+30h] 401038 mov edx, offset WriteFile 40103D test al, 2 40103F jz short loc_40104F 401041 mov edx, offset WriteConsoleA 401046 test al, 1 401048 jz short loc_40104F 40104A mov edx, offset WriteConsoleW 40104F loc_40104F: 40104F test al, 4 401051 jz short loc_401065 401053 lea edi, [ebp-4] 401056 mov dword ptr [edi], 0A000Dh 40105C test al, 1 40105E jnz short loc_401065 401060 mov word ptr [edi], 0A0Dh 401065 loc_401065: 401065 xor eax, eax 401067 mov esi, edi 401069 test byte ptr [ebp+30h], 1 40106D jz short loc_40107A 40106F shr ecx, 1 401071 repne scasw 401074 jnz short loc_40107F 401076 dec edi 401077 dec edi 401078 jmp short loc_40107F 40107A loc_40107A: 40107A repne scasb 40107C jnz short loc_40107F 40107E dec edi 40107F loc_40107F: 40107F sub edi, esi 401081 mov eax, [ebp+30h] 401084 and al, 3 401086 xor al, 3 401088 jnz short loc_40108C 40108A shr edi, 1 40108C loc_40108C: 40108C push 0 40108E push dword ptr [ebp-8] 401091 push edi 401092 push esi 401093 push ebx 401094 call edx ; WriteFile 401096 cmp [ebp-8], edi 401099 loc_401099: 401099 mov esp, ebp 40109B popa 40109C retn 10h 40109C sub_401018 endpЗанимает этот исполняемый файл 2596 байт, и там на самом деле не только start().
Ну а дальше берётся любой анализатор заголовка и смотрится куда утекают оставшиеся байты (80 строк на ассемблере обычно не занимают два килобайта):

На более высокоуровневом языке конечно будет пожирнее, если на Расте, то 124898 байт. Но и этим килобайтам есть рациональное объяснение, само собой, там уже обработка исключений и много чего до кучи:

IDA
diakin
БумагаЖелезо все стерпит!