-
Notifications
You must be signed in to change notification settings - Fork 0
stage 4 clock processes calc
What does an operating system actually do? Tell the time, run tasks, compute — and make the shell pleasant to use.
Stage 3 gave the kernel a shell with five commands. Stage 4 answers a bigger question — what an OS is actually for — by adding three real subsystems and a much richer shell. It reads wall-clock time from the CMOS real-time clock, models processes with a control block and a cooperative scheduler, evaluates arithmetic with fixed-point numbers (no FPU math library), and makes the prompt ergonomic with command history, Tab completion, and aliases. The command count grows from 5 to 20.
- Directory:
helloworld-os-c-v2/ - Mode: 32-bit protected mode
- Language: C (multiple modules) + NASM
- Built kernel:
shell.c(~1,322 lines)
| Stage 3 | Stage 4 | |
|---|---|---|
| Modules | one shell.c
|
shell.c + rtc.c + process.c
|
| Commands | 5 | 20 |
| Clock | none | CMOS RTC: time, date, clock, uptime
|
| Processes | none | PCB + cooperative scheduler: ps, run, kill, suspend, resume
|
| Math | none | fixed-point calc (+ - * /, 3 decimals) |
| Line editing | backspace + shift | + history (↑/↓, history), Tab completion, aliases
|
| Sectors loaded | 15 |
39 (boot.asm:35) |
The boot path is unchanged from Stage 2/3 (GDT, protected mode, kernel at
0x1000, stack at 0x90000). The bootloader simply reads more sectors because
the kernel is now roughly 19.5 KB across three compiled objects.
⚠️ Caveat:kernel.cis present in this directory but is not compiled. The Makefile buildsshell.o,process.o, andrtc.o—kernel.cis a leftover reference copy of the Stage 2 demo. When reading this stage,shell.cis the kernel.
| File | Role |
|---|---|
shell.c |
The kernel and shell: VGA, keyboard, history/completion/aliases, the calculator, and the 20-command dispatcher. |
process.c / process.h
|
The process control block and cooperative round-robin scheduler model. |
rtc.c / rtc.h
|
The CMOS real-time clock and uptime tracking. |
keyboard.h |
Scancode tables (as in earlier stages). |
kernel.h, stdint.h, stddef.h
|
Small freestanding headers (fixed-width types, NULL, kernel print helpers). |
README_SHELL.md |
The complete shell command reference for this stage. |
kernel.c |
Unused reference copy — not built. |
rtc.c reads time and date from the CMOS chip through two I/O ports: 0x70
selects a register, 0x71 reads or writes its value.
uint8_t rtc_read_register(uint8_t reg) {
outb(CMOS_ADDRESS, reg); // 0x70: select the register
return inb(CMOS_DATA); // 0x71: read its value
}CMOS stores each field (seconds, minutes, hours, day, month, year, weekday) in binary-coded decimal by default — each nibble is one decimal digit. The conversion is a one-liner:
uint8_t bcd_to_binary(uint8_t bcd) {
return ((bcd >> 4) * 10) + (bcd & 0x0F);
}A full read waits out any update in progress, reads every register, then converts from BCD (and from 12-hour to 24-hour form if needed):
void rtc_read_time(rtc_time_t* time) {
rtc_wait_update(); // spin while Status A bit 7 is set
time->second = rtc_read_register(RTC_SECONDS);
time->minute = rtc_read_register(RTC_MINUTES);
time->hour = rtc_read_register(RTC_HOURS);
/* ... day, month, year, weekday ... */
uint8_t status_b = rtc_read_register(RTC_STATUS_B);
if (!(status_b & RTC_BINARY)) { // values are BCD -> convert
time->second = bcd_to_binary(time->second);
/* ... and the rest ... */
}
time->year += (century * 100); // 2-digit year -> 4-digit
}rtc_record_boot_time() is called once at startup, and uptime is the
difference between "now" and that recorded timestamp (calculate_time_diff
handles the same-day case and a midnight wrap). The register map, the BCD
encoding, and the update-in-progress flag are explained in
cmos-rtc.md; the ports are listed in
io-ports.md.
⚠️ Caveat:rtc_wait_update()waits for the update-in-progress flag to clear before reading, but the reads themselves are not re-checked against a second update. If a CMOS update begins midway through the seven register reads, a field can momentarily be inconsistent. A fully robust reader loops until two consecutive reads agree; this one keeps it simple.
process.c introduces a Process Control Block — process_t in process.h
— holding a PID, name, state, a saved CPU-context struct, a statically allocated
4 KiB stack, a priority, and a next pointer for the ready queue:
typedef struct process_t {
uint32_t pid;
char name[PROCESS_NAME_LEN];
process_state_t state; // READY / RUNNING / BLOCKED / TERMINATED
cpu_context_t context; // saved registers
uint8_t* stack;
uint32_t stack_size;
void (*entry_point)(void);
uint32_t time_slice;
uint32_t priority;
struct process_t* next;
} process_t;process_create finds a free table slot, allocates one of ten static 4 KiB
stacks, initializes the PCB and its context (entry point, stack pointer,
eflags = 0x200), and links it onto the ready queue. The scheduler is plain
round-robin: it rotates the current process to the tail of the queue and picks
the new head.
The crucial honesty of this stage is here:
void process_scheduler(void) {
/* ... rotate ready_queue, pick next ... */
if (next && next != current_process) {
process_t* old = current_process;
current_process = next;
current_process->state = PROCESS_RUNNING;
// Context switch would happen here in real implementation
// context_switch(&old->context, ¤t->context);
}
}The scheduler updates bookkeeping — which PCB is "current", queue order,
states — but the actual register-level context switch is not performed. The
extern context_switch() declared in process.h is never implemented and never
called; the line that would invoke it is a comment (process.c:237). Scheduling
is also cooperative: a task only relinquishes the CPU when it calls
process_yield() (which just calls the scheduler). There is no timer
interrupt and therefore no preemption.
The three sample workloads — process_counter, process_fibonacci,
process_prime — each loop, busy-wait, and yield:
void process_counter(void) {
int count = 0;
while (1) {
print_string("[Counter] Count: ");
print_int(count++);
print_string("\n");
for (volatile int i = 0; i < 10000000; i++); // simulate work
process_yield();
}
}So ps, run, kill, suspend, and resume operate on a faithful model of
processes — the table, the states, the queue, the scheduling policy — without the
low-level mechanism that would make them truly preemptive tasks. This is a
deliberate teaching choice: the data structures and policy of a scheduler are
worth understanding in isolation from the assembly that saves and restores
registers. The theory is in
cooperative-scheduling.md.
💡 Tidbit: Because there is no real context switch and scheduling is cooperative,
run counterregisters the process in the table but the sample workloads do not actually run concurrently with the shell. Treat the process commands as a guided tour of a scheduler's structure, not a multitasking runtime.
There is no FPU math library, so calc represents decimals as integers scaled by
1000 — three fractional digits of precision. parse_float reads a number and
returns it pre-scaled:
// Returns the number multiplied by 1000 (3 decimal places).
int parse_float(const char* str, int* has_decimal) {
int result = 0, decimal_places = 0, sign = 1, i = 0;
/* ... sign, integer part ... */
if (str[i] == '.') {
*has_decimal = 1; i++;
while (str[i] >= '0' && str[i] <= '9' && decimal_places < 3) {
result = result * 10 + (str[i] - '0');
decimal_places++; i++;
}
/* skip any extra digits */
}
while (decimal_places < 3) { // pad to exactly 3 fractional digits
result *= 10; decimal_places++;
}
return sign * result;
}So 3.14 becomes 3140 and 2 becomes 2000. Addition and subtraction are
then exact integer operations. Multiplication and division need scale
adjustment, done carefully to avoid 32-bit overflow:
case '*': {
/* split each operand into integer and fractional parts */
int temp_high = (num1 / 1000) * num2; // int x whole
int temp_low = (num1 % 1000) * (num2 / 1000); // frac x int
int temp_frac = ((num1 % 1000) * (num2 % 1000)) / 1000; // frac x frac
result = temp_high + temp_low + temp_frac;
/* re-apply sign */
}
case '/': {
if (num2 == 0) { println("Error: Division by zero!", RED_ON_BLACK); return; }
int quotient = num1 / num2;
int remainder = num1 % num2;
int fractional = (remainder * 1000) / num2; // recover lost precision
result = quotient * 1000 + fractional;
}float_to_str converts the scaled integer back to a decimal string, trimming
trailing zeros. The full reasoning behind scaled-integer arithmetic — the
multiply/divide scale rule, overflow, rounding — is in
fixed-point-arithmetic.md.
The shell keeps a fixed-size history ring and a flat command table used both for Tab completion and as the source of truth for valid commands:
const char* available_commands[] = {
"help", "clear", "echo", "calc", "memory",
"stats", "history", "about", "shutdown",
"ps", "run", "kill", "suspend", "resume",
"time", "date", "clock", "uptime", "alias", "unalias"
};
const int num_commands = 20;Tab completion matches the typed prefix against that table:
const char* find_completion(const char* partial, int* match_count) {
const char* match = 0; *match_count = 0;
for (int i = 0; i < num_commands; i++) {
if (starts_with(available_commands[i], partial)) {
match = available_commands[i];
(*match_count)++;
}
}
return (*match_count == 1) ? match : 0; // unique match completes
}If exactly one command matches, get_line completes the word in place; if
several match, show_completions lists them and leaves the prefix as typed. The
↑/↓ arrow keys (decoded as 0x11/0x12 in this stage's get_key) walk the
history buffer, and history prints it. Aliases let alias name=command
define a shorthand that is resolved to its target before dispatch.
Dispatch is still the Stage 3 if/else if chain over strcmp, just longer, with
an alias-resolution step in front:
char* alias_cmd = resolve_alias(cmd);
if (alias_cmd) { /* rebuild the line from the alias + args, re-parse */ }
if (strcmp(cmd, "help") == 0) cmd_help();
else if (strcmp(cmd, "calc") == 0) cmd_calc(args);
else if (strcmp(cmd, "time") == 0) cmd_time();
/* ... 17 more ... */
else { print("Unknown command: ", RED_ON_BLACK); println(cmd, RED_ON_BLACK); }Startup wires the subsystems together: kernel_main() calls process_init()
then shell_main(), which calls rtc_init() and rtc_record_boot_time() before
printing the banner and the current date/time. (The on-screen banner still reads
SimpleShell OS v1.0, carried over from Stage 3.) The complete behavior of every
command is documented in README_SHELL.md and in
command-reference.md.
help clear echo calc memory stats history about shutdown
ps run kill suspend resume time date clock uptime
alias unalias
| Group | Commands |
|---|---|
| General |
help, clear, echo, about, history, shutdown
|
| Calculator | calc |
| System info |
memory, stats
|
| Clock |
time, date, clock, uptime
|
| Processes |
ps, run, kill, suspend, resume
|
| Aliases |
alias, unalias
|
From helloworld-os-c-v2/:
make # compile shell.o, process.o, rtc.o; link; build the image
make run # boot in QEMU
make debug # boot under QEMU with a GDB stub (-s -S)
make clean # remove build artifactsThe link step now pulls in three objects:
ld -m elf_i386 -T linker.ld -o kernel.bin \
kernel_entry.o shell.o process.o rtc.o
cat boot.bin kernel.bin > helloworld-c.img
truncate -s 20480 helloworld-c.imgSee building-and-running.md and toolchain-and-build.md.
- Reading a hardware clock over I/O ports and decoding BCD time fields.
- The structure of a scheduler — PCBs, states, a ready queue, a round-robin policy — separated from the register-level context switch.
- Doing real arithmetic with scaled integers when no floating-point library exists.
- The mechanics of a friendlier shell: history, prefix completion against a command table, and user-defined aliases.
- The scheduler is a model. No real context switch is performed and there is no timer preemption; tasks are cooperative and the sample workloads do not run concurrently with the shell.
- RTC reads aren't fully synchronized. A read that races a CMOS update can return an inconsistent field.
-
Fixed-point, not floating-point.
calcis limited to three decimals and to magnitudes that don't overflow 32-bit intermediate products. -
Linear dispatch persists. Twenty commands are still matched by a chain of
strcmpcalls. -
kernel.cis dead code in this directory — onlyshell.cis built.
Stage 5 is a release-engineering lesson: it trims the experimental alias /
unalias commands (20 → 18), locks the command surface, and commits the built
binaries so the artifact that boots is the artifact in version control.
→ Stage 5 — The Stabilized Release
- cmos-rtc.md — the CMOS clock, registers, and BCD
- cooperative-scheduling.md — PCBs, yielding, round-robin
- fixed-point-arithmetic.md — scaled-integer math
- command-reference.md — every command, every stage
-
io-ports.md —
0x70/0x71,0x60/0x64 - Stage 3 — An Interactive Shell
- 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