먼저 태스크가 정확히 무엇인지 이해해봅시다. 우리는 하루에 많은 태스크를 수행합니다. 예를 들어, 아침 식사하기, 회사 출근하기, 업무, 회의 참석, 전화 받기 등이 될 수 있습니다. 그리고 각 태스크에 몇 분에서 몇 시간을 소비합니다.
때때로 우리는 한 번에 하나의 태스크를 수행하고 혹은 다양한 태스크를 멀티태스킹하기도 합니다. 우리는 가능한 많은 태스크를 달성하기 위해 시간을 관리하려고 합니다. 마찬가지로 컴퓨팅 세계에서도 애플리케이션이 있고 애플리케이션은 다양한 태스크로 구성됩니다.
온도 모니터링 애플리케이션을 예로 들어보겠습니다. 이 애플리케이션은 세 개의 태스크를 사용하여 구현할 수 있습니다. Task1은 센서 데이터를 수집하고, Task2는 디스플레이를 업데이트하며, Task3은 항상 사용자 입력을 처리하기 위해 대기합니다.

FreeRTOS와 같은 실시간 운영 체제에서는 애플리케이션을 다양한 태스크로 나눌 수 있습니다. 실시간 운영 체제에서 태스크는 스케줄 가능한 코드 조각에 불과하며, 애플리케이션의 특정 기능을 구현합니다.
이 애플리케이션에서 Task1의 기능은 센서 데이터를 수집하는 것이며 주기적 또는 비주기적으로 스케줄할 수 있고, 그 스케줄링은 실시간 운영 체제가 처리할 수 있습니다.
그렇다면 FreeRTOS에서 태스크를 어떻게 생성하고 구현할까요? FreeRTOS에서 태스크를 사용하려면 먼저 태스크를 생성해야 합니다. 그런 다음 태스크 구현을 수행하는 함수를 작성해야 하며, 이를 태스크 핸들러(Task handler)라고 합니다.
💻 공식 문서: FreeRTOS > API 참고 > 작업 생성

