[CS:APP] 링커 7.1 - 7.14

이거는 글을 좀 분할을 해야겠다.. [2025년 5월 1일 작성]

링커!!

  • 링커는 소프트웨어 개발하는데 있어 구성요소를 분리 할 수 있게 해준다.
  • 즉, 한 소스 코드에 모든 것을 배치하는 것과 달리 여러 개의 소스로 나눌 수 있다.
  • 이것은 간접적으로든 직접적으로든 관리에 유용함을 가져다 준다.

뭐 어떻게 좋냐고

  • 모듈화, 각 다른 역할을 수행하는 소스 파일로 분리 할 수 있다.
    • 큰 프로그램을 모듈 단위로 나누면 독립적으로 배치된다. 링커 직전의 변환 단계에서 .o 파일들만 만들고, 링커는 최종적으로 이걸 합치면서 실행 파일을 만들게 된다.
  • 빌드 시간 최적화, 변경된 모듈만 컴파일하면 된다.
    • 링커는 변경된 부분만 반영하면서 전체 프로그램을 다시 만드는 역할을 한다.
  • 코드 재사용성, 유지보수
    • 앞이랑 좀 겹치긴 하는데, 수정이 필요한 모듈만 수정하면 되니 전체 코드의 유지보수가 쉬워진다.
  • 캡슐화, 다른 내부 구현은 몰라도 함수 시그니처만 알면 링커가 재량껏 다한다.
    • 컴파일 타임에 정의되지 않은 함수라도 하더라도 링커는 그것을 찾아서 연결 할 수 있다.

나한테도 좋나요?

  • 당연하죠. 당신의 연봉이 오릅니다
    • 큰 프로그램을 만들기 위한 기반 지식을 얻을 수 있다.
      • 큰 규모의 프로그램 개발에는 각 모듈을 독립적으로 만들고 연결하는 작업이 자동화 되어야한다.
      • 이 중심 기술에는 링커가 있다.
    • 치명적인 프로그램 오류를 회피 할 수 있다.
      • 컴파일을 통과 할 수도 있는 코드를 링커가 한 번 더 걸러주는 역할을 한다.
      • 중복된 전역 변수 정의도 막아주어 충돌을 피할 수 있다.
    • 스코핑 룰에 대한 이해를 높일 수 있다.
      • 링커는 어떤 심볼(함수 또는 변수)이 어디에서 접근이 가능한지, 어디에 정의되어야 하는지를 결정한다.
    • 다른 시스템의 근본 개념을 이해한다.
      • 운영체제의 로더, 디버깅 툴이나 성능 분석기를 쓸 때도 링커의 도움을 많은 부분에서 받게 된다.
    • 공유 라이브러리를 활용 할 수 있다.
      • 공유 객체는 프로그램과 따로 존재하는 듯 하지만 실행 시 동적으로 연결된다. 링커는 이들을 실행 시점에 연결 할 수 있는 형태로 참조 정보만 포함 시킨다. 이것을 통해 메모리 절약, 프로그램 크기 감소등을 달성 할 수 있다.

 

7.1 : 컴파일러 드라이버

어떳개 쓰는거임?

gcc -Og -o prog main.c sum.c

이런 코드를 리눅스에서 작동시켰다고 하자. 그럼 gcc가 실행 가능한 파일을 main.c 와 sum.c 를 합쳐 prog이라는 결과를 전달해준다.

이 과정을 보자.

  • 전처리기 (cpp)
    • main.c와 sum.c에서 #include 된 파일을 삽입하고, 매크로 등을 처리한다.
    • 이 전처리기를 거치면 결과는 main.i 와 sum.i 가 남는다.
  • 컴파일러 (cc1)
    • .i 파일들을 읽어서 C 코드들을 어셈블리어로 번역한다.
    • .s 확장자로 변환된다.
  • 어셈블러 (as)
    • 어셈블러 파일을 기계어로 변환한다.
    • .o 확장자로 변환된다.
  • 링커 (ld)
    • .o 파일들을 결합하여 실행 가능한 파일을 생성한다. 여기서 prog이 만들어진다.
  • gcc를 통해 이 네 가지가 호출되는 것은 gcc가 각각의 도구를 내부적으로 호출해주는 드라이버일 뿐이기 때문이다.
  • 우리가 직접 cpp - as - ld를 쓸 수 있지만 gcc가 매크로처럼 뚝딱뚝딱 일련의 결과를 만드는 것. 진짜진짜로, .s 확장자로 손수 할게 없다. 그러니 그냥 실행가능한 파일까지 변환하는 것.
    • 링커라는 것은 각각 독립적인 컴파일 단계에서의 마지막 단계인 것이다.

7.2 : 정적 링킹

방금 일련의 절차에서 확인 할 수 있었던 리눅스의 ld 라는 프로그램은 사실 정적 링커 프로그램이다.

  • 이 프로그램은 재배치 가능한 객체 파일들, 즉 .o 파일에 명령행 인자들을 받는다.
  • 그리고 출력으로 완전히 링킹된 실행 파일, 쉽게 말해 .exe를 생성한다. 이 실행 파일은 메모리에 로드, 실행이 가능한 형태이다.

왜 정적이라고 부릅니까?

해야 할 것들, 함수 주소 계산, 전역 변수 위치 결정, 라이브러리 연결을 컴파일하는 동안 다 끝내기 때문이다. 그래서 static 이다.

.o엔 들어있는게 생각보다 다양해요

  • .o 파일에는 여러 종류의 코드 섹션과 데이터 섹션으로 이루어져 있는데, 각 섹션은 연속된 바이트들의 묶음이다.
    • 기계어 명령어 (실행 코드)는 한 섹션, 보통 .text 섹션에 담긴다
    • 초기화된 전역 변수는 다른 섹션에 담긴다.
    • 초기화되지 않은 전역 변수는 또 다른 섹션에 담긴다.
  • 즉, 객체 파일은 그냥 기계어 한 덩어리가 아니라 섹션 단위로 정보를 구분하게 된다. 이 구조는 링커가 정확한 메모리 레이아웃으로 결합하는데 필요하다.

.exe를 만들기 전 링커는 두 가지의 일을 하게 된다 :

  • Symbol Resolution
    • 변수나 함수에 대한 지역변수 여부, 더 나아가 static 여부 등을 확인해야한다.
  • Relocation
    • 컴파일러와 어셈블러는 코드를 만드는 동시에, 데이터 섹션은 0번 주소부터 시작한다. 링커는 각 Symbol의 정의에 따라 이 섹션들을 배치하게 된다. 그리고 Symbol에 대한 모든 참조를 링커가 배치하고 난뒤의 실제 배치된 곳으로 가리키도록 변경해준다. 즉 재기입이라고 하는게 제일 의도에 가까운 번역일듯.
    • 심볼이 뭘 말하는지 디테일하게 알지 않아도 기계적으로 relocation 이 수행된다. 이 작업은 어셈블러가 만들어놓은 relocation entry를 따라 수행된다. 즉, 바꿔줘야 할 목록을 어셈블러가 만들어서 쥐어준다는 것으로 보자.
  • 링커 입장에서 파일은 그냥 바이트들의 연속이다. 뭐 컴퓨터라는게 그렇지?

7.3 : 오브젝트 파일들

오브젝트 파일은 세 종류가 있다 :

이름 내용
Relocatable Object File 바이너리 코드와 데이터를 다른 Relocatable과 컴파일 중에 합칠 수 있는 형태로 있으며,
그 자체로는 실행 할 수 없고 Executable Object File로 변환 된다.
Executable Object File 바이너리 코드와 데이터가 메모리에 올라 갈 수 있고 실행 될 수 있는 형태로 구성되어 있다.
Shared Object File 별도로 불러오거나 실제 실행중에도 불러 올 수 있는 신기하게 생긴 Relocatable Object File이다. 이것을 보통 동적 라이브러리라고 부른다.

컴파일러와 어셈블러는 Relocatable 을 동적 라이브러리를 포함하여 만들어내고, 링커는 실행 가능한 .exe를 만들어낸다.

Object File들은 특정한 포맷을 통해 결합되는데, 시바시이다.

Unix에서는 a.out을 썼었다. (요즘도 이건 쓴다) 윈도우에서는 Portable Executable (PE)를 썼다. 맥에서는 Mach-O 포맷을 사용한다. 요즘 리눅스와 유닉스는  ELF (Executable and Linkable Format) 이라는 이름으로 사용하고 있다. 이 책도 나도 ELF를 주로 논할예정.

ELF 형식의 .o 파일은 이렇게 생겼다.

ELF 헤더 (여기가 파일 시작점)
.text
.rodata
.data
.bss
.symtab
.rel.text
.rel.data
.debug
.line
.strtab
Section header table

7.4 재배치 가능한 목적 파일 (그냥 .o 에 다룬다고 생각하자)

지금 위에서 본 건 전형적인 ELF 형식의 재배치 가능한 목적 파일이다. 

