ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 메모리 맵
    프로젝트/운영체제 만들기 2023. 6. 1. 00:50

    글의 참고

    - https://en.wikipedia.org/wiki/Memory_map

    - https://wiki.osdev.org/Memory_Map_(x86)


    글의 전제

    - 내가 글을 쓰다가 궁금한 점은 파란색 볼드체로 표현했다.

    - 밑줄로 작성된 글은 좀 더 긴 설명이 필요해서 친 것이다.


    글의 내용

    - 메모리 맵

    : 커널 개발에서 메모리 맵은 정말 중요하다. 커널은 명시적으로 특정 영역에 메모리를 쓸 수 있는 프로그램이다. 그런데, 이미 시스템에 예약된 영역에 뭔가를 쓰게 되면 크래시가 발생할 것이다. 하드웨어적으로 예약된 영역이면 그나마 다행이다. 쓰자마자 크래시가 발생할 것이다. 그런데, 만약 소프트웨어적으로 예약된 영역이라고 지정해 놓았는데, 여기에 뭔가를 쓰는 것이면 이건 디버깅이 굉장히 어려워진다.

     

    : 그리고 커널 개발자들은 대게 메모리 초기화를 가장 먼저 진행하게 된다. 왜냐면, 이제 남은 일들이 전부 메모리를 활용하는 일인데 메모리를 초기화하지 않으면 뒤에 일들을 할 수 없기 때문이다. 여기서 메모리를 초기화할 때, 메모리를 효율적으로 관리하기 위해 메모리 관련 메타 데이터를 선언해야 한다. 그런데, 메모리를 초기화하는 시점에는 메모리를 효율적으로 관리할 메커니즘이 존재하지 않는다. 그래서 커널 영역 어딘가에 이 메타 데이터를 선언해야 한다. 이 때, 메모리 맵을 확인해서 반드시 시스템에 예약된 영역은 피해서 할당해야 한다. 

     

    : 그리고 디버깅시에도 메모리 맵을 알고 있는 것은 중요하다. 예를 들어, 보호 모드 상태에서 커널 프로그램이 동작하다가 갑자기 #GP가 발생했다. 위치가 `0x020003FD`다. 아무리 봐도 모르겠다. 보호 모드라서 0x00000000 ~ 0xFFFFFFFF 까지 접근이 가능하고, GDT도 베이스가 0이고 제한이 0xFFFFFFFF인데, 이유가 뭘까? 이럴 때, 메모리 맵을 확인해 볼 필요가 있다. 시스템에 예약된 영역에 접근하면 #GP가 발생할 수 있다.

     

    : 이제 그럼 각 CPU 모드에서 메모리 맵을 얻는 방법을 알아본다. 일반적으로, 메모리 맵을 얻어 오는 역할은 부트 로더가 맞고 있다. 부트 로더는 메모리 맵을 조회해서 해당 정보를 커널에게 넘겨준다. 부트 로더가 메모리 맵을 조회하는 일을 담당하는 이유는 메모리 맵이 각 시스템 및 벤더사에 상당히 의존적이기 때문이다. 일반인이 취미로 만드는 OS에서 각 시스템의 메모리 맵을 알 수 있을까? 그건 거의 불가능하다. 벤더사에서 제공해주는 시스템 펌웨어 기능을 이용하는게 가장 안전하면서 확실한 방법이다. 

     

    : 그럼 굳이 부트 로더가 메모리 맵을 얻어와야 하는 이유가 뭘까? BIOS를 이용한다면 그럴 수 도 있다. BIOS는 16비트 모드에서만 사용가능하고, BIOS를 호출하는 기능이 어셈블리어라 부트 로더쪽에서 해당 일을 하는게 자연스럽다. 그런데, BIOS는 옛날 펌웨어다. 최근에는 GRUB, U-Boot, CoreBoot등 정말 많은 범용 부트로더들이 존재한다(BIOS나 UEFI는 시스템 펌웨어에 가깝다. UEFI는 펌웨어와 애플리케이션으로 나누긴 하는데, 일단 여기서는 부트 로더로 보지는 않겠다). 이 부트 로더들을 통해서도 메모리 맵을 얻을 수 있다. 그래서 C언어로 바로 접근하는 경우도 많다. 그러나 이러면 안된다. 왤까?

     

    : C언어는 함수 기반의 언어로 반드시 스택을 설정하고 접근해야 한다. 그런데 스택을 사용하려면 `스택 포인터`라는 레지스터에 적절한 스택의 위치를 설정해야 하는데, 이게 어셈블리에서만 가능하다. C언어를 실행하는 순간 스택이 바로 사용되서 수정이 불가능하다. 그리고 저 `적절한 위치` 가 어려운 문제다. 시스템 메모리 맵을 확인되서 적절한 위치를 찾아야 한다. 즉, GRUB, U-Boot, CoreBoot 부트 로더들로 부터 받은 메모리 맵을 확인해서 스택을 설정하기 적절한 위치를 찾아야 한다. 그리고 C언어 메인을 호출하는 것이다. 

     

    : 결국 스택 설정은 어셈블리 코드에서만 가능하므로, 부트 로더가 이 일을 담당하게 된다. 이 글에서 우리는 PC 시장의 시스템 펌웨어 2개와 부트 로더 1개를 대해 알아 본다.

     

     

    : x86 16비트

    " BIOS를 통해 메모리 맵을 얻는다. `BIOS 15h AX=E820h`을 사용하면 간단하게 구할 수 있다. 이 기능은 16비트 모드에서 사용하기에는 상당히 강력한 기능이다. 왜냐면, 4GB 이상의 범위를 커버하기 때문이다. 이 기능은 2002년 이후에 생상된 PC 및 그 언저리에 만들어진 PC에서 사용이 가능하다. 사실 그 이전에는 완전한 메모리 맵을 감지하는 BIOS 서비스가 존재하지 않았다. 주로 `BIOS INT 15h, AX=E801h`가 있었지만, 최대 4GB가 한계다.

     

    : `BIOS INT=15h, AX=E820h` 서비스는 한 번 호출하고 끝나는 함수가 아닌, 연속적으로 모든 메모리 맵을 받을 때 까지 호출하는 함수다. 마치 strtok()와 비슷하다. 

    " 인풋
    EAX  기능 코드로 `E820h`를 작성.
    EBX `연속적인 값`이라고 해서 메모리 맵을 읽어오기 위해서 필요한 값이다. 예를 들어, 최초 호출시에는 0을 준다. 그러면, 제일 첫 번째 메모리 맵을 준다. 그리고 1을 준다. 그러면 2번째 메모리 맵을 전달해준다. 
    ES:DI `Address Range Descriptor Structure`라고 해서 그냥 데이터를 받는 버퍼라고 생각하면 된다. BIOS는 이 주소에 메모리 맵에 대한 정보를 넣어준다. 
    ECX BIOS에 얼마나 데이터를 받을 주 있는지를 알려준다. 예를 들어, 20바이트만 받고 싶으면 ECX에 20바이트만 입력하면 된다. 30바이트 받고 싶으면 30바이트를 입력하면 된다. 이 값은 최소값 20B이다. 그래서 반드시 20B 이상의 값을 입력해야 한다. 그 이유는 아래에서 설명하겠지만, `Address Range Descriptor Structure` 가 20B 이기 때문이다.
    EDX 시그니처를 입력한다. `SMAP`을 입력하면 된다 .이 값은 `0x534d4150` 가 된다. 

    " 아웃풋
    EAX  기능 코드로 `E820h`를 작성.
    EBX 위에서 설명한 `연속적인 값`이다. 최초 0으로 설정하고 서비스를 호출하면, EBX에 1이 반환될 것이다. 이 값을 절대 변경하지 말고, 다음 호출시에 그대로 던져주면 된다. 만약, 이 값이 0이면 마지막 데이터라는 의미이다. 
    ES:DI `Address Range Descriptor Structure`라고 해서 그냥 데이터를 받는 버퍼라고 생각하면 된다. BIOS는 이 주소에 메모리 맵에 대한 정보를 넣어준다. 
    ECX BIOS가 `ES:DI`에 쓴 데이터 양을 알려준다. 이 값이 20이면 20바이트를 썻다는 뜻이다.
    CF 만약 SET 이면 에러를 의미한다.
    " Address Range Descriptor Structure
    오프셋 이름 설명
    0 BaseAddrLow Low 32 Bits of Base Address
    4 BaseAddrHigh High 32 Bits of Base Address
    8 LengthLow Low 32 Bits of Length in Bytes
    12 LengthHigh High 32 Bits of Length in Bytes
    16 Type Address type of this range.
     : 위에서 말한 `메모리 맵`을 위의 자료구조로 전달해준다. 주소와 길이를 던져주는데 말 그대로 `BaseAddrXXX`는 베이스 어드레스를 말한다. 그리고 `LengthXXX`는 해당 메모리의 사이즈를 의미한다. `Type`이 여기서 중요하다.

    의미
    1 사용 가능.
    2 사용 하지 못함.
    기타 미래에  사용을 위해 보류

    : 소스 코드 예제는 다음과 같다.

     

    E820Present = FALSE;
        Regs.ebx = 0;
        do {
            Regs.eax = 0xE820;
            Regs.es  = SEGMENT (&Descriptor);
            Regs.di  = OFFSET  (&Descriptor);
            Regs.ecx = sizeof  (Descriptor);
    	Regs.edx = 'SMAP';
    
            _int( 0x15, Regs );
    
            if ((Regs.eflags & EFLAG_CARRY)  ||  Regs.eax != 'SMAP') {
                break;
            }
    
            if (Regs.ecx < 20  ||  Regs.ecx > sizeof (Descriptor) ) {
                // bug in bios - all returned descriptors must be
                // at least 20 bytes long, and can not be larger then
                // the input buffer.
    
                break;
            }
    
    	E820Present = TRUE;
    	.
            .
            .
            Add address range Descriptor.BaseAddress through
            Descriptor.BaseAddress + Descriptor.Length
            as type Descriptor.Type
    	.
            .
            .
    
        } while (Regs.ebx != 0);
    
        if (!E820Present) {
    	.
            .
            .
    	call INT 15h, AX=E801h and/or INT 15h, AH=88h to obtain old style
    	memory information
    	.
            .
            .
        }

    : 바이오스 서비스 호출후에 4가지 검사를 하는데, `Regs.ecx > sizeof (Descriptor)`를 눈 여겨볼 필요가 있다. 바이오스가 전달해준 데이터의 사이즈가 내가 전달한 사이즈보다 작아도 실패라고 판단한다. 즉, `BIOS INT=15h, AX=E820h`를 호출하면 `Address Range Descriptor Structure` 사이즈에 맞게 호출하는 것이 가장 좋다. 물론, `멀티 부트2`를 지원하는 부트 로더를 만들고 있다면, 24로 설정해도 좋다. 

     

     

    : x86 32비트

    " GRUB 및 UEFI를 통해 얻는다.

     

     

    : x86 64비트

    " UEFI를 통해 얻는다.

     

     

    - 메모리 홀

    : 메모리 맵을 보면, 대개 `메모리 홀`이라는 것이 존재한다. 즉, 사용하지도 않으면서 사용하지도 못하는 영역이 존재한다는 말이다. 대개 이 영역은 `확장성`을 위해서 남겨놓는다. 예를 들어, `플러그-앤-플레이` 디바이스들이 장착되면 해당 디바이스들과 통신하기 위해 메모리 맵을 할당해야 한다. 근데, `플러그-앤-플레이`는 동적이다. 즉, 언제 이 디바이스가 장착될지를 알 수가 없다. 결국 이런 디바이스들을 위한 메모리를 미리 할당해놓게 된다. 그래서 `메모리 홀`이 발생하게 된다. 대개 이런 경우는 PCI, USB 같은 버스 때문에 발생한다. 이 버스들이 `플러그-앤-플레이` 기능을 지원하기 때문이다. 그래서 32비트 x86에서는 `PAE` 같은 기능을 추가해서, 메모리 사용량을 늘리는 시도를 했지만, 호환성 문제로 거의 사용하지 않는다고 한다. 

     

     

    - BIOS와 UEFI 메모리 맵

    : 아래는 BIOS 메모리 맵이다.

    " 1MB 이하의 BIOS 메모리 맵은 다음과 같다.

    출처 -&nbsp;https://wiki.osdev.org/Memory_Map_(x86)

     

    " 1MB 이상에서 BIOS 메모리 맵은 다음과 같다.

    출처 -&nbsp;https://wiki.osdev.org/Memory_Map_(x86)

     

     

    - 각 모드에 따른 배치

    : 각 모드가 변경될 때마다, 이전 모드에 대한 부트 로더 및 커널은 필요가 없다. 예를 들어, 보호 모드에 진입하면 리얼 모드에서 부트 로더와 바이오스가 사용하던 메모리 영역은 더 이상 의미가 없으므로, 새롭게 할당을 해도 된다. 왜냐면, 이전 모드로 돌아갈 필요가 없기 때문이다. 롱 모드로 진입하면 보호 모드에서만 의존적으로 사용하던 것들이 더 이상 필요없다. 그러나 VGA 같은 디바이스들이 맵핑 되어 있는 MMIO 영역들은 CPU 모드에 의존적인 부분이 아닌, 시스템에 의존적인 부분이므로 건드리면 안된다.

     

    : 16비트 메모리 맵

    0x000000:--------------------------------------
    ........:
    ........: YohdaOS Stack
    ........: 
    0x007C00:--------------------------------------
    ........: First Bootlodaer
    0x007E00:--------------------------------------
    ........: Seconday Bootlodaer
    0x00XXXX:--------------------------------------
    ........:
    0x010000:--------------------------------------
    ........: Protected-mode Bootlaoder & Kernel 
    0x050000:--------------------------------------

    " 16비트에서는 2개의 부트 로더를 사용한다. 첫 번째, 부트 로더의 크기가 512B로 제한되므로, 2개로 나누었다. FBL은 섹터 하나를 할당하고, SBL은 `트랙 당 섹터 개수`로 할당받는다. 즉, 한 트랙을 할당한다. 16비트 초기 모드에서는 CHS 기반의 디스크 드라이브를 읽는다. 그리고 각 디스크 드라이브마다 트랙 당 섹터 개수가 다르다. 기본적으로 32개 이지만, 플로피 디스크는 36개 인것도 있다. 

     

    " SBL에 4개의 섹터만 할당하기로 결정했다. 모든 디스크 드라이브를 지원하려면 최소 단위의 섹터가 필요했다. MBR은 코드가 512B가 한계라서 CHS의 복잡한 코드를 넣으면, 용량이 부족할 수 있다. 그래서 단순하게 가장 작은 수의 트랙을 지원하기로 했다. 그 하한선은 일단 플로피 디스크로 정했다. 플로피 디스크 이하의 디스크는 지원하지 않기로 했다. 그럼 플로피에서 `섹터 당 트랙`이 가장 작은 버전은 몇일까?

    " 플로피 용량마다 차이가 있는데, 8이 하한이다. 그래도 혹시 몰라서 4로 잡았다. 4 이하는 지원하지 않기로 했다. 4 미만이 아닌, 4이하를 지원하지 않는다. 즉, 섹터 당 트랙이 최소 5개는 되야 YohdaOS는 지원한다.

     

     

    " 그렇다면 왜 SBL에 한 트랙을 할당하나? 특별한 이유는 없다. BIOS INT13h F2h로 한 번에 읽을 수 있는 섹터의 개수는 128개다(레지스터 크기의 제한). 그런데, 리얼 모드에서 사실 마땅히 해야할 일이 있지는 않다. 일단 혹시 모르니 넉넉한 여유분으로 SBL에 한 트랙을 할당했다. 

     

    " 32비트 부트 로더 및 커널은 0x10000 배치될 것이다. 16비트 모드에서는 주소를 지정하면 세그먼트 주소 지정방식이 사용되기 때문에, 0x0010_0000 이상의 영역에 넉넉하게 메모리를 할당하기가 어렵다. A20이 활성화하면 리얼 모드에서는 0x1F_FFFF 까지는 접근이 가능하지만, 리얼 모드에서는 무리하지 않고 1MB 이하를 최대한 활용하고 32비트에서 64비트 커널을 올릴 때, 1MB에 로딩할 예정이다.

     

    " 스택은 0x7C00으로 설정하여 FBL 바로 아래부터 스택으로 사용할 수 있도록 설정한다. 0x000 ~ 0x500 부터는 예약 영역이라 스택이 너무 내려가지 않게, 주의가 필요하다. 그러나, 리얼 모드에서 함수를 거의 사용할 일이 없고, 길게 남아있을 계획이 없으므로 크게 염려하지 않아도 될 듯 하다. 

     

     

    : 32비트 메모리 맵

    " 32비트 커널은 0x0001_0000 영역에 위치한다. 32비트 부트 로더 및 커널은 64KB(0x40000)로 넉넉하게 할당받는다. 

    0x0010000:----------------------------------------
    ..........:
    ..........: Protected-Mode Bootlodaer & Kernel
    ..........: 
    0x0050000:----------------------------------------

     

     

    : 64비트 메모리 맵

    : 상위 절반 커널 전략을 사용해서 가상 주소는 0xC010_0000에 위치하고 실제 커널의 물리 주소(로딩 주소)는 0x0010_0000으로 배치할 것이다. 이 위치는 BIOS 및 GRUB을 첫 번째 부트 로더로 사용할 경우, 1MB 이하 영역을 앞에 부트 로더들이 사용하기 때문에 안전하게 1MB 이상으로 위치시킨다

    0x00100000:----------------------------------------
    ..........:
    ..........: Long-Mode Bootlodaer & Kernel
    ..........: 
    0x00XXXXXX:----------------------------------------

     

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

    GCC[작성중]  (0) 2023.06.02
    GIT 명령어  (1) 2023.06.01
    커널 이미지  (0) 2023.05.31
    페이징  (0) 2023.05.30
    PIT [작성중]  (0) 2023.05.29
Designed by Tistory.