Skip to content

C 치트시트

시스템, 임베디드, OS 개발을 구동하는 저수준 언어.

01

시작하기

Hello World

모든 C 프로그램은 main()에서 시작합니다. #include <stdio.h>는 표준 I/O 라이브러리(printf, scanf)를 가져옵니다. main은 성공 시 0, 실패 시 0이 아닌 값을 반환합니다. void 키워드는 main이 매개변수를 받지 않음을 명시적으로 선언합니다.

c
#include <stdio.h>

int main(void) {
    printf("Hello, World!\n");
    return 0;
}

변수 & 타입

C는 정적으로 타입이 지정됩니다. 일반 타입: int, float, double, char. float는 f 접미사가 필요합니다. char[]은 문자열(널 종료 배열)입니다. long과 short는 크기 한정자입니다. unsigned는 음이 아닌을 의미합니다. 크기는 플랫폼에 따라 다름; 고정 너비를 위해 <stdint.h>를 사용하세요.

c
int age = 30;
float height = 5.7f;
double pi = 3.14159;
char grade = 'A';
char name[] = "Alice";
long big = 100000L;
unsigned int count = 42;
printf("%s is %d\n", name, age);

입력 & 출력

scanf는 입력을 저장할 변수의 주소(&)가 필요합니다. 버퍼 오버플로우를 방지하기 위해 항상 문자열 입력 길이를 제한하세요(50자 버퍼의 경우 %49s). scanf는 공백에서 문자열 읽기를 중지; 전체 줄은 fgets를 사용하세요.

c
int n;
printf("Enter a number: ");
scanf("%d", &n);
printf("You entered %d\n", n);

char name[50];
printf("Enter name: ");
scanf("%49s", name);  // limit to prevent overflow
printf("Hi, %s!\n", name);

printf 형식 지정자

형식 지정자는 출력을 제어: %d 정수, %f 실수, %c 문자, %s 문자열, %x 16진수, %p 포인터. 너비와 정밀도(예: %5.2f)는 정렬과 소수를 제어합니다. 지정자와 타입이 일치하지 않으면 정의되지 않은 동작을 유발합니다.

c
printf("%d\n", 42);        // integer
printf("%f\n", 3.14);       // float/double
printf("%.2f\n", 3.14159);  // 3.14 (2 decimals)
printf("%c\n", 'A');        // char
printf("%s\n", "hello");    // string
printf("%x\n", 255);        // ff (hex)
printf("%5d\n", 42);        // right-aligned, width 5
printf("%-5d|\n", 42);      // left-aligned

전처리기 & 헤더

전처리기는 컴파일 전에 실행됩니다. #include는 헤더 파일을 붙여넣고, #define은 매크로와 상수를 생성합니다. 이중 포함을 방지하기 위해 헤더에 항상 include guard(#ifndef/#define/#endif)를 사용하세요. 매크로는 텍스트 치환 — 매개변수 주변에 괄호를 사용하세요.

c
#include <stdio.h>   // system header
#include "myheader.h"  // local header

#define PI 3.14159
#define SQUARE(x) ((x) * (x))

#ifndef GUARD_H
#define GUARD_H
// header content
#endif
02

문자열 & String.h

문자열 기본

C 문자열은 널 종료 char 배열입니다. strlen은 '\0' 전의 문자를 셈; sizeof는 버퍼 크기를 반환합니다. strcpy는 널 종료자까지 복사 — 오버플로우를 피하기 위해 항상 대상이 충분히 큰지 확인하세요.

c
#include <string.h>
char s[20] = "Hello";
printf("Length: %zu\n", strlen(s));   // 5
printf("Size: %zu\n", sizeof(s));     // 20

char dest[20];
strcpy(dest, s);   // copy
printf("%s\n", dest);  // Hello

연결 & 비교

strcat은 추가(대상에 공간이 있어야 함). strcmp는 사전순으로 비교: 같으면 0, 첫 번째 < 두 번째면 음수, 첫 번째 > 두 번째면 양수. 문자열 비교에 ==를 절대 사용하지 마세요(내용이 아닌 포인터 비교).

c
#include <string.h>
char s[30] = "Hello";
strcat(s, ", World!");   // s = "Hello, World!"
printf("%s\n", s);

int cmp = strcmp("apple", "banana");
// returns <0 if a<b, 0 if equal, >0 if a>b
if (strcmp(s, "Hello") == 0) {
    printf("Equal!\n");
}

sprintf & sscanf

sprintf는 문자열 버퍼에 형식화(printf와 같지만 문자열로). sscanf는 문자열을 변수로 파싱(scanf와 같지만 문자열에서). 최대 크기를 지정하여 버퍼 오버플로우를 방지하기 위해 sprintf 대신 snprintf를 사용하세요.

c
char buf[100];
int age = 30;
char name[] = "Alice";
sprintf(buf, "%s is %d years old", name, age);
printf("%s\n", buf);

int a, b;
sscanf("10 20", "%d %d", &a, &b);
printf("a=%d, b=%d\n", a, b);  // a=10, b=20

strchr, strstr & strtok

strchr은 문자를 찾고, strstr은 부분 문자열을 찾습니다. strtok은 구분자로 문자열을 분할하지만 원본 문자열을 수정(널 종료자 삽입)하고 스레드 안전하지 않음 — 토큰화를 계속하려면 후속 호출에서 NULL을 전달하세요.

c
#include <string.h>
char s[] = "Hello, World!";
char *p = strchr(s, 'W');   // find first 'W'
printf("%s\n", p);          // World!

char *sub = strstr(s, "World");
printf("%s\n", sub);        // World!

char tokens[] = "a,b,c";
char *tok = strtok(tokens, ",");
while (tok) {
    printf("%s\n", tok);
    tok = strtok(NULL, ",");
}

fgets & 안전한 입력

fgets는 안전한 문자열 읽기 방법 — 오버플로우를 방지하기 위해 크기 제한을 받습니다. scanf와 달리 공백을 읽습니다. 줄바꿈이 결과에 포함됨; strcspn으로 찾아 제거하세요. gets(C11에서 제거됨)보다 항상 fgets를 선호하세요.

c
char line[100];
printf("Enter text: ");
fgets(line, sizeof(line), stdin);
// removes trailing newline
line[strcspn(line, "\n")] = 0;
printf("You said: %s\n", line);
03

숫자 & 수학

정수 타입 & 제한

정확한 크기가 중요할 때 고정 너비 타입(int32_t, int64_t)을 위해 <stdint.h>를 사용하세요. <limits.h>는 플랫폼별 경계를 위해 INT_MAX, INT_MIN 등을 제공합니다. LL 접미사는 long long 리터럴을 표시합니다. int/long의 크기는 플랫폼에 따라 다릅니다.

c
#include <stdint.h>
#include <limits.h>
int32_t a = 100;
int64_t big = 9223372036854775807LL;
uint8_t byte = 255;

printf("INT_MAX = %d\n", INT_MAX);     // 2147483647
printf("INT_MIN = %d\n", INT_MIN);     // -2147483648
printf("UINT_MAX = %u\n", UINT_MAX);   // 4294967295

부동 소수점

float는 4바이트(6-7자리 정밀도), double은 8바이트(15-16자리). 반올림 오류로 인해 float를 ==로 비교하지 마세요 — fabs(a - b) < epsilon 사용. <float.h>는 경계와 정밀도를 위해 DBL_MAX, DBL_EPSILON을 제공합니다.

c
#include <float.h>
double d = 3.141592653589793;
float f = 3.14f;

printf("DBL_MAX = %e\n", DBL_MAX);
printf("DBL_EPSILON = %e\n", DBL_EPSILON);

if (d == 0.1 + 0.2) {
    // likely false! floating point imprecision
}

수학 함수

<math.h>는 표준 수학 함수를 제공합니다. pow와 sqrt는 double을 반환합니다. fabs는 abs의 실수 버전(abs는 int용). 일부 시스템에서는 -lm으로 링크하세요. 재무 계산의 경우 부동 소수점을 피하고 정수 센트를 사용하세요.

c
#include <math.h>
double x = 2.5;
pow(x, 3);      // 15.625
sqrt(x);        // 1.581
fabs(-5.0);     // 5.0
floor(3.7);     // 3.0
ceil(3.2);      // 4.0
fmod(10.5, 3);  // 1.5
exp(1);         // 2.718 (e^1)
log(2.718);     // 1.0 (natural log)

난수

rand()는 0에서 RAND_MAX까지의 의사 난수 int를 반환합니다. 프로그램 시작 시 srand()로 한 번 시드(time(NULL) 사용). rand() % N은 모듈로 편향과 낮은 품질; 심각한 사용의 경우 /dev/urandom을 읽거나 제3자 PRNG 라이브러리를 사용하세요.

c
#include <stdlib.h>
#include <time.h>

srand(time(NULL));  // seed once at start
int r = rand() % 100;       // 0-99
int dice = rand() % 6 + 1;  // 1-6

float fr = (float)rand() / RAND_MAX;  // 0.0 - 1.0

타입 변환 & 캐스팅

캐스팅은 (type)value입니다. 정수 나눗셈은 잘림 — 실수 결과를 얻으려면 실수 피연산자를 사용하세요. atoi/atof는 문자열을 숫자로 변환하지만 에러 검사 없음; errno로 파싱 에러를 보고하는 strtol/strtod를 선호하세요.

c
int i = 65;
char c = (char)i;        // 'A'
double d = 3.99;
int truncated = (int)d;  // 3

// Implicit promotion
int a = 5;
double result = a / 2.0;  // 2.5 (promoted to double)
int bad = a / 2;          // 2 (integer division)

// String to number
int n = atoi("42");
double f = atof("3.14");
04

제어 흐름

If / Else

if/else if/else는 표준 조건문입니다. C는 0을 false, 0이 아닌 모든 값을 true로 취급합니다. 나중에 줄을 추가할 때 버그를 방지하기 위해 단일 명령문에도 중괄호를 사용하세요. C89에는 boolean 타입이 없음; C99는 _Bool과 <stdbool.h>를 추가합니다.

c
int score = 85;
if (score >= 90) {
    printf("A\n");
} else if (score >= 80) {
    printf("B\n");
} else if (score >= 70) {
    printf("C\n");
} else {
    printf("F\n");
}

Switch

switch는 일치하는 case 레이블로 점프합니다. fall-through를 방지하기 위해 항상 break를 사용(케이스 6과 7은 의도적으로 코드 공유). default는 일치하지 않는 값을 처리. switch는 문자열이나 실수가 아닌 정수와 char 타입에서만 작동합니다.

c
int day = 3;
switch (day) {
    case 1: printf("Mon\n"); break;
    case 2: printf("Tue\n"); break;
    case 3: printf("Wed\n"); break;
    case 6:
    case 7: printf("Weekend\n"); break;
    default: printf("Invalid\n");
}

For 루프

for 루프는 init; condition; update를 가집니다. sizeof(nums)/sizeof(nums[0])는 컴파일 타임에 배열 길이를 계산합니다. for 루프 내부에 i를 선언하려면 C99 이상이 필요합니다. 조건이 처음에 false면 루프 본문은 0번 실행됩니다.

c
for (int i = 0; i < 5; i++) {
    printf("%d\n", i);
}

// Iterate an array
int nums[] = {10, 20, 30};
int n = sizeof(nums) / sizeof(nums[0]);
for (int i = 0; i < n; i++) {
    printf("%d\n", nums[i]);
}

While & Do-While

while은 실행 전에 확인(0번 실행 가능). do-while은 본문을 먼저 실행한 후 확인(최소 1번 실행). do-while은 조건을 확인하기 전에 프롬프트가 나타나야 하는 입력 검증과 메뉴 루프에 이상적입니다.

c
int count = 0;
while (count < 3) {
    printf("%d\n", count++);
}

int x;
do {
    printf("Enter positive: ");
    scanf("%d", &x);
} while (x <= 0);  // runs at least once

Break, Continue & goto

break는 가장 가까운 루프/switch를 종료; continue는 다음 반복으로 건너뜁니다. C에는 레이블이 있는 break가 없으므로 깊이 중첩된 루프에서 벗어나는 관용적 방법은 goto입니다. goto는 그 외에는 권장되지 않지만 정리 패턴과 중첩 루프 종료에는 허용됩니다.

c
for (int i = 0; i < 10; i++) {
    if (i == 3) continue;  // skip 3
    if (i == 7) break;     // stop at 7
    printf("%d ", i);      // 0 1 2 4 5 6
}

// goto for breaking nested loops
for (int i = 0; i < n; i++) {
    for (int j = 0; j < m; j++) {
        if (found) goto done;
    }
}
done: printf("exited\n");
05

함수

정의 & 호출

함수는 사용 전에 선언(프로토타입)되거나 정의되어야 합니다. void 반환 타입은 반환 값이 없음을 의미합니다. const char *name은 함수가 문자열을 수정하지 않음을 의미합니다. C는 값으로 인수 전달; 참조로 전달을 시뮬레이션하려면 포인터를 사용하세요.

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

void greet(const char *name) {
    printf("Hello, %s!\n", name);
}

int main(void) {
    int sum = add(3, 4);
    greet("Alice");
    return 0;
}

재귀

재귀는 더 작은 입력으로 자신을 호출합니다. 모든 재귀 함수는 중지하기 위한 기본 케이스가 필요합니다. 위의 단순 fib는 O(2^n) — 지수적입니다. 효율성을 위해 메모이제이션이나 반복을 사용하세요. 깊은 재귀는 호출 스택을 오버플로우할 수 있습니다.

c
int factorial(int n) {
    if (n <= 1) return 1;       // base case
    return n * factorial(n - 1); // recursive case
}

int fib(int n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);
}
// factorial(5) == 120

함수 포인터

함수 포인터는 함수의 주소를 저장하여 콜백과 동적 디스패치를 가능하게 합니다. int (*op)(int, int) 구문은 두 개의 int를 받아 int를 반환하는 함수에 대한 포인터를 선언합니다. qsort, 이벤트 핸들러, 플러그인 시스템에서 사용됩니다.

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

int (*op)(int, int) = add;
printf("%d\n", op(3, 4));  // 7

op = sub;
printf("%d\n", op(3, 4));  // -1

// As a parameter
int apply(int (*f)(int, int), int a, int b) {
    return f(a, b);
}

가변 인수 함수

가변 인수 함수는 <stdarg.h>를 사용하여 가변 개수의 인수를 받습니다. va_start 초기화, va_arg 다음 인수 검색, va_end 정리. 개수를 아는 방법이 필요합니다(예: 카운트 매개변수나 센티넬 값). printf가 이 방식으로 작동합니다.

c
#include <stdarg.h>
int sum(int count, ...) {
    va_list args;
    va_start(args, count);
    int total = 0;
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);
    }
    va_end(args);
    return total;
}
// sum(3, 10, 20, 30) == 60

static & inline

함수/변수에 static은 현재 번역 단위(파일)로 제한합니다. 지역 변수에 static은 호출 간에 유지(전역과 같지만 범위 지정됨). inline은 컴파일러가 함수 본문을 포함하도록 제안; 현대 컴파일러는 무시하고 스스로 결정합니다.

c
// static: internal linkage (file-local)
static int counter = 0;
static int next_id(void) { return ++counter; }

// inline: hint to expand inline
static inline int square(int x) { return x * x; }

// static local: persists across calls
int call_count(void) {
    static int n = 0;
    return ++n;
}
06

배열 & 포인터

배열

배열은 고정 크기, 0 인덱스, 메모리에 연속으로 저장됩니다. sizeof(arr)/sizeof(arr[0])는 길이를 계산하지만 포인터가 아닌 실제 배열에서만 작동(배열은 함수에 전달될 때 포인터로 붕괴하여 크기 정보 손실).

c
int nums[5] = {1, 2, 3, 4, 5};
printf("%d\n", nums[0]);     // 1
printf("%d\n", nums[4]);     // 5

int len = sizeof(nums) / sizeof(nums[0]);  // 5

// Array of strings
char *fruits[] = {"apple", "banana", "cherry"};
printf("%s\n", fruits[1]);   // banana

포인터

포인터는 메모리 주소를 저장합니다. &는 주소를 가져오고, *는 역참조합니다. 항상 포인터를 초기화(아직 할당되지 않은 경우 NULL 사용). NULL이나 초기화되지 않은 포인터 역참조는 정의되지 않은 동작(보통 크래시). 역참조 전에 NULL 확인.

c
int x = 10;
int *ptr = &x;     // ptr holds address of x
printf("%p\n", (void*)ptr);  // address
printf("%d\n", *ptr);        // 10 (dereference)

*ptr = 20;         // modify x through pointer
printf("%d\n", x);  // 20

int *p = NULL;     // null pointer (points to nothing)
if (p) { /* safe to dereference */ }

포인터 산술

포인터 산술은 요소 크기로 조정: p+1은 다음 바이트가 아닌 다음 요소로 이동. 이는 p[i]를 *(p+i)와 동일하게 만듭니다. 같은 배열에 대한 두 포인터의 빼기는 요소 수를 줍니다. 포인터 산술은 배열 내에서만 유효합니다.

c
int arr[] = {10, 20, 30, 40, 50};
int *p = arr;       // points to arr[0]

printf("%d\n", *p);       // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", *(p + 2)); // 30

p += 3;              // now points to arr[3]
printf("%d\n", *p);  // 40

int diff = (p - arr); // 3 (number of elements)

배열 vs 포인터

배열 이름은 함수에 전달되거나 표현식에 사용될 때 포인터로 붕괴하여 크기 정보를 잃습니다. 이 때문에 배열 길이를 별도로 전달해야 합니다. sizeof(arr)는 arr이 포인터가 아닌 진정한 배열일 때만 전체 배열 크기를 줍니다.

