Project

General

Profile

Coroutine for MainLoop and Task abstraction

Added by Runar Tenfjord 3 months ago

I made a small test module with an adaption of the obl.generators.mod
code for a MainLoop and Task abstraction.

MODULE Test;

IMPORT SYSTEM, Exceptions IN OBL;

TYPE
    Coroutine = RECORD*
        frame, stack: SYSTEM.PTR
    END;
    Loop = RECORD (Coroutine)
    END;
    Task = RECORD (Coroutine)
        caller: POINTER TO VAR- Loop;
        time : UNSIGNED32;
        status: BOOLEAN
    END;
VAR
    tick : UNSIGNED32;

PROCEDURE* (VAR coroutine: Coroutine) Call;

PROCEDURE- (VAR coroutine: Coroutine) Transfer (VAR target: Coroutine);
CONST StackSize = ASH (16, SIZE (INTEGER) + SIZE (LENGTH));
VAR pointer: SYSTEM.PTR; exception: Exceptions.AllocationFailure;
BEGIN
    SYSTEM.CODE ("mov ptr [$fp + pointer], ptr $fp");
    coroutine.frame := pointer;
    IF target.frame = NIL THEN
        SYSTEM.NEW (target.stack, StackSize);
        pointer := target.stack;
        IF pointer = NIL THEN exception.Raise END;
        SYSTEM.CODE ("add ptr $sp, ptr [$fp + pointer], ptr StackSize - stackdisp");
        target.Call;
        SYSTEM.CODE ("mov ptr $sp, ptr $fp + exception - stackdisp");
        SYSTEM.DISPOSE (target.stack);
        target.frame := NIL;
    ELSE
        pointer := target.frame;
        SYSTEM.CODE ("mov ptr $fp, ptr [$fp + pointer]");
    END;
END Transfer;

PROCEDURE (VAR task: Task) Sleep(time : UNSIGNED32);
VAR caller: Loop;
BEGIN
    task.time := time;
    caller := task.caller^;
    task.Transfer(caller);
END Sleep;

PROCEDURE (VAR task: Task) Finish;
VAR caller: Loop;
BEGIN
    task.time := 0;
    task.status := FALSE;
    caller := task.caller^;
    task.Transfer(caller);
END Finish;

PROCEDURE (VAR task: Task) Call;
BEGIN
    TRACE("Task.Call");
    task.Sleep(50);
    TRACE("Task.Call2");
    task.Sleep(100);
    TRACE("Task.Call3");
    task.Finish;
    TRACE("Not to be reached!");
    HALT(1);
END Call;

PROCEDURE (VAR loop: Loop) Call;
VAR
    task : Task;
    i : INTEGER;
BEGIN
    task.caller := PTR(loop);
    task.status := TRUE;
    task.time := 0;
    TRACE("Loop.Call");
    i := 0;
    LOOP
        IF ~task.status THEN EXIT END;
        loop.Transfer(task);
        TRACE(task.time);
        TRACE(task.status);
        TRACE(i);
        INC(i);
    END;
    TRACE("Loop finished");
END Call;

PROCEDURE Test;
VAR loop : Loop;
BEGIN
    loop.Call;
END Test;

BEGIN
    Test;
END Test.

In order to get this to work properly I changed the definition of
the field caller to:

caller: POINTER TO VAR- Loop;

Otherwise it will not point to the correct instance of Loop.

The code for Sleep and Finish also corrected for this and
these changes might not be optimal.

This could be an issue with obl.generators.mod also.


Replies (7)

RE: Coroutine for MainLoop and Task abstraction - Added by Florian Negele 3 months ago

I took the liberty and rewrote your module using the existing Generators module:

MODULE Test;

IMPORT Generators IN OBL;

TYPE Task = RECORD (Generators.Generator)
    time: UNSIGNED32;
END;

PROCEDURE (VAR task: Task) Sleep (time: UNSIGNED32);
BEGIN
    task.time := time;
    task.Yield;
END Sleep;

PROCEDURE (VAR task: Task) Finish;
BEGIN
    task.time := 0;
    task.Yield;
END Finish;

PROCEDURE (VAR task: Task) Call;
BEGIN
    TRACE ("Task.Call");
    task.Sleep (50);
    TRACE ("Task.Call2");
    task.Sleep (100);
    TRACE ("Task.Call3");
    task.Finish;
END Call;

