이전 글에서 코드는 컴파일 단계를 거쳐 대상 파일(.o/.obj)로 변환된다는 지점까지 정리했다.
하지만 대상 파일은 아직 실행 파일이 아니다.
파일 간 함수 호출과 전역 데이터 참조가 해결되지 않았고, 최종 메모리 배치도 정해지지 않았기 때문이다.
이번 글에서는 이 조각난 결과물이 링크와 로딩을 거쳐 실제 실행 가능한 프로그램으로 완성되는 흐름을 정리한다.
1. 링커는 무엇을 완성하는가
컴파일이 끝나면 소스 파일마다 대상 파일(.o/.obj)이 만들어진다.
기계어로 바뀌었다는 점에서는 맞지만, 아직 실행 파일은 아니다.
예를 들어 main.o가 log.o의 함수를 호출하는 경우를 생각해 보면,
컴파일 단계에서는 호출 대상의 이름은 알 수 있어도 최종 주소는 아직 확정할 수 없다.
이 빈칸을 실제 실행 규칙으로 채우는 단계가 링크(link)다.
결국 링커(linker)는 단순히 파일을 합치는 도구가 아니라, 흩어진 대상 파일을 “실제로 실행 가능한 하나의 프로그램”으로 완성하는 도구다.
1.1 대상 파일 안에는 무엇이 들어 있는가
링커의 입력은 대상 파일(.o/.obj)과 라이브러리다.
출력은 플랫폼 형식에 맞는 실행 파일(또는 공유 라이브러리)이다.
윈도우에서는 PE(.exe/.dll), 리눅스 계열에서는 ELF 형식으로 결과가 만들어진다.
여기서 중요한 점은 대상 파일 안에 “명령어 비트열”만 있는 게 아니라는 것이다. 링커가 연결 작업을 할 수 있도록 필요한 메타데이터가 함께 저장된다.
- 코드 섹션(
.text): 컴파일된 기계어 명령 - 데이터 섹션(
.data,.bss): 초기화된 전역 변수와 0으로 초기화될 변수 - 심벌 테이블: 이 파일이 외부에 제공하는 이름(정의)과 외부에서 가져와야 하는 이름(참조)
심벌 테이블이 핵심이다. 링커는 이 표를 바탕으로 파일 간 의존 관계를 맞춘다. 참고로 지역 변수는 함수 실행 구간에서만 유효하므로, 보통 외부 연결 대상 심벌로 다루지 않는다.
1.2 심벌 해석: 이름을 실제 구현과 연결하기
컴파일러는 각 소스 파일을 개별적으로 처리한다. 그래서 문법/타입 검사는 통과했더라도, 실제 정의가 존재하는지 여부는 아직 미확정일 수 있다. 이 확인이 마무리되는 지점이 심벌 해석(symbol resolution) 단계다.
링커는 모든 대상 파일의 심벌 테이블을 모아 “필요한 이름(참조)“과 “제공되는 이름(정의)“을 1:1로 연결한다. 여기서 연결에 실패하면 링크 에러가 발생한다.
흔히 마주치는 두 가지 에러가 있다.
undefined reference: 어느 모듈에서도 해당 심벌을 정의하지 않았을 때multiple definition: 두 모듈이 같은 전역 심벌을 동시에 정의했을 때
예를 들어 main.o에서 print_log()를 호출했는데 어느 모듈에도 정의가 없으면 undefined reference가 난다.
반대로 utils.o와 helpers.o가 둘 다 int counter를 전역으로 정의하면 multiple definition이 발생한다.
즉, 심벌 해석은 단순 이름 비교가 아니라, 프로그램 전체에서 호출/참조 관계를 일관되게 맞추는 검증 단계다.
1.3 섹션 병합: 조각을 실행 파일 구조로 정리하기
심벌 연결이 끝나면 링커는 섹션 병합(section merging)을 진행한다.
각 대상 파일에 흩어져 있던 .text, .data, .bss를 실행 파일 레이아웃에 맞춰 하나의 구조로 정리한다.
이 과정은 단순 concat이 아니다.
섹션 정렬(alignment), 배치 순서, 시작 지점(entry point) 같은 제약을 함께 반영한다.
예를 들어 .text 섹션은 보통 읽기+실행 권한으로, .data는 읽기+쓰기 권한으로 매핑되는데,
이런 메모리 보호 속성이 다른 섹션끼리 같은 세그먼트에 섞이면 안 된다.
이렇게 정리해야 운영체제 로더가 실행 파일을 메모리에 올릴 때 일관된 규칙으로 다룰 수 있다.
1.4 정적 링크 vs 동적 링크
라이브러리를 붙이는 방식은 정적 링크(static linking)와 동적 링크(dynamic linking)로 나뉜다. 둘 다 정답이 아니라, 배포 방식과 운영 환경에 따라 선택이 달라진다.
정적 링크는 링크 시점에 라이브러리 코드를 실행 파일 내부로 복사한다. 실행할 때 외부 라이브러리 의존이 줄어 배포가 단순해진다는 장점이 있다. 대신 같은 라이브러리를 여러 프로그램이 각각 포함하면 디스크와 메모리에서 코드가 중복되고, 라이브러리 수정 시 관련 실행 파일을 모두 다시 빌드해야 하는 부담이 생긴다.
동적 링크는 실행 파일 바깥에 라이브러리(.so, .dll)를 두고, 실행 시점에 로더가 로딩한다.
실행 파일 크기를 줄이고, 여러 프로세스가 동일 라이브러리 코드를 공유하기 쉽다.
반면 운영 환경에서 버전/ABI 호환이나 로딩 경로(LD_LIBRARY_PATH, PATH)가 맞지 않으면
“빌드는 됐는데 실행이 실패”하는 문제가 발생할 수 있다.
실무에서 자주 보는 ldd 명령이 바로 동적 링크 의존성을 확인하는 도구다.
# 실행 파일이 의존하는 공유 라이브러리 목록 확인
ldd /usr/bin/ls
결국 핵심은 성능 하나가 아니라, 배포 편의성·메모리 효율·유지보수 비용까지 포함한 트레이드오프다.
1.5 재배치: 임시 주소를 실제 주소로 치환하기
CPU는 이름이 아니라 주소로 명령을 실행한다. 문제는 컴파일 시점에 최종 주소를 아직 알 수 없다는 점이다. 그래서 컴파일러는 호출 위치에 임시값(보통 0)을 넣고, 나중에 수정할 표식을 남긴다.
call 0x00000000 ; 임시값 — 링커가 나중에 채울 자리
링커는 심벌 해석과 섹션 병합을 마친 뒤, 재배치 정보(relocation entry)를 읽어 해당 위치를 실제 주소 값으로 패치한다.
call 0x4004d6 ; 링크 후 채워진 실제 주소(예시)
ELF 형식에서는 이 재배치 정보가 .rel.* 또는 .rela.* 섹션에 들어간다.
각 엔트리에는 “어느 위치를”, “어떤 심벌 기준으로”, “어떤 계산식으로” 패치할지가 기록되어 있다.
정리하면, 재배치는 “이름 기반 참조”를 “주소 기반 참조”로 바꾸는 마지막 변환 단계다.
1.6 실행할 때마다 주소가 달라도 동작하는 이유
프로그램을 실행할 때마다 메모리 시작 주소는 달라질 수 있다. 그런데도 같은 바이너리가 문제없이 동작하는 이유는 가상 메모리(Virtual Memory) 모델에 있다.
링커가 확정하는 것은 물리 주소 자체가 아니라, 가상 주소 기준의 배치 관계와 보정 규칙이다. 실행 시점에는 운영체제 로더가 실행 파일과 공유 라이브러리를 가상 주소 공간에 매핑하고, 필요한 재배치를 적용한 뒤 시작 지점(entry point)으로 제어를 넘긴다.
여기서 ASLR(Address Space Layout Randomization)이 등장한다. ASLR은 보안을 위해 프로그램이 로딩되는 베이스 주소를 실행할 때마다 무작위로 바꾸는 기법이다. 베이스 주소가 달라져도 재배치 규칙이 적용되므로 함수 호출과 데이터 참조는 일관되게 맞춰진다.
CPU 실행 단계에서는 MMU(Memory Management Unit)가 가상 주소를 물리 주소로 변환해 실제 메모리에 접근한다. 이 변환은 페이지 테이블(Page Table)을 기반으로 이뤄지며, 프로세스마다 독립된 가상 주소 공간을 보장하는 핵심 메커니즘이다.
정리하면 링크는 “실행 규칙을 준비하는 단계”이고, 실행 시점의 실제 주소 매핑은 로더와 MMU가 완성한다.
마무리
링크 단계의 핵심은 세 가지다.
- 심벌 해석: 흩어진 대상 파일 간의 참조/정의를 정확히 연결한다.
- 섹션 병합 + 재배치: 조각난 코드와 데이터를 하나의 실행 파일 구조로 합치고, 임시 주소를 실제 주소로 패치한다.
- 가상 메모리와 로딩: 링커가 준비한 규칙을 바탕으로, 실행 시점에 로더와 MMU가 실제 메모리 매핑을 완성한다.
링커는 파일을 묶는 단순 도구가 아니라, 프로그램이 실제로 실행될 수 있는 연결 상태를 확정하는 단계다.
다음 글에서는 운영체제가 프로그램을 메모리에 올린 뒤, 프로세스가 실제로 어떤 구조로 실행되는지로 흐름을 이어갈 예정이다.