32 minute read

운영 체제에서 메모리 관리를 할 때 흔히 사용되는 페이징을 알아본다.

메모리 분리가 필요한 이유와 세그먼테이션의 작동 방식, 가상 메모리, 페이징이 메모리 단편화 문제를 해결하는 방법을 다룰 것이다. 또한, x86_64 아키텍처에서 사용하는 페이지 테이블의 다층 구조도 살펴본다.

메모리 보호

프로그램 간 분리는 운영 체제 반드시 해야 하는 일이다. 예를 들어, 웹 브라우저가 텍스트 에디터를 간섭하는 일이 없어야 한다. 이를 위해 운영 체제는 하드웨어 기능을 사용해서 프로세스가 소유한 메모리 구역을 다른 프로세스가 접근하지 못하게 한다. 하드웨어나 운영 체제 구현에 따라 프로그램 분리하는 방법이 다양하다.

예를 들어, 임베디드 시스템에 사용되는 ARM Cortex-M 프로세서는 메모리 보호 장치 (Memory Protection Unit, MPU)가 있어서 메모리 구역에 다른 접근 권한(ex, 접근 불가, 읽기 전용, 읽고 쓰기 가능)을 갖도록 작은 숫자(ex, 8)를 지정할 수 있다. 메모리에 접근할 때마다 MPU가 메모리 구역 안에 있는 주소를 올바른 접근 권한으로 사용했는지 확인하고, 그렇지 않은 경우 예외를 발생시킨다. 프로세스를 전환할 때마다 구역과 접근 권한을 바꿔서 운영 체제가 각 프로세스가 자신의 메모리에 접근하도록 한다. 이런 식으로 프로세스를 서로 분리한다.

x86은 세그먼테이션페이징, 이 두 가지 메모리 보호 방식을 지원한다.

세그먼테이션 (메모리 분할)

세그먼테이션은 접근할 수 있는 메모리 양을 늘리기 위해 1978년에 도입됐다. 그 당시에 CPU는 16비트 주소만 사용할 수 있어서 접근할 수 있는 메모리 양이 64KB로 제한됐다. 64KB보다 더 많이 접근하기 위해 세그먼트 레지스터들이 추가됐고, 각 세그먼트 레지스터는 오프셋 주소를 담는다. CPU는 메모리에 접근할 때마다 오프셋을 더 해서, 1MB 크기의 메모리까지 접근이 가능했다.

CPU는 메모리 접근 방식에 따라 세그먼트 레지스터를 자동으로 선택한다. 명령어를 가져올 땐 코드 세그먼트(CS)를 사용하고, 스택 연산(push/pop) 시 스택 세그먼트(SS)를 사용한다. 다른 명령어는 데이터 세그먼트(DS)나 여분 세그먼트(ES)를 사용한다. 나중에 추가된 여분 세그먼트 레지스터인 FSGS는 자유롭게 사용된다.

세그먼테이션의 초창기 버전에서 오프셋이 세그먼트 레지스터에 그대로 담겨있었고 접근 제어도 실행되지 않았다. 초창기 버전은 보호 모드가 도입되면서 변경됐다. CPU가 보호 모드에서 작동할 때, 세그먼트 디스크립터는 로컬과 글로벌 디스크립터 테이블의 인덱스를 담는다. 디스크립터 테이블은 오프셋 주소를 포함하여 세그먼트 사이즈와 접근 권한을 담는다. 각 프로세스마다 프로세스 고유의 메모리 영역에만 접근하도록 하는 별개의 글로벌/로컬 디스크립터 테이블을 불러와서 운영 체제가 프로세스 간 분리를 할 수 있다.

실제 메모리 접근 전에 주소를 변경하는 방법을 사용하는 세그먼테이션은 현재 거의 모든 곳에서 사용 중인 가상 메모리 기법을 이미 사용하고 있다.

가상 메모리

가상 메모리 개념은 물리적 저장 장치에서 메모리 주소를 추상화하는 것이다. 저장 장치에 직접 접근하지 않고 변환 단계를 먼저 거친다. 세그먼테이션에서는 현재 사용 중인 세그먼트의 오프셋 주소를 더하는 변환 단계를 거쳤다. 예를 들어, 프로그램이 오프셋이 0x1111000인 세그먼트의 메모리 주소 0x1234000를 접근하면 실제로는 둘을 더한 0x2345000에 접근한다.

