Study Hub

Toolchain / High

RISC-V ABI and Stack

ABI는 함수 호출에서 register와 stack을 누가 보존할지 정하는 약속입니다.

Why This Matters

Uebung 3의 stack frame과 recursion 문제는 ABI 책임 분담을 묻습니다.

compiler가 만든 assembly를 읽으려면 a0, ra, sp, s-register 의미를 알아야 합니다.

sp 복구와 ra 저장을 틀리면 함수가 원래 위치로 돌아가지 못합니다.

Beginner Story

caller는 일을 맡기는 쪽이고 callee는 일을 받는 쪽입니다.

a0-a7은 argument와 return value가 오가는 통로입니다.

s-register는 callee가 빌려 쓰면 원래대로 돌려놓아야 하는 물건입니다.

stack frame은 한 번의 함수 호출이 빌린 memory 방입니다.

Glossary / Key Terms

  • ABIbinary level calling convention 약속입니다. Exam tip: register saver 책임을 물을 때 사용합니다.
  • caller다른 함수를 부르는 쪽입니다. Exam tip: 필요한 t/a register를 저장합니다.
  • callee호출된 함수입니다. Exam tip: 사용한 s-register를 복구합니다.
  • a0-a7argument와 return value register입니다. Exam tip: a0는 호출 전 argument, 호출 후 return value입니다.
  • t0-t6temporary caller-saved registers입니다. Exam tip: caller가 필요하면 저장합니다.
  • s0-s11saved callee-saved registers입니다. Exam tip: callee가 쓰면 저장과 복구가 필요합니다.
  • rareturn address register x1입니다. Exam tip: non-leaf function은 보통 저장합니다.
  • spstack pointer x2입니다. Exam tip: return 직전 entry 값으로 복구합니다.
  • stack frame한 함수 호출을 위한 stack memory 구역입니다. Exam tip: saved ra와 local value offset을 그립니다.
  • prologueframe을 만들고 저장할 값을 저장하는 시작 코드입니다. Exam tip: addi sp, sp, -N이 먼저 옵니다.
  • epilogue저장한 값을 복구하고 frame을 해제하는 끝 코드입니다. Exam tip: lw 후 sp 복구, 그 다음 ret입니다.

Step-by-Step Method

  1. 1. leaf 여부 확인함수 안에 jal 또는 call이 있으면 non-leaf로 보고 ra 저장을 검토합니다.
  2. 2. register 분류a/t/s/ra/sp로 나누고 saver 책임을 정합니다.
  3. 3. frame 구성저장할 word 수와 alignment를 고려해 frame size를 고릅니다.
  4. 4. 복구 검사return 직전 s-register, ra, sp가 맞는지 확인합니다.

Visual Model

higher addresses caller arguments / old frame saved ra saved s0/fp local variables / spill slots sp after allocation, lower addresses

ABI check: every callee-saved register and ra saved in the prologue must be restored before ret; sp returns to its entry value.

Stack frame diagram

higher address에서 lower address로 내려가는 frame입니다.

old sp caller frame, 12(sp) saved ra, 8(sp) saved s0, 4(sp) local, 0(sp) local, new sp lower address

Saver responsibility table

register group과 saver를 비교합니다.

a0-a7 caller-saved, t0-t6 caller-saved, s0-s11 callee-saved, sp callee-saved, ra caller-saved but non-leaf callee saves it

Interactive Visual

ABI Stack Frame Builder

callee-saved register, ra, local slot을 켜고 끄면서 stack frame 모양을 확인하세요.

ready

JavaScript가 켜져 있으면 이 영역이 조작 가능한 visual lab으로 바뀝니다.

Worked Examples

s0와 ra 저장

Problem: foo가 s0를 쓰고 bar를 호출합니다.

  1. s0는 callee-saved입니다.
  2. bar 호출은 ra를 덮습니다.
  3. foo는 s0와 ra를 stack에 저장합니다.
  4. return 전에 둘 다 복구합니다.

