개발/공부 기록

컴퓨터 시스템 9장 핵심 정리

cedis 2026. 4. 16. 07:42

 

컴퓨터 시스템 공부 정리

컴퓨터 시스템 9장 핵심 정리
가상 메모리부터 malloc까지

이번 글에서는 컴퓨터 시스템 9장에서 다루는 내용을 한 번에 정리합니다. 핵심은 단순히 “메모리가 있다” 수준이 아니라, CPU가 주소를 어떻게 보고, 운영체제가 그 주소를 어떻게 관리하고, 그 위에서 malloc이 실제로 어떤 역할을 하는지를 연결해서 이해하는 것입니다.

특히 이번 정리는 9장 전체의 큰 그림을 잡되, 실제 과제와 바로 연결되는 9.9절 동적 메모리 할당기를 중심 축으로 묶어 보는 데 초점을 두었습니다.

이번 글에서 다루는 것

  • 물리 주소 방식과 가상 주소 방식의 차이
  • 페이지, 페이지 테이블, TLB가 왜 필요한지
  • 페이지 적중과 페이지 폴트가 실제로 어떻게 일어나는지
  • 메모리 매핑과 프로세스 보호가 왜 중요한지
  • 9.9절 malloc이 운영체제의 가상 메모리 위에서 어떻게 동작하는지
  • C 프로그래밍에서 메모리 버그가 왜 자주 터지는지

먼저 큰 그림부터: 9장은 무엇을 설명하는 장인가

9장의 핵심 질문은 하나입니다. “프로그램은 메모리를 어떻게 안전하고 효율적으로 쓰는가?” 여기서 말하는 메모리는 단순히 RAM 하나가 아닙니다. CPU, MMU, TLB, 페이지 테이블, 메인 메모리, 디스크, 그리고 사용자 수준의 할당기까지 모두 같이 봐야 합니다.

1. CPU는 가상 주소를 만든다
프로그램은 실제 RAM 주소를 직접 다루지 않고, 자신만의 가상 주소 공간 안에서 동작합니다.
2. MMU가 물리 주소로 번역한다
페이지 테이블과 TLB를 이용해 가상 주소를 실제 물리 메모리 위치로 바꿉니다.
3. RAM은 디스크의 캐시처럼 동작한다
당장 필요한 페이지만 RAM에 올리고, 나머지는 디스크에 둡니다.
4. 사용자 프로그램은 그 위에서 malloc을 쓴다
운영체제가 확보해 준 힙 페이지를 다시 잘게 쪼개 관리하는 것이 동적 메모리 할당기입니다.

1. 물리 주소와 가상 주소는 무엇이 다른가

초창기 시스템은 CPU가 실제 RAM 주소를 바로 불렀습니다. 이 방식은 단순하지만, 여러 프로그램이 동시에 실행되는 현대 환경에서는 문제가 큽니다. 한 프로그램의 버그가 다른 프로그램의 메모리나 커널 메모리를 덮어쓸 수 있기 때문입니다.

그래서 현대 시스템은 CPU가 가상 주소를 만들고, MMU가 이를 물리 주소로 바꾸는 구조를 사용합니다. 이 덕분에 각 프로세스는 자기만의 독립적인 주소 공간을 가진 것처럼 동작할 수 있습니다.

개념 흐름
CPU → 가상 주소 생성
MMU/TLB → 주소 번역
RAM → 실제 데이터 접근

2. 페이지와 페이지 테이블: 가상 메모리의 장부

가상 메모리는 메모리를 페이지라는 고정 크기 조각으로 쪼개서 관리합니다. 그리고 각 페이지가 현재 RAM에 있는지, 디스크에 있는지, 아예 아직 요청되지 않았는지를 페이지 테이블이 기록합니다.

여기서 핵심은 PTE(Page Table Entry)입니다. PTE 안의 유효 비트와 주소 필드를 보면 현재 페이지 상태를 알 수 있습니다.

상태 유효 비트 주소 필드 의미
캐시됨 1 물리 메모리 주소 지금 RAM에 올라와 있어서 바로 접근 가능
캐시되지 않음 0 디스크 주소 디스크에는 있지만 아직 RAM에는 없음
비할당됨 0 NULL 아직 메모리 요청 자체가 없는 빈 공간
헷갈리기 쉬운 부분
“비할당됨”은 디스크 공간을 남겨둔 상태가 아닙니다. 아직 요청조차 없어서 PTE 주소 칸도 비어 있는 상태입니다.

3. TLB가 왜 필요한가: 주소 번역이 느려지지 않는 이유

가상 주소를 쓴다는 말은 결국 주소를 한 번 더 번역해야 한다는 뜻입니다. 그러면 당연히 느려질 것 같지만, 실제로는 그렇지 않습니다. 이유는 MMU 내부에 있는 TLB 덕분입니다.

페이지 테이블 전체는 너무 크기 때문에 DRAM에 저장합니다. 대신 최근에 자주 쓴 번역 정보 일부만 MMU 안의 작은 캐시인 TLB에 저장합니다. 그러면 대부분의 주소 번역은 DRAM까지 가지 않고 칩 내부에서 바로 끝납니다.

원본 장부
페이지 테이블 전체는 DRAM에 저장
빠른 요약본
최근 자주 쓰는 PTE만 TLB에 저장
결과
대부분의 번역이 칩 내부에서 끝나므로 체감 속도 저하가 매우 작음

4. 페이지 적중과 페이지 폴트는 어떻게 일어나는가

CPU가 어떤 가상 주소를 요청했을 때, 해당 페이지가 이미 RAM에 있으면 페이지 적중입니다. 반대로 RAM에 없고 디스크에만 있으면 페이지 폴트가 발생하고 운영체제가 개입합니다.

페이지 폴트 처리 시나리오
① CPU가 가상 주소 접근
② MMU가 PTE 확인 → 유효 비트 0
③ 페이지 폴트 예외 발생
④ 운영체제가 희생자 페이지를 고르고 필요하면 디스크에 기록
⑤ 필요한 페이지를 디스크에서 RAM으로 가져옴
⑥ PTE 갱신 후 같은 명령을 다시 실행

