형한정어란, 변수에 적용하는 문법으로 컴파일러 최적화에 직접적인 영향을 준다.

📌 핵심 예약어

  • const ( 상수화 )
  • volatile ( 최적화 대상에서 제외 )

📌 심볼릭 상수

  • 특정 상수에 이름을 부여해 그 의미가 명확하도록 표현하는 것을 말한다.
  • 형한정어 const로 구현한다.
  • #define 전처리기로 구현한다.  ( 컴파일 전, 전처리 단계에서 상수화 처리 )

 

📌 컴파일러 최적화

컴파일러가 컴파일 과정에서 코드 변환 및 재배치를 수행하는 것을 말한다.

 

🔹 컴파일러 최적화의 목적

  • 실행 속도를 개선
  • 코드 크기를 감소
  • 메모리 사용을 최적화

 

컴파일러 최적화는 일반적으로 프로그램을 빌드( 디버그 모드 )하면 작동하지 않는다.

릴리즈 모드로 빌드를 하게 되면 이때 최적화를 실시한다.

 

불필요한 코드를 제거하는 최적화 방식 적용시 특정 변수에 대한 의존관계를 분석해 최적화가 가능하다.

포인터는 최적화에 방해 요소가 될수 있다. 주소만 있고, 해당 포인터가 가리키고 있는 대상의 크기를 알 수 없기 때문

 

컴파일러 최적화를 돕는 코드가 좋은 코드라고 생각할 수 있다.

개발자 입장에서는 const를 적절한 곳에 붙여 최적화를 도와주면 된다.

 

🔹 예시

int a = 0;

for(int i=0; i< 10; i++)
{
    a = 5;
}

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

 

위와 같은 코드가 있을때, 릴리즈 모드로 빌드하면 어떤일이 일어날까?

 

컴파일러는 for문을 여러번 돌아도 a에 5만 넣고 값이 변하지 않기 때문에

for문 자체를 삭제하고 a에 5를 넣고 끝낸다.

 

컴파일러가 최적화를 하지 못하게 하려면 어떻게 해야할까?

그때 필요한 형한정어가 volatile이다.

 

volatile int a = 0; // volatile를 붙여서 최적화에서 제외시킨다.

for(int i=0; i< 10; i++)
{
    a = 5;
}

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

 

위와 같이 volatile을 붙이고 빌드하면 다음과 같이 코드가 생성된다.

 

 

어셈블리어를 살펴보면 for문도 반복하는 것을 확인할 수 있다.

함수 호출 규약은 함수호출로 증가한 스택 메모리 사용 및 관리에 관련된 규칙을 말한다.

 

📌 종류

⭐ cdecl ( C Declaration )

  • 매개변수를 오른쪽에서 왼쪽 순서로 스택에 저장한다.
  • 호출자( Caller )가 스택을 정리한다.
  • C 언어의 기본 규약이다.

⭐ stdcall ( Standard call )

  • 매개변수를 오른쪽에서 왼쪽 순서로 스택에 저장한다.
  • 호출된 함수( Callee )가 스택을 정리한다.

⭐ fastcall ( Fast Call )

  • 처음 두 개의 인자는 레지스터에 저장하고, 나머지는 스택에 저장한다.
  • 호출된 함수( Callee )가 스택을 정리한다.
스택 프레임은 함수 호출과 반환 과정에서 사용되는 데이터 구조로, 주로 함수 호출 시 함수의 지역 변수,
반환 주소, 매개변수 등을 저장하는 데 사용된다. 
스택 프레임은 프로그램이 실행될 때마다 생성되고, 함수가 반환될 때마다 소멸된다.

 

📌 스택 프레임 지정원리 #1

  • 스택 메모리 주소는 0번지를 향해 증가한다.
  • 지역변수는 선언한 순서대로 스택에 Push 한다.
  • 매개변수는 오른쪽부터 스택에 Push 한다.
  • 스코프 단위로 끊어서 표시한다. ( 함수는 별도 단락으로 표시한다. )

📌 스택 프레임 지정 ( 지역변수 )

예제를 통해 살펴보자

#include<stdio.h>

int main(void)
{ // 1번째 스코프
    int a = 3; // 1번째 a
    int b = 4;
    int aData[5] = { 0x10, 0x20, 0x30, 0x40, 0x50 };
    
    printf("a: %d\n", a); // 1번째 a가 출력
    
    if(a > 2) // 1번째 a를 확인
    { // 2번째 스코프
    	int a = 5; // 2번째 a
        printf("a: %d\n", a); // 2번째 a가 출력
    }
    
	return 0;
}

 

