54 여러 파일 — 분할과 링크
먼저 알아야 할 것
돌아보기
24장에서 “원형을 파일 위쪽에 적어 두면 정의는 아래 어디에 있어도 되고, 더 중요하게는 다른 파일에 있어도 된다”고 했다. 그 말이 성립하는 근거를 16장의 릴레이로 설명하면?
답. 컴파일 단계는 선언만 보고 일하고, 몸통을 찾아 잇는 것은 링커이기 때문이다(16장). 그래서 파일 하나를 컴파일할 때 다른 파일의 내용은 전혀 필요하지 않다 — 필요한 것은 “이런 이름의 함수가 이런 서명으로 어딘가에 있다”는 약속뿐이고, 그 약속을 담아 나르는 그릇이 헤더 파일 이다. 이 장은 그 분업을 실물로 본다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 링크 오류 메시지는 어떻게 읽는가?
- 그러면 링크 오류가 실행 중에 갑자기 날 수도 있는가?
54.1 번역 단위와 헤더
전처리가 끝난 소스 파일 하나 — #include가 전부 펼쳐진 결과 — 를 번역 단위(translation unit)라 부른다. 컴파일러는 언제나 번역 단위 하나만 본다. 여러 파일 프로그램이란 번역 단위 여럿을 각각 목적 파일로 만든 뒤, 링커가 하나로 잇는 것이다.
세 파일짜리 예제로 분업을 본다.
examples/ch54/main.c
/* 여러 파일 예제 — 이 파일은 greet의 선언만 보고 컴파일된다. */
#include "greet.h"
int main(void)
{
greet("world");
printf_count();
return 0;
}
실행 결과
Hello, world!
greet was called 1 times
greet.h(헤더)에는 선언만 있다 — 계약 서명. greet.c에는 정의가 있고, main.c는 헤더만 포함해 계약을 알고 호출한다. 컴파일은 각각 따로 되고, 링커가 greet 호출과 정의를 이어 준다.
헤더의 #ifndef GREET_H ... #define ... #endif는 포함 보호(include guard)다. 헤더가 여러 경로로 두 번 붙어도 내용이 한 번만 펼쳐지게 하는 관용구로, 두 번 선언되면 오류가 나는 것들(구조체 정의 등)을 막는다. 실무에서는 #pragma once 한 줄로 대신하는 경우도 많다(표준은 아니지만 주요 컴파일러가 전부 지원한다).
54.2 연결 — 이름이 파일 경계를 넘는가
이름에는 스코프(24장) 말고 또 하나의 성질이 있다 — 연결(linkage), 즉 다른 번역 단위에서 그 이름을 볼 수 있는가다.
- 외부 연결: 파일 밖에서도 보인다. 함수와 전역 변수의 기본값이다.
- 내부 연결: 이 번역 단위 안에서만 보인다. 파일 수준 선언에
static을 붙이면 된다 — 예제의static int calls가 그 예다.
static이라는 낱말이 44장(정적 수명)과 여기(내부 연결)에서 다른 뜻으로 쓰이는 것은 C의 유명한 낱말 재활용이다 — 함수 안의 static은 수명을, 파일 수준의 static은 연결을 뜻한다.
내부 연결은 실무의 기본 무기다. C에는 프로그래머가 새로 파는 이름 공간이 없어서(55장) 외부 이름은 모두 한 마당에서 부딪히는데, 파일 안에서만 쓰는 도우미 함수· 변수에 static을 붙이면 그 이름은 아예 바깥 마당에 나가지 않는다 — 충돌도 없고, 컴파일러가 더 공격적으로 최적화할 수 있다(그 이름이 다른 파일에서 불릴 리 없음을 아니까 — 13장의 편집자에게 주는 정직한 신호다). 이 무기를 포함해 이름 충돌(name collision)을 막는 방법 전체는 56장에서 다룬다.
흔한 오해. “헤더 파일에 함수 정의를 넣어도 편하니 괜찮다”
포함되는 파일마다 정의가 복사되므로, 두 파일이 그 헤더를 포함하면 같은 함수의 정의가 둘이 되어 링커가 중복 정의 오류를 낸다(“multiple definition of …”). 헤더의 역할은 계약을 나르는 것이지 구현을 나르는 것이 아니다 — 헤더에는 선언, 소스에는 정의가 기본형이다. (예외가 없지는 않다:static inline 함수나 매크로는 헤더에 두는 것이 관행 이고, C23의 constexpr 상수도 그렇다. 예외를 알고 쓰는 것과 규칙을 모르고 어기는 것은 다르다.)문. 링크 오류 메시지는 어떻게 읽는가?
답. 두 문장만 알면 대부분 풀린다. “undefined reference to X” — 선언은 보았으나 정의를 못 찾았다는 뜻이다(16장). 소스 파일을 빌드에서 빠뜨렸거나, 라이브러리를 안 이어 줬거나, 철자가 다르다. “multiple definition of X” — 정의가 둘 이상이다. 헤더에 정의를 넣었거나, 전역 변수를 헤더에서 선언하며 초기화했을 때 흔하다. 두 메시지 모두 컴파일러 가 아니라 링커가 내는 것이라는 점이 진단의 출발점이다 — 문법은 멀쩡했다는 뜻이니까.
54.3 링크는 한 단계가 아니다 — 세 이름
지금까지 “링커가 이어 준다”고 뭉뚱그려 말했다. 실제로는 세 단계이고, 각각 이름이 있다.1
| 단계 | 하는 일 | 언제 |
|---|---|---|
| 링크 편집(link-editing) | 목적 파일들을 합치고 심볼을 주소에 묶는다 | 빌드할 때 — ld |
| 적재(loading) | 실행 파일의 세그먼트를 주소 공간에 들인다 | 실행할 때 — 커널 |
| 런타임 링크(runtime linking) | 공유 라이브러리를 찾아 들이고 심볼을 잇는다 | 실행할 때 — 동적 링커 |
표 54.1
정적 링크된 프로그램은 앞의 둘로 끝난다. 동적 링크된 프로그램은 셋을 다 거친다. 그래서 같은 「링크」라는 낱말이 빌드 시각과 실행 시각 양쪽에 등장해 사람을 헷갈리게 한다.
★ 여기에 실무에서 알아 둘 사실이 하나 붙는다. 런타임 링커는 main이 불리기 전에 공유 객체를 주소 공간에 들이지만, 함수 심볼은실제로 불릴 때까지잇지 않는다. 이것을 게으른 결합(lazy binding)이라 한다. 그래서 쓰지도 않을 라이브러리를 링크해 두어도 시작 비용이 거의 늘지 않는다.
리눅스에서는 LD_BIND_NOW=1을 주면 이 미룸을 끄고 시작할 때 전부 잇는다. 실행 직후의 지연이 문제 되는 프로그램이나, 링크 오류를 나중이 아니라 지금 알고 싶을 때 쓴다.
문. 그러면 링크 오류가 실행 중에 갑자기 날 수도 있는가?
답. 그렇다. 그리고 그것이 게으른 결합의 대가다. 없는 심볼을 부르는 코드 경로가 프로그램이 한참 돌다가 처음 밟히면, 그때 동적 링커가 실패한다 — symbol lookup error 라는 메시지가 그것이다.
★ 그래서 배포 전에는 LD_BIND_NOW=1로 한 번 돌려 보거나, 링크할 때 -Wl,-z,now를 주어 아예 즉시 결합으로 빌드하는 관행이 있다. 보안 쪽에서도 즉시 결합을 선호하는데, 결합이 다 끝난 뒤에는 그 표를 읽기 전용으로 만들 수 있기 때문이다(-Wl,-z,relro와 함께 쓴다).
54.4 동적 링크의 진짜 목적 — 크기가 아니라 ABI
동적 링크의 이점으로 흔히 「실행 파일이 작아진다」를 든다. 재어 보면 사실이기는 하다.
| 빌드 | 크기 | 무엇을 담았나 |
|---|---|---|
gcc -O2 -o hello hello.c | 약 16 KB | puts 호출만. 구현은 밖에 있다 |
gcc -O2 -static -o hello hello.c | 약 758 KB | libc 의 필요한 부분을 통째로 안고 있다 |
표 54.2
★ 그러나 크기는 부산물이다. 진짜 목적은 다른 데 있다. 동적 링크는 프로그램을 특정 라이브러리 판본으로부터 떼어 놓는다. 시스템이 「이런 서비스를 이런 모양으로 제공한다」고 약속하고, 프로그램은 그 약속에만 기댄다. 라이브러리가 고쳐져도 프로그램을 다시 빌드하지 않아도 되는 이유가 이것이다.
이 약속을 ABI(Application Binary Interface, 응용 프로그램 이진 인터페이스)라 부른다. API 가 「소스 수준의 약속」이라면 ABI 는 「이진 수준의 약속」이다 — 함수 이름이 무엇인지뿐 아니라, 인자를 어느 레지스터에 싣는지, 구조체를 어떻게 채워 넣는지, 반환값을 어디에 두는지까지 포함한다.
| ABI 가 정하는 것 | 예 |
|---|---|
| 호출 규약 | 인자를 어느 레지스터에, 넘치면 스택 어디에 |
| 자료의 배치 | 구조체 멤버의 정렬과 채움(45장) |
| 기본 타입의 크기 | long이 4바이트인가 8바이트인가 — 같은 CPU 라도 갈린다 |
| 이름 규칙 | 심볼에 밑줄을 붙이는가 |
| 라이브러리 판본 | soname과 심볼 버저닝 |
표 54.3
soname이 실무에서 자주 만나는 자리다. libfoo.so.2처럼 이름에 붙은 숫자가 「이 판까지는 이진 호환을 지킨다」는 선언이고, 호환이 깨지는 변경을 하면 그 숫자를 올린다. 그러면 옛 프로그램은 여전히 .so.2를 찾고 새 프로그램은 .so.3을 찾으므로 한 시스템에 둘이 공존할 수 있다.
실제 사례. 정적 링크는 죽지 않았다
1990년대의 유닉스 문헌은 정적 링크를 사실상 폐물로 취급했다. ABI 의 이점이 워낙 컸기 때문이다. ★ 그런데 오늘 정적 링크가 되살아났다. 이유는 그때와 다른 곳에 있다.
컨테이너와 배포. 실행 파일 하나만 복사하면 도는 프로그램은 배포가 압도적으로 단순하다. 「그 기계에 그 라이브러리가 그 판으로 깔려 있는가」를 따질 필요가 없다.
재현 가능한 빌드. 같은 소스에서 같은 바이트가 나오려면 의존을 고정해야 하는데, 동적 링크는 실행 시각의 환경에 결과를 맡긴다.
작은 libc 의 등장. musl 같은 구현은 정적 링크를 전제로 설계되어, 위 표의 758 KB 보다 훨씬 작은 결과를 낸다.
그러니 오늘의 판단은 「무엇이 옳은가」가 아니라 「무엇을 사는가」다 — 시스템 라이브러리의 보안 갱신을 자동으로 받고 싶으면 동적 링크가 낫고, 배포의 단순함과 재현성이 더 중요하면 정적 링크가 낫다. ★ 보안 갱신 문제는 특히 크다. 정적으로 링크한 프로그램은 libc 에 취약점이 나면 다시 빌드해 다시 배포해야 한다.
54.5 위치 독립 코드(PIC) — 왜 라이브러리는 이렇게 지어야 하는가
공유 라이브러리에는 풀어야 할 문제가 하나 있다. 같은 라이브러리가 프로세스마다 다른 주소에 들어간다. 그런데 기계어 안의 주소는 어딘가에 박혀 있어야 한다.
가장 소박한 답은 「적재할 때 코드 안의 주소들을 고쳐 쓰는 것」이다. 이 방법에는 치명적인 대가가 있다. ★ 코드 페이지를 고치는 순간 그 페이지는 더 이상 공유될 수 없다. 여덟 개의 프로세스가 같은 라이브러리를 쓰면 코드가 물리 메모리에 한 벌만 있어야 하는데, 각자 다르게 고쳐 쓰면 여덟 벌이 된다. 공유 라이브러리의 존재 이유가 무너지는 것이다.
그래서 나온 것이 위치 독립 코드(position-independent code, PIC)다. 발상은 간단하다 — 코드는 절대 고치지 않고, 고쳐야 할 주소는 전부 「표」에 모아 둔다. 표는 데이터이므로 프로세스마다 따로 가져도 아깝지 않다.
| 무엇 | 표 | 간접의 모양 |
|---|---|---|
| 전역 데이터 접근 | GOT(Global Offset Table) | 표에서 주소를 읽고 → 그 주소로 간다 |
| 함수 호출 | PLT(Procedure Linkage Table) | 작은 중계 코드를 거쳐 간다 |
표 54.4
실물로 보면 분명하다. int counter; int bump(void){ return ++counter; }를 -fPIC -shared로 빌드해 objdump로 뜯어본 결과다(x86-64).
bump:
mov 0x2ec9(%rip),%rdx ← GOT 에서 counter 의 주소를 읽는다
mov (%rdx),%eax ← 그 주소의 값을 읽는다
add $0x1,%eax
mov %eax,(%rdx)★ 읽기가 두 번이다. 40장에서 배열과 포인터를 가른 바로 그 구조 — 「주소를 먼저 읽고, 그다음 값을 읽는다」 — 가 여기서 다시 나온다. PIC 의 비용이란 결국 이 한 걸음이다.
함수 쪽은 PLT 를 거친다.
bump@plt:
jmp *0x2fc2(%rip) ← GOT 항목으로 뛴다
push $0x1 ← 아직 안 이어졌으면 여기로 떨어진다
jmp <동적 링커> ← 링커가 주소를 채우고 다시 뛴다이 두 줄이 게으른 결합의 기계장치다. 처음 부를 때는 동적 링커가 불려 와 GOT 항목을 채우고, 그 뒤로는 첫 jmp 한 번으로 곧장 간다.
플랫폼 노트. 오늘의 사정 — 1990년대와 달라진 것
옛 문헌은 「PIC 는 조금 느리니 실행 파일에는 쓰지 말라」고 적었다. 오늘은 사정이 여럿 달라졌다.
① x86-64 에서 비용이 크게 줄었다. RIP 상대 주소 지정이 생겨, 자기 위치를 알아내려고 별도 계산을 하던 32비트 시절의 수고가 사라졌다. 위 코드의 0x2ec9(%rip)가 바로 그것이다.
② 이제는 선택이 아니다. x86-64 에서 -fPIC 없이 공유 라이브러리를 만들려고 하면 링커가 거부한다 — 실제로 해 보면 이렇게 말한다. relocation R_X86_64_PC32 against symbol 'counter' can not be used when making a shared object; recompile with -fPIC
③ 실행 파일까지 위치 독립으로 짓는다. 오늘 대부분의 배포판은 실행 파일을 PIE(position-independent executable)로 빌드한다. 이유는 성능이 아니라 보안이다 — 실행 파일의 적재 주소까지 무작위로 만들어야 ASLR 이 제 값을 한다.
④ 옛 옵션 이름은 잊어도 된다. Sun 컴파일러의 -K pic, 공유 객체를 만들던 -G 같은 표기는 오늘의 GCC·Clang 에서 각각 -fPIC, -shared다.
손잡이 몇 개는 알아 둘 값이 있다. -Bsymbolic과 -fno-semantic-interposition은 라이브러리 내부 호출이 밖에서 가로채이지 않게 해 간접 비용을 줄이고, --as-needed는 실제로 쓰지 않는 의존을 기록하지 않는다.
54.6 이름이 겹치면 갈아치워진다 — interpositioning
앞 절에서 「기본이 외부 연결」이라는 성질을 보았다. 그 성질이 동적 링크와 만나면 놀라운 일이 생긴다. 내가 만든 함수가 라이브러리 함수를 조용히 대신할 수 있다.
이것을 가로채기(interpositioning)라 한다. 동적 링커는 심볼을 찾을 때 정해진 순서로 뒤지고, 먼저 찾은 것을 쓴다. 그래서 내 프로그램에 malloc이라는 이름의 전역 함수가 있으면, 라이브러리 안쪽에서 일어나는 malloc 호출까지 내 것으로 간다.
★ 여기서 두 가지가 겹쳐야 사고가 된다. 하나는 함수가 기본으로 전역이라는 것, 다른 하나는 컴파일러가 이 재정의를 오류로 보지 않는다는 것이다. 후자는 C 의 오랜 태도 때문이다 — 프로그래머가 의도했다고 가정한다.
반례. 흔한 이름을 전역 함수로 쓰기
/* 내 파일 안, static 없이 */
char *mktemp(char *tpl) { ... } /* 표준 라이브러리에도 있는 이름 */
int index(int i) { ... } /* 오래된 유닉스 함수 이름 */이런 이름은 표준이나 시스템 라이브러리가 이미 쓰고 있을 수 있다. 겹치면 내 함수가 라이브러리 안쪽 호출까지 가로챈다. 증상은 대개 엉뚱한 곳에서 나타난다 — 내가 부른 적 없는 함수가 잘못 동작하는 것이다.
처방은 앞 절의 규율 그대로다. 인터페이스로 내놓을 것이 아니면 static을 붙인다. 그리고 내놓을 것에는 접두어를 단다(55장). 표준이 예약한 이름 꼴을 피하는 것도 여기에 포함된다.
실제 사례. 가로채기가 도구가 되는 자리
이 성질을 일부러 쓰는 자리도 있다. 리눅스의 LD_PRELOAD 환경 변수는 「이 라이브러리를 다른 무엇보다 먼저 찾으라」고 지시한다. 그래서 프로그램을 고치지 않고도 특정 함수를 갈아 끼울 수 있다.
정당한 용법이 여럿이다 — malloc/free를 감싸 누수를 추적하는 도구, 시스템 호출을 흉내 내는 시험 장치, 시각 함수를 고정해 재현 가능한 시험을 만드는 것.
★ 그러나 같은 성질이 위험하기도 하다. 남의 프로그램의 동작을 밖에서 바꿀 수 있다는 뜻이므로, 권한이 올라가는 프로그램(setuid)에서는 이 변수가 무시된다. 이런 「강력하고 위험한 손잡이」를 다룰 때의 판단은 12장의 사다리대로 한다 — 무엇을 사고 무엇을 치르는지 적어 두고 쓴다.
파일 경계를 넘는 법을 배웠다. 그런데 이 장에서 #include와 #ifndef가 또 나왔다 — 16장에서 “전처리기는 C를 모르는 텍스트 도구”라 하고 지나친 그 층이다. 다음 장에서 그 층을 정면으로 열고, 소스 코드가 프로그램이 되기까지의 정식 단계까지 본다.
주
- 이 세 이름과 구분은 링크·적재를 다루는 표준적 서술을 따랐다. 개념 정리에 특히 도움이 된 것은 John R. Levine, Linkers and Loaders (Morgan Kaufmann, 1999)와 System V ABI 의 ELF 절, 그리고
ld.so(8)이다. 1994년에 이 구분을 C 프로그래머용으로 정리해 둔 자리로는 Peter van der Linden, Expert C Programming 의 링크를 다룬 장이 있다. ↩