Proven C Book←↑→

57 링크와 ABI — 이진 수준의 약속

먼저 알아야 할 것

56장 여러 파일로 나누어 짓기 · 번역 단위와 연결
17장 일반적인 컴파일 과정 · 네 단계 릴레이
47장 구조체 · 멤버의 배치와 패딩

돌아보기

앞 장에서 「링커가 이어 준다」고 했다. 그 말이 가리키는 시각은 언제인가 — 빌드할 때인가, 실행할 때인가?

답. 둘 다다. 그리고 그 사실이 이 장의 출발점이다. 목적 파일을 합쳐 심볼을 주소에 묶는 일은 빌드할 때 일어나지만, 공유 라이브러리를 찾아 들이고 그 안의 심볼을 잇는 일은 실행할 때 일어난다. 같은 「링크」라는 낱말이 두 시각에 걸쳐 있어서, 이것을 나누어 부르지 않으면 오류 메시지를 읽을 수 없다.

이 장의 필요성과 맥락

앞 장이 「소스를 어떻게 나누는가」였다면 이 장은 「나눈 것이 이진으로 어떻게 만나는가」다. 그리고 이 만남에는 소스에 적히지 않는 약속이 잔뜩 깔려 있다 — 인자를 어느 레지스터에 싣는지, 구조체를 어떻게 채우는지, long이 몇 바이트인지. 라이브러리를 갈아 끼웠는데 컴파일은 되고 실행이 무너지는 일들이 전부 이 약속을 어긴 자리에서 나온다.

이 장이 끝나면

링크를 세 단계로 갈라 부르고, 동적 링크의 진짜 목적이 크기가 아니라 ABI(application binary interface, 응용 이진 인터페이스) 임을 본다. ABI 가 무엇을 정하는지 조목조목 확인하고, 무엇이 ABI 를 깨뜨리는지 목록으로 세운다. 위치 독립 코드가 왜 필요한지, 이름이 겹치면 어떻게 조용히 갈아치워지는지까지 — 배포와 디버깅에서 만나는 자리들이다.

이 장에서 답할 질문

  1. 그러면 링크 오류가 실행 중에 갑자기 날 수도 있는가?
  2. 내 라이브러리에는 어디까지 필요한가?

57.1 링크는 한 단계가 아니다 — 세 이름#

지금까지 “링커가 이어 준다”고 뭉뚱그려 말했다. 실제로는 세 단계이고, 각각 이름이 있다.1

단계하는 일언제
링크 편집(link-editing)목적 파일들을 합치고 심볼을 주소에 묶는다빌드할 때 — ld
적재(loading)실행 파일의 세그먼트를 주소 공간에 들인다실행할 때 — 커널
런타임 링크(runtime linking)공유 라이브러리를 찾아 들이고 심볼을 잇는다실행할 때 — 동적 링커

표 57.1 — 분할 빌드의 단계

정적 링크된 프로그램은 앞의 둘로 끝난다. 동적 링크된 프로그램은 셋을 다 거친다. 그래서 같은 「링크」라는 낱말이 빌드 시각과 실행 시각 양쪽에 등장해 사람을 헷갈리게 한다.

★ 여기에 실무에서 알아 둘 사실이 하나 붙는다. 런타임 링커는 main이 불리기 전에 공유 객체를 주소 공간에 들이지만, 함수 심볼은실제로 불릴 때까지잇지 않는다. 이것을 게으른 결합(lazy binding)이라 한다. 그래서 쓰지도 않을 라이브러리를 링크해 두어도 시작 비용이 거의 늘지 않는다.

리눅스에서는 LD_BIND_NOW=1을 주면 이 미룸을 끄고 시작할 때 전부 잇는다. 실행 직후의 지연이 문제 되는 프로그램이나, 링크 오류를 나중이 아니라 지금 알고 싶을 때 쓴다.

문. 그러면 링크 오류가 실행 중에 갑자기 날 수도 있는가?

답. 그렇다. 그리고 그것이 게으른 결합의 대가다. 없는 심볼을 부르는 코드 경로가 프로그램이 한참 돌다가 처음 밟히면, 그때 동적 링커가 실패한다 — symbol lookup error 라는 메시지가 그것이다.

★ 그래서 배포 전에는 LD_BIND_NOW=1로 한 번 돌려 보거나, 링크할 때 -Wl,-z,now를 주어 아예 즉시 결합으로 빌드하는 관행이 있다. 보안 쪽에서도 즉시 결합을 선호하는데, 결합이 다 끝난 뒤에는 그 표를 읽기 전용으로 만들 수 있기 때문이다(-Wl,-z,relro와 함께 쓴다).

