본문 바로가기

주소 바인딩

프로그램이 쓰는 논리 주소와 메모리 버스에 실리는 물리 주소를 구분하고, 주소가 컴파일할 때, 적재할 때, 실행할 때 정해지는 세 방식을 비교합니다. 베이스와 한계 레지스터로 주소를 변환하는 가장 단순한 MMU를 모델로 따라가고, 정적 링킹과 동적 링킹, 동적 적재를 정리합니다.

Linux에서 C 프로그램이 malloc(100)을 부르면 0x55d3a1c2b2a0 같은 주소가 돌아옵니다. 이 주소는 컴퓨터에 꽂힌 메모리 칩의 몇 번째 바이트를 가리키는 것일까요? 그렇지 않습니다. 프로그램이 다루는 주소와 실제 메모리의 위치는 다르고, 운영체제와 하드웨어가 그 사이를 이어 줍니다. 프로그램의 주소를 실제 메모리 위치에 연결하는 일을 주소 바인딩(address binding)이라고 합니다.

주소 바인딩을 이해하면 여러 현상이 설명됩니다. 두 프로세스가 같은 주소를 써도 서로의 데이터를 건드리지 않는 이유, 같은 프로그램을 다시 실행하면 포인터 값이 달라지는 이유, 엉뚱한 주소에 접근하면 세그먼테이션 폴트가 나는 이유가 모두 여기서 나옵니다. 이 장에서는 운영체제가 메모리를 관리하는 기본 원리를 다루며, 이 글은 그 출발점입니다.

논리 주소와 물리 주소

논리 주소(logical address)는 CPU가 명령을 실행하며 만들어 내는 주소로, 프로그램이 보는 주소입니다. 가상 메모리를 쓰는 시스템에서는 가상 주소(virtual address)라고도 부릅니다. 프로세스마다 자기만의 논리 주소 공간을 가지므로, 프로세스 A의 0x1000과 프로세스 B의 0x1000은 이름만 같을 뿐 서로 다른 물리 위치를 가리킬 수 있습니다. 반대로 공유 메모리처럼 두 프로세스의 서로 다른 논리 주소가 같은 물리 위치를 가리키게 할 수도 있습니다. 이렇게 프로세스마다 주소 공간이 따로 있다는 것이 프로세스를 서로 격리하는 바탕입니다.

논리 주소 공간의 크기는 주소의 비트 수로 정해집니다. 32비트 주소라면 2322^{32}바이트, 곧 4GiB입니다. 64비트 주소라면 이론상 2642^{64}바이트를 가리킬 수 있지만, 실제 x86-64 프로세서는 그 가운데 48비트(256TiB)나 57비트(128PiB)만 씁니다. 쓸 수 있는 범위는 CPU와 운영체제, 설정에 따라 더 줄어듭니다.

물리 주소(physical address)는 메모리 버스에 실려 실제 메모리 칩의 위치를 가리키는 주소입니다. 메모리 컨트롤러는 이 주소로 DRAM의 데이터를 읽고 씁니다. 물리 주소 공간에는 RAM만 있는 것이 아니라, 2장에서 본 메모리 맵 I/O처럼 장치의 레지스터가 배치된 영역도 있습니다.

프로그램이 보는 주소가 논리 주소라는 것은 간단한 프로그램으로 확인할 수 있습니다.

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

int global_var = 42;

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

    printf("global_var: %p\n", (void *) &global_var);
    printf("stack_var:  %p\n", (void *) &stack_var);
    printf("heap_var:   %p\n", (void *) heap_var);

    free(heap_var);
    return 0;
}

출력되는 세 값은 모두 논리 주소이며, 이 값만으로는 데이터가 실제 메모리의 어디에 있는지 알 수 없습니다. 또 Linux는 주소 공간 배치 무작위화(address space layout randomization, ASLR)로 스택, 힙, 공유 라이브러리, 그리고 위치 독립 실행 파일(PIE)의 코드가 놓이는 위치를 실행할 때마다 바꾸므로, 같은 프로그램을 두 번 실행하면 출력이 달라집니다. ASLR은 공격자가 특정 함수나 버퍼의 주소를 미리 알 수 없게 해, 메모리를 덮어쓰는 공격을 어렵게 만듭니다.

