프로그래밍/부록

@C++ 링킹에 대한 이해

코딩하는상후니 2022. 7. 27. 23:47

 

 

 


 

 

 

*링킹 ( Linking )

 
 
*Linker 는  왜 필요한 것일까 ??
 
=> 모든 obj 파일들을 하나로 합치는 역할.
=> 컴파일 단계에서,
TU 는 독립적으로 실행되기 때문에 합쳐졌을 때의 최종적인 위치를 모른다.
 
 
모든 obj 파일을 하나로 합치는 과정과 동시에
심볼들의 정확한 위치를 확정시키는 역할을 링킹에서 수행한다.
 
 
간단히 말해서,
printf("Hello World"); 에서
printf 는 stdio.h 안에 printf 가 구현되어있다.
 
즉,
컴파일 단계까지는 printf 의 구현부가 어디있는지 알 수 없다.
printf 를 호출한 코드 ( obj ),  printf 가 정의된 코드 ( library )
이 둘을 합치는 과정이 필요.
 
 
또한, 데이터 영역의 전역변수도 마찬가지이다.
현재 각 obj 에 해당하는 전역변수의 위치는
합쳐졌을 때의 최종적인 데이터 영역의 위치가 아니다.
 
 
 
 
*심볼 ( Symbol ) 이란 ??
 
=> 함수, 전역 변수, static 변수 등을 의미.
=> 지역 변수는 포함하지 않음. ( 지역변수는 스택에서 관리. )
 
 
 
 
 
 
*library 란 ??
 
=> 프로그램이 동작하기 위해 필요한 외부 목적 코드들
 
 
 
 
 
링킹 과정에 앞서,
심볼 ( Symbol ) 을 어떻게 표현하는지 몇 가지 개념을 알아보자.
 
 
 

 

 

*저장 방식 지정자

 
=> 심볼들의 위치를 정할 때, 어떤 방식으로 저장할지 알려주는 키워드
 
 
1. static
 
 
2. thread_local
 
 
3. extern
 
 
4. mutable  (  저장 기간,  링크 방식에 영향을 주진 않음. )
 
 
=> 해당 키워드들로 저장 기간 ( Storage duration )  과 링크 방식 ( Linkage ) 지정 가능.
 
 
 
 
 
 
 
 

*저장 기간

 
 
1. 자동 저장 기간
=> {  }  해당 코드블록 안에 있는 지역 변수
=> static, thread_local, extern '이외의 모든 지역 객체' 들이 가지게 됨.
 
 
 
2. static 저장 기간
=> 프로그램 시작할 때 할당, 끝날 때 소멸.
=> 여러 쓰레드에서 같은 함수를 실행해도 static 변수들은 유일하게 존재.
=> static 키워드와 static 저장 기간은 다른 것, 혼동하면 안됨.

 

// 모두 static 저장기간 이다.
extern int Gvalue; 
static int Ga = 10;
int Gaa;

 

 

 

3. 스레드 (thread) 저장 기간

=> 스레드 시작 시, 할당 되고 스레드 종료될 때 소멸.

 

extern thread_local uint32 LThreadId;

 

 

 

4. 동적 (dynamic) 저장 기간

=> 동적 할당 함수를 통해 할당되고 해제되는 객체들.
=> new  /  delete 로 정의되는 객체들.
 
 
 
 
 
 
 
 
 
 

*링크 방식 ( Linkage )

 
 
 
 
1. 링크 방식 없음 ( No linkage )
 
=> 블록 스코프 {  }  안에 정의되어있는 변수들 ( 지역변수 )
int main() 
{ 
	int a = 10; 
	return 0; 
}

 

 

 

 

2. 내부 링크 방식 ( Internal Linkage )

 
=> 같은 TU 범위 안에서만 참조 가능.
 
=> 외부에서는 내부 링크 방식으로 선언된 것들에 접근할 수 없다.
ex) 다른 프로젝트에서 해당 함수, 변수를 쓸 수 없음.

 

static int AA = 10; 
namespace SH 
{ 
	static int SHnumber = 10; 
	int CC = 1; 
}

 

 

 

 

 

3. 외부 링크 방식 ( External Linkage )
 
