Skip to content

Latest commit

 

History

History
2251 lines (1646 loc) · 81.6 KB

1. C 기초 정리.md

File metadata and controls

2251 lines (1646 loc) · 81.6 KB

확실히 저수준에 대한 이해가 있는 상태에서 다시 배우니까 이해가 잘 되고 재밌다. 점프 테이블이 이제 이해된다거나, 형변환 시 원본 데이터에 대한 궁금증이 생긴다거나...

유용한 사이트

정리할때도 참고함

후기

C언어의 기초적인 부분들을 알아 보았는데, 생각보다 기능이 적어서 쉽게 배울 수 있었다.

에러와 관련된 (try-catch)도 없고, 클래스와 같은 객체지향 개념도 없다.

다만 진입장벽이 있다고 느꼈다.
언어의 철학 자체가 개발자의 자유도를 높게 해 주는 것이고, (일반적인 언어와 반대) 개발자가 무슨 코드를 짜고 있는지 이해하고 있다고 가정하는 것 같다. 그래서 컴파일러도 불친절하고, 여러 기능 역시 함정 같은 것들이 많다. (대신 그만큼 강력하다.)

그래도 CS 지식을 알고 있다는 가정 하에, CS 지식이 쓰이는 부분이 많아서 공부에는 도움이 되었다. 포인터나 메모리 세그먼트나, Call Stack 등등.

변수, 타입, printf

#include <stdio.h>  
  
// 구글 C++ 스타일 가이드 번역 (C 가이트는 잘 없더라)  
// https://jongwook.kim/google-styleguide/trunk/cppguide.xml#%EC%A3%BC%EC%84%9D  
  
int add(int a, int b); // 함수 프로토타입, 컴파일러에게 함수의 존재를 알림  
  
/*  
 * 긴 주석, 아마 함수의 명세에 대한 설명을 작성할 때 주로 사용할 듯?  
 * // 는 코드 중간에서 일부 문맥 설명할 때나 쓰고...  
 */int main(void) { // void는 함수의 파라미터/리턴 타입이 없음을 지정, 혹은 모든 타입을 받는 포인터 타입 지정 시 사용  
    // 변수 선언 및 초기화  
    int integer_var = 42;           // 정수형 변수  
    unsigned int uint_var = 42;     // unsigned(부호 없는) 정수형 변수  
    float float_var = 3.14f;        // float, f를 붙여 4byte 리터럴 값임을 명시  
    double double_var = 3.141592;   // double  
    char char_var = 'A';            // 문자형 변수, ' 사용  
    char str_var[] = "Hello, C!";   // 문자열 변수, " 사용  
    int *ptr_var = &integer_var;  
  
  
    // 변수 출력  
    printf("Integer: %d\n", integer_var);   // 정수형 변수 출력 (%d)    printf("Integer: %d\n", uint_var);      // unsigned 정수형 변수 출력 (%d)    printf("Float: %f\n", float_var);       // float (%f)  
    printf("Double: %lf\n", double_var);    // double (%lf)  
    printf("Character: %c\n", char_var);    // 문자형 변수 출력 (%c)    printf("String: %s\n", str_var);        // 문자열 변수 출력 (%s)    printf("Address: %p\n", ptr_var);       // 포인터가 참조하는 주소 (%p)  
    // 함수 호출  
    int result = add(5, 3);                // 함수 호출: add 함수에 5와 3을 인수로 전달  
    printf("Result of add(5, 3): %d\n", result); // 함수 결과 출력  
  
    return 0; // 프로그램 종료 상태를 반환 0은 성공, 1은 실패  
}  
  
// 함수 정의  
int add(int a, int b) {  
    return a + b;  
}

추가로 설명하자면 %i도 있는데, 10, 8, 16진수를 전부 인식한다. 따라서 입력받을때 012을 받으면 8진수로 인식한다. 포함하는 범위가 넒어서 의도와 다르게 동작할 수 있으므로 일반적으로는 추천하지 않는다.

그 외 더 큰 타입을 표기하기 위한 포멧 지정자가 있는데, 필요하면 여기서 찾아보자.

상수

변수 선언문 앞에 const를 추가하여 상수로 만들 수 있다.

상수는 선언과 함께 초기화가 필수이며, 수정할 수 없다. (수정 시 컴파일러 에러 발생)

변수의 선언과 초기화

변수 선언 이후, 초기화를 하지 않으면 쓰레기 값이 남아있는 상태이다.

초기화를 통해 값을 지정해주어야 한다.

이 경우 컴파일러가 경고를 띄울 수 있다.

Bool 타입

C99 부터 bool 타입을 지원한다.

_Bool이라는 이름이 있는데, 일반적인 타입 이름과 달라서 #include <stdbool.h>를 사용해서 bool이라는 이름으로 사용할수도 있다. (이름이 이렇게 된 이유는 생각보다 재밌는 이유가 있다.)

그 전에는 0을 거짓, 나머지 값은 참으로 간주했다.

Void 타입

void는 값을 담는 변수로는 기능할 수 없다. 의미 상 불가능하기 때문. 빈(공허한) 데이터를 저장하는 변수라는게 말이 안됨.

void는 함수의 매게변수나 리턴 타입이 없음을 명시할 때 쓰거나, 모든 타입의 포인터를 받을 때 사용한다.

포인터로서 사용하는 경우, 실제로 데이터에 접근하기 위해서는 적절한 offset을 알아야하기 때문에, 형변환을 수행해야만 한다.

산술, 비트, 대입 연산자

(GPT의 도움을 받음)

우선순위와 같은 부분이 중요하긴 한데, 애매한 부분은 그냥 괄호를 사용하는 식으로 할 예정. 어차피 의미없는 괄호라면 IDE가 경고해주기 때문에... (그리고 그런 애매한 부분은 괄호 써서 확실하게 하는게 좋다고 생각함)

비트 연산이 조금 생소하긴 한데, 이해 못하는 정도는 아니고, java에서도 쓰긴 하니까 따로 정리는 안함.

  • 산술 연산자:
    • 덧셈 (+)
    • 뺄셈 (-)
    • 곱셈 (*)
    • 나눗셈 (/)
    • 나머지 (%)
    • 증감 연산자 (++, --)
  • 비트 연산자:
    • AND (&)
    • OR (|)
    • XOR (^)
    • NOT (~)
    • 왼쪽 시프트 (<<)
    • 오른쪽 시프트 (>>)
  • 대입 연산자:
    • 대입 (=)
    • 더하기 후 대입 (+=)
    • 빼기 후 대입 (-=)
    • 곱하기 후 대입 (*=)
    • 나누기 후 대입 (/=)
    • 나머지 후 대입 (%=)
    • AND 후 대입 (&=)
    • OR 후 대입 (|=)
    • XOR 후 대입 (^=)
    • 왼쪽 시프트 후 대입 (<<=)
    • 오른쪽 시프트 후 대입 (>>=)
#include <stdio.h>

int main() {
    // 산술 연산자
    int a = 10;
    int b = 5;
    int result;

    // 덧셈
    result = a + b;  // result = 15
    printf("a + b = %d\n", result);

    // 뺄셈
    result = a - b;  // result = 5
    printf("a - b = %d\n", result);

    // 곱셈
    result = a * b;  // result = 50
    printf("a * b = %d\n", result);

    // 나눗셈
    result = a / b;  // result = 2
    printf("a / b = %d\n", result);

    // 나머지 연산
    result = a % b;  // result = 0
    printf("a %% b = %d\n", result);

    // 증감 연산자
    a++;  // a = 11
    printf("a++ = %d\n", a);

    b--;  // b = 4
    printf("b-- = %d\n", b);

    // 비트 연산자
    int x = 6;  // 6 = 00000110 in binary
    int y = 3;  // 3 = 00000011 in binary

    // AND
    result = x & y;  // result = 2 (00000010 in binary)
    printf("x & y = %d\n", result);

    // OR
    result = x | y;  // result = 7 (00000111 in binary)
    printf("x | y = %d\n", result);

    // XOR
    result = x ^ y;  // result = 5 (00000101 in binary)
    printf("x ^ y = %d\n", result);

    // NOT
    result = ~x;  // result = -7 (11111001 in binary, two's complement)
    printf("~x = %d\n", result);

    // 왼쪽 시프트
    result = x << 1;  // result = 12 (00001100 in binary)
    printf("x << 1 = %d\n", result);

    // 오른쪽 시프트
    result = x >> 1;  // result = 3 (00000011 in binary)
    printf("x >> 1 = %d\n", result);

    // 대입 연산자
    int z = 0;

    // 더하기 후 대입
    z += a;  // z = z + a (z = 0 + 11, z = 11)
    printf("z += a = %d\n", z);

    // 빼기 후 대입
    z -= b;  // z = z - b (z = 11 - 4, z = 7)
    printf("z -= b = %d\n", z);

    // 곱하기 후 대입
    z *= a;  // z = z * a (z = 7 * 11, z = 77)
    printf("z *= a = %d\n", z);

    // 나누기 후 대입
    z /= b;  // z = z / b (z = 77 / 4, z = 19)
    printf("z /= b = %d\n", z);

    // 나머지 후 대입
    z %= a;  // z = z % a (z = 19 % 11, z = 8)
    printf("z %%= a = %d\n", z);

    // AND 후 대입
    z &= x;  // z = z & x (z = 8 & 6, z = 0)
    printf("z &= x = %d\n", z);

    // OR 후 대입
    z |= y;  // z = z | y (z = 0 | 3, z = 3)
    printf("z |= y = %d\n", z);

    // XOR 후 대입
    z ^= x;  // z = z ^ x (z = 3 ^ 6, z = 5)
    printf("z ^= x = %d\n", z);

    // 왼쪽 시프트 후 대입
    z <<= 2;  // z = z << 2 (z = 5 << 2, z = 20)
    printf("z <<= 2 = %d\n", z);

    // 오른쪽 시프트 후 대입
    z >>= 1;  // z = z >> 1 (z = 20 >> 1, z = 10)
    printf("z >>= 1 = %d\n", z);

    return 0;
}

if, for, while, switch, goto

자바랑 크게 다르지 않고, 간단한 사용법은 C CheatSheet가 잘 되어있어서 생략.

