boot: separate BIOS from UEFI

boot/bios/ now holds the MBR, the El Torito stub and the real-mode stage
2; boot/uefi/ is empty and documented.

Separate directories rather than #ifdefs, because the two share almost
nothing. A BIOS loader lives in 16-bit real mode, reaches the firmware
through software interrupts, and walks itself up into long mode. A UEFI
loader is a 64-bit PE that the firmware has already placed in long mode
with paging on, calling services through a table of function pointers.
What they do share is the hand-off contract, which boot/README.md now
writes down: ELF segments placed, long mode, a 24-byte-entry memory map
at E820_MAP_ADDR, framebuffer geometry at VBE_INFO_ADDR.

That contract is the point of documenting it now. UEFI's GetMemoryMap
returns EFI_MEMORY_DESCRIPTORs, and the right move is to translate them
into the entries kernel/arch/x86_64/e820.c already parses rather than
teach the kernel a second format.

Build output paths under $(BUILD)/boot are unchanged; only sources moved.
This commit is contained in:
Nexoniarz 2026-08-09 16:00:03 +02:00
commit 08a1a9033a
19 changed files with 125 additions and 33 deletions

101
boot/bios/bootasm.S Normal file
View file

@ -0,0 +1,101 @@
# Stage 1 — the actual MBR boot sector. Hard-limited to 510 bytes (+2-byte
# 0x55AA signature) by the BIOS, so there's no room here for anything but
# "load stage 2 off disk and jump to it". All the VBE/A20/protected-mode/
# splash/kernel-loading work now lives in stage2.S + stage2main.c, which
# aren't sector-size-constrained.
#
# How many sectors of stage2 to pull in. This, the padding the Makefile gives
# stage2's binary, and the "+(STAGE2_SECTORS+1)" sector math in stage2main.c's
# readseg() all have to agree on where the kernel starts on disk -- so the
# Makefile passes -DSTAGE2_SECTORS and is the single authority. The value below
# is only a fallback for reading this file standalone.
#
# It is 48, not the old 24, because stage2 now carries the boot logo
# (include/logo.h, 8 KiB). The real ceiling is memory rather than this number:
# stage2 loads at 0x7E00 and the multiboot kernel-search buffer starts at
# 0x10000, so stage2 cannot exceed 0x8200 bytes = 65 sectors.
#ifndef STAGE2_SECTORS
#define STAGE2_SECTORS 48
#endif
#define STAGE2_ADDR 0x7E00
.code16
.global start
start:
cli
xorw %ax, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
movw $0x7c00, %sp
# Read STAGE2_SECTORS sectors, starting at disk sector 1 (LBA), into
# memory right after this boot sector (0x7E00). Raw IDE PIO (LBA28) —
# same register convention as stage2main.c's own readsect(), just
# inlined here since we can't call C code yet. Port numbers 0x1F2-0x1F7
# are all > 0xFF, so they must go through %dx — `out $imm8,%al` only
# encodes an 8-bit port and silently truncates anything larger.
# "You were not booted from a CD." stage2main.c reads this byte to choose
# between ATA and ATAPI when it loads the kernel, and nothing else clears
# it -- so a warm reboot from a disc could otherwise leave the flag set and
# send a perfectly ordinary hard-disk boot looking for an optical drive.
# Placed one byte past the verbose flag; both sit clear of the 256-byte VBE
# mode info block that stage2.S is about to write at 0x30000.
movw $0x3000, %ax
movw %ax, %es
movw $0x101, %di
movb $0, %al
movb %al, %es:(%di)
xorw %ax, %ax
movw %ax, %es
movw $STAGE2_ADDR, %di # ES:DI destination, ES=0
movw $1, %si # first LBA sector to read
movb $STAGE2_SECTORS, %bl
.Lsector:
.Lwait1:
movw $0x1F7, %dx
inb %dx, %al
andb $0xC0, %al
cmpb $0x40, %al
jne .Lwait1
movw $0x1F2, %dx
movb $1, %al
outb %al, %dx # sector count = 1
incw %dx # 0x1F3
movw %si, %ax
outb %al, %dx # LBA [7:0]
incw %dx # 0x1F4
xorb %al, %al
outb %al, %dx # LBA [15:8]
incw %dx # 0x1F5
outb %al, %dx # LBA [23:16]
incw %dx # 0x1F6
movb $0xE0, %al
outb %al, %dx # drive 0, LBA mode
incw %dx # 0x1F7
movb $0x20, %al
outb %al, %dx # READ SECTORS command
.Lwait2:
inb %dx, %al # %dx still 0x1F7
andb $0xC0, %al
cmpb $0x40, %al
jne .Lwait2
movw $256, %cx # 256 words = 512 bytes
movw $0x1F0, %dx
rep insw # reads into ES:DI, DI += 512 automatically
incw %si
decb %bl
jnz .Lsector
ljmp $0, $STAGE2_ADDR

48
boot/bios/cdboot.S Normal file
View file

