728x90

프로시저는 재사용 가능한 코드 블록이며, 함수와 유사하게 특정 작업을 수행한다. 프로시저 P에서 Q로의 호출 과정에는 몇 가지 필수 기능이 포함된다.

1. 제어권 전달: 프로그램 카운터(PC)는 진입할 때 Q에 대한 코드의 시작주소로 설정되고, 리턴할 때는 P에서 Q를 호출하는 인스트럭션 뒤로 설정되어야 할 것이다.

(Passing Control : PC는 Q의 코드의 시작 주소로 설정되고 Q를 호출한 후에 P의 명령어로 설정한다.)

2. 데이터 전달: P는 하나 이상의 매개변수를 Q에 제공할 수 있어야 하며, Q는 다시 Q로 하나 이상의 값을 리턴할 수 있어야 한다.

(Passing Data : P는 하나 이상의 파라미터를 Q에게 제공하고, Q는 P에게 값을 다시 반환한다.)

3. 메모리 할당과 반납: Q는 시작할 때 지역변수들을 위한 공간을 할당할 수 있고, 리턴할 때 이 저장소를 반납할 수 있다.

(Allocating and deallocating memory : Q는 지역 변수에 대한 공간을 할당할 필요가 있다.)

 

스택은 작은 주소 방향을 성장하며, 스택포인터 %rsp는 스택의 최상위 원소를 가리킨다.

 

x86-64 아키텍처에서 프로시저가 레지스터에 저장할 공간을 초과하면 스택에 데이터를 저장한다. 프로시저 호출은 스택의 LIFO 원칙을 따르며, 프로시저 Q가 실행 중일 때, 프로시저 P는 대기 상태에 들어간다. Q 실행 중에는 지역 변수를 위한 새로운 공간을 스택에 할당하고, 다른 프로시저 호출을 준비한다. Q가 끝나면 할당했던 로컬 저장소를 해제한다.

 

프로그램은 스택을 통해 프로시저가 요구하는 저장 공간을 관리한다. P가 Q를 호출하면, 제어와 데이터 정보가 스택에 추가되고, P로 돌아올 때 해당 정보는 스택에서 제거된다. P가 Q를 호출하면서 리턴 주소를 스택에 푸시한다. 이는 Q가 끝나고 P로 돌아가야 할 위치를 나타낸다. Q는 호출될 때 자신의 작업 공간을 스택에 할당하며, 이 공간 안에서 레지스터 값과 지역 변수를 위한 공간을 설정하고, 필요한 인자들을 준비한다

 

많은 함수는 스택 프레임을 요구하지 않고 주로 레지스터 내에서 처리한다. P는 최대 여섯 개의 정수 인자를 레지스터를 통해 전달할 수 있지만, Q가 더 많은 인자를 필요로 하면 P는 이들을 스택 프레임 안에 저장한다. 제어를 Q로 넘기는 것은 PC(Program Counter)를 Q의 시작 주소로 설정하는 것이다. Q가 끝나면, ret 명령어는 스택에서 리턴 주소를 팝하여 PC를 그 주소로 설정해 P로 돌아간다.

call 인스트럭션은 호출된 프로시저가 시작하는 주소를 목적지로 갖는다. jump 인스트럭션과 비슷하게 직접 호출할수도, *를 붙여 간접 호출할 수도 있다. (직접 호출은 라벨이 주어지는 반면에 간접 호출은 *로 주어진다.)

메인함수에서 call이 실행되면서 call 의 다음주소인 400568이 복귀주소로 스택에 푸시된다. 그런다음 쭉 내려와서 retq 가 실행되면, 다시 복귀주소를 팝하고 돌아온다.

제어를 함수 P에서 Q로 넘긴다는 것은 단순히 프로그램 카운터를 Q를 위한 코드 시작주소로 설정하는 것이면 된다. 그렇지만, 나중에 Q가 동작을 마치고 리턴해야 할 때가 오면 프로세서는 P의 실행을 다시 실행해야 하는 코드 위치의 일부 기록을 가지고 있어야 한다. 이를 담당하는 것이 call 인스트럭션이다.

