Proven C Book←↑→

부록 I — 프로그램이 죽은 뒤에: 덤프를 읽는 법

디버거는 살아 있는 프로그램을 다루는 도구다. 멈춰 세우고, 값을 들여다보고, 한 줄씩 밀어 본다. 18장가 그 자리다. 그런데 실무에서 가장 흔한 사고는 그렇게 만나 주지 않는다. 프로그램은 이미 죽었고, 그것도 새벽 세 시에 남의 기계에서 죽었으며, 남은 것은 파일 하나뿐이다.

이 부록은 그 파일을 읽는 법이다.

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

아래의 실행 결과는 모두 리눅스(x86-64, glibc)에서 실제로 잡아 둔 것이다. 코어 덤프의 파일 형식과 설정 이름은 운영체제마다 다르므로, 형식을 외우기보다 「무엇이 담기고 무엇이 안 담기는가」를 붙잡는 편이 오래 간다.

덤프란 무엇인가#

거창한 것이 아니다. 덤프는 프로세스의 기억을 그 순간 그대로 떠낸 사진이다. 여기에 레지스터 값과 신호 정보가 붙는다. 그게 전부다.

무엇이 사진에 찍히는지 알려면 프로세스의 기억이 어떻게 생겼는지부터 보아야 한다. 리눅스에서는 그것을 프로그램이 스스로 볼 수 있다.

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 는 아무 모순이 없다. 사고가 난 뒤에는 그 파일이 없으면 주소를 이름으로 바꿀 길이 아예 없다.

스택을 되감는 법#

「누가 불렀나」를 알려면 스택을 거슬러 올라가야 한다. 두 가지 길이 있다.

  1. 프레임 포인터를 따라간다. 각 프레임이 이전 프레임의 자리를 들고 있으면 사슬을 타고 올라가면 된다. 단순하고 빠르다.
  2. 풀기 표를 읽는다. 최적화가 프레임 포인터를 없애면(레지스터 하나를 아끼려고) 사슬이 끊긴다. 그래서 컴파일러가 별도의 표를 만들어 둔다 — .eh_frame 에 든 CFI 정보다. 「이 주소 범위에서는 반환 주소가 스택의 어디에 있다」를 적어 둔 표다.

18장가 「최적화된 빌드에서는 디버거가 이상해 보인다」고 했던 그 이야기의 뒷면이 여기다. 역추적이 엉키거나 프레임이 통째로 사라지는 것은 디버거가 못나서가 아니라, 그 정보가 코드에서 사라졌기 때문이다. 표가 있으면 되살릴 수 있고, 없으면 못 한다.

읽는 절차#

덤프를 앞에 두고 무엇부터 보는가. 순서가 있다.

  1. 어디서 죽었나 — 프로그램 계수기와 신호. SIGSEGV 인가 SIGABRT 인가부터 갈린다(후자는 대개 우리 코드가 스스로 부른 것이다).
  2. 어떤 주소를 건드리다 죽었나 — 널이면 초기화 안 된 포인터, 아주 작은 값이면 널 포인터에 필드 접근(p->field 에서 p 가 널), 이상하게 큰 값이면 손상된 포인터.
  3. 누가 불렀나 — 역추적. 프레임이 이상하면 스택이 이미 망가진 것을 의심한다.
  4. 값이 말이 되나 — 길이가 음수인가, 포인터가 자기 구조체 밖을 가리키는가.
  5. 가설을 세운다 — 그리고 그 가설을 살아 있는 프로그램에서 재현해 본다.

반례. 덤프에서 결론을 확정한다

덤프는 가설을 세우는 자리이지 확인하는 자리가 아니다. 마지막 순간의 사진 하나로 인과를 단정하면, 사실은 다른 곳에서 이미 벌어진 손상을 애먼 함수에 뒤집어씌우게 된다. 확인은 재현으로 한다.

덤프가 남게 만들기 — 그리고 안 남는 흔한 이유#

사고가 났는데 덤프가 없으면 위의 모든 것이 소용없다. 리눅스에서 확인할 것들.

무엇왜 덤프가 안 남는가
ulimit -c0 이면 아예 안 만든다. 기본이 0 인 배포판이 많다
/proc/sys/kernel/core_pattern파일 이름 규칙, 또는 다른 프로그램으로 넘기는 설정
coredump_filter어떤 조각을 담을지 고르는 비트. 공유 기억을 빼면 그 부분이 없다
디스크수 기가바이트짜리 덤프가 안 써질 수 있다
권한·컨테이너쓰기 권한이 없거나, 경로가 컨테이너 밖을 가리킨다

표 105.4 — 덤프가 안 남는 흔한 이유

실제 사례. 커널은 「떴다」는데 파일이 없었다

이 부록을 쓰며 실제로 만난 일이다. 이 기계에서 일부러 프로그램을 죽여 보니 커널은 코어를 떴다고 말했지만 파일은 어디에도 없었다 — 설정이 덤프를 다른 프로그램으로 넘기게 되어 있었고, 그 프로그램의 저장 자리에는 손이 닿지 않았다. 덤프는 사고가 나기 전에 「남는지」를 확인해 두어야 하는 물건이다.

여기서 남기는 것#

복습 정리

  • 덤프는 프로세스 기억의 사진 + 레지스터다. 그 이상도 이하도 아니다.
  • 그래서 상태는 보여 주지만 과정은 보여 주지 않는다.
  • 주소를 이름으로 바꾸려면 디버그 정보가 있어야 한다 — 배포 빌드도 -g, 따로 보관.
  • 역추적이 엉키는 것은 디버거 탓이 아니라 정보가 없어서다.
  • 그리고 무엇보다, 덤프는 사고 전에 준비하는 것이다.