본문 바로가기
OS/만들면서 배우는 OS 커널의 구조와 원리

5. 태스크 스위칭, 문맥 교환

by 내용증명장인 2026. 4. 13.

태스크 스위칭은 CALL, JMP, IRET 등에서 발생하며, CPU가 수행하는 태스크가 교체될 때, 교체되기 전 태스크의 상태를 특정 저장 장소에 저장하고 교체될 태스크의 레지스터 상태를 특정 저장 장소로부터 CPU에 로드하는 것을 태스크 스위칭이라 한다.

태스크 스위칭 예제 코드를 작성하였다.

init.inc

SysCodeSelector equ 0x08
SysDataSelector equ 0x10
VideoSelector   equ 0x18
TSS1Selector    equ 0x20
TSS2Selector    equ 0x28

boot.asm

%include "init.inc"

[org 0]
    jmp 07C0h:start

start:
    mov ax, cs
    mov ds, ax
    mov es, ax

    mov ax, 0xB800
    mov es, ax
    mov di, 0
    mov ax, word [msgBack]
    mov cx, 0x7FF

paint:
    mov word [es:di], ax
    add di, 2
    dec cx
    jnz paint

read:
    mov ax, 0x1000
    mov es, ax
    mov bx, 0

    mov ah, 2
    mov al, 2
    mov ch, 0
    mov cl, 2
    mov dh, 0
    mov dl, 0
    int 13h

    jc read
    
    mov dx, 0x3F2
    xor al, al
    out dx, al

    jmp 0x1000:0000

msgBack db '.', 0x67

times 510-($-$$) db 0
dw 0AA55h

kernel.asm

%include "init.inc"

[org 0x10000]
[bits 16]

start:
    cld
    mov ax, cs
    mov ds, ax
    xor ax, ax
    mov ss, ax

    xor ebx, ebx
    lea eax, [tss1]
    add eax, 0x10000
    mov [descriptor4+2], ax
    shr eax, 16
    mov [descriptor4+4], al
    mov [descriptor4+7], ah

    lea eax, [tss2]
    add eax, 0x10000
    mov [descriptor5+2], ax
    shr eax, 16
    mov [descriptor5+4], al
    mov [descriptor5+7], ah

    cli
    
    lgdt [gdtr]

    mov eax, cr0
    mov eax, 0x00000001
    mov cr0, eax

    jmp $+2
    nop
    nop

    jmp dword SysCodeSelector:PM_Start


[bits 32]

PM_Start:
    mov bx, SysDataSelector
    mov ds, bx
    mov es, bx
    mov fs, bx
    mov gs, bx
    mov ss, bx

    lea esp, [PM_Start]

    mov ax, TSS1Selector
    ltr ax
    lea eax, [process2]
    mov [tss2_eip], eax
    mov [tss2_esp], esp

    jmp TSS2Selector:0

    mov edi, 80*2*9
    lea esi, [msg_process1]
    call printf
    jmp $

;---------------- SubRoutines ------------------
printf:
    push eax
    push es
    mov ax, VideoSelector
    mov es, ax

printf_loop:
    mov al, byte [esi]
    mov byte [es:edi], al
    inc edi
    mov byte [es:edi], 0x06
    inc esi
    inc edi
    or al, al
    jz printf_end
    jmp printf_loop

printf_end:
    pop es
    pop eax
    ret

process2:
    mov edi, 80*2*7
    lea esi, [msg_process2]
    call printf
    jmp TSS1Selector:0

;-------------- Data Area ---------------

msg_process1 db "This is System Process 1", 0
msg_process2 db "This is System Process 2", 0

gdtr:
    dw gdt_end-gdt-1
    dd gdt

gdt:
    dd 0, 0
    dd 0x0000FFFF, 0x00CF9A00
    dd 0x0000FFFF, 0x00CF9200
    dd 0x8000FFFF, 0x0040920B

descriptor4:
    dw 104
    dw 0
    db 0
    db 0x89
    db 0
    db 0

descriptor5:
    dw 104
    dw 0
    db 0
    db 0x89
    db 0
    db 0

gdt_end:

tss1:
    dw 0, 0
    dd 0
    dw 0, 0
    dd 0
    dw 0, 0
    dd 0
    dw 0, 0
    dd 0, 0, 0
    dd 0, 0, 0, 0
    dd 0, 0, 0, 0
    dw 0, 0
    dw 0, 0
    dw 0, 0
    dw 0, 0
    dw 0, 0
    dw 0, 0
    dw 0, 0
    dw 0, 0

