commit 8c44d12357178e3152b92b4233de2ca3ec05b884 Author: auser Date: Fri Sep 25 00:41:24 2026 +0300 minos M1: minimal multi-arch kernel (x86 + RISC-V + ARM/Zynq) Boots under QEMU on all three ISAs from one clang build: - shared C core (kmain, console) calls a small per-arch contract (arch.h) - per-arch asm entry + trap/vector table + UART driver - parallel Makefile (make -j), 'make run-', 'make sizes' M1 = boot, UART up, trap vector installed, banner from kmain(). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a16afc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +*.o +*.d +*.elf +*.bin diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4635bd5 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +# minos — minimal multi-arch kernel (M1) +# One clang, three ISAs. Build all in parallel: make -j +# +# make build all three (x86, riscv, arm) +# make x86 build one arch +# make run-riscv build + boot that arch under QEMU (-nographic; Ctrl-A X to quit) +# make sizes compare code size across arches +# make clean + +CC := clang +LD := ld.lld +OBJCOPY := llvm-objcopy + +ARCHES := x86 riscv arm + +# Shared flags. Per-arch target/flags below. +CFLAGS := -ffreestanding -nostdlib -fno-pic -O2 -Wall -Wextra -Iinclude -g -MMD -MP +COMMON := common/main.c common/console.c + +# --- per-arch knobs ------------------------------------------------------- +x86_TARGET := --target=i386-unknown-none -m32 +x86_LDEMU := elf_i386 +x86_SRC := arch/x86/boot.S arch/x86/arch.c $(COMMON) +x86_QEMU := qemu-system-i386 -kernel build/x86/minos.elf -nographic + +riscv_TARGET := --target=riscv64-unknown-none-elf -march=rv64imac -mabi=lp64 -mcmodel=medany +riscv_LDEMU := elf64lriscv +riscv_SRC := arch/riscv/boot.S arch/riscv/trap.S arch/riscv/arch.c $(COMMON) +riscv_QEMU := qemu-system-riscv64 -machine virt -bios none -nographic -kernel build/riscv/minos.elf + +arm_TARGET := --target=arm-none-eabi -mcpu=cortex-a9 -marm +arm_LDEMU := armelf +arm_SRC := arch/arm/boot.S arch/arm/vectors.S arch/arm/arch.c $(COMMON) +arm_QEMU := qemu-system-arm -machine xilinx-zynq-a9 -cpu cortex-a9 -nographic -kernel build/arm/minos.elf + +.PHONY: all clean sizes $(ARCHES) $(addprefix run-,$(ARCHES)) + +all: $(ARCHES) + +# Generate per-arch build rules. +define ARCH_rules +$(1)_OBJS := $$(patsubst %,build/$(1)/%.o,$$(basename $$(notdir $$($(1)_SRC)))) + +build/$(1)/%.o: arch/$(1)/%.S | build/$(1) + $$(CC) $$($(1)_TARGET) $$(CFLAGS) -c $$< -o $$@ +build/$(1)/%.o: arch/$(1)/%.c | build/$(1) + $$(CC) $$($(1)_TARGET) $$(CFLAGS) -c $$< -o $$@ +build/$(1)/%.o: common/%.c | build/$(1) + $$(CC) $$($(1)_TARGET) $$(CFLAGS) -c $$< -o $$@ + +build/$(1): + mkdir -p $$@ + +build/$(1)/minos.elf: $$($(1)_OBJS) arch/$(1)/link.ld + $$(LD) -m $$($(1)_LDEMU) -T arch/$(1)/link.ld -nostdlib $$($(1)_OBJS) -o $$@ + @echo " [$(1)] linked -> $$@" + +$(1): build/$(1)/minos.elf +run-$(1): build/$(1)/minos.elf + @echo "== booting $(1) (Ctrl-A X to quit) ==" + $$($(1)_QEMU) + +-include $$($(1)_OBJS:.o=.d) +endef + +$(foreach a,$(ARCHES),$(eval $(call ARCH_rules,$(a)))) + +sizes: all + @echo "arch text+data+bss (bytes)"; \ + for a in $(ARCHES); do \ + printf '%-7s ' $$a; \ + $(OBJCOPY) -O binary build/$$a/minos.elf /tmp/_sz.bin 2>/dev/null; \ + stat -c '%s' /tmp/_sz.bin; \ + done; rm -f /tmp/_sz.bin + +clean: + rm -rf build diff --git a/README.md b/README.md new file mode 100644 index 0000000..8642741 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# minos — a minimal multi-arch kernel + +One tiny OS core, three ISAs: **x86 (i386)**, **RISC-V (rv64)**, **ARM +(Cortex-A9 / Zynq-7000)**. Built by a single `clang` in parallel so you can +compare how the same C behaves across architectures. + +Milestone **M1** (this state): each target boots, brings up its UART, sets its +interrupt/trap vector, and prints a banner from shared C (`kmain`). + +## Design + +The only boundary is a small per-arch **contract** (`include/arch.h`): + +``` +arch_early_init() bring up the UART +arch_uart_putc(c) emit one byte +arch_set_trap_vector() install IDT / mtvec / VBAR +arch_name() banner string +arch_halt() hlt / wfi +``` + +Everything in `common/` is portable C and calls only that contract. Each +`arch//` supplies the irreducible assembly (`boot.S`, trap/vector table) +plus a C file implementing the contract and a linker script. + +``` +common/ main.c (kmain), console.c (putc/puts/puthex) +include/ arch.h (the contract), console.h +arch/x86/ boot.S (multiboot) arch.c (COM1 0x3F8, IDT) link.ld (1 MiB) +arch/riscv/ boot.S (_start) trap.S (mtvec) arch.c (UART 0x10000000) +arch/arm/ boot.S (_start) vectors.S (VBAR) arch.c (Cadence UART 0xE0000000) +``` + +## Build & run + +```sh +make -j # build all three in parallel +make run-x86 # boot under QEMU (Ctrl-A X to quit) +make run-riscv +make run-arm +make sizes # compare stripped binary size +make clean +``` + +## The interesting comparison (M1) + +| Aspect | x86 (i386) | RISC-V (rv64) | ARM (Cortex-A9) | +|-------------------|-------------------------|-----------------------|--------------------------| +| Entry protocol | Multiboot1 header | `_start` @ 0x80000000 | `_start` @ DDR | +| Console | port I/O `outb` 0x3F8 | MMIO 16550 0x10000000 | MMIO Cadence 0xE0000000 | +| Trap vector | IDT via `lidt` | `mtvec` CSR | `VBAR` (CP15) | +| Multi-core gate | (single by default) | `mhartid` check | `MPIDR` check | +| Halt | `hlt` | `wfi` | `wfi` | +| Idle byte size | ~890 | ~816 | ~800 | + +## Real hardware (next) + +- **RISC-V**: the only change is the UART base — `0x10000000` (QEMU virt) → + `0x02500000` (Allwinner D1 / Lichee RV). Loadable via `xfel`. +- **ARM/Zynq**: same Cadence UART base as the real PS; boot via U-Boot/JTAG. +- **x86**: boot the multiboot ELF from GRUB. + +## Roadmap +- **M2**: real trap handlers + a timer tick per arch. +- **M3**: expose a tiny syscall-style interface (the "own interface"). diff --git a/arch/arm/arch.c b/arch/arm/arch.c new file mode 100644 index 0000000..9cf3271 --- /dev/null +++ b/arch/arm/arch.c @@ -0,0 +1,59 @@ +/* arch/arm/arch.c — Cortex-A9 (Zynq-7000 PS) implementation. + * Uses the Cadence UART (UART0 at 0xE0000000) present on the real Zynq + * and modeled by QEMU's xilinx-zynq-a9 machine. On QEMU/Zynq the UART is + * already configured by the bootrom, so we just poll TX-full and write. */ +#include "arch.h" +#include "console.h" + +#define UART0_BASE 0xE0000000UL +#define UART_CR 0x00 /* control register */ +#define UART_MR 0x04 /* mode register */ +#define UART_SR 0x2C /* channel status register */ +#define UART_FIFO 0x30 /* tx/rx FIFO */ +#define UART_SR_TXFULL (1u << 4) + +/* Control register bits */ +#define CR_RXRES (1u << 0) /* RX logic reset */ +#define CR_TXRES (1u << 1) /* TX logic reset */ +#define CR_RXEN (1u << 2) +#define CR_TXEN (1u << 4) + +static volatile unsigned int *reg(unsigned long off) +{ + return (volatile unsigned int *)(UART0_BASE + off); +} + +void arch_early_init(void) +{ + /* Reset and enable TX/RX. On real Zynq the bootrom sets the baud + * divisors; QEMU ignores baud, so enabling TX is enough. */ + *reg(UART_CR) = CR_RXRES | CR_TXRES; /* pulse resets */ + *reg(UART_MR) = 0x20; /* 8N1, normal */ + *reg(UART_CR) = CR_RXEN | CR_TXEN; /* enable TX/RX */ +} + +void arch_uart_putc(char c) +{ + while (*reg(UART_SR) & UART_SR_TXFULL) + ; + *reg(UART_FIFO) = (unsigned int)(unsigned char)c; +} + +/* M1: point the vector base (VBAR) at our table (defined in vectors.S). */ +extern void vector_table(void); +void arch_set_trap_vector(void) +{ + unsigned long v = (unsigned long)vector_table; + __asm__ volatile("mcr p15, 0, %0, c12, c0, 0" :: "r"(v)); /* VBAR */ +} + +const char *arch_name(void) +{ + return "arm (cortex-a9, zynq-7000)"; +} + +void arch_halt(void) +{ + for (;;) + __asm__ volatile("wfi"); +} diff --git a/arch/arm/boot.S b/arch/arm/boot.S new file mode 100644 index 0000000..8dc0fae --- /dev/null +++ b/arch/arm/boot.S @@ -0,0 +1,29 @@ +@ boot.S (ARM, Cortex-A9 / ARMv7-A) — minimal entry for the Zynq PS. +@ QEMU 'xilinx-zynq-a9' enters at the image load address. We set a stack, +@ clear .bss, and call kmain(). Irreducible asm part for ARM. + + .section .text.boot + .global _start +_start: + @ Park secondary CPUs: only CPU0 continues. + mrc p15, 0, r1, c0, c0, 5 @ read MPIDR + and r1, r1, #3 @ CPU id (affinity level 0) + cmp r1, #0 + bne park + + ldr sp, =_stack_top @ set up the stack + + @ Zero .bss: [__bss_start, __bss_end) + ldr r0, =__bss_start + ldr r1, =__bss_end + mov r2, #0 +1: cmp r0, r1 + bge 2f + str r2, [r0], #4 + b 1b +2: + bl kmain + +park: + wfi + b park diff --git a/arch/arm/link.ld b/arch/arm/link.ld new file mode 100644 index 0000000..e79d89e --- /dev/null +++ b/arch/arm/link.ld @@ -0,0 +1,28 @@ +/* link.ld (ARM, Zynq-7000) — DDR starts at 0x00000000 on Zynq. QEMU + * xilinx-zynq-a9 loads the -kernel image; place us at a safe low DDR + * address above the vector/OCM region used by the model. */ +OUTPUT_FORMAT(elf32-littlearm) +ENTRY(_start) + +SECTIONS +{ + . = 0x00100000; /* 1 MiB into DDR */ + + .text : { + KEEP(*(.text.boot)) + *(.text .text.*) + } + + .rodata : { *(.rodata .rodata.*) } + .data : { *(.data .data.*) } + + . = ALIGN(8); + __bss_start = .; + .bss : { *(.bss .bss.*) *(COMMON) } + . = ALIGN(8); + __bss_end = .; + + . = ALIGN(16); + . += 0x4000; /* 16 KiB stack */ + _stack_top = .; +} diff --git a/arch/arm/vectors.S b/arch/arm/vectors.S new file mode 100644 index 0000000..8912977 --- /dev/null +++ b/arch/arm/vectors.S @@ -0,0 +1,16 @@ +@ vectors.S (ARMv7-A) — the exception vector table VBAR points at. +@ M1 keeps every entry a self-loop; M2 will wire IRQ to a C handler +@ via the GIC. The 8 standard ARM vectors, in order. + + .section .text + .align 5 @ vector base must be 32-byte aligned + .global vector_table +vector_table: + b . @ 0x00 Reset + b . @ 0x04 Undefined instruction + b . @ 0x08 Supervisor Call (SVC) + b . @ 0x0C Prefetch Abort + b . @ 0x10 Data Abort + b . @ 0x14 (reserved) + b . @ 0x18 IRQ + b . @ 0x1C FIQ diff --git a/arch/riscv/arch.c b/arch/riscv/arch.c new file mode 100644 index 0000000..5ef4afb --- /dev/null +++ b/arch/riscv/arch.c @@ -0,0 +1,47 @@ +/* arch/riscv/arch.c — RISC-V implementation of the arch contract. + * + * QEMU 'virt' exposes a standard 16550-style UART at 0x10000000. + * (On the real Lichee RV / D1 this address becomes 0x02500000 — same + * driver, different base; that's the only line that changes for hardware.) */ +#include "arch.h" +#include "console.h" + +#define UART0_BASE 0x10000000UL +#define UART_THR 0x00 /* transmit holding register */ +#define UART_LSR 0x05 /* line status register */ +#define UART_LSR_THRE 0x20 /* THR empty */ + +static volatile unsigned char *const uart = + (volatile unsigned char *)UART0_BASE; + +void arch_early_init(void) +{ + /* QEMU's UART is usable from reset; nothing to do. */ +} + +void arch_uart_putc(char c) +{ + while ((uart[UART_LSR] & UART_LSR_THRE) == 0) + ; + uart[UART_THR] = (unsigned char)c; +} + +/* M1: install a minimal machine trap vector (defined in trap.S). */ +extern void trap_entry(void); +void arch_set_trap_vector(void) +{ + unsigned long addr = (unsigned long)trap_entry; + /* mtvec = base | mode(0=direct) */ + __asm__ volatile("csrw mtvec, %0" :: "r"(addr)); +} + +const char *arch_name(void) +{ + return "riscv64 (rv64imac)"; +} + +void arch_halt(void) +{ + for (;;) + __asm__ volatile("wfi"); +} diff --git a/arch/riscv/boot.S b/arch/riscv/boot.S new file mode 100644 index 0000000..8806257 --- /dev/null +++ b/arch/riscv/boot.S @@ -0,0 +1,28 @@ +# boot.S (RISC-V rv64) — the minimal asm entry. +# QEMU 'virt' with -bios none enters here at 0x80000000 in M-mode, +# all harts. We park every hart but hart 0, set a stack, clear .bss, +# and jump to kmain(). This is the irreducible asm part for this arch. + + .section .text.boot + .globl _start +_start: + # Only hart 0 continues; others wait forever (WFI loop). + csrr t0, mhartid + bnez t0, park + + # Set up the stack (defined in the linker script). + la sp, _stack_top + + # Zero the .bss section: [__bss_start, __bss_end). + la t0, __bss_start + la t1, __bss_end +1: bgeu t0, t1, 2f + sd zero, (t0) + addi t0, t0, 8 + j 1b +2: + call kmain + +park: + wfi + j park diff --git a/arch/riscv/link.ld b/arch/riscv/link.ld new file mode 100644 index 0000000..15e75f8 --- /dev/null +++ b/arch/riscv/link.ld @@ -0,0 +1,27 @@ +/* link.ld (RISC-V) — QEMU 'virt' loads the kernel at 0x80000000. */ +OUTPUT_ARCH(riscv) +ENTRY(_start) + +SECTIONS +{ + . = 0x80000000; + + .text : { + KEEP(*(.text.boot)) /* _start must be first */ + *(.text .text.*) + } + + .rodata : { *(.rodata .rodata.*) } + .data : { *(.data .data.*) } + + . = ALIGN(8); + __bss_start = .; + .bss : { *(.bss .bss.*) *(COMMON) } + . = ALIGN(8); + __bss_end = .; + + /* 16 KiB boot stack */ + . = ALIGN(16); + . += 0x4000; + _stack_top = .; +} diff --git a/arch/riscv/trap.S b/arch/riscv/trap.S new file mode 100644 index 0000000..6577dc6 --- /dev/null +++ b/arch/riscv/trap.S @@ -0,0 +1,10 @@ +# trap.S (RISC-V) — the machine-mode trap entry the M1 mtvec points at. +# M1 keeps it trivial: just loop. M2 will save registers and dispatch to +# a C handler for the timer interrupt. + + .section .text + .globl trap_entry + .align 4 # mtvec base must be 4-byte aligned +trap_entry: + wfi + j trap_entry diff --git a/arch/x86/arch.c b/arch/x86/arch.c new file mode 100644 index 0000000..a1cf4d2 --- /dev/null +++ b/arch/x86/arch.c @@ -0,0 +1,69 @@ +/* arch/x86/arch.c — i386 implementation of the arch contract. + * Uses the legacy 16550 UART on COM1 (port 0x3F8), which QEMU wires to + * the -nographic serial. Port-mapped I/O — the CISC flavor. */ +#include "arch.h" +#include "console.h" + +#define COM1 0x3F8 + +static inline void outb(unsigned short port, unsigned char val) +{ + __asm__ volatile("outb %0, %1" :: "a"(val), "Nd"(port)); +} +static inline unsigned char inb(unsigned short port) +{ + unsigned char r; + __asm__ volatile("inb %1, %0" : "=a"(r) : "Nd"(port)); + return r; +} + +void arch_early_init(void) +{ + /* Program COM1: 115200 8N1, FIFO on. */ + outb(COM1 + 1, 0x00); /* disable interrupts */ + outb(COM1 + 3, 0x80); /* DLAB on */ + outb(COM1 + 0, 0x01); /* divisor lo -> 115200 */ + outb(COM1 + 1, 0x00); /* divisor hi */ + outb(COM1 + 3, 0x03); /* 8N1, DLAB off */ + outb(COM1 + 2, 0xC7); /* enable+clear FIFO */ + outb(COM1 + 4, 0x0B); /* RTS/DSR set */ +} + +void arch_uart_putc(char c) +{ + while ((inb(COM1 + 5) & 0x20) == 0) /* wait THR empty */ + ; + outb(COM1, (unsigned char)c); +} + +/* M1: minimal IDT. We load a valid-but-empty IDT so the CPU has a + * table; M2 will fill gates and handle the timer (IRQ0). */ +struct idt_entry { + unsigned short off_lo, sel; + unsigned char zero, flags; + unsigned short off_hi; +} __attribute__((packed)); + +struct idt_ptr { + unsigned short limit; + unsigned int base; +} __attribute__((packed)); + +static struct idt_entry idt[256]; + +void arch_set_trap_vector(void) +{ + struct idt_ptr ptr = { sizeof(idt) - 1, (unsigned int)(unsigned long)idt }; + __asm__ volatile("lidt %0" :: "m"(ptr)); +} + +const char *arch_name(void) +{ + return "x86 (i386, multiboot)"; +} + +void arch_halt(void) +{ + for (;;) + __asm__ volatile("hlt"); +} diff --git a/arch/x86/boot.S b/arch/x86/boot.S new file mode 100644 index 0000000..cdfcf1c --- /dev/null +++ b/arch/x86/boot.S @@ -0,0 +1,28 @@ +# boot.S (x86, i386) — multiboot header + entry. +# The Multiboot1 header lets QEMU -kernel (and GRUB) load us directly. +# We set a stack and call kmain(). Irreducible asm part for x86. + + .set MB_MAGIC, 0x1BADB002 + .set MB_FLAGS, 0x0 + .set MB_CHECKSUM, -(MB_MAGIC + MB_FLAGS) + + .section .multiboot, "a" + .align 4 + .long MB_MAGIC + .long MB_FLAGS + .long MB_CHECKSUM + + .section .bss + .align 16 +stack_bottom: + .skip 0x4000 # 16 KiB stack +stack_top: + + .section .text.boot + .globl _start + .type _start, @function +_start: + mov $stack_top, %esp # set up the stack + call kmain +1: hlt # kmain shouldn't return; if it does, halt + jmp 1b diff --git a/arch/x86/link.ld b/arch/x86/link.ld new file mode 100644 index 0000000..f97caff --- /dev/null +++ b/arch/x86/link.ld @@ -0,0 +1,19 @@ +/* link.ld (i386) — multiboot kernels load at 1 MiB. The multiboot + * header must appear within the first 8 KiB, so put it first. */ +OUTPUT_FORMAT(elf32-i386) +ENTRY(_start) + +SECTIONS +{ + . = 0x00100000; /* 1 MiB */ + + .text : { + *(.multiboot) + KEEP(*(.text.boot)) + *(.text .text.*) + } + + .rodata : { *(.rodata .rodata.*) } + .data : { *(.data .data.*) } + .bss : { *(.bss .bss.*) *(COMMON) } +} diff --git a/common/console.c b/common/console.c new file mode 100644 index 0000000..838d706 --- /dev/null +++ b/common/console.c @@ -0,0 +1,27 @@ +/* console.c — portable text output built on arch_uart_putc(). + * No libc: we implement the tiny bit of formatting we need. */ +#include "arch.h" +#include "console.h" + +void putc(char c) +{ + if (c == '\n') + arch_uart_putc('\r'); /* CRLF for dumb terminals */ + arch_uart_putc(c); +} + +void puts(const char *s) +{ + while (*s) + putc(*s++); +} + +/* Minimal unsigned hex printer — enough to show addresses/registers. */ +void puthex(unsigned long v) +{ + static const char d[] = "0123456789abcdef"; + int i; + puts("0x"); + for (i = (int)(sizeof(v) * 2) - 1; i >= 0; i--) + putc(d[(v >> (i * 4)) & 0xf]); +} diff --git a/common/main.c b/common/main.c new file mode 100644 index 0000000..d060c1e --- /dev/null +++ b/common/main.c @@ -0,0 +1,26 @@ +/* main.c — the arch-agnostic OS entry point. + * + * By the time kmain() runs, the arch's asm entry has: set up a stack, + * cleared .bss (or the arch stub did), and jumped here. Everything below + * is identical C for x86, RISC-V and ARM — that's the whole point of the + * exercise: same core, three ISAs. */ +#include "arch.h" +#include "console.h" + +void kmain(void) +{ + arch_early_init(); + arch_set_trap_vector(); + + puts("\n"); + puts("========================================\n"); + puts(" minos — minimal multi-arch kernel\n"); + puts(" arch: "); + puts(arch_name()); + puts("\n"); + puts(" M1: booted, UART up, trap vector set\n"); + puts("========================================\n"); + puts("hello from kmain()\n"); + + arch_halt(); +} diff --git a/include/arch.h b/include/arch.h new file mode 100644 index 0000000..1826cd7 --- /dev/null +++ b/include/arch.h @@ -0,0 +1,29 @@ +/* arch.h — the per-arch contract. + * + * The common C core is arch-agnostic: it only calls these functions. + * Each arch// provides an implementation. This is the whole + * "boundary" between the minimal asm/glue and the portable C. + */ +#ifndef MINOS_ARCH_H +#define MINOS_ARCH_H + +/* Early, arch-specific bring-up done before kmain() prints anything: + * e.g. point UART at the right MMIO/port. Called from the asm entry's + * C landing pad, or the very top of kmain(). */ +void arch_early_init(void); + +/* Emit one byte to the platform's debug UART. The console layer in + * common/console.c builds print()/puts() on top of just this. */ +void arch_uart_putc(char c); + +/* Install the interrupt/trap vector table (IDT / mtvec / VBAR). + * M1 stubs this; M2 makes it handle a timer tick. */ +void arch_set_trap_vector(void); + +/* Name of the architecture, for the banner. */ +const char *arch_name(void); + +/* Halt the CPU (wfi / hlt) — end of kmain(). */ +void arch_halt(void) __attribute__((noreturn)); + +#endif /* MINOS_ARCH_H */ diff --git a/include/console.h b/include/console.h new file mode 100644 index 0000000..6beea02 --- /dev/null +++ b/include/console.h @@ -0,0 +1,8 @@ +#ifndef MINOS_CONSOLE_H +#define MINOS_CONSOLE_H + +void putc(char c); +void puts(const char *s); +void puthex(unsigned long v); + +#endif