[FreeRTOS 정리] 完. 총집본

Embedded June·2021년 8월 15일
7

FreeRTOS

목록 보기
5/5

FreeRTOS 정리

1. RTOS와 FreeRTOS

1.1. RTOS의 특징

  1. Realtime: 애플리케이션의 수행 마감 시간을 정확히(혹은 조금 융통성있는 정도로) 정할 수 있음.
    • Soft realtime: Task를 가능한 한 빠르게 수행하지만, 반드시 정해진 수행시간을 지킬 필요는 없음.
      예) 커피 자판기의 ‘동전 인식’ 기능은 500ms내에 수행해야 하지만, 550ms 소요됐다고 별 일 없음.
    • Hard realtime: Task마다 정해진 수행시간을 반드시 만족해야 함.
      예) 자동차의 제동 관련 ECU 제어 기능은 반드시 최대 500ms 내에 수행해야 한다. 550ms 소요될 경우, 운전자나 보행자의 안전이 위험할 수 있기 때문임.
  2. Scalability: 환경설정을 통해 사용자가 원하는 기능을 추가/제거해서 크기를 조절할 수 있음.
  3. Preemptive: 현재 실행하는 task보다 우선도가 높은 task가 나타날 경우, 선점을 허용함.
  4. Multitasking: 여러 task를 (마치) 동시에 실행할 수 있음.
  5. Robustness: RTOS는 CPU의 utilization을 극대화할 수 있는 수단임.

1.2. FreeRTOS 소개

  • 2003년 Richard Barry가 만든 ANSI C 기반 RTOS임.
  • 아마존 AWS IoT 서비스 확장을 위해 2017년 11월에 인수함.
  • 200개 이상의 폭넓은 MCU를 지원하며 포팅을 위한 다양한 컴파일러와 예제 제공.
  • MIT 라이센스로 조건없는 상업 목적 사용 가능.
  • Priority based preemptive scheduling을 사용하며, round-robin도 함께 지원한다.
  • 환경설정에 따라 binary image는 4KB ~ 9KB를 가질 정도로 굉장히 light-weight이며 높은 신뢰성과 안정성을 자랑한다.

2. 네이밍 규칙 (Naming rules)

FreeRTOS의 코드는 다음과 같은 네이밍 규칙을 사용한다.

.

FreeRTOS에서 변수명을 결정할 때 접두어는 아래와 같이 결정한다.

  • c: char 타입을 의미한다.
  • s: int16_t 타입 (short)을 의미한다.
  • i: int32_t타입 (int)을 의미한다.
  • x: BaseType_t 타입을 의미한다. 상당히 자주 사용되는데, 구조체나 인스턴스 핸들 등 일반적인 타입을 제외하면 대부분 x다.
  • u: unsigned를 의미한다.
  • p: 포인터 변수를 의미한다.

FreeRTOS에서 함수명을 결정할 때 접두어는 아래와 같이 결정한다.

  • v: void 반환값이 없는 함수를 의미한다.
  • x: 변수명의 접두어인 x와 같은 의미다. BaseType_t를 반환하는 함수다.
  • pv: void* 타입을 반환하는 함수다.
  • prv: private 함수를 의미한다. 대표적으로 아무런 task도 실행되지 않을 때 실행되는 idle task가 호출하는 callback 함수 (hook)가 이 접두어를 사용하고 있다.

FreeRTOS에서 매크로명은 그 매크로가 선언된 소스파일의 이름을 따라 결정된다.
예를 들어 무한정 기다림을 의미하는 portMAX_DELAYportable.h라는 커널 소스에 선언돼있다.
문서에 따르면 semaphore 관련 API는 거의 매크로로 구성돼있지만, 함수명 네이밍 규칙을 따라간다고 명시돼있다.


3. 태스크 (Task)

FreeRTOS에서 하나의 task는 하나의 스레드(thread)를 의미한다.

