요약

  • 프로그램은 실제로 하드웨어의 물리적 주소에 접근하는게 아니라 가상 메모리, Virtual Memory주소에 접근한 다음에 활성 세그먼트의 오프셋을 통해 하드웨어의 물리적 주소에 접근한다.
  • 메모리 관리를 위해서 세그멘테이션, Segmentation과 페이징이라는 2가지 기술을 사용한다.
    • 세그멘테이션은 가변 크기의 메모리 영역을 사용해서 단편화 문제가 존재한다.
    • 페이징은 고정 크기의 메모리 영역을 사용하여 액세스 권한에 대한 세밀한 제어를 허용한다. 불필요한 메모리 낭비를 막기 위해서 다중 레벨 페이지 테이블에 매핑 정보를 저장한다.

내용

목표

  • 운영 체제에서 사용하는 메모리 관리 체계인 페이징을 이해한다.

메모리 보호

  • 운영체제의 주요 작업 중 하나는 프로그램을 서로 격리하는 것이다. 예를 들어 웹 브라우저는 텍스트 편집기를 방해하면 안된다. 이를 위해 운영 체제는 하드웨어 기능을 활용하여 프로세스 메모리 영역에 다른 프로세스가 액세스할 수 없도록 한다.
  • x86에서 하드웨어는 메모리 보호를 세그멘테이션과 페이징을 지원한다.

세그멘테이션, Segmentation

  • 세그멘테이션은 원래 주소 지정이 가능한 메모리 양을 늘리기 위해서 1978년에 도입되었다. 당시는 16비트 주소만 사용이 가능했기 때문에 주소 지정이 가능한 메모리의 양이 64KiB로 제한되었다. 이러한 64KiB 이상에 액세스 할 수 있도록 각각 오프셋 주소를 포함하는 추가 세그먼트 레지스터가 도입되었다. 이를 통해 CPU는 각 메모리 액세스에 오프셋을 자동으로 추가하므로 최대 1MiB의 메모리에 액세스할 수 있게 되었다.
  • 세그먼트, Segment레지스터는 메모리 액세스 종류에 따라 CPU에 의해 자동으로 선택된다.
    • 명령어를 가져오기 위한 코드 세그먼트
    • 스택 작업(push/pop)을 위한 스택 세그먼트
    • 다른 곳에서는 데이터 세그먼트 또는 추가 세그먼트
    • 자유롭게 사용할 수 있는 추가 세그먼트 레지스터
  • 세그멘테에션의 첫 번째 버전에서는 세그먼트 레지스터의 오프셋이 직접 포함되었으며 액세스 제어가 수행되지 않았지만 추후 보호 모드의 도입으로 변경되었다.
    • CPU가 보호 모드에서 실행되면 세그먼트 설명자에는 오프셋 주소 외에도 세그먼트 크기 및 액세스 권한이 포함된 로컬 또는 전역 서술자 테이블, Global Descriptor Table에 대한 인덱스가 포함되었다.
  • 실제 액세스 전에 메모리 주소를 수정함으로써 세그멘테이션은 이미 현재 거의 모든 곳에서 사용되는 기술인 가상 메모리, Virtual Memory를 사용했다.

가상 메모리

  • 가상 메모리의 아이디어는 기본 물리적 저장 장치에서 메모리 주소를 추상화 하는 것이다. 저장 장치에 직접 액세스하는 대신에 변환 단계가 먼저 수행된다. 세그멘테이션의 경우 변환 단계는 활성 세그먼트의 오프셋 주소를 추가하는 것이다. 오프셋이 0x1111000인 세그먼트에서 메모리 주소 0x1234000에 액세스하는 프로그램의 실제 액세스 주소는 0x2345000이다.
  • 두 주소 유형을 구분하기 위해 변환 전 주소를 가상 주소 라고 하고 변환 후 주소를 물리적 주소라고 한다.
  • 이 두 주소 유형의 중요한 차이점 중 하나는 물리적 주소가 고유하고 항상 동일한 고유한 메모리 위치를 참조한다는 것이다. 반면에 가상 주소는 변환 함수에 따라 달라집니다. 두 개의 서로 다른 가상 주소가 동일한 물리적 주소를 참조가 가능하다. 또한 동일한 가상 주소가 서로 다른 변환 함수를 사용할 때 서로 다른 물리적 주소를 참조할 수 있다.
  • 여기서는 동일한 프로그램이 두 번 실행되지만, 변환 함수는 다르다.
    • 첫 번째 인스턴스는 세그먼트 오프셋이 100이므로 가상 주소 0150이 물리적 주소 100250으로 변환된다.
    • 두 번째 인스턴스는 세그먼트 오프셋이 300이므로 가상 주소 0150이 물리적 주소 300450으로 변환된다.
    • 이를 통해 두 프로그램은 서로 간섭하지 않고 동일한 코드를 실행하고 동일한 가상 주소를 사용할 수 있다.
    • 또 다른 장점은 완전히 다른 가상 주소를 사용하더라도 프로그램을 임의의 물리적 메모리 위치에 배치할 수 있다. 따라서 OS는 프로그램을 다시 컴파일할 필요 없이 사용 가능한 메모리의 전체 양을 활용할 수 있다.

