Computer Science
탄탄한 기반 실력을 위한
전공과 이론 지식 모음
Today I Learned!
배웠으면 기록을 해야지
TIL 사진
Flutter 사진
Flutter로 모바일까지
거꾸로캠퍼스 코딩랩 Flutter 앱개발 강사
스파르타코딩클럽 즉문즉답 튜터
카카오테크캠퍼스 3기 학습코치
프로필 사진
박성민
임베디드 세계에
발을 들인 박치기 공룡
임베디드 사진
EMBEDDED SYSTEM
임베디드 SW와 HW, 이론부터 실전까지
ALGORITHM
알고리즘 해결 전략 기록
🎓
중앙대학교 소프트웨어학부
텔레칩스 차량용 임베디드 스쿨 3기
애플 개발자 아카데미 1기
깃허브 사진
GitHub
프로젝트 모아보기
Instagram
인스타그램 사진

Embedded System/FreeRTOS

[FreeRTOS] Task의 생성과 관리

sm_amoled 2025. 11. 15. 21:09

FreeRTOS는 Task 단위로 기능들을 스케줄링하면서 우선순위가 높은 Task 들을 먼저 처리해 입력에 대해 정해진 시간 내에 처리하는 것을 보장한다. 벌써 키워드가 막막 나온다.

  • Task 가 뭐냐, Task를 어떻게 관리하나?
  • 우선순위에 따라 어떻게 작업을 먼저 처리하나?
  • 우선순위가 같은 땐 어떻게 하나?
  • 이걸 누가 어떻게 처리하나? 직접 코드로 짜야하나?

순서대로, 이번 글에서는 Task가 무엇인지, 어떻게 생성하고 관리하는지에 대해서 알아보자. 이후 다음에는 스케줄링을 하는 방법 + 스케줄링을 자동으로 처리해주는 커널 + Task 간의 통신 에 대해서 시리즈로 등장할 예정!

Task 의 구조

Task가 무엇인가?

Task는 독립적으로 실행되는 작은 프로그램이자, RTOS 스케줄러가 실행시킬 코드의 단위.

Task는 실행해야하는 코드 뿐만 아니라, 각자의 Stack 영역, 우선순위, 실행 상태를 비롯한 여러가지 속성 등을 가지고 있다.

Task의 구성

우선, Task는 크게 3가지로 구성된다.

  • Task의 실행 상태이자 메타데이터를 담고있는 TCB
  • Task의 현재 실행 환경과 데이터를 담고 있는 Stack
  • Task가 실행해야할 동작인 코드

차례대로 Task가 어떻게 구성되는지에 대해서 한 번 살펴보자.

각각의 Task는 함수의 형태로 Task의 실행 흐름을 작성해줄 수 있다.

실제 각 Task의 구현은 C언어로 작성할 수 있다.

void vATaskFunction( void *pvParameters )
// Task의 수행에 필요한 데이터는 인자로 받을 수 있음
{
        // 각각의 Task의 실행 환경 초기화

        // 기본적으로 무한루프에서 자신의 실행 흐름을 무한히 실행
    for( ;; )
    {
        -- Task application code here. --
    }

        // 필요한 경우 Task의 종료(삭제)
    vTaskDelete( NULL );
}

Task의 스케줄링을 위한 정보는 TCB (Task Control Block) 에 담겨있다.

TCB는 태스크의 스택 위치, 우선순위, 상태, 함수 포인터, TLS 등 스케줄러가 태스크를 관리하기 위한 모든 핵심 정보를 담은 “구조체”! (tasks.c 파일 안에 TCB_t라는 구조체로 정의됨.) Task의 메타데이터가 담기는 곳이라고도 볼 수 있다.

┌────────────────────────────────────────────┐
│              tskTaskControlBlock           │
├────────────────────────────────────────────┤
│ ● 실행 상태 관리 (Context Info)             │
│   ├─ pxTopOfStack         → 스택의 현재 위치(Top)               
│   │                         ※ 항상 TCB의 첫 번째 멤버         
│   └─ pxStack              → 스택의 시작 주소                   
│                                                                
│ ● Task 상태 관리 (State Info)              │
│   ├─ xStateListItem       → 현재 상태(Ready/Blocked 등) 연결용 
│   ├─ xEventListItem       → 이벤트 리스트 연결용 (세마포어 등) 
│   ├─ uxPriority           → 현재 우선순위                      
│   ├─ pcTaskName[ ]        → 태스크 이름(디버깅용)              
│                                                                     
└────────────────────────────────────────────┘

TCB 구조체가 가지고 있는 여러 속성 값들 중에서 가장 핵심이 되는 요소만 뽑아보면 아래와 같다.

/*
 * Task control block.  A task control block (TCB) is allocated for each task,
 * and stores task state information, including a pointer to the task's context
 * (the task's run time environment, including register values)
 */
