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
을 이용한 디버깅 방법에 대해서 다뤄보고자 한다.
우선 테스트를 위해, Docker
와 ubuntu: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가 발생하는 경우는 없도록 해야 한다. 하지만 언제나 만약의 상황을 대비하는 것은 유지보수의 관점에서 필수적이기 때문에, 위와 같은 방법으로 디버깅을 유용하게 하거나, 로그를 정확하게 남기도록 하는 습관을 기를 필요가 있을 것 같다.