c
int arr[5] = {1, 2, 3, 4, 5};
int *ptr = arr;  // arr decays to &arr[0]

// These are equivalent
printf("%d\n", arr[2]);
printf("%d\n", ptr[2]);
printf("%d\n", *(arr + 2));

// But sizeof differs
printf("%zu\n", sizeof(arr));   // 20 (5 * 4 bytes)
printf("%zu\n", sizeof(ptr));   // 8 (pointer size)

다차원 배열

2D 배열은 배열의 배열, 행 우선 저장. grid[i][j]는 행 i, 열 j 접근. 함수에 전달할 때 열 수를 지정해야 함: void foo(int arr[][3], int rows). 동적 2D 배열의 경우 포인터 배열을 사용하세요.

c
int grid[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

printf("%d\n", grid[0][1]);  // 2
printf("%d\n", grid[1][2]);  // 6

for (int i = 0; i < 2; i++) {
    for (int j = 0; j < 3; j++) {
        printf("%d ", grid[i][j]);
    }
    printf("\n");
}
07

구조체 & 공용체

구조체

구조체는 다른 타입의 관련 변수를 그룹화합니다. 멤버는 점 연산자(.)로 접근. 중괄호 표기법으로 초기화. 구조체는 값으로 전달(복사됨); 복사를 피하고 원본을 수정하려면 포인터(struct Point *)로 전달하세요.

c
struct Point {
    int x;
    int y;
};

struct Point p = {3, 4};
printf("(%d, %d)\n", p.x, p.y);  // (3, 4)

p.x = 10;
p.y = 20;
printf("(%d, %d)\n", p.x, p.y);  // (10, 20)

typedef

typedef는 타입의 별칭을 생성하여 struct Student 대신 Student를 작성할 수 있습니다. 구조체와 함께 구문을 단순화하는 데 일반적으로 사용됩니다. typedef는 함수 포인터 타입도 별칭을 지정하여 콜백을 훨씬 더 읽기 쉽게 만듭니다.

c
typedef struct {
    char name[50];
    int age;
    float gpa;
} Student;

Student s = {"Alice", 20, 3.8};
printf("%s: %d, GPA %.1f\n", s.name, s.age, s.gpa);

// typedef for other types
typedef unsigned long ulong;
typedef int (*CompareFn)(const void*, const void*);

구조체에 대한 포인터

구조체에 대한 포인터가 있을 때 화살표 연산자(->)로 멤버에 접근하세요. ptr->x는 (*ptr).x의 약어입니다. 효율성(큰 구조체 복사 방지)과 수정을 허용하기 위해 구조체 포인터를 함수에 전달하세요.

c
typedef struct {
    int x, y;
} Point;

Point p = {3, 4};
Point *ptr = &p;

// Arrow operator (->) for pointer members
printf("%d\n", ptr->x);   // 3
ptr->y = 10;
printf("%d\n", p.y);      // 10

// Equivalent: (*ptr).x

공용체

공용체는 같은 메모리에 여러 타입을 겹침 — 한 번에 하나의 멤버만 유효. 한 멤버를 설정하면 다른 멤버를 덮어씀. 타입 펀닝(비트를 다른 타입으로 재해석)과 여러 타입 중 하나만 한 번에 필요할 때 메모리 절약에 유용.

c
union Value {
    int i;
    float f;
    char bytes[4];
};

union Value v;
v.i = 65;
printf("%d\n", v.i);          // 65
printf("%c\n", v.bytes[0]);   // 'A' (same memory)

v.f = 3.14f;
printf("%d\n", v.i);  // reinterpreted bits!

비트 필드 & 열거형

비트 필드는 여러 작은 필드를 단일 int로 패킹하여 메모리 절약(프로토콜과 하드웨어 레지스터에서 일반적). 열거형은 명명된 정수 상수(기본 0, 1, 2...)를 정의. 더 나은 디버깅과 타입 안전을 위해 #define 대신 열거형을 사용하세요.

c
struct Flags {
    unsigned int bold : 1;
    unsigned int italic : 1;
    unsigned int size : 6;  // 0-63
};

struct Flags f = {1, 0, 12};
printf("bold=%d, size=%d\n", f.bold, f.size);

enum Color { RED, GREEN, BLUE };
enum Color c = GREEN;
printf("%d\n", c);  // 1
08

메모리 관리

malloc & free

malloc은 힙 메모리를 할당하고 void 포인터(또는 실패 시 NULL)를 반환합니다. 항상 NULL을 확인하세요. 모든 malloc은 메모리 누수를 피하기 위해 free와 짝을 이루어야 합니다. free 후 포인터를 NULL로 설정하면 use-after-free 버그를 방지합니다.

c
#include <stdlib.h>
int *arr = malloc(5 * sizeof(int));
if (arr == NULL) {
    fprintf(stderr, "malloc failed\n");
    return 1;
}
for (int i = 0; i < 5; i++) arr[i] = i * 2;
free(arr);  // release memory
arr = NULL; // avoid dangling pointer

calloc & realloc

calloc은 메모리를 할당하고 0으로 채웁니다(가비지가 있는 malloc보다 안전). realloc은 크기를 조정: 블록을 이동하여 새 포인터를 반환할 수 있습니다. realloc이 실패하면 NULL을 반환하지만 원래 블록은 여전히 유효 — 누수를 피하기 위해 임시 포인터를 사용하세요.

c
#include <stdlib.h>
// calloc: zero-initialized
int *arr = calloc(5, sizeof(int));  // all zeros

// realloc: resize
arr = realloc(arr, 10 * sizeof(int));
if (!arr) { /* handle failure, original still valid */ }

free(arr);

스택 vs 힙

스택 메모리는 자동(함수 호출로 할당/해제)이고 빠르지만 제한적(종종 1-8 MB). 힙 메모리는 malloc/free로 수동 관리, 훨씬 크지만 느리고 누수 발생. 작고 수명이 짧은 데이터에는 스택; 크거나 수명이 긴 데이터에는 힙을 사용하세요.

c
// Stack: automatic, fast, limited size
int local_var = 42;
int arr[100];  // on the stack

// Heap: manual, large, slower
int *heap_arr = malloc(1000000 * sizeof(int));

// Stack frame is freed when function returns
// Heap memory persists until explicitly freed

메모리 누수 & 댕글링 포인터

메모리 누수는 할당된 메모리에 대한 유일한 포인터를 잃을 때 발생(free 불가). 댕글링 포인터는 해제된 메모리를 가리킴 — 역참조는 정의되지 않은 동작. 이중 해제도 정의되지 않음. Valgrind와 AddressSanitizer 같은 도구가 이 버그를 감지합니다.

c
// Memory leak: lost the pointer, can't free
void leak(void) {
    int *p = malloc(100 * sizeof(int));
    // function returns without free -> leaked!
}

// Dangling pointer: using freed memory
int *p = malloc(sizeof(int));
free(p);
*p = 42;  // UNDEFINED BEHAVIOR!

// Double free: also undefined
free(p);  // crash likely

동적 배열 & 문자열

동적 할당은 런타임에 크기가 결정되는 문자열/배열을 생성할 수 있게 합니다. 호출자가 메모리 해제를 담당합니다. 문자열의 경우 항상 strlen+1을 할당(널 종료자). 이 패턴(할당, 반환, 호출자 해제)은 C API에서 일반적입니다.

c
#include <stdlib.h>
#include <string.h>

// Dynamic string copy
char *dup_str(const char *s) {
    char *copy = malloc(strlen(s) + 1);  // +1 for null
    if (copy) strcpy(copy, s);
    return copy;  // caller must free
}

char *name = dup_str("Alice");
printf("%s\n", name);
free(name);
09

파일 I/O

fopen & fclose

fopen은 파일을 열고 FILE 포인터(또는 실패 시 NULL)를 반환합니다. 모드: r(읽기), w(쓰기/잘라내기), a(추가), r+(읽기/쓰기), b(바이너리). 항상 NULL을 확인하세요. fclose는 버퍼를 플러시하고 파일을 닫습니다. fgets는 한 줄을 안전하게 읽습니다.

c
#include <stdio.h>
FILE *f = fopen("data.txt", "r");
if (!f) {
    perror("fopen failed");
    return 1;
}

char line[256];
while (fgets(line, sizeof(line), f)) {
    printf("%s", line);
}

fclose(f);

fprintf & fscanf

fprintf와 fscanf는 파일에서 printf/scanf처럼 작동합니다. fscanf는 취약 — 형식 불일치가 문제를 유발. 견고한 파싱을 위해 fgets로 줄을 읽고 sscanf로 파싱하세요. 버퍼를 플러시하고 리소스를 해제하기 위해 항상 완료 후 파일을 닫으세요.

c
FILE *f = fopen("output.txt", "w");
fprintf(f, "Name: %s\n", "Alice");
fprintf(f, "Age: %d\n", 30);
fclose(f);

FILE *in = fopen("output.txt", "r");
char name[50];
int age;
fscanf(in, "Name: %49s\n", name);
fscanf(in, "Age: %d\n", &age);
printf("%s, %d\n", name, age);
fclose(in);

fread & fwrite (바이너리)

fread/fwrite는 원시 바이트를 읽기/쓰기 — 바이너리 데이터와 구조체에 이상적. 인수: 버퍼, 요소 크기, 카운트, 파일. 바이너리 파일은 간결하지만 아키텍처 간 이식성 없음(엔디안, 구조체 패딩). 항상 'b' 모드로 바이너리 파일을 여세요.

c
typedef struct { int id; float score; } Record;

Record r = {1, 95.5f};
FILE *f = fopen("data.bin", "wb");
fwrite(&r, sizeof(Record), 1, f);
fclose(f);

Record r2;
FILE *in = fopen("data.bin", "rb");
fread(&r2, sizeof(Record), 1, in);
printf("id=%d, score=%.1f\n", r2.id, r2.score);
fclose(in);

fseek, ftell & rewind

fseek은 파일 위치 이동: SEEK_SET(시작에서), SEEK_CUR(상대적), SEEK_END(끝에서). ftell은 현재 위치 반환. rewind는 fseek(f, 0, SEEK_SET)의 약어. 이는 파일에서 임의 접근을 가능하게 하여 데이터베이스와 인덱스 조회에 유용.

c
FILE *f = fopen("data.txt", "r");
fseek(f, 0, SEEK_END);   // jump to end
long size = ftell(f);     // get position = file size
printf("Size: %ld bytes\n", size);

rewind(f);                // back to start
// or: fseek(f, 0, SEEK_SET);

fseek(f, 10, SEEK_SET);   // 10 bytes from start
char c = fgetc(f);
printf("Char at 10: %c\n", c);
fclose(f);

stderr & 표준 스트림

모든 C 프로그램에는 세 개의 스트림이 있습니다: stdin(키보드), stdout(화면), stderr(화면, 버퍼링 없음). stderr에 에러를 쓰면 정상 출력과 분리되어 리다이렉션 가능: program 2> errors.log. stderr는 버퍼링이 없어 크래시 전에 메시지가 나타납니다.

c
#include <stdio.h>
// Three standard streams: stdin, stdout, stderr
fprintf(stdout, "Normal output\n");
fprintf(stderr, "Error: something went wrong\n");

int c;
while ((c = fgetc(stdin)) != EOF) {
    fputc(c, stdout);  // echo input
}

// stderr is unbuffered (appears immediately)
// stdout is line-buffered (flushes on newline)
10

전처리기 & 매크로

#define 상수 & 매크로

#define은 텍스트 치환 매크로를 생성합니다. PI 같은 상수는 가독성과 유지보수성을 향상. 함수형 매크로는 우선순위 버그를 피하기 위해 매개변수를 괄호로 감싸야 함: 괄호 없는 SQUARE(2+3)는 2+3*2+3=11. 매크로보다 const 변수와 inline 함수를 선호하세요.

c
#define MAX_SIZE 100
#define PI 3.14159
#define VERSION "2.0"

// Function-like macro
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int area = SQUARE(5);    // 25
int big = MAX(3, 7);     // 7

조건부 컴파일

조건부 컴파일(#if, #ifdef, #ifndef)은 컴파일 타임에 코드를 포함/제외합니다. 이는 플랫폼별 코드, 디버그 빌드, 기능 플래그에 사용됩니다. #ifdef는 매크로가 정의되었는지 확인; #if는 값을 평가. #elif와 #else는 대안을 제공.

c
#define DEBUG 1

#if DEBUG
    printf("Debug: x=%d\n", x);
#endif

#ifdef _WIN32
    // Windows-specific code
#elif defined(__linux__)
    // Linux-specific code
#endif

#ifndef BUFFER_SIZE
#define BUFFER_SIZE 1024
#endif

Include Guard

Include guard는 헤더의 이중 포함을 방지하여 재정의 에러를 피합니다. #ifndef/#define/#endif 패턴이 표준 C. #pragma once는 더 간단하고 널리 지원되는 대안(표준은 아니지만 모든 주요 컴파일러에서 작동).

c
// myheader.h
#ifndef MYHEADER_H
#define MYHEADER_H

struct Point { int x, y; };
void init_point(struct Point *p);

#endif // MYHEADER_H

// Alternative (non-standard but widely supported):
#pragma once

#pragma & 컴파일러 힌트

#pragma는 컴파일러별 지시문을 제공합니다. #pragma once는 더 간단한 include guard. #pragma pack은 구조체 메모리 레이아웃을 제어(바이너리 프로토콜에 중요). __attribute__(GCC/Clang)는 최적화, 사용 중단, 경고를 위해 함수에 주석을 답니다.

c
#pragma once              // include guard
#pragma pack(1)            // struct packing (no padding)
#pragma GCC diagnostic ignored "-Wunused-variable"

// Common pragmas
#pragma message("Compiling " __FILE__)

// C99 __attribute__ (GCC/Clang)
__attribute__((deprecated)) void old_func(void);
__attribute__((noreturn)) void fatal(void);

문자열화 & 토큰 붙여넣기

#(문자열화)는 매크로 인수를 문자열 리터럴로 변환. ##(토큰 붙여넣기)는 토큰을 새 식별자로 연결. 2단계 STR/XSTR 패턴은 매크로가 문자열화 전에 확장되도록 보장. 코드 생성과 로깅 매크로에 사용됩니다.

c
#define STR(x) #x
#define XSTR(x) STR(x)
#define CONCAT(a, b) a##b

printf("%s\n", STR(Hello World));  // "Hello World"
printf("%s\n", XSTR(VERSION));      // expands VERSION first

int CONCAT(foo, bar) = 42;  // creates variable foobar
printf("%d\n", foobar);     // 42
11

비트 연산

기본 비트 연산자

비트 연산자는 개별 비트를 조작합니다. AND(&)는 비트를 마스크(설정된 비트만 유지), OR(|)는 비트 설정, XOR(^)는 비트 토글, NOT(~)은 모든 비트 반전. 왼쪽 시프트(<<)는 2의 거듭제곱으로 곱하고, 오른쪽 시프트(>>)는 나눕니다(부호 없는 경우). 비트 조작에는 항상 부호 없는 타입 사용 — 부호 있는 오른쪽 시프트는 구현 정의(부호 확장 가능). 비트 연산은 매우 빠름(단일 CPU 사이클)이며 플래그, 하드웨어 레지스터, 압축, 암호화에 사용. 바이너리 리터럴(0b 접두사)은 C23/C++14; 오래된 C에서는 16진수(0x) 또는 10진수 사용.

c
#include <stdio.h>

int main() {
    unsigned int a = 0b1100;  // 12
    unsigned int b = 0b1010;  // 10

    // AND: both bits must be 1
    printf("%u\n", a & b);   // 8  (0b1000)

    // OR: either bit is 1
    printf("%u\n", a | b);   // 14 (0b1110)

    // XOR: bits differ (exclusive or)
    printf("%u\n", a ^ b);   // 6  (0b0110)

    // NOT: flip all bits
    printf("%u\n", ~a);      // 4294967283 (on 32-bit)

    // Left shift: multiply by 2^n
    printf("%u\n", a << 2);  // 48 (12 * 4)

    // Right shift: divide by 2^n (unsigned)
    printf("%u\n", a >> 1);  // 6  (12 / 2)

    return 0;
}

비트 설정, 지우기 & 토글

비트 플래그는 여러 boolean 옵션을 단일 정수로 패킹하여 메모리를 절약합니다. 세 가지 핵심 연산: SET(|= mask), CLEAR(&= ~mask), TOGGLE(^= mask), CHECK(& mask). 읽기 쉬운 플래그 이름을 위해 (1 << n)과 함께 #define 사용. 이 패턴은 시스템 프로그래밍(파일 권한, 장치 제어, 설정 옵션)에서 널리 사용. 예를 들어, Unix 파일 권한(rwxr-xr-x = 0755)은 비트 플래그 사용. 부호 확장 문제를 피하기 위해 항상 부호 없는 정수 사용. bool 배열(플래그당 8비트 대 1비트)보다 메모리 효율적.

c
#include <stdio.h>

// Flag definitions (powers of 2)
#define FLAG_READ    (1 << 0)  // 0b0001
#define FLAG_WRITE   (1 << 1)  // 0b0010
#define FLAG_EXECUTE (1 << 2)  // 0b0100
#define FLAG_ADMIN   (1 << 3)  // 0b1000

int main() {
    unsigned int permissions = 0;

    // SET a bit (OR with mask)
    permissions |= FLAG_READ | FLAG_WRITE;  // 0b0011

    // CHECK if a bit is set (AND, compare to 0)
    if (permissions & FLAG_READ) {
        printf("Read permission granted\n");
    }

    // CLEAR a bit (AND with inverted mask)
    permissions &= ~FLAG_WRITE;  // 0b0001

    // TOGGLE a bit (XOR with mask)
    permissions ^= FLAG_EXECUTE;  // 0b0101 (execute now on)
    permissions ^= FLAG_EXECUTE;  // 0b0001 (execute now off)

    // SET multiple bits at once
    permissions = FLAG_READ | FLAG_EXECUTE | FLAG_ADMIN;

    printf("Permissions: 0x%X\n", permissions);  // 0xD
    return 0;
}

비트 조작 트릭

비트 트릭은 속도를 위해 이진 표현을 활용. x & 1은 홀수/짝수 확인(모듈로보다 빠름). x & (x-1)은 최하위 설정 비트를 지움 — 2의 거듭제곱 확인과 비트 카운팅에 유용. __builtin_popcount(GCC/Clang) 또는 __popcnt(MSVC)는 현대 CPU에서 한 명령어로 설정 비트를 셈. XOR 교환(a^=b; b^=a; a^=b)은 임시 변수를 피하지만 현대 CPU에서 느리고 가독성 저하 — 피하세요. '2의 거듭제곱으로 올림' 트릭은 최고 설정 비트를 모든 하위 비트로 전파한 후 1을 더합니다. 이 트릭은 임베디드 시스템, 게임 엔진, 성능 중요 코드에 유용.

c
#include <stdio.h>

int main() {
    int x = 42;

    // Check if odd/even (faster than x % 2)
    if (x & 1) printf("odd\n"); else printf("even\n");

    // Check if power of 2 (only one bit set)
    // x & (x-1) clears the lowest set bit
    if (x && !(x & (x - 1))) printf("power of 2\n");

    // Count set bits (popcount / Hamming weight)
    unsigned int n = 0b10110110;
    int count = 0;
    while (n) { count += n & 1; n >>= 1; }
    printf("Set bits: %d\n", count);  // 6
    // Or use __builtin_popcount(n) (GCC/Clang)

    // Swap two values without temp (XOR swap)
    int a = 5, b = 10;
    a ^= b; b ^= a; a ^= b;
    // a=10, b=5 (avoid in practice — less readable)

    // Get lowest set bit
    unsigned int lowest = x & (-x);  // isolates lowest 1-bit

    // Round up to next power of 2
    unsigned int v = 5;
    v--; v |= v >> 1; v |= v >> 2; v |= v >> 4; v |= v >> 8; v |= v >> 16; v++;

    return 0;
}

구조체의 비트 필드

비트 필드는 여러 작은 값을 단일 구조체로 패킹하여 메모리를 절약합니다. 콜론 구문(unsigned int field : N)이 비트 너비를 지정. 컴파일러가 비트 추출/삽입을 자동으로 처리. 메모리 제약 시스템, 네트워크 프로토콜, 하드웨어 레지스터 매핑에 유용. 하지만 비트 필드 레이아웃은 구현 정의(바이트 순서, 정렬, 패딩) — 크로스 플랫폼 바이너리 호환성에는 비트 필드를 사용하지 마세요. 이식 가능한 바이너리 형식에는 명시적 비트 마스킹(#define + & |)을 사용. 이름 없는 필드(: 5)는 패딩 추가. 총 크기는 구조체의 정렬로 반올림.

c
#include <stdio.h>

// Bit fields: pack multiple small fields into one int
struct Date {
    unsigned int day   : 5;   // 0-31  (5 bits)
    unsigned int month : 4;   // 0-15  (4 bits)
    unsigned int year  : 12;  // 0-4095 (12 bits)
    unsigned int is_leap : 1; // 0 or 1 (1 bit)
};  // Total: 22 bits (padded to 32)

struct Flags {
    unsigned int visible  : 1;
    unsigned int editable : 1;
    unsigned int locked   : 1;
    unsigned int          : 5;  // unnamed padding (5 bits)
    unsigned int priority : 4;  // 0-15
};

int main() {
    struct Date d = { 15, 6, 2024, 0 };
    printf("Size: %zu bytes\n", sizeof(d));  // 4 bytes
    printf("Date: %u/%u/%u\n", d.day, d.month, d.year);

    struct Flags f = { .visible = 1, .editable = 0, .locked = 1, .priority = 7 };
    printf("Size: %zu bytes\n", sizeof(f));  // 4 bytes

    return 0;
}

실용적 비트 조작 (RGB 색상)

여러 값을 하나의 정수로 패킹하는 것은 그래픽, 네트워킹, 임베디드 시스템에서 일반적입니다. RGB 색상은 세 개의 8비트 채널을 24비트(0xRRGGBB)로 패킹. 왼쪽 시프트(<<)가 각 채널을 배치하고, OR(|)가 결합. 오른쪽 시프트(>>)와 마스킹(& 0xFF)이 개별 채널을 추출. 메모리 절약(3바이트 대 1 int)과 원자적 연산 가능. 같은 패턴이 네트워크 바이트 순서, 하드웨어 레지스터 접근, 데이터 압축에 적용. 이식성을 위해 항상 고정 너비 타입(uint8_t, uint32_t) 사용 — int 크기는 플랫폼에 따라 다름.

c
#include <stdio.h>
#include <stdint.h>

// Pack RGB into a single 32-bit integer (0xRRGGBB)
uint32_t make_color(uint8_t r, uint8_t g, uint8_t b) {
    return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
}

// Extract components
uint8_t get_red(uint32_t color)   { return (color >> 16) & 0xFF; }
uint8_t get_green(uint32_t color) { return (color >> 8)  & 0xFF; }
uint8_t get_blue(uint32_t color)  { return color & 0xFF; }

// Blend two colors (50/50 mix)
uint32_t blend(uint32_t c1, uint32_t c2) {
    uint8_t r = (get_red(c1) + get_red(c2)) / 2;
    uint8_t g = (get_green(c1) + get_green(c2)) / 2;
    uint8_t b = (get_blue(c1) + get_blue(c2)) / 2;
    return make_color(r, g, b);
}

int main() {
    uint32_t red   = make_color(255, 0, 0);    // 0xFF0000
    uint32_t blue  = make_color(0, 0, 255);    // 0x0000FF
    uint32_t purple = blend(red, blue);          // 0x7F007F

    printf("Red:   0x%06X\n", red);
    printf("Blue:  0x%06X\n", blue);
    printf("Mix:   0x%06X (R=%d G=%d B=%d)\n",
           purple, get_red(purple), get_green(purple), get_blue(purple));

    return 0;
}
12

시그널 처리

기본 시그널 처리

시그널은 프로세스에 보내지는 소프트웨어 인터럽트(예: Ctrl+C는 SIGINT 전송, 0으로 나누기는 SIGFPE 전송). signal()은 핸들러 함수를 등록. 핸들러 내에서는 async-signal-safe 함수만 허용 — printf, malloc, 대부분의 stdlib 함수는 안전하지 않음(메인 프로그램이 호출 중간에 중단될 수 있음). 출력에는 write() 사용. 일반 시그널: SIGINT(Ctrl+C), SIGTERM(종료 요청), SIGKILL(강제 종료, catch 불가), SIGSEGV(세그폴트), SIGALRM(타이머). 이식성과 제어를 위해 signal()보다 sigaction()을 선호.

c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

// Signal handler function (must match signature)
void handler(int sig) {
    // WARNING: only async-signal-safe functions allowed here!
    // printf is NOT safe — use write() instead
    const char *msg = "Caught SIGINT\n";
    write(STDOUT_FILENO, msg, 14);
}

int main() {
    // Register handler for Ctrl+C (SIGINT)
    signal(SIGINT, handler);

    // Ignore SIGINT entirely
    // signal(SIGINT, SIG_IGN);

    // Reset to default behavior (terminate)
    // signal(SIGINT, SIG_DFL);

    printf("PID %d waiting. Press Ctrl+C...\n", getpid());
    while (1) {
        sleep(1);
    }
    return 0;
}

sigaction (이식 가능한 시그널 처리)

sigaction()은 현대적이고 이식 가능한 시그널 처리 방법(signal() 동작은 플랫폼에 따라 다름). sa_sigaction 핸들러는 세부 정보가 있는 siginfo_t를 받음: si_pid(전송자 PID), si_uid(전송자 UID), si_signo(시그널 번호), si_code(이유). SA_SIGINFO 플래그는 3인수 핸들러를 활성화. sa_mask는 핸들러 실행 중 특정 시그널을 차단(중첩 인터럽트 방지). 기타 플래그: SA_RESTART(중단된 시스템 콜 자동 재시작), SA_NOCLDWAIT(좀비 자식 없음). 프로덕션 코드에는 항상 sigaction() 사용 — signal()은 일부 플랫폼에서 신뢰할 수 없음.

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

void handler(int sig, siginfo_t *info, void *context) {
    // siginfo_t provides details about the signal
    printf("Signal %d from PID %d\n", sig, info->si_pid);
}

int main() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));

    sa.sa_sigaction = handler;  // use sa_sigaction (not sa_handler)
    sa.sa_flags = SA_SIGINFO;   // enable siginfo_t parameter

    // Block other signals during handler execution
    sigemptyset(&sa.sa_mask);
    sigaddset(&sa.sa_mask, SIGQUIT);  // block SIGQUIT during handler

    // Register (more portable than signal())
    sigaction(SIGINT, &sa, NULL);

    // Send a signal to self
    raise(SIGINT);  // like kill(getpid(), SIGINT)

    printf("Done\n");
    return 0;
}

