TECH 으로 돌아가기
TECH HACKER NEWS 오늘 9분 읽기 27 READS

디버거가 거짓말할 때: 브레이크포인트에서 본 값을 믿으면 안 되는 순간들

디버거가 거짓말할 때: 브레이크포인트에서 본 값을 믿으면 안 되는 순간들
SOURCE IMAGE · HACKER NEWS
디버거가 거짓말할 때: 브레이크포인트에서 본 값을 믿으면 안 되는 순간들

디버거는 진실의 창이 아니에요

버그를 잡을 때 우리가 가장 믿는 도구가 디버거죠. 브레이크포인트 걸고, 멈추고, 변수 값을 들여다보고요. 화면에 x = 42라고 찍히면 당연히 x는 42라고 믿게 되는데요. 시스템 프로그래밍과 임베디드 쪽 글을 꾸준히 써온 다니엘 맨검(Daniel Mangum)이 'When the Debugger Lies', 그러니까 '디버거가 거짓말할 때'라는 제목으로 이 믿음이 깨지는 상황을 다뤘어요. 저도 이 주제로 밤을 새워본 적이 있어서, 디버거가 왜 거짓말을 하게 되는지 차근차근 풀어볼게요.

먼저 전제를 하나 바로잡아야 하는데요. 디버거는 프로그램의 '진짜 상태'를 보여주는 창이 아니에요. 정확히 말하면 컴파일러가 남겨둔 메모(디버그 정보)를 참고해서, CPU 레지스터와 메모리에 있는 비트들을 사람이 이해할 수 있는 이름과 값으로 번역해주는 통역사에 가까워요. 통역사가 참고하는 메모가 틀리거나, 통역하는 순간에 원문이 바뀌면 당연히 엉뚱한 말이 나오죠.

첫 번째 거짓말: 컴파일러 최적화

가장 흔한 케이스예요. 릴리즈 빌드에서 크래시가 나서 디버거를 붙였더니 변수 옆에 <optimized out>이라고 뜨는 거요. 이게 뭐냐면, 컴파일러가 최적화하면서 그 변수를 메모리에 저장할 필요가 없다고 판단해서 레지스터에만 잠깐 뒀거나, 아예 계산 자체를 없애버린 거예요. 소스 코드에는 변수가 있는데 기계어에는 없으니 디버거가 보여줄 게 없는 거죠.

더 헷갈리는 건 값이 보이긴 하는데 틀린 경우예요. 디버그 정보 포맷인 DWARF에는 '이 변수는 이 주소 범위에서는 r5 레지스터에 있고, 저 범위에서는 스택의 어느 위치에 있다'는 식의 위치 목록(location list)이 들어 있어요. 일종의 지도인데요. 최적화가 강하게 걸리면 컴파일러가 이 지도를 완벽하게 갱신하지 못하는 구간이 생겨요. 그러면 디버거는 이미 다른 용도로 재활용된 레지스터를 읽어서 '이게 x의 값입니다'라고 자신 있게 보여주죠. 코드 재배열도 마찬가지예요. 줄 단위로 스텝을 밟는데 커서가 12번 줄에서 9번 줄로 튀었다가 다시 14번 줄로 가는 경험 있으시죠? 컴파일러가 실행 순서를 바꿔놓아서 소스의 줄 번호와 실제 실행 순서가 더 이상 일치하지 않는 거예요. 인라이닝이 되면 콜스택에 있어야 할 함수가 통째로 사라지기도 하고요.

두 번째 거짓말: 읽기만 해도 상태가 바뀌는 하드웨어

임베디드 쪽으로 가면 훨씬 무서운 거짓말이 나와요. 마이크로컨트롤러의 주변장치(UART, SPI, 타이머 같은 것들)는 메모리 주소에 매핑된 레지스터로 제어하는데요. 이 중에는 읽는 행위 자체가 부작용을 일으키는 레지스터가 있어요. 예를 들어 UART의 수신 데이터 레지스터를 한 번 읽으면 그 바이트가 FIFO에서 빠져나가고, 어떤 상태 레지스터는 읽는 순간 인터럽트 플래그가 지워져요.

그런데 디버거의 변수 감시(watch) 창이 뭘 하냐면, 멈출 때마다 그 주소를 읽어서 화면에 보여줘요. 즉 디버거가 보여주기 위해 읽는 바람에 데이터가 사라지거나 플래그가 지워지는 거죠. 그래서 디버거를 붙이면 버그가 사라지고, 떼면 다시 나타나는 이른바 하이젠버그가 생겨요. 관찰하는 행위가 결과를 바꾸는 거예요.

