운영체제 OStudy

[OStudy] 5주차 - 메모리 가상화(1)

Zeka_P 2025. 9. 29. 18:55

 이제 본격적으로 메모리 쪽을 살펴볼 차례입니다. 가상화 라는 개념은 개인적으로 굉장히 매력적인 기법이라고 생각합니다. 이 가상화는 현대 시대에 들어, 어마어마한 기술적 진보가 이룩할 수 있도록 기여한 개념입니다. 메모리의 가상화를 통해 우리는 단순한 계산기, 시뮬레이터였던 컴퓨터가 복잡하고 정교한 논리 모델로 거듭날 수 있게 됨을 목격할 수 있었습니다. 근데 이게 어디 컴퓨터 뿐이었겠습니까? 단연코 21세기 최고의 기술적 비약이었다고 할 수 있는, 유튜버의 가상화(Virtual)를 통해 우리는...... 

 

시분할 시스템

 과거의 컴퓨터는 지금보다 더 간단했다고 합니다. 말 그대로 거대한 처리 기계 였다고 생각해도 될 것 같습니다. 메모리도 굉장히 단순했습니다. 0KB 주소에서부터 시작해서 할당되면 할당되는대로 메모리 공간을 차츰 차츰 차지해나가는 식이었습니다. 하지만 과거 컴퓨터는 굉장히 값비싼 자원이었고, 따라서 여러 사람들이 돌려 써야 했습니다. 하지만 단순히 돌려 쓴다는 것은, 레일이 하나만 있는 것과 다름 없습니다. 그렇기에, 모두가 동시에 사용할 수 있도록 멀티 프로그래밍 기법이 도입되기 시작합니다. 여러 프로세스가 실행 준비 상태로 존재하며, 운영체제에서 이들을 스위칭하며 실행하는, 원시의 운영체제 기법이었습니다. 이를 통해 하나의 컴퓨터 장비를 여러 인원이 동시에 사용할 수 있었습니다.

그리고 이러한 멀티 프로그래밍적 특성을 극대화시키 위해 등장한 시스템이 바로, 시분할 시스템(Time-sharing System)입니다. 프로세스를 아주 짧은 시간 동안만 실행하고, 해당 시간 동안만큼은 모든 메모리에 대한 접근 권한을 부여하는 방식이었습니다. 프로세스의 중단이 이루어 질 때는 그 때의 모든 상태를 디스크와 같은 저장매체에 저장하고 다시 다음 프로세스의 정보를 불러와 실행하는 식의 순환을 이루었습니다. 시분할 시스템의 등장을 통해, 컴퓨터는 기존 일괄 처리 방식에서 대화형 사용 방식으로의 발전을 이루었습니다. 

다만 여기서, 메모리를 디스크로 옮기는 과정은 레지스터에서 메모리로 옮길 때의 비용보다 압도적으로 높습니다. 따라서 해당 방안에서의 최적화가 필요했고, 그 해법으로 가상 메모리 기법이 탄생했습니다. 가상 주소 부여를 통해 프로세스 간 메모리 공간의 제약은 크게 잡지 않으면서, 어쩔 수 없이 초과되는 분량에 대해서만 디스크로 저장시키는, 즉, 하위 단계 캐시에 대한 접근을 최소화시키는 전략을 구축했습니다. 가상 메모리라 할지라도, 결국 물리 메모리는 단일로만 존재하며, 모든 프로세스가 해당 단일 메모리를 공유하는 형태를 띄게 됩니다. 따라서 메모리에 대한 보호 구현 수준이 굉장히 중요합니다.