제목 역할
ELF Header 처음 16바이트는 워드 크기(32/64비트), 바이트 순서(little/big) 등의 정보를 기술한다.
남은 내용은 파일의 해석을 위한 배경지식으로 메타데이터를 제공한다. 그냥 아래 내용들의 종합적인 정보 요약인 것.
.text 컴파일 된 프로그램의 기계어 코드를 담고 있다.
.rodata read only data를 줄였다. printf 문의 형식 문자열, switch문의 점프 테이블 같은게 담겨있다.
.data 초기화가 끝난 전역 및 정적 C 변수가 담겨 있다.
.bss 초기화 되지 않은 전역 및 정적 C 변수, 0으로 초기화 된 전역 또는 정적 변수가 있다. 파일 내에서 실제 공간을ㅊ ㅏ지 않다가, 런타임 때 0으로 초기화 된다.
.symtab 정의되고, 참조되는 함수 및 전역 변수에 대한 정보가 담긴 심볼 테이블이다.
.rel.text .text 섹션 내에서 링커가 다른 객체 파일과 결합 될 때 수정해야 할 위치 목록이다.
.rel.data 모듈에서 참조하거나 정의하는 전역 변수에 대한 재배치 정보를 담고 있다.
.debug 프로그램의 지역 변수, typedef, 정의 및 참조된 전역 변수, 원본 C 소스 파일에 대한 디버깅 심볼 테이블이 있다.
gcc -g 에만 해당
.line 원본 C 소스 파일의 줄 번호와 .text의 기계어와의 매핑 하기 위한 내용이다. gcc -g 에만 해당
.strtab .symtab 및 .debug 섹션의 심볼 테이블과 섹션 헤더의 섹션 이름에 사용 될 문자열 테이블이 있다.

7.5 심볼과 심볼 테이블

재배치가 가능한 각 모듈을 m이라고 하자. 각 m에는 심볼 테이블이 있는데,
심볼 테이블에는 m에 의해 참조나 정의되는 심볼에 대한 것들이 포함되어있다. 링커는 심볼을 세 가지로 구분하게 된다 :

  • Global symbols : 정의된 전역 심볼, m에 의해 직접 정의된 심볼이며, 다른 모듈에서도 참조 할 수 있다.
    • 전역 변수나, non-ststic 함수에 이에 해당한다.
    • 다른 파일에서 연결이 가능하다.
  • External symbols : 외부 전역 심볼, m이 사용하기는 하나, 정의 자체는 다른 모듈에서 일어난 심볼이다.
    • sum은  main.c에선 들어있지 않지만 다른 .o 파일에서 정의 될 것으로 기대하며 링커가 찾아준다.
// main.c 내용
extern int sum(int a, int b);
  • Local symbols : 지역 심볼, 오직 모듈 m 안에서만 정의되고 참조되는 심볼이다.
    • static 키워드를 붙인 함수나 전역 변수가 해당되며. 링커는 이것을 외불로 노출 시키지 않는다.
    • 다른 모듈은 이 이름 자체를 볼 수도 없다.

지역 링커 심볼들과 우리가 흔하게 생각하는 지역 변수가 같지 않다는 것을 아는게 좀 중요하다.

  • .symtab에 있는 symbol table에는 함수 내에서 선언된 non-static 지역 변수는 포함되지 않는다.
  • 이런 애들은 스택에서 관리되며, 링커들은 이들에 관심을 두지 않는다.

재밌는 것은, 함수 내에서 선언된 static 들은 스택 기반 관리를 받지 않는다.
대신에 .data나 .bss에 배정하고 심볼 테이블에 이름이 같아도 중복이 가능하도록 조금 다른 이름으로 올라가게 된다 :

void f() {
    static int x = 0;
}

void g() {
    static int x = 0;
}

둘은 서로 다른 변수이니 정적인 공간에 x.1 | x.2 같은 식으로 구분되어 고유하게 저장이 이루어진다.

심볼 테이블은 어셈블러에 의해 만들어진다고 했다. 어셈블러는 이를 만들면서 컴파일러가 어셈블리 파일로 넘겨준 심볼들을 활용한다.
ELF 기준으로는 .symtab에 포함되어 있으며, 이 테이블은 각 심볼을 나타내는 항목들의 배열들로 구성되어있다.

typedef struct {
int name; /* String table offset */
char type:4, /* Function or data (4 bits) */
binding:4; /* Local or global (4 bits) */
char reserved; /* Unused */
short section; /* Section header index */
long value; /* Section offset or absolute address */
long size; /* Object size in bytes */
} Elf64_Symbol;
이름 내용
name 문자열 테이블 안에서 심볼 이름 문자열이 저장된 위치를 나타낸다.
value 심볼의 주소를 가리킨다. 재배치 가능한 오브젝트 파일인 경우 섹션 시작점으로부터의 오프셋이 담긴다.
실행 가능한 오브젝트 파일의 경우, 절대 런타임 주소를 담는다.
size 그 심볼이 나타내는 변수나 함수의 바이트 단위 크기가 담긴다
type 이 심볼이 변수인지 함수인지에 대한 종류를 담는다.
binding 로컬, 글로벌 여부를 본다.

각 심볼들은 어떤 섹션에 속해있다고 지정이 된다. 위에서 언급한 내용은 섹션 필드에 저장되고, 섹션 헤더 테이블의 인덱스로 표현된다.

하지만, 세 가지 가상 섹션이 있는데, 실제 섹션 헤더 테이블에는 엔트리가 존재하지 않는다.

  • ABS : 절대 주소를 가졌기에, 재배치 되어선 안되는 심볼이다.
  • UNDEF : 정의되지 않은 심볼. 이 모듈은 참조만 하고, 정의는 다른 곳에서 이루어진 경우이다.
  • COMMON : 아직 할당되지 않은 초기화되지 않은 전역 변수들이다.

얘네들은 재배치 가능한 오브젝트 파일에만 존재하며, executable object file에는 없다.

COMMON이 앞에서 언급했던 .bss와 역할이 다소 비슷하다고 여길 수 있지만..

항목 .bss COMMON
정의 방식 int x; static int y; 등 명확한 정의가 있다. 딱히 명시적이진 않다.
중복 허용 중복 허용 시 컴파일 통과 불가 여러 모듈에서 정의 되어도 링커가 하나로 병합한다.
위치 실제 .bss 섹션에 배치된다. 초기엔 정해져 있지 않고, 링커가 .bss에 놓거나 무시한다.

7.6 Symbol Resolution

링커는 각 심볼 참조를 해당 심볼 정의와 연결함으로써 해결한다.
여기서 말하는 정의는, 링커가 입력으로 받는 relocatable object files의 심볼 테이블에서 확인 할 수 있다.

  • 같은 모듈 내에서 정의된 로컬 심볼에 대한 해결은 간단히 해결된다.
    • 컴파일러는 각 모듈안에서 로컬 심볼은 하나만 정의하도록 되어있다. local symbol per module
    • 정적 지역 변수는 로컬 링커 심볼로 처리되며, 컴파일러가 고유한 이름을 부여하게 된다.
  • 하지만 이와 해당이 없는 전역 심볼은 좀 복잡하다. 컴파일러가 현재 모듈에 정의되지 않은 심볼(변수 내지 함수)를 만나면, 다른 모듈에 있을거라고 가정을 하고 심볼 테이블에 기록만 남겨두고 링커가 해결하도록 남겨둔다. 만약 링커가 그 심볼의 정의를 찾지 못하면 난해한 에러 메시지를 출력하고, 작업을 종료한다.

undefined reference to ‘function’

전역 심볼에 대한 Symbol resolution이 까다로운 편이다. 여러 개의 오브젝트 모듈이 같은 이름의 전역 심볼을 정의하고 있을 수 있기 때문이다. 링커 입장에서는 에러를 내고 펑 하던가, 하나의 정의만 선택하고 나머지는 버리는 방식으로 처리한다. 리눅스 시스템이라면 컴파일러-어셈블러-링커 간 상호작용을 통해 문제를 처리한다.

  • 리눅스에서는 하나를 선택하고 나머지를 무시하는 형태를 사용하나, 이게 정확히 뭐가 문제인지 모르게해서 디버깅을 어렵게 만드는 주 요인이 되기도 한다.
    • 가능한 전역변수는 static으로 제한하던가, 헤더에서 extern 선언만 하고, 정의는 단 한 개의 c 소스 파일에서만 이뤄지게끔 해야 이 문제를 피할 수 있다.

7.6.1 링커 입장에서 같은 이름의 심볼 이름을 구분하는 방법

  • 링커가 시작되는데에는 최소 1개, 일반적으론 여러 개의 relocatable object file을 사용한다. 각 오브젝트 파일에는 심볼이 정의되고, 그 중 일부는 로컬 심볼 그리고 일부는 글로벌 심볼이다. 하지만 문제는, 여러 모듈에서 같은 이름의 전역 심볼을 정의했을 경우이다.
    • → 이 때 링커는 심볼의 강도 정보를 기반으로 충돌을 해결한다.
    • 강도가 강한 경우는 명확히 정의 된 함수, 초기화 된 전역 변수가 해당된다.
    • 강도가 약한 경우는 초기화 되지 않은 전역 변수를 말한다.
    • 컴파일러는 어셈블러에게 강도 정포를 포함하여 전달하게 되고, 어셈블러는 이를 오브젝트 파일의 심볼 테이블에 암시적으로 기입한다.
  • 리눅스 링커의 처리 규칙, 모든 심볼의 이름이 같다고 전제한다.
    • 강한 심볼이 여러 개 존재하면 진행 불가하다.
    • 여러 개 중 단 한 개만 강한 심볼인경우 해당 심볼이 선택된다.
    • 모두 약한 심볼이라면, 그 중 아무거나이다.