57.2 동적 링크의 진짜 목적 — 크기가 아니라 ABI#

동적 링크의 이점으로 흔히 「실행 파일이 작아진다」를 든다. 재어 보면 사실이기는 하다.

빌드크기무엇을 담았나
gcc -O2 -o hello hello.c약 16 KBputs 호출만. 구현은 밖에 있다
gcc -O2 -static -o hello hello.c약 758 KBlibc 의 필요한 부분을 통째로 안고 있다

표 57.2 — 빌드별 산출물 크기와 내용

★ 그러나 크기는 부산물이다. 진짜 목적은 다른 데 있다. 동적 링크는 프로그램을 특정 라이브러리 판본으로부터 떼어 놓는다. 시스템이 「이런 서비스를 이런 모양으로 내준다」고 약속하고, 프로그램은 그 약속에만 기댄다. 라이브러리가 고쳐져도 프로그램을 다시 빌드하지 않아도 되는 이유가 이것이다.

이 약속을 ABI(Application Binary Interface, 응용 프로그램 이진 인터페이스)라 부른다. API 가 「소스 수준의 약속」이라면 ABI 는 「이진 수준의 약속」이다 — 함수 이름이 무엇인지뿐 아니라, 인자를 어느 레지스터에 싣는지, 구조체를 어떻게 채워 넣는지, 반환값을 어디에 두는지까지 포함한다.

ABI 가 정하는 것예
호출 규약인자를 어느 레지스터에, 넘치면 스택 어디에
자료의 배치구조체 멤버의 정렬과 채움(46장)
기본 타입의 크기long이 4바이트인가 8바이트인가 — 같은 CPU 라도 갈린다
이름 규칙심볼에 밑줄을 붙이는가
라이브러리 판본soname과 심볼 버저닝

표 57.3 — ABI 가 정하는 것

soname이 실무에서 자주 만나는 자리다. libfoo.so.2처럼 이름에 붙은 숫자가 「이 판까지는 이진 호환을 지킨다」는 선언이고, 호환이 깨지는 변경을 하면 그 숫자를 올린다. 그러면 옛 프로그램은 여전히 .so.2를 찾고 새 프로그램은 .so.3을 찾으므로 한 시스템에 둘이 공존할 수 있다.

실제 사례. 정적 링크는 죽지 않았다

1990년대의 유닉스 문헌은 정적 링크를 사실상 폐물로 취급했다. ABI 의 이점이 워낙 컸기 때문이다. ★ 그런데 오늘 정적 링크가 되살아났다. 이유는 그때와 다른 곳에 있다.

컨테이너와 배포. 실행 파일 하나만 복사하면 도는 프로그램은 배포가 압도적으로 단순하다. 「그 기계에 그 라이브러리가 그 판으로 깔려 있는가」를 따질 필요가 없다.

재현 가능한 빌드. 같은 소스에서 같은 바이트가 나오려면 의존을 고정해야 하는데, 동적 링크는 실행 시각의 환경에 결과를 맡긴다.

작은 libc 의 등장. musl 같은 구현은 정적 링크를 전제로 설계되어, 표 57.3의 758 KB 보다 훨씬 작은 결과를 낸다.

그러니 오늘의 판단은 「무엇이 옳은가」가 아니라 「무엇을 사는가」다 — 시스템 라이브러리의 보안 갱신을 자동으로 받고 싶으면 동적 링크가 낫고, 배포의 단순함과 재현성이 더 중요하면 정적 링크가 낫다. ★ 보안 갱신 문제는 특히 크다. 정적으로 링크한 프로그램은 libc 에 취약점이 나면 다시 빌드해 다시 배포해야 한다.

57.3 ABI 가 약속하는 것 — 조목조목#

표 57.3이 목록이었다면 여기서는 그 하나하나가 실제로 무엇으로 나타나는지 본다. 소스에는 한 글자도 적히지 않지만, 기계어에는 또렷이 박혀 있는 것들이다.

첫째, 호출 규약. 인자를 어디에 싣는가. x86-64 의 System V ABI 에서는 정수·포인터 인자가 rdi, rsi, rdx, rcx, r8, r9 순으로 실리고, 일곱째부터 스택으로 간다. 컴파일러가 낸 기계어를 그대로 보면 의심할 여지가 없다.