위 코드를 실행할때 스택 영역에 쌓이는 데이터를 그림으로 살펴보면 다음과 같다.

Stack 영역 구조

 

첫번째 a와 2번째 a는 이름은 같지만 메모리에 적재되는 위치가 다르기 때문에 다른 지역 변수라고 생각할 수 있다.

 

실제 메모리에 어떻게 들어가는지 확인해보자.

32비트 환경 Stack 영역

 

그림으로 표시한 Stack 영역처럼 메모리에도 순서대로 데이터가 쌓이는 것을 확인할 수 있다.

 

위 구조는 32비트 환경에서 빌드한 것인데, 64비트 환경에서 빌드하면 전혀 다른 스택 프레임이 나타난다.

 

64비트 환경 Stack 영역

 

64비트 환경에서는 Stack이 아래로 쌓이는 것을 확인할 수 있다.

64비트에서는 Stack 영역을 최대값까지 증가된 채로 지정해 놓고 위에서 아래로 내려오는 방식으로 관리하는 것


📌 스택 프레임 지정 ( 함수 )

예제를 통해 살펴보자

int add(int a, int b);

 

위와 같은 함수가 있을때 호출하면 스택 프레임에 매개변수가 쌓이는 순서는 아래 그림과 같다.

앞서 언급한것 처럼 매개변수는 오른쪽부터 아래에 순서대로 쌓인다.

 

#include <stdio.h>

int add(int paramA, int paramB)
{
    int a = paramA;
    int b = paramB;

    printf("a: %d b: %d", a, b);
}

int main()
{
    add(3, 4);
    return 0;
}

 

위 코드를 실행할때 함수안에 지역변수가 있으면 매개변수가 먼저 쌓일까? 지역변수가 먼저 쌓일까?

32비트 환경에서 메모리를 살펴보자.

 

 

메모리를 살펴보면 위 그림과 같다. 매개변수가 먼저 쌓이고, 이후 지역변수가 쌓이는 것을 확인할 수 있다.

 

64비트 환경에서 메모리를 살펴보자.

 

32비트 환경과는 완전히 다른 구조로 변경되어 있는것을 확인할 수 있다.


📌 스택 프레임 지정원리 #2

  • 함수 반환 시 해당 함수가 사용한 스택 메모리는 삭제된다.
  • 전역, 정적 변수는 스택을 사용하지 않는다.
주소에 의한 전달과 참조에 의한 전달의 코드를 살펴보자.

 

📌 주소에 의한 전달

#include<stdio.h>

void swap(int *pA, int *pB)
{
    int nTmp = *pA;
    *pA = *pB;
    *pB = nTmp;
    return;
}

int main(void)
{
    int x = 3, y = 4;
    swap(&x, &y);
    printf("%d, %d\n", x, y);
    return 0;
}

 

 

📌 참조에 의한 전달

#include<iostream>

using namespace std;

void swap(int& pA, int& pB)
{
	int temp = pA;
	pA = pB;
	pB = temp;
	return;
}

int main(void)
{
	int a = 10;
	int b = 20;

	swap(a, b);
	cout << "a = " << a << ", b = " << b << endl;	

	return 0;
}

 

주소에 의한 전달은 c로 작성했고, 참조에 의한 전달은 c++로 작성했다.

실제 어셈블리로 매개변수가 전달되는 부분을 확인하면 어떤 모습일까?

 

c 주소에 의한 매개변수 전달

 

c++ 참조에 의한 매개변수 전달

 

어셈블리 수준에서 두 코드를 보면 똑같이 ptr 즉, 주소로 접근해서 데이터를 처리하는 것을 확인할 수 있다.

이처럼 주소에 의한 전달과 참조에 의한 전달은 모두 주소를 이용해서 데이터를 처리하는 것이므로 차이가 없다.

 

어떤 의미에서는 포인터가 c++ 이나 JAVA에서 참조자가 되어주는 것이라고도 볼 수 있겠다.

매개변수 전달 기법은 함수가 호출될 때, 함수에 데이터를 전달하는 방법을 말한다.

 

매개변수를 전달하는 방식은 여러 가지가 있고, 각 기법은 함수 호출 시의 동작 방식과 메모리 사용에 따라 다르다.

  • 값에 의한 전달
  • 주소에 의한 전달
  • 참조에 의한 전달

 