재미있는 예제가 많다. 연속된 int 2개 중에 주소가 빠른 쪽에 double로 찍어누르기 하면 대 참사가 난다.
둘은 아무 상관없지만 주소가 같다는 이유로 값이 우후죽순 바뀐다.

이 버그는 링커가 같은 이름의 전역 심볼을 여러 개 발견 한 경우, 경고만 내고 그 중 하나만 선택하는 상황에서 발생한다. 문제는 이게 경고에 그치고 만다는 것이다. 실제로는 에러가 알려준 것과 완전 떨어져 있는 위치에서 대참사가 벌어 질 수도 있는게 위험한 것이다.

  • 왜 이러냐?
    • C에서 초기화되지 않은 전역 변수는 COMMON 영역에 들어가고, 이러면 weak symbol로 간주하고 있다. 여러 모듈에서 동일한 이름의 초기화 되지 않은 전역 변수를 정의해도, 링커는 그 중 하나만 선택한다. 여기서 안좋은 일이 일어나는 것
      • -fno-common 옵션 컴파일은 중복 심볼을 오류로 띄우기 때문에 최종 컴파일 전 차단 할 수 있다.
      • -Werror 모든 경고를 오류로 변경한다.

 

  • .bss 와 COMMON 을 구분하는 이유
    • 컴파일러는 변수 x가 다른 모듈에도 정의되어 있는지 알 수 없다. 그렇기 때문에 초기화되지 않은 전역 변수는 일단 COMMON 영역에 배치한다.
    • 그러나 int x = 0; 처럼 0으로 초기화 된 경우에는 명백하게 강한 심볼이므로 .bss에 직접 배치 할 수 있다.
    • 또한, static 변수는 모듈 내부에서만 유효하므로 중복 될 일도 없고, 컴파일러는 자신 있게 .data 또는 .bss에 넣는다.
구분 Symbol 종류 섹션 배치 설명
int x; (초기화가 없다) weak COMMON 링커가 충돌 시 선택한다
int x = 0; (0으로 초기화 한다) strong .bss 중복되면 오류가 발생한다
static int x; local .bss 충돌 가능성은 없다.

 

7.6.2 정적 라이브러리들과 함께 링킹하는 경우

현재까지는 여러 개의 .o 를 링커가 읽고 하나의 실행 파일로 만든다고 했다. 실제로는, 연관된 오브젝트 파일들을 하나로 묶어서 정적 라이브러리 파일 형식인 .a로 패키징한다. 링커는 이 .a 파일을 확인하고 필요한 모듈만 선택하여 실행 파일에 포함시키게 된다.

  • 왜 이렇게 할까?
    • C standard에는 printf 부터 atoi, strcpy, rand가 있고 수학 라이브러리에는 sin, cos, sqrt 같은 함수들도 들어있다.
      이 함수들이 사용 가능한 이유는 링커가 .a 파일을 참조해서 필요한 함수의 코드만 끌어오기 때문이다.
  • 정적 라이브러리의 장점
    • 여러 관련 기능들을 한 파일로 묶어서 관리 할 수 있다.
    • 필요 할 때만 함수 코드를 실행 파일에 포함하므로, 공간 낭비를 줄일 수 있다.
      .a 를 불러오긴 하지만, 그 중에 실제 써야하는 것들만 포함시킨다고 한다.
    • 효율적인 코드 재사용을 가능하게 한다.

근데 정적 라이브러리를 안쓰고 함수를 제공하는 접근 방식도 생각해보자.

  • 컴파일러가 표준 함수 호출을 인식하고 이를 직접 코드로 변환하는 방식이다.
  • C가 아닌 Pascal은 작은 범위의 표준 함수들만 제공하므로 이러한 방식을 취할 수 있지만, C는 할 수 없다.
    • C 표준에서 정의한 표준 함수들이 너무 많기 때문이다. 이 방식은 컴파일러에 큰 복잡성을 추가하여, 함수가 추가/삭제/수정 될 때마다 컴파일러 버전이 새로 필요하다. 그러나 이 덕분에 application programmers에게는 표준 함수들이 항상 사용하니 꽤 편리한 환경이라고 생각 할 수도 있다.

다른 방식으로, 표준 C 함수를 하나의 재배치 가능한 오브젝트 모듈,
예를 들어 libc.o에 넣고, application programmers가 이를 실행 파일에 링크 할 수 있게 하는 것이다.

linux> gcc main.c /usr/lib/libc.o

이런식으로 하면, 표준 합수들의 구현을 컴파일러의 구현과 분리 할 수 있고, 여전히 꽤 합리적으로 편하다는 점이다.

여기서 와닿는 큰 단점은 :

  • 모든 실행 파일에 표준 함수들이 전부 들어가니 디스크 공간을 낭비 할 여지가 있다.
  • 함수들의 복사본이 쓰지도 않는데 메모리에 올라가니, 메모리 낭비 여지가 더 커진다는 것이다.
  • 표준 함수에서 변경이 생기는 경우 다시 컴파일 해야하는데, 시간도 많이 걸리고 표준 함수에 대한 유지 보수가 복잡해진다는 점이다.

여기들 중 몇몇은 각 표준 함수에 대해 따로 relocatable file을 만들고 이를 잘 알려진 파일 경로에 저장하여 해결 할 수 있다.
하지만 이러한 접근은 프로그래머들이 명시적으로 하나하나 적어줘야하니, 휴먼 에러의 발생 여지가 높아진다.

linux> gcc main.c /usr/lib/printf.o /usr/lib/scanf.o...

정적 라이브러리의 존재 의의는 앞에서 이야기한 이런 개판 5분 전을 막기 위해서이다. 관련 함수들은 자동으로 별개의 오브젝트 모듈로 컴파일 된후 하나의 정적 라이브러리 파일로 묶인다. 이후 프로그램은 라이브러리에 정의된 함수를 사용하기 위해 명령줄에서 단일 파일 이름을 지정하면 된다.

linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a

링크가 진행되는 동안 링커는 프로그램에 의해 참조된 모듈들만 복사하므로 실행 파일의 크기를 디스크와 메모리 양쪽에서 모두 줄일 수 있다. 프로그래머는 소스 코드 작성에서 몇 개의 라이브러리 이름만 포함해두면 된다.
리눅스에서의 정적 라이브러리는 아카이브라는 특정 파일 형식으로 디스크에 저장이 이루어진다.

  • 아카이브는 연결된 relocatable object files 이며, 각 멤버 오브젝트 파일의 크기와 위치를 설명하는 헤더가 포함된다.
    아카이브 파일의 이름은 .a 확장자로 표시된다. 라이브러리에 대한 논의를 구체적으로 설명하기 위해, 위에 있는 코드를 보자.
    • 각 함수는 자체 오브젝트 모듈에 정의되어 있으며, 두 개의 입력 벡터에 대한 벡터 연산을 수행하고 그 결과를 출력 벡터에 저장한다. 그리고 다른 쪽에서는 각 함수는 함수 밖에 있는 변수의 내용을 증가시켜 호출 횟수를 기록 할 것이다.
    • 이 함수들의 정적 라이브러리를 만들기 위해 ar 을 사용하겠다.

linux> gcc -c addvec.c multvec.c
linux> ar rcs libvector.a addvec.o multvec.o

이 라이브러리 사용을 위해서는 아래 그림에 위치한 main2.c 와 같은 코드를 작성 할 수 있으며, 여기서는 addvec 함수를 호출하게 된다.

vector.h라는 헤더 파일은 libvector.a에 있는 함수들의 프로토타입을 정의한다.
실행 파일을 만들기 위해 우리는 main2.o 와 libvector.a를 컴파일하고 링크 하면 된다.

linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o ./libvector.a

또는 동일한 코드로 이렇게 쓸 수도 있다 :

linux> gcc -c main2.c
linux> gcc -static -o prog2c main2.o -L. -lvector

이 그림은 링커의 동작을 요약한 것이다.

  • -static 인자는 컴파일러 드라이버에게 로드 타임에 추가적인 링크 없이 메모리에 적재 후 바로 실행 가능한 완전하게 링크된 실행 파일을 생성하라는 의미이다.
  • -lvector는 libvector.a의 축약형이며, -L. 은 링커에게 현재 파일 경로에서 libvector.a를 찾으라고 지시한다.

링커가 실행되면, addvec.o에 정의된 addvec 심볼이 main2.o에서 참조된다는 사실을 감지하고, addvec.o를 실행 파일에 복사한다.
반면 프로그램에서 multivec.o에 정의된 어떤 심볼도 조작하지 않기 때문에, multvec.o는 실행 파일에 포함되지 않는다. 링커는 libc.a에서 printf.o 모듈도 포함한다.

 

7.6.3 링커들이 참조 resolve를 위해 정적 라이브러리를 이용하는 방법