함수 P에서 Q로 제어 전송을 하는 것은 PC에 Q 코드에 대한 시작 주소로 설정하면 된다.

나중에 Q가 반환할 시간이 오면, 프로세서는 P의 실행을 다시 재개해야 하는 위치를 기록해야 한다.

이 정보는 Q를 호출하는 명령어와 함께 프로시저 Q를 부르면서 x86-64 기계에 저장된다.

 

이 명령어는 스택에 주소 A를 푸시하고, PC를 Q의 시작 주소로 설정한다.

푸시된 주소는 반환 주소로 부르며 call 명령어로 명령어의 주소로써 계산된다.

아래와 같이 조금 더 복잡한 경우도 살펴볼 수 있다. main함수 안에 top함수 안에 leaf함수가 있는 경우이다.

main함수가 실행되고 top함수가 호출되면 먼저 스택에는 main복귀주소가 들어가고 rip(PC)는 top으로 세팅된다. 그리고, top함수가 실행되면 그 안에있는 leaf함수도 실행된다. 이떄 top함수의 복귀주소가 들어가고, rip 는 leap로 세팅된다.

leap가 리턴되면 스택에서 팝한 것을 주소로하여 top으로 돌아온다.

top이 리턴되면 스택에서 팝한 값을 주소로 하여 main으로 돌아온다.

함수의 호출에 따라 %rsp의 이동, %rip의 이동, %rsp에는 어떤값이 푸시되는지, return할때는 retq에 의해 어떤 값이 팝되는지의 흐름을 이해해야 한다.

ret은 프로시저 호출이 끝날 때 사용되는 인스트럭션으로, 프로시저가 호출될 때 스택에 저장해 놓았던 복귀 주소를 읽어들여, 스택 프레임을 해제하고 해당 주소로 복귀한다.

x86-64에서는 최대 여섯개의 정수형 인자가 레지스터로 전달될 수 있다. 이 레지스터들은 전달되는 데이터 형의 길이에따라 레지스터 이름을 이용하여 정해진 순서로 이용된다. 인자들은 아래 리스트에서 각자의 순서에 따라 이들 레지스터에 할당된다. (64비트보다 작은 인자들은 더 작은 크기의 레지스터들로 할당 가능)

함수의 인자와 지역/전역변수를 포함한 데이터들의 이동을 고려하지 않고서는, 프로시저의 제어를 전달해봐야 아무 의미가 없다.

프로시저 P가 Q를 호출할때, P에대한 코드는 먼저 인자들을 적절한 레지스터들에 복사해야한다.

함수가 여섯개 이상의 정수형 인자를 가지면 6개를 넘어서는 인자들은 스택으로 전달된다.

인자7 에서 n까지를 위한 충분한 크기의 저장공간을 스택 프레임에 할당해야 한다.

다시말해 인자1~6은 적절한 레지스터들에 복사되고, 인자7~n까지는 스택탑에 넙는다.

Q는 레지스터와 스택을 통해 자신의 인자들에 접근할 수 있다.

 

이후에 만약 Q가 여섯개가 넘는 인자를 갖는 어떤 함수를 호출하려면 앞에서 본 스택프레임에서 Argument build area라는 곳에 공간을 할당할 수 있다.

그렇다면 함수가 여섯 개 이상의 정수형 인자를 가진다면? 다른 인자들은 스택으로 전달된다. 인자 1~6은 적절한 레지스터에 복사하고, 인자 7에서 n까지는 인자 7을 스택 탑에 넣는 방법으로 저장한다.

호출된 프로시저에 제어를 넘겨주고 프로시저가 반환되면, 프로시저 호출은 인수 데이터를 넘기고, 프로시저로부터 반환되는 것은 값을 반환하는 것을 포함한다.

 

