“It is our attitude at the beginning of a difficult task which, more than anything else, will affect its successful outcome.” – William James
Time to write some code. The good news is, you won’t need much, and that’s largely the point. We’re going to let the hardware built into every x86 processor since the Reagan administration do the work for us. All we have to do is nudge it in the right direction and let nature take its course.
First off, we’ll need two tasks. These can be as elaborate as you want, but for demonstration purposes we’ll create two trivially simple and nearly identical tasks. Each task turns on an LED and turns off another LED, and the only difference between them is which LED goes on or off. That way, you can tell which task is running. With any luck, the two LEDs will toggle on and off with every task switch, proving that the tasks are alternating with one another. The code looks something like this:
task1: MOV DX, green_led MOV AL, const_on OUT DX, AL MOV DX, red_led MOV AL, const_off OUT DX, AL JMP task1
The first three lines of assembly code turn on our green LED, and the next three lines turn off the red LED. The mini-program then loops forever.
Obviously, you’ll want to replace the four constants with appropriate values for your hardware. The placeholders green_led and red_led should be the I/O addresses of two different LEDs. The other two placeholders, const_on and const_off, are whatever binary values turn your LEDs on and off, respectively. Our other task is nearly identical, but with the on/off values swapped.
task2: MOV DX, green_led MOV AL, const_off OUT DX, AL MOV DX, red_led MOV AL, const_on OUT DX, AL JMP task2
So… a couple of observations. Both tasks loop forever, hammering on the same I/O addresses over and over. You wouldn’t normally do this, because once you’ve written to the LEDs the first time, you’d expect them to stay that way forever. But, in our multitasking system, you can’t depend on everything staying the way you left it. Another task might swoop in and change things that your first task doesn’t see. In this case, our two tasks battle over the same two LEDs, constantly swapping their condition and undoing each other’s work.
The second thing we notice is that neither task saves or restores any registers. They’re not written like subroutine calls where you have to save register contents or push and pop arguments off the stack. Each task is blissfully unconcerned with preserving state information because all of that is done for us in the hardware.
Third, neither task does anything to force a task switch, either by putting itself to sleep or by jumping to the other task. That all has to be done elsewhere. Tasks don’t usually participate in their own task switching. It’s normally forced upon them.
On one hand, we’ve created tasks that are uninvolved with task switching but, on the other hand, are written with the awareness that they could be task-switched out at any time. A task can be sure that its registers will never change without its knowledge, but that’s not true of memory or I/O devices. You can depend on the registers being exactly as you left them, but not external resources.
Once our two little programs are set up, make sure each one’s TSS points to the correct code segment (CS) and instruction pointer (EIP). Both tasks might share the same code segment, but they definitely have different starting instructions. Also, make sure that both tasks have a valid stack (SS and ESP) loaded into their respective TSS in case of interrupt. As with the code, they can share a stack segment but not a stack pointer.
Be sure to also load the processor’s Task Register (TR) with the ID of the third “dummy” TSS we created earlier. This tells the processor where to dump the current state information when it makes the first task switch. We’ll also need to set the Busy bit in the TSS (offset 100) for both tasks and set the Nested Task (NT) bit in the processor’s EFLAGS register.
There are a lot of ways you can trigger a task switch: interrupt, fault, program code, time slice, or any combination of these. You can even have programs voluntarily preempt themselves. Any time you can JMP, CALL, or IRET to another program or subroutine, you can force a task switch. Once all your structures are set up, it’s remarkably easy. The hardest part is deciding which task to switch to.
In this example, we’ll treat the task switcher as its own task triggered by some sort of interrupt, which is probably how you’d do it in real life. Unlike a typical interrupt service routine (ISR), a self-contained task doesn’t have to worry about saving and restoring registers and preserving other state information. That’s all handled automatically. All we have to do is decide which of our two tasks to tee up to run next.
To do that, this program examines its own TSS to see which task ran last. Was it Task #1 or Task #2? The processor always stores that information into the Back Link field of the incoming task’s TSS (at offset 0) every time there’s a task switch, so you can always learn which task called you or which task was interrupted (assuming you have the ability to read your own TSS). All our program does is examine its Back Link field and then modify it so that we’ll “return” to whichever task wasn’t running before.
sched: MOV AX,WORD PTR DS:[back_link] CMP AX, task_1 JE switch_2 switch_1: MOV AX, task_1 JMP switch switch_2: MOV AX, task_2 JMP switch switch: MOV WORD PTR DS:[back_link], AX IRET JMP sched
The first instruction simply reads 16 bits from the TSS. (This assumes that data segment DS encompasses the current TSS and that back_link is the offset to its Back Link field.) The second instruction compares that value to the selector for Task #1. Does it match? If so, we need to tee up Task #2. Otherwise, we’ve just come from Task #2, so the fourth instruction gets ready to stuff the selector for Task #1 into our own Back Link field. Instructions eight and nine (at label switch) do the real work. The MOV pokes the 16-bit selector for the desired task into the Back Link field of the TSS, and the interrupt return (IRET) completes this task and forces a task switch. Done!
Why is there a JMP at the very end? The almost-final IRET will cause a task switch, which also dumps the state of this task into its TSS. The next time it’s called, it will pick up right where it left off, so without a JMP to create an infinite loop, we’d fall off the end of the code.
And there you have it. Nothing but a few dozen bytes of code, plus a few hundred bytes of data. All the rest is automatic. Our example task switcher was trivially simple, but you can replace it with something more elaborate to implement round-robin scheduling, or a priority scheme, or whatever you like. The mechanics are the same: modify the current Back Link field and execute an IRET to “return” to whatever task you choose. There are plenty of other ways to trigger a task switch, too, but this is enough to get you started on your own system.