7 분 소요

이번 포스트는 OS의 주소공간에 대한 개념과 Memory API에 대해 다룬다.

Address Space

Memory Virtualization

Memory Virtualization (메모리 가상화) 이란?

  • 운영체제가 실제 메모리를 가상화하여 각 프로세스에게 자신만의 독립된 메모리를 가진 것처럼 보이게 하는 기술이다.
  • OS가 물리 메모리를 잘개 쪼개서, 여러 프로세스가 동시에 메모리를 사용하는 것처럼 환상을 제공한다.

왜 필요한가?

초기 메모리 시스템 (Without Virtualization)

예전에는 메모리 내에 한 번에 하나의 프로세스만 로드가 가능하다.

  • 메모리 낭비가 심하고
  • 멀티프로그램이 불가하고
  • 성능이 낮음.

=> 매우 비효율적인 구조

Multiprogramming and Time Sharing

아이디어 : 하나의 메모리에 여러 프로세스를 올려 더 효율적으로 사용

초기 OS : 한 번에 하나의 프로그램만

  • CPU가 노는 시간이 너무 많아서 비효율적

Load multiple processes in memory.

  • 메모리에 여러 프로세스를 동시에 올려주고 번갈아서 실행을 한다.
  • 한 프로세스를 잠시 실행하고 중단한다.
  • 정해진 타임 슬라이스마다 다른 프로세스로 교체를 한다.
    • Time Sharing
  • 프로세스 A가 I/O를 기다릴때, 프로세스 B가 CPU를 사용하면 CPU가 놀지 않기 때문에, 전체 시스템의 효율이 증가한다.

예시 그림)

아래와 같이 구간을 나누어 각 프로세스는 자신에게 배정된 공간 범위만 접근이 가능하다.

[ OS           ]
[ Free         ]
[ Process C    ]
[ Process B    ]
[ Free         ]
[ Process A    ]
[ Free         ]

문제 : Protection Issue

  • 다른 프로세스의 메모리에 잘 못 접근할 수 있다.
    • 예시) 프로세스 A가 프로세스 B의 데이터를 덮어씌우는 상황

Address Space (주소 공간)

개념 : OS가 만드는 가짜의 개인 메모리 공간

  • 하나의 실제 물리 메모리를 여러 프로세스가 공유하면서도 각 프로세스는 자기만의 독립된 메모리를 갖는 것처럼 보인다.
    • 각 프로세스는 자기만의 독립된 메모리를 전체를 다 쓰는 것처럼 보이게 한다.
    • 실제 물리 메모리와 1:1 대응이 아니라 OS가 매핑해서 연결해준다.

C언어에서 주소를 출력하는 코드를 짤 때,

이때 나오는 주소가 가상주소 Address Space 의 값이다.

즉, 각 프로세스는 같은 가상의 주소 0KB를 사용하지만 (예시), 실제로는 다른 물리 주소에 매핑한다. 그리고 이 주소는 OS만 알고 있다.

  • 이 공간을 => Address Space (주소 공간) 이라고 함.
0KB     Program Code
1KB     Heap
         (free space)
15KB    Stack
16KB

위와 같이 프로그램 실행 시 모든 데이터가 이 구조 안에 배치

Address Space 구성 요소

  • Code : 프로그램의 기계어 (instruction)이 저장되는 영역
  • Data : 전역 변수, static 변수
  • Heap : 동적 메모리 (malloc / new로 증가하고, free / delete로 감소)
  • Stack : 함수 호출 스택 (지역 변수, return address 저장)
  • (Free) : Heap, Stack 사이의 비어있는 공간으로 시간에 따라 공간을 확장

Stack과 Heap이 서로 반대 방향으로 향하는 이유

  • 두 공간은 만나지 않고 서로 마주보며 커지도록 설계되어있다.
  • 그 이유는
    • 유연하고
    • 동적 크기로 증가가 가능하다.
    • (중간 Free 영역이 넓게 존재)

왜 Address Space를 사용하는가?

1. 보호 (Protection)

  • 여러 프로세스가 동시에 메모리를 쓸 때 한 프로세스가 다른 프로세스 메모리를 맘대로 건드리면 안된다.
  • OS는 프로세스마다 독립된 address space를 만들어 서로의 메모리 간섭을 원천 봉쇄한다.
    • 아까의 프로세스 A가 프로세스 B의 메모리를 건드리는 것과 같은 문제는 발생 X

2. 편리함 (Convenience)

  • 모든 프로세스는 항상 0KB ~ max 사이의 동일한 구조를 가진다.
  • 개발자가 물리 주소를 신경 쓸 필요가 없다.

3. 효율성 (Efficiency)

  • 실제 물리 메모리는 여러 프로세스가 공유하지만, Address Space는 독립적이므로 물리 메모리의 활용도를 최대로 끌어올릴 수 있다.

Memory API

Memory Allocation Interface

유저 프로그램은 직접 물리 메모리를 만질 수 없다.

메모리 요청의 흐름은 다음과 같다.

