[포인터] 함수포인터 동작 원리

Heechul Yoon·2022년 2월 21일
1

함수포인터의 작동원리를 이해하기 위해서는 컴파일의 과정을 먼저 이해해야 한다.

컴파일 과정

clang 컴파일러는 4가지 단계를 거쳐 소스코드를 실행파일로 만든다. "컴파일"은 넓은 의미에서 전 과정을 포괄하는 말이다. 하지만 좁은 의미의 컴파일은 전처리 다음단계인 translation unit을 assembly code로 바꾸는 과정이다.

전처리 단계

소스코드를 input으로 받아서 전처리기 지시문(include, define 등)을 처리한다.
include<파일명.h>(혹은 "파일명.h")에서 파일명.h에 해당하는 헤더파일안에 있는 소스코드를 전부 복사하고 헤더파일의 선언부분이 복붙된 파일이 만들어진다. 이렇게 나온 결과를 translation unit이라고 한다. .c 파일을 직접 include해서 사용도 가능하다.

컴파일 단계

translation unit을 input으로 받아서 object code와 1대1 대응되는 assembly code를 만들어낸다.
컴파일러는 코드를 하나하나 어셈블리어로 번역(?)한다. 함수의 경우 함수의 시작부분에 변수의 선언들이 모여있기 때문에 스택프래임의 크기를 바로 알수 있을거로 추정된다. 컴파일러가 코드를 읽다가 함수의 호출을 만나면 매개변수를 복사하는 어셈블리어를 만들것이다. 그리고 반환값이 들어온 eax레지스터를 변수에 대입해주는 연산을 할것같다(대입연산을 한다면?). 마지막으로 호출하는 함수가 시작되는 코드로 jump문을 만들텐데 여기서 호출함수의 시작주소를 넣어주는게 아닌 호출함수의 이름을 넣어준다.(호출함수의 이름과 함수의 시작주소의 매핑은 링크단계에서 이루어진다)
이 단계에서 함수와 변수의 선언들을 전부 평가되어 스택프래임의 크기가 정해진다.(sizeof와 같은 연산자를 통해서 컴파일 단계에서 배열의 사이즈를 구할 수 있는 이유)
어셈블리 코드에서는 함수의 구현과 함수의 호출부분이 연결이 안된 상태이다.

int sub(int val1, int val2); /* 1. 함수 전방 선언 */

int main(void)
{
	result = sub(val1, val2)
}

int sub(int val1, int val2) /* 2. 함수의 구현체 */
{
	return val1 + val2;
}

위와같은 소스코드에서 어셈블리코드에서 함수의 호출부분을 보면

00E231A4 call sub (0E21440h)

...

0E21440 push ebp
0E21441 mov  esp ebp

...

이런식으로 sub라는 함수가 있는 주소로 jump를 한다. 어셈블리어 코드에서 jump된 주소로 가면 함수 전방선언이 된 함수의 선언부분이 있는 코드로 가게 된다.
즉, 컴파일 단계에서 컴파일러는 함수의 구현을 신경쓰지 않는다. 따라서 실제로 함수의 선언만 있고 구현은 없어도 어셈블리 코드까지 뽑아낼 수 있다.
궁금하다면 clang컴파일러에서 컴파일 단계 중 어셈블리어 코드까지 만들어서 보여주는 clang -std=c89 -W -Wall -pedantic-errors -S *.c 명령어를 통해서 직접 어셈블리어 코드를 뽑아보면 된다.(여기서 -S 옵션이 컴파일단계까지 실행하겠다는 옵션이다)
위의 코드를 함수의 구현부분을 지워버리고 함수의 전방선언만 남겨두고 어셈블리 코드를 뽑아보면 컴파일 오류가 발생하지 않음을 알수 있다.

어셈블 단계

assembly code를 input으로 받아 기계가 이해할 수 있는 기계어인 object code를 만들어낸다. assembly code 파일 하나당 object code파일 하나가 나오며 이 단계에서도 여전히 함수의 구현과 함수의 정의가 연결된 상태가 아니다.

링크 단계

링커(linker)는 object code들을 input으로 받아서 하나의 실행파일(.exe, .out)을 만든다. 링커는 object code를 하나하나 탐색하면서 함수의 구현의 위치를 기억해두었다가 함수를 호출하는 코드를 만나면 기억된 함수의 구현의 시작주소를 jump할 주소로 넣어준다.
여기서 링커가 함수의 호출부분은 찾았는데 함수의 구현부분을 찾지 못했다면 링커오류를 발생시킨다

Undefined symbols for architecture x86_64:
"_study", referenced from:
      _main in main-1c3762.o
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

함수포인터

컴파일 과정에서 알수있듯이 함수의 호출은 결국 메모리상에서 함수가 구현되어 있는 주소로 jump해서 한줄한줄 실행하는것을 의미한다.(컴파일 단계에서는 링커가 함수의 구현을 찾을수 있게 심볼을 넣어주고, 링킹단계에서는 컴파일 단계에서 넣어준 함수의 심볼에 맞는 함수구현을 찾아서 해당 구현의 주소로 jump하는 명령어가 들어간다)

컴파일러가 위와 같이 동작하는거면 특정 함수의 주소를 매개변수로 넘겨받거나, 변수에 대입해서 call하는것도 가능하지 않을까?

선언

int sub(int val1, int val2); /* 1. 함수 전방 선언 */

