메모리 관리 기초 ⭐⭐
메모리 관리 기초 ⭐⭐¶
개요¶
운영체제의 메모리 관리는 프로그램 실행에 필요한 메모리를 효율적으로 할당하고 관리하는 핵심 기능입니다. 이 장에서는 주소 바인딩, 논리/물리 주소 변환, 그리고 동적 로딩과 스와핑에 대해 학습합니다.
목차¶
1. 메모리 관리의 필요성¶
다중 프로그래밍 환경¶
┌─────────────────────────────────────────────────────────────┐
│ 물리 메모리 │
├─────────────────────────────────────────────────────────────┤
│ 운영체제 (커널) │
├─────────────────────────────────────────────────────────────┤
│ 프로세스 A │
├─────────────────────────────────────────────────────────────┤
│ 프로세스 B │
├─────────────────────────────────────────────────────────────┤
│ 프로세스 C │
├─────────────────────────────────────────────────────────────┤
│ 빈 공간 (Free) │
└─────────────────────────────────────────────────────────────┘
메모리 관리의 목표¶
| 목표 | 설명 |
|---|---|
| 보호 | 프로세스 간 메모리 영역 보호 |
| 재배치 | 프로세스를 메모리 어디든 배치 가능 |
| 공유 | 여러 프로세스가 공통 코드 공유 |
| 효율성 | 메모리 낭비 최소화 |
| 논리적 구성 | 모듈 단위로 프로그램 구성 |
2. 주소 바인딩¶
주소 바인딩은 프로그램의 명령어와 데이터를 메모리 주소에 연결하는 과정입니다.
바인딩 시점¶
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 소스 코드 │───▶│ 목적 코드 │───▶│ 실행 파일 │───▶│ 메모리 │
│ (주소 無) │ │ (재배치가능) │ │ (로드 가능) │ │ (물리주소) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
↑ ↑ ↑
컴파일 시간 로드 시간 실행 시간
바인딩 바인딩 바인딩
2.1 컴파일 시간 바인딩 (Compile-time Binding)¶
프로세스가 메모리의 어느 위치에 적재될지 컴파일 시점에 알 수 있는 경우:
// 절대 주소를 사용하는 예 (과거 MS-DOS)
// 프로그램이 항상 주소 0x1000에서 시작한다고 가정
#define BASE_ADDRESS 0x1000
int main() {
int* ptr = (int*)(BASE_ADDRESS + 0x100); // 절대 주소
*ptr = 42;
return 0;
}
특징: - 절대 코드(Absolute Code) 생성 - 위치가 변경되면 재컴파일 필요 - 임베디드 시스템에서 주로 사용
2.2 로드 시간 바인딩 (Load-time Binding)¶
프로세스가 어디에 적재될지 실행 전까지 모르는 경우:
┌─────────────────────────────────────────────────────────────┐
│ 재배치 가능 코드 │
├─────────────────────────────────────────────────────────────┤
│ LOAD R1, [0x100] ; 상대 주소 0x100 │
│ ADD R1, R2 │
│ STORE R1, [0x200] ; 상대 주소 0x200 │
└─────────────────────────────────────────────────────────────┘
│
▼ 로더가 기준 주소 0x5000 설정
┌─────────────────────────────────────────────────────────────┐
│ 메모리 적재 후 │
├─────────────────────────────────────────────────────────────┤
│ LOAD R1, [0x5100] ; 0x5000 + 0x100 │
│ ADD R1, R2 │
│ STORE R1, [0x5200] ; 0x5000 + 0x200 │
└─────────────────────────────────────────────────────────────┘
특징: - 재배치 가능 코드(Relocatable Code) 생성 - 로더가 모든 주소를 수정 - 로드 후에는 이동 불가
2.3 실행 시간 바인딩 (Execution-time Binding)¶
프로세스가 실행 중에도 메모리 위치를 변경할 수 있는 경우:
┌──────────────────┐ ┌──────────────────┐
│ CPU (논리주소) │ │ 물리 메모리 │
│ │ │ │
│ 주소: 0x100 │─────┐ │ │
└──────────────────┘ │ │ │
▼ │ │
┌──────────────┐ │ ┌────────────┐ │
│ MMU │ │ │ 0x5100 │◀┤
│ │ │ │ (실제위치) │ │
│ Base: 0x5000│────▶│ └────────────┘ │
│ │ │ │
│ 0x100+0x5000│ │ │
│ = 0x5100 │ │ │
└──────────────┘ └──────────────────┘
특징: - 하드웨어 지원 필요 (MMU) - 현대 운영체제의 표준 방식 - 프로세스 이동(스와핑) 가능
3. 논리 주소와 물리 주소¶
3.1 개념 비교¶
| 구분 | 논리 주소 | 물리 주소 |
|---|---|---|
| 별칭 | 가상 주소 (Virtual Address) | 실제 주소 (Real Address) |
| 생성 | CPU에서 생성 | 메모리 장치가 인식 |
| 범위 | 0 ~ 프로세스 크기 | 0 ~ 물리 메모리 크기 |
| 프로그래머 | 사용 | 인식 불필요 |
3.2 주소 공간¶
프로세스 A의 프로세스 B의 물리 메모리
논리 주소 공간 논리 주소 공간
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 0x0000 │ │ 0x0000 │ │ 0x0000 OS │
│ │ │ │ ├──────────────┤
│ 코드 │──────────┼──────────────┼─────────▶│ 0x1000 A코드 │
│ │ │ 코드 │──┐ ├──────────────┤
├──────────────┤ │ │ │ │ 0x2000 A데이터│
│ 데이터 │──────────┼──────────────┼──┼──────▶│ │
│ │ ├──────────────┤ │ ├──────────────┤
├──────────────┤ │ 데이터 │──┼──────▶│ 0x3000 B코드 │
│ 힙 │ │ │ │ ├──────────────┤
│ │ ├──────────────┤ │ │ 0x4000 B데이터│
├──────────────┤ │ 힙 │ │ ├──────────────┤
│ │ │ │ │ │ 0x5000 A힙 │
│ (빈 공간) │ ├──────────────┤ │ ├──────────────┤
│ │ │ │ │ │ 0x6000 B힙 │
├──────────────┤ │ │ │ ├──────────────┤
│ 스택 │ ├──────────────┤ │ │ │
│ 0xFFFF │ │ 스택 │ │ │ Free │
└──────────────┘ │ 0xFFFF │ │ │ │
└──────────────┘ │ └──────────────┘
│
MMU가 주소 변환
4. MMU (Memory Management Unit)¶
4.1 기본 구조¶
┌─────────────────────────────────────────────────────────────────┐
│ CPU │
│ ┌─────────────┐ │
│ │ 프로그램 │ │
│ │ 카운터 │──▶ 논리 주소: 0x1234 │
│ └─────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ MMU │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 논리 주소 재배치 레지스터 물리 주소 │ │
│ │ 0x1234 + 0x8000 = 0x9234 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 한계 레지스터: 0x4000 │ │
│ │ 논리 주소 0x1234 < 0x4000 ? ──▶ OK (접근 허용) │ │
│ │ 논리 주소 0x5000 < 0x4000 ? ──▶ TRAP! (보호 위반) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ 물리 메모리 │
│ │
│ 주소 0x9234 접근 │
└──────────────────────────────────────────────────────────────────┘
4.2 재배치 레지스터와 한계 레지스터¶
// MMU 동작의 의사 코드
typedef struct {
uint32_t relocation_register; // 재배치 레지스터 (기준 주소)
uint32_t limit_register; // 한계 레지스터 (프로세스 크기)
} MMU;
uint32_t translate_address(MMU* mmu, uint32_t logical_address) {
// 1. 한계 검사
if (logical_address >= mmu->limit_register) {
// 보호 위반! 트랩 발생
raise_trap(SEGMENTATION_FAULT);
return 0;
}
// 2. 주소 변환
uint32_t physical_address = logical_address + mmu->relocation_register;
return physical_address;
}
// 예시
MMU mmu = {
.relocation_register = 0x8000, // 프로세스가 0x8000에서 시작
.limit_register = 0x4000 // 프로세스 크기 16KB
};
// 논리 주소 0x1234 → 물리 주소 0x9234 (OK)
// 논리 주소 0x5000 → SEGMENTATION FAULT (한계 초과)
4.3 컨텍스트 스위칭과 MMU¶
┌───────────────────────────────────────────────────────────────────┐
│ 컨텍스트 스위칭 과정 │
├───────────────────────────────────────────────────────────────────┤
│ │
│ 프로세스 A 실행 중 프로세스 B로 전환 │
│ ┌──────────────┐ │
│ │ MMU 레지스터 │ 1. A의 상태 저장 │
│ │ Base: 0x8000 │ - 레지스터들 │
│ │ Limit: 0x4000│ - MMU 설정값 │
│ └──────────────┘ │
│ │ 2. B의 상태 복원 │
│ │ - 레지스터들 │
│ ▼ - MMU 설정값 │
│ ┌──────────────┐ │
│ │ MMU 레지스터 │ 3. 실행 재개 │
│ │ Base: 0x14000│ │
│ │ Limit: 0x6000│ │
│ └──────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
5. 동적 로딩¶
5.1 개념¶
프로그램의 모든 코드를 메모리에 로드하지 않고, 필요할 때만 로드하는 기법입니다.
┌─────────────────────────────────────────────────────────────────┐
│ 동적 로딩 과정 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 프로그램 시작: main() 만 로드 │
│ │
│ 메모리 디스크 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ main() │ │ func_A() │ │
│ │ │ │ func_B() │ │
│ │ │ │ func_C() │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ 2. func_A() 호출 시: 디스크에서 로드 │
│ │
│ 메모리 디스크 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ main() │ ◀── │ func_A() │ │
│ │ func_A() │ │ func_B() │ │
│ │ │ │ func_C() │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.2 구현 예제¶
#include <stdio.h>
#include <dlfcn.h> // 동적 로딩을 위한 헤더
// 함수 포인터 타입 정의
typedef int (*MathFunc)(int, int);
int main() {
void* handle;
MathFunc add_func;
char* error;
// 1. 라이브러리 동적 로드
handle = dlopen("./libmath.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "로드 실패: %s\n", dlerror());
return 1;
}
// 2. 함수 심볼 찾기
dlerror(); // 이전 에러 클리어
add_func = (MathFunc)dlsym(handle, "add");
error = dlerror();
if (error != NULL) {
fprintf(stderr, "심볼 찾기 실패: %s\n", error);
return 1;
}
// 3. 함수 호출
printf("결과: %d\n", add_func(10, 20)); // 출력: 결과: 30
// 4. 라이브러리 언로드
dlclose(handle);
return 0;
}
# 공유 라이브러리 생성
gcc -shared -fPIC -o libmath.so math.c
# 실행 파일 컴파일 (동적 로딩 라이브러리 링크)
gcc -o main main.c -ldl
# 실행
./main
5.3 장단점¶
| 장점 | 단점 |
|---|---|
| 메모리 사용량 감소 | 첫 호출 시 지연 발생 |
| 미사용 코드 로드 안 함 | 구현 복잡성 증가 |
| 운영체제 지원 불필요 | 오류 처리 필요 |
6. 동적 링킹¶
6.1 정적 링킹 vs 동적 링킹¶
┌─────────────────────────────────────────────────────────────────┐
│ 정적 링킹 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 프로그램 A 프로그램 B 프로그램 C │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 코드 │ │ 코드 │ │ 코드 │ │
│ ├──────────┤ ├──────────┤ ├──────────┤ │
│ │ libc │ │ libc │ │ libc │ │
│ │ (복사본) │ │ (복사본) │ │ (복사본) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ 문제: 라이브러리 코드가 3번 중복! │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 동적 링킹 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 프로그램 A 프로그램 B 프로그램 C 공유 라이브러리 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 코드 │ │ 코드 │ │ 코드 │ │ libc.so │ │
│ ├──────────┤ ├──────────┤ ├──────────┤ ┌──│ │ │
│ │ stub │──┼──────────┼──┼──────────┼──┤ │ printf() │ │
│ └──────────┘ │ stub │──┼──────────┼──┤ │ malloc() │ │
│ └──────────┘ │ stub │──┘ │ ... │ │
│ └──────────┘ └──────────┘ │
│ │
│ 장점: 라이브러리 코드 1개만 메모리에! │
└─────────────────────────────────────────────────────────────────┘
6.2 스텁 (Stub)¶
// 동적 링킹의 스텁 동작 (의사 코드)
// 첫 번째 호출 시
void printf_stub() {
// 1. 라이브러리가 메모리에 있는지 확인
if (!is_library_loaded("libc.so")) {
// 2. 없으면 로드
load_library("libc.so");
}
// 3. 실제 함수 주소 획득
void* real_printf = get_symbol_address("printf");
// 4. 스텁을 실제 주소로 대체 (다음 호출은 직접 점프)
replace_stub_with_address(real_printf);
// 5. 실제 함수 호출
jump_to(real_printf);
}
6.3 공유 라이브러리 확인¶
# 실행 파일이 사용하는 공유 라이브러리 확인
$ ldd /bin/ls
linux-vdso.so.1 (0x00007ffd12345000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2 (0x00007f1234567000)
# 메모리에 로드된 공유 라이브러리 확인
$ cat /proc/self/maps | grep "\.so"
7. 스와핑¶
7.1 개념¶
┌─────────────────────────────────────────────────────────────────┐
│ 스와핑 과정 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 메모리 (RAM) 디스크 (Backing Store) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 운영체제 │ │ │ │
│ ├──────────────┤ │ │ │
│ │ 프로세스 A │ ──Swap Out──▶ │ 프로세스 A │ │
│ ├──────────────┤ │ (이미지) │ │
│ │ 프로세스 B │ │ │ │
│ ├──────────────┤ ◀──Swap In─── │ 프로세스 C │ │
│ │ 프로세스 C │ │ (이미지) │ │
│ │ (새로 로드) │ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
7.2 스왑 영역¶
┌─────────────────────────────────────────────────────────────────┐
│ 디스크 구조 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 파일 시스템 파티션 │ │
│ │ /home, /usr, /var, ... │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ 스왑 파티션 │ │
│ │ 프로세스 이미지 저장 공간 │ │
│ │ 빠른 접근을 위해 연속 할당 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
7.3 스왑 시간 계산¶
스왑 시간 = 전송 시간 + 탐색 시간 + 회전 지연
예제:
- 프로세스 크기: 100MB
- 디스크 전송률: 50MB/sec
- 평균 탐색 시간: 8ms
- 평균 회전 지연: 4ms
전송 시간 = 100MB / 50MB/sec = 2초
스왑 아웃 시간 = 2초 + 8ms + 4ms ≈ 2.01초
스왑 인 시간 = 2초 + 8ms + 4ms ≈ 2.01초
총 스왑 시간 ≈ 4.02초
→ 매우 느림! 최소화 필요
7.4 Linux 스왑 관리¶
# 스왑 상태 확인
$ free -h
total used free shared buff/cache available
Mem: 15Gi 8.0Gi 2.5Gi 500Mi 5.0Gi 6.5Gi
Swap: 8.0Gi 1.2Gi 6.8Gi
# 스왑 파티션/파일 확인
$ swapon --show
NAME TYPE SIZE USED PRIO
/dev/sda2 partition 8G 1.2G -2
# 스왑 사용량 상세
$ cat /proc/swaps
# 스왑 파일 생성 (예: 4GB)
$ sudo fallocate -l 4G /swapfile
$ sudo chmod 600 /swapfile
$ sudo mkswap /swapfile
$ sudo swapon /swapfile
# swappiness 설정 (0-100, 높을수록 적극적 스왑)
$ cat /proc/sys/vm/swappiness
60
# swappiness 변경
$ sudo sysctl vm.swappiness=10
7.5 모바일 시스템의 스와핑¶
┌─────────────────────────────────────────────────────────────────┐
│ 모바일 OS 메모리 관리 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 특징: │
│ - 전통적 스와핑 미사용 (플래시 메모리 수명 고려) │
│ - 대신 앱 종료/재시작 방식 │
│ │
│ iOS: │
│ - 메모리 부족 시 백그라운드 앱 종료 │
│ - 앱은 상태 저장 후 종료, 재시작 시 복원 │
│ │
│ Android: │
│ - zRAM (압축 스왑) │
│ - Low Memory Killer (LMK) │
│ - 우선순위에 따라 프로세스 종료 │
│ │
└─────────────────────────────────────────────────────────────────┘
연습 문제¶
문제 1: 주소 변환¶
프로세스의 재배치 레지스터가 0x4000이고 한계 레지스터가 0x3000일 때: 1. 논리 주소 0x1500의 물리 주소는? 2. 논리 주소 0x3500에 접근하면 어떤 일이 발생하는가?
정답 보기
1. 물리 주소 = 0x4000 + 0x1500 = 0x5500 2. 0x3500 >= 0x3000 (한계 초과) → Segmentation Fault 발생문제 2: 동적 로딩 vs 동적 링킹¶
다음 설명이 동적 로딩과 동적 링킹 중 어느 것에 해당하는지 구분하시오.
- 여러 프로그램이 같은 라이브러리 코드를 공유한다
- 운영체제의 특별한 지원 없이 사용자 프로그램에서 구현 가능하다
- 스텁(stub) 코드를 사용한다
- 필요할 때 루틴을 메모리에 적재한다
정답 보기
1. 동적 링킹 - 공유 라이브러리 사용 2. 동적 로딩 - 프로그래머가 직접 구현 가능 3. 동적 링킹 - 스텁이 실제 함수 주소를 찾음 4. 동적 로딩 - 호출 시점에 로드문제 3: 스왑 시간 계산¶
다음 조건에서 프로세스의 스왑 아웃 시간을 계산하시오. - 프로세스 크기: 200MB - 디스크 전송률: 100MB/sec - 탐색 시간: 10ms - 회전 지연: 5ms
정답 보기
전송 시간 = 200MB / 100MB/sec = 2초 = 2000ms 스왑 아웃 시간 = 2000ms + 10ms + 5ms = 2015ms ≈ 2.015초문제 4: 코드 분석¶
다음 코드의 문제점을 찾고 수정하시오.
void* handle = dlopen("./plugin.so", RTLD_LAZY);
void (*func)() = dlsym(handle, "process");
func();
dlclose(handle);
정답 보기
문제점: 1. dlopen() 실패 시 NULL 체크 없음 2. dlsym() 실패 시 NULL 체크 없음 수정된 코드:void* handle = dlopen("./plugin.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "Error: %s\n", dlerror());
return;
}
dlerror(); // 이전 에러 클리어
void (*func)() = dlsym(handle, "process");
char* error = dlerror();
if (error != NULL) {
fprintf(stderr, "Error: %s\n", error);
dlclose(handle);
return;
}
func();
dlclose(handle);
문제 5: 시스템 설계¶
임베디드 시스템에서 컴파일 시간 바인딩을 사용하는 이유를 설명하시오.
정답 보기
1. **결정론적 동작**: 실행 시간 변동이 없어 실시간 시스템에 적합 2. **오버헤드 감소**: MMU 없이 직접 물리 주소 사용으로 성능 향상 3. **메모리 제약**: MMU 하드웨어가 없거나 제한적인 환경 4. **단일 프로그램**: 멀티태스킹이 불필요한 경우 5. **비용 절감**: 단순한 하드웨어로 충분다음 단계¶
11_Contiguous_Memory_Allocation.md에서 메모리 분할과 할당 전략을 배워봅시다!
참고 자료¶
- Silberschatz, "Operating System Concepts" Chapter 8
- Tanenbaum, "Modern Operating Systems" Chapter 3
- Linux man pages:
dlopen(3),mmap(2) /proc/[pid]/maps- 프로세스 메모리 맵 확인