Part1) CH1. 필수요소

songtofu·2022년 11월 5일
0

전문가를 위한 C

목록 보기
1/10

1.1) 전처리기 지시자

  • 전처리: 컴파일러로 보내기 전 소스 코드를 만들고 수정할 수 있도록 하는 과정 (임시)
    -> C의 컴파일 파이프라인, 다른 언어에 비해 최소 한 단계 이상 가지고 있음을 의미. (다른 프로그래밍 언어에서는 컴파일러로 소스 코드가 바로 전달)
  • 전처리의 목적
    1. 전처리 지시자를 제거하고 이 지시자를 C 코드에 의해 생성된 동일한 내용으로 바꾸는 것
    2. 컴파일러로 보낼 준비가 된 최종 소스 파일을 준비
  • 전처리기 지시자 = 헤더와 소스 파일 모두에서 #으로 시작하는 코드 : C 전처리기에만 의미 있음 C 컴파일러에게 의미 X

1.1.1 매크로

매크로

  • 매크로의 활용
    1. 상수 정의
    2. C 함수를 작성하지 않고 함수로 사용
    3. 루프 풀기
    4. 헤더가드
    5. 코드 생성
    6. 조건부 컴파일
  • 매크로 정의하기
    매크로는 #define 지시자를 이용해 정의합니다.
    각 매크로는 이름과 사용 가능한 매개변수 리스트와 값을 가집니다.
    이 값은 매크로 확장이라는 단계를 통해 전처리 단계에서 매크로의 이름으로 대체될 수 있습니다.
    매크로는 #undef 지시자로 매크로의 정의를 제거할 수 있다.
  • 매크로는 컴파일 단계 이전에만 존재합니다. (= 컴파일러가 이론적으로는 매크로에 관해 아무 것도 모른다는 의미)
  • 매크로는 컴파일 이전에 코드를 생성 할 수 있도록 합니다.
  • 변환단위(혹은 컴파일 단위)는 컴파일러로 전달될 준비가 된, 전처리된 C 코드. 변환 단위에서는 모든 지시자가 포함되거나 매크로 확장으로 대체되며 단 한줄의 긴 C 코드가 만들어집니다.
  • 매크로를 확장할 때 # 연산자 = 매개변수를 한 쌍의 따옴표로 둘러싼 문자 형태로 변환.
  • ##연산자는 매크로 정의에서 매개변수와 다른 요소를 문자열로 결합해 변수 이름을 만든다.
  • 전처리된 최종 코드에서 같은 매크로 정의로부터 확장된 모든 행은 같은 행에 위치

가변 인자 매크로

  • 가변 인자 매크로는 입력 인수에 관한 변수를 받을 수 있다.
  • 동일한 매크로를 다른 용도로 사용하는 경우, 인수의 개수를 정확히 모른다면 가변 인자 매크로가 매우 유용하다.
  • ex) 가변 인자 매크로를 단계별로 사용해 루프를 모방.
#include <stdio.h>