공부하면서 필요하다고 생각한 부분만 메모

  • 논리 연산자
    • AND: &&
    • OR: ||
    • & 대신 &&를 쓰는 이유
      • &는 확실하게 비트 AND 연산 외의 경우에는 (유효하더라도) 사용하지 않는게 좋다.
      • Short Circuit Evaluation 떄문인데, 더 이상 논리를 확인하지 않고 결과를 알 수 있는 경우 생략한다. &는 이 SCE를 지원하지 못한다. (정확한 계산 결과가 필요하기 때문)
  • switch 문이 필요한 이유
    • if-else보다 성능이 더 좋은 경우가 있다.
    • if-else의 경우 매 블록마다 비교 연산(CMP)이 발생한다. 최악의 경우, 모든 조건을 평가한다.
    • switch문은 jump table을 사용해서 효율적으로 처리한다. 주어진 값에 따라 해당 로직을 처리하는 주소로 jump하기 때문에 case가 아무리 많아져도 효율적으로 처리된다. (한 번의 검사)
    • 이게 가능한 이유는 switch문의 변수 타입이 제한되어 있기 때문이다.
      • 반대로 if-else문에 작성하는 조건은 자유롭게 작성할 수 있다.
    • switch문이 효율적으로 처리되기 위한 조건
      • 어셈블리어를 설명해야 해서 대충 결론만 설명. 아래 경우가 해당이 안되면 다른 방식으로 최적화 하거나 if-else 처럼 처리하기도 함.
      1. 케이스가 상수 값이어야 함: 변수가 들어가면 jump table로 최적화가 불가능
      2. 케이스 값이 연속적이거나 범위가 좁아야 함: 값 간의 차이가 크면 jump table로 최적화가 비효율적임
    • if-elseswitch 사용에 대한 개인적인 생각
      • 최적화, 가독성을 위한 제한적인 경우 말고는 if-else가 더 좋다고 생각함.
      • switch는 들어오는 변수 타입을 제한해 버려서 확장이 어렵기 때문임.
        • int나 문자열처럼 제한적인게 확실한 경우면 모를까.
        • 코드를 개발하다보면 여러 조건을 처리하게 되는데, 이런 조건을 작성하는데 제한이 생기는게 좋지 않다고 생각함.
        • 물론 여러 조건을 작성해야 하는 경우 자체가 코드 품질을 떨어트리거나, 설계의 오류가 될 수도 있다고 생각하는데
          • 실무에서는 그런 돌발 상황을 처리해야 할 때가 있을꺼라 생각함.
goto문 사용법

goto문을 정리할 필요가 있나 싶긴 한데, 그래도 나중에 궁금해질까봐 간단하게 정리함.

레이블이름:으로 레이블을 정의, goto 레이블이름으로 이동 가능하다.

단, 가독성, 유지보수성, 구조적 프로그래밍 원칙 위반 등의 문제로 거의 사용하지 않는다.

#include <stdio.h>

int main() {
    int i = 0;

loop:
    printf("%d\n", i);
    i++;

    if (i < 5)
        goto loop;

    return 0;
}

형변환(Casting)

(타입이름) 변수 이름을 사용해서 캐스팅 할 수 있다.

서로 다른 구조체/공용체(union) 사이의 형변환이나 상수 포인터의 형변환 등을 제외하고 대부분의 타입 간에서 형변환이 수행 가능하다.

  • 암시적 형변환
    • 형변환(캐스팅) 기능을 사용하지 않는 것.
    • 컴파일 에러가 발생하지는 않지만, 의도하지 않는 값이 들어갈 수 있으므로 작은 크기의 데이터를 큰 데이터로 변환하는 경우 외에는 명시적으로 사용하는걸 권장한다.
    • 기존의 데이터가 손실될 수 있는 경우, IDE나 컴파일러가 경고를 해준다.
  • 명시적 형변환
    • (타입이름) 변수 이름을 사용한 형변환
  • 형변환 시 원본 데이터가 수정되지는 않는다.
    • 임시 변수를 사용하거나,
    • 데이터를 다른 식으로 해석한다.
      • 4바이트 중 뒤 2바이트만 읽어서 intshort로 바꾼다거나 하는 식

배열

사이즈가 고정된 정적 배열이다. 선언 시 정해줘야 함.

배열의 크기를 벗어나는 인덱스로 조회하더라도 컴파일 예외가 발생하지 않기 때문에 주의해야한다.

  • 타입 배열이름[] = {값 목록1, 2}
    • 깂 목록 개수만큼 크기가 정해진다.
    • 아니면 다음처럼 사용할 수도 있다.
      • 타입 배열이름[배열 크기] = {값 목록1, 2}
      • 이러면 0~1번째는 초기화되고, 나머지는 더미 값이 들어간다.
  • 타입 배열이름[배열 크기]
    • 초기화 없이 선언만
  • 배열 안에 배열을 집어넣어 N차원 배열을 만들 수 있다.
    • 타입 배열이름[1번째 배열 크기] ... [N번째 크기]
    • 필요에 따라 초기화를 할 수 있는데,
      • {{1,3}, {2,4}} 처럼 중첩해서 사용하거나
      • {1,3,2,4} 처럼 힌 줄로 초기화 할 수도 있다.
      • 어차피 컴파일러 입장에서는 연속된 메모리 공간에 저장되므로 동일하다.
int myNumbers[] = {25, 50, 75, 100};

printf("%d", myNumbers[0]);
// output 25

// Declare an array of four integers:
int myNumbers[4];

// add element
myNumbers[0] = 25;
myNumbers[1] = 50;
myNumbers[2] = 75;
myNumbers[3] = 100;

C99이후 지원하는 가변 배열

(찾아보니까 별로 권장하는 방식은 아닌듯. 차라리 동적(malloc)으로 사용하는게 더 좋다고 한다. 가변 배열은 안전성이나 호환성, 디버깅 면에서 문제가 있다고 한다.)

배열 선언 시 크기를 런타임에 정하게 할 수 있다.

scanf로 입력받은 만큼의 크기를 선언할 수도 있음.

단, 어떤 위치에서 사용되는 Stack 영역에 정의되기 때문에 스택 오버플로우가 발생하지 않게, 크기를 잘 고려해야 한다.

  • 배열의 메모리 위치
    • 정적/전역 배열: Data
    • 동적 배열(malloc같은거 사용 시): Heap
    • 지역(함수 내부)/동적 배열: Stack

불완전 배열 타입 (Incomplete Array Type)

void printArray(int arr[][3], int rows);와 같이 함수 호출 시, 첫 번째 차원의 크기를 생략할 수 있다.

포인터와 관련된 개념인데, 처음 영역은 없어도 어차피 시작 주소를 구하는데는 문제가 없기 때문이다. (대신 끝 부분을 알 수 없으므로 따로 받아야 한다.)

다른 요소가 필요한 이유는 offset을 통해 접근하는데 기준이 되어줘야 하기 떄문이다.

포인터

개념 자체는 이해하고 있으므로 정리하지 않았다. (nand2tetris 하면서 저수준에서 많이 사용했다. Stack Pointer도 이름 자체가 포인터고, 주소값을 담고 있다. 아니면 Call Stack의 Frame의 Callee 주소도 그렇고)

아래 내용은 봤던 개발 블로그의 설명인데, 나는 이것보다 명확하게 설명할 방법이 생각나지 않는다.

메모리 상에 위치한 특정한 데이터의 (시작)주소값을 보관하는 변수

다음과 같이 정의한다.

  • (포인터에 주소값이 저장되는 데이터의 형) *(포인터의 이름);
  • (포인터에 주소값이 저장되는 데이터의 형)* (포인터의 이름);

포인터는 주소값을 저장한다. 따라서 포인터의 크기는 데이터 타입에 관계없이 시스템 아키텍처에 따라 결정된다.
32비트 시스템에선 4byte, 64비트 시스템에선 8byte이다.

타입 정의가 필요한 이유: 포인터는 특정 데이터의 시작 주소값을 저장하고 있다. 이 시작 주소를 어디까지 읽어야 하는지는 타입(데이터의 크기)를 알아야 하기 때문이다.

*&

포인터를 사용하다보면 *&를 사용하는데, 둘 다 해당 기호룰 사용하는 연산자가 있다.

연산을 위한 *&의 경우에는 두개의 피연산자가 필요하고, 포인터에서 주로 사용되는 *&는 하나의 피연산자(단항)만 필요하기 때문에 구분된다.

* 연산자

* 연산자는 포인터가 가리키는 메모리 주소에 저장된 값을 참조(역참조)하는데 사용된다.

선언 시에는 특정 변수가 포인터임을 나타낸다.

역참조 시에는 포인터가 가리키는 메모리 주소의 값을 가져온다. 이를 통해 함수의 인자로 전달되더라도 원본에 접근할 수 있다.

주소를 통해 간접적으로 접근하기 때문에 간접 연산자라고도 부른다.

& 연산자

다음과 같이 사용한다. &(주소값을 계산할 데이터)

& 연산자는 변수의 메모리 주소를 반환한다.

참조와 역참조

  • 참조 (& 연산자): 변수의 메모리 주소를 가져오는 것.
    • 예: int *p = &x; (변수 x의 주소를 포인터 p에 저장)
  • 역참조 (* 연산자): 포인터가 가리키는 주소의 값을 가져오는 것.
    • 예: int y = *p; (포인터 p가 가리키는 주소의 값을 변수 y에 저장)

포인터와 상수(const)

위치에 따라 2가지의 의미를 가진다.

  • const int *p = &x: 포인터가 가리키는 데이터가 상수
    • x는 변경 가능하지만, *p를 통해서 변경할 수 없음.
  • int const *p = &x: 포인터 자체가 상수
    • *p는 변경 가능하지만, p는 변경 불가.
  • const int *const p = &x: 포인터가 가리키는 데이터와 포인터가 상수

포인터의 연산

다음 연산이 가능하다.

  • 포인터 값에 덧셈과 뺄셈
    • 1을 더하더라도, 타입에 맞게 주소를 이동한다. (int *++ 하면 4바이트를 더함)
  • 포인터 간의 뺼셈: 두 포인터 간의 거리 계산. (덧셈은 불필요하므로 지원하지 않음.)
  • 포인터 비교

포인터와 배열

배열에 sizeof를 사용하면 할당된 영역 만큼의 크기를 반환한다.

하지만, 배열에서 배열의 이름은 배열의 첫 번째 원소의 주소값을 나타낸다. (arr의 값이 arr[0]의 주소와 같다. 따라서 다음과 같이 사용 가능 int *ptr = arr, ptr은 첫 번째 요소를 바라보는 포인터이다.)

내부적으로 배열은 [] 연산자(몰랐는데, []이 연산자라고 한다.)를 사용하여, arr(배열의 첫번째 시작 주소) + offset 연산을 통해 데이터를 가져온다.

포인터를 사용하여 배열의 데이터를 가져오는 것과 내부적으로는 동일하게 동작한다.

따라서 arr[3]*(arr + 3)는 같다. (심지어 3[arr] 처럼 이상하게 사용할 수도 있다. 내부적으로는 *(3 + arr)으로 계산하기 때문)

다만 배열은 배열이고, 포인터는 포인터이기 때문에 배열 변수를 포인터처럼 다룰 수는 없다. (arr++은 컴파일 에러 발생)