typedef struct tskTaskControlBlock         
{
    /* === 실행 상태 관리(context) === */
    volatile StackType_t    *pxTopOfStack;  
    StackType_t                  *pxStack;  

    /* === Task 상태 관리 === */
    ListItem_t            xStateListItem;     
    ListItem_t            xEventListItem;        
    UBaseType_t            uxPriority;             
    char                    pcTaskName[ configMAX_TASK_NAME_LEN ];

} tskTCB;

또, Task는 각자 실행 흐름에서 사용할 Stack 공간을 메모리 상에 별도로 가진다.

  • Stack을 분리한 주요한 목적은 여러 실행되는 Task가 독립적으로 동작할 수 있도록 하는 것
  • Stack의 가장 중요한 용도는 Context-Switch 시 실행 환경을 저장하고 복원하는 것.
  • 이외에도 인터럽트 발생 시 Context 저장, 함수 호출 스택, Task의 지역 변수 저장 공간으로 사용된다.

TCB의 공간과 Stack의 공간이 무조건 붙어있지 않다!! 메모리 상 멀리 떨어져있어도 포인터로 가리키기 때문에 상관없음.
이론적으로 좋은 방법은 TCB 아래에 Stack이 위치하는 것 → 그러면 높은 주소에서 자라는 Stack이 혹시 자신의 영역을 침범해서 Overflow 되더라도 TCB에 담긴 정보들을 덮어쓰지는 않아서 비교적 안전하다. 그치만 만약 동적할당으로 Task 공간을 잡는 경우에는 내 마음대로 할 수 없는 일이라서, 정적으로 공간을 부여하는 경우에는 고려할 만한 사항인 것 같다.

다시 한 번 요약하면 Task는 TCB, Stack, 코드 3가지로 구성된다.

Task를 만드는 방법

그럼 Task를 어떻게 만들어주는지에 대해서 알아보자.

“Task를 생성한다”는 것은 위에서 본 3가지 요소를 메모리에 올리고 값을 초기화하여, RTOS의 커널에 의해 스케줄링을 받을 수 있는 상태를 만드는 것을 말한다.

Task를 생성하기 위해 사용하는 xTaskCreate 함수를 살펴보면, 다음의 인자를 받고있다.

  • pxTaskCode – 실행할 함수의 포인터 (함수 이름 자체를 전달)
  • pcName – 태스크의 이름 (문자열)
  • usStackDepth – 태스크에 할당할 스택 크기 (word 단위!)
  • pvParameters – 태스크에 전달할 매개변수
  • uxPriority – 태스크 초기 우선순위
  • pxCreatedTask – 생성된 태스크의 TCB를 가리키는 포인터(핸들)를 전달받을 변수. 핸들을 통해 태스크를 제어(중지, 삭제, 우선순위 변경 등)할 수 있음.

앞서 살펴본 바와 같이, 하나의 Task를 구성하기 위해 “어떤 코드를 실행할 지” + “스택의 크기는 얼마나 잡을지” + “Task를 스케줄링하기 위한 메타정보” 들을 인자로 받고있다.

그럼, 어떻게 이 과정이 진행되는지에 대해서 코드를 기반으로 살펴보자.

osThreadCreate

우리의 main 코드를 살펴보면, Task를 생성하고 등록하는 코드는 단 2줄만 작성되어있고, 이 2줄이 Task의 생성 및 스케줄러에게 등록을 담당한다.

/* definition and creation of myTask02 */
  osThreadDef(myTask, StartTask, osPriorityIdle, 0, 128);
  myTaskHandle = osThreadCreate(osThread(myTask), NULL);

os 라는 접두사가 붙은 키워드는 CMSIS에서 제공하는 편의 API로, FreeRTOS가 변경되더라도 기존의 코드를 유지할 수 있도록 하는 추상화 계층이다!

전처리기의 적용