Application → libc (malloc/free) → System calls (brk/sbrk) → Kernel
  • libc 란?
    • malloc(), free(), calloc(), realloc()라이브러리 함수 (libc)
    • 실제 메모리 확장은 OS 시스템 콜인 brk()/sbrk() 가 실행함.
    • malloc()은 커널에 직접 요청을 하지 않고, libc가 요청을 처리하고 필요할 때만 커널로 시스템 콜을 호출한다.
  • Heap의 구조
[ metadata | allocated block ]
  • metadata : 할당된 블록의 크기, 상태 정보
  • 포인터가 가리키는 것은 metadata 뒤의 실제 데이터 부분
  • free 시에 metadata를 참조하여 얼마만큼의 메모리를 회수해야 하는지를 알게 된다.

malloc()

  • Heap 영역에서 size만큼 동적 할당하여 그 시작 주소를 반환하는 함수

  • 사용 방법

    #include <stdlib.h>
    void* malloc (size_t size)
    

size_t : unsigned integer (부호 없는 정수 타입) , byte 단위

반환값 :

  • 성공 시에 : void * 포인터
  • 실패 시에 : NULL

사용시 주의사항 :

  • malloc은 실제 할당된 크기 + metadata를 포함한 블록을 내부적으로 관리한다.
  • 포인터가 가리키는 것은 사용자의 데이터 영역이다.

sizeof()

왜 pointer와 array는 값이 다를까?

  • sizeof() 연산자는 컴파일 타입에 타입의 크기를 계산한다.
    • 실제로 들어있는 값이나 malloc으로 할당된 크기와는 관계가 없다.
  1. x가 pointer일 때
int *x = malloc(10 * sizeof(int));
printf("%d\n", sizeof(x));
// result = 8 (64bit 기준), 4 (32bit 기준)

이유 : sizeof(x)포인터 변수 자체의 크기를 알려주는 것이기 때문

64-bit system : pointer는 8바이트, 32-bit는 4 바이트

sizeof(pointer) 와 malloc으로 할당한 메모리 크기는 다르다.

  1. x가 array일 때
int x[10];
printf("%d\n", sizeof(x));
// result = 40 (10 * 4bytes)

이유 : 배열은 고정 크기를 가지며, sizeof(array)는 전체 배열의 바이트 수를 계산한다.

free()

  • free(ptr)malloc()으로 할당했던 메모리를 반환하는 함수
    • OS에 바로 반환하는 것이 아니라, heap allocation(libc)가 관리
  • 동작 방식
void free(void* ptr);

ptr 이 가리키는 메모리 블록의 앞쪽에 있는 metadata(크기, 상태 등)를 보고 해당 블록을 ‘free list’ 로 돌려놓는다.

malloc()은 할당한 메모리 앞부분에 메타데이터를 숨겨놓는다.

free()는 ptr 바로 앞의 meta 정보를 읽어서 어느 영역을 해제해야 할지를 결정한다.

  • 주의할 부분
    • malloc으로 할당하지 않은 메모리 free 금지
    • 한 번 free한 포인터 다시 free 금지 (double free)
    • free 이후 포인터 사용 금지 (Dangling pointer)

Common Errors

1. Forgetting To Allocate Memory

(메모리에 할당하지 않은 포인터에 strcpy 하는 경우)

char *src = "hello";  // 문자열 리터럴 (data segment)
char *dst;            // unallocated pointer
strcpy(dst, src);     // segfault
  • dst쓰레기 주소(garbage)를 가지고 있다.
  • 어디를 가리키는지 OS는 모른다.
    • 힙도 아니고 스택도 아닐 수 있음.
  • strcpy(dst, src)는 src의 문자열을 dst가 가리키는 위치에 무조건 복사한다.

=> 무작위 메모리 영역에 write를 발생하여 보호된 영역이라면 segmentation fault가 일어난다.

-> 올바른 코드

char *src = "hello";                 // 문자열 리터럴
char *dst = (char*)malloc(strlen(src) + 1);   // 널 문자까지 공간 확보!
strcpy(dst, src);                    // OK

2. Not Allocating Enough Memory

(널 문자를 위한 공간을 확보하지 않아 생기는 문제)

char *src = "hello";  
char *dst = (char*)malloc(strlen(src));   // too small (5 bytes)
strcpy(dst, src);                         // 위험하지만 작동할 수도 있음
  • strlen(src) = 5
  • 그러나 문자열은 반드시 hello\0로 위 예시에서는 6바이트가 필요
  • malloc(5)은 NULL 문자가 들어갈 공간이 없다.
  • 이는 6번째 바이트를 힙의 다른 영역을 침범해서 기록한다.
    • overflow가 생긴다.
  • 더 무서운 부분
    • 프로그램은 죽지 않고 실행될 수 있다. (silent corruption)
    • 하지만 결국 heap corruption, segfault, 예측 불가 동작을 일으킨다.

3. Forgetting to Initalize Allocated Memory