📌 값에 의한 전달( Call by Value )

값에 의한 전달은 가장 기본적인 매개변수 전달 기법이다.
함수 호출 시 매개변수에 전달된 값이 복사되어 함수의 지역 변수에 저장( Stack에서 관리 )된다.
  • 함수 호출 시, 매개변수의 값이 복사되어 함수에 전달
  • 함수 내부에서 해당 값을 수정해도 호출한 함수의 값은 변경되지 않는다.

예시

#include <stdio.h>

void Test(int x) {
    x = 20;  // 함수 내부에서만 x의 값이 변경됨
}

int main() {
    int a = 10;
    Test(a);  // a의 값을 Test에 전달, 값이 복사됨
    printf("%d\n", a);  // 출력: 10
    return 0;
}

 

📌 주소에 의한 전달( Call by Reference )

주소에 의한 전달은 함수 호출 시 매개변수의 주소를 전달하는 방식이다.
  • 함수 호출 시, 매개변수의 주소가 전달된다.
  • 함수 내부에서 해당 주소를 통해 원본 변수에 접근하여 값을 변경할 수 있다.

예시

#include <stdio.h>

void Test(int *x) {
    *x = 20;  // 포인터를 통해 원본 변수의 값을 수정
}

int main() {
    int a = 10;
    Test(&a);  // a의 주소를 전달
    printf("%d\n", a);  // 출력: 20
    return 0;
}

 

📌 참조에 의한 전달( Call by reference )

참조에 의한 전달은 주소에 의한 전달과 비슷한 방식으로, 함수 호출 시 매개변수의 참조를 전달한다.
  • 함수 호출 시 매개변수의 참조가 전달된다.
  • 함수 내부에서 해당 참조를 통해 원본 객체나 데이터를 수정할 수 있다.
#include <iostream>
using namespace std;

void Test(int &x) {
    x = 20;  // 참조를 통해 원본 변수 값 수정
}

int main() {
    int a = 10;
    Test(a);  // 참조를 전달
    cout << a << endl;  // 출력: 20
    return 0;
}

 


 

앞선 글에서 쓰레드마다 Stack의 크기는 1MB로 제한되기 때문에 매개변수로 데이터를 전달할 때

값 복사가 이뤄지지 않도록 해야한다.

매개변수와 자동변수(=지역변수)는 모두 스택( Stack )영역에서 관리한다.

프로세스는 최소 한개 이상의 쓰레드를 가지고 있다.

 

쓰레드가 사용할 수 있는 스택의 크기는 1MB 로 한정된다.

#include<stdio.h>
#include<string.h>

int main(void)
{
    char szBuffer[1024];
    strcpy_s(szBuffer, sizeof(szBuffer), "Hello world");
    printf("%s\n", szBuffer);
    
    return 0;
}

 

위와 같이 코드를 작성하고 돌리면 Hello world가 출력된다.

 

#include<stdio.h>
#include<string.h>

int main(void)
{
    char szBuffer[1024 * 1024];
    strcpy_s(szBuffer, sizeof(szBuffer), "Hello world");
    printf("%s\n", szBuffer);
    
    return 0;
}

 

위와 같이 코드를 작성하고 돌리면 Hellow world가 출력되지 않는다.

위 코드 2개의 다른점은 char szBuffer 배열의 크기인데, 아래는 1024 * 1024로 1MB로 스택의 크기를 넘어가버렸기 때문에 

Stack overflow 런타임 에러가 발생해 출력되지 않는 것이다.

 

Stack overflow 발생

 

Stack의 크기는 프로젝트 속성 - 링커 - 시스템에서 스택 예약크기를 수정해서 변경할 수 있다.

보통은 1MB 이면 충분하기 때문에 수정하지 않고 사용한다.

 

이처럼 Stack의 크기는 한정적이기 때문에 유의하면서 사용해야 한다. 

  • 용량이 큰 구조체 자체를 매개변수로 전달할때 값 복사가 일어나지 않게 해야함
  • 함수가 다른 함수를 호출할때, 호출이 얼마나 이어지는지도 생각해야함

 

📌 호출자( Caller )

함수를 호출하는 대상을 말한다.

📌 피호출자( Callee )

함수에게 호출당하는 대상을 말한다.

 

모든 호출자와 피호출자는 호출자이면서 피호출자일 수 있다.

