C언어 기본 문법 2, 빌드 단계

·2022년 12월 1일
0

함수

#include <stdio.h>

static float s_gas = 500.0f;

void honk(void)
{
	printf("Honk~ honk~");
}

void reduce_gas(float consumed_gas)
{
	s_gas -= consumed_gas;
}

(1) C는 접근 제어자가 없음 -> 기본적으로 모두 전역 함수 (즉, 어디서나 호출 가능)

void print_score(int score)
{
	printf("Score: %d\n", score);
}

/*
컴파일 오류
void print_score(float gpa) 
{
	printf("Score: %f\n", gpa);
}
*/

void print_gpa(float gpa)
{
	printf("Score: %f\n", gpa);
}

(2) C는 함수 오버로딩이 없음

함수 선언

함수의 구현체 없이 함수 원형(prototype)만 선언해 주는 것
함수 원형은 다음의 사항들을 명시
(1) 함수의 이름
(2) 반환형
(3) 매개변수들의 자료형

(참고) 함수 정의(definition)는 실제로 함수를 구현해 놓은 것

어떻게 동작할까?
컴파일러는 전방 선언 되어 있는 함수 선언을 보고 실제 어디로 가서 코드를 찾아야 하는지 모르니 구멍으로 남기고, link 단계에서 실제 코드 위치를 찾아서 그 구멍을 메꿔주는 방식으로 작동한다.

어떤 함수가 먼저 호출될까?

int main(void)
{
	int num1 = 128;
    int num2 = 256;
    
    printf("%d, %d\n", add(num1, num2), subtract(num1, num2));
    
    return 0;
}

표준에 따르면, 함수 매개변수의 평가 순서는 명시되어 있지 않음(unspecified)
즉, 컴파일러에 따라 평가 순서가 달라질 수 있음
printf()가 실제로 실행되기 전에 add()와 subtract()가 호출된다는 것은 보장
그러나 누가 먼저 호출되는지는 컴파일러 마음임
-> 실행 순서가 중요한 경우에는 문제가 될 수 있음

undefined behavior

예를 들어 인자가 두 개인 경우 아래와 같이 여러 방법으로 평가가 될 수 있음
(1) 첫 번째 인자가 먼저 평가될 경우
(2) 두 번째 인자가 먼저 평가될 경우
(3) 동시에 평가될 경우
즉, 기본적으로 한 줄에서 동일한 변수를 여러 번 바꾸면 위험함

/* undefined behavior의 예 */
add(++i, ++i);
add(i = -1, i = -1);
add(i, i++);

연산자 우선순위와 평가 순서

int main(void)
{
	int num1 = 10;
    int num2 = 20;
    
    int result = add(num1, num2) + subtract(num1, num2) * divide(num1, num2);
    
    return 0;
}

=, +, * 등의 연산자들은 sequence point가 아님
즉, 연산자 우선순위와 평가 순서는 서로 아무 연관이 없음
평가 순서는 sequence point의 영향만 받는다.

평가 순서를 강제하는 연산자

int i = 0;
int j = 0;
int k = 0;

if(++i || ++j && ++k)
{
	printf("true!\n");
}

printf("%d, %d, %d\n", i, j, k);

/* 출력 결과
true!
1, 0, 0 */

우선순위 상으로는 &&가 ||보다 높지만, ||와 &&는 sequence point 이므로 || 부터 동작한다.
||는 왼쪽과 오른쪽 중 하나만 참이면 되므로, 뒷부분을 동작시킬 필요가 없다.
-> short circuit

정리
한 줄에 있는 피연산자들은 기본적으로 평가 순서가 보장되지 않음
sequence point : ||, &&, ;, 삼항 연산자 등

범위

블록 범위 {} 안에 선언한 것들은 그 블록 안에서만 사용 가능
블록 안에 또 다른 블록을 넣을 수 있음
- 그러면 안쪽 블록은 바깥 블록에 접근 가능
- 그 반대는 안 됨

/* 컴파일 오류 나는 코드 */
int main(void)
{
	int num1 = 10;
    
    printf("num: %d\n", num1);
    
    int num2 = 100;
    int result = num1 + num2;
    
    printf("result: %d\n", result);
    
    return 0;
}

