main을 부르기 전에
앞 글에서는 어셈블리만 사용했는데, 이번에는 C 코드를 추가해봅시다. 모든 C 코드는 main
에서 출발하니, 이전의 어셈블리 코드에서 마지막에 main
을 호출하면 됩니다. 물론, 그 전에 할 일이 있죠.
레지스터 초기화
RISC-V는 C의 지역 변수와 전역 변수에 접근하기 위해 두 레지스터 sp
(스택 포인터)와 gp
(글로벌 포인터)를 사용합니다. C 함수를 호출하기 위해서는 둘 다 반드시 초기화되어 있어야 합니다. sp
의 초기화는 다른 MCU와 마찬가지로 스택 영역의 끝 주소로 하면 됩니다. 별 일 없으면 RAM의 시작 주소와 동일할 겁니다. 문제는 gp
입니다.
gp
는 RISC-V의 특징적인 기능 중 하나로, 변수에 접근할 때 변수의 절대 주소 대신 gp
에 대한 상대 주소로 접근할 수 있게 해줍니다. 절대 주소 접근법은 주소의 상위 20비트를 먼저 레지스터에 저장하고, 이 레지스터에 하위 12비트만큼의 오프셋을 더해 접근하는 두 단계를 거칩니다. 반면에 상대 주소 접근법은 gp
에 12비트 오프셋을 더해 접근하는 한 단계만 거치면 됩니다. 단, 이 오프셋은 부호 있는 12비트 정수 상수이기 때문에 접근할 변수가 gp
가 가리키는 주소를 기준으로 ±2 KiB 범위 내에 있어야 합니다.
따라서 링커는 변수의 주소와 gp
의 값을 보고서 변수에 접근할 때 절대 주소로 접근할지, gp
에 대한 상대 주소로 접근할지를 결정하는 최적화 단계를 거치는데, 이를 gp relaxation이라고 합니다. gp relxation을 사용하기 위해서는 링커에게 gp
의 값을 미리 알려주어야 하며, 이는 링커 스크립트에 미리 약속된 심볼 __global_pointer$
를 정의하여 해결할 수 있습니다. 링커는 이 심볼의 주소가 gp
의 값이라고 가정하고 gp relxation을 수행한 것이기 때문에 gp
를 처음에 __global_pointer$
의 주소로 초기화하고 나서는 절대 값을 변경하면 안 됩니다.
섹션 초기화
이 부분은 여타 MCU와 동일합니다. .data
섹션은 플래시 메모리에서 값을 가져와 RAM에 복사하고, .bss
섹션은 RAM을 0으로 초기화합니다.
main 호출
이제 본격적으로 main
을 호출할 시간입니다. RISC-V의 호출 규약(calling convention)에 따르면, 함수 호출 시 인자는 a0
– a7
레지스터에, 반환 값은 a0
와 a1
레지스터를 통해 전달됩니다. 레지스터보다 두 배 큰 인자는 두 레지스터에 나눠서 전달하는데 이때에도 정렬 제한이 있어서 짝수번 레지스터와 그 다음 레지스터로 나눕니다. (즉, 인자가 a1
과 a2
에 나눠서 전달되지는 않습니다.) 반환값도 크기가 두 배라면 마찬가지로 나눠서 전달합니다. 또한, 레지스터만으로는 부족하거나 크기가 두 배보다 크다면 다른 레지스터가 아닌 다른 방식으로 전달합니다.
C 표준상 main
은 두 인자 int argc
와 char** argv
를 받습니다. 32비트 RISC-V 기준 int
와 포인터 모두 레지스터와 동일하게 32비트이므로, argc
는 a0
로, argv
는 a1
로 전달됩니다.
타입 | RV32 | RV64 |
---|---|---|
char |
1 | 1 |
short |
2 | 2 |
int |
4 | 4 |
long |
4 | 8 |
long long |
8 | 8 |
포인터 | 4 | 8 |
여기서는 따로 명령행 인자를 넣지 않을 것이기 때문에, argc
와 argv
를 0으로 초기화합니다. 즉,
main
을 호출하기 전에 a0
와 a1
에 0을 대입합니다.
구현
위에 따라 구현한 부트 코드는 다음과 같습니다.
.section .start, "ax", %progbits
_start:
/* Jump to reset_handler. */
lui a0, %hi(reset_handler)
jalr zero, %lo(reset_handler)(a0)
.globl reset_handler
.type reset_handler, %function
reset_handler:
/* Set global pointer. */
.option push
.option norelax
la gp, __global_pointer$
.option pop
/* Set initial stack pointer: end of the section .stack */
la sp, _estack
/* Load data section. */
la a0, _sdata // a0 = &_sdata;
la a1, _edata // a1 = &_edata;
la a2, _sidata // a2 = &_sidata;
load_data_loop: // while (1) {
beq a0, a1, load_data_loop_end // if (a0 == a1) break;
lw t0, 0(a2) // t0 = *a2;
sw t0, 0(a0) // *a0 = t0;
addi a0, a0, 4 // a0 += 4;
addi a2, a2, 4 // a2 += 4;
j load_data_loop // }
load_data_loop_end:
/* Clear bss section. */
la a0, _sbss // a0 = &_sbss;
la a1, _ebss // a1 = &_ebss;
clear_bss_loop: // while (1) {
beq a0, a1, clear_bss_loop_end // if (a0 == a1) break;
sw zero, 0(a0) // *a0 = zero;
addi a0, a0, 4 // a0 += 4;
j clear_bss_loop // }
clear_bss_loop_end:
/* Call main. */
li a0, 0
li a1, 0
call main // main(0, 0);
전 글의 코드에서 reset_handler
를 수정했습니다. 먼저 gp
와 sp
를 초기화한 뒤, .data
섹션을 4바이트 단위로 플래시 메모리에서 램으로 복사하고 .bss
섹션을 4바이트 단위로 0으로 초기화합니다. 마지막으로 main
을 호출합니다.
gp
를 초기화할 때에는 gp relaxation을 비활성화하는 norelax
라는 옵션을 주었습니다. 링커 입장에선 gp
의 값을 이미 __global_pointer$
의 주소로 인식하기 때문에 위 코드와 같이 gp
의 값을 초기화하면 초기화하는 명령어가 gp relaxation으로 최적화되어 사라집니다.gp relaxation을 적용한 상태에서 저 부분을 컴파일하면 아무 의미 없는 mv gp, gp
가 됩니다. 따라서 이 경우엔 gp relaxation을 비활성화하는 것이 필요합니다.
나머지 부분의 초기화를 위한 _estack
, _sdata
등의 심볼은 링커 스크립트에서 정의합니다.
OUTPUT_ARCH("riscv")
/* Entry address. */
start = 0x00000000;
/* Reserved stack size. */
_stack_size = 2K;
MEMORY
{
FLASH(rxai!w) : ORIGIN = 0x08000000, LENGTH = 128K
RAM(wxa!ri) : ORIGIN = 0x20000000, LENGTH = 32K
}
SECTIONS
{
/* Flash sections. */
.start :
{
KEEP(*(.start))
} >FLASH
.text :
{
*(.text .text.*)
} >FLASH
.rodata :
{
*(.rodata .rodata.*)
} >FLASH
/* RAM sections. */
. = ALIGN(4);
_sidata = .;
.data : AT(_sidata)
{
_sdata = .;
*(.data .data.*)
. = ALIGN(8);
PROVIDE(__global_pointer$ = . + 0x800);
*(.sdata .sdata.*)
*(.srodata .srodata.*)
. = ALIGN(4);
_edata = .;
} >RAM
.bss :
{
_sbss = .;
*(.sbss .sbss.*)
*(.bss .bss.*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} >RAM
/* Reserve stack section. */
.stack ORIGIN(RAM) + LENGTH(RAM) - _stack_size :
{
. = _stack_size;
_estack = .;
} >RAM
}
다른 MCU의 매우 기본적인 링커 스크립트와 비교하면 대단히 유사합니다만 __global_pointer$
를 정의하는 부분과 .sdata
, .srodata
, .sbss
세 섹션이 추가되었습니다. 앞에서 언급했듯이 gp
에 대한 상대 주소로 접근하는 것은 gp
기준 ±2 KiB 내라는 좁은 범위에서만 가능하므로 전역 변수 중 자주 사용되는 것부터 gp
의 값과 가까운 주소에 배치하는 것이 유리합니다. 따라서 이런 변수들은 별도로 .sdata
, .srodata
, .sbss
세 섹션에 추가됩니다. 위 링커 스크립트는 세 섹션을 연속되게 배치하고, 이 부분의 시작 주소로부터 2 KiB 떨어진 곳을 gp
의 값으로 설정하였습니다. 이렇게 하면 세 섹션이 너무 크지 않은 이상 모두 gp
에 대한 상대 주소로 접근할 수 있습니다.
마지막으로 다음과 같이 main
함수를 정의합니다.
#include <stdint.h>
int main(void) {
uint32_t a = 0xdeadbeef;
volatile uint32_t cnt = 0;
while (1) {
cnt++;
}
}
내용은 이전 글에서 reset_handler
가 하던 것과 똑같습니다.
빌드 후 디버깅을 하면 다음과 같이 main
이 실행되어 a
가 초기화되고 cnt
가 증가하는 모습을 볼 수 있습니다.
(gdb) load
Loading section .start, size 0x64 lma 0x8000000
Loading section .text, size 0x22 lma 0x8000064
Start address 0x00000000, load size 134
Transfer rate: 290 bytes/sec, 67 bytes/write.
(gdb) b main
Breakpoint 1 at 0x8000084: file main.c, line 8.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) c
Continuing.
Breakpoint 1, 0x08000084 in main () at main.c:8
8 cnt++;
(gdb) p/x a
$1 = 0xdeadbeef
(gdb) p cnt
$2 = 1
(gdb) c
Continuing.
Breakpoint 1, 0x08000084 in main () at main.c:8
8 cnt++;
(gdb) p cnt
$3 = 2
자세한 빌드 방법은 저장소의 Makefile을 참고하시기 바랍니다.