메모리 관리 정리와 요구 페이징

기존 메모리 관리 방식

UEFI

메모리 관리는 UEFI에서부터 시작된다. UEFI 펌웨어와 부트로더가 부팅과정에서 필요한 경우 Boot Service 내부에서 메모리를 사용하거나 AllocatePages/AllocatePool 을 호출하여 할당받기도 한다.

커널로 전환되기 전에 마지막 메모리 상태를 gBS->GetMemoryMap API로 획득 후 gBS->ExitBootServices 호출로 부트서비스를 종료하여 메모리 변화를 멈춘 뒤 커널로 전환하며 획득해둔 MemoryMap 구조체를 넘겨준다.

커널에서 사용하는 메모리는 부트서비스 종료 전에 미리 할당해두고 필요한 위치에 PHDR에 맞게 로드해둔다. (커널의 이미지베이스는 0x100000 고정값 이다.)

  CalcLoadAddressRange(kernel_ehdr, &kernel_first_addr, &kernel_last_addr);
  CopyLoadSegments(kernel_ehdr);
  // ... 커널이후 사용하지 않는 메모리 정리

  status = gBS->ExitBootServices(image_handle, memmap.map_key);
  if (EFI_ERROR(status)) {
    status = GetMemoryMap(&memmap);
    _IfErrorHalt(L"failed to get memory map", status);

    status = gBS->ExitBootServices(image_handle, memmap.map_key);
    _IfErrorHalt(L"Could not exit boot service", status);
  }

  typedef void EntryPointType(const struct FrameBufferConfig*,
                              const struct MemoryMap*,
                              const VOID*, VOID* );
  entry_point(&config, &memmap, acpi_table, volume_image);

커널

스택

KernelMain 이라는 asm 함수(실제 entry에 해당)에서 시작되어 rsp에 kernel_main_stack 의 마지막 주소(스택은 거꾸로 자란다)를 세팅하고 C 메인함수를 실행한다.
커널스택은 여기에서 push, pop 명령에 맞춰져 주소가 늘어나거나 줄어든다.

b8d27e78-cc5a-4f0c-bc65-5ea39ef2e846
b8d27e78-cc5a-4f0c-bc65-5ea39ef2e846

페이지테이블

페이지테이블은 PML4 방식으로 관리하는데 가상주소에서 4개의 테이블에 접근하는 오프셋을 9bit(0511) 씩 사용하고, 마지막 12bit(04096)를 오프셋으로 사용하는 방식이다.

모든 테이블을 채우면 pml4[512][512][512][512] = 물리페이지 시작주소 형식이 되고, 256TB(48bit) 까지 접근할 수 있으며 페이지테이블 크기만 512GB가 된다.

b89d10a9-3665-4880-a6d1-53fb7dc83437
b89d10a9-3665-4880-a6d1-53fb7dc83437

실제 물리메모리가 512GB보다 적기 때문에 모든 페이지테이블을 사용하는것이 아니라 필요한 부분만 넣어야 한다.

커널메인에서 InitializePaging 이 호출되면서 0 ~ 64GB (1 * kPageDirectoryCount:64 * 512 * 2MB) 주소 까지 처리 가능한 페이지테이블이 생성된다.

page_directory[i][j]에 물리메모리 세팅하는 코드를 보면 | 0x083 을 추가하는데, 이 플래그가 더이상 페이지테이블을 타지 않고 나머지를 오프셋으로 보는 huge page 세팅이다.
그래서 페이지당 2MB인 9bit + 9bit + 9bit + 21bit 형태의 주소 접근을 하게 된다. (테이블이 줄어서 접근 속도가 조금 더 빠름)

정확한 그림 구조는 아래와 같다.

3c73ebbf-2b2b-47e2-8b55-054996ced8e2
3c73ebbf-2b2b-47e2-8b55-054996ced8e2

가상주소와 물리주소를 1:1 매핑(identity mapping)하기 위해 각각의 오프셋과 page_directory에 저장되는 물리주소를 동일한 값으로 세팅한다.

const size_t kPageDirectoryCount = 64;

namespace {
  const uint64_t kPageSize4K = 4096;
  const uint64_t kPageSize2M = 512 * kPageSize4K;
  const uint64_t kPageSize1G = 512 * kPageSize2M;

  alignas(kPageSize4K) std::array<uint64_t, 512> pml4_table;
  alignas(kPageSize4K) std::array<uint64_t, 512> pdp_table;
  // pdp_table을 채울 page_directory 64개
  alignas(kPageSize4K) std::array<std::array<uint64_t, 512>, kPageDirectoryCount> page_directory;
}