@ -0,0 +1,48 @@
# Stage 1 for optical media -- the El Torito "no emulation" boot sector.
#
# This exists because bootasm.S cannot boot a CD. It loads stage 2 by talking
# straight to the primary IDE controller's ATA registers, and a CD is not an
# ATA disk: it answers to the ATAPI packet interface instead, and under
# El Torito it may not even be the drive at 0x1F0. Rather than teach a
# 512-byte sector two protocols, optical media get their own first stage.
#
# It has almost nothing to do, because El Torito's "load segment / sector
# count" fields do the reading for us: tools/mkiso.sh asks for
# 1 + STAGE2_SECTORS virtual sectors at 0x7C00, so by the time this runs the
# BIOS has already placed stage 2 at 0x7E00 -- exactly where bootasm.S would
# have put it. The only real work is recording *which* medium we came from,
# since stage2main.c has to read the kernel back with the matching protocol.
#
# The flag goes next to the verbose-boot flag, one byte past it, for the same
# reason that one sits where it does: it is clear of the 256-byte VBE mode
# info block at 0x30000 that stage2.S fills in a moment from now.
#define STAGE2_ADDR 0x7E00
#define BOOT_MEDIA_FLAG_SEG 0x3000
#define BOOT_MEDIA_FLAG_OFF 0x101
#define BOOT_MEDIA_CD 1
.code16
.global start
start:
cli
xorw %ax, %ax
movw %ax, %ds
movw %ax, %ss
movw $0x7c00, %sp
# "You were booted from a CD." stage2main.c reads this to decide between
# ATA and ATAPI; without it the loader would read the kernel off whatever
# happens to be sitting at 0x1F0, which on a CD boot is usually nothing.
movw $BOOT_MEDIA_FLAG_SEG, %ax
movw %ax, %es
movw $BOOT_MEDIA_FLAG_OFF, %di
movb $BOOT_MEDIA_CD, %al
movb %al, %es:(%di)
# stage2.S expects zeroed segments and an inherited stack, same as it gets
# from bootasm.S.
xorw %ax, %ax
movw %ax, %es
ljmp $0, $STAGE2_ADDR

129
boot/bios/stage2.S Normal file
View file

@ -0,0 +1,129 @@
# Stage 2 — loaded by stage1 at 0x7E00, still in 16-bit real mode. Does the
# VBE mode setup, A20 line, and real->protected mode switch that used to
# live directly in the boot sector, then hands off to stage2main() (C) to
# draw a splash and load the actual kernel. Not sector-size-constrained —
# only bounded by STAGE2_SECTORS in bootasm.S / the Makefile.
#include "../include/asm.h"
#include "../include/memlayout.h"
#include "../include/mmu.h"
.code16
.global stage2start
stage2start:
# Segments are already zeroed by stage1; %sp is inherited too.
movw $bootmsg, %si
call print16
# VBE Setup. Mode info block goes at 0x30000, not 0x9000 — stage2 itself
# is loaded starting at 0x7E00 and, with the boot-menu/progress-bar code,
# is now big enough to reach 0x9000, so writing the VBE block there would
# clobber stage2's own tail end. 0x30000 is clear of stage2 (ends well
# under 0xA000), the multiboot kernel-search buffer (0x10000-0x14000),
# and the kernel's own load address (0x100000).
movw $0x3000, %ax
movw %ax, %es
xorw %di, %di # ES:DI = 3000:0000 = flat 0x30000
movw $0x0143, %cx
movw $0x4F01, %ax
int $0x10
cmpw $0x004F, %ax
jne e820
movw $0x4143, %bx
movw $0x4F02, %ax
int $0x10
# BIOS E820 memory map — the standard firmware interface every serious
# loader (GRUB, Linux's own boot stub, the BSDs) uses to learn the real
# RAM layout instead of guessing. Entry count goes to flat 0x30200,
# 24-byte entries start at 0x30208 (both inside the 0x3000 segment the
# VBE block already claimed; verbose flag sits at +0x100). Capped at 32
# entries so a pathological BIOS can't run us past the buffer.
e820:
movw $0x3000, %ax
movw %ax, %es
xorl %ebx, %ebx # continuation cookie, 0 = first call
movw $0x208, %di
xorl %ebp, %ebp # entry count
e820loop:
movl $0xE820, %eax
movl $0x534D4150, %edx # 'SMAP'
movl $24, %ecx
int $0x15
jc e820done # carry = end of map (or unsupported BIOS)
cmpl $0x534D4150, %eax
jne e820done
addw $24, %di
incl %ebp
cmpl $32, %ebp
jae e820done
testl %ebx, %ebx
jnz e820loop
e820done:
movl %ebp, %es:(0x200)
xorw %ax, %ax
movw %ax, %es
seta20.1:
inb $0x64, %al
testb $0x2, %al
jnz seta20.1
movb $0xd1, %al
outb %al, $0x64
seta20.2:
inb $0x64, %al
testb $0x2, %al
jnz seta20.2
movb $0xdf, %al
outb %al, $0x60
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0
ljmp $(SEG_KCODE<<3), $start32
# Print null-terminated string at DS:SI via BIOS teletype (real mode only).
# Clobbers AX, BX, SI.
print16:
.Lp16loop:
lodsb
testb %al, %al
jz .Lp16done
movb $0x0E, %ah
movw $0x0007, %bx
int $0x10
jmp .Lp16loop
.Lp16done:
ret
bootmsg:
.asciz "SchOS booting...\r\n"
.code32
start32:
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
xorw %ax, %ax
movw %ax, %fs
movw %ax, %gs
movl $0x7c00, %esp
call stage2main
spin:
jmp spin
.p2align 2
gdt:
SEG_NULLASM
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)
SEG_ASM(STA_W, 0x0, 0xffffffff)
gdtdesc:
.word (gdtdesc - gdt - 1)
.long gdt

878
boot/bios/stage2main.c Normal file
View file

