Embedded systems doesn't necessarily mean that your small energy-efficient MCU can only perform one task. You do however, need some careful planning to insure that your tasks do not run at the same time. After all, we only have a single cpu to do all the work, so scheduling when those tasks get to the processing unit becomes incredibly important. It also means complexity..... or does it? Today's library spotlight looks at a tool that will quickly become one of your favorites like it has mine. For those applications that need to do multiple tasks, but not quite multiple serious things at the same time, this might be the answer.
There are a few approaches to getting some serious work and maximizing usage of your CPU. Normally, when you have a system that needs to do multiple tasks you would first turn to an operating system. An OS is responsible for resource management. I know some of you were thinking that it was to run solitaire while listening to a podcast, but the OS is just a resource management tool. When looking at embedded systems the first and only real operating system is Linux. Unfortunately, the Linux kernel requires a fair amount of RAM to expand into. Our MCUs that you find in the PIC32, ARM, AVR, FTDI, 8051 world doesn't meet those minimum requirements. So the next option is an RTOS ( real time operating system ). These minimalist approaches to an operating system do a great job at the primary mission of resource management, but fall short on usability. RTOSs require that you lock your resource with mutexes and semaphores. If you have an existing library that access the SPI bus, you can only have one task at a time accessing the SPI bus. So locking of these resources are a must to avoid the situation of hard locking. Again, complex. There does exist a middle ground and that is what brings me to our topic. A scheduler. These are not an operating system nor do they offer any management of resources per sa. They do allow use to have several tasks running and will execute them in a seemingly consecutive order. If your application requires intensive operations all working at the same time, schedulers aren't for you. RTOS is your next stop, but if a simple scheduling of tasks are what you need, we have the cure.
What You Will Need
MikroElektronika MikroC IDE / Compiler
MCU that is just waiting to do your bidding and has at least 1 free timer
30 Minutes of play time
https://github.com/MikroElektronika/Task_Scheduler
Dive into Code
Let's dive into the belly of the beast and discover how the monster works.
Header files are where we gain the overview of how the library works, source is where the magic happens. Before we can dive into guts, it is always wise to gain some perspective as to what we will find in the source.
typedef void ( *task_t )( void ); /**< function type ran in scheduler */ /** * @enum Status of tasks in scheduler * */ typedef enum { TASK_EMPTY = 0, TASK_STOPPED, /**< Task is stopped */ TASK_RUNNABLE, /**< Task is ready to be ran */ TASK_RUNNING, /**< Task is currently running */ TASK_ERROR = 99 /**< Error has occurred */ } task_status_e;
The first thing to notice is a typedef of a function pointer. That function pointer accepts no arguments and returns nothing. These are going to be the type of tasks we can run. Ones that accept nothing and return nothing are the signature of the functions that the library can call.
The typedef below is an enumeration and defines the various states of your task. Tasks can take any one of the states based on where they are in execution. These are going to be helpful later on if we want to stop (pause) a task and resume another time or even see if our task is running.
void task_scheduler_init( uint16_t clock );
Initialization of the scheduler will need some idea of how fast our timer is going to be overflowing. This only needs to be called at system initialization. We'll get to clock source in a moment.
Once we have a scheduler initialized, we can start adding tasks to run. Remember the tasks need to match the pattern of void func( void ); .
uint8_t task_add( task_t task, uint32_t period ); uint8_t task_get_count( void );
We add tasks by providing 2 arguments, a task and how often you want it to run. When you create a task it will return with an ID of that task. If you want to pause, stop, or delete that task, you'll want to store that ID somewhere.
*Note The function requires the time in ms. So if your timer that you are going to use with the library overflows every 500 ms and you want your task to run every 250 ms... Guess what, your task will be ran every 500ms because that is the min. amount of time your timer needs to overflow.
void task_delete( uint8_t id ); task_status_e task_get_status( uint8_t id ); void task_stop( uint8_t id ); void task_resume( uint8_t id );
These are convenience functions that work with the task id to delete, stop, resume, or get status of your task.
void task_scheduler_start( void ); void task_scheduler_stop( void );
Once you have your scheduler initialized, added some tasks, and ready to go, you need to tell it to start. You can also stop all tasks by the universal stop.
Now comes the most important of the functions. Dispatch;
void task_dispatch( void );
This function is intended to be ran inside the while( 1 ) loop found inside the main. This worker function is called continuously, and if a task is scheduled to run, it executes the task.
Last but not least, without a clock the scheduler will do nothing for you. You will need a clock source ISR and in that routine you need to call:
void task_scheduler_clock( void );
Accepts no argument and returns no value. Its whole purpose in life is to tick off the time that counts down to when a task needs to run.
Deep Dive into Source
The above could serve as enough to get started and use the library, but for the curious, let's look under the hood.
typedef struct { uint8_t id; /**< Task ID */ task_t task; /**< Pointer to the task */ uint32_t delay; /**< Delay before execution */ uint32_t period; /**< Task was set to at time of adding */ task_status_e task_status; /**< Status of task */ } task_control_t;
This serves as the structure that will hold all the metadata of the task. You have:
- id - number assigned to task
- task - the function to call
- delay - how much time left before executing the task
- period - how often is this task called
- task_status_e - the current status of the task
void task_scheduler_init( uint16_t clock ) { task_scheduler_running = 0; count_per_ms = 1.0f / ( float )clock; return; }
Nothing real special here, turn off the scheduler flag and get an idea of how many counts are there per 1 ms. This is a fractional number that will allow for odd timer counts.
uint8_t task_add( task_t task, uint32_t period ) { uint8_t task_id = 0; float time_calc = ( ( double )period ) * count_per_ms; if( time_calc < 1 ) time_calc = 1.0f; for( task_id = 0; task_id < MAX_TASKS; task_id++ ) { if( task_list[task_id].task_status == TASK_EMPTY ) { task_list[task_id].task_status = TASK_RUNNABLE; task_list[task_id].id = task_id; task_list[task_id].task = task; task_list[task_id].delay = ( time_calc > ( floor( time_calc + 0x5f ) ) ) ? ceil( time_calc ) : floor( time_calc ); task_list[task_id].period = task_list[task_id].delay; return task_list[task_id].id; } } return TASK_ERROR; }
Ok, now some meat. This function does a couple of things. It first finds an empty slot to place our new task. That is done by if( task_list[task_id].task_status == TASK_EMPTY ) by default all the tasks statuses are initialized to TASK_EMPTY. Once the array of tasks are populated, then the task is no longer empty task_list[task_id].task_status = TASK_RUNNABLE; . It is assigned an id: task_list[task_id].id = task_id; this id is simply the number of iterations the loop has to go to find an empty slot.
The delay and period start their life being the same number, but that number is assigned by:
task_list[task_id].delay = ( time_calc > ( floor( time_calc + 0x5f ) ) ) ? ceil( time_calc ) : floor( time_calc );
If the time calculated is greater than the max then take the ceil of time calc else take the floor. Why? Because we need a whole number for the delay. No floating point numbers work well with a counter. What this means is that the task that you add could not be a perfect 4555.3ms it would be a close rounded number of ms.
Then you have the working part of the code:
void task_dispatch() { if( task_scheduler_running == 1 ) { int i; for( i = 0; i < MAX_TASKS; i++ ) { // check for a valid task ready to run if( ( task_list[i].delay == 0 ) && ( task_list[i].task_status == TASK_RUNNABLE ) ) { task_list[i].task_status = TASK_RUNNING; // task is now running ( *task_list[i].task )(); // call the task task_list[i].delay = task_list[i].period; // reset the delay task_list[i].task_status = TASK_RUNNABLE; // task is runnable again } } } }
This function checks to see that scheduler is running, if so it iterates through all the tasks and if the delay == 0 and it is a RUNNABLE task, then it's time to execute that task. So it calls the task, and resets the delay back to the original period.
The last function that deserves some attention is the one called in the timer ISR:
void task_scheduler_clock() { if( task_scheduler_running ) { int i; // cycle through available tasks for( i = 0; i < MAX_TASKS; i++ ) { if( task_list[i].task_status == TASK_RUNNABLE ) { if( task_list[i].delay > 0 ) { task_list[i].delay--; } } } } }
This function iterates through the tasks and if it is not stopped, it will decrement a delay from the task. Pretty straight forward.
Example
/****************************************************************************** * Function Prototypes *******************************************************************************/ void say_hello( void ); void init_timer2( void ); /****************************************************************************** * Function Definitions *******************************************************************************/ void say_hello() { UART1_Write_Text( "Hello" ); } //Timer2 Prescaler :575; Preload = 62499; Actual Interrupt Time = 500 ms void init_timer2() { RCC_APB1ENR.TIM2EN = 1; TIM2_CR1.CEN = 0; TIM2_PSC = 575; TIM2_ARR = 62499; NVIC_IntEnable(IVT_INT_TIM2); TIM2_DIER.UIE = 1; TIM2_CR1.CEN = 1; } void main() { /* Initialize task scheduler by informing it how often the clock interrupts */ task_scheduler_init( 500 ); init_timer2(); task_add( say_hello, SCH_SECONDS_1 ); EnableInterrupts(); task_scheduler_start(); while( 1 ) { task_dispatch(); } } void timer2_interrupt() iv IVT_INT_TIM2 { TIM2_SR.UIF = 0; task_scheduler_clock(); }
The last function is the ISR. Inside there it calls task_scheduler_clock() the scheduler is initialized and a task is added. Inside the while loop, task_dispatch() is called and that's the show.
Some Notes
Q. So how many tasks can you add?
Q. That is defined in the scheduler.h file. Default is 7 but memory is your only limitation. Since the scheduler doesn't take up much space, a lot.
Q. How long of a delay can I have?
A. This depends on 2 things. How often your timer overflows and the largest number you can fit in a uint32_t. With a 500ms timer, you can schedule events days apart from each other. Some of the pre-defined scheduled times are:
#define MAX_TASKS 7 #define SCH_SECONDS_1 1000 #define SCH_SECONDS_5 5000 #define SCH_SECONDS_10 10000 #define SCH_SECONDS_15 15000 #define SCH_SECONDS_30 30000 #define SCH_MINUTES_1 SCH_SECONDS_1 * 60 #define SCH_MINUTES_15 SCH_MINUTES_1 * 15 #define SCH_MINUTES_30 SCH_MINUTES_15 * 2 #define SCH_HOURS_1 SCH_MINUTES_30 * 2 #define SCH_HOURS_12 SCH_HOURS_1 * 12 #define SCH_DAY_1 SCH_HOURS_12 * 2
Q. What is an example of what this can be used for?
A. Well, I often have protocols, like TCP, that need to be updated every 1000ms in order for the protocol to work. This works great. I also have need for updating my time from the RTC. This doesn't need to be every 1ms, so I schedule it to be done every 1 minute. How about radio communications. I gather data and want it sent every 5 minutes. Done.