C나 C++로 프로그램을 작성할 경우, 아무리 신경을 써도 메모리 접근에서 예상치 못한 버그가 발생하고는 한다. 해제한 메모리를 다시 이용하거나(use after free), 할당한 공간을 넘어서 접근한다거나(heap / stack overflow).. 그렇다고 메모리 해제를 소극적으로 하다가는 해제되지 않고 메모리 누수가 일어나서 또 다른 문제를 일으킨다. 물론 이를 해결하기 위한 방법으로 가장 추천하는 것은 Rust처럼 안전한 언어를 이용하는 것이지만, 어쩔 수 없이 C / C++을 이용해야 하는 경우, 디버깅에 도움을 주는 도구를 적극적으로 이용하면 비교적 안전한 코드를 작성할 수 있다.

Static Analysis

clang static analyzer

clang에서 기본으로 제공하는 정적 분석 툴로, 소스 코드를 분석하여 NULL 포인터 접근, 잘못된 비교문(예: 쓰레기 값과 비교), 포인터의 free 이후 접근 등 중요한 오류를 빠르게 찾아준다. 다만 정적 분석이므로 False positive도 꽤 나오는 편이고, 많은 버그를 찾기에는 어려움이 따르기 때문에 뒤에서 이야기 할 런타임 분석 툴과 함께 이용하는 것을 추천한다.

Compilation Database

bear, 혹은 intercept-build를 이용하면 CMake를 이용하는 프로젝트는 물론, Makefile과 같은 레거시 빌드 시스템을 이용하는 프로젝트의 경우에도 쉽게 clang-check를 이용할 수 있다. 위의 툴을 이용하여 intercept-build make 와 같이 빌드를 수행하면 빌드 커맨드와 옵션이 자동으로 compile_commands.json 파일에 쓰여지는데, 이를 clang-check의 인자로 주면 각 소스 파일을 분석할 수 있다.

# http://btorpey.github.io/blog/2015/04/27/static-analysis-with-clang
export COMPILE_DB=$(/bin/pwd);
grep file compile_commands.json |
awk '{ print $2; }' |
sed 's/\"//g' |
while read FILE; do
  (cd $(dirname ${FILE});
   clang-check -analyze -p ${COMPILE_DB} $(basename ${FILE})
  );
done

Runtime Analysis

Valgrind :: Memcheck

C / C++을 이용하는 사람들 사이에서는 가장 유명한 런타임 분석 툴이다. 프로그램의 모든 인스트럭션을 바이너리 레벨에서 수정해서 수행하는 형태로 동작하므로 컴파일을 새로 할 필요도 없고, 따라서 코드를 갖고있지 않은 라이브러리의 버그도 찾을 수 있다는 장점을 갖고 있다.

Valgrind는 메모리 디버깅 외에도 여러 기능을 지원하지만 여기서는 메모리 디버깅을 담담하는 Memcheck만 살펴보자. Memcheck는 기본적으로 힙 영역 메모리에 대한 잘못된 접근을 찾아주며, 초기화가 되지 않을 값을 이용하는 경우나 메모리 누수가 일어나는 경우도 찾아준다.

여기까지 보면 정말 좋은 기능을 가지고 있는 툴인데, 몇 가지 문제가 존재한다:

  • 동시에 한 개의 쓰레드만 수행한다 (마치 파이썬의 쓰레드 처럼 글로벌 락이 존재한다)
    • 스케줄링 문제 (starvation), 퍼포먼스 문제, race condition과 관련된 버그 발견의 어려움을 초래
  • 심각한 속도 저하 (20x 이상 느려진다)
  • 심각한 메모리 사용량

그럼에도 불구하고 앞서 살펴본 장점으로 인해 여전히 많이 사용되고 있으며, 사용 방법 또한 간단하다.

$ valgrind --tool=memcheck --leak-check=full --trace-children=yes --show-reachable=yes --max-threads=2000 --error-limit=no ./a.out

Sanitizers :: AddressSanitizer

Valgrind와 비슷하게 Sanitizer에도 여러 분석 툴이 존재하지만 여기서는 메모리 접근에 대한 분석 툴인 AddressSanitizer(이하 Asan)만 살펴보자. 우선 AddressSanitizer는 컴파일 타임에 메모리 관리와 접근 부분의 코드를 수정하는 방법을 이용하며, Asan 자신이 라이브러리 형태로 해당 실행 파일에 링크되어 동작하므로 Asan을 통해 컴파일되지 않은 라이브러리의 경우 디버깅을 할 수 없다.

대신 이러한 방법을 이용하여 Valgrind보다 기본적으로 10배 빠르고(비교 결과), 멀티 쓰레드 환경을 제대로 지원하며(동시에 여러 쓰레드가 동작 한다), 특이한 기능으로 힙 영역 뿐만 아니라 스택 영역의 메모리 침범과 같은 문제도 발견하는 기능을 갖추었다.

실제로 이러한 속도와 기능으로 Chromium 프로젝트 또한 Valgrind에서 AddressSanitizer로 디버깅 툴을 바꾸었을 만큼 잘 만들어졌다. 다만 단점도 몇 가지 있는데, 대부분은 다른 Sanitizer(MemorySanitizer, LeakSanitizer)를 이용하여 해결할 수 있다.

비교

  • 보통 2배 느려진다
  • 메모리 사용량이 늘어나지만 Valgrind보다는 부담이 적다 (경험상 실제로 비교했을 때 2배 이하)
  • 메모리 누수를 찾는 기능이 부실하다
  • 초기화 하지 않은 값을 이용하는 경우를 찾지 못한다
  • 컴파일을 새로 해야 한다
    • 소스 코드를 가지고 있지 않은 라이브러리의 경우 적용 불가능

AddressSanitizer를 이용해서 컴파일 할 때에는 몇 가지 옵션을 주어야 하고, 보통 정적 링크를 추천한다. gcc의 경우 4.8 버전부터 지원하며, clang의 경우 3.1 버전부터 지원한다(compiler-rt).

# clang
$ clang++ -fsanitize=address -fno-omit-frame-pointer -g main.cc
# gcc
$ g++ -fsanitize=address -fno-omit-frame-pointer -g main.cc -static-libasan

Importance of Negative Tests

런타임 디버깅 툴은 훌륭한 기능을 제공하지만, 해당하는 코드가 실제로 수행되었을 경우에만 문제를 발견할 수 있다. 따라서 테스트 코드가 항상 정상 동작을 가정하고 작성(positive tests)되어 있을 경우에는 테스트 환경에서 문제를 발견하기 어렵다. 이를 완화하기 위해서는 정상 동작이 아닌 경우에 대한 테스트(negative tests)를 작성하는 것을 잊어선 안 된다.