그래도 "N차원 배열은 N중 포인터로 다룰 수 있다." 이 사실을 알아두면 코드 분석할 때 편할 것 같음. 함수 호출하거나 할때, 배열 대신 포인터로 전달하는 경우도 있던거 같음... (아마 동적 메모리 할당을 사용하는 경우)

#include <stdio.h>  
  
int main() {  
    // 배열 선언 및 초기화  
    int arr[5] = {10, 20, 30, 40, 50};  
    int *ptr = arr; // 배열의 첫 번째 원소의 주소를 가리키는 포인터  
  
    // sizeof 연산자 사용  
    printf("배열의 크기 (바이트): %zu\n", sizeof(arr)); // 배열 전체의 크기  
    printf("배열의 원소 개수: %zu\n", sizeof(arr) / sizeof(arr[0])); // 원소 개수  
    printf("포인터의 크기 (바이트): %zu\n", sizeof(ptr)); // 포인터의 크기  
  
    // 배열의 이름과 첫 번째 원소의 주소 비교  
    printf("배열의 첫 번째 원소의 주소: %p\n", (void*)&arr[0]);  
    printf("배열의 이름 (첫 번째 원소의 주소): %p\n", (void*)arr);  
  
    // 배열의 데이터 접근 방법  
    printf("arr[3]: %d\n", arr[3]);  
    printf("*(arr + 3): %d\n", *(arr + 3));  
    printf("3[arr]: %d\n", 3[arr]); // 이상한 방식으로 배열 접근  
  
    // 포인터를 사용하여 배열의 데이터 접근  
    printf("ptr[3]: %d\n", ptr[3]);  
    printf("*(ptr + 3): %d\n", *(ptr + 3));  
  
    // 배열은 배열이고, 포인터는 포인터입니다. 배열 변수는 포인터처럼 다룰 수 없습니다.  
    // arr++; // 이 줄을 주석 해제하면 컴파일 에러가 발생합니다.  
  
    // 포인터는 포인터 연산을 사용할 수 있습니다.  
    ptr++;  
    printf("포인터가 가리키는 두 번째 원소: %d\n", *ptr);  
  
    return 0;  
}

2중 포인터

#include <stdio.h>  
  
int main() {  
    int a;  
    int *pa;  
    int **ppa;  
  
    pa = &a;  
    ppa = &pa;  
  
    a = 3;  
  
    printf("a : %d // *pa : %d // **ppa : %d \n", a, *pa, **ppa);  
    printf("&a : %p // pa : %p // *ppa : %p \n", &a, pa, *ppa);  
    printf("&pa : %p // ppa : %p \n", &pa, ppa);  
  
    return 0;  
}

포인터 배열과 배열 포인터

둘은 다른 개념이다.

포인터 배열 (Array of Pointers)

int *arr[]

포인터를 원소로 가지는 배열

#include <stdio.h>

int main() {
    int a = 10, b = 20, c = 30;
    int *arr[3] = {&a, &b, &c}; // 포인터 배열

    for (int i = 0; i < 3; i++) {
        printf("arr[%d]가 가리키는 값: %d\n", i, *arr[i]);
    }

    return 0;
}

**arr 로도 비슷하게 다룰 수 있긴 하다.

배열 포인터 (Pointer to Array)

int (*ptr)[]

괄호를 사용하지 않으면 포인터 배열로 인식하기 때문에 괄호를 꼭 사용해줘야 한다.

컴파일러가 배열의 각 요소에 접근하기 위해 필요한 메모리 오프셋을 계산할 수 있도록 [] 안에 내부 배열의 크기를 적어줘야 한다.

#include <stdio.h>

int main() {
    int arr[3][4] = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12}
    };
    int (*ptr)[4] = arr; // 배열의 첫 번째 행을 가리키는 포인터

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 4; j++) {
            printf("%d ", ptr[i][j]); // arr[i][j]와 동일
        }
        printf("\n");
    }

	for (int i = 0; i < 3; i++) {  
	    for (int j = 0; j < 4; j++) {  
	        printf("%d ", ptr[0][j]); // arr[i][j]와 동일  
	    }  
	    ptr++;  
	    printf("\n");  
	}

    return 0;
}

포인터 예제 코드

#include <stdio.h>

int main() {
	int *p;
	int a;
	
	p = &a;
	*p = 3;
	
	printf("a 의 값 : %d \n", a);
	printf("*p 의 값 : %d \n", *p);
	
	return 0;
}
#include <stdio.h>

int main() {  
    int x = 42;  
    int *p = &x;  // x의 주소를 p에 저장  
  
    printf("x의 주소: %p\n", &x);  // x의 주소 출력  
    printf("p의 값(즉, x의 주소): %p\n", p);  // p의 값 출력  
    printf("p가 가리키는 값: %d\n", *p);  // p가 가리키는 값(x의 값) 출력  
  
    *p = 100;  // p가 가리키는 주소의 값 변경 (즉, x의 값 변경)  
    printf("x의 새로운 값: %d\n", x);  // x의 값 출력  
  
    return 0;  
}
#include <stdio.h>

int main() {
    int x = 10;
    int y = 20;

    // 포인터가 가리키는 데이터가 상수인 경우
    const int *ptr1 = &x;
    // *ptr1 = 30; // 오류
    ptr1 = &y; // 가능

    // 포인터 자체가 상수인 경우
    int *const ptr2 = &x;
    *ptr2 = 30; // 가능
    // ptr2 = &y; // 오류

    // 포인터가 가리키는 데이터와 포인터 자체가 모두 상수인 경우
    const int *const ptr3 = &x;
    // *ptr3 = 40; // 오류
    // ptr3 = &y; // 오류

    return 0;
}
#include <stdio.h>  
  
int main() {  
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};  
    int *parr;  
    int i;  
    parr = &arr[0];  
  
    for (i = 0; i < 10; i++) {  
        printf("arr[%d] 의 주소값 : %p ", i, &arr[i]);  
        printf("(parr + %d) 의 값 : %p\n", i, (parr + i));  
    }  
    return 0;  
}

함수

개념은 생략.

int main() 또한 함수이다. Call Stack에 올라간다.

상수(const)를 사용해서 int func(const int val); 처럼 인자 값을 불변으로 정의할 수 있다.

함수 원형(prototype)

함수는 미리 정의되어야 사용할 수 있다. 일반적으로 main이 맨 위에 온다.
그래서 원형(prototype)을 사용해서 함수의 정의 전에 작성되어 컴파일러가 함수 호출을 이해할 수 있도록 한다.

또한 서로 호출하는 함수가 존재할 수도 있기 때문에 꼭 필요하다.

함수 원형을 선언할 때는 double get_average(double, double);처럼 파리미터의 이름을 명시하지 않는다. 이름이 바뀔 수 있기 때문이다.

Call By Value/Reference
  • Call By Value: 함수에 값의 복사본을 전달. 원본 값에는 영향 없음. 일반적인 타입 (구조체 포함)
  • Call By Reference: 함수에 변수의 주소를 전달. 원본 값이 변경됨. 포인터를 사용함.
#include <stdio.h>  
  
int swap(int *a, int *b) {  
    int temp = *a;  
  
    *a = *b;  
    *b = temp;  
  
    return 0;  
}  
  
int main() {  
    int i, j;  
  
    i = 3;  
    j = 5;  
  
    printf("SWAP 이전 : i : %d, j : %d \n", i, j);  
  
    swap(&i, &j);  
  
    printf("SWAP 이후 : i : %d, j : %d \n", i, j);  
  
    return 0;  
}
함수 포인터

쓸 일이 뭐 있나 싶긴 한데, 함수 역시 주소 값을 가지고 있으므로 함수 포인터를 사용할 수도 있다.

#include <stdio.h>  
  
int max(int a, int b);  
  
int main() {  
    int a = 1;  
    int b = 2;  
    int (*pmax)(int, int) = max;  
  
    printf("max(a,b) : %d \n", max(a, b));  
    printf("pmax(a,b) : %d \n", pmax(a, b));  
  
    return 0;  
}  
  
int max(int a, int b) {  
    if (a > b)  
        return a;  
    else  
        return b;  
}
main 함수의 인자

다음과 같이 인자를 가지도록 선언할 수도 있다. int main(int argc, char **argv)

인자의 이름은 필수는 아니지만,

  • argc (argument count): 프로그램이 실행될 때 전달되는 인자의 개수
  • argv (argument vector): 프로그램에 전달된 인자들을 가리키는 문자열 배열. argvchar ** 타입으로, 문자열 배열을 가리키는 포인터이다. 따라서 argv[i]와 같이 인덱스로 접근할 수 있으며, 각 요소는 char * 타입의 문자열을 가리킨다. (char *char[] 와 차이가 있기는 하지만, 비슷함. char *는 수정 불가능한 문자열 리터럴의 시작 주소를 가리킴)
    • 아니면 * argv[]로도 받을 수 있다.
#include <stdio.h>

int main(int argc, char **argv) {
  int i;
  printf("받은 인자의 개수 : %d \n", argc);

  for (i = 0; i < argc; i++) {
    printf("이 프로그램이 받은 인자 : %s \n", argv[i]);
  }

  return 0;
}
static function

함수 앞에 static 키워드를 붙여 사용한다.

함수를 해당 소스파일에서만 사용 가능하게 만든다.

참고

문자열

NULL

문자열의 가장 마지막에는 자동으로 \0(NULL) 문자가 들어간다. (문자열을 사용할 때마다 매번 길이를 구하지 않을 수 있기 때문에 이렇게 만들어졌다고 함.)

NULL는 null pointer constant(널 포인터 상수)로, 컴파일러에 의해 (void*)0로 정의된다.

다만 문자열의 경우 0을 표시하기 위해 \0라는 문자를 null로 사용한다.

null 문자는 아스키 값이 0이다. (실제 0은 아스키코드 48이다.)

stdin

stdin은 입력 스트림 혹은 입력 버퍼로, scanf()를 사용하는 경우 해당 버퍼의 값을 읽는다.

scanf("%c", &c);처럼 char를 받는건 권장하지 않다.
경우에 따라 버퍼에 \n(개행문자)가 남아있을 수 있는데, char는 한 개의 문자만을 읽기 떄문에, 남아있는 개행문자를 가져와버린다.

따라서 문자를 하나 받더라도 문자열 %s로 입력받고 처리하는게 좋다.

결론적으로 요약하자면 %s 나 %d 그리고 다른 모든 수 데이터를 입력 받는 형식은 버퍼에 남아 있는 공백 문자에 신경쓰지 않고 사용할 수 있습니다.

그렇다고 해서 %s 를 입력 받는 후 버퍼가 완벽히 깨끗해 지는 것이 아닙니다. 개행 문자는 뒤에 남아있죠!

