Embedded

임베디드 레시피 Chapter 4. ARM ② Assembly와 Bootloader

soreDemo 2025. 1. 29. 18:01
교재(임베디드레시피)는 구체적인 문법을 설명하고 있지 않습니다. 또한, GNU gcc 기반이 아닌, ARM의 ADS를 기반으로 설명하시고 있습니다. 따라서, 어셈블리 문법에 대한 내용만큼은 다른 책을 보며 함께 공부하는 것이 좋을 것 같습니다.

제가 참고했던 ARM 어셈블리 문법 기초를 배우는 링크와 파일입니다. (링크1)  (링크2)

arm-instructionset.pdf
1.28MB
ARM7TDMI Instruction sets

1. ARM Assembly

1.1. ADS와 GNU GCC

ADS vs GNU

위 그림은 ARM의 ADS와 GNU 진영의 gcc 두 컴파일러의 어셈블리로, 보시다시피 문법과 구조가 거의 같습니다. Directive가 소문자 또는 대문자로 쓰인다던지, 맨 앞에 `.` 온점이 들어간다던지, 주석으로 `@`를 쓰냐 `;`를 쓰냐 정도의 미세한 차이가 있습니다.

 

GNU ARM 어셈블리의 구조는 다음과 같습니다.

  1. `_start` 레이블
    • 모든 어셈블리는 필수적으로 단 하나의 `_start` 이라는 이름의 main 레이블을 갖고 있어야 합니다.
    • 모든 프로그램은 이 main 라벨부터 시작되므로 Entry point라고 생각하면 됩니다.
  2. `.text`와 `.data`라는 이름의 섹션을 갖고 있어야 합니다.
    • `.text`: `.text` 섹션은 '본문'이라고 생각하면 됩니다. 어셈블리 명령어들이 나열돼 있습니다. `.text`라는 directive는 생략해도 컴파일러가 알아서 이해하기 때문에 괜찮습니다.
    • `.data`: '본문'에서 사용되고 메모리로도 로드될 변수/데이터들을 선언하거나 나열하는 곳입니다.
  3. `.global` directive
    • 레이블을 `.global`로 지정하면, 컴파일러와 링커에게 하나의 '섹션'으로 식별됩니다.
    • 즉, 링커가 다루는 최소단위 symbol로 인식됩니다.
    • 링커 입장에서는 '이 레이블로 묶인 코드 덩어리는 더 이상 쪼갤 수 없는 하나의 덩어리니까 symbol로 취급해서 주소 할당해야지'라고 생각하게 됩니다.

1.2. (추가내용) 기호