변환하기 전의 주소를 가상 주소, 변환 후의 주소를 물리 주소로 구분할 수 있다. 물리 주소가 유일무이하고 항상 똑같이 구별되는 메모리 주소를 가리키는 것이 두 주소의 큰 차이점이다. 반면, 가상 주소는 변환 함수에 따라 값이 달라진다. 가상 주소가 같아도 서로 다른 물리 주소를 가리킬 수도 있다. 또한, 변환 함수가 다르면 같은 물리 주소인데 서로 다른 물리 주소를 가리킬 수도 있다.

같은 프로그램 두 개를 동시에 돌릴 때 이런 특성이 유용하게 쓰인다.

virtual-memory

위 그림에서 같은 프로그램 두 개가 서로 다른 변환 함수를 가지고 작동 중이다. 첫 번째 인스턴스는 세그먼트 오프셋이 100이므로 0-150 사이 가상 주소는 100-250 사이 실제 주소로 변환된다. 두 번째 인스턴스는 오프셋이 300이므로 0-150 사이 가상 주소는 300-450 사이 실제 주소로 변환된다. 가상 메모리 기법은 두 프로그램이 같은 코드와 같은 가상 주소를 사용하면서 서로 간섭이 없도록 한다.

두 프로그램이 완전히 다른 가상 주소를 사용해도 실제 메모리의 아무 위치에나 프로그램을 놓을 수도 있다. 이 덕분에, 운영 체제는 프로그램을 다시 컴파일하지 않아도 가용 메모리를 모두 사용할 수 있다.

파편화

가상 주소와 물리 주소를 다르게 두는 것이 세그먼테이션의 강력한 기능이지만, 파편화 문제도 뒤따른다.

fragmetation

위 그림을 보면, 충분한 공간이 있어도 프로그램의 세 번째 인스턴스를 가상 메모리에 겹치지 않게 놓을 방법이 없다. 메모리가 연속되어 있어야 하는 것과 비어있는 작은 메모리 공간을 사용할 수 없는 것이 문제다.

파편화를 해결하기 위해, 실행을 멈추고, 사용 중인 메모리끼리 붙여놓고, 오프셋을 변경하고 다시 실행하는 방법이 있다.

brute-force-fragmetation-solution

이제 연속된 공간이 충분히 있어서 세 번째 인스턴스를 실행할 수 있다.

이런 식의 조각 모음은 대용량의 메모리를 복사하므로 성능이 떨어진다. 또한, 파편화가 심해지기 전에 정기적으로 수행돼야 하므로 프로그램이 무작위로 멈춰서 성능 예측이 힘들어지고 운영 체제의 반응이 멈출 수도 있다.

파편화 문제 때문에 대부분의 시스템에서 세그먼테이션을 사용하지 않는다. 정확히는, x86의 64비트 모드가 세그먼테이션을 지원하지 않는다. 대신에 파편화 문제를 완전히 피할 수 있는 페이징 기법을 사용한다.

페이징

페이징은 가상 메모리와 물리 메모리 공간을 고정 크기의 작은 블록으로 나눈다. 가상 메모리 공간의 블록은 페이지라고 하고 물리 주소 공간의 블록은 프레임이라고 한다. 각 페이지를 프레임에 하나씩 매핑할 수 있어서 대량의 메모리를 연속되어 있지 않은 물리 프레임에 나눠서 놓을 수 있다.

페이징을 사용하면 메모리 공간이 파편화되지 않는다.

paging

위 그림에서 메모리 구역이 페이지 사이즈가 50 바이트인 페이지 3개로 나눠졌다. 각 페이지는 개별적으로 프레임에 매핑됐다. 덕분에 연속된 가상 메모리 공간을 연속되지 않은 물리 프레임에 매핑할 수 있게 됐다. 페이징을 사용하면 조각 모음을 하지 않아도 프로그램 인스턴스 세 개를 실행할 수 있다.

숨은 파편화

세그먼테이션은 소수의 크기가 크고 가변인 메모리 공간을 사용하지만, 페이징은 다수의 크기가 작고 고정된 메모리 공간을 사용한다. 모든 프레임의 크기가 같아서, 크기가 작다고 프레임을 사용 못 하는 경우가 없다. 그래서 파편화가 발생하지 않는다.

