어플리케이션
터미널에서 프로그램 실행
프로그램 만들기
일단 hlt 명령만 실행하는 아주 간단한 프로그램을 작성한다.
TARGET = onlyhlt
.PHONY: all
all: $(TARGET)
onlyhlt: onlyhlt.asm Makefile
nasm -f bin -o $@ $<
bits 64
section .text
loop:
hlt
jmp loop
nasm -f bin 옵션으로 컴파일하면 헤더 없는 순수 플랫 바이너리 포맷으로 생성되며, 실행파일 포맷 없이 명령어만 컴파일된다.
터미널 명령어 추가
터미널에서 입력한 문자열이 있지만, 어떤 내장 명령어에도 매칭되지 않을때 파일시스템에서 찾아서 실행한다.
void Terminal::ExecuteLine() {
char* command = &linebuf_[0];
if (strcmp(command, "echo") == 0) {
// ...
} else if (command[0] != 0) {
auto file_entry = fat::FindFile(command);
if (!file_entry) {
Print("no such command: ");
Print(command);
Print("\n");
} else {
ExecuteFile(*file_entry);
}
}
}
파일을 실제로 실행하는 코드인데, 내용은 간단하다.
클러스터 단위로 파일이 저장되기 때문에 파일 전체 사이즈가 채워질때까지 다음 클러스터를 찾아가며 복사해서 가져온다.
flat binary 형식이기 때문에 읽어온 파일의 시작위치를 호출하면 바로 명령어를 실행시킬 수 있다.
void Terminal::ExecuteFile(const fat::DirectoryEntry& file_entry) {
auto cluster = file_entry.FirstCluster();
auto remain_bytes = file_entry.file_size;
std::vector<uint8_t> file_buf(remain_bytes);
auto p = &file_buf[0];
while (cluster != 0 && cluster != fat::kEndOfClusterchain) {
const auto copy_bytes = fat::bytes_per_cluster < remain_bytes ?
fat::bytes_per_cluster : remain_bytes;
memcpy(p, fat::GetSectorByCluster<uint8_t>(cluster), copy_bytes);
remain_bytes -= copy_bytes;
p += copy_bytes;
cluster = fat::NextCluster(cluster);
}
using Func = void ();
auto f = reinterpret_cast<Func*>(&file_buf[0]);
f();
}
이제 빌드한 바이너리를 볼륨이미지를 생성할 때 추가하도록 스크립트를 수정하고 부트해보면 파일시스템에 앱이 표시되며 실행해보면 hlt에 의해 터미널이 프리즈되는것을 볼 수 있다.
터미널 태스크는 이 hlt 루프만 돌기 때문에 계속해서 프리즈되지만, 명령어 실행 전에 sti 를 실행해줬기 때문에 다른 태스크들은 인터럽트로 깨어나며 정상적으로 동작하는 것을 볼 수 있다. (Text Box의 커서가 깜빡이고 마우스를 움직일 수 있다.)
ELF 지원
ELF를 실행할 수 있도록 수정
터미널에서 입력받은 문자열은 command와 나머지(first_arg)로 나뉜다. command는 파일시스템에서 찾아와서 file_entry로 전달받았지만, 프로그램 실행 시 인자로 넘겨줄 수 있도록 command와 first_arg 모두 인자 벡터(argv)로 변경해서 실행할 프로그램에 전달한다.
실행할 파일이 ELF가 아니라면(플랫파일) 기존처럼 실행하고, ELF헤더로 시작된다면 헤더에서 엔트리포인트를 찾아 실행시킨다.
void Terminal::ExecuteFile(const fat::DirectoryEntry& file_entry, char* command, char* first_arg) {
auto cluster = file_entry.FirstCluster();
auto remain_bytes = file_entry.file_size;
std::vector<uint8_t> file_buf(remain_bytes);
auto p = &file_buf[0];
while (cluster != 0 && cluster != fat::kEndOfClusterchain) {
const auto copy_bytes = fat::bytes_per_cluster < remain_bytes ?
fat::bytes_per_cluster : remain_bytes;
memcpy(p, fat::GetSectorByCluster<uint8_t>(cluster), copy_bytes);
remain_bytes -= copy_bytes;
p += copy_bytes;
cluster = fat::NextCluster(cluster);
}
auto elf_header = reinterpret_cast<Elf64_Ehdr*>(&file_buf[0]);
if (memcmp(elf_header->e_ident, "\x7f" "ELF", 4) != 0) {
using Func = void ();
auto f = reinterpret_cast<Func*>(&file_buf[0]);
f();
return;
}
// ELF인 경우 파싱 후 실행
auto argv = MakeArgVector(command, first_arg);
auto entry_addr = elf_header->e_entry;
entry_addr += reinterpret_cast<uintptr_t>(&file_buf[0]);
using Func = int (int, char**);
auto f = reinterpret_cast<Func*>(entry_addr);
auto ret = f(argv.size(), &argv[0]);
char s[64];
sprintf(s, "app exited. ret = %d\n", ret);
Print(s);
}
빌드
ELF 프로그램은 빌드할때 -ffreestanding 옵션을 사용하는데 표준 OS 처럼 멀티스레드, 표준입출력, 파일시스템 조작 등의 작업이 지원되지 않기 때문에 프리스탠딩 환경으로 빌드한다.
마찬가지로 익셉션이나 rtti도 제거된다.
CPPFLAGS += -I.
CXXFLAGS += -O2 -Wall -g --target=x86_64-elf -ffreestanding \
-fno-exceptions -fno-rtti -std=c++17
LDFLAGS += --entry main -z norelro --image-base 0 --static
rpn: rpn.o Makefile
ld.lld $(LDFLAGS) -o rpn rpn.o
%.o: %.cpp Makefile
clang++ $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@
ELF 프로그램 실행
이제 컴파일러를 통해서 ELF 파일을 만들고, 실행을 하려고하면 이전에 부트로더에서 커널을 로드했던 것과 비슷한 문제들이 생기게 된다.
부트로더에서 발생했던 ELF 문제
현재 로직을 확인해보면 파일을 메모리버퍼(벡터)에 읽어와서 메모리버퍼 시작위치 + EntryPoint 로 ELF 파일이 실행될 것 같지만, ELF 헤더에 있는 EntryPoint는 메모리상 주소(vaddr)이기 때문에 PHDR에 맞게 섹션을 배치해줘야 한다.
코드에서 참조하는 문자열리터럴도 문제가 있다. 컴파일할 때 image_base 기준으로 위치가 고정되어 참조하도록 컴파일되는데, 로드된 위치가 image_base가 아니기 때문에 잘못된 위치를 참조하게된다.
디버깅해서 rpn이 실행되는 곳에서 브레이크 걸어두고 확인해보면 알 수 있다.
pwndbg> disassemble Terminal::ExecuteFile
0x000000000016e8fd <+493>: mov rsi,rax
0x000000000016e900 <+496>: mov rax,QWORD PTR [rbp-0x108]
0x000000000016e907 <+503>: call rax ; here!!
pwndbg> b 0x000000000016e907
pwndbg> c
pwndbg> list
250 auto entry_addr = elf_header->e_entry;
251 entry_addr += reinterpret_cast<uintptr_t>(&file_buf[0]);
252 using Func = int (int, char**);
253 auto f = reinterpret_cast<Func*>(entry_addr);
254 auto ret = f(argv.size(), &argv[0]); // same here!!
확인해보면 entry_addr 로 만들어져 있는 곳이 명령어가 담긴것 같진 않다.
파일이 담긴 file_buf와 entry는 정상적으로 보인다.
pwndbg> x/10i entry_addr
0x1437400: add DWORD PTR [rax],eax
0x1437402: add BYTE PTR [rax],al
pwndbg> x/10gx entry_addr
0x1437400: 0x0000000000000001 0x0000000000000001
0x1437410: 0x00000002000000a2 0x0000000000000000
pwndbg> x/30gx &file_buf[0]
0x14361e0: 0x00010102464c457f 0x0000000000000000
0x14361f0: 0x00000001003e0002 0x0000000000001220
pwndbg> p (char*) elf_header->e_entry
$7 = 0x1220 ""
위에서 말한것처럼 entry_point는 phdr 기준으로 적혀있기 때문에 지금처럼 파일을 복사하는 방법으로는 파일오프셋을 사용해야한다.
phdr의 메모리 얼라인된 주소가 0x1000이기 때문에 0x1000을 빼고 확인해보면 ELF 파일의 entrypoint를 볼 수 있다.
pwndbg> x/100i (char*)(0x14361e0 + 0x1220 - 0x1000)
0x1436400: push rbp
0x1436401: mov rbp,rsp
0x1436404: cmp edi,0x1
0x1436407: jle 0x14364de
0x143640d: mov r8d,edi
표준라이브러리 사용하기
커널에서 사용하는 newlib 라이브러리를 이미 빌드했다.
커널을 빌드할때 static 형태로 라이브러리를 링킹하게 되는데, 내부에서 사용하는 어플리케이션 역시 이 라이브러리를 사용하여 빌드하면 된다.
이 스크립트를 사용하면 내가 빌드해둔 라이브러리 경로를 빌드옵션(-I, -L)에 export 해서 커널빌드나 앱빌드 할때 상관없이 사용할 수 있도록 포함된다.
# Usage: source buildenv.sh
BASEDIR="$HOME/min-os/devenv/x86_64-elf"
EDK2DIR="$HOME/min-os/devenv/edk2"
if [ ! -d $BASEDIR ]
then
echo "$BASEDIR が存在しません。"
echo "以下のファイルを手動でダウンロードし、$(dirname $BASEDIR)に展開してください。"
echo "https://github.com/uchan-nos/mikanos-build/releases/download/v2.0/x86_64-elf.tar.gz "
else
export CPPFLAGS="\
-I$BASEDIR/include/c++/v1 -I$BASEDIR/include -I$BASEDIR/include/freetype2 \
-I$EDK2DIR/MdePkg/Include -I$EDK2DIR/MdePkg/Include/X64 \
-nostdlibinc -D__ELF__ -D_LDBL_EQ_DBL -D_GNU_SOURCE -D_POSIX_TIMERS \
-DEFIAPI='__attribute__((ms_abi))'"
export LDFLAGS="-L$BASEDIR/lib"
fi
# clang++ -I/home/kdh/min-os/devenv/x86_64-elf/include/c++/v1 -I/home/kdh/min-os/devenv/x86_64-elf/include \
# -I/home/kdh/min-os/devenv/x86_64-elf/include/freetype2 -I/home/kdh/min-os/devenv/edk2/MdePkg/Include \
# -I/home/kdh/min-os/devenv/edk2/MdePkg/Include/X64 \
# -nostdlibinc -D__ELF__ -D_LDBL_EQ_DBL -D_GNU_SOURCE -D_POSIX_TIMERS -DEFIAPI='__attribute__((ms_abi))'
# -I. -O2 -Wall -g --target=x86_64-elf -ffreestanding -fno-exceptions -fno-rtti \
# -std=c++17 -c rpn.cpp -o rpn.o
# ld.lld -L/home/kdh/min-os/devenv/x86_64-elf/lib --entry main -z norelro --image-base 0 \
# --static -o rpn rpn.o -lc -lc++ -lc++abi
새로운 문제 발생
여기까지 오면 rpn 명령이 0으로만 출력되는 것을 볼 수 있다.
이 문제는 표준라이브러리의 문제가 아니라 사실 함수 호출의 문제이다. -O2로 빌드할땐 어셈블리가 최적화되어 문제되지 않았던 것이다.
파일에서는 0x158 위치에는 -, + 가 문자열 형태로 null을 포함하여 존재한다. 어셈 명령어 자체만 보면 문제 없어보이지만, 우리는 entry_point(rpn의 main)를 호출할때 동적할당한 새로운 버퍼에 파일을 전부 복사 후 메모리상에서 바로 실행했기 때문에 문제가 생긴다.
실제 메모리상에서 문자열이 있는 위치는 buffer주소 + 0x158이 될것이다.
### -O2 빌드
# 전부 최적화돼서 main 함수에서 바로 비교한다.
1272: 3c 2d cmp $0x2d,%al # '-'
1274: 74 2a je 12a0 <main+0x80>
1276: 3c 2b cmp $0x2b,%al # '+'
1278: 75 2c jne 12a6 <main+0x86>
### -O0 빌드
kdh@DESKTOP-MHEA7GE:~/min-os/apps/rpn$ hexdump -C -s 0x158 -n 4 rpn
00000158 2d 00 2b 00 |-.+.|
0000015c
131a: 48 8b 3c c8 mov (%rax,%rcx,8),%rdi
131e: 48 be 5a 01 00 00 00 movabs $0x15a,%rsi
1325: 00 00 00
1328: e8 33 fe ff ff call 1160 <_Z6strcmpPKcS0_>
# 런타임에 rpn 어플리케이션 main 들어간 직후
pwndbg> x/2s 0x14374e0-0x2f0 # rpn의 ELF헤더
0x14371f0: "\177ELF\002\001\001"
0x14371f8: ""
pwndbg> x/2s 0x14374e0-0x2f0+0x158 # 리터럴 문자열 위치
0x1437348: "-"
0x143734a: "+"
당연히 0x158은 이미 사용중이거나 시스템의 영역이기 때문에 비교가 제대로될 수 없다. 운이 좋게 된다고 해도 strcmp call에서 오프셋이 안맞아 이상한 명령이 실행될 것이다. (main은 우리가 맞춰줬기 때문에 괜찮다)
가상메모리
위에서 발생한 문제는 어플리케이션이 빌드된 시점에 정해진 주소를 바로 사용해서 동적으로 커널에 로드된 파일의 주소 기준으로 배치가 되지 않은점이 문제가 된다.
- 커널이 사용하지 않는(않을) 공간으로 실행파일을 배치할 주소를 미리 결정해서 컴파일하는 방법
- 실행하는 시점에 재배치해서 코드안의 주소를 수정하는 방법
- PIE(위치독립코드)로 컴파일해서 상대주소로만 동작하도록 빌드하는 방법도 있다.
운영체제는 모든 어플리케이션이 같은 이미지베이스로 컴파일된다 하더라도 동시에 실행할 수 있도록 가상메모리 개념을 도입하여 테이블을 통해 물리메모리를 매핑하도록 구현한다.
아래 그림을 보면 모든 프로그램이 논리주소에서 0번지부터 시작하지만, 물리메모리에는 각기 다른 주소를 사용하는 것을 볼 수 있다. 프로그램 A를 실행할 때 A용 어드레스 매핑테이블을 CPU에 세팅하는 방식으로 구현된다.
페이징 정리
x86 CPU는 세그멘테이션과 페이징을 사용해서 주소를 매핑한다. 64bit 모드는 세그멘테이션을 거의 사용하지 않기 때문에 페이징으로 구현할 것이다.
이전에 커널 진입하자마자 long mode (64bit cpu mode) 진입을 위해 4-level-paging을 사용했었다.
이때는 물리주소와 가상주소가 동일한 메모리를 가리키는 identity mapping 방식으로 페이지테이블을 만들어서 사용했는데, 유저어플리케이션에서는 제대로 가상메모리를 사용해서 페이징을 할 것 이다.
정확히 말하면 x86 CPU에서는 프로그램 명령어를 실행할때 MMU로 가상주소와 물리주소를 변환해주는 테이블을 CR3 레지스터에 입력할 수 있고, 운영체제 실행 중 컨텍스트가 변경되며 커널의 페이지 테이블(identity mapping), 프로그램A의 페이지 테이블, 프로그램B의 페이지테이블을 갈아끼워가며 실행되는 것일 뿐이다.
그래서 CPU에 전달되는 주소는 가상주소여도, 실제 실행되는건 물리주소인 것이 가능하게된다.
미리 스포하자면 커널의 identity mapping은 부팅 과정에서만 사용되고, 나중에는 가상메모리 상위주소로 고정되고 하위주소는 어플리케이션 주소로 사용된다. 이것이 가상메모리에서 유저영역과 커널영역이 있는 이유이다.
가상주소 -> 물리주소
다시 이 그림을 보면 가상주소는 사실 페이지 테이블 4개에 대한 인덱스가 저장되어 있을 뿐이고, 테이블은 각각의 다음 테이블 인덱스를 가리키지만 마지막 테이블은 실제 물리메모리에 로드된 페이지 주소를 가리킨다.
거기에 오프셋을 더해 실제 물리메모리 주소를 완성해서 접근하게 된다.
4-level-paging을 사용하면 48bit만 사용해서 앞 주소는 unused로 되어있는걸 볼 수 있는데, 이 값은 47번째 bit와 동일한 값을 가져야 CPU 예외가 발생하지 않는다. 그렇기 때문에 실질적으로 개발자가 사용할 수 있는 주소는 줄어들게 된다. (MiknaOS는 48bit 만 사용)
일반적인 운영체제는 커널을 위쪽으로 올리고, 유저어플리케이션이 아래쪽을 사용한다.
(아마 사용 가능한 주소 공간 범위가 변경됐을때 재빌드가 필요없게 하기 위함)
주소공간 분리
MikanOS 에서는 OS 구현의 편의를 위해 어플리케이션을 위로 올려서 사용한다. 그러려면 image-base 를 0xffff800000000000 으로 빌드해야한다. (어플리케이션에서 사용하는 주소는 무조건 가상주소임)
커널은 건들지 않아도되는 이유가 어차피 identity mapping으로 물리주소를 그대로 사용하는데 물리적으로 램이 0x00007fffffffffff 크기가 될 수 없기 때문이다. (125 테라바이트 크기임)
이렇게 구현하게 되면 어플리케이션은 pml4 테이블에서 0b1'0000'0000 (=256) 인덱스부터 사용하게 된다.
- 커널은 어차피 CR3로 테이블을 변환해주면 되는데 굳이 주소 범위를 나눈 이유?
syscall이나 iterrupt가 발생할때 페이지 테이블을 변경하면 TLB 캐싱을 flush해야해서 성능상 단점이 생김. 그래서 같은 페이지 테이블을 사용하고, 모드만 전환하는 방식으로 진화하게됐다. - 컴파일 시 -mcmodel=large 옵션을 사용하는 이유?
컴파일러는 기본적으로 어드레스폭을 32bit로 가정해서 기계어를 만들기 때문에 –image-base 에서 32bit 주소를 초과하는 주소를 지정하면 링크할때 에러가 발생한다.(0xffff8000~ 주소를 링킹할 수 없음)
사실 원래처럼 하위주소를 유저어플리케이션이 쓰면 문제없이 컴파일이 됐을것이다.
메모리에 로드되는 과정
커널을 로드할 때 처럼 ELF는 LOAD 로 되어있는 메모리만 정확한 위치에 로드하면 된다.
어플리케이션에 적용할때 신경 쓸 부분은 가상주소가 아주 큰 값으로 세팅되어 있고, 할당받은 물리주소와 연결해야 한다는 것이다.
LoadFile은 FAT에서 파일을 버퍼로 읽어오고, LoadELF는 물리 메모리에 로드하는 함수이다.
Error Terminal::ExecuteFile(const fat::DirectoryEntry& file_entry, char* command, char* first_arg) {
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 (auto err = LoadELF(elf_header))
{ return error; }
auto argv = MakeArgVector(command, first_arg);
auto entry_addr = elf_header->e_entry;
using Func = int (int, char**);
auto f = reinterpret_cast<Func*>(entry_addr);
auto ret = f(argv.size(), &argv[0]);
}
LoadELF
fat에서 읽어온 파일 버퍼를 그대로 LoadELF로 넘기는데, 프로그램 헤더에서 처음으로 만나는 PT_LOAD 타입의 가상주소를 얻어오고 의도했던 것과 같은 상위주소를 사용하는지 확인한다.
이후 CopyLoadSegments 함수를 호출하여 세그먼트 단위로 물리메모리에 배치시킨다.
Elf64_Phdr* GetProgramHeader(Elf64_Ehdr* ehdr) {
return reinterpret_cast<Elf64_Phdr*>(
reinterpret_cast<uintptr_t>(ehdr) + ehdr->e_phoff);
}
uintptr_t GetFirstLoadAddress(Elf64_Ehdr* ehdr) {
auto phdr = GetProgramHeader(ehdr);
for (int i = 0; i < ehdr->e_phnum; ++i) {
if (phdr[i].p_type != PT_LOAD) continue;
return phdr[i].p_vaddr;
}
return 0;
}
Error LoadELF(Elf64_Ehdr* ehdr) {
if (ehdr->e_type != ET_EXEC) {
return MAKE_ERROR(Error::kInvalidFormat);
}
const auto addr_first = GetFirstLoadAddress(ehdr);
if (addr_first < 0xffff'8000'0000'0000) {
return MAKE_ERROR(Error::kInvalidFormat);
}
if (auto err = CopyLoadSegments(ehdr)) {
return err;
}
return MAKE_ERROR(Error::kSuccess);
}
CopyLoadSegments 에서는 프로그램헤더에서 모든 PT_LOAD 타입들을 세그먼트 단위로 물리 메모리에 로드하게 된다.
페이지는 4k 크기 단위로 만들어지고 SetupPageMaps 함수로 테이블에 세팅한 뒤 세그먼트를 복사하기 때문에 하나의 세그먼트는 연속된 물리, 가상 메모리 공간에 배치된다.
Error CopyLoadSegments(Elf64_Ehdr* ehdr) {
auto phdr = GetProgramHeader(ehdr);
for (int i = 0; i < ehdr->e_phnum; ++i) {
if (phdr[i].p_type != PT_LOAD) continue;
LinearAddress4Level dest_addr;
dest_addr.value = phdr[i].p_vaddr;
// p_vaddr이 해당 페이지의 무조건 0에서 시작하는게 아니다.
// p_vaddr이 0x2b00 에서 시작하고, p_memsz가 0x0c00 인 경우 page가 2개 필요하다.
const auto num_4kpages = (phdr[i].p_vaddr % 4096
+ phdr[i].p_memsz + 4095) / 4096;
if (auto err = SetupPageMaps(dest_addr, num_4kpages)) {
return err;
}
const auto src = reinterpret_cast<uint8_t*>(ehdr) + phdr[i].p_offset;
const auto dst = reinterpret_cast<uint8_t*>(phdr[i].p_vaddr);
memcpy(dst, src, phdr[i].p_filesz);
memset(dst + phdr[i].p_filesz, 0, phdr[i].p_memsz - phdr[i].p_filesz);
}
return MAKE_ERROR(Error::kSuccess);
}
PageMapEntry
페이지테이블을 세팅하기 전에 페이지 엔트리 구조체를 살펴보자
커널 메모리는 페이지테이블을 identity mapping 방식으로 사용하기 위해 std::array<uint64_t, 512> pml4_table; 형태로 만들어서 0번 인덱스만 저장 후 CR3 레지스터에 세팅했다. 링크
(pml4_table[0] 만 해도 512GB를 담을 수 있다.)
LinearAddress4Level 은 가상주소를 파싱하는 방법이 담겨 있고, 페이지테이블의 인덱스는 9bit(0~511)로 표현하면서 오프셋은 12bit로 표현된다.
위에서 한번 언급했듯이 어플리케이션은 0xffff'8000'0000'0000~ 가상주소를 사용하기 때문에 pml4 테이블의 인덱스는 0b1'0000'0000 부터 시작하게 된다.
union LinearAddress4Level {
uint64_t value;
struct {
uint64_t offset : 12;
uint64_t page : 9;
uint64_t dir : 9;
uint64_t pdp : 9;
uint64_t pml4 : 9;
uint64_t : 16;
} __attribute__((packed)) parts;
int Part(int page_map_level) const {
switch (page_map_level) {
case 0: return parts.offset;
case 1: return parts.page;
case 2: return parts.dir;
case 3: return parts.pdp;
case 4: return parts.pml4;
default: return 0;
}
}
// void SetPart(int page_map_level, int value)
}
마지막 페이지 테이블의 엔트리는 PageMapEntry로 표현 가능하고 addr에 물리주소의 페이지 주소가 담기며, present 비트 (data & 0x1) 로 사용중인지 확인이 가능하다.
커널에서는 플래그 비트들을 0x83(global | writable | present) 으로 설정했다.
union PageMapEntry {
uint64_t data;
struct {
uint64_t present : 1;
uint64_t writable : 1;
uint64_t user : 1;
uint64_t write_through : 1;
uint64_t cache_disable : 1;
uint64_t accessed : 1;
uint64_t dirty : 1;
uint64_t huge_page : 1;
uint64_t global : 1;
uint64_t : 3;
uint64_t addr : 40;
uint64_t : 12;
} __attribute__((packed)) bits;
PageMapEntry* Pointer() const {
return reinterpret_cast<PageMapEntry*>(bits.addr << 12);
}
void SetPointer(PageMapEntry* p) {
bits.addr = reinterpret_cast<uint64_t>(p) >> 12;
}
};
SetupPageMap
아까 위에서 LoadELF → CopyLoadSegments 로 로드해야하는 가상메모리를 테이블에 세팅하기 위해 하나의 세그먼트와 세그먼트의 페이지 수를 계산해서 SetupPageMaps 를 호출했다.
// 프로그램 헤더에서 하나의 시그먼트를 가져옴
// CopyLoadSegments 함수 내부...
// LinearAddress4Level dest_addr;
// dest_addr.value = phdr[i].p_vaddr;
// SetupPageMaps(dest_addr, num_4kpages)
// 일단은 현재 CR3 레지스터에 있는 페이지테이블을 가져온다.
Error SetupPageMaps(LinearAddress4Level addr, size_t num_4kpages) {
auto pml4_table = reinterpret_cast<PageMapEntry*>(GetCR3());
return SetupPageMap(pml4_table, 4, addr, num_4kpages).error;
}
// page_map: pml4_table , page_map_level: 4 부터 시작
WithError<size_t> SetupPageMap(
PageMapEntry* page_map, int page_map_level, LinearAddress4Level addr, size_t num_4kpages) {
// 페이지 전부 할당될 때 까지 반복
while (num_4kpages > 0) {
// 가상주소에서 현재 레벨에 해당하는 인덱스를 가져오고,
// 테이블에 엔트리(하위테이블)가 없다면(not present) 하나 할당한다.
// 엔트리도 `8byte * 512 = 4k` 이고, 페이지도 `4k` 이다
const auto entry_index = addr.Part(page_map_level);
auto [ child_map, err ] = SetNewPageMapIfNotPresent(page_map[entry_index]);
// 재귀적으로 가상주소에 맞는 다음 테이블에도 계속해서 세팅한다.
// page_map_level 이 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;
}
// 엔트리가 테이블 크기보다 넘어가면 break 후 상위 테이블에서 기록한다.
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) };
}
// 페이지 엔트리 생성 함수
WithError<PageMapEntry*> NewPageMap() {
auto frame = memory_manager->Allocate(1);
if (frame.error) {
return { nullptr, frame.error };
}
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();
if (err) { return { nullptr, err }; }
// 새로 만든건 하위테이블 혹은 페이지이다.
entry.SetPointer(child_map);
entry.bits.present = 1;
return { child_map, MAKE_ERROR(Error::kSuccess) };
}
페이지 테이블은 사실 CPU와의 약속으로 구현되어 있기 때문에 플래그나 이런 부분이 잘못되면 CPU 예외가 발생할 수 있다.
ex) present 플래그가 0인 위치에 접근 시 페이지 폴트 발생
여기까지 실행되면 커널의 페이지 테이블의 상위주소에 어플리케이션이 기생하게 된다.
메모리 정리
프로그램 실행이 종료되면 더이상 메모리 공간을 차지할 필요가 없기 때문에 당연히 메모리를 정리해둬야 한다.
Error CleanPageMap(PageMapEntry* page_map, int page_map_level) {
for (int i = 0; i < 512; ++i) {
auto entry = page_map[i];
if (!entry.bits.present) {
continue;
}
if (page_map_level > 1) {
if (auto err = CleanPageMap(entry.Pointer(), page_map_level - 1)) {
return err;
}
}
const auto entry_addr = reinterpret_cast<uintptr_t>(entry.Pointer());
const FrameID map_frame{entry_addr / kBytesPerFrame};
if (auto err = memory_manager->Free(map_frame, 1)) {
return err;
}
page_map[i].data = 0;
}
return MAKE_ERROR(Error::kSuccess);
}
Error CleanPageMaps(LinearAddress4Level addr) {
auto pml4_table = reinterpret_cast<PageMapEntry*>(GetCR3());
auto pdp_table = pml4_table[addr.parts.pml4].Pointer();
pml4_table[addr.parts.pml4].data = 0;
if (auto err = CleanPageMap(pdp_table, 3)) {
return err;
}
const auto pdp_addr = reinterpret_cast<uintptr_t>(pdp_table);
const FrameID pdp_frame{pdp_addr / kBytesPerFrame};
return memory_manager->Free(pdp_frame, 1);
}