void InitializePaging() {
  SetupIdentityPageTable();
}

void SetupIdentityPageTable() {
  pml4_table[0] = reinterpret_cast<uint64_t>(&pdp_table[0]) | 0x0003;
  for (int i_pdpt = 0; i_pdpt < page_directory.size(); ++i_pdpt) {
    pdp_table[i_pdpt] = reinterpret_cast<uint64_t>(&page_directory[i_pdpt]) | 0x003;
    for (int i_pd = 0; i_pd < 512; ++i_pd) {
      page_directory[i_pdpt][i_pd] = i_pdpt * kPageSize1G + i_pd * kPageSize2M | 0x083;
    }
  }
  ResetCR3();
}

메모리매니저 초기화

UEFI로부터 전달받은 물리 메모리 사용 정보를 InitializeMemoryManager(memory_map); 호출 시 넘겨준다.
커널에서 물리메모리 사용 여부를 프레임단위(보통 페이지와 크기가 같다)로 관리(컨트롤)할 수 있도록 memory_manager 의 프레임 비트맵을 초기화하게된다.

// 아래 세가지 상태만 할당 가능 메모리 공간으로 생각함
inline bool IsAvailable(MemoryType memory_type) {
  return
    memory_type == MemoryType::kEfiBootServicesCode ||
    memory_type == MemoryType::kEfiBootServicesData ||
    memory_type == MemoryType::kEfiConventionalMemory;
}

힙 영역 (또 메모리매니저)

new를 사용하기 위해서 malloc을 구현해야하고 이미 Newlib에서 내부적으로 구현되어 있다. malloc은 sbrk를 구현하기만 하면 동작한다.

sbrk 는 전체 힙 영역을 관리하는 함수인데, newlib은 sbrk로 확보한 메모리 영역을 힙 풀로 삼고 그 안에서 자체적으로 동적할당을 관리해주는 방식이기 때문에 sbrk만 구현하면 되는 것이다.

caddr_t program_break, program_break_end;

caddr_t sbrk(int incr) {
  // incr이 음수가 될 수 있기 때문에 하한 체크도 필요하다.
  if (program_break == 0 || program_break + incr >= program_break_end) {
    errno = ENOMEM;
    return (caddr_t)-1;
  }

  caddr_t prev_break = program_break;
  program_break += incr;
  return prev_break;
}

메모리매니저의 비트맵 초기화 이후에는 InitializeHeap(*memory_manager) 함수가 호출된다.

kHeapFrames 크기의 아주 큰 영역을 미리 memory_manager에게 할당받고, program_break 를 초기화해둔다.
여기에서 할당받은 힙 크기를 벗어날 수 없다. 페이지는 이미 전체를 채워놨기 때문에 건들지 않아도된다.

  Error InitializeHeap(BitmapMemoryManager& memory_manager) {
    const int kHeapFrames = 64 * 512;
    const auto heap_start = memory_manager.Allocate(kHeapFrames);
    if (heap_start.error) {
      return heap_start.error;
    }

    program_break = reinterpret_cast<caddr_t>(heap_start.value.ID() * kBytesPerFrame);
    program_break_end = program_break + kHeapFrames * kBytesPerFrame;
    return MAKE_ERROR(Error::kSuccess);
  }

segmentation

long mode에서는 세그멘테이션의 주소값을 지정하는 기능이 비활성화 되어있다.
컨텍스트를 변경하며 dpl 을 검사하는 용도로만 사용된다. 커널 세그먼트 셀렉터는 상수값으로 지정되어 있고, 유저 세그먼트 셀렉터는 CallApp 때 직접 전달되어 따로 정의되어있진 않다.

const uint16_t kKernelCS = 1 << 3;   // | idx(13bit) : TI(1bit) : RPL(2bit) | 
const uint16_t kKernelSS = 2 << 3; 
const uint16_t kKernelDS = 0; 
const uint16_t kTSS = 5 << 3;

                                // user ss | RPL(3)    reto 명령에서 세팅됨
int ret = CallApp(argc.value, argv, 3 << 3 | 3, entry_addr,
                  stack_frame_addr.value + 4096 - 8,
                  &task.OSStackPointer());

어플리케이션