혹은, 파편화가 없는 것처럼 보이는 것일 수도 있다. 여전히 숨은 파편화가 존재하고, 이를 내부 파편화라고 한다. 메모리 공간이 모두 페이지 크기에 딱 맞게 나눠지지 않아서 내부 파편화가 발생한다. 페이지 크기가 50 바이트이면 크기가 101 바이트인 프로그램은 페이지 3개를 사용하고 49 바이트를 낭비한다. 한편, 세그먼테이션 사용 시 발생하는 파편화는 외부 파편화라고 한다.

내부 파편화는 나쁘지만, 세그먼테이션으로 발생하는 외부 파편화보단 낫다. 내부 파편화가 메모리를 낭비하긴 해도 조각 모음이 필요 없고 파편화 발생량(메모리 영역 당 평균 페이지 절반 정도)을 예측할 수 있다.

페이지 테이블

페이지가 백만 개 있다면 백만 개 각각 프레임에 매핑돼야 한다. 이때 사용하는 매핑 정보는 어딘가에 보관해야 한다. 세그먼테이션은 활성 메모리 영역마다 별도의 세그먼트 선택자 레지스터를 사용했다. 페이징을 사용할 때는 페이지가 레지스터보다 훨씬 많으므로 별도의 레지스터를 사용할 수 없다. 대신에 테이블 구조의 페이지 테이블을 사용해서 매핑 정보를 보관한다.

위의 예시의 페이지 테이블은 다음과 같이 생겼다.

page-table

프로그램은 각자의 페이지 테이블을 갖는다. x86 CPU는 현재 사용 중인 테이블을 CR3 레지스터에 저장한다. 운영 체제는 프로그램을 실행하기 전에 알맞은 페이지 테이블을 가리키는 포인터를 CR3 레지스터에 저장해야 한다.

메모리에 접근할 때마다, CPU는 이 레지스터에서 테이블 포인터를 읽어와서 접근하려는 페이지에 매핑된 프레임을 테이블에서 찾는다. 이 과정은 하드웨어적으로 실행되고 실행 중인 프로그램에 완전히 투명하다. 많은 CPU 아키텍처는 변환 속도를 높이기 위해, 마지막 변환 결과를 기억하는 특수한 캐시를 갖고 있다.

아키텍처마다 다르지만, 페이지 테이블에 플래그 필드를 둬서 접근 권한 같은 속성을 저장하기도 한다. 위의 그림에서 “r/w” 플래그는 페이지를 읽고 쓸 수 있게 만든다.

다층 페이지 테이블

1단짜리 페이지 테이블은 메모리가 커지면 테이블도 커져서 메모리를 낭비하는 문제가 생긴다. 예를 들어, 프로그램이 가상 페이지 0, 1,000,000, 1,000,050, 1,000,100번을 사용한다고 쳐보자.

multilevel-page-table

프로그램은 물리 페이지를 4개만 사용하지만, 페이지 테이블은 백만 개가 넘는 항목을 갖게 된다. 그렇다고 빈 항목을 테이블에서 뺄 수 없다. 그럴 경우 CPU가 페이지를 변환할 때 알맞은 항목으로 바로 접근할 수 없기 때문이다. (네 번째 페이지가 네 번째 항목을 사용하는지 보장 못 함)

2단 페이지 테이블을 사용해서 메모리 낭비를 줄일 수 있다. 2단 테이블은 주소 구역이 다르면 다른 페이지 테이블을 사용하는 것이다. 레벨 2라는 테이블을 추가해서 주소 구역과 레벨 1 페이지 테이블 간의 매핑을 보관한다.

예를 들어, 레벨 1 페이지 테이블은 크기가 10,000인 구역만 담당하게 한다.

2-level-page-table

페이지 0은 첫 번째 10,000 바이트 구역에 해당하므로 레벨 2 페이지 테이블의 첫 번째 항목을 사용한다. 이 항목은 레빌 1 페이지 테이블인 T1을 가리킨다. T1은 페이지 0이 프레임 0을 가리키고 있음을 알려준다.