왜 둘을 나누었을까

여러 프로그램이 물리 주소를 직접 쓴다고 해 봅시다. 그러면 프로그램마다 다른 프로그램과 겹치지 않는 물리 영역을 미리 정해 두어야 하고, 한 프로그램의 버그가 다른 프로그램의 영역을 덮어쓰지 못하게 막을 방법도 따로 있어야 합니다. 프로그램을 다른 위치에 올리려면 프로그램 안의 모든 주소를 그 위치에 맞게 고쳐야 하고, 실행 중에는 위치를 옮길 수도 없습니다. 메모리보다 큰 프로그램은 실행할 수조차 없습니다.

논리 주소와 물리 주소를 나누면 이 문제들이 풀립니다. 모든 프로그램은 0번지부터 시작하는 자기만의 주소 공간을 가진다고 생각하고 만들어지며, 실제로 어느 물리 위치에 놓일지는 운영체제가 정하고, 다른 프로세스의 영역에 접근하려는 시도는 하드웨어가 막습니다. 다음 절부터 그 연결을 언제, 누가 하는지 살펴봅니다.

주소가 정해지는 세 시점

프로그램의 주소를 물리 위치에 연결하는 시점은 세 가지로 나눌 수 있습니다.

컴파일할 때 바인딩(compile-time binding)하면, 컴파일러가 프로그램이 놓일 물리 위치를 미리 알고 그 위치에 맞는 절대 주소(absolute address)로 코드를 만듭니다. 프로그램은 항상 그 위치에만 올라가야 하므로, 위치를 바꾸려면 다시 컴파일해야 합니다. BIOS가 언제나 0x7C00번지에 올려 실행하는 MBR의 부트 코드처럼, 놓일 위치가 처음부터 정해진 코드가 이런 방식으로 만들어집니다.

적재할 때 바인딩(load-time binding)하면, 컴파일러는 어디에 놓일지 모르는 상태로 재배치 가능한 코드(relocatable code)를 만들고, 프로그램을 메모리에 올리는 로더(loader)가 놓을 위치를 정한 뒤 코드 안의 주소들을 그 위치에 맞게 고칩니다. 이를 재배치(relocation)라고 합니다. 올리는 위치는 실행할 때마다 바꿀 수 있지만, 한번 올린 뒤에는 주소가 고정되므로 실행 중에 다른 위치로 옮기려면 다시 고쳐야 합니다.

실행할 때 바인딩(execution-time binding)하면, 주소를 연결하는 일을 명령이 실행되는 순간까지 미룹니다. 프로그램은 논리 주소만 쓰고, CPU가 명령을 가져오거나 데이터에 접근할 때마다 하드웨어가 그 주소를 물리 주소로 바꿉니다. 그래서 운영체제는 실행 중인 프로세스의 내용을 다른 물리 위치로 옮기고, 변환 설정만 바꾸어 줄 수 있습니다. 오늘날 범용 운영체제의 가상 메모리가 이 방식이며, 주소를 바꾸는 일은 다음에 볼 MMU라는 하드웨어가 맡습니다.

주소 바인딩 · 시점과 역할

실행 시 바인딩은 가상 주소를 유지한 채 물리 매핑을 바꿀 수 있습니다

단순한 컴파일/적재 시 바인딩 모형과 MMU 기반 가상 메모리를 비교합니다. 로더의 참조 재배치와 OS의 매핑 관리, 접근마다 MMU가 수행하는 변환을 구분합니다.

