C 언어

[Unreal Engine 8기] C언어 5일차

조현묵 2026. 2. 27. 22:36
  • [심화] 스택 프레임 (2)
더보기
  • 함수는 몇 회나 호출될 수 있을까요?

함수의 호출 횟수는 제한 할 수 없습니다.

심지어 게임의 중추가 되는 어떤 함수(Tick() 함수 같은)는 1초에 수천번씩 호출될 수도 있습니다.

롤과 같은 게임을 하면 FPS 값을 알 수 있습니다.

60 프레임이라면, 어떤 함수는 1초에 60번 호출되고 있는 것입니다.

그렇기에 함수의 호출 비용은 최대한 적어야 합니다.

  • 함수의 호출 비용을 최대한 줄이려면.

함수는 결국 코드 뭉치라고 했습니다.

함수 A에서 함수 B를 호출하면 함수 A 코드 뭉치에 함수 B 코드 뭉치를 쌓아 올렸다가,

종료되면 함수 B 코드 뭉치를 없애면 됩니다. 이걸 빠르고 호출 비용이 저렴하게 처리해야 합니다.

이를 1초에 수만번씩해도 빠르게 처리 해주는 자료구조가 스택입니다.

군필자라면 탄알집을 생각하시면 됩니다. 나중에 넣은 탄알이 먼저 나오는 자료구조입니다.

수건함 같은 느낌. 굳이 맨 밑 수건을 꺼내면 엄마한테 혼납니다.

가장 위에 있는 수건이 가장 먼저 나오는 구조입니다.

  • 스택 메모리(Stack Memory)

컴퓨터의 메모리 레이아웃은 크게 스택 메모리, 힙 메모리, 코드 섹션, 데이터 섹션으로 나뉩니다.

그 중에서 스택 메모리는 함수 호출에 할당될 메모리입니다. 결국 지역변수들이 저장되는 공간입니다.

스택 메모리는 스택 포인터와 베이스 포인터, 스택 프레임들로 구성되어 있습니다.

  • 스택 프레임(Stack Frame)

함수가 호출되면 해당 함수가 사용할 메모리 크기만큼 공간이 확보됩니다.

해당 함수를 위해 확보된 메모리 공간을 스택프레임이라고 합니다.

나중에 함수가 모두 수행된 뒤에 해당 스택프레임은 다시 반환됩니다.

  • 스택프레임의 동작

스택 포인터(Extended Stack Pointer, ESP)

현재 스택 프레임의 Top을 가르킵니다.

push 명령어나 pop 명령어의 피연산자.

베이스 포인터(Extended Base Pointer, EBP)

현재 스택 프레임의 시작 주소를 가르킵니다.

 

// Main.c

#include <stdio.h>

int add(int a, int b)
{
return a + b;
}

int main(void)
{
int res;

res = add(2, 5);

return 0;
}

# 보기 좋게 정리한 어셈블리 코드입니다. 정확하지 않을 수도 있습니다.
# 스택프레임이 어떻게 생성되고 지워지는지만 확인해보세요!

_main:                   # @main
    pushl %ebp           # esp -= 4하고 [esp] = ebp
    movl %esp, %ebp      # ebp = esp
    subl $16, %esp       # esp -= 16 (로컬 변수 공간 확보)
    ...
    call _add            # add() 함수 호출
    ...
    addl $16, %esp       # esp += 16 (스택 정리)
    popl %ebp            # ebp = [esp]하고 esp += 4
    ret

_add:                    # @add
    pushl %ebp           # esp -= 4, [esp] = ebp
    movl %esp, %ebp      # ebp = esp
    subl $36, %esp       # esp -= 36
    ...
    addl $36, %esp       # esp += 36
    popl %ebp            # ebp = [esp]하고 esp += 4
    ret

 

  • 스택프레임 개념은 아주 중요합니다.

프로그래밍을 계속 하게 된다면, 스택프레임 관련된 내용은 계속해서 나옵니다.

사람들이 어렵다고 생각하는 개념들 거의 대부분이 스택프레임과 관련있기도 합니다.

프로그래밍을 처음해본다면, 일단은 넘어가도 좋습니다.

  • 스코프 (2)