어셈블리 기초 문법은 따로 설명하지 않으려 했으나,
기호에 대한 내용은 교재 내용에 좀 더 추가가 필요한 것 같아서 작성했습니다.
  • `#` 기호 (상수): 상수를 나타내거나 immediate addressing을 나타내는 기호입니다. `#4`는 말 그대로 자연수 4입니다.
  • `!` 기호 (write-back)
    • Load, Store 명령어에 사용되는 기호입니다.
    • 앞에 사용된 피연산자를 base address로 하면서 일정 간격만큼 주소를 업데이트해나갑니다.
    • 예를 들어, `LDR R0, [R1, #4]!`가 있다면, R0에 주소 (R1 + 4)를 넣고, R1은 주소 4를 더한 R1 + 4로 업데이트합니다. 만일 반복문 안에 이 명령어와 기호가 있다면, 자동으로 피연산자 주소가 갱신되며 앞으로 나아가는 것이겠지요?
  • `S` 기호 (S suffix, set flag)
    • 많은 산술비교 명령어 뒤에 사용되는 기호입니다.
    • 명령어를 처리한 연산결괏값에 따라 CPSR의 최상단 n-bit flag bits를 갱신하는 기호입니다.
    • 따라서 이 기호를 사용하면, 다음 명령어를 처리할지 말지 제어할 수 있습니다.
    • 예를 들어, 'SUBS R0, R1`, `bz (목적주소)` 라는 두 명령어는 R0 -= R1값이 0일 때 목적주소로 jump 하는 방식입니다. 첫 번째 명령어를 수행하면 `S` 기호에 의해 CPSR의 mode bit가 갱신됩니다. R0가 0이라면 Z-flag가 set 됐을 겁니다. Branch 명령어를 수행할 때 Z-flag를 확인한 후 set 돼있다면 jump 합니다.
  • `^` 기호 (복원, SPSR -> CPSR)
    • 어떤 명령어와 함께 사용되냐에 따라 다른 역할을 수행합니다.
    • PUSH/ POP 명령어 (`STM`, `LDM`)와 함께 사용되는 경우
      • 피연산자에 PC가 없는 경우: 현재 mode 상관없이 User mode 레지스터에 저장/복원
        • 현재 mode가 무엇이든지 간에 User mode의 레지스터에 값을 저장하거나 User mode 레지스터 값을 가져옵니다.
        • User mode의 context를 제어할 때 유용합니다.
      • 피연산자에 PC가 있는 경우: SPSR → CPSR (복원)
        • 특히 `LDM` 명령어로 스택을 복원할 때 함께 사용합니다. 명시적으로 SPSR을 CPSR로 가지고 오는 명령어를 사용하지 않고도 이 기호 하나만 쓰면 알아서 CPSR을 복원해 줍니다. 단, 피연산자로 가져오는 스택 복원 내용물 중에 반드시 PC가 포함돼야 합니다.
        • `LDMFD SP!, {R0-R12, PC}^`: R0~R12 그리고 PC를 복원합니다. 그리고 `^` 기호 덕분에 CPSR도 갱신되며 동작 mode가 바뀔 겁니다. 마지막으로 write-back 기호 덕분에 스택포인터가 자동으로 복원한 레지스터들 크기만큼 이동합니다.
    • Load 명령어 (`LDR`) + 피연산자 PC인 경우
      • `LDR` 명령어의 피연산자가 PC 레지스터일 때, 이 기호를 사용하면 다음 cycle에 강제로 User mode로 들어갑니다.
      • Privileged mode에서 user mode로 돌아갈 때 사용합니다.

( + 추가내용2 - 스택 Push / Pop )

위에서 스택에 Push하고 Pop하는 instruction으로 `STM`과 `LDM` 명령어를 썼습니다. Thumb mode에서는 `PUSH`, `POP` 명령어를 사용하지만, ARM mode에서는 똑같은 명령어지만 다르게 부르는 `STM`계열 명령어와 `LDM`계열 명령어를 사용합니다.

 

재밌는점은 stack의 구현방법에 따라 호출하는 instruction이 달라진다는 점입니다.

먼저 '스택'이라는 자료구조를 떠올렸을 때, stack을 구현할 수 있는 경우의 수는 몇 가지가 있을까요?

  1. 스택이 증가하는 형태인가? 감소하는 형태인가?
    • 즉, 높은주소와 낮은주소가 있을 때 스택은 어느 방향으로 자라는지)
  2. 스택포인터는 유효한 데이터를 가리키는가? 빈 공간을 가리키는가?
    • 즉, 스택의 최상단 데이터를 가리키는지
    • 아니면 스택 최상단 데이터에 한 칸 앞 빈공간, 다음번에 스택 PUSH 요청 시 바로 저장할 그 주소를 가리키는지
  3. 스택 연산 시 스택포인터는 언제 증감하는가?
    • 데이터 삽입/제거 후 스택포인터가 증감하는지
    • 먼저 스택포인터를 증감한 뒤 데이터를 삽입/제거하는지

총 2³ = 8가지 경우의 수가 나옵니다.
사실 2번과 3번은 같은 고민이기 때문에 4가지 경우의 수로 축약할 수 있습니다.

선요약하자면, ARM 시스템은 default로 감소하는 형태의 유효한 데이터를 가리키는 스택포인터를 가지는 스택입니다.

그러므로 스택에 push하고 pop 할 때는 `STMFD` 명령어와 `LDMFD` 명령어를 주로 사용합니다.

 