코드 예시:
/* 생성될 태스크 */
void vTaskCode( void * pvParameters )
{
/* 아래 xTaskCreate() 호출에서 pvParameters로 1을 전달했으므로,
여기서는 전달된 파라미터 값이 1일 것으로 기대한다. */
configASSERT( ( ( uint32_t ) pvParameters ) == 1 );
for( ;; )
{
/* 태스크 동작 코드는 여기에 작성한다. */
}
}
/* 태스크를 생성하는 함수 */
void vOtherFunction( void )
{
BaseType_t xReturned;
TaskHandle_t xHandle = NULL;
/* 태스크를 생성하고, 생성된 태스크의 핸들을 저장한다. */
xReturned = xTaskCreate(
vTaskCode, /* 태스크를 구현하는 함수 */
"NAME", /* 태스크 이름(문자열) */
STACK_SIZE, /* 스택 크기 (바이트가 아니라 워드 단위) */
( void * ) 1, /* 태스크로 전달할 파라미터 */
tskIDLE_PRIORITY,/* 태스크 생성 시 우선순위 */
&xHandle ); /* 생성된 태스크의 핸들을 저장할 변수 */
if( xReturned == pdPASS )
{
/* 태스크가 정상적으로 생성되었으므로,
저장해 둔 핸들을 사용해 태스크를 삭제한다. */
vTaskDelete( xHandle );
}
}
코드를 보면 알겠지만 먼저 태스크를 생성해야 하고, 그다음 태스크 핸들러를 제공하는 것을 볼 수 있습니다.
태스크 생성은 메모리에서 태스크를 생성하는 단계입니다. 태스크 생성은 Task Control Block(TCB)을 위한 메모리 할당을 포함합니다. 그리고 태스크 구현에 불과한 태스크 핸들러는 태스크의 스케줄 가능한 함수입니다.
이제 FreeRTOS에서 태스크 핸들러를 작성하는 방법을 탐색해봅시다. 일반적인 태스크 구현 함수 또는 태스크 핸들러는 다음과 같습니다.
void vTaskCode(void *pvParameters )
{
/*
변수는 일반 함수에서처럼 선언할 수 있습니다.
이 함수를 사용해 생성된 각 태스크 인스턴스는
iVariableExample 변수의 자기만의 복사본을 가지게 됩니다.
만약 이 변수가 static으로 선언되었다면, 변수의 복사본은 오직 하나만 존재하게 되고
생성된 모든 태스크 인스턴스가 그 하나의 변수를 공유하게 됩니다.
*/
int iVariableExample = 0;
/* 태스크는 일반적으로 무한 루프 형태로 구현됩니다. */
for( ;; )
{
/* 여기에 태스크의 기능을 구현하는 코드가 들어갑니다. */
}
/*
만약 태스크 구현이 위의 루프를 빠져나오게 된다면,
이 함수의 끝에 도달하기 전에 반드시 태스크를 삭제해야 합니다.
vTaskDelete() 함수에 NULL을 전달하면
삭제 대상 태스크가 현재(자기 자신) 태스크임을 의미합니다.
*/
vTaskDelete(NULL);
}
이것은 일반적인 C 함수처럼 보입니다. 여기서 포인터를 전달할 수 있는 하나의 매개변수를 받으며, 태스크 함수의 본문은 일반적으로 무한 루프로 구성됩니다. 그리고 무한 루프 안에 태스크 기능을 위한 코드를 구현해야 합니다. 태스크 구현이 위 루프를 벗어나는 경우, 이 함수의 끝에 도달하기 전에 태스크를 삭제해야 합니다.
for 루프를 벗어나고 싶다고 가정해봅시다. 즉, 이 태스크를 완료했고 종료하고 싶다는 의미입니다. 그런 경우 이 태스크에서 단순히 return할 수 없습니다. 이 태스크를 종료하기 전에 먼저 태스크를 삭제해야 합니다. 그것이 FreeRTOS에서 태스크 핸들러 구현의 규칙입니다.
vTaskDelete() 함수에 전달된 NULL 매개변수는 삭제 대상 태스크가 현재(자기 자신) 태스크임을 의미합니다. 이는 태스크 생성 시 이 태스크가 소비한 메모리를 해제합니다.
변수는 일반적인 C 함수처럼 선언하고 초기화할 수 있습니다. 위 코드의 iVariableExample 은 태스크 핸들러의 지역 변수입니다. 이 함수를 사용하여 생성된 태스크의 각 인스턴스는 변수의 자체 복사본을 갖게 됩니다. 태스크의 스택 메모리에 생성 됩니다. 태스크 함수가 실행될 때 생성되고, 함수가 종료되면 사라집니다.
주의할 점으로, FreeRTOS는 각 태스크마다 독립된 스택 공간을 할당받습니다. 만약 로컬 변수로 큰 배열(uint8_t buf[512])을 선언하면, 설정한 태스크 스택 크기를 초과(Stack Overflow)하여 시스템이 멈출 수 있습니다.
변수가 static으로 선언된 경우에는 변수의 복사본이 하나만 존재하고 이 복사본은 태스크의 생성된 각 인스턴스에 의해 공유됩니다. 저장 위치는 RAM의 데이터(Data/BSS) 영역 (스택 외부) 입니다. 프로그램이 시작될 때 딱 한 번 초기화되며, 태스크가 중단되었다가 다시 실행되어도 이전 값을 그대로 유지합니다.
장점으로는 태스크 스택 용량을 잡아먹지 않습니다. 따라서 스택이 부족할 때 큰 데이터를 처리하기 좋습니다. 주의할 점은, Task A가 값을 바꿨는데 Task B의 동작에 영향을 줄 수 있어 버그의 원인이 될 수 있습니다.
두 개의 태스크가 있다고 가정해봅시다. Task 1과 Task 2입니다. 그리고 두 태스크 모두 위 함수를 핸들러로 사용한다고 가정해봅시다. 그러면 이 변수(iVariableExample)의 별도의 두 복사본이 생성될 것입니다. 한 복사본은 Task1의 스택에 생성되고 다른 복사본은 Task2의 스택에 생성됩니다. 즉, 각 태스크는 자신만의 스택 메모리를 가지고 있습니다.
이 변수가 static 변수라고 가정해봅시다. 여기서 static 키워드를 사용하면 이 변수는 전역 변수로 취급될 것입니다. 그리고 이 변수의 두 개의 별도 복사본이 생성되지 않을 것입니다. 즉, Task1과 Task2 간의 공유 리소스가 된다는 것입니다.
본격적으로 FreeRTOS Task 생성 API를 이해해보겠습니다. API는 xTaskCreate()입니다. 이 API는 동적 메모리 할당을 사용하여 새로운 FreeRTOS 태스크를 생성하고 새로 생성된 태스크를 커널의 ready list 또는 ready queue에 추가합니다.
이 API를 애플리케이션에서 사용하여 새로운 FreeRTOS 태스크를 생성할 수 있으며 여기에 몇 가지 매개변수를 제공해야 합니다.
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // 할당된 태스크 핸들러의 주소
const char * const pcName, // 태스크 이름(문자열)
const configSTACK_DEPTH_TYPE uxStackDepth, // 스택 크기 (바이트가 아니라 워드 단위)
void *pvParameters, // 태스크로 전달할 파라미터 (포인터 형으로 전달)
UBaseType_t uxPriority, // 태스크 우선순위
TaskHandle_t *pxCreatedTask // 생성된 태스크의 핸들을 저장할 변수
);
첫 번째 매개변수는 태스크 핸들러의 주소를 전달해야 합니다. 즉, 스케줄러가 이 태스크를 CPU에서 실행하도록 스케줄할 때, 이 태스크 핸들러가 실행될 것입니다. 함수의 주소이므로, 기본적으로 함수 포인터입니다.
이제 두 번째 매개변수에서는 이 태스크를 식별하기 위한 이름을 제공해야 합니다. 이것은 사용자가 원하는 이름을 지정해주면 됩니다.
위에서도 말했듯이 각 태스크는 독립된 스택 공간을 할당받습니다. 그래서 이 매개변수(usStackDepth)를 사용하여 실시간 커널이 이 태스크에 할당할 스택 메모리의 양을 언급해야 합니다. 기본적으로 타입은 configSTACK_DEPTH_TYPE에 의해 숨겨져 있습니다. 나중에 FreeRTOS 커널에서 이 매크로가 정확히 어디에 정의되어 있는지 보여드리겠습니다.
다음 매개변수에서는 태스크 핸들러가 CPU에서 실행되도록 스케줄될 때 태스크 핸들러에 전달해야 하는 데이터의 포인터를 제공합니다. 여기서 언급하는 포인터는 무엇이든 태스크 핸들러가 CPU에서 실행될 때 이 태스크 핸들러에 전달됩니다. 이 매개변수를 사용하여 태스크 핸들러에 일부 정보를 전달할 수 있습니다.
다음 매개변수는 태스크 우선순위 값입니다. 애플리케이션에 두 개 이상의 태스크가 있는 경우 이것이 어떤 태스크가 CPU에서 먼저 실행되어야 하는지 결정합니다. 따라서 태스크를 높은 우선순위로 만들 수도 있고 태스크를 낮은 우선순위로 만들 수도 있습니다.
다음 매개변수는 이 매개변수를 사용하여 태스크 핸들을 저장할 수 있습니다. 태스크 핸들은 생성된 태스크의 주소에 불과합니다. 숫자입니다. 생성된 태스크를 숫자로 식별할 수 있습니다. 그 숫자를 여기에 포인터를 제공하여 저장할 수 있습니다. 즉, 생성된 태스크의 주소를 알고 싶다면 이 매개변수를 사용하세요. 이를 통해 나중에 태스크 핸들을 사용하여 태스크를 일시 중단하거나 태스크를 차단하거나 태스크를 삭제할 수 있습니다.
💻 공식 문서: FreeRTOS > API 참고 > 작업 생성
FreeRTOS API를 이해하는 데 매우 유용할 API 레퍼런스 문서 입니다. 열어보면 이 웹 페이지에서 이 API에 대해 더 읽을 수 있습니다. 이제 여기서 이 Stack Depth 매개변수에 대해 더 이해해봅시다.

