Что не так с 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? :)

Комментарии (13)


  1. diakin
    08.05.2026 20:41

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


    1. santer_koder
      08.05.2026 20:41

      Память нынче не та. И это не временный тренд, это с нами надолго


      1. Vlad441 Автор
        08.05.2026 20:41

        Это же касается только RAM, кризис HDD пока еще не наступил, так что экономия места там мало кого волнует.


        1. edik-petrof
          08.05.2026 20:41

          Это если смотреть в разрезе LLM и прочих нейросеток. А вот криптовалюта Chia и ~полдесятка других Proof-of-Space токенов с этим утверждением явно не согласны.


  1. feelamee
    08.05.2026 20:41

    «Не плати за то, что не используешь»

    очевидно вы платите за удобство и функционал, который предоставляет . Было бы интереснее посмотреть на проектирование и реализацию всего что позволяет . И сравнение по итогу.

    А так… тут напрашивается хотя бы сравнение с {fmt} (в официальном readme есть пара бенчмарков)


  1. 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
    IDA


    1. Vlad441 Автор
      08.05.2026 20:41

      Я пытался сделать обзор проблемы именно с точки зрения разработчика С++, а не как ревер-инженер на ASM. Целью этого мини-исследования было именно практически добиться минимально возможного размера компилятором С++ (в данном случае GCC) и показать развитие тенденции вместе с обновлением компилятора, конкретное содержание PE заголовков для этого мне показалось излишним. Хотя это вполне имеет место быть, и спасибо вам за дополнение!

      Проблема в том, что более высокоуровневые языки почему-то не выкидывают лишнее в очевидных ситуациях, когда исключения попросту не требуются. Например компилятор GCC догадывается не прикручивать исключения для Hello Worldно как только в коде появляется malloc()/free() компилятор уже может их включить, и тут уже нужно явно указывать флаг что стоит отключить добавление механизма исключений.

      В вашем примере для того чтобы добиться размера меньше, вам уже пришлось использовать ASM, что уже не совсем разработка на С++ :)

      Компилятор по идее для того и нужен чтобы упростить жизнь разработчику и не заставлять его писать на ASM из-за того что компилятор не справился со своей задачей лучше программиста.


      1. AndreyDmitriev
        08.05.2026 20:41

        Компилятор по идее для того и нужен чтобы упростить жизнь разработчику и не заставлять его писать на ASM из-за того что компилятор не справился 

        Тут я согласен по части "упростить", но не очень по части "не справился", ведь прогресс тоже на месте не стоит, и количество зависимостей растёт, и аккуратно избавиться от них или уменьшить бывает непросто, да и к чему, ведь количество памяти тоже? Я начинал программировать больше тридцати лет назад на ДВК, у меня было 56 килобайт оперативной памяти (и, кстати, там была RT11FB - это Foreground/Background операционка, я запускал там две задачи для дифрактометра - одна, на ассемблере Macro-11 снимала данные, а на второй (на Си) оператор мог прошлые обсчитывать, всё параллельно), и надо было программировать "экономно" ценой долгих вечеров в лаборатории. Но эти времена прошли, хотя, конечно есть "перегибы". Даже если взять Раст, у которого с кодогенерацией всё более-менее норм, то текстовый редактор Zed, на нём написанный, тянет за собой под две тысячи зависимостей, в число которых входят Фурье, MP3 и даже Flac, а исполняемый файл занимает отнюдь не два, а триста пятьдесят мегабайт, при этом довольно резво запускается (чуть быстрее чем VS Code), так как интерфейс там не на электроне, а собственный gpui. Обычно при выпуске продукта конечно имеет смысл оценить используемые ресурсы с точки зрения дискового пространства, занимаемой памяти и общей производительности, но некоторые оптимизации могут довольно "дорого" стоить. А компиляторы современные неплохие, особенно интеловский на своих процессорах показывает очень неплохую производительность (кстати, там объём исполняемого файла и библиотек также растёт за счёт раздельного кода под раздельные архитектуры типа AVX2/AVX512 и это норм). Оптимизировать надо "бутылочные горлышки", при этом объём кода может даже вырасти при том же разворачивании циклов, и современные компиляторы с этим справляются.


  1. Gapon65
    08.05.2026 20:41

    [ Я совершено не оспариваю неявное утверждение о том, что iostream в C++ это полнейшее "гуано". ]

    Вопрос лишь - в чем, собственно, смысл делать сборку без динамических/разделяемых библиотек (флаг -static который при сборке передается в ld)? Если Вы строите бинарник для встроенных приложений (микроконтроллеров, систем реального времени) то C++ Standard Library вам точно не нужна поскольку эта библиотека (по умолчанию, если Вы не используете специальные аллокаторы) динамически аллокирует/освобождает память. Во всех осталных случаях удобнее использовать разделяемые библиотеки.

    Если уберете этот флаг и скомпилируете:

    g++ -O2 hello.cpp -o hello.exe

    То результат будет совсем иной:

    ls -al 
    total 39
    drwxr-xr-x 2 xxx yyy     4 May  8 15:23 .
    drwxr-xr-x 8 xxx yyy     8 May  8 15:05 ..
    -rw-r--r-- 1 xxx yyy    75 May  8 15:05 hello.cpp
    -rwxr-xr-x 1 xxx yyy 18192 May  8 15:23 hello.exe
    
    ldd hello.exe 
    	linux-vdso.so.1 (0x00007ffed21c3000)
    	libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f4b17c00000)
    	libm.so.6 => /lib64/libm.so.6 (0x00007f4b17b25000)
    	libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f4b17eca000)
    	libc.so.6 => /lib64/libc.so.6 (0x00007f4b17800000)
    	/lib64/ld-linux-x86-64.so.2 (0x00007f4b17ef0000)
    
    nm hello.exe
    0000000000403de0 d _DYNAMIC
    0000000000404000 d _GLOBAL_OFFSET_TABLE_
    0000000000401140 t _GLOBAL__sub_I_main
    0000000000402000 R _IO_stdin_used
                     w _ITM_deregisterTMCloneTable
                     w _ITM_registerTMCloneTable
                     U _ZNKSt5ctypeIcE13_M_widen_initEv@GLIBCXX_3.4.11
    0000000000401260 W _ZNKSt5ctypeIcE8do_widenEc
                     U _ZNSo3putEc@GLIBCXX_3.4
                     U _ZNSo5flushEv@GLIBCXX_3.4
                     U _ZNSt8ios_base4InitC1Ev@GLIBCXX_3.4
                     U _ZNSt8ios_base4InitD1Ev@GLIBCXX_3.4
                     U _ZSt16__throw_bad_castv@GLIBCXX_3.4
    0000000000404080 B _ZSt4cout@GLIBCXX_3.4
    0000000000404191 b _ZStL8__ioinit
                     U _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@GLIBCXX_3.4
    0000000000402118 r __FRAME_END__
    000000000040201c r __GNU_EH_FRAME_HDR
    0000000000404060 D __TMC_END__
    000000000040037c r __abi_tag
    000000000040405c B __bss_start
                     U __cxa_atexit@GLIBC_2.2.5
    0000000000404058 D __data_start
    0000000000401220 t __do_global_dtors_aux
    0000000000403dd8 d __do_global_dtors_aux_fini_array_entry
    0000000000402008 R __dso_handle
    0000000000403dc8 d __frame_dummy_init_array_entry
                     w __gmon_start__
                     U __libc_start_main@GLIBC_2.34
    00000000004011a0 T _dl_relocate_static_pie
    000000000040405c D _edata
    0000000000404198 B _end
    0000000000401264 T _fini
    0000000000401000 T _init
    0000000000401170 T _start
    0000000000404190 b completed.0
    0000000000404058 W data_start
    00000000004011b0 t deregister_tm_clones
    0000000000401250 t frame_dummy
    00000000004010b0 T main
    00000000004011e0 t register_tm_clones
    


    1. Vlad441 Автор
      08.05.2026 20:41

      Как только вы начинаете полагаться на то что libstdc++ будет предустановлена в системе, есть риск, что на другом компьютере вы можете столкнуться c

      Запуск программы невозможен, так как на компьютере отсутствует libstdc++.dll

      Или какой-либо другой внешней библиотеки на которую вы полагаетесь. Это уменьшает переносимость и вынуждает качать внешние зависимости.

      ldd hello.exe 
        linux-vdso.so.1 (0x00007ffed21c3000)
        libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f4b17c00000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f4b17b25000)
        libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f4b17eca000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f4b17800000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f4b17ef0000)

      В вашем примере вы полагаетесь на то, что в системе будут и libstdc++ и libc и libm и еще от компилятора libgcc_s.

      Если хоть чего-то из этого не будет, или же будет не той версии которой нужно (привет от GLIBC) - ваш файл превращается в тыкву. Он не запуститься в среде с musl или же где нет компилятора с его libgcc_s.so.


      1. DustCn
        08.05.2026 20:41

        Ну так на другой системе будет компилятор. Вы перенесете на неё исходники, мейкфайл, и все. А если захочется "по серьезному" - делаете инсталлятор.
        Если для портабильности все будут носить с собой полные библиотеки, то зачем тогда придумывали .so/dll?


  1. Sap_ru
    08.05.2026 20:41

    Во-первых и прежде всего, вы непонятно чего хотите добиться.
    Во-вторых, есть ощущение, что вы это делаете неправильно.

    Вы зачем-то смело прыгнули в "nostdlib", но при этом не используете оптимизацию линкера!
    Никогда ни в коем случае не нужно на десктопе использовать "nostdlib" и писать свои CRT. Там всё намного-намного сложнее, чем кажется на первый взгляд. Вы совершенно точно что-то неуловимо поломаете и потом месяцами будете ошибки ловить.
    Вы уверены, что ваш куцый CRT корректно отрабатывает работу с исколючениями и все имеющиеся в stdlib способы инициализации/деинициализации?
    А математика вся во всех случаях будет работать? Точно-точно? А где тогда тонкая настройка компилятора?
    У вас же в такой конфигурации масса всего работать не будет (или что хуже - будет работать некорректно) - от многопоточности, до особенностей работы с плавающей точкой и выделением памяти!
    И, кстати, почему для начала не отключили исключения в настройках компилятора? Это сразу бы уменьшило размер файла, без вот этого всего. Всё равно вы практически наверняка поломали их, выкинув стандартный startup.

    И ещё раз: почему вы даже не посмотрели в стону оптимизации линкером, которая во многом и была придумана, чтобы решить вашу "проблему": lto и тому подобное?

    Я три раза портировал stdlib и crt на новые платформы, много раз писал на C++ под "голое железо" и поэтому повтрю: совершенно абсолютно никогда так не делайте на десктопе!!! Такие оптимизации требуют глубочайшего знания особенностей реализации crt и stdlib на конкретной платформе, компиляторе и линкере! На десктопе это применяется только для загрузчиков исполняемых файлов и ядра ОС, в который масса сил тратится на обеспечение совместимости с кокретными версиями компиляторов, линкеров и платформ!

    И даже на микроконтроллерах я видел только две "любительские" (не поддержанные разработчиками контроллеров, компилятора или какого-то очень большого проекта) корректные реализации ctr. Подавляющее большинство реализаций содержат грубые ошибки, который авторы не видят только потому, что ещё не использовали в коде поломанные ими функции.


    1. Vlad441 Автор
      08.05.2026 20:41

      Я упоминал, что:

      И, собственно всё. Иные флаги оптимизаций не влияют на размер при текущей кодовой базе.

      То есть ни -flto/-flto=thin, ни -fno-exceptions ни даже специальный флаг GCC -fwhole-program не помогли выкинуть лишнее в случае включения iostream. Видимо линкер считает все его зависимости используемыми.

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

      Оптимизации линкера очень хороши, но они бессильны если из конкретной сборки стандартной либы физически выкидывать нечего.

      Что на счет отказа от CRT - я это использовал в качестве сравнительного примера именно для Hello World и только под Windows.

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

      Я даже не стал приводить приводить примеров самопального CRT для Linux, т.к. там уже понадобились бы прямые ассемблерные вставки, что уже требует специализаций под конкретную архитектуру, а там уже есть чему ломаться. У части пользователей это могло бы просто не запуститься или что еще хуже выполнить это некорректно. Тут рациональнее использовать статическую линковку с musl заместо glibc.