PROCEDURE Loop;
VAR task: Task; i: INTEGER;
BEGIN
    task.time := 0;
    TRACE ("Loop.Call");
    i := 0;
    WHILE task.Await () DO
        TRACE (task.time);
        TRACE (i);
        INC (i);
    END;
    TRACE ("Loop finished");
END Loop;

BEGIN
    Loop;
END Test.

I don't know if this is helpful. The call to Finish is strictly speaking not necessary as the implicit return from Call actually stops the while loop. The same applies to the status and the caller which I therefore removed. I also turned Loop into a procedure as it does not need to be couroutine either.

RE: Coroutine for MainLoop and Task abstraction - Added by Runar Tenfjord 3 months ago

An almost embarrassing simplification of my version.
Thanks.

RE: Coroutine for MainLoop and Task abstraction - Added by Runar Tenfjord 22 days ago

I got this to work now on the ECSMicro library on a ARM32 MCU with
a simple round-robin scheduler:

(** Simple coroutine demo with round-robin type scheduler *)
MODULE Test;
IMPORT BoardConfig;

IN Std IMPORT Coroutine(256, UNSIGNED32);
IN Std IMPORT UInt := ADTBasicType(UNSIGNED32);
IN Std IMPORT TaskQueue := ADTVector(Coroutine.TaskEntry);

IN Micro IMPORT ARMv7M;
IN Micro IMPORT SysTick := ARMv7MSTM32SysTick0;

CONST Pins = BoardConfig.Pins;

TYPE
    Task1 = RECORD (Coroutine.Task) END;
    Task2 = RECORD (Coroutine.Task) END;

VAR
    queue : TaskQueue.Vector;
    task1: Task1;
    task2: Task2;
    pin : Pins.Pin;
    tasks : INTEGER;

PROCEDURE TaskCompare(left-, right- : Coroutine.TaskEntry): INTEGER;
BEGIN RETURN UInt.Compare(right.time, left.time);
END TaskCompare;

PROCEDURE (VAR task: Task1) Call;
BEGIN
    WHILE TRUE DO
        pin.On;
        TRACE("Task1.On");
        task.Sleep(200);

        pin.Off;
        TRACE("Task1.Off");
        task.Sleep(800);
    END;
END Call;

PROCEDURE (VAR task: Task2) Call;
BEGIN
    WHILE TRUE DO
        task.Sleep(250);

        pin.On;
        TRACE("Task2.On");
        task.Sleep(50);

        pin.Off;
        TRACE("Task2.Off");
        task.Sleep(200);
    END;
END Call;

PROCEDURE AddTask(VAR task: Coroutine.Task);
VAR
    entry : Coroutine.TaskEntry;
BEGIN
    task.time := 0;
    entry.task := PTR(task);
    entry.time := SysTick.GetTicks() + tasks;
    queue.HeapInsert(TaskCompare, entry);
    INC(tasks);
END AddTask;

PROCEDURE Run;
VAR
    entry : Coroutine.TaskEntry;
BEGIN
    TRACE("Loop start");
    (* run loop until task queue is empty *)
    LOOP
        IF queue.Size() = 0 THEN EXIT END;
        queue.Get(0, entry);
        LOOP
            IF SysTick.GetTicks() >= entry.time THEN
                (* remove task from queue *)
                IGNORE(queue.HeapPop(TaskCompare, entry));
                IF entry.task.Await() THEN
                    (* Reschedule task *)
                    entry.time := SysTick.GetTicks() + entry.task.time;
                    queue.HeapInsert(TaskCompare, entry);
                END;
                EXIT; (* Get next task *)
            END;
            ARMv7M.WFI; (* Idle *)
        END;
    END;
    TRACE("Loop finished");
END Run;

BEGIN
    TRACE("START");
    BoardConfig.Init;

    pin.Init(BoardConfig.USER_LED1_PORT, BoardConfig.USER_LED1_PIN, Pins.output,
             Pins.pushPull, Pins.medium, Pins.noPull, Pins.AF0);

    SysTick.Init(BoardConfig.HCLK, 1000);

    tasks := 0;
    queue.Init(2);
    AddTask(task1);
    AddTask(task2);
    Run;
END Test.

The Coroutine module was modified with removed memory allocation
and the code simplified with just a loop as you suggested. Thanks.

This solution should be a good fit for these smaller devices and
much simpler than preemptive task switching.

One question regarding the code in obl.generators.mod.
What does this procedure call in IGNORE (caller) ?

PROCEDURE- (VAR caller: Caller) Call;
BEGIN IGNORE (caller);
END Call;

RE: Coroutine for MainLoop and Task abstraction - Added by Florian Negele 22 days ago