같은 논리 offset을 현재 변환 설정으로 계산A와 B는 동시에 하나의MMU설정이 아니라 각각현재프로세스인두예이다. 각각논리0x0800에서MMU검사뒤 A의물리0x10800 또는 B의0x20800으로변환한다. 사각영역은현재주소공간설정,화살표는주소변환 계산이다.현재 프로세스 A논리 offset0x0800MMU 모형base 0x10000limit 0x5000offset < limit물리 주소0x10800base 더함현재 프로세스 B논리 offset0x0800MMU 모형base 0x20000limit 0x3000offset < limit물리 주소0x20800base 더함
같은 논리 offset을 현재 변환 설정으로 계산A와 B는 동시에 하나의MMU설정이 아니라 각각현재프로세스인두예이다. 각각논리0x0800에서MMU검사뒤 A의물리0x10800 또는 B의0x20800으로변환한다. 사각영역은현재주소공간설정,화살표는주소변환 계산이다.현재 프로세스 A논리 offset0x0800MMU 모형base 0x10000limit 0x5000offset < limit물리 주소0x10800base 더함현재 프로세스 B논리 offset0x0800MMU 모형base 0x20000limit 0x3000offset < limit물리 주소0x20800base 더함
주소를 확정하는 단계와 재배치 책임
바인딩 시점주소를 맞추는 역할물리 위치를 바꿀 때
컴파일 시도구가 미리 정한 물리 위치에 맞춰 주소 생성코드의 주소를 다시 맞춰야 함
적재 시로더가 적재 위치에 맞춰 필요한 참조 재배치적재 이후 이동에는 추가 재배치가 필요
실행 시OS가 매핑을 관리하고 MMU가 접근 주소 변환가상 주소를 유지하면서 매핑을 변경 가능
컴파일 시
주소를 맞추는 역할: 도구가 미리 정한 물리 위치에 맞춰 주소 생성
물리 위치를 바꿀 때: 코드의 주소를 다시 맞춰야 함
적재 시
주소를 맞추는 역할: 로더가 적재 위치에 맞춰 필요한 참조 재배치
물리 위치를 바꿀 때: 적재 이후 이동에는 추가 재배치가 필요
실행 시
주소를 맞추는 역할: OS가 매핑을 관리하고 MMU가 접근 주소 변환
물리 위치를 바꿀 때: 가상 주소를 유지하면서 매핑을 변경 가능

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

그림은 실행할 때 바인딩에서 역할이 어떻게 나뉘는지 보여 줍니다. 운영체제는 프로세스마다 변환에 쓸 설정을 마련해 두고, 프로세스를 바꿀 때 그 설정을 하드웨어에 넣습니다. 메모리에 접근할 때마다 주소를 검사하고 바꾸는 일은 MMU가 합니다. 같은 논리 주소 0x0800이라도 지금 실행 중인 프로세스가 A라면 A의 설정으로, B라면 B의 설정으로 변환되어 서로 다른 물리 주소가 됩니다.

MMU와 주소 변환

MMU(memory management unit)는 CPU 안에 있는 하드웨어로, CPU가 만든 논리 주소를 물리 주소로 바꾸고 허락되지 않은 접근을 막습니다. 운영체제는 변환 규칙과 보호 정보를 설정해 두고, 실제 변환은 모든 메모리 접근마다 하드웨어가 처리합니다. 접근할 때마다 소프트웨어가 개입하면 너무 느리기 때문입니다.

베이스와 한계 레지스터

가장 단순한 MMU는 레지스터 두 개로 이루어집니다. 베이스 레지스터(base register)는 프로세스가 놓인 물리 메모리의 시작 주소를 담고, 재배치 레지스터(relocation register)라고도 부릅니다. 한계 레지스터(limit register)는 프로세스가 쓸 수 있는 메모리의 크기를 담습니다. MMU는 논리 주소가 한계보다 작은지 먼저 확인하고, 작으면 베이스를 더해 물리 주소를 만들고, 같거나 크면 트랩을 일으켜 운영체제에 넘깁니다. 다음 코드는 이 동작을 모델로 만든 것입니다.

class AddressFault(Exception):
    pass

