[OS 개발 16] 태스크 스위칭과 보호 (1)


1. 태스크의 개념


이번 포스팅에서는 태스크 스위칭에 대해 포스팅하겠습니다. 

 

CPU는 작동하는 과정에서 많은 프로세스들을 동시에 실행하고 이를 처리합니다. 여기에서 프로세스가 태스크(Task, 작업)를 가리키는 하나의 유닛으로 볼 수 있습니다. 즉, 태스크는 CPU가 처리하는 일련의 작업들의 각 개체라고 보시면 되겠습니다. 이러한 태스크는 커널에서 관리하고 처리해주는 메커니즘을 적절하게 구현해줘야 합니다. 왜냐하면, 무수히 많은 태스크들이 앞으로 운영체제에서 작동해야 할텐데, 이를 어떤 순서로 어떻게 호출하고 실행할 것인가에 대한 명시가 없다면, 태스크 처리상에 문제가 발생할 것이기 때문입니다. 우리가 다루는 윈도우나 리눅스도 멀티 태스킹 작업을 하면서 보기에는 동시에 프로세스가 처리되는 것처럼 보이지만, 자세히 들여다보면, 정해진 순서에 맞게 진행됩니다. 스레드의 경우에도 마찬가지입니다. 즉, 스레드이든 프로세서이든 모든 태스크를 위한 일련의 처리 방법을 구현해야 하는 것입니다.

 

태스크는 다음과 같이 두 가지 부분으로 구성됩니다.

1. 태스크 실행 영역

2. 태스크 상태(TS) 세그먼트(TSS)

 

운영체제가 프로세서의 특권 레벨 보호 메커니즘을 사용한다면, 태스크 실행 영역은 각 특권 레벨별로 스택을 분리시켜주기 위해 사용됩니다(특권 레벨은 좀 더 뒤에서 다룰 것이다) 여기에서 태스크 실행 영역은 다음과 같이 구성되어 있습니다.

 

1. 코드 세그먼트

2. 스택 세그먼트

3. 하나 이상의 데이터 세그먼트

 

이러한 각 태스크들을 구분지어서 실행을 처리해야할 필요가 있는데, 이는 TSS에 의해 식별될 수 있습니다. 태스크가 프로세서 실행을 위해 로드될 때 세그먼트 셀렉터, 베이스 어드레스, 세그먼트 리미트, 그 외 속성들이 하나의 TSS로 묶여있고, 이 TSS들이 태스크 레지스터로 로드되는 것입니다. 이 개념을 도식화 하면 다음과 같습니다.

 

 

 

 

그리고 각 테스크의 정보가 담긴 레지스터들을 한데 묶어서 저장하기 위한 공간인 TSS의 구성은 아래 그림과 같습니다.

 

그림 1. TSS 구성

 

TSS의 주요 필드

설 명

Previsou Task Link

 CALL 명령으로 태스크 스위칭을 한 후, 태스크를 진행하고, 이전 태스크로 돌아갈 때 IRET 명령을 통해 돌아가게 된다. 이 때 이전 태스크로 연결하기 위한 값으로 사용된다.

ESP, SS

 유저모드와 커널 모드에서 스택을 구분하기 위해 사용되는 공간이며, 레벨 0~3까지 지정 가능하다. 

CR3

 페이징 구현과 관련있다.

디버그용 T비트

 유저레벨에서 태스크를 진행할 때, 브레이크 포인트를 사용하여 디버깅하는 경우가 있는데, 다음 태스크로 스위칭되기 전에, 현재 태스크가 디버깅 중이었다는 사실을 알려주기 위해 사용된다.

 I/O MAP Base Address

 사용가능한 I/O 장치와 불가능한 장치를 구분하여 램의 한 공간에 표시해 둬야 하는데, 이때 램의 시작주소를 의미한다.

 

그리고 TSS의 각 필드를 두 개의 TSS 영역으로 코드를 통해 초기화 하면 다음과 같습니다.

 

코드 1. TSS1과 TSS2 데이터 저장 공간

 

그림 1에서 보다시피 하나의 TSS에 작업중에 필요한 대부분의 레지스터를 저장할 수 있는 것을 확인할 수 있습니다. 다음 태스크를 실행하기 위해서는 현재의 태스크 중에 저장되어 있는 값들을 보존해야 나중에 다시 현재 태스크로 돌아올 때 레지스터를 복원하여 계속 작업을 진행할 수 있기 때문입니다. 이를 그림으로 보면 다음과 같습니다.

 

 

그림 2. 간략한 태스크 스위칭 과정

 

즉, 램 영역에 현재 진행중인 레지스터들을 보내기에 앞서 TSS에 현재 태스크의 상태를 저장하기 위한 모든 레지스터들을 그림 2와 같이 저장하고 보내야 하는 것입니다. 단, 여기에서 LDTR은 TSS에 포함되진 않지만, LDTR도 현재 태스크의 상태를 나타내기 위한 하나의 아이템으로 사용됩니다. TSS에는 LDT의 세그먼트 셀렉터가 저장된 것을 표 1-1에서 확인할 수 있습니다.


 

