[임베디드 OS 개발 프로젝트] 10. 컨텍스트 스위칭

Sijin·2025년 7월 16일

컨텍스트 스위칭

  • 우리는 태스크의 컨텍스트를 태스크의 스택에 저장하고있다
  • 따라서 컨텍스트 스위칭을 다음 과정으로 진행된다
    1. 현재 동작하고 있는 태스크의 컨텍스트를 스택에 백업
    2. 다음 동작할 태스크의 컨텍스트 블록을 스케줄러에서 받음
    3. 2에서 가져온 태스크 컨텍스트 블록에서 스택 포인터를 읽음
    4. 3에서 읽은 태스크의 스택에서 컨텍스트를 읽어서 arm 코어에 복구
    5. 다음 동작할 태스크의 직전 프로그램 실행 위치로 이동
  • 이를 코드로 작성하면 아래와 같다
static KernelTcb_t* sCurrent_tcb;
static KernelTcb_t* sNext_tcb;

void Kernel_task_scheduler(void)
{
    sCurrent_tcb = &sTask_list[sCurrent_tcb_index];
    sNext_tcb = Scheduler_round_robin_algorithm();

    Kernel_task_context_switching();
}
  • 컨텍스트 스위칭 코드는 우선 아래와 같이 껍데기만 작성하자
__attribute__((naked)) void Kernel_task_context_switching(void)
{
    __asm__("B Save_context");
    __asm__("B Restore_context");
}
  • __attribute__ ((naked))라는 atrtribute를 추가했다
    • 컴파일러가 함수를 컴파일할 때 자동으로 만드는 스택 백업, 복구, 리턴 관련 어셈블리인 prologue/epilogue 코드를 생성하지 않는 gcc 컴파일러 어트리뷰터 기능이다
    • 관련 어셈블리 없이 내부에 코딩한 코드 자체만 그대로 남는다
  • 현재는 Save_context, Restore_context가 없어 빌드오류가 난다, 이부분은 나중에 어셈블리로 작성할 것이다

컨텍스트 백업

  • 컨텍스트는 현재 동작중인 태스크의 스택에 직접 백업할 것이다
typedef struct KernelTaskContext_t
{
    uint32_t spsr;
    uint32_t r0_r12[13];
    uint32_t pc;
} KernelTaskContext_t;
  • C언어에서 구조체의 멤버변수는 메모리 주소 작은 값부터 큰 값 순서로 배정된다
  • 스택은 메모리가 큰 값에서 작은 값으로 진행한다
  • 따라서, 스택에는 pc, r0_r12, spsr 순서로 저장해야 의도한대로 메모리 주소에 값이 저장된다
static __attribute__((naked)) void Save_context(void)
{
    // save current task context into current task stack
    __asm__("PUSH {lr}");
    __asm__("PUSH {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12}");
    __asm__("MRS r0, cpsr");
    __asm__("PUSH {r0}");
    // save current save stack pointer into current task context block
    __asm__("LDR r0, =sCurrent_tcb");
    __asm__("LDR r0, [r0]");
    __asm__("STMIA r0!, {sp}");
}
  • 이 순서로 Save_context 함수를 구현해보면 위와 같다
    1. 처음 나중에 태스크가 다시 실행했을 때 복귀할 주소인 link register, 즉 lr 레지스터를 pc 멤버변수에 저장한다
    2. 범용 레지스터 r0~r12를 저장하고
    3. cpsr을 spsr에 저장하는데, 프로그램 상태 레지스터는 바로 메모리에 저장할 수 없으므로 r0를 사용한다
      • r0는 이미 위에서 백업했으니 덮어써도 상관 없다
    4. 현재 동작중인 task context block의 포인터 변수를 읽어 r0에 저장하고
    5. r0 주소에 담긴 값을 읽는다
      • task context block의 첫번째 멤버변수인 sp의 값을 읽게 된다
    6. sCurrent_tcb->sp의 위치에 arm 코어의 sp 레지스터 값을 저장한다
      • 여기서의 간단한 동작을 위해 KernelTcb_t의 첫번째 멤버변수를 sp로 지정하였다

컨텍스트 복구

static __attribute__((naked)) void Restore_context(void)
{
    // save current save stack pointer into current task context block
    __asm__("LDR r0, =sNext_tcb");
    __asm__("LDR r0, [r0]");
    __asm__("LDMIA r0!, {sp}");
    // save current task context into current task stack
    __asm__("POP {r0}");
    __asm__("MRS cpsr, r0");
    __asm__("POP {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12}");
    __asm__("POP {pc}");
}
  • 컨텍스트를 복구시킬 Restore_context 함수는 Save_context의 역순이다
    1. sNext_tcb에서 스택 포인터 값을 읽어온다
    2. 스택에 저장되어 있는 cpsr의 값을 꺼내 arm 코어의 cpsr 레지스터에 값을 쓴다
    3. r0~r12까지의 범용 레지스터도 복구한다
      • 이후로는 r0~r12 값을 변경하면 context가 깨진다
    4. 스택에 마지막으로 남은 lr값을 꺼내 pc에 저장해 태스크 코드로 점프시킨다