sp도 원래 값으로 돌아옵니다.

caller saves t0

Problem: main이 t0를 call 뒤에도 필요로 합니다.

  1. t0는 caller-saved입니다.
  2. calc가 t0를 바꿔도 ABI 위반이 아닙니다.
  3. main이 jal 전에 t0를 stack에 저장합니다.
  4. jal 뒤에 t0를 복구합니다.

callee에게 t0 복구를 요구하지 않습니다.

Common Mistakes

  • t-register를 callee가 복구한다고 믿습니다.caller가 필요한 값만 저장합니다.
  • ra 저장 없이 nested call을 합니다.non-leaf function은 ra를 저장합니다.
  • sp 복구 순서를 바꿉니다.현재 frame에서 load한 뒤 sp를 되돌립니다.

Active Recall

  • a0의 두 역할은Hint: call 전후를 나눕니다.
    정답 확인

    첫 argument와 return value입니다.

  • s1을 callee가 쓰면Hint: saved register입니다.
    정답 확인

    callee가 저장하고 복구합니다.

  • non-leaf에서 ra를 저장하는 이유는Hint: jal이 무엇을 덮는지 봅니다.
    정답 확인

    내부 jal이 기존 return address를 덮기 때문입니다.

Exam Connection

Uebung 3은 prologue와 epilogue, recursion saving 오류를 자주 묻습니다.

답안에는 caller 책임과 callee 책임을 분리해 쓰는 것이 좋습니다.

Source Grounding

Grounding entries are course-file and source-window hints for study. When a problem needs an exact page number, branch penalty, address, or formula convention, verify the cited PDF window before finalizing the answer.

Full beginner lecture

RISC-V ABI and Stack

Intuition

ABI는 함수들이 서로 깨뜨리지 않고 협력하기 위한 약속입니다. caller는 argument를 a0-a7에 놓고 jal로 callee를 부릅니다. callee는 결과를 a0에 놓고 ret으로 돌아갑니다. 문제는 register가 모두 공유 자원이라는 점입니다. 그래서 누가 어떤 register를 저장하고 복구할지 정한 규칙이 필요합니다.

Rule

a0-a7t0-t6는 caller-saved입니다. call 이후에도 값이 필요하면 caller가 저장합니다. s0-s11은 callee-saved입니다. callee가 사용하면 원래 값으로 복구해야 합니다. rajal이 쓰는 return address register입니다. 함수 안에서 다시 jal을 실행하는 non-leaf function은 자기 caller로 돌아갈 ra를 stack에 저장해야 합니다.

Stack은 보통 낮은 address 방향으로 자랍니다. frame을 만들 때 addi sp, sp, -N, 없앨 때 addi sp, sp, N을 씁니다. return 직전 sp는 entry 때의 값과 같아야 합니다.

Visual Block

higher address
old sp -> caller frame
         12(sp): saved ra
          8(sp): saved s0
          4(sp): local or spill
new sp -> 0(sp): local or spill
lower address
caller flow
put args in a0-a7
save needed caller-saved values
jal callee

callee flow
make stack frame
save used s-registers and needed ra
compute result in a0
restore saved values
restore sp
ret

Worked Example 1

Problem. Function foo uses s0 and calls bar. What must it save.

  1. s0 is callee-saved, so foo must save and restore it.
  2. foo calls another function, so jal bar will overwrite ra.
  3. foo still needs its original ra to return to its caller.
  4. A clean 16-byte frame is:
foo:
    addi sp, sp, -16
    sw   ra, 12(sp)
    sw   s0, 8(sp)
    ...
    jal  bar
    ...
    lw   s0, 8(sp)
    lw   ra, 12(sp)
    addi sp, sp, 16
    ret

Worked Example 2

Problem. main has an important value in t0 and calls calc. It needs t0 after the call.

  1. t0 is caller-saved.
  2. calc may freely use t0.
  3. Therefore main, not calc, must save t0 before jal calc.
  4. Example:
