ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [xv6] entry
    프로젝트/운영체제 만들기 2023. 8. 1. 20:08

    글의 참고

    - https://github.com/jserv/xv6-x86_64/tree/64bit


    글의 전제

    - 밑줄로 작성된 글은 강조 표시를 의미한다.

    - 그림 출처는 항시 그림 아래에 표시했다.


    글의 내용

    : `mboot_entry`는 `xv6` 64비트 모드에 진입 시, 가장 먼저 시작되는 엔트리 포인트다. `mboot_entry`에 진입하면 제일 먼저하는 작업은 64비트 페이징 작업을 진행한다. 아래의 내용을 이해하기 위해서는 `페이징`과 `Higher Half Kernel`에 대한 내용을 알고 있어야 한다.

     

    : `mboot_entry`의 처음 시작은 물리 주소 0x1000 ~ 0x6000까지 0으로 초기화한다.

    // entry64.S
    ...
    mboot_entry:
    
    # zero 4 pages for our bootstrap page tables
      xor %eax, %eax
      mov $0x1000, %edi
      mov $0x5000, %ecx
      rep stosb
    
    # P4ML[0] -> 0x2000 (PDPT-A)
      mov $(0x2000 | 3), %eax
      mov %eax, 0x1000
    
    # P4ML[511] -> 0x3000 (PDPT-B)
      mov $(0x3000 | 3), %eax
      mov %eax, 0x1FF8
    
    # PDPT-A[0] -> 0x4000 (PD)
      mov $(0x4000 | 3), %eax
      mov %eax, 0x2000
    
    # PDPT-B[510] -> 0x4000 (PD)
      mov $(0x4000 | 3), %eax
      mov %eax, 0x3FF0
    
    # PD[0..511] -> 0..1022MB
      mov $0x83, %eax
      mov $0x4000, %ebx
      mov $512, %ecx
    ptbl_loop:
      mov %eax, (%ebx)
      add $0x200000, %eax
      add $0x8, %ebx
      dec %ecx
      jnz ptbl_loop
    ...

     

    : 64비트 `xv6` 초기 페이징은  아래와 같은 구조를 뛴다. 이 구조를 알기 위해서는 64비트 `xv6` 커널이 가상 주소 `0xFFFF_FFFF_8000_0000`에 로드된다는 것을 전제로 해야한다. 왜, 저 주소일까? 호환성을 위해서다. 0xFFFF_FFFF_8000_0000은 메모리의 끝지점을 의미한다. 이 영역에 위치하게 되면, 실제 할당 가능한 물리 주소가 64비트로 확장되더라도, 코드가 바뀔 위험이 사라진다.

     

    : 그렇면, 끝지점이 아닌, 맨 앞 지점은 안되나? 예를 들어, `0x0000_0000_0000_0000`도 물리 주소가 확장되더라도 영향을 받지 않는 지점이지 않나?

    : `xv6`는 커널을 가상 주소 `0xFFFF_FFFF_8000_0000`에 로딩할 것이므로, `0xFFFF_FFFF_8000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF` 영역에 가상 주소를 할당해야 한다. 즉, 뒤쪽 2GB만 사용한다는 것이다. x86_64에서 PML4의 엔트리 하나 당 512GB를 관리할 수 있다. 그리고, PDTP 엔트리 하나 당, 1GB를 관리할 수 있다.

     

    : `xv6`는 0x1000에 PML4 테이블을 하나 할당한다. 그리고, 제일 처음과 제일 마지막 엔트리를 `유효하다`고 설정한다. 왜? 페이징이 처음활성화 되는 시점에 현재 실행되는 주소가 1MB 이하일 것이기 때문이다. 그래서 제일 앞에 PML4 엔트리를 `아이텐티티 매핑`해야 한다. `x86_64`는 가상 주소 `0xFFFF_FFFF_8000_0000`가 입력되면, 먼저 상위 16비트는 버린다. 그러면, `FFFF_8000_0000`가 된다. 먼저, CR3에 들어있는 PML4 테이블을 가져온다. 그리고, 여기서 상위 9비트가 PML4 엔트리를 찾는데 사용된다. 즉, `0b111111111`가 PML4 테이블 엔트리 인덱스 번호가 된다. 그래서, 가상 주소 `0xFFFF_FFFF_8000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF` 에 접근하기 위해서는 511번째 PML4 엔트리를 참조하면 된다(`mov %eax, 0x1FF8`).

    0xFFFF_8000_0000 = 0b1111_1111_1111_1111_1000_0000_0000_0000

    : 여기서 끝이 아니다. 위 그림에서 볼 수 있다시피, PDPT 엔트리도 찾아야 한다. 510번째 PDPT 엔트리를 참조하면 된다(`mov %eax, 0x3FF0`). 이 과정은 `PDPT-B`의 과정이다. `PDPT-A`는 왜 사용할까? 페이징이 활성화된 직후는, 아이텐티티 매핑으로 `#PF`가 발생하지 않도록 해야 한다. 그것 때문에, `PDPT-A` 를 임시로 할당한다. 64비트 상위 주소로 점프하기 전에는 `PDPT-A` 를 사용한다. 점프 이후에는 `PDPT-B`를 사용한다.

     

     

    : 그리고 최종적으로 PD 테이블을 할당한다. 모든 PD 엔트리 플래그를 `0x83`으로 설정하고 있다. 여기서 중요한 점은 `0x80` 이다. 7번째 비트는 PSE로 사이즈를 의미한다. 이 값이 1이면, PD 엔트리 하나 당 2MB를 차지하게 된다. 아래에서 `eax`는 `페이지 번호 + 플래그`가 설정된다. 그리고 PD 테이블 엔트리 인덱스를 `ebx`를 통해 할당한다. `ecx`는 총 512개의 PD 엔트리를 만들기 위해 사용된다.

    # PD[0..511] -> 0..1022MB
      mov $0x83, %eax
      mov $0x4000, %ebx
      mov $512, %ecx
    ptbl_loop:
      mov %eax, (%ebx)
      add $0x200000, %eax
      add $0x8, %ebx
      dec %ecx
      jnz ptbl_loop
    ...

     

    : 아래는 각 `eax`, `ebx`, `ecx`가 루프를 돌 때마다 어떻게 증가하는지를 보여준다.

     

      eax ebx ecx
    0 0x83 0x4000 512
    1 0x200083 0x4008 511
    2 0x400083 0x4010 510
    3 0x600083 0x4018 509
    ... ... ... ...
    511 0x3FE083 0x4FF8 1

    : 이렇게 64비트 페이지 테이블을 만드는 과정은 끝났다. 이제 실제 페이지 테이브를 등록하고 64비트 모드로 전환하는 코드를 보자.

     

    : `xv6`는 멀티 프로세서 기반으로 동작하는 운영체제다. `xv6`는 BSP와 AP가 시작하는 부트 코드가 다르다. 예를 들어, BSP는 32비트 부트 소스가 `entry.S` 파일인데, AP는 `entryother.S` 파일이다. 왜 나눴을까? BSP는 부트 시점에 시스템 관련 자료 구조 뿐만 아니라 하드웨어들을 모두 초기화한다. 그런데, 이 과정을 AP가 또 하는 것은 문제를 일으킬 수 있고, 또 시간 낭비인 부분도 있기 때문이다. 그래서, `xv6`는 64비트 모드에 진입하면, BSP면 ebx를 `0`으로 AP면 `1`로 설정해서 서로 다른 루틴을 타도록 한다.

    // entry64.S
    ...
    # Clear ebx for initial processor boot.
    # When secondary processors boot, they'll call through
    # entry32mp (from entryother), but with a nonzero ebx.
    # We'll reuse these bootstrap pagetables and GDT.
      xor %ebx, %ebx
    
    .global entry32mp
    entry32mp:
    # CR3 -> 0x1000 (P4ML)
      mov $0x1000, %eax
      mov %eax, %cr3
    
      lgdt (gdtr64 - mboot_header + mboot_load_addr)
    
    # Enable PAE - CR4.PAE=1
      mov %cr4, %eax
      bts $5, %eax
      mov %eax, %cr4
    
    # enable long mode - EFER.LME=1
      mov $0xc0000080, %ecx
      rdmsr
      bts $8, %eax
      wrmsr
    
    # enable paging
      mov %cr0, %eax
      bts $31, %eax
      mov %eax, %cr0
    
    # shift to 64bit segment
      ljmp $8,$(entry64low - mboot_header + mboot_load_addr)
      
    ...

    : 그 다음 `CR3` 레지스터에 0x1000을 넣는 것을 볼 수 있다. 이 의미는 위에서 작성한 `PML4`를 `xv6` 64비트 커널의 페이지 테이블로 사용하겠다는 의미다. 그리고, 64비트 모드로 진입하기 위한 과저들을(PAE, LME, PAGING) 거친다. 이 내용은 이 글을 참고하자.

     

    : 마지막에 `ljmp $8,$(entry64low - mboot_header + mboot_load_addr)` 의미가 뭘까? `ljmp`는 ASM 문법으로 `Long jump`를 의미한다. 첫 번째 오퍼랜드는 CS 레지스터의 값으로 쓰이고, 두 번째 오퍼랜드는 점프할 주소가 된다. 근데, 왜 `ljmp` 명령어의 첫 번째 오퍼랜드로 `8`이 왔을까? 보호 모드로 진입하면, 세그먼트 레지스터라는 개념은 `HIDDEN` 된다. 예를 들어, 16비트에서는 `mov $8, %ds` 코드를 적용하면, DS 데이터 세그먼트(`%ds`)에 8이 실제로 대입됬다. 그러나, 보호 모드로 진입해서 `mov $8, %ds` 의미는 DS 데이터 세그먼트가 아닌, DS 세그먼트 셀렉터에 8을 넣게되는 것이다. 즉, `GDT에 있는 1번째 세그먼트 디스크립터의 Base를 DS 세그먼트 레지스터에 할당해줘`라는 뜻이 된다. 그래서 `gdt64_begin`의 8바이트 위치에 64비트용 CS 세그먼트 디스크립터가 있는 것을 볼 수 있다.

    // entry64.S
    ...
    # shift to 64bit segment
      ljmp $8,$(entry64low - mboot_header + mboot_load_addr)
    
    .align 16
    gdtr64:
      .word gdt64_end - gdt64_begin - 1;
      .quad gdt64_begin - mboot_header + mboot_load_addr
    
    .align 16
    gdt64_begin:
      .long 0x00000000 # 0: null desc
      .long 0x00000000
      .long 0x00000000 # 1: Code, R/X, Nonconforming
      .long 0x00209800
      .long 0x00000000 # 2: Data, R/W, Expand Down
      .long 0x00009000
    gdt64_end:
    
    .align 16
    .code64
    entry64low:
      movq $entry64high, %rax
      jmp *%rax
      
    .global _start
    _start:
    entry64high:
    
    # ensure data segment registers are sane
      xor %rax, %rax
    
    ...

    : 왜 `entry64low - mboot_header + mboot_load_addr`로 점프할까? `FAR JUMP`의 두 번째 오퍼랜드는 `절대 주소`가 온다. 그리고 `물리 주소`여야 한다. 참고로, `x86`에서 절대 주소는 세그먼트 레지스터에 영향을 받지 않는 주소를 의미한다. 아래의 내용을 보자.

    ffffffff80100000 T mboot_header
    0000000000100000 A mboot_load_addr
    ffffffff801000e0 t entry64low

    : 빌드를 통해 나온 값들을 위와 같다. 결국, `0x1000e0`이 `entry64low`의 램에 로딩되는 물리 주소가 된다.

     

    : 왜 `entry64log`를 거치서, `entry64high`로 점프하는걸까? 한 번에 `entry64high`로 점프하면 안될까? `ljmp $8, $(entry64low - mboot_header + mboot_load_addr)` 명령어를 실행하는 시점에 CPU가 64비트로 동작을 한다. 그런데, 문제는 `어셈블러`에 있다. 어셈블러는 `.code32`를 보면, 그 아래 코드를 32비트 기준으로 빌드한다. 32비트를 초과하는 값들은 모두 0으로 처리한다. 예를 들어, `.code32` 선언된 이후에, `mov %0xF00000000, %rax` 같은 명령어를 사용하면 `%rax`에는 0이 들어간다. 왜냐면, 어셈블러가 32비트 기준으로 값들을 모두 조정하기 때문이다. 그리고, 심볼들의 재비치 주소도 32비트를 기준으로 할당한다. 

     

    : 그런데, `entry64high` 심볼의 주소가 몇일까? 

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

    부트 로더  (0) 2023.08.03
    [빌드] Makefile  (0) 2023.08.03
    [xv6] mkfs  (0) 2023.07.30
    [xv6] Boot-loader  (0) 2023.07.29
    [xv6] - fork  (0) 2023.07.27
Designed by Tistory.