[임베디드 레시피 요약 및 정리] Chapter 6. RTOS & Kernel

Embedded June·2022년 12월 27일
0

임베디드레시피

목록 보기
8/10
post-thumbnail

1. RTOS와 Kernel

  • Kernel 및 OS가 없던 시절의 SW 개발자는 프로그램이 실행되는 데 필요한 모든 기능(컴파일~로드)을 처음부터 끝까지 작성해야 했기 때문에 너무 복잡하고 까다로운 작업을 해야만 했다.
  • 모든 SW는 키보드로 입력받고 모니터에 출력하는 등 I/O라는 공통점이 있었다. I/O 기능을 포함해 SW를 HW에 자동으로 로드하고 실행까지 해주는 모니터링 환경을 구축한 것이 OS의 시초다.
  • 임베디드 시스템에서도 위 작업과 멀티태스킹 그리고 인터럽트 처리 기능 등을 가지고 있는 작은 OS가 만들어졌는데, 이를 RTOS라고 하며, 그 중 핵심적인 부분(Context switching, Scheduler, Memory management, ISR management 등)만 따로 때어낸 것을 kernel이라고 불렀다.
  • Kernel에 대해 많이 오해하는 점인데, kernel은 혼자 살아 움직이면서 전체 시스템을 관리하는 대단한 SW가 아니다. 그저 API가 호출돼야 동작하기 시작해서 시스템을 관리하는 SW일 뿐이다.
  • RTOS 및 kernel(monolithic, micro 등)에 대한 용어 관련 논쟁이 있다. 개인적으로 RTOS는 embedded OS라고 부르는게 더 적절하며 kernel이라 불러도 된다고 생각한다. (RTOS == embedded OS == kernel)
  • 또한, RTOS는 시간 응답성에 따라 hard/ soft로 나뉜다. 옛날에는 임베디드 시스템 속 프로세서의 성능이 낮아 그렇게 분류하는 것이 중요했지만, 요즘은 워낙 성능이 좋아서 soft RTOS도 웬만한 시간 안에 응답을 주기 때문에 구분하는 의미가 딱히 없다.

2. Task

  • Embedded SW는 기본적으로 무한 loop 구조를 가진다. 시스템 전원이 꺼질 때까지 무한히 정해진 일을 반복한다.
  • 하나의 작업은 무한 loop 구조의 함수인 task로 나눠서 구현하는데, RTOS에 있는 ‘scheduler’가 각 task 사이의 순서를 관리하는 역할을 수행한다. 따라서 task는 scheduling(≈ context switching)의 기본(최소) 단위다.
  • Task는 init/ wait/ ready/ running 4가지 state를 가진다.
    1. Init: Task가 생성된 뒤 task에 필요한 기초 작업을 준비하는 단계다. 무한 반복이 필요하지 않은 변수 정의 및 선언 등이 포함된다.
    2. Wait: Task가 특정 함수를 만나거나 signal을 기다리면 자신의 순서를 반환하고 기다리는 상태로 전환한다. Scheduler는 다음 우선순위 task를 선택해 수행한다.
    3. Ready: Task가 다시 수행될 준비를 마쳐 scheduler에게 다시 선택받기를 기다리는 상태다.
    4. Running: Task가 scheduler에게 선택받가 CPU time을 점유해 실행되는 상태다.
void waiter_task() {
    /* init state */
    waiter_task_init();
    
    while (true) {
        wait_signal (SRV_SIGNAL);
        /* wait을 만나자 마자 wait state가 된다.
        누군가가 SRV_SIGNAL을 send 하면, Ready State가 된다.
        Ready State는 scheduler가 '이 task가 누군가로부터 일 해달라고 요청 받았어'라는 걸 알아채는 단계,
        또는 다음 번에 Scheduling할 때 순서를 줘야 하는 task라는 걸 인식시키는 단계다.
        따라서 signal을 받았다고 무작정 실행되지는 않는다. */

        /*  Wait state를 벗어나 running state가 되면,
        SRV_SIGNAL을 다음번에도 받을 수 있도록 초기화 해야 한다. */
        clear_signal (SRV_SIGNAL);

        인사();
        물_접시_서빙하기();
        주문받기();
        
        /* cook_task에게 COOK_SIGNAL을 보내서 요리하도록 지시한다.
        cook_task는 wait에서 ready로, 이후 running state가 된다.*/
        send_signal (cook_task, COOK_SIGNAL);
     }
}