Interesting work. What does the replacement for the removed stack memory allocation in the Coroutine module look like?

The predeclared proper procedure IGNORE evaluates an expression of any type but ignores its value. It was introduced to explicitly ignore the result of function procedures. In this particular case it generates no code but also prevents the compiler from issuing a warning about the otherwise unused variable.

RE: Coroutine for MainLoop and Task abstraction - Added by Runar Tenfjord 21 days ago

Std.Coroutine.mod:

(**
Coroutine module based on Eigen Compiler Suite version
modified for static allocation of the coroutine stack
and add task oriented abstraction.

Copyright (C) Florian Negele
License is GNU General Public License version 3 with ECS Runtime Support Exception.
*)
MODULE Coroutine(StackSize, TimeType) IN Std;

IMPORT SYSTEM;

TYPE
    Coroutine* = RECORD
        frame, stack: SYSTEM.PTR;
        data : ARRAY StackSize OF SYSTEM.BYTE;
    END;

    Caller = RECORD (Coroutine) END;

    Task* = RECORD (Coroutine)
        caller*: Caller;
        time* : TimeType;
        status*: BOOLEAN;
    END;

    TaskEntry* = RECORD
        time* : TimeType;
        task*: POINTER TO VAR Task;
    END;

PROCEDURE (VAR caller: Caller) Call*;
BEGIN IGNORE (caller);
END Call;

PROCEDURE (VAR coroutine: Coroutine) Call*;
BEGIN END Call;

PROCEDURE- (VAR coroutine: Coroutine) Transfer* (VAR target: Coroutine);
CONST StkSize = StackSize;
VAR pointer: SYSTEM.PTR;
BEGIN
    SYSTEM.CODE ("mov ptr [$fp + pointer], ptr $fp");
    coroutine.frame := pointer;
    IF target.frame = NIL THEN
        target.stack := SYSTEM.VAL(SYSTEM.PTR, SYSTEM.ADR(target.data));
        pointer := target.stack;
        SYSTEM.CODE ("add ptr $sp, ptr [$fp + pointer], ptr StkSize - stackdisp");
        target.Call;
        SYSTEM.CODE ("mov ptr $sp, ptr $fp - stackdisp");
        target.frame := NIL;
    ELSE
        pointer := target.frame;
        SYSTEM.CODE ("mov ptr $fp, ptr [$fp + pointer]");
    END;
END Transfer;

(** Waits for a call to the task to return and return status. *)
PROCEDURE (VAR task: Task) Await* (): BOOLEAN;
BEGIN
    task.caller.Transfer (task);
    RETURN task.status;
END Await;

(** Returns from task with request to reschedule execution. *)
PROCEDURE (VAR task: Task) Sleep*(time : TimeType);
BEGIN
    task.time := time;
    task.status := TRUE;
    task.Transfer(task.caller);
END Sleep;

(** Return from task and request to stop the task. *)
PROCEDURE (VAR task: Task) Finish*;
BEGIN
    task.time := 0;
    task.status := FALSE;
    task.Transfer(task.caller);
END Finish;

END Coroutine.

I also removed the exception, as this is not expected anymore.
This seems to work, but I might be overlooking things here.

Thanks for the explanation on IGNORE. This is very useful.

RE: Coroutine for MainLoop and Task abstraction - Added by Florian Negele 21 days ago

Thank you for the insight. Does this mean that tasks should better be global variables due to their increased size?

I think line 50 should read as follows in order to restore the stack pointer correctly. Your version probably still works because the stack pointer is no longer read after that line:

SYSTEM.CODE ("mov ptr $sp, ptr $fp + pointer - stackdisp");

If you like you could also remove SYSTEM.VAL in line 46 which always catches my eye:

target.stack := PTR(target.data);

Keep up the good work!

RE: Coroutine for MainLoop and Task abstraction - Added by Runar Tenfjord 20 days ago

The tasks should preferable be declared as globals as this will allow me to
just check the start of the heap to check the memory situation. Otherwise
stack allocation is more dynamic and difficult to have under complete control.

These small MCUs are more clever than what might be visible a first glance.
The UART and many similar sub-systems actually run in parallel while the
main CPU is in sleep mode. The loop will then wake up (from WFI instruction)
on the timer (each milli-second) or on an sub-system event like a full read buffer.
All to save valuable battery energy.

This is a very important capability to have on these MCU systems. These types
of periodic tasks is very common and with coroutines this can be done efficiently
and easy.

Thanks spending your time and looking into this!

    (1-7/7)