어플리케이션은 베이스주소 0xffff'8000'0000'0000 으로 빌드되어 Terminal::ExecuteFile 에서 실행된다.
커널의 PML4 테이블 기반으로 새로운 PML4 테이블을 만들고 스택과 인자버퍼를 위한 고정 주소를 지정한 뒤 SetupPageMaps 함수에서 페이지테이블에 추가한다.

  // 커널 테이블을 그대로 복사하고 cr3를 바꿔낀다. 
  auto pml4 = SetupPML4(task);
  LoadELF(elf_header);   // CopyLoadSegments 호출

  LinearAddress4Level stack_frame_addr{0xffff'ffff'ffff'e000};
  SetupPageMaps(stack_frame_addr, 1);

  LinearAddress4Level args_frame_addr{0xffff'ffff'ffff'f000};
  SetupPageMaps(args_frame_addr, 1);

                                  // user ss | RPL(3)
  int ret = CallApp(argc.value, argv, 3 << 3 | 3, entry_addr,
                    stack_frame_addr.value + 4096 - 8,
                    &task.OSStackPointer());

커널은 이미 필요한 페이지들을(64GB까지) 모두 테이블에 세팅해뒀기 때문에 페이지테이블이 필요없다.
SetupPageMap 함수는 어플리케이션의 페이지를 세팅할 때만 사용되고, 전달된 주소에 해당하는 페이지와 관련된 테이블을 재귀적으로 세팅하는 함수이다.

유저 어플리케이션이 사용하는 메모리이기 때문에 테이블엔트리의 bits.user 을 세팅해서 ring3 일때에도 접근할 수 있게 세팅한다.
여기에서 세팅되는 페이지엔트리들은 | 0x83 하는 과정이 없기 때문에 huge page를 사용해서 테이블을 3단계만 사용하는 커널과 달리 어플리케이션은 4단계를 사용할 수 있게 된다.

WithError<size_t> SetupPageMap(
    PageMapEntry* page_map, int page_map_level, LinearAddress4Level addr, size_t num_4kpages) {
  while (num_4kpages > 0) {
    const auto entry_index = addr.Part(page_map_level);

    auto [ child_map, err ] = SetNewPageMapIfNotPresent(page_map[entry_index]);
    if (err) {
      return { num_4kpages, err };
    }
    page_map[entry_index].bits.writable = 1;
    page_map[entry_index].bits.user = 1;

    if (page_map_level == 1) {
      --num_4kpages;
    } else {
      auto [ num_remain_pages, err ] = SetupPageMap(child_map, page_map_level - 1, addr, num_4kpages);
      if (err) {
        return { num_4kpages, err };
      }
      num_4kpages = num_remain_pages;
    }

    if (entry_index == 511) break;

    addr.SetPart(page_map_level, entry_index + 1);
    for (int level = page_map_level - 1; level >= 1; --level) {
      addr.SetPart(level, 0);
    }
  }
  return { num_4kpages, MAKE_ERROR(Error::kSuccess) };
}

페이지를 할당하는 구조를 보면 memory_manager로 부터 물리주소 프레임 하나를 할당받고, 그 주소를 entry에 세팅 후 주소(사실상 하위테이블)를 리턴해준다.
맨처음 SetNewPageMapIfNotPresent 에 들어오는 테이블은 pml4_table[511] 인데, 0으로 초기화되어 있기 때문에 present가 0일 것이다.

이 구조에 의해 어플리케이션의 가상주소 범위에 랜덤한 물리주소가 매핑되게 된다.

WithError<PageMapEntry*> NewPageMap() {
  auto frame = memory_manager->Allocate(1);

  auto e = reinterpret_cast<PageMapEntry*>(frame.value.Frame());
  memset(e, 0, sizeof(uint64_t) * 512);
  return { e, MAKE_ERROR(Error::kSuccess) };
}

// 엔트리를 사용하고 있지 않다면 
WithError<PageMapEntry*> SetNewPageMapIfNotPresent(PageMapEntry& entry) {
  if (entry.bits.present) {
    return { entry.Pointer(), MAKE_ERROR(Error::kSuccess) };
  }

  auto [ child_map, err ] = NewPageMap();

  entry.SetPointer(child_map);
  entry.bits.present = 1;

  return { child_map, MAKE_ERROR(Error::kSuccess) };
}

이렇게 매핑하고나면 유저 어플리케이션의 페이지테이블은 아래와 같이 3단계로 구성되는 커널영역과 4단계로 구성되는 유저영역의 테이블을 갖게된다.

c15fae21-de51-4e6d-ad38-9c269cf4c863
c15fae21-de51-4e6d-ad38-9c269cf4c863

Demand Paging

지금까지는 필요한 페이지들을 미리 할당해서 어플리케이션으로 진입하는 구조이기 때문에 어플리케이션 실행 중 처음 할당받은 메모리를 넘어서면 페이지폴트가 발생하게 된다.

요구페이징은 페이지폴트가 발생했을때 인터럽트 핸들러에서 페이지를 만들어주고 복귀해서 명령어를 다시 실행하는 방식이다. 현대 OS에서는 필수 기능이다.

ELF 지연로드를 구현할때도 요구페이징을 사용한다. 프레임은 요구페이징에 의해 자동으로 할당되고 파일을 프레임 단위로만 복사해주면 된다. (이건 구현하지 않음)

핸들러에서 프레임 할당받기

PF예외가 발생하면 인터럽트 핸들러로 넘어오고, CR2 레지스터에는 PF의 원인이 된 메모리 주소가 담겨있다. 요구페이징 조건이 맞다면 프레임을 할당하고 리턴해서 명령어를 다시 실행하게 된다.

에러 코드는 4bit로 표현되며 |RSVD |U/S |W/R |P | 이렇게 되어있다.

  • P: 존재하지 않는 페이지 0, 페이지권한 예외 1
  • W/R: 읽기예외 0 << 1, 쓰기예외 1 << 1
  • U/S: 슈퍼바이저모드 접근 0 << 2, 유저모드 접근 1 << 2
  • RSVD: 예약비트때문이 아님 0 << 3, 예약비트때문임 1 << 3
__attribute__((interrupt))
void IntHandlerPF(InterruptFrame* frame, uint64_t error_code) {
  uint64_t cr2 = GetCR2();
  if (auto err = HandlePageFault(error_code, cr2); !err) {
    return;
  }
  KillApp(frame);
  PrintFrame(frame, "#PF");
  WriteString(*screen_writer, {500, 16*4}, "ERR", {0, 0, 0});
  PrintHex(error_code, 16, {500 + 8*4, 16*4});
  while (true) __asm__("hlt");
}

Error HandlePageFault(uint64_t error_code, uint64_t causal_addr) {
  auto& task = task_manager->CurrentTask();
  // P=1. 페이지레벨 권한 위반 예외. 이건 디맨드페이징 처리를 하면 안된다.
  if (error_code & 1) {
    return MAKE_ERROR(Error::kAlreadyAllocated);
  }
  if (causal_addr < task.DPagingBegin() || task.DPagingEnd() <= causal_addr) {
    return MAKE_ERROR(Error::kIndexOutOfRange);
  }
  return SetupPageMaps(LinearAddress4Level{causal_addr}, 1);
}

페이지범위 설정

핸들러에서 DPagingBegin 과 DPagingEnd 함수가 보이고, 이 범위를 벗어난 액세스에서 발생한 PF는 Error::kIndexOutOfRange 에러를 리턴한다.

어플리케이션의 버그로 너무 많은 주소를 할당받으면 물리메모리 자체가 고갈되고 커널까지 종료될 수 있기 때문에 시스템콜로 어플리케이션에 할당할 수 있는 페이지 범위를 조절할 수 있도록 구현된다.

SYSCALL(DemandPages) {
  const size_t num_pages = arg1;
  // const int flags = arg2;
  __asm__("cli");
  auto& task = task_manager->CurrentTask();
  __asm__("sti");

  const uint64_t dp_end = task.DPagingEnd();
  task.SetDPagingEnd(dp_end + 4096 * numpages);
  return { dp_end, 0 };
}


Error Terminal::ExecuteFile(...) {
  // ... 
  const uint64_t elf_next_page = (elf_last_addr + 4095) & 0xffff'ffff'ffff'f000;
  task.SetDPagingBegin(elf_next_page);
  task.SetDPagingEnd(elf_next_page);

  auto entry_addr = elf_header->e_entry;
}

elf_last_addr 은 LoadELF에서 로드한 어플리케이션 페이지의 마지막 주소이다. 마지막주소의 다음 페이지로 정렬해서 DPagingBegin, DPagingEnd를 세팅한다.

어플리케이션에서 필요한만큼 알아서 시스템콜로 늘려야 한다.

8a342031-69a8-49aa-9dfa-83790f408598
8a342031-69a8-49aa-9dfa-83790f408598

어플리케이션 힙 메모리?

위의 그림을 봤을때 힙메모리와 동일하다고 생각할 수 있다. 어플리케이션 용 sbrk에 DemandPaging을 적용해서 스택 방향으로 필요할때마다 메모리가 커지도록 작성하면 동적할당을 구현할 수 있다.

incr과 program_break는 바이트단위로 들어오고, 페이징은 페이지단위로 실행되기 때문에 페이지가 부족할때만 시스템콜로 늘려야한다.

caddr_t sbrk(int incr) {
  static uint64_t dpage_end = 0;
  static uint64_t program_break = 0;

  if (dpage_end == 0 || dpage_end < program_break + incr) {
    int num_pages = (incr + 4095) / 4096;
    struct SyscallResult res = SyscallDemandPages(num_pages, 0);
    if (res.error) {
      errno = ENOMEM;
      return (caddr_t)-1;
    }
    program_break = res.value;
    dpage_end = res.value + 4096 * num_pages;
  }

  const uint64_t prev_break = program_break;
  program_break += incr;
  return (caddr_t)prev_break;
}

메모리맵 파일

파일을 메모리 주소인 것처럼 매핑해두고 읽고 쓰는 구조이다. (MMIO와 비슷함)
파일을 읽거나 쓸때 랜덤액세스가 가능해지고, 읽고쓰기의 속도도 빨라진다.

MikanOS에서는 이미 UEFI에서부터 FAT 파일시스템을 모두 메모리 버퍼에 올려서 volume_image라는 이름으로 커널에 넘긴 후 fat 함수들이 관리 fat::Initialize 하는 방식을 사용하고 있다.

현재 운영체제에서는 사실 효율적이지는 않지만, 페이지 캐시라는 공간에 파일의 내용(또는 일부)을 복사하고 애플리케이션의 가상 주소에 매핑하는 방식으로 구현된다.

b748fd7d-4653-42a4-abc0-98aa83e52f1e
b748fd7d-4653-42a4-abc0-98aa83e52f1e

어플리케이션에서 매핑되는 페이지캐시 주소는 스택이 고정 크기이기 때문에 스택 위쪽으로 지정된다.
FileMapping은 fd와 매핑된 가상주소를 연결하는 테이블이고, 어플리케이션마다 fd와 가상주소가 관리되기 때문에 task에 매핑테이블을 넣었다.

struct FileMapping {
  int fd;
  uint64_t vaddr_begin, vaddr_end;
};


Error Terminal::ExecuteFile(...) {
  task.SetDPagingEnd(elf_next_page);
  task.SetFileMapEnd(0xffff'ffff'ffff'e000);

  auto entry_addr = elf_header->e_entry;
  int ret = CallApp(...);

  task.Files().clear();
  task.FileMaps().clear();   // std::vector<FileMapping>& FileMaps();
}

시스템콜

어플리케이션에서 메모리맵 파일을 사용하기 위해 SyacallMapFile 을 호출하면, 주소 관리를 위해 FileMaps에 매핑정보를 저장하고 파일을 매핑할 수 있는 주소를 넘겨줄 뿐이다.

파일이 메모리맵 될때마다 FileMapEnd 주소를 이동시키면서 다음 메모리맵 파일의 바닥주소를 기록해둔다. (어디까지 파일이 매핑되어 있는지에 대한 값임)

// /apps/mmap
extern "C" void main(int argc, char** argv) {
  SyscallResult res = SyscallOpenFile("/memmap", O_RDONLY);
  size_t file_size;
  res = SyscallMapFile(fd, &file_size, 0);

  // 그냥 시스템콜 호출해서 얻은 주소에 랜덤액세스 하면 된다. 
  char* p = reinterpret_cast<char*>(res.value);
  for (size_t i = 0; i < file_size; ++i) {
    printf("%c", p[i]);
  }
}

// 시스템콜 구현
SYSCALL(MapFile) {
  const int fd = arg1;
  size_t* file_size = reinterpret_cast<size_t*>(arg2);
  // const int flags = arg3;

  __asm__("cli");
  auto& task = task_manager->CurrentTask();
  __asm__("sti");

  // 매핑할 파일이 open 되어있지 않은경우
  if (fd < 0 || task.Files().size() <= fd || !task.Files()[fd]) {
    return { 0, EBADF };
  }

  *file_size = task.Files()[fd]->Size();
  const uint64_t vaddr_end = task.FileMapEnd();
  // 파일을 결국 페이지단위로 정렬해서 배치해야함
  const uint64_t vaddr_begin = (vaddr_end - *file_size) & 0xffff'ffff'ffff'f000;
  // 다음 FileMapEnd
  task.SetFileMapEnd(vaddr_begin);
  task.FileMaps().push_back(FileMapping{fd, vaddr_begin, vaddr_end});
  return { vaddr_begin, 0 };
}

파일을 메모리에 매핑

시스템콜을 보면 메모리를 할당해서 데이터를 복사하는 것도 아니고, 그냥 파일이 담길 수 있는 적당한 메모리 주소를 기록하고 관리하기만 하고있다.

실제로 파일이 로드되는 곳은 PF가 발생했을 때 이다. Syscall로 요청이 들어왔던 주소라면 PreparePageCache를 호출해서 접근한 위치에 해당하는 페이지 만큼만 페이지 테이블을 세팅하면서 파일을 로드하게된다.

Error PreparePageCache(FileDescriptor& fd, const FileMapping& m,
                       uint64_t causal_vaddr) {
  LinearAddress4Level page_vaddr{causal_vaddr};
  // PF가 발생한 페이지 세팅 및 프레임 할당 (오프셋 값을 0으로세팅)
  page_vaddr.parts.offset = 0;
  if (auto err = SetupPageMaps(page_vaddr, 1)) {
    return err;
  }

  // 복사할 파일에서의 오프셋 구함  (접근한페이지주소 - 접근한주소의시작)
  const long file_offset = page_vaddr.value - m.vaddr_begin;
  void* page_cache = reinterpret_cast<void*>(page_vaddr.value);
  // 로드할 가상주소, 크기, 파일에서의 오프셋
  fd.Load(page_cache, 4096, file_offset);
  return MAKE_ERROR(Error::kSuccess);
}


Error HandlePageFault(uint64_t error_code, uint64_t causal_addr) {
  // 요구페이징 범위인 경우
  if (task.DPagingBegin() <= causal_addr && causal_addr < task.DPagingEnd()) {
    return SetupPageMaps(LinearAddress4Level{causal_addr}, 1);
  }
  // 매핑 요청된 파일인 경우
  if (auto m = FindFileMapping(task.FileMaps(), causal_addr)) {
    return PreparePageCache(*task.Files()[m->fd], *m, causal_addr);
  }
  return MAKE_ERROR(Error::kIndexOutOfRange);
}

Read를 호출하면 buf 위치(페이지단위 매핑된 주소 영역)에 파일 데이터가 써진다 (Read 내부에서 memcpy).

fat::LoadFile 함수도 지정한 버퍼 주소에 파일내용을 전부 쓰는거라 간단하게 변경할 수 있다.

size_t FileDescriptor::Load(void* buf, size_t len, size_t offset) {
  FileDescriptor fd{fat_entry_};
  // 파일 읽기 시작할 오프셋 지정 (접근한 파일에서의 오프셋)
  fd.rd_off_ = offset;

  // 클러스터에서의 위치 찾기
  unsigned long cluster = fat_entry_.FirstCluster();
  while (offset >= bytes_per_cluster) {
    offset -= bytes_per_cluster;
    cluster = NextCluster(cluster);
  }
  fd.rd_cluster_ = cluster;
  fd.rd_cluster_off_ = offset;

  // 클러스터 위치에서 읽어서 buf(mapped vaddr)에 쓰기. rd_off_는 len만큼 이동
  return fd.Read(buf, len);
}


size_t LoadFile(void* buf, size_t len, DirectoryEntry& entry) {
  return FileDescriptor{entry}.Read(buf, len);
}

CoW(Copy on Write)

어플리케이션을 실행할 때마다 동일한 크기의 메모리가 소모된다. 당연하다고 생각할 수 있겠지만 읽기만 하는 페이지는 물리메모리를 공유할 수 있다면 메모리 소비를 줄일 수 있다.

앱 로드 기록 관리

어플리케이션 실행에 대한 테이블을 관리하다가 동일한 어플리케이션(혹은 파일) 등이 메모리에 올라왔을때 같은 테이블을 넘겨주면 동일한 메모리를 공유할 것이다.

apploads에 저장되는 AppLoadInfo의 pml4 테이블은 무결성을 유지할 수 있도록 별도로 만들어서 관리하고, 앱에 할당해주는 테이블은 커널영역복사(얕은얕은복사) + 앱영역복사(얕은복사) 로 만들어서 세팅해준다.

  • 얕은얕은복사 : 테이블 주소조차 공유하는것 실제로 할당되는건 pml4 프레임 하나이다. (== pml4_table은 앱마다 별도)
  • 얕은복사 : 테이블 주소는 전부 새로 할당되지만 실제 물리주소는 복사되는것 (
struct AppLoadInfo {
  uint64_t vaddr_end, entry;
  PageMapEntry* pml4;
};

extern std::map<fat::DirectoryEntry*, AppLoadInfo>* apploads;


WithError<AppLoadInfo> LoadApp(fat::DirectoryEntry& file_entry, Task& task) {
  PageMapEntry* temp_pml4;
  // 커널의 PML4 테이블을 완전히 복사하고 cr3 교체
  if (auto [ pml4, err ] = SetupPML4(task); err) {
    return { {}, err };
  } else {
    temp_pml4 = pml4;
  }

  // 이미 관리중인 file_entry 라면 그 페이지를 복사해서 넘겨준다.
  if (auto it = app_loads->find(&file_entry); it != app_loads->end()) {
    AppLoadInfo app_load = it->second;
    // 페이지 테이블은 새로 만들지만, 기존 pml4를 얕은복사한다.
    // 커널 영역은 완전히 테이블까지 공유하지만 유저영역은(pml4[256:]) 물리주소(프레임)만 공유
    auto err = CopyPageMaps(temp_pml4, app_load.pml4, 4, 256);
    app_laod.pml4 = temp_pml4;
    return { app_load, err };
  }

  // 처음 로드되는 앱이라면
  // 원래 ELF 파일 로드할 때 하던 작업들. phdr에 맞춰서 메모리에 로드 + 페이징
  std::vector<uint8_t> file_buf(file_entry.file_size);
  fat::LoadFile(&file_buf[0], file_buf.size(), file_entry);

  auto elf_header = reinterpret_cast<Elf64_Ehdr*>(&file_buf[0]);
  if (memcmp(elf_header->e_ident, "\x7f" "ELF", 4) != 0) {
    return { {}, MAKE_ERROR(Error::kInvalidFile) };
  }

  auto [ last_addr, err_load ] = LoadELF(elf_header);
  if (err_load) {
    return { {}, err_load };
  }

  // 처음 로드되는 앱이기 때문에 파일 로드 정보에 기록 (temp_pml4 테이블은 앱에 할당하는게 아님)
  AppLoadInfo app_load{last_addr, elf_header->e_entry, temp_pml4};
  app_laods->insert(std::make_pair(&file_entry, app_load));

  // 기록용 pml4 테이블과 실제 앱에 적용할 pml4 테이블을 별도로 유지.
  if (auto [ pml4, err ] = SetupPML4(task); err) {
    return { app_load, err };
  } else {
    app_load.pml4 = pml4;
  }
  // 앱용 영역은 다시 얕은복사로 동일한 물리페이지를 가리키게한다. 
  auto err = CopyPageMaps(app_load.pml4, temp_pml422, 4, 256);
  return { app_load, err };
}

앱용 페이지테이블을 만들때 readonly 세팅

LoadApp으로 앱 파일 영역을 메모리에 논리적으로 적재한(사실은 공유) 이후에 힙(디맨드), 스택, 메모리맵 파일, 인자버퍼 영역은 따로 세팅한다.

하지만 글로벌변수 같이 파일에 포함된 영역에서 수정이 필요한 경우도 있고, 후킹같은 기법에서는 코드를 변경하기도 하기 때문에 같은 물리 메모리를 공유하는 다른 앱에 영향을 주는 경우가 생긴다.

디맨드페이지는 없는 페이지에 접근할때 발생하는 #PF를 이용했는데, readonly 속성의 가상주소에 write작업을 하는 경우에도 #PF가 발생한다.

동일한 물리주소를 가리키는 페이지들은 처음엔 readonly로 설정하고, #PF가 발생했을 때 페이지를 새로 할당해서 복사해주면 된다.

Error CopyPageMaps(PageMapEntry* dest, PageMapEntry* src, int part, int start) {
  // page_table[0:512] 세팅
  if (part == 1) {
    for (int i = start; i < 512; ++i) {
      if (!src[i].bits.present) {
        continue;
      }
      dest[i] = src[i];            // 물리주소 복사
      dest[i].bits.writable = 0;   // readonly 세팅
    }
    return MAKE_ERROR(Error::kSuccess);
  }

  // 처음들어오면 (part==4) pml4_table[start:512] 세팅
  // 재귀적으로 들어오면 pdpt_table[0:512], pd_table[0:512] 세팅
  for (int i = start; i < 512; ++i) {
    if (!src[i].bits.present) {
      continue;
    }
    // page_table(part==1)이 아니라면 새로운 페이지를 생성해서 연결
    auto [ table, err ] = NewPageMap();
    if (err) {
      return err;
    }
    dest[i] = src[i];          // src에서 속성까지 전부 복사
    dest[i].SetPointer(table); // 방금만든 페이지 물리 주소를 저장
    if (auto err = CopyPageMaps(table, src[i].Pointer(), part - 1, 0)) {
      return err;
    }
  }
  return MAKE_ERROR(Error::kSuccess);
}

Copy On Write

#PF 가 발생했을때 인터럽트 핸들러에서 원인에 맞게 처리해야된다.

새로운 프레임(p)을 할당받고 발생한 주소(가상주소)에 있는 값을 memcpy로 복사하는데, p는 identity mapping된 커널주소이기 때문에 물리주소이자 가상주소이다.

long mode에서는 CPU가 모든 주소를 가상주소로 판단하고 하드웨어적으로 CR3 테이블에서 물리주소를 찾기 떄문에 memcpy는 가상주소끼리 복사이고, p의 물리주소가 동일한 주소이기 때문에 그 값을 그대로 페이지테이블의 말단에 세팅하는 값으로 사용해도 된다.

Error HandlePageFault(uint64_t error_code, uint64_t causal_addr) {
  auto& task = task_manager->CurrentTask();
  const bool present = (error_code >> 0) & 1;
  const bool rw      = (error_code >> 1) & 1;
  const bool user    = (error_code >> 2) & 1;

  if (present && rw && user) {
    // 페이지가 있지만, write 로 권한에러 발생, user 앱인 경우. 페이지복사해줌
    return CopyOnePage(causal_addr);
  } else if (present) {
    // 페이지레벨 권한 위반 예외. 이건 디맨드페이징 처리를 하면 안된다.
    return MAKE_ERROR(Error::kAlreadyAllocated);
  }
  // ... 요구페이징처리 
}


Error CopyOnePage(uint64_t causal_addr) {
  auto [ p, err ] = NewPageMap();
  if (err) {
    return err;
  }
  // #PF 발생한 주소에 해당하는 페이지 찾음
  const auto aligned_addr = causal_addr & 0xffff'ffff'ffff'f000;
  // 새로운 페이지(=프레임)에 내용 전부 복사
  memcpy(p, reinterpret_cast<const void*>(aligned_addr), 4096);
  // 프레임을 현재 페이지테이블 말단에 세팅하면서 writable=1 로 세팅
  return SetPageContent(reinterpret_cast<PageMapEntry*>(GetCR3()), 4,
                        LinearAddress4Level{causal_addr}, p);
}

TLB 초기화 및 커널의 항상 쓰기권한 세팅

TLB는 CPU 내부의 주소변환용 캐시이다. SetPageContent 함수에서 writable한 새로운 물리주소를 페이지에 세팅한 이후에는 주소에 해당하는 TLB 캐시를 초기화해야 writable한 주소라는것을 인식할 수 있다.

global InvalidateTLB  ; void InvalidateTLB(uint64_t addr);
InvalidateTLB:
    invlpg [rdi]
    ret


Error SetPageContent(PageMapEntry* table, int part,
                     LinearAddress4Level addr, PageMapEntry* content) {
  if (part == 1) {
    const auto i = addr.Part(part);
    table[i].SetPointer(content);
    table[i].bits.writable = 1;
    InvalidateTLB(addr.value);
    return MAKE_ERROR(Error::kSuccess);
  }

  const auto i = addr.Part(part);
  return SetPageContent(table[i].Pointer(), part - 1, addr, content);
}

또 ring0 권한인 커널도 Write 권한없이는 메모리에 쓸 수 없는데, 이걸 방지하기 위해 CR0의 WP 비트를 제거해야한다.

// 커널의 페이지테이블 초기화함수 
void SetupIdentityPageTable() {
  pml4_table[0] = reinterpret_cast<uint64_t>(&pdp_table[0]) | 0x0003;
  for (int i_pdpt = 0; i_pdpt < page_directory.size(); ++i_pdpt) {
    pdp_table[i_pdpt] = reinterpret_cast<uint64_t>(&page_directory[i_pdpt]) | 0x003;
    for (int i_pd = 0; i_pd < 512; ++i_pd) {
      page_directory[i_pdpt][i_pd] = i_pdpt * kPageSize1G + i_pd * kPageSize2M | 0x083;
    }
  }
  ResetCR3();
  SetCR0(GetCR0() & 0xfffeffff); // Clear WP
}
ESC
Type to search...