이 문서가 말하는 것은 이것이 태스크의 스택으로 사용하기 위해 할당할 워드(바이트가 아님) 수라는 것입니다. 예를 들어, 스택이 16비트 폭이면, 즉 마이크로컨트롤러의 push와 pop 연산이 16비트 폭의 데이터 폭에서 발생하고 Stack Depth 매개변수를 100이라면, 태스크 스택으로 200바이트가 할당될 것입니다. 200에 2바이트를 곱한 것입니다.
또 다른 예로, 스택이 32비트 폭이라면, push 또는 pop 연산을 수행할 때마다 32비트의 데이터 폭이 메모리로 푸시되거나 메모리에서 검색된다는 것을 의미합니다. 그것이 마이크로컨트롤러의 push와 pop 연산이 발생하는 최대 데이터 폭 크기 입니다.
우리의 ARM Cortex-Mx 마이크로컨트롤러(M0, M3, M4, M7 등)에서는 실제로 스택이 32비트(4바이트) 폭입니다. 따라서 ARM Cortex-Mx 프로세서에서 스택 연산 모델은 full descending stack 입니다. 즉, 스택 포인터 SP는 높은 메모리 주소에서 시작하여 push가 발생할 때마다 낮은 메모리 주소로 이동합니다.

이것이 push가 발생하는 방식입니다. 각 push마다 32비트 데이터가 스택 메모리에 푸시됩니다. 이 폭은 기본적으로 32비트입니다. 이것은 다른 프로세서 아키텍처를 기반으로 마이크로컨트롤러에는 해당되지 않습니다. 이 폭이 16비트 또는 8비트일 수 있는 프로세서 아키텍처가 있을 수 있습니다. 이에 대해서는 프로세서 설계 문서를 확인해야 합니다.
즉, push 연산을 수행하면 32비트 폭의 데이터가 메모리에 푸시되고, pop 연산을 수행하면 32비트 폭의 데이터가 메모리에서 검색됩니다. 따라서 스택이 32비트 폭이고 StackDepth를 400으로 하면, 400에 4를 곱한 것, 즉 1600바이트가 태스크의 스택으로 사용하기 위해 할당될 것입니다.
워드가 정확히 무엇인지 궁금하시다면, 액세스할 수 있는 데이터의 최대 크기라고 보면 됩니다. 즉, 프로세서가 단일 명령을 사용하여 단일 클럭 사이클에 load 또는 store 연산을 수행할 수 있는 크기입니다.
ARM Cortex-Mx 프로세서의 예를 들어봅시다. 이 프로세서는 단일 클럭 사이클에 단 하나의 명령만 사용하여 32비트 폭의 데이터를 로드할 수 있는 능력이 있습니다. 프로세서 설계는 이에 대한 네이티브 지원을 제공합니다. 레지스터에 필요한 폭, 버스 폭을 제공하여 워드 크기 데이터를 load 또는 store할 수 있습니다.
워드 크기는 8비트일 수 있습니다. 예를 들어, 8비트 마이크로컨트롤러를 사용하는 경우 워드 크기는 8비트가 될 것입니다. 워드 크기는 16비트일 수도 있고 32비트 또는 그 이상일 수 있습니다. 이는 프로세서 설계에 따라 다릅니다.
이제 이 configSTACK_DEPTH_TYPE 매크로가 정확히 무엇인지 이해해봅시다.
💻 공식 문서 참고: FreeRTOS > 커스텀 설정

