Proven C Book←↑→

부록 J — 디스크는 어떻게 나뉘어 있는가

컴퓨터를 쓰다 보면 이런 말들을 만난다. 「파티션을 나눈다」, 「포맷한다」, 「MBR 이 깨졌다」, 「GPT 로 바꿔야 한다」. 무슨 뜻인지 어렴풋이 알면서도, 디스크 위에 실제로 무엇이 어떻게 적혀 있는지는 대개 모른 채 지나간다.

이 부록은 그 바이트들을 연다. 구조는 표로 적는다. 표의 수는 예제가 규격대로 바이트를 지은 뒤 다시 읽어 해독하는 검사로 확인했고, 지면에는 그 검사의 출력만 싣는다. 지면의 코드는 부트로더 모양의 프로그램 하나에서 가져왔다. 그 프로그램은 디스크의 파티션 표를 읽고, FAT 파티션에서 파일 하나를 찾아 기억 장치로 읽어 들인다. 읽는 디스크는 이 책이 아니라 남의 도구(sfdisk · mkfs.fat · QEMU)가 만든 것이다.

플랫폼 노트. 이 부록의 근거와 한계

구조의 권위는 각 규격이다 — MBR 은 관습과 각 운영체제의 문서, GPT 는 UEFI 명세, FAT 는 마이크로소프트의 FAT 규격. 여기 적은 것은 그 초입에 해당하는 사실들이고, 실제 도구를 만들 때는 규격을 보아야 한다.

이 기계의 디스크는 읽지 않았다. 남의 기계 사정을 문서에 남기지 않는다는 규율 때문이다. 대신 검사 예제가 규격대로 바이트를 지어 보이고 그 바이트를 다시 읽어 해독하며, 부트로더 시연은 도구가 그 자리에서 만든 디스크 이미지 다섯을 QEMU 9.2 의 RISC-V 기계 모형에 달아 읽는다.

먼저 알아 둘 낱말#

뒤에서는 이 낱말들을 풀이 없이 쓴다. 지금 정확히 못 외워도 좋다 — 표로 돌아오면 된다.

낱말뜻비유하자면
섹터(sector)디스크가 읽고 쓰는 가장 작은 칸. 보통 512바이트공책의 한 줄
LBA섹터에 0 부터 붙인 번호(logical block addressing)줄 번호
CHS실린더·머리·섹터로 자리를 부르던 옛 방식「몇 권 몇 쪽 몇 줄」
파티션(partition)디스크를 나눈 한 덩어리. 「몇 번 섹터부터 몇 섹터」일 뿐이다공책에 그은 칸막이
파티션 표파티션들의 목록. 디스크 앞머리에 적힌다공책 첫 장의 차례
볼륨(volume)운영체제가 하나의 저장 공간으로 다루는 것「D 드라이브」
파일 시스템파티션 안에서 파일과 폴더를 관리하는 방식(FAT32, ext4 …)칸 안의 정리 규칙
포맷(format)파티션 안에 파일 시스템의 기본 구조를 새로 적는 일칸 안에 새 양식을 그림
마운트(mount)파티션 안의 파일 시스템을 운영체제의 어느 자리에 붙여 쓰는 일칸을 책상 위에 펴 놓음

표 115.1 — 이 부록에서 쓰는 낱말

흔한 오해. 포맷하면 자료가 지워진다

대개 지워지지 않는다. 빠른 포맷은 파일 시스템의 관리 구조(어디에 무엇이 있는지의 표)만 새로 적을 뿐, 자료 구역의 바이트는 그대로 남는다. 복구 도구가 사는 자리가 여기다. 반대로 말하면, 「지웠다」고 안심하고 남에게 넘기면 안 된다.

디스크는 번호 붙은 칸의 줄이다#

기계가 아는 것은 아주 단순하다. 디스크는 섹터의 줄이고, 각 섹터에는 0 부터 번호가 있다. 「3번 섹터를 읽어라」, 「7번 섹터에 이 512바이트를 써라」 — 장치가 아는 명령은 사실상 이것뿐이다.

파티션도 파일 시스템도 그 줄 위에 약속으로 얹힌 것이다. 디스크는 자기가 어떻게 나뉘어 있는지 모른다.

disk-layers

그림 115.1 — 디스크 한 장에 겹쳐 있는 세 층 — 섹터의 줄, 파티션 표, 파일 시스템.

옛날에는 자리를 CHS 로 불렀다. 실린더 몇 번, 머리 몇 번, 섹터 몇 번. 디스크가 정말로 접시 여러 장이던 시절의 이름이다. 지금은 전부 LBA 로 부른다 — 그냥 번호다.

부르는 이름물리 섹터논리 섹터무엇이 달라지나
512n512바이트512바이트옛 디스크. 둘이 같다
512e4096바이트512바이트속은 4 KiB 인데 512 인 척한다 — 정렬이 어긋나면 느려진다
4Kn4096바이트4096바이트숨김 없이 4 KiB. 옛 소프트웨어가 못 읽는 일이 있다

표 115.2 — 섹터 크기의 세 갈래

문. 512e 에서 정렬이 어긋나면 왜 느려지는가?

답. 디스크가 실제로 다루는 덩어리는 4 KiB 인데 소프트웨어가 512바이트 단위로 어긋난 자리에 쓰면, 장치는 4 KiB 를 읽어 → 그 안의 512바이트를 고쳐 → 다시 4 KiB 를 쓴다(읽기-수정-쓰기). 쓰기 한 번이 세 번의 일이 된다. 파티션을 2048섹터(=1 MiB)에서 시작시키는 오늘날의 관례가 이것을 피하려는 것이다.

왜 나누는가#

디스크 한 장을 통째로 쓰면 안 되는가? 되기도 한다. 그런데 나누는 이유가 있다.

MBR — 첫 섹터 512바이트에 다 적는다#

가장 오래된 방식이다. 디스크의 0번 섹터 하나에 부팅 코드와 파티션 표를 다 넣는다.

오프셋크기이름무엇전형적인 값
0x000446부트스트랩 코드부팅할 때 CPU 가 실행하는 첫 코드. 부트로더의 첫 조각밖에 못 들어간다기계 명령
0x1B84디스크 서명윈도우가 디스크를 알아보는 번호(옛 MBR 에는 없다)임의의 4바이트
0x1BC2예약보통 000 00
0x1BE16파티션 항목 1아래 표 115.4 참조—
0x1CE16파티션 항목 2〃—
0x1DE16파티션 항목 3〃—
0x1EE16파티션 항목 4〃—
0x1FE2부팅 서명이것이 없으면 BIOS 가 부팅 섹터로 인정하지 않는다55 AA

표 115.3 — MBR 512바이트의 전체 자리

★ 표의 첫 줄과 마지막 줄이 이 방식의 성격을 다 말한다. 부팅 코드와 디스크의 배치도(파티션 표)가 같은 512바이트를 나눠 쓴다. 그래서 코드는 446바이트뿐이고, 파티션은 넷뿐이다.

파티션 항목 16바이트#

오프셋크기이름무엇전형적인 값
+01부팅 표시0x80 이면 「여기서 부팅」, 0x00 이면 아님. 그 밖의 값은 잘못된 것80 또는 00
+13시작 CHS옛 방식의 시작 자리. 요즘은 읽지 않는다FE FF FF
+41파티션 종류무엇이 들었는지의 힌트 한 바이트(표 115.6)83, 07, 0C …
+53끝 CHS옛 방식의 끝 자리. 역시 안 읽는다FE FF FF
+84시작 LBA진짜로 쓰이는 값. 이 파티션이 시작하는 섹터 번호2048
+124섹터 수진짜로 쓰이는 값. 이 파티션의 길이2097152(=1 GiB)

표 115.4 — MBR 파티션 항목 16바이트

CHS 세 바이트에 값이 어떻게 실리는지도 적어 둔다. 이 비좁음이 옛 한계의 원인이다.

바이트비트무엇범위
+07~0머리(head)0~255
+17~6실린더의 위 2비트실린더는 모두 10비트
+15~0섹터(sector)1~63 — 0 은 없다
+27~0실린더의 아래 8비트0~1023

표 115.5 — CHS 3바이트의 비트 배치

실행 결과

