안동민 개발노트

본문 시작

주소 바인딩

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

가상 메모리를 사용하는 일반적인 Linux 프로세스에서 malloc(100)이 성공하면 프로그램이 사용할 포인터가 반환됩니다. 실패하면 NULL입니다.

그런데 이 주소는 실제 물리 메모리의 주소일까요?

아닙니다.

프로그램이 사용하는 주소와 실제 RAM의 주소는 다릅니다.

운영체제가 이 둘을 연결해주는 과정이 주소 바인딩(Address Binding)입니다.

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


논리 주소와 물리 주소

논리 주소 (Virtual Address)

논리 주소(Logical Address)는 CPU가 생성하는 주소이며, 가상 주소(Virtual Address)라고도 합니다.

프로그램의 관점에서 보는 주소입니다.

핵심 특성: 프로세스마다 독립적인 주소 공간을 가집니다.

프로세스 A와 B의 같은 가상 주소 0x1000은 서로 다른 물리 위치로 매핑될 수 있습니다. 반대로 공유 메모리는 같은 물리 페이지로 매핑할 수 있습니다.

이것이 프로세스 격리의 기반입니다.

32비트 시스템에서 논리 주소 공간은 232=42^{32} = 4GiB 입니다.

64비트 주소의 이론적 범위는 2642^{64}바이트입니다. x86-64의 구현 예로 48비트(256TiB)와 57비트(128PiB) 가상 주소가 있으며, 실제 사용 가능한 범위는 CPU·OS·설정에 따라 제한됩니다.

물리 주소 (Physical Address)

이 절의 물리 주소(Physical Address)는 RAM에 접근할 위치를 나타냅니다. 전체 물리 주소 공간에는 장치용 MMIO 영역도 포함될 수 있습니다.

메모리 버스에 실리는 주소이며, 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)이 활성화된 환경에서는 배치의 일부를 무작위화합니다. 실행 파일의 PIE 여부와 영역에 따라 출력 주소가 달라질 수 있으며, 이 예제는 주소 변화나 물리 위치를 측정한 결과가 아닙니다.

공격자가 특정 함수나 버퍼의 주소를 예측하기 어렵게 만듭니다.

왜 분리해야 하는가

별도의 주소 변환·보호 없이 여러 프로세스가 물리 주소를 직접 사용한다면:

  • 다른 프로세스의 영역을 덮어쓰지 않도록 접근을 통제해야 합니다.
  • 적재 영역이 겹치지 않도록 정하고, 필요한 주소 참조를 그 위치에 맞춰야 합니다.
  • 실행 중 위치를 옮기려면 주소 참조도 다시 맞춰야 합니다.
  • 한 번에 상주시키는 내용은 RAM에 들어가야 합니다. 더 큰 프로그램은 오버레이 등 별도 관리가 필요합니다.

바인딩 시점

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

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

컴파일러가 절대 주소(Absolute Address)를 생성합니다.

프로그램이 항상 같은 물리 주소에 적재되어야 합니다.

예를 들어 시작 물리 위치를 미리 정한 단순한 실행 환경에서는 그 위치에 맞춘 코드를 만들 수 있습니다. 적재 위치를 바꾸려면 다시 주소를 맞춰야 합니다.

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

컴파일러가 재배치 가능 코드(Relocatable Code)를 생성합니다.

로더가 적재 위치를 정하고 필요한 주소 참조를 재배치합니다.

적재 시 바인딩만 사용하는 모형에서는 이후 이동을 자동으로 처리할 주소 변환 계층이 없습니다. 다른 위치로 옮기려면 주소를 다시 맞추는 작업이 필요합니다.

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

주소 변환을 실행 시점으로 미룹니다.

명령어 인출이나 데이터 접근에 사용하는 주소를 실행 중 변환합니다. MMU와 TLB가 빠른 변환을 돕지만, TLB 미스나 페이지 테이블 탐색에는 추가 비용이 듭니다.

장점: 프로세스를 실행 중에 다른 물리 위치로 옮길 수 있습니다.

가상 메모리에서 페이지를 다른 물리 프레임에 배치하고 이동하는 기반이 됩니다.

