Computer Architecture/CS APP

[CS] 링킹, 목적 파일과 심볼 해석 (Linking)

  • -

링킹(linking)은 여러 개의 코드와 데이터를 모아서 연결하여

메모리에 로드될 수 있고, 실행될 수 있는 한 개의 파일로 만드는 작업이다.

 

컴파일 시 수행할 수 있으며 이때 소스코드는 머신코드로 번역된다.

주로 링커(linker)에 의해 실행되며 이는 각 소스파일들에 대해 독립적인 컴파일을 가능하게 한다.

 

컴파일러 드라이버

대부분의 컴파일 시스템은 사용자를 대신해서 언어 전처리기, 컴파일러, 어셈블러, 링커를 필요에 따라 호출하는 컴파일러 드라이버를 제공한다. (ex. gcc)

 

위 그림은 컴파일러 드라이버가 ASCII 소스 파일에서 Figure 7.1 프로그램을

실행 목적파일로 번역할 때 드라이버의 동작 내용을 요약한 것이다.

  1. C 전처리기(cpp)를 돌리고 소스 파일 main.c를 ASCII 중간 파일인 main.i (전처리 완료된 소스코드)로 번역한다.
  2. C 컴파일러(cc1)를 돌려서 main.i를 ASCII 어셈블리 언어 파일인 main.s로 번역한다.
  3. 어셈블러(as)를 돌려서 main.s를 재배치 가능한 바이너리 목적파일인 main.o로 번역한다.
  4. 링커 프로그램(ld)를 실행하여 필요한 시스템 목적파일들과 함께 실행 가능 목적파일을 생성하기 위해 main.o와 sum.o를 연결한다.
  5. 프로그램을 실행하면 로더(loader)라고 부르는 운영체제 내의 함수를 호출하며, 이는 실행파일의 코드와 데이터를 메모리로 복사하고, 제어를 프로그램의 시작 부분으로 전환한다.

 

정적 연결

정적 링커(ld) 들은 재배치 가능한 목적파일(.o)들과 명령줄 인자들을 입력으로 받아 로드될 수 있고,

실행될 수 있는 완전히 링크된 실행 가능 목적파일(a.out)을 출력으로 생성한다.

 

입력인 재배치 가능 목적파일들은 다양한 코드와 데이터 섹션(연속적인 바이트)로 이루어져 있다.

목적파일들을 단지 바이트 블록들의 집합이다.

이 블록들 중 일부는 프로그램 코드를 포함하고, 다른 블록들은 프로그램 데이터를, 또 다른 블록들은 링커와 로더를 안내하는 데이터 구조를 포함한다.

 

링커는 블록들을 함께 연결하고 런타임 위치를 결정하며, 코드와 데이터 블록 내에 여러가지 위치를 수정한다.

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

 

  1. 심볼 해석 (symbol resolution)
    : 심볼 해석의 목적은 심볼 참조를 정확하게 하나의 심볼 정의에 연결하는 것이다.
    목적파일들은 심볼들을 정의하고 참조한다. 각 심볼은 함수, 전역변수, 정적변수에 대응된다.
  2. 재배치 (relocation)
    : 링커는 이 섹션들을 각 심볼 정의와 연결시켜서 재배치하며,이는 ‘재배치 엔트리’라고 부르는 어셈블러가 생성한 지시사항들을 따른다.
     심볼들로 가는 모든 참조들을 수정해서 이들이 이 메모리 위치를 가리키도록 한다.
    컴파일러와 어셈블러는 주소 0번지에서 시작하는 코드와 데이터 섹션들을 생성한다.

 

 

목적 파일

목적파일에는 세 가지 형태가 있다.

  1. 재배치 가능 목적파일 (Relocatable object file) [.o]
    포맷에 컴파일 할 때 실행 가능 목적파일을 생성하기 위해 
    다른 재배치 가능 목적파일들과 결합될 수 있는 바이너리 코드와 데이터를 포함
  2. 실행 가능 목적파일 (Executable object file) [a.out]
    메모리에 직접 복사될 수 있고 실행될 수 있는 형태로 바이너리 코드와 데이터를 포함
  3. 공유 목적파일 (Shared object file) [라이브러리]
    로드타임 또는 런타임 시에 동적으로 링크되고 메모리에 로드될 수 있는 특수한 유형의 재배치 가능 목적파일

