HELLO WORLD

// return codes · exit status · void main · \0 in format strings · gcc -E

Error · void main(void)

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 int main return type C runtime exit status -Wmain rax register
Error Documented → errors/hello_worldv1.c.error

void main(void) — LSP flagged it before gcc did

hello_worldv1.c — broken source
#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
}
compiler output — -Wall
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.

The rule: Every C program starts with 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.
v2 · int main(void) + return 0

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) return 0 #include spacing printf \n escape sequence stdout buffering
Final Solution → hello_worldv2.c

int main(void) — explicit, correct, standard-conforming

hello_worldv2.c — exact source
// 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.
compile and run
 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.

Observation confirmed: #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.
POC · check_return.sh

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 $?.

$? in bash exit status fork + execve waitpid process lifecycle 8-bit exit codes IPC
POC → check_return.sh

Reading a C program's return value from bash

check_return.sh — exact source
#!/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
running it
 bash check_return.sh
[+] Executing the hello_worldv2
Hello World Again!
The return is 0; The command succeeded...
what happens if you change return 0 to return 42
// 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.

To try yourself: Change 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.
Warning · \0 in a printf format string

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.

\0 null terminator C strings -Wformat-contains-nul printf format string silent truncation gcc -E preprocessor output cpp
Warning Documented → errors/using_printf.c.error

Embedded \0 — compiles, runs, produces wrong output with no crash

using_printf.c — exact source
// 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?
compiler warning — exact
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:

what silent truncation looks like in practice
// 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.

The rule: Never put \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.
Next → scanf