Implementing Arduino-Style millis() on PIC16 Using seqLock for Safe ISR-to-Main Variable Transfer

This engineering brief shows how to transfer a millisecond count value from an Interrupt Service Routine (ISR) to main( ) using the Sequential Lock (seqLock) technique. This technique allows us to safely transfer data without disabling the microcontroller’s global interrupts. The technique is widely applicable to all microcontrollers and will be demonstrated on a PIC16F13145 as shown in Figure 1.

This article is a continuation of the previous millisecond callback article. Please review the prerequisite article to better understand how the MPLAB Code Configurator (MCC) generated callback is used in this application.

Figure 1: Image of the PIC16F13145 Curiosity Nano and pushbuttons mounted on a breadboard.

Tech Tip: The term atomic is a fundamental prerequisite to understanding seqLock. Atomic is defined as the smallest bit width that can’t be split up e.g., completed in one uninterrupted step. It’s 8 bits for the featured PIC16 or 32 bits for microcontrollers such as the ARM.

We encounter a hazard when we transfer data between ISR and main( ) when the variables are above the atomic width of the machine. This hazard is present for the 8-bit PIC transferring the 32-bit millisecond count. To mitigate this problem, we could disable global interrupts or use a dual lock mechanism such as the seqLock described in this note.

What is a sequential lock?

The sequential lock (seqLock) is a mechanism that allows non-atomic transfer of data between two independent sections of code. To understand the mechanism, we will first review the operation of an ISR.

Why use an ISR?

In this PIC16 application we have established a callback function that is triggered once every millisecond via a hardware timer.

  • TMR0 is driven directly from the internal 32 MHz clock.

  • TMR0 takes on the precision characteristics of HFINTOSC. If necessary, it could be improved by switching to an external crystal oscillator.

  • The callback runs independently from main( ). Stated another way, what happens in main( ) will not impact the timing of the callback.

  • There is interrupt latency in switching from main( ) to ISR. However, this is negligible when compared to the overall timing of the millisecond count maintained within the ISR.

Why transfer the millisecond count to main( )?

Let’s start by defining the terms foreground and background processes:

  • Foreground: The ISR operates in the foreground with high priority. In human terms, the ISR is like the reflex nervous system. Things happen very fast with minimal computation. Every clock cycle is important.

  • Background: The main( ) code operates in the background. Think of this as the higher brain processes. While timing is important, things are much more relaxed. A few hundred spilled clock cycles are not important unless we are trying to eke out every ounce of battery energy.

The superloop timing is variable, but the ISR is not

Recall that the time to traverse main’s while(1) superloop is highly variable. It can range from a few microseconds to many seconds depending on the code type and complexity. It would be nearly impossible to keep track of time in this highly variable system.

However, the ISR’s timing is highly predictable. It can maintain the millisecond count with ease and precision.

Use the best of both worlds

We use the ISR and superloop properties to our advantage by maintaining time in the ISR and then transferring it to main( ) when it is needed. We do this with the understanding that main’s on-the-dot value is allowed to slip up to an entire millisecond. For instance, one clock cycle within main( ) can be the difference between using the old or new value for the millisecond count.

Why not run everything in the ISR?

This can be done but it takes considerable programming skill. It only works if every code cycle is completed within the millisecond time slot. One slip and the time will be lost in this cooperative environment. In my opinion, it’s not worth the headache. Use the ISR as the timekeeper and perform the bulk tasks within main( ).

How does the seqLock operate?

The seqLock is a two-part process including a small ISR callback and reader function which is called from main( ).

The ISR portion of seqLock

The millisecond counter is implemented in three lines of code within My_1ms_Callback as shown in Listing 1. The volatile seq variable is incremented both before and after the millisecond accumulator increments. In a moment we will see that this two-step lock is essential for proper transfer of the non-atomic data.

The first statement is designated odd, seq will be odd after the addition operation. The seq++ statement make the seq variable even when it exits the ISR.

static volatile uint8_t  seq = 0u;
static volatile uint32_t msAccum = 0u;
static void My_1ms_Callback(void){
   
    seq++;          // Begin write (odd)
    msAccum++;      // Increment the 1 ms accumulator
    seq++;          // End write (even)
}

Listing 1: The ISR for the seqLock is only three lines of code.

The function portion of seqLock

Listing 2 presents the read, check, and retry portion of the seqLock. The secret is to capture the before and after values of seq. We then compare the s1 and s2 values and proceed only if s1==s2.

Note that the ISR may occur at any time relative to main( ). The atomic transfer of s1 and s2 allows us to detect if an ISR update occurs in the middle of the v = msAccum transfer (4 operations on the PIC16). If a collision occurs, we retry.

Use of the seqLock from within main( )

The complete PIC16 demo program is included as Listing 3. This result is the familiar Arduino-like millis function called using:

        uint32_t now = millis( );

Note that the I/O pins are configured using MPLAB Code Configurator (MCC). Also note that My_1ms_Callback(void) is registered in main( ) using the statement TMR0_OverflowCallbackRegister(My_1ms_Callback);.

uint32_t millis(void){
    uint8_t s1, s2;
    uint32_t v;
    do {   // read, copy, retry
        s1 = seq;
        v  = msAccum;
        s2 = seq;
    } while ((s1 != s2) || (s1 & 1u));   //  Retry if changed. 
    return v;
}

Listing 2: The millis( ) function features a try / retry mechanism to safely transfer non-atomic data from the ISR.