그리고 이러한 태스크들이 존재한다는 사실을 CPU에 알려야 합니다. TSS 세그먼트 디스크립터를 통해서 말이죠. 이 TSS 세그먼트 디스크립터는 각 TSS와 1:1매칭되며, 아래와 같이 글로벌 세그먼트 디스크립터와 구성이 거의 흡사합니다.

 

 

그림 3. TSS 세그먼트 디스크립터

 

이 TSS의 세그먼트 디스크립터도 결국은 GDT에 들어가게 되며, 셀렉터 값이 들어갈 때, 다음과 같이 TSS 셀렉터 별로 8바이트 단위로 저장됩니다.

 

코드 2. TSS1,2의 셀렉터 값

 

 

그리고 위의 TSS 디스크립터의 비트 값을 다음과 같이 커널의 32비트 영역에서 코드로 구현하였습니다.

 

코드 3. 디스크립터 값 저장 공간

 

코드 3에서 볼 수 있듯이, 두 개의 TSS 디스크립터로 구성되어 있음을 볼 수 있습니다. 즉, 두 개의 태스크를 구성한 것입니다. 여기에서 첫 세그먼트 리미트 0~15비트 값을 104(=0x68)로 지정하였는데, 그 이유는 TSS의 디스크립터의 리미트 값은 항상 0x67 이상의 값을 가져야 하기 때문입니다. 만약 0x67 미만의 값을 가지게 되면 TSS 예외(#TS)가 발생하게 됩니다. 그리고 속성값 일부가 0x89로 지정되어 있습니다. 태스크 스위칭은 보통 커널에서 이루어지므로 DPL값은 00으로 두었습니다. Type값은 1001인데, 여기서 세번째 비트인 0은 B비트로, 이에 대해서는 뒤에서 보도록 하겠습니다.

 

그리고 다음과 같이 별도의 루틴을 통해 TSS의 베이스 어드레스에 물리주소 값을 넣는 코드를 커널의 16비트 단에서 구현하였습니다.

 

코드 4. TSS 디스크립터에 베이스어드레스 저장하기

 

코드 4에서 13~19라인, 21~26까지가 각 TSS의 베이스 어드레스를 TSS의 베이스 어드레스 필드에 세 영역으로 나누어 저장하는 루틴입니다. TSS1을 예로 설명해보도록 하겠습니다.

 

주소를 저장할 때 어떻게 분배되어 저장될까요? 먼저 이를 알아보려면, [tss1]의 주소값을 알아야 합니다. 이를 알아보기 위해 디스어셈블링 해보았습니다. 이때, 다음과 같은 명령어로 디스어셈블하게 되면, 부트로더가 메모리에 적재될 때의 첫 주소인 7C00을 기준점으로 두고 보여주기 때문에 더 편하게 볼 수 있습니다.

 

ndisasm.exe -b16 -o7c00h -a -s7c3eh kernel5.img > kernel5_dis.asm

 

32비트 eax에 tss1의 실제 주소를 살펴보니 아래 그림에서 붉은 언더라인으로 표시된 것과 같이 0x13d임을 볼 수 있습니다.

 

코드 5. 디스어셈블링을 통해 tss1, tss2 주소 찾기

 

확인된 tss1 영역의 주소값 0x13d를 커널 프로그램의 베이스 어드레스와 더하면 다음과 같습니다.

0x13d + 0x10000 = 0x1013d

 

이제 TSS1의 베이스 어드레스를 알았으니 TSS1 디스크립터의 베이스 어드레스 필드에 저장하면 됩니다. 아래와 그림과 같이 베이스 어드레스는 세 군데에 나뉘어 저장되는데, 위의 코드에서 나타낸 바와 같이 각 저장 위치의 시작 주소에 저장하기 위해 descript4에 각각 2바이트, 4바이트, 7바이트를 더한 것입니다. 

 

그림 4. 베이스 어드레스 저장

 

그리고 각 주소에 알맞는 Base Address 값이 저장되어 있는 레지스터를 저장하게 되는데, eax 레지스터에는 위에서 계산했던 디스크립터의 베이스 어드레스 주소가 다음과 같이 저장됩니다.

 

eax : 0x1013d -->0000 0000 0000 0001 0000 0001 0011 1101 (32비트)

ax: 0000 0001 0011 1101(eax의 오른쪽 16비트)

 

그런데, 코드 중간에 shr eax, 16 이라는 명령어가 보입니다. 이는 "eax의 비트를 오른쪽으로 16칸(16비트)만큼 이동하라" 라고 보시면 됩니다. 그리고 그 이동한 값이 다음과 같이 저장됩니다.

 

shr eax, 16 명령 수행 결과: 0000 0000 0000 0001

ah: 0000 0000 (쉬프트된 16비트의 왼쪽 8비트)

al: 0000 0001 (쉬프트된 16비트의 오른쪽 8비트)

 

(생각해보니 범용 레지스터의 eax, ax, ah, al과 같이 각 범용레지스터별 관계에 대해 설명을 안했던 것 같습니다... 시간이 되면 나중에 정리하도록 하겠습니다. 일단은 이렇게 된다는 것만 알아두면 될 것 같습니다)

이 베이스 어드레스 값들이 결국 다음과 같이 저장된다고 보면 됩니다. TSS2도 이와 같은 방식으로 보면 됩니다.

 

그림 5. 저장된 베이스 어드레스

 

여기까지 하면 TSS 셋팅을 완료하였습니다. 이제는 현재의 태스킹이 다음 태스킹으로 넘어가기 위한 방법과 다시 되돌아오는 방법, 즉 태스크 스위칭 방법에 대해 알아보도록 하겠습니다.


 


2. 태스크 스위칭 (JMP 스위칭)


 

다음 태스크를 실행하기 위해서 현재 실행중인 태스크를 TSS1에 묶어서 저장하고 이를 램으로 보낸 후에 다음 태스크를 불러들여야 합니다. 어떻게 가능할까요? 다음 태스킹으로 넘어갈 것임을 명시, 혹은 암시하는 구문을 넣어야 하는데, 다음과 같이 크게 5가지 방법으로 분류할 수 있습니다.

 

1. CALL 명령어로 다음 태스크를 불러들일 것임을 명시

2. JMP 명령어로 다음 태스크로 점프할 것임을 명시

3. 인터럽트 핸들러 태스크(interrupt-handler task)를 불러들일 것임을 암시

4. 예외 핸들러 태스크(exception-handler task)를 불러들일 것임을 암시

5. EFLAGS에 있는 NT 필드가 셋될 때 IRET 명령을 통해 되돌아가기

 

현재 태스크에서 다음 태스크로 넘어가기 위해서는 jmp나 call 명령이 사용되어야 하는데, 이 둘에는 사용 과정에서 약간의 차이점이 있습니다. 우선 점프할 경우 어떻게 진행되는지 아래 코드를 보겠습니다.

 

코드 6. TSS2로 점프하기 전에 현재 레지스터 값 저장하기

 

위 코드는 32비트 모드에서 태스크2로 점프하고 다시 돌아와서 내용을 출력하기까지의 과정을 보여주고 있습니다. 우선 TSS1 셀렉터를 ltr에 넣어주는데, 이는 현재 진행중인 태스크가 다른 태스크로 넘어갈 때, 현재 태스크의 값들을 이 TSS영역에 저장하라고 사전에 미리 지정하는 것압나더.

 

그리고 다음 lea eax, [process2] 부터 본격적으로 TSS로 넘어가기 위한 준비를 합니다. process2 영역(서블루틴)의 첫 주소를 eax에 저장하고 이 값을 [tss2_eip]에 저장합니다. 그리고 메모리 스택에서 TSS2의 영역으로 사용될 esp 포인터 값을 [tss2_esp]에 넣어둡니다. 이 [tss2_eip]와 [tss2_esp] 영역은 아래와 같으며, 현재 0으로 초기화된 부분에 각각 EIP, ESP 영역에 저장될 것입니다.

 

코드 7. tss2_eip와 esp 영역 초기화

 

그리고 다시 코드 5를 보면, jmp TSS2Selector:0을 통해 다음 태스크인 TSS2 서브루틴이 실행되는데 이 과정을 정리하면 다음과 같습니다. 포스팅하면서 가장 오래 걸렸던 작업이었습니다. 그런데 도식화하는 과정에서 규모가 커져서 확대해서 보시기 바랍니다. 참고로 여기서 5번은 2~3번과 동일한 과정이므로 생략했습니다. 이를 고려하여 보시기 바랍니다.

 

그림 6. 점프부터 루틴시작 전까지의 과정

 

 

이러한 과정을 거치고 Process2 루틴이 시작되며, 이 프로세스2 루틴에 대한 코드는 아래와 같습니다.

 

코드 8. 서브루틴 실행 후 되돌아가기

 

 

그리고 이 TSS2 서브루틴 마지막에 jmp TSS1Selector:0을 실행하면 다시 위의 도식과 같은 과정을 거쳐서 코드 5에서 mov edi, 80*2*9 부터 명령을 실행하게 됩니다.

 

그리고 코드를 실행해보면 최종적으로 다음과 같은 화면이 출력됩니다.

 

 

 

여기까지 태스크의 개념부터 스위칭까지 알아보았습니다. 지금은 2개의 프로세스만 구현하여 스위칭에 대한 매우 기초적인 부분만 다루어 보았습니다 어려운 개념은 아닌데 저도 이해하기까지 시간이 좀 걸렸네요. 다음 포스팅에서는 CALL 명령을 통한 스위칭에 대해 다루도록 하겠습니다.

 

 


TAGS.

Comments