페이지 1,000,000, 1,000,050, 1,000,100는 100번째 10,000 바이트 구역에 해당하므로 레벨 2 페이지의 100번째 항목을 사용한다. 이 항목은 다른 레빌 1 페이지 테이블인 T2를 가리킨다. T2는 세 페이지를 100, 150, 200 프레임에 매핑한다. 레벨 1 페이지에 있는 페이지 주소는 구역 오프셋을 포함하지 않는다. 즉, 1,000,050 페이지는 그냥 50이다.

여전히 레벨 2 페이지에 항목 백개가 비어있지만, 전에 백만 개 항목이 비었을 때보다 낫다. 10,0001,000,000 사이 매핑되지 않은 메모리 구역에 채워 넣을 레벨 1 페이지를 만들지 않아도 돼서 공간을 많이 절약했다.

2단 페이지 테이블은 3단, 4단 또는 그 이상의 단으로 늘릴 수 있다. 그러면 페이지 테이블 레지스터는 최상위 레벨 테이블을 가리키며, 이 테이블은 다음 하위 레벨 테이블을 가리키고, 다시 그 테이블은 다음 하위 레벨 테이블을 가리키고 이런 식으로 계속된다. 마지막 레벨 1 페이지 테이블은 매핑된 프레임을 가리킨다. 이런 방식을 다층 또는 계층적 페이지 테이블이라고 한다.

페이징과 다층 페이지 테이블의 작동방식을 알았으므로, x86_64 아키텍처에서 페이징이 어떻게 구현됐는지 알아본다. (CPU가 64 비트 모드로 작동한다고 가정함)

X86_64의 페이징

x86_64 아키텍처는 4단 페이지 테이블과 4KB 크기의 페이지를 사용한다. 각 페이지 테이블은 레벨에 상관없이 512개의 항목을 갖는다. 각 항목의 크기가 8 바이트이므로 각 테이블은 512 * 8 Bytes = 4KB 크기를 갖는다. 페이지 한 개도 4KB이므로 테이블이 페이지 하나에 딱 들어맞는다.

가상 주소에서 레벨에 맞는 페이지 테이블 인덱스를 바로 알아낼 수 있다.

page-table-index

각 테이블 인덱스는 9 비트로 구성되며, 이에 맞게 각 테이블은 2^9 = 512개의 항목을 갖는다. 최하위 12 비트는 4KB 크기(2^12 bytes= 4KB)의 페이지 내부의 오프셋을 나타낸다. 48부터 64비트는 버려진다. 즉, x86_64는 48 비트 주소만 지원하므로 실제로 64 비트가 아니다.

48에서 64 비트가 버려져도, 여기에 임의로 값을 설정할 수 없다. 대신에 주소를 고유하게 유지하고 향후에 페이지 테이블을 5단으로 확장하기 위해 이 범위의 모든 비트가 47 비트와 같은 값을 가져야 한다. 이는 2의 보수 확장과 매우 비슷해서 부호 확장이라고 한다. 주소가 올바르게 부호 확장되지 않으면 CPU가 예외를 발생시킨다.

인텔의 최신 아이스 레이크 CPU는 가상 주소를 48비트에서 57비트로 확장하는 5단 페이지 테이블을 선택적으로 지원한다(접근 가능 가상 메모리가 256 TB에서 128 PB로 늘어난다). 아직 특정 CPU에 맞게 커널을 최적화할 필요는 없으므로 4단 페이지 테이블만 다루도록 한다.

변환 과정 예시

예제를 통해 변환 과정의 작동 방식을 자세히 알아본다.

example-translation

현재 활성화된 레벨 4 페이지 테이블(4단 페이지 테이블 루트)의 물리 주소는 CR3 레지스터에 저장된다. 그런 다음 각 페이지 테이블 항목은 다음 레벨 테이블의 물리 프레임을 가리킨다. 레벨 1 테이블의 항목은 매핑된 프레임을 가리킨다. 페이지 테이블의 모든 주소는 가상이 아닌 실제 주소다. 가상 주소를 썼다면 CPU가 해당 주소도 변환해야 하므로 무한 재귀가 발생할 수도 있다.

