Category (Click)
개발보드 덕질하기

[Embedded] MCU가 main()을 실행하는 과정 (feat. Vector Table)

 차완기 - @8/13/2023, 12:55:00 AM
지난 포스팅에서 임베디드 MCU의 메모리에 대해 MMIO를 엮어 알아보았습니다. 0x0800 0000의 정체와 C언어 속에서 포인터 만으로 어떻게 Peripheral를 제어할 수 있는지 알게 되었는데요, 꼬리를 무는 또 한가지의 의문점이 생기게 되었습니다.

그래서...main()은 어떻게 실행되는데?

이번 포스팅에서는 MCU에 전원이 공급된 후 어떻게 main() 함수가 실행되는지 알아보도록 하겠습니다.
여기서는 STM32F103시리즈와 해당 MCU에서 사용하는 Cortex-M3 프로세서를 기준으로 설명합니다.
프로세서마다 부팅 시퀀스가 다를 수 있기 때문에 참고해주세요.

프로세서에 전원이 공급된 직후에는..

arm Documentation - KA001328
구글에서 “cortex-m3 booting sequence”를 검색했을 때 가장 위에 나오는 arm의 공식 자료입니다.
집중해야 할 부분은 크게 두 가지인데요, 프로세서가 시작된 후 가장 첫번째로 0x0 주소의 데이터를 SP로 하고, reset vector에 해당하는 두 번째 주소를 시작점으로 삼는다고 나와있습니다.
STM32는 32-bit 기반이기 때문에 데이터를 처리하는 단위(word)는 32-bit, 즉 4 bytes죠. 즉, 0x0부터 길이 4 bytes 만큼이 SP에 저장되고 0x4부터 또 4 bytes가 프로그램의 시작점이 됩니다.
하나의 주소에는 8개의 bit, 즉 1 bytes에 해당하는 데이터가 위치해있습니다.
종합하자면 위 과정을 거친 후에는 CPU 레지스터 중 SP(Stack Pointer, 스택의 마지막 지점)가 0x0 주소의 데이터로 초기화되고 PC(Programe Counter, 현재 실행할 명령의 위치)에는 0x4 주소의 데이터가 들어가게 됩니다.
0x4에 저장된 주소가 도대체 무엇이기에 main이 실행되는걸까요? 그 전에 부팅 시퀀스에 대해 조금 더 자세히 알아보겠습니다.

Reset behavior

Cortex-M3 프로세서의 아키텍처인 ARMv7-M의 “Architecture Reference Manual”을 살펴보면 이러한 내용을 의사코드로 확인할 수 있습니다.
ARM v7-M Architecture Reference Manual - ID021910
우선 아랫쪽 빨간색 밑줄을 확인해보면 vectortable에 저장된 위치에 접근한 후 8*4 bit(4 bytes) 크기의 데이터를 가져와 SP_main에 집어넣고, 다음 파란색 부분을 통해 vectortable에서 4 bit 뒤의 주소를 읽어 마찬가지로 4 bytes의 데이터를 PC에 저장하는것을 확인할 수 있습니다.
이처럼 SP와 PC를 초기화하기 위해 vectortable을 사용하는데요, vectortable의 정의는 윗쪽의 초록색 부분에서 확인할 수 있습니다. 여기서 VTOR(레지스터)는 메뉴얼에 따라 따로 설정하지 않으면 0x0000 0000이니 기본값으로 간주하고 식을 계산하면 vectortable0x0000 0000이 됩니다.
위의 Reset behavior 코드는 의사코드(Pseudocode)입니다. 메뉴얼의 “Table H-2”을 참고하면 의사코드의 의미를 알 수 있습니다.
여기에서 SP가 SP_main, SP_process로 나뉘어져 있는것을 확인할 수 있는데요, RTOS 등에 활용하기 위해 여분의 SP를 SP_process로 두었다고 이해하면 될 것 같습니다. 자세한 내용은 아래를 참고해보세요.
그럼 이제 계속 언급되고 있는 vectortable에 대해 알아봐야겠죠?

Vector Table

Cortex-M3 Devices User Guide - Vector Table
벡터 테이블은 예외와 인터럽트를 처리할 때 호출하는 ISR의 주소를 저장한 표입니다. 이 표는 특별한 경우가 아니라면 메모리의 시작점인 0x0000 0000부터 위치하고 프로세서에서는 벡터 테이블대로 예외와 인터럽트를 처리합니다.
예를 들어 저를 괴롭히는 Hard fault 예외가 발생하면 프로세서에서는 0x0000 000C 주소의 데이터를 PC에 넣어 Hard fault의 ISR에 해당하는 함수로 점프하게 됩니다.
마찬가지로 MCU에 처음 전원이 공급되어 켜지는 상황도 예외로 처리하는데요, 이 과정이 바로 앞서 언급한 Reset behavior입니다. 전원이 켜지면 0x0000 0004 주소에서 가르키고 있는 함수 주소로 점프하는 것이죠.
Cortex-M 프로세서는 예외와 인터럽트를 동일하게 처리하는데요, 이 때문에 예외에 해당하는 Hard fault와 인터럽트에 해당하는 IRQ가 따로 분리되지 않고 같은 벡터 테이블에 있는 것을 알 수 있습니다.
여기서 인터럽트에 해당하는 부분의 끝이 IRQn이라고 표기된 이유는 지난번에 알아본 Memory Map과 마찬가지로 Vector Table 역시 arm에서는 기본적인 틀만 제공하고 나머지는 MCU 벤더 입맛에 맞게 수정하기 때문입니다. 사용하려는 MCU의 데이터시트를 확인해보면 MCU에 맞는 Vector Table을 확인할 수 있습니다.
그럼 이 Vector Table이 어떻게 C언어 코드와 연결되는지 확인해봅시다.