f:                          /* long f(long a, long b, long c, …) */
        add     rdi, rsi
        add     rdi, rdx
        add     rdi, rcx
        add     rdi, r8

★ 같은 CPU 라도 판이 다르면 규약이 다르다. Windows x64 는 첫 네 인자를 rcx, rdx, r8, r9 에 싣고, 호출자가 32바이트를 미리 비워 둔다 — 이 자리를 영어로 shadow space 라 한다. 같은 기계어 명령을 쓰면서 약속이 다른 것이다. 리눅스용으로 빌드한 목적 파일을 윈도 프로그램에 이어 붙일 수 없는 이유가 여기에 있다.

둘째, 반환값을 어디에 두는가. 이것도 크기에 따라 갈린다.

돌려주는 것어떻게기계어에서 보이는 모습
작은 구조체 (16바이트 이하)레지스터에 담아서두 int 를 한 rax 에 밀어 넣는다
큰 구조체숨은 포인터로호출자가 자리를 잡아 rdi 로 넘긴다

표 57.4 — 구조체를 돌려주는 두 가지 길 (x86-64 System V)

큰 구조체를 돌려주는 함수는 사실 인자가 하나 더 있는 셈이다 — 결과를 쓸 자리의 주소다. 소스 어디에도 없는 이 인자가 ABI 의 일부다.

셋째, 기본 타입의 크기. 여기서 가장 자주 데인다.

모형intlong포인터
LP64 — 리눅스·macOS·BSD488
LLP64 — 윈도448

표 57.5 — 같은 64비트 기계, 다른 약속

long에 포인터를 담는 코드가 리눅스에서 멀쩡히 돌다가 윈도에서 무너지는 고전적인 사고가 이 표 한 줄에서 나온다. 폭이 중요하면 <stdint.h>의 int64_t처럼 폭을 이름에 적은 타입을 쓴다(28장).

넷째, 이름 규칙. C 는 심볼 이름을 거의 장식하지 않는다 — greet 는 그냥 greet 다. 일부 옛 플랫폼이 밑줄을 앞에 붙였을 뿐이다. C++ 는 정반대로 타입까지 이름에 녹여 넣는데(이름 장식, name mangling), 그래서 C++ 에서 C 함수를 부르려면 extern "C" 로 「이 이름은 장식하지 말라」고 일러 주어야 한다. 헤더에서 자주 보는 그 관용구의 정체가 이것이다.

57.4 무엇이 ABI 를 깨뜨리는가#

여기서 한 가지 원리만 붙들면 나머지는 따라온다.

헤더는 호출자 안으로 복사된다. 그래서 헤더에 적힌 구조체의 모양, 매크로의 값, 인라인 함수의 몸통은 라이브러리가 아니라 호출자의 기계어에 굳어 박힌다.

라이브러리만 새로 빌드하고 호출자를 그대로 두면, 굳어 박힌 옛 모양과 새 라이브러리가 만난다. 컴파일러는 번역 단위를 하나씩만 보고, 링커는 이름을 맞출 뿐 배치를 비교하지 않는다. 그래서 이 사고는 조용하다.

examples/ch55/abi_break/main.c

/* 헤더와 라이브러리가 어긋나면 --- 링커는 아무 말도 하지 않는다.

   두 번역 단위가 같은 이름의 구조체를 서로 다른 모양으로 알고 있다.
   호출자는 옛 헤더로 빌드된 채 남아 있고, 라이브러리만 새로 빌드된 상황이다.
   여기서는 구조체를 실제로 주고받지 않는다 --- 각자 무엇을 믿는지만 찍는다. */
#include <stddef.h>
#include <stdio.h>
#include "conf_v1.h"

void lib_report(void);

int main(void)
{
    puts("the same name, two different shapes:");
    printf("  caller (not rebuilt, v1.0) believes:\n");
    printf("    sizeof(struct conf)          = %zu\n", sizeof(struct conf));
    printf("    offsetof(struct conf, width) = %zu\n", offsetof(struct conf, width));
    printf("    offsetof(struct conf, height)= %zu\n", offsetof(struct conf, height));
    lib_report();

    puts("\nnothing in the build complained:");
    puts("  the compiler saw one translation unit at a time,");
    puts("  and the linker matches names, not layouts.");
    printf("\nhad a struct crossed the boundary, the caller would write height at"
           " offset %zu\n", offsetof(struct conf, height));
    puts("  and the library would read it from offset 8 --- a silent wrong answer.");
    return 0;
}

실행 결과

