디버거가 소스 코드를 한 줄씩 따라가고 변수 이름으로 값을 보여 줄 수 있는 건 ELF 파일 안의 디버그 정보 덕분입니다. 여기에는 "이 주소는 몇 번째 줄", "이 변수는 어느 주소"라는 정보가 들어 있고, 디버거는 이 정보로 모든 것을 주소로 찾아요. 그래서 주소가 고정된 전역·static 변수는 실행 중에도 보이지만, 주소가 계속 바뀌는 로컬 변수는 코어를 멈춰야 보입니다.
먼저 알아둘 용어
용어 34개 펼쳐 보기 (본문의 점선 밑줄 단어를 누르면 여기로 와요)
| 용어 | 원래 이름 | 쉽게 말하면 |
|---|---|---|
| 디버거 | Debugger (디버그 프로브) | 컴퓨터와 타깃 칩을 잇는 장비와 소프트웨어. 코어를 멈추고 메모리를 읽고 쓴다 |
| ELF | Executable and Linkable Format | 링커가 만드는 실행 파일 형식. 기계어와 디버그 정보를 함께 담는다 |
| 디버그 정보 | Debug Information | 줄 번호 표, 변수·함수의 이름·타입·위치, 소스 경로를 담은 ELF 안의 데이터 |
| static 변수 | Static Variable | static으로 선언해 프로그램이 도는 내내 고정 주소에 남는 변수. 함수 안에 선언해도 마찬가지 |
| 로컬 변수 | Local (Automatic) Variable | 함수 안에 선언한 일반 변수. 호출될 때 스택이나 레지스터에 생기고 함수가 끝나면 사라진다 |
| 컴파일러 | Compiler | C 소스를 기계어가 든 오브젝트 파일로 바꾸는 프로그램 |
| 오브젝트 파일 | Object File (.o) | 소스 파일 하나를 컴파일한 조각. 아직 최종 주소가 정해지지 않았다 |
| 링커 | Linker | 오브젝트 파일을 합치고 모든 함수·변수의 최종 주소를 정하는 프로그램 |
| 줄 번호 표 | Line Number Table | 기계어 주소와 소스 파일의 줄을 짝지어 둔 표 |
| HEX | Intel HEX 파일 | 기계어와 초기 데이터를 주소와 함께 텍스트로 적은 파일. 디버그 정보는 없다 |
| 부트로더 | Bootloader | 리셋 직후 먼저 실행돼, 필요하면 새 프로그램을 플래시에 써 넣는 작은 프로그램 |
| MAP 파일 | Linker Map File | 링커가 남기는 배치 보고서. 함수·변수의 주소와 크기, 메모리 사용량이 적혀 있다 |
| GCC | GNU Compiler Collection | 임베디드에서도 널리 쓰는 오픈소스 컴파일러 |
| 섹션 | Section | ELF 안에서 코드·변수·디버그 정보를 종류별로 나눠 담는 구역 (.text는 코드, .data·.bss는 변수) |
| 체크섬 | Checksum | 데이터가 깨지지 않았는지 확인하려고 덧붙이는 검사값 |
| CPU | Central Processing Unit | 명령을 가져와 실행하는 코어 |
| 레지스터 | CPU Register | CPU 안의 작고 빠른 저장 칸. 연산 중인 값과 주소를 담는다 |
| PC | Program Counter | 다음에 실행할 명령의 주소를 담은 레지스터 |
| DWARF | DWARF Debugging Information Format | 디버그 정보를 적는 표준 형식. 대부분의 컴파일러와 디버거가 쓴다 |
| halt | Halt (Debug State) | 디버거가 CPU 코어의 명령 실행을 멈춘 상태 |
| 어셈블리 | Assembly | 기계어를 사람이 읽을 수 있는 명령어로 적은 것 |
| 브레이크포인트 | Breakpoint | 지정한 줄(주소)에 도달하면 코어를 halt시키는 기능 |
| 하드웨어 브레이크포인트 | Hardware Breakpoint | 코어 안 주소 비교기로 구현한 브레이크포인트. 개수가 정해져 있다 |
| 스텝 | Step (Source-level Step) | 소스를 한 줄씩 실행하며 따라가는 디버거 기능 |
| 최적화 | Compiler Optimization | 컴파일러가 코드 크기·속도를 개선하려고 명령을 합치거나 옮기는 것 |
| 전역 변수 | Global Variable | 함수 밖에 선언해 프로그램 전체에서 쓰는 변수. 고정 주소에 놓인다 |
| RAM | Random Access Memory | 실행 중 변수가 놓이는 휘발성 메모리 |
| 디버그 포트 | Debug Port | 디버거가 칩 내부와 통신하는 전용 핀과 회로 |
| 라이브 워치 | Live Watch (Real-time Watch) | 코어를 멈추지 않고 디버그 포트로 메모리를 주기적으로 읽어 변수를 보여 주는 기능 |
| 심볼 | Symbol | 함수·변수의 이름과 주소를 짝지은 정보. ELF에 들어 있다 |
| 스택 | Stack | 함수 호출 때 로컬 변수와 복귀 주소를 쌓았다가 끝나면 비우는 RAM 영역 |
| volatile | volatile 한정자 | "이 변수는 언제든 바뀔 수 있으니 매번 메모리에서 읽고 써라"라고 컴파일러에 알리는 키워드 |
| 인터럽트 | Interrupt | 하드웨어 이벤트로 실행 중인 코드를 잠시 멈추고 다른 함수를 실행하는 것 |
| 조건부 컴파일 | Conditional Compilation (#ifdef) | 빌드 설정에 따라 코드 일부를 넣거나 빼는 전처리 기능 |
빌드하면 생기는 파일: ELF, HEX, MAP
디버거로 프로그램을 멈추면 소스 창에 현재 줄이 표시되고, 변수 창에는 speed 같은 변수의 값이 보입니다. 그런데 칩에 들어간 건 기계어뿐이에요. speed라는 이름도, 42번째 줄이라는 개념도 칩 안에는 없습니다. 그렇다면 디버거는 이 정보를 어디서 가져올까요? 답은 빌드할 때 만들어지는 파일에 있습니다.
빌드 버튼을 누르면 컴파일러가 .c 파일을 하나씩 오브젝트 파일(.o)로 바꾸고, 링커가 그 조각들을 합치면서 모든 함수와 전역·static 변수의 최종 주소를 정합니다. 이 과정에서 디버깅과 관련이 깊은 파일 세 개가 만들어져요.
| 파일 | 들어 있는 것 | 주로 쓰는 곳 |
|---|---|---|
| ELF | 기계어와 초기 데이터, 그리고 디버그 정보(줄 번호 표, 변수·함수 정보, 소스 경로) | 디버거 다운로드, 소스 레벨 디버깅 |
| HEX | ELF에서 뽑아낸 기계어와 초기 데이터를 주소와 함께 텍스트로 적은 것 | 양산 라인 프로그래머, 부트로더 업데이트 |
| MAP 파일 | 함수·변수의 주소와 크기, 메모리 사용량을 정리한 링커의 보고서 | 사람이 주소와 메모리 사용량을 확인할 때 |
말로만 보면 감이 잘 오지 않으니, 실제 파일이 어떻게 생겼는지 들여다보겠습니다. 궁금한 파일을 눌러 펼쳐 보세요.
ELF 파일 미리 보기
ELF는 바이너리 파일이라 메모장으로 열면 알아볼 수 없습니다. 맨 앞 4바이트가 7F 45 4C 46, 즉 ".ELF"로 시작하는 게 특징이에요. 안을 보려면 GCC와 함께 설치되는 툴체인(arm-none-eabi)의 readelf나 objdump를 씁니다. 아래는 필요한 열만 추려 간추린 출력이에요. 섹션 목록을 보면 코드가 든 .text, 변수가 든 .data·.bss와 함께 .debug_로 시작하는 디버그 정보 섹션이 들어 있는 걸 볼 수 있어요.
$ head -c 4 firmware.elf | xxd -p
7f454c46
$ arm-none-eabi-readelf -S firmware.elf
[Nr] Name Addr Size
[ 1] .isr_vector 08000000 000188
[ 2] .text 08000188 002a10
[ 4] .data 20000000 00000c
[ 5] .bss 2000000c 000420
[12] .debug_info 00000000 00a3f1
[16] .debug_line 00000000 002b87
이 중 .debug_line 섹션을 풀어 보면, 앞에서 말한 줄 번호 표가 그대로 나옵니다.
$ arm-none-eabi-objdump --dwarf=decodedline
firmware.elf
File Line Starting address
main.c 41 0x8001a10
main.c 42 0x8001a24
main.c 43 0x8001a2c
HEX 파일 미리 보기
HEX는 텍스트 파일이라 메모장으로 바로 열립니다. 한 줄이 하나의 기록이고, 콜론(:) 뒤로 바이트 수, 주소, 기록 종류, 데이터, 체크섬이 차례로 이어져요. 디버그 정보가 빠진 파일이라 변수 이름이나 줄 번호는 어디에도 없습니다.
:020000040800F2
:1000000000800020110A0008150A0008170A0008DD
:10001000190A00081B0A00081D0A00080000000059
...
:00000001FF
첫 줄은 "이후 주소의 앞자리는 0x0800"이라는 뜻이고, 둘째 줄은 0x08000000부터 16바이트의 데이터입니다. 마지막 줄 :00000001FF는 파일의 끝을 나타내요.
MAP 파일 미리 보기
MAP 파일도 텍스트라 바로 열어 볼 수 있습니다. 앞부분에는 칩의 메모리 구성이, 뒷부분에는 함수와 변수가 어느 주소에 몇 바이트로 놓였는지가 이어져요. 아래는 GCC 계열 MAP 파일의 일부입니다.
Memory Configuration
Name Origin Length Attributes
FLASH 0x08000000 0x00040000 xr
RAM 0x20000000 0x00008000 xrw
.text.Motor_Control
0x08001b40 0x3c ./Src/motor.o
0x08001b40 Motor_Control
.bss.g_motor_speed
0x20000010 0x4 ./Src/motor.o
0x20000010 g_motor_speed
셋 중 가장 먼저 만들어지는 건 ELF입니다. HEX는 ELF에서 디버그 정보를 빼고 칩에 넣을 내용만 옮긴 파일이에요. 그래서 칩에 들어가는 내용은 보통 둘이 같지만, 디버거에 필요한 변수 이름과 줄 번호는 ELF에만 있습니다. 툴체인에 따라 ELF의 확장자를 .axf나 .out으로 쓰기도 해요. HEX 역시 칩 제조사나 툴에 따라 같은 역할을 하는 Motorola S-record(.srec, .mot)나 순수 바이너리(.bin)로 대신하기도 합니다.
디버거가 소스 코드를 한 줄씩 따라가는 원리: 줄 번호 표
CPU는 지금 소스의 몇 번째 줄을 실행하는지 모릅니다. CPU가 아는 건 다음에 실행할 명령의 주소뿐이고, 이 주소를 담아 두는 레지스터가 PC(프로그램 카운터)예요.
디버거는 이 주소와 소스 줄을 ELF의 디버그 정보로 연결합니다. 디버그 옵션(GCC라면 -g)을 켜고 빌드하면, 컴파일러가 "0x08001A24부터의 명령은 main.c 42번째 줄"처럼 주소와 줄을 짝지은 줄 번호 표를 만들고, 링커가 이를 ELF에 모아 둡니다. 이 정보를 적는 표준 형식이 DWARF예요.
그래서 코어가 halt되면 디버거는 PC를 읽고, 줄 번호 표에서 그 주소를 찾아, 해당 줄을 소스 창에 표시합니다.
한 가지 알아 둘 점은 ELF에 소스 코드 자체가 아니라 소스 파일의 경로만 들어 있다는 거예요. 빌드한 뒤 소스 폴더를 옮기거나 ELF만 다른 컴퓨터로 가져가면, 디버거는 몇 번째 줄인지는 알아도 파일을 열지 못해 어셈블리만 보여 줍니다. 이때는 디버거 설정에서 소스 경로를 다시 지정하면 됩니다. HEX만 있을 때는 줄 번호 표 자체가 없으니, 경로를 지정해도 소스 레벨 디버깅을 할 수 없어요.
브레이크포인트와 스텝: 같은 표를 거꾸로 읽기
지금까지는 디버거가 줄 번호 표를 "주소 → 줄" 방향으로 읽었습니다. 같은 표를 "줄 → 주소" 방향으로 읽으면 브레이크포인트가 됩니다.
42번째 줄을 클릭하면 디버거는 표에서 그 줄의 시작 주소를 찾아 코어 안의 하드웨어 브레이크포인트 비교기에 등록합니다. 그러면 코어는 명령을 가져올 때마다 주소를 비교하고, PC가 그 주소에 도달하면 halt돼요. 비교기의 개수는 칩마다 정해져 있어서, 이를 넘기면 디버거가 설정을 거부하거나 플래시의 명령을 직접 바꿔 쓰는 방식으로 넘어갑니다.
스텝도 같은 표를 씁니다. C 한 줄은 대개 기계어 여러 개로 이루어져 있어요. 그래서 디버거는 PC가 현재 줄의 주소 범위를 벗어날 때까지 명령을 하나씩 실행하거나, 다음 줄 주소에 임시 브레이크포인트를 걸고 실행합니다. 최적화를 켜면 스텝할 때 현재 줄 표시가 앞뒤로 튀는 것도 이 때문이에요. 컴파일러가 한 줄의 명령을 흩어 놓거나 다른 줄과 합치기 때문에, 소스의 줄 순서와 실제 실행 순서가 어긋납니다.
여기까지가 코드의 위치를 찾는 원리였다면, 다음은 변수의 위치를 찾는 원리입니다.
전역·static 변수는 실시간으로 보이고, 로컬 변수는 멈춰야 보이는 이유
변수를 보는 원리도 주소입니다. 디버그 정보에는 변수마다 이름, 타입, 위치가 적혀 있고, 디버거는 그 위치의 메모리를 읽어 값을 보여 줘요. 그래서 변수를 언제 볼 수 있느냐는 그 위치가 고정돼 있느냐에 달려 있습니다.
전역·static 변수: 주소가 고정
전역 변수와 static 변수는 빌드할 때 링커가 RAM의 주소를 정해 주고, 프로그램이 도는 동안 그 주소가 바뀌지 않습니다. 함수 안에 선언한 static도 마찬가지예요. 주소가 바뀌지 않으니 디버거는 코어를 멈추지 않고도 디버그 포트로 그 주소를 주기적으로 읽어 올 수 있습니다. 이 기능이 라이브 워치예요. const로 선언한 상수는 RAM이 아니라 플래시에 놓이지만, 주소가 고정이라 똑같이 볼 수 있습니다.
그 주소는 MAP 파일에서도 확인할 수 있습니다. 아래는 GCC 계열 MAP 파일의 일부로, g_motor_speed가 0x20000010에서 4바이트를 차지한다는 뜻이에요. 다만 static 변수는 툴체인 설정에 따라 MAP 파일에 이름이 나오지 않기도 합니다. 그래도 디버거는 MAP 파일이 아니라 ELF의 심볼 정보로 변수를 찾기 때문에 보는 데는 문제가 없어요.
.bss.g_motor_speed
0x20000010 0x4 ./Src/motor.o
0x20000010 g_motor_speed
로컬 변수: 호출될 때마다 위치가 바뀜
로컬 변수는 다릅니다. 함수가 호출될 때 스택에 자리를 받거나 레지스터에만 머물다가, 함수가 끝나면 그 자리를 다른 함수가 다시 씁니다. 어디서 호출됐느냐에 따라 주소가 달라지고, 프로그램이 도는 동안에는 그 함수가 지금 호출돼 있는지도 알 수 없어요. 주소를 모르면 읽을 수 없으니, 실행 중에는 로컬 변수를 실시간으로 볼 수 없습니다.
하지만 브레이크포인트로 코어를 멈추면 볼 수 있습니다. 코어가 멈추면 스택 위치와 레지스터 값도 고정되기 때문에, 디버거가 디버그 정보를 이용해 현재 함수와 이를 부른 함수들의 로컬 변수 위치를 계산할 수 있거든요. 다만 최적화가 켜져 있으면 변수가 레지스터에 잠깐 있다가 사라져서, 멈춰도 "optimized out"으로 표시될 수 있습니다.
팁: 로컬 변수를 실시간으로 보고 싶을 때
그런데 제어 루프 안의 오차값처럼, 멈추면 의미가 없어지는 로컬 변수도 있습니다. 코어를 멈추면 제어도 멈추기 때문에 값이 실제로 어떻게 변하는지 볼 수 없죠.
이럴 때는 앞의 원리를 이용하면 됩니다. 실시간으로 볼 수 없는 이유가 주소가 고정되지 않아서였으니, 값을 잠시 고정 주소에 두면 라이브 워치로 볼 수 있어요. 방법은 두 가지입니다.
방법 1: 잠시 static으로 바꾸기
void Motor_Control(void)
{
static volatile int32_t speed_error; /* temporary: static for live watch */
speed_error = target_speed - measured_speed;
Motor_SetDuty(speed_error * KP);
}
변수 앞에 static을 붙이면 변수가 스택에서 고정 주소로 옮겨 가서 라이브 워치로 볼 수 있습니다. 이때 volatile을 꼭 같이 붙이세요. 최적화가 켜져 있으면 컴파일러가 값을 레지스터에만 들고 있다가 메모리에 늦게 쓰거나, 아무도 읽지 않는 변수라고 판단해 쓰는 코드 자체를 지워 버릴 수 있거든요.
대신 주의할 점이 있습니다. static 변수의 초기값(= 0 같은)은 프로그램이 시작할 때 한 번만 들어가요. 매번 새 값이 필요하다면 위 코드처럼 선언과 대입을 나눠야 합니다. 인터럽트처럼 여러 곳에서 같은 함수를 부르면 값이 서로 덮여 계산까지 틀어질 수 있고, 재귀 함수라면 동작 자체가 달라져요. 확인이 끝나면 반드시 원래대로 되돌려 두세요.
방법 2: 전역 디버그 변수에 복사하기
방법 1의 부작용은 모두 변수의 성격을 바꿨기 때문에 생깁니다. 그래서 변수는 그대로 두고 값만 전역 변수에 복사하는 방법이 더 안전해요.
#ifdef DEBUG
volatile int32_t g_dbg_speed_error; /* debug-only copy for live watch */
#endif
void Motor_Control(void)
{
int32_t speed_error = target_speed - measured_speed;
#ifdef DEBUG
g_dbg_speed_error = speed_error;
#endif
Motor_SetDuty(speed_error * KP);
}
로컬 변수의 동작은 그대로이고, 전역 변수는 값을 복사해 둘 뿐입니다. 조건부 컴파일로 감싸 두면 DEBUG를 정의하지 않은 출시 빌드에는 이 코드가 남지 않아요. 보고 싶은 값이 여럿이라면 구조체 하나로 묶어 두면 관리가 편합니다.
target_speed, Motor_SetDuty, KP 같은 이름은 예시이니 쓰는 코드에 맞게 바꾸면 됩니다. 두 방법 모두 라이브 워치가 일정 주기로 메모리를 읽는 방식이라, 그 주기보다 빨리 바뀌는 값은 중간값을 놓칠 수 있어요. 또 32비트보다 큰 값(64비트 변수, 구조체)은 디버거가 읽는 도중에 CPU가 값을 바꾸면 앞뒤가 섞인 값이 보일 수 있습니다.
자주 묻는 질문 (FAQ)
HEX 파일만 있으면 디버깅할 수 없나요?
다운로드하고 실행하거나 어셈블리 단위로 따라가는 건 가능하지만, 줄 번호 표와 변수 정보가 없어 소스 레벨 디버깅은 할 수 없어요. 칩에 들어간 것과 같은 빌드의 ELF가 필요합니다.
정리
칩에는 기계어만 들어가지만, 디버거가 소스와 변수를 보여 줄 수 있는 건 ELF에 디버그 정보가 있기 때문입니다. HEX는 칩에 넣을 내용만 담은 파일이라 이 정보가 없고, MAP 파일은 링커가 정한 주소 배치를 사람이 읽을 수 있게 정리한 보고서예요. 디버거는 줄 번호 표를 "주소 → 줄"로 읽어 현재 줄을 표시하고, "줄 → 주소"로 읽어 브레이크포인트를 겁니다. 변수도 주소로 찾기 때문에 주소가 고정된 전역·static 변수는 실행 중에도 보이고, 위치가 계속 바뀌는 로컬 변수는 멈춰야 보여요. 그래서 로컬 변수를 실시간으로 보려면 잠시 고정 주소에 두면 됩니다.
참고 자료
- DWARF Debugging Information Format Committee, DWARF Debugging Information Format Version 5 — dwarfstd.org
- ARM, Cortex-M4 Technical Reference Manual (ARM DDI 0439B), Flash Patch and Breakpoint Unit — documentation-service.arm.com
'임베디드 실무' 카테고리의 다른 글
| 부트로더란? MCU에 부트로더가 필요한 이유와 용도 [초급] (0) | 2026.10.02 |
|---|---|
| MISRA-C 규칙, 왜 이렇게 까다로울까? 양산 전 코딩 규칙 입문 [초급] (1) | 2026.10.01 |
| 폴링 vs 인터럽트, 버튼을 톡 치면 왜 가끔 안 먹을까? [초급] (0) | 2026.09.29 |
| [초급] 풀다운이 더 직관적인데, 스위치엔 왜 풀업 저항을 쓸까? (0) | 2026.09.27 |
| [초급] 차를 세워 뒀을 뿐인데 왜 배터리가 방전될까? 제어기 슬립(Sleep) 모드가 필요한 이유 (0) | 2026.09.26 |