정적 라이브러리는 유용하지만, Linux 링커가 외부 참조를 해결하는 방식 때문에 프로그래머에게 혼란을 주는 경우도 있다.
심볼 해결 단계에서 링커는 컴파일러 드라이버 명령줄에 지정된 순서대로 relocatable object files와 아카이브(.a) 파일을 왼쪽부터 오른쪽으로 차례대로 스캔한다. 이 과정에서 링커는 아래 세 가지 집합을 유지하게 된다.

  • E : 최종 실행 파일을 구성하게 될 Relocatable Object Files 의 집합
  • U : 정의되지 않고 참조만 찍혀있어 해결이 필요한 심볼들의 집합
  • D : 이전 입력 파일에서 정의된 심볼들의 집합
  • 초기에는 3 가지 모두 비어있다.

명령줄의 각 입력 파일 f에 대해 링커는 f가 오브젝트 파일인지 아카이브인지 판단한다:

  • f 가 오브젝트 파일이라면, f를 E에 추가하고, f의 심볼 정의 및 참조를 반영해 U, D를 업데이트하고, 다음 파일로
  • f가 아카이브 파일이면,
    • 링커는 U에 있는 아직 해결되지 않은 심볼들을, 아카이브의 멤버 오브젝트 파일들이 정의한 심볼들과 매칭시킨다.
    • 어 근데 여기 조금 궁금한게.. 그럼 전체순회를 하는건가?
    • 어떤 아카이브 멤버 m이 U의 심볼 하나에 대한 정의를 해서 해결이 가능하다면,
      m을 E에 추가하고,m이 정의하거나 참조한 심볼에 따라 U와 D를 다시 업데이트한다.
    • 이 작업은 U와 D가 더 이상 바뀌지 않을 때까지 반복된다
    • 이때까지 E에 포함되지 않은 아카이브 멤버들은 버려지고, 링커는 다음 파일로 넘어간다.

링커가 모든 입력 파일을 스캔하고도 U가 비어 있지 않으면, 오류 메시지를 출력하고 종료된다.
반대로 U가 비어 있다면, 링커는 E의 오브젝트 파일들을 병합하고 relocate하여 실행 파일을 생성한다.

다만 이게 마냥 좋다고는 할 수 없는 것이,

  • 링커의 입력 파일 순서가 중요하다.
  • 어떤 심볼을 정의한 라이브러리가 실제 참조하는 오브젝트 파일보다 먼저 등장하면, 링커는 해당 심볼이 필요함을 인지하지 못하고 그냥 지나친다. 따라서 심볼을 해결하지 못하고 오류가 발생한다.

linux> gcc -static ./libvector.a main2.c
/tmp/cc9XH6Rp.o: In function ‘main’:
/tmp/cc9XH6Rp.o(.text+0x18): undefined reference to ‘addvec’

뭔 일이 일어났게?

  • libvector.a 가 처리될 때, U는 비어있다. 따라서 libvector.a의 어떤 멤버 오브젝트 파일도 E에 추가되지 않는다.
  • 그래서 addvec에 대한 참조가 해결되지 않아서 링커는 오류 메시지를 출력하고 종료된다.

라이브러리와 관련된 일반 규칙이 몇 가지 있다 :

  • 라이브러리는 명령 줄에서 항상 마지막에 두도록 한다.
    • 만약 라이브러리의 멤버들이 서로 독립적이라면, 명령줄에서 아무 순서로 배치해도 괜찮다.
    • 독립적이지 않다면, 각 심볼 s가 아카이브의 멤버에서 외부에서 참조되는 경우, s의 정의가 참조 뒤에 와야한다.

예를 들어, foo.c가 libx.a와 libz.a의 함수들을 호출하고, libz.a의 함수가 liby.a의 함수를 호출한다면, libx.a와 libz.a는 liby.a보다 앞에 위치해야 한다. linux> gcc foo.c libx.a libz.a liby.a

 

라이브러리를 명령줄에 반복해서 포함 할 수 있다. 필요하다면 라이브러리를 반복해서 작성 할 수도 있다.

  • 예를 들어 foo.c가 libx.a 의 함수 하나를 호출하고, 그 함수가 liby.a의 함수를 호출하고, 그 후 libx.a의 함수를 호출하는 경우, libx.a는 명령 줄에 두 번 포함되어야 한다.

linux> gcc foo.c libx.a liby.a libx.a

또는 libx.a와 liby.a를 하나의 아카이브로 결합하는 경우도 가능하다.

7. 7 Relocation : 재배치

링커는 컴파일 된 여러 오브젝트 파일들을 받아서 하나의 실행 파일로 만들기 위해 Symbol resolution과 relocation을 수행하는데, 그 중 relocation에 대해 설명하는 파트이다.

  • 두 단계로 요약 할 수 있다 :
    • 섹션과 심볼 정의 재배치
      • 동일한 종류의 섹션들을 하나로 합친다.
      • 각기 다른 이름의 목적 파일 .o의 .data → 실행 파일의 .data로 병합된다.
      • 그리고 각 섹션, 심볼에 대해 실제 실행 시점의 메모리 주소(run-time address)를 할당한다.
        이 과정이 끝나면 모든 명령어와 전역 변수가 고유한 실제 주소를 갖게 된다.
      • 이 단계는 누가 어디에 있을지를 정하는 과정에 해당 된다.
    • 섹션 내 심볼 참조 재배치
      • 이제 실제 메모리 주소를 알고있다. 그래서 코드와 데이터 안에 있는 심볼 참조를 해당 심볼의 실제 주소로 수정한다.
        이 작업은 .o 파일 내부에 있는 재배치 엔트리를 바탕으로 수행된다.
      • 이 엔트리는 “여기 심볼 참조가 존재하니, 나중에 주소를 채워 넣어라”는 정보이다.
      • 이 단계는 정해진 주소로 코드 안의 참조를 고쳐주는 과정이다.
// a.c
int x = 42;

// b.c
extern int x;
int main() { return x; }
  1. a.o와 b.o를 따로 컴파일하면 :
    1. x라는 전역 변수 정의와 참조가 분리되어 있다.
    2. b.o에는 “x가 어디에 있는지 모르지만 참조는 있음” 이라는 재배치 엔트리가 존재한다.
  1. 링크 시 :
    1. x의 주소가 a.o에서 정의된 위치로 고정된다.
    2. b.o의 심볼 참조는 해당 주소로 수정이 이루어진다.

7.7.1 재배치 엔트리들 : Relocation Entries

재배치 엔트리가 왜 필요하냐?

  • 어셈블러는 .c → .o 파일로 바꾸는 과정에서..
    • 코드와 데이터가 실행 파일에서 어디에 위치할지 모른다.
    • 외부 심볼(extern 함수, 전역 변수 등)이 어디에 있는지도 모른다.
  • 따라서, 이처럼 주소가 확정되지 않은 참조를 만날 때마다:
    • “여기 나중에 주소를 채워야 한다” 라는 내용을 재배치 엔트리로 기록한다.
    • 링커는 이 정보를 바탕으로 링크 시점에 정확한 정보를 채워 넣게 된다.

이 그림의 내용인 ELF 재배치 엔트리를 보자. 리눅스 고유 타입인 ELF 포맷에서 재배치 정보는 .rel.text나 .rel.data 섹션에 저장된다.
하나의 엔트리는 다음과 같은 필드를 가지게 된다.

  • offset : 참조가 위치한 섹션 안의 오프셋이다. 수정해야 할 주소 위치를 담는다.
  • symbol : 어떤 심볼을 참조하고 있는지, (printf, var x) 등.
  • type : 어떤 방식으로 참조를 수정 할지를 지정한다. (절대 주소 / 상대적?)
  • addend : 어떤 타입에서는 여기에 상수 값을 더해준다. (바이어스 또는 상수 덧셈 용이다)
    • 얘 용도 똑바로 알아보면 도움 될 것 같다.

중요한 재배치 타입 두 가지를 같이 보자.

  • R_X86_64_PC32 : 32비트 PC 상대 주소
    • 현재 명령어 기준으로 상대적인 오프셋(offset)을 저장한다.
    • 대표적으로 call, jmp 등의 명령어가 사용 된다.
    • call printf → 실제로는 “다음 명령어 기준 + 얼마 만큼 점프” 형태로 표현한다.

  • R_X86_64_32 : 32비트 절대 주소
    • 명령어나 데이터에서 정확한 메모리 주소를 저장해야 할 때 사용한다.
    • CPU는 이 값을 바로 주소로 해석한다.
    • 주로 전역 변수 참조 등에 사용된다.
  • small code model 관련
    • 위 두 타입은 x86-64의 Small code model을 위함이다.
    • 이 유형은 전체 코드와 데이터 크기를 합쳐 2GB 미만이라고 가정한다.
    • 그래서 32비트 주소 만으로도 충분한 프로그램 사용이 가능하다.
    • 기본적으로 gcc는 이 모델을 사용하며, 만약 2GB를 초과하면 옵션 명령어 작성이 필요하다. -mcmodel=medium 또는 -mcmodel=large

7.7.2 재배치되는 심볼 참조들