MMU를 사용하는 현대 범용 OS의 가상 메모리는 이 방식에 기반합니다.

주소를 확정하는 단계와 재배치 책임

컴파일, 적재, 실행 시 바인딩이 물리 위치를 다루는 시점을 비교한다.

주소를 확정하는 단계와 재배치 책임
바인딩 시점주소를 맞추는 역할물리 위치를 바꿀 때
컴파일 시도구가 미리 정한 물리 위치에 맞춰 주소 생성코드의 주소를 다시 맞춰야 함
적재 시로더가 적재 위치에 맞춰 필요한 참조 재배치적재 이후 이동에는 추가 재배치가 필요
실행 시OS가 매핑을 관리하고 MMU가 접근 주소 변환가상 주소를 유지하면서 매핑을 변경 가능
컴파일 시
주소를 맞추는 역할: 도구가 미리 정한 물리 위치에 맞춰 주소 생성
물리 위치를 바꿀 때: 코드의 주소를 다시 맞춰야 함
적재 시
주소를 맞추는 역할: 로더가 적재 위치에 맞춰 필요한 참조 재배치
물리 위치를 바꿀 때: 적재 이후 이동에는 추가 재배치가 필요
실행 시
주소를 맞추는 역할: OS가 매핑을 관리하고 MMU가 접근 주소 변환
물리 위치를 바꿀 때: 가상 주소를 유지하면서 매핑을 변경 가능

실행 시 바인딩이 있어도 임의 시점의 이동이 자동으로 안전한 것은 아닙니다. OS가 데이터 이동과 매핑 변경을 조정해야 합니다.


MMU와 주소 변환

MMU(Memory Management Unit)는 CPU 내부(또는 CPU와 메모리 사이)에 위치한 하드웨어로, 논리 주소를 물리 주소로 변환합니다.

OS가 매핑과 보호 정보를 설정하고, 일반적인 메모리 접근 경로의 주소 변환은 하드웨어가 수행합니다.

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

가장 단순한 형태의 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) {
        /* 범위 초과 → fault → OS가 처리 (POSIX의 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와 다른 물리 주소!
 */

위 코드는 raise_trap 등을 생략한 개념 조각입니다. 유효한 베이스·크기와 주소 폭 안의 덧셈을 전제합니다.

이 모형에서 컨텍스트 스위칭 시 OS는 베이스와 한계 레지스터를 새 프로세스의 값으로 교체합니다.

이것이 프로세스마다 독립된 주소 공간이 보장되는 메커니즘입니다.

한계와 발전

베이스-한계 방식은 프로세스의 메모리가 연속적이어야 합니다.

이는 외부 단편화 문제로 이어집니다.

이 한계를 극복하기 위해 세그먼테이션과 페이징이 등장합니다.

현대 CPU의 MMU는 페이지 테이블을 사용합니다. 4KiB는 흔한 페이지 크기이며, 아키텍처와 설정에 따라 더 큰 페이지도 사용합니다.


동적 링킹과 적재

정적 링킹 vs 동적 링킹

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

실행 파일이 크지만 독립적입니다.

동적 링킹(Dynamic Linking): 라이브러리 코드가 별도 파일(.so, .dll)로 존재하며, 실행 시점에 연결됩니다.

동일한 공유 라이브러리의 읽기 전용 코드 페이지는 여러 프로세스가 공유할 수 있습니다. 수정 가능한 전역 데이터까지 모두 한 복사본으로 공유하는 것은 아닙니다.

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): 프로그램의 모든 코드를 한꺼번에 메모리에 올리지 않고, 호출될 때 필요한 루틴만 적재합니다.

프로그램이 필요할 때 dlopen으로 라이브러리를 적재하고 dlsym으로 심볼을 찾을 수 있습니다. 이 API도 런타임 로더와 OS의 매핑 기능을 사용합니다.

라이브러리를 언제 연결할지 결정하는 동적 적재와, 매핑된 페이지를 언제 RAM에 가져올지 결정하는 요구 페이징은 서로 다른 층의 동작입니다.

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