MAKE OS !!! by 미친감자


 프 로그래밍을 해본 사람이라면 누구나 자신만의 운영체제(OS)를 만들고 싶어 한다. 그러나 OS를 직접 만들기 위해서는 여러 가지 조건이 필요하다. 기술적으로는 타겟 시스템에 대한 깊은 이해와 CPU 아키텍처 그리고 C, C++, 어셈블리 등의 프로그래밍 언어에 대한 지식이 필수적이다. 디버거, 라이브러리 등 평소 개발 과정에서 유용하게 사용했던 도구와 자원이 전혀 제공되지 않기 때문에 개발 환경도 매우 열악하다. QEMU 같은 에뮬레이터가 자체 원격 디버그 메시지를 뿌려주긴 하지만 기존의 개발 방법으로는 한계가 명확하다.
기술 외적인 사항으로는 지속적인 열정과 오기가 필요하다. 아무 것도 없는 상태에서 무언가를 만들어 내는 일은 특별한 의지없이는 불가능하다. 실제로 이번 SEED OS를 개발하는 프로젝트에서도 파일 시스템 구현과 관련해 코드에서 틀린 부분은 없는데 파일에 접근할 수 없어 2주 동안 고생한 적이 있었다. 그 원인이 대용량 하드디스크의 주소 지정 방식이 다르기 때문이란 것을 알았을 때는 정말 허탈했지만 오직 OS를 만들어 보겠다는 마음으로 지금까지 박차를 가하고 있다.
OS를 제작하는 순서는 일반적으로 부트섹터와 부트로더, 커널 이미지를 만들고 여기에 디바이스 드라이버, 파일 시스템 등 필요한 부분을 추가하는 순서로 이루어진다.

OS 개발을 위한 몸풀기, 부팅 시퀀스
SEED OS의 부팅 과정은 <그림 1>과 같다. BIOS가 부트섹터를 메모리에 적재하면 부트섹터는 부트로더를 불러온다. 부트로더는 A20 라인을 활성화시켜 보호모드로 진입하기 위한 GDT(Global Descriptor Table)를 생성한다. 이 과정이 종료되면 커널 이미지를 지정된 주소 공간에 재배치하고 부트로더에서 생성된 GDT를 구조체로 복사한다.
그후 예약된 보호모드 Exception 핸들러와 충돌을 피하기 위해 PIC 리맵핑(remapping)하는데, 이는 인텔에서 보호모드용으로 인터럽트 1~31번까지 예약하고 있고 초기 IRQ는 1~31번 인터럽트 사이에 맵핑돼 있기 때문이다. 보호모드 인터럽트 핸들러와 IRQ와 연결된 인터럽트 핸들러를 IDT 테이블에 추가하고 CPU에 알려준다. 그리고 가상 메모리 시스템을 사용하기 위한 페이징(paging) 시스템을 초기화하고 테스크 스위칭(task switching)하기 위한 TSS(Time Sharing System) Selector를 GDT에 추가한다. 이제 TSS를 초기화한 후 Init 프로세스와 Idle 쓰레드, 셸 쓰레드를 생성하고 스케쥴러를 호출하면 커널이 본격적으로 실행된다. 다음은 80486 이상에서 A20 라인을 활성화시키는 코드와 CPU를 보호모드로 셋팅하는 코드이다.

A20 라인 활성화 코드
mov ax, 2401h
int 15h

보호모드 셋팅 코드
mov eax, CR0
or eax, 01
mov CR0, eax

컴퓨터가 포스트(post)된 후에는 1MByte 메모리만 사용하지만 A20 라인을 활성화시키면 그 이상의 메모리를 쓸 수 있다. 보호모드로 전환한 후 맵핑되지 않은 메모리 주소로 접근하거나 특권 레벨이 적합하지 않은 명령을 수행하면 자동으로 재부팅되므로 주의가 필요하다.

SEED OS 전체 아키텍처
SEED OS는 보호모드 범용 OS로 설계됐으나 향후 임베디드 시스템에 포팅하기 위해 <그림 2>처럼 최대한 간편한 구조로 개발됐다. 일반 애플리케이션은 API 함수를 이용하며, API 함수는 시스템 요청을 통해 커널 수준의 함수를 호출하는 것을 확인할 수 있다. Object Mana ger는 Virtual Memory Object, 프로세스를 비롯해 쓰레드 Object, 이벤트(Wait, Semaphore, Message 등) Object 등을 통합한 의미이며, 스케쥴러를 포함한 커널 레벨에서 각 요청에 의해 수행된다. 또한 하드웨어에 직접적으로 접근할 수 있는 HAL(Hardware Abst raction Layer)은 상위 계층의 요구에 받아 하드웨어에 접근하고 그 결과를 다시 상위 계층에 전달한다.
HAL을 구현하려면 메인보드에 장착된 디바이스에 명령을 내리는 포트 제어가 필요하며, 이는 하드웨어로 명령을 보내거나 혹은 명령어가 처리된 결과를 받는 함수로 구성된다. 다음 코드는 1바이트 명령어를 지정된 포트로 보내거나 혹은 지정된 포트를 통해 읽어 오는 코드이다.

