인프런 - 홍영기 강사님의 ARM Cortex-M 프로그래밍 강의를 공부한 뒤 일부 햇갈리는 내용을 요약, 정리하기 위한 문서입니다.
참고한 문서는 다음과 같습니다. (링크)
- RM0008 - Cortex-M3 Reference Manual: Cortex-M3에 대한 전체적인 모든 정보
- PM0056 - Cortex-M3 Programming Manual: 그 중에서도 프로그래밍을 할 때 특히 필요한 정보가 모여있음. Instruction set을 비롯해 중요 주변장치 (MPU, NVIC, SCB, SysTick)에 대한 내용이 있음
- DDI0337 (링크) - Cortex-M3 Technical Reference Manual: 위 두 문서 합쳐놓은 느낌의 기능 위주의 잘 정리된 문서
- 기타 문서들
- DS5319 - STM32F103 Datasheet: 각 핀이나 내부 소자의 전기적 특성 정보
- UM1724 - Nucleo-64 board User Manual
- MB1136 (링크) - Schematic, NUCLEO-64 보드 공통 회로도)
강의는 NUCLEO-F429I Discovery board를 이용했지만, STM32F103RBT6를 탑재한 NUCLEO-F103RB를 사용해도 전혀 문제가 없었습니다. (강사님께서 F401, F103으로도 실습할 수 있도록 코드를 따로 제공해주시고 계십니다.)
1. Cortex-M3 및 STM32 구조
위 두 그림은 각각 Cortex-M3와 STM32F103xx의 구성을 나타낸 블록 다이어그램입니다.
STM32F103xx는 20KB SRAM을 주 메모리로 사용하며 128KB 플래시메모리가 내장돼있습니다.
- I-Bus: 명령어가 인출되는 통로입니다.
D-Bus: 리터럴 로드 및 디버그용 데이터 통로입니다.- 이렇게 명령어 bus와 데이터 bus가 나뉘어 있는 구조를 하버드 아키텍처라 부릅니다.
- System Bus: 메모리에 접근해 데이터에 액세스하는 통로입니다. (`0x2000_0000`번지에서 시작)
코어쪽에는 AHB 고속 bus를, 주변장치 쪽에는 APB bus를 사용하고 있습니다.
플래시 메모리에 달려있는 인터페이스는 플레시 메모리에 대한 작업과 읽기/쓰기 보호 매커니즘을 갖고있습니다.
이번 학습에서 주로 사용할 명령어는 THUMB2 명령어 입니다.
- 기존 ARM-Thumb 조합 명령어보다 코드밀도는 약 26% 높습니다.
- 기존 Thumb보다 성능은 약 25% 높습니다.
THUMB2 명령어를 사용함의 장점은 mode 상태 전환이 없어진다는 점입니다. 32-bit ARM mode로 만들어진 명령어를 실행하다가 16-bit Thumb mode 명령어를 만나면 상태전환이 이뤄져야 합니다. Thumb2 명령어를 단독 사용하게되면 모두 32-bit로 이뤄져있기 때문에 이러한 상태전환을 할 필요가 없어져 코드밀도 향상 및 성능향상이 이뤄집니다.
좌측은 메모리 구조를 나타내는 memory map입니다.
- 32-bits width를 갖는 메모리이므로 총 4GB 영역을 가리킬 수 있습니다.
- SRAM은 `0x2000_0000`번지부터, 플래시메모리의 코드영역은 `0x0800_0000`번지부터 주소가 시작합니다.
- 일부 영역들이 특정 주변장치들과 Memory mapped I/O 하기 위해 할당돼있습니다. (USART, I2C, GPIO, DMA 등)
- 주소 `0xE000_0000`번지는 코어 주변장치(인터럽트를 관리하는 NVIC, 시스템을 관리하는 SCB 등)를 위한 영역입니다.
우측은 레지스터 구조 및 context입니다.
AAPCS에 따라 일부 레지스터는 특수한 역할이 정해져있습니다.
- R0~R3 (Scratch registers): 빠른 연산을 위해 프로세서가 맘대로 사용하는 임시저장소로 연산 중간결과나 리턴값을 저장하는 용도로 활용합니다. 함수로 넘어가는 인자값으로도 사용됩니다.
- R7 (Stack frame register, FP): 서브루틴으로 이동할 때 context를 저장하게 되는데, 이때의 스택포인터(R13, SP)값을 저장합니다. 이후 스택포인터는 context를 저장한 이후의 스택 최상단을 가리키게 됩니다. 따라서 FP는 프레임 최상단을 가리킵니다.
- R13 (Stack Pointer, SP): 현재 사용중인 스택의 최상단 주소 (엄밀히 말하면 이 시스템은 Full descending 스택을 사용하므로 가장 낮은 주소)를 가리키며 유효한 값을 가리키고 있습니다.
- R14 (Link Register, LR): 돌아갈 주소, 복귀할 주소를 가리키고 있습니다. 이 주소가 오염되면 복귀할 주소를 잃어버리게 되므로, 리프함수가 아닌 경우에는 반드시 스택 (스택프레임)에 돌아갈 주소 LR값을 백업해놔야 합니다.
- R15 (Program Counter): 현재 fetch하고 있는 명령어의 주소를 담고 있습니다. 즉, 다음에 실행될 명령어를 가리키고 있습니다. PC가 가리키는 곳이 실행하는 곳이 아니라는 것을 이해하는 것이 아주 많이 중요합니다! 파이프라인(Pipeline)을 사용하기 때문에 현재 실행하고 있는 곳으로부터 두 단계 앞 (THUMB2를 사용하기 때문에), +0x4 주소를 가리키고 있습니다
정말로 특수한 용도로 사용하는 특수 레지스터들도 있습니다.
xPSR (Process Status Register)
PSR 레지스터는 현재 실행하고 있는 프로그램의 상태값을 알려주는 유용한 레지스터입니다. PSR 레지스터는 A(Application)PSR, I(Interrupt)PSR, E(Execution Program)PSR 3개를 합쳐 통칭하는 단어입니다.
- 방금 실행한 명령어의 결괏값에 따라 업데이트 된 NZCVQ flags를 보여주고,
- T-bit로 현재 THUMB mode인지 알려주며
- ICI 필드에는 인터럽트가 발생한 지점에서 중단된 여러 개의 레지스터를 Load/Store 하는 데 필요한 정보가,
- IT 필드에서 `if-then` (IT명령어) 실행에 대한 정보가,
- 마지막 필드에서는 현재 실행중인 exception이나 인터럽트 번호가 몇 번인지도 알려줍니다.
많은 정보를 알려주므로 디버깅 시 특히 유용합니다.
PRIMASK, FAULTMASK, BASEPRI 레지스터
인터럽트 및 exception의 허용/비허용을 제어하는 레지스터입니다.
PRIMASK: 우선순위를 설정할 수 있는 모든 예외 및 인터럽트를 disable합니다. 즉, 우선순위가 -3, -2, -1로 고정돼있는 NMI, Hard Fault, Memory Management Fault 세 가지만 허용합니다.
FAULTMASK: PRIMASK보다 강력합니다. NMI를 제외한 모든 exception, 인터럽트를 disable합니다.
BASEPRI: [7:0] 8-bits로 지정한 인터럽트 우선순위보다 같거나 낮은 인터럽트를 disable합니다.
PRIMASK 같이 강력한 기능을 제공하는 레지스터는 함부로 접근해서 제어하기에는 너무 위험합니다.|
따라서 권한이 없다면 접근할 수 없도록 해야합니다.
Handler mode vs Thread mode (User Mode vs Privileged Mode)
따라서 사용자를 위한 Thread mode, 특수한 권한을 위한 Handler mode 이렇게 2개의 모드로 권한을 나눕니다.
권한 뿐만 아니라 두 모드는 사용하는 스택 영역도 다릅니다.
• 사용자를 위한 thread mode에서 사용하는 스택포인터를 Process Stack Pointer, PSP,
• 권한모드 handler mode에서 사용하는 스택포인터를 Main Stack Pointer, MSP라고 합니다.
이렇게 스택포인터가 MSP, PSP 두 개로 나뉘어있고 사용하는 영역도 서로 다르지만, 사용자가 코드를 짤 때는 그냥 'sp'만 쓰면 됩니다. 동작모드에 따라 프로세서가 sp를 msp 또는 psp로 알아서 해석해주는 덕분입니다.
CONTROL 레지스터로 프로세서의 동작모드 (권한)를 제어할 수 있습니다.
이때, Handler mode일 경우 MSP를 사용해야만 하므로 1번 bit는 무조건 '0'으로 고정돼있죠.
2. Instruction Set
STM32F103을 가지고 Cortex-M3에서 지원하는 여러 어셈블리 명령어들을 직접 테스트해봅니다.
이 테스트를 거치면서 위에서 다룬
- 다양한 코어 주변장치들과 레지스터들이 어떻게 동작하는지 알아봅니다.
- THUMB mode 동작이 어떻게 이뤄지는지 알아봅니다.
- 서브루틴으로 이동할 때 어떻게 stacking - unstacking 하는지 알아봅니다.
- 인터럽트나 exception이 발생했을 때 어떤 과정이 일어나고, 원인을 어떻게 알 수 있는지 알아봅니다.
▲ 테스트했던 workspace
- 각 테스트마다 `#if 0 ... #endif`로 묶여있습니다. 테스트하고 싶은 부분은 0을 1로 바꿔주면 됩니다.
- Directive 말고 추가로 수정해야 하는 부분이 있다면 주석에 명시해놨습니다.
- 1_Assembly_Test :다양한 어셈블리 명령어를 테스트한 workspace입니다.
- 2_Interrupt_Test: 인터럽트 및 exception들을 테스트한 workspace입니다.
2.1. 준비하기
2.1.1. printf 준비하기
원활한 디버깅을 위해서는 USART 시리얼통신을 통해 원하는 값을 출력해야 합니다.
`<stdio.h>`의 `printf()` 함수는 내부적으로 `_write()` 이라는 함수를 호출하는데, 모니터 같은 출력디바이스가 없는 임베디드 장치에서는 이 함수를 '오버로딩'해서 USART로 연결시켜줘야 합니다.
핀맵 perspective를 열고, PA2가 USART_TX, PA3가 USART_RX로 설정돼있는지 확인합시다.
`__attribute((weak))`: 이 선언을 통해 기존 `_write()` 함수를 오버로딩할 수 있습니다.
`_write()` 함수는 문자열의 길이만큼 `__io_putchar()` 함수를 호출해서 문자를 하나씩 출력합니다.
그러므로 저희는 이 `__io_putchar()` 함수를 USART로 연결시켜주는 코드만 작성해주면 됩니다.
- 들어온 문자가 개행문자일 때 캐리지리턴(carriage return, `\r`)을 덧붙여주도록 합니다.
이 과정을 빼먹으면 개행을 해도 열이 초기화 되지 않아서 가장 마지막 문자가 있던 열에서 계속 이어서 쓰게 됩니다. - `HAL_UART_Transmit()` 함수는 STM32 쪽에서 만든 CMSIS (Common Microcontroller Software Interface Standard) 추상화된 함수입니다. USART 같은 주변장치의 내부동작 과정을 알지 못해도 이렇게 준비된 API를 알맞게 호출함으로써 저희는 1바이트 문자를 USART로 출력할 수 있게 됩니다.
int __io_putchar(int ch) {
if (ch == '\n')
HAL_UART_Transmit(&huart2, (uint8_t*)&"\r", 1, HAL_MAX_DELAY);
HAL_UART_Transmit(&huart2, (uint8_t*)&ch, 1, HAL_MAX_DELAY);
return ch;
}
int __io_getchar(void) {
uint8_t ch;
while (HAL_OK != HAL_UART_Receive(&huart2, &ch, 1, HAL_MAX_DELAY)) ;
return ch;
}
2.1.2. 내장 LED 및 버튼 준비하기
Schematic 회로도를 보면, NUCLEO-F103RB 보드의
- GPIO 포트A 5번핀에 초록 LED가 연결돼있고,
- GPIO 포트C 13번핀에 파란 버튼이 연결돼있습니다.
- PA5는 'LD2' 라는 이름으로 ailiasing 돼있고, 초록 LED를 나타냅니다.
- PC13은 'B1'이라는 이름으로 ailiasing 돼있고, 파란버튼을 나타냅니다.
2.2. 첫 어셈블리 작성
어셈블리는 `libs.s` 파일에 작성합니다.
.syntax unified
.cpu cortex-m3
.fpu softvfp
.thumb
.text
/*
* 1. 첫 어셈블리 및 LR, 스택 Testing
*/
@ void first_asm_func(void)
.global first_asm_func
first_asm_func:
push {lr}
add a1, a2 @ r0 = r0 + r1 || add r0, r0, r1
@ b .
bl F11
pop {pc}
@ bx lr
F11:
bx lr
- 우선, 몇 가지 directive에 대해서 언급하자면,
- `.syntax unified`: ARM mode 문법과 THUMB mode 문법을 통합한, 공통적인 문법만 사용하겠다는 의미입니다. (링크)
- `.cpu`: 이 어셈블리 코드가 동작할 타겟 프로세서를 명시해줍니다.
- `.fpu softvfp`: 부동소수점 연산을 소프트웨어적으로 구현해서 사용하겠다는 의미입니다.
- `.thumb`: THUMB mode 명령어를 사용하겠다는 의미입니다.
- `.text`: 아래 어셈블리들이 메모리 구조 중 `.text` 섹션에 포함된다는 것을 의미합니다.
- `.global`: 다른 소스에서도 참조할 수 있도록 레이블을 외부참조 가능하게끔 심볼화 합니다. 이렇게 선언된 전역 레이블은 심볼테이블에 삽입돼 다른 곳에서 참조할 수 있습니다.
- `.word`: 완전히 동일하진 않지만, 일종의 변수 선언용 키워드로, 1-WORD 크기의 공간을 만듭니다.
- `@`: 주석을 의미합니다. C언어의 `//`와 같습니다. STM32CubeIDE에서는 `/**/`와 `//` 모두 주석으로 지원합니다.
- `first_asm_func` 라는 전역 레이블을 선언했습니다.
- `add a1, a2`는 `add r0, r0, r1`과 같으며 `add r0, r1`과도 같습니다. 덧셈결과는 R0 레지스터에 저장됩니다.
- `bx lr` 명령어 또는 `pop {pc}` 명령어로 복귀주소로 돌아갑니다. `bx lr`은 오직 LR 레지스터값이 오염되지 않음을 보장할 수 있을 때만 사용해야 합니다. 그렇지 않으면 돌아갈 복귀주소를 잃어버리게 됩니다. 위 코드에서는 push, pop 명령어를 이용해서 복귀주소를 백업/복원하고 있습니다.
- `.global`을 사용하지않은 `F11`은 로컬 레이블입니다.
- 로컬 레이블은 외부참조는 안되지만, 어셈블리 파일 내에서는 참조가 가능합니다.
- 레이블명은 레이블 시작주소와 같기 때문에 변수 또는 함수처럼 사용하면 됩니다.
어셈블리를 짰으니 이를 C 소스파일에서 사용할 수 있도록 `int first_asm_func(int, int, int, int, int, int)` 함수를 선언합니다. 함수명은 곧 심볼입니다. 컴파일러는 이 함수명이 위에서 구현한 어셈블리의 전역 레이블과 일치함을 근거로 함수의 정의부가 C 소스파일에 없더라도 어셈블리 파일의 어셈블리로 매칭합니다.
앞서 배웠듯이 함수를 호출했을 때 인자값을 넘겨주는 용도로 스크래치 레지스터들 (R0~R3)을 사용합니다.
앞에서부터 7, 8, 9, 10 4개 인수가 각각 R0, R1, R2, R3에 들어갑니다.
반면 R4, R5는 스크래치 레지스터가 아닙니다. 무언가 중요한 정보를 갖고 있을 수도 있기 때문에 함부로 사용해서는 안되므로
- R3에 넣은 10을 다시 스택에 push 하고 R3에 11을,
- R3에 넣은 11을 다시 스택에 push 하고 R3에 12를 저장합니다.
- 어떻게 넣었는지는 중요하지 않습니다. 중요한 점은 인수 4개는 스크래치 레지스터에, 나머지는 스택에 저장한다는 점입니다.
Branch 명령어는 피연산자로 들어온 주소로 jump하는 명령어입니다.
- 만일 현재 주소를 의미하는 `.` 온점을 branch 명령어와 함께 쓰면 어떻게 될까요?
- 현재 주소로 점프하고, 또 점프하고, 또 점프하고... 무한 루프입니다.
- 보통은 `bx lr`로 복귀할 주소와 함께 사용합니다.
이때 `b`가 아닌 `bx`를 사용하는건 ARM mode와 Thumb mode를 혼용해서 사용할 때 동작 mode를 자동으로 바꿔주기 (또는 그대로 유지) 위한 용도(링크)인데 THUMB2만 사용하는 지금은 별 의미없습니다.
이번에는 돌아오는 주소가 LR 레지스터에 잘 저장돼있는지 확인해봅시다.
함수 진입 전에 다음 시작할 명령어 `mov r3, r0`의 주소 `0x0800_01E2`의 주소를 확인하세요.
함수 진입 후 이 주소를 LR 레지스터가 잘 갖고 있는것을 알 수 있습니다.
- 주소가 다른데 무슨소리냐구요?
- 현재 THUMB2 mode를 사용하기 때문에 PC, LR 등 주소를 가리킬 때는 모두 LSB가 1인 홀수주소를 가리켜야 합니다.
- 왜그러냐는 이유는 따로 없습니다. 그냥 ARM mode와 Thumb mode를 구분하기 위한 규칙이었는데 지금까지 관행적으로 전해내려온 규칙입니다.
- 따라서 LR 레지스터가 비록 `0x0800_01E3`을 갖고 있지만, 돌아갈 주소는 여기서 1을 뺀 `0x0800_01E2`입니다.
스크래치 레지스터는 함수의 인자로 넘어갈 때도 사용되지만, 함수의 결괏값을 저장하는 용도로도 사용됩니다.
- 함수의 결괏값 `0xF`가 R0 레지스터에 잘 저장된것을 알 수 있습니다.
- 별다른 조치를 취하지 않아도 알아서 `printf()`문에 이 결괏값이 잘 출력됩니다.
- 이 모든게 AAPCS 규칙을 따르게끔 컴파일이 됐기 때문입니다.
정해진 규칙에 따라 인자값/결괏값을 누가 갖고있어야 하는지 역할이 보장돼있기 때문에 가능합니다.
첫 어셈블리라 하나하나 언급해야해서 설명이 많이 길어지네요. 마지막입니다.
이번에는 원래 주소로 복귀하기 위해 `bx lr` branch문이 아니라 `push{lr}, pop {pc}`을 사용했습니다.
반복해서 말씀드리지만, `bx lr`은 LR 레지스터 값이 오염되지 않으리라는 확신이 있을 때 사용해야 합니다.
- 만일 위 `first_asm_func()` 함수 내에서 다른 함수를 호출했다고 가정합시다.
- 그렇다면 LR 레지스터는 돌아가야 할 원래 주소를 잃어버리고 새로운 주소가 기록됩니다.
- 이를 방지하기 위해서 `push {lr}`로 스택프레임에 주소를 백업해두면 되겠습니다.
위 사진에 번호와 화살표로 복귀주소의 백업 및 복원과정을 나타냈으니 눈으로 한 번 따라가보시면 되겠습니다.
2.3. 테스트 관련 언급할 사항들
위 2.2절에서 대략적으로 레지스터 및 메모리 그리고 명령어 읽는 방법은 거의 다뤘습니다.
`#if 0 ... #endif`로 묶여있는 각 테스트를 하나씩 `#if 1`로 열고
적절한 곳마다 브레이크 포인트 (최대 6개)를 걸고, 해제하시면서
F5를 누르며 한 줄씩 실행하며 레지스터값이 어떻게 변하는지 확인합시다.
주석에도 각 레지스터에 어떤 값이 들어가 있어야 하는지, 어떤 bit를 주목해야하는지 적혀있습니다.
이 항목에서는 테스트와는 별개로 알아둬야 하는 다른 어셈블리 관련 중요한 사항 위주로 설명하겠습니다.
또한, 각 Cortex-M3의 어셈블리 명령어들에 대한 자세한 설명은 PM0056 Programming manual을 확인합시다.
`#if 1`로 테스트 블럭을 연 뒤 주석을 하나씩 해제하고 (해제한건 다시 주석처리하며) 테스트해봅시다.
2.3.1. Pre-indexed 방식과 Post-indexed 방식의 차이
`libs_sector` 레이블에는 `.word` 명령어로 1-WORD 크기의 변수들이 선언돼있습니다.
- `=` suffix는 immediate value를 가리키며 `=libs_sector`로 레이블의 시작주소를 가리킬 수 있습니다.
- `=` suffix를 사용하지 않으면 libs_sector[0]의 값이 가져와집니다.
- 이게 번거롭고 햇갈린다면 `adr` 명령어를 사용하면 됩니다. `adr r1, libs_sector`로 쓰면 주소를 가져옵니다.
`LDR` 명령어를 어떤 값을 레지스터로 load 하는 명령어입니다.
따라서 `LDR R1, =libs_sector`로 레이블의 시작주소가 가리키는 곳의 '값'을 R1 레지스터로 load합니다.
<중요 사항>
피연산자의 지정방식은 명령어를 떠나서 반드시 알아야합니다!
하나씩 살펴봅시다.
① `LDR R0, [R1]`
- R1값이 가리키는 주소에서 값을 가져옵니다.
- C언어의 참조연산자 (`*`)를 생각하면 됩니다.
- 그러면 레이블명 = 배열명 = 첫 원소의 주소이므로 첫 원소의 값이 R0에 저장됩니다.
- `R0 = libs_sector[0]`가 저장됩니다.
② `LDR R0, [R1, #4]`
- `명령어 목적지, [출발지, #n]` 구조를 pre-indexed 방식의 명령어라고 합니다.
- `[R1 + 4]`: 이 주소가 가리키는 곳의 값을 가져옵니다.
- 즉, 이번에는 `R0 = libs_sector[1]`입니다.
③ `LDR R0, [R1, #4]!`
- ②와 똑같지만 마지막에 `!` 기호가 들어가있습니다.
- 이 기호가 들어가있으면, 피연산자의 주소가 갱신됩니다.
- 즉, `R0 = libs_sector[1]`인데다가 `R1 = R1 + 4`로 갱신됩니다.
④ `LDR R0, [R1], #4`
- `명령어 목적지, [출발지], #n` 구조를 post-indexed 방식의 명령어라고 합니다.
- post-indexed 방식은 위에서 pre-indexed 방식에 `!` 기호를 사용한것처럼 출발지 주소가 갱신됩니다.
- 따라서 `R0 = libs_sector[2]`가 저장되고 `R1 = R1 + 4`로 갱신됩니다.
LDR이 load라면 STR은 store 입니다.
구조는 LDR이랑 완전히 같기 때문에 pre-indexed, post-indexed 모두 동일하게 적용됩니다.
2.3.2. -s 접미사
명령어 중에는 '-s' 접미사가 붙은 명령어가 있습니다.
이 접미사가 붙은 명령어는 결괏값에 따라 PSR 레지스터의 NZCVQ flags를 갱신하는 명령어입니다.
위 코드를 보면, `bics r0, r1, #3 << 30`으로 `0xFFFF_FFFF & ~(0xC000_0000)`을 계산해 R0에 저장하는 연산입니다.
31, 30번 bit가 clear 돼 R0에는 `0x3FFF_FFFF`가 저장됩니다.
레지스터를 보면, C-bit가 set 된것을 확인할 수 있습니다. `0xC000_0000`을 뒤집은 `0x3FFF_FFFF`와 `R1 (0xFFFF_FFFF)`를 AND 연산하는 과정에서 올림(carry)이 발생했기 때문입니다.
이렇게 PSR의 NZCVQ flags를 갱신시켜주면 뭐가 좋을까요?
다음번 명령어가 조건부 실행할 수 있도록 재료를 마련해줄 수 있습니다.
2.3.3. 조건부 명령어 실행, 조건코드
명령어의 조건부 실행을 가능하게 하는 Condition code라는 것이 있습니다.
명령어에 접미사로 붙여서 사용합니다.
- branch 명령어 `b`에 `EQ`를 붙여서 `beq`로 쓰면, Z-flag를 보고 set인지 확인한 후 맞다면 실행합니다.
- `GT`( greater than )를 붙여서 `bgt`를 쓰면, Z-bit가 reset이고 N = V인지 확인한 후 맞다면 실행합니다.
ARM mode의 instructions는 대부분 지원하지만, 우리가 사용하는 THUMB2에는 오직 branch문만 사용할 수 있습니다.
다른 명령어에서 조건부 실행을 사용하고 싶다면, `IT` 명령어를 사용합니다.
IT는 If-Then의 약자로 이름그대로 조건부 실행을 위한 명령어입니다.
`IT{x{y{z}}} condition` 구조를 갖고있으며,
- `x, y, z` 각 자리에는 `T` (Then)가 들어갈 수 있고 마지막 `z`에만 `E` (Else)가 들어갈 수도 있습니다.
- 즉, `IT`, `ITT`, `ITE`, `ITTT`, `ITTE`, `ITTTT`, `ITTTE` 등 조합이 가능합니다.
- `T` 하나당 조건에 만족할 때 실행할 수 있는 연산자 1개라고 생각하면 됩니다.
- `ITTT`라면, 조건에 만족했을때 아래 명령어 3개를 실행합니다~ 라고 이해할 수 있습니다.
- ARMv8 AArch6부터는 IT 명령어가 사라졌고 모두 condition code 또는 conditional branch로 대체됐습니다.
2.3.4. printf_svc0 매크로와 SVC exception
이게 무엇을 의미하는지 알아봅시다.
.macro printf_svc0
svc 0x0
.endm
어셈블리 최상단을 보면 `.macro` --- `.endm`으로 묶인 매크로가 있습니다.
C의 `#define` 매크로와 완전히 똑같으며 `svc 0x0` 이라는 명령어를 `printf_svc0` 이라 별명붙였다 생각하면 됩니다.
`svc` 명령어는 supervisor call의 약자로 SVC exception을 발생시키는 명령어입니다.
앞서 우리는 동작모드가 권한에 따라 두 가지 'thread mode'와 'handler mode'가 있다고 배웠습니다.
저희는 항상 handler mode로 동작하지만, RTOS처럼 엄격히 사용자 영역과 관리영역을 구분하는 시스템이 있다면, 사용자 영역에서 때로는 시스템 자원에 접근하기 위해 요청을 보내야합니다. 마치 리눅스에서 시스템콜 API를 호출하는 것처럼요.
인터럽트와 예외에 대한 handler 함수들이 모여있는 `stm32f1xx_it.c` 소스파일을 확인해봅시다.
`void SVC_Handler(void)`는 STM32CubeIDE가 자체적으로 생성해줍니다.
여기에 원하는 exception handler 내용을 넣으면 되겠습니다.
`__asm()` 키워드로 인라인 어셈블리가 선언돼있습니다. (`__ASM()` 으로 대문자 써도 돼요)
별로 특별한건 아니고 소괄호 내부에 적힌 문자열들이 그대로 어셈블리로 적용되는겁니다.
- 'SVC_Handler_Main' 이라는 전역 레이블을 선언하고
- LR레지스터의 2번 bit를 확인합니다.
- `IT` 명령어에 조건코드는 `NE` (Not Equal)로 Z-flag가 0이라면 다음 문장을 실행합니다.
- `BNE` 명령어는 branch에 조건코드 `NE`가 붙어있으므로 Z-flag가 0일 때 실행됩니다.
- 즉 위 두 명령어는 `ITT`로 합칠 수도 있겠네요.
<추가내용> 인라인 어셈블리의 `TST LR, #4`는 왜 사용했을까요?
`TST` 명령어는 `TST Rn, 상수`구조를 갖는 명령어입니다.
레지스터와 상수를 AND 연산해 NZCVQ flag를 갱신한 뒤 결괏값은 버립니다.
- 즉, 특정 bit가 0인지 1인지 판단하는 용도로 사용하기 좋은 명령어입니다.
- 여기서도 LR 레지스터의 2번 bit가 set 돼있는지 확인하는 용도로 사용됐습니다.
그럼 LR 레지스터의 2번 bit가 무슨 의미를 갖고 있길래 확인했을까요?
SVC_Handler에 진입하는 순간을 보시면, 복귀주소 LR과 스택프레임 포인터 FP를 스택에 백업합니다.
그리고 LR 레지스터는 `0xFFFF_FFF9`라는 이상한 값을 가지게 되는데, 특수한 정보를 의미하는 '매직넘버'입니다.
SVC exception 처럼 예외처리를 위해 exception handler에 진입할 때는, 일반 함수에 진입할 때와 달리 LR 레지스터에 복귀할 context와 관련해 추가정보를 담아야 합니다. 그 추가정보란 복귀할 동작 mode가 thread mode인지 handler mode인지, 사용할 스택은 MSP인지 PSP인지 여부입니다.
- LR `0xFFFF_FFF9`의 하위바이트가 `9 = 0b1001`이므로
복귀할 context는 MSP를 사용하는 특권을 가진 thread mode로 복귀한다는 것을 알 수 있습니다. - `TST` 명령어에서 2번 bit를 확인했던 이유를 이제 아시겠죠?
- PSP를 사용하는, 특권을 가지지 못한 thread mode인지 판단하기 위해서였던 것입니다.
- 만일 PSP를 사용하는 thread mode였다면 `IT` 명령어 조건을 만족하므로 `mrs` 명령어에 따라 R0 레지스터에 PSP 값을 저장한 뒤 `bne` 명령어 조건코드도 만족해서 바로 'SVC_Handler_Main()` 함수로 진입했을 겁니다.
2.3.5. SVC Handler 해석하기
우선, 3가지를 먼저 숙지해야 합니다.
- Exception으로 진입할 때, SW적으로 따로 뭔가를 하지 않더라도 HW적으로 (자동으로) R0, R1, R2, R3, R12, LR, PC, xPSR 이렇게 8개의 레지스터가 현재 스택포인터가 가리키고 있는 곳에 push 됩니다. Exception을 처리한 뒤 온전하게 복귀하기 위한 최소 context입니다.
- 컴파일 타임에 함수 같은 서브루틴에서 사용하는 스택 프레임의 크기가 대략 계산됩니다.
- 따라서 서브루틴으로 jump할 때, 스택포인터와 프레임포인터가 알아서 그 크기만큼 감소합니다.
(주소가 작아지는 아랫방향으로 자라는 full descending 스택이니까요). - 지역변수처럼 프레임에 저장하거나(STR) 가져와야 하는(LDR) 값이 생기면 `LDR [R7, #n]` 이렇게 프레임 포인터에서 주소 n만큼 더해서 가져옵니다. (스택포인터든 프레임포인터든 사용하는 스택의 최상단을 가리키고 있습니다.)
- 따라서 서브루틴으로 jump할 때, 스택포인터와 프레임포인터가 알아서 그 크기만큼 감소합니다.
- 컴파일 타임에 함수 같은 서브루틴에서 어떤 레지스터를 사용하게 되는지도 알려집니다.
- 따라서 서브루틴으로 jump할 때, 사용하게 될 레지스터들은 자동으로 스택에 push 됩니다.
사용자 정의 함수 'SVC_Handler_Main()' 함수의 첫 부분이 이해하기 까다롭습니다.
- `svc_args` 라는 포인터 매개변수는 R0 레지스터로 저장돼 넘어옵니다.
- 코드를 보면 알겠지만 `svc_args`는 이전 스택포인터 msp 값입니다. 엄청 햇갈리기 시작하네요.
- 정적변수 `psp_offset = 4` 이므로
- 지역변수 `r0 = svc_args[4], r1 = svc_args[5], cpsr = svc_args[11]`
- `svc_number = ((char*)svc_args[10])[-2]` 입니다. 인덱스가 음수라니....
지역변수 `r0`가 `svc_args[4]`값을 가지게 되는 과정을 지켜봅시다.
- R3 레지스터에 정적변수 `psp_offset`을 가져옵니다.
- 이 값을 좌측으로 2-bit 쉬프트 합니다. 왜?
매개변수 `svc_args`가 `unsigned int*` 포인터 변수 = 1-WORD = 32-bit = 4-byte 크기이므로
인덱스 [4]의 주솟값을 계산하기 위해 (인덱스 번호 * 배열 자료형 크기)를 계산해준 겁니다. - 이렇게 계산한 주솟값(R3)을 R2와 더해 `svc_args[4]` 값을 가져오고
스크래치 레지스터 R3를 재활용하기 위해 스택 프레임에 `svc_args[4]`를 저장합니다.
지역변수 `r1`, `cpsr`도 위와 똑같은 과정을 거치게 됩니다.
이제 `char *` 형 캐스팅과 `[-2]` 음수 인덱스에 대해서 살펴봅시다.
우선 `svc_args[psp_offset + 6] = svc_args[10]`을 가져오는 과정은 위에서 다룬것과 같습니다.
스택 메모리를 찍어서 `svc_args[10]`의 위치를 살펴보겠습니다.
- `svc_args = &svc_args[0] = 0x2000_4FC8`이고
- 2로 표시한 `svc_args[1] = 0x2000_4FCB` = `SVC_handler(void)`에서 사용한 스택프레임
- 1로 표시한 `svc_args[2] = 0x2000_4FF8`, `svc_args[3] = 0xFFFF_FFF9`는
`SVC_handler(void)`에서 `SVC_Handler_Main()`으로 진입하면서 저장한 이전 스택프레임 포인터와 매직넘버 - 그 아래 빨간 8개 WORD가 SVC exception 발생으로 HW적으로 이전 스택프레임에 저장된 복귀할 context.
`svc_args[10] = 0x0800_0252로, `svc 0` 의 다음 명령어를 가리키고 있는, 복귀주소라는것을 알 수 있습니다.
`char*`로 캐스팅 한 후 `[-2]`를 한 이유를 이제 알겠네요!
- 복귀할 주소의 한 스탭 이전 명령어 `svc 0`을 가리키고 싶은 겁니다!
- `ldrb r3, [r3, #0]`으로 opcode `00 df`에서 1바이트, `0x00`만 가져오네요. (Little endian이라 매개변수가 먼저 나와요)
- 이제 모든 의문이 풀렸습니다. `((char*)svc_args[10])[-2]`는 직전 실행한 명령어의 피연산자를 갖고 오는 과정이었던 것입니다.
이렇게 하면, 피연산자 1바이트, 최대 255개의 서로 다른 동작을 하는 SVC exception을 만들 수 있습니다.
아래를 보세요, switch-case문을 사용해서 svc 명령어의 피연산자로 들어온 값에 따라 서로 다른 동작을 하게 만들 수 있습니다.
각 어셈블리 연산마다 변화하는 PSR 레지스터의 NZCVQ flags와 피연산자로 사용한 R0, R1 레지스터의 값을 출력하고 싶습니다.
- PSR 레지스터의 각 주요 bit 값을 `&` 연산자로 확인한 후 편리하게 대문자/소문자로 가독성있게 출력하고
- R0, R1 레지스터의 값을 16진수와 10진수로 출력합니다.
참 많이 빙- 돌아왔습니다. 이렇게 `bic` 명령어의 결과를 가독성있게 확인할 수 있습니다.
3. 인터럽트와 예외
3.1. 번호와 우선순위
인터럽트 및 예외 처리 과정은 단순하고 직관적입니다.
인터럽트와 예외는 NVIC (Nested Vectored Interrupt Controller)에서 관리합니다.
- 최대 256개의 인터럽트를 동적으로 우선순위 지정하고
- 레벨 및 펄스 인터럽트를 지원하고
- 인터럽트/예외 진입 시 HW에 의해 자동으로 context 백업/복원합니다.
Exception vector table
`0x0000_0000`번지에 고정된 주소를 가지고 있는 Exception vector table은 인터럽트 및 예외를 처리하기 위한 함수들의 시작주소가 모여있는 이른바 함수포인터 배열이라 생각하면 됩니다.
위 표에서 주목해야 할 점이 몇 가지 있다면,
- 가장 첫 entry는 의외로 reset handler도, 함수포인터도 아닌 Initial SP 값입니다.
- 스택포인터의 초기화값이 여기에 저장된 있는 이유는 아예 꺼졌다 켜지는 cold boot가 아닌, SW적인 reset이 발생했을 때, `0x0`번지를 읽어서 스택포인터가 정상적으로 초기화 될 수 있도록 하기 위함입니다.
- Exception number와 IRQ number는 따로 있습니다. 이게 좀 햇갈리긴 합니다.
- 0번 IRQ인 IRQ0은 인터럽트 번호로는 0번이지만, 예외 번호로는 16번입니다.
- Reset이 1번 예외, SysTick이 15번 예외인것은 기억해둡시다.
인터럽트와 예외들은 우선순위를 가질 수 있습니다.
- 이때, 첫 3개 예외 (Reset, NMI, Hard fault)는 대단히 중요한 예외이므로 우선순위가 아예 -3, -2, -1로 고정돼있습니다.
- 나머지 예외들과 인터럽트들은 사용자정의가 가능합니다.
인터럽트 상태
- 활성화 - 실행 중 (Running): 현재 실행중인 인터럽트를 의미합니다. 다양한 레지스터에 현재 실행중인 인터럽트에 관한 정보가 남습니다.
- 활성화 - 대기 중 (Pending, Waiting): 현재 실행중이지만, 선점을 당하거나 시그널을 기다리는 등의 이유로 잠시 실행이 보류된, 대기 중인 인터럽트를 의미합니다. Pending 인터럽트 역시 다양한 레지스터에 정보가 기록되기 때문에 현재 cpu time을 갖길 기다리는 인터럽트가 무엇이 있는지 확인할 수 있습니다.
- 비활성화 (Not Running)
3.2. 주요 레지스터
Exception들은 System Control Block에서 관리하고 Interrupt들은 NVIC에서 관리합니다.
System Control Block (SCB)에 포함된 레지스터는 그 이름에 걸맞게 전체 시스템에 대한 주요 내부정보 제공 및 주요 환경설정을 담당합니다. 형광색으로 표시한 레지스터 이외에도 모두 중요한 역할을 하는 레지스터입니다.
이 중 인터럽트/예외와 관련된 레지스터 몇 개만 다룬다면,
3.2.1. SCB_ICSR (Interrupt Control & State Register)
- NMI, PendSV, SysTick exception을 pending 시킬지 여부를 결정할 수 있습니다.
- 이 레지스터의 가장 매력적인 점은
'현재 대기중인 최고 우선순위의 exception의 번호'와
'현재 실행중인 exception의 번호'를 조회할 수 있다는 점입니다.
3.2.2. SCB_SHPRx (System Handler Priority Registers)
System 주요 fault/ exception에 대한 우선순위를 설정하는 레지스터들입니다.
NMI, Reset, Hard fault 이 3가지는 고정 우선순위를 가지지만, 위 6가지는 우선순위를 설정할 수 있습니다.
3.2.3. SCB_AIRCR (Application Interrupt & Reset Control Register)
AIRCR의 주된 역할은
- 인터럽트와 예외의 우선순위 구조를 변경
- 데이터를 읽어들이는 방식인 리틀-엔디안, 빅-엔디안을 설정
- SW reset 트리거입니다.
시스템에 큰 영향일 미치는 큼지막하고 중요한 설정들 뿐이기 때문에 함부로 수정할 수 없도록 최소한의 안전장치가 필요합니다.
따라서 상단 2바이트를 'VECTKEY'라는 이름의 이른바 패스워드를 설정해놨습니다.
이 레지스터의 값을 바꾸기 위해서는 VECTKEY를 입력하면서 동시에 값을 바꿔야 합니다.
(위 `__NVIC_SetPriorityGrouping()` 함수는 Drivers/CMSIS/Include/core_cm3.h 참고하세요)
예를들어, PRIGROUP 값을 바꾸기 위해서는 `SCB->AIRCR = (0x5FA << 16) | (...);` 로 설정하면 됩니다. 여기서 `0x5FA`가 VECTKEY 비밀번호입니다.
[10:8] 부분인 PRIGROUP[2:0]은 시스템이 인터럽트/예외의 우선순위를 판단하는 방법을 설정합니다.
Priority grouping이라고 하는데, 이를 이해하려면 시스템이 우선순위를 판단하는 방법을 먼저 알아봅시다.
선점우선순위 & 서브우선순위
예외와 인터럽트의 우선순위는 선점우선순위와 서브우선순위 두 가지 부분으로 나눌 수 있습니다.
(★ 중요 ★) 우선순위 번호가 '낮을수록' 우선순위는 '높습니다'.
ⓐ 선점우선순위가 높은 경우
인터럽트 A의 우선순위가 15, 인터럽트 B의 우선순위가 16인 경우
당연히 인터럽트 A가 먼저 우선권을 가지며, B가 실행 중이라면 A가 선점하겠지요?
ⓑ 선점우선순위가 같은 경우, 서브우선순위가 다른 경우
이번에는 인터럽트 B의 우선순위가 15라고 생각해봅시다. (A = B)
인터럽트 A의 서브우선순위는 4, 인터럽트 B의 서브우선순위는 0입니다. (A < B)
선점우선순위가 같기 때문에 선점은 발생하지 않지만, 대기(pending)상태일 때는 얘기가 다릅니다.
- 우선순위가 높은 어떤 인터럽트를 처리하는 중에 인터럽트 A, B가 걸려 대기중이라고 가정합시다.
- 이런 대기상태에서는 서브우선순위로 우선권을 판가름합니다.
- 인터럽트가 종료되고 다음 번 실행될 인터럽트를 고를 때 인터럽트 B가 실행됩니다.
ⓒ 선점우선순위가 같은 경우, 서브우선순위도 같은 경우
이번에는 인터럽트 A와 B가 선점우선순위도 서브우선순위도 같습니다.
선점우선순위가 같기 때문에 선점은 발생하지 않지만, 대기 상태에서 우선권은 '인터럽트 번호가 낮은쪽'이 먼저 실행됩니다.
이제 우선순위를 판단하는 방법을 알아봤으니 다시 본론으로 돌아가서 PRIGROUP[2:0]을 봅시다.
이 3-bit로 선점우선순위를 몇 개 줄 건지, 서브우선순위를 몇 개 줄건지 설정합니다.
- 기본값은 `PRIGROUP = 0x3`으로, 선점우선순위에 4-bit, 서브우선순위에 0-bit를 할당합니다.
그럼 선점우선순위는 0~15값을 가지겠고, 서브우선순위는 사용하지 않습니다. - 그럼 `PRIGROUP = 0x6`일때는 어떨까요? 위 표를 보시면 바로 알 수 있습니다.
선점우선순위에 1-bit, 서브우선순위에 3-bit를 할당합니다.
그러므로 선점우선순위는 오직 0 또는 1 값을 가지고, 서브우선순위는 0~7값을 가집니다.
사실 ARM 코어는 priority grouping에 8-bit를 사용합니다.
- 그러니까, 위처럼 선점우선순위와 서브우선순위가 4-bit를 나눠가지는 방식이 아니라 8-bit를 나눠가집니다.
- 기본값은 선점우선순위에 4-bit, 서브우선순위에 4-bit를 나눠가져서 각각 0~15값을 가집니다.
- 하지만, STM32는 시스템 복잡도를 낮추기 위해서 의도적으로 하위 4-bit를 버리고 상위 4-bit만 사용합니다.
마지막으로 2번 bit는 SYSRESETREQ로 SW reset를 trigger하는 bit입니다.
이 bit를 set하는 즉시 시스템은 SW reset 돼 다시 `0x0000_0000`번지 exception vector table을 읽어 스택포인터를 초기화하고 reset handler를 호출해 시스템을 초기화, 부팅합니다.
3.2.4. SCB_CCR (Configuation & Control Register)
`DIV_0_TRP`: Divide zero 오류 발견시 trap을 발생시킬 것인지 여부를 설정할 수 있습니다. (기본값 Set)
`UNALIGN_TRP`: Address unalign을 발견시 trap을 발생시킬 것인지 여부를 설정할 수 있습니다. (기본값: Reset)
3.2.5. SCB_VTOR (Vector Table Offset Register)
- 앞서 Exception Vector Table이 HW적으로 `0x0000_0000`번지에 고정된 주소를 갖는다고 배웠습니다.
- VTOR 레지스터를 통해 이 table의 시작 주소를 바꿀 수 있습니다. 즉 옮길 수 있습니다.
3.3. 주요 레지스터 - NVIC
Exception들은 System Control Block에서 관리하고 Interrupt들은 NVIC에서 관리합니다.
Enable 관련
- ISER (Interrupt Set-Enable Registers)
- 이 레지스터는 특정 인터럽트를 활성화하는 데 사용됩니다.
- ISER의 각 비트는 하나의 인터럽트에 해당하며, 해당 비트를 1로 설정하면 해당 인터럽트가 활성화됩니다.
- 활성화되면 NVIC는 해당 인터럽트가 발생할 때 프로세서에 알립니다.
- ICER (Interrupt Clear-Enable Registers)
- ISER와 반대로 ICER는 인터럽트를 비활성화하는 데 사용됩니다.
- ICER의 각 비트는 하나의 인터럽트에 해당하며, 해당 비트를 1로 설정하면 해당 인터럽트가 비활성화됩니다.
Pending 관련
- ISPR (Interrupt Set-Pending Registers)
- 이 레지스터는 인터럽트를 수동으로 대기 상태로 만드는 데 사용됩니다.
- ISPR의 각 비트는 하나의 인터럽트에 해당하며, 해당 비트를 1로 설정하면 해당 인터럽트가 대기 상태가 됩니다.
- 이 레지스터 값을 직접 건드리면 마치 인터럽트가 발생한 것처럼 NVIC에 알려서 인터럽트를 발생시킬 수 있습니다. 테스트 용도로 사용하면 좋겠죠?
- ICPR (Interrupt Clear-Pending Registers)
- ISPR과 반대로 ICPR은 인터럽트의 대기 상태를 해제하는 데 사용됩니다.
- ICPR의 각 비트는 하나의 인터럽트에 해당하며, 해당 비트를 1로 설정하면 해당 인터럽트의 대기 상태가 해제됩니다.
기타
- IABR (Interrupt Active Bit Registers)
- 이 레지스터는 현재 활성 상태인 인터럽트를 나타냅니다.
- IABR의 각 비트는 하나의 인터럽트에 해당하며, 해당 비트가 1이면 해당 인터럽트가 현재 처리 중임을 의미합니다.
- IPR (Interrupt Priority Registers)
- 이 레지스터는 각 인터럽트의 우선순위를 설정하는 데 사용합니다!
- 위에서 봤던 SCB_SHPR과 같은 역할을 합니다
3.4. 인터럽트 실습했던 내용 정리 1
`HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4)`는 위에서 SCB_AIRCR 레지스터를 설명할 때 다뤘죠?
3.5. 인터럽트 실습했던 내용 정리 2
3.5.1. Hard Fault :: Handler 함수를 찾을 수 없음
GPIO C의 13번핀이 연결된 파란버튼은 EXTI_13_IRQ가 연결돼있습니다.
Exception vector table의 EXTI15_10_IRQHandler을 일부러 0으로 바꿔서 오류를 만들어봅니다.
버튼을 누르면 인터럽트는 발생하지만, 이제 이 인터럽트를 처리할 handler 함수를 찾을 수 없기 때문에 hard fault가 발생합니다.
SCB_CFSR 레지스터를 확인해보면 INVSTATE bit가 활성화 돼있네요 이를 통해 예외 원인을 짐작할 수 있습니다.
3.5.2. Hard Fault :: 비정렬 액세스 (Unaligned access)
3.5.3. Hard Fault :: 기타
① 알 수 없는 명령어를 읽어서 SCB_CFSR 레지스터의 UNDEFINSTR bit가 set 돼 Hard fault가 발생한 모습
② 2.2절에서 우리는 Thumb mode는 주소의 LSB가 홀수여야 함을 배웠죠? 의도적으로 LSB를 짝수로 해서 호출했더니 Hard fault가 발생한 모습입니다.
③ Divide by zero 오류가 발생해 Hard Fault가 발생한 모습입니다.
4. 링커스크립트
'Embedded' 카테고리의 다른 글
【공부/정리】 시스템 소프트웨어 개발을 위한 ARM 아키텍처 구조와 원리 ① 레지스터와 명령어 (0) | 2025.02.26 |
---|---|
Aarch64 Memory Management 요약 정리 (0) | 2025.02.25 |
임베디드 레시피 Chapter 8. Debug (0) | 2025.02.01 |
임베디드 레시피 Chapter 7. Device control (0) | 2025.02.01 |
임베디드 레시피 Chapter 6. RTOS & Kernel (0) | 2025.01.31 |