https://www.yes24.com/Product/Goods/84909414
공부 목적으로 이만우님의 저서 "임베디드 OS 개발 프로젝트"를 따라가며, RTOS "Navilos"를 개발하는 포스트입니다. 모든 내용은 "임베디드 OS 개발 프로젝트"에 포함되어 있습니다.
개발 목적
임베디드 시스템에 많이 사용되는 RTOS를 직접 개발해 보며 다음과 같은 지식을 학습하고자 합니다.
- RTOS의 핵심 개념에 대해 학습한다.
- 운영체제 핵심 기능을 설계해 보며 학습한다.
- 펌웨어에 대한 진입장벽을 낮춘다.
- ARM 아키텍처에 대해 학습한다.
- 하드웨어에 대한 지식을 학습한다.(펌웨어가 어떻게 하드웨어를 제어하는지)
지금까지의 과정은 아무것도 없는 상태에서 RTOS가 어떻게 개발되는지를 알아보았습니다. 그러나 아직 RTOS 관련된 내용은 설명하지 않았습니다. 그 과정에서 RTOS를 만드는데 필요한 몇 가지 라이브러리 함수를 만들었고, 이제 이 함수들을 이용해 RTOS를 만들겠습니다.
RTOS를 포함한 운영체제라는 것을 한 문장으로 설명하자면, 태스크를 관리하여 사용자가 하고싶은 것을 할 수 있도록 도와주는 것이라 할 수 있습니다.
ex) 휴대폰 볼륨 버튼 → 볼륨을 조절하는 버튼을 누르면, 볼륨을 조절하는 태스크를 실행해 어떤 버튼이 눌렸는지 판단 후 시스템의 파라미터를 변경해 볼륨을 조절
ex) 네트워크 시스템 → 빠르게 처리해야 하는 태스크, 천천히 처리해도 되는 태스크 → 이 태스크들은 쉴새없이 계속 동작 중 → 이때 디버깅이 필요해 UART 입력이 들어옴 → 운영체제는 UART 인터럽트를 처리하고 UART 명령을 처리하는 태스크가 동작해야 함 → 이때 우선순위 관리도 해야됨
태스크 컨트롤 블록
개별 태스크를 추상화하는 자료구조
태스크란 운영체제에서 동작하는 프로그램입니다. 태스크가 다른 태스크로 바뀌면 그것을 전환(switching)이라 부릅니다. 태스크간 전환이 생길 때 프로그램 흐름에 어떤 문제가 생기면 안됩니다. 이를 보장하기 위해 현재 진행 중인 프로그램의 현재 상태 정보를 기록하고 있어야 하고, 프로그램의 현재 상태 정보를 컨텍스트(Context)라 부릅니다.
태스크 컨트롤 블록에 컨텍스트와 태스크의 이름을 넣을 수 있습니다. 이외에도 태스크 번호, 태스크 우선순위 등 개발자가 태스크를 관리하는데 필요한 정보들을 태스크 컨트롤 블록에 넣을 수 있습니다.
태스크 컨트롤 블록을 구현하겠습니다. 구현의 첫번째는 RTOS 커널을 만드는 것 입니다. kernel 디렉터리를 새로 만들고 task.c와 task.h를 추가하겠습니다.
//kernel/task.h
#ifndef KERNEL_TASK_H_
#define KERNEL_TASK_H_
#include "MemoryMap.h"
#define NOT_ENOUGH_TASK_NUM 0xFFFFFFFF
#define USR_TASK_STACK_SIZE 0x100000
#define MAX_TASK_NUM (TASK_STACK_SIZE / USR_TASK_STACK_SIZE)
typedef struct KernelTaskContext_t{
uint32_t spsr;
uint32_t r0_r12[13];
uint32_t pc;
} KernelTaskContext_t;
typedef struct KernelTcb_t{
uint32_t sp;
uint8_t* stack_base;
} KernelTcb_t;
typedef void (*KernelTaskFunc_t)(void);
void Kernel_task_init(void);
uint32_t Kernel_task_create(KernelTaskFunc_t startFunc);
#endif /* KERNEL_TASK_H_ */
- MemoryMap.h를 포함시킨 이유는 9번째 줄, TASK_STACK_SIZE를 사용하기 때문입니다. 동작 모드별 스택을 리셋 핸들러에서 설정할때 만든 값입니다.
- 태스크의 스택 크기는 모든 태스크가 같도록 설계하였습니다. → 당연히 개별 태스크마다 필요한 스택 크기를 다르기 하는 것이 더 유연한 설계입니다. → 이후에 수정해서 바꿔보는 것 추천
- 태스크 스택용으로 64MB를 할당해 놓았고, 각각의 태스크가 1MB씩 스택을 쓸 수 있으므로 태스크를 64개 쓸 수 있습니다. 이는 USR_TASK_STACK_SIZE를 반으로 줄이면 128개로 늘어날 것 입니다.
- Kernel_task_init() 함수와 Kernel_task_create() 함수는 각각 커널의 태스크 관련 기능을 초기화하는 함수와 커널에 태스크를 생성(등록)하는 함수입니다.
- KernelTaskContext_t와 KernelTcb_t 구조체가 태스크 컨트롤 블록입니다.
- KernelTaskContext_t는 컨텍스트를 추상화한 자료구조입니다. A.1에서 설명한 ARM 프로그램 상태 레지스터와 범용 레지스터를 백업할 수 있는 영역을 구조체로 확보한 것입니다. → 이것이 컨텍스트의 실체
- KernelTcb_t는 스택 관련 정보만 저장하고 있습니다. sp는 스택 포인터, stack_base는 컨텍스트에 포함되지 않은 부가 데이터입니다. → 개별 태스크의 스택 베이스 주소를 저장하기 위해 만들었습니다.
태스크 컨텍스트는 결국 레지스터와 스택 포인터 값입니다. 스택 포인터도 레지스터의 일부이므로 태스크 컨텍스트를 전환한다는 것은 코어의 레지스터 값으 다른 태스크의 것으로 바꾼다는 말과 동일합니다.
태스크 컨트롤 블록 초기화
이제 실제 메모리에 태스크 컨트롤 블록 인스턴스를 만들고 기본값을 할당하는 코드를 작성해 보겠습니다. Kernel_task_init()을 구현하는 작업입니다.
//kernel/task.c 태스크 컨트롤 블록 초기화 코드
#include "stdint.h"
#include "stdbool.h"
#include "ARMv7AR.h"
#include "task.h"
static KernelTcb_t sTask_list[MAX_TASK_NUM];
static uint32_t sAllocated_tcb_index;
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){
return NOT_ENOUGH_TASK_NUM;
}
- 7, 태스크 컨트롤 블록을 64개 배열로 선언했습니다. 메모리에 태스크 컨트롤 블록용으로 자리 잡아둔 것 입니다. 동적 메모리 할당을 피하기 위한 객체 풀로 만든 것 입니다.
- 8, sAllocated_tcb_index는 생성한 태스크 컨트롤 블록 인덱스를 저장하는 변수입니다. 태스크를 생성할 때마다 변수의 값을 늘립니다.
- Kernel_task_create() 함수가 한 번 호출될 때마다 sTask_list 배열에서 태스크 컨트롤 블록을 한 개씩 사용합니다. 그러므로 커널은 배열에서 어떤 인덱스가 아직 사용되지 않는지 알아야 합니다.
- sAllocated_tcb_index값 보다 큰 인덱스는 아직 할당하지 않은 것이고, 작은 인덱스는 이미 할당된 태스크입니다.
- 14-23, 태스크 컨트롤 블록 배열을 모두 순회하며 초기화하는 코드입니다.
- 스택 포인터에 -4를 한 것은 태스크 간의 스택 경계를 구분하고자 4바이트를 비운 것입니다.
나빌로스에서는 태스크 컨텍스트를 태스크 컨트롤 블록이 아닌 해당 태스크 스택에 저장합니다. 태스크의 컨텍스트를 어디에 저장하느냐는 설계하기 나름입니다.
태스크 구조는 다음과 같습니다.
스택 포인터가 컨텍스트 다음에 위치하긴 하지만 태스크 컨텍스트는 앞으로 컨텍스트 스위칭 작업에 의해 모두 레지스터로 복사되고 스택 포인터는 태스크 컨텍스트가 없는 위치로 이동하게 됩니다. 그래서 동작 중인 태스크의 스택에는 컨텍스트가 존재하지 않습니다.
태스크 생성
Kernel_task_create()함수를 구현하겠습니다.
//Kernel_task_create()
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);
}
ctx→pc = (uint32_t)startFunc는 파라미터로 넘어오는 함수의 시작 주소를 pc에 넣어주는 코드, 태스크 함수를 태스크 컨트롤 블록에 등록하는 과정입니다.
현재 작업한 태스크 리스트의 인덱스 값을 태스크 컨트롤 블록의 아이디로 사용하겠습니다.
아직까지 태스크를 동작시킬수는 없습니다. 태스크를 동작시키려면 스케줄러와 컨텍스트 스위칭까지 모두 만들어야하기 때문입니다. 지금은 우선 태스크 생성과 등록에 관련된 작업을 진행하겠습니다.
보통 전체 시스템을 각 기능별로 나누어 개발하고 해당 기능을 실행하는 태스크 함수를 대표로 하나 만듭니다. 그리고 펌웨어가 시작될 때 RTOS를 초기화하는 코드에서 개별적으로 태스크를 등록합니다.
일단 Main.c에 더미 태스크 함수를 만들고 커널에 등록하겠습니다.
//더미 태스크 등록
#include "stdint.h"
#include "stdbool.h"
#include "HalUart.h"
#include "HalInterrupt.h"
#include "HalTimer.h"
#include "stdio.h"
#include "stdlib.h"
#include "task.h"
static void Hw_init(void);
static void Kernel_init(void);
static void Printf_test(void);
static void Timer_test(void);
void User_task0(void);
void User_task0(void);
void User_task0(void);
void main(void)
{
Hw_init();
uint32_t i = 100;
while(i--){
Hal_uart_put_char('N');
}
Hal_uart_put_char('\n');
putstr("Hello World\n");
Printf_test();
Timer_test();
Kernel_init();
while(true);
}
static void Hw_init(void){
Hal_interrupt_init();
Hal_uart_init();
Hal_timer_init();
}
static void Printf_test(void){
char* str = "printf pointer test";
char* nullptr = 0;
uint32_t i = 5;
uint32_t* sysctrl0 = (uint32_t*)0x10001000;
debug_printf("%s\n", "Hello printf");
debug_printf("output string pointer: %s\n", str);
debug_printf("%s is null pointer, %u number\n", nullptr, 10);
debug_printf("%u = 5\n", i);
debug_printf("dec=%u hex=%x\n", 0xff, 0xff);
debug_printf("print zero %u\n", 0);
debug_printf("SYSCTRL0 %x\n", *sysctrl0);
}
static void Timer_test(void){
while(true){
debug_printf("current counter : %u\n", Hal_timer_get_1ms_counter());
delay(1000);
}
}
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");
}
}
void User_task0(void){
debug_printf("User Task #0\n");
while(true);
}
void User_task1(void){
debug_printf("User Task #1\n");
while(true);
}
void User_task2(void){
debug_printf("User Task #2\n");
while(true);
}
Kernel_task_create()에 함수 포인터를 넘겨주고, 해당 함수 포인터는 태스크 컨트롤 블록에 저장됩니다. 각 태스크가 무한 루프를 도는 이유는 현재 나빌로스의 태스크 관리 설계에는 태스크의 종료를 보장하는 기능이 없기 때문입니다. 쉽게 말해, 한 번 실행된 태스크는 종료되면 안되고 계속 실행 중이어야 합니다.
마치며
해당 챕터에서는 태스크 컨트롤 블록 자료 구조를 설꼐하고 구현했습니다. 그리고 태스크 컨트롤 블록에 함수 포인터를 연결해서 함수를 태스크로 만들었습니다. 각 태스크는 스택 주소와 레지스터를 독립적으로 가지고 있습니다. 기능적으로 완전히 독립된 프로세스입니다.
참고
RealViewPB 데이터시트 https://developer.arm.com/documentation/dui0417/d/?lang=en
저자 이만우님 깃허브 https://github.com/navilera/Navilos
'임베디드' 카테고리의 다른 글
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.10 (0) | 2024.11.13 |
---|---|
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.9 (0) | 2024.11.12 |
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.7 (1) | 2024.11.11 |
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.6 (2) | 2024.11.06 |
[RTOS 개발하기] 임베디드 OS 개발 프로젝트ch.5 (6) | 2024.11.04 |