void outport (DWORD port, BYTE value)
{
_asm
{
MOV EDX, port
MOV AL, value
OUT DX, AL
}
}

같은 방식으로 2바이트 명령을 보내는 함수도 만들 수 있다.
BYTE inport (DWORD port)
{
BYTE value;
_asm
{
MOV EDX, port
IN AL, DX
MOV value, al
}
return value;
}

멀티테스킹의 핵심, 테스크 스위칭
테스크 스위칭은 한 OS 내에서 여러 개의 프로그램이 실행되도록 하는 기능으로 실행 중인 프로그램의 Context를 TSS(Task State Segment)에 기록하고 다음 번 수행될 프로그램의 Context를 CPU에 지정해주는 역할을 맡는다. 테스크 스위칭 방법은 하드웨어 테스크 스위칭과 소프트웨어 테스크 스위칭 등 두 가지가 있지만, 80x86에서는 하드웨어 테스크 스위칭을 지원하므로 SEED OS도 이 방식대로 구현했다.
테스크 스위칭은 처음 한 번만 셋팅해 놓으면 그 이후부터는 CPU가 자동으로 수행한다. 하드웨어 테스크 스위칭을 구현하기 위해서는 TSS 구조체를 구현하고 GDT에 TSS 디스크립터(descriptor)를 추가해야 한다. 디스크립터는 TSS를 포인팅하는데, 이렇게 하면 테스크 게이트를 통해 TSS 디스크립터가 선택되며 TGD(Task Gate Descriptor)는 IDT, GDT, LDT에 위치할 수 있다. SEED OS에서는 LDT는 구현하지 않았다(인텔의 IA-32 문서에 따르면 OS 설계 제작 GDT는 반드시 하나 이상이 들어가야 하나 LDT는 선택적인 사항으로 나와 있다).
<그림 3>은 TSS의 구조다. 구조체로 개발되며 실제로 모든 필드가 사용되는 것은 아니다. GDT에 위치하는 TSS 디스크립터는 8바이트의 크기를 가지며, 전역변수로 선언된 TSS 구조체에 대한 포인터 값을 Base Address Field에 기록하고 사이즈를 Segment Limit에 기록한다.
테스크 스위칭을 위해선 반드시 TSS를 초기화해야 하는데, SEED OS에서는 임시 TSS 디스크립터를 만들어서 초기화를 수행한다. 초기화 함수는 아무 것도 구현되지 않은 무한 루프만으로 이루어져 있다. 초기화 함수를 만들고 나서 GDT에 TSS 디스크립터를 추가하고, 현재 ESP와 초기화 함수에 대한 포인터 값을 TSS 구조체의 ESP, EIP 필드에 각각 저장한다. 그리고 LTR(TSS 디스크립터에 대한 셀렉터 값) 명령을 수행해 TSS를 초기화시키고 CPU가 이를 인식하도록 하면 된다.
TSS가 초기화됐다면 이제 간단한 CALL 또는 JMP 명령으로 테스크 스위칭을 발생시킬 수 있다. 먼저 쓰레드 생성 부분에서 쓰레드에 대한 TSS 구조체의 EIP 필드에 쓰레드 엔트리 포인트 함수를 지정하고 EAX 레지스터에 쓰레드 생성 뒤 최초로 호출되는 진입함수를 설정한다. 이렇게 하면 테스크 스위칭이 수행됐을 때 CPU는 현재 수행 중인 쓰레드의 TSS와 다음 쓰레드의 TSS를 교체하고, 교체된 TSS의 EIP 필드가 가리키는 쓰레드 엔트리 포인터를 수행한다. 이 때는 TSS의 EAX 레지스터에 지정된 쓰레드 진입함수를 함수 포인터로 타입 캐스팅(Type Casting)해서 함수를 호출함으로써 테스크 스위칭을 마무리한다. <그림 4>는 실제 테스크 스위칭을 수행하는 과정이다.
스위칭할 쓰레드의 포인트를 입력받아 TSS 디스크립터의 포인터 값을 구하고 테스크 스위칭을 하기 위해서는 현재 TSS 디스크립터의 Busy bit를 0으로 설정해야 한다. 그리고 스케쥴러의 현재 쓰레드를 획득된 쓰레드로 지정하고, 전환할 쓰레드의 TSS 디스크립터에 대한 셀렉터로 far jump를 수행하면 TSS의 EIP에 저장돼 있는 쓰레드 엔트리 포인트 함수가 수행되고 이 함수 내에서 EAX에 저장된 쓰레드 진입함수를 호출한다.
OS의 핵심, 커널 구현
커널은 OS에서 가장 기본적인 서비스를 해주는 객체로, 부팅과 함께 메모리에 로드된다. SEED OS의 커널 이미지는 PE 파일 포맷으로 개발했다. PE 파일은 유닉스 시스템 V에서 사용하는 COFF(Common Object File Format)의 확장 버전으로, PE 파일로더만 있으면 Win32 환경에서 실행할 수 있다. 문제는 커널 이미지의 경우 OS가 부팅될 때, 즉 PE파일 로더가 실행되기도 전에 로드돼야 한다는 사실이다. 따라서 재배치 과정을 직접 구현해야 하고 기존 OS의 시스템 콜을 사용할 수 없는 제약사항이 있다. SEED OS는 VC++를 이용해 커널을 개발했기 때문에 프로그램의 진입함수 이름을 직접 지정하고 커널 이미지가 재배치될 베이스 주소를 명시하는 방법으로 해결했다.
<화면 1>은 커널 컴파일 옵션이다. 셋팅에서 베이스 주소가 4MB로 되어 있는 것은 커널이 실행될 때 4MB 영역을 이용한다는 의미이다. 이는 가상 메모리 모듈이 로드되지 않은 상태에서, 물리 메모리 내에서 커널 이미지를 실행해야 하기 때문이다(여기서는 4MB로 설정했지만 이 영역을 꼭 고집할 필요는 없다).
<리스트 2>는 커널의 시작함수이다. naked로 설정돼 있는데, 이는 아직 스택(stack)이 초기화되지 않았기 때문이다. naked function이란 개발자가 직접 스택 프레임을 구성하고 원하는 형태로 함수 구조를 만들 때 사용하는 것으로 모든 스택 프레임에 대한 구성과 해제를 직접 프로그래밍 해야 한다.
프로세스란 프로그램 실행에 필요한 자원들을 확보하고 있는 논리 영역을 의미하며 연결 리스트로 생성돼 스케쥴러에 의해 관리된다. 프로세스 주소 공간의 하위 2GB는 커널 영역으로 사용되며, 상위 2GB는 사용자 영역이다. 만일 여러 사용자 프로세스가 생성됐다면 하위 2GB의 커널 영역은 생성된 사용자 프로세스에 그대로 맵핑되고, 상위 2GB만 별도로 마련된다. 즉 모든 프로세스는 하위 2GB 커널 영역을 공유하는 것이다. 가상 메모리 시스템에서는 페이징 시스템(Paging System)에 의해 가상 메모리를 통해 물리 메모리에 접근하므로, 모든 프로세스가 공유하는 하위 2GB는 1024개의 페이지 디렉토리 엔트리(Page Directory Entry)들 가운데 그 절반을 그대로 복사해서 쓴다.
프로세스를 생성하는 과정은 먼저 구조체에 대한 메모리를 할당받는다. 이 후 pAddrSpace에 대한 메모리를 할당받은데 이 과정은 생성될 프로세스에서 사용할 페이지 테이블을 생성한 뒤, pAddrSpace의 멤버 변수인 pPD가 그 주소를 가리키게 한다. 다음으로 프로세스 ID를 생성한다. ID 생성은 중복되지 않는 유일한 ID 값을 만들어내기 위해 단순하게 1만 증가시키는 방식을 따른다. 이제 최상위 스택을 지정하는데, 최상위 스택 주소는 2GB+136MB 위치이다. 마지막으로 전역 객체로 선언하고 생성된 프로세스를 스케쥴러에 추가한 후 할당된 메모리를 관리하는 구조체를 초기화한다. 그리고 커널 메모리 풀로 사용될 주소 값(8MB)을 지정해 프로세스 생성과정을 마무리한다.
<리스트 3>은 프로세스의 구조체이다. uiTotalThread는 프로세스 내에 생성된 쓰레드의 총 개수이며, dwID는 생성된 프로세스의 고유한 ID, pAddrSpace는 프로세스의 주소 공간을 관리하는 구조체 포인터이다. dwParendID는 생성된 프로세스의 부모 프로세스 ID이며, pPrevProcess와 pNextProcess는 각각 생성된 프로세스의 이전 프로세스와 다음 프로세스다. pStartThread와 pEndThread는 생성된 프로세스 내에 처음 혹은 마지막 생성된 쓰레드이며, pForeground Thread는 프로세스 내에 생성된 Foreground 쓰레드를 의미한다(단 pForegroundThread는 현재 사용되지 않는다). uiExitCode는 프로세스 종료 코드이며, nMemPoolStruct는 동적으로 할당된 메모리를 관리하는 객체, uiNextStackBase는 새로운 쓰레드가 생성됐을 때 다음번 할당할 쓰레드 스택의 위치를 가리킨다. 마지막으로 pStart Stack, pEndStack는 프로세스 쓰레드 스택의 시작과 끝을 가리키며, pPreFG, pNextFG는 Foreground 프로세스의 앞과 뒤를 의미한다.
쓰레드는 프로세스에 할당된 자원을 실제로 사용하는 작업 단위를 뜻한다. 프로세스 내에서 생성된 모든 쓰레드는 프로세스에 할당된 자원을 공유하므로 멀티테스킹 구현 시 반드시 자원 충돌이 일어나지 않도록 동기화 과정이 필수다. 실제로 CPU의 스케쥴링 대상이 되는 것은 프로세스가 아닌 쓰레드이며, 쓰레드들은 여러 큐 가운데 하나에 연결돼 스케쥴링된다. 동일한 큐에 소속된 쓰레드들은 같은 우선순위를 가지며 이 쓰레드들은 큐에 연결된 순서대로 각 쓰레드에 할당된 Time Slice를 가지고 스케쥴링(라운드로빈 스케쥴링)된다.
<리스트 4>는 쓰레드의 구조이다. dwID는 생성된 쓰레드의 고유한 ID를 의미한다. pTSS는 쓰레드마다 하나씩 독립적으로 관리되는 TSS 포인터이며, dwR3EntryFunc는 쓰레드가 생성됐을 때 사용자 영역의 진입 함수 주소를 저장하는 역할을 맡는다. dwMappingFlag는 커널 영역의 맵핑이 바뀌었을 때 하위 2GB에 복사하는데 사용되며, pQ는 쓰레드가 소속된 스케쥴링 큐에 대한 포인터이고, uiState와 uiPrevState는 생성된 쓰레드의 우선순위와 이전 우선순위를 의미한다.
uiWaitTime은 쓰레드가 블로킹됐을 때 블로킹되는 시간을 의미하며 이 값이 타이머 인터럽트 핸들러에서 호출된 KTimerScheduler() 함수에 의해 0이 되면 블로킹 상태를 해제하고 Ready 큐로 보낸다. wEventState는 생성된 쓰레드가 수행 중일 때 변경할 수 있는 여러 쓰레드 상태를 나타내며 uiTimeSlice는 동일한 우선순위 큐에 여러 개의 쓰레드가 위치했을 때 적용될 Time Slice로 이 값이 0이 되면 다음 쓰레드로 CPU를 넘기고 스케쥴러 큐의 맨 뒤에 위치하게 하는 라운드로빈 스케쥴러를 구현하기 위해 사용하는 변수이다. 이밖에도 쓰레드의 구조에 사용되는 변수와 함수는 다음과 같다.

