사전 주소 공간
개념

주소 공간

gabury1고친 사람 github-actions[bot]

주소 공간은 누가 어떤 주소를 쓸 수 있는지 범위를 정해 줍니다. 운영체제에서는 프로세스 하나가 읽고 쓸 수 있는 메모리 번지의 범위를 뜻합니다. 네트워크에서는 인터넷 장비에 나눠 줄 수 있는 주소 번호 전체를 뜻합니다. 두 뜻 모두 번호가 겹쳐 서로 부딪히는 일을 막으려고 둡니다.

쉽고 빠른 이해

주소 공간은 「이 번호들은 네가 써도 된다」고 정해 둔 번호의 범위입니다. 프로그램 둘이 똑같은 메모리 번지를 써도 서로 다른 칸을 보게 되는 것이 이 덕분입니다.

이게 없으면 모든 프로그램이 메모리 한 판을 같이 씁니다. 한 프로그램이 번지를 잘못 쓰면 남의 데이터가 망가집니다. 인터넷에서도 같은 주소를 두 컴퓨터가 쓰면 패킷이 어디로 갈지 모릅니다.

어떻게 도나:

  1. 주소를 몇 비트로 적을지 정하면 쓸 수 있는 번호의 개수가 정해집니다
  2. 운영체제는 프로세스마다 이 범위를 하나씩 줍니다. 그 번호를 진짜 메모리 칸으로 바꾸는 일도 운영체제가 맡습니다
  3. 네트워크에서는 전체 범위를 덩이로 잘라 기관과 망에 나눠 줍니다

언제 함께 쓰고 언제 갈라 쓰나. 한 프로세스 안의 스레드들은 주소 공간 하나를 함께 써서 데이터를 쉽게 나눕니다. 서로 다른 프로세스는 주소 공간을 갈라 써서 서로의 메모리를 못 건드립니다.

대가는 번호를 바꾸는 일이 늘 끼어든다는 것입니다. 프로세스를 바꿔 돌릴 때마다 「번호 → 실제 칸」 짝을 적은 표도 갈아 끼워야 합니다. 네트워크에서는 주소가 모자라 내부망끼리 같은 번호를 겹쳐 씁니다. 인터넷으로 나갈 때만 겹치지 않는 번호로 바꿔 줍니다.

상세

이 절은 주소의 범위, 운영체제가 프로세스마다 주는 주소 공간, 네트워크의 IP(Internet Protocol, 인터넷 프로토콜) 주소 범위를 차례로 봅니다.

주소와 그 범위

메모리는 바이트 칸이 길게 늘어선 모양입니다. 칸마다 0부터 차례로 번호가 붙습니다. 이 번호를 주소 또는 번지라고 부릅니다. 변수의 값을 읽는다는 것은 그 변수가 든 칸의 번호로 찾아가 읽는다는 뜻입니다.

주소 공간은 이 번호로 쓸 수 있는 값을 전부 모은 범위입니다. 「0번부터 몇 번까지 쓸 수 있나」가 곧 주소 공간의 크기입니다. 주소를 담는 변수인 포인터가 가리킬 수 있는 곳도 이 범위 안뿐입니다.

범위의 크기는 주소를 몇 비트로 적느냐가 정합니다. 비트가 n 개면 서로 다른 번호를 2의 n 제곱 개 만들 수 있습니다. 비트가 하나 늘 때마다 범위가 두 배가 됩니다.

주소 폭 번호 개수 바이트 칸으로 치면
16비트 2의 16제곱 · 65,536 64KiB
32비트 2의 32제곱 · 약 43억 4GiB
64비트 2의 64제곱 16EiB

표의 KiB·GiB·EiB 는 1024 를 곱해 올라가는 단위입니다. 32비트 주소로는 4GiB 넘는 칸에 번호를 붙일 수 없습니다. 메모리를 더 꽂아도 한 프로세스가 번지로 가리킬 수 있는 양은 거기서 멈춥니다.