링커가 수행하는 재배치 알고리즘의 의사코드를 볼 수 있다. 이것을 줄 단위로 설명하겠다.

  • 1-2행 : 모든 섹션 s에 대해, 해당 섹션에 속한 모든 재배치 엔트리 r를 반복하겠다.
    • .text 섹션의 .rel.text에 있는 모든 r을 대상으로 반복한다.
    • s는 바이트 배열이다. (코드나 데이터)
    • r는 Elf64_Rela 구조체이다 (앞서 본 offset, symbol, type, addend등 포함)
  • 런타임 주소 가정이 필요하다. 알고리즘 실행 시점에서는 :
    • 각 섹션 s의 런타임 주소 → ADDR(s)
    • 각 심볼의 런타임 주소 → ADDR(r.symbol)
    • 이들은 이미 Symbol resolution과 섹션 재배치 단계에서 정해진 상태이다.
  • 3행 : 수정 대상 참조 위치 계산
    • ref = &s[r.offset]
    • r.offset : 섹션 s 내에서 주소를 수정해야 할 위치이다.
    • 따라서 ref는 실제 수정할 4바이트 참조의 시작 주소를 가리킨다.
  • R_X86_64_PC32 인경우 5-9행을 실행한다.
    • *ref = ADDR(r.symbol) + r.addend - (ADDR(s) + r.offset + 4)
    • CPU는 현재 PC 기준으로 상대 오프셋을 계산하므로, 여기서는 :
      • 심볼 주소 + addend - (이 명령어의 다음 주소)
      • +4는 x86에서 명령어 자체가 4byte 참조 값을 포함하기 때문에 추가된다.
        (즉, PC는 다음 명령어에 위치한다)
      • 여기는 PC-relative, 심볼까지의 거리는 심볼 주소 - (현재 위치 + 4)로 요약
  • R_X86_64_32 인 경우 11-13행을 실행한다.
    • *ref = ADDR(r.symbol) + r.addend
    • 참조 위치에 심볼의 정확한 주소 값을 그대로 쓴다.
    • CPU는 이 값을 그대로 주소로 사용한다.
    • 참조 위치에 심볼의 절대 주소를 기록한다.

다시 간단하게, 알고리즘은 모든 섹션과 엔트리를 순회하여 이 작업을 수행한다.

재배치 예제로 main 함수에서 sum 함수를 호출 한 예시를 보자.

  • 상황 개요, main.o 에는 전역 심볼을 두 개 참조하고 있다 :
    • array → 32비트 절대 주소 | sum → 32비트 PC-relative 주소
    • 여기서는 sum 함수 호출에 대한 PC-relative 재배치를 다룬다.

어셈블리 및 재배치 엔트리에 대해서도 고려해야한다 :

  • 어셈블리 코드 일부를 보자 : 4004de: e8 ?? ?? ?? ?? call sum
  • e8은 call 명령어의 opcode이며, 그 뒤 4바이트가 sum 함수까지의 상대 주소가 들어간다.

재배치 엔트리 r은 어떻게 되느냐?

r.offset = 0xf
r.symbol = sum
r.type = R_X86_64_PC32
r.addend = -4
  • offset 0xf : 수정 할 참조 값의 위치 (즉, e8 바로 뒤 4바이트)
  • addend = -4 는 GCC가 내부적으로 보정하기 위해 삽입한 값으로, 의도적으로 4를 더해줘야 정확한 오프셋이 나올 수 있다.

링크 시점에서의 주소를 설정하자.

  • ADDR(s) = 0x4004d0 // .text 섹션의 시작 주소
  • ADDR(sum) = 0x4004e8 // sum 함수의 시작 주소

그럼 이제 Figure 7.10에 있는 알고리즘을 적용해서 계속 해보자.

  1. 참조 위치를 계산하자.
    • refaddr = ADDR(s) + r.offset = 0x4004d0 + 0xf = 0x4004df
  1. PC-relative 참조 값을 계산한다.
    • *refptr = (unsigned)(ADDR(num) + r.addend - refaddr)
      • = (unsigned)(0x4004e8 - 4 - 0x4004df)
      • = (unsigned)(0x5)

결과로, 실행 파일에 삽입된 call 명령어를 확인 할 수 있다.
4004de: e8 05 00 00 00 callq 4004e8 <sum>

  • 0x05 : 현재 명령어 다음 위치(0x4004e3)에서 sum(0x4004e8)까지의 거리이다.

이 때 CPU가 하는 일

  1. 명령어 포인터 (PC)는 call 직후 주소, 즉 0x4004e3.
  2. call은 다음을 수행하게 된다 :
push 0x4004e3          // return address 저장
jump to 0x4004e3 + 0x5 = 0x4004e8  // sum 함수 시작 위치

→ 이렇게 함으로써 우리가 원하는 sum() 의 호출이 이루어진다.

앞에서 본 PC-relative에 이어 array과 연관딘 Absolute를 보자.

  • 절대 주소 재배치 예시로 array 참조를 해보자.

 

  • 상황 개요
    • main 함수는 전역 배열 array의 주소를 레지스터 %edi에 넣는다.
    • 해당 명령어는 절대 주소를 immediate 값으로 사용한다.
  • 어셈블리 코드 및 재배치 정보
    • 4004d9: bf 18 10 60 00 mov $0x601018, %edi ; %edi = &array
    • 0xbf: mov imm32, %edi의 opcode
    • 그 뒤의 4바이트는 array의 주소가 들어갈 자리
  • 재배치 엔트리 r:
r.offset = 0x
r.symbol = array
r.type = R_X86_64_32
r.addend = 0
  • offset 0xa : 해당 참조가 위치한 곳 (0xbf 다음의 4바이트)
  • addend = 0 : 절대 주소는 addend 없이 그대로 사용
  • 링커가 알고 있는 심볼 주소
    • ADDR(array) = 0x601018
    • .data 섹션 내 array는 이 주소에 위치한다. (7.12 (b)를 확인)
  • 알고리즘을 적용한다. (7.10 Line 13)
*refptr = (unsigned)(ADDR(r.symbol) + r.addend)
			= (unsigned)(0x601018 + 0)
			= 0x601018
  • 최종 결과 4004d9: bf 18 10 60 00 mov $0x601018, %edi
  • 이 명령은 array의 주소를 %edi에 그대로 넣어주는 동작을 수행한다.

 

  • 실행 시 동작
  • 런타임에서, 이 명령은 다음과 같이 동작한다 :
    • %edi <- 0x601018 ; array의 시작 주소
  • 이후 %edi 는 sum 함수에서 인덱싱에 사용된다 : (%rdi, %rcx, 4) → array[rcx]
  • 최종 실행 파일의 섹션 구성은 이렇게 된다
    • .text
      • 4004d9: bf 18 10 60 00 mov $0x601018, %edi
      • 4004de: e8 05 00 00 00 callq 4004e8 <sum>
    • .data
      • 601018: 01 00 00 00 02 00 00 00 ; array = {1, 2}
      • 링커는 각 섹션에 필요한 심불 주소들을 이미 치환했다.
        따라서 로더는 별도 수정 없이 이 내용을 메모리에 복사하고 실행이 가능하다.

 

7.8 Executable Object Files : 실행가능한 목적 파일

C 소스 → 여러 오브젝트 파일 → 하나의 실행파일로 병합되면서, ELF 실행 파일은 아래와 같은 정보를 포함하게 된다.

  1. 코드와 데이터
    • .text : 실행 가능한 명령어, 기계어 코드로 구성 되어 있다
    • .data : 초기화 된 전역 및 정적 변수
    • .bss : 초기화되지 않은 전역 및 정적 변수
  1. 심볼 테이블
    • 프로그램 내에서 정의된 함수 및 전역 변수 이름과 그 주소 매핑 역할
    • 디버깅 및 링크 시 참조용
  1. 재배치 정보 : Relocation Info
    • 컴파일 타임에 주소를 모르는 참조들에 대한 수정을 지시한다.
    • 링커가 심볼 주소를 결정 한 후, 참조를 올바른 주소로 수정하는데 사용된다.
  1. 디버깅 정보 : 디버깅을 실제로 하는 경우
    • 소스 코드와 실행 코드의 매핑을 진행한다.
    • 변수 이름, 라인 번호 정보 등이 담긴다.
  1. 헤더
    • ELF 헤더 : ELF 파일 전체의 구조 및 타입을 정의한다.
    • 프로그램 헤더 : 로더가 어떤 섹션을 어떻게 메모리에 적재할지를 명시한다.
    • 섹션 헤더 : 각 섹션의 크기, 위치, 타입 등의 메타데이터를 말한다.

ASCII 텍스트로 작성된 C 코드가 최종적으로 하나의 실행 가능 파일로 병합되며, 이 파일은 CPU가 즉시 실행 가능한 형태의 정제된 바이너리 정보를 모두 포함한다.

이 7.14 내용을 통해 좀 더 확인해보겠다.

실행 파일과 재배치 파일의 구조 유사성

  • 둘 다 ELF 헤더로 시작한다. 전체 파일 형식을 설명하며, 프로그램 시작 점(Entry Point)를 포함한다.
  • .text .rodata .data 등도 포함되어 있으나..
    • 실행 파일에서는 이들 섹션이 이미 메모리 주소로 재배치되어 있다.
    • 따라서 .rel (재배치 정보) 섹션은 필요가 없다.