void __irq customer_isr(void) {
    send_signal(waiter_task, SRV_SIGNAL);
    clear_interrupt_source(customer_comming);
    // 인터럽트를 지워줘야 ISR 무한 반복을 안 한다.
}
  1. 손님이 오는 순간 custommer_comming interrupt가 발생한다.
    • ARM 실행 mode는 IRQ로 바뀌며 exception vector table로 jump한다.
    • IRQ handler의 시작주소로 다시 jump해 __irq 예약어로 선언된 custormer_isr이라는 ISR이 호출된다.
  2. ISR은 waiter_task라는 task에게 SRV_SIGNAL이라는 signal을 날린다.
  3. Init 후 wait 상태였던 waiter_task는 signal을 받아 ready → running 상태가 되며 다음 번에 signal을 또 받을 수 있도록 signal을 clear해 초기화 한다.
    • waiter_task에 정의된 기능을 수행한다.
    • 인사하고, 물이랑 접시 서빙하고, 주문받는다.
  4. 마지막으로 cook_task라는 task에게 COOK_SIGNAL이라는 signal을 보내 주문받은 요리를 만들라고 지시한다.

3. Scheduling(≈ Context switching)

3.1. Preemptive multi-tasking (선점형 스케줄링)

  • Scheduler의 필요에 따라 task로부터 CPU 제어권을 강제로 뺐어 다른 task가 실행될 수 있도록 실행 순서를 결정하는 방식preemptive scheduling이라고 부른다.
    • Multitasking의 정의에 대한 여러 논쟁이 있지만, 임베디드 시스템에서는 여러 개의 task를 ready 상태로 만들 수 있고, 각자 따로 다른 일을 시킬 수 있는 시스템을 multitasking이라고 부르는 게 좋겠다.
    • Round-robin 같은 시분할 방식은 여기에 플러스로 동시다발적으로 동작하는 것 ‘처럼’ 보이도록 할 수 있는거니까 구분할 필요가 있다.
  • 각 task마다 우선순위(priority)가 있고, 우선순위가 더 높은 task가 있을 경우 현재 실행되고 있는 task로부터 CPU 점유권을 뺐을 수 있다.

  • 위 그림은 preemptive scheduling 과정을 간략하게 나타내는 그림이다.
  • 각 task 사이사이에는 가장 높은 우선순위를 가지는 ISR이 있다.
    • ISR은 다음 번에 실행돼야 하는 task에게 signal을 전송해 ready 상태로 만든다.
    • Scheduler는 ready queue를 살펴보다 task B를 발견하고 running 상태로 만든다.
  • 각 과정 사이사이에는 ‘context switching’이 발생한다.

3.2. Context switching과 TCB(Task Control Block)

  • 우리는 앞서 ‘context’는 바로 현재 register set을 의미하는 것임을 배웠다.
  • 모든 task는 자신만의 stack과 TCB(task control block)를 가진다.
    • 현재 task를 수행할 때의 register 값들(=context)은 각 task가 가진 stack에 백업 및 복구된다.
    • TCB는 task의 여러 정보가 저장되는 자료구조이며 kernel이 task를 관리하기 위한 메타데이터다.
      • 메타데이터란, 데이터를 위한 데이터로서 데이터의 속성들을 따로 데이터로 모아놓은 데이터를 의미한다. 실제 사용자들에게는 의미 없는 데이터일 수 있는 데이터지만 실제 시스템에게는 아주 중요한 데이터를 의미한다.
      • 대표적으로 task name, task stack pointer, wait signal, priority 등이 저장된다.
  • TCB의 SP는 task의 context를 가리키며 각 stack은 자신이 어디까지 실행되고 있었는지 정보를 담고 있다.
  • Context switching을 할 때는 해당 TCB의 SP가 가리키는 곳으로 부터 register를 복사해 넣으면 된다.
typedef struct {
    struct task_tcb *prev_ptr;
    struct task_tcb *next_ptr;
} task_tcb_link_type;       // Doubly linked list로 구현