x86-64에서 6개의 인수가 레지스터를 통해 전달된다. 레지스터들은 전달된 데이터 타입의 크기에 맞는 레지스터를 위해 사용된 이름과 함께 구체적인 순서로 사용된다.

 

함수가 6개보다 많은 인수를 가지고 있으면, 다른 인수는 스택에 전달된다.

인수가 8개인 proc 함수
어셈블리 코드

6번째 인수까지는 레지스터에 저장되지만 마지막 두 개는 스택으로 전달된다.

로컬 데이터는 메모리에 저장되어야 한다.

 

- 로컬 데이터를 가지고 있기에 충분한 레지스터가 없다.

- 주소 연산자 &는 지역 변수에 적용되어서 주소를 생성할 수 있어야 한다.

- 어떤 지역 변수는 배열이나 구조체이므로 배열이나 구조체 레퍼런스로 접근되어야만 한다.

 

일반적으로 프로시저는 스택 포인터를 감소시키면서 스택 프레임에 공간을 할당한다.

 

이 예에서는 함수가 지역 변수를 위해 스택을 사용하는 방식을 볼 수 있다. 함수 실행을 위해 스택을 16만큼 확장하고, movq 연산을 사용하여 함수 인자들을 스택에 배치한다. 함수가 실행된 후, 스택에서 지역 변수들을 사용하여 필요한 계산을 수행하고, 마지막에 스택 포인터를 원래 위치로 돌려 스택을 정리한다.

 

프로그램 레지스터는 모든 함수가 공유하는 자원으로, 한 함수가 다른 함수를 호출할 때, 호출된 함수(callee)는 호출하는 함수(caller)가 나중에 사용할 레지스터 값을 보존해야 한다. 이를 위해 callee는 callee-saved 레지스터(%rbx, %rbp, %r12-%r15)의 원래 값을 스택에 저장하고, 함수가 끝나기 전에 이 값을 복원해야 한다. 이 방식으로 레지스터 값을 안전하게 보존하며, 함수 간의 데이터 손실 없이 호출과 복귀가 이루어진다.

saved registers 자리에 값들이 저장됨으로써 P는 위에 정리한 Callee-saved-register들 또한 자유롭게 쓸 수 있다. Caller-saved-register은 결국에 %rsp를 뺀 나머지 모든 레지스터에 해당된다.

 

Saved registers 자리에 값들이 저장됨으로써 P는 위에 정리한 Callee-saved-register들 또한 자유롭게 쓸 수 있다. Caller-saved-register은 결국에 %rsp를 뺀 나머지 모든 레지스터에 해당된다.

재귀 프로시저도 마찬가지로 위에서 Caller - Callee의 관계처럼 진행된다.

함수 실행 시작에 %rbx 레지스터를 스택에 저장하는 것으로 시작한다. 실행이 끝나면, %rbx를 복원한다. 여기서 %rbx에 처음에 무엇이 저장되어 있었는지는 명시되지 않는다.

 

x86-64 프로시저 규칙은 함수가 재귀적으로 호출될 수 있도록 지원한다. 이는 각 함수가 스택에서 자신만의 공간을 가지고 있기 때문에 가능하다. 이로 인해 서로 다른 호출에서의 지역 변수들이 상호 간섭하지 않는다.

 

함수가 호출되었다가 반환될 때 스택에서 이루어지는 할당과 해제 작업은 로컬 저장소 관리를 위한 적절한 방법을 제공한다. 재귀 호출은 일반 함수 호출과 동일하게 처리되며, 스택의 할당 및 해제 규칙은 함수 호출의 순서와 일치한다.

 

 

728x90

'CS > 어셈블리어' 카테고리의 다른 글

어셈블리어와 제어문  (1) 2024.03.21
728x90