시그널 전송 & 알람

alarm(seconds)는 지정된 시간 후 SIGALRM 예약 — 타임아웃에 유용. pause()는 시그널이 도착할 때까지 차단. volatile sig_atomic_t는 시그널 핸들러와 메인 코드 간에 데이터를 공유하는 유일한 안전한 방법 — volatile은 컴파일러 최적화를 방지, sig_atomic_t는 원자적 접근을 보장. kill(pid, signal)은 다른 프로세스에 시그널 전송. raise(sig)는 자신에게 시그널 전송. SIGKILL(9)과 SIGSTOP은 catch하거나 무시할 수 없음 — 항상 작동. SIGTERM(15)은 정중한 종료 요청(프로그램이 정리하기 위해 catch 가능). 단순 타임아웃에는 alarm() 사용; 더 많은 제어를 위해 setitimer()/timer_create() 사용.

c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t got_alarm = 0;

void alarm_handler(int sig) {
    got_alarm = 1;  // safe: sig_atomic_t is atomic
}

int main() {
    signal(SIGALRM, alarm_handler);

    // Set a timer: deliver SIGALRM after 3 seconds
    alarm(3);
    printf("Waiting for alarm...\n");

    // Wait for the alarm
    while (!got_alarm) {
        pause();  // sleep until any signal arrives
    }
    printf("Alarm fired!\n");

    // Send signal to another process
    // kill(pid, SIGTERM);  // request termination
    // kill(pid, SIGKILL);  // force kill (can't be caught)

    // Send signal to self
    raise(SIGUSR1);

    return 0;
}

일반 시그널 참조

시그널 이해는 Unix 프로그래밍에 필수. SIGKILL(9)과 SIGSTOP은 catch 불가 — 최후의 수단. SIGTERM은 표준 우아한 종료 시그널(상태 저장을 위해 catch). SIGINT는 Ctrl+C(대화형 인터럽트). SIGCHLD는 자식이 종료할 때 발생 — wait()하지 않으면 자식이 좀비가 됨. SIGCHLD를 SIG_IGN으로 설정하면 커널이 자동 수거(또는 SA_NOCLDWAIT 사용). SIGPIPE는 닫힌 파이프/소켓에 쓸 때 발생 — 대부분의 서버는 이를 무시(signal(SIGPIPE, SIG_IGN))하고 write() 반환 값을 확인. 시그널 핸들러에서는 exit()(atexit 핸들러 실행, 시그널 안전하지 않을 수 있음)가 아닌 _exit() 사용.

c
#include <signal.h>

// Common POSIX signals:
// SIGINT  (2)  - Ctrl+C interrupt (terminate)
// SIGQUIT (3)  - Ctrl+\ quit (core dump)
// SIGKILL (9)  - Force kill (CANNOT be caught/ignored)
// SIGSEGV (11) - Segmentation fault (invalid memory access)
// SIGPIPE (13) - Write to broken pipe
// SIGTERM (15) - Termination request (graceful shutdown)
// SIGSTOP (19) - Pause process (CANNOT be caught/ignored)
// SIGCONT (18) - Resume paused process
// SIGCHLD (17) - Child process exited
// SIGALRM (14) - Timer alarm
// SIGUSR1 (10) - User-defined signal 1
// SIGUSR2 (12) - User-defined signal 2
// SIGFPE  (8)  - Arithmetic error (divide by zero)
// SIGBUS  (7)  - Bus error (misaligned access)

// Graceful shutdown pattern:
void cleanup_handler(int sig) {
    // Save state, close files, release resources
    // Then exit cleanly
    _exit(0);  // use _exit in signal handlers (not exit)
}

// In main:
signal(SIGTERM, cleanup_handler);
signal(SIGINT, cleanup_handler);

// Prevent zombie children:
signal(SIGCHLD, SIG_IGN);  // auto-reap children

Self-Pipe 트릭 (시그널 안전 웨이크업)

Self-pipe 트릭은 근본적인 문제를 해결: 시그널 핸들러는 안전하게 복잡한 작업을 할 수 없지만, 메인 루프에서 시그널에 응답해야 함. 해결책: 핸들러가 파이프에 바이트를 쓰고, 메인 루프는 select()/poll()로 감지. 이는 시그널을 이벤트 루프에 안전하게 통합. 핸들러는 write()만 호출(async-signal-safe). 현대 대안: signalfd()(Linux별, 시그널을 파일 디스크립터로 직접 변환) 또는 pselect()(select 중 원자적으로 시그널 차단). 이 패턴은 이벤트 구동 서버(nginx, Redis)에서 경쟁 조건 없이 시그널을 처리하는 데 사용.

c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>

int pipe_fd[2];  // [0]=read, [1]=write

void handler(int sig) {
    // Write one byte to the pipe — wakes up select()/poll()
    write(pipe_fd[1], &sig, sizeof(sig));
}

int main() {
    pipe(pipe_fd);
    // Make read end non-blocking
    fcntl(pipe_fd[0], F_SETFL, O_NONBLOCK);

    signal(SIGINT, handler);
    signal(SIGTERM, handler);

    printf("Waiting (select-based)...\n");

    while (1) {
        fd_set readfds;
        FD_ZERO(&readfds);
        FD_SET(pipe_fd[0], &readfds);

        // select() blocks until pipe is writable (signal received)
        int ready = select(pipe_fd[0] + 1, &readfds, NULL, NULL, NULL);
        if (ready > 0 && FD_ISSET(pipe_fd[0], &readfds)) {
            int sig;
            read(pipe_fd[0], &sig, sizeof(sig));
            printf("Handled signal %d in main loop\n", sig);
            if (sig == SIGTERM) break;
        }
    }
    return 0;
}
13

프로세스 Fork & Exec

fork() 기본

fork()는 현재 프로세스의 정확한 복사본을 생성 — 유일한 차이는 반환 값: 자식에서 0, 부모에서 자식의 PID. 두 프로세스 모두 fork() 호출에서 계속. 자식은 부모의 메모리 복사본을 받음(copy-on-write가 이를 최적화). 세 가지 경우를 항상 확인: pid < 0(에러), pid == 0(자식), pid > 0(부모). waitpid()는 자식이 종료할 때까지 차단하고 상태를 검색. WIFEXITED는 정상 종료 확인, WEXITSTATUS는 종료 코드 가져오기. wait()하지 않으면 자식이 수거될 때까지 좀비가 됨.

c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();  // create a child process

    if (pid < 0) {
        perror("fork failed");
        return 1;
    } else if (pid == 0) {
        // CHILD process (fork returned 0)
        printf("Child: PID=%d, Parent PID=%d\n",
               getpid(), getppid());
        sleep(2);
        printf("Child exiting\n");
        return 42;  // child exit code
    } else {
        // PARENT process (fork returned child's PID)
        printf("Parent: PID=%d, Child PID=%d\n",
               getpid(), pid);

        int status;
        waitpid(pid, &status, 0);  // wait for child

        if (WIFEXITED(status)) {
            printf("Child exited with code %d\n",
                   WEXITSTATUS(status));
        }
    }
    return 0;
}

exec 패밀리 (프로세스 이미지 교체)

exec는 현재 프로세스 이미지를 새 프로그램으로 교체 — PID는 동일하게 유지되지만 코드, 데이터, 스택이 교체. exec는 실패 시에만 반환. 명명 규칙: 'l' = 인수 목록(가변, NULL 종료), 'v' = 인수 벡터/배열, 'p' = 실행 파일을 위해 PATH 검색, 'e' = 사용자 정의 환경. 첫 번째 인수는 관례적으로 프로그램 이름(argv[0]). fork()+exec()는 Unix에서 프로그램을 시작하는 방식 — fork가 프로세스를 생성, exec가 새 프로그램을 로드. 이 분리는 fork와 exec 사이에 파일 디스크립터, 환경, 시그널 설정을 가능하게.

c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // CHILD: replace self with a new program
        // exec never returns on success (only on failure)

        // execlp: search PATH, list arguments
        execlp("ls", "ls", "-la", "/tmp", NULL);

        // execvp: search PATH, array of arguments
        char *args[] = {"ls", "-la", "/tmp", NULL};
        execvp("ls", args);

        // execl: full path, list arguments
        execl("/bin/ls", "ls", "-la", NULL);

        // Only reached if exec failed
        perror("exec failed");
        _exit(1);
    } else {
        wait(NULL);  // parent waits for child
        printf("Child finished\n");
    }
    return 0;
}