또한 호출자는 호출자와 피호출자로 묶인 관계까지만 생각하고, 피호출자가 호출자인지는 생각하지 않는다.

 

호출자는 피호출자를 호출하면, 결과값을 얻을 수 있다.

결과값은 피호출자 함수의 연산 결과이거나 피호출자 함수의 연산이 잘 수행되었는지 확인하는 결과일 수 있다.

 

반면, void는 반환받는 결과값이 없는 반환형이다.

호출자가 void를 반환형으로 하는 피호출자를 호출하는 것은 피호출자에서 제어의 흐름이 가고 코드 연산이 이루어 진 후

흐름이 되돌아오는 것에만 관심이 있다고 생각할 수 있다. 즉, 피호출자 함수가 잘 작동 했는지 못했는지, 아니면 연산 결과를 회수 받기 위한 의도가 전혀 없다는 것을 말한다. 호출에 의해 흐름이 넘어갔다 돌아왔다라는 것만 생각하겠다는 의미

 

호출, 피호출 관계에 있어서 한번 더 생각해야하는 점은 둘의 관계가 모듈이 분리가 되어있는지 생각해봐야하고,

경우에 따라서 호출, 피호출이 쓰레드가 바뀌어 버릴수도 있는지도 생각을 해야한다. 

 

  • 최종 바이너리는 .dll 파일이다.
  • .c, .h, lib, .dll 네 가지 파일로 구성된다.
    • .h는 컴파일 타임에 사용된다.
    • .lib는 링크 타임에 사용된다.
    • .dll은 런 타임에 사용된다.
      • 런타임 라이브러리를 로딩하는 방식은 2가지로 나뉜다.
        • 묵시적 방법
        • 명시적 방법

📌 Visual Studio에서 동적 라이브러리 개발

#include <stdio.h>

int add_in_dll(int a, int b)
{
	puts("add_in_dll v1.0");
	return a + b;
}

 

 

 

위 그림처럼 동적 라이브러리를 만들기 위해 구성 형식을 변경한다.

 

동적 라이브러리를 링킹 하는 주체는 운영체제인데, 프로세스를 로딩할 때 한다.

따라서 운영체제에게 알려주는 코드가 추가로 들어가야한다.

 

__declspec(dllexport) int add_in_dll(int a, int b);

 

 

위 명령어를 통해 add_in_dll을 외부에 공개함으로써 사용할 수 있게 한다. 

 

사용해보기 위해 프로젝트 하나를 더 만들자

 

 

 

정적 라이브러리와 마찬가지로 링크 에러가 난다.

 

동적 라이브러리를 사용하려면 다음과 같이 코드를 입력한다.

__declspec(dllimport) int add_in_dll(int a, int b);

 

 

이렇게 입력하고 빌드하면 링크에러가 또 나는데, 경로를 지정안해줘서 그렇다.

 

 

위 그림처럼 pragma comment를 사용해 경로를 지정해주면 링크에러가 잡히고 동적 라이브러리를 사용할 수 있다.

  • 최종 바이너리는 .lib 파일이다. 
    • 실행파일에서 lib파일을 포함시키는 형식
  • lib 파일에 구현되어 있는 함수를 사용하는 exe는 lib 파일을 반드시 link 해야한다.
  • .c, .h, .lib 세 가지가 하나로 구성된다.
    • .c에 소스코드를 작성한다.
    • 정적 라이브러리를 가져다 사용하는 사람은 .h, .lib 2개가 필요하다.
      • .h에 정적 라이브러리에 사용할 함수의 선언이 작성되어 있다. ( 컴파일 타임에 필요 )
      • .lib에 함수의 선언에 대한 정의가 작성되어 있다. ( 링크 타임에 필요 )

 

📌 Visual Studio에서 정적 라이브러리 개발

#include <stdio.h>

int add_in_lib(int a, int b)
{
	puts("add_in_lib v1.0");
	return a + b;
}

 

 

위와 같은 함수 static_add.c에 생성하고 빌드를 누르면

 

 

main 함수가 없어서 진입점을 잡을 수 없다고 에러가 출력된다.

정적 라이브러리를 개발하려면 프로젝트 속성을 바꿔줘야한다.

 

 

구성형식을 위와 같이 정적 라이브러리로 변경하고 빌드하면 에러가 안나는 것을 확인할 수 있다.

 