여기서 중요한 건, 이 구조가 느려 보이지만 실제로는 지역성(locality) 덕분에 잘 동작한다는 점입니다. 프로그램은 한 시점에 전체 메모리를 다 쓰지 않고, 일부 페이지를 집중적으로 반복해서 쓰는 경향이 있기 때문입니다.

5. 메모리 매핑과 보호: 운영체제가 해주는 일

가상 메모리는 단순히 디스크를 RAM처럼 쓰게 하는 기능만 하는 것이 아닙니다. 프로그램 로딩을 단순하게 만들고, 공통 코드를 여러 프로세스가 공유할 수 있게 하고, 잘못된 접근을 막는 보호 장치 역할도 합니다.

메모리 매핑
실행 파일이나 라이브러리를 디스크에서 바로 가상 메모리 영역에 연결합니다.
공유 객체
printf 같은 라이브러리 코드를 프로세스마다 복사하지 않고 하나의 물리 페이지를 공유합니다.
메모리 보호
권한 비트로 사용자 프로세스가 커널 메모리나 남의 메모리를 함부로 건드리지 못하게 막습니다.
세그멘테이션 오류가 나는 전형적인 상황
존재하지 않는 주소에 접근하거나, 접근 권한이 없는 페이지에 쓰기를 시도하면 MMU와 운영체제가 이를 막고 프로그램을 종료합니다.

6. 9.9절: 우리가 과제로 만난 malloc의 정체

여기서부터가 실전입니다. 운영체제는 페이지 단위로 큰 메모리 덩어리를 주지만, 프로그램은 보통 8바이트, 24바이트, 100바이트처럼 훨씬 작은 단위로 메모리를 요청합니다. 이 사이를 이어주는 것이 바로 동적 메모리 할당기입니다.

즉, 운영체제가 확보해 준 힙 공간을 다시 잘게 나누고, 빈 공간을 찾고, 필요 없어진 블록을 합쳐 주는 로직을 9.9절에서 배웁니다. 우리가 과제로 구현한 것도 바로 이 부분입니다.

int *p = malloc(sizeof(int) * n);
/* ... p 사용 ... */
free(p);

사용자는 위처럼 간단하게 쓰지만, 내부에서는 훨씬 많은 일이 일어납니다. 블록 크기를 맞추기 위한 정렬, 헤더와 풋터 기록, 적당한 가용 블록 찾기, 남는 공간 분할, 인접 빈 블록 병합이 모두 포함됩니다.

헤더와 풋터
블록 크기와 할당 여부를 저장하는 메타데이터입니다.
묵시적 가용 리스트
헤더의 크기 정보를 따라 힙 전체를 순회하며 빈 블록을 찾는 가장 기본 구조입니다.
경계 태그 병합
풋터 덕분에 이전 블록 정보를 상수 시간에 확인해 빈 블록을 빠르게 합칠 수 있습니다.

7. 단편화가 왜 문제인가

9.9절에서 가장 중요한 키워드 중 하나가 단편화입니다. 메모리는 남아 있는데도 제대로 못 쓰는 상황이 생기기 때문입니다.

종류 무슨 뜻인가 왜 생기나
내부 단편화 블록 내부에 남는 낭비 공간 정렬, 최소 블록 크기, 패딩 때문
외부 단편화 전체 빈 공간은 충분하지만 큰 연속 블록이 없음 작은 빈 블록이 여기저기 흩어져 있기 때문
오류 단편화 붙어 있는 빈 블록을 하나처럼 못 보는 상태 병합 로직이 없거나 늦어서

그래서 좋은 할당기는 단순히 “빈 공간을 하나 찾아준다”에서 끝나지 않습니다. 분할과 병합을 어떻게 설계하느냐가 성능과 메모리 효율을 같이 좌우합니다.

8. 9장 후반부를 짧게 묶으면

9.10절 가비지 컬렉션은 더 이상 도달할 수 없는 블록을 자동으로 수거하는 방식입니다. 핵심은 루트에서 닿는 블록은 살리고, 닿지 않는 블록은 쓰레기로 본다는 점입니다.

9.11절은 C에서 흔히 터지는 메모리 버그를 모아 둔 절입니다. 잘못된 포인터 역참조, 초기화되지 않은 메모리 읽기, 버퍼 오버플로우, 이미 free한 메모리 접근, 메모리 누수 같은 것들이 여기에 들어갑니다.

실전 감각으로 기억할 것
가상 메모리를 안다고 해서 안전한 C 프로그램이 자동으로 만들어지지는 않습니다. 오히려 포인터와 힙을 직접 다루는 만큼, 구조를 이해하지 못하면 버그가 훨씬 치명적으로 터집니다.

이번 글에서 기억할 것

  • CPU는 가상 주소를 만들고, MMU가 물리 주소로 바꾼다.
  • 페이지 테이블은 페이지의 현재 상태를 기록하는 장부다.
  • TLB는 주소 번역 속도 저하를 막아 주는 초고속 캐시다.
  • RAM은 디스크의 캐시처럼 동작하고, 페이지 폴트는 그 사이 교체 작업이다.
  • malloc은 운영체제가 준 큰 힙 공간을 다시 잘게 쪼개 관리하는 사용자 수준 할당기다.

스스로 점검

  • 유효 비트가 0이라고 해서 항상 “할당되지 않음”인가?
  • 페이지 테이블은 왜 CPU 안이 아니라 DRAM에 저장될까?
  • TLB가 없다면 가상 주소 방식은 왜 더 느려질까?
  • 운영체제가 페이지 단위로 메모리를 주는데, 왜 사용자 입장에서는 바이트 단위 malloc 요청이 가능할까?

