이 일지에 나온 소스코드는 모두 laonOS 레파지토리에서 확인할 수 있습니다.

  • 작성의 편의를 위해 글 내용은 해라체로 작성되었음을 알려드립니다.
  • 운영체제와 x86_64 아키텍처에 기본적인 지식이 있다는 전제하에 진행됩니다.
  • 별도의 출처가 없는 이미지는 저에게 저작권이 있습니다.

1.1 어떤 커널 구조가 좋을까? : exokernel

엑소커널 다이어그램

커널 아키텍처는 Linux 같은 전통적인 모놀리식 커널부터, Mach와 같은 마이크로 커널까지 다양하다. 개발에 조금의 재미를 가미하기 위해 나는 그중에서도 엑소 커널이라는 아직까지 연구되고 있으며, 조금은 특이한 커널 아키텍처를 채택해서 개발을 진행해볼 예정이다.

엑소 커널의 핵심은 보안과 추상화의 분리다. 기존의 커널은 가상 메모리, 파일 시스템, 네트워크 프로토콜과과 같이 높은 추상화 수준에서 보안을 제공했다면, 엑소 커널은 저비용 추상화 계층을 통해 물리적 리소스를 보호하고 다중화하며, 높은 추상화를 제공하는 libOS(library operating systems)을 통해 기존 애플리케이션과의 호환성을 유지함과 동시에 애플리케이션 개발에 더 높은 유연성을 제공한다. 이러한 유연성은 로우레벨 리소스에 접근이 가능하다는 점에서 최적화를 통한 성능 향상의 가능성을 높인다. 

엑소 커널은 추상화가 전혀 없는 커널이 아니다! 엑소 커널의 핵심 아이디어는 높은 수준의 추상화가 아닌 하드웨어, 예를 들어 디스크의 섹터를 읽는 것과 같은 낮은 수준의 작업을 추상화(다중화) 하는 것이다.
웹 서버 성능 비교 그래프
The Exokernel Operating System Architecture, Dawson R. Engler

성능 향상의 예로 Cheetah라는 HTTP 서버를 들 수 있다. 위의 그래프에서도 확인할 수 있듯이 엑소 커널이 제공하는 유연성과 확장성을 이용한 Cheetah의 처리량은 다른 방식보다 (특히 파일 크기가 작을 때) 최대 8배 더 높다! 특정 도메인에 대한 최적화임에도 불구하고 아키텍처 독립적이기도 하다. 논문이 오래됐다는 것을 고려하면 하드웨어가 발전한 지금은 다른 HTTP 서버와 비교했을 때 처리량이 크게 차이 나지 않을 수도 있겠지만, 적어도 성능면에서 기존 커널을 대체할 가능성이 있다는 것을 방증한다는 의의가 있다고 본다. (보안 면에서는 구조적으로 다른 커널 아키텍처보다 부족하다.)

엑소 커널은 처음에 소개했듯이 아직까지 연구되는 커널 아키텍처이다. 따라서 관련 자료가 많이 부족하고 이런저런 시행착오가 많이 필요할 것으로 보인다.

1.2 더 넓은 주소공간으로! : 롱-모드 전환

저번 챕터에서 GRUB을 이용해 보호 모드에서 간단하게 “Hello, World!”를 출력해보았다. 보호 모드는 접근할 수 있는 메모리가 4GB로 한정되므로, 이번에는 롱-모드로 전환하고 커널을 적재하는 로더를 만들어 더 넓은 주소공간과 더 커진 레지스터들을 맘껏 즐겨보자!

1.2.1 롱-모드 GDT(Global Descriptor Table) 설정

현대 운영체제는 효율적이고 편리한 메모리 관리 등으로 인해 세그멘테이션(segmentation)을 사용하지 않고 페이징(paging)을 사용하며 x86_64 아키텍처 또한 롱-모드에서는 세그멘테이션이 거이 비활성화된다. 따라서 여기서 설정하는 GDT는 세그멘테이션 사용이 목적이 아니다. (정확한 목적은 맨 아래 단락을 참고하면 알 수 있다.)

왜 보호 모드 GDT는 설정하지 않고 롱-모드 GDT만 설정하냐는 의문점이 들 수 있는데, 보호 모드 GDT는 이미 GRUB이 설정했고 바로 롱-모드로 전환할 것이라 굳이 필요 없기 때문이다. (추가로 A20 게이트 또한 GRUB이 연다.)[1]

설정은 너무나 간단하다. 설정해줄 세그먼트 디스크립터(segment disciptor)는 null, code, data 세 개이기 때문이다. 설정에는 많은 방법이 있겠지만, 단순히 디스크립터의 크기만큼의 공간을 예약하고 (아직 메모리 할당을 구현하지 않았기 때문에) 런타임 때 값을 할당해주는 방법으로 구현했다.

세그먼트 디스크립터의 구조는 다음과 같으나 위에서 말했듯이 레거시기 때문에 각 필드에 대해 자세히 알 필요는 없다. Base Address(세그먼트 위치)와 Segment Limit(세그먼트의 크기)가 롱-모드에서는 무시되며, 전체 주소 공간(0 ~ 0xFFFFFFFFFFFFFFFF)으로 설정된다는 것만 기억해두면 좋다. (나중에 나올 TSS 디스크립터의 경우 구조가 조금 다르다.)

세그먼트 디스크립터 구조
Intel® 64 and IA-32 architectures software developer’s manual (이하 Intel manual)

먼저 편리한 설정을 위해 다음과 같이 구조체를 정의해주었다. #pragma pack이 필요한 이유는 구조체의 빠른 접근을 위해 컴파일러가 구조체를 정렬하는 것을 방지하기 위함이다. (정렬되면 구조체의 변수가 엉뚱한 주소를 가리킬 수 있다. 통신 관련 프로그래밍에서도 비슷한 이유로 사용한다.)

segment_t는 앞서 말한 세그먼트 디스크립터를 위한 구조체며, gdt_t는 GDTR 레지스터 설정에 필요한 구조체로 아래 그림처럼 세그먼트 디스크립터의 크기의 합(바이트)과 연속된 세그먼트 디스크립터가 위치한 주소(64비트)를 담는다. (참고로 GDTR 레지스터는 일반적인 레지스터처럼 mov를 통해 설정하는 것이 불가능하고 lgdt라는 특수한 명령을 사용해야 한다.)

GDTR 구조
Intel manual
#pragma pack(push, 1)

typedef struct {
  uint16_t limit;
  uint16_t base;
} segment_descriptor_low_t;
typedef struct {
  uint8_t base_low;
  uint8_t access_byte;
  uint8_t flags;
  uint8_t base_high;
} segment_descriptor_high_t;

