icon

안동민 개발노트

8장 : 메모리 관리 기초

주소 바인딩


malloc(100)을 호출하면 메모리 주소가 반환됩니다. 그런데 이 주소는 실제 물리 메모리의 주소일까요? 아닙니다. 프로그램이 사용하는 주소와 실제 RAM의 주소는 다릅니다. 운영체제가 이 둘을 연결해주는 과정이 주소 바인딩(Address Binding)입니다.

주소 바인딩을 이해하지 못하면 왜 두 프로세스가 같은 주소를 사용해도 충돌하지 않는지, 왜 프로그램을 다시 실행하면 포인터 값이 달라지는지, 세그먼테이션 폴트가 왜 발생하는지를 설명할 수 없습니다.


논리 주소와 물리 주소

논리 주소 (Virtual Address)

논리 주소(Logical Address)는 CPU가 생성하는 주소이며, 가상 주소(Virtual Address)라고도 합니다. 프로그램의 관점에서 보는 주소입니다.

핵심 특성: 프로세스마다 독립적인 주소 공간을 가집니다. 프로세스 A의 주소 0x1000과 프로세스 B의 주소 0x1000은 전혀 다른 물리 위치를 가리킵니다. 이것이 프로세스 격리의 기반입니다.

32비트 시스템에서 논리 주소 공간은 232=42^{32} = 4GB 입니다. 64비트 시스템에서는 이론적으로 2642^{64}이지만, 실제로는 48비트(256TB, x86-64) 또는 57비트(128PB, 5-level paging)만 사용합니다.

물리 주소 (Physical Address)

물리 주소(Physical Address)는 실제 RAM 칩에서의 위치입니다. 메모리 버스에 실리는 주소이며, DRAM 컨트롤러가 이 주소로 데이터를 읽고 씁니다.

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

int global_var = 42;

int main() {
    int stack_var = 10;
    int *heap_var = malloc(sizeof(int));

    printf("global_var 논리 주소: %p\n", (void *)&global_var);
    printf("stack_var 논리 주소:  %p\n", (void *)&stack_var);
    printf("heap_var 논리 주소:   %p\n", (void *)heap_var);
    /* 이 주소들은 논리 주소입니다.
       물리 주소는 프로그램에서 알 수 없습니다.
       같은 프로그램을 두 번 실행하면 주소가 다릅니다 (ASLR). */

    free(heap_var);
    return 0;
}

같은 프로그램을 두 번 실행하면 출력되는 주소가 달라집니다. 이는 ASLR(Address Space Layout Randomization) 때문인데, 보안을 위해 프로세스의 메모리 배치를 매번 랜덤하게 합니다. 공격자가 특정 함수나 버퍼의 주소를 예측하기 어렵게 만듭니다.

왜 분리해야 하는가

두 주소가 분리되어 있지 않다면(모든 프로세스가 물리 주소를 직접 사용한다면):

  • 프로세스 A의 버그가 프로세스 B의 메모리를 덮어쓸 수 있습니다.
  • 프로그램을 특정 물리 주소용으로 컴파일해야 합니다. 그 주소가 이미 사용 중이면 실행할 수 없습니다.
  • 프로세스를 메모리의 다른 위치로 옮길 수 없습니다.
  • 물리 메모리보다 큰 프로그램은 실행 불가능합니다.

바인딩 시점

논리 주소가 물리 주소로 변환되는 시점에 따라 세 가지 방식이 있습니다.

컴파일 시 바인딩 (Compile-time Binding)

컴파일러가 절대 주소(Absolute Address)를 생성합니다. 프로그램이 항상 같은 물리 주소에 적재되어야 합니다.

MS-DOS 시절의 COM 프로그램이 이 방식이었습니다. 프로그램은 항상 주소 0100h에 적재된다고 가정하고 컴파일했습니다. 현대 시스템에서는 동시에 여러 프로그램이 실행되므로 모든 프로그램이 같은 주소를 사용할 수 없어 거의 사용하지 않습니다.

적재 시 바인딩 (Load-time Binding)

컴파일러가 재배치 가능 코드(Relocatable Code)를 생성합니다. 프로그램을 메모리에 적재할 때 시작 주소를 결정하고, 코드 내의 모든 주소를 그에 맞게 조정합니다.

단점: 적재 후에는 프로세스를 다른 위치로 옮길 수 없습니다. 모든 주소가 이미 고정되었기 때문입니다. 메모리 압축(compaction)이 불가능합니다.

실행 시 바인딩 (Execution-time Binding)

주소 변환을 실행 시점으로 미룹니다. CPU가 명령어를 실행할 때마다 논리 주소를 물리 주소로 변환합니다. 이 변환은 하드웨어(MMU)가 수행하므로 성능 저하가 거의 없습니다.