헷갈리기 쉬운 구분

  • 가상 메모리는 RAM 자체가 아니라, 디스크와 RAM을 묶어 쓰게 해 주는 관리 방식이다.
  • 페이지 테이블은 전체 장부이고, TLB는 그중 최근 번역본만 담은 작은 캐시다.
  • malloc은 운영체제가 아니라 사용자 수준 할당기 인터페이스다. 운영체제는 보통 페이지 단위로만 준다.
  • 세그멘테이션 오류는 “메모리가 없어서”가 아니라, 잘못된 주소 또는 권한 위반 때문에 나는 경우가 많다.

정리

컴퓨터 시스템 9장은 단순한 메모리 장이 아닙니다. 하드웨어의 주소 번역, 운영체제의 페이지 관리, 파일과 프로세스의 연결, 그리고 C 프로그래머가 직접 구현하는 할당기까지 모두 이어지는 장입니다.

그래서 9장을 제대로 이해하면 가상 메모리, 페이지 폴트, TLB, 메모리 매핑, malloc/free가 각각 따로 놀지 않고 하나의 흐름으로 보이기 시작합니다.

한 줄 정리
9장은 “운영체제가 페이지를 관리하고, 하드웨어가 주소를 번역하며, 그 위에서 프로그램이 힙을 직접 다룬다”는 흐름을 이해하는 장이다.

가상 메모리와 malloc을 질문 중심으로 다시 이해하기

아래 글에서는 컴퓨터 시스템 책 9장에서 공부한 내용을 한 번에 정리합니다. 다만 단순한 챕터 요약이 아니라, 실제로 공부하면서 계속 막혔던 질문들, 예를 들면 “.bss는 정확히 뭐지?”, “페이지 테이블은 왜 DRAM에 있지?”, “헤더와 풋터는 왜 둘 다 필요하지?”, malloc이 운영체제의 가상 메모리와 어디서 만나는 거지?” 같은 포인트를 중심으로 다시 묶어 보겠습니다.

즉, 책 내용을 “순서대로 요약”하기보다 “헷갈렸던 질문이 결국 어느 구조에서 풀리는가”를 따라가는 글에 가깝습니다.

이번 글에서 다루는 것

  • 물리 주소와 가상 주소가 왜 갈라졌는지
  • 페이지, 페이지 테이블, TLB, 페이지 폴트가 실제로 무슨 역할을 하는지
  • 메모리 매핑, 공유 객체, Copy-on-Write가 왜 중요한지
  • 프로그램 메모리 구조(.text, .data, .bss, heap, stack)의 의미
  • malloc, calloc, realloc, free가 힙 위에서 어떻게 동작하는지
  • 묵시적 가용 리스트, 경계 태그, 병합, 분할, 배치 정책의 의미
  • 명시적 가용 리스트, 분리 가용 리스트, 버디 시스템까지 어디가 달라지는지
  • 가비지 컬렉션과 C 메모리 버그를 왜 같이 봐야 하는지

1. 9장의 진짜 주제는 “메모리를 누가, 어디서, 어떻게 관리하느냐”이다

9장을 처음 읽으면 페이지, TLB, 디스크, 힙, malloc, 가비지 컬렉션이 서로 다른 얘기처럼 보입니다. 그런데 이 장의 중심은 하나입니다.

프로그램이 메모리를 안전하고 효율적으로 쓰도록, 하드웨어와 운영체제와 사용자 수준 코드가 역할을 나눠 갖는 구조를 설명하는 장이다.

CPU
가상 주소를 만든다.
MMU + TLB + 페이지 테이블
가상 주소를 물리 주소로 바꾸고, 접근 권한도 함께 검사한다.
운영체제
RAM과 디스크 사이에서 페이지를 올리고 내리고, 메모리 보호와 매핑을 관리한다.
동적 메모리 할당기
운영체제가 준 힙 공간을 다시 잘게 쪼개 프로그램에 맞는 크기로 나눠 준다.

2. 물리 주소 방식과 가상 주소 방식: 왜 굳이 MMU라는 번역기를 다는가

초창기 시스템은 CPU가 물리 주소를 직접 사용했습니다. 말 그대로 RAM의 진짜 번지수를 바로 불러서 데이터를 가져오는 구조입니다. 직관적이지만, 여러 프로그램이 동시에 도는 현대 시스템에는 치명적인 문제가 있습니다.

  • 한 프로그램의 버그가 다른 프로그램 메모리를 덮어쓸 수 있습니다.
  • 커널 메모리를 잘못 건드릴 수도 있습니다.
  • 프로그램이 실제 RAM의 어디에 올라가 있는지까지 모두 신경 써야 합니다.

그래서 현대 시스템은 CPU가 직접 물리 주소를 다루지 않고, 먼저 가상 주소를 만들게 합니다. 그리고 CPU 안의 MMU(Memory Management Unit)가 이 주소를 물리 주소로 번역합니다.

그림으로 잡는 흐름
CPU가 “이 가상 주소의 데이터가 필요하다” 요청
MMU가 페이지 테이블/TLB를 보고 실제 물리 주소 확인
RAM에서 데이터 접근 또는 디스크에서 페이지를 가져오는 절차 시작

여기서 중요한 건 MMU가 단순한 “보조 시스템”이 아니라는 점입니다. MMU는 CPU 내부에 붙어 있는 핵심 하드웨어이고, 주소 번역과 접근 권한 검사를 동시에 처리합니다. 그래서 사용자 프로그램이 잘못된 메모리에 접근하면 바로 세그멘테이션 오류가 나는 것입니다.

3. 페이지와 페이지 테이블: “이 데이터가 지금 어디 있지?”를 기록하는 장부

가상 메모리 시스템은 메모리를 페이지 단위로 쪼개서 관리합니다. 그리고 각 가상 페이지마다 PTE(Page Table Entry)라는 장부 항목을 둡니다. 이 PTE를 보면 지금 그 페이지가 RAM에 있는지, 디스크에만 있는지, 아직 요청도 안 된 상태인지를 알 수 있습니다.