컴파일러와 어셈블러는 재배치 가능 목적파일을 생성한다. (공유 목적파일 포함)

링커는 실행 가능한 목적파일을 생성한다.

 

목적파일은 디스크에 파일로 저장된 목적 모듈(바이트 배열)이다.

목적파일들은 특정 형식에 따라 구성되며, 이들은 시스템에 따라 다르다.

 

최초 Unix 시스템은 a.out 포맷을 사용하고 (지금까지도),

현대의 x86-64 리눅스와 유닉스 시스템들은 ELF (Executable and Linkable Format)을 사용한다.

 

 

재배치 가능 목적파일

위 그림은 전형적인 ELF 재배치 가능 목적파일의 포맷을 보여준다.

 

ELF 헤더는 이 파일을 생성한 워드 크기와 시스템의 바이트 순서를 나타내는 16바이트 배열이다.

ELF 헤더의 나머지는 링커가 목적파일을 구문분석하고 해석하도록 하는 정보를 포함한다.

(ELF 헤더 크기, 목적파일 타입, 머신 타입, 섹션 헤더 테이블의 파일 오프셋, 크기, 엔트리 수 등)

 

여러가지 섹션들의 위치와 크기는 섹션 헤더 테이블로 나타내며,

이 테이블은 목적파일의 각 섹션에 대해 고정된 크기의 엔트리를 갖는다.

ELF 헤더와 섹션 헤더 테이블 사이에는 섹션 내용이 들어있다.

 

1. .text

    : 컴파일된 프로그램의 머신 코드

2. .rodata

    : printf 문장의 포맷 스트링, switch문의 점프 테이블과 같은 읽기-허용 데이터

3. .data

    : 초기화된 C 전역 및 정적변수, C 지역변수들은 런타임에 스택에 저장되며 .data.bss 섹션에는 나타나지 않는다.

4. .bss

    : 초기화되지 않았거나, 0으로 초기화된 C 전역 및 정적변수.
    이 섹션은 단순히 위치를 표시할 뿐 목적파일에 실제 공간을 차지하지 않는다.
    목적파일 포맷은 공간 효율성을 위해 초기화된 공간과 초기화되지 않은 변수들을 구분한다.

    런타임에 이 변수들은 메모리에 0으로 초기화되어 할당된다.

5. .symtab

    : 프로그램에서 정의되고 참조되는 전역변수들과 함수에 대한 정보를 가지고 있는 심볼 테이블.

    모든 재배치 가능 목적 파일.symtab에 심볼 테이블을 가지고 있다.

    그러나 .symtab 심볼 테이블은 지역변수에 대한 엔트리를 가지고 있지 않다.

6. .rel.text

    : 링커가 이 목적파일을 다른 파일들과 연결할 때, 수정되어야 하는 .text 섹션 내 위치들의 리스트.

    일반적으로 외부 함수 호출이나 전역 변수 참조를 참조하는 인스트럭션은 모두 수정되어햐 한다.

    재배치 정보는 실행 가능 목적파일에는 필요하지 않아서 명시적으로 포함하라고 시키지 않으면 정보가 빠진다.

7. .rel.data

    : 이 모듈에 의해 정의되거나 참조되는 전역변수들에 대한 재배치 정보.

    일반적으로 초기값이 전역변수 또는 외부에 정의된 함수의 주소인 전역변수들은 모두 수정돼야 한다.

8. .debug

    : 프로그램 내에서 정의된 지역변수들과 typedef

    프로그램 최초 C 소스 파일에서 정의되고 참조되는 전역변수들을 위한

    엔트리를 갖는 디버깅 심볼 테이블. (-g 옵션으로 불린 경우에 생성된다.)

9. .line

    : 최초 C 소스 프로그램과 .text 섹션 내 머신 코드 인스트럭션 내 라인 번호들간의 맵핑

    (-g 옵션으로 불린 경우에 생성된다.)