== the MBR partition table (LBA 0) ==
  entry 1    bootable=yes type=0x0c (FAT32 (LBA))
             start LBA=2048 (relative) -> 2048 (absolute)  size=204800 sectors = 100.0 MiB
             CHS start=(0,32,33) end=(12,223,19)
  entry 2    bootable=no type=0x83 (Linux)
             start LBA=206848 (relative) -> 206848 (absolute)  size=2097152 sectors = 1024.0 MiB
             CHS start=(12,223,20) end=(143,106,27)
  entry 3    bootable=no type=0x82 (Linux swap)
             start LBA=2304000 (relative) -> 2304000 (absolute)  size=262144 sectors = 128.0 MiB
             CHS start=(143,106,28) end=(159,187,28)
  entry 4    bootable=no type=0x0f (extended (LBA))
             start LBA=2566144 (relative) -> 2566144 (absolute)  size=4194304 sectors = 2048.0 MiB
             CHS start=(159,187,29) end=(420,208,44)
  signature 0x55aa --- accepted as a boot sector

== the EBR chain inside the extended partition (extended start LBA=2566144) ==

  [EBR 1] absolute LBA of this EBR = 2566144
  logical    bootable=no type=0x83 (Linux)
             start LBA=2048 (relative) -> 2568192 (absolute)  size=1048576 sectors = 512.0 MiB
             CHS start=(0,32,33) end=(65,101,36)
  next EBR   bootable=no type=0x0f (extended (LBA))
             start LBA=1050624 (relative) -> 3616768 (absolute)  size=2097152 sectors = 1024.0 MiB
             CHS start=(65,101,37) end=(195,239,44)

  [EBR 2] absolute LBA of this EBR = 3616768
  logical    bootable=no type=0x07 (NTFS / exFAT)
             start LBA=2048 (relative) -> 3618816 (absolute)  size=2095104 sectors = 1023.0 MiB
             CHS start=(0,32,33) end=(130,138,8)
  next EBR   (empty)
  -> end of the chain

== the limit of what CHS can express ==
  1024 cylinders x 255 heads x 63 sectors x 512 bytes = 8422686720 bytes = 8.4 GB
  beyond that the LBA field (4 bytes) takes over: 2^32 sectors x 512 = 2.2 TB
  -> the MBR 2 TiB limit comes from here (the sector number is 32 bits).

시연이 표를 그대로 따라 지은 뒤 되읽었다. 출력에서 읽을 것은 CHS 자리, MBR 의 크기 한계, 종류 바이트 세 가지다.

첫째, CHS 는 이미 포기된 자리다. CHS 로 적기에 너무 큰 자리는 FE FF FF 라는 관용값으로 채운다. 「CHS 로는 못 적는 자리」라는 뜻이고, 읽는 쪽도 무시한다.

둘째, 한계가 어디서 오는지 계산으로 나온다. 실린더 1024 × 머리 255 × 섹터 63 × 512바이트 = 8.4 GB. 그리고 LBA 필드가 4바이트이므로 232 섹터 × 512바이트 = 약 2.2 TB — 흔히 말하는 MBR 의 2 TiB 한계가 이것이다.

셋째, 종류 바이트는 힌트일 뿐이다. 아래 표의 값은 「대개 그렇다」는 관습이지, 그 안에 정말 무엇이 들었는지는 열어 보아야 안다.

값뜻메모
0x00빈 칸항목이 안 쓰였다
0x05확장 파티션(CHS)옛 방식. 8.4 GB 아래에서만
0x07NTFS / exFAT둘이 같은 값을 쓴다 — 열어 봐야 안다
0x0BFAT32 (CHS)
0x0CFAT32 (LBA)요즘 FAT32 는 대개 이것
0x0EFAT16 (LBA)
0x0F확장 파티션(LBA)요즘 확장 파티션은 이것
0x82리눅스 스왑
0x83리눅스ext4 든 xfs 든 전부 이 값
0xEEGPT 보호 MBR「이 디스크는 GPT 다」
0xEFEFI 시스템 파티션MBR 디스크에서 ESP 를 쓸 때

표 115.6 — 자주 보는 파티션 종류 바이트

부트로더가 이 표를 읽는 코드는 짧다. 아래는 「부트로더가 파일 하나를 읽기까지」 절에서 돌리는 시연의 한 조각이다. 서명을 보고, 첫 항목이 0xEE 면 GPT 로 넘어가고(앞 GPT 가 검사에 걸리면 디스크 끝의 짝 GPT 로), 아니면 항목 넷을 훑는다. 바이트를 구조체로 덮어씌우지 않고 le32 로 맞추는 까닭은 디스크의 수가 작은 끝 (little-endian)이고 4바이트 경계에 놓여 있지 않기 때문이다.

examples/apx-disk/fat_boot/part.c (발췌)

int partitions(struct vblk *d, struct part *out, int max, uint64_t *gpt_at)
{
    *gpt_at = 0;
    if (vblk_read(d, 0, sec) != 0) return -2;             /* 장치가 대답하지 않았다 */
    if (sec[510] != 0x55 || sec[511] != 0xAA) return -1;  /* 서명이 없으면 MBR 아님 */
    if (sec[446 + 4] == 0xEE) {                           /* 보호 MBR: 진짜는 GPT */
        /* 앞의 GPT(1번 섹터)가 검사에 걸리면 디스크 맨 끝 섹터의 짝 GPT 를 읽는다.
           짝의 항목 배열은 짝 머리말 바로 앞에 있고, 머리말이 그 자리를 적고 있다. */
        *gpt_at = 1;
        int n = gpt(d, *gpt_at, out, max);
        if (n < 0) {
            *gpt_at = d->sectors - 1;
            n = gpt(d, *gpt_at, out, max);
        }
        return n;
    }
    int n = 0;
    for (int i = 0; i < 4 && n < max; i++) {
        const uint8_t *e = sec + 446 + 16 * i;               /* 항목 16바이트 넷 */
        if (e[4] == 0) continue;                             /* 종류 0 = 빈 항목 */
        out[n++] = (struct part){ .scheme = 'M', .mbr_type = e[4],
                                  .lba = le32(e + 8), .count = le32(e + 12) };
    }
    return n;
}

파티션이 넷으로 모자랄 때 — 확장 파티션과 EBR 사슬#

항목이 넷뿐이니 다섯 개를 만들 수 없다. 그래서 나온 우회가 확장 파티션이다. 항목 하나를 「이 안에 또 나뉜 것이 있다」는 표시(0x05/0x0F)로 쓰고, 그 파티션 안에 연결 목록을 만든다.

ebr-chain

그림 115.2 — EBR 사슬 — 한 EBR 의 두 항목은 상대 LBA 의 기준이 다르다.

확장 파티션 안에 나눈 파티션을 논리 파티션(logical partition)이라 한다. 각 논리 파티션 앞에 EBR(extended boot record)이라는 섹터가 하나씩 붙고, 그 안의 파티션 표 두 칸만 쓴다.

항목무엇을 가리키나상대 LBA 의 기준종류 바이트
1번 (0x1BE)바로 뒤에 오는 논리 파티션이 EBR 자신실제 종류(0x83 등)
2번 (0x1CE)다음 EBR확장 파티션의 시작0x05 또는 0x0F
3·4번쓰지 않는다—0x00

표 115.7 — EBR 의 두 항목 — 기준이 다르다

★ 표 115.7의 셋째 열이 함정이다. 한 EBR 의 두 항목인데 상대 LBA 를 더하는 기준이 다르다. 위 시연의 뒷부분이 그 사슬을 실제로 따라가며 절대 LBA 를 계산한다 — 손으로 읽을 때 가장 많이 틀리는 자리라서 일부러 지어 보였다.

반례. EBR 사슬 한가운데를 지운다

논리 파티션은 연결 목록이다. 가운데 EBR 을 지우면 그 뒤의 모든 논리 파티션이 한꺼번에 보이지 않게 된다 — 자료는 그대로 있는데 찾아가는 길이 끊긴 것이다. GPT 가 이런 구조를 버리고 배열을 쓰는 이유이기도 하다.

GPT — 배열과 검사와 사본으로 다시 지었다#

GPT(GUID Partition Table)는 같은 일을 다시 설계한 것이다. 바뀐 것을 먼저 한 줄로 말하면 이렇다. 한 섹터에 우겨넣던 것을 여러 섹터의 배열로 펴고, 깨졌는지 알 수 있게 검사값을 붙이고, 통째로 한 벌 더 두었다.

MBRGPT
배치도가 사는 곳0번 섹터 하나1번 섹터(헤더) + 2번부터의 배열
파티션 개수4개 (확장으로 우회)헤더가 정한다 — 보통 128개
파티션 크기 한계2 TiB (섹터 번호 32비트)사실상 없다 (64비트)
파티션 이름없다있다 — 36글자(UTF-16)
종류 구분1바이트 힌트16바이트 GUID
깨짐 검사없다 (55 AA 두 바이트뿐)CRC32 둘 — 헤더와 항목 배열
사본없다디스크 끝에 한 벌 더
옛 도구 보호—보호 MBR(종류 0xEE)