상태 유효 비트 주소 필드
캐시됨 1 물리 메모리 주소 페이지가 현재 RAM에 올라와 있음
캐시되지 않음 0 디스크 주소 디스크에는 있지만 아직 RAM에는 없음
비할당됨 0 NULL 아직 요청조차 없는 상태
여기서 많이 헷갈리는 부분
비할당됨은 “디스크에 남겨둔 여유 공간”이 아닙니다. 아직 요청 자체가 없어서 디스크 공간도 없고, PTE의 주소 필드도 비어 있는 상태입니다.

4. “가상 주소면 더 느린 것 아닌가?”를 해결하는 TLB와 다단 페이지 테이블

이 질문은 아주 자연스럽습니다. 가상 주소를 쓴다는 건 결국 주소 번역이 한 번 더 들어간다는 뜻이니까요. 실제로 페이지 테이블을 매번 DRAM에서 읽어야 한다면 느려질 수밖에 없습니다.

그래서 시스템은 두 가지 장치를 둡니다. 첫째는 TLB, 둘째는 다단 페이지 테이블입니다.

TLB
페이지 테이블 전체는 DRAM에 있지만, 최근에 자주 쓴 번역 정보만 MMU 내부의 작은 캐시에 보관합니다. 대부분의 주소 번역은 여기서 끝납니다.
다단 페이지 테이블
안 쓰는 거대한 가상 주소 공간까지 장부를 전부 만들면 메모리 낭비가 심하므로, 실제로 필요한 하위 테이블만 생성합니다.

여기서 또 한 번 자주 막히는 질문이 있습니다. “하위 테이블은 언제 만들어지지?” 답은 실제로 그 가상 주소 구간에 대한 할당이 생길 때입니다.

예를 들어 어떤 프로그램이 실행만 된 상태라면 코드, 데이터, 스택 근처 몇 구간만 실제로 필요합니다. 그런데 나중에 malloc으로 큰 힙 공간을 요청하면, 그제서야 해당 가상 주소 범위를 관리할 하위 페이지 테이블이 만들어집니다.

정리
번역 속도 문제는 TLB로, 장부 크기 문제는 다단 페이지 테이블로 해결합니다. 그래서 가상 메모리는 느려질 것 같지만 실제로는 충분히 빠르게 동작합니다.

5. 페이지 적중과 페이지 폴트: 실제로는 어떤 시나리오로 돌아가나

페이지 적중은 간단합니다. CPU가 가상 주소를 요청하고, MMU가 PTE를 봤는데 유효 비트가 1이면 이미 RAM에 올라와 있는 페이지이므로 바로 접근합니다.

페이지 폴트는 조금 더 드라마틱합니다. 필요한 페이지가 RAM에 없고 디스크에만 있을 때 발생합니다. 하지만 이름만 “오류”일 뿐, 실제로는 가상 메모리 시스템이 정상적으로 처리해야 하는 작업입니다.

페이지 폴트 처리 흐름
① CPU가 어떤 가상 주소를 참조한다.
② MMU가 PTE를 확인했는데 유효 비트가 0이다.
③ 페이지 폴트 예외가 발생한다.
④ 운영체제가 RAM에서 희생자 페이지를 고른다.
⑤ 희생자 페이지가 수정된 상태면 디스크에 먼저 기록한다.
⑥ 필요한 페이지를 디스크에서 RAM으로 가져온다.
⑦ 페이지 테이블을 갱신하고, 실패했던 명령을 다시 실행한다.

그리고 이 구조가 실제로 쓸 만한 이유는 지역성(locality) 때문입니다. 프로그램은 전체 메모리를 고르게 쓰지 않고, 어떤 시점에는 작은 작업 집합만 반복해서 쓰는 경향이 있으므로 일단 RAM에 올라온 페이지를 계속 재사용하게 됩니다.

6. 메모리 매핑과 Copy-on-Write: 디스크, 프로세스, 라이브러리를 묶는 방식

메모리 매핑은 디스크의 파일이나 무기명 파일을 가상 메모리 영역에 연결하는 방식입니다. 이 덕분에 운영체제는 실행 파일 로딩, 라이브러리 공유, 프로세스 복사 같은 일을 훨씬 단순하고 빠르게 처리할 수 있습니다.

구분 설명
Regular file mapping 실행 파일, 공유 라이브러리 같은 실제 파일을 메모리에 연결
Anonymous mapping 디스크 파일과 직접 연결되지 않는 0으로 초기화된 페이지를 할당
Shared object 여러 프로세스가 같은 물리 페이지를 공유해 메모리 절약
Copy-on-Write 처음에는 복사하지 않고 공유하다가, 실제 쓰기 시점에만 복사

공부하면서 나왔던 아주 좋은 질문이 있습니다. printf 같은 건 일일이 복사하면 낭비 아닌가?” 맞습니다. 그래서 공유 객체를 씁니다. 여러 프로세스의 가상 주소가 하나의 물리 페이지를 가리키게 해서 공통 코드를 돌려쓰는 것입니다.

fork는 메모리를 처음부터 통째로 복사하면 너무 비싸므로 Copy-on-Write로 처리합니다. 부모와 자식이 처음에는 같은 물리 페이지를 공유하고, 어느 한쪽이 실제로 쓰기 작업을 하는 순간에만 새 사본을 만들어 줍니다.

7. 프로그램 메모리 구조: .text, .data, .bss, heap, stack를 다시 정확히 구분하기

malloc을 이해하려면 먼저 프로그램 전체 메모리 지형을 알아야 합니다. 특히 .data, .bss, heap, stack를 자꾸 섞어 생각하면 뒤에서 다 꼬입니다.

영역 무엇이 들어가나 예시
.text 기계어 코드 함수 본문
.data 0이 아닌 값으로 초기화된 전역/정적 변수 int g = 10;
.bss 초기화되지 않았거나 0으로 초기화된 전역/정적 변수 int g2;, static int x = 0;
Heap 동적 할당 메모리 malloc, calloc, realloc
Stack 함수 호출 프레임, 지역 변수 int local = 3;

여기서 꼭 잡고 가야 할 포인트는 이것입니다. malloc, calloc, realloc이 할당하는 메모리는 전부 힙이다. .data나 .bss와는 전혀 다릅니다.

