글 작성자: cjwoov
반응형

0. 요약

 디버거의 소프트웨어 브레이크 포인트는 메모리 패칭(Memory Patching)과 CPU의 예외 처리 메커니즘을 이용해 구현합니다.

  1. 유저가 특정 라인에 브레이크를 걸면, 해당 주소의 원래 기계어 1바이트를 디버거 메모리에 백업하고, 그 자리를 0xCC (INT 3) 명령어로 덮어씁니다.
  2. 타겟 프로세스의 스레드(CPU)가 실행되다 0xCC를 만나면 디버그 익셉션(Trap)이 발생하고, OS는 스레드를 블로킹한 뒤 디버거에게 이벤트를 전달합니다.
  3. 디버거는 이때 CPU 레지스터(Context)와 메모리를 읽어 유저에게 보여줍니다.
  4. 유저가 실행을 재개(F5)하면, 덮어썼던 0xCC를 백업해 둔 원래 기계어로 복원합니다. 그리고 CPU의 명령어 포인터(RIP)를 1바이트 감소시켜 원래 명령어를 정상적으로 실행하도록 한 뒤, 스레드 블로킹을 해제합니다.

 

1. 핵심 개념: 0xCC (INT 3)

 C++ 코드를 컴파일하면 CPU가 읽을 수 있는 기계어(숫자)로 변합니다.

예를 들어 int a = 1; 이라는 코드는 메모리에 B8 01 00 00 00 (어셈블리어로는 mov eax, 1)처럼 올라갑니다.

 

 여기서 디버거가 사용하는 궁극의 마법 주문이 딱 하나 있습니다.

바로 0xCC 라는 1바이트짜리 숫자입니다. 어셈블리어로는 INT 3 (Interrupt 3)라고 부릅니다.

CPU는 코드를 주르륵 읽고 실행하다가 메모리에서 0xCC를 마주치는 순간,

"헉! 함정이다! 모든 동작을 멈추고 OS에게 보고해!"라며 얼어붙습니다.

 

2. 브레이크 포인트의 동작 순서 (기차 길 비유)

CPU를 '기차', 메모리에 깔린 코드를 '기차 선로', 디버거(Visual Studio)를 '관제탑'이라고 상상해 보십시오.

Step 1. 함정 설치 (F9 키를 눌렀을 때)

  • 유저가 150번 줄에 브레이크 포인트(빨간 점)를 찍습니다.
  • 디버거는 150번 줄의 메모리 주소를 찾아갑니다.
  • 거기에 원래 깔려있던 기차 선로(원래 기계어, 예: B8)를 뜯어서 자기 주머니에 백업해 둡니다.
  • 그리고 그 자리에 0xCC (정지 표지판)를 몰래 끼워 넣습니다. (메모리 덮어쓰기)

Step 2. 멈춤과 보고 (기차가 표지판을 밟았을 때)

  • 프로그램이 쌩쌩 돌아갑니다. 기차(CPU)가 선로를 따라 150번 줄까지 옵니다.
  • 기차가 0xCC를 밟는 순간, CPU는 OS에게 "디버그 예외 발생(Trap)!"을 외치고 기차를 급정거시킵니다.
  • OS는 이 기차를 멈춰 세우고, 관제탑(디버거)에게 "야, 네가 설치한 함정에 기차 걸렸어"라고 알려줍니다.
  • 이때 화면에 노란색 화살표가 멈춰있고, 우리는 변수 값(기차 안의 승객들)을 구경할 수 있습니다.

Step 3. 원상 복구와 재출발 (F5 키를 눌렀을 때)

여기가 제일 중요합니다. 기차를 다시 출발시키려면 어떻게 해야 할까요?

  • 디버거는 아까 끼워 넣었던 0xCC를 뽑아내고, 주머니에 숨겨뒀던 원래 선로(B8)를 다시 깔아줍니다.
  • 근데 기차(CPU의 현재 위치를 나타내는 RIP 레지스터)는 이미 0xCC를 밟고 1칸 앞으로 가 있는 상태입니다. 이대로 출발하면 원래 코드를 건너뛰게 됩니다.
  • 그래서 디버거는 기차를 뒤로 딱 1칸(1바이트) 후진시킵니다. (RIP = RIP - 1)
  • 그리고 "자, 이제 다시 원래 선로 밟고 출발해(F5)!"라고 명령합니다.

이것이 소프트웨어 브레이크 포인트의 완벽한 쌩얼입니다. 어셈블리어를 몰라도 흐름이 완벽히 이해되시죠?

 

3. 추가 지식: 1바이트(0xCC)여야만 하는 이유

면접관이 꼬리 질문으로 "왜 굳이 INT 3는 1바이트입니까?"라고 물어볼 수 있습니다.

  • 만약 정지 표지판이 2바이트 이상이라면?
  • 원래 선로가 1바이트짜리 짧은 코드였을 경우, 표지판을 박아넣다가 그다음 줄에 있는 무고한 선로(코드)까지 덮어써서 파괴해 버리는 대참사가 일어납니다. 그래서 무조건 가장 작은 단위인 1바이트여야 완벽한 치환이 가능합니다.
반응형