RISC-V를 분석하면 가장 이해하기 어려운 콤포넌트는 opensbi이다. opensbi는 슈퍼바이저 모드와
머신 모드의 인터페이스라고 할 수 있으며, 리눅스 커널의 가장 낮은 소프트웨어 레이어라고 할 수 있다.
리눅스 커널에서 RISC-V에 dependent한 동작을 정확히 이해하려면, 역시나 opensbi를 이해해야 한다.
이번 포스트에서는 opensbi 스펙 중에 marchid를 중심으로 소프트웨어의 실행 흐름을 정리한다.
ChatGPT이 이런 내용을 알려주지 않으니 많은 개발자에게 도움이 됐으면 좋겠다.
RISC-V 단체에서 기술한 opensbi 스펙 문서를 보자.
4.6. Function: Get machine architecture ID (FID #5)
struct sbiret sbi_get_marchid(void);
https://lists.riscv.org/g/tech-brs/attachment/361/0/riscv-sbi.pdf#:~:text=Function:%20Firmware%20Features%20Set%20(FID%20%230)%20struct,for%20which%20per%20feature%20supported%20values%20are
marchid를 읽어오는 인터페이스이다. 리눅스 커널에서 marchid에 대한 opensbi는 어떻게 구현됐을까?
다음 함수를 보자.
rch/riscv/kernel/sbi.c
long sbi_get_marchid(void)
{
return __sbi_base_ecall(SBI_EXT_BASE_GET_MARCHID);
}
어셈블리 명령어로 보면 sbi_get_marchid 함수의 구현부는 아래와 같다:
SP:FFFFFFFF8000B3BA|sbi_get_marchid: c.addi sp,-0x10 ; sp,-16
SP:FFFFFFFF8000B3BC| c.sdsp s0,0x8(sp) ; s0,8(sp)
SP:FFFFFFFF8000B3BE| c.addi4spn s0,0x10 ; s0,16
SP:FFFFFFFF8000B3C0| c.li a0,0x0
SP:FFFFFFFF8000B3C2| c.li a1,0x0
SP:FFFFFFFF8000B3C4| c.li a2,0x0
SP:FFFFFFFF8000B3C6| c.li a3,0x0
SP:FFFFFFFF8000B3C8| c.li a4,0x0
SP:FFFFFFFF8000B3CA| c.li a5,0x0
SP:FFFFFFFF8000B3CC| c.li a6,0x5
SP:FFFFFFFF8000B3CE| c.li a7,0x10 ; a7,16
SP:FFFFFFFF8000B3D0| ecall
a7 레지스터에 0x10을 로딩한 다음에 ecall 명령어를 실행한다. 이 명령어를 실행하면 바로
머신 모드로 트랩이 유발된다.
이제부터는 opensbi 코드 분석이다.
00000000000003c8 <_trap_handler>:
3c8: 34021273 csrrw tp,mscratch,tp
3cc: 06523023 sd t0,96(tp) # 60 <_try_lottery+0x36>
3d0: 300022f3 csrr t0,mstatus
[...]
468: 3002b073 csrc mstatus,t0
46c: 00010533 add a0,sp,zero
470: 16e0c0ef jal ra,c5de <sbi_trap_handler>
리눅스 커널에서 ecall 명령어를 실행하면, 트랩이 유발되면서 _trap_handler 레이블의 시작 주소로 점프한다.
그 다음에 sbi_trap_handler 함수로 분기한다.
참고로 opensbi에서 트랩 핸들러를 설정하는 루틴은 아래와 같다:
opensbi/firmware/fw_base.S
/* Setup trap handler */
lla a4, _trap_handler
csrr a5, CSR_MISA
srli a5, a5, ('H' - 'A')
andi a5, a5, 0x1
beq a5, zero, _skip_trap_handler_hyp
lla a4, _trap_handler_hyp
_skip_trap_handler_hyp:
csrw CSR_MTVEC, a4
'csrw CSR_MTVEC, a4'이 핵심 명령어이다.
다시 원래 주제로 돌아와서 sbi_trap_handler() 함수를 분석하자.
함수의 분석 내용은 주석을 참고하자.
struct sbi_trap_context *sbi_trap_handler(struct sbi_trap_context *tcntx)
{
int rc = SBI_ENOTSUPP;
const char *msg = "trap handler failed";
struct sbi_scratch *scratch = sbi_scratch_thishart_ptr();
const struct sbi_trap_info *trap = &tcntx->trap;
struct sbi_trap_regs *regs = &tcntx->regs;
ulong mcause = tcntx->trap.cause;
/* Update trap context pointer */
tcntx->prev_context = sbi_trap_get_context(scratch);
sbi_trap_set_context(scratch, tcntx);
/* Austin: mcause 레지스터의 최상위 비트가 1인지를 체크한다. 만약 true이면 트렙의
종류는 인터럽트이다. */
if (mcause & MCAUSE_IRQ_MASK) {
if (sbi_hart_has_extension(sbi_scratch_thishart_ptr(),
SBI_HART_EXT_SMAIA))
rc = sbi_trap_aia_irq();
else
rc = sbi_trap_nonaia_irq(mcause & ~MCAUSE_IRQ_MASK);
msg = "unhandled local interrupt";
goto trap_done;
}
/* Austin: mcause 레지스터는 익셉션 코드 정보를 저장한다. 이 값에 따라 다른 방식으로
트랩을 처리한다. mcause는 Armv8-A의 esr_el3에 대응된다(개념적으로) */
switch (mcause) {
case CAUSE_ILLEGAL_INSTRUCTION:
rc = sbi_illegal_insn_handler(tcntx);
msg = "illegal instruction handler failed";
break;
case CAUSE_MISALIGNED_LOAD:
sbi_pmu_ctr_incr_fw(SBI_PMU_FW_MISALIGNED_LOAD);
rc = sbi_misaligned_load_handler(tcntx);
msg = "misaligned load handler failed";
break;
case CAUSE_MISALIGNED_STORE:
sbi_pmu_ctr_incr_fw(SBI_PMU_FW_MISALIGNED_STORE);
rc = sbi_misaligned_store_handler(tcntx);
msg = "misaligned store handler failed";
break;
case CAUSE_SUPERVISOR_ECALL:
case CAUSE_MACHINE_ECALL:
*/ Austin: 리눅스 커널에서 ecall을 실행했으니, 이 루틴으로 분기한다 */
rc = sbi_ecall_handler(tcntx);
msg = "ecall handler failed";
break;
case CAUSE_LOAD_ACCESS:
sbi_pmu_ctr_incr_fw(SBI_PMU_FW_ACCESS_LOAD);
rc = sbi_load_access_handler(tcntx);
msg = "load fault handler failed";
break;
sbi_ecall_handler() 함수를 분석하자. 분석 내용은 주석을 참고하자.
lib/sbi/sbi_ecall.c
int sbi_ecall_handler(struct sbi_trap_context *tcntx)
{
int ret = 0;
struct sbi_trap_regs *regs = &tcntx->regs;
struct sbi_ecall_extension *ext;
unsigned long extension_id = regs->a7;
unsigned long func_id = regs->a6;
struct sbi_ecall_return out = {0};
bool is_0_1_spec = 0;
ext = sbi_ecall_find_extension(extension_id);
위 루틴에서 regs->a7은 0x10이고 regs->a6이다. 왜냐면, 리눅스 커널에서 ecall 명령어를 실행하기 직전에
a6와 a7 레지스터를 다음과 같이 지정했기 때문이다.
SP:FFFFFFFF8000B3BA|sbi_get_marchid: c.addi sp,-0x10 ; sp,-16
[...]
SP:FFFFFFFF8000B3CC| c.li a6,0x5
SP:FFFFFFFF8000B3CE| c.li a7,0x10 ; a7,16
아래 코드를 보면 extension_id는 base(0x10)이고 func_id는 0x5라는 사실을 알 수 있다.
opensbi/include/sbi/sbi_ecall_interface.h
/* SBI function IDs for BASE extension*/
#define SBI_EXT_BASE_GET_SPEC_VERSION 0x0
#define SBI_EXT_BASE_GET_IMP_ID 0x1
#define SBI_EXT_BASE_GET_IMP_VERSION 0x2
#define SBI_EXT_BASE_PROBE_EXT 0x3
#define SBI_EXT_BASE_GET_MVENDORID 0x4
#define SBI_EXT_BASE_GET_MARCHID 0x5
sbi_ecall_handler() 함수의 아랫 부분 코드를 더 분석하자.
lib/sbi/sbi_ecall.c
int sbi_ecall_handler(struct sbi_trap_context *tcntx)
{
int ret = 0;
struct sbi_trap_regs *regs = &tcntx->regs;
struct sbi_ecall_extension *ext;
unsigned long extension_id = regs->a7;
unsigned long func_id = regs->a6;
struct sbi_ecall_return out = {0};
bool is_0_1_spec = 0;
ext = sbi_ecall_find_extension(extension_id);
if (ext && ext->handle) {
*/ Austin: 'ext->handle' 구문에서 sbi_ecall_base_handler 함수가 호출된다. */
ret = ext->handle(extension_id, func_id, regs, &out);
if (extension_id >= SBI_EXT_0_1_SET_TIMER &&
extension_id <= SBI_EXT_0_1_SHUTDOWN)
is_0_1_spec = 1;
} else {
ret = SBI_ENOTSUPP;
}
그 이유는 extension_id가 0x10이면 sbi_ecall_base_handler 함수가 호출되도록 등록했기 때문이다.
sbi_ecall_base_handler 함수가 호출되는 세세한 구현 방식은 나중에 설명하자.
opensbi/lib/sbi/sbi_ecall_base.c
static int sbi_ecall_base_register_extensions(void)
{
return sbi_ecall_register_extension(&ecall_base);
}
struct sbi_ecall_extension ecall_base = {
.name = "base",
.extid_start = SBI_EXT_BASE,
.extid_end = SBI_EXT_BASE,
.register_extensions = sbi_ecall_base_register_extensions,
.handle = sbi_ecall_base_handler,
};
sbi_ecall_base_handler 함수이다.
opensbi/lib/sbi/sbi_ecall_base.c
static int sbi_ecall_base_handler(unsigned long extid, unsigned long funcid,
struct sbi_trap_regs *regs,
struct sbi_ecall_return *out)
{
int ret = 0;
switch (funcid) {
[...]
case SBI_EXT_BASE_GET_MARCHID:
out->value = csr_read(CSR_MARCHID);
break;
'csr_read(CSR_MARCHID);' 매크로 함수의 리턴 값을 out-value에 저장한다.
'csr_read(CSR_MARCHID);' 구문의 정체는 무엇일까? 바로 marchid CSRs 레지스터이다.
126f8: f12027f3 csrr a5,marchid
126fc: 4501 li a0,0
126fe: e69c sd a5,8(a3)
a3이 out의 주소를 저장하니, 'sd a5,8(a3)' 명령어를 실행하면 marchid 레지스터의 값이 저장된다.
marchid 레지스터의 값이 어떻게 저장되는지 확인하자. sbi_ecall_handler 함수의 구현부이다.
int sbi_ecall_handler(struct sbi_trap_context *tcntx)
{
int ret = 0;
struct sbi_trap_regs *regs = &tcntx->regs;
[...]
ext = sbi_ecall_find_extension(extension_id);
if (ext && ext->handle) {
ret = ext->handle(extension_id, func_id, regs, &out);
[...]
if (!is_0_1_spec)
regs->a1 = out.value;
marchid CSR은 a1 레지스터를 통해서 저장된다. 'regs->a1' 가장 마지막 라인을 보자.
머신 모드에서 슈퍼바이저 모드(리눅스 커널)로 리턴하는 동작
이제 머신 모드에서 슈퍼바이저 모드로 리턴하는 동작이다. 즉 opensbi -> 리눅스 커널이다.
00000000000003c8 <_trap_handler>:
3c8: 34021273 csrrw tp,mscratch,tp
3cc: 06523023 sd t0,96(tp) # 60 <_try_lottery+0x36>
3d0: 300022f3 csrr t0,mstatus
[...]
46c: 00010533 add a0,sp,zero
470: 16e0c0ef jal ra,c5de <sbi_trap_handler>
; sbi_trap_handler 함수 실행이 마무리되면 아래 어셈블리 명령어가 실행된다.
; 스택에 푸시한 (리눅스 커널에서 ecall을 실행하기 직전의) 레지스터를 로딩한다.
; 슈퍼바이저 모드(리눅스 커널)로 돌아가기 위해서이다.
474: 00853083 ld ra,8(a0)
478: 01053103 ld sp,16(a0)
47c: 01853183 ld gp,24(a0)
480: 02053203 ld tp,32(a0)
484: 03053303 ld t1,48(a0)
488: 03853383 ld t2,56(a0)
48c: 6120 ld s0,64(a0)
48e: 6524 ld s1,72(a0)
490: 6d2c ld a1,88(a0)
492: 7130 ld a2,96(a0)
[...]
4de: 30029073 csrw mstatus,t0 ; Austin: 되돌아갈 privilege 모드를 mstatus 레지스터 설정
4e2: 10053283 ld t0,256(a0)
4e6: 34129073 csrw mepc,t0 ; Austin: 되돌아갈 리눅스 커널의 주소 - ecall을 실행한 다음 주소
4ea: 02853283 ld t0,40(a0)
4ea: 02853283 ld t0,40(a0)
4ee: 6928 ld a0,80(a0)
4f0: 30200073 mret ; Austin: 리눅스 커널로 리턴(슈퍼바이저 모드)
SP:FFFFFFFF8000B3BA|sbi_get_marchid: c.addi sp,-0x10 ; sp,-16
SP:FFFFFFFF8000B3BC| c.sdsp s0,0x8(sp) ; s0,8(sp)
SP:FFFFFFFF8000B3BE| c.addi4spn s0,0x10 ; s0,16
SP:FFFFFFFF8000B3C0| c.li a0,0x0
SP:FFFFFFFF8000B3C2| c.li a1,0x0
SP:FFFFFFFF8000B3C4| c.li a2,0x0
SP:FFFFFFFF8000B3C6| c.li a3,0x0
SP:FFFFFFFF8000B3C8| c.li a4,0x0
SP:FFFFFFFF8000B3CA| c.li a5,0x0
SP:FFFFFFFF8000B3CC| c.li a6,0x5
SP:FFFFFFFF8000B3CE| c.li a7,0x10 ; a7,16
SP:FFFFFFFF8000B3D0| ecall
SP:FFFFFFFF8000B3D4| c.bnez a0,0xFFFFFFFF8000B3DE
SP:FFFFFFFF8000B3D6| c.mv a0,a1 ; Austin: 리턴값을 a0 레지스터에 복사한다. 그 이유는: RISC-V에서 리턴값은
; a0에 저장하기 때문.
SP:FFFFFFFF8000B3D8| c.ldsp s0,0x8(sp) ; s0,8(sp)
SP:FFFFFFFF8000B3DA| c.addi sp,0x10 ; sp,16
SP:FFFFFFFF8000B3DC| c.jr ra ; Austin: caller 함수로 리턴한다.
아래는 위 어셈블리 명령어에 대응되는 커널 코드이다.
arch/riscv/kernel/sbi_ecall.c
long __sbi_base_ecall(int fid)
{
struct sbiret ret;
ret = sbi_ecall(SBI_EXT_BASE, fid, 0, 0, 0, 0, 0, 0);
if (!ret.error)
return ret.value;
else
return sbi_err_map_linux_errno(ret.error);
}
EXPORT_SYMBOL(__sbi_base_ecall);
정리
콜 스택을 정리하자:
[리눅스 커널] - supervisor mode
sbi_get_marchid
- __sbi_base_ecall
- ecall
--------------------------
[opensbi]
- _trap_handler
- sbi_trap_handler
- sbi_ecall_handler
- sbi_ecall_base_handler
- a1에 리턴 값 저장
- mret
PS: * 글이 도움이 됐으면 '좋아요'를 눌러주시면 좋겠습니다.