즉, calloc이 0으로 초기화해 준다고 해서 .bss에 들어가는 것이 아닙니다. “0으로 초기화된 힙 메모리”를 줄 뿐입니다.

8. 왜 동적 메모리 할당이 필요한가: 고정 배열로는 해결이 안 되는 문제

9.9절 초반의 요지는 간단합니다. 프로그램을 실제로 실행하기 전에는 필요한 메모리 크기를 모르는 경우가 많습니다. 사용자 입력 크기, 데이터 개수, 트리 노드 수 같은 것은 실행 중에야 정해집니다.

그래서 “최대 크기”를 하드코딩한 정적 배열만으로 처리하면 두 가지 문제가 생깁니다.

  • 최대치를 넘으면 프로그램이 깨집니다.
  • 최대치를 너무 크게 잡으면 메모리를 심하게 낭비합니다.

9. malloc, calloc, realloc, free를 가장 헷갈리지 않게 정리하면

함수 무엇을 하나 초기화 여부
malloc 힙에서 메모리 블록 할당 초기화 안 함
calloc 힙에서 메모리 블록 할당 0으로 초기화
realloc 기존 힙 블록 크기 조절 상황에 따라 복사/이동
free 할당된 힙 블록 반환 데이터를 지우는 게 아니라 “가용 상태”로 바꿈
특히 자주 하는 오해
free(p)를 했다고 해서 변수 p 자체가 사라지는 것은 아닙니다. 포인터 값은 그대로 남아 있고, 그 주소를 다시 쓰면 이미 반환된 메모리를 건드리는 심각한 버그가 됩니다.

10. 정렬과 패딩: “32비트는 8의 배수, 64비트는 16의 배수”의 뜻

이 부분도 처음 보면 표현이 이상합니다. “블록은 8바이트 배수”라고 했다가 “주소는 16바이트 배수”라고 하니 서로 다른 말처럼 들리기 때문입니다.

하지만 연결해 보면 한 이야기입니다. malloc이 돌려주는 것은 결국 블록의 시작 주소입니다. CPU가 데이터를 안전하고 빠르게 읽으려면 이 시작 주소가 특정 배수 경계에 맞아야 하고, 그 결과 블록 크기도 같은 배수 단위로 맞춰집니다.

그래서 사용자가 2바이트만 요청해도 할당기는 헤더/풋터와 정렬 규칙까지 포함해서 훨씬 큰 블록을 줄 수 있습니다. 이때 낭비되는 공간이 바로 내부 단편화입니다.

한 줄로 잡기
“주소를 배수 경계에 맞추려면 블록 크기도 배수 단위로 맞춰야 하고, 그 과정에서 패딩이 생긴다.”

11. sbrk와 mmap: 운영체제에게 힙을 더 달라고 하는 방법

malloc은 모든 메모리를 직접 만드는 함수가 아닙니다. 빈 블록이 없으면 결국 운영체제에게 새로운 메모리 공간을 요청해야 합니다. 전통적으로는 sbrk가 그 역할을 했고, 현대 구현에서는 큰 할당에 mmap도 많이 씁니다.

void *sbrk(intptr_t incr);
  • incr는 “힙을 얼마나 늘리거나 줄일지”를 뜻하는 증가량입니다.
  • 실패하면 -1을 반환하고 보통 메모리 부족 상태를 의미합니다.
  • 양수면 힙 확장, 음수면 힙 축소이지만 음수의 경우 반환값이 “이전 brk”라서 다루기 더 까다롭습니다.

또 하나 중요한 포인트는 운영체제가 매번 7바이트, 13바이트처럼 자잘하게 맞춤형으로 주지 않는다는 점입니다. 시스템 호출은 비싸므로 할당기는 보통 청크 단위로 넉넉하게 받아 두고, 그 안에서 잘라 씁니다.

이때 바로 9.9.12 구현에서 나오는 CHUNKSIZE가 등장합니다.

12. 묵시적 가용 리스트: 9.9절에서 가장 먼저 만나는 할당기 구조

기본 아이디어는 “힙에 있는 모든 블록을 앞에서부터 차례대로 훑어 가며, 크기가 맞는 빈 블록을 찾자”입니다. 이때 각 블록 앞에 헤더를 붙여 블록 크기와 할당 여부를 저장합니다.

블록 크기는 항상 정렬 규칙 때문에 8의 배수이므로, 하위 3비트가 항상 0입니다. 이 여분 비트 중 1비트를 할당 여부 저장에 활용하는 것이 바로 비트 훔치기(bit stealing)입니다.

그림 9.35를 가장 짧게 설명하면
하나의 블록은 보통 헤더 + 페이로드 + (필요하면 패딩/풋터)로 구성되고, 헤더에는 “이 블록 전체 크기”와 “지금 사용 중인가”가 같이 들어갑니다.
#define PACK(size, alloc) ((size) | (alloc))
#define GET_SIZE(p) (GET(p) & ~0x7)
#define GET_ALLOC(p) (GET(p) & 0x1)

“00a”처럼 보이는 표시는 바로 이 구조를 뜻합니다. 아래쪽 세 비트 중 두 비트는 비어 있고, 마지막 한 비트 a가 할당 상태를 나타내는 것입니다.

여기서 추가로 많이 헷갈리는 질문이 “헤더와 풋터는 값이 같은데 어떻게 구분하지?”인데, 답은 내용이 아니라 위치입니다. 블록 포인터 기준으로 정해진 상대 주소를 계산해서 “여기는 헤더”, “여기는 풋터”라고 해석합니다.

13. CSAPP 그림 9.34, 9.37, 9.38, 9.40을 한 번에 묶어 이해하기

공부할 때 헷갈렸던 그림들은 사실 하나의 흐름으로 묶을 수 있습니다.

도해 A. 할당 → 반환 → 더 작은 블록 재사용 (그림 9.34 대응)
중간 블록을 free한 뒤, 더 작은 요청이 오면 그 앞부분부터 다시 쓰는 구조입니다.
파랑 p1 · 초록 p2 · 주황 p3 · 보라 p4 · 회색 free · 진회색 padding
(a) p1 = malloc(4 * sizeof(int))
 
