[JUNGLE] TIL_34. CSAPP ~7.6, RB Tree

모깅·2025년 10월 16일

JUNGLE

목록 보기
35/56
post-thumbnail

7장 링킹 (Linking) 소개

링킹(Linking)은 다양한 코드와 데이터 조각들을 수집하고 결합하여, 메모리에 로드(복사)하고 실행할 수 있는 단일 파일로 만드는 과정입니다.

링킹은 소스 코드가 기계어 코드로 변환되는 컴파일 시간, 프로그램이 로더에 의해 메모리에 로드되어 실행되는 로드 시간, 심지어는 응용 프로그램에 의해 런타임에도 수행될 수 있습니다. 현대 시스템에서는 링커(linker)라는 프로그램에 의해 자동으로 수행됩니다.

링커는 분할 컴파일(separate compilation)을 가능하게 하여 소프트웨어 개발에서 핵심적인 역할을 합니다. 덕분에 거대한 프로그램을 단일 소스 파일로 관리하는 대신, 더 작고 관리하기 쉬운 여러 모듈로 나누어 개별적으로 수정하고 컴파일할 수 있습니다.


링킹을 배워야 하는 이유

초급 프로그래밍 수업에서 작은 프로그램을 만들 때는 링킹이 눈에 보이지 않아 중요하지 않게 느껴질 수 있습니다. 하지만 링킹을 배워야 하는 이유는 다음과 같이 명확합니다.

  • 대규모 프로그램 구축에 도움이 됩니다.
    대규모 프로그램을 만들다 보면 누락된 모듈, 라이브러리 부재, 호환되지 않는 라이브러리 버전 등으로 인한 링커 오류를 자주 마주하게 됩니다. 링커가 참조를 어떻게 해석하고 라이브러리를 어떻게 사용하는지 이해하지 못하면 이러한 오류는 매우 당혹스러울 수 있습니다.
  • 치명적인 프로그래밍 오류를 피할 수 있습니다.
    리눅스 링커가 심볼 참조를 해결하는 방식은 조용히 프로그램의 정확성에 영향을 미칠 수 있습니다. 예를 들어, 전역 변수를 잘못 중복 정의한 프로그램이 아무런 경고 없이 링커를 통과하여, 디버깅하기 매우 어려운 런타임 오류를 일으킬 수 있습니다.
  • 언어의 스코프 규칙(scoping rules)이 어떻게 구현되는지 이해하게 됩니다.
    전역 변수와 지역 변수의 차이점은 무엇인지, 변수나 함수를 static 속성으로 정의하는 것이 실제로 무엇을 의미하는지 등을 명확히 알 수 있습니다.
  • 다른 중요한 시스템 개념을 이해하는 데 도움이 됩니다.
    링커가 생성하는 실행 가능한 오브젝트 파일(executable object files)은 프로그램 로딩 및 실행, 가상 메모리, 페이징, 메모리 매핑과 같은 핵심 시스템 기능에서 중요한 역할을 합니다.
  • 공유 라이브러리(shared libraries)를 능숙하게 활용할 수 있게 됩니다.
    현대 운영체제에서 공유 라이브러리와 동적 링킹의 중요성이 커지면서, 링킹은 지식 있는 프로그래머에게 강력한 힘을 주는 정교한 프로세스가 되었습니다. 예를 들어, 많은 소프트웨어 제품이 런타임에 프로그램을 업그레이드하기 위해 공유 라이브러리를 사용하며, 웹 서버 또한 동적 콘텐츠를 제공하기 위해 동적 링킹에 의존합니다.

이 챕터에서 다룰 내용

이 챕터에서는 전통적인 정적 링킹(static linking)부터 로드 시간 및 런타임에 이루어지는 공유 라이브러리의 동적 링킹(dynamic linking)까지 링킹의 모든 측면을 깊이 있게 다룹니다. 논의는 x86-64 리눅스 시스템과 표준 ELF(Executable and Linkable Format) 오브젝트 파일 형식을 기준으로 구체적인 예시를 통해 진행되지만, 링킹의 기본 개념은 운영체제, ISA, 오브젝트 파일 형식에 관계없이 보편적으로 적용됩니다.

7.1 컴파일러 드라이버 (Compiler Drivers)

대부분의 컴파일 시스템은 사용자를 대신하여 언어 전처리기(preprocessor), 컴파일러(compiler), 어셈블러(assembler), 링커(linker)를 필요에 따라 호출하는 컴파일러 드라이버(compiler driver)를 제공합니다.

