PannOS
- 3 Devlogs
- 14 Total hours
Hi guys, here we go again with another devlog.
Lately I worked a lot, so this devlog will be pretty long.
Let’s start with the first piece, the IDT. I made three files: idt.h with the structs and the prototypes, idt.c with the actual table, and idt_flush.s to load it. The table has 256 entries, one for each interrupt vector.
Then I worked on the exceptions raised by the CPU. I created 32 stubs in isr_stubs.s, one for each exception.
I also had to work on the hardware interrupts. I worked in the file io.h, with I/O ports. I used outb and inb, which are inline assembly, for managing ports in C, since C doesn’t have a way for managing them.
Another big part is the PIC 8259 with the remap. When booted, there’s IRQ 0-7 on vectors 8-15. In protected mode there’s already double fault and page fault, so those vectors are already taken. But without remap, when you type a key the kernel sees it as “coprocessor segment overrun”. A problem is that the chip is really old, so it’s older than the numbering it collides with.
There’s one more thing about the PIC, and it’s the end of interrupt. After it sends you an interrupt, it doesn’t send the next ones until you tell it you’re done, by writing 0x20 to its command port. So I have to do that at the end of every IRQ handler. If you forget it you get exactly one interrupt and then nothing, forever, and the annoying part is that nothing looks broken: no error, no crash, the first interrupt works perfectly.
The last piece is how the handler goes back, and it’s probably the most interesting one.
I would have used ret, like in a normal function, but it doesn’t work here. That’s because ret only restores eip, while an interrupt also pushed cs and eflags. Eflags is where the IF flag lives (the one that says if interrupts are enabled or not) and the CPU turns it off by itself when it gives you the interrupt, so that your handler doesn’t get interrupted in the middle.. So with ret interrupts stay off forever and you never get another one.
The solution is iret. And right before it I have to do add esp, 8, because the vector number and the error code were pushed by me, not by the CPU, and iret knows nothing about them.
To test all of this I unmasked IRQ 0, the timer, and I didn’t have to configure the timer at all: the BIOS had already programmed the PIT, so it was ticking the whole time and the interrupts started coming as soon as I enabled them. The screen fills up with IRQ raised: 00. That screenshot proves three things at the same time: the remap worked, because the interrupt arrives on vector 32 and not on 8; the EOI is getting through, otherwise there would be one single line; and iret puts the stack back exactly as it found it, a few hundred times in a row without missing once.
I’m so happy for this work. Look forward for the next steps
First devlog where the screen looks exactly the same before and after.
Turns out that’s the interesting part: the GDT is the table the CPU reads on every single memory access, and doing it right means nothing visible happens.
What I built
A real VGA text driver (vga.c): putchar, write, line wrap, \n, and scrolling. The surprise: there is no hardware scroll in VGA text mode, scrolling means copying all 2000 cells up one row yourself and blanking the last one. I’d always assumed the terminal did that for you, it’s a for loop.
The GDT (gdt.c, gdt.h): three entries: a null one (mandatory, so an uninitialized segment register faults immediately instead of doing something random), one for code, one for data. Base 0 and limit 4GB on both, the flat model. You can’t turn segmentation off on x86, so you make it irrelevant instead.
Replacing GRUB’s table.: GRUB leaves you a working GDT, otherwise the kernel wouldn’t run at all, but it lives in GRUB’s memory and GRUB is gone. My stack grows, .bss expands, an allocator shows up eventually, and one day something writes over it. From that moment the CPU reads garbage on every memory access.
Shift and mask code for a 40 year old compatibility hack.: A descriptor is 8 bytes with the base address split into three non-contiguous chunks and the limit split in two. The 286 had 24 bit addresses in 1982, the 386 needed 8 more bits of base and 4 more of limit and couldn’t move any existing field without breaking every OS already written, so the new bits got wedged into the spare bytes at the end. __attribute__((packed)) isn’t optional here, one byte of padding and the CPU misreads everything.
The assembly loader (gdt_flush.s): lgdt plus reloading all five data segment registers, because each one caches the descriptor internally and changing the table doesn’t touch registers already loaded.
A jump that goes nowhere.: mov cs, ax does not exist: cs says which segment your code is in and eip says where in it, two halves of one address, so changing only cs lands the next instruction fetch at the old offset inside the new segment. They have to change together, and the only instruction that does that is a far jump. The idiom is jumping to the very next line. I disassembled it to convince myself: the jump sits at 0x15, is 7 bytes long, ends at 0x1c, and its destination is 0x1c. The jump is the vehicle, loading cs is the cargo.
Proof that something invisible happened. Nothing changed on screen so I asked the CPU through the QEMU monitor: GDT= 001042f4 00000017, and nm says my table is at 001042f4 with sizeof(gdt) - 1 = 23 = 0x17. CS reads [-R-], readable not writable, DS reads [-WA], writable not executable. I set the data access byte to 0x92 and the CPU reports 93: that’s the accessed bit, hardware turns it on by itself the first time the segment is used.
Look forward for the next steps
Booted my first kernel today. It’s just a black screen with a white “F” in the corner, but it’s my black screen with my white “F”, printed by code I wrote, with no OS underneath.
A cross-compiler from source. i686-elf-gcc doesn’t exist on a normal Linux install, you build it yourself from binutils + gcc source, targeting a “fake” freestanding platform (no OS, no libc assumed).
I wrote the whole build script in bash, which I’d genuinely never written before this project.
A Multiboot2 header (boot.s, NASM assembly) the exact magic number, checksum, and end tag GRUB looks for to recognize a bootable kernel. Also set up a stack (the CPU doesn’t give you one for free) and the _start entry point that hands off from assembly into C.
A linker script (linker.ld) that tells the linker to load the kernel at 1MB — the first chunk of physical memory not already claimed by legacy BIOS/VGA stuff.
kernel.c — writes directly to the VGA text buffer at physical address 0xB8000 (2 bytes per character: ASCII + color attribute). No printf, no stdlib, just a raw pointer and a cast.
Look forward for the next steps