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

Four background tasks
Two Clock windows counting seconds -- one reading 00:00:23 -- and two Bounce windows each with a small black square moving around inside, all animating at the same time, with four tiles in the dock along the bottom.
Two clocks and two bouncing balls, none of them cooperating. Four independent tasks, each spawned by picking its app from the File menu, each with its own 1,536-byte stack. Nothing in this picture yields to anything else on purpose. The timer interrupt fires, saves whoever was running, and returns into the next one.

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:

  1. 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.
  2. Chain. pushf and 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.
  3. 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.
  4. 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.
  5. Decide. If sch_lock is set -- a floppy read holds it across int 13h -- count the tick and resume the same task. Otherwise consult the mode byte.
  6. 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

os8088 scheduler constants, from kernel/sched.inc
Task slots12MAX_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 task1,536 bytesSCH_STACK. Locals, call frames and the saved register frame all come out of it. Eleven stacks is 16,896 bytes of .bss.
Saved frame24 bytesSCH_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 record8 bytesState, saved SP, wake tick, owning instance index, one byte of padding. The whole table is 96 bytes.
Tick rate18.2065 HzThe 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.
Watchdog18 ticksSCH_WD_TICKS, about one second. Cooperative mode only.
Task states30 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 policyround-robinNo 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.

Control Panel
The Control Panel window on the dithered desktop: a list on the left with Scheduler selected as white text on a solid black bar, a vertical rule, and on the right the heading Scheduler above two radio rows -- Pre-emptive, whose ring carries a filled center dot, and Cooperative, whose ring is empty -- with the line Takes effect immediately below them.
One byte of kernel state, shown three places. The panel draws the radio dot by reading the live scheduler byte every time it paints, so it cannot disagree with the kernel. The About box's third line and the Task Manager's scheduler field read the same byte through the same routine. Flipping it takes effect on the next timer tick: no restart, no pause, no handshake with running tasks.

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.