예를 들어, GNU 컴파일 시스템을 사용하여 예제 프로그램을 빌드하려면 셸에 다음과 같은 명령어를 입력하여 gcc 드라이버를 호출할 수 있습니다.
linux> gcc -Og -o prog main.c sum.c


컴파일 드라이버의 처리 과정

gcc 드라이버는 C 소스 파일을 실행 가능한 목적 파일로 변환하기 위해 다음과 같은 단계를 거칩니다.

  1. 전처리기 (Preprocessor, cpp)
    • C 소스 파일(main.c)을 아스키(ASCII) 중간 파일(main.i)로 변환합니다.
    • cpp [other arguments] main.c /tmp/main.i
  2. 컴파일러 (Compiler, cc1)
    • 중간 파일(main.i)을 아스키(ASCII) 어셈블리어 파일(main.s)로 변환합니다.
    • cc1 /tmp/main.i -Og [other arguments] -o /tmp/main.s
  3. 어셈블러 (Assembler, as)
    • 어셈블리어 파일(main.s)을 바이너리 형태의 재배치 가능한 목적 파일(relocatable object file)main.o로 변환합니다.
    • as [other arguments] -o /tmp/main.o /tmp/main.s
  4. 링커 (Linker, ld)

- 드라이버는 `sum.c`에 대해서도 동일한 과정을 거쳐 `sum.o`를 생성합니다.
- 마지막으로 **링커 프로그램(ld)**을 실행하여 `main.o`와 `sum.o`를 필요한 시스템 목적 파일들과 함께 묶어 최종적으로 **실행 가능한 목적 파일(executable object file)**인 `prog`를 생성합니다.
- `ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o`

프로그램 실행과 로더 (Loader)

생성된 실행 파일 prog를 실행하기 위해 셸 명령어 라인에 파일명을 입력합니다.
linux> ./prog

그러면 셸은 운영체제 내의 로더(loader)라는 함수를 호출합니다. 로더는 실행 파일 prog에 있는 코드와 데이터를 메모리에 복사한 뒤, 프로그램의 시작점으로 제어를 넘겨 프로그램을 실행시킵니다.

7.2 정적 링킹 (Static Linking)

리눅스의 ld 프로그램과 같은 정적 링커(static linker)는 여러 개의 재배치 가능한 목적 파일(relocatable object files)과 커맨드 라인 인자를 입력으로 받아, 로드하고 실행할 수 있는 완전한 형태의 실행 가능한 목적 파일(executable object file)을 출력으로 생성합니다.

입력으로 사용되는 재배치 가능한 목적 파일들은 다양한 코드와 데이터 섹션으로 구성되며, 각 섹션은 연속된 바이트의 시퀀스입니다. 예를 들어, 명령어는 코드 섹션에, 초기화된 전역 변수는 다른 섹션에, 그리고 초기화되지 않은 변수는 또 다른 섹션에 위치합니다.


링커의 두 가지 주요 작업

실행 파일을 만들기 위해 링커는 두 가지 주요 작업을 수행해야 합니다.

  1. 심볼 해석 (Symbol Resolution)
    • 목적 파일들은 심볼(symbol)을 정의(define)하고 참조(reference)합니다. 여기서 심볼은 함수, 전역 변수, 또는 static 변수 등에 해당합니다.
    • 심볼 해석의 목적은 각각의 심볼 참조(reference)를 정확히 하나의 심볼 정의(definition)와 연결하는 것입니다.
  2. 재배치 (Relocation)
    • 컴파일러와 어셈블러는 주소 0에서 시작하는 코드와 데이터 섹션을 생성합니다.
    • 링커는 각 심볼 정의에 실제 메모리 주소를 할당하고, 해당 심볼을 참조하는 모든 부분을 수정하여 이 메모리 주소를 가리키도록 함으로써 섹션들을 재배치합니다.
    • 링커는 이 재배치 과정을 어셈블러가 생성한 재배치 항목(relocation entries)이라는 상세한 지침에 따라 기계적으로 수행합니다.

링커에 대한 기본 사실

  • 목적 파일은 단순히 바이트 블록들의 모음입니다. 이 블록들 중 일부는 프로그램 코드를, 다른 일부는 프로그램 데이터를, 또 다른 일부는 링커와 로더를 안내하는 데이터 구조를 포함합니다. → 이 이유로 가상 메모리가 여러 영역으로 나뉘게 된다!
  • 링커는 이 블록들을 하나로 합치고, 합쳐진 블록이 실행될 때의 메모리 위치를 결정하며, 코드와 데이터 블록 내의 여러 위치를 수정하는 역할을 합니다. → 링킹 단계에서 실제 함수 및 변수의 가상 주소를 할당한다!
  • 링커는 타겟 머신에 대한 최소한의 이해만 가지고 있으며, 대부분의 작업은 이미 목적 파일을 생성한 컴파일러와 어셈블러에 의해 완료된 상태입니다.

