임베디드 OS 개발 프로젝트를 읽고 작성하였습니다.
시작하기 전에
컨텍스트 스위칭 : 컨텍스트를 전환(switching)한다는 것
태스크 : 동작하는 프로그램
컨텍스트 : 동작하는 프로그램의 정보컨텍스트를 어딘가에 저장하고 또 다른 어딘가에서 컨텍스트를 가져다가 프로세서 코어에 복구하면 다른 프로그램이 동작함=> 태스크가 바뀐 것!
10장 컨텍스트 스위칭
나빌로스의 태스크 컨텍스트를 태스크의 스택에 저장
나빌로스의 컨텍스트 스위칭
1. 현재 동작하고 있는 태스크의 컨텍스트를 현재 스택에 백업
2. 다음에 동작할 태스크 컨트롤 블록을 스케줄러에서 받기
3. 2에서 받은 태스크 컨트롤 블록에서 스택 포인터 읽기
4. 3에서 읽은 태스크의 스택에서 컨텍스트를 읽어서 ARM 코어에 복구
5. 다음에 동작할 태스크의 직전 프로그램 실행 위치로 이동
* 편의상 태스크 컨트롤 블록은 TCB로 표기함
kernel/task.c
위 절차를 코드로 옮김
< 스케줄러 함수 >
static KernelTcb_t* sCurrent_tcb; //현재 동작 중인 TCB의 포인터
static KernelTcb_t* sNext_tcb; //라운드 로빈 알고리즘이 선택한 다음에 동작할 TCB 포인터
...
중략
...
void Kernel_task_scheduler(void)
{
sCurrent_tcb = &sTask_list[sCurrent_tcb_index];
sNext_tcb = Scheduler_round_robin_algorithm();
Kernel_task_context_switching(); //컨텍스트 스위칭 함수 호출
}
코드 10.1 스케줄러 함수 task.c
< 컨텍스트 스위칭 함수 >
__attribute__ ((naked)) void Kernel_task_context_switching(void)
{
__asm__ ("B Save_context");
__asm__ ("B Restore_context");
}
코드 10.2 컨텍스트 스위칭 함수
__attribute__ ((naked))
: GCC의 컴파일러 어트리뷰트 기능
어트리뷰트를 naked로 설정 -> 컴파일러가 함수를 컴파일할 때 자동으로 만드는 스택 백업, 복구, 리턴 관련 어셈블리어가 전혀 생성되지 않고 내부에 코딩한 코드 자체만 그대로 남음
인라인 어셈블리로 코딩한 두 줄이 그대로 컴파일됨
c언어 코드 파일에서 코딩한 내용의 앞뒤로 스택을 확보하는 코드와 리턴하는 코드가 추가됨
Save_context와 Restore_context를 호출할 때 ARM 인스트릭션 B를 사용한 것 역시 LR을 변경하지 않기 위함
나빌로스의 컨텍스트 스위칭 과정
전제 : Task#1이 현재 동작 중인 태스크, Task#2가 다음에 동작할 태스크
1. Task#1의 현재 스택 포인터에 그대로 현재 컨텍스트 전체를 모두 백업.
2. 스택 포인터를 TCB에 저장. (커널이 스택 포인터의 위치를 쉽게 가져올 수 있어야 스택해서 컨텍스트를 복구할 수 있기 때문)
3. Task#2의 TCB에서 스택 포인터 값을 읽고 범용 레지스터 SP에 그 값을 씀.
4. 그러면 ARM 코어에서는 스택 포인터가 바로 바뀌고 그 상태에서 스택 관련 어셈블리 명령을 사용해서 컨텍스트를 복구.
5. 컨텍스트를 복구하면서 자연스럽게 스택 포인터를 Task#2가 컨텍스트 스위칭을 하기 직전의 정상적인 스택 포인터 위치로 복구.
10.1 컨텍스트 백업하기
컨텍스트는 현재 동작 중인 태스크의 스택에 직접 백업함
-> 앞서 정의한 컨텍스트 자료 구조에 따라 스택 명령어의 순서를 맞춰야 함
컨텍스트 자료 구조
typedef struct KernelTaskContext_t
{
uint32_t spsr;
uint32_t r0_r12[13];
uint32_t pc;
}KernelTaskContext_t;
=> spsr, r0_r12, pc 순서
C 언어에서 구조체의 멤버 변수는 메모리 주소가 작은 값부터 큰 값으로 배정됨
하지만 스택은 메모리 주소가 큰 값에서 작은 값으로 진행됨
KernelTaskContext_t에 맞춰 컨텍스트를 스택에 백업할 때 pc, r0_r12, spsr 순서로 백업해야 의도한 자료 구조 의미에 맞는 메모리 주소에 값이 저장됨
static __attribute__ ((naked)) void Save_context(void)
{
// save current task context into the 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 task stack pointer into the current TCB
__asm__ ("LDR r0, =sCurrent_tcb");
__asm__ ("LDR r0, [r0]");
__asm__ ("STMIA r0!, {sp}");
}
코드 10.3 컨텍스트 백업 함수
코드 10.3 설명은 더보기 클릭!
save current task context into the current task stack | |
PUSH {lr} | LR을 스택에 푸시 (LR은 KernelTaskContext_t의 pc 멤버 변수에 저장됨) * 나중에 태스크가 다시 스케줄링을 받았을 때 복귀하는 위치는 pc 멤버 변수가 저장하고 있고, 이 위치는 Kernel_task_context_swithching() 함수의 리턴 주소 |
PUSH {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12} | 범용 레지스터인 R0부터 R12까지를 스택에 푸시 * Kernel_task_context_swithching() 함수를 호출하기 직전의 값이 계속 유지됨 |
MRS r0, cpsr | CPSR을 KernelTaskContext_t의 spsr 멤버 변수 위치에 저장 R0를 사용하는 이유? -> 프로그램 상태 레지스터는 직접 메모리에 저장할 수 없어서 |
PUSH {r0} | |
save current task stack pointer into the current TCB |
|
LDR r0, =sCurrent_tcb | 현재 동작 중인 TCB의 포인터 변수 읽기 |
LDR r0, [r0] | 포인터에 지정된 값(=주솟값) 읽기 => r0는 TCB의 온전한 메모리 위치를 읽음 |
STMIA r0!, {sp} | r0를 베이스 메모리 주소로 해서 SP를 저장 |
9~11번째 줄을 C 언어로 표현한 것
sCurrent_tcb->sp = ARM_코어_SP_레지스터값;
or
(uint32_t)(*sCurrent_tcb) = ARM_코어_SP_레지스터값;
10.2 컨텍스트 복구하기
컨텍스트를 복구하는 작업은 컨텍스트를 백업하는 작업의 역순
정확하게 반대로 동작하는 코드임!
static __attribute__ ((naked)) void Restore_context(void)
{
// restore next task stack pointer from the next TCB
__asm__ ("LDR r0, =sNext_tcb");
__asm__ ("LDR r0, [r0]");
__asm__ ("LDMIA r0!, {sp}");
// restore next task context from the next task stack
__asm__ ("POP {r0}");
__asm__ ("MSR cpsr, r0");
__asm__ ("POP {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12}");
__asm__ ("POP {pc}");
}
코드 10.4 컨텍스트 복구 코드
코드 10.4 설명은 더보기 클릭!
restore next task stack pointer from the next TCB | |
LDR r0, =sNext_tcb | TCB의 sNext_tcb에서 스택 포인터 값 읽고 ARM 코어의 SP에 값 쓰기 |
LDR r0, [r0] | |
LDMIA r0!, {sp} | |
restore next task context from the next task stack | |
POP {r0} | 스택에 저장되어 있는 cpsr의 값을 꺼내서 ARM 코어의 cpsr에 값 쓰기 |
MSR cpsr, r0 | |
POP {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12} | 범용 레지스터인 R0부터 R12까지를 복구 |
POP {pc} | 스택 값을 꺼내 PC에 저장하면서 태스트 코드로 점프 실행되는 순간 ARM 코어는 컨텍스트가 백업되기 직전의 코드 위치로 PC를 옮기고 실행을 이어서 함 태스크 입장에서는 누락된 코드 없이 그대로 이어서 프로그램이 계속 실행되는 것 |
10.3 yield 만들기
스케줄링 (scheduling)
: 스케줄러 + 컨텍스트 스위칭
1. 시분할 시스템
정기적으로 발생하는 타이머 인터럽트에 연동해서 스케줄링을 하고 각 테스크가 일정한 시간만 동작하고 다음 테스크로 전환되는 시스템
각 테스크가 시간을 분할해서 사용한다는 의미
일반적으로 시분할 시스템은 거의 선점형 멀티태스킹 시스템임 ( 테스크에 할당한 시간이 만료되면 커널이 강제로 스케줄링하는 방식이 직관적이고 복잡하지 않기 때문)
2. 선점형 멀티태스킹 시스템
테스크가 명시적으로 스케줄링을 요청하지 않았는데 커널이 강제로 스케줄링 하는 시스템
3. 비선점형 멀티태스킹 시스템
테스크가 명시적으로 스케줄링을 요청하지 않으면 커널이 스케줄링하지 않는 시스템
💡 어떤 시스템인지는 RTOS가 동작할 임베디드 시스템의 요구사항에 따라 달라짐
나빌로스 프로젝트에서는 시분할이 아닌 시스템에서 비선점형 스케줄링을 사용
-> 스케줄링하려면 테스크가 명시적으로 커널에 스케줄링을 요청해야 함
-> 태스크가 커널에 스케줄링을 요청하는 동작은 테스크가 CPU 자원을 다음 테스크에 양보한다는 의미로 해석할 수 있음
-> 이런 동작을 하는 함수의 이름은 yield
-> 커널 API를 별도로 만들어서 외부에서 사용하도록 할 예정
kernel/Kernel.h
함수 프로토타입 정의
#ifndef KERNEL_KERNEL_H_
#define KERNEL_KERNEL_H_
#include "task.h"
void Kernel_yield(void);
#endif /* KERNEL_KERNEL_H_ */
코드 10.5 yield 커널 API 정의 Kernel.h
호출만 하면 알아서 동작하도록 만들 것이기 때문에 별다른 파라미터나 리턴 타입은 없음!
kernel/Kernel.c
#include "stdint.h"
#include "stdbool.h"
#include "Kernel.h"
void Kernel_yield(void)
{
Kernel_task_scheduler();
}
- 테스크가 더 이상 할 일이 없을 때 Kernel_yield() 함수를 호출하면 즉시 스케줄러를 호출해서 다음에 동작할 테스크를 선정
- 컨텍스트 스위칭 수행
1. Kernel_yield()를 호출한 테스크의 컨텍스트를 스택에 백업
2. 스케줄러가 선정해 준 테스크의 스택 포인터 복구
3. 스택 포인터로부터 컨텍스트 복구
4. 다음에 동작할 코드의 위치는 테스크의 Kernel_yield()의 리턴 코드 직전임
= 스케줄링 직후에 돌아와서 다음 테스크가 CPU를 사용하는 것
10.4 커널 시작하기
처음 커널을 시작할 때 스케줄러를 그냥 실행하면 태스크가 동작하지 않음!
WHY? 커널을 시작할 때는 현재 동작 중인 태스크가 없기 때문!!
해결>최초로 스케줄링할 때는 컨텍스트 백업을 하지 않기 (컨텍스트 복구만 하기)
kernel/task.c
Kernel_task_init() 함수 수정 및 Kernel_task_start() 함수 구현
static KernelTcb_t sTask_list[MAX_TASK_NUM];
static KernelTcb_t* sCurrent_tcb;
static KernelTcb_t* sNext_tcb;
static uint32_t sAllocated_tcb_index;
static uint32_t sCurrent_tcb_index;
...
중략
...
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();
}
코드 10.7 첫 번째 스케줄링만 처리하는 코드 task.c
코드 10.7 설명은 더보기 클릭!
5번째 줄 | static uint32_t sCurrent_tcb_index; | 현재 실행 중인 태스크의 TCB을 저장하고 있는 정적 전역 변수를 선언 |
12번째 줄 | sCurrent_tcb_index = 0; | 0으로 초기화 |
25~29번째 줄 | void Kernel_task_start(void) { sNext_tcb = &sTask_list[sCurrent_tcb_index]; Restore_context(); } |
커널을 시작할 때 최초 한 번만 호출하는 함수 Kernel_task_scheduler() 함수와 Kernel_task_context_switching() 함수에서 반씩 가져와 만든 함수 |
27번째 줄 | sNext_tcb = &sTask_list[sCurrent_tcb_index]; | sCurrent_tcb_index 변수의 값으로 TCB를 가져와 sNext_tcb에 저장 (= 첫 번째로 생성된 태스크의 TCB를 가져오는 것) |
28번째 줄 | Restore_context(); | 컨텍스트 백업을 하지 않았으므로 지금까지의 컨텍스트는 모두 사라지고 태스크의 컨텍스트를 ARM 코어에 덮어 씀 |
kernel/Kernel.h
#ifndef KERNEL_KERNEL_H_
#define KERNEL_KERNEL_H_
#include "task.h"
void Kernel_start(void); //추가
void Kernel_yield(void);
#endif /* KERNEL_KERNEL_H_ */
코드 10.8 Kernel_start() 함수를 추가 Kernel.h
kernel/Kernel.c
커널 관련 초기화 함수를 Kernel_start() 함수에 모아서 한번에 실행할 예정
void Kernel_start(void)
{
Kernel_task_start();
}
코드 10.9 최초의 Kernel_start() 함수 Kernel.c
boot/Main.c
Kernel_init() 함수와 Kernel_start() 함수 호출 코드 작성
static void Kernel_init(void)
{
uint32_t taskId;
Kernel_task_init();
taskId = Kernel_task_create(User_task0);
if (NOT_ENOUGH_TASK_NUM == taskId)
{
putstr("Task0 creation fail\n");
}
taskId = Kernel_task_create(User_task1);
if (NOT_ENOUGH_TASK_NUM == taskId)
{
putstr("Task1 creation fail\n");
}
taskId = Kernel_task_create(User_task2);
if (NOT_ENOUGH_TASK_NUM == taskId)
{
putstr("Task2 creation fail\n");
}
Kernel_start(); //추가
}
코드 10.10 main() 함수에서 커널 시작하기
debug_printf()에서 스택 주소값 확인하는 코드 작성
void User_task0(void)
{
uint32_t local = 0;
while(true)
{
debug_printf("User Task #0 SP=0x%x\n", &local);
Kernel_yield();
}
}
void User_task1(void)
{
uint32_t local = 0;
while(true)
{
debug_printf("User Task #1 SP=0x%x\n", &local);
Kernel_yield();
}
}
void User_task2(void)
{
uint32_t local = 0;
while(true)
{
debug_printf("User Task #2 SP=0x%x\n", &local);
Kernel_yield();
}
}
코드 10.11 사용자 태스크의 스택 주소를 확인하는 코드
종료하지 않고 끝없이 반복하며 출력됨! => Kernel_yield()가 잘 동작한다
Stack
Task#0의 스택 : 0x8FFFF0
Task#1의 스택 : 0x9FFFF0
Task#2의 스택 : 0xAFFFF0
각 태스크의 스택 주소 차이가 0x100000이므로 1MB씩 잘 할당됨.
TASK_STACK_START = 0x800000 = Task#0의 스택 베이스 주소
10.5 요약
컨텍스트 스위칭을 구현하였다..
어렵다....