매삼햌 - 운영 모드와 메모리 관리 기법 (2022.03.19.)
매삼햌은 매일 세 시간 해킹 공부의 줄임말로, 그 어원은 매삼비(매일 세 개씩 푸는 비문학 독서 기출)에서 왔다.
이는 동아리에서 진행하는 스터디로, 평일 오후 9시마다 모여서, 본인이 그날마다 공부한 내용을 발표하고, 질문을 받는다. 또한, 격주 주말마다 매삼햌 세미나를 열어서, 공부했던 것을 정리하고, 이를 부원들에게 공유한다.
Pwnable, 디지털포렌식, Javascript Fuzzing, 스마트 컨트랙트 워게임 풀이, AI Safety 등 다양한 주제들을 가지고 공부를 하고, 질문을 받는데 다른 부원들이 관심 있는 주제를 듣는 것도 좋고, 발표 내용도 흥미로워서 재밌게 하고 있다.
나는 그중에서 'OS 개발'을 주제로 공부를 하고 있다.
"64비트 멀티코어 OS 원리와 구조"라는 책을 따라하면서, 운영체제가 어떤 식으로 생겨먹은 놈인지 이해하려고 한다.
이번 글에서는 첫 번째 매삼핵 세미나에서 발표했던 PPT를 정리하려고 한다.
목차는 이 주제를 선정한 계기, 운영 모드와 메모리 관리 기법, 추후 계획으로 이루어졌다.
2학년 때, 피치 못할 사정으로, 3학년 전공 수업인 운영체제 강의를 들었는데, 일부 열심히 공부했던 내용(메모리 관리 기법, 임계 구역 문제, 이를 해결하는 동기화 등)만 단편적으로 기억에 남을 뿐, 운영체제 전반에 대한 지식이 부족한 것 같았다. 또한, 리눅스 커널 해킹을 하려면 컴퓨터 전반에 대한 지식이 필요한데, 운영체제에서 다루는 내용은 적어도 알고 있어야 하지 않을까 싶었다.
이제 운영 모드와 메모리 관리 기법에 대해 이야기한다. 이는 운영 체제를 만드는 과정에서 배운 내용으로, 운영체제의 핵심 기능 중 하나가 메모리 관리에 대한 것이기 때문에, 이 지식을 소개하려고 PPT에 넣었다.
먼저, 단편화는 메모리의 빈 공간 또는 자료가 여러 개의 조각으로 나뉘는 현상을 의미한다.
매삼햌 스터디를 진행하면서, 메모리 관리 기법을 두 가지 설명하였고, 하나는 케이크, 다른 하나는 식빵에 비유하였다. 케이크는 먹고 싶은 사람이 원하는 크기만큼 잘라서 가져갈 수 있다. 그리고, 식빵은 모두 똑같은 크기로 잘게 잘라져 있기 때문에, 더 많이 먹고 싶으면 식빵을 여러 개 가져가야 한다.
따라서, 전자는 세그멘테이션 기법, 후자는 페이징 기법이다.
먼저, 페이징에서는 내부 단편화가 발생할 수 있다. 만약 페이징의 최소 단위인 페이지가 100이고, 프로세스는 101 크기를 지녔을 때, 이를 위해선 2개의 페이지가 필요하다. 그리고, 99 공간이 하나의 페이지에서 낭비되게 된다.
이는 세그멘테이션에서 발생할 수 있는 외부 단편화이다. 전체 남은 메모리 공간은 합하면 1500KB이지만, 1300KB라는 연속적인 메모리 공간을 할당하는 것은 불가능한 상황이다. 왜냐하면, 500KB와 1000KB로 가용 메모리 공간이 조각나 있기 때문이다.
CPU 코어 내부에는 MMU라는 장치가 있어서, CPU의 가상 주소를 실제 물리 주소로 변환하는 장치가 존재한다고 하였다. 즉, MMU라는 장치에서 세그멘테이션과 페이징 기법을 하드웨어 자체적으로 수행하는 것이다. 그렇다면, 가용 메모리를 나누는 기술이 어떻게 가상 주소를 물리 주소로 바꾸는데 이용될 수 있을까? 그 원리를 실제 CPU의 운영 모드들을 살펴보며 이해하자.
먼저, 리얼 모드이다. 리얼 모드는 최대 1MB 주소 공간을 지원하고, 세그멘테이션만 수행한다. 또한, 세그먼트의 크기는 64K(2^16)로 고정된다. 왜냐하면, 리얼 모드는 16비트 모드이기 때문이다.
64K를 가지고 어떻게 1M(2^20)을 구현할 수 있을까? 바로, 세그먼트 레지스터의 값에 4번 왼쪽으로 시프트한 값이 세그먼트의 기준 주소가 되기 때문이다. SS(스택 세그먼트)가 0x4000일 때, 0x40000에다가 범용 레지스터의 크기가 16비트이기 때문에, 0x40000 ~ 0x4FFFF까지 쓸 수 있다.
그러면, SS가 0xFFFF일 때, 그 범위는 얼마나 될까?
바로, 0xFFFF0 ~ 0x10FFEF이다. 응? 아까 1M까지 된다고 했지 않았나? 0xFFEF만큼 초과되는데?
맞다. 그런데, 하드웨어의 한계로 0x10FFEF는 0xFFEF로 인식되었다.
그런데, 메모리가 1MB 이상을 넘게 지원하게 되자, CPU는 0x10FFEF를 0x10FFEF로 인식할 수 있게 되었고, 0x10FFEF를 0xFFEF로 인식하는 CPU에서 개발한 프로그램이 더 이상 지원할 수 없게 되었다. 이러한 호환성을 맞추기 위해 도입된 것이 A20 게이트이다. 옛날 PC에서는 그래서 A20 게이트를 기본적으로 비활성화함으로써, 20번째 주소 비트를 0으로 자동으로 세팅되게 하였고, 호환성을 만족하였다. 그런데, 최근에는 A20 게이트를 자동으로 활성화하는 경우가 많다.
두 번째 보호 모드이다. 32비트 모드라고 생각하면 편하다. 보호 모드에서는 세그멘테이션과 페이징을 모두 지원한다. 먼저, 세그멘테이션이 이전에는 세그먼트 레지스터(SS, CS, DS, etc.)를 통해서 이루어졌다면, 보호 모드에서부터는 세그먼트 디스크립터라는 자료구조를 통해서 이루어진다. 위는 세그먼트 디스크립터의 자료 구조이다. 오른쪽 아래부터 왼쪽 위로 갈수록 높은 주소이다. 즉, 세그먼트 디스크립터는 8바이트로 이루어진 자료구조이다.
이전에 사용되던 세그먼트 레지스터는, 세그먼트 디스크립터의 오프셋을 담고 있다. GDT(Global Descriptor Table)이라는 자료 구조에서 여러 개의 세그먼트 디스크립터를 가지고 있고, 첫 번째 8바이트는 NULL로 예약되었다. GDT는 GDTR이라는 시스템 레지스터에 담긴 값을 참조하여 접근할 수 있다. 이러한 세그먼트 디스크립터를 참조하여, 세그멘테이션을 수행하게 된다. 이를 통해, 가상 주소가 선형 주소로 바뀐다.
이후에는 선형 주소를 실제 물리 주소로 바꾸기 위해 페이징 기법이 적용된 사례이다. 위의 사례에서는 3단계 페이징을 수행하며, 10비트를 디렉터리 테이블의 인덱스로, 10비트를 페이지 테이블의 인덱스로, 나머지 12비트는 페이지 내의 오프셋으로 사용된다. CR3 레지스터를 참조하여, 페이지 디렉터리의 시작 주소를 구하고, 페이지 디렉터리의 시작 주소에 페이지 디렉터리의 인덱스를 더해, 페이지 테이블의 기준 주소를 계산한다. 그리고 페이지 테이블의 기준 주소에 페이지 테이블의 인덱스를 더해, 페이지의 기준 주소를 구한다. 마침내 페이지의 기준 주소에 페이지 오프셋을 더하여 실제 참조하는 물리 주소에 접근하게 된다.
마지막으로, IA32-e 모드. 즉, 우리가 일상적으로 쓰는 64비트 모드라고 생각하면 된다. 이 모드에서 세그멘테이션은 조금 요상하다. 기준 주소와 세그먼트 크기가 무시되고, 그대로 선형 주소로 변환된다.
리눅스에서 vmmap으로 메모리 매핑 상태를 보면, 서로 다른 파일인데도 같은 주소에 올라와 있는 걸 볼 수 있다. 이는 프로세스마다 고유의 페이지 테이블을 가지고 있기 때문에, 선형 주소(가상 주소)는 같음에도 불구하고 실제로는 서로 다른 메모리에 위치하고 있는 것이다. 즉, 실제 물리 주소를 다르게 하는 것은 페이징 기법이다.
IA-32e 모드의 페이징 기법에서는 5단계 페이징을 수행한다. 페이지 디렉터리의 개수가 많아진 것일 뿐, 3단계 페이징을 이해하였다면 똑같은 방식으로 5단계 페이징을 따라가면 된다.
다만 추가로 정리할 것은 선형 주소는 48비트만 사용한다는 것이다. 이는 256TB만을 최대로 지원한다는 뜻이다. 물론, 이건 CPU by CPU이다.
그런데, 페이징을 이해하다 보면 정말 의문이 생긴다.
왜 굳이, 5번이나 메모리에 접근을 하는 걸까? 1번 메모리 접근만 해도 엄청 오래 걸리는데, 5번이나 접근을 하려면 얼마나 오래 걸릴까? 그렇게 해서 얻는 이득이 무엇일까? 이에 대한 내용은 PPT에서 다루지 안았으니, 추후에 기회가 생긴다면 포스팅해야겠다.
이제까지 부트 로더 단계까지 구현했으니, 다음 세미나까지 IA-32e 모드로 전환하고 인터럽트 핸들링까지 하겠다는 원대한 목표를 세웠다. 지금은 저 목표가 이루어지지 않았다는 것을 알고 있다. (#`-_ゝ-)
좋은 책 감사합니다.