addi sp, sp, -16
sw   t0, 12(sp)
jal  calc
lw   t0, 12(sp)
addi sp, sp, 16

C-String: Nullterminator와 byte trace

입문 설명

C의 String은 별도의 length field가 붙은 object가 아니라, 연속된 char byte 뒤에 값 0x00을 놓아 끝을 표시하는 경우가 기본입니다. 이 마지막 byte가 Nullterminator입니다.

".string \"RO\"" at base 0x2000

address   byte   meaning
0x2000    0x52   'R'
0x2001    0x4F   'O'
0x2002    0x00   Nullterminator

String length는 실제 문자 수이므로 terminator를 세지 않습니다. char는 1 byte이기 때문에 str+i의 address는 base+i입니다. RISC-V에서는 문자 하나를 읽을 때 lbu, 쓸 때 sb를 사용합니다. lw로 4 byte씩 읽는 최적화는 이 입문 trace의 범위가 아닙니다.

Übung 3의 str_length 핵심은 다음과 같습니다.

str_length:
    addi t0, zero, 0        # len = 0
loop:
    add  t1, a0, t0         # &str[len]
    lbu  t1, 0(t1)          # current byte
    beq  t1, zero, done     # 0x00이면 끝
    addi t0, t0, 1
    j    loop
done:
    addi a0, t0, 0          # return len
    jr   ra

Worked Trace: "RO\0"의 길이

초기 상태는 a0=0x2000, t0=0이고 위 memory byte를 사용합니다.

iterationt0계산한 addresslbubranch다음 상태
100x20000x52not takent0=1
210x20010x4Fnot takent0=2
320x20020x00taken to donea0=2
정답·검산 확인

return length는 2, 첫 terminator 주소는 **0x2002**입니다. 검산은 nonzero byte가 정확히 두 개이고 0x00은 세지 않았는지 확인하는 것입니다.

변형 문제

0x3000부터 byte가 0x41, 0x00, 0x42, 0x00 순서라면 같은 str_length(0x3000)의 반환값과 종료 주소는 무엇인가요?

정답·검산 확인

첫 byte 0x41='A'만 센 뒤 0x30010x00에서 멈추므로 반환값은 1, 종료 주소는 **0x3001**입니다. 뒤의 0x42는 memory에 존재하지만 이 C-String의 일부가 아닙니다.

Active Recall

Q1. "Hi".string으로 저장하면 최소 몇 byte가 필요한가요?

정답 확인

H, i, 0x00의 3 byte입니다.

Q2. 왜 str_length가 terminator를 length에 더하지 않나요?

정답 확인

terminator는 실제 문자 데이터가 아니라 끝을 표시하는 marker이기 때문입니다.

Q3. char *p에서 p+3은 base에서 몇 byte 떨어진 주소인가요?

정답 확인

char 하나가 1 byte이므로 base+3 byte입니다.

Q4. lbu 뒤 현재 byte가 zero일 때 counter를 먼저 증가시키면 어떤 오류가 나나요?

정답 확인

Nullterminator까지 문자로 세어 length가 1 크게 나옵니다.

근거: current:Vorlesung\Rechnerorganisation - Teil 1.pdf, pages 67-69; current:Uebung\Übung 3 Musterlösung.pdf, pages 4-7.

Rekursion: call마다 독립적인 Stack Frame

입문 설명

재귀 함수는 “자기 자신 한 개가 여러 번 움직이는 것”이 아니라, 아직 끝나지 않은 호출들이 각각 자기 argument와 return address를 가진 채 겹쳐 있는 상태입니다. 같은 a0ra register를 다음 recursive call도 다시 쓰므로, 현재 호출이 나중에 필요로 하는 값은 자기 Stack Frame에 저장해야 합니다.

Vorlesung의 factorial 예는 호출마다 8 byte를 사용합니다.

0(sp): saved ra
4(sp): saved n (old a0)

