Introduction
This example program TaskEval
introduces the task system with microseconds (us) timing mentioned as the motivation for the CodeLoading
test program. Module Tasks
is provided together with the example program, not yet in the source library, as it’s a version that is instrumented with test code that will be removed from the library version.
The purpose of this document and the test program are:
- introduce the task system concepts
- outline the timing arithmetic used, including the challenges of doing 32-bit unsigned integer calculations and comparisons using signed INTEGERs
- describe different runs and results of the test program
- demonstrate the correct timing arithmetic
- explore the minimum time between tasks
- explore the minimum set-up time for a task
- experimental: explore a way to force a procedure to run from flash cache
Task System Basics
Refer to module Tasks.mod
in the example directory. As said, it’s an unwieldy version heavily instrumented with test code.
Each core gets allocated one of the timer alarms (see below). A control program can schedule a Task
to be executed at a certain time in the future, specified in microseconds. For each core, and timer alarm, more than one such Task
can be scheduled, eg. to start a measurement in 100 us and to stop it after 150 us. All scheduled tasks are held in a linked list, ordered by alarm trigger time (run-queue).
When the currently active alarm is triggered, the corresponding Task
will be executed, and the alarm time of next Task
in the run-queue will be written to the corresponding timer alarm register, which is called arming. Care must be taken to not arm an alarm with a timer value in the past, else it will take about a full “rotation” of the 32-bit register until it’s triggered, ie. more than 71 minutes, not the intended few microseconds. This could happen if the Task
procedure takes longer to execute than the alarm time for the next Task
in the list allows. That is, the two Task
were scheduled to close to each other. In the task system, in this case the next Task
in the list will be executed immediately (directly). Better execute a Task
with some delay than not execute it. Obviously, this situation must only be an exceptional case, not by program design. It’s a band-aid for an error situation.
The same care about not inadvertently setting an alarm with a time in the past must be taken when putting a Task
in the run-queue. In the task system, the same solution is applied: execute the Task
immediately, without even setting an alarm for it. Again, this must be an exceptional case, to resolve an error situation.
The task system as introduced here cannot control how long a scheduled Task
will take to execute, that’s the responsibility of the control program designer. But we can look at the basic overhead of the above scheduling and execution mechanisms, which we do with this test program. The other purpose is to check the alarm timing behaviour around the 32-bit roll-over and 2-complement overflow points, explained below.
Timer and Timing
Refer to document Timer and Timing.
The Example Program
The example program consist of
TaskEval.mod
, the main program moduleTasks.mod
, the work-in-progress library module, instrumented for testing
TaskEval
creates a series of eight Task
and schedules them for execution. The Task
are scheduled to run in the order 0 to 7, in equidistant time intervals. However, in the first test, they are not put on the run-queue in this order, but as 2, 4, 6, 1, 3, 5, 7, 0, so we can check the correct insertion and ordering in the run-queue. For the timing tests, the tasks will be put on the run-queue in their intended execution time, in order to avoid side effects by already firing alarms while still at work with scheduling.
After the eight test tasks, an additional one is scheduled to print the resulting data collected during the test runs. Obviously, we cannot do any serial text output during timing runs in a microseconds range, as this would add milliseconds.
Collecting the data adds some additional run time, so the results with the “final” non-instrumented module Tasks
should then be somewhat better.
For testing purposes, we set the global timer close to 0FFFFFFFFH
to evaluate the roll-over.
Now, finally, let’s run the example program for different test cases.
Task Scheduling: Timing Comparisons (Run-queue Ordering)
As stored in the repo, the test code is configured for this test case. As outlined above, it puts the Task
in the order 2, 4, 6, 1, 3, 5, 7, 0 on the run-queue, and we can check if the insertions in the queue work, ie. the timing comparison using INTEGER, but actually with CARDINAL values.
The initial test settings are, in case you want to restore the initial conditions:
- In module
TaskEval
- CONST
PrintQueue = TRUE
- CONST
TimeBetweenTasks = TimeBetweenTasks0
, - CONST
FirstTaskAfter = FirstTaskAfter0
, - CONST
TimerStart = TimerStart0
,
- CONST
- in module
Tasks
- CONST
AlarmInRam = FALSE
, - CONST
PutInRam = FALSE
,
- CONST
Build and run TaskEval
, which prints to the serial terminal:1
2 FFFFFFC0H
2 FFFFFFC0H
4 00000024H
2 FFFFFFC0H
4 00000024H
6 00000088H
1 FFFFFF8EH
2 FFFFFFC0H
4 00000024H
6 00000088H
1 FFFFFF8EH
2 FFFFFFC0H
3 FFFFFFF2H
4 00000024H
6 00000088H
1 FFFFFF8EH
2 FFFFFFC0H
3 FFFFFFF2H
4 00000024H
5 00000056H
6 00000088H
1 FFFFFF8EH
2 FFFFFFC0H
3 FFFFFFF2H
4 00000024H
5 00000056H
6 00000088H
7 000000BAH
0 FFFFFF5CH
1 FFFFFF8EH
2 FFFFFFC0H
3 FFFFFFF2H
4 00000024H
5 00000056H
6 00000088H
7 000000BAH
0 FFFFFF5CH
1 FFFFFF8EH
2 FFFFFFC0H
3 FFFFFFF2H
4 00000024H
5 00000056H
6 00000088H
7 000000BAH
13 0007A07CH
done
We see how the run-queue is built, with the tasks’ id and their alarm trigger time, which cross the 32-bit roll-over point. Task 13 is the task that will print the results. It just prints done
for now. We schedule the tasks around the roll-over point, hence 000000024H
is later than 0FFFFFFF2H
. With hexadecimal time output it is easier to follow the roll-over.
Refer to Traps.slotInHandler
to inspect the logic of creating the above run-queue.
Timing Baseline
Now let’s reconfigure the test for timing measurements:
- In module
TaskEval
- set CONST
PrintQueue = FALSE
(keep for all runs below) - set CONST
TimeBetweenTasks = TimeBetweenTasks0
, - set CONST
FirstTaskAfter = FirstTaskAfter1
, - set CONST
TimerStart = TimerStart1
,
- set CONST
- in module
Tasks
- set CONST
AlarmInRam = FALSE
, - set CONST
PutInRam = FALSE
,
- set CONST
Build and run TaskEval
, which prints to the serial terminal:
task sch at q at q-del sch for run int at run at r-del ord
0 FFF0BD8CH FFF0BDB2H 38 FFFFFF61H 0 FFFFFF62H FFFFFF6AH 9 0 ok
1 FFF0BDB4H FFF0BDBAH 6 FFFFFF93H 0 FFFFFF93H FFFFFF93H 0 1 ok
2 FFF0BDBAH FFF0BDBCH 2 FFFFFFC5H 0 FFFFFFC5H FFFFFFC5H 0 2 ok
3 FFF0BDBCH FFF0BDBEH 2 FFFFFFF7H 0 FFFFFFF7H FFFFFFF7H 0 3 ok
4 FFF0BDBFH FFF0BDC1H 2 00000029H 0 00000029H 00000029H 0 4 ok
5 FFF0BDC1H FFF0BDC4H 3 0000005BH 0 0000005BH 0000005BH 0 5 ok
6 FFF0BDC4H FFF0BDC7H 3 0000008DH 0 0000008DH 0000008DH 0 6 ok
7 FFF0BDC8H FFF0BDCBH 3 000000BFH 0 000000BFH 000000BFH 0 7 ok
- task: task id
- sch at: time when putting the task on the run-queue started
- q at: time when the task was put on the run-queue
- q-del: delay between the two values above, ie. the set-up time
- sch for: time for when the task was scheduled to be triggered by the alarm timer
- run: run mode:
0
if run normally via alarm interrupt,1
if run directly due to the scheduling time already being in the past (error) - int at: time when the alarm timer interrupt occurred
- run at: time when the
Task
procedure was run - r-del: delay between the scheduled alarm trigger time and when the task was run
- ord: order in which the tasks were executed: if the order index equals task id print
ok
All times are in microseconds. They are printed as hexadecimal numbers, as they are CARDINAL values, not (signed) INTEGER (CARDINAL as with Modula-2, ie. 0 to 2^32 - 1).
The q-del
value for task 0 shows the caching of the set-up code, the r-del
for task 0 the caching of the alarm handler code. All tasks are executed correctly as alarm interrupt handlers (run = 0
).
Timing Evaluation: Minimum Consecutive Task Time
Decrease Time Between Consecutive Tasks
Now let’s put some pressure on the alarm handler by decreasing the time between consecutive tasks:
- In module
TaskEval
- change CONST
TimeBetweenTasks = TimeBetweenTasks1
, - leave CONST
FirstTaskAfter = FirstTaskAfter1
, - change CONST
TimerStart = TimerStart2
,
- change CONST
- in module
Tasks
- leave CONST
AlarmInRam = FALSE
, - leave CONST
PutInRam = FALSE
,
- leave CONST
Build and run TaskEval
, and we get:
task sch at q at q-del sch for run int at run at r-del ord
0 FFFFFDF7H FFFFFE1DH 38 FFFFFFE4H 0 FFFFFFE5H FFFFFFEDH 9 0 ok
1 FFFFFE1FH FFFFFE25H 6 FFFFFFEEH 1 -- FFFFFFFAH 12 1 ok
2 FFFFFE25H FFFFFE27H 2 FFFFFFF8H 1 -- 00000001H 9 2 ok
3 FFFFFE27H FFFFFE29H 2 00000002H 1 -- 00000002H 0 3 ok
4 FFFFFE2AH FFFFFE2CH 2 0000000CH 0 0000000CH 0000000CH 0 4 ok
5 FFFFFE2DH FFFFFE2FH 2 00000016H 0 00000016H 00000016H 0 5 ok
6 FFFFFE30H FFFFFE32H 2 00000020H 0 00000020H 00000020H 0 6 ok
7 FFFFFE33H FFFFFE36H 3 0000002AH 0 0000002AH 0000002AH 0 7 ok
Now we get cases with run = 1
, ie. the alarm handler (see Tasks.alarmHandler
) was late when trying to schedule the next task in the run-queue, meaning the current time was already past the task’s planned alarm trigger time, so the task’s procedures were run directly, ie. immediately during the same alarm handler execution as task 0. Consequently, tasks 1 and 2 were executed with a delay (r-del
), but in the right order. Task 3 was also executed directly (run = 1
), but just in time.
Hence, with the time between consecutive tasks of TimeBetweenTasks1 = 10
microseconds2 we have crossed the limit when executing everything from flash memory. Not shown here, with this test program, 25 microseconds are OK.
Since the tasks execution time eats into the available time between tasks, this result cannot be generalised: it really depends on the specific use case. This is true for all the following tests.
Same Timing, Run from SRAM
Next, we load the alarm handler into SRAM:
- In module
TaskEval
- leave CONST
TimeBetweenTasks = TimeBetweenTasks1
, - leave CONST
FirstTaskAfter = FirstTaskAfter1
, - leave CONST
TimerStart = TimerStart2
,
- leave CONST
- in module
Tasks
- change CONST
AlarmInRam = TRUE
, - leave CONST
PutInRam = FALSE
,
- change CONST
Build and run TaskEval
, and we get:
task sch at q at q-del sch for run int at run at r-del ord
0 FFFFFDF4H FFFFFE10H 28 FFFFFFE1H 0 FFFFFFE1H FFFFFFE1H 0 0 ok
1 FFFFFE12H FFFFFE18H 6 FFFFFFEBH 0 FFFFFFEBH FFFFFFEBH 0 1 ok
2 FFFFFE18H FFFFFE1AH 2 FFFFFFF5H 0 FFFFFFF5H FFFFFFF5H 0 2 ok
3 FFFFFE1BH FFFFFE1DH 2 FFFFFFFFH 0 FFFFFFFFH FFFFFFFFH 0 3 ok
4 FFFFFE1DH FFFFFE20H 3 00000009H 0 00000009H 00000009H 0 4 ok
5 FFFFFE20H FFFFFE23H 3 00000013H 0 00000013H 00000013H 0 5 ok
6 FFFFFE23H FFFFFE26H 3 0000001DH 0 0000001DH 0000001DH 0 6 ok
7 FFFFFE26H FFFFFE29H 3 00000027H 0 00000027H 00000027H 0 7 ok
As expected, when the alarm handler is run from SRAM, all Task
run on time, normally triggered as alarm handlers, and the r-del
of task 0 has disappeared, since no caching needs to take place.
As an aside, the set-up delay (q-del
) of task 0 has gone from 38 down to 28 microseconds, since the set-up and the alarm handler share some code, which is now in SRAM.
With this test program, running from SRAM, we can achieve about 10 microseconds between tasks (not shown here, see section Pulling All Registers below).
Timing Evaluation: Minimum Task Set-up Time
To check out the minimum set-up time:
- In module
TaskEval
- change CONST
TimeBetweenTasks = TimeBetweenTasks0
, - change CONST
FirstTaskAfter = FirstTaskAfter2
, - change CONST
TimerStart = TimerStart3
,
- change CONST
- in module
Tasks
- change CONST
AlarmInRam = FALSE
, - leave CONST
PutInRam = FALSE
,
- change CONST
Build and run TaskEval
, and we get:
task sch at q at q-del sch for run int at run at r-del ord
0 FFFFFF35H FFFFFF64H 47 FFFFFF42H 1 -- FFFFFF53H 17 0 ok
1 FFFFFF66H FFFFFF6BH 5 FFFFFF74H 0 FFFFFF75H FFFFFF7DH 9 1 ok
2 FFFFFF6BH FFFFFF71H 6 FFFFFFA6H 0 FFFFFFA6H FFFFFFA6H 0 2 ok
3 FFFFFF71H FFFFFF73H 2 FFFFFFD8H 0 FFFFFFD8H FFFFFFD8H 0 3 ok
4 FFFFFF73H FFFFFF89H 22 0000000AH 0 0000000AH 0000000AH 0 4 ok
5 FFFFFF89H FFFFFF8CH 3 0000003CH 0 0000003CH 0000003CH 0 5 ok
6 FFFFFF8CH FFFFFF8EH 2 0000006EH 0 0000006EH 0000006EH 0 6 ok
7 FFFFFF8FH FFFFFF91H 2 000000A0H 0 000000A0H 000000A0H 0 7 ok
Task 0 could not be scheduled in time, as FirstTaskAfter2 = 20
is too short after when module TaskEval
tries to put it onto the run-queue. It is run directly, so the set-up time q-del
includes its execution time.
For task 4, we see an additional set-up delay due to the first firing of an alarm during the time TestEval
sets up all tasks.
Same Timing, Run from SRAM
If we put the set-up code in module Tasks
into SRAM:
- In module
TaskEval
- leave CONST
TimeBetweenTasks = TimeBetweenTasks0
, - leave CONST
FirstTaskAfter = FirstTaskAfter2
, - leave CONST
TimerStart = TimerStart3
,
- leave CONST
- in module
Tasks
- leave CONST
AlarmInRam = FALSE
, - change CONST
PutInRam = TRUE
,
- leave CONST
We get
task sch at q at q-del sch for run int at run at r-del ord
0 FFFFFF34H FFFFFF3AH 6 FFFFFF41H 0 FFFFFF42H FFFFFF4AH 9 0 ok
1 FFFFFF3CH FFFFFF3DH 1 FFFFFF73H 0 FFFFFF73H FFFFFF73H 0 1 ok
2 FFFFFF3EH FFFFFF40H 2 FFFFFFA5H 0 FFFFFFA5H FFFFFFA5H 0 2 ok
3 FFFFFF40H FFFFFF5AH 26 FFFFFFD7H 0 FFFFFFD7H FFFFFFD7H 0 3 ok
4 FFFFFF5BH FFFFFF5DH 2 00000009H 0 00000009H 00000009H 0 4 ok
5 FFFFFF5DH FFFFFF60H 3 0000003BH 0 0000003BH 0000003BH 0 5 ok
6 FFFFFF60H FFFFFF63H 3 0000006DH 0 0000006DH 0000006DH 0 6 ok
7 FFFFFF63H FFFFFF66H 3 0000009FH 0 0000009FH 0000009FH 0 7 ok
With the same timing for the first task 0, the set-up is now in time due to the code being in SRAM, ie. all tasks are run by the alarm interrupt. We see caching of the alarm handler for task 0 (r-del
).
Decrease Time Before First Task, Run from SRAM
Let’s put some pressure on the set-up time by decreasing the time before the first task:
- In module
TaskEval
- leave CONST
TimeBetweenTasks = TimeBetweenTasks0
, - change CONST
FirstTaskAfter = FirstTaskAfter3
, - leave CONST
TimerStart = TimerStart3
,
- leave CONST
- in module
Tasks
- leave CONST
AlarmInRam = FALSE
, - leave CONST
PutInRam = TRUE
,
- leave CONST
We get
task sch at q at q-del sch for run int at run at r-del ord
0 FFFFFF37H FFFFFF43H 12 FFFFFF3AH 1 -- FFFFFF3BH 1 0 ok
1 FFFFFF45H FFFFFF47H 2 FFFFFF6CH 0 FFFFFF6DH FFFFFF75H 9 1 ok
2 FFFFFF48H FFFFFF49H 1 FFFFFF9EH 0 FFFFFF9EH FFFFFF9EH 0 2 ok
3 FFFFFF4AH FFFFFF4CH 2 FFFFFFD0H 0 FFFFFFD0H FFFFFFD0H 0 3 ok
4 FFFFFF4CH FFFFFF4EH 2 00000002H 0 00000002H 00000002H 0 4 ok
5 FFFFFF4EH FFFFFF51H 3 00000034H 0 00000034H 00000034H 0 5 ok
6 FFFFFF51H FFFFFF54H 3 00000066H 0 00000066H 00000066H 0 6 ok
7 FFFFFF54H FFFFFF57H 3 00000098H 0 00000098H 00000098H 0 7 ok
With FirstTaskAfter3 = 10
we have again crossed the line for the first set-up and get a direct execution, if only delayed by one microsecond.
With this test program we can achieve about 15 microseconds first task set-up time (see next section).
Pulling All Registers
Let’s run with the minimum times for set-up and between consecutive tasks as evaluated above, all relevant code in module Tasks
in SRAM:
- In module
TaskEval
- set CONST
TimeBetweenTasks = TimeBetweenTasks1
, - set CONST
FirstTaskAfter = FirstTaskAfter4
, - set CONST
TimerStart = TimerStart4
,
- set CONST
- in module
Tasks
- set CONST
AlarmInRam = TRUE
, - set CONST
PutInRam = TRUE
,
- set CONST
We get
task sch at q at q-del sch for run int at run at r-del ord
0 FFFFFFD4H FFFFFFDAH 6 FFFFFFDCH 0 FFFFFFDCH FFFFFFDCH 0 0 ok
1 FFFFFFDCH FFFFFFE5H 9 FFFFFFE6H 0 FFFFFFE6H FFFFFFE6H 0 1 ok
2 FFFFFFE5H FFFFFFE9H 4 FFFFFFF0H 0 FFFFFFF0H FFFFFFF1H 1 2 ok
3 FFFFFFEAH FFFFFFEBH 1 FFFFFFFAH 0 FFFFFFFAH FFFFFFFAH 0 3 ok
4 FFFFFFECH FFFFFFEEH 2 00000004H 0 00000004H 00000004H 0 4 ok
5 FFFFFFEEH FFFFFFF2H 4 0000000EH 0 0000000EH 0000000EH 0 5 ok
6 FFFFFFF3H FFFFFFF5H 2 00000018H 0 00000018H 00000018H 0 6 ok
7 FFFFFFF5H FFFFFFF7H 2 00000022H 0 00000022H 00000022H 0 7 ok
Experimental: Forcing Code into the Flash Cache
To close, let’s look at an experimental way of forcing a procedure to be in the flash cache memory, as an alternative to loading them into SRAM.
- In module
TaskEval
- set CONST
TimeBetweenTasks = TimeBetweenTasks1
, - set CONST
FirstTaskAfter = FirstTaskAfter4
, - set CONST
TimerStart = TimerStart4
,
- set CONST
- in module
Tasks
- set CONST
AlarmInRam = FALSE
, - set CONST
PutInRam = FALSE
, - set CONST
ForceCache = TRUE
.
- set CONST
We get
task sch at q at q-del sch for run int at run at r-del ord
0 FFFFFFD2H FFFFFFD8H 6 FFFFFFDAH 0 FFFFFFDAH FFFFFFDAH 0 0 ok
1 FFFFFFDAH FFFFFFE3H 9 FFFFFFE4H 0 FFFFFFE4H FFFFFFE4H 0 1 ok
2 FFFFFFE3H FFFFFFE7H 4 FFFFFFEEH 0 FFFFFFEEH FFFFFFEFH 1 2 ok
3 FFFFFFE8H FFFFFFE9H 1 FFFFFFF8H 0 FFFFFFF8H FFFFFFF8H 0 3 ok
4 FFFFFFEAH FFFFFFECH 2 00000002H 0 00000002H 00000002H 0 4 ok
5 FFFFFFECH FFFFFFF0H 4 0000000CH 0 0000000CH 0000000CH 0 5 ok
6 FFFFFFF1H FFFFFFF3H 2 00000016H 0 00000016H 00000016H 0 6 ok
7 FFFFFFF3H FFFFFFF5H 2 00000020H 0 00000020H 00000020H 0 7 ok
We get set-up and run times just like when loading the code into SRAM, see the Pulling All Registers test case.
Check out procedure MemoryExt.CacheProc
and its use in Tasks.Install
.
Bottom Line
We could put more code into SRAM, including the Task
procedures, to further improve the situation, if the first run requires it. In general, the caching does quite a good job, in particular if our timing requirements are above 100+ microseconds. In case we need to be below, a specifically customised program, including module Tasks
and its clients, is advisable, including thorough testing. Module Tasks
, with its design to run tasks directly in case their scheduled time falls behind (before) the current time, ensures that all tasks are run, but maybe with some delay. This can be acceptable or not.
Not sure yet how to implement the library module Tasks
. I don’t like highly parameterised modules – and neither procedures for that matter –, but customised variants coded straight for the use case. Maybe the concept of maintaining a list of Task
to run, and use only one alarm timer per core, is just too “heavy” for low microseconds timing.
Of course it’s nice to be able to schedule more tasks than we have alarm timers available (four), but an approach with restricting the number of tasks to run to the number of alarms, and run the corresponding task procedures directly as alarm interrupt handlers, hence avoiding the list management overhead, might be better for really “tight” timing requirements. I guess I’ll need to implement and test that as well.
Output Terminal
See Set-up, one-terminal set-up.
Build and Run
Build module TaskEval
with Astrobe, and create and upload the UF2 file using abin2uf2
.
Set Astrobe’s memory options as listed, and the library search path as explained.