7.3 목적 파일 (Object Files)

목적 파일(Object file)은 세 가지 형태로 존재합니다.

  1. 재배치 가능 목적 파일 (Relocatable object file)
    • 컴파일 시점에 다른 재배치 가능 목적 파일과 결합하여 실행 가능한 목적 파일을 만들 수 있는 형태의 바이너리 코드와 데이터를 담고 있습니다.
  2. 실행 가능 목적 파일 (Executable object file)
    • 메모리에 직접 복사하여 바로 실행할 수 있는 형태의 바이너리 코드와 데이터를 담고 있습니다.
  3. 공유 목적 파일 (Shared object file)
    • 로드 시점(load time) 또는 실행 시점(run time)에 메모리로 로드되어 동적으로 연결(dynamically linked)될 수 있는 특별한 종류의 재배치 가능 목적 파일입니다.

파일 생성 주체

  • 컴파일러어셈블러재배치 가능 목적 파일공유 목적 파일을 생성합니다.
  • 링커실행 가능 목적 파일을 생성합니다.

참고: 기술적으로 목적 모듈(object module)은 바이트의 순서를 의미하고, 목적 파일(object file)은 디스크 파일에 저장된 목적 모듈을 의미하지만, 이 둘은 보통 혼용해서 사용합니다.


목적 파일 포맷

목적 파일은 시스템마다 다른 특정 목적 파일 포맷(object file formats)에 따라 구성됩니다.

  • a.out: 초기 유닉스 시스템에서 사용되었습니다. (오늘날에도 실행 파일을 a.out 파일이라고 부르곤 합니다.)
  • PE (Portable Executable): 윈도우(Windows)에서 사용합니다.
  • Mach-O: macOS에서 사용합니다.
  • ELF (Executable and Linkable Format): 최신 x86-64 리눅스 및 유닉스 시스템에서 사용합니다.

이 책의 설명은 ELF 포맷에 초점을 맞추지만, 기본적인 개념은 포맷의 종류와 관계없이 유사합니다.

7.4 재배치 가능 목적 파일 (Relocatable Object Files)

전형적인 ELF(Executable and Linkable Format) 재배치 가능 목적 파일의 포맷은 다음과 같은 구조를 가집니다.

  • ELF 헤더 (ELF header): 파일의 시작 부분에 위치하며, 파일을 생성한 시스템의 워드 크기(word size)나 바이트 순서(byte ordering) 같은 정보를 담고 있는 16바이트 시퀀스로 시작합니다. 그 외에도 링커가 파일을 해석하는 데 필요한 정보(헤더의 크기, 목적 파일의 종류, 머신 타입, 섹션 헤더 테이블의 위치 및 크기 등)를 포함합니다.
  • 섹션 헤더 테이블 (Section header table): 목적 파일에 있는 각 섹션의 위치와 크기 정보를 담고 있습니다. 각 섹션마다 고정된 크기의 항목이 존재합니다.

ELF 헤더와 섹션 헤더 테이블 사이에는 아래와 같은 실제 섹션들이 위치합니다.


