What's wrong with Hello World?
It would seem that modern C++ offers so many possibilities... Let's try to start comprehending all this immense power by writing Hello World:
#include <iostream> int main(){ std::cout << "Hello World" << std::endl; }
What's the latest compiler... Let's take some GCC 15.2.0, run the compilation g++ -static -O2 hello.cpp -o hello.exe and... we got 2,30 МБ.
What went wrong? Why did it take bloating the binary to >2МБ to display 11 characters? Let's figure it out.
Compiler flags?
What can affect the size of a binary? The first thing that comes to mind is compiler flags. Let's try adding flags one by one:
-s-(-~1,25 МБ)-> Size:1,05 МБ
Explanation: The flag
-sremoves debugging information from the executable file.
And that's basically it. Other optimization flags do not affect the size with the current codebase.
It's interesting that for a normal compilation, even Hello World the compiler for some reason shoves information by default that is not needed to output 11 characters.
What if?
What do we have in our codebase? #include <iostream> aha, iostream. Hmm, what if we replace it with printf?
#include <stdio.h> int main(){ printf("Hello World"); }
And we get... 42,5 КБ — a reduction of another ~1 МБ. What is it about this iostream that it asks for 1 megabyte of your binary? In fact, it pulls in the initialization of a whole chain of dependencies, starting with the global std::cout -> std::stringstream and ending with locales, virtual functions, and templates, all for the sake of 11 characters. For the standard library of a language built around efficiency, it seems a bit excessive.
Maybe a specific compiler version is to blame? Well, let's try to build with different versions of 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 КБ
The trend clearly shows that the higher the compiler version, the fatter the dependencies pulled in by iostream. What if we also go through the versions for 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 КБ
It's curious that here version 10.3.0 - breaks the trend. Perhaps there are differences in the ports between TDM and MinGW-W64.
It turns out that Hello World can weigh just 5,50 КБ if you choose the right compiler version.
Changing tactics
How can we get closer to this result on more modern compilers? If even printf can pull in extra stuff, what is the most direct way to output? For Windows we can try using the system call WriteFile to write to 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"); }
The comparison table for WriteFile will now look like this:
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 КБ
It's curious that the trend here seems to be non-linear compared to printf: While for new versions the difference is several times (42,5/14,0=303%), for older versions it is closer to the margin of error (15,5/11,5=34,7%), and for 3.4.2 the size is identical.


How to squeeze the most out of it?
What if you want even smaller? The current ~11-15 кб is the minimum you can achieve with the standard compiler runtime. If we want our Hello World to weigh even less, we'll have to manually initialize the 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"); }
To compile without the standard CRT you need to use the following flags:
g++ -static -s -O2 hello.cpp -o hello.exe -nostdlib -Wl,--entry=_start -lkernel32
Explanation: The flag
-nostdlibcombines the actions:-nostartfiles(disables initialization ofCRT)-nodefaultlibs(do not link libraries automatically)
-Wl,--entry=_start: a linker option that explicitly describes our entry point_start()
The example of initializing
CRTis for informational purposes only.
The path of writing a home-brewedCRTfor anything that goes even slightly beyondHello World- is extremely thorny and full of surprises. Only dare to do it if you want hell to seem like paradise.
As a result, for all the mentioned versions 15.2.0, 13.1.0, 10.3.0, 4.9.2 we get a stable 3,50 КБ (3 584 байт)
"Don't pay for what you don't use"
That golden principle they talk about on every corner turns out to be not so universal, and it often happens that the compiler decides for you what to stuff into the binary "just in case" or due to inefficient dependency linking.
It turns out that the less we entrust our functions to the compiler's implementations, the more stable the size of our executables will be when the compiler is updated. Or is it just easier to use good old 3.4.2? :)