class BaseLimitMMU:
    def __init__(self, base, limit):
        self.base, self.limit = base, limit      # 지금 실행 중인 프로세스의 시작 위치와 크기

    def translate(self, logical):
        if logical >= self.limit:                # 더하기 전에 범위부터 검사한다
            raise AddressFault(f"한계 {self.limit:#06x} 이상")
        return self.base + logical

mmus = {"A": BaseLimitMMU(base=0x10000, limit=0x5000),
        "B": BaseLimitMMU(base=0x20000, limit=0x3000)}
for name, mmu in mmus.items():
    for logical in (0x0000, 0x0800, 0x3000, 0x4FFF):
        try:
            print(f"{name}: 논리 {logical:#06x} -> 물리 {mmu.translate(logical):#07x}")
        except AddressFault as fault:
            print(f"{name}: 논리 {logical:#06x} -> 트랩 ({fault})")
A: 논리 0x0000 -> 물리 0x10000
A: 논리 0x0800 -> 물리 0x10800
A: 논리 0x3000 -> 물리 0x13000
A: 논리 0x4fff -> 물리 0x14fff
B: 논리 0x0000 -> 물리 0x20000
B: 논리 0x0800 -> 물리 0x20800
B: 논리 0x3000 -> 트랩 (한계 0x3000 이상)
B: 논리 0x4fff -> 트랩 (한계 0x3000 이상)

같은 논리 주소 0x0800이 A에서는 0x10800, B에서는 0x20800이 됩니다. 프로그램이 쓰는 주소는 같아도 지금 실행 중인 프로세스의 베이스가 다르기 때문입니다. A의 한계는 0x5000이므로 0x4FFF는 마지막으로 쓸 수 있는 바이트이고, B의 한계는 0x3000이므로 0x3000부터는 B의 영역 밖입니다. 같은 0x3000이 A에서는 유효하고 B에서는 트랩이 됩니다. 범위 검사를 덧셈보다 먼저 하는 것이 중요합니다. 먼저 더한 뒤 결과가 그럴듯한지 보는 방식으로는, B의 0x3000이 0x23000이라는 그럴듯한 물리 주소가 되어 다른 프로세스의 영역을 건드리는 것을 막을 수 없습니다.

운영체제는 컨텍스트 스위칭을 할 때 베이스와 한계 레지스터를 다음 프로세스의 값으로 바꿉니다. 그래서 각 프로세스는 자기 영역 안에서만 메모리에 접근할 수 있습니다. 이 두 레지스터를 바꾸는 명령은 특권 명령이어서, 사용자 프로그램이 스스로 범위를 넓힐 수는 없습니다.

베이스와 한계 방식의 한계

베이스와 한계 방식에서는 프로세스 하나의 메모리가 물리 메모리에 연속으로 놓여야 합니다. 그래서 프로세스들이 생기고 사라지며 메모리 여기저기에 빈 틈이 생기면, 틈을 모두 합치면 충분한데도 연속된 큰 공간을 찾지 못해 새 프로세스를 올리지 못하는 문제가 생깁니다. 이를 외부 단편화라고 하며, 다음 글에서 자세히 다룹니다. 이 문제를 풀려고 프로세스를 여러 조각으로 나누어 놓는 세그먼테이션과 페이징이 나왔습니다. 오늘날의 MMU는 메모리를 보통 4KiB 크기의 페이지로 나누고, 페이지마다 물리 위치를 적은 페이지 테이블을 써서 변환합니다. 이 방식은 9장에서 다룹니다.

더 깊이: 물리 주소를 직접 볼 수 있을까

사용자 프로그램은 보통 자기 데이터의 물리 주소를 알 필요도, 알 방법도 없습니다. Linux에는 프로세스의 가상 페이지가 어느 물리 프레임에 매핑되어 있는지 알려 주는 /proc/<PID>/pagemap 파일이 있지만, 물리 주소를 알면 메모리의 특정 위치를 노리는 공격에 쓰일 수 있어서, 관리자 권한(CAP_SYS_ADMIN)이 없으면 물리 프레임 번호가 0으로 가려집니다. 자세한 형식은 proc_pid_pagemap(5)에 있습니다. 또 물리 위치는 운영체제가 페이지를 디스크로 내보내거나 옮기면 바뀔 수 있으므로, 한 순간의 물리 주소는 그 뒤에도 유효하다는 보장이 없습니다.

