Simple Real-Time Scheduling on a Raspberry Pi PICO

Published 2026-03-02

In this article, we'll explore some tried-and-true ways of getting basic scheduling working on a PICO using C.

Generally, I structure my PICO projects as an initialization section, followed by a real-time control loop that does the "work" of the project:

main ()
{
    initialize_hw ();
    for (;;) {
        do_work ();
    }
}

This is a standard pattern that goes back decades. (See Initializing Hardware on a Raspberry Pi PICO for details on the initialize_hw () function.)

The tricky part is doing things on a regular basis; say you want to take temperature readings once every 10 seconds. The obvious solution:

main ()
{
    initialize_hw ();
    for (;;) {
        sample_temperature ();
        sleep (10);
    }
}

is fine, until you want to do something else as well, but at a different rate. For example, you may need to update an LED display (at a 30 Hz scan rate), scan a keyboard (at maybe 10 Hz), handle serial data (at 115,200 baud), and so on.

I use the following outline when doing exactly that:

uint64_t now; // global

main ()
{
    initialize_hw ();
    for (;;) {
        now = time_us_64 ();

        sample_temperature ();
        drive_led ();
        check_keyboard ();
        check_serial ();
    }
}

The time_us_64() call tells me how many nanoseconds have elapsed since the PICO started.

Inside of each function, there's an internal "rate limiter" that makes sure the function runs only as often as required.

For the 30 Hz LED, we want to ensure that it runs 30 times per second. That works out to a period of 33,333,333 nanoseconds.

#define LED_SCAN_NS (33333333)  // 33 million nanoseconds

void
drive_led (void)
{
    static uint64_t last = 0;

    if (now - last < LED_SCAN_NS) return;
    last = now;

    // do whatever work
}

The 33,333,333 nanoseconds is 1/30th of a second. These numbers look awkward to me, so I tend to round to even multiples of nanoseconds when I can. Here, I'd use 30 million nanoseconds, giving me an effective rate of just over 33 Hz

Similarly, the sample_temperature() function would rate limit itself to once every 10 seconds:

#define KILO (1000)                     // typing all those zeros is error prone
#define MEGA (KILO * KILO)              // so I have macros in a common header file
#define GIGA (KILO * MEGA)              // that ensure I always get it right

#define TEMPERATURE_SCAN_NS (10 * GIGA) // 10 billion nanoseconds = 10 seconds

void
sample_temperature (void)
{
    static uint64_t last = 0;

    if (now - last < TEMPERATURE_SCAN_NS) return;
    last = now;

    // do whatever work
}

This pattern can be used for the check_keyboard() function as well.

Now, the check_serial() function is a little more interesting; it's self-limiting if you simply poll for availability of serial characters:

void
check_serial (void)
{
    if (!uart_is_readable (uart)) return;

    // do whatever work
}

If there aren't any characters available at the UART, then you can't do any work anyway, but if there are characters, you should process them.

Run to Completion vs Phasing

The entire scheme so far rests on one major assumption: everything runs fast enough.

If one of the "tasks" takes too long, then the entire timing chain is skewed. A practical example of this might be the temperature sensor — depending on the hardware, the temperature conversion may take hundreds of milliseconds! But the LED needs to run every 33 milliseconds, so we can't afford to wait for the temperature conversion to finish.

The practical solution to this is to run the slow tasks, like the temperature sensor, in phases.

#define TEMPERATURE_SCAN_NS (10 * GIGA)         // loop runs every 10 seconds
#define TEMPERATURE_CONVERSION_NS (100 * MEGA)  // conversion takes 100ms

void
sample_temperature (void)
{
    static enum { Converting, Waiting } state = Waiting;
    static uint64_t last = 0;

    switch (state) {
    case    Converting: // waiting for conversion to complete
        if (now - last < TEMPERATURE_CONVERSION_NS) return;
        temperature = read_hardware ();
        state = Waiting;
        break;

    case    Waiting:    // we have data, waiting for next sample
        if (now - last < TEMPERATURE_SCAN_NS) return;
        trigger_conversion ();
        state = Converting;
        break;
    }
    last = now;
}