#define osThreadDef(name, thread, priority, instances, stacksz)  \
const osThreadDef_t os_thread_def_##name = \
{ #name, (thread), (priority), (instances), (stacksz), NULL, NULL }

osThreadDef(myTask02, StartTask02, osPriorityIdle, 0, 128);
  • 컴파일러가 os_thread_def_myTask라는 const osThreadDef_t 타입의 변수를 생성
  • 이 구조체는 태스크 생성 시 필요한 메타데이터(이름, 스택 크기 등)를 담고 있고, 실제 TCB/스택은 아직 할당되지 않음

위 전처리기를 적용하고나면 아래 코드처럼 작성된다.

const osThreadDef_t os_thread_def_myTask02 = { "myTask", 
                                                                                                (StartTask), 
                                                                                                (osPriorityIdle), 
                                                                                                (0), 
                                                                                                (128), 
                                                                                                NULL, 
                                                                                                NULL };
myTask02Handle = osThreadCreate(&os_thread_def_myTask02, NULL);

os_thread_def 구조체의 정의는 이렇게 되어있다. 이게 약간 생성자에 파라미터로 넘기는 구조체라고 생각하면 될 듯. config 옵션에 따라서 전달해야하는 파라미터값과 개수가 달라져서 + 전달해야하는 데이터 개수가 많아서 옵션에 따른 함수를 여러개 작성해두는 것보다는 구조체로 넘기는게 더 효율적이라고 판단해서 이렇게 구성한 것 같다.

/// Thread Definition structure contains startup information of a thread.
/// \note CAN BE CHANGED: \b os_thread_def is implementation specific in every CMSIS-RTOS.
typedef struct os_thread_def  {
  char                   *name;        
  os_pthread             pthread;     
  osPriority             tpriority;  
  uint32_t               instances;    
  uint32_t               stacksize;    
#if( configSUPPORT_STATIC_ALLOCATION == 1 )
  uint32_t               *buffer;      
  osStaticThreadDef_t    *controlblock;     
#endif
} osThreadDef_t;

확인해본 결과, instances 값은 사용되는 곳을 찾기 어려웠음. 프로젝트 코드에서는 참조하는 곳이 없는 것 같던데, 아마 Cortex-M4 의 FreeRTOS 에서 사용하는 기능은 아닌 것 같다.

osThreadDef 구조체에 담긴 Task를 생성하기 위한 데이터를 osThreadCreate 함수에 넘겨서 실제로 Task를 생성하고, TCB 구조체를 가리키는 포인터를 반환받게된다.

myTask02Handle = osThreadCreate(osThread(myTask02), NULL);

osThreadId osThreadCreate (const osThreadDef_t ***thread_def**, void *argument)
{
  TaskHandle_t handle;

  // Static 한 방식으로 Stack 영역과 TCB 영역을 지정해준 경우
  if((thread_def->buffer != NULL) && (thread_def->controlblock != NULL)) {
    handle = xTaskCreateStatic((TaskFunction_t)thread_def->pthread,
            (const portCHAR *)thread_def->name,
        thread_def->stacksize, 
              argument, 
              makeFreeRtosPriority(thread_def->tpriority),
        thread_def->buffer, 
              thread_def->controlblock);
  }
  // Stack 영역과 TCB 영역의 시작 주소를 지정해주지 않은 경우
  else {
    if (xTaskCreate((TaskFunction_t)thread_def->pthread,
                                  (const portCHAR *)thread_def->name,
                          thread_def->stacksize, 
                                      argument, 
                                      makeFreeRtosPriority(thread_def->tpriority),
                          &handle) != pdPASS)  {
      return NULL;
    } 
  }

  return handle;
}

여기에서 우리는 Stack과 TCB에 대한 주소를 지정해주지 않고 NULL을 전달했기 때문에 동적할당을 받는 경우인 xTaskCreate로 가게된다.

포팅 레이어에게 Task 생성 요청하기

xTaskCreate 함수는 크게 3가지 부분으로 구성된다. 이전까지는 로직적으로 (상위 애플리케이션을 개발하듯이) 그냥 Task의 생성에 대해서 다뤘다면, xTaskCreate 부터는 포팅 레이어에게 HW 적인 요청을 하기 시작한다.

  • Stack과 TCB 구조체를 담을 메모리 공간을 준비
  • TCB 초기화 + Stack 초기화
  • 스케줄러에게 실행 대기 등록
BaseType_t xTaskCreate(    TaskFunction_t pxTaskCode,
                            const char * const pcName,
                            const configSTACK_DEPTH_TYPE usStackDepth,
                            void * const pvParameters,
                            UBaseType_t uxPriority,
                            TaskHandle_t * const pxCreatedTask )
    {
        TCB_t *pxNewTCB;
        BaseType_t xReturn;

        StackType_t *pxStack;

// ======= 1. 메모리 공간 준비 ========

        // Stack의 깊이 x 4 byte의 크기만큼 메모리를 할당받는다.
        pxStack = pvPortMalloc((((size_t) usStackDepth ) * sizeof( StackType_t ) ) );

        if( pxStack != NULL )
        {
            // TCB 크기만큼 메모리를 할당받는다. 
            pxNewTCB = ( TCB_t * ) pvPortMalloc( sizeof( TCB_t ) );
            
            if( pxNewTCB != NULL )
            {
                // 둘 다 할당받는데 성공
                // TCB의 Stack 을 가리키는 곳에 Stack의 주소 담기 
                pxNewTCB->pxStack = pxStack;
            }
            else
            {
                /* The stack cannot be used as the TCB was not created.  Free it again. */
                vPortFree( pxStack );
            }
        }
        else
        {
            pxNewTCB = NULL;
        }


        if( pxNewTCB != NULL )
        {

// ======= 2. Task 관련 데이터 초기화 ========

            prvInitialiseNewTask( pxTaskCode, pcName, ( uint32_t ) usStackDepth, pvParameters, uxPriority, pxCreatedTask, pxNewTCB, NULL );

// ======= 3. 스케줄러 실행 대기 등록 ========

            prvAddNewTaskToReadyList( pxNewTCB );
            xReturn = pdPASS;
        }
        else
        {
            xReturn = errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
        }

        return xReturn;
    }

메모리 공간의 동적 할당

pvPortMalloc 과정은 내부 코드를 살펴보면 직접 메모리 공간을 관리하면서 동적 할당을 요청하면 메모리를 떼어내 제공해주고있다.

그냥 스탠다드 라이브러리를 사용하는 경우도 많지만, 임베디드 세계에서는 코드의 크기를 줄이고 정확하게 내가 아는 로직으로 동작하도록 하기 위해서 이렇게 malloc 코드도 직접 구현하는 경우가 많다. 이것도 결국에는 비용 문제라는게 너무 놀랍다… 😮😮😮

  • First-Fit 방식 : 링크드리스트 형태로 관리되는 여유 공간들 중, 탐색하며 처음으로 만나는 충분한 공간을 할당받는다.

void *pvPortMalloc( size_t xWantedSize )
{
BlockLink_t *pxBlock, *pxPreviousBlock, *pxNewBlockLink;
void *pvReturn = NULL;

    vTaskSuspendAll();
    {
        /* If this is the first call to malloc then the heap will require
        initialisation to setup the list of free blocks. */
        if( pxEnd == NULL )
        {
            prvHeapInit();
        }

      // 혹시 할당받고자 하는 사이즈가 너무 큰지 확인
        if( ( xWantedSize & xBlockAllocatedBit ) == 0 )
        {
            // 실제로 동작 할당을 위해 필요한 공간의 크기를 계산
            if( xWantedSize > 0 )
            {
                xWantedSize += xHeapStructSize;

                // ARM -> 8byte 단위 정렬 
                if( ( xWantedSize & portBYTE_ALIGNMENT_MASK ) != 0x00 )
                {
                    xWantedSize += ( portBYTE_ALIGNMENT - ( xWantedSize & portBYTE_ALIGNMENT_MASK ) );
                }
            }

            // 실제로 할당이 필요한 공간만큼 힙에 아직 여유가 있다면
            if( ( xWantedSize > 0 ) && ( xWantedSize <= xFreeBytesRemaining ) )
            {
                // 크기가 충분한 첫 번째 공간을 찾아 나선다. 
                pxPreviousBlock = &xStart;
                pxBlock = xStart.pxNextFreeBlock;
                while( ( pxBlock->xBlockSize < xWantedSize ) && ( pxBlock->pxNextFreeBlock != NULL ) )
                {
                    pxPreviousBlock = pxBlock;
                    pxBlock = pxBlock->pxNextFreeBlock;
                }

                // 만약 충분한 공간을 찾았다면 (탐색이 끝까지 진행되지 않았다면)
                if( pxBlock != pxEnd )
                {
                    // 반환할 메모리 주소를 계산한다. 
                    // 찾은 메모리의 시작 주소 + 힙 관리를 위한 구조체의 크기 => 동적할당 받는 영역의 시작 주소
                    pvReturn = ( void * ) ( ( ( uint8_t * ) pxPreviousBlock->pxNextFreeBlock ) + xHeapStructSize );

                    // 할당된 블럭은 Free List에서 제거하기
                    pxPreviousBlock->pxNextFreeBlock = pxBlock->pxNextFreeBlock;

                    // 할당하고 남은 공간이 충분히 큰 영역이라면
                    // 남은 공간을 새로운 블럭으로 만들고 Free List에 추가하기
                    if( ( pxBlock->xBlockSize - xWantedSize ) > heapMINIMUM_BLOCK_SIZE )
                    {
                        pxNewBlockLink = ( void * ) ( ( ( uint8_t * ) pxBlock ) + xWantedSize );

                        pxNewBlockLink->xBlockSize = pxBlock->xBlockSize - xWantedSize;
                        pxBlock->xBlockSize = xWantedSize;

                        prvInsertBlockIntoFreeList( pxNewBlockLink );
                    }

                    // 남은 공간에 대해서 변수들을 업데이트하여 다음 할당을 준비하기
                    xFreeBytesRemaining -= pxBlock->xBlockSize;

                    if( xFreeBytesRemaining < xMinimumEverFreeBytesRemaining )
                    {
                        xMinimumEverFreeBytesRemaining = xFreeBytesRemaining;
                    }

                    // 새로 할당한 블럭에 대해서 '할당완료!' 표시 남기기    
                    pxBlock->xBlockSize |= xBlockAllocatedBit;
                    pxBlock->pxNextFreeBlock = NULL;
                    xNumberOfSuccessfulAllocations++;
                }
            }
        }
        traceMALLOC( pvReturn, xWantedSize );
    }

    ( void ) xTaskResumeAll();

    return pvReturn;
}

Stack과 TCB 구조체를 담을 메모리 공간을 준비하고 나면, 메모리는 아래와 같은 상태가 된다. 현재 TCB와 Stack의 공간이 확보된 상태.

TCB와 Stack에 어떻게 값을 할당하는가?

그렇다면 이제 TCB와 Stack에 내용물을 채울 차례.

Task 관련 데이터를 초기화해주는 함수인 prvInitialiseNewTask 에서는 다음의 작업을 수행한다.

  1. Stack의 Top 위치를 TCB에 저장하기
  2. TCB에 Task 이름 저장하기
  3. TCB에 우선순위 저장하기
  4. TCB에 StateListItem 초기화

값 할당을 알기에 앞서, 우선 포팅레이어에서 확인한 아키텍처의 특성이다.

/* Architecture specifics. */
#define portSTACK_GROWTH            ( -1 )
#define portBYTE_ALIGNMENT            8

  • Stack은 Descending 방식으로, 높은 주소에서 낮은 주소로 자라난다.
    • FreeRTOS 문서에서는 Stack을 쌓는 방식에 대한 이야기가 없었으나, Cortex-M 에서는 Full Descending 방식을 사용한다.
    • FreeRTOS는 여러 아키텍처 위에 올라갈 수 있고 Cortex-M 은 아키텍처이므로, FreeRTOS가 Cortex-M 의 방식을 따를 것이라고 생각하였다.
  • 메모리 블럭의 시작주소는 항상 8byte의 배수이다.

함수를 한 번 살펴보자.

static void prvInitialiseNewTask(     
    TaskFunction_t pxTaskCode,
    const char * const pcName,        
    const uint32_t ulStackDepth,
    void * const pvParameters,
    UBaseType_t uxPriority,
    TaskHandle_t * const pxCreatedTask,
    TCB_t *pxNewTCB,
    const MemoryRegion_t * const xRegions ) // xRegions는 Memory Protection Unit (MPU) 를 사용할 때 적용하는 변수
{
StackType_t *pxTopOfStack;
UBaseType_t x;

// =================== TCB 초기화 ===================

    // Stack이 높은 곳에서 낮은 곳으로 자라는 Decreasing 환경
    #if( portSTACK_GROWTH < 0 )
    {
        // Stack을 안전한 범위에서 사용하기 위해서 Stack의 가장 윗 주소에서 한 칸 내려오기
        pxTopOfStack = &( pxNewTCB->pxStack[ ulStackDepth - ( uint32_t ) 1 ] );
        // 혹시 8byte 로 정렬이 안되어있을 경우를 대비해 8byte로 절삭하기
        pxTopOfStack = ( StackType_t * ) ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) ); 
    }
    #else /* portSTACK_GROWTH */

    // TCB에 Task의 이름을 저장
    // 이름 규칙을 벗어나는 경우, 보정해주기
    if( pcName != NULL )
    {
        for( x = ( UBaseType_t ) 0; x < ( UBaseType_t ) configMAX_TASK_NAME_LEN; x++ )
        {
            pxNewTCB->pcTaskName[ x ] = pcName[ x ];

            if( pcName[ x ] == ( char ) 0x00 )
            {
                break;
            }
        }

        pxNewTCB->pcTaskName[ configMAX_TASK_NAME_LEN - 1 ] = '\0';
    }
    else
    {
        pxNewTCB->pcTaskName[ 0 ] = 0x00;
    }

  // 우선순위가 지정된 범위를 벗어나는 경우, 안전한 범위 내로 옮겨주기
    if( uxPriority >= ( UBaseType_t ) configMAX_PRIORITIES )
    {
        // eventList를 위함
        uxPriority = ( UBaseType_t ) configMAX_PRIORITIES - ( UBaseType_t ) 1U;
    }

    pxNewTCB->uxPriority = uxPriority;
    #if ( configUSE_MUTEXES == 1 )
    {
        pxNewTCB->uxBasePriority = uxPriority;
        pxNewTCB->uxMutexesHeld = 0;
    }
    #endif /* configUSE_MUTEXES */

  // Event, State List의 초기화
    vListInitialiseItem( &( pxNewTCB->xStateListItem ) );
    vListInitialiseItem( &( pxNewTCB->xEventListItem ) );

    /* Set the pxNewTCB as a link back from the ListItem_t.  This is so we can get back to    the containing TCB from a generic item in a list. */
    listSET_LIST_ITEM_OWNER( &( pxNewTCB->xStateListItem ), pxNewTCB );

    /* Event lists are always in priority order. */
    listSET_LIST_ITEM_VALUE( &( pxNewTCB->xEventListItem ), ( TickType_t ) configMAX_PRIORITIES - ( TickType_t ) uxPriority ); 
    listSET_LIST_ITEM_OWNER( &( pxNewTCB->xEventListItem ), pxNewTCB );