참고로 모든 분기이동, 실행흐름 변경에 대해 무조건 context를 스택에 저장하지는 않습니다. 백업/복구가 굳이 필요하지 않은 경우가 있는데, 함수 내에서 다른 함수를 호출하지 않고 매개변수, 지역변수 개수도 적은 간단하고 짧은 함수입니다. 이런 경우, 컴파일러는 해당 함수로 분기하지 않고 그냥 인라인 처리하고 스크래치 레지스터 (R0~R3)만으로 처리합니다.  


 

간단한 짧은 어셈블리를 보면서 어떤 방식으로 해석해야 하는지 알아봅시다.

	.text
	.global _start				@ _start 레이블은 심볼입니다.
	.section	.text.startup,"ax",%progbits
	.syntax unified				@ Thumb2
	.thumb						@ Thumb
	.thumb_func					@ Thumb
	.type   _start, %function	@ _start 레이블은 함수입니다.

.code 16					@ 16-bit THUMB2로 진행합니다.
_start:						@ Entry point, 여기서 시작합니다.
	PUSH	{r0-r7, lr}		@ r0~r7과 lr을 스택에 push 합니다. 백업을 하네요.
	LDR		r6, data		@ r6 레지스터에 data의 주소를 넣네요. (레이블 == 시작주소) 
							@ 밑을보니 data는 1바이트 4개를 원소로 갖는 데이터네요.
	MOV		r5, r0			@ r5에 r0를 넣네요. r0는 보통 함수의 인수를 받는 용도죠?
	MOV		r4, #0			@ r4에 0을 넣고
	MOV		r7, #2			@ r7에 2를 넣습니다