1의 보수: 모든 비트를 반전시킨 값으로, 각 비트에 대해 0은 1로, 1은 0으로 변경한다. 1의 보수는 +0과 -0의 두 가지 표현을 갖는다는 단점이 있다.

 

2의 보수: 1의 보수에 1을 더한 값으로, 가장 널리 사용되는 음수 표현 방식이다. 2의 보수는 오버플로를 자연스럽게 처리할 수 있으며, +0과 -0을 단 하나의 0으로 표현한다는 장점이 있다. 이는 산술 연산을 더 간단하게 만들어 준다.

unsign연산 / sign연산

C/C++ 프로그래밍에서 unsigned는 부호가 없는 즉 음수를 표현하지 않겠다 라는 의미이고  signed는 부호가 있는, 즉 음수로 표현이 가능하다라는 의미 이다.

CF(Carry Flag) : 가장 최근의 연산에서 가장 중요한 비트로부터 올림이 발생한 것을 표시한다.

 

-> 비부호형 연산(unsigned)에서 오버플로우를 검출

1111 + 0001 = 0000

 

ZF(Zero Flag) : 가장 최근 연산의 결과가 0인것을 표시

0111 + 0001 = 1000

1000 - 0001 = 0001

 

SF(Sign Flag) : 가장 최근 연산이 음수를 생성한 것을 표시

0000 - 0001 = 0000 

 

OF(Overflow Flag) : 가장 최근 연산이 2의 보수 오버플로우를 발생시킨 것을 표시

-> 부호형 연산(signed)에서 오버플로우를 검출

0100 + 0100 = 1000

인스트럭션은 컴퓨터에게 일을 시키는 단위로서 컴퓨터가 알아들을 수 있는 기계어로 이루어져 있는 명령어이다.

 

기계코드는 명령이 순차적으로 실행되는데 조건부에 걸쳐 jump를 할 수 있다.

 

-점프 인스트럭션 : 시험 결과에 따라서 프로그램의 다른 일부분으로 제어를 넘긴다.

 

-명령부(OP) : 실제 컴퓨터가 수행해야 할 동작을 나타냄, 주로 산술 및 논리연산, 데이터 이동, 분기, 입출력, 그 외의 제어 명령 ex) 위 사진에서의 mov

 

-처리부(operand) : 동작의 대상이 되는 데이터를 지정, 피연산자를 직접 나타내거나 피연산자가 기억된 레지스터 또는 기억장치의 주소이다. ex) 위 사진에서의 eax

mov eax, ecx 라는 명령어 한 줄 중에 eax가 1오퍼랜드, ecx가 2오퍼랜드 mov가 op이다.

mov(move) : 좌변에 우변의 값을 복사 (우변에 연산자가 올 수 없음)

mov eax, ecx+10

>

add ecx, 10

mov eax, ecx

하지만 주소를 나타내는 mov eax,[ebo+4]같은 연산은 가능하다. 예를 들어 ebp 레지스터가 100번지 주소를 저장하고 있고 104번지에 20이라는 값이 있다면 eax엔 20이라는 값이 저장된다.

lea(load effective address) : 좌변에 우변의 주소값을 저장 (좌변에 레지스터만 올 수 있음)

leaq, movq // 둘이 연산식이 같음

임의의 레지스터 안에 있는 값 + 임의의 레지스터 안에 잇는 주솟값 * index에 곱해줄 2,4,8중 하나의 정수 + 8, 16, 32 bit 중 하나의 값

둘 다 메모리 참조 없이 주소를 계산할 수 있는 명령어지만, movq는 계산 후 메모리에 접근하여 메모리에 저장된 값을 가져온다는 의미, 반면에 leaq는 계산한 값을 취하기만 할 뿐 메모리에는 접근하지 않음

다른 레지스터들은 변경시키지 않으면서 조건 코드만 변경해 주는 두개의 인스트럭션 : CMP, TEST

CMP(compare) 명령어는 두 가지 피연산자의 차이에 따라 조건 코드를 설정한다. (두 피연산자를 비교)