yield 만들기

  • 선점형/비선점형 멀티태스킹
    • 태스크가 스케줄링을 요청하지 않았는데, 커널이 강제로 스케줄링 하는 경우 선점형 멀티태스킹 시스템이라고 한다
    • 반대로, 태스크가 스케줄링을 요청해야 스케줄링 하는 시스템을 비선점형 멀티태스킹 시스템이라고 한다
  • 시분할 시스템
    • 정기적으로 발생하는 타이머 인터럽트에 연동해 태스크를 전환하도록 스케줄링을 구성
    • 일반적으로 시분할 시스템은 선점형 멀티태스킹 시스템이다
  • yield 함수
    • 우리는 시분할이 아닌 시스템에 비선점형 스케줄링을 사용할 것이다
    • 즉, 태스크가 명시적으로 커널에 스케줄링을 요청해야 한다
    • 이러한 동작을 하는 함수의 이름을 일반적으로 yield를 많이 쓴다
    • yield를 커널 API로 만들어 외부에서 사용해보자
# kernel/Kernel.c

void Kernel_yield(void)
{
    Kernel_task_scheduler();
}
  • 함수는 매우 간단하게 호출되면 바로 스케줄러, 컨텍스트 스위칭을 호출 할 것이다

커널 시작하기

  • 커널을 시작하기 위해서는 스케줄러를 실행시키면 된다
  • 그러나 우리의 스케줄러는 현재 동작 중인 태스크가 있음을 가정하고 있고, 현재는 커널이 시작하는 시점에 동작하는 태스크가 없다
  • 최초에 동작하는 태스크는 컨텍스트 백업은 생략하고 컨텍스트 복구만 시키면 된다
  • 최초 스케줄링을 처리하는 코드를 작성해보자
# kernel/task.c

void Kernel_task_init(void)
{
    sAllocated_tcb_index = 0;
    sCurrent_tcb_index = 0;

    for (uint32_t i = 0; i < MAX_TASK_NUM; i++)
    {
        sTask_list[i].stack_base = (uint8_t*)(TASK_STACK_START + i * USR_TASK_STACK_SIZE);
        sTask_list[i].sp = (uint32_t)(sTask_list[i].stack_base) + USR_TASK_STACK_SIZE - 4;
        sTask_list[i].sp -= sizeof(KernelTaskContext_t);
        KernelTaskContext_t* ctx = (KernelTaskContext_t*)sTask_list[i].sp;
        ctx->pc = 0;
        ctx->spsr = ARM_MODE_BIT_SYS;
    }
}

void Kernel_task_start(void)
{
    sNext_tcb = &sTask_list[sCurrent_tcb_index];
    Restore_context();
}
  • init 부분에서 current tcb index를 0으로 설정해주었다
  • 커널 시작을 위해 최초 한 번만 호출하는 Kernel_task_start 함수를 추가했다
    • 0으로 설정될 sCurrent_tcb_index번째 tcb를 context 복구시켜 arm 코어에 쓴다
  • 커널 초기화 함수를 모아둘 Kernel_start 함수를 만들고 태스크 시작 함수를 넣자
# kernel/Kernel.c

void Kernel_start(void)
{
    Kernel_task_start();
}
  • 이후 Kernel_start는 Main의 Kernel_init 함수에서 호출시키자
# boot/Main.c

void dummy_task(void)
{
    debug_printf("dummy task\n");
    while (true);
}

static void Kernel_init(void)
{
    uint32_t taskId;
    Kernel_task_init();

    taskId = Kernel_task_create(dummy_task);
    if (taskId == NOT_ENOUGH_TASK_NUM)
    {
        debug_printf("dummy task creation failed\n");
    }

    Kernel_start();
}
  • 스케줄링을 위한 준비가 다 되었으니, task를 만들어서 실제로 동작하는지 확인해보자
void task_1(void)
{
    debug_printf("task_1\n");

    uint32_t local = 0;
    while (true)
    {
        debug_printf("task_1 SP=0x%x\n", &local);
        Kernel_yield();
    }
}

void task_2(void)
{
    debug_printf("task_2\n");

    uint32_t local = 0;
    while (true)
    {
        debug_printf("task_2 SP=0x%x\n", &local);
        Kernel_yield();
    }
}

static void Kernel_init(void)
{
    uint32_t taskId;
    Kernel_task_init();

    if (Kernel_task_create(task_1) == NOT_ENOUGH_TASK_NUM)
    {
        debug_printf("dummy task creation failed\n");
    }

    if (Kernel_task_create(task_2) == NOT_ENOUGH_TASK_NUM)
    {
        debug_printf("dummy task creation failed\n");
    }

    Kernel_start();
}
  • 스케줄링이 정상 동작해서 task_1의 SP는 0x8FFFF0, task_2의 SP는 0x9FFFF0으로 반복적으로 나오는 것을 확인할 수 있다
  • 이 스택 포인터 주소가 제대로 할당된 것인지 확인해보자
# include/MemoryMap.h

...
#define TASK_STACK_START        0x00800000      // 태스크 스택 영역
...
  • task stack start 값이 0x00800000이다
  • 스택은 큰 주소에서 작은 주소로 진행하므로, 스택 포인터에는 스택 공간의 가장 큰 주소를 할당한다
  • 우리는 각 태스크 스택 사이에 4byte의 간격을 패딩으로 두었고, 컴파일러가 사용하는 몇개 스택 이후 로컬 변수가 잡히므로 주소는 0x8FFFF0으로 출력된 것으로 볼 수 있다
  • task1, task2 모두 동일한 방식으로 SP에 값이 들어간 것을 알 수 있다

0개의 댓글