tss2:
    dw 0, 0
    dd 0
    dw 0, 0
    dd 0
    dw 0, 0
    dd 0
    dw 0, 0
    dd 0

tss2_eip:
    dd 0, 0
    dd 0, 0, 0, 0

tss2_esp:
    dd 0, 0, 0, 0
    dw SysDataSelector, 0
    dw SysCodeSelector, 0
    dw SysDataSelector, 0
    dw SysDataSelector, 0
    dw SysDataSelector, 0
    dw SysDataSelector, 0
    dw 0, 0
    dw 0, 0

times 1024-($-$$) db 0

위 코드를 컴파일 후 부팅하면 다음과 같은 화면이 출력된다.


TSS

태스크 스위칭을 구현하기 위해서는 레지스터 값들을 저장할 공간이 있어야 하는데 이를 TSS(Task State Segment)라고 한다.
TSS 디스크립터는 GDT에 지정되어 있다.
각 태스크마다 TSS를 가지고 있고, TSS 디스크립터를 가져 GDT에 각각 존재하게 된다.
TSS 구조는 다음과 같다.

TSS 표 출처 : https://idioth.github.io/2020/03/15/os-kernel-ch5/

 

만들면서 배우는 OS 커널의 구조와 원리 ch5. Task Switching

TSS(Task State Segment) 선점형 방식 : 프로그램이 어떤 상황이던 일단 정지시키고 다른 프로그램이 이전에 실행했던 곳부터 다시 실행되도록 하는 방식 CPU에서 수행되던 프로그램의 레지스터 값들을

idioth.github.io

 

Back Link
JMP, CALL 명령어로 인해 Task A에서 B로 전환될 때, B의 TSS는 A로 돌아가기 위해 back link에 A의 세그먼트 셀렉터 값을 저장한다.

ESP0, SS0
유저모드 태스크에서 커널모드 태스크로 전환되어 태스크 스위칭이 수행될 때 스택 값이 바뀌어야 한다.
이 작업은 커널 측에서 수행하게 된다.
시스템 레벨 별로 스택이 따로 존재하게 된다. ESP0, SS0, ESP1, SS1...
전 챕터에서도 말했듯, 거의 레벨 0(커널)과 3(사용자)만 사용하게 된다.
ESP3, SS3은 없고 유저 레벨 스택은 TSS의 ESP와 SS에 저장된다.

CR3
페이징과 관련이 있다.

디버그용 T 비트
디버깅 중 태스크 스위칭이 행해지는데, 디버깅 중이었다는 걸 표시하기 위한 비트이다.

I/O 허가 비트맵
유저는 주변 장치를 마음대로 사용할 수 없다.
이를 위해 사용할 수 있는 장치와 없는 장치를 구분한다.


TSS 세그먼트 디스크립터

GDT 디스크립터 구조와 비슷하나 S비트와 D비트가 없다.
그리고 Type도 다르다.

SysCodeSelector equ 0x08
SysDataSelector equ 0x10
VideoSelector   equ 0x18
TSS1Selector    equ 0x20
TSS2Selector    equ 0x28

TSS 디스크립터 또한 8바이트임을 확인할 수 있다.

descriptor4:
    dw 104
    dw 0
    db 0
    db 0x89
    db 0
    db 0

Limit는 최소 104 이다.
그리고 0x89는 2진수로 1000 1001인데, P:1, DPL:00, Type: 1001이 되므로 메모리에 올라가 있고(P), 커널 레벨(DPL) 이라는 뜻이 된다.

GDT와 Type이 다르다.

B비트는 현재 태스크가 실행 중인가, 또는 실행을 기다리는 중인가를 나타낸다.


소스코드 분석

우선 이 코드는 PM_Start에서 process2 태스크로 전환될 때 태스크 스위칭이 어떻게 일어나는지에 대한 코드이다.
해당 코드에서 PM_Start는 tss1, process2는 tss2이 배정된다.

GDT의 인덱스가 어떻게 되어있는지 파악해보려한다.

gdt:
    dd 0, 0
    dd 0x0000FFFF, 0x00CF9A00
    dd 0x0000FFFF, 0x00CF9200
    dd 0x8000FFFF, 0x0040920B

descriptor4:
    dw 104
    dw 0
    db 0
    db 0x89
    db 0
    db 0