◆ KMsgQ : 생성된 쓰레드에 전달된 메시지 구조체의 포인터를 갖는다.
◆ pProcess : 쓰레드가 생성된 프로세스의 포인터를 갖는다.
◆ pPrevQLink, pNextQLink : 스케쥴링 큐에 존재하는 쓰레드 연결의 전후 포인터
◆ pPrevTLink, pNextTLink : 커널 내의 모든 쓰레드 연결의 전후 포인터
◆ pPrevCLink, pNextCLink : 한 프로세스에서 쓰레드 연결의 전후 포인터
◆ pStack : 쓰레드 스택에 대한 포인터로, 쓰레드 종료 시 스텍을 해제해 새로 쓰레드를 생성할 때 재사용하기 위한 변수

<그림 5>는 쓰레드를 생성하는 과정을 도식화한 것이다. 먼저 현재 프로세스의 포인터를 얻고 이 프로세스에 대한 페이지 디렉토리 포인터 값을 얻는다. 쓰레드들은 프로세스에 할당된 동일한 페이지 디렉토리를 사용하므로 쓰레드에 포함된 TSS의 CR3 맴버에 페이지 디렉토리 주소 값을 저장한다. 후에 이 쓰레드로 테스크 스위칭됐을 경우 이 쓰레드가 프로세스의 주소 공간을 사용할 수 있게 하기 위해서다.
이제 쓰레드 ID를 설정하고 초기 Time slice 값, 쓰레드 우선순위, 이벤트 상태 등을 지정해 스케쥴러의 맨 마지막에 추가하고, 스케쥴링 대상이 되도록 초기에 지정된 우선순위에 해당하는 스케쥴러 큐에 쓰레드를 추가한다. 이제 쓰레드의 TSS를 초기화해야 하는데 여기서는 테스크 스위칭 시 최초로 수행될 쓰레드 엔트리 포인트를 TSS의 EIP, ESP 값으로 지정한다. 마지막으로 TSS의 EAX, EBX에 각각 쓰레드의 진입 함수와 매개변수 포인터를 지정하고 프로세스에 생성된 쓰레드를 추가한다. 이처럼 하는 이유는 테스크 스위칭이 일어났을 때 최초 수행되는 쓰레드 엔트리 포인트 함수에서 TSS의 EAX 주소 값을 다시 함수 포인터로 타입 캐스팅해 쓰레드 진입함수를 호출하기 위해서다. 여기에서 EBX 값을 매개변수로 해서 실제 함수를 호출한다.

