지난 글에서는 register set 과 register window overflow / underflow 에 대해 정리하였다.
SPARC는 CWP, WIM 2가지 값을 이용해 오버플로우나 언더플로우가 발생하는지 확인하고, 처리하였다.
이번 글에서는 서브루틴에 매개변수를 전달하는 방법을 정리하였다.
서브루틴의 매개변수 전달
서브루틴의 매개변수를 전달할 때, 전에는 그냥 %o0 ~ %o5 를 사용한다고 정리하였다.
그런데 만약 매개변수의 개수가 6개를 넘어가면 어떻게 해야할까?
SPARC에서는 매개변수의 순번에 따라 데이터를 넘기는 방식이 다르다.
6번째 매개변수까지
6번째 매개변수까지는 전에 정리했던대로 o-register 를 이용하여 매개변수를 전달한다.
%o0~%o5 를 사용할 수 있다.
%o6 은 %sp 를 가리키고, %o7 은 return address 를 가리킨다.
예제 1
6개 이하의 매개변수를 사용하여 서브루틴을 호출하는 예시를 보자.
int main() {
int sum;
sum = add4(1, 2, 3, 4);
}
int add4(int a, int b, int c, int d) {
return a + b + c + d;
}
이 코드를 어셈블리로 컴파일해보자.
먼저 항상 하던대로 스택사이즈 계산부터 해보자.
main 함수 내에서 int 형 변수 sum 을 하나 선언하였으니 4 byte 지역변수가 하나 있다.
그리고 함수를 호출하고 있으니, 92byte 를 기본적으로 가진다.
따라서 92byte + 4byte = 96byte
96byte 는 8의 배수이므로 따로 맞춰주지 않아도 된다.
그러면 이렇게 코드를 작성할 수 있다.
main 함수에서는 92byte의 스택프레임을 할당하지만, add4 함수에서는 별도의 함수호출도 없고, 지역 변수도 사용하지 않으므로 64byte 의 사이즈를 할당하였다.
return 값을 설정할 때는 i 레지스터에 할당해야되는데, o레지스터에 할당해서 조금 헤맸다
실행결과는 아래와 같이 잘 나온다.
그런데 여기에서 코드를 조금 더 최적화 할 수 있다.
바로 save, restore 명령어의 특성을 이용하는 것이다.
Save, Restore 명령어의 특성
save, restore 명령어는 레지스터와 스택 프레임을 할당하고 반환하는 명령어이다.
이때 save 명령어를 보면
save %sp, -96, %sp
이렇게 작성하는데, 이는 %sp 를 -96만큼 더해서 다시 %sp에 저장한다는 의미이다.
즉, Save 명령어는 일종의 '덧셈'을 수행하고 있는 것이다.
그런데 이는 restore 명령어도 동일하다.
restore 명령어도 덧셈을 수행할 수 있어, 명령어 호출 비용을 아낄 수 있다.
이때 save, restore 의 명령어 형식은 아래와 같다.
save/restore R, A, S
R 은 register set 이 바뀌기 전 레지스터,
A 는 register set 이 바뀌기 전 레지스터 또는 상수,
S 는 register set 이 바뀐 후, 레지스터이다.
그래서 add4 함수를 아래와 같이 작성할 수도 있다.
이때 기존 코드와 한가지 다른 점이 있다면, 저장할 레지스터를 %i0 이 아니라 %o0 으로 설정했다는 점이다.
그 이유는 S 부분에는 'register set 이 바뀐 후' 기준으로 레지스터를 작성해야 하기 때문이다.
실행 결과는 동일하게 잘 나온다.
ret, retl 명령어
restore 명령어의 특성에 대해 정리하면서, 항상 restore 명령어와 같이 나오는 ret 명령어에 대해서도 같이 정리해보자.
ret 명령어는 분기 명령어로써, 함수를 호출했던 위치의 다다음줄로 이동하는 명령어이다.
함수의 호출 위치는 %i7 에 저장되어 있다.
(함수를 호출한 caller 입장에서는 %o7에 저장하면서 호출하고, 호출받은 callee 입장에서는 %i7 로 넘어왔다고 볼 수 있다.)
따라서 ret 명령어는 %i7 + 8 위치로 분기하는 명령어라고 할 수 있다.
ret 명령어는 restore 명령어 이전에 실행될 때는 의도대로 함수를 호출했던 위치로 돌아가도록 잘 실행된다.
그런데 restore 명령 이후에 실행될 때는 ret 명령어는 의도대로 실행되지 않는다.
ret 명령어는 %i7 + 8 위치로 분기하는 명령어인데, restore 명령어가 실행되고난 이후에는 %i7이 caller의 %i7 을 의미하기 때문이다.
따라서 이렇게 순서를 바꿔서 실행하게 되면,
add4 의 caller 인 메인 함수에 대해 return 을 실행하게 되어 프로그램이 그냥 종료되어 버린다.
그래서 restore 한 이후에 함수 호출 위치의 다다음줄로 이동하고자 한다면,
%i7 이 아니라 %o7을 이용해 리턴 주소를 계산하여야 한다.
그런데 SPARC에서는 이를 retl 명령어를 통해 지원한다.
retl 명령어는 %o7 + 8 위치로 분기하는 명령어이다.
그래서 아래와 같이 코드를 작성하면 다시 의도대로 결과가 잘 나온다.
실행 결과는 다음과 같다.
근데 굳이 restore 한 이후에 ret 을 해야할까?
그냥 항상 쓰던대로 ret 한 이후에 restore을 하면 되는데, 왜 retl 명령어를 지원하는 것일까?
이는 리프 서브루틴이라는 독특한 서브루틴이 있기 때문이다.
이 서브루틴은 자신 이후로 더 이상 서브루틴을 호출하지 않는 콜 그래프의 리프 노드로서,
자신의 스택공간과 레지스터 공간을 가지지 않는 독특한 서브루틴이다.
그래서 자신의 i, l 레지스터를 가지지 않기에 ret 할 때 caller의 레지스터 정보를 이용하여 복귀를 하여야 하기 때문에 %o7을 기준으로 돌아가는 retl 명령어를 사용한다.
자세한 내용은 나중에 리프 서브루틴에 대해 정리할 예정이다.
예제2
두번째 예제로 아래 C코드를 한번 어셈블리로 옮겨보자.
main() {
int r;
r = example(3, 5, 4);
}
int example (int a, int b, char c) {
int x, y;
short ary[128];
register int i, j;
x = a+b;
i = c + 64;
ary[i] = c + a;
y = x * a;
j = x + i;
return x + y;
}
굉장히 독특한 코드다.
i, j 에는 이것 저것 값을 넣있고, ary 에도 값을 넣고 있는데, 넣은 값은 x, y 와 관련이 전혀 없다.
암튼 일단 구현을 한번 해보자.
먼저 main 함수의 스택 프레임 사이즈를 계산해보자.
지역변수는 int 형 변수 r 만 있고, 함수를 호출하고 있으므로, main 함수의 스택 프레임 사이즈는 92 + 4 = 96 byte 이다.
96은 8의 배수이므로 스택프레임의 사이즈는 96 byte 이다.
다음으로 example 함수의 스택 프레임 사이즈를 계산해보자.
함수 내에서 x, y 라는 int 형 변수 2개를 사용하고 있고, short 형의 128 사이즈 배열을 사용하고 있다.
i, j 는 레지스터 이므로 지역변수가 아니다.
함수는 따로 호출하고 있지 않으니, 스택 프레임의 크기는 64 + 4*2 + 2*128 = 64 + 8 + 256 = 328 byte 로 계산할 수 있다.
그러나 코드 중에 x * a 라는 곱셈이 있는데, a 가 어떤 값이 올지 모르니 mul 함수를 사용하는 것이 더 좋아보인다.
따라서 92 byte + 4*2 + 2*128 = 356 byte 가 스택사이즈가 되는데, 이를 8의 배수로 맞춰주면 360 byte를 선언하면 된다.
먼저 위 그림과 같이 main 함수 코드먼저 작성하였다.
example 함수의 실행 결과를 메모리에 저장한 뒤, 그 값을 다시 출력해보도록 하였다.
위와 같이 코드를 작성하였다.
코드 중간에 헤맸던 부분은 short 배열의 값을 쓰고 읽을 때, st, ld 가 아니라 sth, ldsh 를 사용해야 한다는 점이다.
주소값이 2의 배수이기 때문에, 그냥 st 를 사용하면 Bus Error 가 발생할 수 있다.
그리고 반환값이 두 변수의 합이므로, 이 계산을 restore에서 시키도록 아래와 같이 최적화를 시킬 수도 있다.
restore에 최적화를 시킬 때는 i 가 아니라 o 레지스터를 사용하여 S 부분을 적어야 한다.
아래는 실행 결과이다.
그런데 강의록에서는 main 함수 마지막 부분을 아래와 같이 작성하였다.
ret, restore 명령어를 사용하는 대신 g1 레지스터에 값을 넣고, ta 명령어를 사용하였다.
이 코드는 exit(0) 과 동일한 기능을 수행하며, 시스템 콜과 관련된 코드라고 한다.
https://shinluckyarchive.tistory.com/154
%g1 에 미리 정의된 기능을 수행하기 위한 숫자를 넣는다.
%g1 에 1 을 넣으면 exit 을 의미한다.
그 뒤 ta 0 명령어를 실행하면 %g1에 넣어진 값에 해당하는 기능이 수행된다.
SPARC 에서는 Trap 이라는 걸 발생시켜서 운영체제에 시스템 콜을 보내는 것 같다.
7번째 매개변수부터
6개를 넘어가는 7번째 매개변수부터는 스택 메모리를 사용해 메모리를 전달한다.
메모리를 사용하는 경우는 레지스터를 사용하는 경우보다 데이터를 읽고 쓰는 속도가 느려진다.
이렇게 그림과 같이, 6번재 인자까지는 레지스터를 사용하고, 7번째 레지스터부터는 스택을 사용한다.
이때 사용하는 스택의 주소는 %sp+92 부터 순차적으로 증가한다.
근데 여기서 드는 한가지 궁금증,
만약 함수의 매개변수가 int 형이 아니라면, 스택에 선언되는 변수의 크기도 그에 맞게 바뀔까?
왜 하필 %sp + 92 일까?
그 이유는, 서브루틴 하나가 사용하는 스택 프레임 내에서,
처음 64byte는 i, l 레지스터의 백업용도이고,
그 다음 4byte는 구조체 포인터를 반환할 때사용하는 공간,
그 다음 24byte 는 %o0 ~ %o5 까지 6개 매개변수를 백업하는 이다.
여기까지 92 byte를 사용하고 이후로는 지역변수를 위한 공간이다.
그런데 6개를 넘는 매개변수를 사용하기 위해 스택을 사용한다면 당연히 기존 6개 매개변수를 저장한 공간 뒤에 이어서 저장하는 것이 합리적이다.
따라서 %sp + 92 위치에 저장하는 것이다.
%sp를 사용하는 이유는, 함수를 호출할 때 매개변수에 값을 담으면서 스택에 값을 저장하기 때문에, 함수를 호출하는 입장에서 %sp 기준으로 접근해야하기 때문이다.
%fp를 사용한다면 지역변수위치와 매개변수의 순번을 고려하여 계산해야하는데 이는 복잡하다.
그리고 피호출 서브루틴에서 매개변수에 접근할 때는 거꾸로 %fp를 이용하여 매개변수를 읽어와야 한다.
%fp 가 자신을 호출한 함수의 %sp 를 가리키고 있기 때문이다.
여기까지 정리하면 스택프레임의 크기를 정할 때 고려하는 부분이 아래와 같이 된다.
기본 64byte + 서브루틴 호출시, (4 + 24 + 6개 이후 추가되는 매개변수 당 4 byte)
+ 지역 변수 (변수의 선언 순서와 구조체가 차지하는 범위 고려)
만약 6개가 넘는 매개변수를 갖는 함수를 여러개 호출한다면, 그 때는 어떻게 해야할까?
간단하다.
어차피 함수 하나가 실행되고나면, 그 함수의 스택 프레임이 반환되고나서 다른 함수가 실행되므로
그냥 매개변수가 제일 많은 서브루틴을 기준으로 스택프레임 사이즈를 계산하면 된다.
예제 1
이제 6개가 넘는 매개변수를 사용하는 예제를 정리한다.
아래와 같은 C 코드를 어셈블리로 컴파일 해보자.
void main() {
int sum;
sum = add8(1, 2, 3, 4, 5, 6, 7, 8);
}
int add8(int a, int b, int c, int d, int e, int f, int g, int h) {
return a + b + c + d + e + f + g + h;
}
8개의 인자를 받아서 8개의 합을 구하는 간단한 코드이다.
이제 스택 프레임 사이즈를 위에서 정리한 내용을 토대로 계산해보자.
기본 64
내부에서 함수 호출 있으므로 기본으로 4 + 24
매개변수가 6개를 넘어가므로 초과하는 매개변수 2개에 대해 4*2 = 8
지역변수는 int 형 변수 sum 하나가 있으므로 4
따라서 필요한 스택프레임의 크기는 64 + 4 + 24 + 8 + 4 = 92 + 12 = 104
104는 8의 배수 이므로 104 byte 의 스택 프레임을 선언하면 된다.
그러면 이렇게 기본틀을 잡을 수 있다.
여기에 sum 변수에 접근할 때 보기 좋게 상수 값을 선언해주고, 6번째 매개변수까지 인자를 전달해주자.
7, 8 번째 매개변수는 %sp 를 사용하여 %sp + 92, %sp + 96 위치에 넘겨준다.
add8 서브루틴의 스택프레임 사이즈를 계산해보자.
내부에서 어떤 지역변수도, 함수 호출도 없으므로 기본 사이즈인 64byte를 필요로 한다.
add8 함수를 선언해주고 main 함수에서 호출해주자.
add8 서브루틴의 호출 결과를 sum 변수에 저장하고, 테스트로 출력해보는 코드도 작성해보자.
근데 여기에서 한번 테스트로 실행시켜보니 에러가 난다.
그렇군.. st 명령어는 상수를 직접 저장하는 것이 안됐다.
불편하지만 l 레지스터에 7, 8번재 매개변수로 넘길 값을 저장하고, 레지스터를 이용해 스택에 값을 쓰자.
이렇게 수정했다.
한번 실행시켜보면
아주 잘 된다.
이제 add8 함수 내부에 코드를 작성해보자.
레지스터를 통해 넘어온 인자값은 이렇게 처리해주면 된다.
1부터 6까지 합도 잘 구했다.
다음으로 스택에서 7, 8번째 인자값을 레지스터로 읽어와 더해주면 된다.
위에 적었던대로, 피호출함수에서는 %fp 프레임 포인터를 통해 caller 가 넘겼던 매개변수 값을 읽어온다.
1부터 8까지의 합을 잘 구해냈다.
그리고 위에서 기술했던대로, main 함수의 마지막은 아래와 같이 바꿀 수도 있다.
강의록에서는 add8 서브루틴을 아래와 같이 작성하였다.
restore 명령어에서 두번째 %o0 과 세번재 %o0 은 서로 다른 레지스터를 가리킨다는 점을 유의하자.
예제 2
두번째로 다음 C 코드를 한번 옮겨보자
main() {
int a, b = 10;
a = fn1(3, 5, 4);
if (a > b)
b = fn2(10, 20, 30, 40, 50, 60, 70, 80, 90);
else
b = fn3(11, 12, 13, 14, 15, 16, 17);
}
int fn1(int a, int b, int c) {
return a + b + c;
}
int fn2(int a, int b, int c, int d, int e, int f, int g, int h, int i) {
return a + b + c + d + e + f + g + h + i;
}
int fn3(int a, int b, int c, int d, int e, int f, int g) {
return a + b + c + d + e + f + g;
}
a 에는 인자로 넘어온 숫자 3개를 더한다.
그리고 10과 비교해서 더 크면 b 에는 9개 수를 더해서 저장하고, 아니면 7개 수를 더해서 저장한다.
먼저 main 함수의 스택 프레임 사이즈는
기본 64 + 함수호출 (4 + 24) + 초과 매개변수 중 제일 큰 사이즈 ( 9개 이므로 3개 초과, 4*3 = 12) + 지역변수 4*2
따라서 64 + 28 + 12 + 8 = 112 byte 이고, 이는 8의 배수이다.
나머지 함수는 모두 기본사이즈인 64 byte 만 선언해주면 된다.
먼저 main 함수와 fn1, fn2, fn3 의 기본 세팅
그리고 출력을 위한 문자열 상수와 변수 a, b 접근을 위한 상수값을 설정한다.
이제 main 함수부터 작성해보자.
먼저 main 함수 코드는 위와 같이 작성하였다.
a, b, 값을 설정하고, a 값은 fn1 을 호출한 결과값을 저장하도록 하였다.
그 뒤 a, b 를 비교하여 fn2, fn3 중 어떤 것을 부를지 결정한다.
어떤 분기로 가든, 돌아오는 곳은 print_result 분기로 돌아와서 결과를 출력하고 프로그램을 종료한다.
(아직 출력하는 코드는 작성하지 않았다.)
이제 call_fn2 분기를 작성하자.
예제 1에서 했던대로, 6번째를 초과하는 매개변수는 스택 포인터를 이용해서 스택에 저장한다.
함수의 실행 결과를 b 변수에 저장해주고 결과를 출력하는 곳으로 분기한다.
다음으로 call_fn3 분기를 작성하자.
기존에 작성했단 call_fn2 분기를 활용하면 금방 작성할 수 있다.
이제 main 함수에 대한 코드 작성이 완료되었다.
어셈블이 잘 되는지 확인을 해보자.
아주 잘 된다.
다음으로 fn1 함수를 작성한다.
매개변수로 넘어온 값 3개를 더해서 반환하면 된다.
restore 명령어로 덧셈을 할 수 있다는 점을 이용하여 위와 같이 작성할 수 있다.
%i0, %i2 를 더한 값을 %i0 이 아니라 %o0에 저장한다는 점에 주의하자.
이제 a 값이 잘 구해졌는지 확인하기 위해, a, b 값을 출력하는 코드를 main 함수 마지막에 작성하자.
이제 이 시점에서 프로그램을 실행시켜 보자.
실행결과가 예상대로 나온다.
fn1(3, 5, 4) 의 실행결과로 a 에는 12가 저장되고, 12 > 10 이므로 fn2 가 호출된다.
fn2 의 %o0는 10이 전달되어 들어갔고, 함수 로직이 아직 아무것도 없으므로 %o0 에는 10이 그대로 남아있다.
따라서 a =12, b = 10 이 출력되는 것이 옳다.
이제 fn2 함수를 작성하자.
매개변수로 넘어온 9개 값을 모두 더해서 반환하면 된다.
위와 같이 작성할 수 있다.
한번 프로그램을 실행시켜보자.
실행 결과 예상대로 잘 나온다.
마지막으로 fn3 함수를 작성하고,
fn1 에 넘기는 인자값을 조절해서 fn3 의 함수가 예상대로 실행되는지 확인해보자.
이렇게 restore 명령어의 덧셈 연산을 이용해서 조금 더 효율적으로 작성하였다.
임의로 fn1 에 넘기는 인자값을 조절해서 세 수의 합이 10보다 작도록 설정했다.
이렇게 하면 fn3 이 호출될 것이다.
11부터 17까지의 합은 28 * 7 / 2 = 14 * 7 = 98 이므로, 예상한 그대로 결과가 나왔다.
전체코드는 아래와 같다.
.global main
int_a = -4
int_b = -8
main:
save %sp, -112, %sp
mov 10, %o0
st %o0, [%fp + int_b] ! b = 10
! set arguments for fn1
mov 3, %o0
mov 5, %o1
mov 4, %o2
call fn1
nop
st %o0, [%fp + int_a] ! a = fn1(3, 4, 5)
ld [%fp + int_a], %l0 ! load a
ld [%fp + int_b], %l1 ! load b
cmp %l0, %l1
bg call_fn2 ! a > b then, call fn2
nop
ba call_fn3 ! else call fn3
nop
print_result:
set str, %o0
ld [%fp + int_a], %o1
ld [%fp + int_b], %o2
call printf
nop
mov 1, %g1 ! exit
ta 0
call_fn2:
mov 10, %o0
mov 20, %o1
mov 30, %o2
mov 40, %o3
mov 50, %o4
mov 60, %o5
mov 70, %l0
st %l0, [%sp + 92] ! 7th arg = 70
mov 80, %l0
st %l0, [%sp + 96] ! 8th arg = 70
mov 90, %l0
st %l0, [%sp + 100] ! 9th arg = 70
call fn2
nop
test:
st %o0, [%fp + int_b] ! b = fn2(10, 20, ...., 90)
ba print_result
nop
call_fn3:
mov 11, %o0
mov 12, %o1
mov 13, %o2
mov 14, %o3
mov 15, %o4
mov 16, %o5
mov 17, %l0
st %l0, [%sp + 92] ! 7th arg = 17
call fn3
nop
st %o0, [%fp + int_b] ! b = fn3(11, 12, ...., 17)
ba print_result
nop
fn1:
save %sp, -64, %sp
add %i0, %i1, %i0
ret
restore %i0, %i2, %o0
fn2:
save %sp, -64, %sp
add %i0, %i1, %i0
add %i0, %i2, %i0
add %i0, %i3, %i0
add %i0, %i4, %i0
add %i0, %i5, %i0
ld [%fp + 92], %l0 ! 7th argu
add %i0, %l0, %i0
ld [%fp + 96], %l0 ! 8th argu
add %i0, %l0, %i0
ld [%fp + 100], %l0 ! 9th argu
add %i0, %l0, %i0
ret
restore
fn3:
save %sp, -64, %sp
add %i0, %i1, %i0
add %i0, %i2, %i0
add %i0, %i3, %i0
add %i0, %i4, %i0
add %i0, %i5, %i0
ld [%fp + 92], %l0 ! 7th argu
ret
restore %i0, %l0, %o0
.data
str: .asciz "a is %d, b is %d\n"
길어보이지만, 위에 소스코드를 작성한 과정을 따라가면서 부분 부분 나눠서 작성하면 어렵지 않게 작성할 수 있다.
'CS > 어셈블리' 카테고리의 다른 글
[SPARC] 34. Leaf Subroutine & Pointer Type Argument (0) | 2023.12.06 |
---|---|
[SPARC] 33. 구조체 return 하기 (0) | 2023.12.05 |
[SPARC] 31. Register Set & Register Window Over/Underflow (2) | 2023.12.02 |
[SPARC] 30. 서브루틴 개요 & call, jmpl, ret, retl (0) | 2023.12.01 |
[SPARC] 29. 구조체 (0) | 2023.11.30 |