startup과 Vector Table

스타트업 코드는 빌드 과정에서 다른 코드들과 함께 링크되는 어셈블리어 코드입니다. 주로 벡터 테이블에 연결될 데이터나 함수를 설정하며, 이외에도 peripheral 초기화, SRAM 초기화, ISR 정의 등에 대한 코드가 적혀있습니다.
STM32CubeMX 상에서 STM32F103 기준으로 펌웨어를 자동생성하면 Core/Startup/ 위치에 startup_* 으로 시작하는 파일이 생성되는데, 이 파일이 바로 스타트업 코드입니다.
여기서부터 ARM 어셈블리어에 대한 지식이 조금 필요합니다. 저도 잘 몰라요 검색해보세요 검색
코드를 연 후 아래로 조금 내리면 g_pfnVectors 라는 레이블과 조금 더 아래로 내려보면 각 레이블을 weak 심볼로 정의하는 어셈블리어 코드를 확인할 수 있는데요, 바로 이 부분이 벡터 테이블과 펌웨어 상의 ISR을 연결하는 부분입니다.
g_pfnVectors 레이블 아래 내용을 보면 .word 명령을 이용해 word 만큼의 크기를 가지는 레이블을 추가합니다. STM32의 프로세서는 32-bit로, word의 크기는 4 bytes이기 때문에 각 레이블이 차지하는 공간은 4 bytes입니다.
레이블을 몇 개만 살펴보면 벡터 테이블의 가장 위(0x0)에는 SP에 들어갈 _estack이, 두 번째(0x4)에는 Reset_Handler, 그 뒤(0x8~)에는 NMI_Handler, MemManage_Handler 등의 레이블이 위치하게 됩니다. 앞서 살펴보았던 Cortex-M3의 벡터 테이블과 동일하죠.
_estack은 링커스크립트에서 RAM의 크기로 설정됩니다. 힙은 앞에서부터, 스택은 뒤에서부터 자라니 SP는 당연하게도 램의 끝부분이 됩니다.
이제 이 레이블을 C언어로 작성된 ISR 함수와 연결해야 하는데요, 두 번째 사진에서 각 레이블을 .weak, 즉 약한 심볼로 정의합니다.
벡터 테이블의 이해를 돕기 위해 앞서 언급했던 Hard fault 예외로 살펴보겠습니다.
STM32 MCU Feference manual - RM0008
STM32F103xx의 레퍼런스 메뉴얼 중 벡터 테이블을 나타낸 표를 확인해보면 HardFault에 해당하는 벡터 테이블 주소는 0x0000 000C인 점을 확인할 수 있고, startup에서는 g_pfnVectors 레이블의 4 번째 줄에 HardFault_Handler를 배치해 HardFault의 ISR 주소를 담는 레이블을 HardFault_Handler라는 이름으로 만들었습니다.
그 후 코드를 조금 내리면 weak로 HardFault_Handler 레이블을 HardFault_Handler 심볼로 정의합니다.
코드를 빌드하게 되면 링크 과정에서 링커가 HardFault_Handler 심볼의 정의를 찾게 되는데요, 해당 함수의 정의는 Core/src/의 파일 중 ISR이 저장된 C언어 코드에서 찾을 수 있습니다.

0x0000 0004에는?

그럼 이제 진짜 해답을 찾을 차례입니다. 앞서 프로세서에 전원이 공급되면 SP에 0x0 주소의 데이터를 저장하고 PC에 0x4 주소의 데이터를 저장한다고 하였으니 벡터 테이블에서 0x4에 연결된 심볼의 정의를 찾아보면 main이 어떻게 실행되는이 알 수 있습니다.
startup의 g_pfnVectors 레이블에서 두 번째 줄에는 Reset_handler 레이블이 위치해 있습니다. 이는 0x4 주소의 데이터가 Reset_handler 레이블의 주소를 가르키고 있다는 것을 의미합니다.
Reset_handler 레이블의 정의로 가보면 SystemInit 함수를 호출하고 SRAM을 초기화하는 등 여러 동작을 수행하는 것을 알 수 있습니다. 다른 내용은 집어치우고 보라색으로 표시한 부분을 보면.. bl main 이라는 명령이 보입니다.
감이 오셨죠?
The Cortex-M3 instruction set - PM0056
Cortex-M3의 명령어 세트 메뉴얼 중 BL 명령은 뒷쪽의 링크로 jump하도록 합니다. 즉, main() 함수가 실행되는 것이죠.