표 115.8 — MBR 과 GPT

보호 MBR — 0번 섹터는 여전히 MBR 이다#

GPT 디스크의 0번 섹터에도 MBR 이 있다. 다만 파티션 항목 하나가 종류 0xEE 로 디스크 전체를 덮고 있다. GPT 를 모르는 옛 도구가 「빈 디스크네」 하고 덮어쓰는 것을 막으려는 미끼다. 크기 필드에는 디스크 전체(또는 32비트로 표현할 수 있는 최대값)를 적는다.

GPT 헤더 92바이트#

오프셋크기이름무엇전형적인 값
08서명EFI PART 여덟 글자45 46 49 20 50 41 52 54
84개정규격 판 번호00 00 01 00 (= 1.0)
124헤더 크기CRC 를 계산할 범위92
164헤더 CRC32이 자리를 0 으로 두고 헤더를 해싱한 값계산값
204예약반드시 000 00 00 00
248이 헤더의 LBA자기 자신이 어디 있는지1
328짝 헤더의 LBA백업이 어디 있는지디스크 마지막 섹터
408쓸 수 있는 첫 LBA파티션이 시작될 수 있는 가장 앞34
488쓸 수 있는 마지막 LBA파티션이 끝날 수 있는 가장 뒤끝 − 33
5616디스크 GUID이 디스크의 고유 번호16바이트
728항목 배열의 LBA파티션 항목들이 시작하는 섹터2
804항목 개수배열의 칸 수128
844항목 하나의 크기보통 128바이트128
884항목 배열 CRC32배열 전체(개수 × 크기)의 해시계산값

표 115.9 — GPT 헤더의 전체 필드 (LBA 1, 92바이트)

★ 오프셋 72 를 눈여겨보라. 이 부록을 쓰며 그 자리를 64 로 잘못 적었더니 디스크 GUID 의 뒷부분을 덮어써서 출력이 이상해졌다. 표와 코드가 어긋나면 티가 난다는 것이 이 부록의 검증 방식이다.

GPT 파티션 항목 128바이트#

오프셋크기이름무엇전형적인 값
016종류 GUID「무엇에 쓰는 파티션인가」(표 115.13)ESP 라면 C12A7328-…
1616고유 GUID이 파티션 하나의 이름표 — 복제하면 충돌한다파티션마다 다름
328첫 LBA시작 섹터 번호2048
408마지막 LBA끝 섹터 번호 — 이 섹터도 포함206847
488속성 비트표 115.11보통 0
5672이름UTF-16LE 로 최대 36글자“EFI System”

표 115.10 — GPT 파티션 항목의 전체 필드 (128바이트)

비트뜻누가 보나
0시스템 파티션 — 건드리지 말 것파티션 도구
1펌웨어가 무시UEFI 펌웨어
2옛 BIOS 로 부팅 가능옛 방식 부팅
60읽기 전용윈도우
62숨김윈도우
63자동 마운트 금지(드라이브 문자 안 줌)윈도우

표 115.11 — GPT 속성 비트 가운데 알아 둘 것

GUID 는 글로 쓸 때와 디스크에 놓일 때가 다르다#

이것이 GPT 를 손으로 읽을 때 첫 번째로 걸리는 함정이다.

마디크기저장 차례보기 (C12A7328-F81F-11D2-BA4B-00A0C93EC93B)
14바이트작은 끝 — 뒤집힌다28 73 2A C1
22바이트작은 끝 — 뒤집힌다1F F8
32바이트작은 끝 — 뒤집힌다D2 11
42바이트글자 순서 그대로BA 4B
56바이트글자 순서 그대로00 A0 C9 3E C9 3B

표 115.12 — GUID 의 다섯 마디와 저장 차례

실행 결과

== the GPT header (LBA 1, 92 bytes) ==
  signature        : EFI PART
  revision         : 1.0
  header size      : 92 bytes (the sector is 512; the header uses only 92)
  header CRC32     : 0x3e0d10e0
  this / alternate : LBA 1 / LBA 4194303
  usable range     : LBA 34 - 4194270
  disk GUID        : 01234567-89AB-CDEF-0123-456789ABCDEF
  entry array      : from LBA 2, 128 x 128 bytes = 32 sectors
  entry array CRC32: 0xb43cd5f9

== checking it ==
  header CRC recomputed (that field zeroed) : 0x3e0d10e0 -> matches
  entry array CRC recomputed               : 0xb43cd5f9 -> matches

== entries (128 bytes each) ==
  [1] name "EFI System"
      type GUID (as text) : C12A7328-F81F-11D2-BA4B-00A0C93EC93B
      type GUID (byte order): 28 73 2a c1 1f f8 d2 11 ba 4b 00 a0 c9 3e c9 3b 
      LBA 2048 ~ 206847  = 100.0 MiB
      attributes 0x0000000000000000
  [2] name "root"
      type GUID (as text) : 0FC63DAF-8483-4772-8E79-3D69D8477DE4
      type GUID (byte order): af 3d c6 0f 83 84 72 47 8e 79 3d 69 d8 47 7d e4 
      LBA 206848 ~ 3358719  = 1539.0 MiB
      attributes 0x0000000000000000
  [3] name "swap"
      type GUID (as text) : 0657FD6D-A4AB-43C4-84E5-0933C84B4F4F
      type GUID (byte order): 6d fd 57 06 ab a4 c4 43 84 e5 09 33 c8 4b 4f 4f 
      LBA 3358720 ~ 4194270  = 408.0 MiB
      attributes 0x8000000000000000  (bit 63 = do not automount)

== where the alternate (backup) GPT is ==
  disk 4194304 sectors = 2.0 GiB
  alternate header: the last sector, LBA 4194303
  alternate array : LBA 4194271 - 4194302 (32 sectors just before the header)
  -> damage at the front is repaired from the back, and the back from the front.

시연이 세 가지를 증언한다. 헤더 CRC 는 그 자리를 0 으로 두고 계산한다(안 그러면 자기 값이 자기 해시에 들어가 계산이 성립하지 않는다). 항목 배열 CRC 는 개수 × 크기 전체를 덮는다 — 쓰지 않는 빈 칸까지. 그리고 짝 GPT 는 디스크의 맨 끝에 있어, 앞이 깨지면 뒤로 고칠 수 있다.

부트로더 시연의 GPT 검사는 아래와 같다. 머리말의 CRC 칸을 0 으로 지운 뒤 셈하고, 항목 배열은 섹터 단위로 이어 셈하면서 쓰는 항목을 골라 둔다. 머리말이 적은 제 섹터 번호도 맞아야 한다. 하나라도 어긋나면 그 머리말을 믿지 않고, 디스크 맨 끝 섹터의 짝 GPT 를 같은 검사로 읽는다(그 갈림은 앞의 MBR 조각에 있다). 앞 머리말의 바이트 하나를 망가뜨린 넷째 디스크에서 로더는 짝을 읽어 파일까지 꺼냈다 — 아래 「부트로더가 파일 하나를 읽기까지」의 출력에서 primary GPT failed its checks 줄이다.

examples/apx-disk/fat_boot/part.c (발췌)

/* CRC-32(IEEE 802.3, 반사형). 표 없이 한 비트씩 ---
   부트로더는 크기가 속도보다 귀하다. */