// exec variants:
// execl  (path, arg1, arg2, ..., NULL)        — list args, full path
// execlp (file, arg1, arg2, ..., NULL)        — list args, search PATH
// execv  (path, argv[])                        — array args, full path
// execvp (file, argv[])                        — array args, search PATH
// execve (path, argv[], envp[])                — array args, custom env

좀비 & 고아 프로세스

좀비는 자식이 종료했지만 부모가 wait()를 호출하지 않았을 때 발생 — 커널은 수거될 때까지 프로세스 테이블 항목(PID, 종료 상태)을 유지. 좀비는 PID를 낭비하고 프로세스 테이블을 고갈시킬 수 있음. 해결: 항상 자식을 wait(), 또는 SIGCHLD를 SIG_IGN으로 설정(커널이 자동 수거). 고아는 부모가 자식보다 먼저 종료할 때 발생 — init/systemd(PID 1)이 고아를 입양하고 종료 시 수거. 이중 fork 패턴(fork, 자식이 다시 fork, 첫 번째 자식 종료)은 init에 자동으로 재부모되어 터미널에서 분리된 데몬을 생성. 'ps aux | grep Z' 또는 'top'으로 좀비 모니터링.

c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("Child PID=%d\n", getpid());
        _exit(0);  // child exits immediately
    }

    // If parent doesn't wait(), child becomes a ZOMBIE
    // (process table entry remains until reaped)
    sleep(5);  // parent sleeps — child is now a zombie
    // Run 'ps' during this window to see the zombie (state 'Z')

    // Reap the zombie:
    int status;
    waitpid(pid, &status, 0);
    printf("Zombie reaped\n");

    // Orphan: if parent exits before child
    pid_t pid2 = fork();
    if (pid2 == 0) {
        sleep(3);  // parent will exit first
        printf("Orphan adopted by init (PID 1), new parent=%d\n",
               getppid());
        _exit(0);
    }
    // Parent exits immediately — child becomes orphan
    // init/systemd (PID 1) adopts and reaps it

    // Prevent zombies: ignore SIGCHLD
    // signal(SIGCHLD, SIG_IGN);  // kernel auto-reaps children
    // Or use SA_NOCLDWAIT with sigaction

    return 0;
}

데몬 프로세스 생성

데몬은 터미널 없이 실행되는 백그라운드 프로세스(예: 웹 서버, 데이터베이스). 데몬화 단계: 셸에서 분리하기 위해 fork+exit, setsid()로 새 세션 생성(제어 터미널 없음), 안전을 위해 다시 fork, 파일 시스템을 잡지 않도록 chdir('/'), 예측 가능한 파일 권한을 위해 umask 설정, stdio를 /dev/null로 닫기/리다이렉트. 이중 fork는 open()을 통해 데몬이 터미널을 재획득하는 것을 방지하는 Unix 관례. 현대 시스템은 데몬 관리를 위해 systemd 서비스 파일을 제공하지만, 수동 데몬화 이해는 임베디드 시스템과 이식 가능한 코드에 여전히 중요. stdout이 /dev/null이므로 파일에 로그.

c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>

void daemonize() {
    // 1. Fork and exit parent (child continues in background)
    pid_t pid = fork();
    if (pid > 0) exit(0);  // parent exits
    if (pid < 0) exit(1);

    // 2. Create new session (detach from controlling terminal)
    setsid();

    // 3. Fork again (prevent reacquiring a terminal)
    pid = fork();
    if (pid > 0) exit(0);
    if (pid < 0) exit(1);

    // 4. Change working directory to / (don't hold filesystem)
    chdir("/");

    // 5. Set umask to 0 (full control over file permissions)
    umask(0);

    // 6. Close standard file descriptors (detach from terminal)
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);

    // 7. Redirect them to /dev/null (in case code writes to them)
    open("/dev/null", O_RDWR);  // fd 0 = stdin
    dup(0);                      // fd 1 = stdout
    dup(0);                      // fd 2 = stderr
}

int main() {
    daemonize();
    // Now running as a daemon (background, no terminal)
    while (1) {
        // Daemon work here (e.g., log to file, listen on socket)
        sleep(60);
    }
    return 0;
}

프로세스 간 통신 (IPC) 개요

IPC는 프로세스가 통신하게 함. 파이프는 가장 단순(부모-자식, 단방향). 명명된 파이프(FIFO)는 파일 시스템 경로를 통해 관련 없는 프로세스 간 작동. 공유 메모리는 가장 빠름(제로 카피)하지만 동기화 필요(세마포어/뮤텍스). 소켓은 가장 유연(양방향, 네트워크 가능). 메시지 큐는 구조화된, 메시지 경계 통신 제공. 시그널은 최소(단지 숫자). 필요에 따라 선택: 단순 부모-자식에는 파이프, 고성능 데이터 공유에는 공유 메모리, 네트워크 통신에는 소켓. System V IPC(shmget, semget)는 오래됨; POSIX IPC(shm_open, sem_open)는 더 깔끔하지만 보편적으로 사용 가능하지는 않음.

c
#include <stdio.h>
// C provides several IPC mechanisms:

// 1. PIPES: unidirectional byte stream between parent/child
//    pipe(fd) creates fd[0]=read, fd[1]=write
//    Only works between related processes (fork)

// 2. NAMED PIPES (FIFOs): like pipes but have a filesystem path
//    mkfifo("/tmp/myfifo", 0666);
//    Works between unrelated processes

// 3. SHARED MEMORY: fastest IPC (both processes access same RAM)
//    shmget/shmat (System V) or shm_open/mmap (POSIX)

// 4. MESSAGE QUEUES: structured messages (not byte stream)
//    msgget/msgsnd/msgrcv (System V) or mq_open (POSIX)

// 5. SEMAPHORES: synchronization (not data transfer)
//    semget/semop (System V) or sem_open (POSIX)

// 6. SOCKETS: bidirectional, works across machines (network)
//    socket/bind/listen/accept/connect

// 7. SIGNALS: minimal data (just signal number)
//    kill(pid, SIGUSR1)

// Choosing IPC:
// - Same machine, related processes → pipes
// - Same machine, unrelated processes → named pipes, shared memory
// - Different machines → sockets
// - Need synchronization → semaphores, mutexes
// - Need structured messages → message queues
14

파이프 & IPC

익명 파이프 (부모-자식)

파이프는 관련 프로세스(fork로 생성) 간 단방향 통신을 제공. pipe(fd)는 두 개의 파일 디스크립터 생성: fd[0]은 읽기, fd[1]은 쓰기. 중요: 각 프로세스에서 사용하지 않는 끝을 닫기 — 부모는 읽기 끝을 닫고, 자식은 쓰기 끝을 닫음. 쓰기 끝이 닫히지 않으면 자식의 read()는 영원히 차단(더 많은 데이터 대기). read()는 모든 쓰기 끝이 닫힐 때만 0(EOF) 반환. 파이프는 고정 버퍼(일반적으로 64KB) — 버퍼가 가득 차면 write() 차단. 파이프는 부모-자식 통신과 셸 명령 파이핑(ls | grep)에 이상적.

c
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    int fd[2];  // fd[0]=read end, fd[1]=write end
    pipe(fd);   // create pipe

    pid_t pid = fork();

    if (pid == 0) {
        // CHILD: read from pipe
        close(fd[1]);  // close unused write end

        char buf[256];
        int n = read(fd[0], buf, sizeof(buf));
        buf[n] = '\0';
        printf("Child received: %s", buf);

        close(fd[0]);
    } else {
        // PARENT: write to pipe
        close(fd[0]);  // close unused read end

        const char *msg = "Hello from parent!\n";
        write(fd[1], msg, strlen(msg));

        close(fd[1]);  // close write end → child's read returns 0 (EOF)
        wait(NULL);
    }
    return 0;
}

명명된 파이프 (FIFO)

명명된 파이프(FIFO)는 파일 시스템 이름이 있는 파이프 — 관련 없는 프로세스 간 작동. mkfifo()가 파이프 파일 생성; open()은 리더와 라이터가 모두 연결될 때까지 차단(내장 동기화). FIFO는 unlink()될 때까지 지속(프로세스가 종료되면 사라지는 익명 파이프와 달리). 별도 프로그램 간 단순 IPC에 유용. open()의 차단 동작은 리더가 준비될 때까지 라이터가 시작하지 않도록 보장. 비차단 열기를 위해 O_NONBLOCK 사용. FIFO는 단방향 — 양방향 통신에는 두 개의 FIFO나 소켓 사용. 명명된 파이프는 셸 스크립트와 시스템 서비스에서 일반적으로 사용.

c
#include <stdio.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <string.h>

// Process A (writer):
int writer_main() {
    mkfifo("/tmp/myfifo", 0666);  // create named pipe

    int fd = open("/tmp/myfifo", O_WRONLY);
    write(fd, "Hello via FIFO!", 15);
    close(fd);
    return 0;
}

// Process B (reader) — can be a completely separate program:
int reader_main() {
    int fd = open("/tmp/myfifo", O_RDONLY);
    char buf[256];
    int n = read(fd, buf, sizeof(buf));
    buf[n] = '\0';
    printf("Received: %s\n", buf);
    close(fd);
    return 0;
}

// Named pipes persist in the filesystem (use unlink to remove):
// unlink("/tmp/myfifo");

// open() blocks until BOTH a reader and writer are connected
// (unless O_NONBLOCK is used)

공유 메모리 (가장 빠른 IPC)

공유 메모리는 가장 빠른 IPC — 두 프로세스가 같은 물리적 RAM을 매핑하여 데이터 전송이 제로 카피. shmget()이 세그먼트 생성, shmat()이 프로세스의 주소 공간에 부착, shmdt()가 분리, shmctl(IPC_RMID)가 파괴. 중요한 주의: 공유 메모리는 동기화를 제공하지 않음 — 두 프로세스가 동시에 접근하면 데이터 경쟁 발생. 접근을 조정하기 위해 세마포어, 뮤텍스 또는 다른 동기화를 반드시 사용. ftok()가 파일 경로에서 키 생성(두 프로세스가 키에 동의해야 함). 완료 시 항상 공유 메모리 파괴(프로세스 종료 후에도 지속되어 메모리 누수). POSIX 공유 메모리(shm_open/mmap)는 더 깔끔한 대안.

c
#include <stdio.h>
#include <sys/shm.h>
#include <sys/ipc.h>
#include <string.h>
#include <unistd.h>

#define SHM_SIZE 1024

int main() {
    key_t key = ftok("/tmp/shmfile", 65);  // generate unique key

    // Create shared memory segment
    int shmid = shmget(key, SHM_SIZE, 0666 | IPC_CREAT);

    pid_t pid = fork();

    if (pid == 0) {
        // CHILD: attach and read
        char *shared = (char *)shmat(shmid, NULL, 0);
        sleep(1);  // wait for parent to write
        printf("Child reads: %s\n", shared);
        shmdt(shared);  // detach
    } else {
        // PARENT: attach and write
        char *shared = (char *)shmat(shmid, NULL, 0);
        strcpy(shared, "Hello from shared memory!");
        printf("Parent wrote to shared memory\n");
        shmdt(shared);
        wait(NULL);

        // Destroy shared memory after use
        shmctl(shmid, IPC_RMID, NULL);
    }
    return 0;
}

// WARNING: shared memory has NO synchronization!
// Use semaphores or mutexes to prevent race conditions.

dup2 & 리다이렉션

dup2(oldfd, newfd)는 newfd를 oldfd의 복사본으로 만듦 — 이것이 셸 리다이렉션의 작동 방식. stdout을 파이프로 리다이렉트: dup2(pipe_write, STDOUT_FILENO) — 이제 stdout으로의 printf/write가 파이프로 감. stdin을 파이프에서 리다이렉트: dup2(pipe_read, STDIN_FILENO) — 이제 stdin에서의 scanf/read가 파이프에서 옴. 이것이 셸이 파이프(ls | sort), 리다이렉션(ls > file), 입력(sort < file)을 구현하는 정확한 방식. dup2 후 원래 fd를 닫으세요(복제됨). 이 패턴은 Unix 파이프라인을 프로그래밍 방식으로 구축하는 데 근본적이며 셸, popen(), 프로세스 관리 라이브러리에서 사용.

c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>

// Implement shell-like pipeline: ls | sort
int main() {
    int pipefd[2];
    pipe(pipefd);

    pid_t pid1 = fork();
    if (pid1 == 0) {
        // First child: ls (writes to pipe instead of stdout)
        close(pipefd[0]);                    // close read end
        dup2(pipefd[1], STDOUT_FILENO);      // stdout → pipe write
        close(pipefd[1]);                    // close original (dup'd)
        execlp("ls", "ls", NULL);
    }

    pid_t pid2 = fork();
    if (pid2 == 0) {
        // Second child: sort (reads from pipe instead of stdin)
        close(pipefd[1]);                    // close write end
        dup2(pipefd[0], STDIN_FILENO);       // stdin → pipe read
        close(pipefd[0]);                    // close original (dup'd)
        execlp("sort", "sort", NULL);
    }

    // Parent: close both ends and wait
    close(pipefd[0]);
    close(pipefd[1]);
    waitpid(pid1, NULL, 0);
    waitpid(pid2, NULL, 0);

    return 0;
}

popen (고급 파이프)

popen()은 fork+pipe+exec+shell의 고급 래퍼 — /bin/sh를 통해 명령을 실행하고 출력 읽기('r') 또는 입력 쓰기('w')를 위해 FILE*을 반환. 수동 fork/pipe/exec보다 훨씬 단순하지만 셸을 통해 실행되므로 신뢰할 수 없는 입력을 절대 전달하지 마세요(셸 인젝션 위험). 반환된 FILE*에서 일반 파일처럼 fgets/fprintf 사용. pclose()가 파이프를 닫고 자식이 종료될 때까지 대기(상태 반환). 신뢰할 수 없는 입력의 경우 직접 fork+execvp 사용(셸 없음). popen은 빠른 스크립트, 시스템 관리 도구, 명령 출력 읽기에 완벽. 양방향 통신의 경우 socketpair() 또는 두 개의 파이프 사용.

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

int main() {
    // popen opens a process with a pipe (like shell command | ...)
    FILE *fp = popen("ls -la /tmp", "r");
    if (fp == NULL) {
        perror("popen failed");
        return 1;
    }

    // Read command output line by line
    char buf[256];
    while (fgets(buf, sizeof(buf), fp) != NULL) {
        printf(">> %s", buf);
    }

    pclose(fp);  // closes pipe and waits for child

    // Writing to a process (like ... | command):
    FILE *wp = popen("grep hello", "w");
    fprintf(wp, "hello world\n");
    fprintf(wp, "goodbye\n");
    pclose(wp);  // grep outputs "hello world"

    return 0;
}

// popen is simpler than fork+pipe+exec but:
// - Runs via /bin/sh (shell injection risk with user input!)
// - Less control over the child process
// - Use fork+exec directly for untrusted input
15

Makefile & 빌드 도구

기본 Makefile 구조

Make는 컴파일을 자동화. Makefile은 규칙으로 구성: target(빌드할 파일), prerequisites(의존성), recipe(셸 명령, TAB 들여쓰기). 변수(CC, CFLAGS)가 설정을 중앙화. 자동 변수: $@(target 이름), $<(첫 번째 전제조건), $^(모든 전제조건). 패턴 규칙(%.o: %.c)이 모든 소스 파일에 대한 컴파일을 일반화. .PHONY는 파일이 아닌 target(clean, all, install)을 선언. 첫 번째 규칙이 기본(인수 없는 make는 'all' 빌드). Make는 파일 타임스탬프를 추적 — 전제조건이 target보다 최신인 경우에만 재빌드. 이 증분 빌드는 큰 프로젝트에서 시간을 절약.

c
# Makefile — build automation for C/C++ projects
# Rule syntax: target: prerequisites
#               recipe (must start with TAB, not spaces)

# Variables
CC = gcc
CFLAGS = -Wall -Wextra -g -O2
TARGET = myapp
SRCS = main.c utils.c parser.c
OBJS = $(SRCS:.c=.o)  # substitute .c with .o

# Default target (first rule)
all: $(TARGET)

# Link object files into executable
$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) -o $@ $^

# Compile each .c to .o
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# Clean build artifacts
clean:
	rm -f $(OBJS) $(TARGET)

# Run the program
run: $(TARGET)
	./$(TARGET)

# Phony targets (not files)
.PHONY: all clean run

# Usage:
# $ make        — builds 'all' (the default)
# $ make clean  — removes build files
# $ make run    — builds and runs
# $ make -j4    — parallel build (4 jobs)

자동 변수 & 패턴 규칙

자동 변수는 Makefile을 간결하고 유지보수 가능하게 만듭니다. $@(target), $<(첫 번째 전제조건), $^(모든 전제조건)가 가장 일반적. 패턴 규칙(%.o: %.c)은 모든 소스 파일에 대해 하나의 규칙을 작성하게 함 — %는 임의의 문자열과 일치. $(wildcard)는 글로브와 일치하는 파일을 찾고, $(patsubst)는 문자열을 변환 — 함께 소스를 자동 발견. @ 접두어는 명령 에코를 억제. 정적 패턴 규칙(target: %.o: %.c)은 특정 목록에 적용. 이 기능을 이해하면 반복적인 규칙을 제거하고 Makefile을 큰 프로젝트로 확장. recipe 들여쓰기에는 항상 공백이 아닌 TAB 사용 — Make는 이에 대해 엄격.

c
# Automatic variables in recipes:
# $@   — the target filename
# $<   — the first prerequisite
# $^   — all prerequisites (no duplicates)
# $+   — all prerequisites (with duplicates)
# $?   — prerequisites newer than the target
# $*   — the stem (matching % part)

# Example showing all automatic variables:
program: main.o utils.o
	@echo "Target: $@"        # program
	@echo "First dep: $<"     # main.o
	@echo "All deps: $^"      # main.o utils.o
	@echo "Newer deps: $?"    # (whichever changed)
	gcc -o $@ $^