#define LOOP_3(X, ...) \
	printf("%s\n", #X);

#defind LOOP_2(X, ...) \
	printf("%s\n", #X); \
    LOOP_3(__VA_ARGS__)

#defind LOOP_1(X, ...) \
	printf("%s\n", #X); \
    LOOP_2(__VA_ARGS__)

#define LOOP(...) \
	LOOP_1(__VA_ARGS__)
    
int main(int ac, char **av) {
	LOOP(copy paste cut)
    LOOP(copy, paste, cut)
    LOOP(copy, paste, cut, select)
    return (0);
}
  • 전처리 단계 이후의 ex
...
... content of stdio.h
...

int main(int ac, char **av){
	printf("%s\n", "copy paste cut"); printf("%s\n", "");
    printf("%s\n", "");
    printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut");
    printf("%s\n", "copy"); printf("%s\n", "paste"); printf("%s\n", "cut"); 
    
    return (0);
}

-> (단점)이러한 방식은 이진 파일 크기가 커짐(= 루프 풀기)
루프 풀기는 제한된 환경에서 고성능을 요하는 경우에 용도가 있음.

매크로의 장점과 단점

  • 매크로의 특성:
    1. 매크로에 무언가를 작성할 경우, 매크로에 쓰인 것은 컴파일 단계 이전에 다른 코드로 교체
    2. 컴파일을 거치고 나면 모듈성을 전혀 갖지 않는, 단 한줄로 된 긴 코드를 얻음
    3. 최종 이진 파일에는 존재하지 않게 된다. ( 매크로 사용 시 설계 문제가 생기는 지점)
    -> 최종 변환 단위의 일환인 전처리 단계 이후에 매크로와 관련된 정보를 잃어버릴 수 있다.
  • 매크로는 코드의 가독성을 높이고 반복되는 명령어를 걷어낼 수 있다.

1.1.2) 조건부 컴파일

  • 조건부라는 이름이 붙지만, 컴파일러는 조건적으로 무언가를 하지 않는다.
  • 전처리된 코드를 준비하는 동안 전처리기는 조건을 판단.
  • 조건부 컴파일에 관한 지시자
    1. #ifdef
    2. #ifndef : 헤더 가드 구문(전처리 단계에서 헤더 파일이 두 번 포함되는 것을 방지, #endif와 한 쌍 == #pragma once(C표준아님))
    3. #else
    4. #elif
    5. #endif

1.2) 포인터 변수

1.2.1) 문법

  • 포인터 = 메모리 주소를 저장하는 단순한 변수
  • 선언 시 유효한 주소를 저장하지 않는 경우 포인터를 널로 만드는 것 중요
  • & 연산자(참조 연산자) 옆에 있는 변수의 주소를 반환
    • 연산자(역참조 연산자) ptr 포인터가 가리키는 메모리 셀에 간접적인 접근을 허용(포인터가 가리키는 것을 통해 변수를 읽고 수정할 수 있음)
  • NULL 포인터는 유효한 메모리 주소를 가리키지 않음. 절대로 널 포인터를 역참조 하면 안된다. 정의되지 않은 동작으로 간주되기 때문
  • NULL = 기본 매크로 : 0의 값을 가지도록 정의 NULL 포인터를 사용하면 변수와 포인터 구분이 쉽다
  • C++ 에서는 포인터를 초기화 하는 키워드 = nullptr이 존재. (포인터를 널로 만들거나, 포인터가 널인지 아닌지 확인할 때 사용)

1.2.2) 포인터 변수의 산술연산

  • 메모리를 가장 단순한 그림으로 그리면 아주 긴 1차원 바이트 배열이다. 오로지 앞뒤로 움직일 수 있음. 포인터도 이와 마찬가지. 포인터 증가 -> 포인터 앞으로 , 포인터 감소 -> 뒤로
  • 산술연산 간격 : 포인터를 1만큼 증가-> 메모리에서 1바이트 이상 앞으로 나아감. 각각의 포인터는 살술 연산 간격을 가진다. 이는 포인터가 증가하거나 감소할 때 움직이는 바이트의 숫자
  • 산술연산 간격은 포인터의 C 자료형에 의해 결정 ex) int, char...
  • C에서 배열이란 실제로는 자신의 첫 번째 원소를 가리키는 포인터

1.2.3) 제네릭 포인터

  • void* 자료형의 포인터는 제네릭 포인터라고 함.
  • 주소를 가리킬 수 있지만, 실제 자료형은 알 수 없음. -> 산술연산 간격도 알 수 없음.
  • 다른 포인터의 내용을 담기 위해 주로 사용
  • 하지만, 다른 포인터의 실제 자료형은 담지 않음. 따라서 제네릭 포인터는 역참조 될 수 없음 + 산술연산도 할 수 없음
  • void 포인터(제네릭 포인터)를 할당할 때 명시적 형 변환이 꼭 필요하지는 않음.
  • 메모리 내부에서 움직일 수 있도록 unsigned char 포인터를 사용. 사용하지 않을 경우, void 포인터의 매개변수인 data를 직접 연산할 수 없음.

size_t = C 에서 크기를 저장하기 위해 주로 사용되는 표준 자료형 + 부호가 없는 자료형