정리

MCU에 전원이 들어오고 main()함수가 실행되기까지의 기나긴(?) 여정이 끝났습니다. 한번 다시 정리해봤습니다.
MCU에 전원이 들어오면 프로세서는 미리 약속된 순서에 따라 SP와 PC를 각각 0x00x4 주소의 데이터로 초기화함.
다음 사이클에서 0x4 주소에 저장된 주소의 명령을 실행함.
이 명령은 Cortex-M3 프로세서의 Vector Table에서 Reset이라는 부분에 해당하며, 펌웨어의 startup 코드에서는 이 위치에 Reset_Handler 레이블 주소가 자리하고 있음.
0x4 주소에는 Reset_Handler 레이블의 주소가 저장되어있음.
Reset_Handler 레이블의 명령이 차례대로 실행되며 bl main 명령이 실행될 때 PC에 main() 함수의 주소가 저장됨. → main() 함수 호출
한편 LR(R14)에는 함수 호출의 반환 주소가 저장됨.
main() 함수가 return되면 PC가 LR의 주소로 바뀌어 다음 사이클에서 바로 뒷줄의 명령(bx lr)이 수행되는데, 명령에 따라 LR 레지스터의 주소로 점프하면 LR로 또 점프하는 사실상 무한 루프가 시작됨. (main() 함수가 끝난 후 엉뚱한 명령을 실행하지 않도록 함)
자...그럼 MCU에 디버거를 연결하고 실제로 확인해볼까요?

어??? 근데 벡터 테이블(g_pfnVectors) 0x0부터 시작 안하는데??

그런데, 여기서 한 가지 문제가 생기게 됩니다. startup의 모든 내용은 빌드되어 Flash에 저장되기 때문에 Memory Map의 Flash에 영역을 벗어날 수 없다는 것입니다.
디버깅을 통해 확인해보면 CPU의 SP, PC 레지스터는 예상했던 대로 _estackReset_Handler로 바뀐 것을 알 수 있으나, g_pfnVectors 레이블의 주소를 찍어보면 예상했던 0x0000 0000이 아닌 0x0800 0000에 저장된 것을 확인할 수 있습니다.
이는 벡터 테이블이 0x0800 0000에 저장되어 있다는 것을 의미합니다. 앞의 Reset behavior에서는 0x0000 0000의 벡터 테이블을 사용한다고 되어있는데, 벡터 테이블이 2개가 있는걸까요?
g_pfnVectors 레이블은 .section .isr_vector,"a",%progbits 명령과 링커스크립트에 의해 수정하지 않은 경우 0x0800 0000에 고정됩니다.

STM32의 BOOT Mode

해답은 간단합니다. STM32의 부팅 과정에서 Flash 메모리의 주소인 0x0800 00000x0000 0000에 aliased시키기 때문입니다.
STM32F103x8 datasheet (rev 10)
MCU 데이터시트의 Memory Map을 살펴보면 벡터 테이블이 있어야 할 위치에 문구가 있습니다.
BOOT 핀의 상태에 따라 Flash메모리 또는 시스템 메모리에 aliased된다는 내용입니다.
STM32F103xx, … Reference manual - RM0008
MCU 레퍼런스 메뉴얼을 살펴보면 BOOT0, BOOT1 핀의 상태에 따라 0x0000 0000에 해당하는 위치에 aliased시킨다고 합니다. 만약 저희가 작성한 펌웨어를 실행시키려면 Flash가 0x0에 aliased되어야 하고, 그러기 위해서는 BOOT0을 Pull-down 해야겠네요.
만약 STM32의 내장 부트로더를 실행하기 위해서는 BOOT0을 high, BOOT1을 low로 하면 system memory 영역의 벡터 테이블이 0x0에 aliased되어 저희가 작성한 펌웨어의 main이 실행되는게 아닌, ST에서 의도한 코드가 실행겁니다.
실제로 메모리를 덤프해보면 0x0000 0000의 벡터 테이블과 0x0800 0000의 벡터 테이블 내용이 일치하는것을 알 수 있습니다. (메모리 뷰어 이름을 테스트할 때 설정했던 …03d0으로 해두었네요. 이름만 다르고 위치는 0x0800 0000이 맞습니다)

마무리

여기까지 main함수가 실행되는 과정과 그를 위한 사전 지식인 vector table에 대해 알아보았습니다.
여태껏 아무런 생각 없이 코드를 작성해왔는데, 깊은 곳을 알게 되니 신기하고 한편으로는 재미있는것 같습니다.
언젠가 또 궁금증이 생기게 되면 삽질기로 돌아오겠습니다