static uint32_t crc32_update(uint32_t crc, const uint8_t *p, uint32_t n)
{
    for (uint32_t i = 0; i < n; i++) {
        crc ^= p[i];
        for (int k = 0; k < 8; k++) crc = crc >> 1 ^ (0xEDB88320u & -(crc & 1));
    }
    return crc;
}
/* … */
/* lba 에 있는 GPT 머리말과 그것이 가리키는 항목 배열을 읽는다. */
static int gpt(struct vblk *d, uint64_t lba, struct part *out, int max)
{
    if (vblk_read(d, lba, sec) != 0) return -1;
    static const char sig[8] = { 'E', 'F', 'I', ' ', 'P', 'A', 'R', 'T' };
    for (int i = 0; i < 8; i++) if (sec[i] != (uint8_t)sig[i]) return -1;
    uint32_t hsize = le32(sec + 12);
    if (hsize < 92 || hsize > 512) return -1;
    if (le64(sec + 24) != lba) return -1;           /* 적힌 제 자리가 맞아야 한다 */

    /* 머리말의 CRC 는 *자기 칸을 0 으로 두고* 셈한 값이다. */
    uint32_t want = le32(sec + 16);
    sec[16] = sec[17] = sec[18] = sec[19] = 0;
    if (~crc32_update(~0u, sec, hsize) != want) return -1;

    uint64_t entries_lba = le64(sec + 72);
    uint32_t count = le32(sec + 80), esize = le32(sec + 84);
    uint32_t entries_crc = le32(sec + 88);
    if (esize != 128 || count > 1024) return -1;

    /* 항목 배열 전체의 CRC 를 섹터 단위로 이어 셈하면서 쓰는 항목을 골라 둔다. */
    uint32_t crc = ~0u;
    int n = 0;
    for (uint32_t s = 0; s < (count * esize + 511) / 512; s++) {
        if (vblk_read(d, entries_lba + s, sec) != 0) return -1;
        uint32_t bytes = count * esize - s * 512 < 512 ? count * esize - s * 512 : 512;
        crc = crc32_update(crc, sec, bytes);
        for (uint32_t e = 0; e < bytes; e += 128) {
            const uint8_t *p = sec + e;
            int used = 0;
            for (int k = 0; k < 16; k++) used |= p[k];  /* 종류 GUID 0 = 빈 칸 */
            if (!used || n >= max) continue;
            out[n] = (struct part){ .scheme = 'G', .lba = le64(p + 32) };
            out[n].count = le64(p + 40) - out[n].lba + 1;    /* 끝 LBA 는 포함이다 */
            for (int k = 0; k < 16; k++) out[n].guid[k] = p[k];
            n++;
        }
    }
    return ~crc == entries_crc ? n : -1;
}
GUID무엇메모
C12A7328-F81F-11D2-BA4B-00A0C93EC93BEFI 시스템 파티션(ESP)FAT 로 포맷된다. 펌웨어가 여기서 .efi 를 읽는다 — 「기계가 깨어나는 순서」 부록
21686148-6449-6E6F-744E-656564454649BIOS 부팅 파티션GPT 디스크를 옛 BIOS 로 부팅할 때 GRUB 이 제 몸을 넣는 자리
0FC63DAF-8483-4772-8E79-3D69D8477DE4리눅스 파일 시스템
0657FD6D-A4AB-43C4-84E5-0933C84B4F4F리눅스 스왑
E6D6D379-F507-44C2-A23C-238F2A3DF928리눅스 LVM
EBD0A0A2-B9E5-4433-87C0-68B6B72699C7마이크로소프트 기본 자료NTFS·exFAT·FAT 가 전부 이 값
DE94BBA4-06D1-4D40-A16A-BFD50179D6AC윈도우 복구 환경

표 115.13 — 자주 보는 종류 GUID

정렬 — 왜 하필 2048섹터에서 시작하는가#

요즘 도구는 첫 파티션을 LBA 2048 에서 시작시킨다. 2048 × 512바이트 = 1 MiB 다.

무엇덩어리 크기안 맞으면
512e 디스크(4Kn 은 늘 맞는다)4 KiB쓰기마다 읽기-수정-쓰기가 붙는다
SSD 의 지우기 단위수백 KiB ~ 수 MiB지우기·재기록이 늘어 수명과 속도가 깎인다
RAID 줄무늬64 KiB ~ 1 MiB한 번의 쓰기가 두 장치에 걸친다
가상 디스크 블록1 MiB 안팎호스트 쪽에서 같은 문제가 한 겹 더

표 115.14 — 1 MiB 정렬이 맞춰 주는 것들

★ 1 MiB 는 1 MiB 이하의 흔한 덩어리 크기(2의 거듭제곱) 모두의 배수라서, 하나를 맞추면 전부 맞는다. 옛 도구가 63섹터(CHS 시절의 「첫 트랙 다음」)에서 시작시켰는데, 63섹터는 4 KiB 의 배수가 아니라서 늘 어긋났다.

파티션 안에는 무엇이 있나 — 파일 시스템의 첫 섹터#

파티션은 「몇 번부터 몇 개」일 뿐이고, 그 안을 어떻게 쓸지는 파일 시스템이 정한다. 파일 시스템은 대개 제 첫 섹터(또는 첫 블록)에 자기 설명서를 적어 둔다.

파일 시스템이름어디에알아보는 표시
FAT12/16/32BPB (부트 섹터)파티션의 0번 섹터끝의 55 AA, 그리고 FAT32 이름표
exFAT부트 섹터파티션의 0번 섹터오프셋 3 에 EXFAT
NTFS부트 섹터파티션의 0번 섹터오프셋 3 에 NTFS
ext2/3/4슈퍼블록파티션 시작에서 1024바이트 뒤슈퍼블록 오프셋 0x38 에 53 EF

표 115.15 — 파일 시스템의 「자기 설명서」가 있는 자리

FAT32 — 가장 읽기 쉬운 예#

FAT 는 오래되고 단순해서 「파일 시스템이 무엇을 적어 두어야 하는가」를 배우기에 좋다. 이름의 FAT(file allocation table)는 이 파일 시스템이 볼륨 앞쪽에 두는 표이고, 아래 표의 「FAT 앞」·「FAT 벌 수」·「FAT 크기」의 FAT 는 이 표를 가리킨다.

오프셋크기이름무엇전형적인 값
03점프 명령부팅할 때 코드로 건너뛴다EB 58 90
38만든 곳 이름글자. 뜻은 없다MSWIN4.1
112섹터 크기바이트512
131클러스터당 섹터파일 자리를 나눠 주는 단위8 (= 4 KiB)
142예약 섹터FAT 앞에 두는 자리(부트 섹터 포함)32
161FAT 벌 수사본 개수2
172루트 항목 수FAT32 에서는 0 (루트도 클러스터에 산다)0
192총 섹터(16비트)작은 볼륨용. 안 쓰면 00
211매체 종류옛 흔적F8 (고정 디스크)
222FAT 크기(16비트)FAT32 에서는 00
284숨은 섹터이 파티션이 디스크에서 시작하는 자리2048
324총 섹터(32비트)이 볼륨의 크기2097152
364FAT 크기(32비트)FAT 한 벌의 섹터 수 — 계산해서 나오는 값2046
444루트 클러스터루트 디렉터리가 시작하는 클러스터2
482FSInfo 섹터빈 클러스터 수를 캐시해 두는 자리1
502부트 섹터 사본부트 섹터가 깨질 때를 대비한 사본의 자리6
661확장 서명0x29 면 아래 셋이 유효29
674볼륨 일련번호포맷할 때 정해진다임의
7111볼륨 이름표글자NO NAME
828파일 시스템 이름표믿으면 안 된다 — 그냥 글자다FAT32
5102서명55 AA55 AA

표 115.16 — FAT32 부트 섹터(BPB)의 주요 필드

실행 결과

== the FAT32 BPB (first sector of the partition) ==
  sector size       : 512 bytes
  cluster size      : 8 sectors = 4096 bytes (4 KiB)
  reserved sectors  : 32
  FAT copies / size : 2 x 2046 sectors
  total sectors     : 2097152 = 1024.0 MiB
  root cluster      : 2
  volume label      : "NO NAME    ", filesystem label "FAT32   "
  hidden sectors    : 2048  <- where this partition starts on the disk

== how the partition divides up ==
  reserved area : sectors 0 - 31  (boot sector, FSInfo, copies)
  FAT copy 1    : sectors 32 - 2077
  FAT copy 2    : sectors 2078 - 4123  <- a duplicate of the same content
  data area     : from sector 4124  (cluster numbers start here)
  clusters      : 261628 --- FAT32 needs at least 65525 -> it is FAT32

== changing the cluster size on the same volume ==
  cluster   clusters       one FAT      usable as FAT32
  512 B      2064606        16257 sectors yes
  1 KiB      1040400        8160 sectors yes
  2 KiB      522236         4088 sectors yes
  4 KiB      261628         2046 sectors yes
  8 KiB      130942         1024 sectors yes
  16 KiB     65503          512 sectors  no --- too few clusters
  32 KiB     32759          256 sectors  no --- too few clusters

  -> this is why a small volume ends up FAT16. Larger clusters mean fewer
     of them to manage, and the count falls below 65525.

== cluster number -> sector number ==
  formula: start of data area + (N - 2) x sectors per cluster
        (why subtract 2: 0 and 1 are used as labels and have no real place)

  cluster 2   -> sector 4124   in the partition -> absolute LBA 6172      <- the root directory
  cluster 3   -> sector 4132   in the partition -> absolute LBA 6180    
  cluster 4   -> sector 4140   in the partition -> absolute LBA 6188    
  cluster 5   -> sector 4148   in the partition -> absolute LBA 6196    