하지만 %c 를 이용할 때 에는 버퍼에 무엇이 남아 있는지 잘 고려해야 합니다.

리터럴

문자열 리터럴은 다음과 같이 정의하고 사용한다.

char *str1 = "Hello";

프로그래밍 언어에서 리터럴(literal)이란, 소스 코드 상에서 고정된 값을 가지는 것을 일컫습니다. 특히, C 언어의 경우 큰 따옴표(") 로 묶인 것들을 문자열 리터럴(string literal) 이라 부릅니다.

문자열을 입력받는 경우가 아니라, 코드에서 명시하는 경우 리터럴 값으로 처리된다.

텍스트 세그먼트(text segment) 에 프로그램 코드와 상수, 리터럴 등이 위치하는데, 해당 영역은 읽기 작업만 수행할 수 있다.

따라서 리터럴 값을 새로 정의하는게 아니라 str_val[0] = 'a' 와 같이 수정하는 경우 에러가 발생한다.

다만 char str2[] = "Hello";와 같이 배열로 정의하는 경우, 리터럴 값을 복사해서 배열(stack 영역에 생성됨)에 저장하기 때문에 문제 없이 수정할 수 있다. (정적, 전역 배열의 경우, data에 정의된다. 이 영역 또한 읽기와 수정이 가능하다.)

예시

문자열 합치기

#include <stdio.h>  
  
int stradd(char *dest, char *src);  
  
int main() {  
    char str1[100] = "hello my name is ";  
    char str2[] = "Psi";  
  
    printf("합치기 이전 : %s \n", str1);  
  
    stradd(str1, str2);  
  
    printf("합친 이후 : %s \n", str1);  
  
    return 0;  
}  
  
int stradd(char *dest, char *src) {  
    /* dest 의 끝 부분을 찾는다.*/  
    while (*dest) {  
        dest++;  
    }  
  
    /*  
    while 문을 지나고 나면 dest 는 dest 문자열의 NULL 문자를 가리키고 있게 된다.  
    이제 src 의 문자열들을 dest 의 NULL 문자 있는 곳 부터 복사해넣는다.  
    */    
    while (*src) {  
        *dest = *src;  
        src++;  
        dest++;  
    }  
  
    /* 마지막으로 dest 에 NULL 추가 (왜냐하면 src 에서 NULL 이 추가 되지  
     * 않았으므로) */  
    *dest = '\0';  
  
    return 1;  
}

구조체(struct)

다음과 같이 정의한다.

struct 구조체이름 {
	타입 변수이름; // 구조체 정의 시 초기화를 할 수 없다.
};

// 코드에서는 다음과 같이 접근한다.
int main() {
	struct 구조체이름 구조체변수이름 = {초기 값1, 2 ... }

	struct 구조체이름 구조체변수이름;

	구조체변수이름.멤버변수A = 'A'
	printf(구조체변수이름.멤버변수B)
}
구조체 포인터

struct 구조체이름 *포인터이름 = &기존구조체; 처럼 일반적인 타입들처럼 사용할 수 있다. (크기 역시 일반적인 포인터와 동일하다. 그냥 구조체에 사용하는 포인터일 뿐, 별 차이 없다.)

다만 포인터의 멤버에 접근하는 경우 ->를 사용하는 것이 좋다.

일반적인 포인터를 사용하는 경우 (*ptr).a와 같이 (특정 구조체 포인터 역참조).멤버임을 알려야 하기 때문이다.
그렇지 않으면 *(ptr.a)로 인식해 버린다.
이런 불편함을 해결하기 위해 C언어에서 구조체에 접근할 때는 -> 연산자를 사용하는 것을 권장한다. (그럼 ptr->a와 같이 편리하게 사용할 수 있다.)

#include <stdio.h>

struct test {
    int a, b;
};

int main() {
    struct test st;
    struct test *ptr;
    ptr = &st;
    (*ptr).a = 1;
    ptr->b = 2;
    printf("st 의 a 멤버 : %d \n", st.a);
    printf("st 의 b 멤버 : %d \n", st.b);
    return 0;
}
구조체 멤버인 포인터

다음과 같이 사용할 수 있다.

pt->pointer를 통해서 구조체의 멤버인 포인터에 접근하고, *를 사용하여 포인터가 참조하는 값을 수정한다.

#include <stdio.h>  
  
struct TEST {  
    int c;  
    int *pointer;  
};  
  
int main() {  
    struct TEST t;  
    struct TEST *pt = &t;  
    int i = 0;  
    t.pointer = &i;  
    *t.pointer = 3;  
    printf("%i \n", *t.pointer);  
    *pt->pointer = 4; // *(pt->pointer) = 4와 동일  
    printf("%i \n", *t.pointer);  
    return 0;  
}

반대로 &pt->c와 같은 연산도 가능하다. 이런 경우, add_one(int *a)처럼

#include <stdio.h>
int add_one(int *a);
struct TEST {
    int c;
};
int main() {
    struct TEST t;
    struct TEST *pt = &t;

    /* pt 가 가리키는 구조체 변수의 c 멤버의 값을 0 으로 한다*/
    pt->c = 0;

    /*
    add_one 함수의 인자에 t 구조체 변수의 멤버 c 의 주소값을
    전달하고 있다.
    */
    add_one(&t.c);

    printf("t.c : %d \n", t.c);

    /*
    add_one 함수의 인자에 pt 가 가리키는 구조체 변수의 멤버 c
    의 주소값을 전달하고 있다.

    */
    add_one(&pt->c);

    printf("t.c : %d \n", t.c);

    return 0;
}
int add_one(int *a) {
    *a += 1;
    return 0;
}

& 연산자를 사용하여 변수의 주소를 전달하고, 그 값을 변경하는 걸 볼 수 있다.

추가

다음과 같이 구조체 정의와 선언을 함께 할 수 있다.

#include <stdio.h>  
  
struct HUMAN {  
    int age;  
} Adam = {31}, Eve;  
  
int main() {  
  
    printf("%d \n", Adam.age);  
  
    Eve.age = 42;  
    printf("%d \n", Eve.age);  
  
    return 0;  
}
구조체 패딩
  • Data Structure Padding
    • 메모리 정렬 규칙을 맞추는 것.
    • 구조체에서 많이 발생할 수 있지만, 변수나 배열, 동적 메모리 모두 시작 주소가 메모리 경계에 맞춰지므로 거의 모든 부분에서 패딩이 생긴다고 보는게 맞는 것 같다.
      • 구조체에서 중점적으로 다루는 이유는, 연속된 데이터의 집합이지만 각 타입의 크기가 다르기 때문.
    • 메모리 경계를 맞춰서 데이터가 저장되어야 효율적으로 접근할 수 있다. 대신 메모리 공간의 낭비가 발생하는 편.
      • 네트워크 통신 등에서 메모리 사용량을 줄이기 위해 패딩을 최소화 하기도 함. (일반적으로 직렬화)
    • 기본 메모리 경계는 컴퓨터의 비트 수에 결정됨.
      • 요즘 컴퓨터는 대부분 64bit 컴퓨터이므로 8byte 단위의 경계를 가짐.
      • 이 보다 더 큰 데이터 타입의 경우 정렬을 보장하지 않는다.
  • Data Alignment(정렬 규칙)
    • 1바이트 데이터: 어느 주소에서나 저장 가능 (char)
    • 2바이트 데이터: 2의 배수 주소에서 저장 (short)
    • 4바이트 데이터: 4의 배수 주소에서 저장 (int, float)
    • 8바이트 데이터: 8의 배수 주소에서 저장 (double, long long)

추가로 참고할 만한 글

공용체(union)

멤버끼리 메모리를 공유한다. 이는 타입이 많아져도 동일하다.

intchar가 있다면, char가 처음 1byte, int가 처음부터 4byte를 점유한다. 즉, 처음 1byte의 값을 공유해서 사용한다.

이걸 어디에 써? 하고 찾아보니 여러 데이터 타입 중 하나만 유효해도 되는 경우, 메모리 절약 용도로 (통신 프로코톨 메시지 처리) 사용한다고 한다. (사실 이해 잘 못했음)

다만 주의해야 하는게, 엔디언에 따라 값이 달라질 수 있다. x86, x86-64, ARM는 리틀 엔디언을 사용한다.

  • 빅 엔디언: 0x12345678이 메모리에 다음과 같이 저장: 12 34 56 78
  • 리틀 엔디언: 0x12345678이 메모리에 다음과 같이 저장: 78 56 34 12
    • 저장하는 데이터의 "순서"가 반대인거지. 데이터 "값"의 순서는 동일하다. 2byte를 읽으면 0x5678이 나오는 식

엔디언

열거형(Enum)

다음과 같이 정의할 수 있다.

enum EnumName {
    Value1,
    Value2,
    // ...
};

각 요소는 컴파일 시에 정수로 평가된다.

#include <stdio.h>

enum Colors {
    RED = 3,
    BLUE,
    WHITE = 3,
    BLACK
};

int main() {
    enum Colors color;
    
    color = RED;
    printf("RED: %d\n", color);

    color = BLUE;
    printf("BLUE: %d\n", color);

    color = WHITE;
    printf("WHITE: %d\n", color);

    color = BLACK;
    printf("BLACK: %d\n", color);

    return 0;
}

// 출력 결과
RED: 3
BLUE: 4
WHITE: 5
BLACK: 6

하지만 다음과 같이 사용한다면 문제가 발생할 수 있기 때문에, Enum 정의 시 정수값을 직접 넣어주는 건 주의해야 할 것 같다.

#include <stdio.h>  
  
enum Colors {  
    RED = 3,  
    BLUE,  
    WHITE = 3,  
    BLACK  
};  
  
int main() {  
    enum Colors color;  
  
    color = RED;  
    if (color == WHITE) {  // color는 RED 임에도 조건문이 true가 된다.
        printf("color is white!"); 
    }  
  
    return 0;  
}

얕은 복사 깊은 복사

  • 일반적인 타입: 포인터(혹은 주소를 저장하는 타입)가 아닌 값을 직접 저장하는 int, char 등 기본 데이터 타입은 얕은 복사라는 개념이 없습니다. 이러한 타입은 값 자체를 복사합니다.

  • 배열, 구조체: 배열이나 구조체가 요소나 멤버로 참조(포인터, 배열, 구조체 등)를 가지는 경우, 기본적으로 얕은 복사가 수행된다.

    • 구조체의 경우: 만약 구조체 멤버 중 포인터(배열, 구조체도 포함)가 포함되어 있다면, 포인터의 값(즉, 메모리 주소)만 복사되며, 포인터가 가리키는 실제 데이터는 복사되지 않는다. 이는 원본과 복사본이 동일한 메모리 주소를 가리키게 되어, 한쪽의 변경이 다른 쪽에도 영향을 미칠 수 있음을 의미한다.
    • 배열의 경우: 배열은 대입 연산자를 직접 사용할 수 없으므로, memcpy 함수나 루프를 사용하여 수동으로 복사한다. 이 경우에도 얕은 복사가 발생한다.

그래서 참조 변수(포인터, 배열, 구조체 등)의 경우, 필요한 경우 의도적으로 깊은 복사를 구현해야 한다.

  • 얕은 복사: 포인터가 가리키는 메모리 주소만 복사하여 원본과 복사본이 동일한 데이터를 공유
  • 깊은 복사: 포인터가 가리키는 실제 데이터까지 모두 복사하여 원본과 복사본이 독립적인 메모리 공간을 가짐

변수의 생존 조건 및 데이터 세그먼트 구조

  • 전역 변수
    • 가장 바깥(함수 내부 x)에서 변수 선언 시 해당 변수는 전역 변수
    • 전역변수는 기본적으로 0으로 초기화 됨 (초기화 하지 않았을 때)
    • 가능하면 최소한으로 사용하기 (모든 영역에서 접근/수정 가능하므로)
  • 지역 변수
    • 함수 내부에서 정의되는 변수, 함수가 종료되면 함께 없어짐
  • 정적 변수
    • static 타입 변수이름 = 값;으로 정의
    • 초기화는 단 한 번만 수행됨, 이후 호출해도 문제 없음
    • 선언된 범위를 값이 제거되지 않음. (외부에서 접근 가능하다는 소리는 아님)

전역 변수 예시

#include <stdio.h>  
  
int global_var1;  
int global_var2 = 11;  
  
int main() {  
    printf("%d", global_var1);  
    printf("%d", global_var2);  
    return 0;  
}

정적 변수 예시

#include <stdio.h>  

int function() {  
    static int how_many_called = 0;  
  
    how_many_called++;  
    printf("function called : %d \n", how_many_called);  
  
    return 0;  
}  
  
int main() {  
    function();  
    function();  
    function();  
    function();  
    return 0;  
}
스코프

같은 이름을 가지는 변수더라도, 서로 다른 스코프에 있으면 호출하는 코드와 가장 가까운 변수를 사용한다. 코드블럭({})이나 if,while,for 내부이거나 등...

int i;          /* 1번 선언 */

void f(int i)   /* 2번 선언 */
{
    i = 1;      /* 2 */
}

void g(void)
{
    int i = 2;  /* 3번 선언 */

    if (i > 0) {    /* 3 */
        int i;  /* 4번 선언 */
        i = 3;  /* 4 */
    }

    i = 4;          /* 3 */
}

void h(void)
{
    i = 5;      /* 1 */
}
데이터 세그먼트 주소

씹어먹는 C언어를 주로 참고하는데, 입문 강의라서 이게 정확한지는 모르겠다.

다음과 같은 구조를 가진다.

+-----------------------+ 높은 주소
|       스택 (Stack)     |
|       ...             |
|   동적 할당 (Heap)      |
|       ...             |
|  Data (전역, 정적 변수)  |
| Read-Only Data(상수, 리터럴) |
|    코드/텍스트           |
+-----------------------+ 낮은 주소

모듈화

여러 개의 c 언어 파일로 큰 프로젝트를 구현하는 법 알아보기

지시자

전처리기(Preprocessor)는 컴파일러가 실행되기 전에 실행된다.
전처리기 지시자(Directives)를 전처리기가 처리한다. 모두 #으로 시작하며, 하나의 줄에 하나의 지시자만 있어야 한다.

지시자 종류

  • 파일 추가
    • #include
      • 파일을 포함하는 지시자. 다른 파일의 내용을 현재 파일에 삽입. (헤더 파일 .h 를 가져오는데 사용, .c도 가능하긴 한데, 중복 정의 등의 문제가 발생한다. 참고)
      • #include에 정의한 헤더 파일들은 전처리기가 실제로 파일에 삽입해준다.
        • 전처리 이후에 헤더 파일이 실제로 소스코드에 추가된다. (.i 파일이 된다.)
      • 사용 방법
        • #include <_filename_>: 시스템 헤더 파일들이 있는 디렉토리(혹은 디렉토리들)에서 파일을 찾는다.
        • #include "filename": 현재 디렉토리에서 파일을 찾는다. 만약 존재하지 않는다면 시스템 헤더 파일이 존재하는 디렉토리에서 찾는다.
          • 상대 경로를 사용하거나 절대 경로를 사용해서 가져오게 할 수도 있는데, 컴파일 시 옵션을 사용하는 걸 권장한다. (가독성, 재사용성, 통합성 면에서 빌드 파일이나 컴파일 옵션으로 설정하는게 더 좋다.)
          • 컴파일 시, -I 명령어를 사용해서 추가로 탐색할 디렉토리 경로를 지정할 수 있다.
  • 매그로 정의
    • #define
      • 매크로를 정의하는 지시자. 상수나 간단한 함수 형태를 정의할 때 사용
    • #undef
      • 정의된 매크로를 헤제하는 지시자.
  • 조건적 컴파일
    • #if, #ifdef, #ifndef, #else, #elif, #endif
      • 조건부 컴파일 제어 지시자. 특정 조건에 따라 코드를 컴파일할지 말지를 결정
      • 사용 예시: 특정 운영체제에 종속적인 헤더 파일을 가져오기. 동일한 메서드를 가지는데 로직이 다른 경우.
  • 기타
    • #error
      • 컴파일을 중단하고, 정의한 에러 메시지 출력
    • #pragma
      • 컴파일러에게 특별한 명령을 전달하는 지시자. 컴파일러에 종족적. 딱히 표준이 없음
      • 컴파일러마다 지원하는 프라그마가 다를 수 있음. 지원하지 않으면 무시한다.
      • pack(n)을 사용해서 더블 워드 경계를 설정 가능. (구조체 패딩 그거)
      • once를 사용해서 헤더 가드 없이 한 번씩만 읽도록 명령 가능.
    • #line
      • 이미 정의된 매크로 __FILE__ 의 __LINE__값을 재정의한다.
      • 참고

지시자 사용 방법은 아래 GPT 설명 부분 참고하기. 아니면 마이크로소프트 문서 참고

헤더 파일

.h 확장자를 가지는 파일.

주로 다음과 같은 내용이 포함된다.

  • 전역 변수
  • 구조체, 공용체, 열거형
  • 함수의 원형
  • 일부 특정한 함수 (인라인 함수)
  • 매크로

여러 소스코드(.c)를 사용하는 경우, 원형(프로토타입)과 같은 것들을 미리 적어주어야 한다.

이러한 미리 선언해 주어야 하는 파일들을 .h에 모아서 재사용 가능하도록 해준다.

또한 특정 소스코드의 .h는 소스코드 .c에도 추가하는게 좋다. (이 부분은 확실하진 않음. 일반적으로는 헤더 파일에 원형이나 전역변수가 들어가니까, 소스코드에는 원형을 안 적는 것 같다.)

헤더파일에도 #include 지시자를 사용할 수 있다. (필요에 따라 사용하는 걸 권장하는 것 같다.)

통상적으로 C 프로그래머들은 내부 인클루드를 피하는 편이다(초창기 C에서는 애초에 가능하지도 않았다.) 하지만 C++에선 내부 인클루드가 흔하기 때문에 현재는 내부 인클루드에 대한 선입견은 많이 사라졌다.

매크로

매크로보다는 C99에 추가된 인라인 함수 사용을 권장한다.

매크로를 정의하면, 전처리기가 정의한 매크로 변수/함수를 정의한 값/함수로 대체한다.

주의할 점은 그대로 치환하기 때문에, 계산 식에서 문제가 생길 수 있다.

예를 들어, #define square(x) x * x 가 있을 때, square(3 + 1)3 + 1 * 3 + 1로 치환되며, 이처럼 의도치 않은 결과가 나올 수 있다. 이 경우 #define square(x) (x) * (x)와 같이 정의해야 한다.

  • 매크로 변수
    • #define 식별자 대체목록
  • 매크로 함수
    • #define 식별자( x1 , x2 , … , xn ) 대체목록
사전정의 매크로

C언어에서 사전에 정의한 매크로들이 있다.

  • __LINE__: 컴파일하는 파일의 줄 번호
  • __FILE__: 컴파일하는 파일 이름
  • __DATE__: 컴파일 날짜 ("Mmm dd yyyy"의 형식)
  • __TIME__: 컴파일 시간 ("hh:mm:ss"의 형식)
  • __STDC__: 만약 컴파일러가 C 표준(C89 혹은 C99)일시 1
    • 참고한 책이 2008년 기준이라, C 표준 기준이 늘어났을 수도 있음

다음은 C99에서 추가된 것들

  • __STDC__HOSTED__: 호스트 시행이라면 1; 독립적이라면 0의 값을 갖는다 (호스트 시행이 뭔지 설명하는데, 지금 나에게는 필요 없을 것 같아 생략함)

  • __STDC__VERSION__: 지원하는 C의 표준 버전

  • __STDC__IEC_559__: IEC 60559 고정소수점 산술을 지원할 시 1의 값을 갖는다

  • __STDC__IEC_559_COMPLEX__: IEC 60559 복소수 산술을 지원할 시 1의 값을 갖는다

  • __STDC__ISO_10646__: wchar_t의 값이 특정 년도와 월이 ISO 10646 표준을 만족할 시 yyyymmL의 값을 갖는다

  • __func__: 함수의 이름을 저장하고 있는 문자열 변수처럼 행동한다

  • __VA_ARGS__: 가변 인자를 받는다

  • 참고한 자료들

인라인 함수

__inline int square(int a) { return a * a; } 다음처럼 함수 앞에 __inline이 붙는다. (줄바꿈 여부는 상관 없다.)

인라인 함수는 일반적인 함수로 호출되지 않고, 매크로 함수처럼 구현한 내용이 소스코드 내에 포함된다.

매크로 함수와 다른 점은, 컴파일러가 변경해주기 때문에 위의 매크로처럼 치환을 주의할 필요 없이, 일반적인 함수를 사용하는 것 처럼 작성할 수 있다.

헤더 가드 (Include Guard)

헤더 가드가 필요한 이유

같은 헤더 파일을 두 번 추가하는 것이 언제나 컴파일 오류를 일으키는 것은 아니다. 만약 파일이 매크로 정의, 함수 원형, 변수 선언 등만을 갖고 있다면 큰 문제가 되지는 않을 것이다. 만약 파일이 형정의를 갖고 있다면 반대로 컴파일 오류가 날 것이다.

혹시 모를 오류를 위해 모든 헤더 파일을 다중 인클루드로부터 보호해주어야한다. 이를 통해 나중에 파일에 형정의를 추가하더라도 파일을 보호해야한다는 사실을 상기시킬 필요가 없을 것이다. 또한 프로그램 개발 중 같은 헤더 파일을 불필요하게 재컴파일을 하지 않게 막아 시간을 줄일 수도 있을 것이다.

참고

다음과 같이 사용한다.

#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE

// 헤더 파일선언과 정의

#endif

컴파일 과정

(컴파일 코드에 대한 건 아래 GPT.컴파일 시 필요한 설정들 을 참고)

전처리기 -> 컴파일러 -> 링커 순으로 이루어진다.

  • 전처리기
    • 주석 제거, 지시자 처리를 통해 전처리된 소스 파일 (.o) 생성
  • 컴파일러
    • 최적화를 진행하여 기계어 코드와 심볼 테이블을 가지는 목적 파일(.o, .obj) 생성
  • 링커
    • 여러 목적 파일과 라이브러리를 연결하여 하나의 실행 가능한 파일 생성
    • 기호 해석, 주소 할당, 실행 파일 생성 등의 작업

구체적인 과정은 다음 자료들 참고

동적 메모리 할당 (memory allocation)

동적으로 메모리를 할당/반환 하는 기능을 의미한다.

Heap 영역

사용자가 자유롭게 할당하고 해제할 수 있는 영역. 동적으로 데이터를 다뤄야 할 때 사용한다.

주요 함수

#include <stdlib.h>를 해 주어야 기능을 사용할 수 있다.

  • malloc: memory allocation의 약자, 정의한 바이트 수만큼 메모리를 할당하며, 성공하면 포인터, 실패하면 NULL을 반환.
    • 타입 ptr = (자료형*)malloc(할당할 크기); 와 같이 사용
      • malloc은 포인터를 반환하므로 사용하려는 타입에 맞게 형변환해야 한다.
        • 즉, 할당받은 접근하기 위해서는 포인터를 통해야 한다.
    • 할당할 크기는 주로 sizeof(타입, 구조체도 가능) * 필요한 개수를 사용한다.
      • 구조체의 경우 실행 환경이나 padding 여부로 인해 크기가 바뀔 수 있다. (int도 바뀔 수 있다고 들었는데 확실하진 않음) 그래서 sizeof를 사용해야만 한다.
  • calloc: malloc과 비슷하지만 할당된 메모리를 0으로 초기화한다.
  • realloc: 이미 할당된 메모리 블록의 크기를 변경(확장/축소) 위해 사용. 새로 할당된 메모리 블록의 포인터를 반환하며, 원래의 데이터를 보존함.
    • 포인터 주소를 항상 바뀌는 건 아니고, 현재 메모리 블록에서 더 이상 늘릴 수 없는 경우, 새로운 메모리 블록을 재할당한다.
    • 할당이 불가능하다면 NULL을 반환하는데, 이 경우 기존 메모리 블록이 남아있다. 따라서 포인터 주소를 기억하고 있다가 해제해주어야 한다.
  • free: 할당된 메모리를 해제하기 위해 사용한다.

사용 예시

구조체
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 구조체 정의
typedef struct {
    char name[50];
    int age;
} Person;

int main() {
    // 구조체 메모리 동적 할당
    Person* personPtr = (Person*) malloc(sizeof(Person));
    if (personPtr == NULL) {
        fprintf(stderr, "메모리 할당 실패\n");
        return 1;
    }

    // 구조체 필드에 값 할당
    strcpy(personPtr->name, "Alice");
    personPtr->age = 30;

    // 구조체 값 출력
    printf("Name: %s, Age: %d\n", personPtr->name, personPtr->age);

    // 메모리 해제
    free(personPtr);
    return 0;
}
1차원 배열
#include <stdio.h>
#include <stdlib.h>

int main() {
    int n = 5;
    // 1차원 배열 메모리 동적 할당
    int* array = (int*) malloc(n * sizeof(int));
    if (array == NULL) {
        fprintf(stderr, "메모리 할당 실패\n");
        return 1;
    }

    // 배열에 값 할당
    for (int i = 0; i < n; i++) {
        array[i] = i * 10;
    }

    // 배열 값 출력
    for (int i = 0; i < n; i++) {
        printf("array[%d] = %d\n", i, array[i]);
    }

    // 메모리 해제
    free(array);
    return 0;
}
2차원 배열
2중 포인터 사용하기 (연속되지 않는 배열)
#include <stdio.h>
#include <stdlib.h>

int main() {
    int rows = 3;
    int cols = 4;

    // 2차원 배열 메모리 동적 할당 (포인터 배열 사용)
    int** array = (int**) malloc(rows * sizeof(int*));
    if (array == NULL) {
        fprintf(stderr, "메모리 할당 실패\n");
        return 1;
    }
    for (int i = 0; i < rows; i++) {
        array[i] = (int*) malloc(cols * sizeof(int));
        if (array[i] == NULL) {
            fprintf(stderr, "메모리 할당 실패\n");
            // 할당된 메모리 해제
            for (int j = 0; j < i; j++) {
                free(array[j]);
            }
            free(array);
            return 1;
        }
    }

    // 2차원 배열에 값 할당
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            array[i][j] = i * cols + j;
        }
    }

    // 2차원 배열 값 출력
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("array[%d][%d] = %d\n", i, j, array[i][j]);
        }
    }

    // 메모리 해제.  각 요소들 먼저 해제해줘야 한다.
    for (int i = 0; i < rows; i++) {
        free(array[i]);
    }
    // array를 해제하면 각 요소에 접근할 수 없으므로 제일 나중에 해제한다.
    free(array);
    return 0;
}