/**
 * segment descriptor struct
 */
typedef struct {
  segment_descriptor_low_t low;
  segment_descriptor_high_t high;
} segment_t;

/**
 * GDT descriptor struct
 */
typedef struct {
  uint16_t size;
  uint32_t offset;
} gdt_t;

#pragma pack(pop)

구조체를 이용해 세그먼트 디스크립터를 각각에 맞게 설정해주면 된다. 코드에서 확인할 수 있는 null 디스크립터는 GDT 맨 앞에 존재해야 한다. 마찬가지로 설정한 플래그에 대해 자세히 알 필요는 없으나 주석으로 자세한 내용을 적어놨으니 궁금하면 위의 세그먼트 디스크립터 표를 참고하면 된다.

null 디스크립터는 보호 모드에서 세그먼트 셀렉터를 비활성화하는 용도로 사용된다.[2] (롱-모드에서는 null 세그먼트 체크가 무시된다.[3]) 하지만 값이 꼭 0으로 초기화될 필요는 없다. 단지 첫 디스크립터는 무시될 뿐이다.
// segment descriptor list
segment_t segments[3];
// GDT descriptor
gdt_t gdt_ptr;

// add segment descriptor
static void segment_add(int index, uint8_t access_byte, uint8_t flags) {
  segment_t *segment = &segments[index];

  segment->low.base = 0;
  segment->low.limit = 0;

  segment->high.base_low = 0;
  segment->high.base_high = 0;
  segment->high.access_byte = access_byte;
  segment->high.flags = flags;
}

/*...*/

  gdt_ptr.size = (sizeof(segment_t) * 3) - 1;
  gdt_ptr.offset = (uintptr_t) &segments;

  // 0x9A Pr=1(valid), Privl=0(kernel privilege), Ex=1(code), RW=1(read)
  // 0x92 Pr=1(valid), Privl=0(kernel privilege), Ex=0(data), RW=1(R/W)
  // 0xaf Gr=1(4K block), Sz=0(32-bit protected mode), L=1(long mode)
  // limit and base not used in x64
  segment_add(0, 0, 0);      // null descriptor
  segment_add(1, 0x9a, 0xaf);// code 0x08
  segment_add(2, 0x92, 0xaf);// data 0x10

/*...*/
글로벌 디스크립터 다이어그램
메모리에는 다음과 같이 위치한다.

디스크립터 설정 후에는 앞에서 언급한 lgdt를 사용해 GDT 설정을 완료하고, 세그먼트 셀렉터를 초기화해주면 된다. 세그먼트 셀렉터 초기화는 뒤에서 다시 언급되니 여기서는 생략하겠다.

lgdt명령을 사용하기 전에 cli명령을 사용해 인터럽트를 비활성해주는 것이 좋다. GDT 교체로 인해 인터럽트가 발생할 수 있기 때문이다. 이후 EFLAGS 레지스터를 초기화하거나 sti 명령을 사용해 다시 활성화해주면 된다. (스택 생성 또한 마찬가지라 저번 챕터에도 cli명령을 확인할 수 있다.)
inline static void gdt_set(uintptr_t gdt_address) {
  asm volatile("lgdt (%0)"::"a"(gdt_address));
}
롱-모드에서 세그먼트 셀렉터(레지스터)는 세그먼트 디스크립터를 로드하지만 cs, fs, gs 셀렉터를 제외한 나머지 셀렉터는 선택한 세그먼트 디스크립터를 무시한다[4], 따라서 세그멘테이션 또한 거이 비활성화된다고 볼 수 있다.
• fs, gs 셀렉터는 롱-모드에서 런타임 제한과 속성을 확인하지 않기 때문에[5] 리눅스나 윈도우에서 TLS를 저장하는 등의 용도로 사용된다. (사실 리눅스나 윈도우의 호환성을 위한 것이다.)
• cs 셀렉터는 롱-모드의 서브 모드를 선택하는데 사용된다. code 세그먼트 디스크립터의 L 플래그를 1로 둔 이유가 서브 모드를 64비트 모드로 설정하기 위함이다.[6] 이걸 위해 GDT를 설정한 것이다.

1.2.2 Multiboot2 boot information 읽기

멀티부트(저번 챕터에서 사용한 GRUB이 멀티부트 사양을 준수하는 부트로더다.)는 운영체제에 제어권을 넘겨줄 뿐만 아니라 MBI(Multiboot2 Boot Information)로 유용한 정보도 함께 전달해준다.(ebx 레지스터로 MBI가 위치한 주소를 전달해준다.) MBI가 전달하는 정보는 다양하지만 그중에서 멀티부트 모듈과 메모리 정보만 가져와 사용할 것이다.

GRUB 모듈이 아닌 멀티부트 모듈인 것을 유의하자. GRUB 모듈은 아래 설정 파일에서 나오는 multiboot2와 module2 같은 명령을 추가하는 역할을 한다. 실제로 grub-mkrescu 명령으로 만들어진 iso 파일을 추출하면 /boot/grub/i386-pc에서 multiboot2와 같은 모듈을 찾을 수 있다. 물론 직접 모듈을 만들어 추가할 수도 있다.

만약 멀티부트로 부트 되지 않았다면 MBI를 읽는 것은 불필요할 테니 멀티부트로 부트 되었는지 확인하는 작업을 거쳐야 한다. 확인하는 가장 간단한 방법은 eax 레지스터로 전달되는 매직 값을 확인하는 것이다. 따라서 C로 작성된 로더의 엔트리포인트를 호출하기 전 ebx와 eax 레지스터를 스택에 푸시해주었다. (C의 호출 규약이다.)

push ebx          ;passing boot information as loader_main argument
push eax          ;passing magic value as loader_main argument
call loader_main

;...

void loader_main(uint32_t magic_value, uintptr_t info_address);

앞으로 나오는 코드는 편리함을 위해 멀티부트에 대한 유용한 구조체나 상수가 정의되어 있는 multiboot2.h를 이용할 것이다.(해당 헤더는 여기에서 찾을 수 있다.) 헤더를 이용해 매직값을 다음과 같이 확인할 수 있다. 전 챕터에서 등장한적이 없던 printf가 등장했지만, 이미 해봤던 VGA 출력의 응용이기 때문에 구현에 대해서는 따로 설명하지 않겠다. (레파지토리를 참고!)

bool check_bootloader(const uint32_t magic_value) {
  //check eax magic number <- booting to multiboot2
  if (magic_value != MULTIBOOT2_BOOTLOADER_MAGIC) {
    printf("Error: Please boot with multiboot2. Invalid number: 0x%x\n",
           magic_value);
    return false;
  }
  return true;
}

