초심 프로젝트의 세번째 주제로 커널을 주제로 새롭게 알게된 것을 정리했다. 교재는 다음과 같다.

주교제 Linkers and Loaders

또한 내가 예전에 컴파일러 강의를 들으면서 새롭게 알게된 내용과 나름대로 공부한 내용을 추가했다.
이해를 돕기 위해 강의 자료였던 kipa_dd.zip 을 참고하기 바란다.

컴파일러는 뭐지?

일반적으로 C 와 같은 High-Level Language 로 구현된 소스코드를 기계어로 변환시켜주는 프로그램(Tool) 이라고 생각할 수 있다. 다음은 텀즈에 나온 컴파일러의 정의이다.

컴파일러는 특정 프로그램 언어로 작성된 문장을 처리하여 기계어 또는 컴퓨터가 사용할 수 있는 코드로 변경시켜주는 특수한 용도의 프로그램이라고 정의할 수 있다. 
C 나 Pascal과 같은 언어로 프로그램을 개발할 경우, 프로그래머는 편집기를 이용하여 한줄 한줄 문장을 작성하게 되는데, 이러한 파일들을 소스코드라고 부른다. 
소스코드의 작성이 끝나면 프로그래머는 그 소스코드의 언어에 맞는 컴파일러를 실행시킨다.

다음은 '프로그램은 왜 실패하는가?' 에 나온 컴파일러의 설명이다

1. C, C++ 같은 언어들의 경우 소스 코드는 우선 전처리기를 거친다.
2. 컴파일러는 소스 코드를 적절히 파싱해서 하나의 구문 트리(syntax tree) 를 생성한다.
3. 컴파일러는 그 구문 트리를 운행(traversing)하면서 어셈블리 코드를 만들어 낸다.
4. 어셈블리는 어셈블리 코드를 목적 코드(object code)로 번역한다.
5. 링커는 목적 코드가 담긴 목적 파일을 한데 묶어서 하나의 실행 파일(executable)을 만든다.

가장 흔히 생각하는 컴파일러로 gcc 를 생각할 수 있다. 하지만 gcc 는 GNU Compiler Collection 의 약자로서 우리가 흔히 알고 있는 컴파일러는 cc 라고 하는 명령어로 존재한다.

컴파일러의 구조는 크게 Front End 와 Back End 로 나눌 수 있다.

  1. Front End : 소스 코드를 읽어들이고, 어휘(lexical) 분석, 구문(syntax) 분석, 의미(semantic) 분석을 한 뒤 Back End 를 위한 중간 코드(Intermediate code) 를 생성(build) 한다.
  2. Back End : 중간 코드를 읽어들이고, 최적화해서 코드를 만들어낸다.

Tool-Chain 은 각종 소스 파일들을 컴파일하고 build 하여 실행 파일을 생성하는 데 필요한 여러가지 유틸리티 및 라이브러리의 모임이다.
그게 3 가지로 구성된다.

  1. GCC(GNU Compiler Collection)
  2. GNU Binary Utilies
  3. Libraries(glibc, newlib)

GCC 는 크게 4 가지 과정의 작업을 수행한다.

  1. Preprocessing
  2. Compiling
  3. Assembling
  4. Linking