위의 페이지 테이블 계층은 파랗게 표시된 페이지 두 개를 매핑한다. 페이지 테이블 인덱스에서 두 페이지의 가상 주소가 0x803FE7F0000x803FE00000임을 알 수 있다. 프로그램이 0x803FE7F5CE번 주소를 읽으려고 할 때 어떤 일이 일어나는지 알아보자. 우선, 주소를 이진수로 바꿔서 주소의 페이지 테이블 인덱스와 페이지 오프셋을 정한다.

binary-virtual-address

인덱스를 가지로 페이지 테이블 계층을 따라 주소에 매핑된 프레임을 알아낼 수 있다.

  • CR3 레지스터에서 레벨 4 테이블의 주소를 읽는다.
  • 레벨 4 인덱스가 1이므로, 레벨 4 테이블에서 1번 인덱스를 가진 항목을 찾는다. 이 항목에서 레벨 3 테이블이 16KB 주소에 저장되어 있음을 알 수 있다.
  • 16KB 주소에서 레벨 3 테이블을 불러와서 0번 인덱스 항목을 찾는다. 이 항목에서 레벨 2 테이블이 24KB에 있음을 알 수 있다.
  • 레벨 2 인덱스가 511이므로, 페이지의 마지막 항목을 찾아서 레벨 1 테이블의 주소를 알아낸다.
  • 레벨 1 테이블의 인덱스가 127인 항목을 보고 해당 페이지가 12KB(16진수로 0x3000) 프레임에 매핑되어 있음을 알 수 있다.
  • 마지막으로, 프레임 주소에 페이지 오프셋을 더해서 물리 주소 0x3000 + 0x5ce = 0x35ce를 알아낸다.

translation

레벨 1 테이블 내부의 페이지는 읽기 전용을 뜻하는 r 권한을 갖는다. 권한을 하드웨어가 강제하기 때문에 해당 페이지에 쓰기를 시도하면 예외가 발생한다. 상위 레벨 페이지의 권한이 하위 레벨 테이블의 가능한 권한을 제한하므로 레벨 3 항목을 읽기 전용으로 설정하면 하위 레벨이 읽기/쓰기 권한을 지정해도 해당 항목에 쓸 수 없다.

이 예시에서는 각 테이블의 단일 인스턴스만 사용했지만, 일반적으로 각 주소 공간의 각 레벨마다 여러 개의 인스턴스가 있다. 최대로 다음의 개수의 테이블이 있다.

  • 레벨 4 테이블 1개
  • 레벨 3 테이블 512개 (레벨 4 테이블에 항목이 512개 있으므로)
  • 레벨 2 테이블 512 * 512개(512개의 레벨 3 테이블이 각 항목을 512개 가지므로)
  • 레벨 1 테이블 512 * 512 * 512개

페이지 테이블 포맷

x86_64 아키텍처의 페이지 테이블은 512개 항목을 가진 배열이다. 러스트로 다음과 같이 나타낼 수 있다.

#[repr(align(4096))]
pub struct PageTable {
    entries: [PageTableEntry; 512],
}

repr 속성으로 페이지 테이블을 4KB 경계로 페이지 정렬했다. 이 속성으로 페이지 테이블이 항상 페이지 전체를 채워서 항목들이 밀집하게 붙어있도록 최적화한다.

각 항목은 8 바이트(64 비트) 크기를 갖고 다음의 형식을 따른다.

비트 이름 의미
0 존재함 페이지가 현재 메모리에 있음
1 쓰기 가능 이 페이지에 쓰기 가능
2 사용자 접근 가능 설정 안 되어있으면, 커널 모드에서만 이 페이지에 접근 가능
3 캐시를 통한 쓰기 쓰면 바로 메모리에 저장됨
4 캐시 해제 이 페이지에 캐시 사용 안 함
5 접근됨 이 페이지에 읽기 발생 시 CPU가 이 비트를 설정함
6 더티 이 페이지에 쓰기 발생 시 CPU가 이 비트를 설정함
7 큰 페이지/null P1과 P4에서는 0이고, P3에서는 1GB 페이지를 생성하고, P2에서는 2MB 페이지를 생성함
8 전역 주소 공간 스위치에서 페이지가 캐시에서 지워지지 않음 (CR4 레지스터의 PGE 비트가 설정돼야 함)
9-11 사용 가능 운영 체제가 자유롭게 사용해도 됨
12-51 물리 주소 프레임이나 다음 페이지 테이블의 52비트 물리 주소를 정렬한 페이지
52-62 사용 가능 운영 체제가 자유롭게 사용해도 됨
63 실행 금지 이 페이지에 코드 실행을 금지함 (EFER 레지스터의 NXE 비트가 설정돼야 함)