캐시도 문제예요. 코어는 캐시에 있는 값을 보고 있는데, 디버그 프로브는 버스를 통해 실제 메모리를 직접 읽는 경우가 있거든요. 둘이 다른 값을 보고 있으면 디버거 화면과 프로그램의 판단이 어긋나요. 멀티코어 시스템에서 한 코어만 멈춰 세웠을 때 다른 코어가 계속 메모리를 바꾸는 상황도 비슷하고요.

간단한 예를 하나 볼게요. 하드웨어 상태 레지스터가 준비될 때까지 기다리는 while ((*status & READY) == 0) {} 같은 폴링 루프가 있다고 해볼게요. statusvolatile이 없으면 컴파일러는 이 값이 루프 안에서 바뀔 리 없다고 판단해서 딱 한 번만 읽고 그 값으로 영원히 돌게 만들 수 있어요. 그런데 디버거로 스텝을 밟으면 멈출 때마다 메모리를 새로 읽어서 최신 값을 보여주죠. 화면에는 준비됐다고 뜨는데 프로그램은 루프를 못 빠져나오고 있는 거예요. volatile을 붙여서 매번 다시 읽으라고 알려줘야 해요.

세 번째 거짓말: 시간

브레이크포인트에 멈추는 순간 프로그램의 시간은 멈추잖아요. 그런데 진짜 세상은 안 멈춰요. 센서는 계속 데이터를 보내고, 통신 상대는 타임아웃을 내고, 워치독 타이머는 리셋을 걸어요. 그래서 타이밍이 원인인 버그는 디버거를 붙이는 순간 재현이 안 돼요. 디버거가 거짓말을 한다기보다, 디버거가 만든 세계 자체가 진짜 세계와 다른 거죠.

그러면 뭘 믿어야 하나요

몇 가지 실전 팁을 드릴게요. 첫째, 의심스러우면 어셈블리 창을 여세요. disassemble이나 stepi 같은 명령으로 소스 줄이 아니라 기계어 명령 단위로 밟아보면 컴파일러가 뭘 했는지 그대로 보여요. 둘째, 디버깅용 빌드에는 -Og 옵션을 쓰세요. 최적화를 유지하면서도 디버그 정보를 최대한 살려주는 모드예요. 셋째, 하드웨어 레지스터는 감시 창에 올리지 말고, 코드에서 한 번 읽어 일반 변수에 복사한 뒤 그 변수를 보세요. 넷째, 무시당하기 쉬운 로그 출력과 하드웨어 트레이스(ARM의 ETM이나 RISC-V 트레이스 같은 것)는 실행을 멈추지 않고 기록하기 때문에 타이밍 버그에서는 디버거보다 훨씬 정직해요. 마지막으로 심볼 파일과 실제 올라간 바이너리가 같은 빌드인지 꼭 확인하세요. 이게 어긋나면 모든 게 거짓말이 돼요.

업계에서는 어떻게 풀고 있나

이 문제 때문에 나온 도구들이 있어요. 모질라가 만든 rr은 실행을 통째로 녹화해서 나중에 거꾸로 되감으며 볼 수 있게 해주는데, 관찰이 실행에 영향을 주지 않으니 하이젠버그를 잡기 좋아요. 이걸 웹 UI로 만든 Pernosco도 있고요. 최근 컴파일러들은 최적화된 코드의 디버그 정보 품질을 높이는 데 꽤 공을 들이고 있어서, LLVM 쪽에는 디버그 정보가 얼마나 보존됐는지 측정하는 도구까지 따로 있을 정도예요. 러스트처럼 새로운 언어들도 결국 같은 DWARF 위에서 도는 거라 똑같은 한계를 물려받아요.

한국 개발자에게

임베디드나 펌웨어 쪽에 계신 분들은 오늘 나온 케이스 대부분을 직접 겪으셨을 거예요. 백엔드나 앱 개발자분들도 남 이야기가 아닌 게, 프로덕션 크래시 덤프를 열어봤을 때 변수가 죄다 <optimized out>인 경험은 다들 있으시잖아요. 디버거가 어떤 원리로 값을 보여주는지 알면 이 값은 믿어도 되고 저 값은 다시 확인해야 한다는 감이 생겨요. 그 감이 디버깅 시간을 크게 줄여줘요.

정리

디버거는 컴파일러의 메모를 읽어주는 통역사이지 진실 그 자체가 아니에요. 최적화, 하드웨어 부작용, 시간이라는 세 가지 이유로 언제든 틀린 말을 할 수 있다는 걸 기억하세요.

여러분은 디버거한테 속아본 적 있으세요? 그때 어떻게 진짜 원인을 찾으셨는지 댓글로 공유해주시면 다른 분들께 큰 도움이 될 것 같아요.


🔗 출처: Hacker News

SOURCE · HACKER NEWS
원문 전체 보기 → https://danielmangum.com/posts/when-the-debugger-lies/
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...