/* 컴파일 되는 코드 */
int main(void)
{
	int num1 = 10;
    
    printf("num: %d\n", num1);
    
    {
    	int num2 = 100;
    	int result = num1 + num2;
    
    	printf("result: %d\n", result);
    }
    
    return 0;
}

(1) 함수 중간에 블록을 열고 변수 선언 가능
(2) 함수 시작 지점에서 모든 변수를 선언하면 실수할 여지가 있음
- 정확하게 어디서 사용하는 변수인지 파악 불가능
- 중간에 그 값이 바뀔 수도 있음
-> 블록을 이용해서 함수 중간에 선언하는 것도 하나의 방법

코딩 표준 : 변수 가리기(variable shadowing) 금지
모든 변수 이름을 다르게 지어야 함

#include <stdio.h>

/* 안 좋은 코드 */
int main(void)
{
	int num = 0;
    printf("%d", num); /* 0 */
    {
    	int num = 1;
        printf("%d", num); /* 1 */
    }
    
    return 0;
}

/* 좋은 코드 */
int main(void)
{
	int your_score = 0;
    {
    	int my_score = 1;
        printf("%d", my_score);
    }
    
    return 0;
}

파일 범위

어떤 블록이나 매개변수 목록에도 안 속하고 파일 안에 있는 것

#include <stdio.h>

static int s_num = 1024;

int add(int op1, int op2);

int main(void)
{
	s_num = add(10, 30);
    
    return 0;
}

파일 범위에 있는 변수의 메모리 위치

다른 소스코드 파일에서 링크 가능하고, 프로그램 실행 동안 공간을 차지하므로
스택 메모리가 아닌 데이터 섹션에 들어감 (전역 변수)

함수 범위

유일한 예 : 레이블(lable)
goto 같은 데서 쓰는 것
함수 안에서 선언된 레이블은 함수 어디에서라도 접근 가능
-> 다른 범위들은 위에서 선언된 것만 접근 가능했으나 아래와 같이 밑에 선언된 것을 위에서 사용 가능

#include <stdio.h>

int main(int argc, char** argv)
{
	if (argc != 3)
    {
    	goto exit;
    }
    
    printf("You have 3 arguments!");

exit:
	return 0;
}

함수 선언 범위

함수 선언의 매개변수 목록에 있는 것은 그 목록 안에서 접근 가능

void do_something(double value, char array[10 * sizeof(value)]);

// 상동
void do_something(
	double value, /* 함수 선언 범위 */
    char array[10 * sizeof(value)] /* value는 첫 번째 매개변수 */
);

const 키워드

상수를 만들 때 사용
기본적으로 모든 변수에 const를 붙이고, 정말 값 변경이 필요한 변수에만 const 생략하는 것이 좋음

/* 
const int NUM_CARS = 30;
NUM_CARS = 10; // 컴파일 에러
*/

int caculate_risk(const int id) /* 복사해온 매개 변수 id는 못 바꿔 */
{
	int age = db_get_age(id);
    int amount;
    
    id *= 2; /* 컴파일 오류 */
    
    amount db_get_deposit_amount(id);
    
    return risk;
}

void updatedimension(int w, int h, int data[])
{
	int i = 0;
    const int area = w * h;
    
    area = area + 1; /* 컴파일 오류 */
    
    for (i = 0; i < area; ++i)
    {
    	data[i] = 1;
    }
}

goto 문

goto <lable_name>;
...
<lable_name> :

C는 위에서 아래로 순차적으로 코드를 실행함
goto를 쓰면 이 순서를 어기고 다음에 실행할 코드를 마음대로 지정 가능
같은 함수 내에 있는 레이블(lable)로 점프함
그러나 스파게티 코드 방지를 위해 전방(아래쪽)으로만 사용 권장

goto의 나쁜 예

void do_work(void)
{
infinity:
	printf("work time!\n");
    
    goto infinity;
}

/* 상동 */
void do_work(void)
{
	while (1)
    {
    	printf("work time!\n");
    }
}

배열

자료구조 스택과 헷갈리면 안됨
둘 다 작동방법이 동일해서(LIFO) 스택이란 이름을 쓸 뿐
각 함수에서 사용하는 지역 변수 등을 임시적으로 저장하는 공간
스택 메모리의 크기는 프로그램 빌드 시에 결정되고, 위치는 실행 시에 결정됨