64비트는 이 벽을 사실상 없앴습니다. CPU(Central Processing Unit, 중앙 처리 장치)가 64비트 가운데 일부 비트만 주소로 쓰는 경우가 많습니다. 그래도 꽂힌 메모리보다 훨씬 넓습니다.

프로세스마다 따로 받는 주소 공간

프로세스는 실행 중인 프로그램 하나를 운영체제가 관리하려고 묶은 단위입니다. 오늘날 운영체제는 프로세스마다 주소 공간을 하나씩 줍니다. 그래서 프로세스 A 도 0번지부터 쓰고 프로세스 B 도 0번지부터 씁니다.

두 프로세스가 같은 번지를 써도 부딪히지 않습니다. 프로세스가 쓰는 번지는 진짜 메모리 칸 번호가 아니기 때문입니다. 프로세스가 쓰는 이 번호를 가상 주소라고 합니다.

메모리 칩의 진짜 칸 번호는 물리 주소입니다. 프로세스는 가상 주소만 보고, 물리 주소는 볼 수 없습니다. 그러니 가상 주소를 물리 주소로 바꿔 주는 무언가가 있어야 합니다.

바꾸는 일은 CPU 안의 MMU(Memory Management Unit, 메모리 관리 장치)가 맡습니다. 번지를 쓸 때마다 바꿔야 하므로 소프트웨어가 아니라 하드웨어가 빠르게 처리합니다.

MMU 는 번지를 하나하나 짝지어 두지 않습니다. 번지를 일정한 크기(흔히 4KiB)로 묶은 단위를 페이지라고 합니다. 짝은 이 페이지 단위로 적습니다. 번지마다 적으면 대응표가 너무 커지기 때문입니다.

그 대응표가 페이지 테이블입니다. 「이 프로세스의 몇 번 페이지는 메모리 몇 번 칸 묶음이다」를 적은 표입니다. 운영체제가 프로세스마다 하나씩 만들어 둡니다. MMU 는 이 표를 보고 번지를 바꿉니다.

이렇게 프로그램이 가상 주소로 쓰고 그때마다 물리 주소로 바꿔 쓰는 방식 전체를 가상 메모리라고 부릅니다. 주소 공간을 프로세스마다 나눠 주는 일은 이 방식 위에서 됩니다.

flowchart TD
    subgraph A["프로세스 A 의 주소 공간"]
        A1["가상 주소 0x1000"]
    end
    subgraph B["프로세스 B 의 주소 공간"]
        B1["가상 주소 0x1000"]
    end
    A1 --> TA["A 의 페이지 테이블"]
    B1 --> TB["B 의 페이지 테이블"]
    subgraph M["물리 메모리"]
        P1["칸 7번"]
        P2["칸 42번"]
    end
    TA --> P1
    TB --> P2

그림에서 두 프로세스는 똑같이 0x1000 번지를 씁니다. 대응표가 서로 달라서 하나는 7번 칸에, 다른 하나는 42번 칸에 닿습니다. A 는 B 의 칸으로 가는 번호를 아예 갖고 있지 않으니 B 의 메모리를 건드릴 방법이 없습니다.

아파트에 빗대면 동은 프로세스, 호수는 가상 주소, 실제 집터는 물리 메모리입니다. 101동에도 1203호가 있고 102동에도 1203호가 있지만 다른 집입니다. 다만 주소 공간에서는 모든 동이 같은 땅(물리 메모리)을 나눠 쓰고, 어느 호수가 어느 땅에 서는지는 페이지 테이블이 정합니다.

이 분리가 프로세스마다 주소 공간을 주는 첫째 이유입니다. 한 프로그램이 번지를 잘못 써도 그 피해가 자기 주소 공간 안에서 끝납니다. 둘째 이유는 편의입니다. 프로그램을 만들 때 다른 프로그램이 어느 번지를 쓸지 신경 쓰지 않아도 됩니다.

