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-<arch>', 'make sizes'
M1 = boot, UART up, trap vector installed, banner from kmain().
This commit is contained in:
auser 2026-09-25 00:41:24 +03:00
commit 8c44d12357
18 changed files with 597 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
build/
*.o
*.d
*.elf
*.bin

77
Makefile Normal file
View file

@ -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

65
README.md Normal file
View file

@ -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/<name>/` 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").

59
arch/arm/arch.c Normal file
View file

@ -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");
}

29
arch/arm/boot.S Normal file
View file

@ -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

28
arch/arm/link.ld Normal file
View file

@ -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 = .;
}

16
arch/arm/vectors.S Normal file
View file

@ -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

47
arch/riscv/arch.c Normal file
View file

@ -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");
}

28
arch/riscv/boot.S Normal file
View file

@ -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

27
arch/riscv/link.ld Normal file
View file

@ -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 = .;
}

10
arch/riscv/trap.S Normal file
View file

@ -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

69
arch/x86/arch.c Normal file
View file

@ -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");
}

28
arch/x86/boot.S Normal file
View file

@ -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

19
arch/x86/link.ld Normal file
View file

@ -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) }
}

27
common/console.c Normal file
View file

@ -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]);
}

26
common/main.c Normal file
View file

@ -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();
}

29
include/arch.h Normal file
View file

@ -0,0 +1,29 @@
/* arch.h — the per-arch contract.
*
* The common C core is arch-agnostic: it only calls these functions.
* Each arch/<name>/ 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 */

8
include/console.h Normal file
View file

@ -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