초기 컴퓨터 시스템의 운영체제는 사용자에게 그다지 많은 기능을 제공하지 않았다. 운영체제는 단지 물리주소 0번지부터 메모리에 위치했다.
하나의 프로세스가 물리 메모리의 특정 영역을 독점적으로 사용하고, 나머지 공간은 다른 용도로 사용했다. 초기 시스템에서는 특별한 가상화 기술이 거의 없었다.
멀티프로그래밍과 시분할
CPU는 실제로 하나지만 , 여러 프로세스가 마치 자신만의 CPU를 가진 것처럼 느끼게 하는 것이 목표다. 이를 통해 다수의 프로세스가 동시에 실행되는 것 같은 환상을 만들어 냈다.
시분할 시스템은 컴퓨터 가격이 높았던 시절 더 많은 사람이 동시에 사용할 수 있게 하고 자원 활용도를 극대화할 방법으로 개발되었다. 이는 여러 사용자가 컴퓨터를 동시에 쓸 수 있게 함으로써, 기존의 일괄 처리 방식에 비해 결과를 더 빨리 받아볼 수 있게 했다.
시분할을 구현하는 한 가지 방법은 프로세스를 아주 짧은 시간 동안만 실행시키되, 그 순간에는 메모리 전체에 대한 접근 권한을 부여하는 것이었다. 그 후 프로세스를 중단하고 그때의 모든 상태를 디스크 같은 곳에 저장한 뒤, 다른 프로세스의 정보를 불러와 또 잠깐 실행하는 식이었다.
그러나 이러한 방식의 문제가 있었는데, 레지스터 값을 저장하고 복원하는 것은 빨랐지만, 메모리 전체를 디스크에 옮기는 것은 너무 느렸다. 이러한 문제를 해결하기 위해, 프로세스를 전환할 때에도 메모리에 그대로 둔 채로 시분할을 구현하는 것이 메모리 가상화의 주된 목표가 되었다.
그림을 보면 프로세스 A, B, C 세 개가 있다. 512KB짜리 물리 메모리에서 각자의 영역을 할당받은 것이다. A는 현재 실행 중이고, B와 C는 실행 대기 중이다. 중요한 건 프로세스들이 메모리를 공유하지 않고 각자의 공간을 가진다는 점이다. 이는 프로세스 간 메모리 침범을 방지하기 위함이다.
시분할 시스템에서는 여러 프로세스가 동시에 돌아가므로, 이런 메모리 보호가 매우 중요하다. 각 프로세스가 서로의 데이터나 코드를 함부로 건드리지 못하게 막아야 하는 것이다. 따라서 운영체제는 프로세스별로 할당된 메모리 영역을 엄격히 관리하고 지켜줘야 한다. 그래야 시분할 시스템이 안전하고 안정적으로 작동할 수 있다.
주소 공간
주소 공간(address space)는 실행 중인 프로그램이 메모리가 어떻게 구성되어 있다고 가정하는지를 나타낸다. 운영체제의 메모리 가상화 방식을 이해하는 데 있어 핵심적인 개념이다. 주소 공간은 프로그램이 사용하는 모든 메모리 영역을 포함하며, 운영체제는 이를 효율적이고 안전하게 관리할 책임이 있다. 따라서 주소 공간에 대한 이해는 운영체제가 메모리를 어떻게 다루는지 파악하는 데 있어 필수다.
주소 공간에는 코드, 스택, 힙 등 프로그램을 실행하는 데 필요한 모든 상태 정보가 담겨 있다.
스택(Stack) 영역:
- 함수 호출과 관련된 지역 변수, 매개변수 등이 저장되는 공간.
- 함수가 호출될 때 할당되고, 함수가 종료되면 해제된다.
- 메모리의 높은 주소에서 낮은 주소 방향으로 할당된다.
- 재귀 호출이 너무 깊어지거나 지역 변수가 너무 많으면 스택 오버플로우 오류가 발생할 수 있다.
힙(Heap) 영역:
- 프로그램 실행 중에 크기가 결정되는 동적 메모리 영역.
- 사용자가 직접 공간을 할당하고 해제할 수 있다.
- 주로 참조형 데이터(예: 객체)가 저장된다.
- 메모리의 낮은 주소에서 높은 주소 방향으로 할당된다.
데이터(Data) 영역:
- 전역 변수나 정적(static) 변수 등 프로그램에서 사용하는 데이터가 저장되는 영역.
- 전역/정적 변수를 참조하는 코드가 있다면, 컴파일 후 이 영역을 참조하게 된다.
- 프로그램이 시작할 때 할당되고, 종료되면 해제된다.
- 초기화되지 않은 변수는 그림에는 없지만 BSS 영역에 따로 저장된다.
BSS (Block Started by Symbol) 영역
- 초기화되지 않은 전역 변수와 정적 변수가 저장되는 메모리 영역이다. BSS 영역의 변수들은 프로그램 시작 시 자동으로 0이나 NULL로 초기화된다.
텍스트(Text) 또는 코드(Code) 영역:
- CPU가 실행할 수 있는 기계어 코드가 저장된 영역.
- 프로그램 코드는 변경되면 안 되므로 읽기 전용(read-only)으로 보호된다.
코드는 정적이므로 실행 중에 추가 메모리를 요구하지 않는다. 따라서 메모리에 쉽게 적재할 수 있어 주로 주소 공간의 상단에 위치한다.
그 아래로는 프로그램 실행에 따라 크기가 변할 수 있는 힙과 스택이 자리한다. 힙은 위쪽에, 스택은 아래쪽에 배치되는데, 서로 반대 방향으로 확장될 수 있어야 하기 때문이다.
데이터 영역이 0~16KB 주소를 사용하는 것처럼 보이지만, 실제 물리 메모리에서 해당 주소를 쓰는 건 아니다. 메모리 가상화 기법을 통해 임의의 물리 주소에 맵핑되기 때문이다.
이처럼 운영체제가 가상 주소와 물리 주소의 매핑을 관리하는 것을 메모리 가상화라고 부른다.
가상 메모리 시스템의 목표
가상 메모리의 세가지 주요 목표는 다음과 같다.
투명성(Transparency)
- 메모리 가상화의 핵심 목표 중 하나는 사용자와 응용 프로그램이 물리 메모리의 복잡성과 한계를 직접 다룰 필요가 없게 하는 것이다.
- 각 프로세스는 마치 자신이 시스템의 모든 메모리를 독점하고 있는 것처럼 동작할 수 있어야 한다.
- 운영체제는 이를 위해 각 프로세스에게 독립적인 가상 주소 공간을 제공한다. 프로세스는 이 가상공간 내에서 자유롭게 메모리를 사용할 수 있다.
효율성(Efficiency)
- 메모리 가상화는 시간적으로나 공간적으로나 효율적으로 구현되어야 한다.
- 운영체제가 가상 주소를 실제 물리 주소로 변환하는 과정에서 발생하는 오버헤드를 최소화해야 한다.
- 이를 위해 페이지 테이블(page table)이나 TLB(Translation Lookaside Buffer)와 같은 하드웨어 지원 메커니즘이 필수적이다.
TLB(Translation Lookaside Buffer)
- TLB는 가장 최근에 사용된 가상-물리 주소 매핑 정보를 캐시로 저장하는 하드웨어 장치로, 주소 변환이 필요할 때 페이지 테이블을 탐색하기 전에 먼저 TLB를 확인함으로써, 주소 변환 속도를 크게 향상시킬 수 있다.
보호(Protection)
- 메모리 가상화는 각 프로세스를 다른 프로세스와 운영체제로부터 보호하는 데 있어 필수적인 역할을 한다.
- 프로세스는 오직 자신의 가상 주소 공간 내에서만 메모리에 접근할 수 있어야 하며, 다른 프로세스의 메모리 영역에는 절대 접근할 수 없어야 한다.
- 이는 각 메모리 영역에 대한 접근 권한을 철저히 관리함으로써 달성할 수 있다.
- 가상 메모리 시스템은 각 페이지나 세그먼트 별로 읽기(read), 쓰기(write), 실행(execute) 권한을 설정할 수 있다. 이를 통해 잘못된 메모리 접근이나 악의적인 행위를 차단할 수 있다.
- 이런 보호 기능을 활용하면 프로세스를 서로 완벽히 격리(isolation)시킬 수 있다. 한 프로세스의 오류가 다른 프로세스나 시스템 전체에 영향을 미치지 않도록 하는 것이다.
이번에는 Unix 시스템에서 메모리를 관리하기 위한 API에 대해 알아보겠다.
메모리 공간의 종류
C 프로그램이 실행될 때에는 크게 두가지 유형의 메모리 공간이 할당된다.
- 스택 메모리 : 스택 메모리의 할당과 해제는 프로그래머를 대신하여 컴파일러가 자동으로 처리해 준다.
- 힙 메모리 : 힙은 함수 호출이 끝난 후에도 유지되어야 하는 데이터를 저장하는 데 사용된다. 힙 메모리의 할당과 해제는 프로그래머가 직접 관리해야 한다.
스택 메모리 예시
void func() {
int x; // 스택에 정수형 변수 선언
// ...
}
힙 메모리 예시
void func() {
int *x = (int*) malloc(sizeof(int));
// ...
}
malloc이 반환하는 것은 새로 할당된 공간의 주소이다. 성공 시에는 해당 주소를, 실패 시에는 NULL을 돌려준다.
malloc() 함수
malloc()은 힙에서 메모리를 동적으로 할당하는 함수다. 사용법은 매우 간단하다, 필요한 메모리의 크기(바이트 단위)를 인자로 전달하면 된다. 할당에 성공하면 새로 할당된 메모리 블록의 시작 주소를 가리키는 포인터를 반환하고, 실패하면 NULL을 반환한다.
void* malloc(size_t size);
여기서 size는 할당받고자 하는 메모리의 크기를 바이트 단위로 나타낸 값이다. 이 크기를 정확히 지정하기 위해 보통 sizeof() 연산자를 사용한다.
C에서 sizeof()는 컴파일 시점에 평가되는 연산자로, 인자로 전달된 데이터 타입이나 변수의 크기를 바이트 단위로 반환한다. 따라서 sizeof()는 함수 호출이 아니라 연산자로 취급된다.
예를 들어 malloc(sizeof(double))과 같이 호출하면, sizeof(double)은 컴파일 시점에 상수 값 8(64비트 시스템 기준)으로 대체되어 malloc()에 전달된다.
그런데 sizeof()를 변수에 적용할 때는 주의해야 한다. 다음 코드를 보겠다.
int *x = malloc(10 * sizeof(int));
printf("%d\n", sizeof(x));
첫 번째 줄에서는 정수 10개를 저장할 수 있는 배열을 위한 메모리를 할당했다. 그런데 두 번째 줄에서 sizeof(x)를 출력해 보면, 4(32비트 시스템)나 8(64비트 시스템)이 나온다.
왜 그럴까? 여기서 sizeof()는 x가 가리키는 메모리 블록의 크기가 아니라, 포인터 변수 x 자체의 크기를 반환하기 때문이다.
반면 다음과 같이 정적 배열에 sizeof()를 적용하면 기대한 대로 동작합니다.
int x[10];
printf("%d\n", sizeof(x)); // 40 출력 (32비트 시스템 기준)
문자열을 다룰 때도 조심해야 한다. 문자열을 저장할 공간을 동적 할당할 때는 보통 malloc(strlen(s) + 1)과 같은 코드를 사용한다. strlen()으로 문자열의 길이를 구한 뒤, 마지막 NULL 문자를 저장할 공간까지 확보하는 것이다.
한 가지 더 눈여겨볼 점은 malloc()의 반환 타입이 void*라는 것이다. 이는 C 언어의 특징을 잘 보여주는데, malloc()은 그저 메모리 블록의 주소만 반환할 뿐, 그 공간을 어떤 타입의 데이터를 저장하는 데 사용할지는 전적으로 프로그래머에게 맡기는 것이다.
따라서 malloc()이 반환한 void* 값은 적절한 타입의 포인터로 캐스팅해서 사용해야 한다. 앞선 예제에서 (int*)로 캐스팅한 것처럼 말이다.
free() 함수
메모리 할당보다 어려운 문제는 할당된 메모리를 언제, 어떻게 해제할 것인가 하는 점이다.
힙에서 동적 할당한 메모리가 더 이상 필요 없어졌을 때, 이를 시스템에 반환하는 작업은 전적으로 프로그래머의 책임이다. 이를 위해 free() 함수를 사용한다.
int *x = malloc(10 * sizeof(int));
// ...
free(x);
free()는 인자로 malloc()이 반환한 포인터 값 하나만 받는다. 해제할 메모리 블록의 크기는 전달하지 않는데, 메모리 할당 라이브러리가 내부적으로 관리하고 있기 때문이다.
메모리 누수(memory leak)를 방지하려면 불필요해진 메모리를 빠짐없이 free()로 해제해야 한다.
반대로 이미 해제된 메모리를 또 다시 해제하려 하면 언디파인드 비헤이비어(undefined behavior)를 초래할 수 있으니, 이 점도 주의해야 한다.
운영체제의 지원
C 표준 라이브러리에서 제공하는 malloc()과 free() 함수는 각각 메모리 할당과 해제를 담당한다. 이들은 시스템 콜이 아닌 라이브러리 함수로, 프로세스의 가상 주소 공간 내에서 힙 메모리를 관리하는 역할을 한다.
하지만 라이브러리 자체는 운영체제에게 메모리를 요청하고 반환하는 시스템 콜을 기반으로 동작한다. 대표적인 예가 brk 시스템 콜인데, 이는 프로그램의 ‘브레이크(break)’ 위치를 조정하여 힙의 크기를 늘리거나 줄이는 기능을 한다.
여기서 브레이크란 힙의 끝을 가리키는 포인터를 말한다. brk 시스템 콜은 새로운 브레이크 주소값을 인자로 받아, 그에 맞춰 힙 영역을 조정하는 것이다. 비슷한 역할을 하는 sbrk 함수도 있는데, 이는 브레이크 위치를 얼마나 옮길지 그 증감량을 인자로 받는다.
그런데 중요한 점은 프로그래머가 직접 brk나 sbrk를 호출해서는 안 된다는 것이다. 이들은 malloc()이나 free() 같은 메모리 할당 라이브러리 내부에서만 사용되어야 한다. 직접 호출할 경우 예측 불가능한 문제가 발생할 수 있으므로, 반드시 표준 라이브러리 함수를 통해 메모리를 다뤄야 한다.
또 다른 방법으로는 mmap() 함수를 사용하여 운영체제로부터 메모리를 받아올 수도 있다. 적절한 인자를 전달하면 mmap()은 파일과 연결되지 않은 익명(anonymous) 메모리 영역을 할당해 주는데, 이 공간은 힙과 유사하게 다룰 수 있다. 특히 대용량 메모리가 필요할 때 유용하게 활용할 수 있는 방법이다.
malloc(size) 호출 라이브러리 내부 장부에서 남는 블록이 있나 확인 → 있으면 바로 반환 없으면 OS에 추가 요청 작은/중간 크기: brk/sbrk로 힙을 늘림 큰 크기: mmap으로 별도 구역을 받음 free(p) → 라이브러리가 장부에 “빈 블록”으로 표시 아주 큰 블록이면 munmap으로 OS에 바로 돌려주기도 함
기타 함수들
메모리 관리와 관련하여 알아두면 좋은 추가 함수들이 몇 가지 더 있다.
- calloc()
- malloc()과 비슷하지만, 할당된 메모리 블록을 모두 0으로 초기화한 뒤 반환한다.
- 초기화를 빠뜨리는 실수를 방지하고자 할 때 유용하다.
- 특히 동적 할당한 배열을 0으로 초기화할 때 많이 쓰이며, ‘초기화되지 않은 읽기’와 같은 미묘한 버그를 예방하는 데 도움이 된다.
- realloc()
- 이미 malloc()이나 calloc()으로 할당한 메모리 블록의 크기를 조정할 때 사용한다.
- 동적 할당한 배열의 크기를 늘리거나 줄일 때 유용하다.
- realloc()을 호출하면 기존 블록보다 큰 새 메모리 블록을 할당하고, 이전 블록의 내용을 모두 복사해 온다. 그리고 새 블록의 주소를 반환한.
- 이를 통해 메모리를 재사용하면서도 블록 크기를 유연하게 변경할 수 있다.
'Deep Dive > OS' 카테고리의 다른 글
[OSTEP] 스터디 4주차 - 스케줄링 2 : Part.2 - 비례 배분 (0) | 2025.09.23 |
---|---|
[OSTEP] 스터디 4주차 - 스케줄링 2 : Part.1 - MLFQ (0) | 2025.09.22 |
[OSTEP] 스터디 3주차 - 스케줄링 1 (0) | 2025.09.15 |
[OSTEP] 스터디 2주차 - 가상화의 세계 - 숙제 (0) | 2025.09.09 |
[OSTEP] 스터디 2주차 - 가상화의 세계 part.3 (0) | 2025.09.09 |