Part14. Tutorial2-멀티 태스킹(Multitasking) 기능을 추가해 보자

원문 :  http://kkamagui.springnote.com/pages/353530

 

들어가기 전에...

0.시작하면서...

 프레임워크에서 태스크 스위칭(Task Switching)을 구현하는 방법에 대한 내용은 참고. Multi Tasking 구현 방법에 설명해 놓았으니 참고하도록 하고, 여기서는 간단한 스케줄러를 구현하는 것을 목표로 하자.

 

1.태스크 스위칭(Task Switching) 관련 함수

 00Kernel/Custom/KShell.c 파일을 열어보면 기본적인 태스크 스위칭 코드가 포함되어있다.

  1. // Task를 저장한다.
    TASK vstTask[ 2 ];char g_vcPrompt[8] = "[FRAME] ";
    int g_iCurrentTask;
    BOOL g_bScheduleStart = FALSE;
  2. /**
        Timer Callback에서 수행되는 Scheduling 함수
    */
    void Scheduler( void )
    {
        if( g_bScheduleStart == FALSE )
        {
            return ;
        }
        g_iCurrentTask = abs( 1 - g_iCurrentTask );
        kSwitchTask( &( vstTask[ abs( 1- g_iCurrentTask ) ] ),
            &( vstTask[ g_iCurrentTask ] ) );
    }
  3. /**
        KShell 의 Main
    */
    void Shell()
    {
        // Task 설정 kSetupTask( &( vstTask[ 0 ] ), ShellLoop, NULL );kSetupTask( &( vstTask[ 1 ] ), EdgeDraw, NULL );    g_iCurrentTask = 0;
        g_bScheduleStart = TRUE;
  4.     ShellLoop();
    }

 위의 파란색 부분이 스위칭에 관련된 부분이다. Shell() 함수와 Scheduler() 함수를 보면 2개의 태스크를 설정하고 두 태스크를 번갈아가면서 호출하는 것을 알 수 있다. 여기서 눈여겨 봐야 하는 함수 2개는 kSetupTask()kSwitchTask() 이다(참고. 프레임워크 주요 함수들 내용 참고). 

  • kSetupTask() : 태스크 구조체와 태스크의 엔트리 포인트, 그리고 태스크 종료 시 호출될 함수의 엔트리 포인트를 받아서 태스크를 설정하는 역할 수행. 태스크 구조체를 생성하는 함수
  • kSwitchTask() : 현재 수행중인 태스크를 저장할 태스크 구조체와 다음에 실행할 태스크를 로드할 태스크 구조체를 받아서 두개를 스위칭하는 역할 수행. 태스크 스위칭을 수행하는 함수

 kSetupTask() 함수의 마지막 파라메터는 NULL로 설정가능한데, 디폴트 태스크 종료 핸들러를 부르겠다는 의미로 사용된다.

 디폴트로 설정된 태스크 종료 함수는 kEndTask()로 설정되어있고 아래와 같다.

  1. /**
        Task의 종료 처리
    */
    void kTaskEnd( void )
    {
        // 필요한 뭔가를 하자
        while( 1 ) ;
  2. }

 만약 위의 무한 루프 코드를 삭제하면 어떻게 될까? 그럼 kTaskEnd() 함수에서 return을 하게되는데 이때 스택은 Top이 Bottom을 지나가게되고 알 수 없는 곳으로 점프하여 이상한 코드를 실행하게 될 것이다. 운이 좋으면 수십초 정도 지나서 Fault가 발생할 것이고, 운이 나쁘면 점프한 즉시 Fault가 발생하여 커널이 정지 된다. @0@)/~ 

 이런 수습이 불가능한 상황을이 되기전에 막아야 하므로, 디폴트 핸들러는 무한 루프를 실행하여 다른 곳으로 가지 못하게 한다.

 

2.간단한 스케줄러 설계

 자 이제 간단한 스케줄러에 대한 이야기를 해보자. 우리가 만들 스케줄러는 아래와 같다.

  • 라운드로빈(Round-Robin)의 알고리즘을 이용
  • 태스크의 수는 최대 30개(왜? 너무 크면 커널 스택을 넘어서기때문)
  • 태스크 리스트의 형태는 링크드 리스트(Linked List)의 형태
  • 타이머 인터럽트를 이용

 그럼 이제 구현을 위해 수정해야할 부분을 하나하나 살펴보자.

 