단편화, Fragmentation

  • 가상 주소와 물리적 주소의 구분은 세그멘테이션은 강력하게 만든다. 하지만 단편화라는 문제가 존재한다. 예를 들어 위에서 본 프로그램의 세 번째 사본을 실행하고 싶다고 가정해보면 된다.
  • 사용 가능한 여유 메모리가 충분하더라도, 프로그램의 세 번째 인스턴스를 겹치지 않고 가상 메모리에 매핑할 방법은 없다. 문제는 연속 메모리가 필요하고 작은 여유 chunk, 청크를 사용할 수 없다.
  • 단편화 문제를 해결하는 방법은 실행을 일시 중지하고 사용된 메모리 부분을 서로 더 가깝게 이동하고 변환을 업데이트한 다음에 실행을 재개하는 것 이다.
  • 이를 통해 세 번째 인스턴스를 시작할 수 있는 충분한 공간이 생긴다.
  • 이 조각 모음 프로세스는 대량의 메모리를 복사해야 하기 때문에 성능이 저하된다. 또한 메모리가 너무 조각나기 전에 정기적으로 수행해야 한다. 그렇기에 프로그램이 무작위 시간에 일시 중지되고 응답하지 않을 수 있으므로 성능이 예측 불가능해진다.
  • 단편화 문제는 세그멘테이션이 대부분 시스템에서 더 이상 사용되지 않는 이유 중 하나이다. 이미 x86_64에서는 지원하지 않고 페이징 이 사용되어 단편화 문제를 피할 수 있습니다.

페이징

  • 가상 메모리 공간과 물리적 메모리 공간을 모두 작고 고정된 크기의 블록으로 나눈게 페이징이다.
  • 가상 메모리 공간의 블록은 페이지 라고 하며, 물리적 주소 공간의 블록은 프레임 이라고 한다. 각 페이지는 개별적으로 프레임에 매핑할 수 있으므로 더 큰 메모리 영역을 비연속적인 물리적 프레임으로 분할할 수 있다.
  • 단편화 문제 였던 메모리 대신 페이징을 사용하면 볼 수 있다.
  • 50바이트의 페이지 크기의 메모리를 3개의 페이지로 분할된다. 각 페이지는 개별적으로 프레임에 매핑되므로 연속적인 가상 메모리 영역을 비연속적인 물리적 프레임에 매핑할 수 있다. 이를 통해 조각 모음 필요 없이 세 번째 인스턴스를 시작할 수 있다.

숨겨진 단편화

  • 세그멘테이션과 비교 했을 때, 페이징은 몇 개의 크고 가변적인 크기 영역 대신 작고 고정된 크기의 메모리 영역을 많이 사용한다. 모든 프레임이 같은 크기이므로 사용하기에 너무 작은 프레임이 없으므로 단편화가 발생하지 않는다.
  • 단편화가 발생하지 않는 것으로 보이지만, 여전히 숨겨진 단편화의 일종인 내부 단편화가 있다.
  • 내부 단편화는 모든 메모리 영역 페이지 크기의 정확한 배수가 아니기 때문에 발생한다. 위 예시에서 크기가 101인 프로그램이 있다면, 크기가 50인 페이지가 3개 필요하므로 필요한 것보다 49바이트를 더 차지한다. 두 가지 단편화를 구분하기 위해 세그멘테이션을 사용할 때 발생하는 조각화의 종류를 외부 단편화라고 한다.