10. .strtab

    : .strtab.debug 섹션들 내에 있는 심볼 테이블과 섹션 헤더들에 있는 섹션 이름들을 위한 스트링 테이블 (null terminated)

 

 

심볼과 심볼 테이블

재배치 가능 목적 파일(이하 m)은 m에 의해서 정의되고 참조되는

심볼들에 대한 정보를 포함하는 심볼 테이블을 가지고 있다.

세 가지 서로 다른 종류의 심볼이 존재한다.

  1. m에 의해 정의된 전역 심볼 (다른 파일에서 참조 가능)
    전역 링커 심볼은 비정적 C 함수와 전역변수들에 대응된다.
  2. 다른 파일에 의해 정의된 전역 심볼 (m에서 참조 가능)
    이를 extern이라고 부르며, 다른 파일 내에서 정의된 전역변수들과 비정적 C 함수들에 대응된다.
  3. m에 의해서 독점적으로 참조되고 정의된 지역 심볼
    이는 m 내에서는 어디서나 접근 가능하지만, 다른 파일에서는 접근할 수 없다.
    이들은 static 타입 선언으로 정의된 정적 C 함수와 전역변수들에 대응된다.

지역 심볼(지역 링커 심볼)들이 지역 변수와는 같지 않다는 것을 주의하자.

.symtab에 있는 심볼 테이블은 비정적 지역 변수들에 대응되는 심볼을 포함하지 않는다.

이들은 런타임에 스택에 의해서 관리되며, 링커는 관여하지 않는다.

 

반면에 C의 static 타입 지역 변수들은 스택에서 관리되지 않는다.

컴파일러는 .data.bss에서 각 정의에 대해 공간을 할당하며,

심볼 테이블 내 지역 링커 심볼들을 유일한 이름을 갖도록 생성한다.

 

 

위의 경우에는 컴파일러가 서로 다른 이름의 한 쌍의 지역 링커 심볼을 어셈블러에 보낸다.

f 함수 안에는 x.1을, g 함수 안에는 x.2를 정의할 수 있다.

 

심볼 테이블은 어셈블리어 파일에 export된 심볼을 사용해서 어셈블러에 의해 만들어진다.

ELF 심볼 테이블은 .symtab 섹션에 들어 있고 아래와 같이 엔트리들의 배열을 포함하고 있다.

 

 

name은 null terminated 심볼의 string 이름을 가리키는 string 테이블 내 바이트 offset이다.

value는 심볼의 주소다.

재배치 가능 목적파일value는 객체가 정의된 섹션 시작 부분의 offset 값이다.

실행 가능 목적파일value는 절대 런타임 주소다.

size는 객체의 크기(byte)이며,

type은 대개 데이터 또는 함수다.

binding 필드는 심볼이 지역인지 전역인지 나타낸다.

심볼 테이블은 또한 각 섹션들과 오리지널 소스 파일의 경로 이름에 대한 엔트리를 포함할 수 있다.

그래서 이 객체들에 대한 서로 다른 타입들도 존재한다.

 

각 심볼은 섹션 헤더 테이블 내 인덱스인 section 필드로 표시한 목적파일의 일부 섹션에 연관되어 있다.

여기에는 섹션 헤더 테이블에 엔트리가 없는 세 개의 특별한 pseudo section이 존재한다.

 

  1. ABS
    : 재배치해서는 안 되는 심볼
  2. UNDEF
    : 정의되지 않는 심볼들, 이 목적 파일에서는 참조만 되고 다른 곳에서 정의된 심볼
  3. COMMON
    : 아직 할당되거나 초기화되지 않은 데이터 객체
    COMMON 심볼들의 value 필드는 정렬 요건을 제시하며, size는 최소 크기를 알려준다.
    이 의사 섹션들은 재배치 가능 목적파일에만 존재한다.

COMMON과 .bss 간의 차이는 미세하다. gcc는 아래와 같은 관례에 따라 할당한다.

- COMMON : 초기화하지 않은 전역변수들

