워치포인트로 “이 변수가 언제 바뀌는지”는 잡을 수 있어도, 그 값이 왜 그렇게 됐는지 되짚어 올라가려면 결국 브레이크포인트를 다시 걸고 처음부터 재실행하기를 반복하게 된다. Mozilla의 rr 같은 record-and-replay 디버거는 실행을 통째로 녹화해 시간을 거슬러 올라갈 수 있게 해주는데, 하드웨어 성능 카운터에 의존한다는 게 발목을 잡는다. VirtualBox VM에서 실제로 rr을 설치해 돌려봤더니 “Unable to open performance counter… are hardware perf events available?”로 바로 막혔다 — /sys/bus/event_source/devices/를 보면 breakpoint·kprobe·tracepoint 같은 소프트웨어 이벤트 소스만 있고 하드웨어 PMU(cpu)가 아예 없었다. 클라우드 VM이나 컨테이너에서 흔히 겪는 제약이다. 그런데 GDB에는 오래전부터 내장된, 순수 소프트웨어 방식이라 어디서든 도는 record/replay 기능이 있다. 이 글에서는 record full로 실제 역방향 디버깅을 해본다.
record full로 실행 기록 시작하기
테스트 대상은 bump()를 다섯 번 호출해 전역 변수 counter를 증가시키는 단순한 프로그램이다.
#include <stdio.h>
int counter = 0;
void bump(void) {
counter++;
}
int main(void) {
for (int i = 0; i < 5; i++)
bump();
printf("counter=%d\n", counter);
return 0;
}브레이크포인트를 걸고 실행을 멈춘 시점부터 record full로 기록을 시작한다. 이 시점 이후의 모든 명령어 실행이 녹화된다.
(gdb) break bump
(gdb) run
(gdb) record full
reverse-continue로 거꾸로 브레이크포인트 타기
continue로 몇 번 더 진행해 counter를 3까지 올린 다음, reverse-continue로 시간을 거슬러 올라가봤다.
(gdb) continue
(gdb) continue
(gdb) continue
(gdb) print counter
$1 = 3
(gdb) reverse-continue
Breakpoint 1, bump () at rec_test.c:6
6 counter++;
(gdb) print counter
$2 = 2
(gdb) reverse-continue
Breakpoint 1, bump () at rec_test.c:6
6 counter++;
(gdb) print counter
$3 = 1
reverse-continue를 부를 때마다 같은 브레이크포인트에 걸리는데, counter 값은 3 → 2 → 1로 감소한다. 그냥 재실행이 아니라 진짜로 시간을 거슬러 올라가고 있다는 뜻이다.
워치포인트 + reverse-continue — 값이 바뀐 지점으로 바로 점프
워치포인트와 조합하면 “이 변수가 마지막으로 이 값이 되기 직전”으로 곧장 이동할 수 있다.
(gdb) watch counter
Hardware watchpoint 2: counter
(gdb) reverse-continue
Hardware watchpoint 2: counter
Old value = 1
New value = 0
0x000055555555515a in bump () at rec_test.c:6
6 counter++;
(gdb) print counter
$4 = 0
역방향으로 진행하다 보니 “Old value = 1, New value = 0″으로 표시된다 — 시간의 흐름이 아니라 역재생 관점에서 값이 바뀐 순간을 보여주는 것이다. 정방향 워치포인트만 걸어놓고 계속 continue를 눌러가며 몇 번째에 걸리는지 세는 것보다 훨씬 직접적이다.
reverse-step으로 한 줄씩 되감기
reverse-step은 step의 역방향 버전이라, 함수 호출 경계도 그대로 되짚어 들어간다.
(gdb) print counter
$1 = 2
(gdb) reverse-step
10 for (int i = 0; i < 5; i++)
(gdb) reverse-step
bump () at rec_test.c:7
7 }
(gdb) print counter
$2 = 2
루프문(10번째 줄)에서 reverse-step을 한 번 더 누르니 직전에 실행됐던 bump() 함수 안, 그것도 닫는 중괄호 지점(7번째 줄)으로 되돌아갔다. 정방향 step이 함수 호출 안으로 들어가는 것과 정확히 대칭이다.
rr과의 차이 — 왜 record full을 쓰는가
record full은 명령어 하나하나를 GDB가 직접 인터프리트하며 상태를 저장하는 순수 소프트웨어 방식이다. 하드웨어 성능 카운터로 분기만 기록해두고 재생 시점에 실제 CPU로 다시 실행하는 rr에 비해 훨씬 느리고 메모리도 더 먹지만, 대신 하드웨어 PMU가 없어도 동작한다. 이 글의 테스트 VM처럼 cpu PMU 자체가 노출되지 않는 환경(VirtualBox, 일부 클라우드 인스턴스, 컨테이너)에서는 rr이 애초에 시작조차 못 하지만 record full은 그대로 동작한다.
주의사항
record full이 켜진 동안은 모든 명령어가 인터프리트되므로 프로그램이 체감될 만큼 느려진다. 프로그램 시작부터 기록하지 말고, 문제가 의심되는 지점 직전까지 정상 실행한 뒤 그 지점부터 짧게만 기록하는 게 실전 요령이다.- 기록 길이에 비례해 메모리 사용량이 계속 늘어난다.
record full에는set record full insn-number-max로 기록 개수를 제한하는 옵션이 있으니, 오래 켜둘 거라면 미리 설정해두는 게 안전하다. record function-call-history/record instruction-history같은 명령은record full이 아니라 하드웨어 브랜치 트레이스 기반의record btrace전용이다. 실제로record full상태에서 시도하면You can't do that when your target is `record-full'에러가 난다. 어느 record 백엔드를 쓰고 있는지 헷갈리지 않아야 한다.- 멀티스레드 프로그램에서는
record full이 스레드 전환 타이밍까지 완벽히 재현하지는 못한다. 경합 조건보다는 단일 스레드 로직 버그를 되짚을 때 가장 안정적이다.
마무리
rr이 하드웨어 PMU를 요구하는 것과 정반대로, GDB 내장 record full은 속도를 포기하는 대신 환경을 가리지 않는다. 클라우드 VM이나 컨테이너처럼 성능 카운터 접근이 막힌 곳에서 “이 값이 대체 어디서부터 잘못됐는지” 되짚어야 한다면, 별도 도구 설치 없이 이미 깔려 있는 GDB만으로 record full → reverse-continue/reverse-step 조합을 바로 쓸 수 있다는 게 가장 큰 장점이다.