(b) p2 = malloc(5 * sizeof(int))
 
(c) p3 = malloc(6 * sizeof(int))
 
(d) free(p2)
 
(e) p4 = malloc(2 * sizeof(int))
 
도해 B. 큰 free 블록 분할하기 (그림 9.37 대응)
요청보다 큰 가용 블록을 찾았을 때 필요한 크기만 할당하고, 남는 공간은 다시 free 블록으로 남겨 둡니다.
before
8/1 16/1 32/0 16/1 0/1
↓ split
after
8/1 16/1 16/1 16/0 16/1 0/1
도해 C. 붙어 있는데도 큰 블록으로 못 보는 경우 (그림 9.38 대응)
free 블록이 서로 이웃해 있어도 병합하지 않으면 큰 요청을 처리하지 못합니다. 이것이 오류 단편화입니다.
8/1 16/1 16/0 16/0 16/1 0/1
실제로는 인접 free 블록 2개라서 총 32바이트인데, 병합하지 않으면 24바이트 요청도 실패할 수 있습니다.
도해 D. coalesce의 4가지 경우 (그림 9.40 대응)
현재 블록 n을 free할 때, 앞 블록 m1과 뒤 블록 m2의 상태에 따라 결과가 달라집니다.
Case 1. 앞뒤 모두 alloc
현재 블록만 free로 바뀝니다.
m1 a n f m2 a
Case 2. 뒤만 free
n과 m2를 합쳐 하나의 free 블록으로 만듭니다.
m1 a n + m2 f
Case 3. 앞만 free
m1과 n을 합쳐 하나의 free 블록으로 만듭니다.
m1 + n f m2 a
Case 4. 앞뒤 모두 free
m1, n, m2 세 블록을 한 번에 병합합니다.
m1 + n + m2 f

결국 이 그림들이 말하는 것은 하나입니다. 할당기는 빈 공간을 찾아 쓰는 것만이 아니라, 남는 공간을 다시 쪼개고, 붙어 있는 빈 공간은 다시 합쳐야 한다.

14. 배치 정책: First fit, Next fit, Best fit은 무엇이 다른가

“가용 리스트를 검색한다”는 말은 결국 빈 블록들 중 어디를 줄지 찾는다는 뜻입니다. 여기서 정책이 갈립니다.

정책 동작 장단점
First fit 처음부터 훑다가 처음 맞는 블록 선택 단순하고 빠르지만 앞부분에 조각이 쌓일 수 있음
Next fit 이전 검색이 멈춘 지점부터 다시 시작 검색은 빠를 수 있지만 이미 지나친 앞부분의 빈 공간을 늦게 활용함
Best fit 전체를 다 보고 가장 딱 맞는 블록 선택 메모리 효율은 좋지만 검색 비용이 큼

Next fit에서 “어디까지 봤는지”를 기억하는 값은 힙 내부에 저장하는 것이 아니라, 보통 정적 전역 변수 같은 형태로 따로 들고 있습니다. 그래서 이미 지나친 앞부분에서 새로 free가 일어나도 즉시 보지 못하고, 나중에 다시 처음으로 돌아왔을 때 발견합니다.

또 묵시적 가용 리스트의 끝은 에필로그 블록의 크기 0 헤더로 알 수 있습니다. “크기가 0인 블록”을 만나면 끝이라는 뜻입니다.

15. 풋터와 경계 태그: 왜 상수 시간 병합이 가능한가

헤더만 있으면 다음 블록은 쉽게 찾을 수 있습니다. 현재 블록 크기만큼 앞으로 가면 되기 때문입니다. 문제는 이전 블록입니다. 이전 블록이 어디서 시작하는지는 헤더만으로는 바로 알 수 없습니다.

그래서 블록 끝에도 헤더와 같은 값을 복사한 풋터(경계 태그)를 둡니다. 그러면 현재 블록 시작점 바로 앞을 읽어 이전 블록의 크기와 상태를 즉시 알 수 있습니다.

#define HDRP(bp)  ((char *)(bp) - WSIZE)
#define FTRP(bp)  ((char *)(bp) + GET_SIZE(HDRP(bp)) - DSIZE)
#define NEXT_BLKP(bp) ((char *)(bp) + GET_SIZE(((char *)(bp) - WSIZE)))
#define PREV_BLKP(bp) ((char *)(bp) - GET_SIZE(((char *)(bp) - DSIZE)))

핵심은 “이 값이 헤더인지 풋터인지 따로 식별하는 알고리즘”이 있는 게 아니라, 그 위치는 항상 헤더로 쓰기로 약속했다, 그 위치는 항상 풋터로 쓰기로 약속했다는 구조를 믿고 계산한다는 점입니다.

이 구조 덕분에 그림 9.40의 4가지 케이스, 즉 앞뒤 모두 할당됨 / 뒤만 비어 있음 / 앞만 비어 있음 / 앞뒤 모두 비어 있음이 모두 상수 시간에 처리됩니다.

16. 프롤로그와 에필로그 블록은 왜 필요한가

프롤로그와 에필로그는 실제 데이터를 담기 위한 블록이 아니라, 힙의 양 끝 경계 조건을 단순하게 만들기 위한 가짜 블록입니다.

  • 프롤로그 블록: 힙 시작 쪽 경계 검사 단순화
  • 에필로그 블록: 힙 끝 쪽 경계 검사 단순화

그래서 병합할 때 “지금이 첫 블록인가?”, “지금이 마지막 블록인가?” 같은 예외 처리를 코드 전체에 뿌리지 않아도 됩니다.

17. 9.9.12 구현 코드는 결국 무엇을 하는가 (맬록랩 게시글 참조)

9.9.12는 지금까지 배운 내용을 실제 C 코드로 묶는 파트입니다. 과제의 기본 뼈대도 사실상 여기서 나옵니다.