the same name, two different shapes:
  caller (not rebuilt, v1.0) believes:
    sizeof(struct conf)          = 8
    offsetof(struct conf, width) = 0
    offsetof(struct conf, height)= 4
  library (rebuilt, v1.1) believes:
    sizeof(struct conf)          = 12
    offsetof(struct conf, width) = 0
    offsetof(struct conf, height)= 8

nothing in the build complained:
  the compiler saw one translation unit at a time,
  and the linker matches names, not layouts.

had a struct crossed the boundary, the caller would write height at offset 4
  and the library would read it from offset 8 --- a silent wrong answer.

같은 이름의 구조체를 두 번역 단위가 서로 다른 모양으로 알고 있는데, 빌드 어디에서도 경고가 나오지 않았다. 만약 이 구조체가 경계를 넘나들었다면 호출자는 height 를 4번지에 쓰고 라이브러리는 8번지에서 읽었을 것이다 — 아무도 죽지 않고, 답만 틀린다.

변경소스는이진은
구조체 가운데에 멤버 추가그대로 컴파일된다뒤 멤버의 자리가 전부 밀린다
멤버 순서 바꾸기그대로자리가 뒤바뀐다
멤버 타입 넓히기 (int→long)그대로크기와 정렬이 바뀐다
열거 상수의 값 바꾸기그대로옛 코드에 옛 숫자가 박혀 있다
헤더의 인라인 함수 몸통 고치기그대로옛 몸통이 호출자에 박혀 있다
매크로 상수 값 바꾸기그대로옛 값이 박혀 있다
함수를 없애기빌드가 깨진다실행할 때 심볼을 못 찾는다

표 57.6 — 소스 호환인데 이진 호환이 아닌 변경들

흔한 오해. 라이브러리만 새로 빌드했으니 새 기능이 적용되었을 것이다

헤더에서 온 것은 적용되지 않는다. 구조체 배치·매크로·인라인 함수는 호출자를 다시 빌드해야 바뀐다. 「라이브러리를 갱신했는데 왜 그대로냐」는 물음과 「라이브러리를 갱신했더니 왜 이상해졌냐」는 물음은 같은 뿌리에서 나온다.

57.5 깨뜨리지 않고 고치는 법#

그러면 라이브러리는 영원히 못 고치는가. 그렇지 않다. 오래 사는 라이브러리들은 처음부터 고칠 여지를 남겨 두고 설계한다.

문. 내 라이브러리에는 어디까지 필요한가?

답. 누가 다시 빌드할 수 있는가로 정한다. 내가 전부 다시 빌드하는 사내 코드라면 이 장치들은 대체로 짐이다 — 헤더를 고치고 다 다시 빌드하면 그만이다. 남이 빌드해 둔 것이 내 라이브러리를 물고 도는 순간부터 이야기가 달라진다. ★ 그 경계를 넘는 날이 「이제부터 배치는 계약이다」라고 선언하는 날이다.

57.6 위치 독립 코드(PIC) — 왜 라이브러리는 이렇게 지어야 하는가#

공유 라이브러리에는 풀어야 할 문제가 하나 있다. 같은 라이브러리가 프로세스마다 다른 주소에 들어간다. 그런데 기계어 안의 주소는 어딘가에 박혀 있어야 한다.

가장 소박한 답은 「적재할 때 코드 안의 주소들을 고쳐 쓰는 것」이다. 이 방법에는 치명적인 대가가 있다. ★ 코드 페이지를 고치는 순간 그 페이지는 더 이상 공유될 수 없다. 여덟 개의 프로세스가 같은 라이브러리를 쓰면 코드가 물리 메모리에 한 벌만 있어야 하는데, 각자 다르게 고쳐 쓰면 여덟 벌이 된다. 공유 라이브러리의 존재 이유가 무너지는 것이다.

그래서 나온 것이 위치 독립 코드(position-independent code, PIC)다. 발상은 간단하다 — 코드는 절대 고치지 않고, 고쳐야 할 주소는 전부 「표」에 모아 둔다. 표는 데이터이므로 프로세스마다 따로 가져도 아깝지 않다.

무엇표간접의 모양
전역 데이터 접근GOT(Global Offset Table)표에서 주소를 읽고 → 그 주소로 간다
함수 호출PLT(Procedure Linkage Table)작은 중계 코드를 거쳐 간다

표 57.7 — 링크가 쓰는 표와 간접의 모양

실물로 보면 분명하다. 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)

