Kernel Memory

Kernel Memory

2025년 4월 8일

UEFI 메모리 #

이전에 메모리맵을 가져오는 코드를 UEFI에서 구현했었다.

대충 아래 코드처럼 부트서비스에서 가져오고 있는데 UEFI 부팅과정에서 이 메모리 맵이 여러 함수를 호출하면서 계속 변화하고,
UefiMain 함수에서 커널을 실행할때도 파일을 읽어올 버퍼, 세그먼트(Progra Header) 로드할 메모리공간을 할당/해제 하면서 메모리맵이 변한다.

 1EFI_STATUS GetMemoryMap(struct MemoryMap* map) {
 2  if (map->buffer == NULL) {
 3    return EFI_BUFFER_TOO_SMALL;
 4  }
 5
 6  map->map_size = map->buffer_size;
 7  return gBS->GetMemoryMap(
 8      &map->map_size,
 9      (EFI_MEMORY_DESCRIPTOR*)map->buffer,
10      &map->map_key,
11      &map->descriptor_size,
12      &map->descriptor_version);
13}

gBS->ExitBootServices 를 호출할때 메모리맵의 변화가 멈추게 되며 이 상태의 메모리맵 데이터를 커널에게 넘겨 관리하도록 만든다.

1  gBS->ExitBootServices(image_handle, memmap.map_key);
2
3  UINT64 entry_addr = *(UINT64*)(kernel_first_addr + 24);
4  typedef void EntryPointType(const struct FrameBufferConfig*,
5                              const struct MemoryMap*);
6  EntryPointType* entry_point = (EntryPointType*)entry_addr;
7  entry_point(&config, &memmap);

사용 가능한 메모리 영역 #

UEFI v2.11의 MemoryTypes

총 16가지의 메모리 타입이 있고, ExitBootServices() 를 호출한 직후에 일반적인 용도로 사용 가능한 메모리 타입은 EfiBootServicesCode(3), EfiBootServicesData(4), EfiConventionalMemory(7) 타입 세가지이다.

이중 EfiConventionalMemory는 아직 사용되지 않은 메모리 영역을 말하고 UEFI 에서 메모리를 할당받으면 지정한 메모리 타입으로 변하게 된다.

초기에는 모든 물리 메모리가 EfiConventionalMemory 였다가 부팅과정에서 여러 이유로 사용했기 때문에 여러 타입으로 쪼개지고,
내가 작성한 UefiMain 코드에서 커널 파일의 세그먼트들을 로드할때 사용하는 메모리는 AllocatePages(..., EfiLoaderData,...) 로 할당받고나면 그 크기만큼 EfiConventionalMemory 가 할당돼서 줄어들고 EfiLoaderData 영역이 증가한다.

 1  printk("memory_map: %p\n", &memory_map);
 2  for (uintptr_t iter = reinterpret_cast<uintptr_t>(memory_map.buffer);
 3      iter < reinterpret_cast<uintptr_t>(memory_map.buffer) + memory_map.map_size;
 4      iter += memory_map.descriptor_size) {
 5    auto desc = reinterpret_cast<MemoryDescriptor*>(iter);
 6    for (int i = 0; i < available_memory_types.size(); ++i) {
 7      if (desc->type == available_memory_types[i]) {
 8        printk("type = %u, phys = %08lx - %08lx, pages = %lu, attr = %08lx\n",
 9            desc->type,
10            desc->physical_start,
11            desc->physical_start + desc->number_of_pages * 4096 - 1,
12            desc->number_of_pages,
13            desc->attribute
14        );
15      }
16    }
17  }

커널에서 받아서 출력해보면 이렇게 나온다.
이 세가지 타입은 완전히 비어있다고 생각하고 커널에서 사용할 것이기 때문에 커널 로드 공간은 방해받지 않는 EfiLoaderData 타입으로 한것이다.

BIOS 펌웨어 기반 부트로더에서도 마찬가지로 현재 사용할 수 있는 메모리 공간을 인터럽트 명령 (INT 0x15, EAX=0xE820) 으로 체크하는 것은 펌웨어에서 지원해주기 때문에 메모리 맵을 만들어낼 수 있다.

8c348c7b-20dd-442f-adcd-8679a380efc6


커널에서 메모리 관리하기 #

스택 옮겨오기 #

부트로더로 사용하고 있는 UefiMain 함수는 사실 UEFI 펌웨어 위에서 동작하는 어플리케이션일 뿐이고, UEFI 펌웨어가 어플리케이션을 실행할때 임시 스택 공간을 할당해주기 때문에 내부적으로 스택을 이미 사용하고 있었다.