페이지 테이블

  • 잠재적으로 수백만 개 페이지가 개별적으로 프레임에 매핑되어 있음을 본다. 이 매핑 정보는 어딘가 저장되어야 한다. 세그멘테이션은 각 활성 메모리 영역에 대해 개별 세그먼트 선택기 레지스터를 사용하는데, 이는 페이징에서는 불가능하다. 레지스터보다 페이지가 더 많기 때문이다. 대신 페이징은 페이지 테이블 이라는 테이블 구조를 사용하여 매핑 정보를 저장한다.
  • 페이지 테이블 예시는 다음과 같다.
  • 각 프로그램 인스턴스가 자체 페이지 테이블을 가지고 있는 것을 볼 수 있다. 현재 활성 테이블에 대한 포인터는 특수 CPU 레지스터에 저장된다. 각 프로그램 인스턴스를 실행하기 전에 운영체제의 역할은 이 레지스터를 올바른 페이지에 대한 포인터를 로드하는 것 이다.
  • 각 메모리 액세스에서 CPU는 레지스터에서 테이블 포인터를 읽고 테이블에서 액세스된 페이지에 대한 매핑된 프레임을 찾는다. 이는 전적으로 하드웨어에서 수행되며 실행 중인 프로그램에서는 전혀 보이지 않는다. 변환 프로세스를 가속화하기 위해 많은 CPU 아키텍처에는 마지막 변환 결과를 기억하는 특수 캐시가 있다.
  • 아키텍처에 따라 페이지 테이블 항목은 플래그 필드에 액세스 권한과 같은 속성을 저장할 수도 있다. “r/w”플래그는 페이지를 읽고 쓸 수 있게 만든다.

다중 레벨 페이지 테이블, Multilevel Page Tables

  • 위 페이지 테이블은 더 큰 주소 공간에서 메모리를 낭비한다는 문제가 있다.
  • 물리적 프레임은 4개만 필요하지만 페이지 테이블에는 백만 개가 넘는 항목이 있다. 빈 항목은 생략이 불가능하다. CPU가 변환 프로세스에서 올바른 항목으로 점프할 수 없기 때문이다.
  • 낭비되는 메모리를 줄이기 위해 2단계 페이지 테이블을 사용할 수 있다. 이는 다른 주소 영역에 대해 다른 페이지 테이블을 사용한다는 것이다. 레벨2 페이지 테이블이라는 추가 테이블에는 주소 영역과 레벨1 페이지 테이블 간의 매핑이 포함되어 있다.
  • 레벨1 페이지 테이블이 크기의 영역을 담당한다고 정의하여 예를 들면 다음과 같다.
  • 페이지 0은 첫 번째 10_000_000, 1_000_0501_000_100 모두 100번째 10_000바이 영역에 속하므로 레벨2 페이지 테이블의 100번째 항목을 사용한다. 이 항목은 다른 레벨 1페이지 테이블 T2를 가리키며, 이 테이블은 새 페이지를 프레임 100, 150, 200에 매핑한다. 레벨 1 테이블의 페이지 주소에는 영역 오프셋이 포함되지 않는다는 점에 유의 해야 한다.
  • 우리는 여전히 레벨2 테이블에 100개의 빈 엔트리를 가지고 있지만, 이전의 백만 개의 빈 엔트리보다 적다. 이러한 절약의 이유는 매핑되지 않은 메모리 영역에 대해 레벨 1 페지 테이블을 만들 필요가 없기 때문이다.
  • 2단계 페이지 테이블의 원리는 3, 4 또는 그 이상의 수준으로 확장할 수 있다. 그런 다음 페이지 테이블 레지스터는 가장 높은 수준 테이블을 가리키고, 그 테이블을 바로 다음 낮은 수준의 테이블을 가리키고, 그 테이블은 바로 다음 낮은 수준을 가리키고, 이런 식으 계속 된다. 그런 다음 수준 1 페이지 테이블은 매핑된 프레임을 가리킨다. 이 원리는 일반적으로 다중 수준 또는 계층적 페이지 테이블이라고 한다.