=> 해당 방식으로 정의된 개체들은 '언어 링크 방식' 을 정의 가능해
다른 언어 사이 ( C 와 C++ ) 사이에서 함수를 공유하는 것이 가능.
 
=> extern 키워드 를 사용해 외부 링크 방식 선언 가능.
=> 라이브러리에서 우리가 접근할 수 있는 것들이 extern 키워드로 되어있다.

 

extern "C" int func();  // C 및 C++ 에서 사용할 수 있는 함수.

// C++ 에서만 사용할 수 있는 함수. 
// 디폴트로 extern "C++" 이 붙어있다. 
extern "C++" int func2(); 
int func2();  // 위와 동일

 

 

 

 

 

*extern "C" 의 의미

 
 
=> C 컴파일러와 C++ 컴파일러가 함수 이름을 변환하는 방식이 다름.
따라서, extern "C" 는 'C 컴파일러가 변환하는 방식으로 변환해라' 라는 의미.
 
=> C++ 에선 매개변수만 다른 같은 이름의 함수를 정의 가능하고
다른 namespace , class 의 함수들 이름이 같을 수 있기 때문.
 
 
C 컴파일러  :  함수 이름을 그대로 따옴. ( 함수 오버로딩 불가. )
C++ 컴파일러  :  '이름 맹글링 ( Name mangling ) ' 을 통해 다른 이름을 만들어줌.
 
 
 
 
 
 
 

*재배치 ( Relocation )

 

 

 

@재배치 ( Relocation )
 
=> 링킹 과정에서 기본적으로 코드 섹션, 데이터 섹션을 만들어내는데
사용될 심볼들을 데이터 섹션에 적절한 가상 주소로 정의하고
해당 심볼을 참조하고 있는 심볼들, 명령어들이
올바른 심볼 정의를 가리킬 수 있도록 해주는 작업.
 
 
위 그림을 예로 들면, 대략적으로
foo_bar 라는 전역 변수의 위치가 심볼 테이블에 정의되어진다.
 
그리고 foo_bar 가 실행되는 코드 영역에
'foo_bar 라는 변수는 현재 위치에서 offset 만큼 떨어져있다.' 라고 표시되어짐.
해당 코드가 foo_bar 변수를 찾아갈 수 있도록 함.
 
 
=> 이러한 작업들은 재배치 가능 오브젝트 파일 안에 담기는
'재배치 엔트리' 들의 정보를 바탕으로 수행된다.
 
 
 
 
 
 

@재배치 개체 ( Relocation entries )

 
이전 컴파일 단계에서도 말했다시피,
어셈블러가 obj 파일을 만들 때, 심볼들의 위치가 최종적인 위치가 아니라했다.
그 심볼들을 참조하고 있는 명령어들이 존재하는데
이러한 명령어들을 만날 때마다 '재배치 개체 ( Relocation entries )' 를 생성한다.
 
=> 코드 영역의 재배치 개체는 .rel.text 에 위치.
=> 외부 함수, 정적 변수 들의 참조로 초기화된 변수의 재배치 개체는 .rel.data 에 위치.
=> 각 재배치 개체마다 미리 재배치 타입을 판별.
 
 
 
 
 

@재배치 타입

 
 
재배치 개체는 링커가 실행파일로 합칠 때,
해당 데이터가 어떤식으로 처리되어야하는지에 대한 정보를 준다.

 

//1. offset to the ref to relocate
// -> 정의된 심볼 위치와의 거리
//2. symbol the ref should point to
// ->  심볼을 가리키는 idx 개념
//3. relocation type, tell the linker how to modify the new ref
// -> 재배치 타입 

typedef struct { 
    int offset; // 1
    int symbol:24,  // 2
        type:8; // 3
} Elf32_Rel;

 

 

일전에 살펴본, ELF ( Executable and Linkable Format ) 파일의 경우,
굉장히 많은 재배치 타입들이 존재한다.
 
각각의 재배치 타입의 차이점은 계산 방식의 차이에 있다.
몇 가지만 살펴보자.

 

 

 

 

S  :  재할당 개체의 실제 심볼의 위치.
 
P  :  재배치당하는 위치.
 
A  :  더해지는 값. ( addend )
 
L  :  PLT 테이블 위치. ( PLT, .got.plt )
 
 
 
@유추한 내용
 