1.2.4) 포인터 크기

  • 여러 답 있지만, 서로 다른 아키텍처에서 포인터의 고정된 크기를 정의할 수 없다.
  • 포인터의 크기를 알려면 항상 sizeof 함수를 사용해야한다
  • 작성한 코드가 포인터의 크기에 대한 특정값에 의존하면 안된다. 그렇지 않으면, 다른 아키텍처로 코드를 복사하려고 할 때 문제 발생

1.2.4) 허상 포인터

  • 포인터는 대개 변수가 할당된 지점의 주소를 가리킴. 변수가 저장되지 않은 곳의 주소를 읽거나 수정하는 일 = 충돌 or 세그멘테이션 오류
  • ex)
#include <stdio.h>

int *create_an_int(int value) {
	int var = value;
    return &var;
}

int main() {
	int *ptr = NULL;
    ptr = create_an_int(10);
    printf("%d\n", *ptr);
    return(0);
}

gcc컴파일러로 컴파일 시 경고가 나오지만, 컴파일은 성공적으로 종료되고 최종 실행 파일을 얻을 수 있음.
그러나 실행 파일을 실행하면 세그멘테이션 오류가 발생한다.
-> ptr 포인터는 이미 메모리에서 할당이 해제된 부분을 가르킴. 할당이 해제된 곳은 변수 var의 메모리 영역이었던 곳, var 변수는 create_an_integer 함수의 지역 변수이며, 함수를 떠나면 할당이 해제. 하지만, 이 변수의 주솟값은 함수로 반환 될 수 있음.
==> 이를 해결하기 위해 힙 메모리(malloc함수)를 사용, 다 사용후 반드시 힙 변수를 해제(free)해야함.

1.3) 함수

1.3.1) 함수의 구조

  • C 에서 함수는 언제나 블로킹 함수여야 한다.(= 호출자 함수는 호출된 함수가 종료되기를 기다려야만 호출된 함수가 반환하는 값을 받을 수 있다)
    <-> 논 블로킹 함수=비동기 함수(호출하는 쪽의 함수는 호출 대상이 되는 함수가 종료할 때까지 기다리지 않고 실행을 계속할 수 있음.)

    논블로킹 도식에 콜백 메커니즘 존재, 호출된 함수가 종료될 때 트리거가 된다.

  • C 에는 비동기 함수가 없기 때문에, 멀티스레딩 솔루션을 이용해 이를 실행해야함.

    1.3.3) 스택 관리

  • 스택 세그먼트는 모든 지역 변수, 배열, 구조체가 할당되는 기본 메모리의 위치.
  • 함수에서 지역 변수를 선언할 때마다 지역 변수는 스택 세그먼트에 할당.
  • 할당은 언제나 스택의 제일 윗부분에서 일어남
  • 변수와 배열은 언제나 스택의 가장 위에 할당, 맨 위에 있는 첫 번째 변수가 가장 먼저 제거된다.
  • 함수를 호출할 때, 스택 세그먼트의 사용
    1. 반환 주소와 모든 전달 인수를 포함한 스택 프레임이 스택 세그먼트의 가장 위에 놓인다.
    2. 함수 로직 실행
    3. 함수 호출이 끝난 뒤 스택 프레임 제거
    4. 반환 주소가 지정된 명령어가 실행
    5. 직전 호출 함수로 계속 이어짐

    그림 참고:: http://www.tcpschool.com/c/c_memory_stackframe

  • 함수 몸체에서 선언되는 모든 지역 변수는 스택 세그먼트의 가장 위에 놓인다. 그러므로 함수에서 떠날 때 모든 스택 변수는 비워짐 = 지역 변수 (다른 함수에서 접근 X)
  • 스택에 원하는 크기대로 아무 변수나 생성할 수 없다. 스택은 메모리에서 한정된 일부분이며, 이를 다 채우게 되면 스택 오버플로 오류가 발생

