Addr2line을 이용한 C++ 프로그램 디버깅

Gunhee Jang·2022년 12월 29일
0

C++ 언어를 이용한 개발을 진행하다 보면, 유독 프로그램의 Crash 상황을 마주하게 되는 경우가 빈번하게 발생하는 것 같다. Visual Studio 와 같은 IDE를 이용하는 경우 조금 더 편하게 상황에 대처할 수 있으나, 본인의 경우 업무 시 ARM Architecture를 타겟으로 프로그램 컴파일 후 리눅스 기반 임베디드 시스템 상에서 동작하는 소스를 개발하기 때문에, X86 기반의 PC에서 직접 디버깅이 불가능한 환경이다.

일반적으로 본인이 직접 테스트를 진행하는 경우 GNU Debugger (GDB) 를 이용하여 정확히 어떤 부분에서 문제가 발생하였는지 디버깅을 할 수 있으나, 개발 단계에서 100% 모든 상황을 대비해놓을 수 있는 것은 아니기 때문에 프로그램 유지보수를 위한 디버깅 방안은 마련해 두어야 한다.

C++ 프로그램을 개발하며 마주한 대표적인 이슈를 꼽자면 단연 Nullpointer Exception에 의한 Runtime Error와 Memory Leak이라고 생각한다. 런타임 에러에 대비하기 위해 Try / Catch 문을 이용할 수 있지만, 이는 예외처리 Overhead에 의한 비용이 몹시 큰 편이기 때문에 모든 상황에 적용하기는 어려운 대비책이다.

따라서 프로그램 디버깅 방법 중 하나로, C++에서 Signal Handling 로직을 추가하며 Linux의 Addr2Line 을 이용한 디버깅 방법에 대해서 다뤄보고자 한다.

우선 테스트를 위해, Dockerubuntu:20.04 이미지를 이용하여 컨테이너를 생성하였다. 또한 C++ 소스코드의 빌드를 위해 gcc 를 설치해 주었다.

이후 다음과 같이, 간단한 C++ 소스코드를 작성해 보았다.

#include <iostream>
#include <stdio.h>
#include <execinfo.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>

void handler(int sig){
        void* buffer[50];
        size_t size;

        size = backtrace(buffer, 50);

        std::cout << std::endl << " @@@ Segmentation Fault!! @@@" << std::endl << std::endl;

        backtrace_symbols_fd(buffer, size, STDERR_FILENO);

        std::cout << std::endl << "@@@ Terminate Program @@@" << std::endl;
        exit(1);
}

void kill(){
        int *foo = (int*) -1;
        printf("%d\n", *foo);
}

int main(){
        signal(SIGSEGV, handler);

        std::cout << "Kill My Program!" << std::endl;
        kill();
}

코드에 대해 간단히 살펴보자면, main() 함수에서 SIGSEGV 시그널에 대한 핸들러를 등록해 주었다. 물론 SIGABRT, SIGILL 및 기타 등등 시그널이 존재하므로, 필요에 따라 핸들러를 등록해 주면 된다.

이후 kill() 함수를 호출하여, 잘못된 포인터에 접근하도록 하여 프로그램에 예외를 발생시켰다.

이 때, 등록된 handler() 함수가 호출된다. 내장 함수인 backtrace(...) 함수를 통해 현재 프로그램 레지스터의 backtrace 정보를 buffer에 불러올 수 있다.

위의 소스코드를 실행한 결과는 다음과 같다.

프로그램이 종료된 시점에서의 backtrace 정보가 출력되었다. gdb 를 이용하는 경우에 비하면 매우 단촐한 정보이지만, 어떤 함수에서 호출되었는 지 순서를 확인할 수 있다.

특히, 위의 정보에서 주목해야 하는 점은 대괄호 [ ] 로 감싸져 있는 주소값이다. 본 포스트에서 다루고자 하는 리눅스의 addr2line 을 이용하는 경우, 주소값을 이용하여 실행 파일으로부터 소스코드의 정확한 위치를 얻어낼 수 있다.

위의 결과에서 첫 두 줄은 backtrace를 실행한 핸들러에 대한 부분이므로, 실제 segmentation fault가 발생한 3번째 줄의 주소값 0xaaaae3120f44 를 이용해 보겠다.

하지만 실행 결과는 기대한 것과는 다르다. 이는 이용한 주소 값 0xaaaae3120f44 이 OS가 부여한 가상 주소이기 때문이다. 따라서 이 경우, addr2line_test 실행 파일의 base address를 획득한 후 offset을 구해야 한다.

Base address를 구하기 위해서는, linux의 pmap 명령어를 이용할 수 있다. 우선 프로그램이 즉시 종료되면 base address를 획득하는 데에 어려움이 있기 때문에, 프로그램이 5초 후 종료되도록 조건문을 추가하였다. 이후 실행 결과는 다음과 같다.

이 때, addr2line_test 프로세스의 프로세스 ID를 획득한 후, pmap 명령어를 수행한 결과는 다음과 같다.

해당 프로그램의 base address는 0xaaaadda10000 임을 확인할 수 있다. 확인하고자 하는 backtrace 정보의 주소값 0xaaaadda10fac 과의 차이는 0xfac 이다. 따라서 offset 주소값을 이용하여 addr2line 명령어에 대입한 결과는 다음과 같다.

결과적으로, main.cpp 파일의 30번째 Line에서 Crash가 발생하였음을 확인할 수 있다. 해당 위치를 찾아가 보면 아래와 같이, 잘못된 포인터에 참조하는 부분이 30번째 Line 임을 확인할 수 있다.

이렇듯 간단히 Linux 환경에서 동작하는 C++ 기반 프로그램의 디버깅 방법 중 하나에 대해서 다뤄 보았다. 물론, 개발자가 개발 단계에서 최선의 코드를 작성하여 프로그램이 실제 동작 중 Crash가 발생하는 경우는 없도록 해야 한다. 하지만 언제나 만약의 상황을 대비하는 것은 유지보수의 관점에서 필수적이기 때문에, 위와 같은 방법으로 디버깅을 유용하게 하거나, 로그를 정확하게 남기도록 하는 습관을 기를 필요가 있을 것 같다.

0개의 댓글