Embedded

임베디드 레시피 Chapter 3. SW ① 컴파일부터 로드

soreDemo 2025. 1. 25. 23:51

1. Little endian과 Big endian

프로세서가 메모리를 이해하는 순서 및 저장하는 방식Little endianBig endian 두 가지로 나뉩니다.
이 방식을 이해하지 못하면, SW 디버깅이 불가능하므로 꼭 이해해야 합니다.

[ 외우기 쉬운 표현법 1 ]
Little endian: 낮은 주소에 낮은 바이트부터 읽으며 저장하는 방식
Big endian: 낮은 주소에 높은 바이트부터 읽으며 저장하는 방식

[ 외우기 쉬운 표현법 2 ]
Little endian: LSB부터 읽으면서 저장하는 방식
Big endian: MSB부터 읽으면서 저장하는 방식

[ 외우기 쉬운 표현법 3 ]
사람이 컴퓨터를 만듦 → 사람은 컴퓨터의 형님임 → 사람은 big brother, 컴퓨터는 little brother
Little endian: 사람(Big)이 이해하기 어려운 방식, little brother을 위한 방식, 직관적인 방법과 거꾸로.
Big endian: 사람이 직관적으로 이해하는 방식, big brother을 위한 방식

 

Big endian & Little endian

32-bit 정수 `0A0B0C0D`가 있다고 가정합시다.

  • MSB는 좌측으로 제일 끝값 `0A`, LSB는 우측으로 제일 끝값 `0D` 입니다.
  • Big endian: 낮은 주소(a)에 MSB부터 저장합니다. 사람이 읽는 방식대로 `0A`→`0B`→`0C`→`0D` 순서로 값을 저장합니다.
  • Little endian: LSB부터 저장합니다. 사람이 읽는 방식과 정반대로 `0D`→`0C`→`0B`→`0A` 순서로 저장합니다.

방식을 이해했다면, 이번에는 2-byte씩 저장하는 경우를 생각해 봅시다.

  • Big endian: 똑같은 방식으로 MSB부터 2-byte씩 읽어서 저장하면 됩니다. `0A0B`→`0C0D`가 저장됩니다.
  • Little endian: 똑같은 방식으로 LSB부터 2-Byte씩 읽어서 저장하면 됩니다. `0C0D`→`0A0B`가 저장됩니다.

ARM 프로세서는 little endian을 사용하지만, 15번 co-processor (CP15)의 CR레지스터 내 값을 조정하면 big endian으로 동작하도록 설정할 수 있습니다.


2. 컴파일

2.1. 컴파일이란

Chapter 1에서 CPU는 약속된 절차를 계속해서 반복하는 단순한 것임을 배웠습니다.

  • Fetch: 다음 주소에서 1-WORD 만큼 명령어를 읽어오고
  • Decode: 약속된 bit pattern에 따라 명령어를 해석하고
  • Execute: 명령어를 수행하고 결과를 저장하고

Bit pattern의 나열, 기계어, 즉 바이너리는 사람이 읽기에는 너무 불편하기 때문에 그나마 사람이 읽을 수 있도록 기계어와 1:1 매핑한 표기법인 어셈블리(assembly, mnemonic)를 만들었습니다. 그러나 프로세서마다 사전에 정의된 bit pattern이 다르고 다른 기계어를 기반으로 만들어진 어셈블리도 달랐기 때문에 서로 호환이 될 수가 없었습니다. 같은 동작을 하되 각 타겟 프로세서에 맞게 서로 다른 어셈블리를 만들어주는 편리한 무언가에 대한 수요가 증가했고 어셈블러 및 컴파일러가 등장했습니다.

 

결과적으로, C/C++같은 고수준 언어로 코드를 만들면 컴파일러는 각 프로세서에 약속된 bit battern으로 매칭되는 어셈블리를 만들고, 어셈블러는 적절한 기계어로 만들어줍니다. 이러한 일련의 과정을 컴파일(compile)이라고 통칭합니다. 그 중에서도 크로스컴파일(cross-compile)은 타겟보드의 프로세서와 개발하는 환경인 host PC의 프로세서가 다를 때 타겟보드에서 동작할 수 있는 바이너리를 host PC에서 컴파일하는 작업을 말합니다.

2.2. 컴파일 과정

컴파일 과정 도식화

컴파일 과정을 간략하게 설명하면 다음과 같습니다.

  1. 전처리기(Preprocessor)는 컴파일을 쉽게 할 수 있도록 헤더파일/매크로/미리 계산 가능한 상수들을 소스파일로 옮기며 최적화 작업을 수행합니다.
  2. 전처리가 완료된 파일들은 기계어와 1:1 대응하는 어셈블리로 만듭니다.
  3. 어셈블러는 `.s` 어셈블리 파일들을 object 파일로 만듭니다.
  4. 링커스크립트 (Linker Script, 또는 scatter loading 파일)를 통해 메모리 구성을 원하는 대로 설정합니다.
  5. 링커여러 object 파일과 라이브러리들을 하나로 묶고 엮어 하나의 실행 가능한 ELF(Executable & Loadable File) 형식새로운 object 파일을 생성합니다.
  6. fromelf 또는 objcopy 같은 유틸리티를 이용해 최종적으로 타겟에 올라가는 바이너리 파일을 생성합니다.

예시

#define ADD +

typedef struct {
        char memberChar;
        int memberInt;
        double memberDouble;
} Structure;

( `spaghetti.h` 파일)

#include "spaghetti.h"

int aa = 0;
int bb = 3;
extern int cc = 30;
extern Structure dd[3];

int add(int lhs, int rhs);

int main() {
        int stk = 0;
        volatile int local1, local2, local3;

        local1 = 10 ADD 5;
        local2 = 11 ADD 5;
        local3 = add(local1, local2);
        stk = stk ADD local3;

        return stk;
}

int add(int lhs, int rhs) {
        return (lhs ADD rhs);
}

(`spaghetti.c` 파일)

 

전처리기의 역할을 보기 위해 일부러 헤더파일에 `ADD` 매크로를 선언하고, 소스파일에는 `+` 대신 `ADD`를 사용했습니다.

(32-bit arm OS에서는 gcc 사용)
(Cross compiler 사용 시 arm-none-eabi-gcc 사용)
gcc -save-temps -O3 spaghetti.c

(또는)
gcc -E -O3 spaghetti.c -o spaghetti.i)

 

`-save-temps` 옵션을 사용해서 컴파일 과정에서 생성되는 중간생성물들을 저장하거나,
`-E` 옵션을 사용해서 전처리 결과물만 저장할 수 있습니다.

# 0 "spaghetti.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "spaghetti.c"
# 1 "spaghetti.h" 1

typedef struct {
 char memberChar;
 int memberInt;
 double memberDouble;
} Structure;
# 2 "spaghetti.c" 2

int aa = 0;
int bb = 3;
extern int cc = 30;
extern Structure dd[3];

int add(int lhs, int rhs);

int main() {
 int stk = 0;
 volatile int local1, local2, local3;

 local1 = 10 + 5;
 local2 = 11 + 5;
 local3 = add(local1, local2);
 stk = stk + local3;

 return stk;
}

int add(int lhs, int rhs) {
 return (lhs + rhs);
}

( `spaghetti.i` 전처리 결과물 )

  • 헤더파일의 내용물이 소스파일에 들어갔으며
  • 자동으로 include 된 헤더파일 (`/usr/include/stdc-predef.h`)도 있으며,
  • 모든 `#define` 매크로가 그대로 치환됐습니다.
        .arch armv7-a
        .fpu vfpv3-d16
        .file   "spaghetti.c"
... (생략) ...
main:
        sub     sp, sp, #16
        movs    r2, #15
        movs    r3, #16
        str     r2, [sp, #4]
        str     r3, [sp, #8]
        ldr     r3, [sp, #4]
        ldr     r2, [sp, #8]
        add     r3, r3, r2
        str     r3, [sp, #12]
        ldr     r0, [sp, #12]
        add     sp, sp, #16
        @ sp needed
        bx      lr
        .size   main, .-main
        .text
        .align  1
        .p2align 2,,3
        .global add
        .syntax unified
        .thumb
        .thumb_func
        .type   add, %function
... (생략) ...

( `spaghetti.s` 어셈블리 파일 )

 

movs    r2, #15
movs    r3, #16
str     r2, [sp, #4]
str     r3, [sp, #8]
ldr     r3, [sp, #4]
ldr     r2, [sp, #8]
add     r3, r3, r2

 

`main()` 함수의 어셈블리 중 눈여겨볼 부분입니다.

  • `10 + 5`와 `11 + 5`는 컴파일 과정에서 바로 계산할 수 있는 상수입니다. 따라서 따로 더하는 명령어 없이 바로 15, 16을 레지스터에 넣는 것을 확인할 수 있습니다.
  • R2, R3 레지스터에 저장된 값을 스택포인터를 움직이며 스택에 저장합니다.
  • `add()` 함수를 호출했지만, 해당 함수가 단순덧셈만 수행함을 파악한 컴파일러는 함수로 분기를 옮기지 않고 바로 `ADD` 명령어를 사용해서 R2(`local1`) 값과 R3(`local2`) 값을 더합니다.
  • 컴파일러의 최적화 기법 중 하나로, 함수를 호출했지만 그 함수 내용물이 간단할 경우에는 실행흐름을 옮기지 않고 바로 인라인으로 처리해 버려 함수 호출 오버헤드를 줄입니다.

3. ELF와 링커

3.1. Symbol

Symbol이란, 메모리에 자신만의 고유주소를 갖는 단위, 링커가 인식할 수 있는 기본단위입니다.

  • symbol == global이라고 이해해도 좋습니다.
  • 함수, 전역변수, static 변수 등 고유주소를 갖는 것들이 포함됩니다.
  • 소스파일 내 어디에서든 참조 가능한 것들을 떠올리시면 됩니다.

컴파일 결과물 중 object file에는 'symbol table'이 들어있습니다.
여기에는 소스코드에서 참조하는 여러 symbol의 이름과 주소가 저장돼 있습니다.

  • 현재 소스파일에 선언되지 않은 symbol이 있다면,
  • 링커에게 '지금 나한테는 얘에 대한 정보가 없지만, 네가 다른 소스파일이나 라이브러리에서 정의된 부분이 있는지 확인해 줄래?'라는 의미로 `extern` 키워드를 사용합니다.
  • 링커는 다른 파일에 있는 해당 symbol을 찾아내서 연결해 줍니다.

Symbol Reference Resolving

외부변수, 외부함수처럼 다른 소스파일에서 선언된 symbol을 현재 소스파일에서 사용할 때 `extern`이라는 키워드를 붙입니다.
링커는 `extern` 키워드를 발견하면
1. symbol table에 해당 symbol의 위치정보를 빈칸으로 놔둡니다.
2. 다른 object 파일에서 이 symbol table 정보를 찾으면 빈칸을 주소로 메꿉니다 (link).

3.2. Memory region과 Memory map

Symbol은 내부적으로 3가지 종류로 나뉘며, 각 메모리 영역에 나뉘어 올라갑니다.

  • Read only (RO, `.text` + `.constdata`)
    • 읽기만 가능하고 수정할 수 없는 symbol을 의미합니다.
    • 대표적으로 const형으로 선언된 전역변수라던지, 소스코드 그 자체가 저장되는 영역입니다.
    • 항상 그 값을 유지하고 있어야 하므로 ROM에 저장됩니다.
  • Read write (RW, `.data`)
    • 읽기와 쓰기가 가능해 수정할 수 있는 symbol을 의미합니다.
    • 대표적으로 초기화된, 초깃값이 있는 전역변수가 여기에 속합니다.
    • 초기값을 가져야 하므로 ROM에 위치하면서, 수정도 가능해야 하므로 메모리에 복사되는 영역입니다.
  • Zero initialize (ZI, `.bss`)
    • 0으로 초기화되는 symbol 및 그 영역을 의미합니다.
    • 메모리에만 저장되며, 각 symbol을 0으로 초기화하지 않고, `.bss` 영역의 시작주소와 그 크기만 알아두고, 한 번에 이 영역을 0으로 초기화해버립니다. (그래서 빨라요)

3.3. ELF format object files

컴파일 후 나오는 결과물인 `.o` object 파일들은 ELF 형식을 따릅니다. ELF는 execution and loadable format의 약자로, 메모리에 로드해 실행할 수 있는 무언가를 의미합니다. 크게 object 파일은 Relocatable 그리고 Executable 두 가지 ELF로 나뉩니다.

  • 위 3.1절에서 현재 소스파일의 symbol table에 없는 symbol에 대해 짧게 설명했습니다.
  • 현재 소스파일에 선언되지 않아 주소/위치를 특정할 수 없는,
    그래서 symbol table 일부가 빈 공간으로 남아있는,
    추후 링커에 의해 빈 공간이 매워지고 배치될 object 파일이 'Relocatable object file'입니다.
    • 쉽게 말해서 실행이 가능한 파일은 아니고, 소스파일을 컴파일만 해놓은 상태입니다.
    • 링커가 link 할 수 있도록 table 형태로 만들어 놓은 것이지요.

Relocatable object 파일들이 각 섹션마다 모여서 Exectable object file로

  • 링커에 의해 다른 relocatable object 파일들이 공통된 섹션끼리 합쳐지고, symbol table의 빈공간이 매워지면서 메모리에 올라가 실행할 수 있는 상태가 되는데, 이를 'Executable object file'이라고 합니다.

readelf 유틸리티로 header를 읽으면, 각각 REL과 DYN으로 표시돼있습니다.

위 사진은 'readelf' 유틸리티를 이용해서 각 object 파일의 ELF header를 덤프한 것입니다.

ELF Header에는 endian 정보, 운영체제 정보, CPU 정보, 주요 섹션의 크기와 위치정보가 들어가 있습니다.

`.symtab` Symbol table이 object file 내 section으로 자리 잡고 있는 것을 볼 수 있습니다. 내부에는 저희가 선언했던 전역변수(`aa`, `bb`, `cc`)와 함수들 (`add()`, `main()`)이 들어있는 것을 알 수 있습니다.

  • Num은 링커를 위한 symbol의 번호입니다.
  • Value는 해당 symbol의 시작 offset 주소입니다.
  • Size는 symbol의 크기, Function(함수), object(전역변수)가 아닌 경우는 0입니다.
  • Type은 해당 symbol의 종류 (함수, 전역변수, section 등)를 나타냅니다.
  • Bind는 해당 symbol의 scope를 의미함 Global, Local, Weak를 나타냅니다.
  • Ndx는
    • UND: 현재 file에서 사용되고 있지만 define은 없는 symbol
    • ABS: Relocate 돼서는 안 되는 symbol
    • 1은 .text, 2는 .data, 3은 .bss를 의미합니다.

readelf와 objdump로 본 .text 섹션