주요 섹션의 종류와 역할

  • .text
    컴파일된 프로그램의 기계어 코드가 저장됩니다.
  • `.rodataprintf문의 포맷 문자열이나 switch`문의 점프 테이블(jump table)과 같이 읽기 전용 데이터(read-only data)를 저장합니다.
  • .data초기화된 전역 변수정적(static) 변수가 저장됩니다. (지역 변수는 런타임 시 스택에서 관리되므로 이 섹션에 포함되지 않습니다.)
  • .bss
    초기화되지 않았거나 0으로 초기화된 전역 변수 및 정적 변수를 위한 공간입니다. 이 섹션은 목적 파일 내에서 실제 공간을 차지하지 않고 자리 표시자(placeholder) 역할만 합니다. 이는 공간 효율성을 위한 것으로, 런타임 시 메모리에 할당되면서 0으로 초기화됩니다.
  • .symtab
    프로그램에서 정의되고 참조되는 함수와 전역 변수에 대한 정보를 담고 있는 심볼 테이블(symbol table)입니다. (g 옵션 없이도 모든 재배치 가능 목적 파일은 이 테이블을 가지고 있으며, 지역 변수에 대한 정보는 포함하지 않습니다.)
  • `.rel.text.text` 섹션 내에서, 링커가 다른 목적 파일과 결합할 때 수정이 필요한 위치들의 목록입니다. 일반적으로 외부 함수를 호출하거나 전역 변수를 참조하는 모든 명령어가 여기에 해당합니다.
  • .rel.data
    모듈에 의해 참조되거나 정의된 전역 변수에 대한 재배치 정보입니다. 주로 초기값이 다른 전역 변수의 주소이거나 외부 함수의 주소인 초기화된 전역 변수들이 수정 대상이 됩니다.
  • .debug
    지역 변수, typedef, 프로그램에서 정의하고 참조하는 전역 변수, 원본 C 소스 파일에 대한 정보를 포함하는 디버깅용 심볼 테이블입니다. 컴파일러 드라이버가 g 옵션으로 호출될 때만 생성됩니다.
  • .line
    원본 C 소스 프로그램의 라인 번호.text 섹션의 기계어 명령어 사이의 매핑 정보입니다. g 옵션으로 컴파일할 때만 생성됩니다.
  • `.strtab.symtab.debug` 섹션의 심볼 이름이나 섹션 헤더의 섹션 이름을 위한 문자열 테이블(string table)입니다. NULL로 끝나는 문자열들의 시퀀스로 구성됩니다.

7.5 심볼과 심볼 테이블 (Symbols and Symbol Tables)

각각의 재배치 가능 목적 모듈(m)은 m에 의해 정의(define)되고 참조(reference)되는 심볼에 대한 정보를 담고 있는 심볼 테이블(symbol table)을 가지고 있습니다. 링커의 관점에서 심볼은 세 가지 종류로 나뉩니다.

  1. 전역 심볼 (Global Symbols - Defined)
    • 모듈 m에 의해 정의되고, 다른 모듈에서 참조할 수 있는 심볼입니다.
    • C 언어의 static이 아닌 함수와 전역 변수에 해당합니다.
  2. 전역 심볼 (Global Symbols - Referenced, Externals)
    • 모듈 m에서 참조하지만, 다른 모듈에 정의되어 있는 심볼입니다. 이러한 심볼을 외부 심볼(externals)이라고 부릅니다.
    • 다른 모듈에 정의된 static이 아닌 C 함수와 전역 변수에 해당합니다.
  3. 지역 심볼 (Local Symbols)
    • 오직 모듈 m 내부에서만 정의되고 참조되는 심볼입니다.
    • static 속성으로 선언된 C 함수와 전역 변수에 해당하며, 다른 모듈에서는 참조할 수 없습니다.

중요: 링커의 지역 심볼과 프로그램의 지역 변수는 다릅니다. 심볼 테이블(.symtab)은 함수 내의 static이 아닌 지역 변수에 대한 심볼은 포함하지 않습니다. 이러한 변수들은 런타임에 스택에서 관리되며 링커의 관심사가 아닙니다.


C의 정적 지역 변수 (Static Local Variables)

흥미롭게도, 함수 내에 static으로 선언된 지역 변수는 스택에서 관리되지 않습니다. 대신 컴파일러는 각 변수를 위해 .data 또는 .bss 섹션에 공간을 할당하고, 심볼 테이블에 고유한 이름을 가진 지역 링커 심볼을 생성합니다.

예를 들어, 같은 모듈 내의 두 함수가 각각 static int x를 정의하면, 컴파일러는 f 함수의 xx.1로, g 함수의 xx.2와 같이 서로 다른 이름의 지역 심볼을 만들어 어셈블러에게 전달합니다.


ELF 심볼 테이블의 구조

ELF 심볼 테이블은 .symtab 섹션에 있으며, 여러 항목(entry)들의 배열로 구성됩니다. 각 항목은 다음과 같은 정보를 포함합니다.

  • name: 문자열 테이블(.strtab) 내의 오프셋으로, 심볼의 실제 이름을 가리킵니다.
  • value: 심볼의 주소입니다.
    • 재배치 가능 파일: 섹션 시작 부분으로부터의 오프셋(offset)입니다.
    • 실행 가능 파일: 절대적인 런타임 주소입니다.
  • size: 심볼(객체)의 크기(바이트 단위)입니다.
  • type: 보통 함수(function) 또는 데이터(data)입니다.
  • binding: 심볼이 지역(local)인지 전역(global)인지를 나타냅니다.
  • section: 심볼이 속한 섹션을 가리키는 인덱스(섹션 헤더 테이블 내의)입니다.