// =================== Stack 초기화 ===================
    pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );

// 성공적으로 Task를 잘 만들었다면 TCB를 가리키는 포인터인 TaskHandle을 전달
    if( pxCreatedTask != NULL )
    {
        /* Pass the handle out in an anonymous way.  The handle can be used to
        change the created task's priority, delete the created task, etc.*/
        *pxCreatedTask = ( TaskHandle_t ) pxNewTCB;
    }
}

Stack의 Top 위치인 pxTopOfStack 은 2가지를 고려한다.

  • 지정한 위치에서 Read 를 수행하면 메모리 영역을 벗어나 접근을 요청할 수 있기 때문에 안전하게 범위 - 1 을 사용
  • 8byte alignment 를 지키기 위해 8byte 주소에 맞춰서 세팅

위 그림에서 왼쪽은 TopOfStack에서 1을 빼주지 않은 경우. 이때 누가 이 위치에서 Read 를 해버리면 메모리 영역을 침범하여 접근할 수 있기 때문에 위험하다. 미리 한 칸 아래로 이동해서 이 문제를 방지할 수 있다. 또, TopOfStack이 기본 규칙인 주소의 8byte 정렬 규칙을 지키도록 하기 위해서 아래 3개의 bit를 잘라낸다.

// Stack을 안전한 범위에서 사용하기 위해서 Stack의 가장 윗 주소에서 한 칸 내려오기
pxTopOfStack = &( pxNewTCB->pxStack[ ulStackDepth - ( uint32_t ) 1 ] );
// 혹시 8byte 로 정렬이 안되어있을 경우를 대비해 8byte로 절삭하기
pxTopOfStack = ( StackType_t * ) ((( portPOINTER_SIZE_TYPE ) pxTopOfStack ) 
                                                             & (~(( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK)));

그러면 이제 TCB 까지 초기화 완료! (아직 TopOfStack은 TCB에 담아주지 않았다. 더 적용할 게 남아있다)

 

이제 prvInitialiseNewTask 함수의 아래쪽에 남아있는 Stack을 초기화하는 코드도 살펴보자.

Stack 의 내용 구성하기

pxPortInitialiseStack 함수를 통해서 Stack의 내용을 넣어주고 있다.

/* Constants required to set up the initial stack. */
#define portINITIAL_XPSR                        ( 0x01000000 )
#define portINITIAL_EXC_RETURN                ( 0xfffffffd )

/* For strict compliance with the Cortex-M spec the task start address should
have bit-0 clear, as it is loaded into the PC on exit from an ISR. */
#define portSTART_ADDRESS_MASK        ( ( StackType_t ) 0xfffffffeUL )

#define portTASK_RETURN_ADDRESS    prvTaskExitError

잠깐 매크로 상수들을 살펴보자면

  • portINITIAL_XPSR의 값은 0x0100_000, EPSR의 T bit (Thumb 모드) 만 1로 SET 되어있는 형태이다.

  • portINITIAL_EXC_RETURN 은 Kernel이 이 Task를 처음 실행하더라도 “내가 실행하다가 중간에 인터럽트가 걸려서 다른 일을 하러 갔었나?” 라고 판단하고 작업을 재개하도록 만든다.
  • portSTART_ADDRESS_MASK 는 Thumb 모드로 실행되도록 요청하기 위해서 명령어 주소의 0번 bit를 1로 만들 때, 이 모드에 대한 정보를 없애고 명령어 주소에 대한 정보만 남기기 위해 사용
  • portTASK_RETURN_ADDRESS는 return 하면 안되는 Task가 return 되었을 때 돌아갈 명령어 주소를 가리킨다.

StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
    /* Simulate the stack frame as it would be created by a context switch
    interrupt. */

    /* Offset added to account for the way the MCU uses the stack on entry/exit
    of interrupts, and to ensure alignment. */
    pxTopOfStack--;

    *pxTopOfStack = portINITIAL_XPSR;    /* xPSR */
    pxTopOfStack--;
    *pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;    /* PC */
    pxTopOfStack--;
    *pxTopOfStack = ( StackType_t ) portTASK_RETURN_ADDRESS;    /* LR */

    /* Save code space by skipping register initialisation. */
    pxTopOfStack -= 5;    /* R12, R3, R2 and R1. */
    *pxTopOfStack = ( StackType_t ) pvParameters;    /* R0 */

    /* A save method is being used that requires each task to maintain its
    own exec return value. */
    pxTopOfStack--;
    *pxTopOfStack = portINITIAL_EXC_RETURN;

    pxTopOfStack -= 8;    /* R11, R10, R9, R8, R7, R6, R5 and R4. */

    return pxTopOfStack;
}

 