typedef struct task_tcb {
    char    task_name[200]; // for debugging
    void    *sp;            // stack pointer
    uint32  wait_signal;    // 기다리는 signal
    uint32  recv_signal;    // 수신한 signal
    uint32  priority;       // 우선순위
    task_tcb_link_type link;
} task_tcb_type;
  • TCB 구현을 아주 단순하고 간략하게 해보면 위와 같다.
  • TCB들은 doubly linked list로 구현해서 관리하면 새로운 TCB를 중간에 끼워 넣기도 편하고, 우선순위 순으로 정렬하기도 편하다.

3.3. Scheduler

Kernel의 가장 핵심 기능인 scheduler가 하는 대표적인 역할 2개는 다음과 같다.

  1. 다음에 실행될 task를 결정한다.

    • 위에서 설명했듯, TCB를 priority base로 정렬된 linked list로 관리한다면, head 부터 순차적으로 ready된 task를 찾으면 다음 task를 결정할 수 있다.
    • 이외에도 여러가지 방법이 있다. 예를 들어 따로 ready queue를 만들어서 ready된 task만 관리하는 방법도 있다.
  2. Context switching을 수행한다.

    • CPSR의 저장 및 복구
    • Context의 저장 및 복구
    • 현재 task의 SP를 백업, 다음 task의 SP를 복구
    ; 현재 task의 context를 백업하는 과정
    STMFD   SP!, {LR}               ; LR을 stack에 저장한다.
    SUB     SP, SP, #4              ; R13은 TCB에 따로 저장할거니까 비워두기
    STMFD   {R0-R12}                ; R0~R12를 stack에 저장한다.
    MRS     LR, CPSR                ; LR은 그냥 temp 저장소 역할
    STMFD   SP!, {LR}               ; CPSR을 stack에 저장한다.
    LDR     R0, =CUR_TASK_TCB       ; 현재 task의 TCB 시작주소 가져와서
    STR     SP, [R0, #TCB_SP_POS]   ; TCB_SP_POS만큼 offset 주고 SP 저장한다.
    
    ; 다음 task의 context를 복구하는 과정
    LDR     R0, =NXT_TASK_TCB       ; 다음 task TCB 시작주소 가져와서
    LDR     SP, [R0, #TCB_SP_POS]   ; 다음 task의 SP값 가져오고
    LDMIA   SP!, {R0}               ; CPSR의 F랑 C 자리 가져와서 SPSR에 저장한다.
    MSR     SPSR_F, R0
    MSR     SPSR_C, R0
    LDMIA   SP!, {R0-R12, LR, PC}^  ; R0~R12랑 PC값 갱신한다. LR은 그냥 쓰레기값.

4. ISR(Interrupt Service Routain)

4.1. ISR Handler의 구현

Task가 실행되다가 interrupt가 발생하면, 실행을 중단하고 IRQ mode 전환 → vector table로 jump → IRQ handler로 jump하게 된다. 그렇다면 ISR handler는 어떤 방식으로 구현돼있을까?

  1. 우선 IRQ는 pipeline의 execution 단계에서 발생하므로 LR 보정이 필요하다. LR = LR - 4
  2. 실행하던 task의 context 백업하기 (이미 위에서 코드와 함께 자세히 설명함.)
  3. IRQ handler로 branch 및 ISR 호출
    • 이때, 시스템에 interrupt가 하나만 있지는 않을 것이다.
    • 다양한 interrupt를 관리하기 위한 HW로 ‘interrupt controller’라는 HW가 내장돼있는데, 여기서 발생한 interrupt에 관란 정보(IRQ/ FIQ 여부, ISR 식별 등)를 얻을 수 있다.
IRQ_Handler
    SUB     LR, LR, #4          ; LR 보정
    STMFD   SP!, {R0-R12, LR}   ; Context 저장
    LDR     R3, =ISR_Handler    ; ISR 시작주소 가져와서
    LDR     R3, [R3]
    BLX     R3                  ; branch 한다.
    LDMIA   SP!, {R0-R12, PC}^  ; Context 복구
void ISR_Handler(void) {
    uint32 IRQ_NUM;
    // Interrupt controller HW의 시작주소가 0x8000_0000이라 가정
    // 해당 주소를 읽어서 현재 걸린 인터럽트의 번호를 확인한다.
    volatile uint32 *num = (volatile uint32 *)0x8000'0000;
    IRQ_NUM = *num;
    // 현재 인터럽트 번호에 대응하는 ISR을 함수포인터 배열로 호출한다.
    ISRVector[IRQ_NUM]();
}
  • IRQ Handler 및 ISR을 대략적으로 위와 같은 구조로 구현되며, 이를 vector type ISR이라고 부른다.
  • ISR Handler 및 ISR의 내용물은 너무 길면 안 된다.
    • ISR은 다른 모든 task보다 우선순위가 높기 때문에, 다른 task가 제때 해야 하는 일을 못해 문제를 야기할 수 있다.
    • 또한, 우선순위가 낮은 task는 계속해서 실행되지 못하는 starvation을 유발할 수 있다.
    • Task가 계속 실행되지 못해 watchdog에 의한 reset이 발생할 수 있다.
  • 따라서 ISR은 간략하게 만들되, 할 일이 많은 ISR의 경우에는 bottom half 같은 기법을 사용한다.

4.2. Bottom half (Deferred ISR)

  • Bottom half = DPC(Deferred Procedure Call) + APC(Asynchronous Procedure Call)
    • APC는 지금 처리하지 않고 나중에 task level로 처리할 수 있도록 부르는 API를 말하고,
    • DPC는 APC에 의해 호출당한 task를 의미한다.
  • 보통 ISR 내부는 1) 당장 처리해야 하는 사항과 2) 조금 나중에 처리해도 되는 사항으로 나눌 수 있다.
    • 1)은 ISR 내부에서 처리한 뒤
    • ISR 거의 끝 부분에 2)와 관련된 일을 처리하는 task, 즉 DPC를 만들어서 queue에 넣은 뒤에
    • ISR을 빠져나와 복귀한다.
  • DPC가 처리하는 일은 본래 ISR에서 했어야 하는 일이므로
    1. DPC task의 우선순위는 상대적으로 꽤 높도록 설정해야 한다.
    2. 도중에 interrupt가 걸리지 않도록 disable하는 것도 좋은 아이디어다.
  • [※] DPC는 두 가지 방법으로 구현할 수 있습니다.
    1. [※] 책에서는 queue와 함수 포인터를 사용한 방법이 설명돼있습니다.
      • [※] DPC callback 함수를 queue에 enqueue한 뒤,
      • [※] DPC task가 실행되면 interrupt를 비활성화하고
      • [※] queue에서 dequeue해서 callback 함수를 호출합니다.
      • [※] 위 과정에 대해서는 하단 코드를 보면 좀 더 이해하기 쉬우실 겁니다.
    2. [※] 다른 방법으로, ISR의 끝부분에서 DPC를 생성(i.e. createTask()호출)하는 방법도 있습니다.
  • Bottom half의 실제 구현 예시를 보자.