이러한 가상 메모리 기법은 크게 세가지의 목표를 달성하기 위해 설계되었습니다. 먼저, 사용자 입장에서는 복잡한 물리 메모리의 가상화나 그 한계 점에 대해 딱히 직접 알거나 이용해야 할 필요성이 크게 존재하지 못하도록 하기 위한 추상화와 각 프로세스가 모든 메모리를 독점하고 있다는 착각을 일으키는 환상화, 또한 메모리 사용에 제약이 되지 못하도록 가상 주소 공간을 프로세스별로 독립적으로 할당하는 형태들을 묶은 투명성(Transparency) 측면의 목표가 존재하며, 주소 변환 시의 오버헤드를 최소화 하거나, 페이지 테이블이나 TLB(Translation Lookaside Buffer) 등의 일종의 숏컷의 기능을 하는 하드웨어 자원 매커니즘의 존재가 해당하는 효율성(Efficiency) 측면의 목표, 마지막으로 각 프로세스의 접근 가능 영역을 본인의 가상주소 공간으로 한정하고, 이 또한 읽기/쓰기/실행 별로 권한을 구분하여 설정하는 등의 보호(Protection) 측면의 목표가 존재합니다.

 

 

 

주소 공간

 메모리를 이용한 의도치 않은 위험한 행동을 미연에 방지하기 위해, 운영체제는 사용하기 쉬운 방식의 메모리 개념을 유지하고 있습니다. 이는 주소 공간(address space)의 형태에서 확인할 수 있습니다. 주소 공간은 프로그램이 사용하는 모든 메모리 영역을 포함하며, 이를 안전하며 효율적으로 관리하는 것이 운영체제의 역할입니다. 주소 공간은 (사용자 영역의)최상위 주소에서부터 스택 영역(stack)이 하위 주소 방향으로 경계 주소가 상승하며, 반대 편에선 힙(heap) 영역이 상위 주소 방향으로 하강합니다. 이 두 영역은 동적 할당 영역에 해당합니다. 그리고 힙 영역 다음으로는 BSS(Block Started by Symbol), 데이터(Data), 텍스트(Text) 영역이 정적 할당 영역으로 존재하는데 이들은 각각 저장하는 개체들에 따라 영역이 구분되어 있습니다.

먼저 스택 영역은 함수 호출과 관련된 지역변수나 매개변수 등이 저장되는 공간입니다. 우리가 어떠한 함수를 호출할 경우 생성되는 영역인데, 레지스터가 유지할 수 있는 양을 넘어서서 변수를 선언하거나, 함수가 종료되지 않고 지속적으로 호출될 시, 스택의 크기는 커집니다. 그리고 함수가 종료되는 시점에서, 그 함수로 인해 차지되는 스택 영역이 소멸합니다. 재귀 함수가 과도하게 깊어져, 함수가 완료되지 않거나 완료가 지연되는 경우, 또는 그 변수가 너무 많이 생성될 경우, 할당 가능한 스택 영역 공간을 초과하는 위험이 생겨, 스택 오버플로우 오류로 이어질 수 있습니다. 스택 영역은 사용자가 사용할 수 있는 메모리 공간 중에선 가장 최상위 주소에서부터 시작하며(메모리 영역의 최상단에는, 커널 영역이 존재합니다만, 개요 설명을 위해 생략했습니다.) 하위 주소로 점차 상승하는 형태로 확장됩니다. 

영역은 사용자가 임의로 직접 공간을 할당하거나 해제할 수 있는 영역입니다. 객체와 같은 참조형 데이터들이 이곳으로 저장되는데, malloc과 같은 함수를 통해 직접 공간을 지정하고, free 와 같은 함수로 해제하는 방식으로 존재합니다. 이 힙 영역은 스택과 반대로, 동적 할당 영역 중 최하위 주소에서부터 상위 주소 방향으로 점차 하강하는 형태의 확장 형태를 가집니다. 즉, 힙과 스택은 동일한 동적 할당 영역을 각각 다른 끝점에서부터 확장하는 방식으로 공간을 공유합니다. 수동적인 해제가 필요한 영역이기 때문에, 자칫할 경우 메모리 누수가 발생할 수 있습니다. 마비노기 모바일을 플레이하다 보면 핸드폰이 심한 고열 증상을 호소하게 되는데, 바로 이 힙 영역해제가 원활히 이루어지지 않아(놀랍게도) 발생한 메모리 누수가 원인에 해당됐습니다. 사실 힙과 스택 사이에도 공유 라이브러리를 위한 공간이 별도로 존재하기는 하나, 설명을 위해 우선 생략했습니다.

