ISR-메인간 안전한 변수 전달을 위해 순차 잠금을 사용해 PIC16에 아두이노 스타일 millis() 구현하기

이 글에서는 순차 잠금 (sequenc lock, 이하 seqlock) 기법을 활용해 ISR (Interrupt Service Routine, 인터럽트 서비스 루틴)에서 main()으로 밀리초 카운터 값을 전달하는 방법을 설명합니다. 이 기법을 사용하면 마이크로컨트롤러의 글로벌 인터럽트를 비활성화하지 않고도 데이터를 안전하게 전달할 수 있습니다. 이 기법은 모든 마이크로컨트롤러에 폭넓게 적용할 수 있으며, 본 글에서는 그림 1과 같이 PIC16F13145에서 시연할 것입니다.

이 글은 밀리초 콜백을 다룬 이전 글에 이어집니다. MCC (MPLAB Code Configurator, MPLAB 코드 생성기)가 생성한 콜백 함수가 본 애플리케이션에서 어떻게 사용되지 더 잘 이해하기 위해서는 이전 글을 검토해 보시기 바랍니다.


그림 1: 브레드보드에 장착된 PIC16F13145 Curiosity Nano 개발 기판과 푸시버튼의 사진.

기술 팁: 원자성 (atomic)이라는 용어는 seqlock을 이해하기 위한 핵심적인 선행 개념입니다. 원자성은 더 이상 나눌 수 없는 즉, 중단 없이 한 단계에서 완료될 수 있는 가장 작은 비트 폭을 의미합니다. 이 글에서 다루는 PIC16의 경우 8 비트이며, Arm과 같은 마이크로컨트롤러의 경우 32 비트입니다.

변수의 폭이 프로세서의 원자적 폭을 초과하는데 ISR과 main() 사이에서 데이터를 전달해야 할 경우 위험에 직면하게 됩니다. 8 비트 PIC가 32 비트 밀리초 카운터를 전달하려 할 때 바로 이런 위험이 존재합니다. 이 문제를 완화화기 위해, 글로벌 인터럽트를 비활성화하거나 이 글에서 설명하는 seqlock과 같은 이중 잠금 메커니즘을 사용할 수 있습니다.

seqlock이란 무엇인가요?

seqlock은 서로 독립된 두 코드 영역 사이에서 비원자적 전송도 할 수 있게 해주는 매커니즘입니다. 이 메커니즘을 이해하기 위해 먼저 ISR의 동작을 살펴보도록 하겠습니다.

왜 ISR을 사용하나요?

이전 PIC16 애플리케이션에서 하드웨어 타이머를 통해 1 밀리초마다 한 번씩 호출되는 콜백 함수를 설정해 두었습니다.

  • TMR0은 내부 32 MHz 클록으로부터 직접 구동됩니다.
  • TMR0은 HFINTOSC (High-Frequency Internal Oscillator)의 정밀도 특성을 그대로 따릅니다. 필요한 경우, 외부 수정 발진기로 전환하여 정밀도를 향상시킬 수 있습니다.
  • 콜백 함수는 main()과 독립적으로 실행됩니다. 즉, main()에서 발생하는 일이 콜백의 타이밍에는 영향을 주지 않습니다.
  • main()에서 ISR로 전환될 때 인터럽트 지연이 발생하지만, ISR이 유지하고 있는 밀리초 카운트의 전체 타이밍에 비하면 무시할 수 있는 수준입니다.

왜 밀리초 카운트를 main()으로 전달하나요?

먼저 포그라운드 (foreground) 프로세스와 백그라운드 (background) 프로세스라는 용어를 정의해 보겠습니다.

  • 포그라운드: ISR은 포그라운드에서 높은 우선순위로 동작합니다. 인체로 비유하자면, ISR은 반사 신경과 같아서 최소한의 연산만으로 매우 빠르게 처리됩니다. 모든 클록 사이클이 중요합니다.
  • 백그라운드: main() 코드는 백그라운드에서 동작합니다. 인체의 고차원적 두뇌 활동에 비유할 수 있으며, 타이밍이 중요하긴 하지만 상대적으로 여유롭게 처리됩니다. 배터리 에너지의 마지막 한 방울까지도 아껴 써야 하는 경우가 아니라면 수백 개 정도의 클록 사이클이 추가로 소모되더라도 큰 문제가 되지 않습니다.

