Originally published May 4, 2017
Republished June 10, 2024
The first questions that comes to mind are:
- What is a task?
- How big should a task be?
- How long should a task take?
It is slippery as a watermelon seed. The answer, as always, is “it depends”. You have to be able to answer, what am I trying to do?
Always organize the tasks around the functions the device needs. In an earlier video, we looked at lighting an LED based on input from an accelerometer on the board. An example will help illustrate this much better than words, so let’s outline that project as a set of tasks.
What is included in the design:
- Lighting the LED
- General Purpose I/O which drives the LED
- The accelerometer
- An I2C bus, as shown in the video, which reads the accelerometer.
OK, we can assume the reading from the accelerometer is not in a format that can get passed directly to the the GPIO that lights the LED. That is really the main task. For a first pass, the task list would be:
Initialization tasks
- GPIO Init
- I2C Init
- Accelerometer Init
- LED init – probably not needed unless we want to flash the LED to show “all is well”
The task list:
- I2C Read
- Accel calculator
- GPIO set
That is all we need. Simple. Now if you want to add, say logging. It is as simple as adding the log task to the list.
The thing that’s bothering you
I am talking about how the I2C data read gets to the Accelerometer calculator which comes up with a value that is translated to the GPIO for the LED. Each task has to pass data to other tasks. This is called Inter Process Communication or IPC.
For something simple like this example, use a shared variable. Yes, it will be global, and any task could access it. Use the extern keyword, and be careful, and you won’t have any trouble. There are nice ways to build queues and messages. I will save those for later and not confuse the TaskTurner code.
The include file
The task.h file is separate from taskrunner.h. Each task has an include file that basically mirrors the task.h. The function declaration and a named Task structure should be included. The runNow variable should also be declared as an external variable. Meaning it is here for reference, but is declared elsewhere. (TODO example)
I can guess what your thinking, keep that thought, and I will explain why below.
Interrupts
Interrupts deserve a blog post all to themselves. If you don’t know what interrupts are, or how they work, we will explain them quickly. The hardware can “interrupt” the running software and jump to a special routine called the interrupt handler. The handler figures out which hardware needs attention, takes care of the hardware and returns to the exact place and state the running software was at when the interrupt occurred. If that is not enough explaination, for now just hum along, the deep specifics won’t be needed here.
An interrupt should be short to work well with the tasks, just copy data as needed to or from hardware, and maybe set the runNow variable for a task.
Keep them short and life is easy. Let them grow and so will your grey hair.
Adding tasks
This comes up from time to time. “How do I add a task?”
The reason the include file is structured in a very specific way makes this easy. Someplace in the code there will be a product or project specific task table and the forever loop that calls taskrunner(). It is simple, put the named task structures into and array, and pass it to the runner.
Each task defines the function called and the runNow variable locally, in the task file. I like to make each file use a name like “task_foo.c”, and “task_foo.h”.
But, someone always asks, “How do I add a task dynamically, at run time?” Don’t.
Seriously, it is a resource constrained embedded system. Don’t make a headache for yourself. If there are variations in the table for different products or configurations, make multiple tables, or use ‘#ifdef’ to setup the variations at compile time, not at run time.
What does the task do
It does one thing and does it well.
Some basic rules
There can not be endless loops in the task. That seems obvious, but they can sneak into a task. In an OS like Linux or most RTOS, there is “time slice scheduling”. Every process gets some CPU time. Yes, that is an oversimplification, but we are doing a simple system, remember. The tasks are run to completion.
There is no way to stop an endless loop, so don’t poll on hardware. Do a single read or a specific number of reads, then let the task run again after everyone else gets a chance. All the tasks in the list are called repeatedly, so this one will get another turn.
If the task takes too long, build a state machine, so you can pick up where you left off. Again, beyond the scope of this blog post, but a great future topic.
Those are all the hard rules of task building.
A handy guideline is to keep them small and focused. It is sometimes better to have a “task_send()” and a separate “task_receive()” just to keep them small and easy to test.
Testing the task
If you do a TDD process, the task is perfect. A function with a single purpose and well defined interface is easy to test.
One thing about task testing. If there is a problem, the task should always return the error. The best tests exercise the error cases. Testing has it’s own book. I think some of the book examples will be great for future posts.
Now, go build your own
That is all the advice I have on building tasks. One thing to notice, the structure makes it very easy to refactor code to more small tasks, or to combine them. Use a task like a LEGO to build anything you need.
Next time, we will lower the power used in your system.
Leave a Reply