descriptor5:
    dw 104
    dw 0
    db 0
    db 0x89
    db 0
    db 0

gdt_end:
SysCodeSelector equ 0x08
SysDataSelector equ 0x10
VideoSelector   equ 0x18
TSS1Selector    equ 0x20
TSS2Selector    equ 0x28

첫 번째 코드인 kernel.asm과 두 번째 코드인 init.inc이다.
kernel.asm에서 gdt가 정의된 것을 보면 null, SysCodeSelector, SysDataSelector, VideoSelector가 정의되어있고 그 뒤에 descriptor4, descriptor5가 정의된 것을 확인할 수 있다.
각 크기는 8바이트이므로 descriptor4, 5는 각각 0x20, 0x28인 것을 알 수 있고 이는 TSS1Selector와 TSS2Selector와 위치가 겹친다.
descriptor4는 TSS1Selector, descriptor5는 TSS2Selector라는 것을 알 수 있다.

다시 상기하자면 TSS1Selector는 세그먼트이고, tss1은 레지스터 값이다.

    xor ebx, ebx
    lea eax, [tss1]
    add eax, 0x10000
    mov [descriptor4+2], ax
    shr eax, 16
    mov [descriptor4+4], al
    mov [descriptor4+7], ah

    lea eax, [tss2]
    add eax, 0x10000
    mov [descriptor5+2], ax
    shr eax, 16
    mov [descriptor5+4], al
    mov [descriptor5+7], ah

TSS 디스크립터에 Base Address를 삽입하는 코드이다.
tss1, tss2의 주소를 모르니 lea eax, [tss1]의 상대주소와 0x10000을 합쳐 실제 물리주소를 구한다.
TSS 디스크립터에서 Base Address가 15~0비트, 23~16비트, 31~24비트가 나뉘어져 있으므로 descriptor4+2 형식으로 tss1의 위치를 descriptor에 삽입하는 코드이다.

    mov ax, TSS1Selector
    ltr ax

LTR: TR 레지스터에 TSS 디스크립터의 셀렉터 값을 넣는 명령어이다.
TR 레지스터 : CPU가 사용해야 할 TSS가 GDT 몇 번째 인덱스인지 알려주는 레지스터이다.
이 코드로 인해 현재 진행 중인 태스크가 스위칭이 되면 TSS1Selector 구역에 레지스터 값이 저장된다.
즉, ltr 명령어로 tr 레지스터에 TSS 디스크립터의 인덱스 값을 넣었으니, 태스크 스위칭을 수행할 때 CPU는 해당 TSS에 레지스터 값을 저장하게 되는 것이다.

    lea eax, [process2]
    mov [tss2_eip], eax
    mov [tss2_esp], esp

process2는 태스크이다.
tss2의 EIP에 process2 시작 주소를 넣어 태스크 스위칭 시 process2가 실행되도록 한다.

tss2_eip:
    dd 0, 0
    dd 0, 0, 0, 0

tss2_esp:
    dd 0, 0, 0, 0

tss2에 지정된 eip와 esp 주소를 나타낸다.
tss2의 eip에 process2 시작 주소를 저장하여 PM_start로 부터 태스크 스위칭 후 process2가 시작될 수 있도록 한다.
TSS는 각각 스택을 따로 갖고 있어야 하는데, 그냥 tss1이 쓰던 스택 주소를 그대로 사용하겠다는 의미이다.
책의 필자는 커널 모드끼리의 태스크 스위칭이고, 설명 상 편의를 위해 이렇게 했다고 한다.
원래는 이렇게 하면 안된다고 한다.

    jmp TSS2Selector:0

위 코드에서 태스크 스위칭이 수행된다.

process2:
    mov edi, 80*2*7
    lea esi, [msg_process2]
    call printf
    jmp TSS1Selector:0

태스크 스위칭을 통해 process2 루틴이 실행되고 msg_process2 메시지가 출력된다.
그리고 TSS1Selector로 인해 태스크 스위칭이 발생한다.

    mov edi, 80*2*9
    lea esi, [msg_process1]
    call printf
    jmp $

jmp TSS2Selector:0 다음 코드이며 process2에서 다시 PM_Start로 돌아와 나머지 코드를 수행한다.
msg_process1 메시지를 출력 후 무한루프를 수행한다.