멀티테스킹의 주역, 스케쥴러
스케쥴러는 멀티테스킹 구현의 핵심으로 스케쥴러에 따라서 범용 OS와 실시간 OS로 구분된다. 스케쥴러는 대상이 되는 모든 쓰레드를 연결하고 있으며, 각 우선순위 별로 정의된 스케쥴러 큐에 포함된다. 스케쥴러 큐는 우선순위별로 생성된 쓰레드를 스케쥴링하며 높은 우선순위 큐에 소속된 쓰레드가 그 보다 낮은 우선순위 스케쥴러 큐에 소속된 쓰레드보다 먼저 수행된다. 낮은 우선순위 스케쥴러 큐에 소속된 쓰레드는 실행되는 중에 더 높은 우선순위 스케쥴러 큐에 소속된 쓰레드에 의해 선점될 수 있다. 같은 우선순위 스케쥴러 큐에 포함된 경우엔 동일한 우선순위를 가지며, Time Slice에 의해 라운드로빈 스케쥴링 기법이 적용된다.
<리스트 5>는 스케쥴러의 구조다. uiTotal는 스케쥴러 큐에 추가된 전체 쓰레드의 개수를, dwState는 스케쥴링 상태를, pStartThread와 pEndThread는 스케쥴러 큐에 소속된 쓰레드의 처음과 마지막 포인터를 의미한다. q[MAX_SCHEDULER_QUEUE]는 스케쥴러 큐의 10단계의 우선순위를 정의한 것이고 pCurrentThread는 스케쥴러에 의해 스케쥴링되고 있는 현재 쓰레드 포인터를 나타낸다. uiTotalProcess는 스케쥴러에 추가된 모든 프로세스의 개수를, pStartProcess와 pEntProcess는 스케쥴러에 추가된 처음 프로세스 포인터와 마지막 프로세스 포인터를 나타내며, uiTotalThread는 스케쥴러에 추가된 모든 쓰레드의 개수를, pStartThread와 pEnd Thread는 스케쥴러에 추가된 처음과 마지막 쓰레드 포인터를 의미하는 것이다.
SEED OS는 멀티테스킹을 지원하도록 설계됐기 때문에 쓰레드를 스케쥴링해 줄 스케쥴러가 필요하다. 스케쥴러는 범용 OS와 실시간 OS일 때 각각 다르게 구현된다. 멀티쓰레드 모델을 구현하기 위해서는 몇 가지 고려할 사항이 있다. Fork 수행 시 부모 프로세스가 여러 개의 쓰레드를 가지고 있을 경우 모든 쓰레드를 복제할 것인지 여부나 여러 개의 쓰레드 가운데 입력 대기 혹은 이벤트 대기 등으로 인해 블로킹되면 프로세스 전체를 블로킹시킬 것인지 여부, 쓰레드 간 데이터 공유는 어떤 방법을 이용할 것인지 등이다. 이밖에 프로세스 시그널이 도착하면 어느 쓰레드에 전달할 것인지, 쓰레드별 스택이 확장되면서 폴트가 발생했을 때 이것이 스택의 확장에 의한 것인지 아니면 메모리 폴트에 의한 것인지를 알아내는 것도 고려해야 한다.
스케쥴링 방식 중 주로 사용되는 것은 FCFS, 라운드로빈, SJF, 실시간, 우선순위(priority) 등이다. SEED OS에서는 각 태스크마다 Time Slice를 주어 태스크를 수행하고 실행 도중 주어진 시간을 다 소비하면 다음 태스크를 수행하는 식으로 일정 시간을 나누어서 사용하는 방식을 적용했다.