void keyboard_isr(void) {
    /* Keyboard 관련해서 당장 처리해야 하는
    중요한 일들을 수행한다 */
    
    enqueue_APC(keyboard_DPC);    // queue에 callback 함수를 넣는다.
    send_signal(DPC_task, DPC_SIGNAL);  // DPC_task를 ready 시킨다.
}

void DPC_task(void) {
    while (1) {
        wait_signal(DPC_SIGNAL);
        clear_signal(DPC_SIGNAL);
        
        lock_interrupt();   // Disable interrupt.
            // queue에 있는 callback 함수들 호출해서 DPC 수행한다.
            while ( (dequeue_APC())() );
        free_interrupt();   // Enable interrupt.
    }
}
  • 함수 포인터와 queue를 사용하면, 하나의 task(DPC_task)로 여러가지 callback 함수를 호출해서 ISR이 하지 못한 업무를 수행할 수 있다는 점에서 구조가 훨씬 깔끔해진다.

4.3. Nested Interrupt

  • 중첩이 가능한 ISR을 구현하는 것도 아주 중요하다.
  • Interrupt를 처리하는 도중에 다시 interrupt가 걸리는 상황을 ‘중첩’(nesting)이라고 표현한다.
  • 중첩 interrupt를 구현할 때는 task 처럼 interrupt 사이에도 우선순위를 부여해서 상대적으로 중요한 interrupt부터 먼저 처리할 수 있도록 한다.
  • 우리는 위에서 ISRVector[IRQ_NUM]()라는 vector type interrupt를 사용하는 코드를 살펴봤는데, 이 배열을 우선순위에 따라 내림차순으로 정렬되도록 관리한다.
  • 어떤 interrupt가 수행되면, 해당 interrupt를 clear해주고 (signal 처럼) 현재 interrupt보다 낮은 우선순위 level의 interrupt는 disable 시킨다.
  • 현재 처리중인 interrupt 보다 높은 우선순위 interrupt가 걸리면, counter를 하나 증가시키고 nesting interrrupt를 먼저 처리한다.
  • 돌아올 때는 counter를 하나 감소시킨 뒤 원래 처리하던 ISR을 처리한다.
  • Counter가 0일 때는 걸린 interrupt가 없다는 의미이므로 scheduler는 다음 highest priority task를 수행한다.