위 코드를 정리하자면 다음과 같다.
1. Protected Mode로 전환된 후 현재 실행 중인 태스크는 PM_Start이다. ltr 명령어를 통해 PM_Start 태스크 실행 중 태스크 스위칭이 발생하면 TSS1Selector 세그먼트 내 존재하는 "태스크 스위칭 발생 시 tss1에 레지스터 값을 저장해라" 라는 명령이 내려진다.
그리고 jmp TSS2Selector:0 명령이 수행된다.
2. jmp 명령이 수행되었으므로 태스크 스위칭을 수행하기 위해 tr 레지스터에 저장된 TSS1Selector의 디스크립터 인덱스를 찾는다.
3. TSS1Selector 디스크립터를 통해 tss1을 찾아 CPU의 레지스터 값을 저장한다.
4. TSS2Selector 디스크립터를 통해 tss2를 찾아 CPU에 레지스터 값을 로드한다.
5. 로드 시 process2 주소가 eip에 저장되어 있으므로 process2가 실행된다.

물론 위 과정에서 gdt에서 tss를 찾아낼 때 gdtr + 인덱스 번호를 통해 접근하게 된다.


CALL 명령에 의한 Task Switching

이번엔 실습은 안하고 그냥 개념정리만 하였다.

EFLAG에는 여러 플래그가 있는데 그 중 14번 비트 플래그는 NT라고 한다.
NT 비트는 nested의 약자이고 코드에서 IRET 명령어를 만났을 때, 인터럽트 핸들러의 IRET인지, 태스크 스위칭에 의한 IRET인지 구별하기 위해 사용한다.

LTR 명령어는 TSS 디스크립터의 B 비트를 1로 세팅한다.
CPU는 현재 실행되고 있는 태스크의 B 비트는 항상 1이라고 인식한다.

Task1에서 Task2가 CALL에 의해 호출되면 태스크 스위칭을 수행하게 된다.
TASK1은 실행 중이므로 B비트는 1이다. 태스크 스위칭 시에도 여전히 B 비트는 1이다.
TASK2는 NT 비트가 1로, B비트도 0에서 1로 변환된다.
그리고 CALL에 의해 호출되었으므로 루틴이 종료되면 돌아가야 하기 때문에 back link에 셀렉터가 저장된다.

Task2를 마치고 다시 Task1로 돌아가기 위해 IRET 명령어를 만나면 태스크 스위칭을 수행하게 된다.
이 때는 Task2는 더 이상 실행 중인 태스크가 아니게 되므로 NT비트는 1에서 0으로 변환, B비트는 실행 중이 아니게 되므로 0이 된다.


만약 TASK1, TASK2, TASK3이 있을 때 TASK1에서 TASK2를 CALL, TASK2에서 TASK3을 CALL 했을 때, TASK3에서 TASK2로 CALL을 통해 되돌아갈 수 없다.
왜냐하면 B비트가 1이기 때문이다.


만약 TASK1에서 TASK2를 CALL하고  TASK2 수행 중 인터럽트가 발생하였을 때, 태스크 스위칭이 거의 일어나지 않는다.
인터럽트 발생 시 TASK2의 레지스터 값들은 저장이 되는데, 이는 메모리에 저장하고 불러오는 것이다.
즉, TSS를 거쳐 문맥 교환이 발생하는 것이 아니라, 그냥 임시적으로 스택에 저장 및 로드를 하는 것이기 때문에 대부분의 인터럽트의 경우 태스크 스위칭이라고 볼 수 없다.


CALL 말고 JMP 명령어의 경우에도 태스크 스위칭이 발생한다.
CALL에 의한 태스크 스위칭은 반드시 호출된 태스크로 돌아가야 하지만, JMP는 돌아가지 않아도 된다는 점이다.

JMP 명령에 의해 태스크 스위칭이 발생하면 다음과 같다.

JMP를 하게 되면 Task1은 실행 중이 아니기 때문에 B비트가 0이 된다.
또한 IRET을 통해 전 태스크로 돌아갈 필요가 없기 때문에 NT 비트는 0이 된다.

'OS > 만들면서 배우는 OS 커널의 구조와 원리' 카테고리의 다른 글

7. 유저모드 Task Switching  (0) 2026.04.19
6. 보호  (0) 2026.04.13
4. 인터럽트와 예외  (0) 2026.04.10
3. Protected Mode로 변환  (1) 2026.04.08
2. 커널 로드  (0) 2026.04.06