The first version used void main(void). The LSP complained before you even compiled.
This tab documents exactly what is wrong with it, what the runtime actually does with a void main,
and why the standard explicitly forbids it.
void main(void) — LSP flagged it before gcc did
#include<stdio.h> // declare the main function. It is advisable that the main function is not like this : void main(void) { // ← wrong return type. C standard requires int. printf("Hello World Again!\n"); // This implies that this function takes zero arguments, returns nothing }
warning: return type of 'main' is not 'int' [-Wmain] note: the C standard requires 'main' to return 'int'
What the C standard actually says
ISO C (C99, C11, C17) defines exactly two conforming signatures for main:
int main(void) for programs that do not use command-line arguments, and
int main(int argc, char *argv[]) for those that do. Every other signature —
including void main(void) — is undefined behavior. The standard says
implementations may accept other forms as extensions, but no conforming, portable
C program relies on that.
What void main actually does to the runtime:
When your program exits, the C runtime startup code (compiled into crt1.o
and linked automatically by gcc) takes the return value of main and passes
it to exit(). On x86_64, the calling convention says: a function's return
value lives in the rax register. If you declare main as
void, you tell the compiler "this function returns nothing" — so the
compiler does not place anything meaningful in rax before returning.
But the C runtime still reads rax and hands it to exit().
Whatever happened to be in rax at that moment — the return value of the
last function your main called, an intermediate arithmetic result, anything —
becomes your process exit status. It is garbage. It is almost certainly non-zero.
To the shell, non-zero means failure.
Why the LSP caught it before gcc:
clangd (your LSP, running inside NvChad) runs the clang parser incrementally on every
keystroke. Clang's semantic analysis checks the return type of main as
part of normal parsing. The moment you wrote void main, clangd had already
parsed the function signature and flagged the violation — before you saved the file,
before you ran anything. gcc only sees the file when you explicitly invoke it. Without
-Wall, gcc may silently accept void main on some platforms as
a compiler extension. With -Wall, the -Wmain warning fires.
This gap — between what the LSP catches in real-time and what gcc catches at compile
time — is one of the main reasons to use a language server while writing C.
Your comment in the source was exactly right:
"The return type of main is not int? Why is the LSP complaining about it? This is
because we can use $? in shell scripts, or use the available functions in
C to check if the function succeeded successfully or not. Any non-zero return type implies
error. So we have to return something, default a zero."
The chain is: return 0 → C runtime calls exit(0) → kernel
stores 0 as exit status → shell reads into $?. You had the whole chain
correct before seeing the full explanation.
int main(void) or
int main(int argc, char *argv[]). Nothing else. Always ends with
return 0; on success or a non-zero value to signal failure to the shell.
The corrected version. int main(void) with an explicit return 0;.
Also tests whether the space in #include <stdio.h> is required.
Every line explained at the system level.
int main(void) — explicit, correct, standard-conforming
// hello world // include std input output library //#include <stdio.h> ← commented out — testing: does the space matter? #include<stdio.h> // ← no space. still works. // declare the main function. It is advisable that the main function is not like this : int main(void) { printf("Hello World Again!\n"); return 0; // This implies that this function takes zero arguments, returns nothing } // The return type of main is int. We can use "$?" in shell scripts to check // if the function succeeded. Any non-zero return implies error. // So we have to return something — default a zero.
❯ gcc -Wall -Wextra -Wpedantic -std=c11 -o hello_worldv2 hello_worldv2.c (no warnings) ❯ ./hello_worldv2 Hello World Again!
Line by line — what the compiler and runtime actually do
#include<stdio.h> — space is irrelevant:
The preprocessor tokenises include directives independently of whitespace. Both
#include<stdio.h> and #include <stdio.h> produce
identical output. The angle-bracket form tells the preprocessor: search the system include
directories only (/usr/include, /usr/local/include, and any
paths passed via -I). It finds stdio.h there and pastes its
entire content — about 900 lines of type definitions, macro definitions, and function
declarations — into your translation unit in place of the directive. The compiler never
sees the #include line; by the time it runs, the substitution has already
happened.
int main(void) — what void in the parameter list does:
In C, empty parentheses main() mean "unspecified number of arguments of
unspecified types" — a legacy from K&R C 1978. main(void) means
"explicitly no arguments." The compiler enforces this: calling main with
arguments when declared (void) is a compile-time error. It is also what
-Wpedantic requires. Always use (void) for main that takes
no arguments.
printf("Hello World Again!\n") — what \n is at the byte level:
The string literal "Hello World Again!\n" is stored in the .rodata
(read-only data) section of the compiled binary. The compiler translates the two-character
escape sequence \n into the single byte 0x0A — ASCII Line Feed.
When printf writes this byte to stdout, your terminal emulator (Alacritty) intercepts it and
moves the cursor to the start of the next line. Without \n, the next shell
prompt would appear immediately after your text on the same line.
return 0; — the full journey to the shell:
This single statement triggers a chain. The C runtime's __libc_start_main
called your main and is waiting for it to return. When it gets the return
value 0, it calls exit(0). exit() flushes all
open stdio buffers (so any buffered output is written), runs any functions registered
with atexit(), and calls the _exit() syscall. The kernel marks
the process as a zombie, stores 0 as the exit status, and sends SIGCHLD to
the parent process (your shell). The shell calls waitpid(), collects the
status, and stores the low 8 bits in $?. That is why $?
is 0 after a successful program.
#include<stdio.h> (no space) and
#include <stdio.h> (with space) produce identical binaries. The preprocessor
does not care. The space is style, not syntax.
A bash proof-of-concept that captures the return value from a C program and branches on it.
Proves that return 0 in C is not just a convention — it travels all the way
from the C runtime through the kernel to the calling shell and is readable as $?.
Reading a C program's return value from bash
#!/bin/bash # Let's have a POC here... # What can we do with a return from C? echo "[+] Executing the hello_worldv2" ./hello_worldv2 result=$? ← captured IMMEDIATELY after the program exits if [[ $result -eq 0 ]]; then echo "The return is 0; The command succeeded..." else echo "Non zero error code! $result" fi
❯ bash check_return.sh [+] Executing the hello_worldv2 Hello World Again! The return is 0; The command succeeded...
// in hello_worldv2.c: return 42; ← changed ❯ bash check_return.sh [+] Executing the hello_worldv2 Hello World Again! Non zero error code! 42
Deep Explanation — the full process lifecycle from C return to shell $?
What happens when bash runs ./hello_worldv2:
The shell calls fork() — a syscall that creates an exact copy of the shell
process, including its memory, file descriptors, and state. The child process calls
execve("./hello_worldv2", argv, envp). The kernel replaces the child's
entire address space with the contents of the ELF binary. The child's code, data, and
stack are wiped and replaced with the program's. The kernel finds the ELF entry point —
not main, but _start in crt1.o — and starts
executing there.
The C runtime startup — _start to main:
_start sets up the stack pointer, zeroes rbp (the frame
pointer, signalling the bottom of the call stack), and calls
__libc_start_main. This function initialises the C library (sets up
locale, registers atexit handlers for libc cleanup), and then calls your
main(). When main executes return 0, that
value flows back to __libc_start_main, which calls exit(0).
exit(0) — what happens before the kernel sees it:
exit() is a libc function, not a direct syscall. Before it terminates the
process, it: (1) calls all functions registered with atexit() in reverse
order of registration, (2) flushes and closes all open stdio streams (this is why
printf output appears even if you forgot \n and the buffer
was not flushed — exit() flushes it), and (3) calls
_exit(0) — the actual syscall. The kernel receives status code 0.
How the kernel stores it and the parent shell reads it:
The kernel marks the child process as a zombie — it is dead but its exit status is
preserved in the process table entry until the parent collects it. The parent (your
bash shell) was blocked in waitpid(child_pid, &status, 0) since it
forked the child. The kernel unblocks it, returning the status. The shell extracts
the exit code (low 8 bits of the status word) and stores it in $?.
Why result=$? must come immediately after ./hello_worldv2:
$? holds the exit status of the most recently completed foreground
command. Every command you run overwrites it. If you put even a single
echo between ./hello_worldv2 and result=$?,
$? would contain the exit code of echo (which is always 0)
rather than your program's. Capturing into a variable immediately is the correct pattern.
The 8-bit limit on exit codes:
Only the low 8 bits of the exit status are preserved by the kernel's
waitpid mechanism. Exit codes are in the range 0–255. If your C program
does return 256, the shell sees $? as 0 — 256 modulo 256 is 0.
return 257 gives $? = 1. This matters for programs that use
specific non-zero exit codes to communicate distinct error states — always keep them
under 256.
Why this matters beyond toy programs:
Every build system (make, cmake, ninja), every CI pipeline (GitHub Actions, GitLab CI),
every shell script, and every && / || chaining in the
terminal uses exit codes to decide whether to proceed or fail. When gcc returns 1 on
a compile error, make stops the build. When your test suite returns 0, the CI marks
the build green. The humble return 0 in your C program is the beginning
of that entire chain.
return 0 to return 42
in hello_worldv2.c, recompile, run the script. You will see "Non zero error code! 42".
Change to return 256 and you will see "Non zero error code! 0" — the 8-bit
wrap-around in action.
You added a null byte \0 to the end of a printf string and got a compiler warning:
-Wformat-contains-nul. This tab explains what \0 is, how printf uses it to find
the end of a string, and why embedding one in the middle silently truncates all output after it.
Also covers the gcc -E flag you noted in the comments.
Embedded \0 — compiles, runs, produces wrong output with no crash
// printf is a function declared in the standard input output library. // But actually it is present in some location : in the standard C library glibc.o // (The above statement has to be checked again!) // Run this to get the preprocessor output: // gcc -Wextra -Wall -std=c11 -E -o using_printf using_printf.c #include <stdio.h> int main(void) { printf("This is how you need to use this printf function \n\0"); // Wait what is this \0 -> This is the null byte. } // Why did the compiler warn about ending a printf with a null byte? // This implies there is a memory risk or what? // We should not be using this nullbyte at the body of function?
using_printf.c: In function 'main': using_printf.c:11:62: warning: embedded '\0' in format [-Wformat-contains-nul] 11 | printf("This is how you need to use this printf function \n\0"); | ^~
Deep Explanation — null terminators, how printf reads strings, and why \0 silently kills output
What \0 is:
The null byte — the byte with value 0x00, ASCII code 0. It is a real
character with a real byte value; it just happens to be invisible and non-printing.
In C, every string literal is automatically null-terminated: the compiler appends a
\0 byte after the last character of every string. The string
"hello" occupies 6 bytes in the .rodata section of your
binary: h e l l o \0. C has no string length field — it uses the null
byte as a sentinel to mark the end.
How printf finds the end of the format string:
printf receives a const char * — a pointer to the first byte
of the format string. It then loops: read a byte, if it is not \0, process
it (print it literally or handle a format specifier if it is %), advance
the pointer, repeat. When it reads \0, it stops. That is the entire string
scanning mechanism. There is no length argument, no bounds checking — just the null byte.
What your \0 actually does to the output:
Your format string in memory is:
T h i s ... f u n c t i o n SP \n \0 \0.
The first \0 is your explicit one. The second is the automatic terminator
the compiler appended. Printf reads through the string, prints everything, hits your
\0 at position 51, and stops. In this particular example the result is
identical to not having the \0 — because there is nothing after it that
you wanted printed. But consider this:
The dangerous version — where output is silently lost:
// This is the dangerous case. No crash. Wrong output. Hard to spot. printf("Status: %s\0 — Extra info: %s\n", status, extra); // ^ // printf stops here. " — Extra info: %s\n" is never printed. // The second %s argument (extra) is never read. // No crash. No error. Just silently wrong output. // Another subtle case: printf("Count: %d\0%d\n", count, other_count); // Only prints count. other_count is passed but ignored. // -Wformat-contains-nul catches this before it bites you.
The gcc -E flag — seeing what the preprocessor actually produces
Your comment noted this command:
gcc -Wextra -Wall -std=c11 -E -o using_printf using_printf.c.
The -E flag stops gcc after the preprocessing stage and outputs the
result. This is the most educational thing you can do to understand what the compiler
actually receives. Try it on any file that includes headers.
What you would see:
The output would be roughly 900 lines of expanded stdio.h — all the type
definitions, macro expansions, and function declarations pasted in — followed by your
four-line main. The #include directive is gone, replaced by
its contents. All #define macros have been substituted. All
#ifdef conditionals have been resolved. What remains is pure C with no
preprocessor directives left — exactly what cc1 (the actual C compiler
inside gcc) parses.
Correcting your comment — "in the standard C library glibc.o":
printf does live in glibc (the GNU C Library), but not in a .o
file. .o files are intermediate object files produced during compilation —
temporary build artifacts. The final product is a shared library:
/usr/lib/libc.so.6 on Arch. You can confirm:
nm -D /usr/lib/libc.so.6 | grep " printf$" — you will see the symbol
listed with its address. The T flag next to it means it lives in the text
(code) section of the library — compiled, executable machine code.
\0 inside a format string argument
to printf (or sprintf, fprintf, etc.). It silently
truncates everything that follows. The -Wformat-contains-nul warning (part of
-Wformat, included in -Wall) is there to catch exactly this.
If you genuinely need to print a null byte, use fwrite() instead.