# Pattern rule: compile any .c to .o
%.o: %.c
	gcc -c $< -o $@
# $< = source (.c file), $@ = target (.o file)

# Static pattern rule (specific files):
$(OBJS): %.o: %.c
	gcc -c $< -o $@

# Built-in functions:
SRCS = $(wildcard src/*.c)           # find all .c files
OBJS = $(patsubst src/%.c,build/%.o,$(SRCS))  # path substitution
DIRS = $(sort $(dir $(SRCS)))         # unique directories

의존성 & 헤더 파일

헤더 의존성 추적은 중요 — 없으면 .h 파일을 변경해도 이를 포함하는 .c 파일의 재컴파일이 트리거되지 않아 오래된 빌드 발생. 해결책: gcc -MMD -MP는 모든 의존성(헤더 포함)을 나열하는 .d 파일 생성. -include는 이를 Makefile로 가져옴. -MP는 헤더에 대한 가짜 target 추가(헤더가 삭제된 경우 에러 방지). 이것이 C/C++ 프로젝트의 표준 접근 방식. 없으면 모든 헤더 의존성을 수동으로 나열해야 함 — 큰 프로젝트에서 관리 불가. 첫 빌드에는 .d 파일이 없음(-include의 '-'가 에러 억제); 컴파일 중에 생성되어 후속 빌드에서 사용.

c
# When a header file changes, dependent .c files must recompile
# Make doesn't track this automatically — use gcc -MMD

CC = gcc
CFLAGS = -Wall -MMD -MP  # generate .d dependency files

SRCS = $(wildcard src/*.c)
OBJS = $(SRCS:.c=.o)
DEPS = $(OBJS:.o=.d)    # dependency files

all: myapp

myapp: $(OBJS)
	$(CC) -o $@ $^

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# Include auto-generated dependency files
-include $(DEPS)  # '-' means don't error if missing

clean:
	rm -f $(OBJS) $(DEPS) myapp

.PHONY: all clean

# How it works:
# 1. gcc -MMD creates main.d next to main.o
# 2. main.d contains: main.o: main.c utils.h parser.h
# 3. -include pulls these in, so Make knows header dependencies
# 4. If utils.h changes, main.o rebuilds automatically

다중 디렉토리 프로젝트 Makefile

실제 프로젝트는 여러 디렉토리에 걸쳐 있음. 이 Makefile은 소스를 자동 발견(wildcard), 빌드 디렉토리에 매핑(patsubst), 필요에 따라 디렉토리 생성. | 구문은 order-only 전제조건 생성 — $(BUILDDIR)이 컴파일 전에 생성되지만, 타임스탬프 변경이 재빌드를 트리거하지 않음(| 없으면 디렉토리 생성이 매번 모든 것을 재빌드하게 함). -Iinclude는 gcc에게 헤더를 찾을 위치를 알림. -MMD는 빌드 디렉토리에 의존성 파일 생성. 이 구조는 소스, 빌드, 바이너리 디렉토리를 분리 — 정리가 쉽고(rm -rf build) 소스 트리를 오염시키지 않음. 매우 큰 프로젝트의 경우 CMake나 Meson을 고려.

c
# Project structure:
# project/
#   src/       — source files
#   include/   — header files
#   build/     — object files (created by make)
#   bin/       — final executable

CC = gcc
CFLAGS = -Wall -Iinclude -g
SRCDIR = src
INCDIR = include
BUILDDIR = build
BINDIR = bin

TARGET = $(BINDIR)/myapp
SRCS = $(wildcard $(SRCDIR)/*.c)
OBJS = $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))
DEPS = $(OBJS:.o=.d)

all: $(TARGET)

$(TARGET): $(OBJS) | $(BINDIR)
	$(CC) -o $@ $^

# Order-only prerequisite: create build dir before compiling
$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR)
	$(CC) $(CFLAGS) -MMD -c $< -o $@

$(BUILDDIR):
	mkdir -p $(BUILDDIR)

$(BINDIR):
	mkdir -p $(BINDIR)

-include $(DEPS)

clean:
	rm -rf $(BUILDDIR) $(BINDIR)

.PHONY: all clean

# Order-only prerequisites (| syntax) create directories
# without triggering rebuilds when the dir timestamp changes

CMake 기본 (Make의 대안)

CMake는 메타 빌드 시스템 — CMakeLists.txt 파일에서 Makefile(또는 Ninja, Visual Studio, Xcode 프로젝트)을 생성. 크로스 플랫폼 컴파일, 의존성 감지, IDE 통합을 처리하므로 C/C++ 프로젝트의 사실상 표준. 주요 명령: project()는 프로젝트 이름 설정, add_executable()은 빌드 target 정의, target_include_directories()는 헤더 경로 추가, target_link_libraries()는 라이브러리 링크. 소스 외 빌드(mkdir build && cd build && cmake ..)는 소스 트리를 깔끔하게 유지. CMake는 플랫폼별로 컴파일러와 플래그를 자동 감지. 새 C/C++ 프로젝트의 경우 원시 Makefile보다 CMake를 선호 — 더 유지보수 가능하고 이식 가능.

c
# CMakeLists.txt — CMake is a cross-platform build generator
# It generates Makefiles (or Ninja, VS, Xcode projects)

cmake_minimum_required(VERSION 3.10)
project(MyApp C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra")

# Add executable from source files
add_executable(myapp src/main.c src/utils.c src/parser.c)

# Include directory
target_include_directories(myapp PRIVATE include)

# Link a library
target_link_libraries(myapp m)  # math library (-lm)

# Build type flags
set(CMAKE_C_FLAGS_DEBUG "-g -O0")
set(CMAKE_C_FLAGS_RELEASE "-O3 -DNDEBUG")

# Usage:
# $ mkdir build && cd build
# $ cmake ..            # generate Makefiles
# $ make                # build
# $ make install        # install (optional)
# $ cmake .. -DCMAKE_BUILD_TYPE=Debug  # debug build

# Out-of-source builds keep source tree clean
# CMake is the standard for C/C++ cross-platform projects
16

함수 포인터 심층 가이드

함수 포인터 구문 & 사용

함수 포인터는 함수의 주소를 저장하여 런타임 디스패치를 가능하게 합니다. int (*fp)(int, int) 구문은 악명 높게 혼란스럽습니다 — 'fp는 (int, int)를 받아 int를 반환하는 함수에 대한 포인터'로 읽으세요. typedef가 이를 단순화: typedef int (*math_func)(int, int)는 읽기 쉬운 별칭을 생성. 함수 이름은 포인터로 붕괴(배열 이름처럼), 'add'와 '&add'는 동일. 함수 포인터는 콜백, 이벤트 핸들러, 전략 패턴, 디스패치 테이블(switch 같은 디스패치를 위한 함수 포인터 배열)을 가능하게. qsort의 비교기와 GUI 이벤트 시스템의 기초.

c
#include <stdio.h>

// Function pointer syntax: return_type (*name)(param_types)
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }

int main() {
    // Declare a function pointer
    int (*operation)(int, int);

    // Assign (function name decays to pointer)
    operation = add;          // or &add
    printf("5 + 3 = %d\n", operation(5, 3));  // 8

    operation = subtract;
    printf("5 - 3 = %d\n", operation(5, 3));  // 2

    // typedef for readability
    typedef int (*math_func)(int, int);
    math_func fn = add;
    printf("Result: %d\n", fn(10, 20));

    // Array of function pointers (dispatch table)
    math_func ops[] = {add, subtract};
    printf("op[0](4,2)=%d  op[1](4,2)=%d\n",
           ops[0](4, 2), ops[1](4, 2));

    return 0;
}

콜백 (qsort 예제)

qsort는 콜백으로서의 함수 포인터의 고전적 예입니다. 비교기는 const void* 포인터(제네릭)를 받고 순서를 나타내는 정수를 반환합니다. qsort는 요소 순서를 결정하기 위해 비교기를 호출 — 다른 함수를 전달하여 정렬 동작을 제어합니다. 이것이 C의 전략 패턴: 알고리즘(qsort)은 고정되지만 비교 로직은 주입됩니다. void*는 제네릭 프로그래밍을 가능하게(모든 타입 정렬). 비교기는 순수 함수(부작용 없음)이고 일관성이 있어야 함(a<b이고 b<c면 a<c). 이 패턴은 C 표준 라이브러리 전체(bsearch, atexit, signal)에서 사용됩니다.

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

// Comparator function for qsort
// Returns: negative if a<b, 0 if equal, positive if a>b
int compare_asc(const void *a, const void *b) {
    return (*(int *)a - *(int *)b);
}

int compare_desc(const void *a, const void *b) {
    return (*(int *)b - *(int *)a);
}

int main() {
    int arr[] = {5, 2, 8, 1, 9, 3, 7, 4, 6};
    int n = sizeof(arr) / sizeof(arr[0]);

    // qsort takes a function pointer as the comparator
    qsort(arr, n, sizeof(int), compare_asc);
    printf("Ascending: ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");

    // Same array, different comparator (descending)
    qsort(arr, n, sizeof(int), compare_desc);
    printf("Descending: ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");

    return 0;
}

함수 포인터가 있는 구조체 (C에서의 OOP)

C는 함수 포인터가 있는 구조체로 OOP를 시뮬레이션할 수 있습니다 — 이것이 C++ vtable이 내부적으로 작동하는 방식입니다. 구조체는 함수 포인터를 보유하는 'vtable'(가상 함수 테이블)에 대한 포인터를 포함합니다. 각 '서브클래스'(Circle, Square)는 자체 구현이 있는 자체 vtable을 가집니다. Circle*를 Shape*로 캐스팅하면 다형성이 가능 — print_shape()는 vtable을 통해 올바른 area() 함수를 호출합니다. 이 패턴은 실제 C 코드에서 사용: Linux 커널(장치 드라이버), GObject(GTK), SQLite. 캡슐화, 상속(구조체 임베딩), 다형성을 제공. C++에 비해 장황하지만 메모리 레이아웃과 가상 디스패치에 대한 완전한 제어를 제공.

c
#include <stdio.h>

// Simulating OOP with structs + function pointers
typedef struct Shape Shape;

// Virtual function table (vtable)
typedef struct {
    double (*area)(Shape *);
    double (*perimeter)(Shape *);
    void (*describe)(Shape *);
} ShapeVTable;

struct Shape {
    const ShapeVTable *vtable;  // pointer to virtual functions
    char name[32];
};

// Circle implementation
typedef struct {
    Shape base;      // inherit from Shape
    double radius;
} Circle;

double circle_area(Shape *s) {
    return 3.14159 * ((Circle *)s)->radius * ((Circle *)s)->radius;
}

double circle_perimeter(Shape *s) {
    return 2 * 3.14159 * ((Circle *)s)->radius;
}

static const ShapeVTable circle_vtable = {
    circle_area, circle_perimeter, NULL
};

Circle *circle_create(double r) {
    Circle *c = malloc(sizeof(Circle));
    c->base.vtable = &circle_vtable;
    strcpy(c->base.name, "Circle");
    c->radius = r;
    return c;
}

// Polymorphic function (works with any Shape)
void print_shape(Shape *s) {
    printf("%s: area=%.2f, perimeter=%.2f\n",
           s->name, s->vtable->area(s), s->vtable->perimeter(s));
}

콜백이 있는 이벤트 구동 프로그래밍

함수 포인터는 C에서 이벤트 구동 아키텍처를 가능하게 — 발행/구독 패턴. 핸들러는 on_event()로 등록(구독), emit_event()는 등록된 모든 핸들러를 호출(발행). 이는 이벤트 생산자를 소비자로부터 분리 — 발행자는 핸들러가 하는 일을 모름. 이 패턴은 GUI 프레임워크(버튼 클릭 → 핸들러), 게임 엔진(충돌 → 콜백), 비동기 I/O(데이터 준비 → 읽기 핸들러)에 근본적. 핸들러 서명(이벤트 이름 + void* 데이터)은 모든 이벤트 타입에 충분히 제네릭. 프로덕션에서는 에러 처리(핸들러가 충돌하면?), 우선순위 정렬, 구독 취소 기능 추가. 이것이 libuv, libevent, Node.js가 내부적으로 작동하는 방식.

c
#include <stdio.h>

// Event system using function pointers
typedef void (*EventHandler)(const char *event, void *data);

// Simple event emitter
#define MAX_HANDLERS 10
static EventHandler handlers[MAX_HANDLERS];
static int handler_count = 0;

void on_event(EventHandler handler) {
    if (handler_count < MAX_HANDLERS) {
        handlers[handler_count++] = handler;
    }
}

void emit_event(const char *event, void *data) {
    for (int i = 0; i < handler_count; i++) {
        handlers[i](event, data);  // call each registered handler
    }
}

// Concrete handlers
void log_handler(const char *event, void *data) {
    printf("[LOG] Event: %s\n", event);
}

void alert_handler(const char *event, void *data) {
    if (strcmp(event, "error") == 0) {
        printf("[ALERT] Error occurred!\n");
    }
}

int main() {
    // Register handlers (subscribe)
    on_event(log_handler);
    on_event(alert_handler);

    // Emit events (publish)
    emit_event("click", NULL);
    emit_event("error", NULL);
    emit_event("scroll", NULL);

    return 0;
}

함수 포인터 함정

함수 포인터에는 여러 함정이 있습니다. NULL 함수 포인터 호출은 크래시(세그폴트) — 호출 전에 항상 NULL 확인. 잘못된 서명으로 캐스팅은 정의되지 않은 동작(호출 규칙이 다를 수 있음). 함수 포인터의 동등성 비교는 유효(같은 함수), 하지만 순서(<, >)는 정의되지 않음. typedef를 일관되게 사용 — 함수 포인터 구문은 오류 발생하기 쉽고, typedef는 선언을 읽기 쉽고 유지보수 가능하게 만듭니다. C에서 함수 포인터는 런타임 다형성과 콜백을 달성하는 유일한 방법이므로 마스터하는 것이 필수. C++는 더 안전한 대안으로 std::function, 람다, 가상 함수를 추가.

c
#include <stdio.h>

// PITFALL 1: Calling a NULL function pointer (crash!)
void bad_call() {
    void (*fp)(void) = NULL;
    fp();  // SEGFAULT — always check for NULL
    if (fp) fp();  // safe
}

// PITFALL 2: Wrong signature (undefined behavior)
void takes_int(int x) { printf("%d\n", x); }
void wrong_sig() {
    void (*fp)(void) = (void (*)(void))takes_int;  // WRONG cast
    fp();  // UB: missing argument, garbage value
}

// PITFALL 3: Function pointer to a local function (dangling)
typedef int (*callback_t)(int);
callback_t get_callback() {
    // Returning pointer to local function is OK (functions aren't local)
    // But returning a pointer to a local VARIABLE is not
    return NULL;  // functions have static storage, safe to return
}

// PITFALL 4: Comparing function pointers
int f1(int x) { return x; }
int f2(int x) { return x; }
void compare_fps() {
    int (*p1)(int) = f1;
    int (*p2)(int) = f1;
    if (p1 == p2) printf("Same function\n");  // OK
    // Comparing p1 == f2 is valid but they're different functions
}

// GOOD: Always use typedef for complex function pointers
// typedef int (*comparator_t)(const void *, const void *);
// This makes declarations readable and consistent
17

가변 인수 (varargs)

기본 가변 인수 함수 (stdarg)

가변 인수 함수는 stdarg.h를 사용하여 가변 개수의 인수를 받습니다. va_list가 인수 목록을 보유, va_start가 초기화(... 앞의 마지막 명명된 매개변수 필요), va_arg가 지정된 타입으로 다음 인수를 검색, va_end가 정리. 함수는 몇 개의 인수를 읽어야 하는지 알아야 함 — 카운트 매개변수(printf의 형식 문자열처럼) 또는 센티넬 값(NULL 종료자)을 통해. '...'는 항상 마지막 매개변수여야 함. va_arg는 타입 검사 안 함 — 잘못된 타입 전달은 정의되지 않은 동작. 이것이 printf, fprintf, execl이 작동하는 방식.

c
#include <stdio.h>
#include <stdarg.h>

// Variadic function: takes variable number of arguments
// The '...' must be the LAST parameter
int sum(int count, ...) {
    va_list args;           // argument list type
    va_start(args, count);  // initialize (needs last named param)

    int total = 0;
    for (int i = 0; i < count; i++) {
        int val = va_arg(args, int);  // get next argument (as int)
        total += val;
    }

    va_end(args);  // cleanup
    return total;
}

int main() {
    printf("%d\n", sum(3, 10, 20, 30));      // 60
    printf("%d\n", sum(5, 1, 2, 3, 4, 5));   // 15
    printf("%d\n", sum(0));                    // 0
    return 0;
}

// The 'count' parameter tells the function how many args follow.
// Without it, the function can't know when to stop.

사용자 정의 printf 구현

vprintf/vfprintf/vsprintf는 ... 대신 va_list를 받는 가변 인수 헬퍼 — 사용자 정의 printf 같은 함수를 빌드할 수 있게. log_msg 예제는 로그 수준 접두어와 함께 printf를 래핑. print_values 예제는 혼합 타입 처리 방법을 보여줌: 각 값 앞에 타입 태그를 전달한 다음 태그를 switch하여 올바른 타입으로 va_arg 호출. va_arg가 정확한 타입을 요구하므로 필요 — 런타임 타입 정보가 없음. 타입 태그 패턴은 다형성 C API(예: SQLite의 바인드 함수)에서 사용. 항상 va_arg 타입 정확히 일치 — int vs long, float vs double(varargs에서 float는 double로 승격).

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

// Custom logging function with format string
void log_msg(const char *level, const char *format, ...) {
    printf("[%s] ", level);

    va_list args;
    va_start(args, format);

    // vprintf: like printf but takes va_list instead of ...
    vprintf(format, args);

    va_end(args);
    printf("\n");
}

// Variadic function with mixed types
void print_values(int count, ...) {
    va_list args;
    va_start(args, count);

    for (int i = 0; i < count; i++) {
        int type = va_arg(args, int);  // type tag
        switch (type) {
            case 0:  // int
                printf("int: %d\n", va_arg(args, int));
                break;
            case 1:  // double
                printf("double: %f\n", va_arg(args, double));
                break;
            case 2:  // string
                printf("string: %s\n", va_arg(args, char *));
                break;
        }
    }
    va_end(args);
}

int main() {
    log_msg("INFO", "User %s logged in from %s", "Alice", "192.168.1.1");
    log_msg("ERROR", "Failed to open %s (code %d)", "config.txt", 13);

    print_values(2, 0, 42, 2, "hello", 1, 3.14);
    return 0;
}

센티널 종료 가변 인수 함수

센티널 종료 가변 인수 함수는 카운트 대신 특수 값(보통 NULL)을 사용하여 인수 목록의 끝을 표시. 문자열 중심 API에 더 깔끔 — 호출자가 인수를 셀 필요 없음. exec 패밀리(execl, execlp)는 센티널로 NULL 사용. 단점: 호출자가 NULL을 잊으면 함수가 가비지 메모리를 읽음(정의되지 않은 동작). 일부 컴파일러(GCC)는 __attribute__((sentinel))을 지원하여 누락된 센티널 경고. NULL이 필요하다는 것을 항상 문서화. 버퍼 크기 매개변수는 버퍼 오버플로우를 방지 — 항상 대상 크기를 전달하고 strcat 전에 경계 확인.

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

// Sentinel-terminated: last argument is NULL
void concat_strings(char *dest, size_t size, ...) {
    va_list args;
    va_start(args, size);

    dest[0] = '\0';  // start with empty string
    size_t used = 0;

    while (1) {
        char *s = va_arg(args, char *);
        if (s == NULL) break;  // sentinel — stop reading

        size_t len = strlen(s);
        if (used + len < size) {
            strcat(dest, s);
            used += len;
        }
    }

    va_end(args);
}

int main() {
    char result[256];
    concat_strings(result, sizeof(result),
                   "Hello, ", "world", "! ", "How are you?", NULL);
    printf("%s\n", result);
    // Hello, world! How are you?

    // Common C APIs using sentinels:
    // execl("/bin/ls", "ls", "-l", NULL);  // exec family
    // sqlite3_exec(db, sql, callback, NULL, NULL);
    return 0;
}

가변 인수 전달

가변 인수 전달은 va_copy(할당이 아닌)가 필요 — va_list는 =로 복사할 수 없는 불투명 타입일 수 있음. va_copy를 통해 인수 목록을 여러 번 순회 가능(예: 먼저 측정, 그 다음 출력). LOG 매크로는 __VA_ARGS__를 사용하여 모든 인수를 fprintf로 전달. ##__VA_ARGS__ GCC 확장은 가변 인수가 제공되지 않을 때 선행 쉼표 제거(LOG("msg")가 후행 쉼표 없이 작동). 이 패턴은 C 로깅 매크로에서 널리 사용. C99는 ... 앞에 최소 하나의 인수 요구; C11/C23과 GCC는 0개 허용. C++의 타입 안전 대안은 가변 템플릿이나 std::format 사용.

c
#include <stdio.h>
#include <stdarg.h>

// Wrapper that forwards varargs to another function
// Use va_copy for the copy (needed for multiple passes)
void custom_printf(const char *format, ...) {
    va_list args1, args2;
    va_start(args1, format);
    va_copy(args2, args1);  // copy for second use

    // First pass: count characters that would be printed
    int len = vsnprintf(NULL, 0, format, args1);
    printf("[len=%d] ", len);

    // Second pass: actually print
    vprintf(format, args2);

    va_end(args1);
    va_end(args2);
}

// Macro forwarding (common pattern for logging)
#define LOG(fmt, ...) \
    fprintf(stderr, "[LOG] %s:%d: " fmt "\n", \
            __FILE__, __LINE__, ##__VA_ARGS__)

// The ## operator removes the comma if __VA_ARGS__ is empty
// (GCC extension, widely supported)

int main() {
    custom_printf("Value: %d, Name: %s\n", 42, "test");
    LOG("Simple message");           // no extra args
    LOG("With value: %d", 100);      // with args
    return 0;
}

가변 매크로 (C99)

C99 가변 매크로는 __VA_ARGS__를 사용하여 매크로 정의의 ...와 일치하는 모든 인수를 캡처. ##__VA_ARGS__(GCC 확장, C20에서 표준)는 가변 인수가 전달되지 않을 때 쉼표를 제거. COUNT 매크로는 영리한 트릭 사용: N개 인수를 N, 5, 4, 3, 2, 1에 매핑하고 N번째 위치가 카운트를 줌. ASSERT의 do { ... } while (0) 관용구는 매크로가 단일 명령문처럼 작동하게 함(중괄호 없는 if/else에서 안전). #은 매크로 인수를 문자열화. 가변 매크로는 C의 로깅, 디버깅, 제네릭 프로그래밍에 필수. 많은 라이브러리 API와 Linux 커널의 로깅 시스템의 기초.

c
#include <stdio.h>

// C99 variadic macros: __VA_ARGS__ captures all extra args
#define DEBUG_PRINT(fmt, ...) \
    printf("DEBUG: " fmt "\n", ##__VA_ARGS__)

#define MAX(...) (max_of(__VA_ARGS__))

// Assert macro with message
#define ASSERT(cond, fmt, ...) \
    do { \
        if (!(cond)) { \
            fprintf(stderr, "Assertion failed: %s\n" fmt "\n", \
                    #cond, ##__VA_ARGS__); \
            exit(1); \
        } \
    } while (0)

// Count arguments (GCC __VA_OPT__ or recursive macros)
#define COUNT(...) COUNT_N(__VA_ARGS__, 5, 4, 3, 2, 1)
#define COUNT_N(_1, _2, _3, _4, _5, N, ...) N

// Stringification of all args
#define STR(...) #__VA_ARGS__

int main() {
    DEBUG_PRINT("x = %d", 42);
    DEBUG_PRINT("no args");  // ## removes comma

    ASSERT(x > 0, "x was %d", x);

    printf("Count: %d\n", COUNT(a, b, c));  // 3
    printf("Stringified: %s\n", STR(hello, world));  // "hello, world"

    return 0;
}
18

비트 조작 트릭

일반적인 비트 트릭

비트 조작은 이진 표현에서 직접 작동. n & 1은 최하위 비트를 확인하여 홀수/짝수 판단. 왼쪽 시프트(<<)는 2배 곱; 오른쪽 시프트(>>)는 나누기. XOR 교환은 임시 변수를 피하지만 가독성 저하. n & (n-1)은 최하위 설정 비트를 지워 2의 거듭제곱 확인과 popcount에 유용. 이 트릭들은 빠르지만 애플리케이션 코드에서는 가독성을 우선.

c
// Check if odd
int is_odd(int n) { return n & 1; }

// Multiply/divide by powers of 2
int doubled = x << 1;    // x * 2
int halved = x >> 1;     // x / 2
int times8 = x << 3;     // x * 8

// Swap without temp variable
void swap(int *a, int *b) {
    *a ^= *b; *b ^= *a; *a ^= *b;
}

// Check if power of 2
int is_pow2(int n) { return n > 0 && (n & (n-1)) == 0; }

// Count set bits (population count)
int popcount(unsigned int n) {
    int count = 0;
    while (n) { n &= (n-1); count++; }
    return count;
}

비트 플래그 & 마스크

비트 플래그는 여러 boolean 옵션을 단일 정수로 패킹하여 메모리 절약. 각 플래그는 2의 거듭제곱(하나의 비트). OR(|)은 플래그 설정, AND(&)는 플래그 확인, XOR(^)은 토글, AND NOT(&= ~)은 지우기. 이 패턴은 시스템 프로그래밍에서 널리 사용: 파일 권한(O_RDONLY, O_CREAT), 소켓 옵션, GPU 상태. 가독성을 위해 명명된 상수 사용.

c
#define FLAG_READ    (1 << 0)  // 0x01
#define FLAG_WRITE   (1 << 1)  // 0x02
#define FLAG_EXECUTE (1 << 2)  // 0x04

// Set flags
unsigned int perms = FLAG_READ | FLAG_WRITE;

// Check if flag is set
if (perms & FLAG_WRITE) { /* write allowed */ }

