초창기 컴퓨터는 운영체제(OS)가 물리 메모리의 낮은 주소에 상주하고, 하나의 프로그램만이 나머지 메모리를 통째로 쓰는 구조였다. 사용자 기대도 낮았고, 보호나 편의성 같은 추상화는 거의 없었다. 이후 고가의 컴퓨터를 여러 사람이 효율적으로 쓰기 위해 다중 프로그래밍과 시분할(time sharing)이 등장했고, 여러 프로세스를 메모리에 동시에 올려두고 번갈아 실행하게 되었다. 이때 서로의 메모리를 침범하지 않도록 보호가 필수가 되었고, 메모리 전체를 디스크로 저장/복구하는 방식은 너무 느리므로(특히 메모리가 클수록) 메모리에 올려둔 채 문맥 전환을 해야 했다.
이 역사적 배경이 곧 가상 메모리의 필요성을 설명한다. VM은 각 프로세스에 “나만의 큰 메모리”라는 환상(illusion)을 제공하며, 실제 물리 메모리의 복잡한 공유를 OS와 하드웨어가 뒤에서 처리한다.
주소 공간(Address Space)이라는 추상화
주소 공간은 “실행 중인 프로그램이 바라보는 메모리의 논리적 뷰”다. 일반적으로 다음 세 구역으로 설명한다: 코드(code) · 힙(heap) · 스택(stack). 코드는 정적인 기계어 영역, 힙은 malloc/new로 동적으로 커지는 데이터, 스택은 함수 호출/지역 변수/인자·반환값을 위한 프레임들이다. 힙과 스택은 서로 반대 방향으로 성장하도록 배치하는 것이 관례다(힙은 한쪽으로, 스택은 반대쪽으로).
중요한 점: 이 배치는 추상화일 뿐, 실제 물리 메모리의 배치와는 다르다. 예를 들어 프로세스 A가 가상 주소 0을 읽으려 할 때, OS와 하드웨어는 이를 A가 실제로 적재된 물리 주소(예: 320KB)로 변환해야 한다. 이것이 곧 주소 변환이며, 가상 메모리의 핵심이다.
주소 공간의 전형적인 구성(개념도)
+------------------------------+ 높은 가상 주소
| Stack … ↓ |
| |
| (free) |
| |
| ↑ … Heap |
+------------------------------+
| Code |
+------------------------------+ 낮은 가상 주소
가상 메모리의 3대 목표: 투명성 · 효율 · 보호
투명성(Transparency): 애플리케이션 입장에서는 마치 “개인 전용 물리 메모리”를 쓰는 것처럼 보여야 한다. 즉, VM의 동작은 보이지 않아야 한다.
효율(Efficiency): 시간(성능 저하 최소화)과 공간(부가 메타데이터 크기 최소화) 모두에서 효율적이어야 한다. 이를 위해 하드웨어의 TLB 같은 가속 장치에 크게 의존한다.
보호(Protection): 한 프로세스는 다른 프로세스(또는 OS)의 메모리를 읽거나 쓰면 안 된다. 이 격리(isolation)가 시스템 신뢰성을 높인다(마이크로커널은 OS 내부조차 더 잘 격리한다는 논의도 있음).
사이드노트: “프로그래머가 보는 모든 주소는 가상 주소다”
C에서 포인터를 %p로 출력해 보면 0x… 형태의 큰 값이 나온다. 이건 가상 주소다. 코드 주소도, 힙에서 malloc()로 받은 포인터도, 스택 변수의 주소도 모두 그렇다. 실제 물리 메모리의 위치는 OS와 하드웨어만 알고 있다.
예시 코드 열기: va.c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
printf("location of code : %p\n", main);
printf("location of heap : %p\n", malloc(100e6));
int x = 3;
printf("location of stack: %p\n", &x);
return 0;
}
64-bit 환경에서 출력해 보면 코드 → 힙 → 스택 순으로 가상 공간이 배치된 모습을 확인할 수 있다(정확한 값은 OS/빌드/보안설정에 따라 달라짐).
VM은 실제로 어떻게 동작할까? (큰 그림)
프로세스는 가상 주소로 메모리를 접근한다.
하드웨어(MMU)가 페이지 테이블·TLB 등을 이용해 이를 물리 주소로 변환한다.
OS는 페이지 테이블을 관리하며, 보호 비트(읽기/쓰기/실행)와 매핑 유효성 등을 통해 보호와 격리를 구현한다.
메모리가 부족하면, OS는 정책에 따라 덜 쓰는 페이지를 디스크로 내보내고(스왑/페이지 아웃), 필요시 다시 불러온다(페이지 인).
이후 장(chapter)에서 메커니즘(페이지 테이블, TLB)과 정책(교체 알고리즘, 메모리 할당)을 차례로 배우게 된다.
한 장 요약
가상 메모리는 각 프로세스에 큰·희소·사적인 주소 공간이라는 환상을 제공한다. 프로그램은 가상 주소로 접근하고, OS와 하드웨어가 이를 물리 주소로 변환하여 보호와 격리를 유지한다. 목표는 투명성, 효율, 보호이며, 이는 현대 OS의 근간이다.
운영체제 인터루드: Memory API 제대로 이해하기
왜 이걸 배워야 할까?
C/UNIX 환경에서 메모리 할당과 관리는 견고한 소프트웨어의 기본기다. 스택과 힙의 차이, malloc()/free() 사용법, 그리고 흔한 버그 패턴을 모르면, 프로그램은 “겉으로는 돌아가도” 언제든 터질 수 있다.
메모리 유형: 스택 vs 힙
스택(stack): 함수가 호출될 때 컴파일러가 자동으로 공간을 만들고, 리턴하면 자동 해제된다. 지역 변수/매개변수/리턴 주소 등이 여기에 올라간다. (수명: 짧다)
힙(heap): 프로그래머가 malloc() 등으로 명시적으로 할당/해제한다. 유연하지만 책임도 크다. (수명: 원하는 만큼)
// 스택 예시
void func(void) {
int x; // 스택에 자동 할당, func() 종료 시 자동 해제
}
// 힙 예시
void func(void) {
int *x = (int*)malloc(sizeof(int)); // 힙에 명시적 할당
/* ... 사용 ... */
free(x); // 직접 해제 필수
}
malloc(): 기본과 관례
시그니처는 void* malloc(size_t size). 성공 시 새로 할당된 영역의 시작 주소를, 실패 시 NULL을 반환한다. 일반적으로 sizeof(타입) 또는 sizeof(*포인터)를 써서 크기를 계산한다. 문자열은 널 종료 문자를 고려해 strlen(s)+1을 잊지 말자. (C에서는 malloc()의 반환값 캐스팅이 필수는 아니지만, 프로젝트 컨벤션에 맞춘다.)
free()는 반드시 과거 malloc()이 반환한 정확한 포인터만 인자로 받아야 한다. 크기는 인자로 넘기지 않으며, 할당 라이브러리가 내부적으로 관리한다. 해제 시점/소유권을 분명히 하지 않으면, 메모리 누수 또는 dangling pointer로 이어진다.
int *a = malloc(10 * sizeof(*a));
/* ... */
free(a); // OK
// free(a); // ❌ double free (정의되지 않은 동작)
자주 하는 실수(필독 체크리스트)
할당하지 않고 사용: strcpy(dst, src) 호출 전에 dst가 가리키는 공간을 꼭 마련할 것.
부족하게 할당 (버퍼 오버플로): 문자열에서 +1 누락, 구조체/배열 크기 착각. 보안 취약점의 단골.
초기화 누락: malloc()은 쓰레기 값을 준다. 필요 시 calloc() 또는 명시적 초기화.
메모리 누수: 장수 서버/서비스에서 치명적. 종료 직전에 OS가 회수하긴 하지만, 습관 들이면 큰일 난다.
dangling pointer: 아직 쓰는 중인데 먼저 free()함. 이후 접근은 use-after-free.
double free: 같은 블록을 두 번 해제. 결과는 미정(undefined).
잘못된 포인터로 free: malloc()가 준 정확한 시작 주소만 free()해야 함. 중간 주소 금지.
// 문자열 안전 패턴
char *dup = malloc(strlen(src) + 1); // +1!
strcpy(dup, src);
// 또는 strdup() (리눅스 확장)
char *dup2 = strdup(src); // 내부에서 길이 계산+복사
OS 레벨 지원: brk/sbrk, mmap() (직접 쓰지 말 것)
malloc()/free()는 라이브러리 함수다. 내부적으로 커널에 메모리를 더 달라거나 돌려줄 때 brk/sbrk(힙 브레이크 이동)나 mmap()(익명 매핑)을 쓴다. 그러나 애플리케이션 코드에서 brk/sbrk를 직접 호출하면 망가뜨리기 쉽다. 사용자 레벨에서는 malloc()/free()에만 의존하자.
그 밖의 API
calloc(n, sz): 0으로 초기화된 n×sz 바이트를 할당. 초기화 가정 버그를 줄여준다.
realloc(ptr, newsz): 크기 재조정. 내부적으로 새 블록 할당→복사→기존 해제가 될 수 있으니, tmp = realloc(p, n); if (tmp) p = tmp; 패턴을 추천.
strdup(): 문자열 복제(널 포함 길이 계산+복사). 편리하지만 일부 환경에서 POSIX/비표준 여부 확인.
디버깅: 도구는 필수 스킬
gdb: 심볼(-g)을 포함해 빌드하고, 크래시 지점을 역추적한다.
valgrind (memcheck): 누수/이중해제/초과 접근/미초기화 읽기 등을 정적분석 수준으로 찾아준다. 실습 과제도 valgrind로 잡아보는 흐름을 권장한다.