위의 작업을 담당하는 여러가지 프로그램들로 구성되어 있다.

  1. c(g++) : C 뿐만 아니라 C++ 소스 파일또한 컴파일가능하다. 자동으로 Linking 시에 standard C++ 라이브러리를 include 한다.
    - cc1 : 실질적인 C 컴파일러
    - cc1plus : 실질적인 C++ 컴파일러
    - collect2 : 시스템에 GNU Linker 가 없을 때, global initialization code(예를 들면, constructors 와 destructors) 를 생성한다.
    - configure : GCC 를 컴파일하기 위해 필요한 makefile 을 만들고, 변수값들을 설정한다.
    - crt0.o : C runtime 시에 필요한 초기 파일을 같이 링킹시킨다.
    - gcc : 컴파일러와 링커가 output 파일을 생성해낸다.
    ==== GNU Binary Utilies ====
    binutils 에는 아래와 같은 두개의 주요한 유틸리티와 부가적인 유틸리티들이 포함되어 있다.

    - ld : GNU Linker
    - as : GNU assembler
    - nm : 오브젝트 파일로 부터 심볼 리스트를 출력
    - objcopy : 오브젝트 파일 포맷을 변환해서 복사한다. (ex : ELF -> HEX, BIN 으로 변환해서 복사)
    - objdump : 오브젝트 파일의 정보를 출력
    - ar : archive 파일(.a)을 생성하고 수정하고, 추출한다.
    - ranlib : archive 의 index 를 만들어서 archive 에 저장한다. index 는 라이브러리를 링크할 때 속도를 향상시키고 archive 에서 위치에 관계없이 함수들이 서로 호출이 가능하게 한다.
    - size : 오브젝트 파일의 각 섹션 별 용량과 총 용량을 출력
    - strings : 파일에서 printable 한 문자의 리스트를 출력
    - strip : 오브젝트 파일에서 심볼을 지워서 사이즈를 줄일 때 사용

    ==== Libraries ====
    크게 두 가지 종류의 라이브러리를 사용한다.

    - Glibc : FSF 에서 관리, native UNIX system 에서 사용하던 라이브러리를 완벽하게 대체
    - Newlib : 많은 프로그래머들에 의해서 생성된 코드들의 모음으로서 embedded system 에서 사용할 수 있도록 패키지화 했다. glibc 와는 달리 UNIX system 에서 사용되는 라이브러리를 완벽하게 대체하지 못한다.

    라이브러리는 링크될 때, 사용되는 데 방법에 따라서 정적 라이브러리(libc.a)와 동적 라이브러리(libc.so)로 나뉘어 진다.
    일반적으로 gcc 를 이용해서 컴파일을 하는 경우, 기본적으로 동적 라이브러리(libc.so)와 링크된다.
    정적 라이브러리로 링크하기 위해서는 다음과 같이 한다.
    <code text>
    #gcc -static -o test test.c
    </code>
    ====== Object Files ======
    소스 파일을 컴파일하게 되면, 오브젝트 파일(Object file)이 생성된다. 흔히 UNIX 계열에서는 ELF format 을 사용하고, Window 계열에서는 PE format 을 사용한다. 그 외에도 각 아키텍처 별, 또는 운영체제 별로 다르게 존재한다. 여기서는 가장 많이 사용되고 있는 ELF 에 대해서 자세히 알아볼 것이다. 오브젝트 파일은 binary 코드와 데이터로 구성되어 있으며, 크게 3 가지로 분류할 수 있다.
    - Linkable : link editor(linker) 에 의해서 사용(input)되어 질 수 있는 프로그램
    - Executable : loader 에 의해서 메모리에 올려서 실행할 수 있는 프로그램
    - Loadable : loader 에 의해서 프로그램과 함께 메모리에 올리는 프로그램(ex : .so)
    오브젝트 파일은 아래와 같이 구성되어진다.
    - Header information : size of code, name of source file, creation date
    - Object code
    - Relocation information : 동적 라이브러리(Dynamic) 로 컴파일할 경우만 사용
    - Symbols
    - Debugging information
    ===== ELF (Executable and Linking Format) =====
    COFF 의 단점을 개선한 포맷으로 원래 cross-compiled embedded system 을 위해 만들어 졌다. COFF 는 time-sharing 시스템에서 잘 동작하지 않고, C++ 를 지원하지 않으며 dynamic linking 의 문제가 있었다. ELF 는 현재 UNIX System V, Linux and BSD 에서 채택했으며, 기존의 a.out 포맷보다 shared library 에 대해서 나은 지원을 한다. 또한 디버거를 위한 정보 또한 이전보다 완성도가 높다.
    ELF 오브젝트 파일은 3 가지로 타입으로 구분할 수 있다.

    - Relocatable object(=Linkable) : section tables
    - Executable object : program header table
    - Shared object : have both of them

    하나의 segment 는 여러 개의 section 들로 구성된다. 예를 들어, 하나의 loadable read-only segment 는 code section, read-only data section, dynamic linker 를 위한 symbol 들로 구성되어 있다. 여기서 section 들은 linker 에 의해 처리되어 지는 부분이고 segment 는 loader 에 의해 memory 로 mapping 되는 부분이다.
    아래의 그림은 Relocatable 과 Executable object 를 비교한 그림이다.

    {{ :computer:compiler:compiler3.jpg

| .symtab and .dynsym | 각각 SYMTAB 과 DYNSYM 타입을 가짐, symbol table 을 포함, dynamic linker symbol table 은 ALLOC 타입임 |

.strtab and .dynstr STRTAB 타입, symbol table 을 위한 name strings 또는 section table 을 위한 section name 의 table, .dynstr section 은 ALLOC 타입임
.interp interpreter(해석 프로그램)를 사용하기 위해서 이름을 포함하고 있음, 예를 들어 dynamic linking 을 하기 위해 /lib/ld-linux.so 를 사용 할 수 있음, shell script 같은 스스로 실행할 수 있는(self-running) interpreted text file 과 같은 개념임
.got(global offset table), .plt(procedure linkage table) dynamic linking 을 위해 사용됨
.debug 디버거를 위한 symbol 이 포함됨
.line 디버거를 위한 것으로서 object code 의 위치를 source code 위치와 매핑하는 정보를 가짐
.comment 문서화를 위한 문자열을 포함하고 있음(예를 들면 version control number)
.hash symbol hash table


  • p_vaddr : physical address 와 virtual address 가 같을 수 있는 데, 이때 physical address 는 진짜 physical address 가 아니다. Executable object 파일에서 physical address 가 명시되어 있다고 해도, 나중에 메모리에 접근할 때는 MMU 를 거쳐서 가기 때문에 동일하지 않을 수 있다.

Linker

Linker 는 위와 같이 여러개의 소스 파일을 컴파일해서 나온 각각의 relocatable object 파일을 executable object 파일로 만들어 준다.
컴파일시에 특정 ld 파일을 따로 지정해서 컴파일 할 수도 있다.

  1. object 파일을 합침(merge) : 여러 개의 relocatable(.o) object 파일을 하나의 executable object 파일로 합쳐, loader 에 의해서 load 되고 실행되어지게끔 한다.
  2. symbol resolution : 현재 소스에 없는 함수나 변수들(external reference) 을 다른 object 파일의 정의된 symbol 에서 참조한다. 정의되지 않은(undefine) symbol 을 찾기 위해서 라이브러리들을 찾는다.
  3. relocates symbol : .o 파일에서 관계있는 symbol 들끼리, 새로운 절대적인(absolute) executable object 파일에 위치시킨다. symbol 들은 새로운 위치를 반영하기 위해서 모든 참조(references)들은 업데이트 된다.
  1. Modularity : 프로그램이 작은 소스 파일의 모음으로서 쓰여질 수 있고, 오히려 모놀리틱 하다. 함수들을 라이브러리로 빌드할 수 있다.
  2. Efficiency : 시간(하나의 소스 파일이 바뀌면, compile 하고 relink 해야 하지만 다른 소스 파일들은 다시 compile 할 필요가 없다), 공간(함수들을 라이브러리 형태의 하나의 파일로 모을 수 있다. executable 파일과 메모리에서 실행되는 이미지(image) 들은 오직 실제적으로 사용되는 함수를 위한 code 를 포함하고 있다.

위 그림은 각각의 relocatable object 파일의 section 을 합쳐서 하나의 segment 로 만드는 과정을 보여준다.

Linker 는 link script 라는 파일에 의해서 조정(controll) 된다.
이 파일은 input 파일안의 section 들을 어떻게 매핑하여 output 파일로 만들어 내는지에 관한 정보를 가지고 있다.
output 파일의 memory layout 을 조정할 수 있다. 간단한 text 파일의 형태로 되어 있다. Linux 경우, ld-script 라는 이름으로 존재한다.

  1. VMA(Virtual Memory Address) : output 파일이 실행될 때, 각 section 이 가지는 주소
  2. LMA(Load Memory Address) : section 이 로드될 주소

대부분의 경우 LMA 와 VMA 는 동일하며, DATA section 이 FALSH 에 있고 RAM 으로 load 되는 경우는 FLASH 주소가 LMA 가 되고 RAM 주소가 VMA가 된다.
다음은 Linker Script Commands 를 표로 나타낸 것이다.

command 설명
ENTRY(symbol) entry point 를 설정, 일반적으로 함수 호출과 비슷
INCLUDE filename filename 의 linker script 를 include 시킴
INPUT(file file…) link 에서 named file 을 include 시킴
GROUP(file file…) INPUT 과 비슷하며, archive named file 들은 제외시킴
OUTPUT(filename) 컴파일시에 '-o filename' 옵션을 주는 것과 동일함
STARTUP(filename) INPUT 명령어와 비슷하고, 링크 될 first input file 이 될 filename 은 제외시킴
SECTION{} command linker 가 output section 에서 input section 을 어떻게 매핑할 것인지 알려줌

실제로 컴파일 할 때는 자동으로 linker script 가 지정되어 컴파일 되어진다. 만일 따로 linker script 를 지정해주고 싶다면,

#gcc -o test main.c libcl.o -Wl -T default.lds

위와 같이 하면 된다. 여기서는 defaut.lds 라는 파일을 따로 지정했다.

Library

프로그램이 실행되기 전에 프로그램 안에 설치(installed) 되며, 간단히 object 파일의 모음으로 실행된다. 확장자는 .a 이다.

  • 장점 : 재 컴파일하기 위한 코드 없이 프로그램의 link 를 할 수 있다. 재 컴파일 시간을 줄일 수 있다. shared library 에 비해서 실행 속도가 약간 빠르다.(1~5%)
  • 단점 : shared library 보다 용량이 크다.

프로그램이 시작할 때 로드되며, 프로그램 들 사이에서 shared 될 때 사용된다.

  • 장점 : shared library 를 이용한 실행 프로그램은 static library 를 이용한 것보다 크기가 훨씬 작다. library 독립적으로 프로그램을 작성할 수 있다.
  • 단점 : static library 를 이용한 것보다 실행 속도가 조금 느리다.

Loader

실행 binary object file 을 메모리에 적재하여 실행할 수 있도록 하는 과정.
현재의 대부분의 OS 에서는 모든 실행 프로그램이 고정적인 주소에 적재되고 그 주소에 대해서 링크될 수 있다.
loading 과정은 다음의 단계를 거친다.

  • object file 로 부터 header 정보를 읽어 loading 가능한 file 인지 판단한 후 얼마나 많은 주소 공간이 필요한지 찾는다.
  • 주소 공간을 할당한다. 만약 object format 이 개별적인 segment 를 가진다면 각각의 segment 에 대해서 주소 공간을 할당한다.
  • 프로그램을 주소 공간상의 세그먼트로 읽는다.
  • virtual memory system 이 자동적으로 하지 않는다면 bss 영역을 0 으로 초기화 한다.(초기화 코드)
  • 필요하다면 stack segment 를 생성한다.
  • program arguments 와 환경 변수와 같은 run-time 정보들을 설정한다.
  • program 의 시작 번지에서 프로그램을 시작한다.(_start)
  • Load-time dynamic linking : executable ELF file 은 디스크 에서 메모리로 읽고, 아직 결정되지 않은 symbol 들을 결정(resolve) 한다.
  • Run-time dynamic linking(or lazy linking) : executable ELF file 은 디스크 에서 메모리로 읽고, 아직 결정되지 않은 참조들에서 유효하지 않은 것(일반적으로 0)은 버린다.
  • Program Interpreter(dynamic linker) : dynamic linking 을 이용하도록 executable ELF file 을 생성할 때, linker 는 .interp segment(PT_INTERP type) 에 file 을 실행하는 데 필요한 interpreter 를 추가한다.

GOT 는 Global Offset Table 의 약자로서, .got section 에 있다. shared object 는 GOT 를 가지고 있다. linker 는 shared object 에서 참조하기 위해 executable ELF file 에 GOT 를 생성한다. ELF executable file 이 실행될 때, dynamic linker 는 GOT 안의 symbol 들을 결정한다.

PLT 는 Procedure Linkage Table 의 약자로서, .plt section 에 있다. shared object 에서 참조하는 것을 찾는 것은 PLT entry 를 생성하고, PLT 에서 jump 함으로서 결정된다.

Linux 에서 loader 의 과정을 보고 싶다면, 아래와 같이 한다.

#export LD_DEBUG=all
#./test > test.debug 2>&1

다음은 http://www.mobilelab.co.kr/programming/content.asp?num=299&tname=study_brew 에서 퍼온 글이다.
컴파일 시에 옵션으로 '-DPIC' 를 주었을 때, 달라지는 점들에 대해서 설명하고 있다.

애플리케이션은 폰의 메모리에 로딩된 후 실행이 됩니다.
 
그럼 메모리의 어느 부분에 로딩이되고 실행이 될까요??
 
답은 아무도 모른다 입니다. 폰도 모릅니다.. -_-; 그때 그때 상황에 따라 달라지니까요..
 
물론 CPU 나 OS 의 특성에 따라 달라지지만 일반적으로 OS 는 프로그램을 로딩하고 실행할 때 그 프로그램을 위한 고유의 가상메모리를 만든후 프로그램을 실행 시키죠.
 
예를들어 쉽게 얘기하면 프로그램의 입장에서 보면 프로그램의 가장 첫 부분의 address 는 0000 이 된다는 말입니다.
 
이렇게 해야 프로그램내에서 변수 또는 함수의 Address 를 쉽게 찾을 수 있기 때문입니다.
 
그런데, BREW 는 불행하게도 프로그램에 어떠한 고유 Address 를 주지 않습니다.
 
이런 상황에서 프로그램이 정상적으로 실행이 되려면, 프로그램은 자신이 로딩되는 위치에 상관 없이 동작 할 수 있도록 코드가 구성 되어야 합니다.
 
이런 방식을 Position Independent Code (PIC) 라고 합니다.
 
ADS 에서 MAKEFILE 을 살펴보신 분들은 아시겠지만, ropi 라는 옵션을 사용합니다.
 
이것이 PIC 를 만들어주는 옵션입니다.
 
그런데, ADS 의 PIC 는 조금 문제가 있습니다.
 
ADS 에서 사용하는 PIC 방식은 PC Relative Adressing 방식입니다.
 
이 방식은 PC ( Program address Counter : 현재 실행되는 instruction 의 위치를 가지는 레지스터 ) 를 기준으로 "+/- 얼마" 이런 방식으로 Function 의 실제 Address 를 구해내는 방식입니다.
 
이 방식은 Function 을 엑세스 하는데는 빠르지만, 변수를 엑세스 하기에는 굉장히 어려운 방식입니다.
 
그런 이유로 ADS 를 사용하면 전역 변수를 사용할 수가 없는 것입니다. ( 그런데 지금도 그런가요?? )
 
그럼 GCC 는 어떨까요?
 
GCC 도 타겟 CPU 에 따라 조금씩 다르기는 하지만, 기본적으로 GOT ( Global Offset Table ) 라는 테이블을 사용하여 변수및 함수의 주소를 찾는 방식으로 되어있습니다.
 
GOT 에 대해서 잠시 설명 드리겠습니다.
 
GOT는 프로그램에서 사용하는 모든 Function 또는 변수의 상대적 주소를 가지고 있습니다.
 
즉 프로그램이서 어떤 변수 또는 함수를 엑세스 하려고 할때, 직접 그 어드레스를 엑세스하지 않고, GOT 의 값을 참고해서 엑세스 하는 방식을 말합니다.
 
GOT 방식을 사용할 경우, 프로그램이 메모리에 로딩된 이후에 GOT 의 모든 값을 실제 Address 로 치환해 주어야 합니다. 
물론 이것은 프로그램이 로딩된 주소를 안다면 아주 쉽습니다.
프로그램이 시작주소 + GOT 의 값을 다시 써넣어 주기만 하면 되니까요.
 
이해하기 쉽게 PC Relative 방식과 GOT 방식을 C 코드로 적어보면 다음과 같습니다.
 
주소는 그냥 임의의 값입니다.
 
1) 원래 코드
절대주소 상대주소 
00123000 00000000 void Func1(void) {}
00123010 00000010 void Func2(void) {}
00123020 00000020 void main(void) {
00123030 00000030 Func1();
00123040 00000040 Func2();
}
 
2) PC Relative 방식
00123000 00000000 void Func1(void) {}
00123010 00000010 void Func2(void) {}
00123020 00000020 void main(void) {
00123030 00000030 (PC-30)(); // PC = 00123030
00123040 00000040 (PC-30)(); // PC = 00123040
}
 
3) GOT 방식 ( 로딩전(GOT치환전) )
00123000 00000000 void Func1(void) {}
00123010 00000010 void Func2(void) {}
00123020 00000020 void main(void) {
00123030 00000030 GOT[0]();
00123040 00000040 GOT[1]();
}
GOT[] = { 00000000, 00000010 };
 
GOT 방식 ( 로딩후(GOT치환후) )
00123000 00000000 void Func1(void) {}
00123010 00000010 void Func2(void) {}
00123020 00000020 void main(void) {
00123030 00000030 GOT[0]();
00123040 00000040 GOT[1]();
}
GOT[] = { 00123000, 00123010 }; 
 
이해가 되시죠??
 
GOT 방식을 사용하면, Function 과 변수가 모두 GOT를 통해 접근이 되므로 속도가 조금 느려질 수 밖에 없습니다.
 
그러나 눈에 띄일 정도는 아니니 걱정 안하셔도 됩니다.
 
그럼 우리가 앞으로 GCC 를 사용하기 위해서 어떤 일을 해야 하는지 대충 감이 잡히시나요?
 
네.. 단 한가지 입니다.
 
프로그램이 폰으로 로딩되면 GOT 만 실제 Address 로 치환해 주는 일만 하면 됩니다.
 
그러자면 프로그램이 로딩되는 Address 를 알아야 겠죠?
 
BREW 에서 프로그램이 메모리에 로딩이 되면, 로딩된 Address ( 상대주소 = 0 ) 에 위치하는 함수를 곧바로 호출합니다.
 
이 함수가 바로 AEEMod_Load(...) 입니다.
 
GCC 를 사용하기 위해서 손봐야할 부분이 바로 이 AEEMod_Load 입니다.
 
AEEMod_Load(...) 를 수정해서 실제 프로그램이 로딩되 주소를 알아내야 하고, 그 주소를 기반으로 GOT 를 변경해 주어야 합니다.
 
오늘은 여기 까지만 하겠습니다. 
 
아마도 대충 감이 잡히셨을 것이라고 생각됩니다.
  • computer/programming/컴파일러의_이해.txt
  • Last modified: 3 years ago
  • by likewind