부록 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바이트를 써라」 — 장치가 아는 명령은 사실상 이것뿐이다.
파티션도 파일 시스템도 그 줄 위에 약속으로 얹힌 것이다. 디스크는 자기가 어떻게 나뉘어 있는지 모른다.
그림 115.1 — 디스크 한 장에 겹쳐 있는 세 층 — 섹터의 줄, 파티션 표, 파일 시스템.
옛날에는 자리를 CHS 로 불렀다. 실린더 몇 번, 머리 몇 번, 섹터 몇 번. 디스크가 정말로 접시 여러 장이던 시절의 이름이다. 지금은 전부 LBA 로 부른다 — 그냥 번호다.
| 부르는 이름 | 물리 섹터 | 논리 섹터 | 무엇이 달라지나 |
|---|---|---|---|
| 512n | 512바이트 | 512바이트 | 옛 디스크. 둘이 같다 |
| 512e | 4096바이트 | 512바이트 | 속은 4 KiB 인데 512 인 척한다 — 정렬이 어긋나면 느려진다 |
| 4Kn | 4096바이트 | 4096바이트 | 숨김 없이 4 KiB. 옛 소프트웨어가 못 읽는 일이 있다 |
표 115.2 — 섹터 크기의 세 갈래
문. 512e 에서 정렬이 어긋나면 왜 느려지는가?
답. 디스크가 실제로 다루는 덩어리는 4 KiB 인데 소프트웨어가 512바이트 단위로 어긋난 자리에 쓰면, 장치는 4 KiB 를 읽어 → 그 안의 512바이트를 고쳐 → 다시 4 KiB 를 쓴다(읽기-수정-쓰기). 쓰기 한 번이 세 번의 일이 된다. 파티션을 2048섹터(=1 MiB)에서 시작시키는 오늘날의 관례가 이것을 피하려는 것이다.
왜 나누는가#
디스크 한 장을 통째로 쓰면 안 되는가? 되기도 한다. 그런데 나누는 이유가 있다.
- 여러 운영체제를 함께 둔다. 각자 제 파일 시스템을 쓴다.
- 성질이 다른 자료를 가른다. 스왑, 시스템, 개인 자료. 하나가 가득 차도 다른 쪽은 산다.
- 부팅에 필요한 자리를 따로 둔다. UEFI 의 ESP(EFI 시스템 파티션)처럼 펌웨어가 읽을 수 있는 파일 시스템이어야 하는 자리가 있다.
- 복구와 백업의 단위가 된다. 파티션 하나만 통째로 떠 두는 식으로.
MBR — 첫 섹터 512바이트에 다 적는다#
가장 오래된 방식이다. 디스크의 0번 섹터 하나에 부팅 코드와 파티션 표를 다 넣는다.
| 오프셋 | 크기 | 이름 | 무엇 | 전형적인 값 |
|---|---|---|---|---|
0x000 | 446 | 부트스트랩 코드 | 부팅할 때 CPU 가 실행하는 첫 코드. 부트로더의 첫 조각밖에 못 들어간다 | 기계 명령 |
0x1B8 | 4 | 디스크 서명 | 윈도우가 디스크를 알아보는 번호(옛 MBR 에는 없다) | 임의의 4바이트 |
0x1BC | 2 | 예약 | 보통 0 | 00 00 |
0x1BE | 16 | 파티션 항목 1 | 아래 표 115.4 참조 | — |
0x1CE | 16 | 파티션 항목 2 | 〃 | — |
0x1DE | 16 | 파티션 항목 3 | 〃 | — |
0x1EE | 16 | 파티션 항목 4 | 〃 | — |
0x1FE | 2 | 부팅 서명 | 이것이 없으면 BIOS 가 부팅 섹터로 인정하지 않는다 | 55 AA |
표 115.3 — MBR 512바이트의 전체 자리
★ 표의 첫 줄과 마지막 줄이 이 방식의 성격을 다 말한다. 부팅 코드와 디스크의 배치도(파티션 표)가 같은 512바이트를 나눠 쓴다. 그래서 코드는 446바이트뿐이고, 파티션은 넷뿐이다.
파티션 항목 16바이트#
| 오프셋 | 크기 | 이름 | 무엇 | 전형적인 값 |
|---|---|---|---|---|
+0 | 1 | 부팅 표시 | 0x80 이면 「여기서 부팅」, 0x00 이면 아님. 그 밖의 값은 잘못된 것 | 80 또는 00 |
+1 | 3 | 시작 CHS | 옛 방식의 시작 자리. 요즘은 읽지 않는다 | FE FF FF |
+4 | 1 | 파티션 종류 | 무엇이 들었는지의 힌트 한 바이트(표 115.6) | 83, 07, 0C … |
+5 | 3 | 끝 CHS | 옛 방식의 끝 자리. 역시 안 읽는다 | FE FF FF |
+8 | 4 | 시작 LBA | 진짜로 쓰이는 값. 이 파티션이 시작하는 섹터 번호 | 2048 |
+12 | 4 | 섹터 수 | 진짜로 쓰이는 값. 이 파티션의 길이 | 2097152(=1 GiB) |
표 115.4 — MBR 파티션 항목 16바이트
CHS 세 바이트에 값이 어떻게 실리는지도 적어 둔다. 이 비좁음이 옛 한계의 원인이다.
| 바이트 | 비트 | 무엇 | 범위 |
|---|---|---|---|
+0 | 7~0 | 머리(head) | 0~255 |
+1 | 7~6 | 실린더의 위 2비트 | 실린더는 모두 10비트 |
+1 | 5~0 | 섹터(sector) | 1~63 — 0 은 없다 |
+2 | 7~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 아래에서만 |
0x07 | NTFS / exFAT | 둘이 같은 값을 쓴다 — 열어 봐야 안다 |
0x0B | FAT32 (CHS) | |
0x0C | FAT32 (LBA) | 요즘 FAT32 는 대개 이것 |
0x0E | FAT16 (LBA) | |
0x0F | 확장 파티션(LBA) | 요즘 확장 파티션은 이것 |
0x82 | 리눅스 스왑 | |
0x83 | 리눅스 | ext4 든 xfs 든 전부 이 값 |
0xEE | GPT 보호 MBR | 「이 디스크는 GPT 다」 |
0xEF | EFI 시스템 파티션 | 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)로 쓰고, 그 파티션 안에 연결 목록을 만든다.
그림 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 사슬 한가운데를 지운다
GPT — 배열과 검사와 사본으로 다시 지었다#
GPT(GUID Partition Table)는 같은 일을 다시 설계한 것이다. 바뀐 것을 먼저 한 줄로 말하면 이렇다. 한 섹터에 우겨넣던 것을 여러 섹터의 배열로 펴고, 깨졌는지 알 수 있게 검사값을 붙이고, 통째로 한 벌 더 두었다.
| MBR | GPT | |
|---|---|---|
| 배치도가 사는 곳 | 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바이트#
| 오프셋 | 크기 | 이름 | 무엇 | 전형적인 값 |
|---|---|---|---|---|
0 | 8 | 서명 | EFI PART 여덟 글자 | 45 46 49 20 50 41 52 54 |
8 | 4 | 개정 | 규격 판 번호 | 00 00 01 00 (= 1.0) |
12 | 4 | 헤더 크기 | CRC 를 계산할 범위 | 92 |
16 | 4 | 헤더 CRC32 | 이 자리를 0 으로 두고 헤더를 해싱한 값 | 계산값 |
20 | 4 | 예약 | 반드시 0 | 00 00 00 00 |
24 | 8 | 이 헤더의 LBA | 자기 자신이 어디 있는지 | 1 |
32 | 8 | 짝 헤더의 LBA | 백업이 어디 있는지 | 디스크 마지막 섹터 |
40 | 8 | 쓸 수 있는 첫 LBA | 파티션이 시작될 수 있는 가장 앞 | 34 |
48 | 8 | 쓸 수 있는 마지막 LBA | 파티션이 끝날 수 있는 가장 뒤 | 끝 − 33 |
56 | 16 | 디스크 GUID | 이 디스크의 고유 번호 | 16바이트 |
72 | 8 | 항목 배열의 LBA | 파티션 항목들이 시작하는 섹터 | 2 |
80 | 4 | 항목 개수 | 배열의 칸 수 | 128 |
84 | 4 | 항목 하나의 크기 | 보통 128바이트 | 128 |
88 | 4 | 항목 배열 CRC32 | 배열 전체(개수 × 크기)의 해시 | 계산값 |
표 115.9 — GPT 헤더의 전체 필드 (LBA 1, 92바이트)
★ 오프셋 72 를 눈여겨보라. 이 부록을 쓰며 그 자리를 64 로 잘못 적었더니 디스크 GUID 의 뒷부분을 덮어써서 출력이 이상해졌다. 표와 코드가 어긋나면 티가 난다는 것이 이 부록의 검증 방식이다.
GPT 파티션 항목 128바이트#
| 오프셋 | 크기 | 이름 | 무엇 | 전형적인 값 |
|---|---|---|---|---|
0 | 16 | 종류 GUID | 「무엇에 쓰는 파티션인가」(표 115.13) | ESP 라면 C12A7328-… |
16 | 16 | 고유 GUID | 이 파티션 하나의 이름표 — 복제하면 충돌한다 | 파티션마다 다름 |
32 | 8 | 첫 LBA | 시작 섹터 번호 | 2048 |
40 | 8 | 마지막 LBA | 끝 섹터 번호 — 이 섹터도 포함 | 206847 |
48 | 8 | 속성 비트 | 표 115.11 | 보통 0 |
56 | 72 | 이름 | 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) |
|---|---|---|---|
| 1 | 4바이트 | 작은 끝 — 뒤집힌다 | 28 73 2A C1 |
| 2 | 2바이트 | 작은 끝 — 뒤집힌다 | 1F F8 |
| 3 | 2바이트 | 작은 끝 — 뒤집힌다 | D2 11 |
| 4 | 2바이트 | 글자 순서 그대로 | BA 4B |
| 5 | 6바이트 | 글자 순서 그대로 | 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-00A0C93EC93B | EFI 시스템 파티션(ESP) | FAT 로 포맷된다. 펌웨어가 여기서 .efi 를 읽는다 — 「기계가 깨어나는 순서」 부록 |
21686148-6449-6E6F-744E-656564454649 | BIOS 부팅 파티션 | 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/32 | BPB (부트 섹터) | 파티션의 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 는 이 표를 가리킨다.
| 오프셋 | 크기 | 이름 | 무엇 | 전형적인 값 |
|---|---|---|---|---|
0 | 3 | 점프 명령 | 부팅할 때 코드로 건너뛴다 | EB 58 90 |
3 | 8 | 만든 곳 이름 | 글자. 뜻은 없다 | MSWIN4.1 |
11 | 2 | 섹터 크기 | 바이트 | 512 |
13 | 1 | 클러스터당 섹터 | 파일 자리를 나눠 주는 단위 | 8 (= 4 KiB) |
14 | 2 | 예약 섹터 | FAT 앞에 두는 자리(부트 섹터 포함) | 32 |
16 | 1 | FAT 벌 수 | 사본 개수 | 2 |
17 | 2 | 루트 항목 수 | FAT32 에서는 0 (루트도 클러스터에 산다) | 0 |
19 | 2 | 총 섹터(16비트) | 작은 볼륨용. 안 쓰면 0 | 0 |
21 | 1 | 매체 종류 | 옛 흔적 | F8 (고정 디스크) |
22 | 2 | FAT 크기(16비트) | FAT32 에서는 0 | 0 |
28 | 4 | 숨은 섹터 | 이 파티션이 디스크에서 시작하는 자리 | 2048 |
32 | 4 | 총 섹터(32비트) | 이 볼륨의 크기 | 2097152 |
36 | 4 | FAT 크기(32비트) | FAT 한 벌의 섹터 수 — 계산해서 나오는 값 | 2046 |
44 | 4 | 루트 클러스터 | 루트 디렉터리가 시작하는 클러스터 | 2 |
48 | 2 | FSInfo 섹터 | 빈 클러스터 수를 캐시해 두는 자리 | 1 |
50 | 2 | 부트 섹터 사본 | 부트 섹터가 깨질 때를 대비한 사본의 자리 | 6 |
66 | 1 | 확장 서명 | 0x29 면 아래 셋이 유효 | 29 |
67 | 4 | 볼륨 일련번호 | 포맷할 때 정해진다 | 임의 |
71 | 11 | 볼륨 이름표 | 글자 | NO NAME |
82 | 8 | 파일 시스템 이름표 | 믿으면 안 된다 — 그냥 글자다 | FAT32 |
510 | 2 | 서명 | 55 AA | 55 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 와 어떻게 다른가#
| 오프셋 | 크기 | 이름 | 무엇 | 전형적인 값 |
|---|---|---|---|---|
0x00 | 4 | 아이노드 총수 | 파일 하나에 아이노드 하나 — 포맷할 때 정해진다 | 계산값 |
0x04 | 4 | 블록 총수 | 볼륨 크기(블록 단위) | 계산값 |
0x18 | 4 | 블록 크기 지수 | 블록 크기 = 1024 << 이 값 | 2 (= 4 KiB) |
0x20 | 4 | 그룹당 블록 수 | 블록 그룹의 크기 | 32768 |
0x28 | 4 | 그룹당 아이노드 수 | 블록 그룹 하나에 든 아이노드 수 | 계산값 |
0x38 | 2 | 매직 | 53 EF — 이것으로 ext 임을 안다 | 53 EF |
0x3A | 2 | 상태 | 깨끗이 언마운트되었는가 | 1 |
0x58 | 2 | 아이노드 크기 | 보통 256바이트 | 256 |
0x60 | 4 | 기능 비트(호환) | 저널 등 | — |
0x64 | 4 | 기능 비트(비호환) | 모르면 마운트하면 안 되는 기능들 | — |
0x68 | 16 | UUID | 볼륨의 고유 번호 — /etc/fstab 이 이것을 쓴다 | 16바이트 |
0x78 | 16 | 볼륨 이름 | 글자 | — |
표 115.18 — ext4 슈퍼블록의 주요 필드 (파티션 시작 + 1024바이트)
FAT 가 「다음 칸 번호의 배열」이라면, ext4 는 아이노드가 중심이다. 파일 하나가 아이노드 하나를 갖고, 거기에 크기·권한·시각과 자료가 어디 있는지가 적힌다. 그리고 자리를 번호 하나씩이 아니라 익스텐트(이어진 구간)로 적어 큰 파일을 짧게 표현한다.
| 오프셋 | 크기 | 이름 | 무엇 | 전형적인 값 |
|---|---|---|---|---|
3 | 8 | 이름표 | NTFS 로 알아본다 | NTFS |
11 | 2 | 섹터 크기 | 512 | |
13 | 1 | 클러스터당 섹터 | 8 | |
28 | 4 | 숨은 섹터 | 파티션의 시작 자리 | 2048 |
40 | 8 | 총 섹터 | 64비트 — 그래서 큰 볼륨이 된다 | 계산값 |
48 | 8 | $MFT 의 클러스터 | 모든 것의 목록이 시작하는 자리 | 계산값 |
56 | 8 | $MFTMirr 의 클러스터 | 그 목록의 사본 | 계산값 |
64 | 1 | MFT 레코드 크기 | 음수면 2|값| 바이트라는 뜻(보통 1024) | F6 |
510 | 2 | 서명 | 55 AA |
표 115.19 — NTFS 부트 섹터의 주요 필드
NTFS 에서는 모든 것이 파일이다 — 파일 목록($MFT)조차 파일이고, 부트 섹터도 $Boot 라는 파일이다. 그 목록의 자리만 부트 섹터에 적어 두면 나머지는 목록을 따라간다.
| 이름 | 자리 관리 | 저널 | 파일 크기 한계 | 주로 어디서 |
|---|---|---|---|---|
| FAT32 | FAT 사슬 | 없다 | 4 GiB 미만 | USB·SD·ESP — 어디서나 읽힌다 |
| exFAT | FAT 사슬(64비트) | 없다 | 사실상 없다 | 큰 SD 카드·카메라 |
| NTFS | MFT + 익스텐트 | 메타데이터 | 매우 크다 | 윈도우 |
| 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번이면 한 바퀴에 초, 곧 8.33 밀리초다. 원하는 섹터는 평균적으로 반 바퀴 뒤에 오므로 그 절반인 4.17 밀리초. 기계의 회전 속도 하나로 지연이 정해진다 — 소프트웨어가 어쩔 수 없는 값이다.
반도체 — 쓰는 단위와 지우는 단위가 다르다#
SSD 에는 움직이는 부품이 없으니 탐색이 없다. 대신 다른 성질이 생긴다. 제자리에 덮어쓸 수 없다.
그림 115.3 — SSD 의 쪽과 블록 — 쓰기 증폭이 생기는 자리.
| 단위 | 크기(대략) | 무엇에 쓰나 | 제약 |
|---|---|---|---|
| 셀 | 1비트~4비트 | 값을 담는 가장 작은 것 | 쓸 때마다 조금씩 닳는다 |
| 쪽(page) | 4~16 KiB | 읽기와 쓰기의 단위 | 빈 쪽에만 쓸 수 있다 |
| 블록(block) | 수 MiB (쪽 수백 개) | 지우기의 단위 | 지우려면 통째로 |
표 115.22 — SSD 안의 세 가지 단위
여기서 SSD 의 모든 성질이 나온다.
| 무엇 | 무엇인가 | 프로그래머가 느끼는 것 |
|---|---|---|
| FTL(사상 계층) | 「논리 주소 → 실제 쪽」 표를 장치가 스스로 관리 | 같은 주소에 써도 매번 다른 자리에 간다 |
| 쓰기 증폭 | 4 KiB 를 고치려고 블록을 옮겨 쓰고 지운다 | 작은 무작위 쓰기가 유난히 비싸다 |
| 가비지 컬렉션 | 죽은 쪽이 섞인 블록을 정리해 빈 블록을 만든다 | 가끔 쓰기가 갑자기 느려진다 |
| TRIM | 「이 쪽은 이제 안 쓴다」를 파일 시스템이 알려 준다 | 지우고 나면 다시 빨라진다 |
| 마모 평준화 | 같은 블록만 닳지 않게 골고루 돌려 쓴다 | 「빈 공간이 없으면 수명이 준다」 |
| 예비 공간 | 사용자에게 안 보이는 여분 | 광고 용량보다 실제 칩이 크다 |
표 115.23 — 「덮어쓸 수 없다」가 낳는 것들
흔한 오해. 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 — 증상 → 어느 층이 상했나
실제 사례. 「초기화하시겠습니까」를 누르지 않은 덕에 살았다
여기서 남기는 것#
복습 정리
- 디스크가 아는 것은 번호 붙은 섹터뿐이다. 파티션도 파일 시스템도 그 위의 약속이다.
- MBR 은 512바이트 섹터 하나에 부팅 코드와 배치도를 함께 넣었다 — 그래서 코드 446바이트, 파티션 넷, 2 TiB 한계가 나왔다.
- 넷을 넘기려고 만든 확장 파티션은 연결 목록이고, EBR 마다 두 항목이 상대 LBA 를 세는 기준이 달라 헷갈린다.
- GPT 는 같은 일을 배열 + CRC + 사본으로 다시 지었다. 그리고 0번 섹터에는 옛 도구를 막는 보호 MBR 을 남겼다.
- GUID 는 글로 쓸 때와 저장될 때의 바이트 차례가 다르다.
- 파티션 안은 파일 시스템의 세계다. FAT 는 「다음 칸 번호」의 배열, ext4 는 아이노드, NTFS 는 모든 것이 파일.
- 층이 다르므로 증상으로 어느 층이 상했는지 가릴 수 있다. 그리고 상한 층이 배치도라면 자료는 대개 그대로 있다 — 그때 가장 위험한 것은 성급히 쓰는 것이다.