mm_init
힙을 초기화하고 프롤로그/에필로그를 세운 뒤 초기 가용 블록을 만듭니다.
extend_heap
빈 공간이 없을 때 운영체제로부터 힙을 더 가져옵니다.
find_fit / place
맞는 가용 블록을 찾고, 필요하면 분할해서 배치합니다.
mm_free / coalesce
블록을 반환하고 인접한 가용 블록과 병합합니다.

즉 9.9.12는 “과제와는 별개인 예제 코드”가 아니라, 과제에서 기본 베이스라인으로 삼는 구조라고 보면 됩니다.

18. 명시적 가용 리스트와 분리 가용 리스트: 왜 더 빠른가

묵시적 가용 리스트는 단순하지만 느립니다. 빈 블록을 찾기 위해 할당된 블록까지 전부 훑어야 하기 때문입니다. 이를 개선한 것이 명시적 가용 리스트입니다.

명시적 가용 리스트에서는 빈 블록의 페이로드 영역에 pred / succ 포인터를 넣어 빈 블록끼리만 이중 연결 리스트를 만듭니다. 그러면 할당된 블록을 무시하고 가용 블록만 빠르게 검색할 수 있습니다.

구조 핵심 차이 효과
묵시적 가용 리스트 힙 전체 블록을 순회 구현은 단순하지만 검색이 느림
명시적 가용 리스트 빈 블록만 연결 검색 비용 감소
분리 가용 리스트 크기 클래스별로 여러 리스트 관리 검색 속도와 공간 효율을 같이 잡기 쉬움

또 명시적 가용 리스트에서는 새로 반환된 블록을 리스트 어디에 넣을지도 정책이 됩니다.

  • LIFO: 맨 앞에 바로 넣음. 빠르지만 단편화가 늘 수 있음
  • 주소 순서 정렬: 메모리 주소 기준으로 정렬 삽입. 느리지만 공간 효율이 좋아질 수 있음

여기서도 핵심은 “어떤 자료구조를 쓰느냐”가 “어떤 fit 정책을 쓰느냐”보다 먼저라는 점입니다. 동일한 First fit이라도 묵시적 리스트 위에서 돌리느냐, 명시적 리스트 위에서 돌리느냐에 따라 체감 성능이 크게 달라집니다.

19. 분리 맞춤과 버디 시스템: 왜 2의 제곱수 이야기가 나오는가

분리 가용 리스트 안에서도 몇 가지 변형이 있습니다. 간단한 분리 저장장치는 너무 원초적이라 메모리 낭비가 심하고, 실제로 더 쓸 만한 것은 분리 맞춤(segregated fit)입니다.

분리 맞춤은 크기 클래스별로 가용 리스트를 나누고, 각 리스트 안에서 블록을 찾은 뒤 필요하면 분할하고 병합까지 수행합니다. 즉, 빠른 검색과 괜찮은 메모리 효율을 동시에 노리는 구조입니다.

버디 시스템은 여기서 더 극단적으로 가서 블록 크기를 무조건 2의 제곱수 단위로만 다룹니다. 예를 들어 33바이트가 필요해도 64바이트 블록을 주는 식입니다.

왜 굳이 이렇게 무식하게 보이는가
2의 제곱수 크기라면 반으로 나누거나, 짝꿍 블록과 다시 합치는 과정이 비트 연산 하나로 단순해집니다. 그래서 분할과 병합 속도가 매우 빠릅니다.

대신 내부 단편화가 커질 수 있습니다. 결국 할당기 설계는 언제나 “속도”와 “메모리 효율” 사이의 타협이라는 점이 다시 드러납니다.

20. 가비지 컬렉션과 9.9절은 어떻게 연결되는가

9.10절의 Mark & Sweep은 루트에서 도달 가능한 블록은 전부 표시하고, 도달할 수 없는 블록은 가비지로 보고 쓸어 담는 방식입니다.

여기서 중요한 연결점은, 가비지 컬렉터가 “새로운 메모리 시스템”을 만드는 것이 아니라 9.9절에서 배운 블록 구조와 free 로직을 재사용한다는 것입니다. 즉, 마킹되지 않은 블록을 결국 가용 블록으로 되돌리는 작업은 9.9절의 할당기 위에서 일어납니다.

다만 C에서는 숫자와 포인터를 구분하기 어렵기 때문에 보수적으로 동작할 수밖에 없고, 그래서 메모리 누수를 완벽하게 막지 못할 수 있습니다.

21. 9.11절 C 메모리 버그: 왜 이 장 끝에 이걸 붙여놨는가

앞에서 구조를 아무리 잘 배워도, 실제 C 코드에서 포인터를 잘못 쓰면 그 모든 보호 장치와 관리 로직이 한순간에 문제 상황으로 바뀝니다.

버그 대표 상황
잘못된 포인터 역참조 scanf("%d", val)처럼 주소 대신 값을 넘김
초기화되지 않은 메모리 읽기 malloc이 자동으로 0을 채워줄 거라 착각
버퍼 오버플로우 범위를 넘는 입력을 받아 스택/힙 메모리를 덮어씀
Off-by-one 반복문 경계 하나 잘못 잡아 배열 밖을 건드림
이미 반환된 블록 참조 free 이후 같은 포인터를 다시 사용
메모리 누수 더 이상 쓸 수 없는 힙 블록을 반환하지 않음

대화에서 실제로 나왔던 질문 빠르게 다시 정리

