폰노이만의 구조를 기반으로 우리가 사용하는 프로그램(코드)는 반드시 메모리에 올라가져야 합니다. 이러한 메모리 용량은 보통 8 ~ 16Gb 정도로 많으면 32Gb정도 됩니다.
그런데 리눅스 운영체제에서의 예로 프로세스 하나의 크기는 4Gb로 알고 있습니다. 이론 상으로 만약 16Gb의 메모리를 사용한다면 4개의 프로세스만 사용할 수 있을 겁니다. 하지만 우리는 실제 메모리 용량보다 더 많은 프로세스를 사용하고 있습니다. 바로 가상 메모리를 통해서 말이죠.
가상 메모리의 원리는 생각보다 간단합니다. 프로세스가 4Gb의 크기를 가지고 있다고는 하지만 CPU가 한번에 4Gb의 코드를 읽을 수는 없습니다. 한번에 읽을 수 있는 코드, 다시 말해 메모리 크기는 제한적이라는 말입니다.
이는 CPU가 프로세스를 실행하기 위해서는 모든 코드에 대한 주소가 필요하지 않고 현재 실행 중인 코드에 대한 물리 메모리 정보만 알고 있으면 된다는 말입니다. 그리고 다음 코드로 넘어간다면 이미 읽었던 코드에 대한 정보는 물리 메모리에서 지워버리고 다시 읽어야 하는 코드 주소에 대한 정보만 실제 메모리에 올려서 실행하면 되는겁니다.
위와 같은 원리로 동작을 하기 위해서는 두 개의 주소가 필요합니다. 0 ~ 4Gb의 주소를 가지고 있는 프로세스가 참조하는 가상 주소(Vitual address)와 이러한 가상 주소 중에서 실제로 메모리에 올라가야 하는 주소인 물리 주소(Physical address)가 필요합니다.
CPU는 프로세스를 실행하기 위해서 먼저 가상 주소에 대한 정보를 찾습니다. 그리고 해당 가상 주소를 가지고 프로세스를 실행하기 위해서는 가상 주소가 실제로 어떤 물리 주소에 위치해 있는지 알아야합니다.
MMU는 CPU에서 코드를 실행할시에 가상 주소를 실제 물리 주소로 변환해주는 하드웨어 칩입니다.
정리하자면 CPU는 프로세스를 실행하기 위해 가상 메모리 주소에 대한 정보를 찾고 실제로 해당 주소로 접근할 시에 MMU라는 하드웨어가 실제 물리 주소로 변환해서 CPU에게 전달해 줍니다.
이렇게 실제 메모리 용량보다 더 많은 프로세스를 실행하기 위해서 프로세스를 일정 부분만 메모리에 올려서 실행하는것과 그것을 도와주는 하드웨어에 대해서 알게되었습니다. 그런데 궁굼한 점은 어떤 규칙으로 프로세스를 나누냐는 겁니다.
가상 메모리 시스템에서 가장 많이 사용되는 방법으로 프로세스를 나누는 규칙이라고 생각할 수 있습니다.
페이징이라는 의미 그대로 프로세스를 크기가 동일한 페이지라는 단위로 가상 주소 공간과 이에 매칭되는 물리 주소 공간을 관리하는데 이러한 페이징 시스템을 구현하기 위해서는 하드웨어의 지원이 필요하며 리눅스의 예로 페이지의 한 단위로 4KB를 지원합니다.
그리고 이렇게 4KB의 크기로 나누어진 각각의 페이지들은 순서대로 번호를 부여 받습니다. 이렇게 부여 받은 번호를 기반으로 가상 주소와 물리 주소의 매핑 정보를 기록하게 됩니다.
이것이 가능한 이유는 프로세스의 상태 정보를 가지고 있는 PCB 안에 가상 주소와 물리 주소를 매핑해주는 Page Table의 구조체를 가리키는 주소가 있기 때문입니다.
4Gb 크기의 데이터를 4KB로 나누어 번호를 부여해서 페이지 테이블을 만든다고 했는데요. 생각해보면 이러한 테이블 또한 메모리에 공간을 차지합니다. 만약 4Gb의 데이터 중에서 필요 없는 데이터가 있더라로 번호를 부여받고 의미없이 메모리의 공간을 차지할 수 있기 때문에 필요없는 페이지는 생성하지 않으면서 공간을 절약할 수 있어야하기 때문에 다중 단계 페이징 시스템을 사용합니다.
페이징 시스템과 MMU
CPU는 프로세스를 실행하기 위해서 가상 주소에 접근하며, 하드웨어 장치인 MMU가 물리 메모리에 접근을 해서 가상 메모리 주소를 물리 메모리 주소로 변환해줍니다.
프로세스가 생성되면 페이지 테이블 정보를 생성하는데 PCB는 이러한 페이지 테이블에 접근이 가능합니다. 또한 관련 정보는 물리 메모리에 적재되며, 프로세스가 처음에 실행될 때 페이지 테이블이 물리 메모리에 적재됨과 동시에 해당 페이지 테이블의 base 주소가 CR3라는 레지스터에 저장되고 CPU가 가상 주소로 접근할 시에 MMU가 CR3 레지스터를 가져와 페이지 테이블의 base 주소에 접근해 물리 주소로 변환하여 해당 물리 주소에 있는 데이터를 CPU에 가져다줍니다.
지금까지 정리한 내용을 그림으로 보면 위와 같습니다.
하지만 위와 같은 방법의 문제점은 물리 주소를 요청하기 위해서 계속해서 MMU가 메모리에 접근해야 한다는 겁니다. 왜 문제가 되냐면 CPU 레지스터와 메모리의 속도의 차이 때문입니다.
그래서 위와 같은 그림처럼 처음에 물리 주소를 받아온 뒤에 TLB라는 캐쉬에 해당 정보를 저장해 MMU가 메모리에 위치한 페이지 테이블에 접근할 필요 없이 속도가 더욱 빠른 캐쉬인 TLB에 요청해서 물리 조수에 접근할 수 있습니다.
페이징 시스템을 사용해서 프로세스들을 페이지 단위로 만들었습니다. 그러면 이렇게 만들어둔 페이지들을 어느 시점에, 어떤 방법으로 물리 메모리에 넣어야하는지 알아야하는데, 이때 필요한 기법이 요구 페이징입니다.
프로세스의 모든 데이터를 메모리에 적재하는것이 아니라 실행 중 필요한 시점에만 메모리로 적재하는 방법으로 미리 프로세스 관련 데이터를 메모리에 올려놓고 실행하는 선행 페이징과 반대 개념입니다. 그리고 더 이상 필요 없는 페이지는 다시 저장매체에 저장되기 때문에 물리 메모리를 보다 효율적으로 사용할 수 있습니다.
내부적으로 프로세스가 페이지 단위로 나뉘어져있고 해당 프로세스의 페이지 테이블에는 페이지 번호, 가상 주소, 물리 주소, valid - invalid bit가 있습니다.
페이지 번호 | 가상 주소 | 물리 주소 | valid - invalid bit |
---|
그리고 프로세스가 어떤 페이지를 실행하고자 할때 페이지 테이블의 valid - invalid bit를 확인해서 물리 주소에 올라가져 있는지 확인 후 없다면 해당 페이지를 메모리에 올리고 페이지 테이블에 올려준 페이지에 대한 정보를 테이블에 저장합니다.
실행하고자 하는 페이지가 물리 메모리에 없을 때 일어나는 인터럽트로 운영체제는 이러한 페이지 폴트가 발생하면 해당 페이지를 물리 메모리에 올려주고 재실행을 하게합니다. 이러한 과정을 그림으로 살펴보면 아래와 같습니다.
TLB에 물리 주소가 있다면 메모리에 있는 페이지 테이블에 가지 않고 바로 물리 주소에 해당하는 데이터를 확인 후에 전달하면 됩니다.
없을 경우에는 CR3 레지스터 값을 통해서 페이지 테이블에 접근하며 이때 페이지 테이블의 정보(valid - invalid bit값)을 확인해서 물리 주소에 대한 데이터가 있을 경우 전달해주면 됩니다.
페이지 테이블에도 물리 메모리 데이터가 없다면 페이지 폴트 인터럽트를 통해서 운영체제에게 알려줍니다. 그러면 운영체제는 해당 인터럽트를 처리하기 위해서 프로세스의 공간에서 페이지를 가져와 물리 메모리에 올려주게 되고 페이지 테이블을 업데이트하게 됩니다. 그리고 다시 CPU에게 가상 주소 요청을 하도록합니다.
물론 이러한 페이지 폴드가 빈번하게 발생하면 실행 시간이 오래 걸립니다.
앞에서 요구 페이징 기법을 이용해서 페이지를 물리 메모리에 올려준다고 했습니다. 이때 운영체제가 특정 페이지를 물리 메모리에 올려줘야 하는데, 물리 메모리 공간이 없을 경우 기존 페이지 중 하나를 물리 메모리에서 저장 매체로 옮기고 새로운 페이지를 해당 물리 메모리 공간에 올려야합니다. 이것을 페이지 교체 정책이라 합니다. 그리고 이러한 방법 또한 알고리즘으로 해결을 해야합니다.
많은 프로그램을 실행하면 CPU가 어느정도 무리없이 소화하다가 어느순간부터 프로그램들이 동작하지 않게 되는데 반복전인 페이지 폴트 인터럽트가 발생해서 페이지 교체 작업이 빈번하게 일어나서 정작 실행해야 하는 코드들은 실행하지 못하고 인터럽트와 페이지 교체가 계속해서 들어오다보니 프로그램이 실질적인 동작을 하지 못하고 인터럽트 처리와 페이지 교체만 이루어지는 상황입니다.
가상 메모리에서 가장 많이 사용되는 방법은 페이징 시스템이지만 세그멘테이션 기법이란것도 있다라고만 알아두면 됩니다.
페이징 시스템은 딱히 의미가 있는 블록이 아닌 동일한 크기의 블록으로 가상 메모리를 분할하지만 세그멘테이션 기법은 서로 크기가 다른 논리적인 단위인 세그먼트로 분할합니다.