메모리 관리 비법, 페이징
메모리 관리는 프로세스, 파일 시스템, 디바이스와 함께 커널의 가장 기초적이면서 중요한 기능이다. 커널의 다른 기능들이 필요로 할 때 메모리를 제공하고 사용이 끝나면 해제하는 역할을 담당하기 때문이다. SEED OS에서는 페이징을 이용했다. 페이징에서 페이지(page)란 실제 물리 메모리를 동일한 크기의 여러 개 블럭으로 나누었을 때 그 중 한 개의 블럭을 뜻한다. 예를 들어 1000이라는 크기의 물리 메모리가 있을 때 이를 100개의 블럭으로 나누면 10 크기를 가진 블럭이 100개 생기게 된다. 이때 10 크기를 가진 한 개의 블럭을 페이지라고 한다.
페이징은 물리 메모리 1000개 중 특정한 곳을 찾아야 할 때 어떤 페이지 안에 있는지를 추적하는 기능이다. 56이라는 메모리 주소를 참조한다면 페이지 번호는 5이고 오프셋은 6이 되는 식이다. 이처럼 페이징은 참조하고자 하는 메모리가 페이지 단위의 실제 물리 메모리의 어디에 맵핑돼 있는지를, 또 페이지 안에서 몇 번째인지를 알아내 메모리를 참조한다.
페이징을 사용하려면 80386 이상의 CPU가 필요하다. 페이징을 이용하면 디스크를 가상 메모리로 이용할 수 있고 커널 메모리 공간과 사용자 메모리 공간을 구분할 수 있으며 메모리 페이지별로 접근 권한을 지정해 효과적으로 공유 메모리를 구현할 수 있다.
이번 프로젝트에서는 32비트 프로세서 시스템에서 페이징 기능을 구현했다. 프로그램을 만들 때 주소 값에 0x80000000(2GB)을 입력해도 시스템이 동작할 수 있는 이유는 메모리의 특정 영역에 ‘0x8000 0000의 값은 물리 메모리의 x200000(2MB)이다’라는 정보를 미리 넣어두었기 때문에 가능한 것이다. 이 특정 영역들이 페이지 디렉토리와 테이블이다. 총 4GB의 선형 주소는 한 개의 페이지 디렉토리와 1024개의 페이지 테이블로 이루어진다. 한 개의 페이지 테이블은 4KB 크기의 1024개 페이지들의 메모리 위치 정보를 가지고 있고 페이지 디렉토리는 이러한 페이지 테이블 1024개가 메모리의 어디에 4KB 단위로 위치하는지에 관한 정보를 갖고 있다. 따라서 1024× 1024×4KB=4GB가 되므로 모든 메모리 영역을 참조할 수 있다.
이제 커널 페이지 디렉토리와 테이블을 설정한다. SEED OS는 커널을 실제 메모리 4MB에 올려 놓았고 다른 중요한 정보들도 4MB 이하에 고르게 분포하도록 설계했으므로 적어도 8MB(0~7MB) 범위를 다루려면 최소한 페이지 테이블 두 개가 필요하다. 한 개의 페이지 디렉토리와 2개의 페이지 테이블로 커널의 0~7MB 영역을 참조할 수 있게 설정한 후 향후 필요에 따라 페이지 풀에서 더 많은 페이지 테이블을 할당하게 하면 얼마든지 4GB까지 접근할 수 있다.
<그림 6>은 현재 실제 메모리의 구성과 맵핑 후의 메모리 구성이다. 별도 표시한 부분이 0~7MB를 다룰 수 있는 페이지 디렉토리와 테이블이다. 부트로더(1000h)와 커널(40000h)을 임시로 올려놓은 곳은 쓰이지 않으므로 맵핑하지 않았고, 맵핑되지 않은 영역들은 선형 주소에서 없는 곳으로 간주된다. 페이징 후 1000h에 접근하려고 하면 페이지 펄트가 일어나는 것이다. 커널과 기본적인 시스템 자원은 가상 주소와 실제 주소가 동일하게 맵핑돼 있다. 이제 맵핑이 다 됐으므로 CR3 값을 416000h로 설정한 후 CR0의 PG 모드를 1로 설정하면 페이징이 동작한다. 페이지 단위는 4KB이므로 엔트리 값 중 하위 12비트 값은 페이지 정보를 나타내는데 사용된다.
메모리를 이용하는 방법은 스택을 이용하는 지역 변수와 컴파일 시 메모리를 할당하는 정적 메모리, 실행시간에 메모리를 이용하는 동적 메모리 등이 있다. 동적 메모리를 할당해 메모리를 사용하려면 메모리 단편화 문제가 필연적으로 제기된다. 물론 동적 메모리에 관련된 작업이 실행될 때마다 이웃하는 메모리끼리 병합하는 방식으로 어느 정도 해결할 수 있지만 완벽한 해결책은 없다. 메모리를 동적으로 할당해서 사용자가 꼭 필요한 때만 사용해 단편화를 최소화하는 것이 최선이다.
<그림 7>은 SEED OS 개발 과정에서 사용한 메모리 풀 모델이다. 새로운 메모리 블럭이 할당되면 메모리 풀 구조체의 마지막 메모리 블럭 값을 재설정함을 알 수 있다. 메모리 위치를 빨리 찾기 위해 같은 크기의 메모리 블럭들은 사이즈 링크(size link)를 이용해 검색의 효율을 증가시켰다. mfl 배열은 각각 크기에 따라 분류한 메모리 블럭들의 첫 번째 블럭을 가리킨다. 동적 메모리 블럭을 할당하려면 메모리 풀 구조체의 mfl 값을 이용해 요구된 메모리 크기보다 큰 메모리 블럭을 찾는다. 만약 조건에 맞는 메모리 블럭이 없으면 페이지 맵핑을 통해 메모리 풀의 크기를 늘리고 새로운 블럭을 생성한 다음 메모리를 할당한다. 메모리가 할당되면 사이즈 링크에서 할당된 블럭을 제거한다.

