ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • OS 개발일지 3. 부트로더와 커널 작성하기
    개발/Operating System 2024. 2. 24. 20:42

      벌써 OS 개발을 시작한지 많은 날이 지나갔지만, 생각만큼 진도가 잘 안나간다. 현재까지의 개발 상황은 키보드 드라이버와 마우스 드라이버를 구현한 상태이다. 

     

      개발을 진행하면서 많은 부분에서의 부족함을 깨닫게 되었다. 그중에서 특히 상대적으로 익숙하지 않은 어셈블리, 하드웨어를 다루는 코드들이 어려웠다. 튜토리얼을 보고 진행하면서도, 내가 무슨 말을 적고 있는지 모르겠는 상태로 진행하는 경우도 있었다.

     

    이 OS의 최종 목표는 GUI와 네트워크를 구현하는 것이다. 취미로 시작했지만 꼭 끝을 보려고 한다. 그런 다음에는 친구의 조언을 따라 Pintos로 넘어가서 공부해보려고 한다.

     

    부트로더란?

      컴퓨터가 켜지면 어떤 일들이 일어나게 되는지 간단하게 설명하고 넘어가겠다. 마음 같아선 CPU 구조와 어떤 방식으로 명령어가 전달되는지 등등을 자세하게 풀어서 설명하고 싶지만, 너무 길어지므로 다음에 기회가 있을 때 따로 설명하도록 하겠다.

     

      컴퓨터 전원이 들어오면, 메인보드에 있는 바이오스를 RAM에 적재하게 된다. 이것이 펌웨어(Firmware)인데, 전원이 꺼져도 데이터가 보존되는 ROM에 존재하기 때문에 계속 전원을 유지할 필요가 없다. 

     

    이후 CPU의 레지스터 중 하나인 Instruction Pointer, 다른말로 Program Counter가 이 펌웨어를 가리키게 된다. Program Counter는 다음 실행할 부분을 가리키는 레지스터인데, 최초에 RAM에 적재된 펌웨어를 가리키고, 이 펌웨어를 따라 Program Counter가 움직이면서 CPU가 동작하게 되는 것이다.

     

    그림1. Intel 8086 Processor

     

    CPU가 펌웨어의 Instruction을 차례로 읽어나가면서 동작하는데, (이때도 프로그램 카운터를 사용한다!) 이 Instruction은 HD의 어느 섹션을 가리키고 있다. 이 섹션을 RAM에 적재한다. 이후에 Program Counter가 새로 할당된 영역에 점프하게 된다.

     

    새로 할당된 영역 즉, GRUB 부트로더의 Instruction은 또 다른 HD의 어느 섹션을 살펴보라고 지시하는데 이 곳이 /boot/grub/grub.cfg이고, 여러 운영체제 중에 사용자가 하나의 운영체제를 선택할 수 있다.

     

    이후 적절한 운영체제를 고르면 그 운영체제의 kernel 프로그램을 RAM에다 적재 후 그 영역으로 점프한다. 

     

    이것이 컴퓨터가 시작되면 처음 동작하는 방식이라 할 수 있겠다. 이때 사용되는 부트로더를 최대한 간단하게 작성하는 것이 이번 개발일지의 목표이다.

     

    문제점 : 부트로더는 스택 포인터 레지스터를 따로 설정하지 않는다.

      Program Counter와 마찬가지로 CPU에는 스택 포인터 레지스터로 사용되는 레지스터가 있다. (두 레지스터 이외에도 범용 목적의 General Register, 0만 저장하는 Zero Register 등 다양한 레지스터들이 있고, CPU에 따라 또는 운영체제에 따라 사용되는 레지스터들이 다르다.)

     

    부트로더는 따로 스택 포인터 레지스터를 설정하지 않는데 kernel을 제작할 언어인 C++는 오직 스택 포인터가 설정된 상태에서만 실행이 가능하다. 다행히도 이 문제를 해결할 수 있는 방법이 있다.

     

    문제점 :  부트로더는 스택 포인터 레지스터를 따로 설정하지 않는다 : : 해결법

      방법은 바로 어셈블러를 사용하는 것이다. 어셈블러로 스택 포인터를 할당하고 바로 커널로 점프하면 C++ 코드를 제대로 실행할 수 있을 것이다. 다만 어셈블러로 작성된 loader.s와 C++로 작성된 kernel.cpp는 각각 다른 언어로 작성되기 때문에 이를 묶어주는 linker.ld 파일도 필요하다.

     

    대략적인 컴파일 과정은 이렇다. loader.s, kernel.cpp 파일을 각각 컴파일하여 loader.o, kernel.o 파일을 얻는다. 이후 linker.ld를 통해 두 오브젝트 파일을 합쳐 kernel.bin 파일을 만든다. 이 파일이 OS의 정수인 kernel 이면서 동시에 매우 간단한 OS라고 볼 수 있는 것이다. 모든 프로그램은 이 커널 위에 올라가게 된다.

     

    이제 loader.s를 살펴보자.

     

    loader.s

    loader.s는 C++가 실행되기 전 스택 포인터 레지스터를 설정해주는 역할을 수행한다. 부트로더에 따로 스택 포인터 레지스터를 설정하는 기능이 없기 때문에 이런 전처리 과정을 거친다. 대략적인 메커니즘은 스택 포인터 레지스터를 설정하고, kernelMain을 호출한다.

    .set MAGIC, 0x1badb002
    .set FLAGS, (1<<0|1<<1)
    .set CHECKSUM, -(MAGIC + FLAGS)
    
    .section .multiboot
        .long MAGIC
        .long FLAGS
        .long CHECKSUM
    
    
    .section .text
    .extern kernelMain
    .extern callConstructors
    .global loader
    
    loader:
        mov $kernel_stack, %esp
        call callConstructors
        push %eax
        push %ebx
        call kernelMain
    
    _stop:
        cli
        hlt
        jmp _stop
    
    
    .section .bss
    .space 2*1024*1024 # 2MB
    kernel_stack:

     

    생각보다 간단하다. 이 부분에서 가장 주목해야 하는 곳은 loader:로 시작되는 부분이다.

     

    .section .text
    .extern kernelMain
    .extern callConstructors
    .global loader
    
    loader:
        mov $kernel_stack, %esp
        call callConstructors
        push %eax
        push %ebx
        call kernelMain

     

      앞서 소개했던 것처럼 esp (스택 포인터, 앞의 e는 extended 라는 의미이다.) 에 kernel_stack을 할당한다. 이후 kernelMain이 시작되기 전 초기 값들을 callConstructor를 통해 설정하고, kernelMain을 부르게 된다. 

     

    .extern 키워드 이후의 단어들은 C++에 선언된 함수들이다. loader.s에 선언된 함수들이 아니기에 .extern 키워드로 불러온 것이다.

     

    Kernel

      kernel은 OS의 핵심적인 부분으로써, 실질적으로 사용자가 올린 프로그램들을 관리하는 역할을 한다. 간단한 kernel은 거대한 루프 처럼 생겼는데, 프로그램을 차례대로 집어넣으면 커널에서 실행시키는 구조인 것이다. 사용자 프로그램과 kernel이 다른점이 있다면 kernel은 항상 RAM에 상주한다는 점이다. 

     

    그림2. 2 Level Kernel mode

     

     

      그림2는 kernel mode와 user mode 간의 전환을 보여주고 있다. kernel에서 할 수 있는 작업과 user mode에서 할 수 있는 작업이 서로 나뉘어 있는데, 유저 프로그램이 운영체제의 기능을 사용하거나 인터럽트가 발생하여 스케쥴링이 필요할 때, 보안을 거쳐 실행해야 하는경우 등 여러 가지 요인에 있어서 커널을 사용하게 된다. 

     

    각각의 프로그램들은 자신이 언제 다른 프로그램으로 권한을 넘겨줄지, 또는 넘겨받을 지 알 필요 없다. 커널이 이 프로그램들을 적절하게 배치하여 조금이나마 효율적이게 CPU를 사용할 수 있게 한다. 즉, 운영체제의 본질인 프로그램의 가상화는 커널에 의해 실현되는 것이다. 

     

     

    그림3. Protection Ring

     

    현대에 와서 효율을 높이기 위해 시스템이 복잡해지고 각각의 단계마다 권한을 제한적으로 두어 운영체제를 만드는 곳도 있다. Protection Ring 은 각각의 단계별로 시스템을 사용할 수 있는 권한을 준다. 

     

      Kernel은 항상 RAM에 상주해야 하며, 모든 프로그램을 효율적으로 관리해야 한다. 즉, 커널은 무거우면 안된다. 간단하면서도 다른 프로그램에 방해가 되지 않게 kernel을 작성해야 한다.

     

     

    ※ 흥미로운 점 

      커널과 보안에 관해 몇 가지 흥미로운 점을 소개하려 한다. 다음글과 다다음 글에 본격적으로 설명할 예정인데, 유저 영역에서 커널 영역으로의 임의 점프를 막기 위해 각각의 영역을 나눈다. 이 기법은 Interrupt를 구현할 때 유용하게 사용된다. 

     

     

    kernel.cpp

    extern "C" void kernelMain(const void* multiboot_structure, uint32_t /*magicnumber*/)
    {
        printf("Hello World!\n");
    
        while(1);
    
    }

     

    위 코드는 간단한 커널이다. 위의 loader.s에서 일련의 작업들을 마친다면, kernelMain으로 점프해서 넘어오게 된다. 이 기초적인 뼈대인 커널에 점점 살을 붙이면 일반적으로 사용하는 윈도우 같은 OS를 만들 수 있다. 

     

    extern "C"

      주목할 점은 C++ 문법인데, C와 다르게 C++는 오버라이딩, 오버로딩 둘 다 가능하므로 함수의 이름만으로는 어떤 부분에서 함수가 정의되었는지 알 수 없다는 것이다. 이는 구분하기 위해 어셈블러가 함수 이름들을 어떤 규칙에 의해 바꿔주는데 그 함수 이름들은 아래 명령어를 통해 확인할 수 있다.

    nm kernel.o

     

     

    서론이 길었는데, C 문법처럼 함수 이름을 어셈블러 레벨에서 변경하고 싶지 않다면 extern "C" 키워드를 사용해야 한다. 만약 이 키워드를 사용하지 않고 코드를 컴파일 한다면 loader.s에서 kernelMain을 찾지 못할 것이다. 

     

     

    printf 함수

      원래라면 glibc 라이브러리에서 가져와 printf를 사용했겠지만, 아무 것도 없는 상태에서 작성하기 때문에 printf 함수도 작성해야 한다. stdio.h에 정의되어 있는 printf는 여러 기능이 있으나, 대부분의 기능은 제외하고 간단한 기능 위주로 작성되었다.

    void printf(char* str)
    {
        unsigned short* VideoMemory = (unsigned short*)0xb8000;
    
        for (int i = 0; str[i] != '\0'; ++i)
            VideoMemory[i] = (VideoMemory[i] & 0xFF00) | str[i];
    
    }

     

    printf의 원리는 생각보다 허무한데, VideoMemory가 시작되는 주소값인 0xb8000에 문자열에 해당하는 값들을 차례로 넣으면 된다. 그러면 GPU가 이 연산 결과를 화면에 출력하게 된다. 

     

     

    그림4. printf VideoMemory 구조

     

      그런데 VideoMemory는 short 배열로 하나의 인덱스마다 2byte가 할당된다. 그림4를 참고하면 첫 0.5, 0.5, 1 byte를 합쳐 하나의 인덱스가 된다. 앞의 두 0.5 byte는 각각 배경색, 글자색이며 원하는 색으로 지정할 수 있다. 필자는 배경을 검정, 글자를 하얀색으로 처리하려고 한다. 운이 좋게도, 두 색은 따로 색을 지정해주지 않아도 기본값으로 그 색을 가지고 있어 0xFF00를 곱해 색을 유지할 수 있다. 이후에는 원래 하려고 했던 것처럼 char 값을 넣어주어 처리해준다.

     

    이렇게 kernel.o와 loader.o를 컴파일하고 linker로 두 파일을 묶어주면 kernel.bin이라는 바이너리 파일이 나온다. 필자의 경우, 이 바이너리 파일을 이용해 kernel.iso를 빌드했고, 이를 가상머신 상에 올렸다.

     

    그림5. OS 실행모습

     

     

    다음은 kernel section과 user section의 분리를 구현한 것에 대해서 포스팅 해보도록 하겠다.

     

    https://github.com/seyoung4503/myos

     

    GitHub - seyoung4503/myos: Make own OS

    Make own OS. Contribute to seyoung4503/myos development by creating an account on GitHub.

    github.com

     

    댓글

Designed by Tistory.