특수 의사 섹션 (Pseudosections)

재배치 가능 파일에만 존재하는 세 가지 특수 섹션이 있습니다.

  • ABS: 재배치되어서는 안 되는 심볼을 위한 섹션입니다.
  • UNDEF: 이 모듈에서는 참조하지만 다른 곳에 정의된, 정의되지 않은 심볼(undefined symbols)을 위한 섹션입니다.
  • COMMON: 아직 할당되지 않은 초기화되지 않은 데이터 객체를 위한 섹션입니다.

COMMON vs .bss

최신 gcc는 다음과 같은 규칙에 따라 심볼을 할당합니다.

  • COMMON: 초기화되지 않은 전역 변수
  • .bss: 초기화되지 않은 정적 변수 및 0으로 초기화된 전역/정적 변수

readelf를 이용한 심볼 테이블 확인 예제

GNU의 readelf 프로그램을 사용하면 목적 파일의 내용을 쉽게 확인할 수 있습니다. 아래는 main.o 파일의 심볼 테이블 항목 예시입니다.

Num: Value Size Type Bind Vis Ndx Name 8: 0000000000000000 24 FUNC GLOBAL DEFAULT 1 main 9: 0000000000000000 8 OBJECT GLOBAL DEFAULT 3 array 10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND sum

  • main: 전역 심볼(GLOBAL)이며, 24바이트 크기의 함수(FUNC)입니다. .text 섹션(Ndx=1)의 오프셋 0에 위치합니다.
  • array: 전역 심볼이며, 8바이트 크기의 객체(OBJECT)입니다. .data 섹션(Ndx=3)의 오프셋 0에 위치합니다.
  • sum: 이 모듈에서 정의되지 않은(Ndx=UND) 외부 심볼입니다.

7.6 심볼 해석 (Symbol Resolution)

링커는 입력으로 받은 재배치 가능 목적 파일들의 심볼 테이블을 참조하여, 각각의 심볼 참조(reference)를 정확히 하나의 심볼 정의(definition)와 연결하는 방식으로 심볼을 해석합니다.


지역 심볼의 해석

지역 심볼(Local Symbol)에 대한 참조를 해석하는 과정은 매우 간단합니다.

  • 컴파일러는 각 모듈 내에서 지역 심볼이 오직 한 번만 정의되도록 보장합니다.
  • 또한, 지역 링커 심볼이 되는 정적(static) 지역 변수들이 고유한 이름을 갖도록 처리합니다.

전역 심볼의 해석

반면, 전역 심볼(Global Symbol)을 해석하는 과정은 더 까다롭습니다.

  1. 정의를 찾지 못하는 경우 (Undefined Reference)C

    • 컴파일러는 현재 모듈에 정의되지 않은 심볼(변수나 함수)을 만나면, 일단 다른 모듈에 정의되어 있을 것이라 가정하고 링커에게 처리를 넘깁니다.
    • 만약 링커가 모든 입력 모듈을 다 확인해도 해당 심볼의 정의를 찾지 못하면, "정의되지 않은 참조(undefined reference)" 오류를 출력하고 종료합니다.
    // linkerror.c
    void foo(void); // 선언만 있고 정의가 없음
    
    int main() {
        foo();
        return 0;
    }

    위 코드를 컴파일하면 링커 단계에서 foo에 대한 정의를 찾을 수 없어 다음과 같은 오류가 발생합니다.
    undefined reference to ‘foo’

  2. 동일한 이름의 전역 심볼이 여러 개 정의된 경우

    • 여러 목적 파일이 같은 이름의 전역 심볼을 정의하는 경우, 링커는 오류를 보고하거나 혹은 여러 정의 중 하나를 선택하고 나머지는 버려야 합니다.
    • 리눅스 시스템이 채택한 방식은 컴파일러, 어셈블러, 링커 간의 협력을 통해 이루어지며, 이 때문에 부주의한 프로그래머에게는 매우 혼란스러운 버그를 유발할 수 있습니다.

번외: C++과 Java의 링커 심볼 맹글링(Mangling)

C++과 Java는 소스 코드에서 이름은 같지만 매개변수 리스트(parameter list)가 다른 메소드 오버로딩(overloaded methods)을 허용합니다. 그렇다면 링커는 이 서로 다른 오버로딩된 함수들을 어떻게 구별할까요?