1.3.4) 값에 의한 전달 vs 참조에 의한 전달

  • C에서는 값에 의한 전달만 존재.
  • C에는 참조가 없다. 그러므로 참조에 의한 전달도 없다. 모든 것은 함수의 지역 변수에 복사되고 함수를 떠나면 지역 변수를 읽거나 수정할 수 없다.
  • 직접 포인터를 전달하기보다는 포인터를 변수로 전달하는 것이 포인터에 의한 전달이다.
  • 함수에 큰 객체를 전달하는 것 대신 인수로 포인터를 사용하는 편을 주로 권장 -> 8바이트짜리 포인터 인수를 복사하는 것이 수백 바이트의 큰 객체를 복사하는 것보다 훨씬 더 효율적이기 때문

1.4) 함수 포인터

  • 함수 포인터의 활용법 중, 큰 이진 파일을 작은 이진 파일로 나누고 이를 다른 작은 실행 파일에 다시 넣는 것 가장 중요. 이로 인해 모듈화와 소프트웨어 설계가 가능.
  • 함수 포인터도 함수의 주소를 저장하며 함수를 간접적으로 호출할 수 있도록 구현
  • 함수 포인터를 이용해 같은 인수 목록으로 다른 함수를 호출 할 수 있다.
    (= 객체지향 프로그래밍에서 다형성과 가상함수와 비슷)
  • 포인터 변수처럼 함수 포인터도 반드시 초기화해야함.
  • 함수 포인터에 대해 타입 별칭을 정의하는 것을 권장 (typedef 키워드)

1.5) 구조체

  • 구조체의 도입으로 프로그래밍 언어가 캡슐화의 단계로 나아갈 수 있었다. (아마 인간의 사고를 닮은 언어를 가질 수 있다는 말 같음)

1.5.1) 왜 구조체인가?

  • 모든 프로그래밍 언어는 원시 자료형(PDT)(ex. int, char...) 을 가진다. 자신이 직접 정의한 자료형이 필요할 떄 그리고 프로그래밍 언어의 자료형으로 충분하지 않을 때 구조체도입.
  • 사용자 정의 자료형(UDT)은 사용자가 마든 자료형이며 프로그래밍 언어에 속하지 않음
  • 사용자 정의 자료형은 typedef를 사용해서 정의할 수 있는 자료형과 다르다!!! typedef 키워드는 실제로 새 자료형을 만들지 않는다. 별칭이나 동의어를 재정의함.
  • 구조체는 오나전히 새로운 사용자 정의 자료형을 프로그램에 넣을 수 있도록 한다.
    ex) C++와 자바의 클래스나 펄의 패키지 == 타입 메이커로 간주

1.5.2) 왜 사용자 정의 자료형인가?

CPU는 원시 자료형과 빠른 계산을 고수한다. 그러므로 고급 언어(C++, Java...)로 프로그램을 작성한다면, 이는 CPU 수준의 명령어로 번역되어야 하므로 더 많은 시간과 자원이 필요하다.

1.5.3) 구조체의 역할

  • 관련된 값을 캡슐화한다.

1.5.4) 메모리 레이아웃

  • 메모리에 나쁜 레이아웃이 있을 경우 특정 아키텍처에서는 성능이 저하된다.
    (나쁜 레이아웃이란 뭘까?????)
  • 구조체 변수의 메모리 레이아웃은 배열과 유사. 배열의 모든 원소는 메모리에서 서로 인접, 구조체 변수와 구조체 변수의 필드도 마찬가지.
  • 배열: 모든 원소가 같은 자료형을 가지며 크기도 같음.
    구조체 변수: 해당 X, 각 필드가 다른 자료형을 가질 수 있음 = 크기가 다를 수 있음.
  • 메모리에서 구조체 변수의 크기는 몇 가지 요소에 따라 다르며 쉽게 결정되지 않는다.
    ex)
typedef struct {
	char first;
	char second;
	char third;
	short fourth;
} sample_t;

void print_size(struct sample_t* var) {
	printf("Size: %lu bytes\n", sizeof(*var));
}

void print_bytes(struct sample_t* var) {
	unsigned char* ptr = (unsigned char *)var;
    for (int i = 0; i < sizeof(*var); i++, ptr++) {
    	printf("%d ", (unsigned int)*ptr);
    }
    printf("\n");
}