.init 섹션

  • 작은 함수 _init 이 들어있고, 프로그램 초기화 루틴에 의해 호출된다.

 

프로그램 헤더 테이블

  • ELF 실행 파일의 로딩 방식을 설명한다.
  • 연속된 파일 바이트 → 연속된 메모리 영역으로 매핑되도록 설계되어 있다.
    • 직접적인 내용은 objdump 등을 통해 확인 할 수 있다. 바로 위의 사진이 그 내용.
      • 첫 번째 세그먼트는 코드 세그먼트이다.
        • 권한은 읽기와 실행이 가능 (Read/ eXcutable), 시작 주소는 0x400000
        • 파일 상의 크기는 0x69c 바이트이다.
        • ELF 헤더 + 프로그램 헤더 테이블 + .init + .text + .rodata 가 포함되어 있다.
    • 두 번째 세그먼트는 데이터 세그먼트이다.
      • 권한은 읽기와 쓰기가 가능 (Read/Write), 시작 주소는 0x600df8
      • 메모리 크기는 0x230 바이트이다.
        • 그 중 0x228 바이트는 .data 섹션에서 채우고,
        • 나머지 8 바이트는(이건 16진수이다) .bss에 해당하며 런타임에 0 초기화한다.

 

메모리 정렬 조건

    • vaddr mod align == off mod align
    • vaddr : 세그먼트가 메모리에 로드 될 시작 주소이다.
    • off : 해당 세그먼트의 첫 섹션이 파일 내에서 시작되는 위치이다.
    • align : 프로그램 헤더에서 지정한 정렬 단위인데, 보통 2^21 = 0x200000 이다.
    • 세그먼트 s 는 다음과 같은 조건을 만족해야한다.
    vaddr mod align = 0x600df8 mod 0x200000 = 0xdf8
    off   mod align = 0x0df8 mod 0x200000 = 0xdf8
    • 이 정렬은 가상 메모리 시스템의 효율적인 로딩을 위한 최적화이다.

 

7.9 Loading Executable Object Files

이전까지는 Linking에 대해 논했지만 여기는 이 실행 파일이 실제로 메모리에 올라가 실행되기까지의 내용을 논한다.

리눅스 셸은 ./prog 같은 식으로 프로그램을 실행한다.

  • 셸이 prog가 내장 명령이 아니라고 판단하면 → loader를 호출해서 실행한다.
  • 로더는 커널에 상주하는 코드로, 파일을 메모리에 올리고 실행하는 역할을 한다.

로더의 역할

  • execve() 시스템 호출로 진입한다. (모든 실행 파일 실행에 있어 핵심이다)
  • 디스크에 있는 실행 파일을 열고 → ELF 포맷을 파싱해서 → 필요한 부분을 메모리에 로딩한다.
    • 코드 (.text) | 데이터 (.data, .bss)
    • 그 후 Entry Point 주소로 점프하여 실행을 시작하게 된다.

메모리 레이아웃 : 런타임 메모리 이미지

  • 위 그림은 리눅스 x86-64에서 실행 중인 프로세스의 메모리 구조이다. 커널 영역에서 볼 수 있는 2^48 - 1을 트집 잡아보면,
    • 커널 영역은 2^47 이상 주소부터 존재하며, 일반 프로그램은 접근 불가하다. 이 쪽 내용에 접근하다간 쾅! 한다.

실행 진입 흐름

  • 로더가 실행 파일을 메모리에 올린 뒤 점프하는 진입점은 항상:
_start -> __libc_start_main -> main
  • _start : 시스템 제공 함수이다.(ctr1.o)
  • __libc_start_main : libc에서 제공하며, 환경 초기화 후 main을 호출한다.
  • main : 사용자 정의 함수를 말한다.

이쪽을 알아두면 :

  • main의 호출 과정, argc/argv가 어떻게 전달되는지 이해 할 수 있다.
  • 메모리 보호, 힙 오염, stack overflow 같은 보안 이슈 이해에도 필수적이다.
  • gdb에서 디버깅 시 _start부터 트레이스 하는 것도 가능해진다.
  • execve() 와 로더 동작을 이해하면 시스템 프로그래밍에서 fork/exec 구조도 쉽게 다뤄질 수 있다.

7.10 공유 라이브러리를 이용한 동적 링킹

정적 라이브러리에 대한 이리저리 사용법을 알아보았다.
좋은 방법이긴 하지만 여전히 정적 라이브러리만으로는 원하는 것을 이루기엔 다소 모자르다. 왜냐하면,

  1. 업데이트 문제
    • 정적 라이브러리는 컴파일 시점에 라이브러리 코드를 실행파일로 복사한다. 그래서 라이브러리가 업데이트되더라도, 실행 파일은 옛날 코드 그대로 사용하게 된다. 개발자가 수동으로 다시 linking 해야 최신 버전을 사용 할 수 있게 된다.
      • 예를 들어 libfoo.a가 업데이트 되면, myprog을 다시 gcc myprog.c -lfoo 로 재 컴파일/ 재 링킹 해야한다.
  1. 메모리 낭비
    • 모든 실행 파일이 동일한 라이브러리 함수를 각자 복사해 가진다.
    • 예를 들어 printf, scanf 같은 함수는 거의 모든 C 프로그램이 쓴다. → 수십, 수백 개의 프로세스마다 이 함수 코드가 중복되어 메모리에 로드된다. 이는 메모리 자원의 불필요한 낭비이다.
      • 메모리는 항상 부족한 자원이다. 주방의 쓰레기통 공간처럼..

그래서 등장한 공유 라이브러리 개념 !!

  • 실행 혹은 로드 시 메모리에 공유 방식으로 불러 올 수 있는 객체 모듈이다.
  • .so 확장자를 가진다. 윈도우에서는 .dll 이라는 파일 형식이다.
  • 한 번만 로드되고, 여러 프로그램이 같은 메모리 영역의 .text 섹션을 공유하게 된다.

간단하게 정적 라이브러리와 비교하면?

항목 정적 라이브러리 .a 공유 라이브러리 .so
링크 시점 컴파일/링크 시 포함한다. 실행 시, 또는 로딩 시 링크된다.
코드 중복 각 실행 파일에 포한된다. 메모리에서 여러 프로세스 간 공유된다.
업데이트 시 반영 여부 재링크가 필요하다. 즉시 반영된다. 파일 교체만 하면 돼
파일 크기 크다. 라이브러리 코드를 포함해서 작다. 공유 링크 방식이라서
로딩 속도 빠르다. 느릴 수도 있다. 링크 과정 때문에

 

이 그림에서 일어나는 Dynamic linking 과정을 요약해서 보자.

  1. 공유 라이브러리를 생성한다.
    • gcc -shared -fpic -o libvector.so addvec.c multvec.c
    • -fpic : Position Independent Code (PIC) 생성 → 어디에 로드되도 실행 가능
    • -shared : Shared Object로 링킹한다.
    • 결과 : libvector.so가 생성되고, 여러 실행 파일에서 공유 할 수 있다.
  1. 실행 파일을 생성한다. 동적 링킹 방식으로..
    • gcc -o prog2l main2.c ./libvector.so
    • prog2l은 libvector.so를 링크하지만, 실제 코드나 데이터는 복사되지 않는다.
    • 대신, 심볼 테이블, 재배치 정보만 포함된다.
    • 즉, prog2l은 부분적으로만 링크된 실행 파일이며, 실행 시에 진짜 연결이 된다.
  1. 실행 시 동작한다. 여기선 런타임 동적 링커의 역할을 본다.
    1. 일반적인 로딩
      • loader가 prog2l을 읽고 메모리에 올린다.
    1. .interp 섹션 확인
      • .interp에는 동적 링커 경로가 있다. /lib64/ld-linux-x86-64.so.2
      • 이걸 보고 동적 링커를 먼저 실행한다.
    1. 동적 링커(ld-linux.so)가 하는 일
      • libc.so와 libvector.so를 메모리에 로드한다.
      • prog2l 내의 미완성 심볼들을 이 두 라이브러리 안에서 찾아서 재배치한다.
      • prog2l 안에 있는 addvec()을 호출하고, libvector.so 에서 addvec의 주소를 찾아 연결한다.
  1. 프로그램의 실행을 시작한다.
    • 모든 링크가 끝나면 → 동적 링커가 _start로 점프한다.
    • 그 다음은 __libc_start_main → main() 호출 → 실행 시작

7.11 애플리케이션이 공유 라이브러리를 로딩하고 링크하는 과정

실행 중 동적 링킹

  • 뭐길래?
    • 프로그램이 이미 실행되고 있는 런타임 중에 임의의 공유 라이브러리를 동적으로 불러오고 사용한다.
      컴파일 시점에 전혀 연결하지 않아도 된다.