(참고)
모든 기본 자료형 변수(char, int, float)를 new없이 사용할 수 있었던 이유는 스택 메모리에 할당됐기 때문임
기본 자료형을 함수 매개변수로 전달하면 스택에 복사본을 만듦(값형)
스택 메모리를 빌리고 반환할 때마다 언제나 빈 공간 없이 차곡차곡 쌓여 있음
new로 만든 데이터는 힙(heap)메모리에 할당됨

간단한 함수 호출과 스택 메모리

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

int main(void)
{
	int a = 1;
    int b = 2;
    return add(a, b);
}

스택의 크기가 1MB일 때, 정해진 메모리보다 더 많은 양을 사용하는 경우 스택 오버플로우 발생

배열의 요소 개수 구하는 방법

int value[30];
size_t array_size = sizeof(values);

/* 방법 1 */
const size_t num_vals = sizeof(values) / sizeof(values[0]);

/* 방법 2 */
/* 함수 밖에서 */
# define ARRAY_LENGTH(arr) (sizeof(arr) / sizeof(arr[0]))

/* 매크로 함수 사용 */
const size_t num_vals2 = ARRAY_LENGTH(values);

배열 전체 바이트 크기를 구하고 그 크기를 첫번째 배열 사이즈로 나누면 배열 크기가 나옴

배열 생성 시 모든 값을 0으로 초기화 하는 방법은? -> 0 뒤에 쉼표를 찍자

int nums[10] = { 0, };

버퍼 오버플로우

int buffer[2] = { 2, 2 };
int buffer2[2] = { 1, 1 };
size_t i;

for(i = 0; i <= 2; ++i)
{
	buffer2[i] = 0;
}

배열의 크기는 2인데 for문 내에서 i를 2 이하로 받음으로서 i가 3까지 증가 가능해짐
-> 쓰레기 값을 불러 들여 버퍼 오버플로우 발생

소스코드에서 실행파일까지

C 프로그램의 빌드 과정
빌드 : 사람이 읽기 쉬운 소스코드를 기계어 명령어로 변환하는 과정
그리고 그 명령어들을 모아 기계에서 실행 가능한 실행파일로 만드는 과정

C의 빌드는 4단계로 나뉘어져 있음
(1) 전처리 단계
보통 전처리기라는 별도의 프로그램이 담당
- 입력 : c 파일 하나
- 출력 : 확장된 소스코드(컴파일의 기본 단위인 트랜슬레이션 유닛)
- 주석 제거
- 매크로 확장(복붙)
- 인클루드 파일들 확장
(2) 컴파일 단계
컴파일러라는 프로그램이 담당
- 입력 : 트랜스레이션 유닛
- 출력 : 어셈블리어 코드
- 어셈블리어 코드는 아직 정의를 모르는 심볼(함수나 변수의 이름)을 사용할 수 있음
-> 헤더를 통한 선언만으로 컴파일이 가능한 이유임

어셈블리어 코드가 나왔다는 의미는?
이 단계 이후부터 코드는 특정 플랫폼(기계)에서만 동작한다는 이야기
C가 크로스 플랫폼이라는 주장은 컴파일되기 전까지만
(여러 플랫폼에서 돌지만 어셈블리어로 컴파일 되는 순간 그 기계에서만 도는 코드가 됨)
또한 타겟 플랫폼이 몇 비트냐도 이미 결정된 후임(32, 64비트)

(3) 어셈블 단계
어셈블러라는 프로그램이 담당
- 입력 : 어셈블리 코드
- 출력 : 오브젝트 코드(기계가 곧바로 이해 가능한 기계 코드, 즉 이진 코드)
- 어셈블리어 코드와 마찬가지로 여전히 메꿔야 하는 구멍이 있음

(4) 링크 단계
링커라는 프로그램이 담당
- 입력 : 모드 오브젝트 코드들
- 출력 : 최종 실행파일(.exe, .out)
링커는 모든 오브젝트 코드들을 모아다 구멍을 메꾼 뒤 실행파일로 저장

/* adder.h */
int add(const int a, const int b);

/* adder.c */
#include "adder.h"

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

/* main.c */
#include "adder.h"