12-51 비트만 물리 프레임 주소 저장에 사용된다. 나머지 비트는 플래그로 사용되거나 운영 체제에서 자유롭게 사용된다. 이는 항상 ① 4096 바이트로 정렬한 주소나 ② 페이지 정렬된 페이지 테이블이나 ③ 매핑된 프레임의 시작을 가리키기 때문에 가능하다. 즉, 0-11 비트는 항상 0이므로, 하드웨어가 주소를 사용하기 전에 비트를 0으로 설정할 수 있기 때문에 0-11 비트를 저장할 필요 없다. x86_64 아키텍처는 52 비트 물리 주소만 지원하므로 (48 비트 가상 주소만 지원하듯이) 52–63 비트도 마찬가지로 저장할 필요 없다.

사용 가능한 플래그들 자세히 살펴본다.

  • 존재함 플래그는 페이지의 매핑 여부를 구별한다. 주 메모리가 꽉 찼을 때 페이지를 임시로 디스크에 스왑 아웃하는 데 사용할 수 있다. 이후에 스왑 아웃된 페이지에 접근하면 페이지 폴트 예외가 발생한다. 디스크에서 누락된 페이지를 다시 불러와 프로그램을 재개해서 운영체제가 대응할 수 있게 한다.
  • 쓰기 가능실행 금지 플래그는 페이지의 내용이 쓰기 가능하거나 실행 가능한 명령어를 가지고 있는지 여부를 제어한다.
  • 접근됨더티 플래그는 페이지에 읽기/쓰기 발생 시 CPU에 의해 자동으로 설정된다. 운영 체제는 이 정보를 활용해서 스왑 아웃할 페이지를 결정하거나 마지막 디스크 저장 이후 페이지 내용이 변경됐는지 확인한다.
  • 캐시를 통한 쓰기캐시 해제 플래그를 사용하면 모든 페이지의 캐시를 개별적으로 제어할 수 있다.
  • 사용자 접근 가능 플래그는 사용자 공간 코드에서 페이지를 사용할 수 있게 한다. 설정이 안 된 경우, CPU가 커널 모드인 경우에만 페이지에 접근할 수 있다. 이 기능으로 사용자 공간 프로그램이 실행되는 동안 커널을 매핑해서 시스템 호출 속도를 높이는 데 사용할 수 있다. 스펙터 취약점이 사용자 공간 프로그램이 커널 모드용 페이지를 읽을 수 있게 한다.
  • 전역 플래그는 모든 주소 공간에서 페이지를 사용할 수 있다고 알리므로 주소 공간 스위치의 변환 캐시에서 제거할 필요거 없다(아래 TTLB 섹션에서 다룸). 일반적으로 전역 플래그는 커널 코드를 모든 주소 공간에 매핑하기 위해 유저 접근 가능 플래그와 함께 사용된다.
  • 큰 페이지 플래그를 사용하면 레벨 2나 레벨 3 페이지 테이블의 항목이 맵핑된 프레임을 직접 가리키게 해서 더 큰 크기의 페이지를 만들 수 있게 한다. 큰 페이지 플래그가 설정되면, 페이지 크기를 512배 늘릴 수 있다. 레벨 2 항목은 512 _ 4KB = 2MB, 레벨 3 항목은 512 _ 2MB = 1GB 크기의 페이지를 갖게 된다. 페이지 크기가 크면 변환 캐시와 페이지 사용량과 줄어드는 장점이 있다.

x86_64 크레이트는 페이지 테이블과 페이지 항목을 표현하는 타입을 제공하므로, 페이징에 사용할 구조체를 만들지 않아도 된다.

변환 색인 버퍼 (Translation Lookaside Buffer)

4단 페이지 테이블의 가상 주소 변환은 각 변환 당 메모리 접근을 4번씩 해야 하므로 비용이 크다. x86_64 아키텍처는 성능 향상을 위해 변환 색인 버퍼에 마지막 변환 몇 개를 캐시 한다. 번환이 캐시 되어있으면 변환 과정을 건너뛸 수 있다.