위 코드에서는 다양한 값들을 Stack에 넣어주고 있다. 사실 여기에서 값 자체가 크게 의미가 있지는 않고, “이전에 실행하다가 그만둔 상태처럼 만드는 것”이 목표이다. OS 커널 입장에서 이 Task가 처음 실행되는 것인지 아닌지는 크게 중요하지 않다. 오히려 처음 실행되는 경우에 대한 로직을 따로 작성해야한다면 복잡도가 증가하게 될 것이다. 그래서 Task의 첫 시작은 커널의 입장에서는 “실행 재개”처럼 되도록 해야한다. 이를 위해서 Stack에 이전에 실행하던 것처럼 레지스터를 불러올 수 있도록 자리를 잡아줘야한다.

  1. 상태 레지스터 xPSR → Thumb 모드로 실행중임 표시
  2. PC → 내가 실행해야하는 Task 코드의 첫 줄 (함수 시작) 가리키기
  3. LR → 혹시 이 상태에서 return 해버리면 안되니깐 return 시 에러 함수로 가도록 설정함
  4. 레지스터 값들은 쓰레기 값이지만 자리만 잡아두기
  5. R0 위치에는 argument 전달
  6. 그 다음, R14 자리에 Exception 으로 인해 Context-Switch가 발생했던 것마냥 EXC_RETURN 을 두기.
  7. 남은 레지스터들도 자리만 잡아두기