“malloc이랑 calloc, realloc은 .bss나 .data에 저장되는 거야?”
아닙니다. 세 함수로 얻는 공간은 전부 힙입니다. .data.bss는 전역/정적 변수용입니다.
“32비트에서는 8배수, 64비트에서는 16배수라는 말이 무슨 뜻이야?”
반환되는 블록의 시작 주소가 그 배수 정렬을 만족해야 한다는 뜻입니다. 그래서 실제 블록 크기도 패딩 때문에 함께 커집니다.
“패딩 때문에 메모리 낭비가 심해질 수도 있어?”
그렇습니다. 이것이 내부 단편화입니다. 특히 작은 요청이 반복될수록 관리 오버헤드와 패딩 비율이 커집니다.
“sbrk는 단순히 힙 경계선을 움직이는 애야?”
핵심적으로 맞습니다. 다만 성공 시 새 경계가 아니라 이전 brk를 반환하고, 음수 증가량은 힙 축소 자체는 가능하지만 반환값 해석이 까다롭습니다.
“가용 리스트를 검색한다는 게 무슨 뜻이야?”
할당기가 현재 힙에서 비어 있는 블록들 중 요청 크기를 담을 수 있는 블록을 찾는 과정입니다. 묵시적 리스트는 힙 전체를, 명시적 리스트는 빈 블록만 따라갑니다.
“Next fit은 지나간 앞부분에서 새로 free된 블록을 바로 못 쓰는 거야?”
맞습니다. 마지막 탐색 종료 지점부터 다시 검색하기 때문에, 이미 지나간 앞부분에 새로 생긴 빈 블록은 다시 한 바퀴 돌 때까지 못 볼 수 있습니다.
“풋터는 헤더 주소를 담는 거야?”
아닙니다. 풋터는 헤더와 같은 값, 즉 블록 크기와 할당 비트를 복사해 둔 것입니다. 그 값을 읽어 이전 블록 크기를 알아내는 데 씁니다.
“헤더와 풋터는 값이 같은데 이게 헤더인지 풋터인지는 어떻게 알아?”
값으로 구별하는 것이 아니라 위치 규칙으로 구별합니다. 현재 payload 포인터에서 몇 바이트 앞은 헤더, 몇 바이트 뒤는 풋터라는 식으로 계산해서 사용합니다.
“명시적 가용 리스트에서 말하는 리스트는 어디서 갑자기 나온 거야?”
힙 밖에 따로 있는 구조가 아니라, free 블록 payload 안에 predsucc 포인터를 넣어서 빈 블록끼리 이중 연결 리스트를 만든 것입니다.
“분리 가용 리스트의 {1}, {2}, {3,4}, {5-8} 같은 표기는 무슨 뜻이야?”
빈 블록을 크기 구간별로 나눈 클래스입니다. 요청 크기에 맞는 클래스부터 찾아가면 전체 힙을 뒤지지 않아도 됩니다.
“추가 메모리 요청은 필요한 만큼만 7바이트처럼 딱 맞게 하나?”
아닙니다. 먼저 블록 단위 정렬 크기로 보정하고, 실제 운영체제 요청은 보통 CHUNKSIZE 같은 큰 덩어리로 합니다. 시스템 콜을 자주 부르면 너무 비싸기 때문입니다.
“간단한 분리 저장장치는 그냥 연습문제용 원초적 모델이야?”
그렇게 이해해도 됩니다. 분할도 병합도 하지 않아 빠르지만, 메모리 낭비가 심해서 실전형 할당기로 쓰기에는 거칠다는 점을 보여주는 모델입니다.
“버디 시스템은 왜 2의 제곱수로만 맞추는 거야?”
짝 블록의 주소를 비트 연산 하나로 계산하고, 절반 분할과 병합을 매우 빠르게 처리하기 위해서입니다. 대신 내부 단편화는 커집니다.
“결국 9.9에서 배운 건 운영체제가 준 큰 메모리를 직접 관리하는 로직이구나?”
맞습니다. 운영체제는 페이지 단위로 크게 주고, 9.9절의 할당기는 그 위에서 사용자 요청 크기에 맞게 다시 쪼개고 합치고 추적합니다.

이번 글에서 기억할 것

  • CPU는 가상 주소를 만들고, MMU는 그 주소를 물리 주소로 번역한다.
  • 페이지 테이블은 페이지 상태를 기록하는 장부이고, TLB는 그 장부의 초고속 요약본이다.
  • RAM은 디스크의 캐시처럼 동작하며, 페이지 폴트는 필요한 페이지를 RAM으로 데려오는 정상 절차다.
  • .data, .bss, heap, stack는 역할이 완전히 다르며, malloc 류 함수는 전부 힙에서 동작한다.
  • 9.9절의 할당기는 운영체제가 준 힙 공간을 블록 단위로 나누고, 찾고, 쪼개고, 합치는 사용자 수준 관리기다.
  • 헤더, 풋터, 경계 태그, 분할, 병합, fit 정책은 전부 단편화와 검색 비용을 줄이기 위한 장치다.

스스로 점검

  • 유효 비트가 0일 때 “캐시되지 않음”과 “비할당됨”은 무엇으로 구분하는가?
  • 페이지 테이블 전체는 왜 MMU 안이 아니라 DRAM에 저장되는가?
  • TLB가 없다면 가상 주소 방식은 왜 느려질 수밖에 없는가?
  • calloc이 0으로 초기화해도 왜 .bss가 아니라 힙인가?
  • 헤더만으로는 왜 이전 블록을 바로 찾기 어렵고, 풋터가 있으면 왜 쉬워지는가?
  • 묵시적 가용 리스트에서 Next fit이 앞부분의 새 빈 블록을 즉시 못 보는 이유는 무엇인가?

정리

9장을 공부하고 나면, 가상 메모리, 페이지 폴트, 메모리 매핑, malloc, 단편화가 더 이상 따로 노는 개념이 아니라는 점이 보입니다.

운영체제는 페이지 단위로 큰 메모리 지형을 관리하고, 하드웨어는 주소 번역과 보호를 담당하며, 사용자 프로그램은 그 위에서 동적 메모리 할당기로 힙을 세밀하게 나눠 씁니다.

그래서 9장은 단순한 “메모리 장”이 아니라, 하드웨어, 운영체제, C 포인터, 자료구조가 한 번에 만나는 장이라고 보는 편이 맞습니다.

한 줄 정리
9장은 “운영체제가 페이지를 관리하고, MMU가 주소를 번역하고, 그 위에서 malloc이 힙을 직접 조작한다”는 하나의 흐름으로 이해해야 제대로 보인다.