OS 안정성의 열쇠, 파일 시스템
파일 시스템은 OS의 최적화와 안정성에 직결되는 부분이기 때문에 매우 복잡하며 기술적인 사항을 요구한다. SEED OS는 FAT32 파일 시스템을 기반으로 성능보다는 구현에 중점을 두었고 현재는 읽기만 지원된다.
<그림 8>은 일반적인 OS의 파일 시스템 구조를 나타낸다. 중간에 VFS(Virtual File System)가 있어서 단일 시스템에서 여러 파일 시스템을 사용할 수 있다(그러나 SEED OS는 파일 시스템 인터페이스에서 직접 로컬 파일 시스템에 연결했다). 파일 시스템을 구현할 때는 우선 하드디스크에 대해서 알아야 한다. 모든 하드디스크는 <그림 9>처럼 첫 번째 섹터에 하드디스크에서 사용할 수 있는 파티션을 지정한 MBR을 가지고 있다. 여기서 하드디스크에서 사용하는 주 파티션(Primary Partition)을 최대 4개까지 설정할 수 있고 확장 파티션(Extended Partition)을 추가로 하나 설정해 사용할 수 있다. 여러 파티션이 필요한 경우에는 확장 파티션 내에 다시 확장 파티션을 만들어 사용한다.
하드디스크를 읽어서 디스크 내의 모든 파티션을 파악했다면 이제 각 파티션을 인식해야 한다. FAT32 파일 시스템은 클러스터 체인 구조로 데이터를 표현하고 FAT 영역을 두 군데 두어 파일과 디렉토리에 대한 정보를 기록한다. <그림 10>과 같이 클러스터 체인을 따라가면서 데이터를 읽다가 마지막에 FFFFFFF가 나오면 데이터의 마지막이다.