- .bss : 초기화하지 않은 정적변수들과 0으로 초기화된 전역변수나 정적변수들

 

 

심볼 해석

심볼의 해석은 동일한 모듈 내에 정의된 지역 심볼들로 참조를 한 경우에 대해서는 간단하다.

컴파일러는 모듈당 단 하나의 지역 심볼 정의만을 허용한다.

또한 지역 링커 심볼들을 갖게 되는 정적 지역변수들이 유일한 이름을 갖도록 보장한다.

 

그러나 전역 심볼들에 대한 참조를 해석하는 것은 좀 더 까다롭다.

컴파일러가 현재 모듈에서 정의되지 않은 심볼을 만나면,

이것이 다른 모듈에서 정의되어 있다고 가정하고 링커 심볼 테이블 엔트리를 생성하며,

링커가 이것을 처리하도록 남겨둔다.

 

만일 링커가 자신의 입력 모듈 중에 어디에서라도 참조된 심볼을 위한 정의를 찾을 수 없다면

에러 메시지를 출력하고 종료한다.

 

 

예를 들어 위의 코드를 컴파일하면

 

 

링커가 foo()에 대한 참조를 해석할 수 없을 때 종료한다.

 

또한 전역 심볼들에 대한 심볼의 해석은 까다로운데,

그것은 여러 개의 오브젝트 파일들이 같은 이름을 갖는 글로벌 심볼들을 정의할 수도 있기 때문에

이 경우에 링커는 에러를 출력하거나 하나의 정의를 선택하고 나머지를 무시한다.

 

링커가 중복으로 정의된 전역 심볼을 해결하는 방법

링커의 입력은 여러 개의 재배치 가능 목적 파일이다.

만약 여러 개의 오브젝트 파일들이 같은 이름을 갖는 글로벌 심볼들을 정의한다면,

컴파일러가 각 전역 심볼을 어셈블러로 강하게 또는 약하게 보내며,

어셈블러는 이 정보를 재배치 가능 목적파일의 심볼 테이블에 묵시적으로 인코딩한다.

(함수들과 초기화된 전역변수는 강한 심볼, 비초기화 전역변수는 약한 심볼)

  1. 동일한 이름을 갖는 복수의 강한 심볼은 허용되지 않음
  2. 동일한 이름의 강한 심볼과 약한 심볼이 있으면, 강한 심볼을 선택
  3. 동일한 이름을 갖는 복수의 약한 심볼은 어떤 심볼을 선택해도 관계없음

 

 

위의 케이스는 int x = 15213강한 심볼이고

double x약한 심볼이라서 무시될 예정이다.

하지만 bar5.c에서 x에 -0.0을 대입하는 바람에, foo5.c의 x, y 공간에 8바이트 double 0을 대입한다.

 

 

이는 안 좋은 버그지만 링커는 경고만 하고, 에러는 나중에 실행 중에 드러낼 수 있기 때문에 버그 수정에 애를 먹을 수 있다.

gcc -fno-common 으로 중복 정의된 전역심볼을 발견하면 에러를 출력해준다.

혹은 -werror 옵션으로 모든 경고 메시지를 에러로 변환시킬 수 있다.

 

정적 라이브러리와 링크하기

정적 라이브러리: 관련된 파일들을 하나의 파일로 패키징하는 메커니즘

이 라이브러리는 다음에 링커의 입력으로 제공될 수 있다.

 

출력 실행파일을 만들 때, 링커는 응용프로그램이 참조하는 라이브러리 내의 객체 모듈만을 복사한다.

 

라이브러리라는 개념이 생겨난 이유는 컴파일러가 표준 함수들로의 모든 호출을 인식하고 직접 적절한 코드를 생성하는 것이

표준 함수들이 적은 파스칼 언어에서는 가능했지만, C 표준은 많이 정의되어있어서 컴파일러에 상당한 복잡성을 더하게 된다.

또한, 시스템의 모든 실행파일들이 표준 함수들의 전체 복사본을 포함하며 이는 디스크 낭비다.