두 피연산자의 값이 값이 같다면 결과는 0이 되고 ZF가 1으로 세트된다. 값이 다르면 ZF가 0으로 세트된다.

 

SUB 명령어와 같은 방식으로 작동하지만, 그들의 목적지를 업데이트하지 않고 조건 코드를 설정하는 것은 다르다. (SUB과 비슷하지만 값을 바꾸진 않음)

 

TEST 명령어는 AND 명령어와 같은 방식으로 작동하지만, 그들의 목적지를 변경하지 않고 조건 코드를 설정하는 것은 다르다.

조건 코드를 직접적으로 읽는 거 보다, 조건 코드를 사용하는 데 세 가지 흔한 방법이 있다.

1. 조건 코드의 어떤 조합에 의존하여 단일 바이트를 0또는 1로 설정한다.

2. 다른 프로그램으로 조건적으로 jump할 수 있다.

3. 데이터를 조건적으로 전송할 수 있다.

SET 인스트럭션은 목적지로 하나의 '단일바이트' 레지스터나, '단일바이트'메모리주소를 사용한다. 이 바이트를 0이나 1로 기록할 것이기 때문에 굳이 큰 바이트가 필요할까 싶다.

각 명령어들은 조건 코드의 조합을 기반으로 단일 바이트를 0 또는 1로 지정한다.

어떤 명령어는 "synonyms"를 가지고 있으며, 이는 같은 기계 명령어의 대안 이름이다.

L 동의어 (setl set less가 아닌 set long으로 정하는건 컴퓨터가 알아서 랜덤으로 판단)

jump 명령어는 프로그램에 완전히 새로운 위치로 바꾸어 실행한다.

jump 목적지는 label(라벨)로 어셈블리 코드 내를 가리킨다.

jump .L1 명령어는 밑에 movq 명령어를 skip하고popq 명령어로 재개한다. 오브젝트-코드 파일을 생성하여, 어셈블러는 모든 라벨된(labeled) 명령어의 주소를 결정하고 jump 타겟을 jump 명령어의 한 부분으로 인코딩한다.

jmp *%rax는 레지스터 내의 값인 %rax를 jump 타겟으로 사용하고, jmp *(%rax)는 메모리로부터 jump 타겟을 읽는다. 어떻게 jump 명령어의 타겟이 인코딩되는지를 이해하는 것은 링킹(linking)에서 중요한 부분이다. jump 타겟은 상징적 라벨을 사용해서 작성된다.

간접 점프의 예:

jmp *%rax -> 레지스터 rax의 값을 점프 목적지로 사용한다.

jmp *(%rax) -> 레지트터 rax에 저장된 값을 주소로 하는 메모리값을 점프목적지로 읽는다.

어셈블러와 링커는 jump 타겟의 적절한 인코딩을 생성한다.

많은 인코딩이 있지만, 가장 흔하게 사용하는 것은 PC relative(PC 상대적 방식)이다. 점프목적지는 심벌레이블을 사용해서 작성한다. 어셈블러와 링커는 점프 목적지를 적절히 인코딩한다. 대상 인스트럭션과 점프 인스트럭션 바로 다음에 나오는 인스트럭션 주소와의 차이를 인코딩한다.

타겟 명령어의 주소와 jump을 따라가는 명령어의 주소 간의 차이를 인코딩한다. 두 번째 인코딩 방법은 "절대적인" 주소를 주는 것으로 타겟을 바로 구체화하기 위해4 바이트를 사용한다. PC-relative 어드레싱을 수행할 때 Program Counter의 값은 jump 자체가 아닌 jump을 따르는 명령어의 주소이다.