Here we've split the temperature sampling function into two parts; one that tells the hardware to begin the conversion process, and one that reads the results. Since the hardware may take hundreds of milliseconds to perform the conversion, we don't want to wait around.

Walkthrough

Initially, the state is set to Waiting. The sample_temperature() function goes into case Waiting. Since this is the first time through, last will have the value 0, and the result of now - last will exceed TEMPERATURE_SCAN_NS. This means that the trigger_conversion() function will be called, and the hardware is now doing the long process of gathering the temperature. We change the state to Converting, and update the last value to the current time.

The next time sample_temperature() is called, it goes into case Converting. There it sees if the conversion time (given by TEMPERATURE_CONVERSION_NS) has elapsed; if not, it simply returns. Once elapsed, the function reads the temperature from the hardware, changes state to Waiting, and updates the last value to the current time.

This time, in the case Waiting, the last value is not zero, and for the next 10 seconds the function will simply return almost immediately.

The main trick here is that we use a simple state machine to "phase" the operation for things that take a long time.

Subtle Skews

You may have noticed that the cycle time of the temperature collection is actually 10.1 seconds, not 10 seconds. That's because there's a 100 ms conversion delay which gets added to the 10 s sampling delay. While this isn't terribly important for this article, it will result in a slow "drift" of your samples over time. The solution is simple — make the TEMPERATURE_CONVERSION_NS value be 100 milliseconds less, so that the total cycle time through both phases of the function comes out to 10 seconds exactly.

Hard Scheduling

Even with the adjustment above, there will still be a tiny drift. We're setting last to the value of the clock that we sampled in main(). The total amount of time that each task consumes will add to the clock time, so by the time you've exceeded the now - last computation, the time that you reset your last variable with isn't going to be an exact "tick."

In that case, you want something like this version:

#define TEMPERATURE_SCAN_NS (10 * GIGA)         // loop runs every 10 seconds
#define TEMPERATURE_CONVERSION_NS (100 * MEGA)  // conversion takes 100ms

void
sample_temperature (void)
{
    static enum { Converting, Waiting } state = Waiting;
    static uint64_t next = 0;
    if (now < next) return;

    switch (state) {
    case    Converting: // waiting for conversion to complete
        temperature = read_hardware ();
        next += TEMPERATURE_CONVERSION_NS;
        state = Waiting;
        break;

    case    Waiting:    // we have data, waiting for next sample
        trigger_conversion ();
        next += TEMPERATURE_SCAN_NS;
        state = Converting;
        break;
    }
}

Here, we specify the absolute time that the next phase of operations should happen, rather than as an increment from whatever the "current" time happens to be.

One small thing to be aware of is that since we initialize next to zero, we're already "late" as far as the very first operation goes. You can do a "clever hack" like this:

void
sample_temperature (void)
{
    static enum { Converting, Waiting } state = Waiting;
    static int64_t next = -TEMPERATURE_SCAN_NS;

    if (next < 0) {
        next = now - next;
    }
    ...
}

This changes next to be signed, and we use the fact that it's a negative value to indiciate that it needs to be initialized to the current time plus the next scheduled time.

You might need to do something like this if the time between PICO boot and the first run of the loop is going to cause unacceptable jitter. Generally, startup is pretty fast, so this would usually occur if you're just starting a task much later on in time. For example, if the temperature task was an optional service that got turned on one hour into operation, it would fire 360 times (once every 10 seconds for an hour's worth of time) all in successive calls as it raced to "catch up" to real time. With this initialization, it would correctly wait ten seconds before running for the first time.

Further reading

You may be interested in the Initializing PICO Hardware article for some tips on the initialize_hw() function.