그러나 1차원 배열에서 다른 배열의 주소들을 저장하고 있고, 그걸 통해서 다른 1차원 배열에 접근하는 식으로 구현된다.

따라서 하나의 연속된 메모리 공간에 위치하지 않는다.

malloc은 속도가 많이 느려서 최소한으로 사용해야 하는데, 많이 발생하게 된다. 또한, 연속되지 않은 메모리 구조로 인해 접근(캐싱 관점)이 느려질 수 있다.

진짜 2차원 배열 사용하기 (C99 이후부터 지원)

배열 포인터를 사용한다.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int height = 3;
    int width = 4;

    // 2차원 배열 포인터 선언 및 메모리 동적 할당
    int (*arr)[width] = (int (*)[width])malloc(height * width * sizeof(int));
    if (arr == NULL) {
        fprintf(stderr, "메모리 할당 실패\n");
        return 1;
    }

    // 2차원 배열에 값 할당
    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            arr[i][j] = i * width + j;
        }
    }

    // 2차원 배열 값 출력
    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            printf("arr[%d][%d] = %d\n", i, j, arr[i][j]);
        }
    }

    // 메모리 해제
    free(arr);
    return 0;
}

이 경우 일반적인 배열을 사용하는 것 처럼 사용할 수도 있고, 연속된 메모리 공간을 가진다.