f060d696-8f12-44ff-8999-984a12c5ef7c

힙은 동적 메모리라기 보다는 mmap과 비슷한 AllocatePool 함수 호출로 메모리맵에서 사용할 수 있는 특정 공간을 할당받을 뿐이다.
(EfiLoaderData 등 함수 호출 시 요청한 타입에서 할당됨)


UEFI 임시 스택 공간은 관행적으로 EDK2 기준으로 128kb인데, 커널의 스택으로 사용하기에는 너무 작기 때문에 커널이 관리할 수 있는 영역으로 옮겨와야 한다.

주소가 16바이트로 정렬된 stack 전역변수를 크게 생성하고 실제 커널 메인을 call 하기 전에 rsp 레지스터를 스택 주소의 맨 아래(스택은 거꾸로 자란다)로 지정한다.

 1// main.cpp
 2// 주소가 16byte 로 정렬된 1MB 크기의 stack 공간.
 3alignas(16) uint8_t kernel_main_stack[1024 * 1024];
 4// EntryPoint는 KernelMain이기 때문에 이름을 바꿔준다.
 5extern "C" void KernelMainNewStack(const FrameBufferConfig& frame_buffer_config,
 6                           const MemoryMap& memory_map) {
 7
 8// asmfunc.asm
 9extern kernel_main_stack
10extern KernelMainNewStack
11
12global KernelMain
13KernelMain:
14    mov rsp, kernel_main_stack + 1024 * 1024    ; kernel_main_stack   주소
15    call KernelMainNewStack        ; 어차피 매개변수는 이미 레지스터에 세팅되어 있다.
16.fin
17    hlt           ; 커널이 리턴되지 않아서 의미 없지만 멈추도록 구현
18    jmp .fin

커널 바이너리에서 전역변수를 선언하면 ELF 기준으로 .bss 영역에 할당되고, 커널이 로드될 때 섹션 주소를 계산하면서 정적으로 더 큰 주소를 할당하게 된다.

정확히 .bss 영역의 크기가 선언한 스택 크기인 0x100000 만큼 증가했다.

a897a766-22a6-4479-866f-ea5999f8575c


세그멘테이션 설정 #

CPU의 세그멘테이션(Program Header의 세그먼트와는 다름)은 메모리 보호나 주소를 세그먼트 셀렉터 기준 오프셋으로 접근할 수 있는 기능을 말한다.

원래 x86의 16비트 CPU에서는 가상메모리 개념이 없었고 세그먼트를 이용해서 코드, 데이터, 스택 영역을 물리주소에서 구분했으며 처음 OS가 부팅되면서 세그먼트 기본 베이스가 세팅됐다.

32bit 부터 가상메모리 MMU 페이징 기법이 지원되면서 페이징을 활성화한 경우 세그먼트를 통한 메모리 참조는 가상메모리를 가리키게 되고, 계산의 편리와 명령어 최적화를 위해 모든 세그먼트의 베이스가 0이 되어 세그먼트의 역할이 CPU 실행권한을 제어하는 것만 남았다.

FS, GS 는 스레드별로 다른 주소를 저장하도록 하고 컨텍스트 스위칭때 저장/복원 되는 방식으로 TLS(스레드로컬스토리지)를 참조할때 사용하기 때문에 예외적으로 베이스 주소를 아직도 사용한다.


GDT (Global Descriptor Table) #

세그먼트에 대한 속성을 정의한 8byte 크기의 세그먼트 디스크립터 구조체 배열이다. 디스크립터 0번은 사용하지 않는 null descriptor이고, 1번부터 사용한다.

fa847f67-2d2d-4128-a7e6-a9847fec3b7d

여기에서 2bit 크기의 DPL이 권한 관련 정보인데, ring 0 ~ ring 3 까지 지정할 수 있고, 4bit 크기의 Type과 1bit 크기의 S(is system segment)로 세그먼트의 타입을 나타낸다.
사실 이 구조가 IDT에서 디스크립터 등록할때의 구조와 동일한데, IDT 에서 등록하는 InterruptGate, TrapGate 등의 디스크립터는 이 system_segment 필드가 0으로 세팅되어 있다.

선택한 세그먼트에 따라 코드 세그먼트인지 데이터 세그먼트인지 ring 0인지 ring 3인지가 결정된다.

  1. 처음 부팅될때 CPU는 real mode로 실행되며 물리주소 기반으로 GDT를 사용하지 않는다. UEFI 펌웨어에서 여러 부팅과정을 거치며 EFI 어플리케이션의 실행환경은 protected mode (long mode)에서 실행되도록 초기화 되기 때문에 기본 GDT와 페이징이 설정되어 있다. 56ef0f3b-3b4a-40fc-9d8c-2e145566dbd1
  2. 커널이 처음 실행될때 새로운 GDT 테이블 생성 후 lgdt 명령으로 로드한다. 각 인덱스에는 세그먼트 타입과 권한 등이 저장된다.
  3. 각 세그먼트 레지스터(cs, ds, ss 등)에 GDT 인덱스를 ring 0 권한으로 세팅해서 CPU 실행 모드를 전환한 후 커널 코드가 계속 실행된다.
  4. 코드 실행 중 세그먼트 인덱스를 설정하는 코드가 실행될 때 CPU는 GDT에 설정된 속성을 참고해서 접근 불가능한 세그먼트라면 GP 예외를 발생시킨다.
  5. 유저 프로세스가 실행될때는 ring 3의 디스크립터들로 세팅해서 프로세스의 권한을 격리시키고 코드가 실행될 때마다 현재 cs의 디스크립터를 통해 하드웨어 회로 수준으로 권한 검사를 한다. (현재 가지고있는 디스크립터 값과 논리 게이트를 태워서 권한에 따라 예외 자동트리거)
  6. 유저 프로세스가 ring 0 권한이 필요할땐 시스템 콜 등의 인터럽트로 접근한다.

코드작업 #

 1union SegmentDescriptor {
 2  uint64_t data;
 3  struct {
 4    uint64_t limit_low : 16;     // limit & 0xffffu;
 5    uint64_t base_low : 16;      // base & 0xffffu;
 6    uint64_t base_middle : 8;    // (base >> 16) & 0xffu;
 7    DescriptorType type : 4;
 8    uint64_t system_segment : 1;
 9    uint64_t descriptor_privilege_level : 2;
10    uint64_t present : 1;
11    uint64_t limit_high : 4;     // (limit >> 16) & 0x0fu;
12    uint64_t available : 1;
13    uint64_t long_mode : 1;      // 64bit 'code' segment 인 경우 1
14    uint64_t default_operation_size : 1;   
15    uint64_t granularity : 1;    
16    uint64_t base_high : 8;      // (base >> 24) & 0xffu;
17  } __attribute__((packed)) bits;
18} __attribute__((packed));
  • system_segment: 시스템 세그먼트 디스크립터 타입(TSS, LDT)인 경우 1로 세팅한다
  • present: 세그먼트가 유효한지 여부. 0인 디스크립터를 사용하는 경우 CPU 예외 발생
  • available: CPU가 사용하지 않는 필드로 일부 OS에서 마음대로 사용하는 경우가 있다.
  • default_operation_size: long_mode가 1이면 무조건 0으로 세팅해야 한다. L=1, D=1 은 CPU 명세상 정의되지 않은 동작이기 때문에 CPU 예외가 발생할 수 있다.
  • granularity: 1로 세팅하면 limit이 4kb단위로 해석되는데 64bit 모드에서는 limit이 무시되므로 의미없는 비트가 됐다.

 1global LoadGDT  ; void LoadGDT(uint16_t limit, uint64_t offset);
 2LoadGDT:
 3    push rbp
 4    mov rbp, rsp
 5    sub rsp, 10
 6    mov [rsp], di       ; limit
 7    mov [rsp + 2], rsi  ; offset
 8    lgdt [rsp]
 9    mov rsp, rbp
10    pop rbp
11    ret
12
13global SetCSSS  ; void SetCSSS(uint16_t cs, uint16_t ss);
14SetCSSS:
15    push rbp
16    mov rbp, rsp
17    mov ss, si  ; ss
18    mov rax, .next
19    push rdi    ; cs
20    push rax    ; .next
21    o64 retf    ; o64: 64bit mode prefix. retf = pop ip; pop cs
22.next:
23    mov rsp, rbp
24    pop rbp
25    ret
26
27global SetDSAll  ; void SetDSAll(uint16_t value);
28SetDSAll:
29    mov ds, di
30    mov es, di
31    mov fs, di
32    mov gs, di
33    ret

LoadGDT는 인터럽트를 구현할 때 IDT를 로드했던 것처럼 생성한 GDT 테이블을 사용하겠다고 로드하는 함수이다. UEFI 펌웨어는 EFI 어플리케이션 실행을 위해 기본 GDT가 세팅되어 있지만 커널을 위한 환경이 아니기 때문에 커널에서 새로 만든 GDT를 로드할 필요가 있다.

ds, es, fs, gs 는 64bit에서 사용되지 않기 때문에 널디스크립터를 가리키도록 한다.

모든 명령은 실행시에 cs가 가리키는 디스크립터의 설정에 따라 액세스 권한 검사가 수행된다.
cs는 mov 명령으로 세팅할 수 없어서 retf를 사용하는데, 세팅할 cs값을 푸시하고 의미없는 함수주소도 푸시해서 스택을 세팅한 후 retf 를 호출하면 cs 레지스터에 값이 세팅된다.

  • far call: 세그먼트 간 점프가 가능한 call 명령인데, 리턴 주소만 저장하는 near call과는 달리, cs 레지스터와 pc를 모두 스택에 저장한다. retf 는 far call의 리턴 명령이기 때문에 cs 레지스터로 pop 하는 작업이 포함되어 있는 것이다.

커널 메인함수에 들어오면 SetupSegments를 먼저 하고 GDT 인덱스를 이용해서 cs, ss 레지스터를 세팅하게 된다.

이때 SetupSegments 에서 먼저 GDT를 로드하게 되고, cs를 변경하기 전까지는 이미 사라진 디폴트 GDT 인덱스를 가리키고 있을텐데 문제가 발생하지 않는 이유는 cs 값을 업데이트할 때 변경된 GDT가 적용되기 때문이다.

 1void SetupSegments() {
 2  gdt[0].data = 0;
 3  SetCodeSegment(gdt[1], DescriptorType::kExecuteRead, 0, 0, 0xfffff);
 4  SetDataSegment(gdt[2], DescriptorType::kReadWrite, 0, 0, 0xfffff);
 5  LoadGDT(sizeof(gdt) - 1, reinterpret_cast<uintptr_t>(&gdt[0]));
 6}
 7
 8extern "C" void KernelMainNewStack(const FrameBufferConfig& frame_buffer_config,
 9                           const MemoryMap& memory_map) {
10  SetupSegments();
11  const uint16_t kernel_cs = 1 << 3;    // GDT의 인덱스가 8byte 단위라서 << 3 함
12  const uint16_t kernel_ss = 2 << 3;
13  SetDSAll(0);
14  SetCSSS(kernel_cs, kernel_ss);
15}

이 이후부터는 새로생긴 GDT에 따라 메모리 접근권한을 적용할 수 있게 된다.

26e10f8b-161a-49a6-8d60-b223bded91ee

만약 이 상태에서 code segment descriptor 의 present 를 0으로 세팅해서 유효하지 않도록 만들면 GDT 테이블이 변경되자마자 GP 예외가 발생한다.
아래 이미지는 0xd(General Protection Fault) 예외가 발생했고 처리하지 못해서 0x8(Double Fault)가 발생한 로그이다.

e0411a49-f3a6-454d-813a-d36eedc53c57


Identity 페이징 설정 #

페이징은 메모리 공간을 페이지 단위로 관리(할당, 해제, 접근제어, 캐싱 등) 한다는 의미이다. x86 64bit 시스템에서 long mode 를 사용하기 위해서는 페이징이 필수인데, 하드웨어 설계상 long mode 이후에는 CPU가 전달받은 가상주소를 MMU를 통해 CR3이 가리키는 페이지테이블로 변환해서 접근하며 접근권한까지 검사하게된다.

원래는 CR3에 가상메모리 테이블의 물리주소를 연결해두고 MMU가 가상주소를 테이블을 이용해서 물리주소에 접근할 수 있도록 구현해야 하지만,
여기에서는 일단 identity 페이징 설정을 해서 물리주소 테이블을 연결해두고 1:1로 매핑해서 사용하게된다.

UEFI 어플리케이션은 PE+(64bit) 구조이기 때문에 UEFI 펌웨어에서도 기본적으로 4 level paging 방식을 사용하고 있다.


UEFI 4 level paging #

UEFI 에서도 x86의 4 level paging 방식을 동일하게 사용하며 48bit 가상주소를 페이지테이블을 이용해 물리주소와 매핑한다.

CR3 레지스터값과 PML4 테이블 오프셋을 통해 PDP 테이블의 베이스(물리주소)를 가져오고 PDP 테이블의 오프셋으로 PD 테이블 베이스를 가져오면서 4개의 페이지 테이블을 거쳐 결국 물리주소를 알아오는 방식이다.

UEFI 펌웨어 부팅 과정에서 SEC/PEI 단계에서 protected mode → long mode로 진입한다.
페이지테이블을 identity mapping 방식으로 생성 및 초기화해두고 필요한 레지스터(CR3 등)을 초기화한 뒤 long mode를 활성화 시키고 64bit cs로 점프해서 진입하게 된다.

MMU는 어차피 가상주소를 이 순서로 메모리상의 페이지 테이블에 직접 접근해서 해석하도록 구현되어 있고 long mode에서는 MMU가 활성화되도록 회로가 구현되어 있기 때문에 long mode에서는 무조건 이 페이지 테이블 방식을 사용해야한다.

ad93195a-7aec-41ce-935c-328ac4e0c48f

UEFI에서 페이징을 관리하고 있지만, 커널은 UEFI 메모리 맵의 사용 가능한 물리주소 정보만 꺼내고 원하는 메모리 레이아웃(커널영역과 유저영역을 나누기 위해 특정 가상주소 영역에 위치시키는 등)에 맞게 새로 구현하면서 직접 관리하게 된다.

프로세스가 생성되면 공통적으로 사용하는 커널영역의 페이지테이블과 프로세스마다 하나씩 사용하는 유저영역 페이지 테이블을 참조할 수 있게 된다.


아이덴티티 매핑 #

가상주소는 MMU를 통해 물리주소로 바꿀 수 있는데, 가상주소와 물리주소를 동일하게 사용하는 것이 identity mapped 방식이다.
UEFI의 메모리맵을 읽어보면 가상주소는 전부 0인 것을 볼 수 있다.

bb0d9e68-e021-48b3-b0ad-280474a55017

long mode 라서 강제로 페이지 테이블은 사용해야 하지만 구현, 관리, 디버깅의 편의성을 위해 임시로 사용하거나 DMA, MMIO 같은 하드웨어가 메모리를 접근할때 사용하면 주소변환이 직관적이라 편리하기 때문에 일정 구간만 매핑하기도 한다.


코드작업 #

 1// CR3 값을 세팅하는 함수
 2global SetCR3   ; void SetCR3(uint64_t value);
 3SetCR3:
 4    mov cr3, rdi
 5    ret
 6
 7// paging.cpp
 8namespace {
 9  const uint64_t kPageSize4K = 4096;
10  const uint64_t kPageSize2M = 512 * kPageSize4K;
11  const uint64_t kPageSize1G = 512 * kPageSize2M;
12
13  // page directory pointer 512개를 저장할 수 있는 pml4 테이블
14  alignas(kPageSize4K) std::array<uint64_t, 512> pml4_table;    
15  // page directory 512개를 저장할 수 있는 pdp 테이블 한개 
16  alignas(kPageSize4K) std::array<uint64_t, 512> pdp_table;     
17  // 내부적으로 8*512(4K)바이트 크기의 pd 테이블 64(kPageDirectoryCount)개
18  alignas(kPageSize4K) std::array<std::array<uint64_t, 512>, kPageDirectoryCount> page_directory; 
19
20void SetupIdentityPageTable() {
21  // 0번 pml4 에 pdp table 한개를 Identity Mapped 방식으로 세팅한다. 
22  // pdp_table은 4K로 align되어 주소가 000으로 끝나며, 이 12bit는 플래그로 사용된다. 
23  // 0x0003 플래그는 Present(1) + RW(1) 로 세팅해서 유효한 페이지이며 RW 가 가능하다는 의미를 담는다. 
24  pml4_table[0] = reinterpret_cast<uint64_t>(&pdp_table[0]) | 0x0003;
25  // pdpt 중에 64(kPageDirectoryCount)개만 세팅
26  for (int i_pdpt = 0; i_pdpt < page_directory.size(); ++i_pdpt) {
27    // 각 pdp_table에 4K 크기의 pd 하나씩 총 64개 저장. 마찬가지로 P(1) + RW(1)
28    pdp_table[i_pdpt] = reinterpret_cast<uint64_t>(&page_directory[i_pdpt]) | 0x003;
29    for (int i_pd = 0; i_pd < 512; ++i_pd) {
30      // 각 pd에 2M 짜리 페이지 512개를 넣는다.
31      // 2MB 씩 512개이기 때문에 pdpt당 1GB 씩 저장할 수 있으며 총 64개가 저장되기 때문에 64GB 주소를 매핑했다.
32      // 0x80 플래그는 1로 세팅되면 2Mib (huge page)
33      page_directory[i_pdpt][i_pd] = i_pdpt * kPageSize1G + i_pd * kPageSize2M | 0x083;
34    }
35  }
36  // CR3은 pml4 테이블의 베이스 주소를 지정하면서 방금 만든 페이지 테이블이 사용되기 시작한다.
37  SetCR3(reinterpret_cast<uint64_t>(&pml4_table[0]));
38}

작업하면 페이지 테이블은 아래와 같은 모양이 된다.
pdp_table은 63 번째 인덱스까지만 채워지고 0 ~ 64GB의 주소까지 Identity Mapped 방식으로 매핑된다.

맨처음 PML4 방식을 설명할땐 PageTable에 Page가 따로 있는데, 지금 page_directory 배열을 보면 PageTable Entry가 아니라 주소가 매핑되어 있는것을 알 수 있다.

0x80 (PS) 플래그를 세팅하면 huge page가 적용되어 페이지가 2MB 크기가 되고, PT 단계를 건너뛸 수 있게 된다.

5fc6869b-f442-4424-afbc-1df8417aa702


ex) 0x4567890a 주소를 변환해보자. #

 11. 0x4567890a       // 가상주소
 22. 00...00 01000101 01100111 10001001 00001010  // 2진법으로
 33. offset(12)=100100001010 (0x90a)   // 페이지 테이블 적용
 4   pt_off(9)=001111000 (120=0x78)    // huge page라서 여기서부터 오프셋. 
 5                                     // 0x7890a 가 오프셋이 된다. 
 6   pd_off(9)=000101011 (43)
 7   pdpt_off(9)=01
 8   pml4_off(9)=0  
 94. 1*1GiB (0x40000000) 
10   + 43*2MiB (0x5600000) 
11   + 120*4KiB (0x78000) 
12   + 0x90a 
13   = 0x4567890a     // 물리주소

플래그의 의미 #

x86_64 시스템의 page table entry이다. 8de2a58d-7c81-4c3d-a9e5-5f000b460199


메모리 매니저 구현 #

4KiB (4*1024) 단위의 페이지 프레임(물리주소)를 관리하는 메모리 매니저를 구현할 것이다.
프레임마다 1bit를 사용해 사용중(1), 미사용(0)으로 관리하는 매니저라서 BitmapMemoryManager 라고부른다.

매니저는 메모리 공간을 사용/미사용 체크해서 재접근이 불가하도록 막는 역할만 한다고 보면 된다.


프레임 #

 1namespace {
 2  // 사용자 정의 리터럴. 리터럴 suffix 를 오버로딩하는 c++11 문법이다.
 3  // KB = 1000byte, KiB = 1024byte   (사실 다 KB로 쓰기도한다.)
 4  constexpr unsigned long long operator""_KiB(unsigned long long kib) {
 5    return kib * 1024;
 6  }
 7  constexpr unsigned long long operator""_MiB(unsigned long long mib) {
 8    return mib * 1024_KiB;
 9  }
10  constexpr unsigned long long operator""_GiB(unsigned long long gib) {
11    return gib * 1024_MiB;
12  }
13}
14
15// 각 프레임(페이지)는 4KiB 크기이다. 
16static const auto kBytesPerFrame{4_KiB};
17
18class FrameID {
19public:
20  explicit FrameID(size_t id) : id_{id} {}
21  size_t ID() const { return id_; }
22  // 각 id_는 메모리에서 페이지 프레임 마다 순차적으로 증가하는 인덱스이다.
23  void* Frame() const { return reinterpret_cast<void*>(id_ * kBytesPerFrame); }
24
25private:
26  size_t id_;
27};
28
29// 정의되지 않은 프레임번호의 의미하는 상수값이다. 
30// 페이지 프레임 탐색 함수에서 찾지 못했을 때의 리턴값
31static const FrameID kNullFrame{std::numeric_limits<size_t>::max()};

BitmapMemoryManager 구현 1 - 초기화 관련 코드 #

페이지 프레임 한개를 1bit로 생각하고 할당 여부를 관리하는 BitmapMemoryManager를 구현한다.

전체 페이지 프레임 수 만큼 bit로 표현해야 하기 때문에 alloc_map_ 은 어떤 자료형의 1차원 배열 형식이지만 그 자료형의 각 비트가 페이지 하나를 의미한다. (현재는 unsigned long)

 1class BitmapMemoryManager {
 2public:
 3  static const auto kMaxPhysicalMemoryBytes{128_GiB};
 4  static const auto kFrameCount{kMaxPhysicalMemoryBytes / kBytesPerFrame};
 5  // 페이지 프레임 비트 라인의 타입
 6  using MapLineType = unsigned long;
 7  // 각 라인당 비트 수
 8  static const size_t kBitsPerMapLine{8 * sizeof(MapLineType)};
 9
10  BitmapMemoryManager();
11
12  WithError<FrameID> Allocate(size_t num_frames);
13  Error Free(FrameID start_frame, size_t num_frames);
14
15  // FrameID에 해당하는 비트를 할당됨(1)로 세팅한다. 
16  void MarkAllocated(FrameID start_frame, size_t num_frames);
17  // 그냥 시작 FrameID, 끝 FrameID 를 전달해서 초기화한다. 
18  void SetMemoryRange(FrameID range_begin, FrameID range_end);
19
20private:
21  // 비트맵. 1차원 배열이지만, 사실 자료형 크기만큼 2차원 비트배열인 것이다. 
22  std::array<MapLineType, kFrameCount / kBitsPerMapLine> alloc_map_;
23  FrameID range_begin_;
24  FrameID range_end_;
25
26  bool GetBit(FrameID frame) const;
27  void SetBit(FrameID frame, bool allocated);
28};
29
30
31// FrameID 에 해당되는 사용가능 여부를 비트맵에 세팅한다. 
32void BitmapMemoryManager::SetBit(FrameID frame, bool allocated) {
33  // 프레임은 선형적으로 0번부터 시작할 것이기 때문에 x,y 좌표 세팅
34  auto line_index = frame.ID() / kBitsPerMapLine;
35  auto bit_index = frame.ID() % kBitsPerMapLine;
36
37  // 사용불가면 1을 해당 좌표에 세팅. 사용 가능이라면 1을 해당 좌표에서 제외
38  if (allocated) {
39    alloc_map_[line_index] |= (static_cast<MapLineType>(1) << bit_index);
40  } else {
41    alloc_map_[line_index] &= ~(static_cast<MapLineType>(1) << bit_index);
42  }
43}
44
45// 해당되는 프레임 범위는 전부 true로 세팅
46void BitmapMemoryManager::MarkAllocated(FrameID start_frame, size_t num_frames) {
47  for (size_t i = 0; i < num_frames; ++i) {
48    SetBit(FrameID{start_frame.ID() + i}, true);
49  }
50}
51
52void BitmapMemoryManager::SetMemoryRange(FrameID range_begin, FrameID range_end) {
53  range_begin_ = range_begin;
54  range_end_ = range_end;
55}

메모리매니저 초기화 #

비트맵 메모리 매니저를 사용해서 전체 물리 메모리 중 어떤 공간이 사용 가능한지 마크해둬야 한다.

UEFI 메모리맵은 사용 불가능한 영역도 같이 반환되고, 그중 IsAvailable 함수로 쓸 수 있는 공간을 걸러내야 한다.
MMIO, Reserved, 범위를 벗어난 메모리 등은 메모리맵에 포함되지 않기 때문에 그것까지 생각해서 사용해야한다.

심지어 UEFI Spec 상 배열이 정렬되어있다는 보장도 없다.

 1extern "C" void KernelMainNewStack(const FrameBufferConfig& frame_buffer_config_ref,
 2                           const MemoryMap& memory_map_ref) {
 3  printk("memory_map: %p\n", &memory_map);
 4  ::memory_manager = new(memory_manager_buf) BitmapMemoryManager;
 5
 6  // memory_map을 physical_addr 오름차순으로 InPlace 정렬
 7  SortMemoryMapInPlace(memory_map);
 8
 9  const auto memory_map_base = reinterpret_cast<uintptr_t>(memory_map.buffer);
10  // 체크해야되는 메모리 프레임 시작 주소
11  uintptr_t check_offset = 0;
12  for (uintptr_t iter = memory_map_base;
13      iter < memory_map_base + memory_map.map_size;
14      iter += memory_map.descriptor_size) {
15
16    auto desc = reinterpret_cast<const MemoryDescriptor*>(iter);
17    // 메모리 디스크립터에 포함되지 않은 (GAP) 메모리가 있는경우
18    // 마지막 검증 끝낸 메모리부터 현재 위치까지 사용불가 마킹
19    if (check_offset < desc->physical_start) {
20      memory_manager->MarkAllocated(
21        FrameID{check_offset / kBytesPerFrame},
22        (desc->physical_start - check_offset) / kBytesPerFrame
23      );
24    }
25
26    const auto physical_end = desc->physical_start + desc->number_of_pages * kUEFIPageSize;
27    // 현재 메모리 공간 (physical_start 부터) 사용가능 여부에 따라 마킹
28    if (!IsAvailable(static_cast<MemoryType>(desc->type))){
29      memory_manager->MarkAllocated(
30        FrameID{desc->physical_start / kBytesPerFrame},
31        desc->number_of_pages * kUEFIPageSize / kBytesPerFrame
32      );
33    }
34    // check_offset 은 GAP 페이지 프레임을 검사하기 위해 마지막 체크한 영역을 업데이트한다.
35    check_offset = physical_end;
36  }
37
38  // 물리메모리의 페이지 프레임 범위를 지정하는 함수이다. 
39  // 1번 프레임부터 메모리맵 마지막 프레임 사이에서만 사용하기로 한다. 
40  memory_manager->SetMemoryRange(FrameID{1}, FrameID{check_offset - 1 / kBytesPerFrame});
41}

메모리할당 #

 1// 전달된 프레임 수 만큼 연속된 메모리를 찾아준다. 
 2WithError<FrameID> BitmapMemoryManager::Allocate(size_t num_frames) {
 3  // 그냥 무한루프 돌면서 빈 곳 찾음
 4  size_t start_frame_id = range_begin_.ID();
 5  while (true) {
 6    size_t i = 0;
 7    // 연속으로 할당 가능한 곳 찾기
 8    for(; i < num_frames; ++i) {
 9      // range_end_ 를 넘은 경우
10      if (start_frame_id + i >= range_end_.ID()) {
11        return {kNullFrame, MAKE_ERROR(Error::kNoEnoughMemory)};
12      }
13      // 이미 할당되어 사용할 수 없는 메모리를 발견한 경우
14      if (GetBit(FrameID{start_frame_id + i})) {
15        break;
16      }
17    }
18    // 원하는 페이지 프레임 수에 맞다면
19    if (i == num_frames) {
20      MarkAllocated(FrameID{start_frame_id}, num_frames);
21      return {
22        FrameID{start_frame_id},
23        MAKE_ERROR(Error::kSuccess);
24      };
25    }
26    // 검사한 위치(i)는 점프하고 다음프레임
27    start_frame_id += i + 1;
28  }
29}
30
31// Free는 그냥 원하는 범위 전부 false로 세팅하는 함수이다. 
32Error BitmapMemoryManager::Free(FrameID start_frame, size_t num_frames) {
33  for (size_t i = 0; i < num_frames; ++i) {
34    SetBit(FrameID{start_frame.ID() + i}, false);
35  }
36  return MAKE_ERROR(Error::kSuccess);
37}

new / delete 구현 #

이제까지는 동적 할당이라는 개념이 없었기 때문에 전역 메모리에 kernel의 공간을 할당해서 displacement new 방식으로 초기화만 하도록 사용해왔다.

new 를 사용하기 위해서는 malloc을 구현해야 하는데, malloc은 Newlib에 포함되어 있고 내부적으로 sbrk만 구현하면 사용할 수 있게된다.


sbrk #

caddr_t sbrk(int incr); 함수는 incr 바이트 만큼 프로그램 브레이크를 증감하는 함수이고, 프로그램 브레이크는 힙 메모리의 끝을 나타낸다.

965f3bdc-8f4c-4c9c-9a5d-74b8e0385073

sbrk 를 작성하기 위해서는 힙 메모리의 시작지점과 끝 지점(한계)를 정해야한다.
program_break_end 까지만 늘릴 수 있으며, 리턴값은 이전 program_break 값이고 실패 시 ENOMEM 에러 및 -1을 리턴한다.

연속된 메모리에 힙 영역을 둬야하기 때문에 원하는 크기만큼 미리 memory_manager로 페이지를 할당받은 후 힙영역으로 사용한다.

 1// newlib_support.c
 2caddr_t program_break, program_break_end;
 3
 4caddr_t sbrk(int incr) {
 5  // incr이 음수가 될 수 있기 때문에 하한 체크도 필요하다.
 6  if (program_break == 0 || program_break + incr >= program_break_end) {
 7    errno = ENOMEM;
 8    return (caddr_t)-1;
 9  }
10
11  caddr_t prev_break = program_break;
12  program_break += incr;
13  return prev_break;
14}
15
16// memory_manager.cpp
17// 힙 초기화 함수 구현. 힙 영역 할당 및 program_break, program_break_end 를 초기화한다.
18extern "C" caddr_t program_break, program_break_end;
19
20Error InitializeHeap(BitmapMemoryManager& memory_manager) {
21  // 사용할 힙 사이즈. 연속된 공간이 할당돼야 한다.
22  const int kHeapFrames = 64 * 512;
23  // 할당된 힙 시작프레임
24  const auto heap_start = memory_manager.Allocate(kHeapFrames);
25  if (heap_start.error) {
26    return heap_start.error;
27  }
28  // 메모리 프레임의 ID 값은 0번(널 프레임)부터 관리하는 프레임 순서대로 부여됐고
29  // 페이지 프레임 크기를 곱하면 해당 위치의 페이지 주소가 된다. 
30  program_break = reinterpret_cast<caddr_t>(heap_start.value.ID() * kBytesPerFrame);
31  program_break_end = program_break + kHeapFrames * kBytesPerFrame;
32  return MAKE_ERROR(Error::kSuccess);
33}
comments powered by Disqus