4.4. Clock Tick ISR (TImer Service)

  • Clock Tick ISR은 이름 그대로 정해진 시간에 한 번씩 interrupt가 발생해 수행하는 ISR을 의미한다.
  • 특정시간 후에 어떤 일을 수행하고 싶을 때, 일정한 주기마다 어떤 일을 반복하고 싶을 때 사용한다.
  • Clock tick ISR도 위에서 봤던 다른 vector type interrupt와 유사하게 배열 또는 linked list를 이용해서 callback 함수들을 관리하면 편리하다.
    1. 특정 API를 call해서 clock tick ISR callback 함수와 시간을 등록한다.
    2. Clock tick ISR은 정해진 시간 (보통 5ms 사용)마다 한 번씩 interrupt가 걸려 호출된다.
    3. Clock tick ISR은 callback 함수 배열 또는 linked list를 순회하며 기록된 시간으로부터 5ms를 감소한다.
      • 이때 0ms가 돼 expire 된 callback 함수는 함수 포인터를 사용해서 바로 호출해버린다.
      • 호출된 callback 함수는 배열 또는 list에서 삭제한다.
      • [※] 조금 궁금한게, 만일 expire된 callback 함수가 많다면 앞에서부터 호출 및 실행하는 동안에 뒤에 있는 함수들은 정해진 시간이 아니라 한참 늦은 시간에 호출되는게 아닌가? 라는 궁금증이 생기네요. 그 부분에 대한 설명은 없네용.
  • Kernel의 timer는 이런 clock tick ISR을 이용한 service다.

4.5. Watchdog

  • 임베디드 시스템에는 거의 대부분 watch dog이라는 timer 기능을 하는 HW가 존재한다.
  • Watch dog timer란, 모든 task가 정상적으로 운용되고 있음을 확인해 문제 발생 시(i.e. starvation, deadlock 등) HW적으로 타겟을 reset 시키는 HW를 말한다.
  • Watch dog timer를 관리하는 task가 있다.
    • 이 task는 다른 모든 task의 실행주기 및 보고해야 하는 주기를 알고 있다.
    • 다른 모든 task는 주기마다 한 번씩 이 task에게 ‘저 정상적으로 잘 동작했어요’라고 보고해야 한다.
    • 이 task는 주기적으로 wake-up 돼 task들이 잘 보고했는지 확인한 뒤 문제 확인 시 target을 reset 시킨다.
void watch_dog_task(void) {
    set_timer(watch_dog_task, SIGNAL_WAKE_UP, 적당한 주기);
    wait_signal(SIGNAL_WAKE_UP);
    // ... sleep and wait
    if (get_signal() == SIGNAL_WAKE_UP) {
        for (task 개수) {
            /* 각 task의 'report 주기'에서 '적당한 주기'를 빼준다.
            빼준 값이 0 이하가 되면 target을 reset 시킨다.
            report한 task는 'report 주기'를 원상복귀 해준다. */
        }
        clear_signal(SIGNAL_WAKE_UP);
    }
}
  • Watch dog timer 관리 task는 위와 같은 형식으로 구현한다.
  • Target을 watch dog reset 시키는 범인은 report를 하지 않은 task일 수도 있지만, 그보다 priority가 높은 task가 원인인 경우도 많다. 걔 때문에 다른 task가 starvation에 처해 report를 하지 못했을 수도 있다.

5. 다시 kernel로