링킹과 적재

프로그램이 쓰는 라이브러리 코드가 언제 연결되는지도 주소 바인딩과 맞닿아 있습니다.

정적 링킹(static linking)은 라이브러리의 코드를 실행 파일 안에 복사해 넣습니다. 실행 파일이 커지지만, 실행할 때 다른 파일이 필요 없어 그 파일 하나만으로 어디서나 실행할 수 있습니다. 동적 링킹(dynamic linking)은 라이브러리를 Linux의 .so나 Windows의 .dll 같은 별도 파일로 두고, 프로그램을 실행할 때 동적 링커가 그 파일을 찾아 주소 공간에 매핑하고 연결합니다. 여러 프로그램이 같은 라이브러리를 쓰면, 라이브러리의 읽기 전용 코드 페이지는 물리 메모리에 한 벌만 두고 모든 프로세스가 공유할 수 있습니다. 다만 라이브러리의 전역 변수처럼 고칠 수 있는 데이터는 프로세스마다 따로 둡니다. 라이브러리를 고치면 그 라이브러리를 쓰는 프로그램들을 다시 빌드하지 않아도 새 버전을 쓰게 된다는 장점도 있습니다.

Linux에서 프로그램이 어떤 공유 라이브러리를 쓰는지는 ldd로 볼 수 있습니다.

ldd /bin/ls          # 동적 링커가 연결할 공유 라이브러리와 그 경로

출력에는 libc.so.6 같은 라이브러리 이름과, 동적 링커가 찾은 실제 파일 경로가 줄마다 나옵니다. 다만 ldd는 경우에 따라 대상 프로그램을 실제로 실행해 정보를 얻으므로, 믿을 수 없는 프로그램에는 쓰지 말라고 ldd(1)이 경고합니다. 그런 경우에는 objdump -p 프로그램 | grep NEEDED처럼 파일 내용만 읽는 방법을 씁니다.

동적 적재(dynamic loading)는 프로그램이 실행 중에 필요해진 시점에 라이브러리를 직접 불러오는 것입니다. POSIX에서는 dlopen()으로 라이브러리를 적재하고 dlsym()으로 그 안의 함수 주소를 찾습니다. 플러그인처럼 실행하기 전에는 무엇이 필요할지 모르는 코드를 다룰 때 씁니다. 이것은 9장에서 볼 요구 페이징과 다른 이야기입니다. 동적 적재는 어떤 라이브러리를 주소 공간에 들일지 정하는 일이고, 요구 페이징은 이미 매핑된 페이지 가운데 무엇을 언제 실제 메모리에 올릴지 정하는 일입니다.

정리

  • 논리 주소는 프로그램이 보는 주소이고, 물리 주소는 메모리 버스에 실려 실제 메모리 위치를 가리키는 주소입니다. 프로세스마다 논리 주소 공간이 따로 있어 서로 격리됩니다.
  • 주소는 컴파일할 때, 적재할 때, 실행할 때 정해질 수 있습니다. 실행할 때 바인딩하면 프로세스를 실행 중에 옮길 수 있으며, 오늘날의 가상 메모리가 이 방식입니다.
  • MMU는 메모리 접근마다 논리 주소를 물리 주소로 바꾸고 보호를 검사하는 하드웨어입니다. 가장 단순한 형태는 범위를 먼저 검사한 뒤 베이스를 더하는 베이스와 한계 레지스터입니다.
  • 베이스와 한계 방식은 프로세스를 연속으로 놓아야 해서 외부 단편화가 생기고, 이를 풀려고 세그먼테이션과 페이징이 나왔습니다.
  • 정적 링킹은 라이브러리를 실행 파일에 넣고, 동적 링킹은 실행할 때 연결해 코드 페이지를 공유하며, 동적 적재는 실행 중에 필요한 라이브러리를 불러옵니다.