단, 위 코드에서 width는 고정된 상수 값이여야 한다. (const 로 선언할 필요는 없지만, 함수로 호출하거나 할떄, 값이 변하지 않아야 함)

함수 호출에서도 마찬가지이다.

void print_array(int width, int (*arr)[width], int height) {
  for (int i = 0; i < height; i++) {
    for (int j = 0; j < width; j++) {
      printf("%d ", arr[i][j]);
    }
    printf("\n");
  }
}

width 파라미터를 먼저 정의하여, (*arr)[width]에서 width 값을 미리 알 수 있도록 해야 한다.

아니면 (*arr)[3] 처럼 고정된 값을 사용해야 한다.

typedef, volatile

typedef

typedef 기존의 데이터 타입에 새로운 이름(별칭)을 부여할 수 있다.

// 기본 데이터 타입에 별칭
typedef unsigned long ulong;

struct Person { 
	char name[50]; 
	int age; 
}; 

// 구조체에 이름 부여
typedef struct Person Person;

// 익명 구조체에 이름 부여
typedef struct {
    char name[50];
    int age;
} Person;

// 포인터
typedef int* IntPtr;

IntPtr p = &a;

// 배열
typedef int IntArray[10];

IntArray arr = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

// 함수포인터
typedef int (*Operation)(int, int);

Operation op = add; 
int result = op(5, 3);

// enum 
typedef enum { 
	RED, 
	GREEN, 
	BLUE 
} Color;
volatile

volatile 키워드를 붙인 변수에 대한 최적화를 진행하지 않는다. (자바의 volatile 키워드와 역할이 다르다.)

다음같은 상황에서, 컴파일러가 항상 값이 0이라고 판단하고 무한루프를 돌도록 최적화 할 수도 있기 때문이다.

#include <stdio.h>
typedef struct SENSOR {
  /* 감지 안되면 0, 감지되면 1 이다.*/
  int sensor_flag;
  int data;
} SENSOR;

int main() {
  volatile SENSOR *sensor;
  /* 값이 감지되지 않는 동안 계속 무한 루프를 돈다*/
  while (!(sensor->sensor_flag)) {
  }
  printf("Data : %d \n", sensor->data);
}

파일 입출력

스트림(stream)

스트림은 여러 I/O 장치에 대한 입/출력을 통일된 방식으로 처리할 수 있게 해준다. 운영체제가 제공하는 추상화된 인터페이스이다. (물리적인 실체가 없으므로 "장치"라는 표현은 적절하지 않다.)

스트림은 데이터의 연속적인 흐름을 추상화한 개념으로, 파일, 네트워크 소켓, 표준 입출력 등 다양한 데이터 소스를 일관된 방식으로 처리할 수 있게 해준다. (이건 그냥 개념 설명이고, C에서 파일과 네트워크 다루는 문법은 다르다.)

읽어보긴 했는데, 자료구조 구현하는 입장에서 별로 필요한 기능은 아니라 지금은 정리하지 않고 넘어감.

이후에 DB를 만들거나 할 때는 필요하겠지만...

�나중에 다시 공부하거나 정리할떄는 다음 링크 참고

라이브러리

(자세한건 아래 GPT 설명 참고)

stdio.h

Standard Input/Output library (표준입출력 라이브러리)이다.

파일 조작, 콘솔 입출력 등의 함수를 가진다.

string.h

메모리 블록이나 메모리를 다루는 함수를 가진다.

stdlib.h

문자열 변환, 난수, 동적 메모리 관리 등의 함수를 가진다.

코드 최적화