== when one file is scattered --- the FAT chain ==
  the FAT is an array of cluster number -> next cluster number (4 bytes each).
  if a file starts at cluster 3:

    cluster 3   (LBA 6180    , 4 KiB) -> next is 4
    cluster 4   (LBA 6188    , 4 KiB) -> next is 7
    cluster 7   (LBA 6212    , 4 KiB) -> end of chain

  so a file that is not contiguous can still be read. And damage to one FAT
  removes the way to find the rest of a file --- which is why there are two copies.

이 시연에서 배울 것은 자리 계산, FAT 의 짜임, 클러스터 크기 세 가지다.

첫째, 자리 계산은 덧셈뿐이다. 자료 구역의 시작 = 예약 섹터 + (FAT 벌 수 × FAT 크기). 그리고 클러스터 N 의 섹터 = 자료 구역 시작 + (N − 2) × 클러스터당 섹터. 2 를 빼는 까닭은 0번과 1번 클러스터가 FAT 의 예약 항목으로 쓰여 실제 자리가 없기 때문이다.

둘째, FAT 는 「다음 클러스터 번호」의 배열이다. 파일이 디스크 여기저기에 흩어져 있어도, 클러스터마다 다음 클러스터의 번호를 적어 두면 이어 읽을 수 있다. 그래서 FAT 가 상하면 자료는 있는데 뒷부분을 찾아갈 길이 사라진다 — 사본을 두 벌 두는 까닭이다.

값뜻메모
0x0000000빈 클러스터쓸 수 있다
0x0000002 ~ 0xFFFFFEF다음 클러스터 번호사슬이 이어진다
0xFFFFFF7불량 클러스터쓰지 않는다
0xFFFFFF8 이상사슬의 끝파일의 마지막 클러스터

표 115.17 — FAT32 의 항목 값(아래 28비트만 쓴다)

셋째, 클러스터 크기가 파일 시스템의 종류까지 정한다. 시연의 표가 보여 주듯, 같은 1 GiB 볼륨이라도 클러스터를 16 KiB 로 키우면 클러스터 수가 65,525 아래로 떨어져 FAT32 로 쓸 수 없다. 작은 USB 가 FAT16 으로 포맷되는 일이 여기서 나온다.

부트로더가 파일 하나를 읽기까지#

여기까지의 표를 부트로더가 차례로 쓴다. 섹터를 읽어 오는 일은 virtio 블록 장치의 드라이버가 맡는다. virtio 는 QEMU 같은 가상 기계가 내놓는 장치의 규격이고, 이 드라이버는 「섹터 하나 읽기」만 한다. 레지스터는 「기계는 어떻게 이야기하는가」 부록의 드라이버들과 같은 함수 넷으로만 건드리고, 요청과 대답은 기억 장치에 둔 고리(virtqueue)로 주고받는다. 장치가 그 고리와 섹터 버퍼를 DMA(direct memory access, CPU 를 거치지 않는 장치의 기억 장치 접근)로 직접 읽고 쓴다.

examples/apx-disk/fat_boot/virtio_blk.c (발췌)

int vblk_read(struct vblk *d, uint64_t lba, void *buf512)
{
    req = (struct req){ .type = 0, .sector = lba };
    req_status = 0xFF;
    /* 머리(무엇을 읽나) → 자료(512바이트) → 결과(1바이트), 셋이 사슬로 이어진다 */
    q_desc[0] = (struct desc){ (uintptr_t)&req, sizeof req, F_NEXT, 1 };
    q_desc[1] = (struct desc){ (uintptr_t)buf512, 512, F_WRITE | F_NEXT, 2 };
    q_desc[2] = (struct desc){ (uintptr_t)&req_status, 1, F_WRITE, 0 };

    uint16_t seen = q_used.idx;
    q_avail.ring[q_avail.idx % QN] = 0;       /* 0번 서술자부터 이어진 요청 하나 */
    atomic_thread_fence(memory_order_seq_cst);   /* 고리를 다 쓴 *뒤에* idx 를 올린다 */
    q_avail.idx++;
    atomic_thread_fence(memory_order_seq_cst);
    wr(d, Q_NOTIFY, 0);                       /* 「0번 고리를 보라」 */

    /* 장치는 레지스터가 아니라 기억 장치(쓴 고리)에 대답한다. */
    for (long i = 0; i < 10000000; i++) {
        if (*(volatile uint16_t *)&q_used.idx != seen) {
            atomic_thread_fence(memory_order_seq_cst);
            wr(d, INT_ACK, rd(d, INT_STATUS));
            return req_status == 0 ? 0 : -1;
        }
    }
    return -2;                                /* 대답이 없다 */
}

그 위에서 FAT 을 읽는 코드가 이것이다. BPB 에서 자리를 셈하고, FAT 의 종류를 정하고, 뿌리 디렉터리에서 이름을 찾는다. FAT12 의 항목은 1바이트 반이라 두 항목이 3바이트를 나눠 쓰고, 그래서 한 항목이 FAT 의 섹터 경계에 걸칠 수 있다. 찾는 이름 boot-kernel.bin 은 8.3 꼴(이름 여덟 글자 · 확장자 세 글자)에 들지 않아서, 파일을 넣은 도구가 짧은 이름(BOOT-K~1.BIN 따위)과 함께 긴 이름(LFN, long file name) 항목을 붙인다. 긴 이름은 짧은 이름 항목 바로 앞에 32바이트 조각으로 거꾸로 놓이고, 조각마다 짧은 이름의 체크섬을 담아 제 짝을 가리킨다.

examples/apx-disk/fat_boot/fat.c (발췌)

int fat_mount(struct fat *fs, struct vblk *d, uint32_t part_lba)
{
    if (vblk_read(d, part_lba, sec) != 0) return -1;
    if (le16(sec + 11) != 512 || sec[13] == 0 || sec[510] != 0x55 || sec[511] != 0xAA)
        return -1;
    uint16_t reserved = le16(sec + 14);
    uint8_t  nfats    = sec[16];
    uint16_t rootents = le16(sec + 17);                     /* FAT32 에서는 0 */
    uint32_t fatsz    = le16(sec + 22) ? le16(sec + 22) : le32(sec + 36);
    uint32_t total    = le16(sec + 19) ? le16(sec + 19) : le32(sec + 32);
    uint32_t rootsecs = (rootents * 32u + 511) / 512;

    *fs = (struct fat){ .disk = d, .spc = sec[13], .root_sectors = rootsecs };
    fs->fat_lba  = part_lba + reserved;
    fs->root_lba = fs->fat_lba + nfats * fatsz;             /* FAT 들 바로 뒤 */
    fs->data_lba = fs->root_lba + rootsecs;                 /* FAT32 에서는 같은 자리 */
    fs->clusters = (total - (reserved + nfats * fatsz + rootsecs)) / fs->spc;

    /* FAT 의 종류는 이름표("FAT16   ")가 아니라 클러스터 수가 정한다(명세의 규칙). */
    fs->bits = fs->clusters < 4085 ? 12 : fs->clusters < 65525 ? 16 : 32;
    if (fs->bits == 32) fs->root_cluster = le32(sec + 44);
    return 0;
}
/* … */
/* FAT 은 「이 클러스터 다음은 몇 번」을 적은 배열이다. 항목 하나가 FAT12 는
   1바이트 반, FAT16 은 2바이트, FAT32 는 4바이트(위 4비트는 쓰지 않는다)다.
   끝 표시는 셋 모두 0x0FFFFFFF 로 맞춰 돌려준다. */
static uint32_t next_cluster(struct fat *fs, uint32_t c)
{
    uint32_t at = fs->bits == 12 ? c + c / 2 : c * (fs->bits / 8);
    if (vblk_read(fs->disk, fs->fat_lba + at / 512, sec) != 0) return 0x0FFFFFFF;
    if (fs->bits == 12) {
        /* 12비트 항목 둘이 3바이트에 들어 있어, 한 항목이 섹터 경계에 걸칠 수 있다. */
        uint32_t v = sec[at % 512];
        uint32_t next = fs->fat_lba + at / 512 + 1;
        if (at % 512 == 511 && vblk_read(fs->disk, next, sec) != 0) return 0x0FFFFFFF;
        v |= (uint32_t)sec[(at + 1) % 512] << 8;
        v = c & 1 ? v >> 4 : v & 0xFFF;                     /* 홀수 번은 위 12비트 */
        return v >= 0xFF8 ? 0x0FFFFFFF : v;
    }
    if (fs->bits == 16) {
        uint16_t v = le16(sec + at % 512);
        return v >= 0xFFF8 ? 0x0FFFFFFF : v;
    }
    return le32(sec + at % 512) & 0x0FFFFFFF;
}