슈퍼루프 (superloop) 타이밍은 변동적이지만, 그렇지 않은 ISR

콜백 함수에서 main()의 while(1) 슈퍼루프가 한 번 실행되는 데 걸리는 시간은 매우 가변적입니다. 코드의 종류와 복잡도에 따라 몇 마이크로초에서 수 초까지도 달라질 수 있습니다. 이렇게 변동성이 큰 시스템에서 시간을 정확하게 추적하는 것은 사실상 불가능합니다.

그러나, ISR의 타이밍은 매우 예측 가능하며, 밀리초 카운트를 쉽고 정확하게 유지할 수 있습니다.

두 방식의 장점 활용하기

ISR에서 시간을 관리하고 필요할 때만 main()으로 전달하는 방식으로 ISR과 슈퍼루프 특성을 유리하게 사용합니다. 이때 main()에서 읽은 정확한 시점 값이 최대 1 밀리초까지도 오류가 발생할 수 있음을 전제하고 사용합니다. 예를 들어, main()의 클록 사이클 하나 차이로 인해 밀리초 카운트의 이전 값을 읽을 수도 있고, (갱신된) 새로운 값을 읽을 수도 있습니다.

왜 ISR에서 모두 처리하지 않나요?

구현할 수는 있지만 상당한 수준의 프로그래밍 기술을 필요로 합니다. 모든 코드 사이클이 1 밀리초 안에 완료되어야만 정상 동작하기에, 이런 공조 작업 환경에서는 한 번의 오류로 타이밍이 틀어질 수 있습니다. 제 생각에는 이렇게까지 할 필요가 없습니다. ISR은 시간 관리를 담당하게 하고 대부분의 작업은 main()에서 처리하도록 하십시오.

seqlock은 어떻게 동작하나요?

seqlock은 짧은 ISR 콜백 함수와 main()에서 호출되는 리더 (reader) 함수로 이루어진 두 단계로 처리됩니다.

seqlock의 ISR 부분

목록 1과 같이 밀리초 카운터는 My_1ms_Callback 함수 내 세 줄의 코드로 구현됩니다. volatile가 선언된 seq 변수는 밀리초 누산기 (accumulator)를 증가시키기 전과 후에 각각 한 번씩 증가됩니다. 곧 설명하겠지만, 이러한 두 단계 잠금 (2‑step lock)은 비원자적 데이터를 올바르게 전달하기 위해서는 필수적입니다.

첫 번째 문장은 홀수 (odd)로 정해져 있으며, 덧셈 연산 후 seq는 홀수가 됩니다. ISR을 빠져나올 때 seq++ 문장이 seq 변수를 짝수 (even)로 만듭니다.

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)
}

목록 1: seqlock의 ISR은 단 세 줄의 코드입니다.

seqlock의 함수 부분

목록 2는 seqlock의 읽기, 확인, 재시도 절차를 보여줍니다. 핵심은 seq 변수의 이전 값과 이후 값을 수집하는 것입니다. 그런 다음 s1과 s2 값을 비교하여 두 값이 동일한 경우에만 다음 단계로 진행합니다.

ISR은 main()의 실행과 무관하게 언제든지 발생할 수 있습니다. s1과 s2의 원자적 전송을 통해, PIC16에서 4번의 연산이 필요한 msAccum를 v로 전송하는 과정 중간에 ISR 업데이트가 발생했는지 감지할 수 있습니다. 만약 충돌이 발생하면, 재시도합니다.

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;
}

목록 2: millis( ) 함수는 ISR로부터 비원자적 데이터를 안전하게 전달하기 위해 시도 / 재시도 메커니즘을 사용합니다.

main( ) 내에서 seqlock 사용

PIC16 데모 프로그램 전체는 목록 3에 포함되어 있습니다. 이 결과로, 아두이노와 동일한 방식으로 millis 함수를 다음과 같이 호출할 수 있습니다:

        uint32_t now = millis( );