링킹 이후에 프로그램에서 명령어는 다른 주소로 재배치 받지만 jump 타겟은 바뀌지 않는다. C에서 조건문과 명령어를 기계 코드로 번역하는 가장 일반적인 방법은 조건적인 jump와 무조건적인 jump의 조합을 사용하는 것이다.(PC상대 방식으로 점프 목적지를 인코딩하면, 인스트럭션들이 간결하게 인코딩되고 목적코드는 수정없이 메모리상의 다른 위치로 이동될수 있다고 한다. // 역추적도 가능)

조건 연산을 구현하기 위한 가장 관습적인 방법은 제어의 조건적 전송을 통한 구현이다. 이 방법은 매우 단순하고 일반적이지만, 모던 프로세서에서는 비효율적이다. 대안적인 전략은 데이터의 조건적 전송을 통해 구현하는 것이다. 이 접근은 조건 연산의 결과와 조건에 속하는 것을 하나 선택하여 계산한다. 이 전략은 매우 제한적인 경우지만, 간단한 조건적 이동 명령어에 의해 구현된다.

조건적 데이터 전송에 따른 코드가 조건적 제어 전송보다 성능이 좋은 지 이해하려면, 모던 프로세서 연산을 이해해야 한다. 프로세서는 파이프라이닝(pipelining)을 통해 높은 성능을 얻는다.

최신 프로세서들은 파이프라인을 통해 높은 성능을 얻는데, 프로세서가 조건부 점프를 만나게 되면, 프로세서는 분기 조건에 대한 계산이 완료될때까지 어느쪽으로 분기될지 결정을 할 수 없다. 즉 조건계산이 될때까지 기다려야한다. 만약에 조건식 계산을 빠르게 예측가능하다면, 조건제어가 더 빠를 수 있겠지만, 그걸 예측하는거는 힘들다고 한다. 조건부 이동명령을 이용하여 컴파일을 하면 적당한 사이클의 시간이 걸리기때문에 사용하기에 좋다. 이 방식은 프로세서가 파이프라인을 꽉 찬 상태로 유지하는것을 더 쉽게 해준다.

C는 do-while, while, for문과 같은 다양한 반복문을 제공한다. 기계 코드에는 대응하는 명령어가 없지만, test와 jump의 조합으로 반복문을 구현한다.

1. Do-While Loops

2. While Loops

3. For Loops

조건부 테스트와 점프를 적절히 사용해서 구현해야한다. 이 부분에서 do-while, while, for에 대한 각각의 어셈블리코드는 책에 예제 코드가 잘 나와있다. 결국에 이 모든걸 jump로 구현할 수 있다.

Switch문은 정수 인덱스 값에 따라 다중분기 기능을 제공한다. 테스트해야하는 경우의 수가 많은 경우에 특히 유용하다. 점프 테이블이라는 자료구조를 사용해서 효율적인 구현이 가능하다

점프테이블은 switch문의 인덱스가 i일때, 프로그램이 실행해야 하는 동작을 구현하는 코드 블록의 주소가 되는 배열을 말한다. if-else문을 사용하는 것보다 switch 문을 사용하는것이, case의 수에 관계없이 일정하다는 것이 장점이다.

a에서 주목해야할것은 case102와 104106이다.

b에서는 들어오는 n값에서 100을 뺀것을 인덱스로 취하는 점프테이블을 사용한다.

점프테이블에서, 비어있는 인덱스는 loc_def로 가게끔 해놓은 것이 인상깊다.

어셈블리 코드를 살펴보면 점프테이블로 가는 간접점프가 나온다. %rsi에 인덱스를 저장하여 사용된다.

점프테이블 또한 어셈블리 코드로 번역된다.

 

자세하게는 알것 없고, 맨위에 써있는 L4가 앞서 점프테이블 간접점프에서 쓰였는데, 이는 이 레이블의 시작주소로 활용된 것이다. 이렇게 점프테이블을 이용하면 다중분기를 매우 효율적인 방식으로 구현할 수 있게된다.

 

728x90

'CS > 어셈블리어' 카테고리의 다른 글

프로시저란 무엇인가?  (0) 2024.03.21

+ Recent posts