코드로 보는 분리

fork 는 지금 프로세스를 복제해 자식 프로세스를 하나 만드는 호출입니다. 자식은 부모의 주소 공간을 복사해 받습니다. 복사본이므로 번지는 같습니다. 칸은 서로 다릅니다.

아래 코드는 전역 변수 x 를 자식에서만 바꾼 뒤 두 프로세스가 각자 번지와 값을 찍습니다. 오른쪽 주석이 두 프로세스가 찍는 결과입니다.

C
#include <stdio.h>
#include <unistd.h>

int x = 1;

int main(void) {
    if (fork() == 0) x = 2; // 자식만 바꿈
    printf("%p ", (void*)&x); // 같은 번지
    printf("%d\n", x); // 부모 1 · 자식 2
    return 0;
}

번지는 둘이 같게 찍힙니다. 값은 1 과 2 로 갈립니다. 같은 가상 주소가 서로 다른 물리 칸을 가리키고 있다는 뜻입니다. 자식이 쓴 2 는 자식의 칸에만 들어갔습니다.

주소 공간을 나눈 구역

주소 공간은 통 하나로 쓰지 않고 쓰임에 따라 구역을 나눕니다. 구역마다 들어가는 것과 읽기·쓰기 허용이 다릅니다.

구역을 보기 전에 한 낱말을 풉니다. 커널은 하드웨어와 프로세스를 관리하는 운영체제의 핵심부입니다. 많은 운영체제는 주소 공간 위쪽 일부를 커널 몫으로 떼어 둡니다.

교과서가 흔히 그리는 배치는 아래와 같습니다. 위쪽이 높은 주소입니다.

block-beta
columns 1
  k["커널 몫 · 사용자 코드는 못 건드림"]
  s["스택 · 아래로 자람"]
  e["비어 있는 주소"]
  h["힙 · 위로 자람"]
  d["데이터 · 전역 변수"]
  c["코드 · 실행할 명령"]

스택과 힙은 실행 중에 크기가 늘어납니다. 스택은 높은 주소에서 아래로, 힙은 낮은 주소에서 위로 자랍니다. 둘이 서로를 향해 자라도록 가운데를 비워 두면, 어느 쪽이 더 크게 자랄지 미리 몰라도 빈 곳을 함께 쓸 수 있습니다.

각 구역에 무엇이 드는지는 아래 표와 같습니다.

구역 담는 것
코드 실행할 기계어 명령. 대개 읽기만 허용합니다
데이터 전역 변수와 정적 변수
힙 실행 중에 요청해서 받는 메모리
스택 함수를 부를 때마다 쌓이는 지역 변수와 돌아갈 곳

커널 몫은 모든 프로세스의 주소 공간에 같이 붙어 있습니다. 사용자 코드가 이 구역을 읽으려 하면 CPU 가 막습니다. 그런데도 붙여 두는 까닭은, 프로그램이 시스템 호출로 커널에 일을 맡길 때 대응표를 갈아 끼우지 않고 바로 커널 코드로 넘어가기 위해서입니다.

비어 있는 주소와 잘못된 접근

주소 공간의 번호가 전부 메모리 칸에 이어져 있지는 않습니다. 64비트 주소 공간은 꽂힌 메모리보다 훨씬 넓어서 대부분이 빈 번호입니다. 페이지 테이블에 대응이 적힌 번호만 바로 쓸 수 있습니다.

대응이 없는 번지에 접근하면 MMU 가 멈추고 운영체제에 알립니다. 이 알림을 페이지 폴트라고 합니다. 운영체제는 그 번지가 이 프로세스가 예약해 둔 범위인지부터 봅니다.

예약한 범위인데 아직 칸이 없을 뿐이면, 운영체제가 칸을 내주고 프로그램을 이어서 돌립니다. 이 경우는 아래 「주소 공간 크기와 쓰는 메모리의 차이」에서 다시 봅니다.