참고

  • 주의점 - 다음 설명한 최적화가 실제로 성능에 도움이 되는지는 알 수 없다. 검증해야만 한다.
  • 산술 관련
    • 부동소수점은 가능하면 사용하지 말자, 구조가 복잡하여 연산이 느리다. 소수점 첫~둘 째 정도면 10, 100배를 곱해서 계산하는게 더 효율적이다.
    • 나눗셈을 피하라
      • if 연산을 사용하거나, 2배수인 경우 시프트 연산을 사용할 수 있다.
    • 비트 연산 사용하기
      • (대부분의 컴파일러는 알아서 최적화 해주긴 함)
      • 어떤 상태(bool)를 나타내는 경우, 비트 연산을 사용하면, 하나의 int 타입으로 여러 상태를 표시할 수 있다.
      • 짝수, 홀수 연산을 빠르게 수행할 수 있다.
  • 루프 관련
    • 일반 연산으로 대체 가능한 loop는 대체하기
    • 가능하다면 도중에 끝내기
    • 한 번 돌때 많이 하기 = 가능하다면 적게 반복하기
    • 조건은 0과 비교하기 - CPU에서 0과 비교하는 명령어는 따로 있어서 더 효율적임
  • 조건문
    • if문을 2의 배수로 쪼개기 - 근데 이건 굳이? 가독성이 너무 떨어져서
    • 가능하면 룩업 테이블을 사용하기 - 계산 결과 미리 저장해놓기 - DP같은 느낌
  • 함수
    • 함수 호출에는 시간이 걸린다 - 재귀나 많은 함수 호출은 좋지 않음
    • 인라인 함수를 사용하라

GPT

[GPT 사용] C언어 함수 호출 과정 어셈블리어로 이해하기

ARM64 어셈블리어 사용함. (mac m1을 사용하기 때문)

어차피 공통적인 흐름 정도를 알아보기 위한 거니까 ㄱㅊ

#include <stdio.h>

int add(int a, int b);

int main(void) {
    int a = 2;
    int b = 12;

    printf("Result of add(a, b): %d\n", add(a, b));

    return 0;
}

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