static uint32_t cluster_lba(const struct fat *fs, uint32_t c)
{ return fs->data_lba + (c - 2) * fs->spc; }                /* 클러스터 번호는 2 부터 */
/* … */
/* 긴 이름(LFN)은 짧은 이름 항목 *앞에* 거꾸로 놓인 조각들이다. 조각마다 UCS-2 글자
   13개와 순번(마지막 조각에 0x40), 그리고 짝이 될 짧은 이름의 체크섬을 담는다.
   조각은 디렉터리 섹터의 경계를 넘을 수 있어 상태를 섹터 밖에 둔다. */
struct lfn { char name[261]; uint8_t sum; int valid; };

static void lfn_piece(struct lfn *l, const uint8_t *e)
{
    static const uint8_t at[13] = { 1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30 };
    unsigned ord = e[0] & 0x1F;
    if (e[0] & 0x40) {                                  /* 마지막 조각이 먼저 나온다 */
        l->valid = ord >= 1 && ord <= 20;
        l->sum = e[13];
        if (l->valid) l->name[ord * 13] = '\0';
    }
    if (!l->valid || e[13] != l->sum || ord < 1 || ord > 20) { l->valid = 0; return; }
    for (unsigned k = 0; k < 13; k++) {
        uint16_t ch = le16(e + at[k]);
        char *p = &l->name[(ord - 1) * 13 + k];
        if (ch == 0x0000) *p = '\0';                   /* 이름의 끝 */
        else if (ch != 0xFFFF) *p = ch < 0x80 ? (char)ch : '?';   /* 0xFFFF 는 채움 */
    }
}

/* 짧은 이름 11바이트의 체크섬: 오른쪽으로 한 비트 돌리고 더하기를 되풀이한다. */
static uint8_t short_sum(const uint8_t *e)
{
    uint8_t sum = 0;
    for (int k = 0; k < 11; k++) sum = (uint8_t)(((sum & 1) << 7) + (sum >> 1) + e[k]);
    return sum;
}
/* … */
/* 뿌리 디렉터리: FAT12 · FAT16 은 FAT 뒤의 고정된 영역,
   FAT32 는 여느 파일처럼 클러스터 사슬이다. */
static int find(struct fat *fs, const char *name, struct hit *h)
{
    struct lfn l;                   /* = { 0 } 은 memset 을 부른다(libc 가 없다) */
    l.valid = 0;
    int r = 0;
    if (fs->bits != 32)
        for (uint32_t s = 0; s < fs->root_sectors && r == 0; s++)
            r = scan(fs, fs->root_lba + s, name, &l, h);
    else
        for (uint32_t c = fs->root_cluster; !is_end(c) && r == 0;
             c = next_cluster(fs, c))
            for (uint32_t s = 0; s < fs->spc && r == 0; s++)
                r = scan(fs, cluster_lba(fs, c) + s, name, &l, h);
    return r == 1 ? 0 : -1;
}

실행 결과

== this machine: the commands the driver sends (trace backend) ==
R  0x10001000 -> 0x00000000
R  0x10002000 -> 0x00000000
   ... 5 more like this, every 0x1000, up to 0x10007000
R  0x10008000 -> 0x74726976
R  0x10008004 -> 0x00000002
R  0x10008008 -> 0x00000002
W  0x10008070 <- 0x00000000
W  0x10008070 <- 0x00000001
W  0x10008070 <- 0x00000003
W  0x10008014 <- 0x00000001
R  0x10008010 -> 0x00000001
W  0x10008024 <- 0x00000000
W  0x10008020 <- 0x00000000
W  0x10008024 <- 0x00000001
W  0x10008020 <- 0x00000001
W  0x10008070 <- 0x0000000b
R  0x10008070 -> 0x0000000b
W  0x10008030 <- 0x00000000
R  0x10008034 -> 0x00000100
W  0x10008038 <- 0x00000004
W  0x10008080 <- 0x005064c0
W  0x10008084 <- 0x00000000
W  0x10008090 <- 0x005064a8
W  0x10008094 <- 0x00000000
W  0x100080a0 <- 0x00506480
W  0x100080a4 <- 0x00000000
W  0x10008044 <- 0x00000001
W  0x10008070 <- 0x0000000f
R  0x10008100 -> 0x00020000
R  0x10008104 -> 0x00000000
#  virtio-blk at 0x10008000, 131072 sectors
W  0x10008050 <- 0x00000000
#    sector 0: the device did not answer
W  0x00100000 <- 0x00005555

== QEMU virt (emulated RISC-V): the same loader on five virtio disks ==
virtio-blk at 0x10004000, 8192 sectors
  MBR partition 1: sectors 2048 + 6144, type 0x01
    FAT12: 1525 clusters of 4 sectors, FAT at sector 2049, data at sector 2091
    boot-kernel.bin, 770042 bytes: hello from sfdisk + mkfs.fat (MBR, FAT12)
    110000 numbered lines, all in order
virtio-blk at 0x10005000, 131072 sectors
  primary GPT failed its checks; using the backup at sector 131071
  GPT partition 1: sectors 2048 + 126976, type c12a7328-f81f-11d2-ba4b-00a0c93ec93b
    FAT32: 127006 clusters of 1 sectors, FAT at sector 2080, data at sector 4066
    boot-kernel.bin, 770042 bytes: hello from sfdisk + mkfs.fat (GPT, FAT32)
    110000 numbered lines, all in order
virtio-blk at 0x10006000, 131072 sectors
  GPT partition 1: sectors 2048 + 126976, type c12a7328-f81f-11d2-ba4b-00a0c93ec93b
    FAT32: 127006 clusters of 1 sectors, FAT at sector 2080, data at sector 4066
    boot-kernel.bin, 770042 bytes: hello from sfdisk + mkfs.fat (GPT, FAT32)
    110000 numbered lines, all in order
virtio-blk at 0x10007000, 131072 sectors
  MBR partition 1: sectors 2048 + 129024, type 0x0c
    FAT32: 127006 clusters of 1 sectors, FAT at sector 2080, data at sector 4066
    boot-kernel.bin, 770042 bytes: hello from sfdisk + mkfs.fat (MBR, FAT32)
    110000 numbered lines, all in order
virtio-blk at 0x10008000, 1032192 sectors
  MBR partition 1: sectors 63 + 1032129, type 0x06
    FAT16: 64474 clusters of 16 sectors, FAT at sector 64, data at sector 600
    boot-kernel.bin, 770035 bytes: hello from QEMU vvfat (MBR, FAT16)
    110000 numbered lines, all in order

QEMU 쪽 절반은 디스크 다섯에서 모두 boot-kernel.bin 을 긴 이름으로 찾아 읽었다. sfdisk · mkfs.fat 이 만든 셋은 GPT 와 FAT32, MBR 과 FAT32, MBR 과 FAT12 다. 넷째는 GPT 디스크의 사본에서 앞 GPT 머리말을 망가뜨린 것이라 짝 GPT 로 읽혔다. QEMU 의 vvfat(디렉터리 하나를 FAT 디스크로 보여 주는 기능)이 만든 나머지 하나는 MBR 에 FAT16 이다. 파일은 약 750 KiB 이고 번호를 단 줄 110,000 개로 되어 있어서, 클러스터 사슬을 한 번이라도 잘못 따라가면 번호가 끊긴다. 다섯 모두 번호가 끝까지 이어졌다. FAT12 디스크에서는 사슬이 클러스터 376 개에 걸쳐 섹터 경계에 걸친 항목(341번)을 지나는데, 그 경계를 다루는 줄을 빼고 짓자 그 디스크에서만 사슬이 끊겼다.

이 드라이버는 FAT 의 종류를 이름표가 아니라 위 셋째 점의 규칙대로 클러스터 수로 정한다 — 1,525 개는 4,085 보다 적으니 FAT12, 64,474 개는 65,525 보다 적으니 FAT16 이고, 127,006 개는 FAT32 다. 실제로 vvfat 에 FAT32 를 달라고 해도 부트 섹터는 FAT16 의 것이 나왔고(QEMU 스스로 「FAT32 는 시험하지 않았다」고 경고한다), 그래서 vvfat 디스크는 FAT16 으로 두었다.