non-base call은 n-1jal factorial을 실행합니다. child가 a0=factorial(n-1)을 반환하면 parent는 자기 frame의 saved n을 불러 n * a0를 계산하고, saved rasp를 복구합니다.

Worked Trace: factorial(3), initial sp=0x1000

정확한 machine-code address 대신 return address를 의미가 분명한 기호로 둡니다: Rcaller는 factorial을 부른 상위 함수로 돌아갈 주소, R3R2는 각각 factorial(3)factorial(2)의 recursive jal 다음 주소입니다.

내려갈 때
active callallocation 뒤 sp0(sp) saved ra4(sp) saved n
factorial(3)0x0FF8Rcaller3
factorial(2)0x0FF0R32
factorial(1)0x0FE8R21

factorial(1)은 base case라 a0=1로 만들고 자기 8-byte frame을 해제하여 sp=0x0FF0으로 돌아갑니다. 이 호출은 내부 jal을 하지 않았으므로 현재 ra=R2로 parent에게 돌아갈 수 있습니다.

올라올 때
  1. factorial(2)4(sp)에서 old n=2를 읽고 child result a0=1과 곱해 a0=2를 만듭니다.
  2. 0(sp)R3ra에 복구하고 frame을 해제하여 sp=0x0FF8로 돌아갑니다.
  3. factorial(3)는 자기 4(sp)에서 old n=3을 읽고 child result a0=2와 곱해 a0=6을 만듭니다.
  4. Rcaller를 복구하고 frame을 해제하여 최종 sp=0x1000으로 돌아갑니다.
정답·검산 확인

최대 동시 frame은 3개, 가장 깊은 sp는 **0x0FE8, 최종 결과는 a0=6, 최종 stack pointer는 초기값과 같은 0x1000**입니다.

검산 invariant:

호출 깊이 d에서 sp = SP0 - 8*d
return할 때마다 sp += 8
각 parent는 자기 frame의 n과 ra만 복구
최종 sp = SP0

변형 문제

같은 구현으로 factorial(2)sp=0x8000에서 시작합니다. 최대 동시 frame 수, 가장 깊은 sp, 최종 a0sp를 구하세요.

정답·검산 확인

active call은 factorial(2)와 base case factorial(1)2개입니다. 두 번 8 byte를 할당하므로 가장 깊은 sp=0x7FF0입니다. unwind 뒤 a0=2*1=2, sp=0x8000입니다.

Active Recall

Q1. recursive call 전에 현재 n을 stack에 저장해야 하는 이유는 무엇인가요?

정답 확인

child 호출이 argument/return value용 a0를 덮지만 parent가 child 반환 뒤 old n을 곱셈에 다시 써야 하기 때문입니다.

Q2. 왜 모든 active call이 서로 다른 saved ra를 가져야 하나요?

정답 확인

각 call은 서로 다른 jal 다음 위치로 돌아가야 하며, 다음 recursive jal이 현재 ra를 덮기 때문입니다.

Q3. base case도 frame을 만들었다면 return 전에 무엇을 반드시 해야 하나요?

정답 확인

자신이 할당한 크기만큼 sp를 복구해야 합니다. 내부 call이 없었다면 저장한 ra를 다시 load하는 것은 불필요할 수 있어도 stack 해제는 필요합니다.

Q4. recursion trace에서 최종 결과만 맞고 spSP0-8이면 올바른가요?

정답 확인

아닙니다. ABI invariant를 깨서 caller의 stack 상태를 손상하므로 sp는 반드시 진입 값으로 돌아와야 합니다.

근거: current:Vorlesung\Rechnerorganisation - Teil 1.pdf, pages 85-87; current:Uebung\Übung 3.pdf, page 4와 current:Uebung\Übung 3 Musterlösung.pdf, pages 7-9.

Common Mistakes

Active Recall

  1. Which registers carry the first arguments and return value.
  2. Who saves s0 if a function uses it.
  3. Who saves t0 if caller needs it after a call.
  4. Why does recursion need a stack frame per call.

Source Grounding