다른 CPU 캐시와 달리, TLB는 완전히 투명하지 않고 페이지 테이블의 내용이 변경됐을 때 변환을 업데이트하거나 제거하지 않는다. 즉, 커널이 페이지 테이블을 변경할 때마다 TLB도 직접 업데이트해야 한다. 이를 위해 TLB에서 특정 페이지의 변환을 제거해서 다음 접근 시 페이지 테이블을 다시 로드하게 하는 invlpg (“invalidate page”)라는 CPU 명령어가 있다. 또한, 주소 공간 스위치를 시뮬레이트 하는 CR3 레지스터를 새로고침해서 TLB를 완전히 비울 수 있다. x86_64 크레이트는 tlb module을 통해 invlpgCR3를 새로고침하는 함수를 모두 제공한다.

페이지 테이블을 변경할 때마다 TLB를 비우는 게 중요하다. 그렇지 않으면 CPU가 이전 변환을 계속 사용해서 디버깅하기 어려운 비결정적 버그가 발생할 수 있다.

구현

알고보면 커널은 이미 페이징을 사용하고 있다. 아주 작은 러스트 커널에서 모든 커널의 페이지를 물리 프레임에 매핑하는 4단 페이징 계층을 설정했다. x86_64 64비트 모드에서 페이징은 필수이므로 부트로더가 페이징 설정을 해놨다.

그래서 커널에서 사용하는 모든 메모리 주소는 가상 주소이다. 0xb8000 주소의 VGA 버퍼에 접근하는 것도 부트로더가 가상 페이지 0xb8000와 물리 프레임 0xb8000을 매핑한 덕분에 가능했다.

페이징은 범위를 벗어난 메모리 접근에 임의의 물리적 메모리에 쓰는 대신 페이지 폴트를 일으키기 때문에 커널을 비교적 안전하게 만든다. 또한, 부트로더는 페이지에 올바른 접근 권한을 설정해서 코드가 들어있는 페이지만 실행할 수 있고 데이터 페이지에만 쓸 수 있게 한다.

페이지 폴트

커널 밖에 있는 메모리에 접근해서 페이지 폴트를 일으켜본다. 우선, 페이지 폴트 처리 함수를 만들어서 IDT에 등록한다. 이를 통해 더블 폴트 대신에 페이지 폴트가 발생한다.

// 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: &mut 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();
}

페이지 폴트 발생 시 CPU에 의해 자동으로 설정되는 CR2 레지스터는 접근돼서 페이지 폴트를 발생시킨 가상 주소를 담고 있다. x86_64 크레이트의 Cr2::read 함수로 CR2 레지스터를 읽고 출력한다. PageFaultErrorCode 타입은 페이지 폴트를 일으킨 메모리 접근의 종류에 대한 자세한 정보를 제공한다. 문제를 일으킨 연산이 읽기인지 쓰기인지 등의 정보를 담고 있으므로 출력한다. 페이지 폴트 해결 전까지 실행을 재개할 수 없으므로 마지막에 hlt_loop에 들어간다.

커널 외부 메모리에 접근해본다.

// src/main.rs 내부

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");

    blog_os::init();

    // 새로 추가
    let ptr = 0xdeadbeaf as *mut u32;
    unsafe { *ptr = 42; }

    // 전과 같음
    #[cfg(test)]
    test_main();

    println!("It did not crash!");
    blog_os::hlt_loop();
}

커널 실행 시 페이지 폴트 처리 함수가 실행된다.

page-fault-handler-called

CR2 레지스터는 접근을 시도한 0xdeadbeaf 주소를 담고 있다. 에러 코드가 담고 있는 CAUSED_BY_WRITE를 통해 쓰기 작업 실행을 하던 중에 페이지 폴트가 발생했다는 것을 알 수 있다. 설정되지 않은 비트를 통해 더 많은 정보를 알 수 있다. 예를 들어 PROTECTION_VIOLATION 플래그가 설정되지 않았다는 것은 해당 페이지가 없어서 페이지 폴트가 발생했다는 뜻이다.