3.1. Task의 특징과 생김새

  • 각 task에는 우선순위를 할당하며 숫자가 높을수록 큰 우선순위를 의미한다.

    • 우선순위는 0부터 configMAX_PRIORITIES까지 할당이 가능하다.
    • 우선순위가 높은 task는 낮은 task를 선점(preemption)할 수 있고, 이때 context switching이 발생한다.
    • 동일한 우선순위 사이에서는 round robin을 사용한다.
  • Task는 return value가 없으며 (void*) 타입으로 여러 자료형을 매개변수로 받을 수 있다.

  • Task는 일회용 task주기적 task 2가지 종류가 있다.

    • 일회용 task는 기능을 한 번 수행한 뒤 마지막에 스스로를 삭제한다. (꼭 해줘야 함.)

      void 태스크이름( void* pvParameters ) {
          /* 어쩌고 저쩌고 */
          vTaskDelete(NULL);
      }
    • 주기적 task는 무한 loop가 들어있다. Delay 또는 suspend 함수 등으로 task의 state가 blocked(waiting)로 변하지 않는 이상 계속해서 기능을 빠르게 반복 실행한다.

      void 태스크이름( void* pvParameters ) {
          while(true) {
              /* 어쩌고 저쩌고 */
          }
      }
  • 각 task마다 local stack 공간이 할당된다. 이 공간은 메모리의 .bss영역 또는 .heap 영역에 들어간다.

    • Stack 메모리는 정적 또는 동적으로 할당이 가능하다.
    • 후술하겠지만, stack 메모리 할당은 5가지 방법으로 사용자가 환경설정 할 수 있으며, 메모리 단편화(fragmentation)를 최대한 막으며 메모리를 할당한다.
    • configSUPPORT_DYNAMIC_ALLOCATIONconfigSUPPORT_STATIC_ALLOCATION 값을 어떻게 설정하느냐에 따라, 그리고 task 생성 함수를 xTaskCreatexTaskCreateStatic중 어느 것을 사용하느냐에 따라 할당 방법이 달라진다.

3.2. Task 관련 API