3.구현

3.1 Task.c/h 파일 수정

 일단 태스크 구조체를 조금 수정해서 태스크를 링크드 리스트의 형태로 만들 수 있도록 하자. FW/Task.h 파일을 열어서 TASK 구조체가 아래와 같이 되어있는 지 확안하고 그렇지 않다면 수정하자.

  1. // Task에 대한 정보 저장
    typedef struct kTaskStruct
    {
        // Stack의 15DWORD를 Register를 저장하는데 사용한다.
        DWORD vdwStack[ MAX_STACKSIZE ];
  2.     int iTID; 
  3.     struct kTaskStruct* pstNext;
    } TASK, *PTASK;

 언제나 그렇듯이 파란색 부분이 주의깊게 볼 부분이다. 태스크 구분을 위한 iTID를 추가하고 다음 태스크 구조체를 연결하기위한 pstNext 부분을 추가했다.

 

 아래 함수는 FW/Tash.c 파일에 포함된 함수인데, 태스크 구조체, 태스크 시작 엔트리 포인트, 태스크 종료 엔트리 포인트를 받아서 태스크 구조체를 설정하는 함수이다. Task가 종료되었을 때 불리는 함수를 설정하는 부분에 버그가 있어서 붉은색 부분을 수정했다. 붉은색 부분을 확인하자.

  1. /**
        TASK 구조체를 설정한다.
    */
    BOOL kSetupTask( PTASK pstTask, void* pfStartAddr, void* pfEndAddr ){
        DWORD* pdwStackTop;
  2.     // 구조체를 초기화 한다.
        kMemSet( pstTask, 0, sizeof( pstTask->vdwStack ) );
  3.     // Stack의 Top
        pdwStackTop = ( DWORD* ) ( pstTask->vdwStack + ( MAX_STACKSIZE - 1 ) );
  4.     // ESP, EBP, EFLAG, EAX, EBX, ECX, EDX, ESI, EDI 순으로 Push 한다.
        pstTask->vdwStack[ 14 ] = ( DWORD ) ( pdwStackTop - 1 );
        pstTask->vdwStack[ 13 ] = ( DWORD ) ( pdwStackTop - 1 );
        pstTask->vdwStack[ 12 ] = EFLAG_DEFAULT;
  5.     // cs, ds, ss, es, fs, gs는 kernel의 기본값으로 설정해 준다.
        pstTask->vdwStack[ 5 ] = GDT_VAR_KERNELCODEDESC;
        pstTask->vdwStack[ 4 ] = GDT_VAR_KERNELDATADESC;
        pstTask->vdwStack[ 3 ] = GDT_VAR_KERNELDATADESC;
        pstTask->vdwStack[ 2 ] = GDT_VAR_KERNELDATADESC;
        pstTask->vdwStack[ 1 ] = GDT_VAR_KERNELDATADESC;
        pstTask->vdwStack[ 0 ] = GDT_VAR_KERNELDATADESC;
  6.     // 마지막으로 Stack의 Return Address를 pfAddr로 설정해 준다.
        pdwStackTop[ -1 ] = ( DWORD ) pfStartAddr;
        // 만약 Task 종료 함수가 설정되지 않으면 Default를 설정
        if( pfEndAddr != NULL )
        {
            pdwStackTop[ 0 ] = ( DWORD ) pfEndAddr;
        }
        else
        {
            pdwStackTop[ 0 ] = ( DWORD ) kTaskEnd;
        }
        return TRUE;
    }

 