힙 영역을 기점으로 정적 할당 영역으로 넘어가게되는데 정적 할당 영역 중 최상단에는 BSS(Block Started by Symbol) 영역이 가장 최상단에 오게 됩니다. 사실 텍스트나 데이터는 딱 봐도 뭘 이야기하는지 알 수 있겠지만, 이 바사삭, BSS는 처음 공부하는 사람 입장에선 다소 생소하게 다가올 수도 있습니다. BSS 영역은, 초기화되지 않은 전역변수나 배열, 정적 변수들이 저장되는 영역입니다. 이는 변수에 대해 자세하게 들어가야 구분지을 수 있는 내용인데, 쉽게 설명하자면, 변수는 크게 지역(local) 변수와 전역(global) 변수로 나눌 수 있는데, 위에서 언급하였듯, 지역 변수는 먼저 레지스터에, 그리고 그 다음은 스택 영역에 저장됩니다. 그야 말 그대로 지역적이고, 따라 해당 변수가 속해있는 함수가 종료되면 더 이상 필요 없는 변수이기에 동적으로 할당해서 다 사용하면 즉시즉시 해제해주는게 효율적이기 때문입니다. 그렇기에 남은건 전역 변수 뿐인데, 이들은 프로그램이 실행되는 내내 그곳에 있었고, 존재해야만 하는 변수 종류입니다. 하지만 최초의 상태에 따라 저장공간이 갈립니다. 그 기준이 이 전역변수가 초기화된 전역변수냐(=초기값이 부여된 상태였는가) 인데, 초기화된 변수라 함은, 결국 어떤 값을 가지고 있다는 뜻이고, 이는 메모리에 그 값을 저장하여 유지하고 있어야 한다는 뜻이 됩니다. 이와는 반대로 초기화되지 않은 전역 변수는 그냥 그 변수의 공간만 확보하면 끝입니다. 당연히 같은 전역 변수여도 다른 속성을 보이는 이 둘을 혼용하여 저장하는 것보단, 별도의 영역으로 따로 구분하는 것이 보다 효율적이니, 초기화되지 않은 경우는 BSS에, 초기화된 경우는 데이터 영역에 저장하여 유지합니다. 따라서 BSS 영역의 변수들은 프로그램 시작 시, 자동으로 0 이나 NULL로 초기화되어 시작합니다.

데이터 영역은 방금 언급한대로, 초기화된 전역변수나 배열, 정적변수를 저장합니다. 이들을 참조하는 코드가 존재할 경우, 컴파일 과정 이후, 해당 영역으로 참조하게 됩니다. 데이터 영역은, 프로그램 시작했을 때 할당되는 방식이며 프로그램이 종료되어야 해제됩니다.  데이터와 BSS 영역들은 모두 변수를 저장한다는 특징을 가지고 있습니다. 따라서 특정한 설정을 따로 하지 않는 이상 읽고, 쓰는 작업 모두 진행할 수 있습니다.

가장 최하위 주에는 텍스트(Text) 영역이 존재합니다. 해당 영역은 CPU가 실행할 수 있는 기계어 코드를 저장합니다. 당연히 어떠한 문제로 인해서든, 텍스트 영역을 변질시켜 프로그램을 원 의도와 다르게 조작하는 것을 방지하기 위해, 읽기 전용(Read Only)으로만 존재하는 방식으로 영역 내 값들이 보호됩니다. 

 