더보기
  • 스코프(Scope)

변수나 함수 이름을 사용할 수 있는 범위를 뜻합니다.

  • 스코프의 종류

1. 블럭 스코프(Block Scope)

2. 파일 스코프(File Scope)

  • 블럭 스코프(Block Scope)

중괄호 내부에 선언된 변수는 해당 중괄호 내부에서만 사용할 수 있습니다.

조건문, 반복문 같은 문(statement)에 사용되는 중괄호 범위도 블럭 스코프라고 합니다.

블럭 스코프 안에 또 다른 블럭 스코프가 들어갈 수도 있습니다.

바깥쪽 블럭 스코프에서 안쪽 블럭 스코프에 선언된 지역 변수에 접근 불가능합니다.

반대로 바깥쪽 블럭 스코프에 선언된 지역 변수를 안쪽 블럭 스코프에서는 접근 가능합니다.

  • 블럭 스코프
블럭 스코프
  • 변수 가리기(Variable Shadowing) 금지

블럭 스코프가 다르면, 같은 변수명을 가진 변수들을 선언할 수 있습니다.

그러나 이런 코드를 절대 작성하지 맙시다.

// Main.c

#include <stdio.h>

int main(void)
{
int MyScore = 87;

{
int MyScore = 95;    /* Bad. */

printf("MyScore: %d\n", MyScore);
}

printf("MyScore: %d\n", MyScore);

return 0;
}

  • 파일 스코프(File Scope)

어떤 블럭 스코프에도 속하지 않고, 파일에 작성된 경우입니다.

// Main.c

#include <stdio.h>

int FileScopeVariable = 0; // 파일 스코프에 선언된 변수. -> 전역변수

int ReturnZero(void)       // 파일 스코프에 선언 및 정의된 함수. -> 전역함수
{
return 0;
}

int main(void)
{

return 0;
}

  • 전방 선언 (2)
더보기
  • 전방 선언(Forward Declaration)

함수의 원형(머리부분)만 따서 파일 스코프 상단에 두고

함수의 정의는 파일 스코프의 하단에 위치 시키는 방법입니다.

  • 전방 선언
전방 선언
  • 전방선언을 하는 이유.

지금은 큰 이유가 없습니다. 이후에 나오는 분할 컴파일을 위해서입니다.

  • 변수의 종류 (2)
더보기
  • 변수의 종류

변수에는 지역 변수/전역 변수가 있습니다

여기에 static이나 const, extern 같은 키워드가 붙어서 조금씩 뉘앙스가 달라집니다.

  • 지역 변수(Local Variable)

블럭 스코프 내에 선언된 변수. 따라서 스택 메모리에 저장됩니다.

함수가 종료되면 스택 프레임이 반환되면서 더이상 접근 불가능합니다.

  • 지역 변수와 함수 마을

함수를 하나의 마을이라고 생각해봅시다.

main() 마을에서는 Add() 마을에서 선언된 지역 변수 A를 접근할 수 없습니다.

그리고 Add() 함수가 종료되면, 지역 변수 A도 사라진다고 이해합시다.

  • 지역변수
지역 변수
  • 정적 지역 변수

지역 변수 앞에 static 키워드가 붙으면 데이터 섹션에 저장됩니다.

즉, 함수 종료시 접근 불가한 스택메모리에 저장되는게 아닙니다.

함수가 종료되어도 값이 유지가 됩니다

이런 변수를 정적 지역 변수라고 부릅니다.

  • 전역 변수(Global Variable)

파일 스코프에 선언된 변수. 데이터 섹션에 저장됩니다.

  • 전역 변수
전역 변수
  • 정적 지역 변수와 전역 변수
정적 지역 변수 와 전역 변수
  • 정적 전역 변수

만약 전역 변수 앞에 static 키워드가 붙는다면, 해당 변수는 해당 파일 내에서만 접근 가능합니다.

// Main.c

#include <stdio.h>

static int A;

int main(void)
{

return 0;
}

 

// MyMath.c

#include "Main.c"

