One thing I've found useful on a number of machines is a simple stack switcher. I haven't actually written one for the PIC, but I would expect the approach would work just fine on the PIC18 if both/all threads use a total of 31 or fewer stack levels. On the 8051, the main routine is:
_taskswitch:
xch a,SP
xch a,_altSP
xch a,SP
ret
On the PIC, I forget the name of the stack pointer, but the routine would be something like:
_taskswitch:
movlb _altSP >> 8
movf _altSP ,w,b
movff _STKPTR,altSP
movwf _STKPTR,c
return
At the start of your program, call a task2() routine which loads altSP with the address of the alternate stack (16 would probably work well for a PIC18Fxx) and runs the task2 loop; this routine must never return or else things will die a painful death. Instead, it should call _taskswitch whenever it wants to yield control to the primary task; the primary task should then call _taskswitch whenever it wants to yield to the secondary task. Often, one will have cute little routines like:
void delay_t1(unsigned short val)
{
do
taskswitch();
while((unsigned short)(millisecond_clock - val) > 0xFF00);
}
Note that the task switcher doesn't have any means of doing any 'wait for condition'; all it supports is a spinwait. On the other hand, the task switch is so fast that a attempting a taskswitch() while the other task is waiting for a timer to expire will switch to the other task, check the timer, and switch back faster than a typical task-switcher would determine that it doesn't need to taskswitch.
Note that cooperative multitasking has some limitations, but it avoids the needs for a lot of locking and other mutex-related code in cases where invariants that are temporarily disturbed can be reestablished quickly.
(Edit): A couple caveats regarding automatic variables and such:
- if a routine which uses task-switching is called from both threads, it will generally be necessary to compile two copies of the routine (possibly by #including the same source file twice, with different #define statements). Any given source file will either contain code for only one thread, or else will contain code which will be compiled twice--once for each thread--so I can use macros like "#define delay(x) delay_t1(x)" or #define delay(x) delay_tx(x)" depending upon which thread I'm using.
- I believe that PIC compilers that can't "see" a function being called will assume that such a function may trash any and all CPU registers, thus avoiding the need to save any registers in the task-switch routine [a nice benefit compared with preemptive multitasking]. Anyone considering a similar task switcher for any other CPU needs to be aware of the register conventions in use. Pushing registers before a task switch and popping them after is an easy way to take care of things, assuming adequate stack space exists.
Cooperative multitasking doesn't allow one to completely escape issues of locking and such, but it really does greatly simplify things. In a preemptive RTOS with a compacting garbage collector, for example, it's necessary to allow objects to be pinned. When using a cooperative switcher, this isn't necessary provided that code assumes GC objects may move any time taskswitch() is called. A compacting collector which doesn't have to worry about pinned objects can be much simpler than one which does.