@ -0,0 +1,878 @@
#include "../include/types.h"
#include "../include/x86.h"
#include "../include/memlayout.h"
#include "../include/font.h"
#define SECTSIZE 512
// Kernel starts right after stage2 on disk. The Makefile passes
// -DSTAGE2_SECTORS so this, bootasm.S and stage2's padding cannot drift apart;
// the value here is a fallback for reading the file on its own.
#ifndef STAGE2_SECTORS
#define STAGE2_SECTORS 48
#endif
// stage2 is loaded at 0x7E00 and must not reach the multiboot kernel-search
// buffer at 0x10000. Nothing checks this at runtime, and the failure it would
// produce -- a kernel image partly overwritten by the loader that loaded it --
// would be spectacularly hard to read backwards from.
_Static_assert(0x7E00 + STAGE2_SECTORS * 512 <= 0x10000,
"stage2 would overlap the kernel-search buffer at 0x10000");
// One palette for the whole system; see include/theme.h. The splash used to
// use the panic screen's deep purple and gold, which meant a healthy boot was
// dressed identically to a crash.
#include "theme.h"
// The boot logo, generated from Assets/ by tools/mklogo.py. Shared verbatim
// with kernel/dev/vga.c so there is one mask, not two.
#include "logo.h"
// VBE mode info block, written here by stage2.S's VBE call. NOT at 0x9000
// (the classic single-stage bootloader's choice) — stage2's own code now
// runs from 0x7E00 up to nearly 0x9000 once the boot-menu/progress-bar
// logic is included, so a block at 0x9000 would get partially overwritten
// by stage2's own tail end. 0x30000 is clear of stage2, the multiboot
// kernel-search buffer (0x10000-0x14000), and the kernel's load address
// (0x100000).
#define VBE_INFO_ADDR 0x30000
// Fixed low-memory scratch byte, read by kernel/core/main.c's kmain() before
// any other init: 1 if the user picked "verbose" from the boot menu. Placed
// just past the VBE info block (which is at most 256 bytes), NOT at 0x7000
// — kernel/core/main.c's startothers() already uses 0x7000 for the AP
// (secondary CPU) boot trampoline. That's write-after-read from this flag's
// perspective (verbose is read before startothers() runs), so it wasn't an
// active bug, but reusing a documented-elsewhere address is exactly the
// kind of thing that turns into one later.
#define BOOT_VERBOSE_FLAG_ADDR (VBE_INFO_ADDR + 0x100)
// Set by boot/bios/cdboot.S when we were booted from optical media. One byte past
// the verbose flag, for the same reason that one sits where it does: clear of
// the 256-byte VBE mode info block at VBE_INFO_ADDR.
#define BOOT_MEDIA_FLAG_ADDR (VBE_INFO_ADDR + 0x101)
#define BOOT_MEDIA_CD 1
// E820 memory map, collected by stage2.S in real mode (the same firmware
// interface GRUB and Linux's boot stub use). Count at +0x200, then 24-byte
// entries: {uint64 base; uint64 length; uint32 type; uint32 acpi_ext}.
// The kernel can read the same addresses later; the bootloader uses it to
// report detected RAM like any grown-up loader does.
#define E820_COUNT_ADDR (VBE_INFO_ADDR + 0x200)
#define E820_MAP_ADDR (VBE_INFO_ADDR + 0x208)
#define E820_USABLE 1
// NOT include/types.h's uint64: that's `unsigned long`, which is 4 bytes
// in this file's -m32 build and would silently halve the struct.
typedef unsigned long long e820_u64;
struct e820entry {
e820_u64 base;
e820_u64 length;
uint32 type;
uint32 acpi_ext;
} __attribute__((packed));
// Total usable RAM in MB according to the firmware's E820 map.
static uint32
e820_usable_mb(void)
{
uint32 count = *(uint32*)E820_COUNT_ADDR;
struct e820entry *e = (struct e820entry*)E820_MAP_ADDR;
e820_u64 total = 0;
if (count > 32) return 0; // garbage count: BIOS didn't fill the map
for (uint32 i = 0; i < count; i++)
if (e[i].type == E820_USABLE)
total += e[i].length;
return (uint32)(total >> 20);
}
// Minimal unsigned-to-decimal for the memory line; no printf down here.
static char *
utoa10(uint32 v, char *buf)
{
char tmp[12];
int n = 0;
do { tmp[n++] = '0' + v % 10; v /= 10; } while (v);
int i = 0;
while (n) buf[i++] = tmp[--n];
buf[i] = 0;
return buf;
}
struct mbheader {
uint32 magic;
uint32 flags;
uint32 checksum;
uint32 header_addr;
uint32 load_addr;
uint32 load_end_addr;
uint32 bss_end_addr;
uint32 entry_addr;
};
void readseg(uchar*, uint, uint);
static void draw_centered(int y, const char *s, uint32 color);
static void draw_memory_line(int y, uint32 color);
// Which medium the kernel has to be read back from. cdboot.S sets the flag
// this is loaded from; bootasm.S clears it, and 0 means "an ATA disk, the way
// it has always been". The ATAPI machinery itself is down at the bottom of
// the file next to readsect(), which is the only thing that uses it.
#define CD_SECTSIZE 2048
static int g_from_cd;
static int g_atapi_io = 0; // 0 until a drive is found
static int g_atapi_slave = 0;
static uint g_cd_base512 = 0; // where our boot image starts, in 512s
static int atapi_find(void);
static int atapi_boot_base(void);
// --- Framebuffer state ---------------------------------------------------
// Standalone drawing (not the kernel's vga.c): paging isn't set up yet, so
// the LFB physical address read out of the VBE mode info block is used
// directly, no P2V translation needed. Only handles 32bpp (what QEMU's
// std VGA actually returns for the mode this bootloader requests) —
// anything else just skips all drawing rather than risk drawing garbage.
static uint8 *g_fb;
static uint32 g_stride;
static int g_width, g_height;
static int g_have_fb;
static void
fb_init(void)
{
g_stride = *(ushort*)(VBE_INFO_ADDR + 16);
g_width = *(ushort*)(VBE_INFO_ADDR + 18);
g_height = *(ushort*)(VBE_INFO_ADDR + 20);
uchar bpp = *(uchar*)(VBE_INFO_ADDR + 25);
uint32 fbaddr = *(uint32*)(VBE_INFO_ADDR + 40);
g_fb = (uint8*)fbaddr;
g_have_fb = (bpp == 32 && fbaddr && g_width && g_height);
}
static void
draw_pixel(int x, int y, uint32 color)
{
*(uint32*)(g_fb + y * g_stride + x * 4) = color;
}
// --- Boot logo -------------------------------------------------------------
//
// include/logo.h is a 4-bit coverage mask generated from Assets/ by
// tools/mklogo.py (`make logo`). It costs 8 KiB, which is most of the reason
// STAGE2_SECTORS went from 24 to 48.
//
// Colour is SCH_FG, not SCH_ACCENT. The source art is a silver gradient and the
// mask preserves its relative values, so compositing coverage straight to
// brightness reproduces that gradient. The accent stays reserved for the single
// rule under the wordmark -- one spot colour, per include/theme.h.
static void
draw_logo(int x0, int y0)
{
if (!g_have_fb)
return;
for (int dy = 0; dy < LOGO_H; dy++) {
int y = y0 + dy;
if (y < 0 || y >= g_height)
continue;
uint32 *row = (uint32*)(g_fb + y * g_stride);
for (int dx = 0; dx < LOGO_W; dx++) {
int x = x0 + dx;
if (x < 0 || x >= g_width)
continue;
unsigned a = LOGO_INK(dy * LOGO_W + dx);
// Skip empty pixels rather than painting them in the background colour.
// The mask is square but the mark is a circle, so most of it is empty;
// filling that would stamp a visible box onto the splash.
if (a == 0)
continue;
if (a >= 255) {
row[x] = SCH_FG;
continue;
}
// Blend towards SCH_BG per channel. Integer only -- there is no FPU set
// up at this point in boot, and no libgcc to fake one with.
uint32 r = (((SCH_FG >> 16) & 0xFF) * a + ((SCH_BG >> 16) & 0xFF) * (255 - a)) / 255;
uint32 g = (((SCH_FG >> 8) & 0xFF) * a + ((SCH_BG >> 8) & 0xFF) * (255 - a)) / 255;
uint32 b = (((SCH_FG ) & 0xFF) * a + ((SCH_BG ) & 0xFF) * (255 - a)) / 255;
row[x] = (r << 16) | (g << 8) | b;
}
}
}
static void
fill_rect(int x0, int y0, int w, int h, uint32 color)
{
// Compute each row's base pointer once instead of re-deriving it (via a
// multiply + add) for every single pixel through draw_pixel() — this is
// the full-screen splash fill, 480,000 pixels at 800x600, done at least
// twice per boot.
for (int y = y0; y < y0 + h; y++) {
uint32 *row = (uint32*)(g_fb + y * g_stride) + x0;
for (int x = 0; x < w; x++)
row[x] = color;
}
}
static void
draw_char(int x, int y, uchar c, uint32 color, uint32 bg)
{
if (c > 127) return;
for (int row = 0; row < 16; row++) {
uchar bits = font_data[c][row];
for (int col = 0; col < 8; col++)
draw_pixel(x + col, y + row, (bits & (0x80 >> col)) ? color : bg);
}
}
static void
draw_string(int x, int y, const char *s, uint32 color, uint32 bg)
{
while (*s) {
draw_char(x, y, *s, color, bg);
x += 8;
s++;
}
}
// Pads with trailing spaces (drawn in bg) so re-drawing a shorter string
// over a longer one doesn't leave stale glyphs behind.
static void
draw_string_padded(int x, int y, const char *s, uint32 color, uint32 bg, int cols)
{
int n = 0;
while (s[n]) { draw_char(x + n * 8, y, s[n], color, bg); n++; }
while (n < cols) { draw_char(x + n * 8, y, ' ', color, bg); n++; }
}
// --- Keyboard: raw PS/2 scancode polling, no interrupts, no BIOS calls --
// (BIOS INT 16h only works in real mode; we're in protected mode by the
// time any of this runs.) Scancode set 1: F8 make code 0x42, arrows are
// extended (0xE0 prefix byte), Enter 0x1C, digits '1'-'3' are 0x02-0x04.
static int
kbd_haskey(void)
{
return inb(0x64) & 1;
}
static int
kbd_readscancode(void)
{
return inb(0x60);
}
// Blocking read of one logical key: plain scancodes pass through, extended
// (0xE0-prefixed) scancodes come back OR'd with 0x100 so callers can tell
// "up arrow" (0x148) apart from a plain byte that happens to equal 0x48.
static int
kbd_getkey(void)
{
int sc;
for (;;) {
while (!kbd_haskey())
;
sc = kbd_readscancode();
// 0xE0 is the extended-key prefix, not a break code, even though it
// also has bit 7 set — must be checked before the break-code test
// below or every arrow/Home/End/etc. press gets silently swallowed.
if (sc == 0xE0) {
while (!kbd_haskey())
;
sc = kbd_readscancode();
if (sc & 0x80) continue; // extended break code: ignore, try again
return 0x100 | sc;
}
if (sc & 0x80) continue; // plain break code: ignore, try again
return sc;
}
}
// Polls for up to `iterations` busy-loop spins, watching for F8's make
// code. Returns 1 if seen, 0 on timeout. Ignores everything else so a
// stray keypress during the splash doesn't do anything surprising.
static int
wait_for_f8(uint32 iterations)
{
for (volatile uint32 i = 0; i < iterations; i++) {
if (kbd_haskey()) {
int sc = kbd_readscancode();
if (sc == 0x42)
return 1;
}
}
return 0;
}
// --- ATA drive presence (primary channel: master/slave) ------------------
// Standard "floating bus" trick: select the drive, write a distinguishable
// pattern to two data-path registers, and read it back. A missing drive
// leaves the bus floating and the readback won't match.
//
// Only the primary channel (0x1F0) is checked. QEMU's IDE emulation
// answers the same pattern test "successfully" on the secondary channel
// (0x170) even with nothing attached to it — its floating-bus behavior
// doesn't match real hardware closely enough to tell present from absent —
// so showing it would just be confidently wrong. This project only ever
// configures drives on the primary channel (-hda/-hdb) anyway.
static int
ata_present(ushort iobase, int slave)
{
outb(iobase + 6, slave ? 0xB0 : 0xA0);
inb(iobase + 7); inb(iobase + 7); inb(iobase + 7); inb(iobase + 7);
outb(iobase + 2, 0xAA);
outb(iobase + 3, 0x55);
outb(iobase + 2, 0x55);
outb(iobase + 3, 0xAA);
outb(iobase + 2, 0xAA);
outb(iobase + 3, 0x55);
return inb(iobase + 2) == 0xAA && inb(iobase + 3) == 0x55;
}
// --- Real-time load progress bar -----------------------------------------
#define BAR_WIDTH 400
#define BAR_HEIGHT 18
// --- Splash layout -------------------------------------------------------
//
// Every vertical position on the splash derives from here. It used to be a
// handful of independent offsets from the screen centre -- wordmark at cy-18,
// rule at cy+6, bar at cy+30 -- which was fine while the tallest element was a
// line of text. Adding a 128px mark on top of that stack pushed the group's
// optical centre about 80px above the screen's, so the whole composition read
// as top-heavy with a large void underneath.
//
// Now the group is measured and centred as a unit. The progress bar counts
// towards that height even though it is only on screen during the kernel load:
// if it did not, everything would jump upwards the moment the bar appeared.
#define SPLASH_MARK_GAP 24 // mark to wordmark
#define SPLASH_TEXT_H 16 // one line of the 8x16 font
#define SPLASH_RULE_GAP 8 // wordmark to rule
#define SPLASH_RULE_H 2
#define SPLASH_BAR_GAP 30 // rule to bar; the bar's label lives in here
#define SPLASH_GROUP_H (LOGO_H + SPLASH_MARK_GAP + SPLASH_TEXT_H + \
SPLASH_RULE_GAP + SPLASH_RULE_H + SPLASH_BAR_GAP + BAR_HEIGHT)
#define SPLASH_MARK_Y ((g_height - SPLASH_GROUP_H) / 2)
#define SPLASH_TEXT_Y (SPLASH_MARK_Y + LOGO_H + SPLASH_MARK_GAP)
#define SPLASH_RULE_Y (SPLASH_TEXT_Y + SPLASH_TEXT_H + SPLASH_RULE_GAP)
#define SPLASH_BAR_Y (SPLASH_RULE_Y + SPLASH_RULE_H + SPLASH_BAR_GAP)
static uint32 g_load_total, g_load_done;
static int g_progress_active;
static int g_bar_x, g_bar_y;
static int g_last_pct;
static void
progress_label(const char *label)
{
draw_string_padded(g_bar_x, g_bar_y - 22, label, SCH_ACCENT, SCH_BG, BAR_WIDTH / 8);
}
// Border never changes once drawn — separated out so it's only ever drawn
// once per bar instead of being redrawn on every single tick.
static void
progress_border(void)
{
for (int x = g_bar_x; x < g_bar_x + BAR_WIDTH; x++) {
draw_pixel(x, g_bar_y, SCH_FG);
draw_pixel(x, g_bar_y + BAR_HEIGHT, SCH_FG);
}
for (int y = g_bar_y; y <= g_bar_y + BAR_HEIGHT; y++) {
draw_pixel(g_bar_x, y, SCH_FG);
draw_pixel(g_bar_x + BAR_WIDTH, y, SCH_FG);
}
}
static void
progress_fill(int pct)
{
if (pct > 100) pct = 100;
int fillw = (BAR_WIDTH - 4) * pct / 100;
for (int y = g_bar_y + 2; y < g_bar_y + BAR_HEIGHT - 1; y++)
for (int x = g_bar_x + 2; x < g_bar_x + BAR_WIDTH - 2; x++)
draw_pixel(x, y, (x - g_bar_x - 2 < fillw) ? SCH_ACCENT : SCH_BG);
}
static void
progress_start(const char *label, uint32 total)
{
if (!g_have_fb) return;
g_bar_x = (g_width - BAR_WIDTH) / 2;
g_bar_y = SPLASH_BAR_Y;
g_load_total = total;
g_load_done = 0;
g_last_pct = -1;
g_progress_active = 1;
progress_label(label);
progress_border();
progress_fill(0);
g_last_pct = 0;
}
static void
progress_tick(uint32 n)
{
if (!g_have_fb || !g_progress_active) return;
g_load_done += n;
// Deliberately all 32-bit. The uint64 cast this used to carry was there to
// stop `done * 100` overflowing, but a 64-bit divide in 32-bit code calls
// libgcc's __udivdi3, which a freestanding bootloader does not link -- it
// only ever built because uint64 was accidentally 32 bits wide here.
//
// Scaling both sides to KiB first keeps the multiply inside 32 bits for any
// image up to 4 GiB, which is comfortably more than a kernel will ever be.
uint32 done_k = g_load_done >> 10;
uint32 total_k = g_load_total >> 10;
uint32 pct = total_k ? (done_k * 100) / total_k : 0;
if (pct > 100) pct = 100;
// A full-bar redraw is a few thousand pixel writes; without this, a
// ~330KB kernel image redraws it ~650 times (once per 512-byte sector)
// instead of at most 100 (once per percentage point) — the single
// biggest chunk of the bootloader's own wall-clock time.
if ((int)pct == g_last_pct) return;
g_last_pct = (int)pct;
progress_fill((int)pct);
}
// --- Boot menu -------------------------------------------------------
static void
reboot_to_firmware(void)
{
if (g_have_fb) {
fill_rect(0, 0, g_width, g_height, SCH_BG);
draw_string((g_width - 26 * 8) / 2, g_height / 2 - 8,
"Rebooting to firmware...", SCH_ACCENT, SCH_BG);
}
outb(0x64, 0xFE); // pulse the keyboard controller's reset line
for (;;)
;
}
static void
show_drive_line(int row, const char *label, int present)
{
draw_string(g_width / 2 - 160, 340 + row * 20, label, SCH_FG, SCH_BG);
draw_string(g_width / 2 + 20, 340 + row * 20,
present ? "present" : "not present",
present ? SCH_ACCENT : SCH_DIM, SCH_BG);
}
// Returns 1 for "boot verbose", 0 for "boot normal". Only returns at all
// once the user picks a boot option; "reboot" never returns.
static int
boot_menu(void)
{
const char *items[] = {
"Boot SchOS",
"Boot SchOS (verbose)",
"Reboot to firmware setup",
};
int nitems = 3;
int selected = 0;
int hda = ata_present(0x1F0, 0);
int hdb = ata_present(0x1F0, 1);
for (;;) {
fill_rect(0, 0, g_width, g_height, SCH_BG);
draw_centered(60, "SchOS Boot Manager", SCH_ACCENT);
draw_memory_line(84, SCH_DIM);
for (int i = 0; i < nitems; i++) {
uint32 fg = (i == selected) ? SCH_ACCENT : SCH_FG;
draw_string(g_width / 2 - 140, 140 + i * 24, (i == selected) ? "> " : " ", fg, SCH_BG);
draw_string(g_width / 2 - 124, 140 + i * 24, items[i], fg, SCH_BG);
}
draw_string(g_width / 2 - 160, 300, "Detected drives:", SCH_FG, SCH_BG);
show_drive_line(0, "Primary Master (hda)", hda);
show_drive_line(1, "Primary Slave (hdb)", hdb);
draw_string(g_width / 2 - 168, g_height - 40,
"Up/Down to choose, Enter to select", SCH_DIM, SCH_BG);
int key = kbd_getkey();
if (key == 0x148) selected = (selected + nitems - 1) % nitems; // up
else if (key == 0x150) selected = (selected + 1) % nitems; // down
else if (key == 0x02) selected = 0; // '1'
else if (key == 0x03) selected = 1; // '2'
else if (key == 0x04) { reboot_to_firmware(); } // '3'
else if (key == 0x1C) { // Enter
if (selected == 2)
reboot_to_firmware();
return selected == 1;
}
}
}
// Centered one-liner helper; strlen computed here so callers stop
// hand-counting characters (three of them had it wrong at some point).
static void
draw_centered(int y, const char *s, uint32 color)
{
int n = 0;
while (s[n]) n++;
draw_string((g_width - n * 8) / 2, y, s, color, SCH_BG);
}
// "Memory: NNNN MB" — assembled by hand, no printf in the bootloader.
static void
draw_memory_line(int y, uint32 color)
{
char line[32];
char num[12];
int i = 0;
const char *pfx = "Memory: ";
while (*pfx) line[i++] = *pfx++;
utoa10(e820_usable_mb(), num);
for (int j = 0; num[j]; j++) line[i++] = num[j];
const char *sfx = " MB";
while (*sfx) line[i++] = *sfx++;
line[i] = 0;
draw_centered(y, line, color);
}
static void
splash(void)
{
if (!g_have_fb) return;
fill_rect(0, 0, g_width, g_height, SCH_BG);
// The product, not the loader.
//
// This used to read "SchOS Boot Manager / version 1.0 / Memory: 1023 MB /
// Press F8 for boot menu...", centred, in gold on purple. Every part of that
// is something a system shows its developer rather than its user: the name
// of the component doing the work, its version, and a memory figure nobody
// asked for. No shipping OS names its bootloader on the splash.
//
// What is left is the name, a rule in the accent colour, and the one hint
// that is actually actionable -- moved to the bottom of the screen, where a
// hint belongs, rather than sitting under the title as though it were part
// of the identity.
// The mark, drawn here in the loader rather than by the kernel for a timing
// reason: the kernel does not reach vga_init() until the last few hundred
// milliseconds of a ~2.8s boot, so a kernel-drawn logo appeared for about one
// frame before console_takeover() wiped it. Here it is up for the whole boot.
draw_logo((g_width - LOGO_W) / 2, SPLASH_MARK_Y);
draw_centered(SPLASH_TEXT_Y, "S C H U N I X", SCH_FG);
// A 120x2 rule, centred, in the accent -- slightly wider than the 104px
// wordmark so it reads as an underline rather than a strikethrough. Two
// pixels rather than one: a single-pixel line looks like a rendering
// artefact at this size.
fill_rect((g_width - 120) / 2, SPLASH_RULE_Y, 120, SPLASH_RULE_H, SCH_ACCENT);
draw_centered(g_height - 40, "Press F8 for boot options", SCH_DIM);
}
void
stage2main(void)
{
struct mbheader *hdr;
uint32 *x = (uint32*) 0x10000;
uint n;
int verbose = 0;
fb_init();
// Sort out where the kernel is coming from before anything is drawn, so a
// disc we cannot read says so on the splash instead of failing silently
// several seconds later in the middle of the progress bar.
if (*(uchar*)BOOT_MEDIA_FLAG_ADDR == BOOT_MEDIA_CD) {
if (atapi_find())
g_cd_base512 = atapi_boot_base() * (CD_SECTSIZE / SECTSIZE);
g_from_cd = (g_atapi_io != 0 && g_cd_base512 != 0);
}
splash();
// ~0.6s at QEMU TCG speeds (measured), watching for F8. Shortened from
// ~1s (5000000) — still enough time to react to, but it's flat overhead
// on every single boot whether or not anyone wants the menu.
if (wait_for_f8(3000000))
verbose = boot_menu();
*(uchar*)BOOT_VERBOSE_FLAG_ADDR = verbose;
if (g_have_fb) {
fill_rect(0, 0, g_width, g_height, SCH_BG);
draw_string((g_width - 7 * 8) / 2, g_height / 2 - 50, "SCHOS", SCH_ACCENT, SCH_BG);
}
// Booted from a disc we then could not read. Falling through would send
// readseg() at the primary ATA channel, which on a CD-only machine is empty
// -- so it would grind through a bounded wait per sector and then jump into
// whatever the uninitialised buffer happened to hold. Stopping with a
// legible reason is the only useful thing left to do. This is drawn after
// the clear above on purpose: an earlier version put it on the splash,
// where it was wiped a moment later and nobody ever saw it.
if (*(uchar*)BOOT_MEDIA_FLAG_ADDR == BOOT_MEDIA_CD && !g_from_cd) {
draw_centered(g_height / 2, "No readable boot disc found.", SCH_ACCENT);
draw_centered(g_height / 2 + 20, "The drive did not answer ATAPI.", SCH_DIM);
for (;;)
asm volatile("hlt");
}
if (g_have_fb)
progress_start("Detecting kernel...", 16384);
// Read 32 sectors (16KB) looking for the multiboot header.
readseg((uchar*)x, 16384, 0);
for (n = 0; n < 16384/4; n++) {
if (x[n] == 0x1BADB002) {
if ((x[n] + x[n+1] + x[n+2]) == 0) {
hdr = (struct mbheader *) (x + n);
if (g_have_fb)
progress_start("Loading kernel...", hdr->load_end_addr - hdr->load_addr);
// Load the actual kernel segments
readseg((uchar*) hdr->load_addr,
hdr->load_end_addr - hdr->load_addr,
(n * 4) - (hdr->header_addr - hdr->load_addr));
// Zero out BSS
if (hdr->bss_end_addr > hdr->load_end_addr)
stosb((void*) hdr->load_end_addr, 0, hdr->bss_end_addr - hdr->load_end_addr);
if (g_have_fb)
progress_label("Starting SchOS...");
// Jump to kernel entry. NOT a normal C call: kernel/arch/x86_64/
// entry.S's mboot_entry reads its "argument" (the framebuffer
// physical address) out of EAX directly, not off the stack — a
// multiboot-style raw-register handoff, not cdecl. A plain
// `((void(*)(uint32))entry)(fbaddr)` pushes fbaddr on the stack
// per cdecl and leaves EAX as whatever happened to be lying around;
// it only ever "worked" because simpler bootmain.c code coincidentally
// left the right value in EAX as a side effect of its own codegen.
// Adding the boot-menu/progress-bar code changed that codegen and
// broke it silently (kernel received garbage instead of the real
// LFB address). Pin it down explicitly instead of hoping.
{
uint32 fbaddr = *(uint32*)(VBE_INFO_ADDR + 40);
uint32 entry = hdr->entry_addr;
asm volatile("jmp *%0" : : "r"(entry), "a"(fbaddr));
}
}
}
}
}
// Bounded, because there might not be a disk there at all.
//
// This used to be `while((inb(0x1F7) & 0xC0) != 0x40);`. On a machine with no
// ATA drive on the primary channel the status register reads 0xFF forever and
// the loader hung on a splash screen with no message and no way to tell what
// had gone wrong -- which is exactly what happened the first time a CD boot
// fell back to this path. A bounded wait turns that into a failed read, and
// stage2main() says so on screen.
void waitdisk(void) {
for (int i = 0; i < 4000000; i++)
if ((inb(0x1F7) & 0xC0) == 0x40)
return;
}
void readsect_ata(void *dst, uint offset) {
waitdisk();
outb(0x1F2, 1);
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20);
waitdisk();
insl(0x1F0, dst, SECTSIZE/4);
}
// ---------------------------------------------------------------------------
// ATAPI -- reading the kernel back off a CD.
//
// An optical drive does not answer the ATA commands above. It speaks the
// packet interface: you hand it a SCSI command descriptor block through the
// data port and it hands back 2048-byte logical blocks. The register file and
// the PIO loop are otherwise the same, which is why this lives here rather
// than forcing the loader back into real mode to use INT 13h -- by the time
// the kernel is read, stage2.S has long since switched to protected mode and
// the BIOS is gone.
// ---------------------------------------------------------------------------
static void bcopy(void *dst, const void *src, uint n) {
uchar *d = dst;
const uchar *s = src;
while (n--) *d++ = *s++;
}
// A channel has to be reset before its signature means anything.
//
// ATAPI devices report themselves by leaving 0x14 in LBA-mid and 0xEB in
// LBA-high -- but only until something issues a command, after which those
// registers hold that command's byte count instead. The BIOS has just
// finished reading this very disc in order to load us, so on a CD boot the
// signature is *always* stale by the time we look. Resetting the channel is
// what puts it back.
static void atapi_reset(int io) {
int ctrl = io + 0x206; // 0x1F0 -> 0x3F6, 0x170 -> 0x376
outb(ctrl, 0x04); // SRST
for (int i = 0; i < 10000; i++) // hold it well past the required 5us
inb(ctrl);
outb(ctrl, 0x00);
for (int i = 0; i < 1000000; i++) // bounded: an empty channel reads 0xFF
if (!(inb(io + 7) & 0x80)) // and would otherwise spin forever
break;
}
static int atapi_find(void) {
static const int bases[2] = { 0x1F0, 0x170 };
for (int b = 0; b < 2; b++) {
atapi_reset(bases[b]);
for (int s = 0; s < 2; s++) {
outb(bases[b] + 6, s ? 0xB0 : 0xA0);
// Four throwaway status reads: the standard 400ns settle after a drive
// select, before the register file is meaningful.
inb(bases[b] + 7); inb(bases[b] + 7);
inb(bases[b] + 7); inb(bases[b] + 7);
if (inb(bases[b] + 4) == 0x14 && inb(bases[b] + 5) == 0xEB) {
g_atapi_io = bases[b];
g_atapi_slave = s;
return 1;
}
}
}
return 0;
}
// One 2048-byte logical block. Returns 0 on success.
static int atapi_read_block(uint lba, void *dst) {
int io = g_atapi_io;
uchar cdb[12];
int i;
outb(io + 6, g_atapi_slave ? 0xB0 : 0xA0);
inb(io + 7); inb(io + 7); inb(io + 7); inb(io + 7);
outb(io + 1, 0); // features: PIO, no DMA
outb(io + 4, CD_SECTSIZE & 0xFF); // how much we are willing to take
outb(io + 5, CD_SECTSIZE >> 8);
outb(io + 7, 0xA0); // PACKET
// Wait for the drive to ask for the command packet: BSY clear, DRQ set.
for (i = 0; i < 0x1000000; i++) {
uchar st = inb(io + 7);
if (st & 0x01) return -1; // ERR
if (!(st & 0x80) && (st & 0x08)) break;
}
if (i == 0x1000000) return -1;
// READ(12). Big-endian LBA and transfer length, because SCSI is.
for (i = 0; i < 12; i++) cdb[i] = 0;
cdb[0] = 0xA8;
cdb[2] = (uchar)(lba >> 24);
cdb[3] = (uchar)(lba >> 16);
cdb[4] = (uchar)(lba >> 8);
cdb[5] = (uchar)lba;
cdb[9] = 1; // one block
outsl(io, cdb, 3); // 12 bytes = 3 dwords
// Wait for the data itself.
for (i = 0; i < 0x1000000; i++) {
uchar st = inb(io + 7);
if (st & 0x01) return -1;
if (!(st & 0x80) && (st & 0x08)) break;
}
if (i == 0x1000000) return -1;
insl(io, dst, CD_SECTSIZE / 4);
return 0;
}
// Where our boot image sits on the disc.
//
// Sector numbers on a CD are absolute, but readseg() counts from the start of
// the boot image -- which on a hard disk is the start of the disk and on a CD
// is wherever the ISO's filesystem happened to leave room. El Torito writes
// that address down, so we read it rather than guess:
//
// LBA 17 Boot Record Volume Descriptor, holding a pointer at offset 0x47
// catalog validation entry (32 bytes), then the default entry, whose
// "Load RBA" at offset 8 is the boot image's LBA
//
// Returns 0 if the disc does not look like something we booted from.
static int atapi_boot_base(void) {
static uchar buf[CD_SECTSIZE];
uint catalog, image;
if (atapi_read_block(17, buf) != 0)
return 0;
if (buf[0] != 0 || buf[1] != 'C' || buf[2] != 'D' || buf[3] != '0' ||
buf[4] != '0' || buf[5] != '1')
return 0;
catalog = (uint)buf[0x47] | ((uint)buf[0x48] << 8) |
((uint)buf[0x49] << 16) | ((uint)buf[0x4A] << 24);
if (catalog == 0)
return 0;
if (atapi_read_block(catalog, buf) != 0)
return 0;
if (buf[32] != 0x88) // default entry not marked bootable
return 0;
image = (uint)buf[40] | ((uint)buf[41] << 8) |
((uint)buf[42] << 16) | ((uint)buf[43] << 24);
return image;
}
// A CD block holds four 512-byte sectors, and readseg() asks for them one at
// a time and in order -- so caching the last block turns four reads into one.
// Without it every byte of the kernel would be fetched four times.
static uchar g_cd_cache[CD_SECTSIZE];
static uint g_cd_cached = 0xFFFFFFFF;
static void readsect_atapi(void *dst, uint offset) {
uint abs = offset + g_cd_base512;
uint blk = abs / (CD_SECTSIZE / SECTSIZE);
uint sub = abs % (CD_SECTSIZE / SECTSIZE);
if (blk != g_cd_cached) {
if (atapi_read_block(blk, g_cd_cache) != 0) {
// Nothing useful to do here: there is no console yet and no way back.
// Leaving the buffer alone means the multiboot search simply fails to
// match, which stops with the splash up rather than jumping to garbage.
return;
}
g_cd_cached = blk;
}
bcopy(dst, g_cd_cache + sub * SECTSIZE, SECTSIZE);
}
void readsect(void *dst, uint offset) {
if (g_from_cd)
readsect_atapi(dst, offset);
else
readsect_ata(dst, offset);
progress_tick(SECTSIZE);
}
void readseg(uchar* pa, uint count, uint offset) {
uchar* epa = pa + count;
pa -= offset % SECTSIZE;
// Kernel starts right after stage2 (1 boot sector + STAGE2_SECTORS
// stage2 sectors), not immediately after just the boot sector.
offset = (offset / SECTSIZE) + (1 + STAGE2_SECTORS);
for(; pa < epa; pa += SECTSIZE, offset++)
readsect(pa, offset);
}