uxStackDepth 매개변수의 타입을 언급하는 데 사용됩니다. xTaskCreate() 호출 및 스택 크기가 사용되는 다양한 다른 곳에서 스택 깊이를 지정하는 데 사용되는 타입을 설정합니다.
구버전 FreeRTOS의 경우 스택 깊이의 타입을 언급하는 이 매크로를 찾을 수 없다는 점에 유의하세요. 구버전 FreeRTOS는 UBaseType_t 타입의 변수를 사용하여 스택 크기를 지정했지만, 8비트 마이크로컨트롤러에서는 너무 제한적이라는 것이 발견되었습니다. 그래서 그 제한을 제거하기 위해 이 config 항목이 제공됩니다.
UBaseType_t가 정확히 무엇인지 이해해봅시다.
💻 공식 문서 참고: FreeRTOS > Coding Standard, Testing and Style Guide

여기서 사용된 다양한 명명 규칙, 데이터 타입을 볼 수 있습니다. 이것들은 C에서 사용 가능한 다양한 기본 데이터 타입 위에 사용되는 FreeRTOS 특정 타입 정의입니다. 여기 BaseType_t가 보입니다. 이것은 아키텍처에 가장 효율적이고 자연스러운 타입으로 정의됩니다.
예를 들어, ARM Cortex-Mx 프로세서와 같은 32비트 아키텍처 기반 프로세서를 사용하는 경우, BaseType_t는 32비트 C 데이터 타입의 타입 정의가 될 것입니다. 16비트 아키텍처에서 BaseType_t는 16비트 타입으로 정의될 것입니다.
마찬가지로 8비트 아키텍처의 경우 BaseType_t는 8비트 타입으로 정의될 것입니다. 이것은 uint8_t StackDepth 가 되는 거죠. 근데 이것은 너무 제한적입니다.
그래서 이것이 제거되었고, 대신 Stack Depth 변수의 타입을 사용자가 정의할 수 있도록 config 항목이 도입되었습니다. 이것은 FreeRTOS.h에 정의되어 있습니다. 애플리케이션에서 이것도 제한적이라고 생각한다면 이 config 항목을 재정의할 수 있으며 여기에 원하는 데이터 타입을 사용할 수 있습니다.
과거와 현재를 비교 정리해보면, 다음과 같습니다.