// Toggle a flag
perms ^= FLAG_EXECUTE;

// Clear a flag
perms &= ~FLAG_APPEND;

// Check if ALL flags in mask are set
int has_all = (perms & (FLAG_READ|FLAG_WRITE))
            == (FLAG_READ|FLAG_WRITE);

구조체의 비트 필드

비트 필드는 구조체 내에서 여러 작은 값을 최소 비트 수로 패킹. 콜론 구문이 비트 너비를 지정. 작은 필드가 많은 데이터 구조(날짜, 플래그, 하드웨어 레지스터)에 메모리 절약. 하지만 비트 필드 레이아웃은 구현 의존적: 바이트 순서, 패딩, 정렬이 컴파일러마다 다름. 이식 가능한 데이터 형식에는 비트 필드 피하기; 대신 명시적 비트 마스크 사용.

c
// Pack multiple small fields into one int
struct Date {
    unsigned int day   : 5;   // 0-31 (5 bits)
    unsigned int month : 4;   // 0-15 (4 bits)
    unsigned int year  : 23;  // 0-8M (23 bits)
};  // Total: 32 bits = 4 bytes

struct Date today = { 21, 6, 2025 };
printf("Size: %zu bytes\n", sizeof(today));  // 4

today.day = 15;
if (today.month == 12) {
    today.year++;
    today.month = 1;
}

엔디안 변환

엔디안은 바이트 순서를 결정: 리틀 엔디안(x86, ARM 기본)은 LSB를 먼저 저장; 빅 엔디안(네트워크, 일부 MIPS)은 MSB를 먼저 저장. 네트워크 프로토콜은 빅 엔디안(네트워크 바이트 순서) 사용. 이식 가능한 네트워크 코드를 위해 htonl/ntohl 사용. 시프트와 마스크를 사용한 수동 바이트 교환은 모든 플랫폼에서 작동. 컴파일 타임에 __BYTE_ORDER__로 엔디안 감지하여 최적화된 코드 경로 사용.

c
#include <arpa/inet.h>  // htonl, ntohl

// Host to network byte order (big-endian) and back
uint32_t host_val = 0x12345678;
uint32_t net_val = htonl(host_val);
uint32_t back = ntohl(net_val);

// Manual byte swap (portable)
uint32_t swap32(uint32_t v) {
    return ((v & 0xFF000000) >> 24) |
           ((v & 0x00FF0000) >> 8)  |
           ((v & 0x0000FF00) << 8)  |
           ((v & 0x000000FF) << 24);
}

// Detect endianness at compile time
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
    printf("Little-endian system\n");
#endif

비트와이즈 해킹

분기 없는 비트 해킹은 타이트 루프에서 성능을 위해 조건부 점프를 피함. abs 트릭은 산술 오른쪽 시프트로 마스크 생성. next_pow2는 최고 설정 비트 아래의 모든 비트를 채운 후 1을 더함. 비트 역전은 분할 정복 사용: 니블 교환, 그 다음 쌍, 그 다음 단일 비트. 암호화, 해싱, DSP에 유용. 현대 CPU는 종종 더 빠른 내장 명령어(POPCNT, LZCNT)를 가짐.

c
// Absolute value without branching
int abs_val(int n) {
    int mask = n >> (sizeof(int)*8 - 1);
    return (n ^ mask) - mask;
}

// Round up to next power of 2
unsigned int next_pow2(unsigned int n) {
    n--;
    n |= n >> 1;  n |= n >> 2;
    n |= n >> 4;  n |= n >> 8;
    n |= n >> 16;
    return n + 1;
}

// Reverse bits in a byte
uint8_t reverse_byte(uint8_t b) {
    b = (b >> 4) | (b << 4);
    b = ((b & 0xCC) >> 2) | ((b & 0x33) << 2);
    b = ((b & 0xAA) >> 1) | ((b & 0x55) << 1);
    return b;
}
19

시그널 처리 고급

시그널 집합 & 차단

sigprocmask는 시그널을 차단하여 대기열에 넣고(손실되지 않음) 나중에 전달. 이는 중요 섹션을 인터럽트로부터 보호. SIG_BLOCK은 마스크에 추가, SIG_UNBLOCK은 제거, SIG_SETMASK는 교체. 대기 중인 시그널을 확인하려면 sigpending 사용. 시그널은 잠깐만 차단; 긴 차단은 중요 이벤트를 놓칠 수 있음. 시그널 마스크는 프로세스별이며 fork를 통해 상속.

c
#include <signal.h>

sigset_t mask, oldmask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);

// Block signals (they will be queued)
sigprocmask(SIG_BLOCK, &mask, &oldmask);

// Critical section - signals are deferred
do_critical_work();

// Unblock - pending signals are now delivered
sigprocmask(SIG_SETMASK, &oldmask, NULL);

// Check for pending signals
sigpending(&mask);
if (sigismember(&mask, SIGINT)) {
    printf("SIGINT is pending\n");
}

안전한 시그널 핸들러

시그널 핸들러는 async-signal-safe해야: 재진입 함수만 사용(write, _exit, signal). printf, malloc, 대부분의 라이브러리 함수는 피하기 — 호출 중간에 중단되어 상태를 손상시킬 수 있음. 핸들러 설정 플래그에는 volatile sig_atomic_t 사용. SA_RESTART는 중단된 시스템 콜을 자동으로 재시작. 이식 가능하고 잘 정의된 동작을 위해 signal보다 sigaction 선호.

c
#include <signal.h>
#include <unistd.h>

// Only safe type in handlers
volatile sig_atomic_t got_signal = 0;

void handler(int sig) {
    // ONLY use async-signal-safe functions!
    // write() is safe; printf() is NOT
    const char msg[] = "Signal received\n";
    write(STDERR_FILENO, msg, sizeof(msg)-1);
    got_signal = 1;
}

int main() {
    struct sigaction sa;
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;  // Restart interrupted syscalls
    sigaction(SIGINT, &sa, NULL);

    while (!got_signal) pause();
    return 0;
}

Self-Pipe & signalfd

signalfd(Linux)는 시그널을 파일 디스크립터로 변환하여 이벤트 루프(epoll, select)에 통합. 먼저 시그널을 차단한 다음 signalfd 생성. self-pipe 트릭은 이식 가능: 핸들러가 파이프에 바이트를 쓰고 메인 루프가 읽음. 두 접근 방식 모두 시그널 처리를 제한된 핸들러 컨텍스트에서 모든 함수를 안전하게 호출할 수 있는 일반 코드로 이동.

c
#include <sys/signalfd.h>

// signalfd: handle signals as file descriptors (Linux)
int setup_signalfd() {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGTERM);
    sigprocmask(SIG_BLOCK, &mask, NULL);  // Must block first

    int fd = signalfd(-1, &mask, SFD_CLOEXEC);
    return fd;
}

// In event loop:
struct signalfd_siginfo si;
read(signalfd_fd, &si, sizeof(si));
printf("Got signal %d\n", si.ssi_signo);

// Self-pipe trick (portable):
// Write to pipe in handler, read in main loop
int pipefd[2];
void pipe_handler(int sig) {
    write(pipefd[1], &sig, sizeof(sig));
}

타이머 & SIGALRM

setitimer는 간격으로 SIGALRM을 전달. ITIMER_REAL은 실제 시간 사용; ITIMER_VIRTUAL은 CPU 시간 사용; ITIMER_PROF은 CPU + 시스템 시간 사용. 타이머는 취소될 때까지 반복. 현대 코드의 경우 스레드별 타이머를 위해 SIGEV_THREAD와 함께 timer_create를 선호, 또는 이벤트 루프와 통합을 위해 전용 타이머 fd(Linux의 timerfd_create) 사용. 기본 종료를 피하기 위해 항상 시그널 처리.

c
#include <sys/time.h>

volatile sig_atomic_t timer_fired = 0;

void timer_handler(int sig) { timer_fired = 1; }

int main() {
    struct sigaction sa = {0};
    sa.sa_handler = timer_handler;
    sigaction(SIGALRM, &sa, NULL);

    // Set interval timer: 2 sec initial, 1 sec repeat
    struct itimerval timer;
    timer.it_value.tv_sec = 2;
    timer.it_interval.tv_sec = 1;
    setitimer(ITIMER_REAL, &timer, NULL);

    int count = 0;
    while (count < 5) {
        pause();
        if (timer_fired) {
            printf("Timer %d\n", ++count);
            timer_fired = 0;
        }
    }
    return 0;
}

시그널 전송

kill은 PID로 프로세스에 시그널 전송. kill(0, sig)는 전체 프로세스 그룹에 전송. raise는 호출 프로세스에 시그널 전송. sigqueue는 첨부 데이터가 있는 시그널 전송(siginfo). 자식 프로세스를 수거하고 종료 상태를 확인하기 위해 항상 waitpid 사용. WIFSIGNALED는 정상 종료와 시그널 사망을 구별. SIGTERM(SIGKILL이 아닌) 전송은 우아한 종료를 허용.

c
#include <signal.h>
#include <sys/wait.h>

pid_t child = fork();
if (child == 0) {
    while (1) { sleep(1); }
} else {
    sleep(3);
    kill(child, SIGTERM);  // Send SIGTERM to child

    int status;
    waitpid(child, &status, 0);
    if (WIFSIGNALED(status))
        printf("Killed by signal %d\n", WTERMSIG(status));
}

