|
If you take a close look at the program running in simple electronic appliances,
you will see that the microprocessor is spending most its time wait for an event to
happen. In many cases the application - say a toaster, is so simple that a simple
control loop is all that is required. The microprocessor waits for a switch or
button to be pressed, switches the heating element on, then waits for a set period
of time, ignoring all input until time-out, at which point, it switches the heating
element off and pops the toast
During the timer period, the microprocessor is probably just in a loop, waiting for
the timer to set an I/O or register bit, called a flag, indicating a timeout has
occurred. This method is called flag polling. When the flag is set, the processor
proceeds to the next step. The processor is executing one task at a time.
The problem with this method is that the microprocessor spends most of its time
doing nothing. As long as there is nothing else to process this method works fine.
However, it would be nice if we could use some of that wasted processing time to add additional
features.
For example, the loop can be redesigned to add extra checks like a stop switch being
pressed, or the toast time changed by the user. As long as the additional checks, and
any associated service do not cause a
delay in detecting when the timer is done, or our toast will become toast so to speak!
A more sophisticated version of the program may use an interrupt to break out of the
wait loop, by automatically calling an interrupt service routine to process the event
which caused the interrupt. In this case, if we are in the middle of the loop of an
additional service section, the interrupt routine will cause the microprocessor to
automatically switch to the toast routine to switch off the heater and save our toast.
Typically, this is exactly the way it is typically done. The microprocessor has a
fixed interrupt table somewhere in its program memory with a list of interrupt
service routines associated with specific interrupts, call interrupt service vectors
(ISV). When interrupts are enabled, if an interrupt flag comes true, the
microprocessor, saves the next address in line from where it is currently at in what
is usually called the "Background Task", and does a call to the interrupt service
routine. At completion of the interrupt routine service, the microprocessor return
to the saved address and continues the program from where it left off in the
Background Task. The background task is usually a simple loop - like looking at the
state of our start and stop buttons. To the outside, the microprocessor appears to
be executing two tasks simultaneously, or multi-tasking.
This fixed interrupt table works fine, as long as the interrupt service routine
is generic, because it will always begin at exactly the same point - the address
stored in the interrupt service table.
Now suppose that our research into toasting discovered that perfect toasting
requires a specific heating profile depending upon what we are toasting.
For example, suppose that perfect white toast requires a uniform hot heat over a
fixed time, while a bagel is best toasted with a slow heat-ramp-up, a middle hold
time of moderate heat, than a final pulse of high heat.
Making perfect white toast is no problem. We just turn on the heating element,
start the timer, wait until the timer interrupt service routine is called to turn
off the element, and our toast is done.
Perfectly toasted bagels on the other hand look more difficult to achieve.
We know that our basic toaster element can physically generate different heat
levels by pulse modulating the heating element - that is pulsing the element on and
off at different rates to create an average overall heat. The timer can be used to
generate variable on and off pulse intervals, but the problem is, the pulse on and
off intervals will be different depending upon where we are in the toasting profile.
A simple single interrupt service routine like we used for white toast will not
suffice. It always enters at the same point, and does exactly the same thing, the
same way each time it is called. In programming terms, we say the routine lacks
context. This means from call to call since it does not preserve any information
about the current state of the task when it suspends, it cannot automatically resume
from where it left off when it last suspended.
The bagel routine however, does require that we keep track of where we were each
time we suspend, and where we need to go next time we resume because the toasting
profile heating pulse rate changes at each point in the process.
More powerful microprocessors have built in resources and to handle context storage
and switching, but all out little microprocessor probably has is a fixed timer
interrupt service address and few hundred bytes of SRAM at best. Does this mean
we are back to a polling loop again?
Actually no! As long as we have a stack, a return instruction, a push instruction,
and a couple bytes of SRAM we can read and write to store task context to, with just
a few instructions we can context switch just fine.
The principle is simple. Instead of servicing the interrupt directly, the interrupt
service vector calls an intermediate routine, an ISV service routine. Using context
data stored in SRAM, the ISV service routine switches "context" and returns to
the real interrupt service routine at the point of suspension. Part of the stored
context is the resume point in the interrupt service routine. Since the context is
stored in SRAM, the resume point can be changed with each subsequent ISV call.
In other words, when we suspend the task at some point, we save the address
we want to resume at. The next time the interrupt service is called, we resume at
the address we saved.
Here is how it works. First we decide how much information we need to save between
calls. This will determine the size of SRAM memory we need to use. This block of
memory in a operating system is referred to as either as a Task Control Block (TCB)
or Process Control Block (PCB). In our case there is no difference between a process
and a task, so we will use TCB.
When the ISV service is called, the interrupt return address is usually already
placed on the stack, so we do not have to be concerned about saving that.
However, at the completion of the interrupt service routine to properly resume the
background task at the calling point, we need to be sure that anything that may
have changed in our service routine is restored back to its original state.
This may include the processor status or flag register, and any registers we may
use in the interrupt service routines. The simplest strategy is simply to save
these values in block of SRAM returnBlock sized.
The next step is load the context of the last state of the interrupt service
routine. The processor status, registers etc... which were saved or initialized in
a block of SRAM suspendBlock sized.
IN AVR assembler, our TCB will look something like the following:
Toast_TCB:
.BYTE addressSize ;the entry address goes here
callingProcess:
.BYTE returnBlockSize ;save area for return
suspendedProcess:
.BYTE suspendBlockSize ;a context save
If you have enough stack space, the TCB could actually just be a dedicated section
of stack you push and pop the resources off and on to. In this case, all you need to do is to restore and save the stack pointer as you enter and exit respectively. Each Task would need a dedicated section of stack - including some extra space for any calls or pushes made during execution.
When the ISV service routine is called during an interrupt, it saves the current
processor context, restores the interrupt service context, then here is the trick,
the ISV routine reads the saved resume address, PUSHes it on the stack, and executes
a RET instruction which creates a jump to the saved resume point of the interrupt
service routine.
So how does the resume address get there in the first place? It gets there two ways.
The first way is when the TCB is initialized - every TCB needs an initialization
routine. The initialization routine places the START address of the interrupt
service routine into the PCB, and initializes any other required context.
The second way is at each suspend point of the service routine, the RESUME address,
or the address of the NEXT instruction to execute is saved in the TCB, replacing
the old address that was saved there.
When the service reaches a suspend point, it saves the next execution or resume
address, and jumps back the ISV routine which saves the context, then simply does a
return to the address already on the stack.
In AVR assembler a suspend task is done as follows (easily implemented as a macro):
suspend_point:
ldi r16, LOW (resume_address)
lds toast_TCB, r16
ldi r16, HIGH (resume_address
lds toast_TCB +1 , r16
jmp toast_ISV_return
....
resume_address: ;a program address label
...
The jump instruction returns to the ISV service routine which saves the context of the interrupt service routine, restores the context of the processor at the time of the interrupt, and executes a return from interrupt. It should be noted that the resume_address does not have to be the next in line instruction.
In AVR assembler, a minimum ISV service routine looks like the following:
ISV_service:
;*** save any context of current process which may be
;* changed***
;*
sts callingProcess, rx : ;save register(s) x
...
;*** restore interupt service context
;*
lds rx, Toast_TCB ;load return address
push rx ;and push on stack
lds rx, Toast_TCB + 1 ;
push rx ;the return address now on stack
;*** restore context of suspended process***
;*
lds suspendedProcess, rx ;restore register x
...
ret ;restart process (and re-level the stack)
ISV_return:
;*** save context of current process ***
;*
sts suspendedProcess, rx ;save register x
....
;*** restore context of calling process***
;*
lds callingProcess, rx ;restore register x
...
reti ;return to calling point
Some microprocessors like the AVR also support indirect jumps which can be used instead of the RET instruction for the initial jump.
Finally, you probably already figured out that the ISV service routine can be rewritten as a general dispatch routine by simply passing to a common routine the address of the task TCB we want to switch to. Add a simple kernel (to create and initialize TCBs) and scheduler (to schedule the order in which tasks run) routines, and you have the basis of a simple real time operating system (RTOS).
|