Pre-emptive multitasking on an 8086
Twelve task slots, 1,536 bytes of stack each, and a timer interrupt that takes the CPU
away from whatever is running 18.2065 times a second. No task is asked to cooperate and no
task can refuse. This page is the whole mechanism: what the 8086 does not give you, why
sharing one 64K segment is what makes the switch cheap, and what the interrupt handler
actually does between the clock tick and the iret.
12 task slots · 1,536-byte stacks · 24-byte saved frame · 18.2065 Hz · 18-tick watchdog
What the 8086 does not give you
There is no memory management unit. There are no privilege levels -- no kernel mode, no user mode, no supervisor bit. There is no memory protection of any kind: every instruction can write any byte in the machine. There is no atomic compare-and-swap, no lock prefix worth the name, and no instruction that shifts a register by an immediate count other than one.
There is also no way to reach more than 64K with a single pointer. Memory is addressed through segments: a 16-bit segment register supplies a base in 16-byte units, a 16-bit offset indexes inside it, and the two add to a 20-bit physical address. Crossing a segment boundary means reloading a segment register, and on an 8088 that is an expensive thing to be doing on every context switch.
So the usual definition of a process -- an address space, a protection boundary, a privileged kernel to enforce it -- is unavailable. What remains is a stack and a program counter. os8088 builds tasks out of exactly that and nothing else.
One segment for everything
The kernel, every task, and every program loaded from floppy run in what NASM calls the tiny model: CS = DS = SS = 0x1000. Code, data, and all twelve stacks live in the same 64K segment. A loaded package is placed in that same segment too, in the pool at offsets 0xA000 to 0xEFFF, which is why its paint and click handlers are ordinary near pointers the kernel calls exactly like a built-in's.
This is not a safety decision. A buggy task can scribble over the kernel and there is nothing to stop it. It is a speed decision, and it buys one specific thing: a context switch is just swapping SP. No segment register changes. No page table exists to reload. The saved state of a suspended task is 24 bytes sitting on its own stack, and the only thing the scheduler has to remember about it is where that stack is.
The task record is eight bytes: a state byte, the saved SP, the tick count to wake at, and the index of the instance that owns the task. Twelve of those is 96 bytes. The stacks are the expensive part -- eleven of them at 1,536 bytes is 16,896 bytes, the single largest allocation in the kernel.
The int 08h hook, in order
The PC's 8253 timer counts down from 65,536 at 1.19318 MHz and raises IRQ0 each time it reloads: 18.2065 interrupts per second, about 55 milliseconds apart. At 4.77MHz that is roughly 262,000 CPU cycles in a time slice. os8088 does not change that rate -- the BIOS keeps time by counting these interrupts, and moving the rate would make the clock lie.
The kernel takes over the vector at 0000:0020 and does six things, in an order that is load-bearing:
- Save. Push AX, CX, DX, BX, SI, DI, BP, ES, DS -- nine registers -- onto whichever task's stack was interrupted. The hardware already pushed IP, CS and FLAGS, so the frame is 24 bytes. Load DS = the kernel segment.
- Chain.
pushfand far-call the saved BIOS handler first. It maintains the BIOS tick count and sends the end-of-interrupt to the PIC, so the kernel must not send another. - Count.
inc word [ticks], then charge the slice that just ended to the task that just ran. Accounting happens before any decision about switching, so the Task Manager's numbers stay honest even on ticks where nothing switches. - Wake. Scan all twelve records for sleepers whose deadline has passed and mark them ready. The comparison is a subtraction and a sign test, so it survives the tick counter wrapping at 65,536.
- Decide. If
sch_lockis set -- a floppy read holds it across int 13h -- count the tick and resume the same task. Otherwise consult the mode byte. - Switch. Park SP in the current record, scan round-robin from the next slot for
one marked ready, load its SP, pop the nine registers,
iret.
The handler runs with interrupts disabled throughout and never issues sti.
It calls no BIOS service other than the chain in step 2.
The heart of it
This is the switch itself, from kernel/sched.inc. The record stride is 8
bytes, so the index-to-record conversion is a shift by 3 -- which on an 8086 has to go
through CL, because shifting by an immediate other than 1 is a 186 instruction and the build
rejects it.
sch_switch:
mov byte [sch_hold], 0 ; the outgoing task reached the switch path
mov bl, [sch_cur] ; park the outgoing task's SP
xor bh, bh
mov cl, 3
shl bx, cl
mov [bx+sch_tasks+T_SP], sp
mov dl, [sch_cur] ; scan cur+1, cur+2, ... (cur itself last)
mov dh, MAX_TASKS
.scan:
inc dl
cmp dl, MAX_TASKS
jne .nowrap
xor dl, dl
.nowrap:
mov bl, dl
xor bh, bh
mov cl, 3
shl bx, cl
cmp byte [bx+sch_tasks+T_STATE], 1
je .pick
dec dh
jnz .scan
; ... nothing ready: fall back to the outgoing task
.pick:
mov [sch_cur], dl
mov sp, [bx+sch_tasks+T_SP]
; fall through into sch_resume
sch_resume:
pop ds
pop es
pop bp
pop di
pop si
pop bx
pop dx
pop cx
pop ax
iret
That is the entire mechanism. The mov sp, [bx+sch_tasks+T_SP] is the moment
one program becomes another; every instruction after it is popping somebody else's
registers. Counting the save half in the interrupt handler, a switch is about thirty
instructions.
A placement rule that is easy to break.
sch_isr does not jump to sch_switch -- it falls through into it,
and sch_switch falls through into sch_resume. Inserting any
routine between the three sends every unlocked tick through that routine's ret
with no return address on the stack, and the machine dies at boot. task_exit
reaches sch_switch by an explicit jmp for exactly this reason.
The numbers
| Task slots | 12 | MAX_TASKS. Slot 0 is the boot thread, which becomes the UI task; it runs on the boot stack at SS:0xFFFE and never sleeps and never exits. Slots 1 to 11 are spawned and freed at run time. |
|---|---|---|
| Stack per task | 1,536 bytes | SCH_STACK. Locals, call frames and the saved register frame all come out of it. Eleven stacks is 16,896 bytes of .bss. |
| Saved frame | 24 bytes | SCH_FRAME. Nine registers plus the IP, CS and FLAGS the hardware pushed. The timer handler, task_yield and task_spawn all build or consume this identical layout. |
| Task record | 8 bytes | State, saved SP, wake tick, owning instance index, one byte of padding. The whole table is 96 bytes. |
| Tick rate | 18.2065 Hz | The stock PC rate, unchanged. Timer channel 0 is reprogrammed from square-wave mode to rate-generator mode so its counter can be read as a monotone phase for CPU accounting -- the interrupt rate is identical either way, so BIOS timekeeping is unaffected. |
| Watchdog | 18 ticks | SCH_WD_TICKS, about one second. Cooperative mode only. |
| Task states | 3 | 0 free, 1 ready, 2 sleeping. There is no blocked state and no wait queue -- a task that wants something waits by sleeping and looking again. |
| Scheduling policy | round-robin | No priorities, no aging, no quantum accounting. The scan starts at the slot after the current one, so the current task is considered last. |
The life of a task
Spawn
task_spawn takes a near entry point in AX and an argument word
in DX. It finds a free slot, carves a 24-byte frame off the top of that slot's stack, and
fills it in by hand: DX gets the argument, the other general registers are zero, DS, ES and
CS are the kernel segment, IP is the entry point, and FLAGS is 0x0202 -- interrupts enabled.
The task has never run, but it has a stack that looks exactly like one that was interrupted,
so it starts life by being resumed.
The timer is live while all of this happens, so publication order matters. The state byte is written last. A byte store is atomic with respect to interrupts on an 8086, so the scheduler either does not see the slot at all or sees a completely built one. That is the whole synchronisation protocol; there is nothing else to it.
Sleep
task_sleep takes a tick count, writes the wake deadline,
writes the sleeping state -- deadline first, so the interrupt handler's scan can never see a
sleeper with a stale deadline -- and yields. A Clock instance sleeps nine ticks between
redraws and costs nothing in between.
Yield
task_yield does not raise int 08h, which would re-run the BIOS
tick and corrupt the time of day. It fakes the hardware frame instead:
pushf, cli, push cs, then a near call whose return
address lands in the IP slot. The stack now looks byte-for-byte like an interrupted task's,
so it can drop straight into the same switch path. The eventual iret returns to
the instruction after the call with every register and the caller's interrupt flag
restored.
Exit
A task's entry routine must never ret -- there is nothing to
return to. It terminates through task_exit, which is self-exit only: no task
can kill another. Closing a window belonging to a task-owned app sets a die flag on the
instance record and hides the window; the task notices at its next wake and exits itself.
task_exit begins with a cli that has no matching
popf, because control never comes back. Inside that one interrupts-off window
it charges its final slice, zeroes the instance record's state byte, zeroes its own task
record, and jumps into the switch. The instance slot and the task slot therefore free at the
same instant -- "instance gone but the task table still holds a zombie" is not a state any
other code can observe.
The switch that turns it back into 1984
The Control Panel has one page, and on it are two radio buttons. Pre-emptive is how the
system boots. Cooperative is how the 1984 Macintosh worked: the timer declines to switch,
and the running task keeps the CPU until it hands it back through
task_yield, task_sleep or task_exit.
Why flipping the mode is free
Both modes are round-robin over the same task table, the same stacks and the same 24-byte
frame. A mode change adds no state, removes no state and invalidates no saved frame, so no
task has to be parked, drained or told about it. Setting it is two byte stores under
cli: the mode itself, and a reset of the hold counter so the incoming mode
starts with a full watchdog budget.
The obvious shortcut -- reusing the existing scheduler lock as a "do not switch" flag --
would be wrong, and the source says so at length. task_yield tests that lock
too and resumes itself when it is set, so raising it would not make the system cooperative;
it would stop multitasking altogether and freeze every Clock and Bounce forever, their
task_sleep never handing the CPU on. Cooperative mode has to leave the
voluntary path working and suppress only the involuntary one, which is why it is a separate
byte tested after the lock.
The watchdog
Cooperative scheduling has a famous failure mode: one program stops sharing and the machine is dead. os8088 lets you feel the scheduling without inheriting the crash. Every tick in cooperative mode increments a hold counter, and at 18 ticks -- about one second -- the kernel forces a switch anyway and counts the event.
The counter means "ticks since the running task last reached the switch path", not "ticks
since the last task change", which is why it is cleared at the single entry point of the
switch routine rather than at each caller. All three ways in clear it: the fall-through from
the timer handler, the jump from task_yield, and the jump from
task_exit. Three consequences are relied on. A task that yields voluntarily can
never trip the watchdog, however long it runs in total. An idle system with nothing else
ready clears the counter through the fallback path, so it does not manufacture watchdog
hits. And because the scheduler lock is tested before the mode, a floppy read
accumulates no hold ticks at all and can never trip it.
Being honest about this one: the watchdog is a safety net, not a feature. It is deliberately invisible -- the only two words the interface ever shows for the modes are "Pre-emptive" and "Cooperative". The count of forced switches exists as a 16-bit diagnostic in memory and is not displayed anywhere; you read it with a debugger.
The hazard this creates
Pre-empting at an arbitrary instruction means pre-empting a task halfway through programming the VGA's Graphics Controller. Two tasks interleaved inside that sequence produce garbage on a display that has no back buffer to hide it -- the four planes of a 640x480 16-color screen come to 153,600 bytes of shadow copy, which 256K of RAM cannot spare, so every pixel is composited live on the visible screen.
The answer is one global drawing lock rather than per-window locking. Whoever holds it owns the screen. Note what it deliberately does not do: it does not stop pre-emption. A Clock task still gets its turn in the middle of a window drag; it is held up only at the instruction where it would touch the card, and it re-checks whether its window is still visible after taking the lock, so an animation cannot paint over a window that was raised a moment earlier. The mouse cursor is the subtle case, because it is drawn by a different interrupt handler that can fire while a task is inside the lock.
How the drawing lock works All the internals Try it in the browser
What this scheduler does not do
- No priorities. Round-robin over ready slots, in slot order, forever.
- No memory protection between tasks. One 64K segment, twelve stacks in it, and an 8086 that will happily let a task run off the end of its 1,536 bytes into a neighbour's.
- No blocking primitives. There are no semaphores, no message queues and no wait states -- a task waits by sleeping and checking again.
- No way to kill another task. Termination is always self-service; closing a window asks, and the task complies at its next wake.
- Twelve slots, and the twelfth spawn fails cleanly rather than growing the table. The stacks are statically reserved, because there is no heap to grow them from.