RISC-V Programming

김민욱·2025년 6월 16일

RISC-V Function Calling Convention

  • Call function : jump and link (jal func)
  • Return from function : jump register (jr ra)
  • Arguments : a0 - a7
  • Return value : a0

함수 호출에는 두 가지 역할이 존재한다. 함수를 호출하는 Caller 함수와 호출되는 Callee 함수이다.

int main() // Caller
{
	simple(); // function call
    a = b + c;
    /* ... */
}

void simple() // Callee
{
	return;
}

\darr

0x00000300 main : jal simple		# call
0x00000304        add s0, s1, s2	# a = b + c
...               ...

0x0000051c simple : jr ra	# return
  • jal simple
    jump 전에 ra 레지스터에 PC+4를 저장한다.
    PC = 0x00000300, PC+4 = 0x00000304 = 다음 명령어 주소.
    이후 simple 주소로 jump
    PC = 0x0000051c

  • jr ra
    PC = ra = 0x00000304

caller 함수는 callee 함수에게 인자(arguments)를 넘겨주고 calle로 점프한다.
callee 함수는 내부 기능을 수행한 후 caller 함수에게 결과를 return한 후 return address로 점프한다. 이 과정에서 callee는 caller가 사용하는 레지스터나 메모리를 덮어써서는 안되기 때문에 백업이 필요하다.

Stack

스택은 함수 내부에서 변수 값 등을 저장해두기 위한 메모리의 가상 공간(scratch space)이다.
LIFO의 규칙을 따르며 필요에 따라 확장되거나 줄어든다.
stack은 높은 주소에서 시작하여 아래로 확장되는데, stack의 꼭대기를 sp로 가리킨다.

호출된 함수는 주 목적 이외에 side effect(의도하지 않은 부수적인 결과 발생)를 일으키면 안된다.
따라서 덮어 쓰게 될 레지스터의 값들을 미리 stack에 백업해 두었다가 함수의 기능이 끝나면 caller 함수로 돌아가기 전에 레지스터의 값들을 복원한 후 return 한다.

int diffofsums(int f, int g, int h, int i)
{
	int result;
    result = (f+g)-(h+i);
    
    return result;
}

\darr

# s3 = result
diffofsums :
	addi sp, sp, -12 # make space on stack
    
    sw s3, 8(sp) # save s3 on stack
    sw t0, 4(sp) # save t0 on stack
    sw t1, 0(sp) # save t1 on stack
    
    add t0, a0, a1
    add t1, a2, a3
    sub s3, t0, t1
    add a0, s3, zero # result
    
    lw s3, 8(sp) # restore s3 from stack
    lw t0, 4(sp) # restore t0 from stack
    lw t1, 0(sp) # restore t1 from stack
    addi sp, sp, 12 # deallocate stack space
    
    jr ra # return to caller


레지스터의 보존 책임은 다음과 같다.
좌측은 callee가 책임을 지고, 우측은 caller가 책임을 진다.

이 책임 정책에 따라서 위 diffofsums 함수를 최적화 하면 아래와 같이 작성할 수 있다.

diffofsums :
	add t0, a0, a1
    add t1, a2, a3
    sub a0 t0, t1
    jr ra

보존 책임이 없는 레지스터만 사용하여 stack을 건드리지 않고 컴파일한 것이다.

Non-leaf function call ; Nested function call

다른 함수를 호출하는 함수를 Non-leaf function이라고 한다.

# f1 (non-leaf function) uses s4-s5 and needs a0-a1 after call to f2
f1:
    addi sp, sp, -20   # make space on stack for 5 words
    sw a0, 16(sp)
    sw a1, 12(sp)
    sw ra, 8(sp)     # save ra on stack
    sw s4, 4(sp)
    sw s5, 0(sp)
    jal func2
    ...
    lw ra, 8(sp)     # restore ra (and other regs) from stack 
    ...
    addi sp, sp, 20  # deallocate stack space  
    jr ra            # return to caller
    
 # f2 (leaf function) only uses s4 and calls no functions
 f2:
    addi sp, sp, -4   # make space on stack for 1 word
    sw s4, 0(sp)
    ...
    lw s4, 0(sp)
    addi sp, sp, 4    # deallocate stack space 
    jr ra             # return to caller

