부록 I — 프로그램이 죽은 뒤에: 덤프를 읽는 법
디버거는 살아 있는 프로그램을 다루는 도구다. 멈춰 세우고, 값을 들여다보고, 한 줄씩 밀어 본다. 18장가 그 자리다. 그런데 실무에서 가장 흔한 사고는 그렇게 만나 주지 않는다. 프로그램은 이미 죽었고, 그것도 새벽 세 시에 남의 기계에서 죽었으며, 남은 것은 파일 하나뿐이다.
이 부록은 그 파일을 읽는 법이다.
플랫폼 노트. 이 부록의 근거
덤프란 무엇인가#
거창한 것이 아니다. 덤프는 프로세스의 기억을 그 순간 그대로 떠낸 사진이다. 여기에 레지스터 값과 신호 정보가 붙는다. 그게 전부다.
무엇이 사진에 찍히는지 알려면 프로세스의 기억이 어떻게 생겼는지부터 보아야 한다. 리눅스에서는 그것을 프로그램이 스스로 볼 수 있다.
examples/apx-postmortem/whats_in_a_dump.c
/* 덤프에 무엇이 들어 있는가 --- 프로세스가 제 기억을 스스로 들여다본다.
코어 덤프가 하는 일이 정확히 이것이다: 이 목록과 이 바이트들을 파일에 적는 것. */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int global_zero; int global_init = 0x41424344;
int main(void){
char *heap = malloc(4096); strcpy(heap, "heap");
char stackbuf[64]; strcpy(stackbuf, "stack");
printf("code=%p rodata=%p data=%p bss=%p heap=%p stack=%p\n",
(void*)main, (void*)"ro", (void*)&global_init, (void*)&global_zero, (void*)heap, (void*)stackbuf);
FILE *f=fopen("/proc/self/maps","r"); char line[512]; int n=0;
puts("\n--- /proc/self/maps (first 8 lines) ---");
while (fgets(line,sizeof line,f) && n++<8) fputs(line,stdout);
fclose(f);
/* 제 기억을 스스로 읽어 본다 --- 덤프가 하는 일이 이것이다 */
FILE *m=fopen("/proc/self/mem","rb");
if (m){ fseek(m,(long)(size_t)&global_init,SEEK_SET); unsigned v=0;
size_t got=fread(&v,1,4,m);
printf("\nread through /proc/self/mem at &global_init = 0x%08X (%zu bytes)\n", v, got);
fclose(m); }
free(heap); return 0;
}
실행 결과
code=0x56101c99b1c9 rodata=0x56101c99c008 data=0x56101c99e060 bss=0x56101c99e074 heap=0x56104d7342a0 stack=0x7fffa6f48aa0
--- /proc/self/maps (first 8 lines) ---
56101c99a000-56101c99b000 r--p 00000000 00:57 1077115 ./apx-postmortem/whats_in_a_dump
56101c99b000-56101c99c000 r-xp 00001000 00:57 1077115 ./apx-postmortem/whats_in_a_dump
56101c99c000-56101c99d000 r--p 00002000 00:57 1077115 ./apx-postmortem/whats_in_a_dump
56101c99d000-56101c99e000 r--p 00002000 00:57 1077115 ./apx-postmortem/whats_in_a_dump
56101c99e000-56101c99f000 rw-p 00003000 00:57 1077115 ./apx-postmortem/whats_in_a_dump
56104d734000-56104d755000 rw-p 00000000 00:00 0 [heap]
7f42baf3b000-7f42baf3e000 rw-p 00000000 00:00 0
7f42baf3e000-7f42baf66000 r--p 00000000 00:66 3857579 /usr/lib/x86_64-linux-gnu/libc.so.6
read through /proc/self/mem at &global_init = 0x41424344 (4 bytes)
세 가지를 짚는다.
첫째, 기억은 한 덩어리가 아니라 조각의 목록이다. 각 조각에는 시작·끝 주소와 권한이 있다. r-xp 는 읽고 실행할 수 있으나 쓸 수 없는 조각이니 코드이고, rw-p 는 쓸 수 있으니 자료다. r--p 는 읽기 전용 — 문자열 리터럴이 사는 자리다. 5장에서 그림으로 본 배치가 여기서는 목록으로 나온다.
둘째, 이름표가 붙은 조각이 있다. [heap], [stack], 그리고 사상된 파일의 경로 (libc.so.6 처럼). 어떤 주소가 어디에 속하는지 이 목록으로 판정할 수 있고, 덤프를 읽을 때 하는 일의 절반이 그 판정이다.
셋째, 주소만 알면 그 자리의 바이트를 읽을 수 있다. 시연이 /proc/self/mem 에서 global_init 의 주소를 찾아 0x41424344 를 그대로 읽어 냈다.
★ 코어 덤프를 만드는 일이 바로 이것이다. 커널이 위와 같은 조각 목록을 훑어, 각 조각의 바이트를 파일에 적는다. 특별한 마법이 아니라 「읽어서 적기」다.
무엇이 들어 있고, 무엇이 없는가#
여기서 오해가 갈린다.
흔한 오해. 덤프에는 그 순간의 모든 것이 들어 있다
| 무엇 | 덤프에 | 왜 |
|---|---|---|
| 스택·힙·전역 | 있다 | 프로그램이 쓴 자료가 거기 있다 |
| 레지스터, 프로그램 계수기 | 있다 | 「어디서 죽었나」의 출발점 |
| 코드 | 대개 없다 | 실행 파일에 그대로 있으니 다시 읽으면 된다 |
| 사상된 파일의 내용 | 대개 없다 | 같은 이유. 다만 파일이 바뀌면 못 맞춘다 |
| 커널 안의 상태(열린 파일, 소켓) | 없다 | 프로세스 바깥이다 |
| 다른 프로세스 | 없다 | 사진은 한 프로세스만 찍는다 |
| 지나간 일 | 없다 | 덤프는 마지막 순간이지 과정이 아니다 |
표 105.1 — 덤프에 담기는 것과 담기지 않는 것
표 105.1의 마지막 줄이 덤프의 근본 한계다. 덤프는 「무엇이 잘못된 상태인가」를 보여 주지만 「어쩌다 그렇게 되었나」는 보여 주지 않는다. 그때 필요한 것이 기록·재생이나 새니타이저이고, 그 이야기는 18장에 있다.
문. 코드가 덤프에 없다면, 역추적은 어떻게 하는가?
답. 덤프를 읽는 도구에게 사고 난 그 실행 파일을 함께 준다. 커널이 코드를 빼먹는 것은 그 바이트를 이미 디스크에서 구할 수 있기 때문이다. 그래서 배포한 바이너리를 버리면 덤프도 함께 못 쓰게 된다 — 같은 소스를 다시 빌드해도 주소가 어긋날 수 있다.
파일의 모양 — 리눅스의 코어는 ELF 다#
리눅스의 코어 덤프는 별도 형식이 아니라 ELF(executable and linkable format) 파일이다. 다만 종류가 다르다 — 실행 파일이 ET_EXEC·ET_DYN 이라면 코어는 ET_CORE 다.
| 프로그램 헤더 | 무엇이 들어 있나 |
|---|---|
PT_LOAD | 기억 조각 하나하나 — 위 목록의 각 줄에 대응한다 |
PT_NOTE | 레지스터, 신호 번호, 스레드 목록, 프로세스 이름 같은 「메모」 |
표 105.2 — 코어 파일의 프로그램 헤더
윈도우의 미니덤프는 형식이 다르지만 담는 것은 같다 — 기억 조각과 레지스터와 메모. 형식을 외울 일이 아니라 「무엇이 담기는가」를 알면 된다.
주소를 이름으로 바꾸기#
덤프에서 얻는 첫 단서는 대개 주소다 — 0x00005584a1b2c3d4 같은. 이것을 parse_header 같은 이름으로 바꾸려면 디버그 정보가 필요하다.
examples/apx-postmortem/who_called_me.c
/* 주소를 이름으로 바꾸는 두 층 --- 심볼 표와 디버그 정보.
여기서는 살아 있는 프로그램이 제 스택을 되감지만, 덤프를 읽을 때 디버거가 하는
일이 정확히 같다: 주소를 모아, 그것을 이름과 줄 번호로 바꾼다. */
#define _GNU_SOURCE
#include <execinfo.h>
#include <stdio.h>
#include <stdlib.h>
static void level3(void) /* static --- 동적 심볼 표에 오르지 않는다 */
{
void *frames[16];
int n = backtrace(frames, 16); /* 되감아 주소를 모은다 */
char **names = backtrace_symbols(frames, n); /* 이름을 붙여 본다 */
printf("frames captured: %d\n\n", n);
for (int i = 0; i < n && i < 5; i++)
printf(" [%d] %s\n", i, names[i]);
free(names);
puts("\nnotice: main is named, the static functions are not.");
puts("a name here comes from the *symbol table*, and static functions are local");
puts("symbols -- they never reach the dynamic one. the debug information (DWARF)");
puts("still knows them, which is what addr2line reads.");
}
static void level2(void) { level3(); }
static void level1(void) { level2(); }
int main(void) { level1(); return 0; }
실행 결과
frames captured: 7
[0] ./apx-postmortem/who_called_me(+0x1198) [0x560ae7b24198]
[1] ./apx-postmortem/who_called_me(+0x126b) [0x560ae7b2426b]
[2] ./apx-postmortem/who_called_me(+0x1277) [0x560ae7b24277]
[3] ./apx-postmortem/who_called_me(+0x1283) [0x560ae7b24283]
[4] /lib/x86_64-linux-gnu/libc.so.6(+0x29ca8) [0x7f3e5918bca8]
notice: main is named, the static functions are not.
a name here comes from the *symbol table*, and static functions are local
symbols -- they never reach the dynamic one. the debug information (DWARF)
still knows them, which is what addr2line reads.
시연이 그 두 층을 한 화면에 보인다. main 은 이름으로 나오는데 level1 level3 은 주소 조각으로만 나온다. 셋이 static 이라 지역 심볼이고, 실행 중에 이름을 붙여 주는 표에는 오르지 않기 때문이다. 심볼 표를 직접 보면 그 차이가 글자 하나로 드러난다 — 소문자 t 가 지역, 대문자 T 가 전역이다.
nm ./who_called_me --- 이 기계에서 갈무리한 것
000000000000126e t level1
0000000000001262 t level2
0000000000001179 t level3
000000000000127a T main
그러나 디버그 정보는 이 셋을 알고 있다. addr2line 에 그 주소를 주면 파일과 줄 번호가 나온다. 곧 「이름이 안 보인다」는 것은 정보가 없다는 뜻이 아니라 어느 표를 보았는가의 문제다. 덤프를 읽을 때 이름이 안 나오면 먼저 물을 것은 「심볼이 없는가, 디버그 정보가 없는가, 아니면 엉뚱한 바이너리를 보고 있는가」다.
| 무엇 | 무엇을 아는가 | 언제 사라지는가 |
|---|---|---|
| 심볼 표 | 함수 이름과 주소의 대응 | strip 하면 사라진다 |
| DWARF | 변수 이름, 타입, 줄 번호까지 | -g 없이 빌드하면 애초에 없다 |
| 빌드 아이디 | 어느 디버그 파일이 이 바이너리의 짝인지 | 따로 지우지 않는 한 남는다 |
표 105.3 — 주소를 이름으로 바꾸는 세 층
★ 배포 빌드도 -g 로 만들고, 디버그 정보를 버리지 말고 따로 보관한다. 최적화를 끄라는 말이 아니다 — -O2 -g 는 아무 모순이 없다. 사고가 난 뒤에는 그 파일이 없으면 주소를 이름으로 바꿀 길이 아예 없다.
스택을 되감는 법#
「누가 불렀나」를 알려면 스택을 거슬러 올라가야 한다. 두 가지 길이 있다.
- 프레임 포인터를 따라간다. 각 프레임이 이전 프레임의 자리를 들고 있으면 사슬을 타고 올라가면 된다. 단순하고 빠르다.
- 풀기 표를 읽는다. 최적화가 프레임 포인터를 없애면(레지스터 하나를 아끼려고) 사슬이 끊긴다. 그래서 컴파일러가 별도의 표를 만들어 둔다 —
.eh_frame에 든 CFI 정보다. 「이 주소 범위에서는 반환 주소가 스택의 어디에 있다」를 적어 둔 표다.
18장가 「최적화된 빌드에서는 디버거가 이상해 보인다」고 했던 그 이야기의 뒷면이 여기다. 역추적이 엉키거나 프레임이 통째로 사라지는 것은 디버거가 못나서가 아니라, 그 정보가 코드에서 사라졌기 때문이다. 표가 있으면 되살릴 수 있고, 없으면 못 한다.
읽는 절차#
덤프를 앞에 두고 무엇부터 보는가. 순서가 있다.
- 어디서 죽었나 — 프로그램 계수기와 신호.
SIGSEGV인가SIGABRT인가부터 갈린다(후자는 대개 우리 코드가 스스로 부른 것이다). - 어떤 주소를 건드리다 죽었나 — 널이면 초기화 안 된 포인터, 아주 작은 값이면 널 포인터에 필드 접근(
p->field에서p가 널), 이상하게 큰 값이면 손상된 포인터. - 누가 불렀나 — 역추적. 프레임이 이상하면 스택이 이미 망가진 것을 의심한다.
- 값이 말이 되나 — 길이가 음수인가, 포인터가 자기 구조체 밖을 가리키는가.
- 가설을 세운다 — 그리고 그 가설을 살아 있는 프로그램에서 재현해 본다.
반례. 덤프에서 결론을 확정한다
덤프가 남게 만들기 — 그리고 안 남는 흔한 이유#
사고가 났는데 덤프가 없으면 위의 모든 것이 소용없다. 리눅스에서 확인할 것들.
| 무엇 | 왜 덤프가 안 남는가 |
|---|---|
ulimit -c | 0 이면 아예 안 만든다. 기본이 0 인 배포판이 많다 |
/proc/sys/kernel/core_pattern | 파일 이름 규칙, 또는 다른 프로그램으로 넘기는 설정 |
coredump_filter | 어떤 조각을 담을지 고르는 비트. 공유 기억을 빼면 그 부분이 없다 |
| 디스크 | 수 기가바이트짜리 덤프가 안 써질 수 있다 |
| 권한·컨테이너 | 쓰기 권한이 없거나, 경로가 컨테이너 밖을 가리킨다 |
표 105.4 — 덤프가 안 남는 흔한 이유
실제 사례. 커널은 「떴다」는데 파일이 없었다
여기서 남기는 것#
복습 정리
- 덤프는 프로세스 기억의 사진 + 레지스터다. 그 이상도 이하도 아니다.
- 그래서 상태는 보여 주지만 과정은 보여 주지 않는다.
- 주소를 이름으로 바꾸려면 디버그 정보가 있어야 한다 — 배포 빌드도
-g, 따로 보관. - 역추적이 엉키는 것은 디버거 탓이 아니라 정보가 없어서다.
- 그리고 무엇보다, 덤프는 사고 전에 준비하는 것이다.