일하다가 다른 방에 심부름을 갈 때, 돌아올 자리를 손바닥에 적고 간다. 심부름이 끝나면 손바닥을 보고 돌아온다. MIPS도 함수를 부를 때 돌아올 주소를 정해진 칸 하나에 적고 건너뛴다. 메모리에 쓰지 않아서 빠르다. 하지만 손바닥은 하나뿐이다. 심부름 간 곳에서 또 심부름을 가면 앞의 주소가 지워지므로, 그 전에 수첩(스택)에 옮겨 적어 두어야 한다. 어느 칸을 누가 옮겨 적어 지킬지는 기계가 아니라 프로그래머끼리의 약속이 정하고, 한쪽이 약속을 어기면 기계는 막지 않는다.
예시로 보기
같은 코드를 여러 곳에서 불러 쓰려면 두 가지가 필요하다. 그 코드로 건너뛰는 일과, 끝나면 부른 자리로 돌아오는 일이다. j로는 건너뛰기만 된다. 부른 자리가 여러 곳이라 돌아갈 주소를 코드에 못 박을 수 없다.
네 수를 받아 (a + b) - (c + d)를 돌려주는 diff를 부른다. 왼쪽 숫자는 명령이 놓인 주소다1.
0x00400000 main: addi $s0, $zero, 99 # main이 오래 쓸 값
0x00400004 addi $a0, $zero, 10 # 넘길 값 넷
0x00400008 addi $a1, $zero, 7
0x0040000C addi $a2, $zero, 4
0x00400010 addi $a3, $zero, 2
0x00400014 jal diff # $ra = 0x00400018, diff로 간다
0x00400018 add $s1, $v0, $zero # 돌려받은 값을 $s1에
0x0040001C j Done
0x00400020 diff: addi $sp, $sp, -4 # 스택에 한 칸 마련
0x00400024 sw $s0, 0($sp) # 부른 쪽의 $s0을 맡겨 둔다
0x00400028 add $t0, $a0, $a1 # a + b
0x0040002C add $t1, $a2, $a3 # c + d
0x00400030 sub $s0, $t0, $t1 # 계산에 $s0을 쓴다
0x00400034 add $v0, $s0, $zero # 돌려줄 값
0x00400038 lw $s0, 0($sp) # 맡겨 둔 $s0을 되돌린다
0x0040003C addi $sp, $sp, 4 # 스택 한 칸 반납
0x00400040 jr $ra # $ra에 적힌 곳으로 돌아간다
0x00400044 Done:
jal diff는 두 가지를 한다. 바로 다음 명령의 주소 0x00400018을 $ra에 넣고, diff가 있는 0x00400020으로 건너뛴다. diff의 마지막 줄 jr $ra는 $ra에 든 주소로 건너뛴다. 그래서 jal 바로 다음 줄로 돌아온다.
끝나면 $s1 = 11이다. (10 + 7) − (4 + 2)다. $s0은 diff가 중간에 11로 바꿨지만 돌아오기 전에 되돌려서 99 그대로다. $sp도 처음 값으로 돌아온다.
$s1 = 11, $s0 = 99, $ra = 0x00400018, $sp 복구), 재귀 sum의 추적 표와 스택 내용, n = 0~7에서 결과와 $sp 복구, 두 실패 시나리오, 카드의 값 — 18_mips-procedure-call_verify.py. 실제 하드웨어가 아니라 파이썬 흉내로 확인했다. 흉내기는 jal이 PC + 4를 $ra에 넣는 것으로 친다.정의
프로시저는 이름을 붙여 불러 쓰는 코드 덩어리다. C의 함수다. 부르는 쪽을 호출자, 불리는 쪽을 피호출자라 부른다.
부르고 돌아오는 명령2.
| 명령 | 하는 일 |
|---|---|
jal L |
다음 명령의 주소를 $ra에 넣고 L로 간다 (jump and link) |
jr $ra |
$ra에 든 주소로 간다 (jump register) |
$ra는 31번 레지스터다. return address, 돌아갈 주소라는 뜻이다.
jal이 넣는 주소: PC + 4인가 PC + 8인가
참조표는 jal의 동작을 R[31] = PC + 8로 적는다3. 실제 MIPS 프로세서는 점프 명령 바로 다음 줄을 점프하기 전에 먼저 실행하도록 만들어졌고, 그래서 돌아올 곳이 두 줄 뒤다. 교재 본문과 여러 공개 강의 자료는 이 사정을 빼고 “PC + 4를 넣는다”로 설명한다2. 이 문서와 검증 코드도 PC + 4로 적는다.
호출의 여섯 걸음4.
- 호출자가 넘길 값을 피호출자가 볼 수 있는 곳에 둔다 (
$a0~$a3). - 피호출자로 건너뛴다 (
jal). - 피호출자가 쓸 자리를 마련한다 (
$sp를 내린다). - 피호출자가 할 일을 한다.
- 돌려줄 값을 호출자가 볼 수 있는 곳에 둔다 (
$v0~$v1). - 호출자에게 돌아간다 (
jr $ra).
레지스터 약속. 레지스터는 32개뿐이고 호출자와 피호출자가 같은 칸을 쓴다. 그래서 누가 어느 칸을 지킬지 정해 둔다5.
| 레지스터 | 호출 뒤에도 값이 그대로인가 | 지키는 쪽 |
|---|---|---|
$s0~$s7, $sp, $fp, $gp, $ra |
그렇다 | 피호출자. 쓰려면 먼저 스택에 맡기고, 돌아가기 전에 되돌린다 |
$t0~$t9, $a0~$a3, $v0~$v1 |
아니다 | 호출자. 호출 뒤에도 필요하면 부르기 전에 스택에 맡긴다 |
앞줄을 피호출자 저장(callee-saved), 뒷줄을 호출자 저장(caller-saved) 레지스터라 부른다. $ra가 앞줄에 있다는 말은, 프로시저가 jr $ra를 할 때 $ra가 들어올 때의 값이어야 한다는 뜻이다. 안에서 jal을 또 하는 프로시저는 $ra가 덮어써지므로 먼저 스택에 맡겨야 한다.
스택. 프로시저가 값을 맡겨 두는 메모리 구역이다. 높은 주소에서 낮은 주소 쪽으로 자란다. $sp(stack pointer, 29번)가 스택의 꼭대기, 곧 가장 최근에 쓴 칸을 가리킨다6.
- 한 칸 마련하기:
addi $sp, $sp, -4 - 맡기기:
sw $s0, 0($sp). 되찾기:lw $s0, 0($sp) - 반납하기:
addi $sp, $sp, 4
프로시저 한 번의 호출이 스택에 마련한 칸들을 그 호출의 스택 프레임이라 부른다.
기호로 쓰면, \(R[x]\)가 레지스터 \(x\)의 값이고 PC가 지금 명령의 주소일 때 다음과 같다.
\[\texttt{jal}\ L:\quad R[31] \leftarrow \mathrm{PC} + 4,\quad \mathrm{PC} \leftarrow L \qquad\qquad \texttt{jr}\ rs:\quad \mathrm{PC} \leftarrow R[rs]\]재귀 호출 추적
자기 자신을 부르는 프로시저는 $ra와 인수를 스택에 맡겨야 한다. 0부터 n까지 더하는 sum이다1.
int sum(int n) { if (n < 1) return 0; else return n + sum(n - 1); }
0x00400000 main: addi $a0, $zero, 3
0x00400004 jal sum
0x00400008 add $s0, $v0, $zero
0x0040000C j Done
0x00400010 sum: addi $sp, $sp, -8 # 두 칸 마련
0x00400014 sw $ra, 4($sp) # 돌아갈 주소를 맡긴다
0x00400018 sw $a0, 0($sp) # n을 맡긴다
0x0040001C slti $t0, $a0, 1 # n < 1 ?
0x00400020 beq $t0, $zero, Rec # 아니면 Rec로
0x00400024 add $v0, $zero, $zero # 0을 돌려준다
0x00400028 addi $sp, $sp, 8
0x0040002C jr $ra
0x00400030 Rec: addi $a0, $a0, -1 # n - 1을 넘긴다
0x00400034 jal sum # $ra가 0x00400038로 바뀐다
0x00400038 lw $a0, 0($sp) # 내 n을 되찾는다
0x0040003C lw $ra, 4($sp) # 내 돌아갈 주소를 되찾는다
0x00400040 addi $sp, $sp, 8
0x00400044 add $v0, $a0, $v0 # n + sum(n - 1)
0x00400048 jr $ra
0x0040004C Done:
$sp = 0x7FFFFFFC에서 시작해 sum(3)을 부르면 이렇게 흘러간다.
| 일어난 일 | $a0 |
$ra |
$sp |
$v0 |
|---|---|---|---|---|
main이 jal sum |
3 | 0x00400008 |
0x7FFFFFFC |
|
sum(3)이 두 칸에 맡김 |
3 | 0x00400008 |
0x7FFFFFF4 |
|
sum(3)이 jal sum |
2 | 0x00400038 |
0x7FFFFFF4 |
|
sum(2)가 맡기고 jal sum |
1 | 0x00400038 |
0x7FFFFFEC |
|
sum(1)이 맡기고 jal sum |
0 | 0x00400038 |
0x7FFFFFE4 |
|
sum(0)이 맡김. n < 1이라 0을 돌려줌 |
0 | 0x00400038 |
0x7FFFFFDC → 0x7FFFFFE4 |
0 |
sum(1)이 되찾고 더해 돌려줌 |
1 | 0x00400038 |
0x7FFFFFEC |
1 |
sum(2)가 되찾고 더해 돌려줌 |
2 | 0x00400038 |
0x7FFFFFF4 |
3 |
sum(3)이 되찾고 더해 돌려줌 |
3 | 0x00400008 |
0x7FFFFFFC |
6 |
가장 깊이 들어갔을 때(sum(0)이 맡긴 직후) 스택은 이렇다.
주소 들어 있는 값 누가 맡겼나
0x7FFFFFF8 0x00400008 sum(3)의 $ra (main으로 돌아갈 곳)
0x7FFFFFF4 3 sum(3)의 n
0x7FFFFFF0 0x00400038 sum(2)의 $ra
0x7FFFFFEC 2 sum(2)의 n
0x7FFFFFE8 0x00400038 sum(1)의 $ra
0x7FFFFFE4 1 sum(1)의 n
0x7FFFFFE0 0x00400038 sum(0)의 $ra
0x7FFFFFDC 0 sum(0)의 n ← $sp
$ra 레지스터는 하나인데 돌아갈 주소는 넷이다. 셋은 스택에 있다. 호출마다 두 칸(8바이트)씩 쌓여 네 겹이 32바이트다. 끝나면 $s0 = 6이고 $sp는 0x7FFFFFFC로 돌아온다. 실행한 명령은 48개다.
sequenceDiagram
participant C as 호출자
participant S as 스택
participant P as 피호출자
C->>P: 넘길 값을 a0~a3에 두고 jal (ra에 돌아올 주소)
P->>S: sp를 내리고 ra와 쓸 s 레지스터를 맡긴다
Note over P: 할 일을 한다. 안에서 jal을 또 해도 된다
S->>P: 맡긴 값을 되찾고 sp를 올린다
P->>C: 돌려줄 값을 v0에 두고 jr ra
피호출자가 스택에 맡긴 것은 돌아가기 전에 모두 되찾는다1.
지키는 것과 지키지 못하는 것
모든 프로시저가 다음 세 가지를 지킨다고 하자. ① $sp를 내린 만큼 올린다. ② $s0~$s7과 $ra를 바꿨으면 돌아가기 전에 되돌린다. ③ 자기가 마련한 칸보다 높은 주소의 스택에 쓰지 않는다.
지키는 것. 호출이 끝나고 돌아왔을 때 $sp, $s0~$s7, 그리고 호출 전에 스택에 있던 값이 호출 전과 같다. 호출이 몇 겹이든 그렇다.
까닭 펼치기
호출이 겹친 깊이에 대한 수학적 귀납법이다.
- 깊이 0 (안에서 다른 프로시저를 부르지 않는 프로시저).
$sp와$s를 바꾸는 것은 자기 코드뿐이다. ①과 ②에 따라 돌아갈 때 처음 값이다. 쓴 스택 칸은 자기가 마련한 칸뿐이라(③) 그 위의 값은 그대로다. - 깊이 \(k\)까지 맞다고 하자. 깊이 \(k + 1\)인 프로시저 P가 안에서 Q를 부른다. Q의 깊이는 \(k\) 이하이므로 가정에 따라 Q가 돌아온 뒤
$sp,$s, P가 맡겨 둔 스택 값이 Q를 부르기 전과 같다. - 그러면 P가 맡겨 둔
$ra와$s가 스택에 그대로 있다. P는 그것을 되찾고(②)$sp를 올린다(①). 그래서 P가 돌아갈 때도 처음 값이다. ∎
지키지 못하는 것.
$t0~$t9,$a0~$a3,$v0~$v1은 호출 뒤에 어떤 값이 들어 있을지 모른다.- 반납한 스택 칸의 값은 다음 호출이 덮어쓴다. 지역 변수의 주소를 돌려주면 안 되는 까닭이다.
- 약속을 지켰는지 기계는 검사하지 않는다. 하드웨어가 하는 일은
jal이$ra에 주소를 넣는 것뿐이다. - 스택이 얼마나 깊어져도 되는지는 정해 주지 않는다. 재귀가 멈추지 않으면 스택이 다른 구역을 침범한다.
실패 시나리오
$ra를 맡기지 않은 재귀. sum에서 sw $ra, 4($sp)와 lw $ra, 4($sp) 두 줄을 뺀다. 줄이 빠져 뒤의 주소가 당겨지므로 안쪽 jal sum의 다음 줄은 0x00400034가 된다. sum(3)을 부르면 안쪽의 jal이 $ra를 0x00400034로 덮어쓰고, 그 뒤로 아무도 되돌리지 않는다. 안쪽 호출들은 0x00400034로 잘 돌아가고 $v0도 0, 1, 3, 6으로 맞게 쌓인다. 그런데 맨 바깥 sum(3)의 jr $ra도 0x00400034로 간다. main으로 돌아갈 주소 0x00400008은 어디에도 남아 있지 않다. 프로그램은 스택 바깥의 메모리를 인수인 줄 알고 읽고 $sp를 또 8 올리려 한다. 이 예에서는 그 덧셈이 32비트를 넘어 넘침 예외로 멈춘다. main의 add $s0, $v0, $zero는 끝내 실행되지 않는다.
$s0을 맡기지 않은 피호출자. 첫 예의 diff에서 sw $s0, 0($sp)와 lw $s0, 0($sp)를 뺀다. diff는 여전히 11을 돌려준다. 하지만 돌아온 main의 $s0은 99가 아니라 11이다. 틀린 값이 조용히 남고, main이 나중에 $s0을 쓸 때에야 드러난다.
스스로 설명해 보기
1. 까닭의 1단계에서 “쓴 스택 칸은 자기가 마련한 칸뿐”이 왜 필요한가
호출자가 맡겨 둔 값이 스택의 더 높은 주소에 있기 때문이다. 피호출자가 거기에 쓰면 $sp와 $s를 되돌려도 호출자가 되찾을 값이 망가져 있다.
2. 2단계에서 귀납 가정을 Q에 쓸 수 있는 까닭
Q 안에서 겹치는 호출의 깊이가 P보다 하나 이상 얕기 때문이다. 가정은 깊이 \(k\) 이하인 모든 프로시저에 대한 것이다.
3. 3단계에서 P가 $ra를 되찾을 수 있는 까닭
P가 $ra를 맡긴 칸은 P의 프레임 안에 있고, Q는 자기 프레임보다 높은 주소에 쓰지 않는다(③). 그래서 Q가 무슨 일을 해도 그 칸은 그대로다.
이 논증의 핵심 생각은?
각 프로시저가 자기 몫만 지키면 전체가 지켜진다. 부르는 쪽은 불리는 쪽의 안을 몰라도 약속만 믿으면 된다.
이 방법을 쓸 수 있는 다른 상황은?
재귀 함수가 맞는지 보일 때(“더 작은 입력에서 맞다면 이 입력에서도 맞다”), 괄호가 올바로 짝지어졌는지 볼 때(여는 만큼 닫으면 안쪽이 무엇이든 바깥 균형이 맞다).
활용
- 안에서 다른 프로시저를 부르지 않는 프로시저를 말단 프로시저라 부른다.
$ra가 덮어써질 일이 없어 맡기지 않아도 된다.$t레지스터만 쓰면 스택을 아예 안 써도 된다7. - 넘길 값이 넷을 넘으면 다섯째부터는 스택에 둔다8.
- 흔한 실수: 맡긴 순서와 되찾는 오프셋이 어긋나는 것.
sw $ra, 4($sp)로 맡기고lw $ra, 0($sp)로 되찾으면 인수를 주소로 알고 건너뛴다. - 흔한 실수: 호출 뒤에도
$t0에 값이 남아 있으리라 믿는 것. 피호출자는$t0을 마음대로 써도 된다.
연결
- 선수: MIPS 레지스터와 메모리 피연산자, MIPS 분기와 점프
- x86과 견주기. x86의
call은 돌아갈 주소를 스택에 넣고ret이 스택에서 꺼낸다(프로시저와 CALL·RET). MIPS의jal은 레지스터에 넣는다. 그래서 말단 프로시저는 메모리에 한 번도 닿지 않고 불렸다 돌아간다. x86은 인수를 스택에 쌓아 넘기고 EBP를 말뚝으로 삼아 찾는다(스택 프레임). MIPS는 넷까지$a0~$a3에 담는다. x86에서 쌓은 인수를 누가 치우는지의 약속(호출 규약)은 MIPS에서 “어느 레지스터를 누가 지키는지”의 약속이 된다. 둘 다 양쪽이 같은 약속을 믿어야 하고 기계가 검사하지 않는다는 점이 같다. - 컴파일러 과목의 함수 호출의 코드 생성은 돌려받을 값, 인수, 돌아갈 주소를 모두 스택에 쌓는 단순한 방식을 쓴다. 그 스택 한 덩어리가 활성 레코드이고, 이 문서의 스택 프레임과 같은 것이다. 실제 MIPS 약속은 레지스터를 먼저 쓰고 모자랄 때만 스택을 쓴다.
- 재귀가 스택을 먹는 모습: 재귀 프로시저
jal의 주소 칸에 적는 값: MIPS 주소 지정 방식
자주 하는 오해
jal이 돌아갈 주소를 스택에 넣어 준다”틀렸다. jal은 돌아갈 주소를 $ra 레지스터 하나에 넣을 뿐이고 스택은 건드리지 않는다. x86의 call이 스택에 넣어 주기 때문에 그럴듯해 보인다. MIPS에서는 스택에 맡기는 일을 프로그래머(또는 컴파일러)가 sw $ra, …로 직접 적어야 한다. 확인하는 법: 위의 sum에서 sw $ra와 lw $ra 두 줄을 빼고 돌려 본다. 스택에 주소가 저절로 쌓인다면 잘 돌아야 하지만, 실제로는 맨 바깥 호출이 main으로 돌아오지 못한다.
확인 문제
C1 jal L과 jr $ra가 하는 일을 쓰라. 호출 뒤에도 값이 그대로여야 하는 레지스터와 그렇지 않은 레지스터를 나눠 쓰라.
답: jal L: 다음 명령의 주소를 $ra에 넣고 L로 간다. jr $ra: $ra에 든 주소로 간다. 그대로여야 하는 것: $s0~$s7, $sp, $fp, $gp, $ra. 그렇지 않은 것: $t0~$t9, $a0~$a3, $v0~$v1.
흔한 오답: $a0~$a3을 “그대로”에 넣는 것. 피호출자가 다음 호출의 인수를 담느라 바꾼다.
C2 위의 sum을 $sp = 0x7FFFFFFC에서 sum(2)로 부른다(main의 첫 줄만 addi $a0, $zero, 2로 바꾼다). $sp가 가장 낮아졌을 때의 값은? 그때 0x7FFFFFF8번지와 0x7FFFFFF0번지에 든 값은? 돌려주는 값은?
답: $sp의 최저값은 0x7FFFFFE4. 0x7FFFFFF8에는 0x00400008, 0x7FFFFFF0에는 0x00400038. 돌려주는 값은 3.
이유: sum(2), sum(1), sum(0) 세 겹이 8바이트씩 24바이트를 쓴다. 맨 위 프레임의 $ra는 main의 jal 다음 줄이고, 그 아래 프레임들의 $ra는 sum 안의 jal 다음 줄이다.
C3 말단 프로시저는 $ra를 스택에 맡기지 않아도 되는데, 안에서 jal을 하는 프로시저는 왜 반드시 맡겨야 하는가?
답: jal은 늘 $ra에 새 주소를 쓴다. 안에서 jal을 하면 자기가 돌아갈 주소가 덮어써진다. 말단 프로시저는 jal을 하지 않으니 $ra가 들어올 때 값 그대로다.
C4 “피호출자가 돌려주는 값만 맞으면 프로그램은 맞다”가 틀리는 예를 첫 예의 diff로 만들어라.
답: diff에서 sw $s0, 0($sp)와 lw $s0, 0($sp)를 뺀다. diff는 11을 맞게 돌려준다. 그러나 main이 $s0에 두었던 99가 11로 바뀐 채 돌아온다.
이유: $s0은 피호출자가 지켜야 하는 칸이다. 돌려주는 값은 약속의 일부일 뿐이고, 건드린 $s 레지스터를 되돌리는 것도 약속이다.
C5 프로시저 f가 g를 부른다. f는 g를 부른 뒤에도 $t3과 $s2의 값이 필요하다. 각각을 스택에 맡기는 일은 f와 g 중 누구의 몫인가?
답: $t3은 f(호출자)의 몫이다. 부르기 전에 맡기고 돌아온 뒤 되찾는다. $s2는 g(피호출자)의 몫이다. g가 $s2를 쓰지 않으면 아무도 맡기지 않아도 된다.
이유: 호출자 저장 레지스터는 “필요한 사람이 지킨다”, 피호출자 저장 레지스터는 “건드리는 사람이 되돌린다”다.
-
두 예(
diff,sum)의 코드와 주소·수치, 추적 표, 스택 그림, 순서 다이어그램, “지키는 것”의 주장과 귀납 논증, 두 실패 시나리오, 스스로 설명해 보기, 오해 항목, x86·컴파일러 과목과의 비교, 카드는 위 자료에 그대로 있지 않다. 스택이0x7FFFFFFC에서 시작한다는 것은 Patterson & Hennessy 2장의 메모리 배치 관례를 따른 예시 값이다. ↩ ↩2 ↩3 -
University of Utah CS3810 (R. Balasubramonian), Lecture 5 “Procedure Calls”, https://users.cs.utah.edu/~rajeev/cs3810/slides/3810-15-05.pdf, 슬라이드 5 (프로그램 카운터, jal은 PC + 4를
$ra에 넣고 프로시저의 주소로 간다, jal이$ra를 덮어쓰므로 그 전에 어딘가에 맡겨야 한다). Patterson & Hennessy, Computer Organization and Design (MIPS Edition), 2장 “Instructions: Language of the Computer” ↩ ↩2 -
MIPS Reference Data(Green Sheet), NUS CS2100 공개본 https://www.comp.nus.edu.sg/~cs2100/lect/MIPS_Reference_Data_page1.pdf (
jal: R[31] = PC + 8; PC = JumpAddr.jr: PC = R[rs]). 점프 다음 줄을 먼저 실행하는 방식(지연 분기)은 Patterson & Hennessy, 같은 책, 4장 “The Processor”가 다룬다. ↩ -
University of Utah CS3810, Lecture 5, 같은 자료, 슬라이드 3 (호출의 여섯 걸음) ↩
-
Harris & Harris, Digital Design and Computer Architecture 2판 6장 강의 슬라이드, https://www.esa.informatik.tu-darmstadt.de/archive/twiki/pub/Lectures/Prozessorarchitektur16De/DDCA_Ch6.pdf, 슬라이드 96 (Preserved / Callee-Saved:
$s0~$s7,$ra,$sp,$sp위의 스택. Nonpreserved / Caller-Saved:$t0~$t9,$a0~$a3,$v0~$v1,$sp아래의 스택). Green Sheet의 PRESERVED ACROSS A CALL? 열 ($gp,$fp도 Yes) ↩ -
University of Utah CS3810, Lecture 5, 같은 자료, 슬라이드 6 (스택은 높은 주소에서 낮은 주소로 자란다), 7 (호출과 복귀 때 스택 칸을 마련하고 반납한다) ↩
-
같은 자료, 슬라이드 8 (말단 프로시저 예: 스택을 아예 쓰지 않을 수도 있었다), 12 (Saving Conventions) ↩
-
KFUPM ICS 233 (A. R. Naseer), Lecture 4, https://faculty.kfupm.edu.sa/coe/arnaseer/ICS233_Lecture4%20Slides.pdf, 슬라이드 7 (
$a0~$a3은 처음 네 인수를 넘기고 나머지는 스택으로 넘긴다) ↩