C++과 Java의 메소드 오버로딩이 가능한 이유는 컴파일러가 각각의 고유한 메소드와 매개변수 리스트 조합을 링커를 위한 고유한 이름으로 인코딩(encoding)하기 때문입니다. 이 인코딩 과정을 맹글링(mangling)이라 하고, 그 반대 과정을 디맹글링(demangling)이라고 합니다.

다행히 C++과 Java는 호환되는 맹글링 방식을 사용합니다.

  • 클래스 이름: 이름에 포함된 문자의 개수(정수)와 원본 이름을 순서대로 붙여 인코딩합니다.
    • 예: 클래스 Foo3Foo로 인코딩됩니다.
  • 메소드 이름: [원본 메소드 이름] + __ + [맹글링된 클래스 이름] + [각 인자의 단일 문자 인코딩] 순서로 구성됩니다.
    • 예: Foo::bar(int, long) 메소드는 bar__3Fooil로 인코딩됩니다. (inti로, longl로 인코딩)

전역 변수나 템플릿 이름에도 비슷한 방식이 사용됩니다.

7.6.1 링커가 중복된 심볼 이름을 해결하는 방법

링커는 여러 목적 모듈을 입력으로 받는데, 만약 여러 모듈이 같은 이름의 전역 심볼을 정의하고 있다면 어떻게 처리할까요? 리눅스 컴파일 시스템은 다음과 같은 접근 방식을 사용합니다.


강한 심볼과 약한 심볼 (Strong and Weak Symbols)

컴파일 시, 컴파일러는 각 전역 심볼을 강한(strong) 심볼 또는 약한(weak) 심볼로 구분하여 어셈블러에게 전달하고, 어셈블러는 이 정보를 목적 파일의 심볼 테이블에 기록합니다.

  • 강한 심볼 (Strong Symbol): 함수와 초기화된 전역 변수.
  • 약한 심볼 (Weak Symbol): 초기화되지 않은 전역 변수.

중복 심볼 처리 규칙 (Rules for Handling Duplicate Symbols)

리눅스 링커는 강한 심볼과 약한 심볼의 개념을 바탕으로 다음과 같은 규칙에 따라 중복된 심볼 이름을 처리합니다.

  1. 규칙 1: 같은 이름의 강한 심볼이 여러 개 있으면 허용되지 않습니다. (링커 오류 발생)
  2. 규칙 2: 하나의 강한 심볼과 여러 개의 약한 심볼이 있으면, 강한 심볼을 선택합니다.
  3. 규칙 3: 여러 개의 약한 심볼만 있으면, 그중 아무거나 하나를 선택합니다.

규칙 적용의 위험성

이러한 규칙, 특히 규칙 2와 3은 프로그래머가 예상치 못한 미묘한 런타임 버그를 유발할 수 있습니다.

  • 의도치 않은 값 변경: 한 모듈에서 초기화된 강한 심볼 x가 다른 모듈의 초기화되지 않은 약한 심볼 x의 값을 덮어쓰게 됩니다. 이로 인해 main 함수 작성자의 의도와 다른 결과를 초래할 수 있습니다. (foo3.c, bar3.c 예제)
  • 메모리 오염 (Memory Corruption): 가장 위험한 경우는 중복 정의된 심볼의 타입이 다를 때입니다. (foo5.c, bar5.c 예제)
    • foo5.cint x = 15213; (4바이트, 강한 심볼)
    • bar5.cdouble x; (8바이트, 약한 심볼)
    • 링커는 규칙 2에 따라 강한 심볼 int x를 선택하고 주소를 할당합니다.
    • 하지만 런타임에 bar5.c의 코드(x = -0.0;)가 실행되면, 링커가 할당한 주소에 8바이트 크기의 double을 덮어쓰게 됩니다.
    • 이 과정에서 원래 할당된 int x(4바이트)의 공간뿐만 아니라, 그 뒤에 위치한 변수 y의 메모리 공간까지 침범하여 값을 훼손시키는 치명적인 버그가 발생합니다.

이런 버그는 링커가 오류가 아닌 경고만 띄우고, 문제가 발생한 위치와 멀리 떨어진 곳에서 증상이 나타나기 때문에 원인을 찾기가 매우 어렵습니다. 이를 방지하려면 -fno-common 플래그(중복 정의를 오류로 처리)나 -Werror 플래그(모든 경고를 오류로 처리)를 사용하여 링크하는 것이 좋습니다.


COMMON과 .bss 할당 규칙의 이유