위 함수를 거쳐서 TCB에 pxTopOfStack 값을 넣어준다면 아래와 같이 Stack의 값들이 초기화되게 된다.

 

생성한 Task를 스케줄러에 등록

처음에 Task를 생성하는 코드의 3번째 순서가 스케줄러에 등록이였다. 드디어 스케줄러에게 현재 생성한 Task를 등록하는 과정이다.

prvAddNewTaskToReadyList( pxNewTCB );

이 코드에서는 Critical Section으로 작업을 보호하면서

  • Task의 개수를 추가하고
  • 우선순위에 따라 실행 대기로 등록한다.
static void prvAddNewTaskToReadyList( TCB_t *pxNewTCB )
{
    taskENTER_CRITICAL();         // (1) 임계 구역 보호
    {
        uxCurrentNumberOfTasks++;   // (2) 전체 Task 개수 증가

        if (pxCurrentTCB == NULL)   // (3) 첫 번째 Task 생성 시
        {
            pxCurrentTCB = pxNewTCB;     // 현재 실행 Task로 설정
            if (uxCurrentNumberOfTasks == 1)
                prvInitialiseTaskLists(); // Task 리스트 초기화
        }
        else if (xSchedulerRunning == pdFALSE) // (4) 스케줄러 시작 전
        {
            if (pxCurrentTCB->uxPriority <= pxNewTCB->uxPriority)
                pxCurrentTCB = pxNewTCB; // 우선순위 더 높으면 교체
        }

        uxTaskNumber++;                 // (5) Task 고유 번호 부여
        traceTASK_CREATE(pxNewTCB);     // (6) Trace용 (선택적)
        prvAddTaskToReadyList(pxNewTCB);// (7) Ready 리스트에 추가
        portSETUP_TCB(pxNewTCB);        // (8) 포트별 초기화 (옵션)
    }
    taskEXIT_CRITICAL();

    if (xSchedulerRunning != pdFALSE)   // (9) 스케줄러 실행 중이면
    {
        // 우선순위가 높은 Task일 때
        if (pxCurrentTCB->uxPriority < pxNewTCB->uxPriority)
            taskYIELD_IF_USING_PREEMPTION(); // 즉시 컨텍스트 스위칭
    }
}
  • 새 태스크를 시스템에 등록 → Ready 리스트에 추가 → 필요 시 즉시 스위칭
    • 임계 구역 진입 → 다른 태스크나 인터럽트가 중간에 끼어들지 못하게 보호.
    • taskENTER_CRITICAL()
    • 전체 태스크 수 증가 uxCurrentNumberOfTasks++
    • 첫 번째 태스크인지 확인
      • pxCurrentTCB == NULL이면 → 현재 실행 태스크로 등록
      • 첫 태스크라면 리스트 초기화(prvInitialiseTaskLists())
    • 스케줄러가 아직 안 돌고 있을 때
      • 새 태스크의 우선순위가 더 높다면 → 현재 태스크 교체
    • Ready 리스트에 추가 → 이 태스크를 실제로 “실행 준비 상태”에 올림
    • prvAddTaskToReadyList(pxNewTCB)
    • 임계 구역 종료
    • 스케줄러 실행 중일 경우
      • 새 태스크의 우선순위가 더 높다면 → 즉시 taskYIELD()로 컨텍스트 스위칭