Tech Tip: The ISR and main( ) are not time coordinated. There are two extremes to consider:

  • Short superloop: where main( ) calls millis( ) thousands of times before the ISR reads the counter.

  • Long superloop: where many ISR callbacks advance the counter before it is read from main( ).

Either way, the ISR maintains the millisecond counter and the latest value is available to main( ) when the millis( ) is called.

#include "mcc_generated_files/system/system.h"

#define HEARTBEAT_MS   (500u)
static volatile uint16_t msTicks = 0u;

static volatile uint8_t  seq = 0u;
static volatile uint32_t msAccum = 0u;

/**
 * @brief 1 ms timer overflow callback (registered with MCC).
 * @details seqLock pattern: bump @c seq before and after updating @c msAccum
 *          so readers can detect mid-update without disabling interrupts.
 *          Also toggles LATB5 every @c HEARTBEAT_MS as a heartbeat.
 * @note Register with TMR0_OverflowCallbackRegister(My_1ms_Callback).
 *       Assumes LATB5 has been configured as an output by MCC.
 */
static void My_1ms_Callback(void){
   
    seq++;          // Begin write (odd)
    msAccum++;      // Increment the 1 ms accumulator
    seq++;          // End write (even)

    if (++msTicks >= HEARTBEAT_MS) {  // Heartbeat LED every 500 ms
        msTicks = 0u;
        LATBbits.LATB5 ^= 1;
    }

   
}

/**
 * @brief Atomic millisecond read via seqLock.
 * @details Read @c seq, then @c msAccum, then @c seq again.
 *          Retry if the version changed (s1 != s2) or if the start version
 *          was odd (writer in progress on architectures that allow nesting).
 *          On PIC16, nesting doesn’t occur, but we keep the odd check for portability.
 * @return Monotonic millisecond count (wraps after ~49.7 days).
 */
uint32_t millis(void){
    uint8_t s1, s2;
    uint32_t v;
    do {   // read, copy, retry
        s1 = seq;
        v  = msAccum;
        s2 = seq;
    } while ((s1 != s2) || (s1 & 1u));   //  Retry if changed. Keep (s1 & 1u) as a portability reminder if this code is ever transferred to a uC that allows ISR nesting.
    return v;
}



int main(void){
   
    SYSTEM_Initialize();
   
    TMR0_OverflowCallbackRegister(My_1ms_Callback);

    INTERRUPT_GlobalInterruptEnable();
    INTERRUPT_PeripheralInterruptEnable();
   
    uint32_t now = 0;
    uint32_t last = 0;
    LATBbits.LATB4 = 1; // Prime the pump

    while (1){
       
        now = millis( );
   
        if (last != now){
            last = now;
            LATBbits.LATB4 ^= 1;
        }
       
        // Add code here

    }  
}

// Doxygen comments generated using GPT-5o.

Listing 3: PIC16 demo program for the seqLock technique.

Demonstration of the seqLock program

The code in Listing 3 outputs two physical signals from the PIC16F13145 including:

  • ISR generated signal: a 1 Hz output on pin B5

  • main( ) generated signal: a 500 Hz square wave output on pin B4

The relationship between the signals is shown in Figure 2 as captured using a Digilent Analog Discovery. There we see a stable coincidence at time 0 ms indicating that the millis( ) transferred occurred without error. While this is not definitive proof, the signal relationship remained stable for the duration of the observed test.

Figure 2: Verification of the PIC16 seqLock by comparing waveforms originating in ISR and main( ).

Efficiency of the seqLock technique

The efficiency of the seqLock ISR is best demonstrated by the disassembly code shown in Listing 4. In total, it takes 11 lines of assembly to implement the ISR portion of the seqLock. This is highly desirable for a trim and fast ISR.

!    seq++;          // Begin write (odd)
0x9F: MOVLW 0x1
0xA0: MOVLB 0x0
0xA1: ADDWF seq, F

!    msAccum++;      // Increment the 1 ms accumulator
0xA2: MOVLW 0x1
0xA3: ADDWF msAccum, F
0xA4: MOVLW 0x0
0xA5: ADDWFC 0x29, F
0xA6: ADDWFC 0x2A, F
0xA7: ADDWFC 0x2B, F

!    seq++;          // End write (even)
0xA8: MOVLW 0x1
0xA9: ADDWF seq, F

Listing 4: Disassembly code for the seqLock ISR.

Parting thoughts

The seqLock technique allows us to transfer non-atomic variables from the ISR to main( ) without disabling the global interrupts. The resulting ISR is tiny.

At this point we should go back and compare the seqLock to a dual lock or GIE-mask transfer. Which is faster, and how does disabling the GIE bit impact the other ISRs within your code. Those are topics for another day.

Best wishes,

APDahlen

Related articles by this author

If you enjoyed this article, you may also find these related articles helpful:

About this author

Aaron Dahlen, LCDR USCG (Ret.), serves as an application engineer at DigiKey. He has a unique electronics and automation foundation built over a 27-year military career as a technician and engineer which was further enhanced by 12 years of teaching (interwoven). With an MSEE degree from Minnesota State University, Mankato, Dahlen has taught in an ABET-accredited EE program, served as the program coordinator for an EET program, and taught component-level repair to military electronics technicians.

Dahlen has returned to his Northern Minnesota home, completing a decades-long journey that began as a search for capacitors. Read his story here.