3.2 Scheduler.c/h 파일 추가

 Custom/Scheduler.c/h 파일은 스케줄러의 구현이 들어갈 파일이다. KShell.c 파일에 스케줄러를 구현해도 되지만 코드가 길어지고 쉘과는 크게 관계 없는 부분이므로 따로 구현하는게 좋다.

 아래의 코드는 Scheduler.h에 포함된 스케줄러 관련 구조체이다. 스케줄러의 태스크 관리 부분은 태스크의 pstNext 필드를 이용하여 링크드 리스트(Linked List)를 이용해서 구현했다.

  1. // 개수를 너무 크게 설정하면 4M 이상으로 넘어가버린다.
    // 그렇게 되면 커널 스택이 데이터를 덮어쓰게 되서 문제가 발생하므로 적당히 조절해야한다.
  2. #define MAX_TASKCOUNT 30
  3.  
  4. // 스케줄러 구현을 위한 태스크 정보를 포함한 구조체
    typedef struct SchedulerStruct
    {
        int iTaskCount;

        // 리스트의 헤더
        TASK* pstHeader;

        // 플래그 변수들
        BOOL bEnableScheduler;
        BYTE bLock;
       
        // 현재 수행중인 태스크 ID
        int iCurrentTID;

        // 태스크 저장을 위한 공간
        BOOL vbAlloc[ MAX_TASKCOUNT ];
        TASK vstTask[ MAX_TASKCOUNT ];
    } SCHEDULER,* PSCHEDULER;

 

 태스크를 추가하고 삭제하는 것에 대한 세부 내용은 그리 어렵지 않으므로 첨부파일을 살펴보도록 하고, 약간의 트릭이 가미된 부분을 중점적으로 살펴보자.

 스캐줄러를 구현할 때마다 고민하는 부분이기도 한데... 구현에 문제가 되는 부분이 태스크가 자신의 ID를 알아내는 방법이다. 태스크가 자신의 ID 즉 TID가 얼마인지 어떻게 알까? 가장 쉬운 방법은 스케줄러가 현재 태스크의 ID를 글로벌 변수 같은데 저장하고 태스크가 그 변수에 접근해서 읽는 것이다.

 이 방식을 사용한다면 태스크 스위칭 시에 글로벌 변수에서 자기 ID를 읽어온 뒤에 다음 태스크를 검색하여 찾고, 스위칭 하기 전에 글로벌 변수에 다음 태스크 번호로 업데이트한 후 스위칭을 완료해야 한다. 왜냐하면 스케줄러가 호출되고 난 뒤는 이미 다른 태스크로 스위칭된 상태이므로 중단되었다가 복원된 태스크가 갑자기 글로벌 변수에 현재 TID를 바꾼다는 것은 기대하기 힘들다.

 그럼 여기서 발생하는 문제!!! 타이머에서 스케줄러 함수를 호출하고 태스크도 수시로 스케줄러 함수를 호출하면 어떻게될까?

 별 다른 처리를 하지 않았다면... 정답은 "엉망이 된다" 이다. ㅡ_ㅡ;;;;  아래 코드를 한번 보자.

  1. /**
        다른 태스크를 실행시킨다.
    */
    void SwitchTask( void )
    {
        TASK* pstCurTask;
        TASK* pstNextTask;
  2.     pstCurTask = &( gs_stScheduler.vstTask[ GetCurrentTID() ] );
        pstNextTask = pstCurTask->pstNext;
        if( pstNextTask == NULL )
        {
            pstNextTask = gs_stScheduler.pstHeader;
        }
        gs_stScheduler.iCurrentTID = pstNextTask->iTID;
  3.     kSwitchTask( pstCurTask, pstNextTask );
  4. }

 위의 상황을 실제 코드에서 시뮬레이션 한번 해보자. 커널에 총 3개의 태스크가 존재한다고 생각하고 현재 태스크를 T1이라 하고 T1의 다음 태스크를 T2, T2의 다음 태스크를 T3라고 가정하자.

 태스크가 스케줄러 함수를 호출해서 위의 파란라인까지 실행한다음, 타이머에 의해서 다시 스케줄링이 되면 어떻게 될까? 아직 태스크 스위칭 함수가 호출되지 않은 상태에서 글로벌 변수인 gs_stScheduler.iCurrentTID 값이 T2로 바뀐 상태이므로 타이머에 의해서 스케줄러가 호출되었을 때는 T2의 태스크 구조체에 T1의 태스크를 저장하게 되고 복원되어 실행되는 태스크는 T3가 된다.

 순식간에 T2 태스크가 사라져 버렸다. 그리고 다시는 T2 태스크를 볼 수 없을 것이다.

 

 실제로 이 코드를 그대로 돌려보면 아래와 같은 기분좋은(??) 크래쉬 화면을 볼 수 있다(General Fault는 코드 실행 시에 발생한 Exception을 의미한다).

크래쉬.PNG