주의. 스택 폭이랑 스택 깊이랑 헷갈리지 마세요. 스택 폭은 CPU 워드 크기라서 개발자가 바꿀 수 없습니다. 여기서 말하는건 스택 깊이 입니다. 태스크에 얼마만큼의 스택 메모리를 줄지는 개발자가 결정할 수 있게 한 거예요. (아 이제 이해했어...)
연습 문제를 통해 실제 코드를 작성하고 실행해보도록 하겠습니다.
연습 문제는 동일한 우선순위를 가진 두 개의 태스크를 생성하도록 합니다. 태스크 1은 "Hello world from Task-1" 메시지를 출력하고, 태스크 2는 "Hello world from Task-2" 메시지를 출력합니다. 스케줄러는 선점형과 협력형을 사용해보면서 결과를 비교해봅니다.
선점형 스케줄링, 그 중에서 라운드 로빈(순환형) 방식의 실행 결과는 다음과 같습니다.

원래 기대한 출력 결과는 Task 1과 Task 2 메시지가 번갈아 가면서 나오는 거였는데, 뭔가 잘못된 결과가 나왔습니다. 무슨 일이 일어나고 있는 걸까요?
선점형 - 라운드 로빈 방식은 일정 시간이 되면 태스크가 자동으로 바뀌게 됩니다. 처음에는 Task 1이 실행되다가 출력이 다 끝나지도 못했는데 Task 2로 넘어갈 수 있습니다.
이번에는 협력형 스케줄링을 사용해봅시다. 먼저 선점을 끄겠습니다. FreeRTOSConfig.h로 가서 use preemption 매크로를 0으로 만들어줍니다. 이 상태에서 실행을 해보면 Task 1만 출력이 될 것입니다. 선점을 껐기 때문에 Task 2는 절대 실행되지 않습니다.
음. 근데 Task 2가 출력되네요? 찾아보니까 FreeRTOS 내부 구현상, 같은 우선순위 내에서는 가장 최근에 생성되어 Ready 리스트의 끝(또는 앞)에 추가된 태스크가 먼저 선택되는 경우가 많다고 합니다. 어쨌거나 지금 이 상태는 협력하는게 아니긴 하네요.

협력형은 FreeRTOS 커널에서 제공하는 taskYIELD 함수을 사용해서 구현할 수 있습니다. 자발적으로 프로세서를 포기한다는 의미에서 양보(Yielding)라고 합니다.
Task 1이 실행되고 메시지를 출력한 다음에 taskYIELD 함수를 실행하면 Task 2로 넘어가고, Task 2도 마찬가지로 메시지를 출력한 다음에 taskYIELD 함수를 실행해주면 되겠죠?
실행 결과를 보겠습니다. 원하던 결과가 나왔습니다. 깔끔하네요.

이처럼 협력형은 선점이 관여하지 않습니다. 지 태스크가 자발적으로 전환하는 것입니다. (강제가 아닌 서로 양보하는 세상이네요)
음... 근데 아쉽게도 완벽하지는 않네요. 가끔 한번씩 출력이 꼬이는 경우가 있습니다.

이걸 찾아보니까 저는 Serial Wire Viewer(SWV)를 사용해서 디버깅 모드로 출력 결과를 보고 있는데 printf 를 사용하면 내부적으로는 FIFO 버퍼를 사용하면서 ITM 버퍼에 데이터가 채워지는 도중에 Task가 전환이 될 수 있다고 하네요.
즉, printf 가 실행이 완료된 시점이 화면에 출력된 시점이 아니라는 겁니다. printf 실행해서 메모리 버퍼에 담기고 UART 장치에서 전달을 시작하지만 printf는 할 일을 다 했다고 판단하고 리턴되는 상황인거죠. (흠... 그럴 수 있군요)
권장되는 해결방법은 Mutex(뮤텍스)를 사용하는 거랍니다. 아니면 taskYIELD 전 아주 미세한 지연을 주는 것도 임시 방편으로 해결할 수 있는 방법입니다. printf가 리버팅된 _write 함수 내부에 ITM 버퍼가 빌 때까지 기다리는 코드를 넣을 수도 있습니다.