x86_64에서의 페이징

  • x86_64 아키텍처는 4단계 페이지 테이블과 4KiB의 페이지 크기를 사용한다. 각 페이지 테이블은 레벨과 무관하게 512개 항목의 고정된 크기를 갖는다. 각 항목의 크기는 8바이트 이므로 각 테이블은 512*8B = 4KiB크기이며 따라서 정확히 한 페이지에 맞는다.
  • 각 레벨의 페이지 테이블 인덱스는 가상 주소에서 직접 파생된다.
  • 각 테이블 인덱스가 9비트로 구성되어 있는 것을 볼 수 있는데, 이는 각 테이블에 2^9=512개의 항목이 있기 때문에 의미가 있다. 가장 낮은 12 비트는 4KiB페이지 오프셋이다. (2^12바이트 = 4KiB). 비트 48~64는 삭제되며, 이는 x86_64가 48비트 주소만 지원하므로 실제로는 64비트가 아니라는 것을 의미한다.
  • 비트 48~64는 버려지지만, 임의의 값으로 설정할 수는 없다. 대신, 이 범위의 모든 비트는 주소를 고유하게 유지하고 5단계 페이지 테이블과 같은 향후 확장을 허용하기 위해 비트 47의 복사본이어야 한다. 이를 부호 확장이라고 하는데, 2의 보수에서 부호 확장과 매우 유사하기 때문이다. 주소가 올바르게 부호 확장되지 않으면 CPU에서 예외가 발생한다.

변환 예제

  • 현재 활성화된 레벨 4 페이지 테이블의 물리적 주소는 4레벨 페이지 테이블의 루트이며 CR3레지스터에 저장된다. 그런 다음 각 페이지 테이블 항목은 다음 레벨 테이블의 물리적 프레임을 가리킨다. 그런 다음 레벨 1 테이블의 항목은 매핑된 프레임을 가리킨다.
    • 페이지 테이블의 모든 주소는 가상이 아닌 물리적 주소로 CPU도 해당 주소를 변환해야 한다.
  • 위 페이지 테이블 계층은 두 페이지(파란색)을 매핑한다. 페이지 테이블 인덱스에서 이 두 페이지의 가상 주소가 0x803FE7F000~0x803FE00000를 알 수 있다.
  • 프로그램이 주소(0x803FE7F5CE)에서 읽으려고 할 때 무슨 일이 일어나는지 보면 다음과 같다.
  • 먼저 주소를 이진으로 변환하고 주소에 대한 페이지 테이블 인덱스와 페이지 오프셋을 확인한다.
  • 이러한 인덱스를 사용하면 페이지 테이블 계층을 탐색하여 주소에 대한 매핑 프레임을 확인할 수 있다.
    • CR3 레지스터에서 레벨 4 테이블의 주소를 읽는다.
    • 레벨 4 인덱스는 1이므로, 해당 테이블의 인덱스 1 항목을 살펴보면 레벨3 테이블이 16KiB주소에 저장되어 있다는 것을 알 수 있다.
    • 해당 주소에서 레벨3 테이블을 로드하고 인덱스0의 항목을 살펴보면 24KiB에 있는 레벨2 테이블이 보인다.
    • 레벨2 인덱스는 511이므로, 해당 페이지의 마지막 항목을 살펴보면 레벨1 테이블의 주소를 알 수 있다.
    • 레벨1 테이블의 인덱스 127 항목을 통해 해당 페이지가 프레임 12KiB, 즉 16진수로 0x30000에 매핑되어 있다는 것을 알 수 있다.
    • 마지막 단계는 프레임 주소에 페이지 오프셋을 더하여 물리적 주소 0x3000 + 0x5ce = 0x35ce를 구하는 것이다.
  • 레벨1 테이블의 페이지에 대한 권한은 r읽기 전용을 의미한다. 하드웨어는 이러한 권한을 적용하고 해당 페이지에 쓰려고 하면 예외를 throw한다. 상위 레벨 페이지의 권한은 하위 레벨의 가능한 권한을 제한하므로 레벨3 항목을 읽기 전용으로 설정하면 하위 레벨이 읽기/쓰기 권한을 지정하더라도 이 항목을 사용하는 페이지는 쓸 수 없다.
  • 이 예에서 각 테이블의 단일 인스턴스만 사용했지만 일반적으로 각 주소 공간에 각 레벨의 여러 인스턴스가 있다는 점에 유의하는 것이 중요하다.
    • 4단계 테이블 1개,
    • 512개의 레벨 3 테이블(레벨 4 테이블에는 512개 항목이 있기 때문)
    • 512 * 512 레벨 2 테이블(512 레벨 3 테이블 각각에 512개 항목이 있기 때문)
    • 512 * 512 * 512 레벨 1 테이블(레벨 2 테이블당 512개 항목).