int main(void)
{
	const int res = add(1, 2);
    return 0;
}

C 파일(.c)

실제 프로그램을 돌게 하는 로직 코드를 저장해 두는 파일
내용물
- 함수 정의(= 함수 구현)
- 전역 변수 등
- 매크로

헤더파일(.h)

여러 소스코드 파일에 공통적으로 필요한 것들을 저장해 두는 파일
내용물
- 함수 선언
- 매크로
- extern 변수 선언 등
#include로 포함함 (예) #include "adder.h"

main.c를 컴파일하려면 add() 함수의 원형(함수의 선언)을 알아야 함
"내가 add라는 함수를 어딘가에 만들어 놓을게 필요하면 너가 가져다가 써~"

동일한 함수를 여러 곳에서 써야한다면 복붙을 해야할까?
-> 헤더파일을 사용한다.

#include "action.h"
walk(speed, dir);

/* player.c, monster.c에서 가져다 쓸 수 있음 */

함수 선언만 가지고 어떻게 프로그램이 돌까?
빌드가 여러 단계로 쪼개져 있는 이유가 바로 정의 없이 선언만 가지고도 컴파일이 되게 하기 위해서임
그리고 실제 올바른 기능 호출은 링크 단계가 책임짐

#include<> vs #include ""

인클루드 하는 법은 두 가지
이 둘의 차이는 디스크 상의 어디에서 헤더 파일을 찾느냐 차이

(1) <>
시스템 경로에서만 헤더 파일을 검색
- 보통 컴파일러가 제공하는 시스템 헤더 파일을 인클루드 할 때 사용
(2) ""
개발자가 구현한 헤더 파일들을 인클루드 할 때 사용
(현재 작업중인 디텍터리에서 헤더파일을 먼저 검색한 뒤 없으면 시스템 경로를 검색)

아까 본 어셈블리 코드에 레이블이 있었죠?
링커가 오브젝트 파일을 다 모아서 하나의 이진 파일로 만들어 주는 도중 함수의 위치를 기억하고 있다가 함수를 호출하려는 코드를 만나면 실행위치에 아까 그 위치(주소)로 점프하는 코드를 넣어줌

만약 선언만 믿고 사용한 함수나 변수가 여전히 구멍으로 남아 있다면?
즉, 다른 오브젝트 코드에서 정의를 못 찾았다면?
- 링커가 못 찾는다며 링커 오류를 뱉음
- 그 함수나 변수가 없어 실행할 방법이 없기에 경고가 아니라 오류

왜 굳이 링크단계가 분리되어 있을까?
사람들은 보통 컴파일(처음 세 단계)과 링크, 두 단계로 나눠서 생각
수많은 구멍을 컴파일 할 때마다 메꾸기엔
조금 전 예에서 추측 가능했겠지만 .c 파일이 많이 있으면 구멍 메꿔주는 일이 매우 복잡
예) c 파일이 수 천개나 있는 프로젝트에서 .c 파일 하나 컴파일 할 때 마다 모든 함수를 찾아서 구멍을 메꿔줘야 하나?
여러 개의 .c파일에서 동일한 외부 함수를 사용할 경우 최종 실행파일에 그 함수 정의가 중복으로 들어가는 것도 막아야 함

라이브러리

위에서 본 함수 등을 기계어로 변환 후 파일 하나로 저장해 놓은 것
나중에 다른 .c 파일에서 이 기능이 필요할 때 같이 링크해서 쓸 수 있음

라이브러리는 두 종류가 있음
- 정적 라이브러리
- 동적 라이브러리

정적 라이브러리와 링크

함수들을 만들어놓은 바이너리 덩어리를 사용하는 소스코드가 있으면
그 소스코드 가져와서 컴파일 한다음 마지막에 링크를 함

정적 라이브러리와 링크하는 것을 정적 링킹, 동적 라이브러리와 링크하는 것을 동적 링킹이라고 함
정적 링킹 : 라이브러리 안에 있는 기계어를 최종 실행파일에 가져다 복사함
동적 링킹 : 실행파일 안에 여전히 구멍을 남겨두어 실행파일을 실행할 때 링킹이 일어나게 함

extern

/* monster_repo.h */
void add_monster(void); 

/* monster_repo.c */
#include "monster_repo.h"

int g_mob_count = 0;