int main(int ac, char **av) {
	struct sample_t var;
    var.first = 'A';
    var.second = 'B';
    var.third = 'C';
    var.fourth = 765;
    print_size(&var)
    print_bytes(&var)
    return (0);
}

출력값::

$ ./a.out
Size: 6 bytes
65 66 67 0 253 2
$

sample_t 타입의 각 변수는 메모리 레이아웃에서 5바이트일 것으로 생각된다. 하지만, 결과를 보면 6바이트이다. 추가 바이트가 생겼다!

  • 이유? 메모리 정렬
    CPU는 언제나 모든 계산을 한다. 게다가 계산하기 전 메모리로부터 값을 로드해야 하며 계산한 이후에는 그 값을 메모리에 다시 저장해야한다. CPU 내부의 계산은 매우 빠르지만, 메모리 접근은 비교적 느리다. CPU는 각 메모리에 접근할 때 특정 바이트의 숫자를 주로 읽는다. 이 바이트의 수는 주로 워드(Word)라고 함. 메모리는 워드로 나뉘며, 메모리로부터 읽고 쓰기 위해 CPU가 사용하는 작은 단위를 워드라 한다. 워드에 있는 바이트의 실제 숫자는 아키텍처에 따라 다름.

    이와 같이 패딩(Padding)을 사용해 메모리의 값을 정렬한다.
    패딩은 정렬을 맞추기 위해 바이트를 추가하는 기술
  • 반면, 패킹된 구조체는 정렬되지 않은 것이며 이를 사용하면 이진 파일 비호환성을 야기하고 성능이 저하된다.
#include <stdio.h>

struct __attribute__((__packed__)) sample_t {
	char first;
    char second;
    char third;
    short fourth;
};

...위의 예시코드와 동일한 main()

결과값 ::

$ ./a.out
Size: 5 bytes
65 66 67 253 2
$

패킹된 구조체는 메모리가 제한적인 환경에서 주로 사용된다.

  • 참고로 메모리 정렬은 기본적으로 활성화 되어 있다.

1.5.5) 중첩 구조체

typedef struct {
	int x;
  	int y;
} point_t;

typedef struct {
	point_t center;
  	int radius;
} circle_t;

typedef struct {
	point_t start;
  	point_t end;
} line_t;

에서 point_t는 단순한 사용자 정의 자료형.
circle_t와 line_t는 복합 사용자 정의 자료형.

  • 복합 구조체의 크기는 단순 구조체와 정확히 동일한 방식으로 모든 필드의 크기를 더해 계산한다. 다만, 복합 구조체의 크기에 영향을 줄 수 있기 때문에 정렬에 주의해야함.

    구조체 변수 객체를 호출하는 일 흔함. 구조체 변수 객체는 객체지향 프로그래밍의 객체와 정확히 유사. 그리고 값과 함수 모두를 캡슐화할 수 있다. 그러므로 구조체 변수 객체를 C의 객체라고 불러도 틀린 말 아님.

1.5.6) 구조체 포인터

  • 사용자 정의 자료형도 포인터를 가질 수 있다. 사용자 정의 자료형의 포인터는 원시 자료형의 포인터와 정확히 같다.
#include <stdio.h>

typedef struct {
	int x;
    int y;
} point_t;

typedef struct {
	point_t center;
    int radius;
} circle_t;

int main(int ac, char **av) {
	circle_t c;
    
    circle_t* p1 = &c;
    point_t* p2 = (point_t*)&c;
    int *p3 = (int *)&c;
    
    printf("p1: %p\n", (void *)p1));
    printf("p2: %p\n", (void *)p2));
    printf("p3: %p\n", (void *)p3));
    
    return (0);
}
$ ./a.out
p1: 0x77ffee846c8e0
p2: 0x77ffee846c8e0
p3: 0x77ffee846c8e0
$
  • 메모리에서 같은 셀의 주소를 가리키는 3개의 다른 포인터를 갖게된다.
  • 모든 포인터는 같은 바이트를 가리키지만 자료형은 서로 다르다.
profile
읽으면 머리에 안들어와서 직접 쓰는 중. 잘못된 부분 지적 대환영

0개의 댓글