실제 사례

  1. 소프트웨어 배포와 업데이트
    • 윈도우 응용프로그램 개발자들이 주로 사용한다.
    • 새 버전의 DLL 파일을 배포 → 사용자들이 기존 DLL을 새 DLL로 교체한다.
    • 다음 실행 시, 프로그램은 자동으로 새 DLL을 링크한다.
  1. 고성능 웹서버
    • 예전엔 CGI 방식을 사용했다 → 요청마다 fork + execve로 CGI 프로그램을 실행했고 무척 비효율적이었다.
    • 현대 웹 서버는..
      • 각 동적 콘텐츠 생성 함수를 .so로 구성한다.
      • 웹 요청이 오면 해당 .so를 동적으로 로딩하고 함수를 호출한다.
      • 함수는 서버 주소 공간에 캐싱되어 재사용이 가능하다.
      • 필요 시 .so 파일만 교체하거나 새로운 .so 추가하면 → 서버 재시작 없이 기능 업데이트가 가능하다.
      • 이로써 처리 속도가 대폭 향상되고 유지보수 유연성이 증가하게 되었다.
    • 리눅스에서는 glibc에서 제공하는 dlopen, dlsym, dlclose, dlerror 등을 쓴다.
    #include <dlfcn.h>
    void *dlopen(const char *filename, int flag);
    // Returns: pointer to handle if OK, NULL on error
    • dlopen은 공유 라이브러리 파일을 실행 중에 메모리에 로드하고 링크하는 역할이다.
      • filename 자리에 로딩할 공유 라이브러리의 경로를 작성한다.
      • flag 자리에 링커 동적 방식을 지정해야 한다.
        • RTLD_NOW : 심볼을 얻는 즉시 해석한다.
        • RTLD_LAZY : 심볼 해석이 실제 사용 시점에서 이루어진다.
          • 예를 들어 함수 포인터 같은 걸 얻어오자마자 사용하면 RTLD_NOW가 안전하다.
        • RTLD_GLOBAL : dlopen으로 로드한 라이브러리의 심볼들이 전역 노출된다. 이후 로드하는 다른 .so 파일에서 이 심볼들을 참조 할 수 있다.
      • 별개로 컴파일 옵션 중에 -rdynamic이 있는데, 현재 실행 파일의 전역 심볼들을 심볼 테이블에 노출시킨다. 그래야 dlopen이 이 실행 파일의 함수나 변수들을 .so에서 참조 할 수 있기 때문이다.
#include <dlfcn.h>
void *dlsym(void *handle, char *symbol);
// Returns: pointer to symbol if OK, NULL on error
  • dlsym 함수는 동적으로 로드된 공유 라이브러리에서 특정 심볼의 주소를 반환하는 함수이다. 주로 dlopen 함수와 함께 사용된다.
  • handle : dlopen으로 공유 라이브러리를 로드하고 얻은 핸들이다.
  • symbol : 로드한 라이브버리에서 찾을 심볼의 이름이 기입된다.
  • void *이기 때문에 반환 값이 있다.
    • symbol이 존재하면 해당 심볼의 주소를 반환한다. 존재하지 않으면 NULL.
#include <dlfcn.h>
int dlclose (void *handle);
// Returns: 0 if OK, -1 on error
  • dlclose 함수는 이전에 dlopen 으로 열었던 공유 라이브러리를 메모리에서 언로드 하는데 사용된다. 단, 다른 라이브러리나 프로세스가 사용하고 있다면 언로드 되지 않는다.
  • handle : dlopen으로 열린 공유 라이브러리의 핸들이다. 성공 시 0, 실패 시 -1 반환.
#include <dlfcn.h>
const char *dlerror(void);
// Returns: error message if previous call to
// dlopen, dlsym, or dlclose failed; NULL if previous call was OK

dlerror 함수는 이전에 설명했던 세 함수 호출 중 발생한 가장 최근의 오류를 설명하는 문자열을 반환한다.
오류가 발생하지 않았다면 NULL 을 반환한다.