void add_monster(void)
{
	++g_mob_count;
}

/* main.c */
#include <stdio.h>
#include "monster_repo.h"

int main(void)
{
	add_monster();
    printf("# monsters" %d\n", g_mob_count);
    
    return 0;
}

컴파일러가 각 .c 파일을 따로따로 컴파일 하기 때문
main.c는 monster_repo.c 안에 있는 g_mob_count의 존재를 모름

전역 변수 두개가 서로 같은 이름이면 안됨

다른 방법이 필요
따라서 새로운 전역변수를 만드는게 아니라 monster_repo.c안에 있는 것을
가져다 쓸거다라고 컴파일러에게 말해줘야 함
-> 그래야 컴파일러가 구멍을 쏭쏭 비워 놓음 마치 함수 전방선언이 그랬던 것처럼

그걸 하는것이 extern 키워드

extern int g_mob_count;
void add_monster(void); // monster_repo.h

#include "monster_repo.h" // monster_repo.c

int g_mob_count = 0;

void add_monster(void)
{
	++g_mob_count;
}

// main.c
#include <stdio.h>
#include "monster_repo.h"

int main(void)
{
	add_monster();
    printf("# monsters" %d\n", g_mob_count);
    
    return 0;
}

extern 키워드
- 다른 파일에 있는 전역 변수에 접근하려면 extern 키워드를 사용

전역 변수의 문제
- extern 사용하면 아무데서나 다 확인 가능하고, 심지어는 맘대로 내 파일안의 변수를 바꿔버릴 수 있음 -> 다른데서 내 전역 변수를 못 쓰게 하려면 static 키워드 사용

static

// monster_repo.h
void add_monster(void);

// monster_repo.c
#include "monster_repo.h"

static int s_mob_count = 0;

void add_monster(void)
{
	++s_mob_count;
}

// main.c
#include <stdio.h>
#include "monster_repo.h"

extern int s_mob_count; // extern을 써도 컴파일은 되지만 링커에서 오류 발생됨
						// static으로 선언 되어 있기 때문에

int main(void)
{
	add_monster();
    printf("monster count %d\n", s_mob_count);
    return 0;
}

다른 파일에서 전역 변수에 접근 못하게 막는 방법
이 변수의 범위가 파일로 한정됨 (흔히 정적 변수라고 함)
여전히 전역 변수로 프로그램 실행 동안에 실제 공간을 계속 차지하고 있음
static 변수를 다른 파일에서 접근하려고 하면 링커 오류

#include "monster_repo.h"

void add_monster(void)
{
	static int mob_count = 0; // 스택에 생성되는 것이 아닌 전역 변수처럼 데이터에 생성
    ++mob_count;
}

static이 없으면 지역변수, 함수 반환 시 그 변수도 사라짐
static을 쓰면 개념상 전역 변수, 허나 그 함수 안에서만 접근 가능
-> 즉, 함수가 반환돼도 여전히 값은 저장되어 있음

.c와 .h 파일 정리

빌드의 4단계가 올바로 돌게 하려면 아래의 기본 원칙을 따라야 함
(1) 헤더 파일에는 선언만 들어간다.
- 함수 선언
- 전역 변수 extern 선언
(2) .c 파일에는 정의가 들어간다
- 함수 정의
- 전역 및 정적 변수 정의

include가 중첩되지 않게 방지하는 방법 (순환 헤더 인클루드와 해결법)

해결법 1.
(1) #include 는 가능하면 .c에서만 하기
(2) b헤더에서 a헤더를 인클루드 하는 대신 a에 정의된 것을 전방선언 하기

해결법 2. 인클루드 가드 사용

#ifndef FOO_H /* 만약 FOO_H가 정의되지 않았다면 */
#define FOO_H /* FOO_H를 정의할 것 */
/* 원래 해더 파일 내용 */ /* 그리고 이 코드를 원래대로 포함시킬 것 */
#endif					/* #ifdef 블록의 끝 */

해결법 3. #pragma once
표준 아님
그냥 최신 컴파일러가 대체적으로 다 지원하는 것 -> c++에서 사용
포팅 생각한다면 예전 컴파일러 및 시스템과의 호환을 위해 그냥 인클루드 가드 쓸 것

0개의 댓글