이 기계 쪽 절반은 0번 섹터를 청한 데서 멈춘다. 드라이버가 고리를 차리고 Q_NOTIFY 에 쓰는 데까지는 레지스터 명령이라 찍히지만, 섹터의 내용은 장치가 DMA 로 기억 장치에 쓰는 것이라 대답 표로는 흉내 낼 수 없다. 출력에서 0x10008080 따위에 쓰는 고리 주소는 이 기계에서 프로그램의 변수가 놓인 자리다. 또 QEMU 의 virt 모형은 DMA 와 캐시가 어긋나지 않는 기계(dma-coherent)라 이 드라이버에는 캐시를 비우는 코드가 없다. 그렇지 않은 기계에서는 「기계는 어떻게 이야기하는가」 부록의 DMA 절에 적은 캐시 처리가 더 필요하다.

ext4 와 NTFS 는 FAT 와 어떻게 다른가#

오프셋크기이름무엇전형적인 값
0x004아이노드 총수파일 하나에 아이노드 하나 — 포맷할 때 정해진다계산값
0x044블록 총수볼륨 크기(블록 단위)계산값
0x184블록 크기 지수블록 크기 = 1024 << 이 값2 (= 4 KiB)
0x204그룹당 블록 수블록 그룹의 크기32768
0x284그룹당 아이노드 수블록 그룹 하나에 든 아이노드 수계산값
0x382매직53 EF — 이것으로 ext 임을 안다53 EF
0x3A2상태깨끗이 언마운트되었는가1
0x582아이노드 크기보통 256바이트256
0x604기능 비트(호환)저널 등—
0x644기능 비트(비호환)모르면 마운트하면 안 되는 기능들—
0x6816UUID볼륨의 고유 번호 — /etc/fstab 이 이것을 쓴다16바이트
0x7816볼륨 이름글자—

표 115.18 — ext4 슈퍼블록의 주요 필드 (파티션 시작 + 1024바이트)

FAT 가 「다음 칸 번호의 배열」이라면, ext4 는 아이노드가 중심이다. 파일 하나가 아이노드 하나를 갖고, 거기에 크기·권한·시각과 자료가 어디 있는지가 적힌다. 그리고 자리를 번호 하나씩이 아니라 익스텐트(이어진 구간)로 적어 큰 파일을 짧게 표현한다.

오프셋크기이름무엇전형적인 값
38이름표NTFS 로 알아본다NTFS
112섹터 크기512
131클러스터당 섹터8
284숨은 섹터파티션의 시작 자리2048
408총 섹터64비트 — 그래서 큰 볼륨이 된다계산값
488$MFT 의 클러스터모든 것의 목록이 시작하는 자리계산값
568$MFTMirr 의 클러스터그 목록의 사본계산값
641MFT 레코드 크기음수면 2|값| 바이트라는 뜻(보통 1024)F6
5102서명55 AA

표 115.19 — NTFS 부트 섹터의 주요 필드

NTFS 에서는 모든 것이 파일이다 — 파일 목록($MFT)조차 파일이고, 부트 섹터도 $Boot 라는 파일이다. 그 목록의 자리만 부트 섹터에 적어 두면 나머지는 목록을 따라간다.

이름자리 관리저널파일 크기 한계주로 어디서
FAT32FAT 사슬없다4 GiB 미만USB·SD·ESP — 어디서나 읽힌다
exFATFAT 사슬(64비트)없다사실상 없다큰 SD 카드·카메라
NTFSMFT + 익스텐트메타데이터매우 크다윈도우
ext4아이노드 + 익스텐트있다(선택)16 TiB 급리눅스
btrfs·ZFS·APFS복사-후-쓰기 + 체크섬구조 자체가 대신한다매우 크다스냅숏·무결성이 필요한 곳

표 115.20 — 자주 만나는 파일 시스템

★ 표의 「저널」 열이 실무에서 가장 크게 갈리는 자리다. 저널이 없는 FAT 계열은 쓰다가 전원이 나가면 파일 시스템 자체가 어긋난 상태로 남을 수 있다. USB 를 뽑기 전에 「안전하게 제거」를 눌러야 하는 이유가 여기 있다.

매체의 물리 — 왜 무작위 쓰기가 느린가#

여기까지는 디스크에 적힌 구조(파티션 표와 파일 시스템의 첫 섹터)를 읽었다. 이제 그 아래, 바이트가 실제로 사는 물건을 본다. 같은 「저장」 인데 회전하는 원판과 반도체는 성질이 아주 다르고, 그 차이가 파일 시스템과 데이터베이스의 설계를 정한다.

회전하는 원판 — 자리를 찾아가는 시간#

하드디스크에서 한 번 읽는 시간은 셋으로 나뉜다.

무엇무엇을 기다리나전형적인 값줄일 수 있나
탐색(seek)헤드가 그 트랙으로 옮겨 가는 시간5~10 밀리초가까운 자리를 읽으면 준다
회전 지연원판이 돌아 그 섹터가 오기를 기다림7200 rpm 이면 평균 4.2 밀리초줄이기 어렵다
전송실제로 바이트를 옮기는 시간수십 마이크로초순차면 아주 작다

표 115.21 — 하드디스크에서 한 번 읽는 데 드는 시간

★ 앞의 둘이 자리를 찾는 비용이고 셋째가 일하는 비용이다. 그리고 앞의 둘이 뒤의 것보다 백 배쯤 크다. 「무작위 접근이 느리다」는 말이 여기서 나온다 — 4 KiB 를 읽든 1 MiB 를 읽든, 자리를 한 번 찾는 비용은 같기 때문이다.

문. 7200 rpm 에서 평균 회전 지연이 왜 4.2 밀리초인가?

답. 분당 7200번이면 한 바퀴에 607200 초, 곧 8.33 밀리초다. 원하는 섹터는 평균적으로 반 바퀴 뒤에 오므로 그 절반인 4.17 밀리초. 기계의 회전 속도 하나로 지연이 정해진다 — 소프트웨어가 어쩔 수 없는 값이다.

반도체 — 쓰는 단위와 지우는 단위가 다르다#

SSD 에는 움직이는 부품이 없으니 탐색이 없다. 대신 다른 성질이 생긴다. 제자리에 덮어쓸 수 없다.

ssd-erase

그림 115.3 — SSD 의 쪽과 블록 — 쓰기 증폭이 생기는 자리.

단위크기(대략)무엇에 쓰나제약
셀1비트~4비트값을 담는 가장 작은 것쓸 때마다 조금씩 닳는다
쪽(page)4~16 KiB읽기와 쓰기의 단위빈 쪽에만 쓸 수 있다
블록(block)수 MiB (쪽 수백 개)지우기의 단위지우려면 통째로

표 115.22 — SSD 안의 세 가지 단위

여기서 SSD 의 모든 성질이 나온다.

무엇무엇인가프로그래머가 느끼는 것
FTL(사상 계층)「논리 주소 → 실제 쪽」 표를 장치가 스스로 관리같은 주소에 써도 매번 다른 자리에 간다
쓰기 증폭4 KiB 를 고치려고 블록을 옮겨 쓰고 지운다작은 무작위 쓰기가 유난히 비싸다
가비지 컬렉션죽은 쪽이 섞인 블록을 정리해 빈 블록을 만든다가끔 쓰기가 갑자기 느려진다
TRIM「이 쪽은 이제 안 쓴다」를 파일 시스템이 알려 준다지우고 나면 다시 빨라진다
마모 평준화같은 블록만 닳지 않게 골고루 돌려 쓴다「빈 공간이 없으면 수명이 준다」
예비 공간사용자에게 안 보이는 여분광고 용량보다 실제 칩이 크다

표 115.23 — 「덮어쓸 수 없다」가 낳는 것들

흔한 오해. SSD 는 무작위 접근이 순차 접근만큼 빠르다

읽기는 거의 그렇다 — 탐색이 없으니까. 그러나 쓰기는 다르다. 작은 무작위 쓰기는 쓰기 증폭과 가비지 컬렉션을 부르고, 그때 지연이 튄다. 그래서 데이터베이스와 파일 시스템은 SSD 에서도 여전히 쓰기를 모아 순차로 내리려고 애쓴다(로그 구조).

이 기계에서 잰 것#

examples/apx-disk/storage_cost.c (발췌)