페이지 테이블 형식

  • x86_64 아키텍처의 페이지 테이블은 기본적으로 512개 항목의 배열이다.
#[repr(align(4096))]
pub struct PageTable {
    entries: [PageTableEntry; 512],
}
  • 속성에서 알 수 있듯이 repr페이지 테이블은 페이지 정렬, 즉 4KiB 경계에 정렬되어야 한다. 이 요구 사항은 페이지 테이블이 항상 전체 페이지를 채우고 항목을 매우 컴팩트하게 만드는 최적화를 허용한다.
  • 각 항목은 8바이트(64비트) 크기이고 형식은 다음과 같다
비트이름의미
0present페이지가 현재 메모리에 있습니다
1writable이 페이지에 쓰기가 허용됩니다
2user accessible설정하지 않으면 커널 모드 코드만 이 페이지에 액세스할 수 있습니다.
3write-through caching쓰기는 메모리에 직접 들어갑니다
4disable cache이 페이지에는 캐시가 사용되지 않습니다.
5accessed이 페이지가 사용될 때 CPU는 이 비트를 설정합니다.
6dirtyCPU는 이 페이지에 쓰기가 발생하면 이 비트를 설정합니다.
7huge page/nullP1 및 P4에서는 0이어야 하며 P3에서는 1GiB 페이지를 생성하고 P2에서는 2MiB 페이지를 생성합니다.
8global페이지가 주소 공간 스위치의 캐시에서 플러시되지 않음(CR4 레지스터의 PGE 비트가 설정되어야 함)
9-11availableOS에서 자유롭게 사용 가능
12-51physical address프레임 또는 다음 페이지 테이블의 페이지 정렬 52비트 물리 주소
52-62availableOS에서 자유롭게 사용 가능
63no execute이 페이지에서 코드 실행을 금지합니다(EFER 레지스터의 NXE 비트가 설정되어야 함)
  • 12~51비트만 물리적 프레임 주소를 지정하는데 사용한다. 나머지 비트는 플래그로 사용되거나 운영 체제에서 자유롭게 사용할 수 있다. 이는 항상 4096바이트로 정렬된 주소, 페이지 정렬된 페이지 테이블이나 매핑된 프레임의 시작을 가리키기 때문에 가능하다.
  • 사용 가능한 플래그는 자세히 보면 다음과 같다.
    • present는 매핑된 페이지와 매핑되지 않은 페이지를 구분한다. 주 메모리가 가득찰 때 페이지를 디스크로 임시 스왑하는데 사용할 수 있다. 이후에 페이지에 액세스하면 페이지 폴트라는 특수 예외가 발생하고, 운영 체제는 디스크에서 누락된 페이지를 다시 로드한 다음 프로그램을 계속 진행하여 대응할 수 있다.
    • writableno execute 각각 페이지의 내용이 쓰기 가능한지 또는 실행 가능한 지침을 포함하는지 여부를 제어한다.
    • accesseddirty페이지에 대한 읽기 또는 쓰기가 발생할 때 CPU에 의해 자동으로 설정된다. 이 정보는 운영 체제에서 활용할 수 있다. 예를 들어, 어떤 페이지를 스왑할지 또는 마지막으로 디스크에 저장한 이후 페이지 내용이 수정되었는지 여부를 결정하는 데 사용할 수 있다.
    • write-through caching를 disable cache하면 각 페이지에 대한 캐시를 개별적으로 제어할 수 있다.
    • user accessible플래그는 페이지를 사용자 공간 코드에서 사용할 수 있게 하고, 그렇지 않으면 CPU가 커널 모드에 있을 때만 액세스할 수 있다. 이 기능은 사용자 공간 프로그램이 실행되는 동안 커널을 매핑하여 시스템 호출을 더 빠르게 만드는 데 사용할 수 있다.
    • global는 하드웨어 페이지가 모든 주소 공간에서 사용 가능하므로 주소 공간 스위치에서 변환 캐시에서 제거할 필요가 없음을 알린다. 이 플래그는 일반적으로 지워진 user accessible플래그와 함께 사용되어 커널 코드를 모든 주소 공간에 매핑한다.
    • huge page는 레벨2 또는 레벨3 페이지 테이블의 엔트리가 매핑된 프레임을 직접 가리키도록 하여 더 큰 크기의 페이지를 생성할 수 있게 한다. 이 비트가 설정되면 페이지 크기가 512배로 증가하여 레벨2 엔트리의 경우 2 MiB = 512 * 4KiB 또는 레벨3 엔트리의 경우 1 GiB = 512 * 2MiB가 된다. 더 큰 페이지를 사용하는 이점은 변환 캐시의 줄이 적고 페이지 테이블이 덜 필요하는 것이다.
  • 크레이트에서 페이지 테이블과 위 항목에 대한 유형을 제공하므로 직접 만들 필요는 없다.

