ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Higher Half Kernel
    프로젝트/운영체제 만들기 2023. 6. 6. 19:03

    글의 참고

    - https://wiki.osdev.org/Higher_Half_x86_Bare_Bones

    - https://medium.com/@connorstack/how-does-a-higher-half-kernel-work-107194e46a64

    - https://littleosbook.github.io/#paging-and-the-kernel


    글의 전제

    - 내가 글을 쓰다가 궁금한 점은 파란색 볼드체로 표현했다. 나도 모르기 때문에 나중에 알아봐야 할 내용이라는 뜻이다.

    - 밑줄로 작성된 글은 좀 더 긴 설명이 필요해서 친 것이다. 그러므로, 밑 줄 처친 글이 이해가 안간다면 링크를 따라서 관련 내용을 공부하자.

    - `글의 참조`에서 빨간색 볼드체로 체크된 링크는 이 글을 작성하면 가장 많이 참조한 링크다.

    - `운영체제 만들기` 파트에서 퍼온 모든 참조 글들과 그림은 반드시 `이 글과 그림을 소스 코드로 어떻게 구현을 해야할까` 라는 생각으로 정말 심도있게 잠시 멈춰서 생각해봐야 실력이 발전한다.


    글의 내용

    - 개념

    " 상위 절반 커널이란 실제 커널이 물리적으로 램의 아래쪽에 로딩되어 있지만, 가상 주소로는 상위에 맵핑시키는 것을 의미한다. 상위 절반 커널은 링커 스크립트를 통해 로드된다. 아래 그림에서 보면 가상 주소로 0x0000_0000 ~ 0x0040_0000(0 ~ 4MB)와 0xC000_0000 ~ 0xC040_0000(3GB ~ 3GB + 1MB)가 물리 주소 0x0000_0000 ~ 0x0040_0000(0 ~ 4MB)에 맵핑되어 있다. 이건 일단 `아이텐티티 맵핑`을 통해서 오류를 발생시키지 않게 하기 위함이다. 0xC000_0000으로 점프후에는 0x0000_0000 ~ 0x0040_0000 가상 주소의 아이텐티티 맵핑은 제거하고, 가상 주소 0xC000_0000 ~ 0xC040_0000 번지의 아이텐티티 맵핑만 남겨놓는다.

     

    출처 - https://medium.com/@connorstack/how-does-a-higher-half-kernel-work-107194e46a64

     

    " 대개 상위 절반 커널이 로드되는 위치는 1MB 이상에 로드된다. 왜냐면, BIOS 및 GRUB이 1MB 이하 영역에 위치하기 때문이다.

     

     

    " 가상 주소 0xC010_0000을 물리 주소 0x0010_0000으로 간편하게 맵핑하기 위해 페이지 사이즈는 4MB로 설정하는 것이 편하다. 대개 상위 절반 커널에서 이 방식을 채택을 하고 있다. 4MB를 선택하는 이유가 뭘까?

     

    " 예를 들어, 페이지 사이즈가 4KB 라고 가정해보자. 아이텐티티 페이징 설정을 하려면 어떻게 해야 할까? 0xC010_0000을 0x0010_0000에 매핑시키기 위해서는 2레벨을 기준으로 하면 다음과 같은 페이지 디렉토리 및 테이블이 필요하다.

    1" 페이지 디렉토리 - 0b1100000000 - (512 + 256) - 768번째 페이지 디렉토리 엔트리
    2" 페이지 테이블 - 0b0100000000 - (256) - 256번째 페이지 테이블 엔트리

    " 위의 2개 테이블을 하나씩 만들면 4MB는 커버가 가능하다. 그리고, 페이지 테이블 엔트리에 20비트를 차지하는 페이지 넘버를 작성해야 한다. 여기에는 실제 물리 주소가 들어간다. 계산 해보자.

     

    1" 페이지 테이블에 0x00000을 작성하면 실제 맵핑되는 물리 주소의 범위는 0x0000_0000 ~ 0x0000_0FFF를 의미한다.
    2" 페이지 테이블에 0x00001을 작성하면 실제 맵핑되는 물리 주소의 범위는 0x0000_1000 ~ 0x0000_1FFF를 의미한다.
    3" 페이지 테이블에 0x00002을 작성하면 실제 맵핑되는 물리 주소의 범위는 0x0000_2000 ~ 0x0000_2FFF를 의미한다.
    ...
    ...
    4" 페이지 테이블에 0x00100을 작성하면 실제 맵핑되는 물리 주소의 범위는 0x0010_0000 ~ 0x0010_0FFF를 의미한다.
    5" 페이지 테이블에 0x00101을 작성하면 실제 맵핑되는 물리 주소의 범위는 0x0010_1000 ~ 0x0010_1FFF를 의미한다.
    ...
    6" 페이지 테이블에 0x003FF을 작성하면 실제 맵핑되는 물리 주소의 범위는 0x003F_F000 ~ 0x003F_FFFF를 의미한다.

    " 위의 내용을 소스 코드로 작성해야 한다. 만약 하지 않으면 어떻게 될까? 당연히 페이지 폴트가 발생하게 된다. 그 이유는 프로세서가 제일 먼저 확인하는 건 각 페이지의 P 비트이기 때문이다. 이 방식의 불편한 점은 일일히 아이텐티티 맵핑을 4MB까지 해야한다는 것이다. 만약하지 않을 경우, 최초의 0x0000_0000 ~ 0x0000_0FFF 범위에 있는 가상 주소를 처리할 경우는 괜찮겠지만, 0x0000_1000 ~ 0x0000_1FFF 부터는 페이지 폴트를 넘어서 #DF가 발생할 수 도 있다. 왜 그럴까? 0x0000_0000 ~ 0x0000_0FFF 범위는 우리가 설정하지 않아도 되는 범위다. 왜냐면, 4KB 페이지에서 우리가 설정하는 범위는 상위 5바이트다. 하위 3바이트는 프로세서에 의해서 자동으로 처리가 된다.

     

    " 그러나 두 번째 주소인 0x0000_1000 ~ 0x0000_1FFF 은 우리가 페이지 엔트리에 0x00001 을 설정해놔야 정상적 아이텐티티 페이징이 된다. 결론은 4KB로 가능은 하다. 그러나, 4MB가 처리하는 방식을 보면 4KB는 상위 절반 커널에 도입하고 싶지 않을 것이다.

     

    " 4MB는 페이지 디렉토리 하나만 사용한다. 즉, 페이지 테이블이 필요없다. 4MB 페이지에서는 4MB는 오프셋을 의미한다. 즉, 2^22을 오프셋으로 사용한다. 그러면, 나머지 10 비트는? 상위 10 비트는 페이지 디렉토리 엔트리를 찾는데 사용한다. 그러면, 엔트리 개수 1024(2^10) * 4MB(2^22)을 곱하면 4GB를 커버할 수 있다는 것을 알 수 있다. 아래 그림은 페이지 사이즈를 4MB로 설정했을 때, 페이징 구조이다. 참고로, 페이지 사이즈를 4MB 키우기 위해서는 CR4.PSE와 PDE.PS가 모두 SET 되어야 한다.

    출처 - https://en.wikipedia.org/wiki/Page_Size_Extension

     

    " 4MB 페이징은 상위 절반 커널을 아주 쉽게 작업하게 해준다. 4MB 페이징을 통해 0xC010_0000을 0x0010_0000에 맵핑해보자. 일단 아래의 페이지 디렉토리 엔트리가 필요하다.

    1" 페이지 디렉토리 - 0b1100000000 - (512 + 256) - 768번째 페이지 디렉토리 엔트리

     

    " 아래의 그림은 페이지 사이즈를 4MB로 설정할 시, 페이지 디렉토리 엔트리의 모습이다. 그러면 이 엔트리의 실제 상위 10비트를 설정해줘야 한다.

    출처 - https://wiki.osdev.org/File:Page_directory_entry.png

     

    " 4MB 페이지를 0xC010_0000을 0x0010_0000에 아이텐티티 맵핑하기 위해서는 다음의 규칙을 보자.

    1" 0x0000_0000 - 0x003F_FFFF
    2" 0x0040_0000 - 0x007F_FFFF
    3" 0x0080_0000 - 0x00BF_FFFF
    ...

    " 최초의 페이지 디렉토리 엔트리 하나로 0x0000_0000 ~ 0x003F_FFFF 까지 커버가 가능하다. 즉, 페이지 사이즈 크기만 4MB로 하고, 특별한 초기화를 해주지 않아도 알아서 아이텐티티 페이징이 된다. 물론, 4MB 까지만 이다. 이 방식의 주의점은 커널 사이즈가 3MB 이상이면, 그 다음 페이지 디렉토리 엔트리를 하나 더 설정해줘야 한다는 점이다. 결국, 이렇게 하면 커널 사이즈는 3MB 이하 여야 한다.

     

    " 페이지 디렉토리 엔트리 별로 PS비트가 존재하기 때문에, 페이지의 사이즈는 페이지 디렉토리 엔트리 별로 다르게 가져갈 수 있다. 즉, 상위 절반 커널을 위한 768번째 디렉토리 엔트리 4MB는 아이텐티티 맵핑으로 가져가면서, 나머지 페이지 디렉토리 엔트리는 PS비트를 0으로 해서 4KB 페이지 형식을 가져가면 된다. 대신 다른 가상 주소들이 0x0000_0000 ~ 0x0040_0000 물리 주소에 맵핑되어서는 안된다.

     

    - 첫 페이지 비활성화

    : 상위 절반으로 점프를 할 경우, 맨 앞 4MB는 이제 더 이상 필요가 없다. 그러므로, `무효화`를 시킨다. `INVLPG` 명령어는 인자의 주소에 온 페이지 캐시(TLB) 하나를 무효화 시킨다. 그러나, 저 명령어만 사용하면 TLB만 사라지는 것이지 페이징을 무효화하는 것은 아니다. 그러므로, 먼저 앞에서 `mov [pde], dword 0`을 통해 맨 앞 4MB 페이지 자체를 없애고 진행한다.

    mov [pde], dword 0
    invlpg [pde]

     

    - 링커 스크립트

    " 그리고 나서 해야 할 작업은 링커 스크립트를 작업해줘야 한다. 아래 코드를 보자.

    ENTRY(loader)           /* the name of the entry symbol */
    
    . = 0xC0100000          /* the code should be relocated to 3 GB + 1 MB */
    
    /* these labels get exported to the code files */
    kern_virt_start = .;
    kern_phy_start = . - 0xC0000000;
    
    /* align at 4 KB and load at 1 MB */
    .text ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
    {
    	*(.text)            /* all text sections from all files */
    }
    
    /* align at 4 KB and load at 1 MB + . */
    .rodata ALIGN (0x1000) : AT(ADDR(.rodata)-0xC0000000)
    {
    	*(.rodata*)         /* all read-only data sections from all files */
    }
    
    /* align at 4 KB and load at 1 MB + . */
    .data ALIGN (0x1000) : AT(ADDR(.data)-0xC0000000)
    {
    	*(.data)            /* all data sections from all files */
    }
    
    /* align at 4 KB and load at 1 MB + . */
    .bss ALIGN (0x1000) : AT(ADDR(.bss)-0xC0000000)
    {
    	*(COMMON)           /* all COMMON sections from all files */
    	*(.bss)             /* all bss sections from all files */
    }
    
    kern_virt_end = .;
    kern_phy_end = . - 0xC0000000;

     

    - 로딩 시점

    : 일반적으로 상위 절반 커널은 32비트가 모두 완료된 시점부터 사용한다. 왜냐면, 페이징 기능의 도입은 32비트 프로세서가 나오면서 부터 지원된 기능이기 때문이다. 그래서 x86 기준으로 리얼 모드에서는 먼저 A20, GDT, 보호 모드로 진입 하고 나서 상위 절반 커널을 로딩하는게 좋다.

     

     

    - 롱 모드 상위 절반 전략

    : 롱 모드에서 상위 절반 커널이 로딩될 가상 주소의 시작점은 `0xFFFF8000_00000000`이다. 롱 모드에서는 4KB, 2MB, 1GB 페이지를 지원한다. 그런데, QEMU에서 1GB 페이지를 지원하지 않는 모델이있다. 실제 인텔 모델중에서도 1GB 페이지를 지원하지 않는 경우가 있다고 한다. 1GB 페이지 지원 여부는 `확장 기능 CPUID 0x80000001` 를 통해서 알 수 있다.

     

    : QEMU `q35` 는 Intel-DualCore II로 1GB 페이지를 지원하지 않는다. 그래서 2MB로 전략을 바꿔야 한다. 페이지 테이블은 3개를 정의해야 한다. 

     

    : 모든 상위 절반 전략들은 곧 바로 그 다음 모드가 지원하는 상위로 점프하지 못한다. 예를 들어, 

     

     

    : 64비트 상위 절반은 대개 바로 64비트로 점프하지 못한다. `트램폴린` 전략으로 점프해야 한다.

     

    - 64비트 상위 절반 스택 설정

    : 64비트 상위 절반에서 스택 포인트의 위치를 잘 고려해야 한다. 현재 YohdaOS는 `0xFFFFFFFF_80000000` 을 커널을 로딩할 주소로 잡았다. 이제 빌드를 할 때마다, 모든 변수 및 코드는 저 지점을 기준으로 주소가 할당된다. YohdaOS 64비트 초기 커널을 빌드하고 심볼 테이블을 확인해보면 다음과 같다.

    0000000000100000 T smode64
    0000000000100000 T _start_64
    000000000010008c t _trampoline64
    0000000000101000 d page_pml4
    0000000000102000 d page_low_pdtp
    0000000000103000 d page_low_pd
    0000000000104000 d page_high_pdtp
    0000000000105000 d page_high_pd
    0000000000106000 D emode64
    00000000c0000080 a PAGE_IA32_EFER_ADDR
    ffffffff80000000 A HH_BASE
    ffffffff80106000 t _lenter
    ffffffff8010601e T itoa
    ffffffff801060f4 T itoh
    ffffffff801061ae t pre_comm
    ffffffff801061b9 t pre_int
    ffffffff80106210 t pre_char
    ffffffff8010621a t pre_str
    ffffffff80106287 t pre_hex
    ffffffff801062e3 T kprintf
    ffffffff80106385 T vkprintf
    ffffffff80106526 T reverse
    ffffffff8010661d T strtok
    ffffffff80106714 T strrchr
    ffffffff8010678d T strchr
    ffffffff80106801 T strlen
    ffffffff8010685f T strncpy
    ffffffff801068fe T strcmp
    ffffffff801069b7 T memset
    ffffffff801069fd T memcpy
    ffffffff80106a6f T abs
    ffffffff80106a83 T max
    ffffffff80106a99 T min
    ffffffff80106aaf T log
    ffffffff80106b0d T power
    ffffffff80106b67 T main
    ffffffff80106b94 t _vga_text_write
    ffffffff80106c66 T vga_text_write
    ffffffff80106c8f T vga_text_init
    ffffffff801070a8 r __FUNCTION__.1352
    ffffffff80108000 d printable
    ffffffff80109000 b stk.1467
    ffffffff80109008 b i.1470
    ffffffff8010900c b len.1469
    ffffffff80109010 B pre
    ffffffff80109018 B vga_text_bp
    ffffffff80109020 B cl
    ffffffff80109028 B vga_text_cp
    ffffffff80109030 B kern_virt_end_addr

    : 초반 앞에는 64비트 페이징 설정을 위한 32비트 코드다. 그리고 32비트에서 FAT JUMP를 통해서 64비트의  0xFFFFFFFF_80000000 주소로 오게 된다. 위에서 `__FUNCTION__.1352`는 GCC 컴파일러 지원해주는 함수의 이름을 출력하기 위한 매크로인 `__FUNCTION__`을 의미한다. 지금 저 매크로 주소가 저렇 

     

    mov ax, GDT_DATA64
    ...
    mov ss, ax
    mov rbp, 0x50000
    mov rsp, 0x50000
    ...
    ...
    #define debug(fmt, ...) kprintf("[f:%s][l:%d]" fmt, __FUNCTION__, __LINE__, ##__VA_ARGS__);
    ...
    #include "debug.h"

    int main();
    int main()
    {
        vga_text_init();

        debug("64bit-kernel start\n");                                                                   
        
        while(1);   
    }

    : 위에서 `debug()` 함수를 호출하면 어떻게 될까?

     

    check_exception old: 0xffffffff new 0xd
         1: v=0d e=0000 i=0 cpl=0 IP=0018:ffffffff80106843 pc=ffffffff80106843 SP=0020:000000000004ee80 env->regs[R_EAX]=6c5b5d73253a665b
    RAX=6c5b5d73253a665b RBX=000000000000000d RCX=ffffffff80107090 RDX=0000000000000000
    RSI=0000000000000000 RDI=6c5b5d73253a665b RBP=000000000004ee80 RSP=000000000004ee80
    R8 =0000000000000000 R9 =0000000000000000 R10=0000000000000000 R11=0000000000000000
    R12=0000000000000000 R13=0000000000000000 R14=0000000000000000 R15=0000000000000000
    RIP=ffffffff80106843 RFL=00200002 [-------] CPL=0 II=0 A20=1 SMM=0 HLT=0
    ES =0020 0000000000000000 ffffffff 00cf9300 DPL=0 DS   [-WA]
    CS =0018 0000000000000000 ffffffff 00af9a00 DPL=0 CS64 [-R-]
    SS =0020 0000000000000000 ffffffff 00cf9300 DPL=0 DS   [-WA]
    DS =0020 0000000000000000 ffffffff 00cf9300 DPL=0 DS   [-WA]
    FS =0020 0000000000000000 ffffffff 00cf9300 DPL=0 DS   [-WA]
    GS =0020 0000000000000000 ffffffff 00cf9300 DPL=0 DS   [-WA]
    LDT=0000 0000000000000000 0000ffff 00008200 DPL=0 LDT
    TR =0000 0000000000000000 0000ffff 00008b00 DPL=0 TSS64-busy
    GDT=     0000000000007f4b 00000028
    IDT=     000000000001b010 000007ff
    CR0=80000011 CR2=0000000000000000 CR3=0000000000101000 CR4=00000020
    DR0=0000000000000000 DR1=0000000000000000 DR2=0000000000000000 DR3=0000000000000000 
    DR6=00000000ffff0ff0 DR7=0000000000000400
    CCS=0000000000000000 CCD=6c5b5d73253a665b CCO=ADDQ    
    EFER=0000000000000500

    : 위에서 보는 바와 같이 #GP가 발생했다. 즉, 접근하면 안되는 곳에 접근한 것이다. x86-64에서는 0x00007FFF_FFFFFFFF ~ FFFF8000_00000000 까지 값은 유효하지 않다. 원인은 스택 포인트 최대가 0x50000인데 `__FUNCTION__.1352` 의 값이 ffffffff801070a8 이기 때문이다. 무슨 말일까? `__FUNCTION__.1352` 은 함수로 전달되는 인자다. 인자는 스택에 저장된다. 

     

    - 리눅스 커널의 상위 절반

    : 64비트에서 리눅스 커널에 2G를 할당한다. 이 주소는 신기하게 `0xFFFFFFFF_80000000 ~ 0xFFFFFFFF_FFFFFFFF` 를 차지하게 된다.

    '프로젝트 > 운영체제 만들기' 카테고리의 다른 글

    재배치  (0) 2023.06.12
    세그먼테이션  (0) 2023.06.07
    [x86] 인터럽트  (0) 2023.06.05
    권한  (0) 2023.06.05
    C 런타임  (2) 2023.06.04
Designed by Tistory.