MBI의 정보는 가변적이기 때문에 데이터가 순서대로 정렬됨을 보장하지 않는다. 따라서 아래 그림과 같이 반복문을 이용해 멀티부트 태그의 타입을 확인한 후 정보를 가져와야 한다.

multiboot2 boot information 파싱 방법
MBI 예시

MBI를 다음과 같이 파싱하여 얻고자 했던 MULTIBOOT_TAG_TYPE_BASIC_MEMINFO 태그를 찾고, 최소 메모리 요구 조건(여기에선 64M 이상)을 충족하는지 확인해주면 된다. 최소 메모리는 넉넉하게 잡아야지 페이징 테이블(paging table)이나 기타 필수적인 자료구조를 복잡한 과정 없이 메모리에 상주시킬 수 있다.

멀티부트2는 부팅 시에 추가적인 모듈을 인수와 함께 적재할 수 있으며 이 기능을 사용하여 64비트 커널을 적재할 것이다. 따라서 MULTIBOOT_TAG_TYPE_MODULE 태그로 적재된 모듈의 정보를 얻고 인수를 OS_KERNEL_CMDLINE와 비교하여 모듈이 커널인지 확인하고 모듈의 정보가 있는 주소를 kernel_module에 저장했다.

bool multiboot2_init(uintptr_t info_address, elf64_t *kernel_file) {

/*...*/
 
  uintptr_t memory_upper;
  multiboot_tag_module_t *kernel_module = 0;

  multiboot_tag_t *tag =
      (multiboot_tag_t *) (info_address + MULTIBOOT_TAG_ALIGN);
  for (; tag->type != MULTIBOOT_TAG_TYPE_END;
         tag = (multiboot_tag_t *) ((uint8_t *) tag +
             ALIGN(tag->size, MULTIBOOT_TAG_ALIGN))) {
    switch (tag->type) {
    case MULTIBOOT_TAG_TYPE_MODULE:
      if (strcmp(((multiboot_tag_module_t *) tag)->cmdline,
                 OS_KERNEL_CMDLINE) == 0) {
        kernel_module = (multiboot_tag_module_t *) tag;
      }
      break;
    case MULTIBOOT_TAG_TYPE_BASIC_MEMINFO:
      memory_upper =
          ((struct multiboot_tag_basic_meminfo *) tag)->mem_upper;
      if (memory_upper <= (MINIMUM_MEMORY)) {// check memory 64M+
        printf("Error: Less than minimum memory: %uKB\n",
               memory_upper);
        return false;
      }
      break;
    }
  }

/*...*/

}

멀티부트(2) 모듈을 추가하려면 module2 명령을 사용하면 된다. 저번 챕터에서 만든 grub.cfg 파일을 수정하면 다음과 같이 보일 것이다. (파일 위치 뒤에 붙는 문자열은 인수다. 위 코드에서 보이듯이 이 인수를 이용해 모듈이 커널인지 확인한다.) 커널 빌드는 로더 빌드와 유사하지만 타깃 아키텍처를 x86_64로 변경해주고  -shared 플래그를 통해 재배치 가능한(relocateable) 바이너리로 만들어줘야 한다.

menuentry "OS name" {
    multiboot2 /boot/loader.elf
    module2 /boot/kernel.elf "KERNEL"
}
이미 눈치챘겠지만 이 파일은 쉘 스크립트와 유사하다. GRUB 모듈로 추가된 여러 명령을 사용할 수 있는데, 그중 sha1sum을 이용해 다음과 같이 파일 체크섬을 확인해줄 수도 있다. sha1.txt 파일은 저번 image 폴더 내에 위치하면 되며, CMake의 custom command를 이용하면 쉽게 자동화할 수 있다. (예시는 레파지토리를 확인하면 찾을 수 있다.)
if ! sha1sum -c /sha1.txt;then
    echo -e "\nWarning: The kernel is corrupted."
    echo -e "\nNot recommended: Press Enter to continue."
    read
fi