변환 색인 버퍼, Translation Lookaside Buffe

  • 4단계 페이지 테이블은 각 변환에 4개의 메모리 액세스가 필요하기 때문에 가상 주소 변환은 비싸다. 성능을 개선하기 위해 x86_64 아키텍처는 변환 색인 버퍼(TLB)에 마지막 몇 개의 변환을 캐시한다. 이렇게 하면 변환이 아직 캐시되어 있을 대 변환을 건너뛸 수 있다.
  • 다른 CPU 캐시와 달리 TLB는 완전히 투명하지 않으며 페이지 테이블의 내용이 변경될 때 변환을 업데이트하거나 제거하지 않는다. 즉 커널은 페이지 테이블을 수정할 때마다 TLB를 수동으로 업데이트 해야 한다. 이를 위해서 invlpgTLB에서 지정된 페이지 변환을 제거하여 다음 액세스 페이지 테이블에서 다시 로드되도록 하는 특수 CPU 명령어가 있다.
  • 각 페이지 테이블이 수정될 때 마다 TLB를 플러시하는건 중요하다. 그렇지 않으면 CPU가 이전 변환을 계속 사용할 수 있으며, 이로 인해 디버깅이 매우 어려운 비 결정적 버그가 발생할 수 있다.

구현

  • 사실 지금까지 만든 커널은 이미 페이징에서 실행된다. 1. A Minimal Rust Kernel에서 추한 부트로더는 이미 커널의 모든 페이지를 물리적 프레임에 매핑하는 4단계 페이징 계층이 설정되어 있다. 이는 페이징이 x86_64에서 필수적이기 때문이다.
  • 즉, 커널에서 사용한 모든 메모리 주소는 가상 주소이다. 주소에서 VGA 버퍼에 액세스하는 것은 0xb8000부트로더 ID가 해당 메모리 페이지를 매핑했기 때문에 동작했다. 즉 가상 메모리를 물리적 프레임에 매핑했다는 의미이다.
  • 페이징은 이미 커널을 비교적 안전하게 만들어준다. 경계를 벗어난 모든 메모리 액세스가 임의의 물리적 메모리에 쓰는 대신 페이지 폴트 예외를 일으키기 때문이다. 부트로더는 각 페이지에 대한 올바른 액세스 권한을 설정하기도 하는데, 이는 코드가 포함된 페이지만 실행 가능하고 데이터 페이지만 쓸 수 있음을 의미한다.

페이지 오류

  • 커널 외부의 메모리에 접근하여 페이지 폴트를 발생 시킬 수 있다. 먼저 페이지 폴트 핸들러를 생성하여 IDT에 등록하면 일반적인 이중 폴트 대신 폴트 예외가 표시된다.
// in src/interrupts.rs
 
lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
 
        […]
 
        idt.page_fault.set_handler_fn(page_fault_handler); // new
 
        idt
    };
}
 
use x86_64::structures::idt::PageFaultErrorCode;
use crate::hlt_loop;
 
extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    use x86_64::registers::control::Cr2;
 
    println!("EXCEPTION: PAGE FAULT");
    println!("Accessed Address: {:?}", Cr2::read());
    println!("Error Code: {:?}", error_code);
    println!("{:#?}", stack_frame);
    hlt_loop();
}
  • 레지스터 CR2는 페이지 폴트 시 CPU에 의해 자동으로 설정되고 페이지 폴트를 일으킨 액세스된 가상 주소를 포함한다. 크레이트 Cr2::read의 함수를 사용하여 x86_64를 읽고 프린트한다. PageFaultErrorCode유형은 페이지 폴트를 일으킨 메모리 액세스 유형에 대한 자세한 정보를 제공한다. 페이지 폴트를 해겨하지 않고는 실행을 계속할 수 없으므로 hlt_loop끝에 a를 입력한다.
// in src/main.rs
 
#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");
 
    blog_os::init();
 
    // new
    let ptr = 0xdeadbeaf as *mut u8;
    unsafe { *ptr = 42; }
 
    // as before
    #[cfg(test)]
    test_main();
 
    println!("It did not crash!");
    blog_os::hlt_loop();
}
  • 커널 외부의 일부 메모리에 액세스가 가능해진다.
  • 레지스터 CR2에는 실제로 0xdeadbeaf 접근하려고 시도한 주소가 들어있다. 오류 코드는  CAUSED_BY_WRITE쓰기 작업을 시도하는 동안 오류가 발생했다는 것을 알려준다. 설정 되지 않은 비트를 통해 더 많은 것을 알려준다. 예를 들어, 플래그가 설정되지 않았다는 사실은 PROTECTION_VIOLATION대상 페이지가 없어서 페이지 오류가 발생했다는 것을 의미한다.
  • 현재 명령어 포인트가 0x2031b2 이므로 이 주소가 코드 페이지를 가르키고 있음을 알 수 있다. 코드 페이지는 부트로더에 의해 읽기 전용으로 매핑되므로 이 주소에서 읽기는 동작하지만 쓰기는 페이지 오류를 일으킨다. 0xdeadbeaf 포인터를 0x2031b2로 변경하여 이를 시도해 볼 수 있다.
// Note: The actual address might be different for you. Use the address that
// your page fault handler reports.
let ptr = 0x2031b2 as *mut u8;
 
// read from a code page
unsafe { let x = *ptr; }
println!("read worked");
 
// write to a code page
unsafe { *ptr = 42; }
println!("write worked");
  • 마지막 줄을 주석 처리하면 읽기 액세스는 작동하지만 쓰기 액세스는 페이지 오류를 발생 시킨다.
  • “read worked” 메시지가 출력되어 있는 것을 볼 수 있는데 , 이는 읽기 작업이 오류를 일으키지 않았음을 나타낸다.

페이지 테이블 인덱스

// in src/main.rs
 
#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");
 
    blog_os::init();
 
    use x86_64::registers::control::Cr3;
 
    let (level_4_page_table, _) = Cr3::read();
    println!("Level 4 page table at: {:?}", level_4_page_table.start_address());
 
    […] // test_main(), println(…), and hlt_loop()
}
  • Cr3:read함수는 x86_64레지스터에서 현재 활성화된 레벨4 페이지 테이블을 반환한다. a와 타입 CR3의 튜플을 반환한다. 우리는 프레임에만 관심이 있으므로 튜플의 두 번째 요소는 무시한다.
Level 4 page table at: PhysAddr(0x1000)
  • 실행하면 위와 같은 출력이 표시된다. 따라서 현재 활성화된 레벨4 페이지 테이블은 래 유형에서 표시된 대로 물리적 0x1000메모리의 주소에 저장된다. 커널에서 이 테이블에 어떻게 액세스 할 수 있을까?
  • 페이징이 활성화되어 있을 때는 실제 메모리에 직접 액세스할 수 없다. 프로그램이 메모 보호를 쉽게 우회하여 다른 프로그램의 메모리에 액세스할 수 있기 때문이다. 따라서 테블에 액세스할 수 있는 유일한 방법은 주소에 있는 실제 프레임에 매핑된 가상 페이지를 통하는 것 이다. 페이지 테이블 프레임에 대한 매핑을 만드는 이 문제는 커널이 장기적으페이지 테이블에 액세스해야 하기 때문에 일반적인 문제이다. 예를 들어 새 스레드에 스택을 할당할 때이다.

참고