How to do high-resolution, timestamp-based, non-blocking, single-threaded cooperative multi-tasking
This isn't a "how to read a sensor" problem, this is a "how to do non-blocking cooperative multi-tasking" problem. Assuming you are running bare-metal (no operating system, such as FreeRTOS), you have two good options.
First, the datasheet shows you need to wait up to 9.04 ms, or 9040 us.
Now, here are your cooperative multi-tasking options:
Send a command to tell the device to do an ADC conversion (ie: to take an analog measurement), then configure a hardware timer to interrupt you exactly 9040 us later. In your ISR you then can either set a flag to tell your main loop to send a read command to read the result, OR you can just send the read command right inside the ISR.
Use non-blocking time-stamp-based cooperative multi-tasking in your main loop. This will likely require a basic state machine. Send the conversion command, then move on, doing other things. When your time stamp indicates it's been long enough, send the read command to read the converted result from the sensor.
Number 1 above is my preferred approach for time-critical tasks. This isn't time-critical, however, and a little jitter won't make any difference, so Number 2 above is my preferred approach for general, bare-metal cooperative multi-tasking, so let's do that.
Here's a sample program to demonstrate the principle of time-stamp-based bare-metal cooperative multi-tasking for your specific case where you need to:
- request a data sample (start ADC conversion in your external sensor)
- wait 9040 us for the conversion to complete
- read in the data sample from your external sensor (now that the ADC conversion is complete)
Code:
enum sensorState_t
{
SENSOR_START_CONVERSION,
SENSOR_WAIT,
SENSOR_GET_CONVERSION
}
int main(void)
{
doSetupStuff();
configureHardwareTimer(); // required for getMicros() to work
while (1)
{
//
// COOPERATIVE TASK #1
// Read the under-water pressure sensor as fast as permitted by the datasheet
//
static sensorState_t sensorState = SENSOR_START_CONVERSION; // initialize state machine
static uint32_t task1_tStart; // us; start time
static uint32_t sensorVal; // the sensor value you are trying to obtain
static bool newSensorVal = false; // set to true whenever a new value arrives
switch (sensorState)
{
case SENSOR_START_CONVERSION:
{
startConversion(); // send command to sensor to start ADC conversion
task1_tStart = getMicros(); // get a microsecond time stamp
sensorState = SENSOR_WAIT; // next state
break;
}
case SENSOR_WAIT:
{
const uint32_t DESIRED_WAIT_TIME = 9040; // us
uint32_t tNow = getMicros();
if (tNow - task1_tStart >= DESIRED_WAIT_TIME)
{
sensorState = SENSOR_GET_CONVERSION; // next state
}
break;
}
case SENSOR_GET_CONVERSION:
{
sensorVal = readConvertedResult(); // send command to read value from the sensor
newSensorVal = true;
sensorState = SENSOR_START_CONVERSION; // next state
break;
}
}
//
// COOPERATIVE TASK #2
// use the under-water pressure sensor data right when it comes in (this will be an event-based task
// whose running frequency depends on the rate of new data coming in, for example)
//
if (newSensorVal == true)
{
newSensorVal = false; // reset this flag
// use the sensorVal data here now for whatever you need it for
}
//
// COOPERATIVE TASK #3
//
//
// COOPERATIVE TASK #4
//
// etc etc
} // end of while (1)
} // end of main
For another really simple timestamp-based multi-tasking example see Arduino's "Blink Without Delay" example here.
General time-stamp-based bare-metal cooperative multi-tasking architecture notes:
Depending on how you do it all, in the end, you basically end up with this type of code layout, which simply runs each task at fixed time intervals. Each task should be non-blocking to ensure it does not conflict with the run intervals of the other tasks. Non-blocking on bare metal means simply "do not use clock-wasting delays, busy-loops, or other types of polling, repeating, counting, or busy delays!". (This is opposed to "blocking" on an operating-system-based (OS-based) system, which means "giving the clock back to the scheduler to let it run another thread while this task 'sleeps'." Remember: bare metal means no operating system!). Instead, if something isn't quite ready to run yet, simply save your state via a state machine, exit this task's code (this is the "cooperative" part, as your task must voluntarily give up the processor by returning), and let another task run!
Here's the basic architecture, showing a simple timestamp-based way to get 3 Tasks to run at independent, fixed frequencies withOUT relying on any interrupts, and with minimal jitter, due to the thorough and methodical approach I take to check the timestamps and update the start time at each run time.
1st, the definition for the main()
function and main loop:
int main(void)
{
doSetupStuff();
configureHardwareTimer();
while (1)
{
doTask1();
doTask2();
doTask3();
}
}
2nd, the definitions for the doTask()
functions:
// Task 1: Let's run this one at 100 Hz (every 10ms)
void doTask1(void)
{
const uint32_t DT_DESIRED_US = 10000; // 10000us = 10ms, or 100Hz run freq
static uint32_t t_start_us = getMicros();
uint32_t t_now_us = getMicros();
uint32_t dt_us = t_now_us - t_start_us;
// See if it's time to run this Task
if (dt_us >= DT_DESIRED_US)
{
// 1. Add DT_DESIRED_US to t_start_us rather than setting t_start_us to t_now_us (which many
// people do) in order to ***avoid introducing artificial jitter into the timing!***
t_start_us += DT_DESIRED_US;
// 2. Handle edge case where it's already time to run again because just completing one of the main
// "scheduler" loops in the main() function takes longer than DT_DESIRED_US; in other words, here
// we are seeing that t_start_us is lagging too far behind (more than one DT_DESIRED_US time width
// from t_now_us), so we are "fast-forwarding" t_start_us up to the point where it is exactly
// 1 DT_DESIRED_US time width back now, thereby causing this task to instantly run again the
// next time it is called (trying as hard as we can to run at the specified frequency) while
// at the same time protecting t_start_us from lagging farther and farther behind, as that would
// eventually cause buggy and incorrect behavior when the (unsigned) timestamps start to roll over
// back to zero.
dt_us = t_now_us - t_start_us; // calculate new time delta with newly-updated t_start_us
if (dt_us >= DT_DESIRED_US)
{
t_start_us = t_now_us - DT_DESIRED_US;
}
// PERFORM THIS TASK'S OPERATIONS HERE!
}
}
// Task 2: Let's run this one at 1000 Hz (every 1ms)
void doTask2(void)
{
const uint32_t DT_DESIRED_US = 1000; // 1000us = 1ms, or 1000Hz run freq
static uint32_t t_start_us = getMicros();
uint32_t t_now_us = getMicros();
uint32_t dt_us = t_now_us - t_start_us;
// See if it's time to run this Task
if (dt_us >= DT_DESIRED_US)
{
t_start_us += DT_DESIRED_US;
dt_us = t_now_us - t_start_us; // calculate new time delta with newly-updated t_start_us
if (dt_us >= DT_DESIRED_US)
{
t_start_us = t_now_us - DT_DESIRED_US;
}
// PERFORM THIS TASK'S OPERATIONS HERE!
}
}
// Task 3: Let's run this one at 10 Hz (every 100ms)
void doTask3(void)
{
const uint32_t DT_DESIRED_US = 100000; // 100000us = 100ms, or 10Hz run freq
static uint32_t t_start_us = getMicros();
uint32_t t_now_us = getMicros();
uint32_t dt_us = t_now_us - t_start_us;
// See if it's time to run this Task
if (dt_us >= DT_DESIRED_US)
{
t_start_us += DT_DESIRED_US;
dt_us = t_now_us - t_start_us; // calculate new time delta with newly-updated t_start_us
if (dt_us >= DT_DESIRED_US)
{
t_start_us = t_now_us - DT_DESIRED_US;
}
// PERFORM THIS TASK'S OPERATIONS HERE!
}
}
The code above works perfectly but as you can see is pretty redundant and a bit irritating to set up new tasks. This job can be a bit more automated and much much easier to do by simply defining a macro, CREATE_TASK_TIMER()
, as follows, to do all of the redundant timing stuff and timestamp variable creation for us:
/// @brief A function-like macro to get a certain set of events to run at a desired, fixed
/// interval period or frequency.
/// @details This is a timestamp-based time polling technique frequently used in bare-metal
/// programming as a basic means of achieving cooperative multi-tasking. Note
/// that getting the timing details right is difficult, hence one reason this macro
/// is so useful. The other reason is that this maro significantly reduces the number of
/// lines of code you need to write to introduce a new timestamp-based cooperative
/// task. The technique used herein achieves a perfect desired period (or freq)
/// on average, as it centers the jitter inherent in any polling technique around
/// the desired time delta set-point, rather than always lagging as many other
/// approaches do.
///
/// USAGE EX:
/// ```
/// // Create a task timer to run at 500 Hz (every 2000 us, or 2 ms; 1/0.002 sec = 500 Hz)
/// const uint32_t PERIOD_US = 2000; // 2000 us pd --> 500 Hz freq
/// bool time_to_run;
/// CREATE_TASK_TIMER(PERIOD_US, time_to_run);
/// if (time_to_run)
/// {
/// run_task_2();
/// }
/// ```
///
/// Source: Gabriel Staples
/// https://mcmap.net/q/21042/-best-way-to-read-from-a-sensor-that-doesn-39-t-have-interrupt-pin-and-requires-some-time-before-the-measurement-is-ready/50032992#50032992
/// @param[in] dt_desired_us The desired delta time period, in microseconds; note: pd = 1/freq;
/// the type must be `uint32_t`
/// @param[out] time_to_run A `bool` whose scope will enter *into* the brace-based scope block
/// below; used as an *output* flag to the caller: this variable will
/// be set to true if it is time to run your code, according to the
/// timestamps, and will be set to false otherwise
/// @return NA--this is not a true function
#define CREATE_TASK_TIMER(dt_desired_us, time_to_run) \
{ /* Use scoping braces to allow multiple calls of this macro all in one outer scope while */ \
/* allowing each variable created below to be treated as unique to its own scope */ \
time_to_run = false; \
\
/* set the desired run pd / freq */ \
const uint32_t DT_DESIRED_US = dt_desired_us; \
static uint32_t t_start_us = getMicros(); \
uint32_t t_now_us = getMicros(); \
uint32_t dt_us = t_now_us - t_start_us; \
\
/* See if it's time to run this Task */ \
if (dt_us >= DT_DESIRED_US) \
{ \
/* 1. Add DT_DESIRED_US to t_start_us rather than setting t_start_us to t_now_us (which many */ \
/* people do) in order to ***avoid introducing artificial jitter into the timing!*** */ \
t_start_us += DT_DESIRED_US; \
/* 2. Handle edge case where it's already time to run again because just completing one of the main */ \
/* "scheduler" loops in the main() function takes longer than DT_DESIRED_US; in other words, here */ \
/* we are seeing that t_start_us is lagging too far behind (more than one DT_DESIRED_US time width */ \
/* from t_now_us), so we are "fast-forwarding" t_start_us up to the point where it is exactly */ \
/* 1 DT_DESIRED_US time width back now, thereby causing this task to instantly run again the */ \
/* next time it is called (trying as hard as we can to run at the specified frequency) while */ \
/* at the same time protecting t_start_us from lagging farther and farther behind, as that would */ \
/* eventually cause buggy and incorrect behavior when the (unsigned) timestamps start to roll over */ \
/* back to zero. */ \
dt_us = t_now_us - t_start_us; /* calculate new time delta with newly-updated t_start_us */ \
if (dt_us >= DT_DESIRED_US) \
{ \
t_start_us = t_now_us - DT_DESIRED_US; \
} \
\
time_to_run = true; \
} \
}
Now, there are multiple ways to use it, but for the sake of this demo, in order to keep the really clean main()
loop code which looks like this:
int main(void)
{
doSetupStuff();
configureHardwareTimer();
while (1)
{
doTask1();
doTask2();
doTask3();
}
}
Let's use the CREATE_TASK_TIMER()
macro like this. As you can see, the code is now much cleaner and easier to set up a new task. This is my preferred approach, because it creates the really clean main loop shown just above, with just the various doTask()
calls, which are also easy to write and maintain:
// Task 1: Let's run this one at 100 Hz (every 10ms, or 10000us)
void doTask1(void)
{
bool time_to_run;
const uint32_t DT_DESIRED_US = 10000; // 10000us = 10ms, or 100Hz run freq
CREATE_TASK_TIMER(DT_DESIRED_US, time_to_run);
if (time_to_run)
{
// PERFORM THIS TASK'S OPERATIONS HERE!
}
}
// Task 2: Let's run this one at 1000 Hz (every 1ms)
void doTask2(void)
{
bool time_to_run;
const uint32_t DT_DESIRED_US = 1000; // 1000us = 1ms, or 1000Hz run freq
CREATE_TASK_TIMER(DT_DESIRED_US, time_to_run);
if (time_to_run)
{
// PERFORM THIS TASK'S OPERATIONS HERE!
}
}
// Task 3: Let's run this one at 10 Hz (every 100ms)
void doTask3(void)
{
bool time_to_run;
const uint32_t DT_DESIRED_US = 100000; // 100000us = 100ms, or 10Hz run freq
CREATE_TASK_TIMER(DT_DESIRED_US, time_to_run);
if (time_to_run)
{
// PERFORM THIS TASK'S OPERATIONS HERE!
}
}
Alternatively, however, you could structure the code more like this, which works equally as well and produces the same effect, just in a slightly different way:
#include <stdbool.h>
#include <stdint.h>
#define TASK1_PD_US (10000) // 10ms pd, or 100 Hz run freq
#define TASK2_PD_US (1000) // 1ms pd, or 1000 Hz run freq
#define TASK3_PD_US (100000) // 100ms pd, or 10 Hz run freq
// Task 1: Let's run this one at 100 Hz (every 10ms, or 10000us)
void doTask1(void)
{
// PERFORM THIS TASK'S OPERATIONS HERE!
}
// Task 2: Let's run this one at 1000 Hz (every 1ms)
void doTask2(void)
{
// PERFORM THIS TASK'S OPERATIONS HERE!
}
// Task 3: Let's run this one at 10 Hz (every 100ms)
void doTask3(void)
{
// PERFORM THIS TASK'S OPERATIONS HERE!
}
int main(void)
{
doSetupStuff();
configureHardwareTimer();
while (1)
{
bool time_to_run;
CREATE_TASK_TIMER(TASK1_PD_US, time_to_run);
if (time_to_run)
{
doTask1();
}
CREATE_TASK_TIMER(TASK2_PD_US, time_to_run);
if (time_to_run)
{
doTask2();
}
CREATE_TASK_TIMER(TASK3_PD_US, time_to_run);
if (time_to_run)
{
doTask3();
}
}
}
Part of the art (and fun!) of embedded bare-metal microcontroller programming is the skill and ingenuity involved in deciding exactly how you want to interleave each task and get them to run together, all as though they were running in parallel. Use one of the above formats as a starting point, and adapt to your particular circumstances. Message-passing can be added between tasks or between tasks and interrupts, tasks and a user, etc, as desired, and as required for your particular application.
Here's an example of how to configure a timer for use as a timestamp-generator on an STM32F2 microcontroller.
This shows functions for configureHardwareTimer()
and getMicros()
, used above:
// Timer handle to be used for Timer 2 below
TIM_HandleTypeDef TimHandle;
// Configure Timer 2 to be used as a free-running 32-bit hardware timer for general-purpose use as a 1-us-resolution
// timestamp source
void configureHardwareTimer()
{
// Timer clock must be enabled before you can configure it
__HAL_RCC_TIM2_CLK_ENABLE();
// Calculate prescaler
// Here are some references to show how this is done:
// 1) "STM32Cube_FW_F2_V1.7.0/Projects/STM32F207ZG-Nucleo/Examples/TIM/TIM_OnePulse/Src/main.c" shows the
// following (slightly modified) equation on line 95: `Prescaler = (TIMxCLK/TIMx_counter_clock) - 1`
// 2) "STM32F20x and STM32F21x Reference Manual" states the following on pg 419: "14.4.11 TIMx prescaler (TIMx_PSC)"
// "The counter clock frequency CK_CNT is equal to fCK_PSC / (PSC[15:0] + 1)"
// This means that TIMx_counter_clock_freq = TIMxCLK/(prescaler + 1). Now, solve for prescaler and you
// get the exact same equation as above: `prescaler = TIMxCLK/TIMx_counter_clock_freq - 1`
// Calculating TIMxCLK:
// - We must divide SystemCoreClock (returned by HAL_RCC_GetHCLKFreq()) by 2 because TIM2 uses clock APB1
// as its clock source, and on my board this is configured to be 1/2 of the SystemCoreClock.
// - Note: To know which clock source each peripheral and timer uses, you can look at
// "Table 25. Peripheral current consumption" in the datasheet, p86-88.
const uint32_t DESIRED_TIMER_FREQ = 1e6; // 1 MHz clock freq --> 1 us pd per tick, which is what I want
uint32_t Tim2Clk = HAL_RCC_GetHCLKFreq() / 2;
uint32_t prescaler = Tim2Clk / DESIRED_TIMER_FREQ - 1; // Don't forget the minus 1!
// Configure timer
// TIM2 is a 32-bit timer; See datasheet "Table 4. Timer feature comparison", p30-31
TimHandle.Instance = TIM2;
TimHandle.Init.Period = 0xFFFFFFFF; // Set pd to max possible for a 32-bit timer
TimHandle.Init.Prescaler = prescaler;
TimHandle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
TimHandle.Init.CounterMode = TIM_COUNTERMODE_UP;
TimHandle.Init.RepetitionCounter = 0; // NA (has no significance) for this timer
// Initialize the timer
if (HAL_TIM_Base_Init(&TimHandle) != HAL_OK)
{
// handle error condition
}
// Start the timer
if (HAL_TIM_Base_Start(&TimHandle) != HAL_OK)
{
// handle error condition
}
}
// Get the 1 us count value on Timer 2.
// This timer will be used for general purpose hardware timing that does NOT rely on interrupts.
// Therefore, the counter will continue to increment even with interrupts disabled.
// The count value increments every 1 microsecond.
// Since it is a 32-bit counter it overflows every 2^32 counts, which means the highest value it can
// store is 2^32 - 1 = 4294967295. Overflows occur every 2^32 counts / 1 count/us / 1e6us/sec
// = ~4294.97 sec = ~71.6 min.
uint32_t getMicros()
{
return __HAL_TIM_GET_COUNTER(&TimHandle);
}
References:
- https://www.arduino.cc/en/tutorial/BlinkWithoutDelay
- Doxygen: What's the right way to reference a parameter in Doxygen?
- Enum-based error codes for error handling: Error handling in C code
- Other architectural styles in C, such as "object-based" C via opaque pointers: Opaque C structs: various ways to declare them
See also:
- A full, runnable Arduino example with an even better version of my
CREATE_TASK_TIMER()
macro from above:
- My answer for C and C++, including microcontrollers and Arduino (or any other system): Full coulomb counter example demonstrating the above concept with timestamp-based, single-threaded, cooperative multi-tasking
- My answer: Arduino: fill array with values from
analogRead()