위 함수의 stack 변화를 살펴보자.

return 시에는 이 과정의 반대 순서로 값들이 복원되고, stack 공간이 deallocate 된다.

Function call summary

Caller

  • 필요한 레지스터만 백업한다. (ra, 혹은 t0-t6/a0-a7)
  • a0-a7에 인자를 담는다.
  • Call function : jal callee(label)
  • 호출 결과는 a0에 있다.
  • 저장된 레지스터의 값들을 복원한다.

Callee

  • 덮어쓸 레지스터들을 백업한다. (s0-s11)
  • 함수 기능을 수행한다.
  • 결과를 a0에 담는다.
  • 레지스터 값들을 복원한다.
  • Return : jr ra

Recursive Function ; 재귀함수

스스로를 호출하는 함수를 재귀함수(Recursive function)라고 한다.
재귀호출을 어셈블리로 변환할 때는 다음과 같은 단계를 거친다.

  • first pass, 재귀호출을 마치 다른 함수를 호출하는 것 처럼 다루고 덮어써진 레지스터들을 무시한다.
  • 호출 이후 덮어써진 레지스터들을 필요에 따라 stack에 저장한다.
int factorial(int n) {
	if (n <= 1) return 1;
    else return (n*factorial(n-1));
}

\darr

factorial :
	addi sp, sp, -8 	# make space for a0, ra
    sw a0, 4(sp)
    sw ra, 0(sp)
    addi t0, zero, 1	# t0 = 1
    bgt a0, t0, else	# if a0>=t0 goto else
    addi a0, zero, 1	# else a0 = 1 ; == return 1
    addi sp, sp, 8		# deallocate space
    jr ra				# return

else :
	addi a0, a0, -1 	# n = n - 1
    jal factorial		# call factorial(n-1)
    lw t1, 4(sp)		# restore n into t1
    lw ra, 0(sp)		# restore ra
    addi sp, sp, 8		# deallocate
    mul a0, t1, a0		# a0 = n * factorial(n-1)
    jr ra				# return

함수 호출 중 stack의 변화이다.

RISC-V jump & Pseudo instruction

RISC-V는 두 타입의 무조건 jump가 있다.

  • Jump and link (jal rd, imm_{20:0})
    rd = PC+4; PC = PC + imm
  • Jump and link register (jalr rd, rs, imm_{11:0})
    rd = PC+4; PC = rs + imm

이 명령어 사용을 조금 더 편하게 만들기 위해 의사명령어(pseudo instruction)이 존재한다.

  • j imm = jal x0, imm
  • jal imm = jal ra, imm
  • jr rs = jalr x0, rs, 0
  • ret = jr ra

위 내용에서 우리는 함수 콜에 label을 사용했다.
label은 다음과 같이 다뤄진다.

  • imm = 점프 명령 이후 # bytes. 즉, offset.
    예) imm = 0x51C- 0x300 = 0x21C
    jal simple = jal ra, 0x21C

jal의 imm size는 20bits, jalr의 imm size는 12bits이다. 따라서 먼 거리의 주소로 점프하려면 별도의 방법이 필요하다.
그 해결책이 auipc 명령이다.

  • auipc rd, imm : PC에 상위 20bit를 더한다.
    rd = PC + {imm31:12_{31:12}, 000...0(12bit)_{(12bit)}}

의사명령어 : call imm_{31:0}

내부 구현
auipc ra, imm_{31:12}
jalr ra, ra, imm_{11:0}
  • 의사명령어와 내부 구현 정리

<참고자료>
Harris & Harris, Digital Design and Computer Architecture, RISC-V Edition, 2022.

0개의 댓글