Minimal 32bit x86 preemptive multitasking example with basic kernel/userspace separation.
As simple as possible. No paging/memory protection. Uses multiboot.
To build:
make
To build and run with qemu:
make run
Preemptive multitasking works by quickly switching the execution context between different tasks (or threads).
An execution context on x86 consists of registers like the instruction pointer (EIP), stack pointer (ESP), the general purpose registers (EAX, ECX, etc..), segment registers, and so on.
To perform a task switch between two contexts, we save the registers of the current context and then load in the registers of the next one.
You could copy these registers to variables but its faster to push/pop them in a set order to an appropriate stack. This is what most kernels do.
We're going to use the interrupt generated by the PIT to exchange these registers without the individual tasks ever noticing.
So, which stack should we use for this? Well, each task is going to need its own userspace stack for executing functions and such. However if we want separation between kernel and userspace, we probably shouldn't use this.
Instead, lets allocate a stack in kernel space for each task to use when switching tasks.
Another thing to note is that we do not have to push/pop all the registers to the stack. Some are automatically pushed by the interrupt(EIP, CS, EFLAGS, and optionally ESP and SS if transitioning from user to kernel mode), some are pushed by the cdecl convention(EAX, ECX, EDX).
So, the basic steps are as follows:
- Set up protected mode, initial stack, GDT, IDT, PIT and TSS
- When a PIT interrupt occurs:
- Decide on which task should run next
- Update ESP0 of the TSS. This determines which stack to use when going from user to kernel mode. (I.e. when interrupted)
- Push registers that we want to save
- Change stack pointer to the next tasks kernel stack
- Pop registers that we want to load (in reverse order to step 3)
- Return from the interrupt into the next task
You may notice a problem with this though. What happens if we attempt to switch to a newly created task? It'll work fine until step 5 where it tries to pop registers from an empty newly created kernel stack!
To fix this, we populate the kernel stack of the newly created task with data in the order that step 5 expects.