menuentry "OS name" {
...

1.2.3 Relocateable ELF 64bit 헤더 파싱

64비트 ELF(Extensible Linking Format) 파일과 32비트 ELF 파일은 구조상 큰 차이는 없으나 몇몇 필드의 크기가 64비트(8바이트)이므로 64비트 곱셈이나 나눗셈 연산이 종종 필요하다. 하지만 아직 보호 모드 환경이므로 단순한 명령으로는 이러한 연산을 수행할 수 없기 때문에 런타임 라이브러리를 링킹 해주어야 한다. (원래라면 자동으로 링킹 되지만 저번 빌드에서 사용된 -ffreestanding 플래그가 암시적으로 -nostdlib 플래그를 포함하기 때문에 따로 링킹 해주어야 한다.)

런타임 라이브러리는 명령 셋이 지원하지 않는 구현이 까다로운 산술 연산(실수, 워드보다 큰 자료형의 곱셈과 나눗셈 등)을 소프트웨어적으로 구현한 로우-레벨 루틴의 집합을 포함하고 있다. (어셈블리로 구현했다고 생각하면 된다.) 이밖에도 여러 기능을 제공하며, 컴파일러와 긴밀하게 상호 종속되어있기 때문에 사실 필수적으로 링킹 해줘야 한다. 런타임 라이브러리는 clang에서는 compiler-rt, gcc에서는 libgcc로 불린다.

링킹은 어렵지 않다. 대부분의 환경에서 타겟 컴퓨터 아키텍처에 맞는 런타임 라이브러리만 설치되므로 llvm 프로젝트 릴리즈에서 타겟이 우분투인 바이너리를 다운로드하고, 적당한 위치에 lib/clang/버전/lib/linux 에 있는 런타임 라이브러리를 옮겨준 후 링커에 -lclang_rt.builtins-i386 플래그를 추가로 전달하면 된다. (물론 커널도 마찬가지로 -lclang_rt.builtins-x86_64 플러그를 추가해주면 된다.)

이제 본격적으로 파싱을 시작해보자! 구조체 multiboot_tag_module로 확인할 수 있듯이, 위에서 찾은 커널 모듈 정보인 kernel_module을 통해 kernel_module->mod_start(커널의 시작 위치)와 kernel_module->mod_end(커널의 끝 위치)를 얻을 수 있다. 하지만 당연하게도 커널은 ELF로 빌드되었기 때문에, 단순히 커널의 시작 위치로 분기한다고 해서 커널로 제어 흐름을 넘길 수 없다. 따라서 ELF 파일을 파싱해 재배치를 수행하고, 엔트리 포인트를 찾아 해당 위치로 분기해야 한다. (물론 재배치 대신 이전 챕터처럼 링커 스크립트를 이용하거나 PIE을 사용할 수도 있다, 이에 관한 내용은 뒤에서 다루겠다.)

여러 함수 사이에서 여러 정보를 인자 하나만으로 깔끔하게 전달하기 위해 파싱한 정보를 담고 있는 아래 elf64 구조체를 사용했다.

typedef struct {
  uintptr_t file_start; // module start
  uintptr_t file_end;   // module end
  uintptr_t entry;     // entry address
} elf64_t;

ELF 파일의 앞부분은 ELF 헤더, 프로그램 헤더 테이블로 이루어져 있으며 맨 앞에 위치한 ELF 헤더는 버전, 타입 등 해당 ELF 파일에 대한 정보를 담고 있다. 이 ELF 헤더의 여러 필드를 통해 적재된 ELF 파일이 적합한지 확인할 수 있으며, 재배치를 위한 섹션(Ex .rela) 헤더를 찾을 수 있다. (elf64로 시작하는 구조체들의 원형은 레파지토리의 elf64.h를 확인하면 찾을 수 있다. 글에서는 괄호로 표준 변수명을 표기했다.)

elf 파일 구조
일반적인 elf 파일의 구조 (.rodata, .data, .bss등의 자세한 섹션명은 생략되어 있다.)

멀티부트 모듈은 파일을 그대로 메모리에 적재하기 때문에 구조체를 통해 커널의 시작 위치를 참조하면 쉽게 필요한 필드를 찾을 수 있다. 엔트리 필드(엔트리 포인트)를 찾고 아래와 같은 필드를 확인해 적재된 ELF 파일이 롱-모드에 적합한지 확인할 수 있다.

#define ELF_MAGIC 0x464c457f
#define ELF_IDENT_CLASS_64 2
#define ELF_ISA_X86_64 0x3E
#define ELF_ORIGIN_VERSION 1

/*...*/

bool elf64_init_executable(elf64_t *file, uintptr_t physical_location) {

  /*...*/

  elf64_file_t *header = (elf64_file_t *) physical_location;
  file->entry = header->entry;

  {
    // check ELF magic
    if (header->identify.magic != ELF_MAGIC) {
      printf("Error: Invalid magic number: 0x%x\n",
             header->identify.magic);
      return false;
    }

    // check 64bit binary
    if (header->identify.class != ELF_IDENT_CLASS_64 &&
        header->isa != ELF_ISA_X86_64) {
      printf("Error: Not 64-bit ELF file: class %u\n",
             header->identify.class);
      return false;
    }

    // check ELF version
    if (header->identify.version != ELF_ORIGIN_VERSION &&
        header->version != ELF_ORIGIN_VERSION) {
      printf("Warning: Invalid version: %u\n",
             header->version);
    }
  }

  /*...*/

  return true;
}

엔트리 포인트를 찾았으니 이제 섹션 헤더를 찾아야 한다. .shstrtab이라는 (주로 테이블 마지막에 위치한) 섹션 헤더 이름 테이블을 통해 섹션의 이름을 알 수 있다. 파일의 시작 위치인 file→file_ptrsection_entry(e_shoff, 섹션 헤더 테이블 오프셋)를 더해주고 section_index(e_shstrndx, 헤더 이름 테이블 인덱스) 번째 헤더을 찾아주면 된다. 마지막으로 헤더를 이용해 섹션의 오프셋(sh_offset)을 얻을 수 있다.

inline static elf64_section_t *
elf64_section(elf64_file_t
              *file_ptr,
              uint64_t section_offset
) {
  return &((elf64_section_t *) ((uint64_t) file_ptr +
      file_ptr->section_entry))[section_offset];
}

// get section str table
inline static char *elf64_shstrtab(elf64_file_t *file_ptr) {
  return (char *) file_ptr +
      elf64_section(file_ptr, file_ptr->section_index)->offset;
}
.shstrtab 섹션 위치
.shstrtab 섹션을 찾는 과정

elf64_shstrtab로 찾은 .shstrtab 섹션을 통해 이제 특정 섹션을 찾을 수 있다. 각 섹션에는 이름을 나타내는 오프셋(sh_name)이 있는데, 이 오프셋을 .shstrtab 섹션 위치에 사용하면 이름을 얻을 수 있다. 즉 아래와 같이 이름을 비교해 섹션 헤더를 검색할 수 있다. (메모이제이션을 추가할 수도 있겠지만 속도가 크게 중요한 부분이 아니라서 생략했다.)

static elf64_section_t *elf64_section_name(elf64_file_t *file_ptr, char *name) {
  elf64_section_t *section = elf64_section(file_ptr, 0);
  char *strtab = elf64_shstrtab(file_ptr);
  char temp_char[10];
  size_t name_size = strlen(name);

  for (size_t index = 0;
       index < file_ptr->section_number; ++index) {
    if (section->type == 0) {// unused section header table
      section++;
      continue;
    }

    // compare only name length
    strcpy_s(temp_char, name_size, strtab + section->name);
    temp_char[name_size] = '\0';
    if (strcmp(temp_char, name) == 0) {
      return section;
    }

    section++;
  }

  return NULL;// not found
}
ELF 섹션 이름 찾기
섹션 이름을 찾는 과정

ELF 사양에는 .bss 섹션을 0으로 초기화해야 한다고 명시되어 있다.[7] 따라서 위의 elf64_section_name 으로 .bss 섹션을 찾아 초기화해주면 된다. 0으로 초기화하기 위해 필요한 memset는 아래 코드처럼 간단하게 구현해줄 수 있다.

memset에 사용된 rep은 16바이트로 정렬되고, 반복 횟수가 많을수록 효율적이다.[8] ELF 사양에 명시적으로 나타나있지는 않지만 대부분의 링커는 섹션을 4KB로 정렬하므로 rep을 사용해 최적화할 수 있다.
#define ELF_SECTION_BSS ".bss"

bool elf64_init_executable(elf64_t *file, uintptr_t physical_location) {

  /*...*/

  elf64_section_t *section = elf64_section_name(header,
                                                ELF_SECTION_BSS);
  if (section) {
    // init .bss section
    memset((uintptr_t *) (file->relocate + section->offset),
           0, section->size);
  }

  return true;
}

void *memset(uintptr_t *ptr, int number, size_t size) {
  asm("cld;"
      "rep; stosb;"::"D"(ptr),
  "c"(size), "a"(number));

  return ptr;
}

여기서부터는 PIE를 사용할지 .text 재배치를 사용할지로 나뉠 수 있다. 멀티 아키텍처 커널을 개발하기 위해서라면 호환성이 좋은 .text 재배치를 이용해야 하고, x86_64에서의 성능을 고려하면 PIE가 더 나은 선택이다. 이 밖에도 워낙 고려해야 할 사항이 많아서 어느 것이 더 좋을지 확신할 수 없어 OS 개발이 어느 정도 진행된 다음 여러 테스트를 진행해 확실하게 결정하기로 했다. (플래그 몇 개 바꾸고, 코드를 조금 수정하기만 하면 되기 때문에 나중에 결정해도 큰 상관이 없다.)

PIE(Position Independent Executables, PIC과 거의 같음)는 말 그대로 프로그램이 적재된 주소와 상관없이 실행되도록 .text 섹션을 위치 독립적으로 만드는 방법중 하나다. 간단히 말하자면 mov와 같이 절대 주소가 필요한 명령에서 데이터를 직접적으로 참조하지 않고 .data 섹션에 있는 GOT(Global Offset Tables)를 통해 간접적으로 참조함으로써 원칙적으로 읽기 전용인 텍스트 섹션을 수정하지 않고 GOT를 수정해 재배치하는 방법이다. PIC은 원래 공유 라이브러리를 위해 만들어졌고, PIE는 실행 파일의 ASLR 등을 위한 PIC의 특수한 경우다.

x86_64에서는 RIP 상대 주소 지정(RIP relative addressing)을 지원하기 때문에, GOT를 사용한다고 가정해도 32비트에서처럼 GOT 포인터를 위해 ebx 레지스터를 예약할 필요도 없고, 굳이 명령을 늘리는 여러 트릭을 사용할 필요도 없다. 따라서 GOT으로 인한 추가적인 간접 참조의 오버헤드는 충분히 무시할 수 있다.

PIE을 사용하지 않고 -mcmodel=large 플래그를 사용해 재배치하거나, 링커스크립트로 시작 주소를 지정해주는 방법도 있다. 다만 -mcmodel=large 플래그는 모든 메모리를 절대 주소 지정(absolute addressing)하기 때문에, -mcmodel=small(기본값)이나 -mcmodel=kernel을 사용했을 때의 RIP 상대 주소 지정보다 속도가 느리다는 단점이 있다. 여기서는 일단 이 방법을 택해 -shared 플래그로 빌드한 다음 재배치를 수행해보겠다. (추후에 다른 멀티부트 모듈(Ex libOS)을 추가할 계획이 있기 때문에, 링커스트립트가 아닌 재배치를 택했다.)

RIP 상대 주소 지정은 32비트 오프셋(앞뒤로 2GB)을 사용하기 때문에 PIE을 사용하지 않고 64비트 바이너리를 빌드하면 반드시 mcmodel이 large로 설정되어야 한다.

커널은 다음과 같은 플래그를 추가해서 빌드해야 한다. PIE으로 빌드하는 경우 플래그만 추가하고 아래 재배치는 건너뛰면 된다.

최신 컴파일러들은 x86_64 PIE에서 PIC과 달리 외부 심볼에 GOT 간접 참조가 아닌 RIP 상대 주소 지정을 사용한다. 즉 -shared 플레그를 굳이 추가하지 않는 이상 일반적인 케이스에서(심볼의 가시성을 변경하거나 KASLR 같은 것을 구현하지 않는 이상) GOT 테이블의 재배치는 필요하지 않다. 하지만 다른 아키텍쳐는 컴파일러별로 동작의 일관성이 없고 GOT를 사용할 때도 있으므로 주의가 필요하다.
#non-PIE
-shared -mcmodel=large
#PIE
-fpie -mcmodel=kernel

재배치는 중요한 내용이 아니므로 간략하게 설명하겠다. elf64_section_name으로 재배치에 필요한 정보를 담고 있는 .rela 섹션을 찾고 (물론 정석적인 방법은 .dynamic 섹션을 통하는 것이다.) .rela 섹션 헤더의 sh_link(링크된 섹션의 인덱스) 필드를 통해 링크된 .dynsym 섹션을 찾을 수 있다.

재배치의 타입(원래 r_info라는 8바이트 변수로 하위 4바이트는 심볼 오프셋, 상위 4바이트는 재배치의 타입을 담는 데 편의를 위해 4바이트 변수 symbol, type 두 개로 대체했다.)에 따라 변수의 의미가 달라지는데, 여기에서는 st_value는 가리키는 심볼에 대한 섹션 오프셋(섹션을 가리키는 게 아니라, 오프셋이 섹션의 주소를 가리킨다는 뜻이다.), r_addend는 재배치를 위해 추가할 상숫값, r_offset은 재배치할 심볼의 섹션 오프셋을 의미한다.

따라서 단순히 재배치할 심볼의 오프셋인 r_offset에 파일의 시작 위치인 file_address를 더한 주소에 위치한 값을 오프셋과 상숫값, 그리고 재배치할 주소(location)를 더해준 값으로 바꿈으로써 재배치를 수행할 수 있다. (R_X86_64_RELATIVEst_value을 더할 필요가 없지만, 0이기 때문에 아래와 같이 구현했다.)

bool elf64_relocate_executable(elf64_t *file, uint64_t location) {
  elf64_file_t *header = (elf64_file_t *) file->file_start;
  elf64_section_t *rela_section =
      elf64_section_name(header, ELF_SECTION_RELA);
  elf64_section_t *dynsym_section =
      elf64_section(header, rela_section->link);

  // check section
  if (!rela_section || !dynsym_section ||
      rela_section->type != ELF_SHT_RELA ||
      dynsym_section->type != ELF_SHT_DYNSYM) {
    printf("Error: Not shared binary\n");
    return false;
  }

  uint64_t file_address = file->file_start;

  elf64_rela_t *rela =
      (elf64_rela_t *) (file_address + rela_section->offset);

  elf64_sym_t *symbol_table =
      (elf64_sym_t *) (file_address + dynsym_section->offset);
  elf64_sym_t *symbol;

  uint64_t temp_value;

  size_t size = rela_section->size / rela_section->entsize;
  for (size_t i = 0; i < size; ++i) {
    symbol = &symbol_table[rela->symbol];     // get symbol
    temp_value = symbol->value + rela->addend;// pre calculate

    switch (rela->type) {
    case R_x86_64_NONE:break;
    case R_x86_64_64:
    case R_X86_64_RELATIVE:
      *(uint64_t *) (file_address + rela->offset) =
          location + temp_value;
      break;
    default:
      printf("Error: Not supported relocation: %d\n",
             rela->type);
      return false;
    }

    rela++;
  }

  return true;
}

마지막으로 multiboot2_init함수에서 찾은 커널 모듈 정보(kernel_module)와 지금까지 나온 함수들을 호출해 모듈을 로드하는 load_module 함수를 이용해 아래와 같이 커널의 재배치를 수행할 수 있다.

bool multiboot2_init(uintptr_t info_address, elf64_t *kernel_file) {

  /*...*/

  if (kernel_module != 0) {
    return load_module(kernel_file,
                       kernel_module,
                       OS_KERNEL_VME,
                       0,
                       true);
  } else {
    printf("Error: Kernel module not found: %s\n",
           OS_KERNEL_CMDLINE);
    return false;
  }
}

static bool
load_module(elf64_t *file,
            const multiboot_tag_module_t *module,
            const uint64_t location,
            const uintptr_t physical_location,
            bool is_shared) {
  file->file_start = module->mod_start;
  file->file_end = module->mod_end;
  bool status_init = elf64_init_executable(file, physical_location);
  file->entry += location;

  bool status_relocate = true;
  if (status_init && is_shared) {
    status_relocate = elf64_relocate_executable(file, location);
  }

  status_print(printf("* Loading %s module at 0x%x-0x%x",
                      module->cmdline, file->file_start, file->file_end),
               status_init && status_relocate);
  return status_init && status_relocate;
}
릴리즈 바이너리에도 일부 디버그 정보가 포함될 수 있다. 따라서 objcopy를 이용해 다음과 같이 릴리즈 바이너리에서 불필요한 디버그 섹션과 심볼을 제거해야 한다. 오로지 디버깅을 위한 정보기 때문에, 가급적 제거해주는 것이 좋다.
objcopy --strip-debug filename.elf

프로그램 헤더 테이블을 파싱해 페이지 권한을 설정하는 것은 나중으로 미뤄두자. 지금은 필수적인 과정에만 집중할 것이다.

1.2.4 롱-모드 페이지 생성

상반부 커널(higher half kernel)은 커널을 가상 메모리의 상반부에 위치시키는 것을 의미한다. 상반부 커널은 여러 장점이 있는데, 나는 메모리 관리를 깔끔하게 하기 위해 선택했다. 32비트 호완을 위한 WOW64 같은 하위 시스템은 만들 계획이 없긴 하지만, 애플리케이션이 가상 주소 맨 처음부터 위치할 수 있다 보니 32비트 애플리케이션이 4GB 주소 전체를 활용할 수 있다는 이점도 있다.

로더에서 생성하는 페이지 테이블은 임시로 사용할 예정이기 때문에, 최소한의 기능만 할 수 있도록 간단하게 만들었다. 페이징은 상당히 복잡하기 때문에 지금은 플래그만 간단히 확인하고 넘어가면 충분하다.

커널에는 2MB 페이지를 사용할 예정인데, 이는 큰 페이지(2MB, 4MB)를 위한 TLB(Translation Lookaside Buffer, 페이지를 위한 캐시)가 별도로 존재하기 때문에[9] 자주 접근하는 커널 영역의 적중률을 높이기 위한 일종의 최적화이다. 사실 OS 최적화는 구조적 최적화를 제외하면, 이런 캐시의 적중률을 높이거나, 암호화와 같은 특정 요구조건에서 AVX와 같은 명령셋을 사용하는 것이 대부분이다.
Intel manual

위 표에 보이는 것처럼 4KB 페이징과 2MB 페이징은 테이블 구조가 조금 다르다. 4KB 페이징은 PDE((Page Directory Entry)까지 필요하지만, 2MB는 PDTE(Page Directory Pointer Entry)까지만 있으면 된다. 단 반드시 PDE의 7번째 비트인 PS(Page Size)가 1로 켜져 있어야 한다.

상반부 커널은 여러 주소에 위치할 수 있겠지만, 여기에서는 전통적으로 사용되는 -2GB(0xfffffff800000000) 주소에 위치시켰다. (리눅스와 같은 주요 커널 또한 이 주소를 사용한다.) 각 테이블은 4KB로 정렬되어야 하며, 하나의 테이블은 512개의 엔트리를 갖는다. 아래 그림을 참고하면 PML5E(Page Map Level 5 Entry)와 PML4E는 511번째에, PDPE는 480번째에, PDE는 0번째에 있어야 한다는 것을 알 수 있다.

-2GB 페이지 설명
-2GB 주소 구성

페이지를 사용하려면 활성화 상태를 표시해주는 0번째 P(Present)비트를 키고 1번째 비트인 R/W(Read/Write)을 켜 읽고 쓰기가 가능하게 만들어줘야 한다. 또 주소는 0x1000(4KB)로 정렬되어있어서, 0b11인 숫자 3을 주소에 OR 연산시켜주면 간단하게 두 비트를 켜줄 수 있다. 마찬가지로 PD 엔트리는 0x83(0b10000011)과 주소를 OR 연산하면 7번째 PS 비트를 키고 P와 R/W비트도 켜줄 수 있다.

커널의 크기가 아직 작아서, 2MB 엔트리 하나로도 충분하다. 즉 여기서 만든 PDE은 0부터 시작해서 2MB까지의 물리 주소를 맵핑한다. 페이징을 활성화하는 시점에서 IP는 아직 메모리 하반부에 있기 때문에 0번째에도 엔트리를 만들어줘야 정상적으로 동작할 수 있다. 물론 이 엔트리는 커널로 이동한 후 제거해야 한다.

Module alignment tag를 사용하면 모듈을 페이지 경계(4KB)에 맞출 수 있어서,[10], 4KB 페이징을 사용했다면 커널이 적재된 주소를 기준으로 -2GB 위치에 맵핑할 수 있겠지만 여기에서는 2MB 페이징을 사용하기 때문에 0부터 맵핑했다. (GDT, IVT 등 때문에 커널이 적재된 주소 아래도 접근이 필요하기 때문이기도 하다.)
static uintptr_t create_page_tables(uintptr_t address, bool is_5_level_support) {
  const int table_count = is_5_level_support ? 2 : 1;

  address = ALIGN(address, 0x1000);
  memset((uintptr_t *)address, 0, 0x1000 * (table_count + 1));

  // page directory
  add_page_table(address, 0, 0x83);

  // page directory pointer
  address += 0x1000;
  add_page_table(address, 0, address - 0x1000 | 3);
  add_page_table(address, 480, address - 0x1000 | 3);

  // page map level-4, page map level-5
  for (int i = 0; i < table_count; ++i) {
    address += 0x1000;
    add_page_table(address, 0, address - 0x1000 | 3);
    add_page_table(address, 511, address - 0x1000 | 3);
  }

  return address;
}

inline static void add_page_table(uintptr_t address, size_t index,
                                  uint64_t value) {
  ((uint64_t *)address)[index] = value;
}

마지막으로 CPU의 정보를 가져오는 cpuid 명령을 사용해 프로세서가 롱-모드와, LA57을 지원하는지 확인해주자. 직접 인라인 어셈블리를 작성할 수도 있겠지만, 여기서는 편의를 위해 cpuid.h를 사용했다.

inline static bool is_long_mod_support() {
  uint32_t edx, tmp_null;
  __get_cpuid(0x80000001, &tmp_null,
              &tmp_null, &tmp_null, &edx);

  if (edx & bit_LM) {
    return true;
  } else {
    return false;
  }
}

inline static bool is_5_level_support() {
  uint32_t ecx, tmp_null;
  __get_cpuid_count(7, 0, &tmp_null,
                    &tmp_null, &ecx, &tmp_null);

  if (ecx & (1 << 16)) {
    return true;
  } else {
    return false;
  }
}

1.2.5 롱-모드 전환과 페이징 활성화

위에서 만든 페이지를 이제 사용해볼 차례다. 롱-모드로 전환하기 전 cr4의 PAE(Physical Address Extension, 최대 64비트 주소 사용) 플래그를 켜주고, cr3의 PDBR에 페이지 테이블 주소(LA57에 따라 PML4 또는 PML5)를 넣고, 마지막으로 MSR의 일종인 EFER(Extended Feature Enable Register)의 LME(Long-Mode Enable) 플래그를 켜줌으로써 롱 모드를 활성화할 수 있으며, 페이지를 활성화할 준비가 끝났다.

CR3, CR4
Intel manual

MSR은 일반적인 레지스터처럼 mov로 값을 수정하거나 읽는게 불가능하고 rdmsr(값 읽기), wrmsr(값 쓰기)등의 명령어를 사용해야 한다. MSR은 각각 특정한 번호를 가지고 있고, EFER은 0xc0000080를 이용해 접근할 수 있다.

롱-모드를 활성화 하기 전에는 반드시 페이지 테이블이 설정되어야 한다.[11] 또 LA57도 롱-모드 활성화 전에 켜줘야 한다.[12]
inline static void page_init(const uintptr_t table_address,
                             bool is_5_level_support) {
  asm volatile(
  // disable paging
  "mov %%cr0, %%ebx; and $~(1 << 31), %%ebx; mov %%ebx, %%cr0;"
  // enable PAE(Physical Address Extension)
  "mov %%cr4, %%edx; or $(1 << 5), %%edx; mov %%edx, %%cr4;"
  // set page map level 4 or 5 table
  "mov %[pml_table], %%eax; mov %%eax, %%cr3;"
  ::[pml_table] "r"(table_address)
  : "%cr0", "%cr3", "%cr4", "%ebx", "%edx", "%eax");

  if (is_5_level_support) {
    asm volatile(
    "mov %%cr4, %%eax;"
    "or $(1 << 12), %%eax;"
    "mov %%eax, %%cr4;"
    ::: "%cr4", "%eax");
  }

  asm volatile(
  //enable long mode
  "mov $0xc0000080, %%ecx; rdmsr; or $(1 << 8), %%eax; wrmsr;"
  ::: "%eax", "%ecx");
}

페이징 활성화는 매우 간단하다. 컨트롤 레지스터중 하나인 cr0레지스터의 PG(페이징 활성화)플레그를 키고 CD(캐시 비활성화), NW(Write-Through 캐시, 메모리와 캐시 쓰기 동기화 비활성화) 플래그를 꺼주기만 하면 끝이다. 추가로 위에서 미처 하지 못했던 세그먼트 셀렉터 초기화를 진행하고, 64비트 커널의 엔트리 포인트로 점프해주면, 드디어 본격적으로 커널을 작성할 준비가 끝난 것이다. (NW은 64비트 커널 초기화시 MMIO(Memory-mapped I/O)를 사용할 가능성이 있어 꺼주었다.)

Intel manual

다음과 같은 인라인 어셈블리를 통해 페이징을 활성화하고 세그먼트 셀렉터를 초기화할 수 있다. cs(코드 세그먼트) 셀렉터는 mov로 초기화할 수 없고 다음과 같이 jmp로 초기화해야 한다. entry_address로 점프하는 부분은 정상적인 방법으론 32비트가 타겟인 인라인 어셈블리에서 64비트 레지스터를 사용할 수 없기 때문에, 부득이하게 상위 32비트와 하위 32비트로 나눠 rax에 저장하고 점프했다.

inline static void x86_64_enter_kernel(uint64_t entry_address) {
  // enable paging
  asm volatile goto("mov %%cr0, %%eax;"
  // set PG(paging), disable CD, NW
  "or $((1 << 31) | (1 << 29) | (1 << 30)), %%eax;"
  "xor $((1 << 29) | (1 << 30)), %%eax;"
  "mov %%eax, %%cr0;"
  "mov $0x10, %%ax;"// init
  "mov %%ax,%%ds;"
  "mov %%ax, %%es;"
  "mov %%ax,%%fs;"
  "mov %%ax, %%gs;"
  "jmp $0x08, $%l[jump_64];"
  ::
  : "%cr0", "%eax", "%ax"
  : jump_64);
jump_64:
  asm volatile(".code64 \n\t push %%rax; mov %1, +4(%%rsp); pop %%rax;"
               "jmp *%%rax;"::
  "a"((uint32_t) (entry_address & 0xFFFFFFFFLL)),
  "b"((uint32_t) ((entry_address >> 32) & 0xFFFFFFFFLL)));
}
cr0레지스터의 CD와 NW는 확실하게 켜져있다고 장담할 수 없기 때문에[13] orxor을 사용해주어야 한다.

이제 정말로 끝이다! 디버깅을 하면 -2GB 위치에서 커널이 실행되는 것을 확인할 수 있다.

-2GB 위치에서 실행되는 커널

물론 아직 몇 가지 할 일이 남았다. 임시로 만들었던 0번째 엔트리를 제거해줘야 하고, 이에 따라 1:1 맵핑된 메모리 하반부 주소로 접근할 수 없어 GDT를 다시 설정해야 한다. (GDTR 레지스터는 물리 주소가 아닌 가상 주소를 사용한다) 또 위에서 CD 플래그를 껐기 때문에, TLB도 flush 해줘야 한다.

1.3 부록

1.3.1 QEMU

저번에 QEMU을 설정해주었는데 이번에 롱-모드 전환하며 몇 가지 수정해야 할 사항이 있다. 먼저 qemu-system-i386을 qemu-system-x86_64로 바꾸고, 4GB 이상 메모리 접근을 테스트할 수 있게 -m 인자를 이용해 메모리를 넉넉히 설정해주고, 5레벨 페이징을 위해 -cpu qemu64,+la57을 추가해 la57 기능을 활성화해주자!

IDE에서는 다음과 같은 스크립트를 post build 옵션 등으로 실행하게 만들어두면 편하다. (스크립트가 종료되면 QEMU 또한 종료되는 간단한 스크립트다. IDE의 중지 버튼을 누르면 자동으로 QEMU도 종료되어 QEMU 창을 일일이 닫아줄 필요가 없어진다.)

=== debug.sh ====

export ADD_QEMU="-s -S" DEBUG=1
$(cd "$(dirname "$0")"; pwd -P)/qemu_run.sh

=== run.sh ===

function cleanup {
  kill -9 $!
}

cd $(cd "$(dirname "$0")"; pwd -P)

trap cleanup EXIT
qemu-system-x86_64 \
  -cpu qemu64,+la57 \
  -smp 4 \
  -m 6G \
  -serial stdio \
  -name osName \
  $ADD_QEMU \
  -drive index=0,media=disk,format=raw,file=$ISO_PATH

1.3.2 GDB 원격 디버깅

OS를 개발하다 보면 디버깅을 빈번하게 하게 된다. QEMU는 gdb remote를 이용해 원격 디버깅을 제공하는데 위의 스크립트에도 보이듯이 -s -S 플래그를 통해 디버깅을 활성화할 수 있다.

다음과 같은 방식으로 원격 디버깅을 진행할 수 있다. (루트 디텍토리의 .gdbinit 파일로 GDB을 실행할 때마다 자동으로 QEMU와 연결하도록 만들 수도 있다.)

gdb
(gdb) target remote localhost:1234
(gdb) set architecture i386:x86-64:intel
(gdb) symbol-file bin/debug/loader.sym

로더와 커널의 타겟 아키텍쳐가 달라서 심볼을 같이 불러오는데 애로사항이 많다. 64비트 커널을 위해 qemu-system-x86_64을 사용한 탓에 GDB 타겟도 64비트로 설정되어 로더의 단순한 디버깅은 작동하지만 벨류를 확인할 때는 이상하게 나타난다. 어쩔 수 없이 로더에서 값을 확인하려면 printf로 일일이 찍어보는 수밖에 없다.

1.3.3 printf 커서

크게 중요한 부분은 아니지만, 커서가 출력된 텍스트의 끝으로 이동하지 않는 것이 불편해, 커서를 옮겨주기로 했다.

특정한 I/O 포트 주소에 1바이트의 값을 쓰는 명령인 outb 와 1바이트의 값을 읽는 inb 를 이용해 VGA 텍스트 모드의 커서 위치를 다음과 같이 움직여줄 수 있다. 빠른 실행을 위해 grub.cfgtimeout=0으로 설정되어있기 때문에, 커서를 활성화해주는 작업도 필요하다.

#define VGA_VIDEO_ADDRESS 0xb8000

// video memory pointer 4B text + 4B style
static volatile char *video_mem = (volatile char *) VGA_VIDEO_ADDRESS;

/*...*/

  // enable cursor
  outb(0x3D4, 0x0A);
  outb(0x3D5, (inb(0x3D5) & 0xC0));
  outb(0x3D4, 0x0B);
  outb(0x3D5, (inb(0x3D5) & 0xE0));

/*...*/

static void cursor_position() {
  uint32_t position = (uint32_t) (video_mem -
      (volatile char *) VGA_VIDEO_ADDRESS) / 2;

  outb(0x3D4, 0x0F);
  outb(0x3D5, (uint8_t) (position & 0xFF));
  outb(0x3D4, 0x0E);
  outb(0x3D5, (uint8_t) ((position >> 8) & 0xFF));
}

/*...*/

inline static void outb(uint16_t port, uint8_t value) {
  asm("outb %0, %1"
  :
  : "a"(value), "Nd"(port));
}

inline static uint8_t inb(uint16_t port) {
  uint8_t temp;
  asm("inb %1, %0"
  : "=a"(temp)
  : "Nd"(port));
  return temp;
}

다음에 해볼 것들

이번 챕터에서는 간단한 로더를 통해 성공적으로 커널을 상반부에 적재하고 제어권도 넘겨줬다. 다음번엔 대략적인 커널 인터페이스를 설계하고 관련 논문을 조사해봐야겠다. 다음 챕터부터는 이렇게 테크니컬한 부분보다 이론적인, 즉 설계에 더 중점을 맞추고 진행할 예정이다.

참고문헌

각주

각주
1 3.2 Machine state, ‘CS’ – Must be a 32-bit read/execute code segment with an offset of ‘0’ and a limit of ‘0xFFFFFFFF’. The exact value is undefined.,
‘DS’, ‘ES’, ‘FS’, ‘GS’, ‘SS’ – Must be a 32-bit read/write data segment with an offset of ‘0’ and a limit of ‘0xFFFFFFFF’. The exact values are all undefined. ‘A20 gate’ – Must be enabled.
2 Intel manual, The first descriptor in the GDT is not used by the processor. /…/ By initializing the segment registers with this segment selector, accidental reference to unused segment registers can be guaranteed to generate an exception.
3 Intel manual, In 64-bit mode, the processor does not perform runtime checking on NULL segment selectors.
4 Intel manual, Because ES, DS, and SS segment registers are not used in 64-bit mode, their fields (base, limit, and attribute) in segment descriptor registers are ignored.
5 Intel manual, In 64-bit mode, memory accesses using FS-segment and GS-segment overrides are not checked for a runtime limit nor subjected to attribute-checking.
6 Intel manual, Code segments continue to exist in 64-bit mode even though, for address calculations, the segment base is treated as zero. /…/ If CS.L = 1 and IA-32e mode is active, the only valid setting is CS.D = 0.
7 ELF specification, By definition, the system initializes the data with zeros when the program begins to run.
8 Intel optimization manual, When the destination buffer is 16-byte aligned, memset() using Enhanced REP MOVSB and STOSB can perform better than SIMD approaches.
9 Intel manual, The TLBs are divided into four groups: instruction TLBs for 4-KByte pages, data TLBs for 4-KByte pages; instruction TLBs for large pages (2-MByte, 4-MByte or 1-GByte pages), and data TLBs for large pages.
10 3.1.11 Module alignment tag, If this tag is present modules must be page aligned.
11 Intel manual, Load CR3 with the physical base address of the Level 4 page map table (PML4) or Level 5 page map table (PML5).
12 Intel manual, 57-bit linear addresses (bit 12 of CR4), This bit cannot be modified in IA-32e mode.
13 Intel manual, The CD and NW flags are unchanged, bit 4 is set to 1, all other bits are cleared.

글쓴이

phruse

쉬운 길보다는 어려운 길을 즐깁니다. 다양한 분야에 관심이 많으며 언젠가 많은 사람이 사용하는 기반 기술을 개발하는 것이 목표입니다.