이러한 링커의 동작 방식 때문에 컴파일러는 심볼을 COMMON.bss에 할당할 때 독특한 규칙을 따릅니다.

  • 컴파일러는 초기화되지 않은 전역 변수 (약한 심볼)를 만났을 때, 다른 모듈에도 같은 이름의 심볼이 있을지 알 수 없습니다. 어떤 심볼이 선택될지 예측할 수 없으므로, 결정을 링커에게 미루고 해당 심볼을 COMMON 블록에 할당합니다.
  • 반면, 0으로 초기화된 변수(강한 심볼)static 변수는 유일함이 보장되므로, 컴파일러는 확신을 갖고 이를 .bss.data에 직접 할당할 수 있습니다.

7.6.2 정적 라이브러리와의 링킹 (Linking with Static Libraries)

실제로 모든 컴파일 시스템은 연관된 목적 모듈들을 정적 라이브러리(static library)라는 단일 파일로 묶는 메커니즘을 제공합니다. 링커는 실행 파일을 빌드할 때, 애플리케이션 프로그램이 참조하는(referenced) 라이브러리 내의 목적 모듈들만 복사하여 포함시킵니다.


라이브러리가 필요한 이유

printf, strcpy, sin, cos와 같은 수많은 표준 함수들을 어떻게 사용자에게 제공할 수 있을까요? 정적 라이브러리가 없다면 다음과 같은 방법들을 고려할 수 있지만, 모두 단점이 명확합니다.

  1. 컴파일러에 내장: 컴파일러가 표준 함수 호출을 직접 인식하고 코드를 생성하는 방식입니다.
    • 문제점: C언어의 표준 함수는 너무 많아서 컴파일러가 지나치게 복잡해지고, 함수가 추가/수정될 때마다 새 버전의 컴파일러가 필요합니다.
  2. 단일 목적 파일 (libc.o): 모든 표준 함수를 하나의 거대한 목적 파일에 넣어두고 링크하는 방식입니다.
    • 문제점 1 (공간 낭비): 사용하지 않는 함수까지 모두 포함되어 실행 파일의 크기가 불필요하게 커지고, 실행 시 메모리를 심각하게 낭비합니다.
    • 문제점 2 (유지보수): 단 하나의 함수만 수정해도 전체 라이브러리 소스 파일을 다시 컴파일해야 합니다.
  3. 개별 목적 파일 (printf.o, scanf.o ...): 각 함수를 별도의 목적 파일로 만들어 제공하는 방식입니다.
    • 문제점: 프로그래머가 자신이 사용하는 모든 함수의 목적 파일을 일일이 명시하여 링크해야 하므로 매우 번거롭고 오류가 발생하기 쉽습니다.

정적 라이브러리: 완벽한 해결책

정적 라이브러리는 위 문제점들을 해결하기 위해 개발되었습니다.

  • 연관된 함수들을 개별 목적 모듈(.o)로 컴파일한 뒤, ar 도구를 사용하여 하나의 아카이브(archive) 파일(.a)로 묶습니다.
  • 프로그래머는 단지 몇 개의 라이브러리 파일 이름만 링크 명령어에 포함시키면 됩니다.
    • linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a
  • 핵심 장점: 링커는 라이브러리 전체가 아닌, 프로그램이 실제로 참조하는 심볼이 정의된 목적 모듈만을 선별하여 실행 파일에 복사합니다.
    • 디스크와 메모리 공간을 모두 절약할 수 있습니다.

정적 라이브러리 생성 및 사용법

리눅스 시스템에서 정적 라이브러리는 아카이브(archive)라는 특별한 파일 형식으로 저장되며, .a 확장자를 가집니다.

  1. 생성 (Creation)
    • linux> gcc -c addvec.c multvec.c (소스 파일을 목적 파일로 컴파일)
    • linux> ar rcs libvector.a addvec.o multvec.o (ar 도구로 목적 파일들을 묶어 아카이브 생성)
  2. 사용 (Usage)
    • linux> gcc -static -o prog2c main2.o ./libvector.a (직접 파일 경로 지정)
    • linux> gcc -static -o prog2c main2.o -L. -lvector (플래그 사용)
      • static: 완전하게 링크된 실행 파일을 생성하라는 옵션입니다.
      • lvector: libvector.a 라이브러리를 링크하라는 의미의 축약형입니다.
      • L. : 현재 디렉토리(. )에서 라이브러리를 찾으라는 의미입니다.

링커의 동작 방식