fd = open(path, O_WRONLY);
double s[9];
for (int r = 0; r < 9; r++) {
    t0 = ns();
    for (int i = 0; i < 20; i++) {
        if (write(fd, buf, BLK) != (ssize_t)BLK) { perror("write"); return 1; }
        fsync(fd);
    }
    t1 = ns();
    s[r] = (t1 - t0) / 20.0 / 1e6;         /* fsync 한 번당 밀리초 */
}
qsort(s, 9, sizeof *s, cmp_d);
/* … */
for (int cold = 0; cold < 2; cold++) {
    if (cold) posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);   /* 이 파일의 캐시만 버린다 */
    lseek(fd, 0, SEEK_SET);
    t0 = ns();
    for (unsigned i = 0; i < FILE_MiB; i++)
        if (read(fd, buf, MiB) != (ssize_t)MiB) { perror("read"); return 1; }
    t1 = ns();
    double mbs = FILE_MiB / ((t1 - t0) / 1e9);
    if (cold) seq_cold = mbs; else seq_cached = mbs;
}

실행 결과

== what is being measured ==
  measured with one 64 MiB temporary file. This is not the figure of one device
  but of the filesystem, the cache and the device together --- the whole stack.

== 1. sequential writing ==
  writing 64 MiB (into cache) :   5331.6 MiB/s
  one fsync after it          :     55.0 ms  <- this much had not reached the disk
  4 KiB write + fsync         :    0.396 ms each -> about 2523 per second
  * that is the cost of one database commit. Which is why designs gather several
    commits and flush them together (group commit).

== 2. reading --- with the cache, and after asking to drop it ==
  sequential read (cached)  :  10115.3 MiB/s
  sequential read (dropped) :   8888.2 MiB/s  -> a factor of 1.1
  random 4 KiB (cached)     :      7.3 us each -> 136970 per second
  random 4 KiB (dropped)    :      7.7 us each -> 129560 per second

  * the two are nearly equal --- the request to drop the cache was not honoured.
    This filesystem keeps its own cache, which `posix_fadvise` does not
    empty. So read the numbers above not as the speed of the device but as
    the speed of reading a file that is already in memory.
    (8888 MiB/s is the speed of memory, not of storage.)
    Really measuring the device needs bypassing the cache (O_DIRECT) or the
    privilege to drop the system cache, and this environment has neither ---
    so what could not be measured is left unmeasured.

== 3. placed on the ladder from earlier ==
  what                                   each against L1 (1.4 ns)
  L1 cache read                        1.4 ns            1 x
  main memory read                    75.0 ns           54 x
  random 4 KiB (from cache)            7.3 us         5215 x
  4 KiB write + fsync                396.3 us       283059 x

  * the ladder does not end at the cache. Several more orders of magnitude lie below.
    And on those lower rungs, reading in order matters far more.

★ 이 측정의 결론에서 가장 중요한 것은 잰 값이 아니라 못 잰 것이다. 캐시를 버리라는 부탁(posix_fadvise)이 이 파일 시스템에서 듣지 않아, 「차례로 읽기」가 11 GiB/초로 나왔다 — 그것은 저장 장치가 아니라 기억의 속도다. 시연은 그 사실을 스스로 확인해 말한다.

실제 사례. 장치를 재려 했는데 캐시를 쟀다

저장 장치 측정에서 가장 흔한 잘못이 이것이다. 파일 시스템 캐시가 중간에 있으므로, 아무 준비 없이 재면 기억의 속도가 나온다. 제대로 재려면 캐시를 우회하거나 (O_DIRECT) 시스템 전체의 캐시를 비워야 하는데, 둘 다 권한이 필요하다.

그래서 이 부록은 「이 환경에서는 장치의 속도를 캐시와 가르지 못했다」로 남긴다. 대신 가를 수 있었던 것은 분명하다 — fsync 의 비용이다. 그 비용은 캐시로 감출 수 없기 때문이다.

fsync — 캐시로 감출 수 없는 비용#

쓰기는 대개 캐시까지만 간다. 전원이 나가면 사라진다. 「정말로 남았다」를 보장받으려면 fsync 를 불러야 하고, 그 비용은 캐시까지 쓰는 것과 자릿수가 다르다.

무엇한 번당L1 대비무엇이 시간을 쓰나
L1 캐시 읽기약 1.4 나노초1배—
주기억 읽기약 75 나노초약 54배기억까지의 거리
파일에서 4 KiB 읽기(캐시에 있음)약 2.3 마이크로초약 1,600배시스템 호출과 복사
4 KiB 쓰기 + fsync약 0.3 밀리초약 20만배정말로 장치까지 내려간다

표 115.24 — 기억 사다리의 아래층 (이 기계에서 잰 값)

★ 마지막 줄이 데이터베이스 설계를 통째로 설명한다. 커밋 한 번에 0.3 밀리초면 초당 3천 번이 상한이다. 그래서 데이터베이스는 여러 커밋을 모아 한 번에 내리고(그룹 커밋), 자료 파일 대신 순차로만 쓰는 로그에 먼저 적는다(WAL).

상황무엇을 하나왜
작은 쓰기가 아주 잦다모아서 한 번에 쓴다fsync 한 번의 비용이 크기와 거의 무관하다
「저장했다」를 약속해야 한다fsync 를 부르고 그 비용을 예산에 넣는다캐시까지 쓴 것은 아직 저장이 아니다
무작위 읽기가 많다SSD 라면 괜찮다, 회전 원판이라면 배치를 바꾼다원판에서는 자리를 찾는 비용이 전송 비용의 백 배쯤이다
로그를 남긴다덧붙이기만 하는 파일로순차 쓰기는 어느 매체에서나 가장 싸다
저장 장치를 잰다캐시를 우회하거나 비운 뒤에아니면 기억을 재게 된다

표 115.25 — 이 층에서 나오는 설계 지침

무엇이 어디서 깨지는가#

증상만 보고 어느 층이 상했는지 가릴 수 있다. 이것이 이 부록의 실용적인 쓸모다.

증상상한 층자료는무엇을 먼저 보나
부팅이 안 되는데 다른 기계에 꽂으면 파일이 다 보인다부트스트랩 코드(446바이트)멀쩡하다부트로더 다시 설치
디스크는 보이는데 「초기화하시겠습니까」가 뜬다파티션 표대개 멀쩡하다아무것도 쓰지 말 것. 짝 GPT 나 파티션 복구 도구
파티션은 보이는데 「포맷하시겠습니까」가 뜬다파일 시스템의 첫 섹터대개 멀쩡하다부트 섹터 사본(FAT 는 6번 섹터), 슈퍼블록 사본
파일 목록은 보이는데 내용이 깨져 있다FAT·MFT 등 관리 구조부분적검사 도구(fsck·chkdsk)를 디스크 이미지 사본에서
특정 파일만 못 읽는다자료 구역의 섹터그 파일만디스크 자체의 건강(SMART)

표 115.26 — 증상 → 어느 층이 상했나

실제 사례. 「초기화하시겠습니까」를 누르지 않은 덕에 살았다

파티션 표만 상한 디스크는 자료가 전부 그대로 있다. 파티션이 어디서 시작했는지만 알아내면 통째로 되살릴 수 있고, GPT 라면 디스크 끝의 짝 헤더가 그 답을 갖고 있다. 이때 가장 위험한 행동은 「고쳐 주겠다」는 제안을 눌러 새 파티션 표를 쓰는 것이다 — 그 순간 짝까지 덮어써질 수 있다. 손대기 전에 통째로 이미지를 뜨는 것이 순서다.

여기서 남기는 것#

복습 정리

  • 디스크가 아는 것은 번호 붙은 섹터뿐이다. 파티션도 파일 시스템도 그 위의 약속이다.
  • MBR 은 512바이트 섹터 하나에 부팅 코드와 배치도를 함께 넣었다 — 그래서 코드 446바이트, 파티션 넷, 2 TiB 한계가 나왔다.
  • 넷을 넘기려고 만든 확장 파티션은 연결 목록이고, EBR 마다 두 항목이 상대 LBA 를 세는 기준이 달라 헷갈린다.
  • GPT 는 같은 일을 배열 + CRC + 사본으로 다시 지었다. 그리고 0번 섹터에는 옛 도구를 막는 보호 MBR 을 남겼다.
  • GUID 는 글로 쓸 때와 저장될 때의 바이트 차례가 다르다.
  • 파티션 안은 파일 시스템의 세계다. FAT 는 「다음 칸 번호」의 배열, ext4 는 아이노드, NTFS 는 모든 것이 파일.
  • 층이 다르므로 증상으로 어느 층이 상했는지 가릴 수 있다. 그리고 상한 층이 배치도라면 자료는 대개 그대로 있다 — 그때 가장 위험한 것은 성급히 쓰는 것이다.