prvAddTaskToReadyList 코드는 이렇게 생겼다. 다 매크로로 작성되어 있는데, 결국 하는 일은 이거다.

  • Task를 Ready List에 넣기
  • 스케줄러가 활용할 수 있도록 현재 대기중인 가장 높은 우선순위값 갱신
/*
 * Place the task represented by pxTCB into the appropriate ready list for
 * the task.  It is inserted at the end of the list.
 */
#define prvAddTaskToReadyList( pxTCB )                                                                \
    traceMOVED_TASK_TO_READY_STATE( pxTCB );                                                        \
    taskRECORD_READY_PRIORITY( ( pxTCB )->uxPriority );                                                \
    vListInsertEnd( &( pxReadyTasksLists[ ( pxTCB )->uxPriority ] ), &( ( pxTCB )->xStateListItem ) ); \
    tracePOST_MOVED_TASK_TO_READY_STATE( pxTCB )

추가로 궁금한 것들)

Stack과 TCB를 Static 하게 할당하는 경우에는 어떻게 될까?

여기에 xTaskCreateStatic 함수는 TCB와 Stack의 위치를 정적으로 잡아주는 경우에 해당 위치에 TCB와 Stack을 초기화한다. 그럼 이 공간은 어떻게 잡히는걸까?

사실 처음에는 이 코드를 보면서 “뭔가 내가 임의로 메모리 공간을 지정해주면 거기를 사용하는구나!” 라고 생각했다. 그런데 이게 너무나도 위험한 방식 아닌가? OS의 메모리 관리와 별개로 내가 코드로 직접 메모리 주소를 넣고 공간을 잡고 사용한다면 OS의 메모리 사용과 충돌할 가능성이 너무나도 높을 것 같았다. 이 의문이 들었을 때에는 “아, 내가 주소를 잡으면 거기를 사용하는구나!” 라고 생각했는데, Static으로 생성하는 Task는 어디에 저장되어야하냐? 는 질문을 받으니 말문이 턱 막혔다. 이게 내가 모르는 부분이구나 싶어서 얼른 찾아봤다.