( 여러 사이트를 참고해 유추해본 사실이기에 확실하지 않다. )
 
 
R_X86_64_PC32  :  일반적으로 내부 링크 방식에서 사용하는 것처럼 보인다.
 
 
 
R_X86_64_PLT  :  외부 링크 방식에서 사용하는 것처럼 보인다.
=> 해당 재배치 타입 은 링킹 방식에 따라 약간의 차이가 있다.
 

 

 

정적 링킹  :  기존의 R_X86_64_PC32  의 방식으로 동작되어짐.
왜냐하면, 이미 실행 파일 안에 라이브러리가 존재하기에 심볼의 정의를 알 수 있음.
 
 
 
동적 링킹  :  PLT 와 GOT 를 사용해, 실제 공유 라이브러리가 가지고 있는 심볼 위치를 찾아줌.
=> 런타임 시, 해당 심볼의 정의를 찾아주는 과정 필요.
=> 실행 파일에서 해당 공유라이브러리의 위치만을 가지고 있음.
 
 
 
 
정적 링킹  /  동적 링킹에 대해 더 자세히 알아보자.
 
 

 

 

Static Linking  : 

 
=> 우리가 필요로 하는 라이브러리가 링킹 후에 완성된 프로그램(exe) 안에 포함되어져있음.
=> 정적 라이브러리는 링크 타임에 바인딩.
 
 
 
 
*문제점
 
1. 파일크기가 커짐. ( 표준 C 라이브러 libc 크기가 2MB. )
 
 
2.  만약 동일한 라이브러리를 쓰는 프로그램이 3개가 실행되면
메모리에 동일한 라이브러리 코드가 3개 중복되어 쓰여지게 됨.
또한, 쓰이지 않는 함수까지 실행 파일에 포함하게 됨.
=> '메모리 낭비' 우리의 메모리는 소중하다.
 
 
3. 새로운 라이브러리 버전 출시  후, 적용하기 위해선 다시 컴파일 필요.
 
 
 
*해결방안은 ??
 
=> 하나의 라이브러리를 올려놓고 모든 프로그램들이 공유하자!!
( Shared LIbrary )

 

 

 

 


 

 

 

Dynamic Linking ( Shared library ) : 

 
 
=> '페이지 테이블' 이용해, 가상 메모리 주소를 물리 메모리로 변환함으로써,
각각의 프로그램들이 해당 라이브러리를 공유할 수 있다.
 
=> 기존의 정적 링킹 처럼 실행 파일에 라이브러리가 존재하지 않기 때문에,
해당 라이브러리의 심볼의 위치를 찾을 수 없다.
 
즉, 해당 라이브러리 함수를 직접 호출할 수가 없다.
 
 
 
 
 
 
*그렇다면 동적 라이브러리 에 있는 함수들을 어떻게 호출할까 ??
 
=> 재배치 영역에서 R_X86_64_PLT32 타입 형태로 링크하고 있음.
=> PLT, GOT 를 이용.
 
 
 
 
 
 
*PLT ( Procedure Linkage Table )
 
=> 외부 프로시저를 연결해주는 테이블.
=> 링크 타임 시, 위치를 알 수 없는 함수들의 위치를 찾아내주는 루틴들을 모아놓은 테이블.
 
 
 
 
*GOT ( Global Offset Table )
 
=> PLT 가 참조하는 테이블.
=> 해당 이름의 데이터 테이블을 프로그램 내부에 만들고,
실제 함수들의 주소값을 여기에 저장.
=> 하지만 이 때, 모든 외부 함수 주소를 한번에 로딩하지 않고
호출 시점에 해당 함수의 주소를 공유 라이브러리로부터 알아오게 하는 동작과정 함수가 들어있음.
 
 
 
 
 
 
 

* 공유 라이브러리 함수가 처음 실행될 때.

 

 

 

@bar 함수가 GOT 두 번째 위치 ( GOT[1] ) 에 있다고 가정.
@bar 함수가 첫 번째 CALL 될 때  /  두 번째 CALL 될 때
 
 
 
*bar 함수가 처음 CALL 될 때.

 