(초기화하지 않은 메모리 읽기)

  • malloc()메모리만 할당하지, 값을 초기화하지 않는다.
  • 따라서 할당된 메모리 안에는 이전에 그 위치를 사용했던 프로그램의 쓰레기 값이 들어있다.
  • 초기화 되지 않은 메모리를 읽으면 undefined behavior로 예측이 불가능한 값을 출력한다.
int *x = (int*)malloc(sizeof(int));
printf("%d\n", *x); // 초기화되지 않은 메모리 접근

위의 예시로는 힙의 해당 위치에 어떤 값을 갖고 있었는지를 알 수 없기에 *x에는 쓰레기 값이 들어있고, 이를 읽는 것은 위험하다.

=> 따라서 메모리를 받은 뒤에는 반드시 다음과 같이 초기화를 해야한다.

*x = 0;
// or
int *x = calloc(1, sizeof(int)); // calloc은 0으로 초기화

4. Memory Leak (Forgetting to Free Memory)

  • malloc()한 메모리를 free()하지 않으면 누수가 일어난다. (Memory Leak)
  • 누수가 반복되면 힙의 공간이 고갈되고 이는 프로그램의 crash가 일어난다.
  • C에서는 GC(Garbage Collector)가 없기 때문에 free()는 반드시 직접 호출해야 한다.
int *a;
while(1) {
    a = (int*)malloc(sizeof(int)); // free 없음 → 누수 발생
}
  • 반복문 안에서 힙에 계속 메모리를 요청
  • 반환을 하지 않기에 힙 영역이 점점 많아진다.
    • 이는 메모리 부족 (out of memory) 로 프로그램이 종료된다.

5. Dangling Pointer (Freeing Memory While in Use)

(사용중인 메모리를 free)

  • Dangling Pointer : 해제된 (freed) 메모리를 가리키는 pointer
  • free한 뒤에도 포인터가 그대로 남아있으면, 접근 시에 segementation fault 또는 예측 불가능한 버그가 발생한다.
  • free 이후에는 반드시 pointer를 NULL로 설정해야 한다.
    • ptr = NULL;
int *b = malloc(4);  
free(b);     // 메모리 해제
*b = 10;     // danger! dangling pointer 사용!

6. Double Free (Freeing Memory Repeatedly)

  • Double Free : 이미 free()한 포인터를 또 free()하는 것
  • 첫 번째 free로 정상적인 메모리를 해제
  • 두 번째 free 는 이미 해제된 메모리를 다시 해제하려고 한다.
    • undefined behavior
    • 프로그램이 바로 죽을 수도 있고, 조용히 망가질 수도 있다.
    • (예) heap metadata 손상 -> heap corruption 발생

malloc / free 는 thread-safe 하지 않을 수 있기에

동시성 (Synchronization)문제도 발생할 수 있다.

7. Invalid Free (Calling free() Incorrectly)

  • malloc()으로 할당된 시작 주소만 free()가 가능하다.
  • 포인터 연산 등으로 다른 주소를 free() 하면 heap metadata가 손상된다.
int *x = malloc(4 * sizeof(int)); // allocated
free(x + sizeof(int));            // invalid free
  • free 는 실제로 malloc 블록의 헤더 (metadata)를 기반으로 해제한다.
  • 잘못된 주소를 free 하면:
    • heap metadata가 꼬인다.
    • 다음 malloc / free가 이상하게 동작한다.
    • 프로그램의 전체가 불안정해지는 결과를 낸다.

calloc(), realloc()

calloc()

  • calloc(num, size)
  • 메모리 할당 + 0으로 초기화
int *arr = calloc(10, sizeof(int)); // 10개 int를 모두 0으로 초기화

realloc()

  • 기존 메모리 블록을 더 크게 / 더 작게 변경할 때 사용
  • 내부에서 새로운 블록을 만들고 복사한 뒤 기존 블록을 free 할 수 있다.
ptr = realloc(ptr, new_size);

-> 바로 덮어쓰면 realloc 실패로 메모리를 잃어버릴 수 있다.

void *tmp = realloc(ptr, new_size);
if (tmp != NULL) 
  ptr = tmp;

System Calls : brk(), sbrk()

  • malloc() 내부에서 필요한 힙 공간이 부족하면 OS에게 요청하는 시스템 콜:
    • brk() : 힙의 끝 (brk pointer)을 특정 위치로 이동한다.
    • sbrk() : brk pointer를 상대적으로 증가 / 감소한다.
brk pointer

힙의 끝을 나타내는 주소로 Heap은 아래 방향으로 확장되기에 brk가 내려가면 힙이 커진다.

  • 동작 흐름
  1. malloc() 요청이 들어온다.
  2. 현재 free 리스트에 적절한 블록이 없으면
  3. brk() or sbrk() 호출에 힙이 확장된다.
  4. 새 힙 영역을 블록으로 관리하여 반환한다.

힙 관리자는 glibc 내부에서 관리중이기에

직접 brk/sbrk를 쓰면 heap 구조를 망가뜨릴 위험이 존재한다.

태그:

카테고리:

업데이트: