임베디드 레시피 Chapter 3. SW ① 컴파일부터 로드
1. Little endian과 Big endian
프로세서가 메모리를 이해하는 순서 및 저장하는 방식은 Little endian과 Big 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을 위한 방식
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. 컴파일 과정
컴파일 과정을 간략하게 설명하면 다음과 같습니다.
- 전처리기(Preprocessor)는 컴파일을 쉽게 할 수 있도록 헤더파일/매크로/미리 계산 가능한 상수들을 소스파일로 옮기며 최적화 작업을 수행합니다.
- 전처리가 완료된 파일들은 기계어와 1:1 대응하는 어셈블리로 만듭니다.
- 어셈블러는 `.s` 어셈블리 파일들을 object 파일로 만듭니다.
- 링커스크립트 (Linker Script, 또는 scatter loading 파일)를 통해 메모리 구성을 원하는 대로 설정합니다.
- 링커가 여러 object 파일과 라이브러리들을 하나로 묶고 엮어 하나의 실행 가능한 ELF(Executable & Loadable File) 형식의 새로운 object 파일을 생성합니다.
- 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 파일들이 공통된 섹션끼리 합쳐지고, symbol table의 빈공간이 매워지면서 메모리에 올라가 실행할 수 있는 상태가 되는데, 이를 'Executable object file'이라고 합니다.
위 사진은 '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를 의미합니다.