그 프로세스 몫이 아닌 번지라면 운영체제가 프로세스를 강제로 끝냅니다. 리눅스에서 흔히 보는 세그멘테이션 폴트가 이 경우입니다.

널 포인터를 따라가면 프로그램이 죽는 것도 같은 원리입니다. 운영체제는 0번지 근처를 일부러 비워 둡니다. 그래서 초기화하지 않은 포인터를 쓰는 실수가 조용히 넘어가지 않고 바로 드러납니다.

주소 공간을 함께 쓰는 스레드

스레드는 프로세스 안에서 도는 실행 흐름 하나입니다. 한 프로세스 안의 스레드들은 주소 공간 하나를 함께 씁니다. 스택만 스레드마다 하나씩 받습니다. 힙과 전역 변수는 같은 칸을 봅니다.

함께 쓰니 스레드끼리는 포인터 하나만 넘겨도 데이터를 나눌 수 있습니다. 대신 두 스레드가 같은 칸을 동시에 고치면 값이 꼬입니다.

프로세스끼리는 주소 공간이 갈라져 있습니다. 그래서 데이터를 나누려면 프로세스 간 통신이라는 별도 통로를 거쳐야 합니다.

주소 공간을 갈아 끼우는 비용

CPU 가 다른 일로 넘어가는 것을 문맥 교환이라고 합니다. 같은 프로세스 안의 스레드로 넘어갈 때는 주소 공간이 그대로라 대응표를 안 바꿔도 됩니다. 다른 프로세스로 넘어갈 때는 페이지 테이블도 그 프로세스 것으로 갈아 끼워야 합니다.

갈아 끼우는 데서 비용이 생깁니다. CPU 는 최근에 바꾼 번호를 TLB(Translation Lookaside Buffer, 주소 변환 캐시)라는 작은 캐시에 들고 있습니다. 주소 공간이 바뀌면 그 캐시 내용이 쓸모없어져 한동안 번호 바꾸기가 느려집니다. 프로세스 전환이 스레드 전환보다 무거운 이유 중 하나가 이것입니다.

주소 공간 크기와 쓰는 메모리의 차이

주소 공간이 크다고 그만큼 메모리를 쓰는 것은 아닙니다. 프로세스는 번지 범위를 먼저 예약만 해 둡니다. 실제 칸은 그 번지에 처음 접근할 때, 앞에서 본 페이지 폴트를 거쳐 받습니다.

그래서 주소 공간은 수십 GiB 인데 쓰는 메모리는 수백 MiB 인 프로세스가 흔합니다. top 같은 도구는 이 둘을 나눠 보여 줍니다.

VIRT(virtual, 가상 크기)는 예약한 주소 공간의 크기입니다. RES(resident, 상주 크기)는 그중 물리 메모리에 올라와 있는 양입니다. 메모리가 모자라는지 볼 때는 RES 쪽을 봐야 합니다.

같은 원리로 메모리 매핑도 돕니다. 파일을 주소 공간의 한 구역에 붙여 두면 그 번지를 읽을 때 파일 내용이 칸으로 올라옵니다. 큰 파일을 붙여도 읽은 만큼만 메모리를 씁니다.

네트워크에서 말하는 주소 공간

네트워크에서 주소 공간은 IP 주소로 나눠 줄 수 있는 값 전체를 뜻합니다. IP 주소는 인터넷에 붙은 장비마다 붙는 번호입니다. 메모리 주소와 마찬가지로 몇 비트로 적느냐가 범위를 정합니다.

IPv4(Internet Protocol version 4)는 주소를 32비트로 적습니다. 그래서 IPv4 주소 공간은 약 43억 개입니다. 인터넷에 붙는 장비가 이 수를 넘어서면서 주소가 모자라게 됐습니다. IPv6(Internet Protocol version 6)는 주소를 128비트로 넓혀 이 문제를 풉니다.