라이브러리의 사용으로 이를 해결하고 응용프로그램들은 명령줄에 한 개의 파일 이름만 명시해서 라이브러리 내에 정의된 어떤 함수라도 사용할 수 있다.

 

리눅스에서 정적 라이브러리는 아카이브라고 알려진 특정 파일 포맷으로 저장된다.

아카이브는 연결된 재배치 가능 목적파일의 집합으로

헤더는 각 멤버 목적파일의 크기와 위치를 기술한다.

 

 

위의 두 함수는 아래와 같이 정적 라이브러리로 만들 수 있고,

 

 

 

 

아래와 같이 만든 라이브러리를 사용할 수 있다.

 

 

또한 동등하게 아래와 같이도 사용 가능하다.

 

 

vector.h에는 libvector.a의 함수 프로토타입을 정의하고 있다.

-static 인자는 컴파일러 드라이버에게 메모리에 적재될 수 있고, 로드 시에 추가적인 링킹 과정이 필요 없이 돌아가도록 완전히 링크된 실행 가능 목적파일을 링커가 만들어야 한다는 것을 의미한다.

-lvector 인자는 libvector.a를 줄인 것이다.

-L. 인자는 현재 디렉토리에서 링커가 libvector.a를 찾으라고 알려준다.

 

 

링커가 실행되면 addvec.o에서 정의된 심볼이 main2.o에서 참조되는지 결정하고 addvec.o를 실행파일에 복사한다.

프로그램이 mulvec.o에 의해 정의된 심볼을 참조하지 않아서 실행파일에 복사하지 않는다.

 

또한 C 런타임 시스템으로부터 다른 모듈들과 함께 printf.o를 libc.a에 복사한다.

 

링커가 참조를 해석하기 위해 정적 라이브러리를 사용하는 방법

리눅스 링커는 정적 라이브러리를 사용해서 외부 참조를 해석하는데 사용한다.

심볼 해석 단계 동안에 링커는 파일들과 컴파일러 드라이버의 명령줄에 나타난 것과 같은

순차적인 순서로 좌에서 우로 재배치 가능 목적파일들과 아카이브들을 스캔한다.

 

이 스캔 과정에서 링커는 실행파일을 구성하기 위해 합쳐질 재배치 가능 목적파일들의 집합 E와 미해석 심볼 집합 U, 이전 입력파일에서 정의된 심볼 집합 D를 유지한다. 처음에는 E, U, D가 모두 비어있다.

  1. 입력파일 f에 대해서 링커는 f가 목적파일 또는 아카이브인지 결정한다.
  2. f가 목적파일이면 링커는 f를 E에 추가하고 U와 D도 갱신한다.
  3. f가 아카이브면 링커는 U 안의 심볼들을 아카이브의 심볼들과 매칭하려고 한다.
    아카이브가 이를 일부 해결하면 그 일부 멤버들을 E에 추가되고 U와 D를 갱신한다.
    U와 D가 더이상 바뀌지 않는 지점까지 반복하고 그 시점까지 E에 포함되지 않은 멤버들은 버린다.
  4. 링커가 입력파일들을 스캔하는 작업을 끝마칠때,
    U가 비어있지 않다면 에러를 출력하고, 아니면 E에 있는 목적파일을 합치고 재배치해서 출력 실행파일을 작성한다.

하지만 이 과정은 라이브러리와 목적파일의 순서가 중요하다.

다음과 같이 라이브러리가 심볼을 참조하는 목적파일 전에 나타난다면, 이 참조는 해결되지 않고 실패한다.

 

 

이처럼 라이브러리에 대한 일반적인 규칙은, 이들을 명령줄 마지막에 두는 것이다.

또한 라이브러리들이 의존적이라면, 순서를 지켜야한다.

 

 

foo.clibx.a에 들어있는 함수를 호출하고, 그 함수는 libz.a 함수를 호출하고…

또다른 방법으로는 이들을 하나의 아카이브로 묶을 수도 있다.

 

『Computer Systems: A Programmer's Perspective』 책을 읽고 정리한 글입니다.
검색을 허용하지 않고, 수익을 창출하지 않습니다.
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.