부록 G — 프로그램이 죽은 뒤에: 덤프를 읽는 법
디버거는 살아 있는 프로그램을 다루는 도구다. 멈춰 세우고, 값을 들여다보고, 한 줄씩 밀어 본다. 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=0x55a2413941c9 rodata=0x55a241395008 data=0x55a241397060 bss=0x55a241397074 heap=0x55a2657e22a0 stack=0x7ffdc9f15e90
--- /proc/self/maps (first 8 lines) ---
55a241393000-55a241394000 r--p 00000000 00:56 1943597 ./apx-postmortem/whats_in_a_dump
55a241394000-55a241395000 r-xp 00001000 00:56 1943597 ./apx-postmortem/whats_in_a_dump
55a241395000-55a241396000 r--p 00002000 00:56 1943597 ./apx-postmortem/whats_in_a_dump
55a241396000-55a241397000 r--p 00002000 00:56 1943597 ./apx-postmortem/whats_in_a_dump
55a241397000-55a241398000 rw-p 00003000 00:56 1943597 ./apx-postmortem/whats_in_a_dump
55a2657e2000-55a265803000 rw-p 00000000 00:00 0 [heap]
7fa6c982d000-7fa6c9830000 rw-p 00000000 00:00 0
7fa6c9830000-7fa6c9858000 r--p 00000000 00:67 5715590 /usr/lib/x86_64-linux-gnu/libc.so.6
read through /proc/self/mem at &global_init = 0x41424344 (4 bytes)
시연 출력의 조각 목록(/proc/self/maps)과 읽어 낸 바이트에서 세 가지를 본다.
첫째, 기억은 한 덩어리가 아니라 조각의 목록이다. 각 조각에는 시작·끝 주소와 권한이 있다. r-xp 는 읽고 실행할 수 있으나 쓸 수 없는 조각이니 코드이고, rw-p 는 쓸 수 있으니 자료다. r--p 는 읽기 전용 — 문자열 리터럴이 사는 자리다. 5장에서 그림으로 본 배치가 여기서는 목록으로 나온다.
둘째, 이름표가 붙은 조각이 있다. [heap], [stack], 그리고 사상된 파일의 경로 (libc.so.6 처럼). 어떤 주소가 어디에 속하는지 이 목록으로 판정할 수 있고, 덤프를 읽을 때 하는 일의 절반이 그 판정이다.
셋째, 주소만 알면 그 자리의 바이트를 읽을 수 있다. 시연이 /proc/self/mem 에서 global_init 의 주소를 찾아 0x41424344 를 그대로 읽어 냈다.
★ 코어 덤프를 만드는 일이 바로 이것이다. 커널이 위와 같은 조각 목록을 훑어, 각 조각의 바이트를 파일에 적는다. 특별한 마법이 아니라 「읽어서 적기」다.
무엇이 들어 있고, 무엇이 없는가#
덤프에 무엇이 담기는가를 두고 흔한 오해가 하나 있다.
흔한 오해. 덤프에는 그 순간의 모든 것이 들어 있다
| 무엇 | 덤프에 | 왜 |
|---|---|---|
| 스택·힙·전역 | 있다 | 프로그램이 쓴 자료가 거기 있다 |
| 레지스터, 프로그램 계수기 | 있다 | 「어디서 죽었나」의 출발점 |
| 코드 | 대개 없다 | 실행 파일에 그대로 있으니 다시 읽으면 된다 |
| 사상된 파일의 내용 | 대개 없다 | 같은 이유. 다만 그 사이 파일이 바뀌면 덤프와 맞지 않는다 |
| 커널 안의 상태(열린 파일, 소켓) | 없다 | 프로세스 바깥이다 |
| 다른 프로세스 | 없다 | 사진은 한 프로세스만 찍는다 |
| 지나간 일 | 없다 | 덤프는 마지막 순간이지 과정이 아니다 |
표 115.1 — 덤프에 담기는 것과 담기지 않는 것
표 115.1의 마지막 줄이 덤프의 근본 한계다. 덤프는 「무엇이 잘못된 상태인가」를 보여 주지만 「어쩌다 그렇게 되었나」는 보여 주지 않는다. 그때 필요한 것이 기록·재생이나 새니타이저이고, 기록·재생은 111장에서, 새니타이저는 18장에서 다뤘다.
문. 코드가 덤프에 없다면, 역추적은 어떻게 하는가?
답. 덤프를 읽는 도구에게 사고 난 그 실행 파일을 함께 준다. 커널이 코드를 빼먹는 것은 그 바이트를 이미 디스크에서 구할 수 있기 때문이다. 그래서 배포한 바이너리를 버리면 덤프도 함께 못 쓰게 된다 — 같은 소스를 다시 빌드해도 주소가 어긋날 수 있다.
파일의 모양 — 리눅스의 코어는 ELF 다#
리눅스의 코어 덤프는 별도 형식이 아니라 ELF(executable and linkable format) 파일이다. 다만 종류가 다르다 — 실행 파일이 ET_EXEC·ET_DYN 이라면 코어는 ET_CORE 다.
| 프로그램 헤더 | 무엇이 들어 있나 |
|---|---|
PT_LOAD | 기억 조각 하나하나 — /proc/self/maps 목록의 각 줄에 대응한다 |
PT_NOTE | 레지스터, 신호 번호, 스레드 목록, 프로세스 이름 같은 「메모」 |
표 115.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);
}
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(+0x1188) [0x55f0d8ddc188]
[1] ./apx-postmortem/who_called_me(+0x121f) [0x55f0d8ddc21f]
[2] ./apx-postmortem/who_called_me(+0x122b) [0x55f0d8ddc22b]
[3] ./apx-postmortem/who_called_me(main+0x9) [0x55f0d8ddc237]
[4] /lib/x86_64-linux-gnu/libc.so.6(+0x29ca8) [0x7f8fc256aca8]
시연이 주소를 이름으로 바꾸는 층 가운데 둘, 심볼 표와 디버그 정보를 한 화면에 보인다. main 은 이름으로 나오는데 level1 level3 은 주소로만 나온다. 이름을 붙이는 backtrace_symbols 는 실행 중에 이름을 찾는 동적 심볼 표(.dynsym)만 보는데, 셋은 static 이라 지역 심볼이어서 그 표에 오르지 않기 때문이다(main 이 그 표에 오르는 것도 -rdynamic 으로 링크했을 때뿐이다). 파일에 든 정적 심볼 표(.symtab)를 nm 으로 직접 보면 셋도 거기 있고, 그 차이가 글자 하나로 드러난다 — 소문자 t 가 지역, 대문자 T 가 전역이다.
nm ./who_called_me --- 이 기계에서 갈무리한 것
000000000000126e t level1
0000000000001262 t level2
0000000000001179 t level3
000000000000127a T main
디버그 정보는 그보다 더 안다. addr2line 에 그 주소를 주면 함수 이름만이 아니라 파일과 줄 번호가 나온다. 곧 「이름이 안 보인다」는 것은 정보가 없다는 뜻이 아니라 어느 표를 보았는가의 문제다. 덤프를 읽을 때 이름이 안 나오면 먼저 물을 것은 「심볼이 없는가, 디버그 정보가 없는가, 아니면 엉뚱한 바이너리를 보고 있는가」다.
| 무엇 | 무엇을 아는가 | 언제 사라지는가 |
|---|---|---|
| 심볼 표 | 함수 이름과 주소의 대응 | strip 하면 사라진다 |
| DWARF | 변수 이름, 타입, 줄 번호까지 | -g 없이 빌드하면 애초에 없다 |
| 빌드 아이디 | 어느 디버그 파일이 이 바이너리의 짝인지 | 따로 지우지 않는 한 남는다 |
표 115.3 — 주소를 이름으로 바꾸는 세 층
★ 배포 빌드도 -g 로 만들고, 디버그 정보를 버리지 말고 따로 보관한다. 최적화를 끄라는 말이 아니다 — -O2 -g 는 아무 모순이 없다. 사고가 난 뒤에는 따로 보관한 디버그 정보 파일이 없으면 주소를 이름으로 바꿀 길이 아예 없다.
스택을 되감는 법#
「누가 불렀나」를 알려면 스택을 거슬러 올라가야 한다. 두 가지 길이 있다.
- 프레임 포인터를 따라간다. 각 프레임이 이전 프레임의 자리를 들고 있으면 사슬을 타고 올라가면 된다. 단순하고 빠르다.
- 풀기 표를 읽는다. 최적화가 프레임 포인터를 없애면(레지스터 하나를 아끼려고) 사슬이 끊긴다. 그래서 컴파일러가 별도의 표를 만들어 둔다 —
.eh_frame에 든 CFI(call frame information, 호출 프레임 정보)다. 「이 주소 범위에서는 반환 주소가 스택의 어디에 있다」를 적어 둔 표다.
18장이 「최적화된 빌드에서는 디버거가 이상해 보인다」고 했던 그 이야기의 뒷면이 여기다. 역추적이 엉키거나 프레임이 통째로 사라지는 것은 디버거가 못나서가 아니라, 그 정보가 코드에서 사라졌기 때문이다. 표가 있으면 되살릴 수 있고, 없으면 못 한다.
읽는 절차#
덤프를 앞에 두고 무엇부터 보는가. 순서가 있다.
- 어디서 죽었나 — 프로그램 계수기와 신호.
SIGSEGV인가SIGABRT인가부터 갈린다(후자는 대개 우리 코드가 스스로 부른 것이다). - 어떤 주소를 건드리다 죽었나 — 널이면 널인 채로 쓴 포인터(검사하지 않은
malloc결과 따위), 아주 작은 값이면 널 포인터에 필드 접근(p->field에서p가 널), 이상하게 큰 값이면 손상된 포인터. - 누가 불렀나 — 역추적. 프레임이 이상하면 스택이 이미 망가진 것을 의심한다.
- 값이 말이 되나 — 길이가 음수인가, 포인터가 자기 구조체 밖을 가리키는가.
- 가설을 세운다 — 그리고 그 가설을 살아 있는 프로그램에서 재현해 본다.
반례. 덤프에서 결론을 확정한다
덤프가 남게 만들기 — 그리고 안 남는 흔한 이유#
사고가 났는데 덤프가 없으면 위의 모든 것이 소용없다. 리눅스에서 확인할 것들.
| 무엇 | 왜 덤프가 안 남는가 |
|---|---|
ulimit -c | 0 이면 아예 안 만든다. 기본이 0 인 배포판이 많다 |
/proc/sys/kernel/core_pattern | 파일 이름 규칙, 또는 덤프를 다른 프로그램으로 넘기는 설정 |
coredump_filter | 어떤 조각을 담을지 고르는 비트. 공유 기억을 빼면 그 부분이 없다 |
| 디스크 | 수 기가바이트짜리 덤프가 안 써질 수 있다 |
| 권한·컨테이너 | 쓰기 권한이 없거나, 경로가 컨테이너 밖을 가리킨다 |
표 115.4 — 덤프가 안 남는 흔한 이유
실제 사례. 커널은 「떴다」는데 파일이 없었다
여기서 남기는 것#
복습 정리
- 덤프는 프로세스 기억의 사진 + 레지스터다. 그 이상도 이하도 아니다.
- 그래서 상태는 보여 주지만 과정은 보여 주지 않는다.
- 주소를 이름으로 바꾸려면 디버그 정보가 있어야 한다 — 배포 빌드도
-g, 따로 보관. - 역추적이 엉키는 것은 디버거 탓이 아니라 정보가 없어서다.
- 그리고 무엇보다, 덤프는 사고 전에 준비하는 것이다.