정복하지 못한 영역, 네트워크
운영체제 개발에 있어 네트워크는 빼놓을 수 없는 필수 분야다. 일반적으로 네트워크 패킷(Network Packet)이 실질적으로 이동할 때 각 레이어마다 해더가 그 내용을 찾아 올라가며 데이터를 보내준다. 그러나 이 부분은 내용이 매우 방대할뿐만 아니라 다양한 기능들이 끊임없이 추가, 개정돼 사실상 다루기가 쉽지 않았다. SEED OS도 네트워크 디바이스 드라이버를 중심으로 구현했기 때문에 레이어 부분을 제대로 개발하지 못했다(향후 추가로 보완할 예정이다). 따라서 여기서는 개념 중심으로 패킷의 송수신 과정을 살펴본다.
NIC(Network Interface Card)의 구조는 크게 IEEE 802.3의 MAC 코어와 Receive 관련 메모리(Rcv FIFO), Send 관련 메모리(Xmt FIFO)로 구성돼 있다. 그 외에 메모리를 제어하는 FIFO 컨트롤과 외부 신호처리를 담당하는 LED도 있다. 랜 선이 연결되면 네트워크 디바이스 드라이버에 의해 작동하는데 이를 CPU에 알려주는 BUS 역할을 하는 것이 바로 PCI이다. SEED OS의 경우 ISA보다 PCI를 중심으로 개발해 PCI BUS를 사용했다.
먼저 RJ-45 커넥터에 연결돼 이 디바이스가 동작하면, MAC 코어가 패킷을 받아들인다(이 때 10BASE-T를 사용함을 알 수 있고 물리적으로 어떤 포트를 사용하는지 설정하는 작업을 드라이버에서 구현할 예정이다). 이렇게 얻어온 패킷, 아직 보내지 않은 패킷은 각 메모리(Rcv FIFO 혹은 Xmt FIFO)에 저장되고, 인터럽트(interrupt)가 발생해 BUS를 통해 인터럽트 핸들러가 작동하는 것이다.
<그림 11>는 AM79C970의 구조이다(다른 NIC도 크게 다를 것이 없다. 다른 점이라면 메모리 다루는 방법에 약간의 차이가 있을 뿐이다). AMD의 Am79C970 도큐먼트를 기반으로 개발했으며 기본 기능은 세 가지다. 시스템 버스 기능 부분은 주로 하드웨어로, PCI Con figuration을 가지고 처음 초기화 부분을 담당한다. 소프트웨어 인터페이스는 PCI configuration 레지스터, DMA 그리고 Receive, Transmit과 관련된 디스크립터 부분이다. 이 부분은 모두 소프트웨어적으로 구현할 수 있고 이를 통해 드라이버와 연결해 실행할 수 있다. 마지막 네트워크 인터페이스 부분은 AUI(Attachment Unit Interface)에 대한 것으로 10BASE-T 인터페이스를 따르는 NIC는 twisted-pair 이더넷 포트를 가진다. auto-sensing mechanism이 있어서 자동으로 그 부분을 찾아주는 것이 특징이다.
BMU(Bus Management Unit)는 초기 프로시저를 구현하고 디스크립터와 버퍼를 관리하는 역할을 맡는다. 초기화는 CSR0의 INIT 비트가 셋팅될 때 initialization block을 읽어 메모리에 대한 파라미터 값을 얻는데 사용된다. INIT 비트는 STRT 비트와 동시 혹은 그 전에 셋팅돼야 하는데, 일단 initialization block이 셋팅되면 CSR0의 IDON이 셋팅되고 IENA가 셋팅된 상태라면 인터럽트가 발생한다.
버퍼 management는 receive ring과 transmit ring으로 구성된다. 메시지 디스크립터 엔트리는 4DWORD이고, 만약 SSIZE32가 1이 되면 16바이트로 처리된다. 각각의 ring structure는 반드시 contigu ous 메모리에 할당돼야 한다. 이 부분은 매우 중요한데 SEED OS의 malloc이 이를 지원하지 않았기 때문이다(실제로 이번 프로젝트에서도 malloc 부분만 처리하다가 이 오류를 잡지 못해 수일 동안 고생하기도 했다). 이렇게 만들어진 ring은 몇 가지 중요한 정보를 가지고 있는데 메모리 속에 있는 메시지의 주소와 길이 관련 정보가 바로 그것이다. 이를 이용해 실질적인 데이터를 수신할 수 있다.
Am79c970는 32비트 데이터 구조를 사용하므로 이 부분을 중점적으로 살펴보자. 다음은 리눅스의 NIC 디바이스 드라이버에서 볼 수 있는 32비트 데이터 구조다. initialization block에 등록되면 RMD 0~3을 하나의 데이터 버퍼로 읽는다.
/* The PCNET32 32-Bit initialization block, described in databook. */
struct pcnet32_init_block {
u16 mode;
u16 tlen_rlen;
u8 phys_addr[6];
u16 reserved;
u32 filter[2];
/* Receive and transmit ring base, along with extra bits. */
u32 rx_ring;
u32 tx_ring;
};

