본 포스트팅은 인하대학교 컴퓨터공학과 시스템 프로그래밍 수업자료에 기반하고 있습니다. 개인적인 학습을 위한 정리여서 설명이 다소 미흡할 수 있으니, 간단히 참고하실 분들만 포스팅을 참고해주세요 😎
앞서 살핀 파일들의 포맷(relocateable, executable, shared object file ) 을 통칭해서 ELF 라고 부른다.
ELF 파일은 위와 같은 구조를 가진다. (그런데 실제로는 저렇게까지는 안생겼고, 막상 실제로 열어보면 바이너리들이 주르륵 나오는 형태이다. 그냥 논리적인 구조를 따져봤을 때 저렇게 생겼다는거다. )
Segment header table : Page 사이즈, virtual addresses (가상 메모리 주소), memory segments, segment sizes 등등을 저장
.text : 어셈블리어 소스코드 부분
지난 예제에서 main.c 와 sum.c 를 machine level 로 변환했을 떄 이 부분에 어떻게 들어갈까?
=> 원래는 컴파일된 순서대로 넣는것이 디폴트지만, 중간중간에 함수 호출, 해당 함수로 점프를 뛰는 코드등을 고려해서 알맞은 순서로 구성해서 전체적인 하나의 코드로 합쳐놓은(merge 한) 것이다.
.rodata : Read only data. 말그래로 읽기전용 데이터 집합. => jump table 이 속한다. (이전에 jump table 이란 c언어의 switch 문을 어셈블리어로 변환했을 때 생기는 테이블이라 말했었다!)
.data : 초기화된(initialized) 변수들이 할당됨
ex. local 변수에 넣어놓는 초기값들이 들어감
.bss : 변수들중에 초기화하지 않은 변수들 (공간을 차지하지는 않는데 section header 는 있다네?)
.symtab : symbol table 이 들어감
.rel.text // .rel.data : 앞선 .text 와 .rodata 에서는 코드와 데이터를 section 하나로 모아놨었으나 실제로 실행할떄는 저장되어있는 순서와 동일한 순서로 실행한다는 보장이 없다.
따라서 하나로 모아져있는 텍스트를 실행할 때 어떤 순서로 어떤 offset 을 계산해서 명령어를 수행하는 순서가 바뀌어야 하는지를 저장해놓는다.
.debug : 컴파일러한테 -g 옵션을 주면 디버깅할떄 필요한 데이터를 이곳에 넣어둔다.
Section header table : 각 section 들의 시작주소와 offset, size 등을 저장
cf) symbol 은 변수(variable) 과 다른것이다! symvbol 은 "이름"이라고 했었다. 즉 local variable(지역 변수) 와 local symbol 은 다른것이다.
cf) Module : .o 또는 .so 와 같이 independent 하게 떨어져서 컴파일된 바이너리 오브젝트 파일들
=> 왜냐하면 링크(link) 할떄 main 함수와 sum 함수가 실행되는 명령어들이 각각 따로 있을텐데, linker 가 해줘야하는 일은 sum 이라는 symbol 이 reference(정의) 되었는데 해당 함수는 main 이 가지고 있지 않는 별도의 함수이다. 그래서 sum 에 대한 정보와 명령어들을 가지고 뭔가를 해줘야하는데, local 변수는 이미 main 안에 다 들어가있다. 즉 어디서 뭔가를 가져온다거나, 다른 곳에 있는 주소를 알아야한다는 등을 할 필요가 없다. 그래서 신경을 쓰지 않는것이다. 어짜피 컴파일 타이밍에 다 처리가됨.
나중에 symbol 테이블에 등록이 되어야하므로 global 로 인식되는 것이다.
그냥 평범한 local 변수임. 런타임 타이밍에 필요x. stack frame 에 저장된다.
만일 함수 a( ) 에 static 변수 x 가 있는데 어떤 함수 z를 계속 g 에서 호출하면 x 값이 호출할떄 마다 1씩 증가한다고 해보자.
그런데 만일 변수 x가 static 이 아니였다면, 그 함수 z 안에 들어가서 x 값을 1증가시킨다고 한들 해당 x 변수는 없어질것이다 (그냥 non-static 한 local 변수라면 스택안에 들어가 있으므로 영향을 못끼침).
반면 이 경우는 static local 변수인데, 해당 변수는 스택에 따로 가지고 있는 것이 아니고 dss 나 data 영역(section) 에 따로 가지고 있으므로 다른 함수의 영향을 받을 수 있다.
static 변수의 생명시간은 프로그램이 종료되기 전까지 유지된다 (non-static 한 local 변수는 함수 호출이 끝나면 스택에서 제거되서 없어진다)
프로그램의 Symbol 이 이름이 중복되는 경우, storing, weak 상태에 따라서 프로그램을 구분지어준다.
linker 입장에서는 프로그램 symbol 이 strong 한지 weak 한것인지의 개념이 있다.
=> strong 과 weak 는 초기화 여부 에 따라서 strong 한지, weak 한지가 나뉜다.
strong 한 symbol : strong 이 weak 보다 우선순위가 높다. 즉 이름이 동일한데 strong 이 있다면 이를 먼저 고른다. 위 예제에서 파일이 2개 있는데, 변수 foo 가 중복되서 선언되어 있고, p1.c 파일에서 foo = 5 로 초기화가 되어있다. 초기화 되있는 쪽이 strong 하다. 그러면 link 타이밍에 p1.c 에 있는 foo 변수를 고른다.
weak 한 symbol : 초기화 되지 않는 symbol
링커의 symbol 에 대한 규칙을 살펴보자.
이름이 동일한 중복되는 strong symbol 이 있다면 에러가 발생한다.
이름이 다 같은데 하나만 strong symbol 이고, 나머지가 모든 weak symbol 인 경우 strong symbol 을 택한다.
weak symbol 만 존재하는 경우, 먼저 linking 된 symbol 을 택한다.
p1 함수가 위처러 2개 있다면 에러가 발생한다. p1 이 둘다 global 이기 떄문에 strong symbol 이기때문
함수 이름은 p1, p2 로 달라졌으나 변수 x가 global 변수이고 중복된다. 에러는 발생하지 않으나 둘 중 어느것이 선택될지는 모른다.
함수 이름도 p1, p2 로 다르고 변수 x의 타입은 이름의 중복되지만 타입이 다르다. symbol resolution 에서는 타입을 보는 것이 아닌, symbol 이름을 보기 떄문에 타입을 보지않는다. 따라서 둘다 link 를 한다. 그런데 실제로 런타임 할떄는 8바이트 짜리인 double 이 int 를 덮어씌운다.
double x 가 int x 를 덮어씌운다. (앞서 언급했듯이 초기화 되있는것이 더 strong 하지만, 타입이 더 큰것이 작은놈을 덮어씌워버림)
초기화된 x 가 strong symbol 이다.
system, main, sum 오브젝트 파일이 다 따로였지만, exe 파일을 만들때 하나로 병합된다. 이떄 exe 파일의 구성 순서는 오브젝트 파일을 준 순서대로 구성된다. 즉 재배치해주는 과정을 relocation 이라고한다.
위는 relocated 된 이후의 코드이다.
위 그림은 보면, 앞서 배웠던 내용들이다. 스택은 위로 자라나고, 힙은 아래로 자라난다.
loading 할떄 어떻게 되는지를 보면, 우선 왼쪽은 executable 파일이다.
(다음 시간에 자세히 설명한다 하심)
앞으로 linking 라이브러리들을 살펴볼것이다.
이 방식은 프로그래머가 직접 자주 사용하는 함수를 모아서 packaing 시키켜서 라이브러르로 만든 방식이다.
그런데 이렇게 계속 함수들을 모으는 방식은 귀찮으니, 이를 해결해주는 2가지 옵션이 있다.
1) 모든 함수를 하나의 소스파일에 다 떄려박으면, 컴파일 타이밍에 한번에 linking 이 된다.
2) malloc 을 부르는 데이터가 다 다른 파일에 있다치고, malloc 을 각 소스코드 파일에 다 붙이는것이다.
(위와 같은 방식은 둘다 안좋으니, 외부 라이브러리를 사용하자! 그래서 등장하는 것이 static 라이브러리와 dynamic 라이브러리이다. )
.a 로 끝나는 파일들이 Static library 파일이다. 여러개의 .o 파일들을 모아놓은 것이 .a 파일이다.
컴파일 타이밍에 linking 타이밍에 여러개의 .o 파일들이 .a 로 통합된다.
=> 즉, 미리 build 해놓은 오브젝트 파일들을 하나로 합쳐서 쓰기 편하게 보관하는 방식이다.
컴파일을 시작한다. Translator 를 통해 각 c언어 파일을 오브젝트로 만든다 (각 파일을 따로 컴파일 하는것)
오브젝트 파일에 대해 linker 가 아닌 Archiver 라는 것이 있는데, 이 컴파일러 툴을 가지고 오브젝트 파일들을 하나로 합쳐서 .a 파일을 만든다.
main 함수가 addvec 함수를 실행하고 싶은 경우, addvec 함수가 자주 사용될 것 같다면 libvector.c 파일로 묶어서 libvector.c 를 라이브러리처럼 사용한다.
아래 구조처럼, c언어 파일들을 컴파일해서 Archiver 를 통해 libvector.a 를 만들고, 헤더파일을 미리 만들어놔서 main 를 사용할떄는 헤더파일을 include 하고 내가 쓰고싶은 그 라이브러리 안에 무슨 함수가 있는지 알것이고, 그것을 가져다 쓰면 된다. 그러면 컴파일하고 linking 할때 linker 가 헤더파일을 가져오면 된다
기본적으로 전부 external reference(외부에서 정의한것) 이다. 우리가 직접 정의한게 아니라 다른 모듈에 있는 것을 가져온 것이므로.
main 함수 입장에서는 다 뒤적뒤적해서 필요한 symbol 들을 그떄마다 찾아야한다.
main 함수를 Scanning 할때 라이브러리 안에 있는 함수를 호출했다면, 해당 함수들은 당연히 main 함수안에 없을것이다(라이브러리에 있을테니.)
=> 일단 resolve(해결)이 안된 애들을 모아놓고 나중에 라이브러리에서 뒤적뒤적 하는 것이다.
executable 파일에 모든 오브젝트 파일들을 가져다 붙이는 방식이었다.
=> 만일 프로그램 2개를 만들었는데, 두 프로그램에서 모두 libc 라이브러리를 사용한다면 두 곳에 모두 붙는다.
런타임시 프로그래밍 두 개를 동시에 실행시키면 완전히 똑같은 2개의 라이브러리가 중복해서 메모리에 올라가있게 된다.
라이브러리에 문제(버그)가 있어서 프로그램1에서 해당 라이브러리를 수정을 하는경우, 프로그램2에도 영향을 끼친다. 프로그램2 입장에서는 해당 라이브러리를 다시 link (컴파일을) 해야할 수 있다.
=> 장점 : static library 가 shared library 보다 더 빠르다. linking 할떄 relocation 이 미리 되있어서 원하는 주소를 다 알수있다. 그리고 런타임할떄 메모리에 라이브러리들을 다 미리 올려놓는다.
단점 : 메모리가 아닌 디스크 어딘가에 저장해 놓는다. (자주 쓰이는 녀석들은 cache 에 저장)
=> 런타임 할때마다 relocation 을 해줘야한다.
오브젝트 파일이 load time 또는 런타임에 직접 라이브러리를 가져온다.
libc.a, libvector.a 와 같은 .a 파일이 아닌 .so 파일이 만들어진다.
=> 즉, 컴파일하고 link 할때 relocation 하고 symbol table 까지만 만들어놓는다.
그런데 이때 프로그램에 실제로 해당 라이브러리를 붙여놓는게 아니다. 주소계산만 해놓는 것이다.
그러고 나중에 실행할대 dynamic linker 에 의해 런타임 타이밍에 필요할때 라이브러리를 동적으로 유도리있게 가져다 주는 방식이다.
=> offset 은 미리 계산되서 meta data 들을 executable 에 넣어만 놓고, 실제로 런타임될때 가져오는 것이다.