현재 명령어 포인터가 0x2031b2이므로, 이 주소는 코드 페이지를 가리킨다. 코드 페이지는 부트로더에 의해 읽기 전용으로 매핑되므로 이 주소를 읽을 수는 있지만, 쓰면 페이지 폴트가 발생한다. 0xdeadbeaf 포인터를 0x2031b2 바꿔서 실행해볼 수 있다.

// 주의: 실제 주소는 환경마다 다를 수 있음
// 페이지 폴트 처리 함수가 표시하는 주소를 사용할 것
let ptr = 0x2031b2 as *mut u32;

// 코드 페이지 읽기
unsafe { let x = *ptr; }
println!("읽기 성공");

// 코드 페이지에 쓰기
unsafe { *ptr = 42; }
println!("쓰기 성공");

마지막 줄을 주석 처리하면 읽기 접근은 정상 작동하지만, 쓰기 접근 시 페이지 폴트가 발생한다.

![code-page-write-access]](https://user-images.githubusercontent.com/22253556/81690949-04d10080-9497-11ea-94e5-3cc5863d6796.png)

“읽기 성공” 메시지가 출력되어 읽기 작업 시 오류가 발생하지 않았음을 알 수 있다. 그러나 “쓰기 성공” 메시지가 출력되는 대신 페이지 폴트가 발생한다. 이번에는 PROTECTION_VIOLATION 플래그가 CAUSED_BY_WRITE 플래그와 함께 설정돼서 페이지가 있지만, 허용되지 않은 연산이 실행됐음을 알 수 있다. 이 경우 코드 페이지가 읽기 전용으로 매핑됐기 때문에 페이지 쓰기가 허용되지 않았다.

페이지 테이블 접근하기

커널 매핑 방식을 결정하는 페이지 테이블을 살펴본다.

// 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()
}

x86_64Cr3::read 함수는 CR3 레지스터에서 현재 활성화된 레벨 4 페이지 테이블을 반환한다. Cr3::read()PhysFrameCr3Flags 타입의 튜플을 반환한다. 프레임만 살펴보고 싶으므로 튜플의 두 번째 요소는 무시한다.

위의 코드를 실행하면 다음과 같이 출력된다.

Level 4 page table at: PhysAddr(0x1000)

현재 활성화된 레벨 4 테이블이 물리 메모리 0x1000 주소에 저장되어 있고 물리 메모리 주소는 PhysAddr 래퍼 타입으로 표시됐다. 커널에서 이 테이블에 접근하려면 어떻게 해야 할까?

프로그램이 메모리 보호를 우회하고 다른 프로그램의 메모리에 접근하는 것을 막기 위해, 페이징 사용 시 물리 메모리에 직접 접근은 불가능하다. 따라서 테이블에 접근하기 위해서는 주소 0x1000의 물리 프레임에 매핑된 일부 가상 페이지를 통해야 한다. 페이지 테이블 프레임용 매핑 생성은 일반적인 문제다. 예를 들어, 커널이 새 스레드에 스택을 할당할 때 페이지 테이블에 정기적으로 액세스해야 하기 때문이다.

이 문제는 다음 포스트에서 자세히 다룬다.

요약

이번 장에서 메모리 보호 기법인 세그먼테이션과 페이징을 소개했다. 세그먼테이션은 가변크기의 메모리 구역을 사용하고 외부 파편화가 발생하는 문제점이 있다. 페이징은 고정 크기의 페이지를 사용하고 접근 권한 제어를 더 세밀하게 할 수 있다.

페이징은 1단 이상의 페이지 테이블에 페이지 매핑 정보를 저장한다. x86_64 아키텍처는 4단 페이지 테이블을 사용하고 페이지 사이즈는 4KB이다. 하드웨어가 자동으로 페이지 테이블을 따라 이동하고 변환 결과는 변환 색인 버퍼(TLB)에 캐시 한다. 변환 색인 버퍼는 투명하게 업데이트되지 않으므로 페이지 테이블 변경 시 수동으로 비워야 한다.

커널이 이미 페이징 위에서 실행되고 있으며 불법 메모리 액세스가 페이지 폴트 예외를 일으킨다. 현재 활성화된 페이지 테이블에 접근하려고 했지만, CR3 레지스터가 커널에서 직접 접근할 수 없는 물리 주소를 저장하고 있어서 페이지 테이블에 접근할 수 없었다.

Categories:

Updated: