상세 컨텐츠

본문 제목

가상메모리 | (2) 가상메모리 개념, 가상 주소공간

본문

 

가상메모리 정의

가상메모리: 메인 메모리의 추상화. 각 프로세스에 하나의 크고 통합된, private한 가상 주소공간을 제공한다. 

 

 

도입 배경

초기의 cpu들은 물리 주소 방식을 사용했다. 따라서 프로세스가 메인 메모리에 직접 접근할 수 있었고, 이는 메인 메모리의 용량 한계로 인한 프로그램 확장성 제한, 프로세스 간 메모리 영역 침범, 일관되지 않은 주소 등의 문제로 이어졌다.

 

일반적으로 한 시스템의 여러 프로세스들은 CPU, 메인 메모리를 공유한다.
CPU를 공유하는 부분에 대해서는 일반적으로 순서를 기다리느라 단지 느려질 뿐이고 심각한 오류는 발생하지 않는다.
그러나 프로세스들이 존재하는 메모리가 여유가 없이 지나치게 많은 요구에 의해 오염될 경우, 프로그램의 논리와 무관하게 오류가 난다. 이를 방지하기 위한 기술이 바로 가상메모리이다.

 

 

따라서 현대의 프로세스는 가상주소방식을 사용한다 (1960년대 본격적으로 도입). 이는 곧 가상메모리 라는 개념을 사용한다는 말과 같으며, 가상메모리는 메인메모리의 추상화이다. 

 

 

 

가상 메모리의 세 가지 중요한 기능

  1. 실제 메인 메모리보다 더 큰 가상의 메모리를 운용하도록 해준다.
    • 이는 메인 메모리를 보조기억장치의 캐시로 취급하고, 필요한 경우 대상 페이지를 스왑함으로써 달성된다. 
      • 현재 사용하지 않는 페이지들을 보조기억장치로 옮김으로써, 메인 메모리를 더 중요한 작업에 할당할 수 있게 한다. 이는 시스템의 전체적인 성능을 향상시키며, 실시간으로 메모리를 관리하는 데 도움을 준다.
    • 또한, 같은 메모리라도 더 효율적으로 관리할 수 있게 되었다. 
      • 가상 메모리는 작은 메모리 chunk를 ‘페이지’라는 단위(4kb~2mb)로 관리함으로써, 메모리 공간을 더욱 효율적으로 할당하고 재사용할 수 있게 한다. 이는 프로세스가 요구하는 메모리의 정확한 양을 할당받을 수 있도록 하여, 메모리의 낭비를 줄인다.
    • 물리 메모리의 크기에 구애받지 않고 프로세스를 실행할 수 있기 때문에, 시스템은 더 많은 프로그램과 더 큰 프로그램을 운영할 수 있는 확장성을 갖게 된다.
  2. 각 프로세스에 통일된 주소공간을 제공함으로써 메모리 관리를 단순화한다.
    • 가상 메모리는 프로그램이 물리 메모리의 실제 구조와 독립적으로 실행될 수 있도록 한다. 이는 프로그램이 어느 메모리 위치에 로드되어도 동일하게 실행될 수 있음을 의미하며, 프로그래머가 메모리 관리에 대해 신경 쓸 필요가 없게 한다.
  3. 각 프로세스의 주소공간을 다른 프로세스가 침범하지 않도록 보호한다. 
    • 프로세스는 각각 독립된 가상 메모리 공간을 가지므로, 한 프로세스의 메모리 접근이 다른 프로세스에 영향을 미치지 않는다. 이것은 메모리 보호 메커니즘을 통해 실현되며, 프로세스 간 메모리 충돌과 오류를 방지한다.

 

앞선 포스팅에서 살펴본 바 있지만, 가상주소방식을 사용하기 위해 CPU 칩 내부에는 MMU 라는 하드웨어(가상주소를 물리주소로 번역하는 장치)가 장착되기 시작했다. 또한 TLB 라는 장치가 추가되었으며, 메인메모리 안에는 PTE 영역이 포함되게 되었다. 스왑 개념이 등장함에 따라 보조기억장치 안에는 스왑 영역이 생겨나게 되었다.

 

왜 이렇게 복잡한 과정을 거쳤을까? 가상 메모리 자체가 캐싱 아이디어에서 비롯된 개념이기 때문이다.

  • 메인 메모리는 보조기억장치의 캐시, PTE는 필요한 경우 L1 캐시에 캐시된다. 또한 이 비용을 더 줄이기 위해 많은 시스템들은 TLB라는 작은 캐시를 MMU 내에 내장한다. TLB는 PTE의 캐시이다. 

결국 위 장치들은 효과적인 캐싱을 가능케하는 장치들이다. 

 

캐시 미스가 매우 빈번하지만 않는다면 캐시는 데이터 이동의 효율을 극적으로 개선한다. 

이를 위해 미래에 쓸 것들을 잘 예측해서 가까이에 두는 게 중요한데, 그 예측 과정에 있어서 지역성이라는 속성이 매우 큰 역할을 한다.

 

지역성 (Locality)

지역성은 프로그램이 갖는 여러 속성 중 하나이다. 지역성의 종류는 크게 두 가지가 있다: 1) 시간 지역성, 2) 공간 지역성

 

시간 지역성

  • 좋은 시간 지역성을 갖는 프로그램에서는 한번 참조된 메모리 위치는 가까운 미래에 다시 여러 번 참조될 가능성이 높다.

공간 지역성

  • 좋은 공간 지역성을 갖는 프로그램에서는 만일 어떤 메모리 위치가 일단 참조되면, 이 프로그램은 가까운 미래에 근처 메모리 위치를 참조할 가능성이 높다.

 

좋은 지역성을 갖는 프로그램이 나쁜 지역성을 갖는 프로그램보다 더 빨리 동작한다. 왜냐하면, 위 가상 메모리만 봐도 그렇고 그 외에도 수많은 현대의 하드웨어, 운영체제, 응용프로그램은 지역성을 활용하도록 설계되었기 때문이다. (즉, 좋은 개발자라면 좋은 지역성을 갖도록 프로그램을 짤 수 있어야 한다.)

 

 

+) 프로그램 내에서 지역성을 정성적으로 평가하는 간단한 일부 규칙

  1. 동일한 변수들을 반복적으로 참조하는 프로그램은 좋은 시간 지역성을 누린다.
  2. 루프는 명령어 읽기에 대해 좋은 시간 및 공간 지역성을 지닌다. 루프 본체가 작으면 작을 수록, 루프 반복 실행의 수는 더 커지고 지역성도 더 좋다.
  3. Stride-k 참조 패턴(연속적인 벡터의 매 k번째 원소를 방문하는 것)을 갖는 프로그램에 대해서, stride가 적으면 적을수록 공간 지역성도 좋아진다.
    • 예를 들어, Stride-1 참조 패턴을 갖는 프로그램들은 좋은 공간 지역성을 가진다.
      → 쉽게 말하면 메모리 공간 이곳저곳 뛰어다니는 놈은 나쁜 공간 지역성을 지닌 것
# 낮은 Stride
for i in range(N):
	for j in range(N):
		print(a[i][j])

# 높은 Stride
for i in range(N):
	for j in range(N):
		print(a[j][i])

 

 

 

가상 주소 공간 (Virtual Address Space, VAS)

다시 가상 메모리로 돌아와서, 각 프로세스는 가상 메모리를 부여받는다고 했는데 프로세스 입장에서 바라본 가상 메모리는 어떤 모습일까?

이를 가상 주소를 갖고 있는 가상 주소 공간(Virtual Address Space, VAS)이라고 표현한다.

가상 주소공간의 구조는 앞선 포스팅(동적 메모리 할당 | (1) 기본 개념, 메모리 누수, 단편화)에서 잠깐 본 바 있는데, 잠시 복습해보자. 

 

가상 주소공간 구조

프로세스에 할당된 가상 메모리는 크게 네 부분으로 나눌 수 있다. 다른 프로그래밍 언어와 실행 환경도 비슷한 구조를 사용한다. 

  • 코드 영역 (Code Segment): 코드 세그먼트(Code Segment)는 프로그램의 기계어 코드, 즉 실행 가능한 바이너리 코드가 저장되는 메모리 영역이다. 이 영역에는 프로그램이 실행되는 동안 CPU에 의해 직접 실행되는 명령어들이 저장된다.
  • 데이터 영역 (Data Segment): 전역 변수, 정적 변수 등이 저장되며, 프로그램의 런타임동안 유지된다. 데이터 영역은 초기화된 데이터(전역 변수 등)를 위한 영역과 초기화되지 않은 데이터(BSS, Block Started by Symbol)를 위한 영역으로 나뉜다.
  • 스택 영역 (Stack Segment): 함수의 호출과 함께, 해당 함수와 관련된 매개변수, 리턴 주소, 로컬 변수들이 저장되는 영역. 
  • 힙 영역 (Heap Segment): 프로그램이 실행 중에 필요한 양만큼 동적으로 메모리를 할당하고 해제하는 영역. 

 

간단한 C 코드를 통해 각 구성요소가 어디에 저장되는지 확인해보면 더 이해가 빠를 것 같다. 

#include <stdio.h>
#include <stdlib.h>

int initialized_global_var = 5; // 데이터 세그먼트
static int static_initialized_var = 10; // 데이터 세그먼트

int uninitialized_global_var; // BSS 세그먼트
static int static_uninitialized_var; // BSS 세그먼트

void my_function(int function_param) {
    static int static_var_in_function = 3; // 데이터 세그먼트
    static int static_uninitialized_var_in_function; // BSS 세그먼트
    int local_var = 2; // 스택
    int *heap_var = malloc(sizeof(int)); // 힙 (포인터는 스택에 저장됨)
    *heap_var = 4; // 힙에 저장된 값
    printf("Local variable = %d\n", local_var);
    free(heap_var); // 할당된 힙 메모리를 해제
}

int main() {
    int local_var_in_main = 1; // 스택
    my_function(local_var_in_main);
    return 0;
}

 

 

여기서, main() 함수와 my_function() 함수의 정의, 그리고 이 함수들 내부의 명령어들(예: printf(), malloc(), free() 호출, 변수 할당 등)은 코드 영역에 저장된다.

 

CPU는 코드 세그먼트의 기계어 코드를 읽고, 그때그때 필요한 데이터를 스택, 힙, 데이터 세그먼트에서 가져오는 식으로 프로그램이 동작한다.

 

 


가상 메모리 공간이 할당된 이후

보조기억장치에 있던 실행파일(컴파일 과정을 거친 바이너리 파일. Linux 계열에서는 ELF 형식이 표준)이 프로세스화 될 때의 대략적인 과정은 다음과 같다. 

 

1) 커널은 이와 같은 구성의 가상주소공간을 할당하고,

2) 보조기억장치의 ELF 파일에서 코드(.text)와 데이터(.data) 세그먼트의 내용을 파싱하고 메모리의 해당 영역으로 복사한다. 이 과정에서 파일의 바이너리 내용이 메모리에 로드된다. 동시에, 커널에 의해 코드 세그먼트는 실행 가능하도록 설정되며, 데이터 세그먼트는 읽기/쓰기 가능하도록 설정된다.

  • 이는 주로 보조기억장치 I/O 작업을 통해 수행된다. (그러나 커널에 의해 수행되기 때문에 read(), write() 등의 보다 추상화된 시스템콜 함수보다 훨씬 저수준으로 구현되는 듯 하다.)

 

3) 메모리(커널의 버퍼)에 로드된 코드들을 1에서 할당한 주소공간에 매핑(mapping)한다. 

 

 

 

 

 

관련글 더보기