void PrintA(void)
{
printf("%d", A); // 다른 파일에 정적 전역 변수로 선언된 A이므로, 접근 불가.

return;
}

  • const 변수

const 키워드가 붙은 변수. 초기화 이후에 값을 변경할 수 없습니다. 초기화가 강제됩니다.

  • const 키워드의 필요성

// Main.c

#include <stdio.h>

int main(void)
{
double PI = 3.141592;

// ... 200만줄의 유인우주선 개발 코드 ...

PI = 3.15; // 절대 하면 안됩니다. 그래서 const 키워드가 필요한 것입니다.

// ... 200만줄의 유인우주선 개발 코드 ...

return 0;
}

  • const 키워드
const 키워
  • 포인터 (2)
더보기
  • 포인터의 필요성

이전에 배열을 배우면서 5천만개 크기의 배열을 생각해봤습니다.

int 자료형의 크기는 4byte이므로, 5천만개 배열의 크기는 2억바이트입니다.

이걸 어딜가나 들고다니기엔 무리입니다.

배열은 연속적인 메모리에 저장된다고 했으니까 자료형과 시작 주소만 알면 될 것 같습니다.

시작 메모리 주소로 가서 자료형을 통해 얻은 한 칸의 크기로 모두 접근 가능하기 때문입니다.

  • “들고 다닌다”의 의미

main() 함수에서 대선투표결과가 저장된 배열이 있다고 해봅시다.

Count() 함수에서 개표해보고자 합니다.

즉, Count() 함수로 대선투표결과가 저장된 배열을 전달해야 합니다.

그럼 Count() 함수의 스택 프레임은 2억바이트를 할당해줘야 할까요? 들고다니기엔 무리입니다.

ex) 아파트 1채 10톤(2억 바이트) Vs. 아파트 1채의 소유문서 10g (시작 메모리 주소값. 8바이트)

실제 함수의 동작에서도 Count(int Votes[]) 함수를 호출하면

시작 메모리 주소 값이 인자값으로 전달되게 됩니다.

근데 지금까지 배운 개념 중에는 메모리 주소를 저장할 수 있는 자료형은 없었습니다.

메모리 주소를 저장할 수 있는 변수인 포인터에 대해 알아봅시다.

  • 포인터(Pointer)

메모리 주소를 저장하기 위한 변수.

 

자료형 변수명 = 값;

자료형* 변수명 = 메모리주소값; // 자료형쪽에 Asterisk를 붙힙시다.
    // 변수명쪽에 Asterisk를 붙히면 뒤에서 배울 역참조 연산자와 심히 헷갈립니다.

  • 포인터를 선언할 때 자료형은 왜 필요할까요?

해당 메모리 주소로 가서 얼마만큼의 크기로 읽어야 내 데이터인지 모르기 때문입니다.

자료형이 있어야 크기를 알 수 있습니다.

더 나아가서, 자료형 크기만큼 가면 다음 데이터를 얻을 수도 있습니다.

  • 주소 연산자와 역참조 연산자 (2)
더보기
  • 주소 연산자(address-of operator) &

피연산자의 메모리 주소를 반환하는 연산자.

기호 &(Ampersand)를 사용합니다.

  • 사실 scanf() 함수에서 사용 했었던 주소 연산자

“~에”라고 해석하자고 배웠었습니다.

사실은 변수 앞에 &를 붙혀서 그 변수의 메모리 주소를 얻은 것입니다.

해당 메모리 주소에 입력 받은 값을 저장할 수 있게된 것입니다.

  • 메모리 주소를 얻을 수 있는 두 가지 방법
  1. 주소 연산자
  2. 배열의 이름

int Array[1024]; 
// 여기서 Array는 메모리 주소를 저장한 변수. 특히 그 메모리 주소는 배열의 시작 메모리 주소.

  • 역참조 연산자(indirection operator) *

피연산자로 포인터를 받아서, 해당 메모리 주소에 저장된 값을 읽거나 값을 수정할 때 사용하는 연산자.

  • “메모리 그리기”

포인터 관련 예제를 풀 때는 “메모리 그리기”를 해봅시다.