.L10:
	LSL		r5, r5, r7		@ r7에 2가 들어있죠? r5를 2-bit 왼쪽으로 shift 하네요.
	ADD		r4, r4, r5		@ r4에 r4+r5를 넣습니다.
	MOV		r0, r4			@ r4를 r0로 옮깁니다.
	LDR		r1, [r6, #4]	@ r6가 가리키는 주소 + 4의 값을 r1으로 가져옵니다.
	BL		manual			@ manual 이라는 함수로 branch하네요. 'L'이 붙었으니 복귀주소 저장하겠네요.
	CMP		r0, #0			@ manual 함수의 return 값이 r0에 저장될테니 이게 0인지 확인하네요
	BNE		.L10			@ 0이 아니라면 다시 .L10으로 돌아가서 반복합니다.
	MOV		r0, r4			@ 0이 맞다면 r4를 r0로 옮깁니다.
	POP		{r0-r7, pc}		@ 백업했던 레지스터들을 복구합니다.

	.text
	.global data			@ data라는 레이블은 심볼입니다.
	.data					@ data라는 레이블은 .data 섹션입니다.
	.align  2
	.type   data, %object
	.size   data, 8
data:						@ data라는 변수는 1바이트씩 4개, 4바이트 데이터네요
	.byte  10
	.byte  20
	.byte  30
	.byte  40

 

  • `_start` 라는 함수가 호출되면, r4, 5, 6, 7을 사용하기 위해 각각 초기화합니다.
  • `.L10` 레이블을 보니,
    • `manual()` 이라는 함수를 호출하고 그 결괏값(r0)에 따라서 다시 처음부터 반복하는 것을 보니, `.L10` 레이블은 반복문인가 보네요.
    • r7 레지스터는 좌측 shift 연산을 위한 상수 2를 저장하는 용도네요.
    • r4는 초기값이 0이었는데, 반복문을 돌면서 r5가 더해지는 구조입니다. 그럼 r5는 무언가 중간값을 저장하기 위한 용도네요.
    • r6는 1바이트짜리 4개 데이터를 갖고 있는 배열? 같네요. data 레이블로 선언된 것을 보아하니 전역변수입니다.

이렇게 코드를 해석하고 로직을 알고 나니 정확하지는 않아도 원래 함수가 무엇이었는지 리버싱 할 수 있을 것 같습니다.

byte data[] = {10, 20, 30, 40};

int _start(int param) {
        int sum = 0;
        while (1) {
                param <<= 2;
                sum += param;
                if (!manual(sum, data[1])) break;
        }
        return sum;
}
  • 전역변수로 1바이트 데이터 4개를 갖는 `data` 배열이 있습니다.
  • `_start()` 함수는 매개변수 1개를 받습니다. 'param', 'sum' 등의 변수명은 임의로 붙인 것입니다.
  • 무한반복을 돕니다. 탈출조건은 `manual()` 함수의 반환값이 0일 때입니다.
  • 반복문 내에서 좌측 2-bit shift 연산한 결과를 계속 더하는 것을 볼 수 있습니다.

2. Exceptions

Chapter 2의 4절에서 배웠던 exception에 대한 내용을 다시 복습해 봅시다.

Exception이 발생하면, ① ARM mode로 전환하고, ② CPSR을 SPSR에 백업하고, ③ EVT(Exception Vector Table)로 jump 하고, ④ 예외를 처리하는 핸들러의 시작주소 식별 후 그쪽으로 jump 한 뒤, ⑤ SPSR을 다시 CPSR로 가져오며 스택프레임을 복원합니다.

 

이번 절에서는 exception을 처리한 후 '어떻게 안전하게 원래 실행흐름으로 돌아올 것이냐?'에 집중해서 더 자세히 배워봅시다.

 

파이프라인에 의해 exception이 발생한 지점과 그 순간의 PC 위치는 다르다, 2-cycle 앞에 있다는 것을 배웠습니다.
따라서 원래 실행흐름으로 돌아가기 위해서는 복귀주소를 조정해줘야 합니다.

 

그럼 각 exception 마다 어떻게 복귀주소를 할당하는지 알아봅시다.

LR calculation when exit exception handler

⦿ Reset handler

  • 시스템에 처음 전원이 인가돼 부팅할 때 기본으로 동작하는 default mode입니다.
  • HW적으로 불가피하게 발생하는 exception이기 때문에 언제든지 발생할 수 있고, 현재 프로그램 및 context의 상황을 봐가면서 발생하는 exception이 아니기 때문에 SW적으로 어떻게 처리한 후 복귀할 수가 없습니다.
  • 따라서 복귀주소를 계산할 수도 필요도 없습니다.

⦿ UNDEF handler

  • 명령어 opcode를 decode 했는데 무슨 명령어인지 알 수 없을 때 발생하는 exception이므로, 파이프라인의 'decode 단계'에서 발생하는 exception입니다. 이때 PC는 1-cycle 뒤에 있습니다. (32-bit 기준 ARM은 주소+4, Thumb는 주소+2)
  • UNDEF handler에서 이 알 수 없는 명령어에 대한 처리를 잘 해준 뒤,
    • (Default) 잘 처리했으니 해당 명령어를 다시 수행할 필요가 없다면 방금 PC가 있던 곳으로 돌아가 예외가 발생했던 곳 이후에서 이어서 진행합니다. `PC_current <- LR = PC_prev` 입니다.
    • (필요 시) 해당 명령어를 다시 수행해야 한다면, 방금 PC가 있던 곳에서 1-cycle 뒤로 물러나면 됩니다.
      `PC_current <- LR - (1-cycle)` 입니다.

⦿ Prefetch ABT handler

  • Fetch한 주소가 알 수 없는 주소 거나 접근할 수 없는 영역의 주소일 때 발생하며 'fetch 단계'에서 발생하는 exception입니다.
  • PC가 가리키고 있는 곳에서 예외가 발생하네요
  • 중요한 점은 ARM은 PABT 예외에 대해서는 LR에 PC+4(또는 PC+2)를 저장해 줍니다. 즉, 예외가 발생한 곳의 다음 명령어부터 시작할 수 있도록 (규칙성을 깨서 더 헷갈리게 만들지만) 나름 친절함을 발휘한 겁니다.
  • 그러므로, 복귀주소를 조정할 때는
    • 다시 예외가 발생했던 곳으로 돌아가서 명령어를 재실행하고 싶다면 `PC_current <- LR - (1-cycle)`
    • (친절함대로) 예외가 발생했던 곳 이후에서 이어서 하고 싶다면 `PC_current <- LR`
  • PABT exception 발생 시 handler 내부에는 개발자가 최대한 많은 핵심 디버깅 정보를 얻을 수 있도록 요란하게 많은 정보를 제공하고 디버깅용 코드를 삽입하는 경우가 잦습니다.

⦿ Data ABT handler

  • Instruction을 실행하려고 보니 피연산자에 이상이 있어서 발생하는 exception이므로 'execute 단계'에서 발생합니다.
  • 이때 PC는 2-cycle 뒤에 있습니다. (32-bit 기준 ARM은 주소+8, Thumb는 주소+4)
  • 위에서 PABT 때는 친절함을 발휘해 줬지만, DABT는 친절함을 발휘하지 않습니다.
  • 그러므로, 복귀주소를 조정할 때는
    • 다시 예외가 발생했던 곳으로 돌아가서 명령어를 재실행하고 싶다면 `PC_current <- LR - (2-cycle)`
    • 예외가 발생했던 곳 이후에서 이어서 하고 싶다면 `PC_current <- LR - (1-cycle)`

⦿ SWI

  • Kernel 자원을 이용하거나 system call을 호출하는 등 의도적으로 발생하는 exception이므로 'decode 단계'에서 발생합니다.
  • UNDEF 때와 마찬가지로 PC는 1-cycle 뒤에 있습니다. (32-bit 기준 ARM은 주소+4, Thumb는 주소+2)
  • 굳이 다시 SWI를 할 필요 없이 다음 명령어부터 이어가면 되겠죠? 그러므로 복귀주소는 `PC_current <- LR` 입니다.

⦿ IRQ/FIQ

  • SWI와 달리 IRQ/FIQ는 HW적으로 언제 발생할지 모르는 exception입니다. 하지만, 무언가 실행하고 있는 도중에 걸리는 건 확실하죠? 그러므로 'execute 단계'에서 발생한다고 간주할 수 있습니다.
  • DABT 때와 마찬가지로 PC는 2-cycle 뒤에 있습니다. (32-bit 기준 ARM은 주소+8, Thumb는 주소+4)
  • 예외 발생 주체가 SW가 아니기 때문에 재실행할 필요 없이 다시 원래 실행흐름으로 돌아가 인터럽트가 발생 직후에서 이어서 실행하면 됩니다. 따라서 PC가 가리키고 있던 곳의 1-cycle 이전으로 돌아갑니다. `PC_current <- LR - (1-cycle)`

★ 공통사항 - Context 저장 및 복원

CPSR <- SPSR
아래 모든 handler마다 공통적으로 적용되는 사항입니다. 복귀할 때는 주소뿐만 아니라 원래 동작 mode로도 복귀해야 하므로 SPSR에 백업해 둔 정보를 CPSR로 가져와야 합니다.

공통적으로 모든 handler는 진입할 때 지금까지 하던 진행내용 (context)을 스택 (또는 스택프레임)에 저장하고 handler를 수행한 후 다시 context를 복원한 뒤 조정된 복귀주소로 돌아옵니다.

stmfd sp!, {r0-r12, r14} @ Context 백업
@ ...Handler에서 처리해하는 내용들... @
ldmfd sp!, {r0-r12, pc}^ @ Context 복구
  1. Context(R0~R12, LR)를 스택에 백업합니다. `!` 기호 덕분에 SP도 함께 자동으로 감소합니다.
  2. Handler에서 처리할 내용들을 처리하고, 복원주소도 조정한 뒤
  3. 백업해 뒀던 context를 스택에서 꺼내옵니다. `^` 기호 덕분에 CPSR도 SPSR로부터 값을 가져옵니다.
    (피연산자 중에 PC가 있기 때문)

+ 추가내용 1. SWI Handler 하나로 여러 SWI 대처하기

앞서 SWI는 시스템 자원에 접근하기 위해 개발자가 의도적으로 발생시키는 SW적 exception이라고 설명했습니다. 따라서 다양한 자원에 대한 서로 다른 처리가 필요할 수 있기 때문에 Handler 하나로 여러 가지 작업을 수행해야 할 가능성이 큽니다. 그러므로, 마치 exeption vector table처럼 어떤 종류의 SWI 인지를 식별하고 해당하는 SWI handler로 branch 할 수 있도록 switch-case 형태의 코드를 작성해서 이러한 요구사항을 만족시킬 수 있습니다.

 

일반적으로는 `SWI`의 피연산자는 버려집니다. 하지만, 여러가지 피연산자를 통해 여러가지 SWI로 분기할 수 있습니다.

  • 첫 4-bit는 명령어의 조건 필드입니다. 거의 모든 ARM 명령어는 명령어를 바로 실행하는 게 아니라 EQ 라던지 NE라던지 CPSR의 condition flags를 먼저 본 뒤 실행한다고 배웠죠?
  • `1111`은 `SWI`의 opcode입니다.
  • 나머지 하위 24-bit가 `SWI`의 피연산자입니다.

버려지는 부분이라 뭘 쓰든 무의미했지만, 의도적으로 피연산자에 어떤 값을 준다면 어떨까요? 이 하위 24-bit를 추출한다면, 값에 따라 switch-case 같은 분기문을 이용해서 원하는 SWI handler로 jump 할 수 있겠습니다.

 

발생시킨 주소는 exception이 발생했을 당시의 (PC - 2-cycle)입니다. 이 주소를 참조한다면 버려질 피연산자를 레지스터로 가져오는 게 가능합니다. 이 피연산자를 함수의 매개변수라고 생각했을 때, 피연산자값이 무엇이냐에 따라 서로 다른 SWI handler로 branch 하도록 만드는 것이 핵심 개념입니다.

// test1.c
void SWI_func() {
    SWI_Exception();
}

// test2.s
...
SWI_Excpetion: @label
    SWI    0x123456
...

// startup.s
...
SWI_Handler
    STMFD    SP!, {LR}
    LDR      R0, [LR, #-4]
    BIC      R0, R0, 0xFF000000
    BL       SWI_C_Handler
    LDMFD    SP!, {LR}
...

// test1.c
...
__swi(0x121212) void UART_OUT(void);
__swi(0x987654) void BUTTON_OUT(void);

void SWI_C_Handler(int option) {
    switch(option) {
    case 0x121212:
        UART_MESSAGE("UART Interrupt! 0x121212");
        break;
    case 0x123456:
        LCD_MESSAGE("LCD Interrupt! 0x123456");
        break;
    case 0x987654;
        BUTTON_MESSAGE("Button pushed! 0x987654");
        break;
    }
}

 

  1. `SWI 0x123456`이라는 명령어를 ARM core가 decode 했다고 가정합니다.
  2. 시스템적으로 약속된 절차를 수행합니다. (CPSR값 SPSR에 백업, 레지스터 백업, PC값 LR에 백업 등)
  3. `LDR R0, [R14, #-4]`로 SWI가 발생한 line의 instruction을 R0에 저장합니다.
  4. `BIC R0, R0, #0xFF000000` BIC는 operand의 보수값이랑 AND 연산하는 명령어입니다. 따라서 `R0 & 0x00FFFFFF`를 수행해서 R0에 LSB 24-bit가 저장됩니다.
  5. AAPCS에 의해 R0는 매개변수 역할을 하므로 SWI handler의 parameter에 방금 뽑아낸 0x123456가 들어갑니다.
  6. Switch-case 문을 활용하면, 하나의 handler에서 parameter 값에 따라 다양한 일을 수행할 수 있게 됩니다.

+ 추가내용 2. Co-processor 관련 명령어

모든 작업을 프로세서 혼자서 처리할 수는 없습니다. 시스템 정상동작을 위해 프로세서 주위에서 시스템의 핵심 역할을 도와주는 여러 보조프로세서(co-processor)들이 존재합니다. 프로세서는 이들과 전용 레지스터로 데이터를 주고받으며 통신합니다.

 

Coprocessor 관련 명령어는 세 가지 종류로 나뉩니다.

  1. Coprocessor 사이의 소통 (`CDP`)
    • `CDP [Coprocessor 번호], [Coprocessor 명령어], CRd, CRs` 형태입니다.
    • 특정 Coprocessor 번호를 지정하고, Coprocessor 전용 명령어를 사용해 CRd (도착 레지스터), CRs (출발 레지스터)가 소통합니다.
  2. Coprocessor와 프로세서의 register 사이의 소통 (`MRC`, `MCR`)
    • `MCR(MRC) [ Coprocessor 번호 ], 0, [ 레지스터 번호 ], [ Coprocessor 레지스터 번호 ], c0, 0` 형태입니다.
    • M X ← Y 형식으로 `MRC`는 Coprocessor에서 레지스터로, `MCR`은 레지스터로 Coprocessor로 값을 씁니다.
    • `MCR p15, 0, r4, c2, c0, 0` = '프로세서의 R4 레지스터 값을 CP15의 2번 레지스터로 써라'라는 뜻입니다.
  3. Coprocessor와 메모리 사이의 소통 (`LDC`, 'STC`)
    • Coprocessor가 메모리 주소를 접근해서 값을 가지고 오도록 하는 명령어입니다.
    • `LDC(STC) [ Coprocessor 번호 ], [ Coprocessor 레지스터 번호 ], 타깃 메모리 주소` 형식입니다.
    • `LDC p15, c2, [r0, 0x100]` = 'R0가 가리키는 곳에서 0x100 떨어진 곳의 값을 CP15의 2번 레지스터로 가져와라'라는 뜻입니다.

3. Reset handler (Bootloader)

지금까지 저희는 여러 챕터에 걸쳐서 저희가 작성한 코드가 컴파일돼 링크되면서 각 특성에 따라 서로 다른 저장공간과 메모리 영역에 저장되는 것을 배웠습니다.

  • 각 symbol과 변수들이 특성에 따라 RO, RW, ZI로 분류되는 것을 배웠습니다.
    • `.text`와 `.constdata`는 읽기만 가능한 RO니까 ROM에,
    • `.data`는 초깃값을 가지고 있는 전역변수이고 RW니까 ROM에 저장 후 RAM에 복사해서 쓰기/수정이 가능하도록,
    • `.bss`는 초깃값이 없어서 0으로 초기화돼야하는 전역변수이고 ZI이므로 ROM에 저장하지 않고 RAM에 시작주소 + offset만 할당한 후 한방에 0으로 초기화하도록
  • 컴파일 후 ELF 형식을 따르는 object file을 load view (ROM에 저장되는 방식을 설명, relocatable 함), execution view (메모리에 올라와 실행될 때의 모습을 설명, executable 함)으로 나눠서 관리하는 것을 배웠습니다.

이렇게 프로그램의 메모리 구조를 잘 정리했으니 이제 이걸 어떻게 써먹는지에 대해 알아봅시다.

3.1. Bootloader

링커스크립트 (또는 scatter loading)를 직접 만들었든 링커가 자동으로 만들었든 컴파일러 및 링커의 도움으로 애플리케이션의 메모리 구조를 얻을 수 있습니다. Bootloader는 이를 이용해 시스템을 초기화하는 작업을 수행하게 됩니다.

 

소형/단순 임베디드 시스템의 경우에는 따로 메모리 구조를 지정하지 않고 링커가 만들어주는 default 메모리 모델을 사용합니다. 이때 링커는 자동으로 RO, RW, ZI에 대한 symbol을 만들어줍니다.

링커가 자동으로 만들어주는 각 영역의 시작주소 심볼

(ADS 기준으로는 `image$$RO$$base`, `image$$RO$$limit`, image$$ZI$$base` 같은 symbol이 생성됩니다.)

 

r0 = |image$$ZI$$Base|  ; .bss_start
r1 = |image$$ZI$$Limit| ; .bss_length
r2 = 0

BEGIN:
    str r2, [r0], #4    ; post-index, r0 += 4 해가면서 0으로 write함.
    cmp r0, r1          ; Base부터 Limit까지
    bne BEGIN           ; 반복

 

자동으로 생성된 symbol을 위와 같이 어셈블리와 C 코드에서 활용할 수도 있습니다. 위 코드는 `.bss` (ZI영역)을 반복문을 이용해 0으로 초기화시키는 간단한 어셈블리입니다.

링커가 자동으로 생성하는 load view와 exec view에서의 symbol들

Q. Load view에는 'base'만 있고 (노란색) 'Length'는 없는 이유

Load view든 execution view든 저장되는 장소 (ROM이냐 RAM이냐)만 달라지지 그 크기 정보 'Length'는 달라지지 않습니다. 따라서 execution view에 명시되는 length가 곧 load view의 length와 같습니다.
Q. Load view에는 'ZI 영역'이 없는 이유

계속 반복했던 이야기입니다. 0으로 초기화해야 하고, 읽고/쓰고/수정해야 하는 전역변수 `.bss`들을 ROM에 저장할 필요가 없겠죠? 그리고 ZI 영역의 시작주소 (`Image$$DRAM$$Base`)와 길이(`Image$$DRAM$$Length`)만 주면, bootloader가 알아서 영역 통째로 0으로 초기화해버립니다.

 

3.2. Reset handler가 하는 일

위에서 Bootloader 이야기가 나왔으니, 전원이 인가된 후 reset handler와 bootloader를 거쳐서 사용자 애플리케이션이 실행되는 과정을 배워봅시다.

 

먼저 Reset handler가 하는 일을 순서대로 정리하면 다음과 같습니다.

  1. IRQ/FIQ를 비활성화: 인터럽트가 걸리지 않도록 막습니다.
  2. PLL setting & 적절한 clock 인가: 설정된 PLL 설정대로 system clock, bus clock, watchdog timer 등을 초기화합니다.
  3. Memory controller 초기화
  4. 각 동작모드의 stack 초기화
  5. NAND 및 메모리에 영역별 주소 (execution view에 맞게) 설정하기 + RW 영역 메모리로 복사하기 + ZI 영역 0으로 초기화하기
  6. ` __rt_entry()` 호출 → `main()` 호출

ARM Application Note 107

위 그림을 보면, 저희가 진입점으로 알고 있던 `main()`을 호출하기 이전에 많은 과정이 선행되는 것을 알 수 있습니다.

  • 모든 애플리케이션은 `__main()` 부터 시작하는 것이 약속입니다.
  • 그러나, 임베디드 시스템은 `__main()` 역할을 reset handler가 하므로 (low vector 기준) `0x0`번지의 exception vector table의 최상단에 `ENTRY` directive와 함께 가장 먼저 reset handler가 위치하고 있습니다.
  • Reset handler 내부에서 (위에서 다룬) bootloader가 하는 일들을 수행한 뒤 `__rt_entry()`를 호출합니다.
  • `__rt_entry()`에서는 ⓘ C 라이브러리에서 사용할 stack과 heap 영역을 잡고, ② C 라이브러리 내부의 전역변수들을 초기화하는 등 C 라이브러리 초기화를 담당합니다.
EXPORT __main
EXPORT _main

AREA INIT_VECTOR, CODE, READONLY
CODE32

__main   ; __main symbol을 label로 만들어준다.
_main    ; __main symbol이 없으면 _main을 찾기 때문에 label 2개 쓴다.
    ENTRY
    B        Reset_handler
    B        Undefined_handler
...
  • `EXPORT`문을 사용해서 exception vector table에서 `__main()`을 가로채서 진입점으로 설정하는 과정입니다. (`__main()`이 없으면 `_main()`을 호출하므로 둘 다 `EXPORT` 해줍니다.
  • 만일 exception vector table 최상단을 진입점으로 쓰지 않으려면, reset handler 마지막에서 `__rt_entry()`를 호출하는 대신 `__main()`을 호출하면 됩니다. 그러면 C 라이브러리의 `__main()`이 호출되고 자연스럽게 `__rt_entry()`가 호출됩니다.