6가지 API가 주로 사용되며 앞 3개가 중요하고, 뒤 3개는 잘 안 쓴다.

  1. xTaskCreate()

    .

    • pvTaskCode는 task의 기능이 선언된 함수의 함수포인터를 말한다.
    • pcName은 디버깅 용도로 사용하는 문자열이며 task의 이름을 말한다.
    • usStackDepth는 task마다 할당되는 stack 메모리를 말한다. 단위는 WORD이며 우리가 사용하는 ARM Cortex-M보드에서는 1WORD == 4Byte라는 점을 기억하자.
    • pvParameters는 task 함수로 전달할 매개변수를 말한다. 없다면 NULL 쓰면 된다. 전달할 매개변수를 (void*) 타입으로 캐스팅한 뒤 여기다가 넣어주면 task 함수에서 사용할 수 있다.
    • pxCreatedTask는 task를 제어하기 위한 TaskHandle_t 타입 핸들을 말한다. Task의 우선순위를 바꾸거나, task를 멈추거나 등 task에 대한 설정은 모두 이 핸들을 통해서 이뤄진다.
    • `xTaskCreate((TaskFunction_t)foo, “task”, 64, NULL, 10, handle);``
  2. vTaskDelay()

    .

    • Task의 상태를 running에서 blocked(waiting)으로 변경하는 함수다. 설정된 시간 xTicksToDelay동안 해당 task는 blocked task가 되며, 다음 우선순위를 가진 task가 실행된다.
    • 확장성 및 이식성 좋은 코드를 만들기 위해서 tick단위 시간을 사용하는 것 보다 pdMS_TO_TICKS() 매크로를 사용해서 우리에게 편한 ms(밀리초) 단위를 tick으로 변환해서 사용하는 것이 좋다.
      • 왜냐하면, 사용하는 보드와 사용자 환경설정 값에 따라 1 Tick이 의미하는 시간이 다르기 때문이다.
    • 문서에서도 그렇게 권장하고 있다.
  3. vTaskDelayUntil()

    • .

    • vTaskDelay()와 똑같은 기능을 수행하지만, 작동 방식이 조금 다르다.

    • vTaskDelay()는 호출 시점부터 지정된 시간만큼 blocked 되는 반면,
      vTaskDelayUntil()은 호출 시점과 관계 없이 목표 절대 시간 주기에 맞춰 blocked 된다.

  4. vTaskSuspend()vTaskResume()

    • Delay 함수는 task를 일정 시간동안 blocked state로 만들지만, suspend 함수는 task를 기약없이 blocked state로 만든다.
    • Suspend 된 task는 resume 함수를 사용할 때 까지 계속 blocked state가 된다.
    • Task의 우선순위를 바꿀 때 task를 일단 blocked state로 만들 필요가 있다. 이때 사용한다.
    • 괄호 안에는 task의 handle이 들어간다는 점을 잊지말자.
  5. vTaskPrioritySet()

    • Task의 우선순위를 바꾼다.

3.3. 실습 01 - Task

전체 소스코드는 저작권을 고려해 올리지 않는다.

실습 내용은 다음과 같다.

  • 알파벳 ‘a’를 1초마다 한 번씩 출력하는 우선순위 10의 task1을 만든다.
    알파벳 ‘b’를 1초마다 한 번씩 출력하는 우선순위 9의 task2를 만든다.
    • xTaskCreate((TaskFunction_t)Task1, "Task1", 128, NULL, 10, &xHandle1);
    • xTaskCreate((TaskFunction_t)Task2, "Task2", 128, (void*)Param, 9, &xHandle2 );
  • 다양한 데이터를 담고있는 구조체를 만들고 task2의 parameter로 넘겨준다.
  • 중간에 task1의 우선순위를 8로 낮춘다.
    • vTaskSuspend(xHandle1);
    • vTaskPrioritySet(xHandle1, 8);
    • vTaskResume(xHandle1);
  • task1과 task2의 vTaskDelay()를 주석처리 해보면서 두 task가 어떻게 작동하는지 확인한다.
    • .
      • Task1, 2 모두 vTaskDelay()를 주석처리 하지 않았을 때 결과다.
      • Task2의 우선순위가 task1보다 높기 때문에 먼저 b를 출력하고 1초간 blocked 된다.
      • 이제 task1이 실행될 수 있으며 a를 출력하고 1초간 blocked 된다.
      • 아무것도 실행되지 않으므로 idle task가 실행될 것이다.
      • Blocked state에서 벗어난 task2가 b를 출력하고 1초간 blocked 되는 과정이 반복된다.
    • .
      • Task 1의 vTaskDelay()를 주석했을 때 결과다.
      • 먼저 task2가 시작되고, vTaskDelay()를 만난 뒤 blocked 되면 task1이 시작된다.
      • Task1은 빠른 속도로 a를 출력한다.
      • 1초 이후 task2가 task1을 선점한 뒤 b를 1회 출력한 뒤 다시 blocked 된다.
        (위 사진에는 안나와있지만, 한참 뒤에 b가 한 번 찍힌다.)
      • Task1은 다시 빠른 속도로 a를 출력하는 과정이 반복된다.
    • .
      • Task 2의 vTaskDelay()를 주석했을 때 결과다.
      • 먼저 task2가 시작되며 blocked 될 일이 없기 때문에 task1은 영원히 실행되지 않는다.
    • .
      • Task 1, 2 모두 vTaskDelay()를 주석했을 때 결과다. (위 사진과 같은 결과)
      • 먼저 task2가 시작되며 blocked 될 일이 없기 때문에 task1은 영원히 실행되지 않는다.

이번 실습을 통해 다음 세 가지를 배울 수 있었다.

  1. FreeRTOS에서 멀티테스킹이 작동하는 방식 - Priority와 preemption.
  2. Task를 생성하고 우선순위를 변경하며 관리하는 방법.
  3. 실행흐름을 올바르게 인식하지 못하면 task의 priority 설정을 잘못해 starvation을 유발할 수 있다.

3.4. Stack Overflow 검사

우리는 앞서 각 task마다 지정된 크기의 local stack을 갖고있다고 배웠다. 만일 task 내에서 해당 stack size를 넘는 데이터를 선언하고 연산을 수행한다면 stack overflow가 발생할 것이다. 이 치명적인 fault를 어떻게 발견할 수 있을까? FreeRTOS는 stack overflow를 발견하기 위한 기초적인 두 가지 알고리즘을 제공한다.

FreeRTOSConfig.h 헤더파일 안에는 configCHECK_FOR_STACK_OVERFLOW라는 상수가 정의돼있다. 이 상수는 0, 1, 2 세 가지 값을 가질 수 있다. 0일 경우 stack overflow를 컨트롤러가 따로 검사하지는 않는다.

1번 방법

  • Stack 하위 빈 영역을 ‘0’으로 모두 초기화 한 뒤 context switching이 발생할 때 ‘0’ 패턴이 얼마나 남았는지를 통해 stack overflow를 검사하는 방법이다.
  • 간단한 방법이지만, context switching 때만 검사하기 때문에 stack overflow를 발견했을 때 이게 언제 어디서 발생했는지는 알 수 없다.

2번 방법

  • Stack 하위 20Byte를 인식하기 편리한 정해진 패턴으로 초기화 한 뒤 이 패턴이 조금이라도 변경될 경우 stack overflow가 발생했다고 판단하는 방법이다.
  • 1번 방법에 비해 속도는 느리지만 stack overflow가 발생하는 시점과 위치를 정확하게 알 수 있다. 하지만, 2번 방법도 모든 stack overflow를 찾지는 못한다.

3.5. 실습 02 - Stack overflow

저작권을 존중하므로 전체 실습 소스코드는 공유하지 않는다.

  • Task2의 local stack size는 128WORD = 512B이다.
  • Task2에 550B 짜리, 1024B 짜리 char형 배열을 선언하고 memset 함수를 이용해서 초기화하는 함수를 이용해서 stack overflow를 유도한다.
  • Stack overflow 검사 2번 방법을 사용했음에도 불구하고 실행 결과 stack overflow가 발생하지 않고 정상적으로 실행되는 모습을 확인할 수 있다.
  • 사실 이게 더 위험한거다!! 문제가 발생했음에도 assertion이 걸리지 않은 이 상황이 훨씬 위험하다. 후속조치를 취할 수도 없고 문제가 발생했다는 사실조차 알 수 없기 때문이다.

  • 반면에 800B 짜리 배열로 다시 돌렸을 때는 stack overflow를 정상적으로 발견한 것을 확인할 수 있다. 따라서 FreeRTOS에서 제공하는 stack overflow 검사 알고리즘도 완전히 신뢰할 수 없다는 것을 배울 수 있다.
  • 결국 stack overflow를 막기 위한 최선의 방법은 시큐어 코딩 및 개발자의 경험치인 것이다.

4. 임계영역 (Critical Section)

4.1. Critical section 개요

IPC를 사용하다보면 필연적으로 공유자원을 사용하고 싶은 욕구가 생긴다.

.

  • Task A와 Task B는 buttonCnt라는 공유자원을 사용하고 있다.
  • buttonCnt++라는 단순한 한 줄 짜리 연산도 어셈블리어로 무려 5줄이나 차지하고 있다. 저 어셈블리 연산은 atomic 하지 않기 때문에 얼마든지 저 사이에 접근이 일어날 수 있다.
  • 연산 도중 특정 값이 바뀌어버리면, 우리가 원하는 대로 프로그램이 동작하지 않을 수 있다.
    • 예를 들어, task A에 buttonCnt == 100이면 특정 동작을 수행하도록 조건문을 만들었는데, buttonCnt++연산 도중 task B에 의해 한 번 더 증가해 buttonCnt = 101이 됐다고 하자. 그럼 task A의 특정 동작은 수행될 수가 없다.
    • 만일 저 영역을 critical section 보호처리 했다면, 5개의 어셈블리 연산은 모두 atomic함이 보장되므로 프로그램이 우리가 의도했던 대로 동작할 것이다.

∴ 따라서, critical section problem은 OS가 반드시 중요하게 다뤄야하는 문제다! Critical section에 대한 소유권은 반드시 단일 task가 독점하도록 보장해줘야 한다.

4.2. Critical Section Problem 해소방법

  1. 인터럽트 중단: SYSTICK interrupt를 막아서 preemption 및 context switching이 일어나지 않도록 만들어 해당 critical section에 대한 독점을 보장하는 방법. Critical section 진입 전에 인터럽트 비활성화하고, 수행 후에 인터럽트 활성화한다.
    • taskENTER_CRITICAL(), taskEXIT_CRITICAL() 이라는 커널 API를 제공한다. 이 API는 인터럽트보다 우선순위가 높다!!
  2. 스케줄링 중단: 마찬가지로 context switching이 일어나지 않도록 만든다. 무식한 방법이다. 애초에 FreeRTOS에서는 지원하지 않는다.
  3. 공유자원 또는 전역변수를 아예 사용하지 않는다: 믿기 어렵겠지만 좋은 해결방법이다. 알고리즘을 변형하거나 조금 더 생각한다면, 공유자원을 사용하지 않고도 서비스를 구현할 방법이 있다. 특정 HW 자원에 대한 접근을 특정 task에게 독점/제한 하는 방법이다.
  4. Semaphore/Mutex 사용: 가장 적합한 방법. Critical section에 대한 lock(p, wait, take) 연산과 unlock(v, signal, give) 연산을 제공해 독점권을 보장한다. 비록 그 자체로 오버헤드가 되겠지만, 안전한 프로그램을 만들 수 있다는 것이 훨씬 큰 장점이다.

4.3. 실습 03 - Critical section 보호 (1)

저작권을 존중하므로 전체 실습 소스코드는 공유하지 않는다.

FreeRTOS의 critical section 보호 방법 중 하나인 taskENTER_CRITICAL을 사용한다.

4.4. Semaphore

  • 다익스트라 알고리즘으로 유명한 Edsger Dijkstra가 만든 개념으로 공유자원에 대한 접근을 semaphore라는 잠금 기능을 통해 보호한다.
  • Semaphore는 동기화 및 공유자원 보호 그리고 이벤트 전달 용도로 사용한다.
  • 먼저 semaphore를 초기화한 뒤, 공유자원에 접근하기 위해서는 semaphore를 휙득해야 하며, 공유자원을 다 사용한 뒤에는 semaphore를 반환해야 한다.
    • 초기화: xSemaphoreCreateBinary(), xSemaphoreCreateCounting()
    • 휙득: p 연산, wait 연산, take 연산 이라고 부른다.
      xSemaphoreTake(), xSemaphoreTakeFromISR()
    • 반환: v 연산, signal 연산, give 연산 이라고 부른다.
      xSemaphoreGive(), xSemaphoreGiveFromISR()
    • 제거: vSemaphoreDelete()
  • Semaphore는 공유자원에 따라 두 가지 종류를 가진다.
    • Binary Semaphore: 01 값으로만 이뤄진 semaphore다. Critical section에 대해 p연산을 수행하면 semaphore가 0이 되서 다른 task의 접근을 막는다. 접근을 요청한 task는 blocked 상태가 되며 들어갈 때 까지 기다린다. (무한정 기다리는 건 아니고, take 함수에서 기다리는 시간을 설정할 수 있다.)
    • Counting Semaphore: 0부터 사용자가 설정한 수까지 값을 가지는 semaphore다. 예를 들어 공유자원이 10칸짜리 배열일 때, semaphore를 10으로 초기화하면, counting semaphore는 남은 공유자원의 개수를 의미한다. semaphore가 0이 되면 해당 배열이 가득 찼음을 의미하며 접근을 요청한 task는 blocked 상태가 되며 기다린다.

4.5. 실습 04 - Critical section 보호 (2)

1. Binary semaphore에 대한 실습

2. Counting semaphore에 대한 실습

  • STM32F103RB의 B1 버튼을 클릭할 때마다 몇 번 클릭됐는지 출력한다. Clicked[]
  • 버튼을 클릭할 때마다 무거운 연산이 수행되며 give() 연산으로 semaphore 값 증가한다.
  • semaphore가 생기면, take() 중인 task가 blocked에서 running이 되며 몇 번째 semaphore이고 현재 semaphore의 값이 무엇인지 출력한다. Sem번호(semaphore 값)
  • 버튼을 굉장히 빠른 속도로 눌러보자. 버튼에 대한 인터럽트가 task보다 우선순위가 높고, 연산이 무겁기 때문에 Clicked[12], [13], [14]가 먼저 출력되는 것을 확인할 수 있다. 그럼 이제 semaphore가 0, 1, 2로 늘었을 것이고, task는 연속해서 3번 take() 연산을 수행할 수 있다.
  • Task는 semaphore에 대한 정보를 연달아서 3번 출력하는 것을 확인할 수 있다. 즉, critical section에 대한 진입 회수가 semaphore의 값과 동일하다는 것을 확인할 수 있다.

(P.s. ISR에서 무거운 연산을 처리할 경우 버튼에 대한 인터럽트가 온전히 실행되지 않을 수 있다. 실제로 버튼을 굉장히 빠르게 5번 눌렀지만, 4번(11, 12, 13, 14)만 인식이 됐다. ISR은 최대한 작게 만들어야 다른 인터럽트나 task에 악영향을 최소한으로 만들 수 있다. ISR이 무겁고 속도가 느릴 수 밖에 없다면 뒤에서 배울 deferred interrupt processing 이라는 좋은 방법을 사용해서 해결할 수 있다.)

4.5. Mutex

Mutex는 binary semaphore과 거의 같은 기능을 수행하지만, 중대한 차이가 하나 있다.
바로 우선순위 역전 현상을 예방해줄 메커니즘을 포함하고 있다는 점이다.

Priority Inversion (우선순위 역전)

Semaphore는 critical section 및 공유자원에 대한 독점권을 보장하는 훌륭한 동기화 수단이지만, 치명적인 문제인 ‘우선순위 역전 현상’을 유발할 수 있다.

  • 우선순위 역전 현상은, 우선순위가 높은 task가 되려 우선순위가 낮은 task의 수행이 끝날 때까지 기다리는 상황을 말한다. Semaphore를 사용할 경우 이런 문제가 발생할 수 있다.
  1. Task1이 실행되고 take() 연산을 수행해 critical section이 lock이 걸린다.
  2. HPT인 task2가 생성되면 task1을 preempt하고 context switching이 발생한다.
  3. Task2도 take() 연산을 수행한다. 이때 semaphore가 0이므로 task2는 blocked state로 진입한다.
  4. 다시 task1이 실행된다.
  5. Task3이 생성되면 task1을 preempt하고 context switching이 발생한다.
  6. Task3은 실행을 완료하고 delete된다.
  7. 다시 task1이 실행되고, critical section을 수행한 뒤 give() 연산으로 semaphore를 반환한다.
  8. 반환되자마자 task2가 실행된 뒤 완료하고 delete된다.
  • Semaphore 때문에 task1이 give()하기 전까지 task2는 계속 기다리는 상황이며 심지어 우선순위가 낮은 task3가 먼저 실행되는 어이없는 상황이 발생한다.

우선순위 상속 (Priority Inheritance)

우선순위 역전 현상을 해결하는 방법으로 FreeRTOS는 우선순위 상속(inheritance)을 사용한다.

  • 동일한 semaphore를 요청한 우선순위가 높은 task의 우선순위를 상속받아 우선순위가 높게 만들어 위 그림과 같이 문제를 해결한다.
  • 이때 task1은 give() 하자마자 다시 원래 우선순위로 돌아간다.
  • 상속 방법은 역전 현상을 완전히 해결할 수는 없지만, 역전 현상의 빈도를 상당히 낮추고, 발생하더라도 적정한 time bound 내에 해결되도록 유도할 수 있는 좋은 방법이다.

5. 문맥전환과 인터럽트 (Context switching & Interrupt)

5.1. Context Switching

Context switching이 무엇인지는 간략하게 알고있지만, 정확한 정의는 잘 모르는 경우가 많다. 이번 기회에 context switching이 무엇인지, 무엇 때문에 오래 걸려서 잦은 수행을 피해야하는지 알아보자.

1. Context의 정의

  • Context는 CPU의 모든 register 데이터를 말한다. 즉, 현재 task를 실행하는 도중의 CPU register의 모든 정보가 하나의 context를 의미한다.
  • 또한 현재 실행중인 task 또는 process의 모든 정보도 포함한다. Task의 stack pointer, 이름, 우선순위, 시작 주소 등 모든 정보를 TCB (Task Control Block)이라는 곳에 구조체 리스트로 저장한다. (우리는 운영체제에서 프로세스의 정보를 PCB(Process Control Block)에 저장한다고 이미 배웠다.)

2. Context Switching의 과정

5.2. Interrupt

오해하기 쉽지만, 인터럽트는 반드시 빠르게 처리해야 하는 것이 아니다. 인터럽트는 단순히 HW가 SW 적인 작업을 수행하기 위해 CPU에게 비동기적으로 기능을 요청하는 신호일 뿐이고, ISR우선순위가 다른 task보다 높은 하나의 task일 뿐이다. ISR은 다른 task와 마찬가지로 제 시간 안에만 처리하면 되는 메커니즘일 뿐이다. 그렇기 때문에 다른 인터럽트를 놓치지 않도록 ISR을 경량으로 유지하는 것이 중요하다.

  1. 인터럽트가 발생하면, 현재 실행중인 task 및 context 정보를 TCB에 저장한다.
  2. IVT (Interrupt Vector Table)에서 발생한 인터럽트의 종류를 파악하고, 수행한 ISR의 포인터를 따라간 뒤 PC를 포함한 CPU register를 수정한다.
  3. ISR을 수행한다.
  4. ISR이 끝난 뒤 다른 highest priority task (HPT)가 있을 경우 그쪽으로 context를 restore하면 되고, 없다면 방금 저장했던 context를 restore 하면 된다.

5.3. Deferred Interrupt Processing (DFI)

FreeRTOS는 ISR를 최대한 경량화할 것을 권장하며 다음과 같은 네 가지 근거를 든다.

  1. ISR은 task보다 우선순위가 높기 때문에 언제나 먼저 실행된다. 따라서 ISR의 수행시간이 길어지면, task의 수행 시작시간과 종료시간에 악영향을 줄 수 있다.
  2. ISR 수행시간이 길어지면 새로운 인터럽트를 accept할 수 없을 가능성이 증가한다. ISR 수행 도중에는 다른 ISR을 수행할 수 없기 때문이다.
  3. 물론 ARM의 NVIC (Nested Vectored Interrrupt Controller) 덕분에 인터럽트 간에도 우선순위를 적용할 수 있지만, FreeRTOS를 포함해 많은 전문가가 nested interrupt 사용을 지양한다. 왜냐하면 ① 프로그램의 복잡도를 기하급수적으로 높히고 ② 예측가능성을 낮추며 ③ 개발자가 인터럽트 간 우선순위를 배정하는 기준이 애매모호하기 때문이다.
  4. ISR이 커지고 복잡해지면 공유자원을 사용할 가능성이 커진다. Task-인터럽트 간 공유자원 사용에 대한 책임은 오롯히 개발자가 져야하며 이는 매우 위험하다.

FreeRTOS는 꼭 ISR이 크고 복잡한 기능을 수행해야 한다면, 그 기능을 대신 수행할 높은 우선순위의 task를 생성한 뒤 위임할 것을 강력히 권장한다. 이 task가 바로 deferred interrupt processing task다.

5.4 실습 05 - Deferred Interrupt Processing

이번 실습에서는 수 MB의 데이터를 처리하는 매우 무거운 연산은 버튼 인터럽트 ISR에서 처리하는 경우와 ISR()이 semaphore give()해서 깨운 deferred processing task에서 처리하는 경우로 나눠 결과를 살펴본다. 차이를 알기위해 xTaskGetTickCount() 함수로 현재 SYSTICK의 값을 출력한다.

ISR을 최대한 경량화해야하는 이유에 대해 잘 알 수 있었던 실습이었다.


6. IPC (Inter Process Communication)

Task와 task 간의 communication을 위해서 FreeRTOS에서 마련한 세 가지 방법은 다음과 같다.

  1. Semaphore
  2. Event flag
  3. Message box(queue)

이 중 semaphore에 대해서는 앞서 다뤘으니, 이번에는 나머지 IPC 방법들에 대해 다뤄보자.

6.1. Event Flag Group

Event flag는 비트마스킹을 사용해 task로부터 event를 수신하는 방법이다. 임의의 task 또는 ISR에서 event flag를 set()하면, 대응하는 다른 task 또는 ISR에서 get()해서 특정 event를 수행하도록 만드는 것이 flag를 사용한 통신의 핵심이다.

예를 들어, 하나의 동작을 구성하는 n개의 작은 task가 있다고 가정하자. i번째 task는 i번째 인덱스의 bit에 대응하도록 event flag bit 배열을 만든다. 특정 task에서 문제가 발생해서 k번째 bit가 1이 됐다. 모든 task를 수행한 뒤 event & 0 == event라면 동작을 수행할 수 있지만, k번째 bit가 1이므로 문제가 발생했고 그것이 k번 task에서 발생했음을 알 수 있다.

  • 생성: EventGroupHandle_t xEventGroupCreate()
  • 전달: EventBits_t xEventGroupSetBits(EventGroupHandle_t handle, EventBits_t uxBitsToSet)
  • 대기:
    • xEventGroup은 event group에 대한 handle을 의미한다. Create 함수의 반환 객체를 여기다가 넣어주면 된다.
    • uxBitsToWaitFor는 기다리고 있는 bit나 event를 ‘+’기호를 사용해서 표현한다.
    • xClearOnExit는 이름 그대로 event를 전달 받았을 때 해당 bit를 다시 초기화할 것인지 여부를 설정한다. 이를 pdTRUE로 설정하지 않으면 마치 event가 미친듯이 빠른 속도로 연속해서 들어오는 것으로 인식한다. 특수한 경우 아니면 pdTRUE로 고정할 것.
    • xWaitForAllBits는 기다리는 bits를 OR연산 처리할 것인지 AND연산 처리할 것인지 여부를 결정한다. pdFALSE면 OR연산 처리되서 기다리는 bit 중 어느 하나라도 들어오면 성공 처리하고, pdTRUE면 AND연산 처리되서 기다리는 bit 모두 들어와야 성공 처리한다.
    • xTicksToWait은 event를 기다리는 시간을 의미한다. 별 상관없으면 보통 portMAX_DELAY를 사용해서 계속 기다리도록 설정한다.
  • 삭제: void xEventGroupDelete(EventGroupHandle_t handle)

6.2. 실습 06 - Event Group Flag

  • Task1이 xEventGroupSetBits()group_id에게 ENGINE_OIL_PRES_OK event를 10번 전송한다.
  • Task2는 ENGINE_OIL_PRES_OKxEventGroupWaitBits()로 기다리다가 받자마자 ***Event Arrived!*** 문장을 송출한다.

6.3. Message Queue

지금까지 배운 IPC인 semaphore와 event flag는 bit값을 바꾸면서 서로 약속된 의미로 해석한 뒤 특정 기능을 수행하는 방식으로 돼있다면, message queue는 정수, 실수, 구조체, 심지어는 사진, 음악도 전송할 수 있는 아주 강력한 IPC 기능이다.

  1. Task A가 Queue에 자신의 지역변수 x를 write한다.
  2. Task A가 후속과정을 처리하다가 x값을 변경하고, x를 다시 queue에 write한다.
  3. Task B가 생성된 뒤 queue를 read해 자신의 지역변수 Y를 초기화한다. 이때 read와 동시에 delete한다.

FreeRTOS의 queue는 ‘copy’ 방식을 채택한다. 물론 queue에 무엇이 저장될지 알 수 없기 때문에 포인터를 사용한 reference 방식이 좋겠지만, 당연히 task에서 선언한 변수는 local stack에 저장되기 때문에 포인터를 사용했다가는 엄청난 fault가 발생할 것이 예상되기 때문이다.

Message Queue를 활용한 통신의 종류는 다음과 같다.

  1. 1:1 (단방향 / 양방향) 통신
  2. 1:多 통신
  3. 多:1 통신
    • 1:1과 1:多는 너무 기본적인 내용이라 직관적으로 이해가 갈 것이다. Queue가 비어있다면, receive를 요청한 task는 blocked 될 것이기 때문이다.
    • 반면 多:1은, 해당 정보를 ‘누가 보냈는가?’를 해소해줘야 한다. 따라서, FreeRTOS에서 권장하는 방법은 구조체를 사용하고, 약속된 임의의 task_id 라는 uint16_t 형의 양수를 선언해서 queue의 해당 정보가 어느 task로부터 전송됐는지를 표현하는 방법이다.
  4. 多:多 통신

6.4. 실습 07 - Message Queue 활용

  • Task1은 qid라는 message queue에서 xMessage를 기다리는 동안 blocked 될 것이다.
  • Task2는 1초에 한 번씩 qid에 10의 배수를 xQueueSend() 함수로 전송한다.
  • 메시지를 받은 task1은 blocked에서 풀려나며 받은 메시지를 출력한다.


profile
임베디드 시스템 공학자를 지망하는 컴퓨터공학+전자공학 복수전공 학부생입니다. 타인의 피드백을 수용하고 숙고하고 대응하며 자극과 반응 사이의 간격을 늘리며 스스로 반응을 컨트롤 할 수 있는 주도적인 사람이 되는 것이 저의 20대의 목표입니다.

1개의 댓글

comment-user-thumbnail
2024년 1월 17일

좋은 글 감사합니다.

답글 달기