// Send signal to self
raise(SIGSTOP);  // Stop (resume with SIGCONT)

// Send to process group
kill(0, SIGUSR1);  // 0 = own process group

// Send with data (sigqueue)
union sigval value = { .sival_int = 42 };
sigqueue(child, SIGUSR1, value);
20

프로세스 관리 고급

fork & exec 패턴

fork-exec 패턴은 자식 프로세스를 생성(fork)하고 새 프로그램으로 이미지를 교체(exec). fork는 프로세스를 복제; exec는 새 프로그램을 로드. 자식은 exec 실패 시 부모 버퍼를 플러시하지 않도록 exit가 아닌 _exit를 호출해야 함. waitpid는 자식이 종료할 때까지 차단. WEXITSTATUS는 종료 코드 추출. 이것이 셸이 명령을 실행하는 방식.

c
#include <unistd.h>
#include <sys/wait.h>

int run_program(const char *path, char *const args[]) {
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return -1; }

    if (pid == 0) {
        // Child: exec replaces process image
        execvp(path, args);
        perror("execvp");  // Only on failure
        _exit(127);
    }

    // Parent: wait for child
    int status;
    waitpid(pid, &status, 0);
    return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}

// Usage
char *args[] = {"ls", "-la", "/tmp", NULL};
int result = run_program("ls", args);

데몬화

setsid는 새 세션과 프로세스 그룹을 생성하여 제어 터미널에서 분리. 이중 fork 패턴은 데몬이 터미널을 재획득하는 것을 방지. 데몬화 후 표준 파일 디스크립터를 닫고 /dev/null이나 로그 파일로 리다이렉트. chdir to /는 언마운트 차단을 방지. umask(0)는 예측 가능한 파일 권한을 보장. 이것이 표준 데몬 패턴.

c
#include <unistd.h>

int main() {
    pid_t child = fork();
    if (child > 0) _exit(0);  // Parent exits
    if (child < 0) return 1;

    // First child: create new session
    setsid();

    // Second fork (prevent reacquiring terminal)
    pid_t grandchild = fork();
    if (grandchild > 0) _exit(0);

    // Daemon process
    chdir("/");
    umask(0);
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);

    while (1) {
        sleep(60);
        // Do daemon work
    }
    return 0;
}

posix_spawn 대안

posix_spawn은 MMU가 없는 시스템(임베디드)이나 큰 메모리 풋프린트가 있는 시스템(fork는 페이지 테이블 복사)에서 fork+exec보다 더 효율적인 대안. 프로세스 생성과 exec를 하나의 호출로 결합, 파일 작업(리다이렉트, 닫기)이 원자적으로 적용. fork와 exec 사이에 자식 상태를 수정할 필요가 없을 때 사용. POSIX 표준이며 Linux, macOS, 대부분의 Unix 시스템에서 사용 가능.

c
#include <spawn.h>
#include <sys/wait.h>

extern char **environ;

int spawn_child(const char *cmd, char *const argv[]) {
    pid_t pid;
    posix_spawn_file_actions_t actions;
    posix_spawn_file_actions_init(&actions);
    posix_spawn_file_actions_addclose(&actions, STDIN_FILENO);

    posix_spawnattr_t attr;
    posix_spawnattr_init(&attr);

    int ret = posix_spawnp(&pid, cmd, &actions, &attr, argv, environ);

    posix_spawn_file_actions_destroy(&actions);
    posix_spawnattr_destroy(&attr);

    if (ret != 0) return -1;

    int status;
    waitpid(pid, &status, 0);
    return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}

좀비 방지

좀비 프로세스는 자식이 부모가 wait를 호출하기 전에 종료할 때 발생. 다음으로 방지: (1) 루프에서 waitpid로 SIGCHLD 처리(WNOHANG으로 차단 방지), (2) SIGCHLD를 SIG_IGN으로 설정(커널이 자동 수거), 또는 (3) 손자가 고아가 되어 init(PID 1)에 입양되어 자동 수거되도록 이중 포크. 시그널 핸들러에서 항상 errno를 저장하고 복원.

c
#include <sys/wait.h>
#include <signal.h>

// Reap zombies with SIGCHLD handler
void sigchld_handler(int sig) {
    int saved_errno = errno;
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        // Reap all available zombies
    }
    errno = saved_errno;
}

// Setup: handle SIGCHLD
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);

// Alternative: explicitly ignore (auto-reap)
// signal(SIGCHLD, SIG_IGN);

// Double-fork to prevent zombies
pid_t inter = fork();
if (inter == 0) {
    if (fork() == 0) {
        // Grandchild does the work
        execlp("sleep", "sleep", "10", NULL);
        _exit(1);
    }
    _exit(0);  // First child exits immediately
}
waitpid(inter, NULL, 0);

리소스 제한

setrlimit는 프로세스에 리소스 제한을 부과: CPU 시간(RLIMIT_CPU), 가상 메모리(RLIMIT_AS), 파일 크기(RLIMIT_FSIZE), 열린 파일(RLIMIT_NOFILE), 스택 크기, 코어 덤프 크기. 소프트 제한은 시행됨; 하드 제한은 상한. CPU 시간 초과는 SIGXCPU 전송; 메모리 초과는 malloc 실패 유발. 버그나 공격으로부터 리소스 고갈을 방지하기 위해 자식 프로세스에서 제한 사용.

c
#include <sys/resource.h>

void set_limits() {
    struct rlimit lim;

    // Limit CPU time to 10 seconds
    lim.rlim_cur = 10; lim.rlim_max = 10;
    setrlimit(RLIMIT_CPU, &lim);

    // Limit memory to 256 MB
    lim.rlim_cur = 256*1024*1024;
    lim.rlim_max = 256*1024*1024;
    setrlimit(RLIMIT_AS, &lim);

    // Limit open files
    lim.rlim_cur = 32; lim.rlim_max = 64;
    setrlimit(RLIMIT_NOFILE, &lim);
}

void show_limits() {
    struct rlimit lim;
    getrlimit(RLIMIT_NOFILE, &lim);
    printf("Max files: soft=%lu, hard=%lu\n",
           lim.rlim_cur, lim.rlim_max);
}
21

파이프 & IPC 고급

익명 파이프

익명 파이프는 부모와 자식 프로세스 간 단방향 통신을 제공. 사용하지 않는 끝을 항상 닫기: 라이터는 읽기 끝을 닫고, 반대의 경우도 마찬가지. 쓰기 끝을 닫으면 리더에게 EOF 신호(read가 0 반환). 파이프는 고정 버퍼(일반적으로 64KB); 가득 차면 쓰기 차단. 파이프는 관련 프로세스(부모-자식) 전용. 관련 없는 프로세스의 경우 명명된 파이프(FIFO) 또는 소켓 사용.

c
#include <unistd.h>

int main() {
    int pipefd[2];
    pipe(pipefd);

    pid_t pid = fork();
    if (pid == 0) {
        // Child: read from pipe
        close(pipefd[1]);  // Close unused write end
        char buf[256];
        ssize_t n = read(pipefd[0], buf, sizeof(buf)-1);
        buf[n] = '\0';
        printf("Child received: %s", buf);
        close(pipefd[0]);
    } else {
        // Parent: write to pipe
        close(pipefd[0]);  // Close unused read end
        const char *msg = "Hello from parent!\n";
        write(pipefd[1], msg, strlen(msg));
        close(pipefd[1]);  // EOF signal to reader
        wait(NULL);
    }
    return 0;
}

명명된 파이프 (FIFO)

명명된 파이프(FIFO)는 관련 없는 프로세스 간 파이프로 작동하는 특수 파일. mkfifo가 파일 생성; open은 리더와 라이터가 모두 있을 때까지 차단. 비차단 열기를 위해 O_NONBLOCK 사용. FIFO는 unlink될 때까지 파일 시스템에 지속. 독립적인 프로그램 간 단순 IPC에 유용하지만, 복잡한 통신의 경우 Unix 도메인 소켓이나 메시지 큐를 고려.

c
#include <sys/stat.h>
#include <fcntl.h>

// Create a named pipe (persists in filesystem)
mkfifo("/tmp/myfifo", 0666);

// Writer process
int fd = open("/tmp/myfifo", O_WRONLY);
write(fd, "Hello FIFO", 10);
close(fd);

// Reader process (can be unrelated to writer)
int fd = open("/tmp/myfifo", O_RDONLY);
char buf[256];
ssize_t n = read(fd, buf, sizeof(buf));
close(fd);

// Non-blocking open
int fd = open("/tmp/myfifo", O_RDONLY | O_NONBLOCK);

// Clean up
unlink("/tmp/myfifo");

공유 메모리

공유 메모리는 가장 빠른 IPC: 프로세스가 같은 물리적 메모리를 주소 공간에 매핑. shm_open이 POSIX 공유 메모리 객체 생성; mmap이 매핑. 변경 사항은 모든 매핑자에게 즉시 표시. 동기화를 위해 세마포어나 뮤텍스(PTHREAD_PROCESS_SHARED와 함께) 사용. 누수를 방지하기 위해 항상 언매핑하고 unlink. 공유 메모리는 큰 데이터에 이상적; 오버헤드는 초기 매핑뿐.

c
#include <sys/mman.h>
#include <fcntl.h>

// Create shared memory object
int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);  // Set size

// Map into process address space
char *shared = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                    MAP_SHARED, fd, 0);
close(fd);  // Can close after mmap

// Write data (visible to other processes)
sprintf(shared, "Shared data at %p", (void*)shared);

// Synchronize (flush to backing store)
msync(shared, 4096, MS_SYNC);

// Unmap and clean up
munmap(shared, 4096);
shm_unlink("/my_shm");

Unix 도메인 소켓

Unix 도메인 소켓은 같은 머신에서 양방향, 스트림 지향 IPC를 제공. TCP보다 빠름(네트워크 오버헤드 없음)이며 SCM_RIGHTS를 통해 프로세스 간 파일 디스크립터 전달 지원. 신뢰할 수 있는 스트림에는 SOCK_STREAM, 데이터그램에는 SOCK_DGRAM 사용. 소켓 경로는 파일 시스템 항목; address in use 에러를 피하기 위해 bind 전에 unlink. Unix 소켓은 Docker, X11, systemd 통신의 기초.

c
#include <sys/socket.h>
#include <sys/un.h>

// Server
int sfd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/mysocket");
unlink("/tmp/mysocket");
bind(sfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sfd, 5);

int cfd = accept(sfd, NULL, NULL);
char buf[256];
read(cfd, buf, sizeof(buf));
write(cfd, "Response", 8);
close(cfd);

// Client
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/mysocket");
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
write(sock, "Request", 7);
close(sock);

unlink("/tmp/mysocket");

메시지 큐

POSIX 메시지 큐는 우선순위 정렬된, 메시지 기반 IPC를 제공. 각 메시지는 우선순위를 가짐; 더 높은 우선순위 메시지가 먼저 수신. mq_send와 mq_receive는 단일 메시지에 대해 원자적. 비차단 작업에는 O_NONBLOCK 사용 또는 타임아웃에는 mq_timedreceive 사용. 메시지 큐는 프로세스가 죽어도 사라지는 파이프와 달리 unlink될 때까지 지속. 작업 디스패치와 이벤트 알림에 이상적.

c
#include <mqueue.h>

// Create/open a message queue
struct mq_attr attr = {
    .mq_maxmsg = 10,
    .mq_msgsize = 256
};
mqd_t mq = mq_open("/my_queue", O_CREAT | O_RDWR, 0666, &attr);

// Send a message (priority-based)
mq_send(mq, "Hello MQ", 8, 1);  // priority 1

// Receive a message (highest priority first)
char buf[256];
unsigned int prio;
ssize_t n = mq_receive(mq, buf, sizeof(buf), &prio);
printf("Received (priority %u): %.*s\n", prio, (int)n, buf);

// Non-blocking receive
mqd_t mq_nb = mq_open("/my_queue", O_RDONLY | O_NONBLOCK);

// Timed receive
struct timespec ts = { .tv_sec = time(NULL) + 5 };
mq_timedreceive(mq, buf, sizeof(buf), &prio, &ts);

mq_close(mq);
mq_unlink("/my_queue");
22

Makefile & 빌드 고급

자동 변수 & 패턴

자동 변수는 Makefile을 간결하고 유지보수 가능하게 만듭니다. 패턴 규칙(%.o: %.c)은 패턴과 일치하는 파일을 빌드하는 방법을 정의. -MM은 헤더 의존성을 추적하는 의존성 파일(.d)을 생성하여 헤더 편집이 의존 .c 파일의 재컴파일을 트리거. -include 지시문은 의존성 파일이 존재하는 경우 자동으로 포함. 이것이 견고한 C/C++ 빌드 시스템의 기초.

c
# Automatic variables:
# $@ = target name
# $< = first prerequisite
# $^ = all prerequisites
# $? = prerequisites newer than target

# Pattern rule: compiles any .c to .o
%.o: %.c %.h
	$(CC) $(CFLAGS) -c $< -o $@

OBJS = main.o utils.o parser.o

program: $(OBJS)
	$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS)

# Automatic dependency generation
%.d: %.c
	@$(CC) -MM $< > $@

-include $(OBJS:.o=.d)

clean:
	rm -f $(OBJS) program

변수 & 조건부

즉시 평가에는 := 사용(더 빠르고 예측 가능), 지연 평가에는 = 사용(전방 참조 허용). ?=는 변수가 설정되지 않은 경우에만 설정하여 명령줄에서 사용자 재정의 허용. 조건부(ifeq, ifdef)는 디버그/릴리스 빌드를 가능하게. Q 트릭은 VERBOSE가 설정되지 않은 한 명령 에코를 억제. MAKECMDGOALS는 명령줄의 target을 포함.

c
# = recursive (evaluated when used)
# := simple (evaluated when defined)
# ?= conditional (only if not already set)

CC := gcc
CFLAGS ?= -Wall -Wextra -O2
DEBUG := $(filter debug,$(MAKECMDGOALS))

ifeq ($(DEBUG),debug)
    CFLAGS += -g -DDEBUG -O0
else
    CFLAGS += -DNDEBUG
endif

ifdef VERBOSE
    Q =
else
    Q = @
endif

build:
	$(Q)$(CC) $(CFLAGS) -c main.c

함수 & 텍스트 처리

Make 함수는 텍스트 변환을 가능하게: 패턴 교체에는 patsubst, 파일 선택에는 filter, 글로빙에는 wildcard, 반복에는 foreach. shell 함수는 파싱 타임에 명령을 실행 — 버전 정보 임베딩에 유용. 치환 참조($(VAR:.c=.o))는 단순 접미사 변경을 위한 patsubst의 간결한 대안.

c
SRCS = main.c utils.c parser.c

# Substitution
OBJS = $(patsubst %.c,%.o,$(SRCS))  # main.o utils.o parser.o
# Or shorthand:
OBJS = $(SRCS:.c=.o)

# Filter files by extension
C_SRCS = $(filter %.c,$(wildcard *.c))

# Add prefix
FLAGS = $(addprefix -I,$(INCLUDE_DIRS))

# Foreach
DIRS = src lib include
CREATE = $(foreach dir,$(DIRS),mkdir -p $(dir);)

# Shell function
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
CFLAGS += -DGIT_HASH=\"$(GIT_HASH)\"

하위 디렉토리 & 재귀 Make

재귀 make(하위 디렉토리 Makefile)은 전통적이지만 병렬 빌드에서 느리고 오류 발생 가능. 비재귀 접근(vpath가 있는 단일 Makefile)은 정확성과 속도를 위해 선호. 재귀 make를 사용하는 경우 변수를 명시적으로 전달하고 target을 전파하기 위해 MAKECMDGOALS 사용. 큰 프로젝트의 경우 더 나은 의존성 추적과 IDE 지원을 위해 원시 Make 대신 CMake나 Meson을 고려.

c
SUBDIRS = lib src tests

.PHONY: all $(SUBDIRS) clean test

all: $(SUBDIRS)

# Pass variables to sub-makes
$(SUBDIRS):
	$(MAKE) -C $@ $(MAKECMDGOALS)

# Parallel build: make -j4

clean: $(SUBDIRS)
	rm -f *.o program

# Non-recursive alternative (single Makefile)
vpath %.c src:lib
vpath %.h include

CFLAGS += -Iinclude -Ilib

program: main.o lib/utils.o
	$(CC) $^ -o $@

CMake 통합

CMake는 선언적 CMakeLists.txt에서 Makefile(또는 Ninja, VS, Xcode 프로젝트)을 생성. 현대 CMake는 전역 변수 대신 target 기반 명령(target_include_directories, target_link_libraries)을 사용. 생성기 표현식($<$<CONFIG:Debug>:...)은 구성별 플래그를 가능하게. CMake는 더 나은 IDE 통합과 크로스 플랫폼 지원으로 C/C++ 프로젝트의 사실상 표준.