다음 코드를 CLion의 기능을 사용해 어셈블리어로 번역했다. (`-std=gnu11 -arch arm64 -isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX13.3.sdk`)
```arm
_main:
	sub	sp, sp, #48             // 스택 포인터를 48바이트 감소시켜 스택 프레임을 설정합니다.
	stp	x29, x30, [sp, #32]     // 이전 프레임 포인터(x29)와 링크 레지스터(x30)를 스택에 저장합니다.
	add	x29, sp, #32            // 현재 스택 포인터에 32를 더해 프레임 포인터(x29)를 설정합니다.
	mov	w8, #0                  // w8 레지스터에 0을 저장합니다.
	str	w8, [sp, #16]           // w8(0)을 sp+16 위치에 저장합니다.
	stur	wzr, [x29, #-4]     // wzr(0)을 x29-4 위치에 저장합니다.
	mov	w8, #2                  // w8 레지스터에 2를 저장합니다.
	stur	w8, [x29, #-8]      // w8(2)을 x29-8 위치에 저장합니다. (a = 2)
	mov	w8, #12                 // w8 레지스터에 12를 저장합니다.
	stur	w8, [x29, #-12]     // w8(12)을 x29-12 위치에 저장합니다. (b = 12)
	ldur	w0, [x29, #-8]      // x29-8 위치에서 값을 로드하여 w0 레지스터에 저장합니다. (a 값 로드)
	ldur	w1, [x29, #-12]     // x29-12 위치에서 값을 로드하여 w1 레지스터에 저장합니다. (b 값 로드)
	bl	_add                    // add 함수를 호출합니다.
	mov	x9, sp                  // 스택 포인터 값을 x9 레지스터에 저장합니다.
	mov	x8, x0                  // x0 레지스터의 값을 x8 레지스터에 저장합니다.
	str	x8, [x9]                // x8의 값을 x9가 가리키는 메모리 위치에 저장합니다.
	adrp	x0, l_.str@PAGE     // 문자열 리터럴 l_.str의 페이지 주소를 x0 레지스터에 로드합니다.
	add	x0, x0, l_.str@PAGEOFF  // 문자열 리터럴 l_.str의 페이지 오프셋을 x0 레지스터에 더합니다.
	bl	_printf                 // printf 함수를 호출합니다.
	ldr	w0, [sp, #16]           // sp+16 위치에서 값을 로드하여 w0 레지스터에 저장합니다.
	ldp	x29, x30, [sp, #32]     // 스택에서 이전 프레임 포인터(x29)와 링크 레지스터(x30)를 복원합니다.
	add	sp, sp, #48             // 스택 포인터를 48바이트 증가시켜 원래 상태로 복원합니다.
	ret                         // 함수에서 리턴합니다.

_add:
	sub	sp, sp, #16             // 스택 포인터를 16바이트 감소시켜 스택 프레임을 설정합니다.
	str	w0, [sp, #12]           // w0 레지스터의 값을 sp+12 위치에 저장합니다.
	str	w1, [sp, #8]            // w1 레지스터의 값을 sp+8 위치에 저장합니다.
	ldr	w8, [sp, #12]           // sp+12 위치에서 값을 로드하여 w8 레지스터에 저장합니다.
	ldr	w9, [sp, #8]            // sp+8 위치에서 값을 로드하여 w9 레지스터에 저장합니다.
	add	w0, w8, w9              // w8과 w9의 값을 더하여 w0 레지스터에 저장합니다.
	add	sp, sp, #16             // 스택 포인터를 16바이트 증가시켜 원래 상태로 복원합니다.
	ret                         // 함수에서 리턴합니다.

	.asciz	"Result of add(a, b): %d\n" // 문자열 리터럴
중요한 부분 정리
  • main 함수에서부터 Call Stack을 사용하여 처리하는 모습을 볼 수 있다.

  • add 함수 호출 시

    • Stack Frame을 정의하고, w0, w1로 전달받은 매개변수를 Stack에 저장한다.
      • 미리 Stack Pointer를 증가시켜 사용할 Frame(매개변수, Callee 주소, 지역변수)을 공간을 선언한다.
    • Stack에 저장된 변수를 가져와서 w8, w9에 저장하고 연산 결과를 w0에 저장한다.
    • 이후 SP를 낮춘 후 리턴한다.
  • 파일 내 존재하지 않는 함수인 printfbl이라는 명령어로 호출한다.

매개변수 전달 시 Stack에 저장하지 않은 이유

GTP 피셜로는 ARM의 경우 매개변수 4개까지는 함수 호출 시 레지스터를 사용한다고 한다. (반환 시에는 값이 하나이므로 마찬가지로 레지스터를 사용한다. x86의 경우에도 2개까지는 레지스터 (ECX, EDX)를 통해서 전달한다고 한다.)

4개를 넘어가면 스택을 사용한다고 한다.

GPT의 해설

함수 호출 전후의 프레임 처리
  1. 메인 함수 스택 프레임 설정:
    • sub sp, sp, #48:
      • 스택 포인터를 48바이트 감소시켜 새로운 스택 프레임을 설정합니다. 이 공간은 지역 변수와 함수 호출의 저장 공간으로 사용됩니다.
    • stp x29, x30, [sp, #32]:
      • 이전 프레임 포인터(x29)와 링크 레지스터(x30)를 스택의 sp+32 위치에 저장합니다. 이는 함수 호출이 끝난 후 원래의 호출 프레임으로 돌아가기 위해 필요합니다.
    • add x29, sp, #32:
      • 현재 스택 포인터 값에 32를 더하여 새로운 프레임 포인터(x29)를 설정합니다. 이제 x29는 현재 함수의 프레임 베이스 포인터가 됩니다.
  2. 변수 초기화:
    • mov w8, #2stur w8, [x29, #-8]:
      • w8 레지스터에 2를 저장하고, 이를 x29-8 위치에 저장합니다. 이는 변수 a를 초기화하는 과정입니다.
    • mov w8, #12stur w8, [x29, #-12]:
      • w8 레지스터에 12를 저장하고, 이를 x29-12 위치에 저장합니다. 이는 변수 b를 초기화하는 과정입니다.
  3. 함수 호출 준비:
    • ldur w0, [x29, #-8]ldur w1, [x29, #-12]:
      • x29-8 위치의 값을 w0 레지스터에 로드하고, x29-12 위치의 값을 w1 레지스터에 로드합니다. 이는 함수 add의 인수 ab를 준비하는 과정입니다.
    • bl _add:
      • add 함수를 호출합니다. 이때 w0w1 레지스터의 값이 함수의 인수로 전달됩니다.
add 함수의 스택 프레임 설정 및 해제
  1. add 함수 스택 프레임 설정:
    • sub sp, sp, #16:
      • 스택 포인터를 16바이트 감소시켜 새로운 스택 프레임을 설정합니다.
    • str w0, [sp, #12]str w1, [sp, #8]:
      • w0 레지스터의 값을 sp+12 위치에 저장하고, w1 레지스터의 값을 sp+8 위치에 저장합니다. 이는 add 함수의 인수 ab를 스택에 저장하는 과정입니다.
  2. 변수 값 로드 및 덧셈 연산:
    • ldr w8, [sp, #12]ldr w9, [sp, #8]:
      • sp+12 위치의 값을 w8 레지스터에 로드하고, sp+8 위치의 값을 w9 레지스터에 로드합니다.
    • add w0, w8, w9:
      • w8과 w9의 값을 더하여 w0 레지스터에 저장합니다. 이는 a + b의 결과를 계산하는 과정입니다.
  3. add 함수의 스택 프레임 해제 및 리턴:
    • add sp, sp, #16:
      • 스택 포인터를 16바이트 증가시켜 원래 상태로 복원합니다.
    • ret:
      • 함수에서 리턴합니다. 이때 결과 값은 w0 레지스터에 저장되어 있습니다.
main 함수의 나머지 처리
  1. printf 호출 준비 및 호출:
    • mov x9, spmov x8, x0:
      • 스택 포인터 값을 x9 레지스터에 저장하고, add 함수의 결과 값을 x8 레지스터에 저장합니다.
    • str x8, [x9]:
      • x8의 값을 x9가 가리키는 메모리 위치에 저장합니다.
    • adrp x0, l_.str@PAGEadd x0, x0, l_.str@PAGEOFF:
      • 문자열 리터럴 l_.str의 페이지 주소와 페이지 오프셋을 x0 레지스터에 로드합니다.
    • bl _printf:
      • printf 함수를 호출합니다.
  2. 메인 함수 스택 프레임 해제 및 리턴:
    • ldr w0, [sp, #16]:
      • sp+16 위치의 값을 w0 레지스터에 로드합니다. (사용되지 않는 코드로 보임)
    • ldp x29, x30, [sp, #32]:
      • 스택에서 이전 프레임 포인터(x29)와 링크 레지스터(x30)를 복원합니다.
    • add sp, sp, #48:
      • 스택 포인터를 48바이트 증가시켜 원래 상태로 복원합니다.
    • ret:
      • 함수에서 리턴합니다.

[GPT] 컴파일 시 필요한 설정들

컴파일 시 필요한 설정들

  • 컴파일할 소스 파일들, 필수 사용 (나머지는 옵션)
  • -o: 생성 될 프로그램 이름 지정, 기본 값(a.out)
  • -I: 헤더 파일 포함 경로 지정, 여러 번 사용 가능
  • -L: 라이브러리 포함 경로 지정, 여러 번 사용 가능
  • -l: 라이브러리 링크, 어려 번 사용 가능

주로 다음과 같은 순서를 권장함.
컴파일러 옵션 -> 헤더 파일 포함 경로 -> 라이브러리 포함 경로 -> 링크할 라이브러리 -> 출력 파일 이름 -> 소스 파일

예시

gcc -Wall -I./include -L./libs -lmylib -o myprogram main.c src/myheader.c src/anotherfile.c

GPT 설명

C 프로그램을 컴파일할 때, 컴파일할 소스 파일과 사용하는 라이브러리, 헤더 파일에 대한 정보를 제공해야 합니다. 이 정보를 제공함으로써 컴파일러가 소스 파일을 올바르게 처리하고, 필요한 외부 라이브러리와 헤더 파일을 찾을 수 있도록 도와줍니다.

기본적인 컴파일 예시

다음은 기본적인 컴파일 명령어의 예시입니다:

gcc -o myprogram main.c

여기서 main.c는 컴파일할 소스 파일이고, -o myprogram은 출력 파일의 이름을 지정합니다.

헤더 파일 포함 경로 지정

프로젝트의 헤더 파일이 특정 디렉토리에 있는 경우, -I 옵션을 사용하여 포함 경로를 지정할 수 있습니다. 예를 들어, 헤더 파일이 include 디렉토리에 있는 경우:

gcc -I./include -o myprogram main.c src/myheader.c
라이브러리 포함 경로 및 링크

외부 라이브러리를 사용하는 경우, -L 옵션을 사용하여 라이브러리 파일이 있는 디렉토리를 지정하고, -l 옵션을 사용하여 라이브러리 파일을 링크합니다. 예를 들어, mylib라는 라이브러리를 libs 디렉토리에서 사용하는 경우:

gcc -I./include -L./libs -o myprogram main.c src/myheader.c -lmylib
예시 프로젝트 구조

다음은 예시 프로젝트 구조와 이를 컴파일하는 방법입니다.

project/
├── include/
│   └── myheader.h
├── src/
│   └── myheader.c
├── libs/
│   └── libmylib.a
└── main.c
헤더 파일 (include/myheader.h)
#ifndef MYHEADER_H
#define MYHEADER_H

void my_function();

#endif // MYHEADER_H
소스 파일 (src/myheader.c)
#include "myheader.h"
#include <stdio.h>

void my_function() {
    printf("Hello from my_function!\n");
}
메인 파일 (main.c)
#include "myheader.h"

int main() {
    my_function();
    return 0;
}
컴파일 명령어

위 프로젝트 구조를 컴파일하려면 다음과 같이 할 수 있습니다:

gcc -I./include -L./libs -o myprogram main.c src/myheader.c -lmylib
  • -I./include: 헤더 파일을 찾을 경로 지정
  • -L./libs: 라이브러리 파일을 찾을 경로 지정
  • -o myprogram: 출력 파일 이름 지정
  • main.c src/myheader.c: 컴파일할 소스 파일들
  • -lmylib: libs 디렉토리에서 libmylib.a 라이브러리 파일을 링크

CMake 추가하기

CMake를 사용하면 프로젝트의 빌드 설정을 더 편리하게 관리할 수 있습니다. CMake는 플랫폼과 컴파일러에 독립적인 빌드 시스템을 생성해주며, 다양한 빌드 도구와 통합할 수 있는 설정 파일을 제공합니다. 다음은 CMake를 사용하여 위의 예제 프로젝트를 설정하고 빌드하는 방법을 설명합니다.

CMakeLists.txt

CMakeLists.txt 파일은 CMake 빌드 시스템의 설정 파일입니다. 여기에서 프로젝트의 구조, 포함 경로, 소스 파일, 라이브러리 등을 설정할 수 있습니다.

cmake_minimum_required(VERSION 3.10)
project(MyProject)

# 헤더 파일 포함 경로 설정
include_directories(${PROJECT_SOURCE_DIR}/include)

# 소스 파일 설정
set(SOURCES main.c src/myheader.c)

# 실행 파일 생성
add_executable(myprogram ${SOURCES})

# 라이브러리 디렉토리 설정 (필요한 경우)
# link_directories(${PROJECT_SOURCE_DIR}/libs)

# 라이브러리 링크 (필요한 경우)
# target_link_libraries(myprogram mylib)
빌드 과정
  1. 빌드 디렉토리 생성 및 이동 CMake 빌드 파일과 출력 파일을 소스 디렉토리와 분리하기 위해 빌드 디렉토리를 만듭니다.

    mkdir build
    cd build
  2. CMake를 사용하여 빌드 설정 생성 CMake를 실행하여 빌드 설정 파일을 생성합니다. 이 과정에서 CMake는 CMakeLists.txt 파일을 읽고 필요한 설정 파일을 생성합니다.

    cmake ..
  3. 프로젝트 빌드 생성된 빌드 설정 파일을 사용하여 프로젝트를 빌드합니다.

    cmake --build .

CMake를 사용하면 다음과 같은 장점을 얻을 수 있습니다:

  • 빌드 시스템 독립성: 다양한 플랫폼과 컴파일러에서 일관된 빌드 환경을 제공합니다.
  • 유지보수 용이성: 프로젝트 구조가 변경되더라도 CMake 설정 파일만 수정하면 됩니다.
  • 확장성: 큰 프로젝트에서도 효율적으로 빌드 설정을 관리할 수 있습니다.

[GPT] 라이브러리 정리

찾아보진 않았지만, C언어 특성 상, 사용할 때 주의점 같은게 있을거 같음. (경고 안해주니까) 처음 써보는 거면 검색해보고 쓰기.

string.h

string.h 헤더 파일은 문자열 처리를 위한 함수들을 제공합니다. 주요 함수들은 다음과 같습니다:

  1. strlen: 문자열의 길이를 반환합니다.

    size_t strlen(const char *str);
  2. strcpy: 문자열을 복사합니다.

    char *strcpy(char *dest, const char *src);
  3. strncpy: 지정된 길이만큼 문자열을 복사합니다.

    char *strncpy(char *dest, const char *src, size_t n);
  4. strcat: 문자열을 연결합니다.

    char *strcat(char *dest, const char *src);
  5. strncat: 지정된 길이만큼 문자열을 연결합니다.

    char *strncat(char *dest, const char *src, size_t n);
  6. strcmp: 두 문자열을 비교합니다.

    int strcmp(const char *str1, const char *str2);
  7. strncmp: 지정된 길이만큼 두 문자열을 비교합니다.

    int strncmp(const char *str1, const char *str2, size_t n);
  8. strchr: 문자열에서 특정 문자를 찾습니다.

    char *strchr(const char *str, int c);
  9. strrchr: 문자열에서 특정 문자의 마지막 위치를 찾습니다.

    char *strrchr(const char *str, int c);
  10. strstr: 문자열에서 부분 문자열을 찾습니다.

    char *strstr(const char *haystack, const char *needle);
  11. memcpy: 메모리 영역을 복사합니다. 단, 소스와 대상 메모리 영역이 겹치지 않아야 합니다.

    void *memcpy(void *dest, const void *src, size_t n);
  12. memmove: 메모리 영역을 복사합니다. 소스와 대상 메모리 영역이 겹쳐도 안전합니다. 대신 이를 위해 버퍼를 사용하므로 memcpy보단 성능이 떨어집니다.

    void *memcpy(void *dest, const void *src, size_t n);
  13. memset: 메모리 영역을 특정 값으로 설정합니다.

    void *memset(void *str, int c, size_t n);
stdlib.h

stdlib.h 헤더 파일은 일반적인 유틸리티 함수들을 제공합니다. 주요 함수들은 다음과 같습니다:

  1. malloc: 동적 메모리를 할당합니다.

    void *malloc(size_t size);
  2. calloc: 초기화된 동적 메모리를 할당합니다.

    void *calloc(size_t num, size_t size);
  3. realloc: 동적 메모리의 크기를 재조정합니다.

    void *realloc(void *ptr, size_t size);
  4. free: 동적 메모리를 해제합니다.

    void free(void *ptr);
  5. exit: 프로그램을 종료합니다.

    void exit(int status);
  6. atoi: 문자열을 정수로 변환합니다.

    int atoi(const char *str);
  7. atof: 문자열을 실수로 변환합니다.

    double atof(const char *str);
  8. rand: 난수를 생성합니다.

    int rand(void);
  9. srand: 난수 생성기의 시드를 설정합니다.

    void srand(unsigned int seed);
  10. abs: 정수의 절대값을 반환합니다.

    int abs(int x);
  11. qsort: 배열을 정렬합니다.

    void qsort(void *base, size_t nitems, size_t size, int (*compar)(const void *, const void *));
  12. bsearch: 정렬된 배열에서 이진 검색을 수행합니다.

    void *bsearch(const void *key, const void *base, size_t nitems, size_t size, int (*compar)(const void *, const void *));
stdio.h

stdio.h 헤더 파일은 표준 입출력 함수를 제공합니다. 주요 함수들은 다음과 같습니다:

  1. printf: 표준 출력에 형식화된 데이터를 출력합니다.

    int printf(const char *format, ...);
  2. scanf: 표준 입력에서 형식화된 데이터를 읽어들입니다.

    int scanf(const char *format, ...);
  3. fopen: 파일을 열고, 파일 포인터를 반환합니다.

    FILE *fopen(const char *filename, const char *mode);
  4. fclose: 파일을 닫습니다.

    int fclose(FILE *stream);
  5. fread: 파일에서 데이터를 읽어옵니다.

    size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
  6. fwrite: 파일에 데이터를 씁니다.

    size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
  7. fgets: 파일에서 한 줄을 읽어옵니다.

    char *fgets(char *str, int n, FILE *stream);
  8. fputs: 문자열을 파일에 씁니다.

    int fputs(const char *str, FILE *stream);
  9. fscanf: 파일에서 형식화된 데이터를 읽어들입니다.

    int fscanf(FILE *stream, const char *format, ...);
  10. fprintf: 파일에 형식화된 데이터를 씁니다.

    int fprintf(FILE *stream, const char *format, ...);
  11. fseek: 파일 포인터의 위치를 설정합니다.

    int fseek(FILE *stream, long int offset, int whence);
  12. ftell: 파일 포인터의 현재 위치를 반환합니다.

    long int ftell(FILE *stream);
  13. rewind: 파일 포인터를 파일의 처음으로 이동시킵니다.

    void rewind(FILE *stream);