1. 함수호출 -> PLT 이동
2.  GOT [ 1 ]  이동
3. ( PLT ) 으로 점프 후, GOT 저장.
4. Dynamic Loader 로 점프. ( 필요한 부분만 적재. )
=> 이 때, bar 함수 위치를 찾을 수 있음.
5. 위치를 찾고 GOT 업데이트. GOT[1] 에는 실제 bar 함수 주소값이 들어가있음.
6. bar 함수 실행.
 

 

=> 해당 과정을 함수 호출 시, Stub 이 발생한다고 표현
=> Stub : 라이브러리를 어떻게 찾을 것인가를 알려주는 작은 코드 조각.
 
 
 
 
 
*bar 함수가 두 번째 CALL 될 때.
=> GOT[1] 안의 bar 주소값 바로 실행.
 
 
 
 
 
*해당 방식을 'lazy binding' 이라고 함.
장점  :  해당 함수를 한 번도 호출되지않았을 경우, 어떤 비용도 없다.
단점  :  해당 함수를 처음 실행할 때, 많은 비용이 듬.
 
 
*실행 시, 필요한 모든 심볼들을 GOT 에 등록하는 방법도 존재.
 
 
 

 

  

*결론

 
 
 
 
대략적인 링킹 과정을 설명하자면,
 
어셈블러가 만든 모든 obj 파일을 하나로 합치는 과정과 동시에
심볼들의 정확한 위치를 확정시키는 역할을 수행한다.
 
또한, 심볼이 정의됨과 동시에
다른 심볼들을 참조하거나 외부 코드를 실행 하는 명령어들의
심볼의 위치를 정의된 위치와 계산해 정상적으로 실행될 수 있도록 유도한다.
 
 
심볼 테이블에서는 각각의 심볼이 어떠한 방식으로 배치할지에 대한 정보가 저장되어있다.
 
 
재배치 테이블에서는 심볼, 명령어들이 참조하는 심볼이 아직 정의되어지지 않았다면,
'재배치 개체' 를 생성해서 재배치 테이블에 포함시킨다.
 
 
=> 해당 과정을 모든 심볼 참조들이 재대로 연결되어질 때까지 반복해 수행.
 
 
 
 
이어서,
라이브러리를 어떻게 처리할 것인지에 대해서
2가지 방식으로 나뉘게 되는데,
 
해당 방식에 따라서 '외부 링크 처리 방식' 이 달라지게 된다.
 
 
1. 정적 링킹
 
=> 정적 링킹에서는 라이브러리가 실행 파일 안에 포함되어져서 만들어지기 때문에,
심볼 정의 위치를 내부적으로 바로 바인딩 가능하다.
 
=> 하지만 프로그램 크기가 커지고 라이브러리가 중복되어 메모리 낭비가 심하다.
 
 
 
2. 동적 링킹
 
=> 동적 링킹에서는 공유 라이브러리에 해당하는 심볼들을 실행 시점까지 미루게 된다.
공유 라이브러리가 어디에 존재하는지 모르기 때문에 바인딩할 수 없다.
 
 
외부 링크를 참조하는 명령어를 만나게 되면 재배치 개체를 생성하고
재배치 정보들을 이용해 구별.
 
 
만약 외부 함수를 호출한다면,
정적 링킹은 내부 링크 처리 방식과 동일하게 진행되고
동적 링킹은 PLT, GOT 를 사용하는 재배치 타입으로
런타임에 해당 프로시저를 호출 시, 심볼의 정의를 구하는 과정을 실행한다.
 
 
 
런타임 중 외부 함수 호출 시,
공유 라이브러리의 함수들의 위치를
외부 링크 방식의 함수들에게 해당 위치를 바인딩해주는 것을
'lazy binding' 이라고 한다.
 
 
단점은 처음 함수 호출 시, 비용이 많이 들지만 호출하지 않는 함수들은 비용이 들지 않는다.
 
 
처음 함수 호출 시, 부하가 걸리는 것을 예방하기 위해
프로그램 시작 시, 모든 외부 함수들을 GOT 에 넣어주는 방법도 존재한다.
 
 
 

 


 

참고 링크

 

 

 
 
 
 

 

 

 

'프로그래밍 > 부록' 카테고리의 다른 글

@DLL 동적 라이브러리 만들기  (0) 2023.01.30
@C++ 컴파일 에 대한 이해  (0) 2022.07.26