-
Notifications
You must be signed in to change notification settings - Fork 0
freestanding c
Writing C with no operating system underneath it — no libc, no headers, no main, no safety net.
When you write an ordinary C program, an enormous amount of machinery is hidden
from you. The C standard library (printf, malloc, memcpy, …) is linked in.
Standard headers like <stdio.h> and <stdint.h> are available. A startup
routine (crt0) runs before main, sets up the stack and arguments, and calls
your code. None of that exists when your program is the operating system.
MyOS-Simple's C kernels are compiled in freestanding mode, which strips all of that away. This page explains what freestanding means, what the compiler flags in the Makefile actually do, and how the project fills the resulting gaps by hand.
The C standard formally distinguishes two execution environments:
-
Hosted — the normal case. A full standard library is present, the program
starts at
main, and the whole library is available. -
Freestanding — the minimal case. Only a handful of headers are guaranteed
(
<stddef.h>,<stdint.h>,<limits.h>, …), the entry point is implementation-defined, and there is no standard library implementation. This is the mode used for kernels, firmware, and embedded targets.
MyOS-Simple is firmly in the freestanding camp: the kernel runs in protected mode on bare metal, with nothing beneath it but the CPU and the hardware.
The kernel's compilation flags come straight from the
Makefile (CFLAGS):
gcc -m32 -ffreestanding -fno-pic -fno-pie -nostdlib -nostdinc \
-fno-builtin -fno-stack-protector -nostartfiles -nodefaultlibs \
-Wall -Wextra -c kernel.c -o kernel.oEach flag exists to remove an assumption the compiler would otherwise make about running under an OS:
| Flag | What it does |
|---|---|
-m32 |
Generate 32-bit i386 code (matches the protected-mode target). |
-ffreestanding |
Tell GCC the program is freestanding: no hosted-environment assumptions, no implicit main semantics. |
-fno-pic / -fno-pie
|
Disable position-independent code. The kernel is loaded at a fixed address (0x1000) and uses absolute addresses. |
-nostdlib |
Do not link the standard library or startup files. |
-nostdinc |
Do not search the standard system include directories. |
-fno-builtin |
Do not assume libc functions exist or behave as built-ins (so GCC won't, say, replace a loop with a call to memset). |
-fno-stack-protector |
Remove stack canary checks, which call into a runtime that isn't present. |
-nostartfiles |
Do not link the C runtime startup objects (crt0/crt1). There is no automatic call to main. |
-nodefaultlibs |
Do not link any default libraries. |
-Wall -Wextra |
Enable thorough warnings — important when you have no safety net. |
-c |
Compile only; linking is done separately by the linker script. |
⚠️ Caveat:-nostdincmeans#include <stdint.h>would normally fail — the system header path is gone. Stages 4 and 5 of the project therefore ship their own minimal headers next to the source (e.g.helloworld-os-c-v3/stdint.h), defining exactly the typedefs the kernel needs:typedef unsigned char uint8_t; typedef unsigned short uint16_t; typedef unsigned int uint32_t; typedef unsigned int size_t;
In a hosted program, crt0 runs first and eventually calls main. With
-nostartfiles, there is no crt0. The project supplies the entry point itself
in kernel_entry.asm:
[BITS 32]
[EXTERN kernel_main]
global _start
_start:
call kernel_main
jmp $That is the entire C runtime for this kernel. There is no argument marshalling,
no environment setup, no global constructors — just a call into C, and an
infinite jmp $ halt if kernel_main ever returns. The stack was already set up
by the bootloader (esp = 0x90000) before this code ran.
💡 Tidbit: The C entry point is named
kernel_main(orshell_mainin the shell stage), notmain. The namemainis only special to the hosted startup code, which we don't have. Here, the entry point is whatever_startchooses to call — seeshell.c:305, wherekernel_mainjust forwards toshell_main.
With -nostdlib and -fno-builtin, none of the familiar conveniences exist:
-
No
printf— output is produced by writing(char, attribute)pairs directly into VGA text memory at0xB8000. -
No
malloc— there is no heap; all buffers are fixed-size locals or globals. -
No
<string.h>— string helpers are written by hand.
For example, shell.c:121-137 provides
its own strcmp and strncmp, used to match typed commands against the built-in
command table:
int strcmp(const char* s1, const char* s2) {
while (*s1 && (*s1 == *s2)) {
s1++;
s2++;
}
return *(unsigned char*)s1 - *(unsigned char*)s2;
}
int strncmp(const char* s1, const char* s2, int n) {
while (n && *s1 && (*s1 == *s2)) {
s1++;
s2++;
n--;
}
if (n == 0) return 0;
return *(unsigned char*)s1 - *(unsigned char*)s2;
}A freestanding kernel still has to reach the hardware, and there is no library wrapper to do it. MyOS-Simple uses GCC inline assembly for the two primitives it needs: port I/O and memory-mapped I/O.
The keyboard controller and other devices are read and written through x86 I/O
ports, which require the in/out instructions — something C has no operator
for. The project wraps them in tiny inline-asm functions. From
shell.c:38-42:
unsigned char inb(unsigned short port) {
unsigned char result;
__asm__ __volatile__("inb %1, %0" : "=a"(result) : "Nd"(port));
return result;
}The corresponding writer, used elsewhere in the project, is:
void outb(unsigned short port, unsigned char data) {
__asm__ __volatile__("outb %0, %1" : : "a"(data), "Nd"(port));
}The constraints are worth understanding: "=a" puts the result in AL/AX,
"a" supplies the source from AL, and "Nd" lets the port be an 8-bit
immediate when small or DX otherwise — exactly matching how the in/out
instructions encode their operands.
💡 Tidbit:
__volatile__tells GCC not to optimize the asm away or reorder it relative to other volatile accesses. Hardware I/O has side effects the compiler cannot see, so withoutvolatilean "unused"inbcould be deleted, or two reads could be collapsed into one — breaking, for example, the keyboard status poll inshell.c:145.
VGA text output is memory-mapped rather than port-based: the screen is a region
of RAM starting at 0xB8000. The kernel writes to it through a volatile
pointer, as in kernel.c:51-56:
void putchar_at(char c, int x, int y, char attr) {
volatile char* video = (volatile char*)VIDEO_MEMORY; /* 0xB8000 */
int offset = (y * 80 + x) * 2;
video[offset] = c;
video[offset + 1] = attr;
}💡 Tidbit: The
volatilequalifier is essential here too. To the compiler, writing a character to0xB8000and never reading it back looks like a dead store it is free to remove.volatileforces every write to actually happen, because the side effect — a glyph appearing on screen — is invisible to the optimizer.
A freestanding kernel also can't rely on an ELF loader to place its sections in
memory — the bootloader just copies raw bytes to 0x1000
and jumps. That requirement is what drives the
linker script to emit OUTPUT_FORMAT(binary) and to link the
entry stub first. See that page for the full story.
-
Linker scripts — how the freestanding objects become a flat binary at
0x1000 - Protected mode — the environment the kernel runs in
- VGA text mode — the memory-mapped display the kernel writes to
-
PS/2 keyboard (8042) — the port-mapped device
inbreads -
reference/io-ports.md— the I/O ports the kernel touches -
reference/toolchain-and-build.md— every tool and flag in the build - Stage 2: C in protected mode — where C first appears
- Home
Stages
- 1 · Assembly boot
- 2 · C protected mode
- 3 · Interactive shell
- 4 · Clock / processes / calc
- 5 · Stabilized release
Concepts — boot
Concepts — protected mode
Concepts — hardware
Concepts — OS services
Reference
- Memory map
- I/O ports
- GDT descriptor format
- Scancode tables
- Command reference
- Toolchain & build
- Glossary
Guides