<프레임워크 크래쉬 화면>

 

 정상적으로 실행하기위해서 코드를 어떻게 수정해야 할까? 해결책은 간단하다. 위 코드를 하나의 태스크만 실행하도록 수정하면 된다. 가장 간단한 방법으로는 위 코드의 시작부터 끝까지를 인터럽트 불가로 설정하면 된다. 이렇게 하면 실행에는 문제가 없지만 스위칭을 할때마다 인터럽트가 불가가되니 인터럽트 처리에 문제가 생길 수 있다(인터럽트 지연이 발생한다).

 그럼 다른 방법은 없는걸까? 조금만 더 생각해 보면 인터럽트를 불가해야 하는 부분을 줄일 수 있다. 크게 두가지 부분으로 나눌 수 있는데, 첫번째 부분은 스케줄링 함수를 중복으로 호출하지 못하게 하여 처리할 수 있는 부분이고, 두번째 부분은 인터럽트 불가로 해결해야하는 부분이다.

  1. /**
        다른 태스크를 실행시킨다.
    */
    void SwitchTask( void )
    {
        TASK* pstCurTask;
        TASK* pstNextTask;
        DWORD dwFlags;
  2.  
  3.     // Scheduler를 다른곳에서 호출 못하도록 Lock을 건다.
        if( kLock( &gs_stScheduler.bLock ) == FALSE )
        {
            return ;
        }
  4.     pstCurTask = &( gs_stScheduler.vstTask[ GetCurrentTID() ] );
        pstNextTask = pstCurTask->pstNext;
        if( pstNextTask == NULL )
        {
            pstNextTask = gs_stScheduler.pstHeader;
        }
        gs_stScheduler.iCurrentTID = pstNextTask->iTID;

  5.     // 플래그 레지스터를 저장하고, 인터럽트를 불가로 설정한다.
        dwFlags = kReadFlags32();
        kClearInt();
  6.  
  7.     kUnlock( &gs_stScheduler.bLock );
  8.     kSwitchTask( pstCurTask, pstNextTask );   
        // 플래그 레지스터를 복원하여 이전 인터럽트 플래그를 복구한다.
        kWriteFlags32( dwFlags );
  9. }

 위 부분에서 푸른색 부분이 태스크 스위칭을 중복으로 허용하지 않으면 해결할 수 있는 부분이다. 주의해서 볼 것은 붉은 색 부분인데, 이 부분은 인터럽트와 관련된 부분으로 인터럽트 불가를 설정한 뒤 스위칭을 하고 다시 원래의 인터럽트 플래그를 복원하는 코드이다.

 이렇게하면 혹시나 현재 저장된 이 태스크가 다시 복원되어 플래그 레지스터를 복원하기 전까지는 인터럽트가 불가가 되는게 아닐까? 너무 위험한 생각이 아닐까?

 그렇지 않다. 태스크 스위칭 함수를 호출했을 때 복원되고 저장되는 레지스터중에 EFLAG 레지스터(인터럽트나 각종 상태가 저장되어있는 레지스터)가 포함되어 있기 때문이다. 다시 말해 태스크 별로 인터럽트 가능/불가 플래그를 가지고 있으므로 다른 태스크를 복원했을 때 복원한 태스크가 인터럽트 가능 상태였다면 EFLAG 레지스터 복구를 통해 자연스럽게 가능 상태로 설정된다.

 만약 이것을 kUnlock() 함수를 호출해서 태스크 스위칭 불가 플래그를 풀어주는 방법으로 구현한다고 생각해보자. 태스크를 복원했을 때 제일 처음 해야 할일이 kUnlock()을 호출하는 일이기 때문에 여러가지 꼼수를 사용해서 이를 처리해야 하는데 굉장히 복잡하다.(한번 상상을 해보자... 어떻게 구현할 것인지.. ㅡ,.ㅡ;;;).

 플래그(EFLAG) 레지스터는 태스크 스위칭을 하면서 복원되기 때문에 아주 간단하게 인터럽트 불가 영역을 줄이면서 동기화의 문제를 해결할 수 있다.

 

 뭐 사실 지금 상황에서 인터럽트 불가 영역을 줄이는게 큰 의미가 있겠냐고 의문을 가지는 사람이 있을지도 모르겠다. 스케줄러 전 영역을 태스크 불가로 만들어도 괜찮겠다고 생각하는 사람은 스케줄러 코드가 수십줄이 아니라 수천줄일 때도 괜찮을지 생각해 보기 바란다. 이런 복잡한 스케줄러 코드에서 스케줄러 함수의 시작부터 끝까지 인터럽트를 불가를 한다면? 결과는 상상에 맡기겠다 @0@)/~

 

 kLock()과 kUnlock() 함수는 Atomic Operation으로 플래그의 값을 변경해주고 그 결과를 리턴값으로 나타내는 함수이다. Atomic Operation은 해당 역할을 끝내기 전에 다른 이유로하여 중단됨이 없다는 것을 보장하는 동작이다. 따라서 세머포어(Semaphore)나 뮤택스(Mutex)와 같은 동기화 객체 구현에 많이 사용된다.

 kClearInt() 함수와 kReadFlags32(), kWriteFlags32() 함수는 플래그(EFLAG) 레지스터와 관련된 함수인데 Intel Architecture에 관련된 부분이라서 자세하게 설명하진 않겠다.  자세한 것은 참고. 프레임워크 주요 함수들과 Part5. Intel Architecture에 대한 소개 문서를 참고하자.

 스케줄러의 전체 파일은 아래에 첨부했다.

 