★ 읽기가 두 번이다. 41장에서 배열과 포인터를 가른 바로 그 구조 — 「주소를 먼저 읽고, 그다음 값을 읽는다」 — 가 여기서 다시 나온다. 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(address space layout randomisation) 이 제 몫을 한다.

④ 옛 옵션 이름은 잊어도 된다. Sun 컴파일러의 -K pic, 공유 객체를 만들던 -G 같은 표기는 오늘의 GCC·Clang 에서 각각 -fPIC, -shared다.

손잡이 몇 개는 알아 둘 만하다. -Bsymbolic과 -fno-semantic-interposition은 라이브러리 내부 호출이 밖에서 가로채이지 않게 해 간접 비용을 줄이고, --as-needed는 실제로 쓰지 않는 의존을 기록하지 않는다.

57.7 이름이 겹치면 갈아치워진다 — interpositioning#

앞 절에서 「기본이 외부 연결」이라는 성질을 보았다. 그 성질이 동적 링크와 만나면 놀라운 일이 생긴다. 내가 만든 함수가 라이브러리 함수를 조용히 대신할 수 있다.

이것을 가로채기(interpositioning)라 한다. 동적 링커는 심볼을 찾을 때 정해진 순서로 뒤지고, 먼저 찾은 것을 쓴다. 그래서 내 프로그램에 malloc이라는 이름의 전역 함수가 있으면, 라이브러리 안쪽에서 일어나는 malloc 호출까지 내 것으로 간다.

★ 여기서 두 가지가 겹쳐야 사고가 된다. 하나는 함수가 기본으로 전역이라는 것, 다른 하나는 컴파일러가 이 재정의를 오류로 보지 않는다는 것이다. 후자는 C 의 오랜 태도 때문이다 — 프로그래머가 의도했다고 가정한다.

반례. 흔한 이름을 전역 함수로 쓰기

/* 내 파일 안, static 없이 */
char *mktemp(char *tpl) { ... }      /* 표준 라이브러리에도 있는 이름 */
int   index(int i)      { ... }      /* 오래된 유닉스 함수 이름 */

이런 이름은 표준이나 시스템 라이브러리가 이미 쓰고 있을 수 있다. 겹치면 내 함수가 라이브러리 안쪽 호출까지 가로챈다. 증상은 대개 엉뚱한 곳에서 나타난다 — 내가 부른 적 없는 함수가 잘못 동작하는 것이다.

처방은 앞 절의 규율 그대로다. 인터페이스로 내놓을 것이 아니면 static을 붙인다. 그리고 내놓을 것에는 접두어를 단다(59장). 표준이 예약한 이름 꼴을 피하는 것도 여기에 포함된다.

실제 사례. 가로채기가 도구가 되는 자리

이 성질을 일부러 쓰는 자리도 있다. 리눅스의 LD_PRELOAD 환경 변수는 「이 라이브러리를 다른 무엇보다 먼저 찾으라」고 지시한다. 그래서 프로그램을 고치지 않고도 특정 함수를 갈아 끼울 수 있다.

정당한 용법이 여럿이다 — malloc/free를 감싸 누수를 추적하는 도구, 시스템 호출을 흉내 내는 시험 장치, 시각 함수를 고정해 재현 가능한 시험을 만드는 것.

★ 그러나 같은 성질이 위험하기도 하다. 남의 프로그램의 동작을 밖에서 바꿀 수 있다는 뜻이므로, 권한이 올라가는 프로그램(setuid)에서는 이 변수가 무시된다. 이런 「강력하고 위험한 손잡이」를 다룰 때의 판단은 13장의 사다리대로 한다 — 무엇을 사고 무엇을 치르는지 적어 두고 쓴다.

이진 수준의 약속을 배웠다. 그런데 앞 장에서 #include와 #ifndef가 또 나왔다 — 17장에서 “전처리기는 C를 모르는 텍스트 도구”라 하고 지나친 그 층이다. 곧 그 층을 정면으로 열고, 소스 코드가 프로그램이 되기까지의 정식 단계까지 본다.

주

  1. 이 세 이름과 구분은 링크·적재를 다루는 표준적 서술을 따랐다. 개념 정리에 특히 도움이 된 것은 John R. Levine, Linkers and Loaders (Morgan Kaufmann, 1999)와 System V ABI 의 ELF(executable and linkable format) 절, 그리고 ld.so(8) 이다. 1994년에 이 구분을 C 프로그래머용으로 정리해 둔 자리로는 Peter van der Linden, Expert C Programming 의 링크를 다룬 장이 있다. ↩