c
# CMakeLists.txt (modern CMake 3.15+)
cmake_minimum_required(VERSION 3.15)
project(MyApp C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

add_executable(myapp src/main.c src/utils.c)

target_include_directories(myapp PRIVATE include)
target_compile_options(myapp PRIVATE -Wall -Wextra)

# Per-configuration flags
target_compile_options(myapp PRIVATE
    $<$<CONFIG:Debug>:-g -O0 -DDEBUG>
    $<$<CONFIG:Release>:-O2 -DNDEBUG>
)

# Find and link library
find_package(MATH REQUIRED)
target_link_libraries(myapp PRIVATE m)

install(TARGETS myapp DESTINATION bin)
23

GDB로 디버깅

시작 & 중단점

-g로 컴파일하여 디버그 심볼을 임베드하고 -O0으로 최적화를 비활성화(그렇지 않으면 변수가 최적화로 사라질 수 있음). break는 함수, 줄, 조건에 중단점을 설정. watch(데이터 중단점)는 변수가 변경될 때 트리거 — 메모리 손상 찾기에 강력. 조건부 중단점(break func if cond)은 조건이 true일 때만 발생, 루프에 유용. tbreak는 일회성 중단점.

c
# Compile with debug symbols
gcc -g -O0 -o program program.c

# Start GDB
gdb ./program
gdb --args ./program arg1 arg2

# Breakpoints
(gdb) break main           # Function
(gdb) break 42             # Line 42
(gdb) break file.c:50      # Specific file
(gdb) break func if x > 10 # Conditional
(gdb) tbreak main          # Temporary (removed after hit)
(gdb) watch x              # Break when x changes

# Manage breakpoints
(gdb) info breakpoints
(gdb) delete 2             # Delete breakpoint #2
(gdb) disable 1            # Temporarily disable
(gdb) enable 1

스텝핑 & 검사

next는 함수 호출을 넘어가고; step은 진입. finish는 현재 함수의 끝까지 실행. print 형식: /x(16진수), /c(문자), /s(문자열), /t(이진). @ 연산자는 배열 슬라이스 출력: arr@5는 5개 요소 표시. display는 각 정지에서 변수를 자동 출력. backtrace는 호출 스택 표시; frame N은 해당 프레임을 검사하기 위해 컨텍스트 전환.

c
(gdb) run                  # Start program
(gdb) continue             # Continue to next breakpoint
(gdb) next                 # Step over (no function entry)
(gdb) step                 # Step into (enters functions)
(gdb) finish               # Run until function returns
(gdb) until 50             # Run until line 50

# Inspect variables
(gdb) print x              # Print variable
(gdb) print *ptr           # Dereference pointer
(gdb) print arr[0]@5       # First 5 elements
(gdb) print/x x            # Print in hex
(gdb) display x            # Auto-print at each stop
(gdb) info locals          # All local variables

# Backtrace
(gdb) backtrace            # Call stack
(gdb) frame 2              # Switch to frame #2
(gdb) up / down            # Navigate stack

메모리 검사

examine(x) 명령은 원시 메모리를 검사. 형식은 카운트, 표시 형식, 단위 크기를 지정. x/10i $pc는 프로그램 카운터에서 10개 명령어를 역어셈블. x/s는 메모리를 널 종료 문자열로 처리. info proc mappings는 가상 메모리 레이아웃(text, data, heap, stack, 공유 라이브러리) 표시. 버퍼 오버플로우와 메모리 손상 디버깅에 필수.

c
# x/[count][format][size] address
(gdb) x/10x &arr           # 10 words in hex
(gdb) x/20cb &str          # 20 bytes as chars
(gdb) x/4xw ptr            # 4 words in hex
(gdb) x/s str              # String

# Format: x(hex) d(decimal) u(unsigned) o(octal)
#         t(binary) f(float) a(addr) i(instr) c(char) s(string)
# Size: b(byte) h(half/2) w(word/4) g(giant/8)

# Disassemble
(gdb) disassemble main
(gdb) disassemble /r main  # With raw bytes

# Memory map
(gdb) info proc mappings   # Process memory layout

코어 덤프

코어 덤프는 사후 분석 디버깅을 위해 크래시 시점의 프로세스 상태를 캡처. ulimit -c unlimited로 활성화. gdb program core로 코어 파일 로드. backtrace는 크래시 발생 위치 표시; info locals는 변수 값 표시. 다중 스레드 프로그램의 경우 thread apply all bt는 모든 스레드 상태 표시 — 교착 상태 분석에 필수.

c
# Enable core dumps
ulimit -c unlimited
echo "core.%p" > /proc/sys/kernel/core_pattern

# Run program until it crashes
./program                  # Produces core.12345

# Analyze core dump
gdb ./program core.12345

(gdb) bt                   # Backtrace at crash
(gdb) bt full              # With local variables
(gdb) frame 0              # Top frame (crash location)
(gdb) info locals          # Variables at crash
(gdb) print *ptr           # What was the pointer?

# Multithreaded
(gdb) info threads         # List all threads
(gdb) thread 3             # Switch to thread 3
(gdb) thread apply all bt  # Backtraces for ALL threads

GDB 스크립트 & 자동화

.gdbinit는 시작 시 공통 설정을 자동화. define은 반복 작업을 위한 사용자 정의 명령을 생성. commands는 중단점에 작업을 첨부(예: 변수를 기록하고 계속). GDB는 복잡한 분석을 위해 Python 스크립팅을 지원: 테스트 실행 자동화, 데이터 구조 시각화, 또는 통계 추출. Python 스크립트는 gdb 모듈을 통해 GDB 내부에 접근 가능. 스크립트를 사용하여 팀 전체의 디버깅 워크플로우를 표준화.

c
# .gdbinit file (loaded on startup)
set pagination off
set print pretty on
set print element 0        # No limit on string display

# Define custom commands
define print_array
    set $i = 0
    while $i < $arg0
        printf "[%d] = %d\n", $i, $arg1[$i]
        set $i = $i + 1
    end
end
# Usage: print_array 10 my_array

# Commands attached to breakpoints
break main
commands 1
  silent
  printf "x = %d\n", x
  continue
end

# Python scripting (GDB 7+)
python
import gdb
gdb.execute("break main")
gdb.execute("run")
val = gdb.parse_and_eval("x")
print(f"x = {int(val)}")
end
24

메모리 정렬 & 비트필드

구조체 정렬 & 패딩

컴파일러는 각 멤버가 자연스럽게 정렬되도록(일반적으로 크기에 따라: char=1, short=2, int=4, double=8) 패딩을 삽입. 멤버를 큰 것에서 작은 것 순으로 재정렬하면 패딩을 최소화. offsetof로 레이아웃 검사. 64비트 시스템에서 포인터는 8바이트 정렬 필요. 과도한 패딩은 메모리 낭비와 캐시 성능 저하. 항상 구조체 멤버를 크기 내림차순으로 정렬.

c
#include <stddef.h>

// Without alignment consideration (padded)
struct Bad {
    char a;     // 1 byte + 7 padding
    double b;   // 8 bytes
    char c;     // 1 byte + 7 padding
};  // sizeof = 24

// Reordered for efficiency
struct Good {
    double b;   // 8 bytes
    char a;     // 1 byte
    char c;     // 1 byte + 6 padding
};  // sizeof = 16

printf("Bad: a=%zu b=%zu c=%zu total=%zu\n",
    offsetof(struct Bad, a),
    offsetof(struct Bad, b),
    offsetof(struct Bad, c),
    sizeof(struct Bad));

정렬 제어

C11 alignas는 타입이나 변수에 최소 정렬을 지정 — SIMD(16/32바이트 정렬)와 DMA에 유용. 팩된 구조체(__attribute__((packed)) 또는 #pragma pack)는 모든 패딩을 제거하여 공간을 절약하지만 접근이 느려질 수 있음(일부 아키텍처에서 정렬되지 않은 메모리 접근은 폴트 가능). 정확한 레이아웃이 중요한 네트워크 프로토콜과 파일 형식에는 팩 사용. 빠른 접근이 필요한 구조체는 절대 팩하지 마세요.

c
#include <stdalign.h>

// C11 explicit alignment
struct alignas(16) Aligned16 {
    int data[4];  // 16 bytes, 16-byte aligned
};

// Check alignment
printf("Alignment of int: %zu\n", alignof(int));     // 4
printf("Alignment of struct: %zu\n",
       alignof(struct Aligned16));  // 16

// Packed struct (no padding) - GCC/Clang
struct __attribute__((packed)) Packed {
    char a;
    int b;    // No padding after a
    char c;
};  // sizeof = 6

// MSVC packed
#pragma pack(push, 1)
struct PackedMSVC { char a; int b; char c; };
#pragma pack(pop)

유연한 배열 멤버

유연한 배열 멤버(C99)는 구조체가 마지막 멤버로 가변 길이 배열을 가질 수 있게. malloc(sizeof(struct) + desired_length)로 할당. 배열은 단일 할당을 공유하므로 하나의 free가 모든 것을 해제. 별도의 포인터 + malloc보다 더 효율적이고 깔끔. 동적 배열, 문자열, 네트워크 패킷 헤더에서 일반적. sizeof(struct)는 유연한 배열을 제외.

c
#include <stdlib.h>

// C99 flexible array member (last member with no size)
struct Buffer {
    size_t size;
    char data[];  // Flexible array
};

// Allocate with space for data
size_t data_len = 1024;
struct Buffer *buf = malloc(sizeof(struct Buffer) + data_len);
buf->size = data_len;
memset(buf->data, 0, data_len);

// Use data
strcpy(buf->data, "Hello");

// Single free (no separate allocation for data)
free(buf);

공용체 타입 펀닝

공용체는 같은 메모리에 멤버를 겹쳐 타입 펀닝(비트를 다른 타입으로 재해석)을 가능하게. 마지막으로 쓴 것 이외의 공용체 멤버를 읽는 것은 C에서 허용(구현 정의). 포인터 캐스팅과 달리 공용체 타입 펀닝은 strict aliasing 하에서 합법. 익명 공용체(C11)는 멤버 이름 없이 멤버를 직접 노출. 태그된 변형과 IEEE 754 float 내부 접근을 위해 공용체 사용.

c
#include <stdio.h>

// Union: members share the same memory
union Data {
    int i;
    float f;
    char bytes[4];
};

int main() {
    union Data d;
    d.i = 0x41424344;

    printf("As int: %d\n", d.i);
    printf("As float: %f\n", d.f);
    printf("As bytes: %02x %02x %02x %02x\n",
           d.bytes[0], d.bytes[1], d.bytes[2], d.bytes[3]);

    // Safe type punning (legal in C)
    union { float f; int i; } u;
    u.f = 3.14f;
    printf("Float bits: 0x%08x\n", u.i);

    // Anonymous unions (C11)
    struct Variant {
        int type;
        union { int i; float f; char *s; };
    };
}

메모리 레이아웃 & 엔디안

엔디안은 메모리의 바이트 순서를 결정: 리틀 엔디안(x86, ARM 기본)은 LSB를 먼저 저장; 빅 엔디안은 MSB를 먼저 저장. 이식 가능한 바이너리 형식을 작성할 때 memcpy 대신 명시적 시프트로 직렬화. 디버깅 중 원시 메모리를 검사하려면 dump_hex 사용. 네트워크 프로토콜은 빅 엔디안(네트워크 바이트 순서) 사용; 이식 가능한 코드를 위해 htonl/ntohl 사용. 이식 가능한 바이너리 I/O를 작성할 때 항상 두 엔디안에서 테스트.

c
#include <stdint.h>

void dump_hex(void *ptr, size_t len) {
    unsigned char *bytes = ptr;
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", bytes[i]);
        if ((i + 1) % 16 == 0) printf("\n");
    }
}

int main() {
    uint32_t val = 0x12345678;
    printf("Value: 0x%08x\n", val);
    printf("Bytes: ");
    dump_hex(&val, sizeof(val));

    // Little-endian: 78 56 34 12 (LSB first)
    // Big-endian:    12 34 56 78 (MSB first)

    // Serialize to big-endian (network order)
    unsigned char be[4];
    be[0] = (val >> 24) & 0xFF;
    be[1] = (val >> 16) & 0xFF;
    be[2] = (val >> 8)  & 0xFF;
    be[3] = val & 0xFF;

    // Deserialize from big-endian
    uint32_t recovered = (be[0]<<24)|(be[1]<<16)|(be[2]<<8)|be[3];
    printf("Recovered: 0x%08x\n", recovered);
    return 0;
}
25

가변 인수 함수 고급

va_list 기본

가변 인수 함수는 va_list를 사용하여 가변 인수에 접근. va_start는 마지막 명명된 매개변수로 목록을 초기화. va_arg는 지정된 타입으로 다음 인수를 검색. va_end가 정리. 호출자는 카운트와 타입을 전달해야 함(예: printf는 형식 지정자 사용). 가변 인수 함수는 타입 안전성이 부족 — 일치하지 않는 타입은 정의되지 않은 동작을 유발.

c
#include <stdarg.h>

// Variadic function: variable number of arguments
int sum(int count, ...) {
    va_list args;
    va_start(args, count);  // Initialize with last named param

    int total = 0;
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);  // Get next argument
    }

    va_end(args);  // Cleanup
    return total;
}

// Usage
int result = sum(3, 10, 20, 30);  // 60
int result2 = sum(5, 1, 2, 3, 4, 5);  // 15

센티널 종료 가변 인수

센티널 값(종종 NULL)은 카운트 매개변수의 필요성을 제거하고 인수 목록의 끝을 표시. execl 같은 C API에서 일반적. GCC __attribute__((sentinel))은 마지막 인수가 NULL이 아닌 경우 경고. 예상되는 센티널을 항상 문서화. 단점은 센티널이 유효한 데이터 값으로 나타날 수 없다는 것.

c
#include <stdarg.h>

// Sentinel value marks the end
void log_messages(const char *first, ...) {
    va_list args;
    va_start(args, first);

    const char *msg = first;
    while (msg != NULL) {  // NULL is the sentinel
        printf("%s\n", msg);
        msg = va_arg(args, const char *);
    }

    va_end(args);
}

// Usage: NULL terminates the list
log_messages("Error 1", "Error 2", "Error 3", NULL);

// GCC attribute to enforce sentinel
void log_messages(const char *first, ...)
    __attribute__((sentinel));

vfprintf & 형식 문자열

vprintf/vfprintf/vsnprintf는 ... 대신 va_list를 받아 사용자 정의 printf 같은 함수를 가능하게. 버퍼 오버플로우를 방지하기 위해 vsprintf 대신 항상 vsnprintf(바운드) 사용. va_list를 직접 전달. 이 패턴은 로깅 라이브러리, 에러 보고, 사용자 정의 포매터에서 사용. 형식 문자열 취약점(사용자 제어 형식)은 보안 위험 — 사용자 입력을 형식으로 절대 전달하지 마세요.

c
#include <stdarg.h>
#include <stdio.h>

// Custom printf-like function
void logf(const char *format, ...) {
    va_list args;
    va_start(args, format);

    // Add timestamp prefix
    printf("[LOG] ");
    vprintf(format, args);  // Pass va_list to vprintf
    printf("\n");

    va_end(args);
}

// Write to string with vsnprintf
void format_msg(char *buf, size_t size, const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, size, fmt, args);  // Safe: bounded
    va_end(args);
}

// Usage
logf("User %s logged in (id=%d)", username, id);
char msg[256];
format_msg(msg, sizeof(msg), "Error %d: %s", code, text);

함수 포인터 & 콜백

함수 포인터는 C에서 콜백과 다형성을 가능하게. return_type (*name)(params) 구문은 함수에 대한 포인터를 선언. qsort는 제네릭 정렬을 위해 비교 콜백 사용. 함수 포인터 배열은 디스패치 테이블(switch의 대안)을 구현. 콜백 서명이 정확히 일치하는지 항상 확인. 함수 포인터는 이벤트 핸들러, 플러그인, C의 전략 패턴의 기초.

c
#include <stdlib.h>

// Function pointer type
typedef int (*compare_fn)(const void *, const void *);

// Comparison function for qsort
int cmp_int(const void *a, const void *b) {
    return *(const int *)a - *(const int *)b;
}

int main() {
    int arr[] = {5, 2, 8, 1, 9, 3};
    size_t n = sizeof(arr) / sizeof(arr[0]);

    // qsort takes a function pointer
    qsort(arr, n, sizeof(int), cmp_int);

    // Array of function pointers
    double (*ops[])(double, double) = {
        add, subtract, multiply, divide
    };
    double result = ops[2](10.0, 3.0);  // multiply

    // Function pointer as parameter
    void apply(int *arr, size_t n, int (*fn)(int)) {
        for (size_t i = 0; i < n; i++) arr[i] = fn(arr[i]);
    }
    apply(arr, n, square);
    return 0;
}

가변 매크로

가변 매크로(__VA_ARGS__)는 가변 인수를 받아 로깅과 디버깅에 유용. __VA_OPT__(C2x)는 쉼표를 조건부로 포함하여 0 인수 경우를 처리. ##__VA_ARGS__ GCC 확장은 인수가 전달되지 않을 때 선행 쉼표를 제거. 릴리스 빌드에서 아무것도 컴파일되지 않는 디버그 매크로는 코드 변경 없이 오버헤드를 제거. 형식 문자열 공격을 방지하기 위해 항상 형식 문자열을 보호.

c
// C99 variadic macros
#define LOG(fmt, ...) printf("[LOG] " fmt "\n", __VA_ARGS__)

// Usage
LOG("Value: %d", x);
LOG("User %s, age %d", name, age);

// GCC __VA_OPT__ (C99/C2x): handle zero arguments
#define LOG2(fmt, ...)     printf("[LOG] " fmt "\n" __VA_OPT__(,) __VA_ARGS__)

// Count arguments (GCC extension)
#define COUNT(...) NARG_(__VA_ARGS__, 5, 4, 3, 2, 1)
#define NARG_(_1, _2, _3, _4, _5, N, ...) N

// Debug macro that compiles out in release
#ifdef NDEBUG
    #define DEBUG(fmt, ...) ((void)0)
#else
    #define DEBUG(fmt, ...) fprintf(stderr, fmt, ##__VA_ARGS__)
#endif

// ## removes comma if no variadic args (GCC extension)
DEBUG("just a message");
DEBUG("value = %d", x);

Was this helpful?

Learning path

Learn from scratch

Learn this language from the ground up with structured lessons.