위 코드는 우리가 개발한 함수고, 정적 라이브러리를 사용할 대상에게 파일을 만들어서 제공해줘야 한다.

프로젝트 하나를 더 만든다.

 

 

TestApp 프로젝트를 생성하고 testapp.c에 main 함수를 생성한 후 위에서 만들어준 add_in_lib 함수를 호출한다.

이 상태에서 빌드하면

 

 

add_in_lib를 찾을 수 없다고 하는 링크에러가 난다. 

 

 

add_in_lib를 선언 해주면 찾을 수는 있지만 안에 있는 내용을 알 수 없기 때문에 동일한 링크에러가 난다.

 

앞서 만든 정적 라이브러리의 위치를 알아야한다.

 

위 그림을 통해 Debug 폴더 아래에 staticAdd.lib가 있는 것을 확인할 수 있다.

 

 

#pragma comment(lib, "..\\Debug\\staticAdd.lib")

 

TestApp는 다른 프로젝트 이기 때문에 ..으로 우선 상위 폴더로 이동한 후 Debug 폴더 안에 있는 staticAdd.lib를 입력하면

정적 라이브러리를 사용할 수 있다. 


 

  • 정적 라이브러리는 앞서 언급했듯이 실행파일에 결합된다. 따라서 static_add.c 파일의 내용을 수정하면 TestApp 프로젝트를 새로 빌드해서 실행파일을 다시 만들어줘야한다.
  • 위와 같은 경로를 .h 파일에서 따로 작성하고 .h와 lib 파일을 사용하는 사람에게 제공해주면 보다 편리하게 정적 라이브러리를 사용할 수 있게 된다.

 

 

정적 라이브러리와 동적 라이브러리는 프로그램 개발에서
코드의 재사용성과 모듈화를 위해 사용되는 함수를 모아 놓은 실행 파일을 말한다.

 

📌 정적 라이브러리( Static Library )

정적 라이브러리는 실행 파일과 Link타임에 결합되어 한 실행 파일로 합성되는 것을 말한다.

 

특징

  • 파일 확장자
    • .lib
  • 컴파일 및 링크 과정
    • 라이브러리의 소스 코드를 컴파일하여 개별적인 오브젝트 파일( .o 또는 .obj )을 생성한다.
    • 이 오브젝트 파일들을 하나의 정적 라이브러리 파일( .a 또는 .lib )로 묶는다.
    • 프로그램을 빌드할 때 정적 라이브러리를 포함하여 최종 실행 파일을 생성한다.
  • 배포 방식
    • 실행 파일에 라이브러리 코드가 포함되므로, 라이브러리 파일을 별도로 배포할 필요가 없다.
  • 속도
    • 실행 속도가 빠르다. ( 라이브러리가 이미 실행 파일에 포함되어 있기 때문 )
  • 단점
    • 실행 파일의 크기가 커진다.
    • 라이브러리가 변경되면, 모든 프로그램을 다시 컴파일 해야 한다.

 

📌 동적 라이브러리( Dynamic Library )

동적(= 런타임) 라이브러리는 실행 파일과 링크타임에 결합되지 않고 독립적인 실행파일 형태로 생성되며
실행 파일이 실행 될 때 '동적으로' 결합되는 것을 말한다.

 

특징

  • 파일 확장자
    • .dll
  • 컴파일 및 링크 과정
    • 라이브러리의 소스 코드를 컴파일하여 개별 오브젝트 파일( .o 또는 .obj )을 생성한다.
    • 동적 라이브러리( .so, .dll, .dyblib )를 생성한다.
    • 프로그램을 컴파일할 때 라이브러리를 참조하지만, 실행 시점에 라이브러리를 로드한다.
  • 배포 방식
    • 실행 파일에는 라이브러리 코드가 포함되지 않으며, 별도의 동적 라이브러리 파일이 필요하다.
  • 메모리 사용 최적화
    • 여러 프로그램이 하나의 동적 라이브러리를 공유하기 때문에 메모리 사용이 정적 라이브러리보다 효율적이다.
  • 업데이트 용이성
    • 라이브러리를 수정해도 프로그램을 다시 컴파일할 필요 없이 새 라이브러리만 배포하면 된다.
  • 단점
    • 실행 시 라이브러리를 로드해야 하므로 성능이 정적 라이브러리보다 약간 낮을 수 있다.
    • 라이브러리가 없거나 버전이 맞지 않으면 실행 오류가 발생할 수 있다.

+ Recent posts