위 예시에서 링커는 다음과 같이 동작합니다.

  1. main2.oaddvec 심볼을 참조하는 것을 확인합니다.
  2. libvector.a 라이브러리 내부를 탐색하여 addvec.oaddvec 심볼을 정의하고 있음을 발견합니다.
  3. addvec.o 모듈 전체를 실행 파일에 복사합니다.
  4. 프로그램이 multvec.o의 심볼은 전혀 참조하지 않으므로, multvec.o 모듈은 실행 파일에 포함시키지 않습니다.

7.6.3 링커가 정적 라이브러리를 사용하여 참조를 해결하는 방법

정적 라이브러리는 유용하지만, 링커가 외부 참조를 해결하는 방식 때문에 프로그래머에게 혼란을 주기도 합니다. 핵심은 링커의 순차적 처리 방식에 있습니다.


링커의 참조 해결 알고리즘

링커는 컴파일러 드라이버의 커맨드 라인에 명시된 순서대로 재배치 가능 목적 파일(.o)과 아카이브(.a)를 왼쪽에서 오른쪽으로 스캔합니다. 이 과정에서 링커는 세 가지 집합을 유지 관리합니다.

  • E: 최종 실행 파일을 구성할 목적 파일들의 집합
  • U: 아직 정의를 찾지 못한, 해결되지 않은 심볼(Unresolved symbols)들의 집합
  • D: 이전 파일들에서 이미 정의된 심볼(Defined symbols)들의 집합

링커는 커맨드 라인의 각 파일 f에 대해 다음과 같이 동작합니다.

  1. f가 목적 파일(.o)일 경우:
    • fE에 추가합니다.
    • f의 심볼 정의와 참조를 반영하여 UD를 업데이트합니다.
    • 다음 파일로 넘어갑니다.
  2. f가 아카이브(.a, 라이브러리)일 경우:
    • 현재 U에 있는 미해결 심볼들과 아카이브 내 멤버(.o 파일)들이 정의하는 심볼들을 맞춰봅니다.
    • 만약 어떤 멤버 mU에 있는 심볼의 정의를 제공한다면, 그 멤버 mE에 추가하고 UD를 업데이트합니다.
    • 이 과정은 더 이상 U에 있는 심볼을 해결할 수 없을 때까지 아카이브 내에서 반복됩니다.
    • E에 포함되지 않은 나머지 멤버들은 그냥 버려집니다.
  3. 스캔 종료 후: 모든 파일을 스캔한 후에도 U가 비어있지 않다면, 링커는 오류를 출력하고 종료합니다. 그렇지 않으면 E에 있는 목적 파일들을 병합하고 재배치하여 최종 실행 파일을 생성합니다.

순서의 중요성: 흔한 링커 오류의 원인

이 알고리즘 때문에 커맨드 라인에 라이브러리와 목적 파일의 순서를 어떻게 지정하는지가 매우 중요해집니다.

만약 심볼을 정의하는 라이브러리가 그 심볼을 참조하는 목적 파일보다 먼저 나오면, 참조가 해결되지 않아 링크 오류가 발생합니다.

# 잘못된 순서
linux> gcc -static ./libvector.a main2.c
... undefined reference to ‘addvec’

오류 원인: 링커가 libvector.a를 처리할 시점에는 U(미해결 심볼 집합)가 비어있습니다. 따라서 libvector.a의 어떤 멤버도 E에 추가되지 않고 그냥 넘어갑니다. 그 후에 main2.c를 처리하면서 addvec 심볼이 U에 추가되지만, 이미 libvector.a 스캔이 끝났기 때문에 addvec의 정의를 찾을 수 없게 됩니다.


올바른 라이브러리 사용 규칙

  • 일반 규칙: 라이브러리는 항상 커맨드 라인의 끝 부분에 위치시켜야 합니다.
    linux> gcc [모든 .o 파일들] [모든 .a 라이브러리들]
  • 라이브러리 간 의존성: 만약 라이브러리끼리 서로의 함수를 호출하는 의존성이 있다면, 심볼을 사용하는 라이브러리가 심볼을 정의하는 라이브러리보다 먼저 와야 합니다.
    • libx.aliby.a의 함수를 호출한다면: ... libx.a liby.a 순서여야 합니다.
  • 순환 의존성: 만약 libx.aliby.a를 호출하고, liby.a가 다시 libx.a를 호출하는 경우, 라이브러리를 반복해서 명시해야 할 수 있습니다.
    • linux> gcc foo.c libx.a liby.a libx.a




RB Tree

여기서 RB Tree 정리 확인할 수 있다!

profile
멈추지 않기

0개의 댓글