본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 메모리 관리 기초

주소 바인딩

논리 주소와 물리 주소를 구분하고 컴파일·적재·실행 시점의 바인딩과 MMU의 동적 주소 변환을 이해합니다.

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의 요구 페이징이 이를 자동으로 처리합니다.

아래 다이어그램은 논리 주소가 실행 중에 물리 주소로 바뀌는 흐름과 그 결과로 얻는 격리 효과를 정리한 것입니다.

주소 바인딩을 정리하면, CPU가 만든 논리 주소를 MMU가 보호와 재배치 규칙에 맞춰 물리 위치로 보내는 과정입니다.

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