I/O 핀들은 MCC (MPLAB Code Configurator)를 사용해 설정하였습니다. 또한 My_1ms_Callback(void) 함수는 main() 내에 TMR0_OverflowCallbackRegister(My_1ms_Callback); 구문을 사용해 등록되어 있습니다.

#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.

목록 3: seqlock 기법을 위한 PIC16 데모 프로그램.

기술 팁: ISR과 main()은 시간적으로 동기화되어 있지 않으며, 다음 두 가지 극단적인 상황을 고려해 볼 수 있습니다:

  • 짧은 슈퍼루프: ISR이 카운터를 읽기 전에 main()이 millis()를 수천 번 호출하는 경우.
  • 긴 슈퍼루프: main()이 카운터를 읽기 전에 ISR 콜백이 여러 번 실행되어 카운터를 계속 증가시키는 경우.

어느 경우든, ISR은 밀리초 카운터를 유지해서 millis()가 호출될 때 main()에서는 최신 값을 읽을 수 있습니다.

seqlock 프로그램의 데모

목록 3의 코드는 PIC16F13145에서 다음과 같은 두 개의 물리적 신호를 출력합니다:

  • ISR에서 생성된 신호: B5 핀에서 출력되는 1 Hz 신호
  • main()에서 생성된 신호: B4 핀에서 출력되는 500 Hz 사각파 신호

Digilent Analog Discovery를 사용해 캡처한 두 신호 간의 관계가 그림 2에 나타나 있습니다. 0 ms 지점에서 안정적인 일치 상태를 관찰할 수 있으며, 이는 millis() 값이 오류 없이 전송되었음을 나타냅니다. 비록 이것이 확정적인 증거라고 할 순 없지만, 관측된 테스트 기간 동안 두 신호 간의 관계가 안정적으로 유지되고 있습니다.

그림 2: ISR과 main()에서 생성된 파형 비교를 통해 PIC16의 seqlock 검증.

seqlock 기법의 효율

seqlock ISR의 효율은 목록 4에 있는 디스어셈블리 코드를 통해 가장 잘 드러납니다. seqlock의 ISR 부분을 구현하는데 총 11줄의 어셈블리 코드만 필요로 하며, 이는 간결하고 빠른 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

목록 4: seqlock ISR의 디스어셈블리 코드.

글을 맺으며

seqlock 기법을 통해 글로벌 인터럽트를 비활성화하지 않고도 ISR에서 main()으로 비원자적 변수를 전달할 수 있으며, 그 결과 ISR은 매우 간결해집니다.

이 시점에서 seqlock을 이중 잠금 방식이나 GIE (Global Interrupt Enable) 마스크 전송 방식과 비교해볼 필요가 있습니다. 어느 쪽이 더 빠른지, 그리고 GIE 비트의 비활성화가 코드 내 다른 ISR에 어떤 영향을 미치는지. 이러한 주제들은 추후에 다루도록 하겠습니다.

감사합니다.

APDahlen

저자의 관련 게시글

이 글을 흥미롭게 읽었다면, 다음 글들도 도움이 될 수 있습니다:

저자 소개

미합중국 해안경비대(USCG) 소령(LCDR)으로 전역한 Aaron Dahlen은 디지키에서 애플리케이션 엔지니어로 근무하고 있습니다. 27년간의 군 복무 동안 기술자 및 엔지니어로서 쌓아온 그 만의 전자 및 자동화에 대한 지식은 12년간의 교단을 통해 (상호 연계되어) 더욱 향상되었습니다. 미네소타 주립대학, Mankato에서 전기공학 석사(MSEE) 학위를 받은 Dahlen은 ABET(Accreditation Board for Engineering and Technology, 미국 공학 기술 인증 위원회) 공인 전기공학 과정을 가르치고, EET(Electrical Engineering Technology, 전기공학 기술) 과정의 프로그램 조정관으로 일했으며, 군 전자 기술자에게 부품 수준의 수리에 대해 가르쳤습니다. 미네소타 주 북부의 집으로 돌아와 이런 류의 연구와 글쓰기를 즐기고 있습니다.



영문 원본: Implementing Arduino-Style millis() on PIC16 Using seqLock for Safe ISR-to-Main Variable Transfer