int main(void)
{
	int val1 = 1;
    int val2 = 2;
    int result;
    int (*calculate) (int val1, int val2); /* 2. 함수 포인터 변수 */
    
    calculate = sub; /* 3 */
	result = calculate(val1, val2);
}

int sub(int val1, int val2) /* 4. 함수의 구현체 */
{
	return val1 + val2;
}

위와같이 함수 포인터 변수는 해당 함수의 반환형과 매개변수 타입이 같이 정의되어있어야 한다. 그리고 calculate 변수에 sub함수의 선언 주소를 넣어준다. 이렇게 해주면 3에서처럼 calculate를 호출할 때, 컴파일중 컴파일단계에서 sub함수의 선언주소로 Jump하는 코드를 어셈블리어로 뽑아줄것이고, 링크단계에서 링커가 sub함수의 구현체로 링크를 걸어줄것이다.

함수 포인터와 시그니처

함수포인터는 일반적인 변수처럼 선언할 수 없다. 이유는 함수의 시그니처가 정해져있고 그 시그니처를 컴파일러가 알아야하기 때문이다.

/* 함수의 전방선언 */
int calculate(int (*func)(int val1, int val2), int v1, int v2);
int sub(int val1, int val2);

int main(void)
{
    printf("%d\n", calculate(sub, 4, 1));

    return 0;
}

int calculate(int (*func)(int, int), int v1, int v2)
{
    return func(v1, v2);
}

int sub(int val1, int val2)
{
    return val1 - val2;
}

여기서 calculate함수의 함수포인터인 func의 호출부분을 잘 봐야된다.
sub함수를 호출하면서 매개변수를 넘겨줄때 val1과 val2의 원본 값을 넘겨주는게 아니라 sub함수의 스택프레임이 생성되기직전에 val1과 val2를 복사해주고, sub함수에서는 복사본을 사용하게 된다. 그렇다면 만약에 calculate함수 안에서 함수포인터의 반환형과 매개변수형을 모른다면 어떻게 될까?

int calculate(function* func, int v1, int v2)
{
    return func(v1, v2);
}

그냥 예시로 작성해본 의사코드인데, func함수포인터는 반환형과 매개변수 형이 없다. 여기서 컴파일러가 func를 어셈블리어로 만들면서 func함수(이 함수가 호출되는 시점에서는 sub함수의 선언을 매개변수로 받아온다는 가정)에 매개변수를 넘겨줘야하는데, 컴파일러는 func함수의 매개변수형이 어떻게 되는지 모르기 때문에 스택프레임을 만들기전에 스택프레임 안에서 사용할 매개변수를 미리 복사해둘수 없게 된다.
그리고 calculate 함수의 반환형인 int가 func함수의 반황형과 동일한지 알수없기 때문에 해당 코드를 컴파일할 수 없을것이다.

이러한 이유로 함수포인터는 반환형과 매개변수형이 필요하다

OOP의 탄생(?)

구조체와 함수포인터를 배우면서 OOP가 기술적으로 가능해지게 된 배경을 생각해보았다.
인간은 사물을 소속지어 세상을 이해하려는 특징이 있다. 예를들어 마우스 오른쪽버튼과 마우스 왼쪽버튼을 따로생각하지 않고 "마우스"안에 속한 버튼이라고 생각한다.
C89를 사용하던 과거의 프로그래머도 인간인이상 100퍼센트 기계적으로 프로그래밍을 하기보다는 인간이 이해하기 쉽게 프로그래밍을 하려는 노력이 있었다. 그 결과가 구조체이다.

어셈블리코드까지만 와도 구조체는없다. 구조체는 컴파일단계까지 프로그래머가 인간답게(?) 사용하라고 만들어진 언어적 장치이다.

구조체와 함수포인터를 공부하고 사용해보면서 이 두개를 합치면 멤버변수와 메서드를 가지는 클래스가 될거라고 생각했다. 그래서 간단한 구조체를 클래스처럼 만들어보았다.

void study(char* name, char* location); /* 1. 전방선언 */

typedef struct {
    int number;
    char name[10];
    void (*study)(char* name, char* location);
} student_t;

int main(void)
{
    student_t student;
    student.study = study /* 2. 함수의 선언을 구조체의 멤버변수로 대입해준다 */;

    student.study("heechul", "library");
}

void study(char* name, char* location)
{
    printf("my name is %s, and I am studying at %s\n", name, location);

}

구조체로 만들어본 클래스는 일반적인 oop언어에 있는 클래스와 다른점이았다.

  • 첫번째로 생성자가 없다. student 개체가 선언됨과 동시에 유효하지 않기 때문에 student.멤버변수 = 값 와 같은 형태로 대입연산을 해주어야 한다.
  • 두번째로는 함수의 구현을 구조체에 소속되게 할 수 없다. 보통 oop언어에서 클래스안에 있는 함수의 구현체는 해당 클래스에 소속되어 "메서드"의 형태로 존재한다. 하지만 c에서 주먹구구식으로 만든 클래스는 메서드가 함수포인터로 존재하기 때문에 멤버변수와 마찬가지로 대입연산의 과정을 거쳐야 유효해진다.
  • 마지막으로 구조체는 힙메모리가 아닌 스택메모리에서 선언되어 사용된다. 따라서 os에게 힙메모리 공간좀 달라고 부탁할 필요가 없이 구조체의 선언에 의해 컴파일 단계에서 구조체 크기만큼 스택프레임 크기를 늘리기 때문에 성능상으로도 더 빠를것으로 예상된다
profile
Quit talking, Begin doing

0개의 댓글