극한의 효율을 추구하면 어떻게 될까?

 위에서 언급했든 메모리의 효율성을 위해 가상 메모리 기법이 발달했습니다. CPU에서도 비슷하게, 추측 실행(Speculative execution)이라는 기법과, 비순차적 실행(Out of order execution)이 존재합니다. 먼저 추측 실행이란, 어떠한 분기점에 도달했을 때, 결국 그 분기점이 참인지 거짓인지에 대한 연산을 거쳐야 합니다. 그리고 그 연산에 사용되는 변수가 레지스터에 있다면 참 좋겠지만, 메모리에 존재하는 경우도 많을 것입니다. 그렇다면 이 또한 비용입니다. 이에 대한 극한의 효율을 추구하고자, 추측 실행이라는 기법이 등장했습니다. 개념 자체는 단순합니다. 필요하지 않을 수도 있는 작업을 일당 시행하고 보는 겁니다. 예를 들어 메모리에 저장된 변수 X 의 값이 50보다 작을 경우의 분기점에서, 메모리에 접근하는 데 시간이 걸리니, 그동안 미리 이때 참일 경우의 코드를 실행합니다. 새 변수 Y를 'wah'로 생성하는거라 해볼까요? 그럼 X가 50보다 작은지 검증하기 위해 불러오는 동안, 배열 Y를 미리 wah로 초기화 해두게 됩니다. 그리고 만약 X가 50보다 컸다면 이미 Y가 초기화 되었으니, 바로 다음 명령어로 넘어가고, 만약 틀렸다면 Y를 폐기합니다. 즉, 예측에 성공할수록 그만큼 시간적 비용이 절약되는 기법입니다.

if( X < 50 )
 char Y[] = "wah";

 

비순차적 실행은, 실행 자체가 기존 순서와 다르게 실행되는 기법입니다. CSAPP 1챕터에서 잠시 언급되었던 파이프라이닝이 여기서 나오는데, 원래의 순차적 파이프라인을 사용하는 프로세서의 경우, 데이터 의존성이 존재할 때, 해당 의존성이 해결될 수 있는 연산 처리가 끝날 때까지, 파이프라인이 일시중지, 스톨(stall)됩니다. 예를 들어, 아래의 코드에서는 세번째 줄이 실행되려면 두번째 줄이, 두번째 줄이 실행되려면 첫번째 줄이 실행되어야 합니다. 

int A = 3;
int B = A;
int C = B; //고마워요 나무위키!

 

여기서 흥미로운 점은, 조금의 최적화를 통해서 이 의존성을 제거해줄 수 있는 케이스가 존재한다는 것입니다. 그리고 이 최적화 기법의 관점에서는, 의존성을 제거할 수 있는 경우에 해당하나 발생되는 스톨은 결국 비효율에 해당할 것입니다. 위와 같이 읽고, 쓰기가 이루어지는 경우(Read After Write, RAW) 제거가 불가능한 '진짜' 의존성에 해당하지만, 그냥 쓰기로만 덮어버리는(Write After Write, WAW) 경우나 쓰고 나서 읽는(Write After Read, WAR) 경우는 최적화를 통해 제거가 가능한 '가짜 의존성'에 해당합니다.

y = x + 15;
x = 8;
z = x * 2;

예시를 들어볼까요? 이 코드는 첫째 줄과 둘째 줄이 x를 읽고, 그 다음에 x의 값을 설정했으니 WAR, 두번째줄과 세번째 줄은 반대로 x가 먼저 쓰여진 상태에서 읽어왔으니 RAW 의존성 관계에 놓여 있습니다. 이때, 중복되는 변수 x를 x1이라는 새로운 변수를 할당하여 구분시켜보면 어떨까요? x1과 z와의 RAW 의존성 관계는 계속 존재하나, WAR 관계는 해소됨을 확인할 수 있습니다. 이는 WAW도 같은 방식으로(예를 들어 x = f(); x = h(); 로 존재한다면, x와 x1으로 구분해주면 완전히 별개가 되어, 의존성이 존재하지 않게됩니다.) 제거 가능합니다.

y = x + 15;
x1 = 8;
z = x1 * 2;

 

이 의존성을 제거하는 최적화의 장점은, 독립적인 흐름을 가져갈 수 있는 코드를 분리시킬 수 있다는 점입니다. 이는 의존성으로 인해 스콜된 상태가 된다면, 그 동안 해당 의존성으로부터 독립적인 뒤의 코드를 먼저 실행하여 실행 시간을 보다 단축시킬 수 있습니다. 이 기법을 비순차적 실행이라 합니다.

위 두 기법은 굉장히 효율적이고 흥미로운 기법입니다. 그런데, 이런 극한의 효율성을 추구하는 최적화가 가끔은 예상치 못한 사고를 불러 일으킬 때가 있습니다. 이 기법들이 CPU에 적용되며 나타난 부작용 중 대표적으로, 일부 아키텍처에서 발생했던 멜트다운(Meltdown) 취약점이 존재합니다. 멜트다운 취약점은, 사용자가 악의적으로 커널 메모리의 데이터를 열람하는 공격 중 하나인데, 추측실행과 비순차적 실행으로 인해 커널 메모리가 잠시 노출되는 틈에 캐싱을 통해 간접적으로 가로채는 수법입니다. 그 방법은 아래와 같습니다. 

 

먼저, 읽고자 하는 커널 주소 ka와 L1캐시와 같은 크기의 배열 ar_l 그리고 길이 256의 배열 ar_t 를 생성합니다. 그 다음, 배열 ar_l 전체를 읽습니다. 읽히는 만큼 L1 캐시에 올라가니, L1 캐시의 모든 영역이 ar_l로 캐싱됩니다. 이는 다르게 말하면 ar_t가 현재 L1 캐시 그 어느곳에도 캐싱되어 있지 않음이 보장됩니다.(뒤에 사용합니다.) 그 다음, ka로 접근해여 해당 주소에 저장된 값을 al 레지스터에 저장하도록 시행합니다. 이때 추측실행이 발생합니다. 실제로는 커널 모드가 아니기 때문에 거부당해야 하지만(실제로도 그렇지만) 이 모드 검증에도 시간이 들어가니, 컴퓨터는 일단 ka 에 저장된 값을 먼저 확보합니다. 그 다음 검증 연산이 끝나고 이것이 권한 밖의 시도임이 확인되며 해당 조회 연산을 무효처리하고 되돌립니다. 이때 첫 문제점이 드러납니다. 물론 결과적으론 무효처리되니 해당 값을 알 수 없다 하더라도, 매우 잠깐이나마 커널 주소에 저장된 값은 al 레지스터에 머물렀다 사라집니다.

바로 다음 단계에서 이 빈틈의 실을 이용합니다. 위에서 언급한 ar_t의 al레지스터에 저장된 값 인덱스로 조회를 시도합니다. ar_t[al]. 물론 저희가 만들었으니 별 값은 들어있지 않습니다. 하지만 다음 단계에서 모든 빌드업이 연쇄적으로 터지게 됩니다. 우리는 아까, L1캐시에 ar_l로만 가득찼음을 보장받을 수 있었습니다. 그런데 방금, ar_t[al]을 조회했습니다. 그리고, 원래라면, 예외 처리가 일어나고 거부당해야 했을 명령어 실행이, 비순차적 실행으로 인해(WAR) ar_t[al]을 별도의 변수로 분리하여, 예를 들자면 ar_t[al1] (여기서 al1 = al)으로 수정되어 실행됩니다. 그렇다면, 이 al 값이 뭔지는 몰라도, 방금 조회는 했으니 현재 L1 캐시에는 배배열 ar_l과 딱 한 인덱스 만큼의 ar_t, 그것도 ar_t의 al번째 인덱스만이 캐싱되어 있습니다. 그렇다면, 캐싱 히트 미스를 측정하면 이 al을 역추적 할 수 있습니다. ar_t[0]부터 ar_t[255]까지 무작위로 하나씩 조회하며 시간을 측정하고, 유난히 빨리 조회된, 이미 캐시로 올라와 있었던 값을 찾습니다. 만약 ka에 저장된값이 a였다면 아스코드론 97, 따라서 al에 저장된 값이 97이었을 겁니다. 그렇다면 al1도 97로 복제되었을 것이고, 조회된 배열의 인덱스 역시 ar_t[97]이었고, 가장 빠르게(히트된) 조회된 인덱스 역시 97로 나오게 됩니다. 그렇다면 공격자는 ka에 저장된 값이 97, a 임을 알 수 있게 됩니다.