연습 문제

1. 같은 주소, 다른 결과

본문의 A(베이스 0x10000, 한계 0x5000)와 B(베이스 0x20000, 한계 0x3000)에서 논리 주소 0x2FFF와 0x3000을 각각 변환하세요. 이 결과만 보고 "두 프로세스는 물리 메모리를 절대 공유할 수 없다"고 결론 내릴 수 있을까요?

해설 보기

0x2FFF는 두 한계보다 작으므로 A에서는 0x12FFF, B에서는 0x22FFF가 됩니다. 0x3000은 A의 한계 0x5000보다 작아 0x13000이 되지만, B에서는 한계와 같으므로 트랩이 됩니다. 이 결과는 두 프로세스가 서로 겹치지 않는 영역을 받은 이 예의 설정에서 나온 것일 뿐입니다. 베이스와 한계 방식에서도 운영체제가 두 영역을 겹치게 설정할 수 있고, 페이지 단위로 변환하는 시스템에서는 두 프로세스의 페이지를 같은 물리 프레임에 매핑해 공유 메모리나 공유 라이브러리를 만들 수 있습니다. 주소 공간이 따로 있다는 것이 모든 물리 메모리를 독차지한다는 뜻은 아닙니다.

2. 더한 다음에 검사하면

동료가 MMU 모델을 "먼저 베이스를 더하고, 결과가 물리 메모리 크기보다 작으면 허락한다"로 바꾸자고 제안했습니다. 물리 메모리가 1MiB(0x100000)일 때, B(베이스 0x20000, 한계 0x3000)의 논리 주소 0x5000은 어떻게 처리될까요? 이 제안의 문제를 설명하세요.

해설 보기

0x20000 + 0x5000 = 0x25000은 물리 메모리 크기 0x100000보다 작으므로 허락됩니다. 하지만 0x5000은 B의 한계 0x3000을 넘으므로, B가 자기 영역 밖의 메모리에 접근한 것입니다. 0x25000이 다른 프로세스의 영역이라면 B가 그 프로세스의 데이터를 읽거나 덮어쓰게 됩니다. 물리 메모리 크기와 비교하는 검사는 존재하지 않는 메모리에 접근하는 것만 막을 뿐, 프로세스 사이의 격리는 지키지 못합니다. 격리를 지키려면 프로세스마다의 한계와 논리 주소를 먼저 비교해야 합니다.

3. 라이브러리 버그 수정

여러 서비스가 쓰는 압축 라이브러리에 보안 버그가 발견되어 새 버전이 나왔습니다. 서비스 X는 이 라이브러리를 정적으로 링크했고, 서비스 Y는 동적으로 링크했습니다. 각 서비스에 수정을 반영하려면 무엇을 해야 할까요?

해설 보기

정적으로 링크한 X는 라이브러리의 코드가 실행 파일 안에 복사되어 있으므로, 새 라이브러리로 X를 다시 빌드하고 새 실행 파일을 배포해야 수정이 반영됩니다. 동적으로 링크한 Y는 실행할 때 시스템의 라이브러리 파일을 연결하므로, 시스템의 라이브러리를 새 버전으로 바꾼 뒤 Y를 다시 시작하면 됩니다. 이미 실행 중인 Y의 프로세스는 옛 라이브러리를 매핑한 채로 있으므로, 다시 시작하기 전까지는 옛 코드를 쓴다는 점에 주의해야 합니다. 동적 링킹은 이처럼 수정을 한곳에서 반영할 수 있는 장점이 있지만, 라이브러리 버전이 바뀌며 동작이 달라지는 위험도 함께 가집니다.

다음 글 연속 메모리 할당에서는 프로세스를 메모리에 연속으로 놓을 때 빈 공간을 어떻게 고르는지, 그리고 단편화가 왜 생기는지 살펴봅니다.