FreeRTOS 시리즈

9 / 12

FreeRTOS Documentation - FreeRTOS™

이 문서에서 제공하는 PDF를 읽고 정리한 글입니다.

생성 일 : 2026-07-16 최종 수정일 : 2026-07-18


선점으로 발생하는 문제들

멀티태스킹 시스템에서는 태스크가 실행되는 도중 다른 태스크가 실행되면서 여러 문제가 발생할 수 있다.

  • 동시에 페리퍼럴을 접근하는 경우

    두 태스크가 LCD에 출력하려고 한다. 태스크 A는 “안녕하세요”를, 태스크 B는 “반갑습니다”를 출력한다고 할 때, 태스크 A가 “안녕하”까지 출력한 후 스케줄러에 의해 태스크 B가 선점한다면, 최종 실행 결과는 “안녕하반갑습니다세요”이다.

  • 동시에 데이터 IO를 하는 경우

    LOAD  R1, [#x]      ; x 값을 메모리에서 읽어 R1에 저장
    MOVE  R2, #100      ; 상수 100을 R2에 저장
    ADD   R1, R2        ; R1 = R1 + 100
    STORE R1, [#x]      ; 계산 결과를 다시 x에 저장

    위 코드는 x = x+100 c코드를 컴파일한 결과다. 태스크 A와 B가 위 코드를 실행하면서 기대 결과는 200이라고 하자.

    태스크 A가 ADD 명령어까지 실행한 후 태스크 B가 선점한다고 하자. 그러면 R1 레지스터에 100이 저장된 상태에서 다시 태스크 B가 LOAD 명령어를 통해 R1 레지스터에 0을 저장하게 된다. 이후 STORE 명령어까지 수행하여 x에 100이 저장되었지만, 태스크 A가 다시 실행된다면 R1 레지스터에 있는 100을 x에 저장한다.

  • 재진입 불가능한 함수

    함수가 자신의 스택 내부에 있는 변수 그리고 자체 레지스터만 사용한다면 그 함수는 재진입 가능하다고 말한다. 재진입 가능한 함수는 동시에 여러 태스크에서 호출 가능하다. 재진입 불가능한 함수는 여러 태스크가 동시에 호출한다면 데이터를 오염시키거나 연산을 망가뜨릴 수 있다.

    다음은 재진입 가능한 함수와 그렇지 않은 함수의 예시다.

    // thread-safe
    int add100(int value) {
        int ret;
        ret = value + 100;
        return ret;
    }
    // Not thread-safe
    int add100(int value) {
        // 함수 의도랑 맞지 않은 구현이지만,
        //  static 변수는 함수 내 스택이나 레지스터가 아니므로 
        //  이런 변수를 쓰면 오염될 수 있음을 보여줌.
        static int state = 0; 
        int ret;
        switch(state) {
            case 0:
            ret = value + 100;
            state = 1;
            break;
            case 1:
            ret = value + 200;
            state = 0;
            break;
        }
        return ret;
    }

상호 배제 (Mutual Exclusion)

그래서 데이터를 여러 태스크에서 접근할 때 오류가 발생하지 않게 하기 위해서는 상호 배제(Mutual Exclusion)가 필요하다. 상호 배제란, 한 태스크가 자원을 사용하고 있을 때 다른 태스크가 자원을 사용하지 못하게 막는 것이다.

이상적으로는 자원을 공유하지 않도록 설정하는 것이 좋지만 실전에서는 그렇지 않은 경우가 있으므로, FreeRTOS가 제공하는 API를 사용하여 문제를 해결한다.

크리티컬 섹션(Critical section)

상호 배제가 지켜져야하는 구역을 크리티컬 섹션이라고 한다. 이 구역을 만드는 기본적인 매크로는 taskENTER_CRITICAL()taskEXIT_CRITICAL()이다.

다음은 버퍼 출력을 할 때 다른 태스크가 출력하지 못하도록 막기 위해서 크리티컬 섹션을 지정한 예시다.

void printString(const char *str) {
    taskENTER_CRITICAL();
    {
        printf("%s\n", str);
        fflush(stdout);
    }
    taskEXIT_CRITICAL();
}

이렇게 구현된 크리티컬 섹션은 굉장히 단순한 방법을 사용한다. 인터럽트를 비활성화하거나, port에 따라서 인터럽트 우선순위까지 비활성화 하기도 한다. 컨텍스트 스위칭은 인터럽트가 발생할 때 일어나기 때문에, taskEXIT_CRITICAL()로 크리티컬 섹션을 해제할 때까지는 방해받지 않게 된다.

이렇게 단순무식한 방법으로 크리티컬 섹션을 만든다면 가능한 빠르게 그 영역을 탈출해야한다. 인터럽트를 비활성화하므로 시스템의 응답성을 낮추게 만든다. 위 예시는 크리티컬 섹션에 printf를 넣었으므로 좋은 예시가 아니다.

크리티컬 섹션은 중첩될 수 있다. 매크로가 호출하는 함수 내부에서 카운터를 사용해 중첩 횟수를 세고, 카운터가 0이 될 때 크리티컬 섹션을 해제한다. 아래 코드는 FreeRTOS 커널 코드이다.

void vPortEnterCritical( void )
{
    portDISABLE_INTERRUPTS();
    uxCriticalNesting++;
    if( uxCriticalNesting == 1 )
    {
        configASSERT( ( portNVIC_INT_CTRL_REG & portVECTACTIVE_MASK ) == 0 );
    }
}

void vPortExitCritical( void )
{
    configASSERT( uxCriticalNesting );
    uxCriticalNesting--;

    if( uxCriticalNesting == 0 )
    {
        portENABLE_INTERRUPTS();
    }
}

taskENTER_CRITICAL()taskEXIT_CRITICAL()은 내부 구현 상 인터럽트 활성화 상태를 변경하는 매크로이므로, 다른 코드에 의해 인터럽트 활성화 상태가 변경되면 중첩 횟수와 인터럽트 상태가 일치되지 않게 되어 크리티컬 섹션이 무너진다.

이 매크로들은 ISR 내부에서 호출하면 안되고 taskENTER_CRITICAL_FROM_ISR(), taskEXIT_CRITICAL_FROM_ISR()을 사용해야한다. 인터럽트 중첩을 허용하지 않는 port라면, 이 매크로가 의미없다.

스케줄러 중단 또는 락킹

크리티컬 섹션을 만드는 또다른 방법은 스케줄러를 중단시키는 방법이다. 실행 중간에 다른 태스크로 전환하지 않도록 막는 것이다. 이 말은 스케줄링을 멈추는 방법으로 만든 크리티컬 섹션 안에서는 다른 태스크의 코드가 실행되지는 않지만 ISR은 실행될 수 있다는 것이다.

void vTaskSuspendAll( void );
BaseType_t xTaskResumeAll( void );

스케줄러를 정지시키거나 재개하는 API는 위와 같다. 컨텍스트 스위칭은 하지 않게 만들지만 인터럽트는 발생할 수 있다.

xTaskResumeAll()이 실행 완료되기 전에 컨텍스트 스위칭이 발생했다면 pdTRUE를 반환하고, 그렇지 않으면 pdFALSE를 반환한다.

주의할 점은, 스케줄러가 정지 상태일 때 여러 인터럽트가 발생하더라도 스케줄러는 이를 당장 처리하지 않고 남겨둔다. 나중에 스케줄러가 재개될 때 미뤄뒀던 처리를 한 번에 처리한다. 크리티컬 섹션 안에서 작업을 오래 한다면 인터럽트에 의해 처리해야할 작업이 점점 쌓이고, 나중에 xTaskResumeAll()을 호출하는 순간 처리해야할 작업들을 처리하느라 태스크 코드가 바로 실행되지 않을 수 있다.

이 API 역시 내부적으로 카운터를 통해 크리티컬 섹션 중첩을 지원한다.

뮤텍스

뮤텍스는 특별한 바이너리 세마포어라고 볼 수 있다. Mutex의 어원은 MUTual EXclusion의 약자이다. configUSE_MUTEXES 매크로가 1로 설정되어 있어야 쓸 수 있다.

쉽게 생각하자면 뮤텍스는 열쇠같은 것이다. 자원을 쓰고 싶은 사람은 열쇠를 가지고 혼자 방에 들어가 방문을 잠그고 사용한 뒤 방에서 나온 다음 열쇠를 반환한다고 비유할 수 있다. 일단 열쇠를 가진 사람이 방에 들어가 문을 잠그면 아무도 열 수 없는 것이므로 상호 배제가 성립한다.

뮤텍스와 바이너리 세마포어가 많은 점에서 비슷하지만 다른 점은 다음과 같다.

  • 상호 배제를 위해 사용되는 세마포어는 무조건 반환되어야 한다.
  • 동기화를 위해 사용되는 세마포어는 반환되지 않으며 폐기된다(재사용되지 않는다).

아래 예시는 뮤텍스를 통해 공유 자원에 접근하는 시나리오다.

뮤텍스 생성

SemaphoreHandle_t xSemaphoreCreateMutex(void);

반환값이 NULL이면 힙 메모리 부족으로 생성되지 않은 것이고, NULL이 아니면 뮤텍스가 생성된 것이다.

인터럽트를 비활성화하는 방식으로 구현했던 크리티컬 섹션은 다음과 같이 고칠 수 있다.

void printString(const char *str) {
    xSemaphoreTake(xMutex, portMAX_DELAY);
    {
        printf("%s\n", str);
        fflush(stdout);
    }
    xSemaphoreGive(xMutex);
}

예시

이 함수를 두 태스크에서 동시에 호출하고 각 태스크는 함수 호출 후 랜덤한 대기시간을 가진다고 하자.

// 태스크 A, B 코드
static void printTask(void *pvParameters ) {
    char* str = (char*) pvParameters;
    const TickType_t xMaxBlockTimeTicks = 0x20;
    for (;;) {
        printString(str);
        vTaskDelay( rand()%xMaxBlockTimeTicks );
    }
}
// main.c
int main() {
    xMutex = xSemaphoreCreateMutex();
    xTaskCreate(printTask, "p1", 1000, "태스크 A**********************************", 1, NULL);
    xTaskCreate(printTask, "p1", 1000, "태스크 B----------------------------------", 2, NULL);
    vTaskStartScheduler();
    for (;;) { }
}

두 태스크 모두 동일한 코드로 실행되지만 태스크 A의 우선순위는 1, 태스크 B의 우선순위는 2이다. 실행 결과는 다음과 같다.

태스크 B보다 태스크 A가 더 짧은 시간만큼 기다리게 되어 먼저 선점한다. printString()함수를 호출하고, 뮤텍스를 획득한다. 크리티컬 섹션 안에서 문자열 출력을 하지만, 문자열이 길어서 오래 걸린다.

태스크 A가 출력을 하는 사이 태스크 B의 대기가 끝난다. 그러나 태스크 B가 printString()을 호출하면 이미 태스크 A가 출력 중이므로 자원을 사용하지 못하고 xSemaphoreTake() 줄에서 Blocked 상태로 변한다.

다시 태스크 A가 실행되고 출력을 마친 후 크리티컬 섹션을 벗어나자마자 태스크 B가 Blocked 상태에서 Ready 상태로 바뀐다. 태스크 B는 우선순위에 의해 선점하며 바로 뮤텍스를 획득하고 출력을 시작한다. 이 때 태스크 A가 대기를 마치더라도 우선순위 때문에 실행되지 못한다. 태스크 B가 출력을 완전히 마치고 뮤텍스를 반환한 후 다시 vTaskDelay를 실행해야 태스크 A가 실행된다.

출력 결과는 다음과 같다. 여기서 실행 순서는 그다지 중요하지 않고, 각 태스크가 출력할 때 다른 태스크에 의해 방해받지 않았다는 것이 중요하다.

태스크 A**********************************
태스크 B----------------------------------
태스크 A**********************************
태스크 A**********************************
태스크 B----------------------------------
태스크 B----------------------------------
태스크 A**********************************
태스크 B----------------------------------
태스크 A**********************************
태스크 A**********************************
태스크 B----------------------------------
태스크 B----------------------------------
태스크 A**********************************
태스크 B----------------------------------
태스크 A**********************************
...

우선순위 역전 (Priority Inversion)

뮤텍스 예시에서 보이듯이, 태스크 A보다 태스크 B의 우선순위가 더 높지만, 태스크 A가 크리티컬 섹션에서 코드를 실행하고 있다면 태스크 B보다 먼저 실행되는 것으로 보인다. 우선순위 역전은, 크리티컬 섹션에서 코드가 실행되고 있을 때 우선순위가 낮은 태스크의 작업이 우선순위가 높은 태스크의 작업보다 먼저 처리되는 것이다.

위 예시에서는 3개의 태스크가 등장한다.

  1. 낮은 우선순위 태스크(이하 LP)가 뮤텍스를 획득한다.
  2. 높은 우선순위 태스크(이하 HP)가 깨어나면서 선점하게된다. 뮤텍스를 획득하려하지만, LP가 이미 뮤텍스를 가지고 있으므로 Blocked 상태가 된다.
  3. LP가 계속 실행되다가 중간 우선순위 태스크(이하 MP)가 깨어나면서 선점하게 된다.
  4. MP는 뮤텍스를 획득하지 않는 작업을 처리하기 때문에 실행에 방해받지 않는다.

위 실행 과정대로 보면 우선순위는 HP > MP > LP인데, 실제 실행 순서는 MP > LP > HP이다. 이렇게 크리티컬 섹션 안에서 코드를 실행할 때 우선순위대로 실행되지 않는 것이 우선순위 역전이다.

우선순위 상속 (Priority Inheritance)

바이너리 세마포어는 우선순위를 상속시키는 것이 불가능하지만, 뮤텍스는 가능하다. 우선순위 상속은 우선순위 역전 현상을 완벽히 해결해주지는 않지만 우선순위 역전 현상이 시간적인 제한을 받게 만들어 영향을 줄이게 해준다. 그렇지만 우선순위 상속에 의존해 문제를 해결하는 것은 좋지 않다.

우선순위 상속이란, 일시적으로 뮤텍스를 갖고 있는 태스크의 우선순위를 높게 바꾸되 뮤텍스를 획득하려는 태스크들의 우선순위 중에서 가장 높은 값으로 바꾸는 것이다. 이렇게 되면 위 예제에서 LP는 HP의 우선순위 값과 동일해진다.

위 사진에서 3번이 그 현상을 의미한다. 우선순위 상속을 적용하지 않았을 때에는 이 시점에서 MP가 깨어나 선점하게 되는데, 여기선 MP보다 LP의 우선순위가 더 높기 때문에 선점당하지 않고 뮤텍스를 반환할 때까지 실행된다. 그 이후에는 HP의 우선순위가 더 높기 때문에 MP는 실행되지 않고 HP가 실행되어 뮤텍스를 획득한다.

우선순위 상속은 FreeRTOS 커널 코드에 구현되어 있다. 뮤텍스를 쓴다면 자동으로 적용된다. 그러나 우선순위 상속을 제대로 처리하려면 복잡하고 비용이 크므로, 효율적으로 처리하기 위해 다음 세 가지 특이한 경우가 존재한다.

  • 이미 상속된 우선순위를 갖고 있는 태스크도 우선순위가 더 높아질 수 있다. 획득한 뮤텍스를 해제하지 않고 다른 뮤텍스를 획득하면 이 현상이 발생할 수 있다.
  • 일단 태스크가 우선순위 상속을 통해 제일 높은 우선순위를 갖게 되었다면 모든 뮤텍스를 해제하기 전까지 유지된다.
  • 태스크가 여러 뮤텍스를 획득하고 있을 때, 그 뮤텍스들 중 어떤 것을 기다리던 다른 태스크가 타임아웃되어 더 이상 획득하려하지 않더라도, 그 과정에서 태스크의 우선순위가 높아졌다면 우선순위 값은 유지된다.

데드락 (교착 상태)

상호 배제를 위해 크리티컬 섹션에서 뮤텍스를 쓸 때 발생 가능한 문제다.

두 개의 태스크가 서로 하나의 자원을 점유하며 절대 반환하지 않고 상대가 점유한 자원을 획득하길 기다리고 있는 상태를 데드락 혹은 교착 상태라고 부른다.

데드락이 발생하는 시나리오는 다음과 같다.

  1. 태스크 A가 뮤텍스 X를 소유한다.
  2. 태스크 B가 태스크 A를 선점하고, 뮤텍스 Y를 소유한다.
  3. 태스크 B가 뮤텍스 X를 소유하려하지만 이미 태스크 A가 가지고 있으므로 Blocked 상태로 바뀐다.
  4. 태스크 A가 다시 실행되고 뮤텍스 Y를 소유하려 하지만 이미 태스크 B가 가지고 있으므로 Blocked 상태로 바뀐다.
  5. 두 태스크 모두 Blocked 상태에서 벗어나질 못한다.

데드락을 피하는 가장 좋은 방법은 설계 단계에서 데드락이 발생하지 않게 하는 것이다. 특히, 설계 단계에서 자원을 소유하기 위해 무한히 기다리지 않게 하여야 한다.

재귀 뮤텍스 (recursive mutex)

태스크 스스로 데드락에 걸릴 수 있다.

  1. 뮤텍스를 소유한다.
  2. 뮤텍스를 소유한 상태에서 라이브러리 함수를 호출한다.
  3. 라이브러리 함수는 소유한 뮤텍스를 또 소유하려고 한다.
  4. 이미 소유된 뮤텍스가 반환되기를 기다리는데, 소유 주체는 자신이므로 무한히 Blocked 상태에 빠진다.

이미 뮤텍스를 소유한 상태에서 또 소유하려고 하므로 스스로 데드락에 빠진다. 이런 상황에서는 재귀 뮤텍스(recursive mutex)를 사용함으로 문제를 해결할 수 있다.

재귀 뮤텍스는 한 태스크가 여러 번 소유 가능한 뮤텍스다. 재귀 뮤텍스 API 역시 이름만 살짝 다를 뿐 일반 뮤텍스 API와 비슷하다.

xSemaphoreCreateRecursiveMutex()
xSemaphoreTakeRecursive()
xSemaphoreGiveRecursive()

태스크 스케줄링에서의 뮤텍스

우선순위가 서로 다른 태스크가 같은 뮤텍스를 소유할 때 스케줄링은 명확하다. 우선순위가 낮은 태스크가 뮤텍스를 소유한 후 반환하는 순간 우선순위가 높은 태스크가 바로 뮤텍스를 소유한다.

그러나, 우선순위가 서로 같은 태스크가 뮤텍스를 소유할 때 스케줄링은 특이하다.

  1. 태스크 2가 실행될 때 뮤텍스를 소유한다.
  2. 라운드로빈 스케줄링 때문에 t2에서 태스크 1이 실행된다.
  3. 태스크 1이 뮤텍스를 소유하려고 하지만 태스크 2가 소유중이므로 Blocked 상태가 된다.
  4. 태스크 2가 실행되는데, t3~t4 사이에서 뮤텍스를 반환한다. 그러나, 태스크 1과 우선순위가 같아서 우선순위에 의한 컨텍스트 스위칭이 발생하지 않는다. 게다가, 이 시점에서는 라운드로빈 스케줄링 상 태스크 2의 실행 순서이기 때문에 컨텍스트 스위칭이 발생하지 않는다. 여기서는 태스크 1이 Blocked 상태에서 Ready 상태로 바뀌기만 한다.

위 예시에서 보이듯, 같은 우선순위의 태스크들이 서로 다른 실행 시간을 받게 됨을 알 수 있다. 극단적인 경우는 다음과 같다.

t3까지는 동일한 흐름으로 진행된다. 그러나 t3~t4에서 태스크 2가 뮤텍스를 반환하지만 곧바로 다시 소유한다.

t4에서 태스크 1이 실행되지만 뮤텍스 획득에 실패하기 때문에 다시 Blocked 상태가 된다. 그러면 여전히 태스크 2가 다음 틱 인터럽트까지 계속 실행된다. t3~t4와 같은 일이 앞으로 계속 일어난다면, 태스크 1은 실행 기회를 잡지 못하게 된다.

이런 경우 태스크 2가 실행될 때 뮤텍스를 반환하고나서 taskYIELD()를 함으로 해결할 수 있다.

void taskFunction(void *pvParameters) {
    extern SemaphoreHandle_t xMutex;
    TickType_t xMutexTakenTime;
    for (;;) {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        xMutexTakenTime = xTaskGetTickCount();
        /* 크리티컬 섹션 작업 */
        xSemaphoreGive(xMutex);
        
        if (xTaskGetTickCount() != xMutexTakenTime) {
            taskYIELD();
        }
    }
}

위 코드는 뮤텍스를 획득하고 나서 작업이 지난 시간을 알기 위해 틱 카운트를 비교한다. 크리티컬 섹션 작업 시간이 너무 짧으면 틱 카운트가 변하지 않게 되고, 서로 짧은 시간동안만 작업 후 실행 순서를 양보하게 되어 잦은 컨텍스트 스위칭이 발생한다. 비효율적인 컨텍스트 스위칭을 막기 위해 틱 카운트가 변했을 때에만 양보하게 된다.

게이트키퍼 태스크

데드락이 발생하는 문제는 자원을 소유하려는 주체가 둘 이상이라는 것이다. 자원을 소유하는 주체가 단 하나면 데드락이 발생하지 않는다. 이 태스크를 게이트키퍼 태스크라고 부른다. 다른 태스크들은 게이트키퍼 태스크가 제공하는 서비스만 사용하고, 직접적으로 자원에 접근하지 않는다.

// 게이트키퍼 태스크
static void gatekeeperTask(void *pvParameters) {
    char* str;
    for (;;) {
        xQueueReceive(xPrintQueue, &str, portMAX_DELAY);
        printf("%s\n", str);
        fflush(stdout);
    }
}
// 출력 태스크
static void printTask(void *pvParameters) {
    for (;;) {
        xQueueSendBack(xPrintQueue, (const char*)pvParameters, NULL);
        vTaskDelay(rand() & 0x20); // 랜덤한 시간만큼 기다리기
    }
}
// 틱 인터럽트가 호출하는 콜백 함수
static const char* message = "애플리케이션 tick hook 발동\n";
void vApplicationTickHook(void) {
    static int count = 0;
    count++;
    if (iCount >= 200) {
        xQueueSendToFrontFromISR(xPrintQueue, message, NULL);
        iCount = 0;
    }
}
// main.c
QueueHandle_t xPrintQueue;

int main() {
    xPrintQueue = xQueueCreate(5, sizeof(char*));
    xTaskCreate(printTask, "print1", 1000, "태스크 1-------------------------\n", 1, NULL);
    xTaskCreate(printTask, "print1", 1000, "태스크 2-------------------------\n", 2, NULL);
    xTaskCreate(gatekeeperTask, "gatekeeper", 1000, NULL, 0, NULL);
    vTaskStartScheduler();
    for (;;)
        ;
}

위 코드들은 두 개의 출력 태스크가 stdout에 직접 접근하지 않고 게이트키퍼 태스크에게 출력 메시지를 전달만 하는 구조로 작성되어 있다. xPrintQueue에 출력해야할 문자열의 주소만 전달하면 게이트키퍼 태스크는 큐에서 메시지를 꺼내어 출력을 한다. 애플리케이션 틱 훅 함수는 단지 설명을 위해 추가한 함수다.

애플리케이션 tick hook 발동
애플리케이션 tick hook 발동
태스크 2-------------------------
태스크 1-------------------------
애플리케이션 tick hook 발동
태스크 1-------------------------
애플리케이션 tick hook 발동
태스크 2-------------------------
애플리케이션 tick hook 발동
태스크 2-------------------------
태스크 1-------------------------
태스크 1-------------------------
애플리케이션 tick hook 발동
애플리케이션 tick hook 발동
...