ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 함수 호출 규약
    프로젝트/운영체제 만들기 2023. 8. 7. 02:13

    글의 참고

    - https://en.wikipedia.org/wiki/Calling_convention 

    - https://en.wikipedia.org/wiki/X86_calling_conventions

    - https://en.wikibooks.org/wiki/X86_Disassembly/Calling_Conventions

    - https://bugaevc.github.io/asmwall/?cdecl 

    - https://developer.arm.com/documentation/102374/0101/Procedure-Call-Standard


    글의 전제


    글의 내용

    : 함수 호출 규약이란 함수를 호출할 때, 파라미터를 어떻게 전달해야 하는지, 반환값을 어떻게 반환해야 하는지에 대한 스펙이다. 스펙이니깐 반드시 지켜야 제대로 동작을 보장한다. 함수 호출 규약에 기본적으로 쓰이는 2가지 용어가 있다.

     

    1" Caller
    2" Callee

     

    : Caller는 함수를 호출하는 입장이고, Callee는 호출를 받는 입장이다. 함수 호출 규약은 이 2개 객체에 대한 약속을 정해놓은 것이다. 함수 호출 규약에서 핵심적인 규칙은 딱 2가지다.

     

    1" 파라미터를 어떻게 전달할 것인가
    2" 리턴값을 어떻게 반환할 것인가

     

    : 예를 들어, `파라미터를 어떻게 전달할 것인가`에 대해서는 순서가 있다. 흔히, `left-to-right` 혹은 `left-to-right` 라고 해서 함수의 파라미터를 오른쪽 인자부터 전달할 것인가, 왼쪽인자부터 전달할 것인가의 여부가 규약에 정의되어 있다. 그리고 스택을 통해서 전달할 것인가, 혹은 레지스터를 통해서 전달할 것인가에 대해서도 규악이 정해져있다.

     

    : 반환값의 전달 또한 특정 레지스터를 통해 전달할 것이냐 스택을 통해 전달할 것이냐에 규약에 정의되어 있다.

     

    : 그런데 문제는 이 함수 호출 규약이 CPU 아키텍처, 플랫폼, 프로그래밍 언어, 운영 체제에 따라 다르다는 것이다. 그래서 OS를 개발할 때는 이 부분에 대해 명확하게 알고 있어야 한다. 하이 레벨 랭기지를 사용할 때는, 이 부분을 걱정하지 않아도 되지만 로우 레벨 랭기지인 어셈블리언어를 사용하면 반드시 알고 있어야 하는 내용이다.

     

    : 참고로 C 프로그램은 `cdecl`이라는 함수 호출 규약을 사용한다.

     

    - 32비트 x86 함수 호출 규약

     

    : 32비트 x86 아키텍처에서는 `cdecl` 이라는 함수 호출 규약을 사용한다. 32비트 x86 아키텍처에서는 레지스터의 개수가 부족해서 스택을 파라미터를 전달한다. 대신 리턴값은 레지스터를 통해 전달한다. 그러면, 레지스터 개수가 많으면, 즉, 여유가 되면 레지스터를 통해 함수의 파라미터를 전달하나? 맞다. 레지스터를 여유가 되면 레지스터를 통해 전달한다. 그 이유는 레지스터가 속도가 빠르기 때문이다. 

     

    : 아래와 같은 C 언어로 작성된 함수가 있다고 하자.

    int callee(int a, int b, int c);
    
    int caller(void)
    {
    	return callee(1, 2, 3) + 5;
    }

     

    : 위의 callee() 함수를 호출하려면, 32비트 x86의 콜러는 아래의 절차를 통해 callee() 함수를 호출한다. 

    push    3
    push    2
    push    1
    call    callee    ; call subroutine 'callee'
    add     esp, 12   ; remove call arguments from frame
    add     eax, 5    ; modify subroutine result
                      ; (eax is the return value of our callee,
                      ; so we don't have to move it into a local variable)

    : 위에서 볼 수 있다시피, 함수 호출 시 콜러에서 제일 먼저 준비하는 작업은 콜리에서 받을 파라미터 준비다. right-to-left 이기 때문에, push 3은 callee의 3번째 인자인 c 변수에 삽입된다. 

     

    : 마지막 `add esp, 12`는 굉장히 중요한 내용이다. 이 명령어는 스택 프레임을 제거하는 내용인데, 자세한 내용은 이 글을 참조하자.

     

    : 그리고 마지막으로 `add eax, 5` 내용은 아키텍처에 의존적이다. 즉, 32비트 x86에서는 callee에서 반환한 값이 eax 레지스터에 저장된다. 그래서 반환값에 5를 더한다는 내용이다.

     

    : cdecl 에서 콜리(callee)의 전형적인 구조는 아래와 같다.

    callee:
      push EBP            ; save old frame pointer
      mov EBP,ESP         ; get new frame pointer
      sub ESP,localsize   ; reserve stack space for locals
      .
      .                   ; perform calculations, leave result in EAX
      .
      mov ESP,EBP         ; free space for locals
      pop EBP             ; restore old frame pointer
      ret paramsize       ; free parameter space and return.

    : 레지스터에 대한 설명은 생략한다. `push bp`는 말 그대로 이전 스택 프레임에 대한 정보를 저장하는 것이다. 왜 그래야 할까? 저렇게 하지 않으면 이전 함수의 스택 작업 내용을 알 수가 없기 때문이다. 예를 들어, A라는 함수가 동작중에 B라는 함수가 호출되면 A의 스택을 그대로 둔 상태에서 B 함수의 새로운 스택 프레임이 형성된다. 이렇게 되면, ESP, EBP를 새로운 스택 프레임에 맞게 위치를 새롭게 조정해야 한다. 그런데, ESP, EBP는 시스템에 하나만 존재하는 레지스터이므로, 이전으로 스택 프레임으로 돌아갈 수 있는 방법을 제공해줘야 한다. `push bp`는 이전 함수의 스택 프레임의 베이스 주소를 현재 스택에 저장함으로써 이전 함수의 스택을 그대로 유지하면서 이전 스택 프레임으로 돌아갈 수 있게 해주는 방법이다.

     

     

    : `mov bp, sp` 명령은 현재 함수에 스택 프레임을 만드는 것이다. 대개 bp와 sp의 관계는 아래와 같다. 참고로, 모든 스택의 형태가 이렇지 않다. 이 형태는 cdecl 함수 호출 규약임을 전제로 한다. 다른 함수 호출 규약에서는 스택의 형태가 아래와 다를 수 있다.

    출처 - https://bugaevc.github.io/asmwall/?cdecl

    : 노란색 테두리는 새로운 함수가 호출되어 생성되 스택 프레임이고, 파란색 부분은 노란색 함수를 호출한 콜러의 스택 프레임이다. 그 외 영역은 다음과 같다.

    0" 파란색 영역 - 콜러의 스택 프레임
    1" 빨간색 영역 - 콜리에게 전달하는 파라미터들
    2" 노란색 영역 - 콜리의 스택 프레임을 의미한다.

    : 저 `return address`는 복귀 주소로 콜러가 실행하던 코드로 돌아가는 주소를 의미한다. 저 주소는 x86에서는 콜러가 `call` 명령어를 실행하면 자동으로 스택으로 삽입된다. 그리고 콜리에서 `ret` 명령어를 사용하면 자동으로 저 주소로 복귀한다. 그리고 `ret` 명령은 SP를 `return address`가 있던 곳으로 이동시킨다. 

     

    출처 - https://bugaevc.github.io/asmwall/?cdecl

    : 콜리에서 콜러가 전달한 파라미터에 접근하고 싶다면, 위의 그림에서 처럼 BP+8 BP+12, ... 처럼 접근할 수 있다. 여기서 한 SP의 동작 방식에 대해 알 수 있다.

     

    : 궁금한 점이 있다. SP는 아래의 2가지 동작에서 어떤 방식으로 동작을 할까?

    1" 주소는 내려간 뒤, 값을 삽입한다.
    2" 값을 삽입한 뒤, 주소를 내려가간다.

    : 답은 1번이다. 테스트를 해보고 싶다면 1번과 같이 SP가 동작한다면, 값을 넣고 곧 바로 SP를 출력하면 방금 넣은 값이 출력되어야 한다. 그리고 이 답은 pop 동작과 연계를 생각하면 쉽게 이해가 된다. pop은 데이터를 꺼낸 뒤 이동을 시키는게 자연스럽지, 이동을 하고 데이터를 꺼내는 것은 부자연스럽다. 왜냐면, 빈 메모리를 바라보고 있는거 자체가 문제를 발생시킬 여지가 많기 때문이다. 

     

     

    - x86 함수 호출 규약

    : x86 함수 호출 규약은 `cdecl`을 따른다. 대부분의 함수 호출 규약에는 `Caller`와 `Callee`에 대한 규칙이 명시되어 있다. 

     

     

    - Caller-saved 레지스터

    : 아래의 내용은 Caller는 반드시 함수 호출전에 `EAX, ECX, EBX`의 내용을 자신의 스택에 저장해서, callee가 caller가 사용했던 값을 망가트리는 것을 막아야 한다는 것이다. 즉, 서브루틴에서 복귀한 후에 caller는 이전에 사용했던 값이 변하지 않았다는 것을 보장받을 수 있어야 한다. 앞에 내용은 32비트 x86 기반의 컨텍스트 스위칭 구현 시 유용하게 쓰인다. 그러나, `Caller`가 이 내용을 무조건 지켜야 한다는 것은 아니다. `Caller`는 함추 호출 후에 위의 레지스터들의 값을 보존할 필요가 있을 때만,이 규칙을 따르면 된다.

    The caller should adhere to the following rules when invoking a subroutine:

    1. Before calling a subroutine, the caller should save the contents of certain registers that are designated caller-saved. The caller-saved registers are EAX, ECX, EDX. If you want the contents of these registers to be preserved across the subroutine call, push them onto the stack.

    2. To pass parameters to the subroutine, push them onto the stack before the call. The parameters should be pushed in inverted order (i.e. last parameter first) – since the stack grows down, the first parameter will be stored at the lowest address (this inversion of parameters was historically used to allow functions to be passed a variable number of parameters).
    ...
    ...

     

     

    - Callee-saved 레지스터

    : `callee-saved` 레지스터도 `caller-saved` 레지스터와 크게 다르지 않다. 단지, 레지스터를 스택에 저장하는 주체가 caller가 아닌, callee가 된다.

    the values of any registers that are designated callee-saved that will be used by the function must be saved. To save registers, push them onto the stack. The callee-saved registers are EBX, EDI and ESI (ESP and EBP will also be preserved by the call convention, but need not be pushed on the stack during this step).

     

     

    - 지역 변수

    " x86 어셈블리언어에서 지역변수는 어떻게 만들까?

    void MyFunction()
    {
      int a, b, c;
      ...
      ...
    
    _MyFunction:
      push ebp    
      mov ebp, esp
      sub esp, 12

    : `sub esp, 12`를 사용해서 스택에 공간을 먼저 할당한다. 즉, 3개의 지역 변수를 할당할 공간을 만든다. 그리고 실제 지역 변수에 접근하기 위해서는 ebp를 사용한다.

     

    a = 10;
    b = 5;
    c = 2;
    
    ...
    ...
    
    mov [ebp -  4], 10  ; 지역 변수 a
    mov [ebp -  8], 5   ; 지역 변수 b
    mov [ebp - 12], 2   ; 지역 변수 c

    : 그리고 x86의 함수 종료 프로세스는 다음과 같다.

     

    void MyFunction3(int x, int y, int z)
    {
      int a, b, c;
      ...
      return;
    }
    
    ...
    ...
    
    _MyFunction3:
      push ebp
      mov ebp, esp
      sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
      
      ... ...
      To-do
      ... ...
      
      mov esp, ebp
      pop ebp
      ret 12 ; sizeof(x) + sizeof(y) + sizeof(z)

    : 마지막에 `ret 12`는 파라미터로 전달된 인자들의 크기를 의미한다. 저렇게 마지막에 `ret 12`를 함으로써, 자동으로 인자로 전달된 바이트만큼 POP이 된다. 이렇게 함으로써, 콜러측에서 `add esp, 12` 명령어를 생략할 수 가 있다.

     

    - 64비트 x86 함수 호출 규약

    : 위에서도 언급했지만, 함수 호출 규약은 아키텍처, 프로그래밍 언어, 운영 체제마다 다르기 때문에 본인이 사용하는 플랫폼에 대한 선행 지식을 먼저 알고 있어야 한다. x86_64는 기본적으로 마이크로소프트에서 제시한 `fastcall` 방식을 사용한다.

     

    - x86_64 가변 인자

    : x86_64에서 가변인자의 전달은 상당히 복잡해졌다. 기존 32비트 x86에서는 함수 인자전달을 스택만을 이용했지만, x86_64 부터는 레지스터와 스택을 모두 사용한다.

     

     

     

    - arm64 [ 참고1 ]

    : 아래는 `arm64`의 함수 호출 규약(PCS)을 보여준다. `main` 함수에서 `foo` 함수를 호출하는 모습을 보여주고 있다. arm64는 기본적으로 함수에 인자를 전달할 때, `x0 ~ x7` 레지스터를 통해서 전달한다. 즉, 함수 인자 8개 까지는 레지스터로 커버한다는 것이다. 그 이후에 인자들은 스택을 통해 전달한다.

     

    : arm64 컴파일러는 `foo(b, c)`를 보면, `w0 = b`, `w1 = c` 로 `foo`에 전달한다(`foo` 함수의 인자들이 모두 32비트 이므로, `x0 & x1`이 아닌, `w0 & w1` 사용). 


    https://developer.arm.com/documentation/102374/0101/Procedure-Call-Standard

     

    '프로젝트 > 운영체제 만들기' 카테고리의 다른 글

    C 상식  (2) 2023.08.08
    PIC  (0) 2023.08.07
    코드 점프  (0) 2023.08.07
    BIOS  (0) 2023.08.07
    [운영체제 만들기] ATA / IDE  (0) 2023.08.07
Designed by Tistory.