네트워크는 기본적으로 OSI 7 레이어에 맞춰 올리는 것이 정석이지만 SEED OS 개발 당시에는 프로젝트 일정상 리눅스처럼 정확하게 구축하지 못했다. 대신 몇몇 필요한 내용만을 처리하도록 별도로 개발했고 이 가운데 가장 중요한 것이 ARP(Address Resolution Protocol)였다. ARP는 특정 IP 주소의 MAC 주소를 알아내는 프로토콜로, <그림 12>와 같은 헤더를 갖고 있다. 실제 보내질 패킷은 다음과 같다. 이렇게 전송하면 상대방 컴퓨터는 응답을 하고, receive MAC 주소 값을 되돌려 준다.

// Vmware MAC : 00 0c 29 ae e4 20 (src) -> 203.237.53.146(cb ed 35 92)
// My com MAC : 00 08 74 41 87 44 (dest) -> 203.237.53.144(cb ed 35 90)
0x00 ,0x08 ,0x74 ,0x41 ,0x87 ,0x44 // dest
,0x00 ,0x0c ,0x29 ,0xae ,0xe4 ,0x20 // src
,0x08 ,0x06 ,0x00 ,0x01 // ehternet type
,0x08 ,0x00 ,0x06 ,0x04 ,0x00 ,0x01 //
,0x00 ,0x0c ,0x29 ,0xae ,0xe4 ,0x20 // sender MAC address
,0xcb ,0xed ,0x35 ,0x92 // sender IP Address
,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 // receive MAC address
,0xcb ,0xed ,0x35 ,0x90 // receive IP Address
,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 // Tailer
,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00 ,0x00
};

이번 SEED OS 개발 프로젝트의 기간은 3개월 남짓이었다. 파일 시스템의 쓰기 등 몇몇 부분을 제대로 구현하지 못해 많이 아쉬웠지만 OS를 직접 개발하는 것이 얼마나 어려운 일인가를 다시 한 번 깨닫게 됐다. 얼마전부터 기존의 SEED OS를 보완해 구현하지 못했던 기능을 추가하는 새로운 프로젝트를 진행 중이다. 팀원들 모두에게 마지막까지 최선을 다하자는 격려 메시지를 보내고 싶다.

정리 | 박상훈 | nanugi@imaso.co.kr


FROM : http://www.imaso.co.kr/?doc=bbs/gnuboard_pdf.php&bo_table=article&page=2&wr_id=5764&publishdate=20050401