장점: 프로세스를 실행 중에 다른 물리 위치로 옮길 수 있습니다. 가상 메모리, 페이징, 스와핑 등 모든 현대적 메모리 관리 기법의 전제 조건입니다.

현대 운영체제는 모두 실행 시 바인딩을 사용합니다.

방식주소 결정 시점재배치 가능사용 시기
컴파일 시컴파일불가MS-DOS COM
적재 시로더 실행 시불가초기 멀티프로그래밍
실행 시매 명령어 실행 시가능현대 OS

MMU와 주소 변환

MMU(Memory Management Unit)는 CPU 내부(또는 CPU와 메모리 사이)에 위치한 하드웨어로, 논리 주소를 물리 주소로 변환합니다. 소프트웨어로는 매 메모리 접근마다 변환을 수행할 수 없습니다 — 너무 느립니다. 따라서 하드웨어가 필수입니다.

베이스-한계 레지스터 방식

가장 단순한 형태의 MMU입니다. 두 개의 레지스터를 사용합니다.

베이스 레지스터(Base Register, Relocation Register): 프로세스의 시작 물리 주소를 저장합니다. 논리 주소에 베이스 값을 더하면 물리 주소가 됩니다.

한계 레지스터(Limit Register): 프로세스의 메모리 크기를 저장합니다. 논리 주소가 한계 값보다 크면 잘못된 접근이므로 트랩을 발생시킵니다.

address_translation.c
/* 개념적 의사 코드 — 실제로는 하드웨어가 매 클럭마다 수행 */
typedef struct {
    unsigned int base;   /* 프로세스 시작 물리 주소 */
    unsigned int limit;  /* 프로세스 크기 */
} MMU;

unsigned int translate(MMU *mmu, unsigned int logical_addr) {
    if (logical_addr >= mmu->limit) {
        /* 범위 초과 → 트랩(인터럽트) → OS가 SIGSEGV 전달 */
        raise_trap(SEGFAULT);
        return (unsigned int)-1;
    }
    return mmu->base + logical_addr;
}

/*
 * 프로세스 A: base = 0x10000, limit = 0x5000
 *   논리 주소 0x0000 → 물리 주소 0x10000
 *   논리 주소 0x3000 → 물리 주소 0x13000
 *   논리 주소 0x6000 → 트랩! (0x6000 >= 0x5000)
 *
 * 프로세스 B: base = 0x20000, limit = 0x3000
 *   논리 주소 0x0000 → 물리 주소 0x20000
 *   같은 논리 주소 0x0000이지만 A와 다른 물리 주소!
 */

컨텍스트 스위칭 시 OS는 베이스와 한계 레지스터를 새 프로세스의 값으로 교체합니다. 이것이 프로세스마다 독립된 주소 공간이 보장되는 메커니즘입니다.

한계와 발전

베이스-한계 방식은 프로세스의 메모리가 연속적이어야 합니다. 이는 외부 단편화 문제로 이어집니다. 이 한계를 극복하기 위해 세그먼테이션과 페이징이 등장합니다. 현대 CPU의 MMU는 페이지 테이블을 사용하여 4KB 단위로 주소를 변환합니다.


동적 링킹과 적재

정적 링킹 vs 동적 링킹

정적 링킹(Static Linking): 라이브러리 코드가 실행 파일에 포함됩니다. 실행 파일이 크지만 독립적입니다.

동적 링킹(Dynamic Linking): 라이브러리 코드가 별도 파일(.so, .dll)로 존재하며, 실행 시점에 연결됩니다. 여러 프로세스가 같은 라이브러리를 메모리에서 공유할 수 있어 메모리를 절약합니다.

libc.so는 거의 모든 C 프로그램이 사용합니다. 100개 프로세스가 각각 libc를 포함하면 수백 MB가 낭비되지만, 동적 링킹으로 물리 메모리에 한 복사본만 두면 됩니다.

dynamic_vs_static.py
import subprocess

# Linux에서 동적 링킹된 라이브러리 확인
result = subprocess.run(["ldd", "/bin/ls"], capture_output=True, text=True)
print(result.stdout)
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
# ld-linux-x86-64.so.2 (0x00007f...)

동적 적재

동적 적재(Dynamic Loading): 프로그램의 모든 코드를 한꺼번에 메모리에 올리지 않고, 호출될 때 필요한 루틴만 적재합니다. 에러 처리 루틴처럼 드물게 실행되는 코드는 호출되기 전까지 메모리를 차지하지 않습니다. OS의 지원 없이도 프로그래머가 직접 구현할 수 있지만(dlopen/dlsym 사용), 현대 OS의 요구 페이징이 이를 자동으로 처리합니다.

다음 절에서는 여러 프로세스의 메모리를 연속으로 할당하는 방식과 그 한계인 단편화 문제를 살펴봅니다.

목차