Writing a (valid) C program without main()
Table of contents
Start tutorial
Reset Progress<br>Are you sure you want to reset your progress for this tutorial? This action cannot be undone.<br>Cancel<br>Reset
close
Tutorial on Programming, Linux<br>Discussion Discord<br>cgccassembly
This tutorial was created by a community author. Community content is reviewed by the iximiuz Labs team on a best effort basis.
Tutorial on Programming, Linux Last updated: Jul 25, 2026
Writing a (valid) C program without main()
by Başar Subaşı
Walk through the C compilation pipeline: preprocessor, compiler, assembler, and linker. Start with a normal hello world, inspect macros and generated assembly, and end by producing a running binary that has no main() function.
Why C?<br>Most programming languages start execution from a main() function. Go has func main(), Java has public static void main(), and C has int main(). So why did I pick C for this tutorial?<br>C is the closest you can get to the operating system without writing assembly. The Linux kernel itself is written in C, and the system call interface that every program relies on is designed with C conventions in mind. When you learn how C programs are compiled, linked, and executed, you are learning how the Linux API actually works.<br>A typical C program starts with a main() function. But where does main() come from? The compiler? The linker? The operating system?<br>In this tutorial, you will walk through the entire C compilation pipeline. You will start with a normal hello world, watch the preprocessor expand a macro, watch the compiler turn C into assembly, and watch the assembler and linker turn assembly into a binary. Then you will remove main() from the picture and still produce a working executable.<br>The playground is a vanilla Ubuntu 24.04 machine with gcc, as, ld, and friends already installed.<br>Step 1: Hello World<br>Create a file named hello.c with the following content:<br>#include
int main(void) {<br>printf("Hello, world!\n");<br>return 0;<br>Copy to clipboard<br>Compile it and run it:<br>gcc hello.c -o hello<br>./hello<br>Copy to clipboard<br>You should see the familiar greeting. Now ask yourself: how many separate tools ran when you typed that single gcc command?<br>Behind the scenes, gcc is a driver that runs several tools in sequence:<br>Preprocessor (cpp) - handles #include, #define, and conditional compilation.<br>Compiler (cc1) - turns C source into assembly.<br>Assembler (as) - turns assembly into an object file.<br>Linker (collect2 / ld) - links object files and libraries into the final executable.<br>The rest of the tutorial visits each of these stages.<br>The four stages of the C compilation pipeline.
Step 2: The preprocessor<br>The preprocessor is a text processor. It does not understand C types or control flow; it only expands directives and macros. The -E flag tells gcc to stop after preprocessing.<br>Add a macro to hello.c so it looks like this:<br>#include
#define GREETING "Hello, preprocessed world!\n"
int main(void) {<br>printf("%s", GREETING);<br>return 0;<br>Copy to clipboard<br>Run the preprocessor and read the expanded output:<br>gcc -E hello.c -o hello.i<br>Copy to clipboard<br>Open hello.i and try to find the line where GREETING used to be. What happened to it? And why is the file so much larger than the original source?
Step 3: From C to assembly<br>The compiler turns C into assembly. The -S flag tells gcc to stop after that stage.<br>gcc -S hello.c -o hello.s<br>Copy to clipboard<br>Open hello.s and look at the function labeled main. The exact instructions depend on the architecture, but on x86_64 you will see something like a function prologue, a call to printf, and a return.<br>To see how optimization changes the output, create a file named loop.c with this function:<br>int sum(int n) {<br>int s = 0;<br>for (int i = 0; i n; i++) {<br>s += i;<br>return s;<br>Copy to clipboard<br>Generate three versions of the assembly:<br>gcc -O0 -S loop.c -o loop-O0.s<br>gcc -O2 -S loop.c -o loop-O2.s<br>gcc -Os -S loop.c -o loop-Os.s<br>Copy to clipboard<br>Compare the files. With -O0, the compiler is literal: it keeps the loop, the counter, and the addition exactly as written. With -O2, the optimizer may unroll the loop, vectorize it, or even replace it with the closed-form formula n * (n - 1) / 2. With -Os, the optimizer tries to keep the code small while still being fast.<br>Optimization is not magic. It is the compiler applying a set of transformations to the intermediate representation of your program. The more information the compiler has, the more aggressively it can optimize.
Step 4: From assembly to object, from object to binary<br>The assembler turns the .s file into an object file, and the linker turns object files into an executable. You can drive these steps manually.<br>Assemble the hello-world assembly:<br>gcc -c hello.c -o hello.o<br>Copy to clipboard<br>The -c flag tells gcc to stop after assembling. Inspect the object file:<br>nm hello.o<br>objdump -d hello.o<br>Copy to clipboard<br>You will see the symbols main, printf, and perhaps references to the .rodata section. printf is an unresolved symbol...