사각형을 그린 다음, 변수들을 적고 각 변수에 메모리 주소를 0x100번지부터 적어줍니다.

  • 주소 연산자와 역참조 연산자 1
주소 연산자와 역참조 연산자1
  • 주소 연산자와 역참조 연산자 2
주소 연산자와 역참조 연산자 2
  • 주소 연산자와 역참조 연산자 3
주소 연산자와 역참조 연산자 3
  • 주소 연산자와 역참조 연산자 4
주소 연산자와 역참조 연산자 4
  • 포인터 Vs. 역참조 연산자 Vs. 곱셈 연산자

셋은 모두 *(Asterisk) 기호를 사용합니다.

포인터: 자료형 오른쪽에 붙습니다.

역참조 연산자: 피연산자가 한 개. 특히 피연산자로 메모리 주소를 받습니다.

곱셈 연산자: 피연산자가 두 개.

  • 주소 출력

메모리 주소를 출력할 때 %X로 출력하는 것은 올바르지 않습니다.

형식 지정자 %p로 메모리 주소를 출력하는 게 올바르나,

void* 자료형으로 형변환이 필요합니다.

 

주소 출력
  • 포인터의 단점

큰 데이터들의 시작 메모리 주소만으로 가볍게 공유할 수 있다는 점은 좋습니다.

다만, 공유 받은 사람이 착한 사람인지는 모릅니다.

해당 주소로 가서 들어있는 값을 마음대로 수정 할수도 있습니다.

아주 강력한 기능임과 동시에 그만큼 잘못쓰면 큰일 날 수도 있습니다.

  • 다양한 자료형과 포인터
다양한 자료형과 포인터
  • char 자료형과 포인터
char 자료형과 포인터
  • 뒤섞기 1
뒤섞기 1
  • 뒤섞기 2
뒤섞기 2
  • 참조에 의한 호출 Vs. 값에 의한 호출

원본값이 바뀌냐 Vs. 안 바뀌냐.

함수 A가 함수 B를 호출 할 때, 인자를 전달하면서 호출한다고 가정해봅시다.

함수 B가 종료될 때 인자의 원본값도 바뀐다면 참조에 의한 호출입니다.

인자의 원본값이 바뀔 수 없다면 값에 의한 호출이라고 부릅니다.

  • Swap() 함수 1
Swap() 함수 1
  • Swap() 함수 2
Swap() 함수 2
  • GetMinMax() 함수
GetMinMax() 함수
  • NULL과 void 포인터 (2)
더보기
  • NULL 포인터

#define NULL ((void*)0)

아무것도 가르키지 않는 포인터.

  • NULL을 활용한 포인터 유효성 검사
NULL을 활용한 포인터 유효성 검사
  • NULL 포인터의 쓰임새
  1. 포인터의 초기화
  2. 포인터가 더이상 사용중이지 않음을 알리고 싶을 때.
  3. 포인터가 유효한 메모리 주소를 저장하고 있는지 확인할 때.

Ptr = NULL;

if (NULL == Ptr) 

/* alert */ 
}

  • 자료형이 정해지지 않은 포인터

다시 한 번 포인터 선언 및 초기화 코드를 살펴봅시다.

자료형* 변수명 = 메모리주소값;

NULL 포인터는 메모리주소값이 아직 정해지지 않았을 때 사용 했습니다.

자료형을 지금 바로 정할 수 없을 때도 있습니다.

  • void 포인터

범용 포인터라고도 부릅니다.

void* 변수명 = 메모리 주소 값;

  • 어떤 자료형의 포인터라도 void*에 대입 가능합니다.

즉, 매개변수 자료형으로 void*를 사용하면, 어떤 자료형의 포인터라도 모두 받을 수 있는 함수입니다.

  • void 포인터의 주의점
  1. void*에 역참조 연산은 불가능. 해당 메모리 주소부터 몇 바이트만큼 읽어야 내 데이터인지 모르기 때문입니다.
  2. void*에 정수를 더하거나 빼는 연산은 불가능. 해당 메모리 주소부터 몇 바이트만큼 더하거나 빼야하는지 모르기 때문입니다.
  • Ex070302) void*와 역참조
void*와 역참조