우선 static 이라는 이름이 있는 것처럼, 이 영역은 내가 혼자서 판단해서 공간을 잡아주는게 아니라 그냥 static 키워드를 붙여서 잡아주면 되는 영역이였다. 딱히 지금 초기값이 의미가 있지 않으니 그냥 전역변수처럼 static 키워드를 붙여 만들어주면 링킹 과정에서 BSS 영역에 이 변수의 주소를 할당해주게 된다.

// 1) TCB (Task Control Block) 영역
static StaticTask_t myTask02_ControlBlock;

// 2) Stack 영역
#define TASK02_STACK_SIZE  128  // Word 단위 (128 * 4 = 512 bytes)
static StackType_t myTask02_StackBuffer[TASK02_STACK_SIZE];

그러면 아래처럼 인자를 전달해줄 때 Stack과 TCB를 담을 공간이 이미 준비가 된 상태이니, 내부에서는 동적할당과정이 빠진 xTaskCreateStatic 이 호출될 것이다.

  osThreadDef_t myTask02_def = {
    .name = "myTask02",
    .pthread = myTask02,
    .tpriority = osPriorityNormal,
    .instances = 0,
    .stacksize = 128,
    .buffer = myTask02_StackBuffer,
    .controlblock = &myTask02_ControlBlock
  };

  myTask02Handle = osThreadCreate(&myTask02_def, NULL);

이걸 사용하는 목적은 프로그램 동작 시 항상 사용할 Task에 대한 공간을 미리 정적으로 위치를 잡아두는 것이다. WHY? 정적으로 잡을 필요가 있을까? 이건 실행 환경과 관계없이 항상 메모리 공간을 확정적으로 할당받은 채 유지할 수 있게 해주는 일종의 장치이다. 즉, 처음부터 끝까지 실행되는 Task에 대해서는 미리 BSS영역에 TCB와 Stack의 공간을 잡아둘 수 있다. 반면에, 만약 Task가 실행되다가 제거되거나 동적으로 생성되어야 하는 Task라면 동적으로 공간을 Malloc 받아 사용하는 방식을 채택하는게 더 효율적이다.

 

EXC_RETURN 이 특정한 값이 아니라 종류였다

Stack을 초기화하는 과정에서 들어가는 R14 (LR) 레지스터에 담기는 EXC_RETURN 이라는 게 나는 특정한 값을 부르는 이름이라고 생각했었다. 그런데 이게 특정한 값이 아니라 어떤 역할을 하는 값들의 종류를 부르는 이름인 것을 Task에 대해 발표하는 도중에 알게되었다.

(진짜 몰랐음)
처음에는 질문 받았을 때 ‘무슨 소리신지…?’ 라고 생각했었는데, 오히려 강사님께서 ‘무슨 소리신지…?’ 라고 생각하고 있었나보다 ㅋㅋ

EXC_RETURN 의 값은 아래처럼 여러 종류를 가지고 있다. 이중에서 Task를 생성할 때 Stack에 넣어주는 값은 0xFFFFFFFD 이다. 이 값은 다음의 의미를 가진다.

  • 이제 Exception에 대한 처리가 완료되어서, 레지스터들을 자동으로 복구하면 된다.
  • Handler 모드에서 Thread 모드로 넘어간다.

요 값이 Stack에 담긴 이후에 어떤 역할을 담당하는지는 이후에 Context-Switch에서 한 번 더 다룰 예정!

320x100