5.1. Bootup 중 kernel로의 진입 (main() 함수)

  • [※] 우리는 지금까지 reset handler와 __rt_entry()에서 시스템 동작 및 C라이브러리를 준비한 뒤 main()함수로 진입하는 과정을 배웠습니다. 이제 main()에 진입한 후 본격적으로 kernel에 진입하기 까지 어떤 과정을 수행하는지 알아봅시다.
  • Kernel의 entry point는 바로 main()함수다.
    void idle_task(void) {
        define_tasks();    // task들을 정의하고 생성한다.
        start_tasks();     // task들을 실행하고 scheduler도 실행한다.
        while (1) {
            wait_signal(IDLE_SIGNAL);
        }
    }
    
    void kernel_init(
        tcb_type    pTcb,
        byte*       pStack,
        dword       priority,
        func_type   fTask,
        char*       pTaskName) {
            pTcb->sp = pStack;            // SP 초기화
            pTcb->priority = priority;    // 우선순위는 최하위로!
            while ( (pTcb->task_name[idx] = pTaskName[idx]) && (idx++ < 200));
            fTask();    // idle task를 실행한다.
    }
    
    int main(void) {
        kernel_init(
            &idle_task_tcb,
            (void *)idle_stack,
            idle_priority,
            idle_task,
            "IDLE_TASK"
        );
        return 0;
    }
    1. kernel_init()등의 함수를 통해 IDLE task를 생성하는 걸로 시작한다.
      • IDLE task는 그 어떤 task도 실행되지 않을 동안 kernel을 점유하고 있을 task를 말한다. 우선순위는 최하위로 설정되며 idle task에게 순서가 돌아가는 경우는 거의 없다. 왜냐하면, 전력소모 감소를 위해 수행할 task가 없을 때는 시스템이 sleep mode로 동작하게끔 HW적으로 구현되곤 하기 때문이다.
    2. IDLE task를 생성한 뒤 실행한 뒤에는 수행할 task들을 define하고 start하는 과정이 수행된다.
    3. 마지막으로 scheduler를 작동시키면, 이제 kernel 및 IDLE task 위에서 define 된 tasks가 우선순위에 따라 동작한다.
    4. 모든 task가 wait state 또는 sleep 상태여서 실행할 task가 없을 때 IDLE_SIGNAL signal이 전달돼 IDLE task가 깨어나 무한 loop를 돌게 된다.

5.2. Kernel을 포팅한다는 것의 의미

  • ‘Kernel을 포팅한다’라는 말을 참 많이 들어봤을 탠데, 대체 정확히 무엇을 의미하는 걸까?
  • Porting 한다는 것은 ‘이미 만들어져 있는 SW가 target에서 정상 동작할 수 있도록 수정 + 개선하는 작업을 총칭한다.
  • 즉, 이미 만들어져 있는 kernel 및 embedded OS가 우리가 사용할 target에서 동작하게끔 만들고 있다는 의미다.
  • 따라서 필연적으로 CPU 종속적인 코드를 수정하는 작업이 포함되며 대략 다음과 같은 작업들로 구성돼있다.
    1. Interrrupt lock & unlock을 하는 방식에 대한 고려
    2. Context switching 시 백업해야 하는 context에 대한 고려
    3. Stack이 쌓이는 방향에 대한 고려
    4. SWI를 호출하는 방법에 대한 고려
    5. Interrupt 처리 방법에 대한 고려
    6. CPU 동작 mode에 따른 stack 관리 방법에 대한 고려
    7. Watch dog timer 설정에 대한 고려
  • 거의 모두 CPU마다 달라질 수 있는 사항들에 대해 수정하는 작업으로 이뤄져있다.
  • Kernel과 target의 CPU가 어떻게 동작하는지 모두 이해해야 가능한 작업이므로 결코 쉬운 작업이 아니다. 위 7가지 작업을 보면서 어떻게 해야할지 대략적인 감이 온다면, 포팅 할 수 있는 기본자세가 됐다고 보면 무리가 없다.
profile
임베디드 시스템 공학자를 지망하는 컴퓨터공학+전자공학 복수전공 학부생입니다. 타인의 피드백을 수용하고 숙고하고 대응하며 자극과 반응 사이의 간격을 늘리며 스스로 반응을 컨트롤 할 수 있는 주도적인 사람이 되는 것이 저의 20대의 목표입니다.

0개의 댓글