IPv4 주소는 192.168.0.1 처럼 점으로 나눈 숫자 넷으로 적습니다. 숫자 하나가 8비트라서 0부터 255 까지 씁니다. 넷을 합치면 32비트입니다.

전체 범위는 덩이로 잘라서 나눠 줍니다. 덩이는 앞쪽 몇 비트를 고정해서 정합니다. 앞 8비트, 곧 첫 숫자를 10 으로 고정하면 뒤 24비트만큼인 약 1,677만 개짜리 덩이가 됩니다.

이렇게 「앞 몇 비트가 같은 주소들」로 덩이를 적는 방식이 CIDR(Classless Inter-Domain Routing)입니다. 10.0.0.0/8 처럼 슬래시 뒤에 고정한 비트 수를 씁니다. /8 은 첫 숫자 하나만 고정했다는 뜻입니다.

flowchart TD
    W["IPv4 주소 공간 전체 · 32비트"]
    W --> A["10.0.0.0/8 · 사설 주소"]
    W --> B["172.16.0.0/12 · 사설 주소"]
    W --> C["192.168.0.0/16 · 사설 주소"]
    W --> L["127.0.0.0/8 · 자기 자신"]
    W --> P["나머지 대부분 · 공인 주소"]

그림의 사설 주소 세 덩이는 인터넷으로 나가지 않는 내부망에서 누구나 겹쳐 써도 되게 떼어 둔 범위입니다. 집과 회사마다 192.168.0.1 이 있어도 부딪히지 않는 까닭입니다.

공인 주소는 인터넷 전체에서 겹치지 않도록 한 곳에만 나눠 주는 주소입니다. 내부망 장비가 인터넷으로 나갈 때는 NAT(Network Address Translation, 네트워크 주소 변환)가 사설 주소를 공인 주소로 바꿔 줍니다.

127.0.0.0/8 덩이는 장비가 자기 자신을 가리킬 때 씁니다. 이 주소로 보낸 패킷은 밖으로 나가지 않고 그 장비로 돌아옵니다.

두 뜻이 닮은 곳

두 뜻은 뼈대가 같습니다. 주소 폭이 범위를 정합니다. 그 범위를 여러 쓰는 쪽에 나눠 줍니다. 목적은 번호가 겹치지 않게 막는 것입니다.

번호를 바꿔 주는 장치를 두는 것도 닮았습니다. 프로세스마다 0번지가 있어도 페이지 테이블이 물리 칸으로 바꿔 줍니다. 집마다 192.168.0.1 이 있어도 NAT 가 공인 주소로 바꿔 줍니다. 두 경우 모두 안쪽에서는 번호를 겹쳐 씁니다. 바깥으로 나갈 때만 겹치지 않는 번호로 바꿉니다.

관련 항목

주소 공간을 만들고 지키는 메모리 장치

가상 메모리 · 페이지 테이블 · MMU · TLB · 페이지 · 페이지 폴트 · 물리 메모리

주소 공간 안에서 쓰이는 번호

가상 주소 · 물리 주소 · 포인터 · 널 포인터 · 주소 폭 · 64비트

프로세스 주소 공간을 나눈 구역

코드 세그먼트 · 데이터 세그먼트 · 힙 · 스택 · 커널 공간 · 사용자 공간 · 메모리 매핑

주소 공간을 가지거나 함께 쓰는 실행 단위

프로세스 · 스레드 · 커널 · 운영체제 · fork · 시스템 호출 · 태스크

주소 공간 분리가 낳는 비용과 보호 수단

문맥 교환 · 프로세스 간 통신 · 공유 메모리 · 세그멘테이션 폴트 · 메모리 보호 · ASLR

네트워크 주소 공간을 나누고 바꾸는 방식

IP 주소 · IPv4 · IPv6 · CIDR · 서브넷 · 사설 IP 주소 · NAT · IPv4 주소 고갈

주소 공간을 재는 지표

가상 메모리 크기 · 상주 메모리 크기 · 메모리 사용량 · 스왑

다른 이름: address space