3.3 KShell.c/h 파일 수정

 테스트용 코드가 삽입되어있던 부분을 스케줄러 파일로 다 옮기고 간단히 수정한다. 쉘의 전체 파일은 아래의 코드 첨부를 통해 다운 받도록 하자.

  1. /**
        KShell 의 Main
    */
    void Shell()
    {
        // 초기화
        InitScheduler();
  2.     // 새로운 태스크 등록
        AddTask( EdgeDraw );
  3.     EnableScheduler( TRUE );    
        // Shell의 실행
        ShellLoop();
  4.     while( 1 );
    }

 

3.4 makefile 파일 수정(프레임워크 1.0.3 버전 이전)

 프레임워크 1.0.3 버전 이전 사용자는 makefile을 수정해 주어야 한다. Scheduler.c/h 파일을 추가했으니 makefile을 수정하자.

  1. # 응용 프로그램 파일
    FW.o : $(CUSTOMDIR)Framework.c
     $(GCC) -o FW.o $(CUSTOMDIR)FrameWork.c
    KShell.o : $(CUSTOMDIR)KShell.c
     $(GCC) -o KShell.o $(CUSTOMDIR)KShell.c
    Sched.o : $(CUSTOMDIR)Scheduler.c $(GCC) -o Sched.o $(CUSTOMDIR)Scheduler.c
  2. #Object 파일 이름 다 적기
    #아래의 순서대로 링크된다.
    OBJ = A.o K.o Is.o D.o Int.o Key.o Stdlib.o Task.o FW.o KShell.o Sched.o

 Scheduler.c 파일을 추가했으니 makekernel.bat를 실행해 보자.

 

4.마치면서...

 이번에 스케줄러 코드를 추가하면서 그 동안 숨겨져왔던 코드들의 버그가 속속들이 드러났다. 디버깅한다고 혼쭐이 났는데... 고생한걸 생각하면 눈물이.. ㅜ_ㅜ...

 기념으로 스크린 샷 하나 올린다. starttask 명령과 showtask 명령이 추가되었다. starttask 함수는 EdgeDraw 태스크를 실행해주는 역할을 하고, showtask 함수는 현재 동작중인 태스크의 개수를 리턴하는 역할을 한다.

멀티태스크.PNG

<멀티 태스킹 실행 화면>

 

 

5.첨부

5.1 프레임워크 1.0.3 버전 이전

 프레임워크 1.0.3 버전 이전 파일이다. 다운 받아서 덮어쓰면 된다(기존 코드의 버그도 같이 수정했다).

  • Custom.zip : Custom 폴더의 파일들
  • FW.zip : FW 폴더의 파일들 
  • makefile : Scheduler 파일 빌드용 makefile 

 

5.2 프레임워크 1.0.3 버전 이후

 프레임워크 1.0.3 버전 이후 파일이다. 다운 받아서 덮어쓰면 된다.

 

이 글은 스프링노트에서 작성되었습니다.

+ Recent posts