https://www.yes24.com/Product/Goods/84909414
임베디드 OS 개발 프로젝트 - 예스24
나만의 임베디드 운영체제를 만들어 보자.이 책은 펌웨어 개발 과정을 실시간 운영체제(RTOS)를 만들어 가며 설명한다. 임베디드 운영체제를 개발 환경 구성에서 시작해 최종적으로 RTOS를 만드는
www.yes24.com
공부 목적으로 이만우님의 저서 "임베디드 OS 개발 프로젝트"를 따라가며, RTOS "Navilos"를 개발하는 포스트입니다. 모든 내용은 "임베디드 OS 개발 프로젝트"에 포함되어 있습니다.
개발 목적
임베디드 시스템에 많이 사용되는 RTOS를 직접 개발해 보며 다음과 같은 지식을 학습하고자 합니다.
- RTOS의 핵심 개념에 대해 학습한다.
- 운영체제 핵심 기능을 설계해 보며 학습한다.
- 펌웨어에 대한 진입장벽을 낮춘다.
- ARM 아키텍처에 대해 학습한다.
- 하드웨어에 대한 지식을 학습한다.(펌웨어가 어떻게 하드웨어를 제어하는지)
이번 챕터에선 컨텍스트 스위칭을 구현하겠습니다.
컨텍스트 스위칭은 말 그대로 컨텍스트를 전환하는 것 입니다. 태스크란 동작하는 프로그램이고, 동작하는 프로그램의 정보는 컨텍스트입니다. 이 컨텍스트를 어딘가에 저장하고 또 다른 어딘가에서 컨텍스트를 가져다가 프로세서 코어에 복구하면 다른 프로그램이 동작합니다. 이 동작이 컨텍스트 스위칭입니다.
해당 프로젝트에선 태스크 컨텍스트를 태스크의 스택에 저장하기로 했습니다. 그러므로 나빌로스의 컨텍스트 스위칭 프로세스는 다음과 같습니다.
- 현재 동작하고 있는 태스크의 컨텍스트를 현재 스택에 백업합니다.
- 다음에 동작할 태스크 컨트롤 블록을 스케줄러에서 받습니다.
- 2에서 받은 태스크 컨트롤 블록에서 스택 포인터를 읽습니다.
- 3에서 읽은 태스크의 스택에서 컨텍스트를 읽어서 ARM 코어에 복구합니다.
- 다음에 동작할 태스크의 직전 프로그램 실행 위치로 이동합니다. 이러면 이제 현재 동작하고 있는 태스크가 됩니다.
위 절차를 코드로 옮기겠습니다.
//task.c 스케줄러 함수, 컨텍스트 스위칭 함수 추가
#include "stdint.h"
#include "stdbool.h"
#include "ARMv7AR.h"
#include "task.h"
static uint32_t sAllocated_tcb_index;
static uint32_t sCurrent_tcb_index;
static KernelTcb_t sTask_list[MAX_TASK_NUM];
static KernelTcb_t* Scheduler_round_robin_algorithm(void);
static KernelTcb_t* sCurrent_tcb;
static KernelTcb_t* sNext_tcb;
void Kernel_task_init(void){
sAllocated_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;
}
}
uint32_t Kernel_task_create(KernelTaskFunc_t startFunc){
KernelTcb_t* new_tcb = &sTask_list[sAllocated_tcb_index++];
if (sAllocated_tcb_index > MAX_TASK_NUM){
return NOT_ENOUGH_TASK_NUM;
}
KernelTaskContext_t* ctx = (KernelTaskContext_t*)new_tcb->sp;
ctx->pc = (uint32_t)startFunc;
return (sAllocated_tcb_index - 1);
}
static KernelTcb_t* Scheduler_round_robin_algorithm(void){
sCurrent_tcb_index++;
sCurrent_tcb_idx %= sAllocated_tcb_index;
return &sTask_list[sCurrent_tcb_index];
}
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_swiching(void){
__asm__ ("B Save_context");
__asm__ ("B Restore_context");
}
Kernel_task_scheduler()에서는 라운드 로빈으로 선택한 다음 태스크의 태스크 컨트롤 블록 포인터와 현재 태스크의 태스크 컨트롤 블록 포인터를 저장하고, 컨텍스트 스위칭을 수행하는 함수입니다.
__atribute__ ((naked)) 이것은 GCC 컴파일러의 어트리뷰트 기능입니다.
어트리뷰트를 naked 로 설장하면 컴파일러가 함수를 컴파일할 때 자동으로 만드는 스택 백업, 복구, 리턴 관련 어셈블리어가 전혀 생성되지 않고 내부에 코딩한 코드 자체만 남습니다.
Kernel_task_context_switching()을 역어셈블해서 보면 다음과 같습니다.
0000021c <Kernel_task_context_switching>:
21c: ea000000 b 224 <Save_context>
220: ea000006 b 240 <Restore_context>
인라인 어셈블리로 코딩한 두 줄이 그대로 컴파일되어 있고 다른 코드는 없습니다.
만약 `__attribute__ ((naked)) 부분을 지우고 컴파일하면 다음과 같습니다.
0000021c <Kernel_task_context_switching>:
21c: e52db004 push {fp} ; (str fp, [sp, #-4]!)
220: e28db000 add fp, sp, #0
224: ea000003 b 238 <Save_context>
228: ea000009 b 254 <Restore_context>
22c: e24bd000 sub sp, fp, #0
230: e49db004 pop {fp} ; (ldr fp, [sp], #4)
234: e12fff1e bx lr
C 언어 코드 파일에서 코딩한 내용의 앞뒤로 스택을 확보하는 코드와 리턴하는 코드가 추가되어 있습니다. 나빌로스는 컨텍스트를 스택에 백업하고 스택에서 복구할 것이므로 컨텍스트 스위칭할 때 되도록 스택을 그대로 유지하는 것이 좋습니다. 그래서 __attribute__ ((naked)) 를 사용한 것입니다. 또한, Save_context 와 Restore_context 를 호출할 때 ARM 인스터럭션 B를 사용한 것 역시 LR을 변경하지 않기 위함입니다.
위 코드는 일반적인 C 언어 코드로 이해하면 안됩니다. 스택 확보도 안 하고 LR에 리턴 주소도 넣지 않았기 때문입니다. 그러나 Save_context와 Restore_context에서 어셈블리어를 이용해 직접 제어를 하여 마치 함수를 호출하듯 태스크를 전환합니다.
아래 그림은 컨텍스트 스위칭 과정을 간략히 설명한 것입니다.
Task#1이 현재 동작 중이고, Task#2가 다음에 동작할 태스크입니다. 다음과 같은 과정을 통해 컨텍스트 스위칭을 합니다.
- Task#1의 현재 스택 포인터에 현재 컨텍스트를 백업합니다. → 처음 함수를 호출했을 때 하는 작업과 동일합니다. 다만, 함수에서는 함수에서만 사용하는 레지스터만 백업하지만, 여기선 전체 컨텍스트를 모두 백업합니다.
- 스택 포인터를 태스크 컨트롤 블록에 저장합니다. → 스택 포인터만 따로 모아 저장하는 이유는 커널이 스택 포인터의 위치를 쉽게 가져올 수 있어야 스택에서 컨텍스트를 복구할 수 있기 때문입니다.
- Task#2의 태스크 컨트롤 블록에서 스택 포인터 값을 읽어 범용 레지스터 SP에 그 값을 씁니다. → ARM 코어에서는 스택 포인터가 바로 바뀌게 됩니다.
- 스택 관련 어셈블리 명령을 사용해 컨텍스트를 복구합니다. → 컨텍스트를 복구하며 스택 포인터를 Task#2가 컨텍스트 스위칭하기 직전 정상적인 스택 포인터 위치로 복구합니다.
컨텍스트 백업하기
컨텍스트는 현재 동작 중인 태스크의 스택에 직접 백업합니다. 따라서 앞서 정의한 컨텍스트 자료 구조에/ 따라 스택 명령어의 순서를 맞춰야 합니다.
//앞 장에서 정의한 컨텍스트 자료 구조
typedef struct KernelTaskContext_t{
uint32_t spsr;
uint32_t r0_r12[13];
uint32_t pc;
} KernelTaskContext_t;
sp, r0_r12, pc 순서입니다. C언어에서 구조체의 멤버 변수는 메모리 주소가 작은 값부터 큰 값으로 배정됩니다. 하지만 스택은 반대입니다.
그렇기 때문에 스택에 백업할 때는 pc, r0_r12, spsr 순서로 백업해야 합니다.
//컨텍스트 백업 코드
static __attribute__ ((naked)) void Save_context(void)
{
// save current task context into tihe 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}");
}
- 4, LR을 스택에 푸시합니다. 그러면 LR은 KernelTaskContext_t의 pc 멤버 변수에 저장됩니다. 나중에 태스크가 다시 스케줄링을 받았을 때 복귀하는 위치는 pc 멤버 변수가 저장하고 있고, 이 위치는 Kernel_task_context_switching()의 리턴 주소입니다. 그러므로 pc 멤버 변수에 현재 컨텍스트 LR값을 그대로 저장하는 것입니다.
- 5, 범용 레지스터 R0-R12까지 스택에 푸시하는 코드입니다. __attribute__ ((naked)) 를 사용했기 때문에 Kernel_task_context_switching() 호출 직전의 값을 그대로 유지하고 있습니다.
- 6-7, CPSR 을 KernelTaskContext_t의 spsr에 저장하는 코드입니다.
- 9, 현재 동작 중인 태스크 컨텍스트 블록의 포인터 변수를 읽는 코드입니다.
- 10, 포인터에 저장된 값을 읽습니다.
- 11, 읽은 값을 베이스 메모리 주소로 SP에 저장하는 코드입니다.
- 9-11을 C언어로 표현하면 다음과 같습니다. sCurrent_tcb→sp = ARM_코어_SP_레지스터값; or (uint32_t)(*sCurrent_tcb) = ARM_코어_SP_레지스터값;
컨텍스트 복구하기
컨텍스트 복구 작업은 컨텍스트 백업의 역순입니다.
//컨텍스트 복구 코드
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}");
}
- 4-6, 태스크 컨트롤 블록의 sp 멤버 변수의 값을 읽어 ARM 코어의 SP에 값을 저장하는 코드입니다.
- 8-9, 스택에 저장되어 있는 cpsr 값을 꺼내 ARM 코어의 CPSR에 값을 쓰는 작업입니다. 백업할땐 PUSH 어셈블리어 명령을 쓰니 복구할 때는 POP 을 사용합니다.
- 10, R0부터 R12까지 범용 레지스터를 복구하는 코드입니다. 이 이후론 R0~R12 레지스터 값을 변경하면 컨텍스트 복구에 실패합니다. 그래서 다른 작업을 하지 않고 바로 11번째 줄에서 스택 값을 꺼내 PC 에 저장하며 태스크 코드로 점프합니다.
- 11, ARM 코어는 컨텍스트가 백업하기 직전의 위치로 PC를 옮겨 실행을 이어 합니다.
yield 만들기
스케줄러와 컨텍스트 스위칭을 합쳐서 스케줄링이라 합니다. 이제 언제 스케줄링을 할 것인지를 정해야 합니다. 타이머 인터럽트와 연동해 일정 시간동안 동작시키고 다음 태스크로 넘어가는 시분할 시스템이 있습니다. 일반적으로 시분할 시스템은 선점형 멀티캐스팅 시스템입니다.
해당 프로젝트에서는 시분할이 아닌 비선점형 스케줄링 을 사용하겠습니다. 즉, 태스크가 커널에 스케줄링을 요청해야 스케줄링이 이루어집니다. 태스크가 커널에 스케줄링을 요청하는 동작은 태스크가 CPU 자원을 다음 태스크에게 양보한다는 의미입니다. 그래서 이 함수를 양보한다는 의미인 yield() 를 많이 씁니다.
yield() 함수는 커널 API로 만들겠습니다. kernel 디렉터리 하위에 Kernel.c와 Kernel.h를 만들겠습니다.
//kernel/Kenel.h
#ifndef KERNEL_KERNEL_H_
#define KERNEL_KERNEL_H_
#include "task.h"
void Kernel_yield(void);
#endif /* KERNEL_KERNEL_H_ */
//kernel/Kenel.c
#include "stdint.h"
#include "stdbool.h"
#include "Kernel.h"
void Kernel_yield(void){
Kernel_task_scheduler();
}
Kernel_yield()의 구현은 Kernel_task_scheduler() 를 직접 호출하는 것이 전부입니다. 태스크가 더 이상 할 일이 없을 때 Kernel_yield()를 호출하면 즉시 스케줄러를 호출해 다음 동작할 태스크를 선정합니다. 그리고 컨텍스트 스위칭을 수행합니다.
커널 시작하기
앞서 커널에 태스크를 세 개를 생성했고, 스케줄러도 만들고 컨텍스트 스위칭도 구현했기 때문에 태스크를 실행시킬 수 있습니다. 방법은 스케줄러를 실행하면 됩니다. 하지만 처음 커널을 시작할 때 스케줄러를 그냥 실행하면 태스크가 동작하지 않습니다.
왜냐하면 커널을 시작할 때는 현재 동작 중인 태스크가 없기 때문입니다. 컨텍스트 스위칭할 때 현재 태스크를 백업하는 과정이 있는데 ,처음엔 태스크가 없기 때문에 커널 스택에 쓰레기값을 백업하게 됩니다. 사실 동작엔 문제 없지만 엄밀하게 동작해야 할 RTOS에선 어울리지 않으니 해결해야 합니다.
해결 방법은 최초로 스케줄링할 때는 컨텍스트 백업을 하지 않으면 됩니다. Kernel_task_init() 함수의 코드를 수정해 최초 스케줄링을 처리하는 함수로 만들겠습니다.
//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();
}
sCurrent_tcb_index 를 커널이 시작할 때 0으로 초기화 해주었습니다. 그리고 커널이 시작할 때 최초 한 번만 호출하는 함수 Kernel_task_start() 를 구현하였습니다.
이제 Kernel_task_start()를 커널 API인 Kernel_start()함수에 연결하고 main()에서 호출하여 실행하겠습니다.
//Kernel.h에 Kernel_start() 추가
#ifndef KERNEL_KERNEL_H_
#define KERNEL_KERNEL_H_
#include "task.h"
void Kernel_start(void);
void Kernel_yield(void);
#endif /* KERNEL_KERNEL_H_ */
//Kernel.c에 Kernel_start() 구현
void Kernel_start(void){
Kernel_task_start();
}
//main()에서 커널 시작하기
//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();
}
이제 사용자 태스크가 제대로 스택을 할당받았는지 확인해 보는 코드를 추가해서 의도한대로 잘 되었는지 확인해 보겠습니다.
우리가 구현한 디버깅 도구인 debug_printf()를 통해 출력해보겠습니다.
//각 태스크에 디버깅 함수 추가
void User_task0(void){
debug_printf("User Task #0\n");
uint32_t local = 0;
while(true){
debug_printf("User Task#0 SP=0x%x\n", &local);
Kernel_yield();
}
}
void User_task1(void){
debug_printf("User Task #1\n");
uint32_t local = 0;
while(true){
debug_printf("User Task#1 SP=0x%x\n", &local);
Kernel_yield();
}
while(true);
}
void User_task2(void){
debug_printf("User Task #2\n");
uint32_t local = 0;
while(true){
debug_printf("User Task#2 SP=0x%x\n", &local);
Kernel_yield();
}
}
빌드하고 테스트 해보니
이처럼 결과가 나왔습니다. 각 태스크의 스택 주소 차이가 0x100000이므로 간격이 딱 1MB입니다. 간격은 의도한대로 잘 할당되었습니다.
메모리 맵을 보면 TASK_STACK의 시작점이 0x800000입니다. TASK#0의 스택 베이스 주소입니다. 스택 포인터에는 스택 공간의 최댓값을 할당합니다. 각 태스크 스택 간 4바이트 간격을 패딩으로 설계했으니, 초기 스택 포인터는 0x8FFFFC입니다. 여기에 컴파일러가 사용하는 스택이 몇 개 되고 로컬 변수가 스택으로 잡혔기 때문에 0x8FFFF0이 출력된 것 입니다. 마찬가지로 TASK#1과 TASK#2도 출력되었습니다.
의도했던대로 동작하는 것을 확인할 수 있었습니다.
마치며
이번엔 컨텍스트 스위칭을 만들어 보았습니다. 이번 내용이 가장 어려운 내용이었을 것입니다. 일반적인 호출-리턴 관계가 아닌 강제로 컨텍스트를 백업-리스토어하는 것이 익숙하지 않기 때문입니다.
참고
RealViewPB 데이터시트 https://developer.arm.com/documentation/dui0417/d/?lang=en
Documentation – Arm Developer
developer.arm.com
저자 이만우님 깃허브 https://github.com/navilera/Navilos
GitHub - navilera/Navilos: RTOS for various embedded platform
RTOS for various embedded platform. Contribute to navilera/Navilos development by creating an account on GitHub.
github.com
'임베디드' 카테고리의 다른 글
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.12 (0) | 2024.11.18 |
---|---|
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.11 (1) | 2024.11.16 |
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.9 (0) | 2024.11.12 |
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.8 (0) | 2024.11.11 |
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.7 (1) | 2024.11.11 |