#include <stdio.h>
#include <stdlib.h>
 #include <dlfcn.h>

 int x[2] = {1, 2};
 int y[2] = {3, 4};
 int z[2];

 int main()
 {
 void *handle;
 void (*addvec)(int *, int *, int *, int);
 char *error;

 /* Dynamically load the shared library containing addvec() */
 handle = dlopen("./libvector.so", RTLD_LAZY);
 if (!handle) {
 fprintf(stderr, "%s\n", dlerror());
 exit(1);
 }

 /* Get a pointer to the addvec() function we just loaded */
    addvec = dlsym(handle, "addvec");
    if ((error = dlerror()) != NULL) {
    fprintf(stderr, "%s\n", error);
    exit(1);
    }

    /* Now we can call addvec() just like any other function */
30 addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
/* Unload the shared library */
if (dlclose(handle) < 0) {
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
return 0;
}

// Figure 7.17 예제. libvector.so를 런타임에서 동적으로 불러온다.

7.12 위치 독립적 코드 (Position Independent Code)

공유 라이브러리의 핵심 목적 중 하나는 여러 실행 중인 프로세스가 동일한 라이브러리 코드를 메모리에서 공유하여 메모리 자원을 절약하는 것이다. 그런데 서로 다른 프로세스가 어떻게 한 프로그램의 복사본 하나만을 공유 할 수 있을까?

  • 단순한 아이디어부터 논하기 : 라이브러리마다 고정 주소를 정해두자
    • 라이브러리 A는 항상 0x500000, B는 0x600000에 위치시킨다.
  • 뭐가 문제였냐하면..
    1. 비효율적인 주소 공간 사용 : 어떤 프로세스가 특정 라이브러리를 사용하지 않더라도 해당 메모리 공간이 예약되어 있어 공간 낭비가 발생한다.
    2. 관리의 어려움
      • 주소 충돌의 방지가 필요하다.
      • 라이브러리 업데이트 시 주소 범위에 맞지 않으면 재배치가 필요하다.
      • 수백 개의 라이브러리 관리 시 메모리 주소 공간이 조각화 되어 작은 빈틈들이 생긴다. 이는 재사용이 어렵다.
    1. 시스템 간 비일관성
      • 시스템마다 라이브러리 주소 할당이 다르면 배포 및 관리가 더욱 복잡해진다.
  • 그래서 위치 독립 코드가 논해졌다!
    • 공유 모듈의 코드가 메모리 어디에 로드되든 실행 가능하도록 컴파일 하는 것이다.
    • 이렇게하면, 라이브러리의 코드 부분은 한 번만 메모리에 로드되고, 여러 프로세스에서 같은 복사본을 공유 할 수 있게 된다.
      단, 각 프로세스에서의 쓰기 가능한 데이터 영역은 별도의 복사본을 갖는다.
    • 특징
      • Relocation 없이도 실행이 가능하다.
      • GNC 컴파일러에서는 -fpic 옵션으로 PIC 를 생성 할 수 있다.
        • gcc -fpic -c some_module.c
    • x86-64 시스템에서, 같은 오브젝트 모듈 내부의 참조는 PC-relative addressing으로 처리가 가능하다.
      → 즉, 특별한 처리가 필요 없다
      • 하지만 외부 심볼(외부 함수, 전역 변수를 참조하는 경우에는 특별한 처리가 필요하다.

PIC 데이터의 참조

  • PIC는 코드가 메모리 어디에 로드되든 작동하도록 만들어진 코드라고 했다. 공유 라이브러리에 필수적이라는 것까지.
    • 코드 - 데이터 상대 거리 = 상수
      • 한 객체 모듈 내에서 code segment와 data segment는 항상 같은 상대적 거리를 유지한다.
      • 따라서 어떤 instruction이 전역 변수를 참조하는지에 대한 offset은 런타임에서도 일정하게 움직인다.
    • GOT (Global Offset Table)
      • 전역 변수 또는 함수 주소를 간접적으로 참조하기 위한 테이블이다.
      • 각 전역 객체에 대해 GOT에는 해당 객체의 실제 주소가 담긴다.
      • 코드에서는 GOT에 대한 상대적 주소로 접근하여 해당 객체를 직접 참조한다.
      • mov addcnt(%rip), %rax 와 같이 GOT를 통한 간접 접근이 가능하다.
    • Relocation
      • 각 GOT 항목은 컴파일 시에 재배치 항목 (relocation entry)로 마킹된다.
      • 로드 시 동적 링커가 실제 주소로 초기화시킨다.
    • 왜 addcnt는 GOT을 통해 접근하냐면,
      • addcnt는 현재 모듈(libvector.so)에 정의되어 있어 직접 접근도 가능하다.
      • 하지만, 다른 공유 라이브러리에 정의된 전역 객체 일 수도 있다.
      • 이 가능성을 대비해 가장 일반적인 방법인 GOT 간접 접근을 기본적으로 사용한다.

왜 이렇게 설계하냐하면,

  • PIC를 통해 공유 라이브러리가 메모리 어디에 올라가도 문제 없이 동작하게 만들기 위해서이다.
  • GOT 방식은 일반적이고 범용적이기 때문에, 모듈 간 의존성 유연성을 확보 할 수 있다.
  • 결과적으로 성능을 약간 희생하지만, 이식성과 공유성을 확보하는 전략인 것이다.

 

PIC Function Calls

PIC 환경에서는 공유 라이브러리 함수의 실제 주소를 컴파일 시점에 알 수 없다.
예를 들어 printf()는 libc.so에 있지만, 이 라이브러리가 런타임에 어느 주소에 로드될지는 알 수 없다.

  • PIC가 아닌 경우의 일반적인 방법
    • 코드에 call 0x80486a0 같은 절대 주소를 써두고, 동적 링커가 코드를 수정해서 함수 주소를 채운다.
    • 문제는, 코드 세그먼트는 보통 읽기 전용이니 수정이 어렵다는 것이다. → PIC 아님.
  • 이때 해결책은 PLT + GOT + Lazy Binding
    • 구성 요소 두 가지에 대해 먼저 읊자.
      1. GOT (Global Offset Table)
        • 함수 이름 → 실제 주소로 변환하는 간접 참조 테이블 (data segment)
        • 초기에는 함수 주소가 아니라 PLT 엔트리를 가리킨다.
        • 이후 동적 링커가 주소를 채우게 된다.
      1. PLT (Procedure Linkage Table)
        • 코드 세그먼트의 jump table이다.
        • 각 함수 호출은 PLT를 통해 수행된다 : call printf@PLT
      • Lazy Binding은 동작 형식이다 :
        1. 처음 호출. call printf@PLT
          • printf@PLT는 내부적으로 jmp *GOT[printf] 형태이다.
          • GOT[printf]는 현재 동적 링커 호출용 코드로 설정되어 있다.
          • 실행 결과로, PLT[0]으로 점프되며, 여기서 동적 링커가 호출된다.
        1. 동적 링커가 작동된다
          • 동적 링커는 실제 printf()의 주소를 찾는다.
          • GOT[printf]에 실제 주소를 저장한다.
        1. 두 번째 호출부터
          • jmp *GOT[printf]가 이미 세팅된 실제 주소로 점프한다.
          • → 더 이상 PLT[0]을 거치지 않고, 단 한 번의 메모리 간접 참조만 일어나기에 매우 빠르다.
        • 핀트는, 실제 함수 주소를 최초 호출 시에만 동적 링커가 해석한다는 것

  • 기본 개념 요약을 하고 나서, 단계별 절차를 확인해보자.
    • PLT는 코드 세그먼트에 위치하며 함수 호출 진입점의 역할을 한다. 초기에는 GOT 간접 참조로 동적 링커를 호출한다.
    • GOT는 데이터 세그먼트에 위치하며 함수 실제 주소를 저장한다. 처음에는 PLT로 되돌아간다.
    • 동적 링커는 ld-linux.so라는 공유 라이브러리에 위치하며 함수 주소를 해석하고 GOT에 채우는 역할을 수행한다.
  • addvec을 기준으로 최초 호출 시의 흐름을 보자.

최초 호출, 목표 : addvec() 함수 호출 | Figure 7.19 (a)

  • 하지만 런타임까지 addvec의 주소를 모른다 → Lazy Binding 사용
  • 단계별 설명
    1. 프로그램은 직접 addvec()을 호출하지 않고 대신 PLT[2]로 점프한다. 이 PLT 엔트리는 addvec을 위한 것이다.
    2. PLT[2]는 첫 번째 명령으로 jmp *GOT[4]를 수행한다. 하지만 현재 GOT[4]는 아직 addvec 주소를 모르고, 대신 PLT[2]의 두 번째 명령어를 가리킨다. → 다시 PLT[2]로 돌아온다.
    3. PLT[2]는 이제 push $0x1 (addvec에 해당하는 식별자)을 스택에 넣고, 다시 jmp PLT[0] 으로 이동한다.
    4. PLT[0]은 GOT[1]을 통해 추가 인자를 push하고, GOT[2]를 통해 동적 링커로 점프한다.
  • 이 시점에서 스택에는
    • 0x1 (addvec의 ID)
    • 추가 인자

동적 링커는 이를 바탕으로 addvec()의 실제 주소를 계산하고, GOT[4]를 그 주소로 덮어쓴다.
그리고 최종적으로 해당 주소로 제어를 넘긴다.

두 번째 이후 호출 시 흐름 | Figure 7.19 (b)

  • 단계별 설명
    1. 프로그램이 다시 addvec()을 호출하면, 역시 PLT[2]으로 점프한다.
    2. 이번엔 jmp *GOT[4]가 진짜 addvec() 주소를 가리키므로, 바로 addvec으로 제어가 넘어간다.
    • 더 이상 PLT[0]이나 동적 링커를 거치지 않는다.
    • 단순한 한 번의 간접 점프로 함수 호출이 완료된다.

단 한번만 매핑 후 유지되는 것이 Lazy Binding의 핵심이다.

7.13 Library Interpositioning

Library Interpositioning 은 실행 중인 프로그램이 공유 라이브러리의 함수를 호출 할 때 그 호출을 가로채고 중간에서 원하는 로직을 수행 할 수 있도록 하는 강력한 기능이다.

  • 목적
    • 함수 호출 횟수 세기
    • 함수 인자 및 반환값 출력
    • 함수 동작 자체를 변경 또는 완전히 대체
    • 디버깅, 로깅, 성능 분석, 보안 검사
  • 세 가지 시점에서 구현이 가능하다 :
    • 컴파일 타임, 소스 코드 수준에서 직접 함수 호출 변경한다.
    • 링크 타임, 링크 옵션으로 함수 호출을 래퍼로 감싼다.
    • 런타임, 환경 변수를 사용해 동적 링크 시 인터포즈 한다. 

컴파일 타임에서의 인터포지셔닝

gcc -DCOMPILETIME -c mymalloc.c
gcc -I. -o intc int.c mymalloc.o
./intc

malloc(32)=0x9ee010
free(0x9ee010)

 

링크 타임에서의 인터포지셔닝

  • 이 경우엔 시도 할 예제가 다소 다르다 :
linux> gcc -DLINKTIME -c mymalloc.c
linux> gcc -c int.c

  • GNU id 링커에 제공되는 —wrap=<symbol> 옵션을 이용 할 수 있다. 다음과 같이 동작한다 :
    • 코드에서 malloc 을 호출한다고 해보자.
    • 그럼 링커는 대신 __wrap_malloc을 호출하게 된다.
    • 원래의 malloc은 __real_malloc으로 이름이 바뀐다.
    • 이걸 통해 원하는 로직을 삽입 할 수 있다.

런타임에서의 인터포지셔닝

여기서는 런타임(LD_PRELOAD 기반)으로의 인터포지셔닝 기법을 보겠다. 프로그램의 소스 코드나 오브젝트 파일 없이도, 오직 실행 파일만 가지고도 라이브러리 함수 호출을 가로채서 추적하거나 수정 할 수 있다는데에 의의가 있다.

  • 핵심 개념, LD_PRELOAD
    • 동적 링커가 공유 라이브러리를 로딩 할 때, 지정된 라이브러리를 우선적으로 검색하게 만든다.
  • 기능을 요약하면,
    • 어떤 실행 파일이든, 해당 바이너리의 라이버르리 호출을 “가로채서” 내가 만든 함수를 대신 실행 시킬 수 있다.
    • 가장 일반적인 용도는, malloc free open read 등의 호출을 추적하던가, 커스터마이징 하는데에 있다.
  • 위 그림에서 보이는 함수를 이용한다.
  • 공유 라이브러리로 컴파일 한다.
    • gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl
  • 테스트용 실행 파일을 만든다.
    • gcc -o intr int.c
  • LD_PRELOAD로 실행한다.
    • LD_PRELOAD=./mymalloc.so ./intr
malloc(32) = 0x1bf7010
free(0x1bf7010)

이 내용은 시스템 프로그램에도 적용 할 수 있다.

방법 설명 요구사항
Compile-time 전처리기 수준에서 인터포즈 소스 코드가 필요하다
Link-time 링커 옵션으로 인터포즈 오브젝트 파일이 필요하다
Run-time (LD_PRELOAD) 실행 시 동적 링커에 의해 함수 오버라이드 실행 파일만 있으면 가능하다

 

7.14 목적 파일의 조정을 위한 도구들

GNU binutils 패키지는 리눅스에서 기본적으로 제공되는 오브젝트 파일을 이해하고 조작하는데 사용 할 수 있는 도구들을 모아놨다.

  • ar : 정적 라이브러리 .a 파일 형식을 생성하고, 멤버 삽입/삭제/조회/추출이 가능하다.
  • strings : 오브젝트 파일 내 출력 가능한 문자열들을 나열한다.
  • strip :심볼 테이블 정보를 삭제해서 파일을 경량화한다.
  • nm : 오브젝트 파일에 정의된 심볼 목록을 출력한다.
  • size: 각 섹션의 이름과 크기를 출력한다. (.text, .data 등)
  • readelf :ELF 형식의 구조 전체를 출력한다. size, nm의 기능 포함.
  • objdump :디스어셈블을 포함한 거의 모든 정보를 출력 가능한 종합 도구이다.

'컴퓨터 이론 > CS:APP' 카테고리의 다른 글

[CS:APP] Chap 9.1 - 9.8 : Virtual Memory  (0) 2025.04.23
[CS:APP] Chap 8 : Exceptional Control Flow  (0) 2025.04.23
[CS:APP] Chap 3.10 - 11  (1) 2025.04.09
[CS:APP] Chap 3.7 - 9  (1) 2025.04.09
[CS:APP] Chap 3.4 - 6  (0) 2025.04.09