본문으로 건너뛰기
Previous
Next
C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩

C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩

C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩

이 글의 핵심

int보다 작은 타입이 왜 먼저 int로 올라가는지, 혼합 타입 연산이 왜 비용·오버플로 의미를 바꾸는지, 그리고 이것이 포인터 연산·비트 연산과 어떻게 충돌하는지 설명합니다.

시리즈 안내

#02 | 📋 전체 목차 | 이전: #01 기초·번역 단위 · 다음: #03 제어 흐름

C의 타입은 “이런 뜻이에요” 스티커가 아니라, 컴파일러가 승격·통상 산술 변환을 끼워 넣는 연쇄다. size_t vs int, unsigned 래핑, float 정밀도, 직렬화·ABI까지 한번에 훑는다. 근데 용어부터 정의로 시작하면 먼저 잠이 온다. 그래서 자주 터지는 것부터 본 뒤, 뒤에서 타입을 정리해보자.

이 글 흐름: 흔한 실수(실수담) → 기본·정수·부동 → 한정자 → 변환·승격·signed/unsigned → IEEE 754 → typedef·enum → 배열·포인터·void → 구조체 예고·호환성 → 실전 팁.


먼저, 자주 터지는 것

unsigned랑 int 섞다가 “무한 루프” 류로 가는 썰

iint, 끝을 나타내는 값은 size_t—이 조합 진짜 많이 터진다. 더 직관적인 건 밑에 같은 거: isize_t로 잡고 i >= 0을 돌리면, unsigned는 0 밑이 없으니 i--해도 SIZE_MAX로 돌고 끝이 없다.

/* size_t i로 잡고 "0까지" — 조건 i >= 0 은 unsigned에선 늘 참에 가깝다 */
void infinite_oops(int *p, size_t n) {
    size_t i;
    for (i = n; i >= 0; --i) {
        p[i] = 0;  /* 탈출 불가(전형 케이스) */
    }
}

n이 0이면 “0번 돈다”가 아니라, n - 1int랑 섞느냐 size_t냐에 따라 SIZE_MAX 류로 래핑되기도 하고, i >= 0 루프(int라고 착각)랑 겹치면 탈출 조건이 애초에 안 맞는다.

void another_int_and_size_t_mess(int *p, size_t n) {
    for (int i = (int)(n - 1); i >= 0; --i) {
        p[i] = 0;  /* n==0이면 n-1 래핑, int 범위 밖 n이면 UB 등 */
    }
}

intunsigned를 비교할 때 intunsigned로 맞춰지면, -1엄청 큰 양의 정수로 읽힌다(전형적 2의 보수 + 통상 변환). 그러면 “작다/크다”가 직관이랑 반대로 가고, 루프는 끝이 안 보이게 될 수 있다.

int i = -1;
unsigned u = 10u;
if (i < u) {
    /* 직관으론 참인데, unsigned로 맞으면 -1이 큰 unsigned → 여기 안 탈 수도 */
} else {
    /* ... */
}

size_t len이랑 int kk < len에서 k가 음이면, ksize_t로 맞춰지며 조건이 항상 참이 되는 식(큰 len이면)도 자주 쓰인다. 정리하자: 인덱스·상한·0 근처는 한쪽 타입으로 통일하거나, int를 쓸 거면 “음이 안 온다”는 걸 API에서 박는 게 덜 아프다.

실수담

  • i < n인데 루프가 한 번도 안 돈다 / 끝이 없다”i·nintsize_t로 갈리면, 승격 때문에 조건이 골프 스윙처럼 꺾인다. 루프 변수를 size_t로 맞추거나, n이 음이 될 수 없게 호출 쪽을 잡는다.

  • uint8_t끼리인 줄 알고 더했는데 300 — 8비트끼리가 아니라 승격으로 int에서 돌다가, 다시 잘릴 때 튄다. 중간 누적은 넓은 unsigned에 두고 마지막에만 uint8_t로 캐스팅.

  • 1/2가 0 — 정수 나눗셈이다. 1.0/21.0f/2.0f.

  • NaN==isnan 쓰자.

  • memcpy는 했는데 값이 엉망엔디안. *(uint32_t*)buf 류는 피하고, 시프트·스왑으로 조립.

  • volatile 썼는데 멀티스레드가 깨짐 — 동기화가 아니다. stdatomic이나 락.

  • restrict 썼는데 터짐 — 인자끼리 같은 덩어리를 겹쳤다. memmove.

  • 윈도우만, 리눅스만 이상long·핸들 폭, 호출 규약. stdint·void*.

  • DLL 사이 enum 크기가 다름-fshort-enums 혼자 켰다. 옵션 맞추거나 외부 API는 int32_t로 고정.


1. 기본 타입

C99 이후 기본 타입은 크게 정수부동으로 나뉜다. 정수 쪽에는 char, short, int, long, long long(C99)과 각각에 대응하는 signed·unsigned 변형이 있다. charsigned char·unsigned char·“일반 char” 세 가지 중 구현이 하나를 택하며, 문자 리터럴의 기본은 int이지만 저장에는 보통 char 배열을 쓴다.

1.1 charsigned char·unsigned char

  • char: “바이트”를 담는 최소 주소 단위. 부호가 있을 수도·없을도 구현에 따른다. 네트워크·파일 I/O·버퍼에서는 unsigned char로 바이트를 다루면 부호 확장(정수로 승격될 때의 부호 해석)이 덜 꼬인다.
  • signed char: -128~127(일반적 8비트 2의 보수 가정) 범위의 정수. 비트 연산·고정폭이 필요하면 stdint.hint8_t 등을 쓰는 것이 이해·이식에 유리하다.
unsigned char buf[4];
/* ... 바이트 스트림을 buf에 수집 ... */
uint32_t u = (uint32_t)buf[0] << 24u | (uint32_t)buf[1] << 16u
           | (uint32_t)buf[2] << 8u  | (uint32_t)buf[3];

1.2 int, long, long long

  • int: 대부분의 레지스터 폭에 맞춘 “자연한” 정수. 표준은 최소 비트만 규정하며(예: int ≥ 16비트), 데스크톱에서는 흔히 32비트, 일부 64비트 LP64 모델에서도 32비트로 유지되기도 한다.
  • long: Win64(LLP64)에선 32비트, Unix 64비트(LP64)에선 64비트인 경우가 많다. “항상 64비트”가 아니다.
  • long long: C99·최소 64비트. 큰 정수·타임스탬프(나노초)·파일 오프셋(구형 API 제외)에 쓰기 좋다.
#include <stdio.h>
int main(void) {
    printf("sizeof(int)=%zu, long=%zu, long long=%zu, void*=%zu\n",
           sizeof(int), sizeof(long), sizeof(long long), sizeof(void*));
    return 0;
}

1.3 floatdouble

  • float: 보통 IEEE 754 단정밀도(binary32). double에 비해 유효 자릿수가 작고, 누적 합·행렬 연산에서 오차가 빨리 드러난다.
  • double: 배정밀도(binary64). 수치 코드의 기본 실수형으로 쓰는 경우가 많다. 상수 3.14는 상황에 따라 double로 해석될 수 있으니, float 변수에 넣을 때는 3.14f처럼 접미사를 쓰는 습관이 좋다.
float  x = 0.1f;   /* float 리터럴 */
double y = 0.1;    /* double 리터럴 */
long double z = 0.1L;  /* long double (구현·ABI에 따름) */

long double는 x86 80비트 확장, 다른 ABI에서는 128비트 소프트 구현 등 플랫폼별 차이가 커서, 바이너리 호환이 중요한 경우에는 double 또는 고정소수/정수로 설계하는 편이 안전하다.


2. 정수 타입의 크기와 범위, stdint.h

C 표준은 CHAR_BIT(한 char의 비트, 보통 8)와 각 타입의 최소 범위를 정의한다. 정확한 비트 수는 “구현 정의(implementation-defined)”이며, limits.h·stdint.h매크로를 확인하는 것이 실무의 출발이다.

2.1 플랫폼별로 달라지는 대표 케이스

64비트 Unix·리눅스(LP64) 쪽이면 long이 64비트로 가는 경우가 많고, Win64(LLP64)long이 32비트로 남는 경우가 많다. void*size_t는 64비트로 맞는 편이지만, int는 데스크톱에선 둘 다 흔히 32비트. 그러니 long에 포인터를 숫자로 박는 코드는 Win에서 깨질 수 있고, 포인터는 uintptr_tvoid*로 쓰는 게 맞다. 헷갈리면 sizeof로 찍어 본다.

/* 같은 머신에서도 "long"만 믿지 말 것 */
#include <stdio.h>
int main(void) {
    printf("long=%zu, void*=%zu\n", sizeof(long), sizeof(void*));
    return 0;
}

2.2 <stdint.h> 고정폭·최소폭 타입

  • int32_t, uint32_t: 정확히 32비트가 존재하는 플랫폼에서만 typedef가 정의될 수 있다(옵셔널).
  • int_least32_t: 최소 32비트 이상을 보장하는 가장 작은 타입.
  • int_fast32_t: 32비트 이상이면서 “빠른”(컴파일러 휴리스틱) 타입. 캐시·레지스터에 유리하다는 보장은 없다.
  • *_min/*_max: INT32_MIN, UINT32_MAX 등.
#include <stdint.h>
#include <inttypes.h> /* PRId32 등 */
int32_t a = -1;
uint32_t b = 2;
/* printf: int32_t → PRId32, uint32_t → PRIu32 */
printf("a=%" PRId32 ", b=%" PRIu32 "\n", a, b);

프로덕션 팁: 와이어 포맷·체크섬·해시·프로토콜은 uint8_t/uint32_t + 명시적 엔디안 변환으로 고정하는 것이, unsigned long에 의존하는 것보다 재현성이 좋다.

2.3 엔디안과 memcpy

고정폭 정수의 비트나 값이 같아도, 메모리에 바이트 순서는 little-endian/big-endian에 따라 달라진다. *(uint32_t*)&bytes 같은 type punning엄격 별칭과 정렬·UB 위험이 있으므로, memcpy나 시프트 조립을 권장한다. (다음 시리즈 #05에서 restrict·별칭을 더 다룬다.)


3. 타입 한정자: const, volatile, restrict

3.1 const

  • 읽기 전용 의도를 컴파일러와 독자에게 전달한다. const 포인터는 “가리키는 쪽”과 “포인터 자체” 둘 다에 붙을 수 있어, 선언을 오른쪽에서 읽는 연습이 필요하다.
const int *p1;   /* *p1 수정 금지, p1은 다른 int를 가리킬 수 있음 */
int *const p2;   /* p2 자체는 다른 주소로 못 바꿈, *p2는 바꿀 수 있음 */
const int *const p3; /* 둘 다 고정 */
  • 최적화: 컴파일러는 const로 표시된 읽기를 캐시할 수 있다. volatile이 아닌 한, 하드웨어 레지스터 매핑에는 부적절하다(아래 volatile).

3.2 volatile

  • 관측 가능한 접근을 유지하라는 힌트. 하드웨어 MMIO, 인터럽트와 공유되는 플래그, setjmp/longjmp 경로 일부, 컴파일러가 “사용되지 않는” 것으로 제거하면 안 되는 longjmp 밀린 변수 등에서 쓰인다.
  • 의미: 스레드 동기화volatile만으로는 충분하지 않다(원자성·순서 보장이 없다). stdatomic.h(C11) 또는 락이 필요하다.
#define DEVICE_STATUS ((volatile uint32_t *)0x40010000u)
void wait_ready(void) {
    while ((*DEVICE_STATUS & 1u) == 0) {
        /* busy-wait: volatile 없으면 루프가 최적화로 사라질 수 있음 */
    }
}

3.3 restrict(C99)

  • 포인터가 같은 객체에 대한 다른 경로로 겹치지 않는다는 약속(프로그래머 계약). 컴파일러는 캐시·명령 스케줄링·벡터화에 활용할 수 있으며, 거짓이면 미정의 동작(UB)이 될 수 있다.
void add_arrays(float * restrict a,
                const float * restrict b,
                const float * restrict c, size_t n) {
    for (size_t i = 0; i < n; i++) {
        a[i] = b[i] + c[i];
    }
}

memcpy·memmove의 시그니처는 설계상 restrict의 의도가 반영되었다(인자 세트가 alias되지 않는 전형적 케이스). 겹침이 있을 수 있으면 memmove를 써야 한다.


4. 형변환: 암묵적·명시적, 통상 산술 변환

4.1 암묵적 변환(일반)

대입·함수 인자(원형 없는 K&R 스타일 제외, 프로토타입 권장)·연산은 상황에 따라 암묵적으로 승급/변환된다. 예: int×unsigned → 공통 상위 부호 없는 타입으로 맞춤 등.

4.2 명시적 캐스팅

uint32_t x = 0xFFFFFFFFu;
int32_t  y = (int32_t)x; /* 구현 정의/구현이 보통 2의 보수면 -1 */
double   d = (double)1 / 2;  /* 0.5가 되도록 실수 쪽으로 */
  • 의도를 드러낸다는 점이 장점. 반면 잘못 쓰면 “경고를 가리는” 용도가 되기도 하니, unsigned·size_t 경계는 캐스팅 전에 값 검증이 안전하다.

4.3 usual arithmetic conversions(통상 산술 변환)

이항 연산 a op b에서 공통의 산술 타입으로 맞추는 규칙이다. 핵심은 다음과 같다(세부는 표준 조항).

  1. 둘 다 정수라면, 먼저 정수 승격이 적용된다(다음 절).
  2. 그 후, 한쪽이 unsigned 계열이면 부호/크기를 맞추는 규칙(구현·표준의 단계)을 따른다.
  3. double > float > 정수의 우선순위로, 실수 쪽이 있으면 상위 부동 쪽으로 맞춘다.

자주 쓰는 사고: size_t lenint ii < leni가 음이면 size_t로 맞춰지며, 큰 size_t로 읽혀 조건이 항상 참이 될 수 있다.

void bad_loop(const int *p, int n) {
    size_t i;
    for (i = 0; i < (size_t)n; i++) { /* n이 음이면? 설계에 따라 */
        (void)p[i];
    }
}

5. 정수 승격(integer promotions)

int보다 작은 정수 변환 순위(char, short, unsigned char, 비트필드 등)의 값은 여러 연산 전에 int 또는 unsigned int로 승격된다(각 타입의 값이 int·unsigned int모두 들어가는지에 따라 어느 쪽을 택하는지가 결정된다). 이유는 레지스터 폭에 맞춰 ALU가 동작하고, C의 산술이 전통적으로 int 중심이기 때문이다.

uint8_t a = 200u, b = 100u;
uint8_t c = a + b; /* a, b는 (보통) int로 승격 → 합이 int, 다시 uint8_t에 대입 */

비용: 핫 루프에서 8/16비트를 쓰더라도 내부는 32비트로 확장될 수 있어, 내부 누적은 unsigned(또는 uint_fast32_t)로 두는 것이 나을 때가 있다(단, 래핑 의미를 정확히 정할 것).

시프트: 시프트 오른쪽 피연산자(이동 횟수)도 정수로 해석·승격 흐름에 섞이므로, “8비트끼리만 돈다”는 가정이 깨질 수 있다.


6. 부호 있는 정수와 부호 없는 정수의 혼합

  • 같은 비트 수int(-1)과 unsigned(큰 수)를 비교하면, 보통 intunsigned로 변환되어, -1UINT_MAX에 가까운 큰 unsigned로 해석되는 일이 흔하다.
int i = -1;
unsigned u = 10u;
if (i < u) {
    /* -1 < 10? — 아니다. (unsigned)로 맞춰지면 -1 → 큰 unsigned */
} else {
    /* 여기로 온다(전형적 2의 보수/LP32 이상) */
}
  • 권장: 비교·산술 전에 한쪽 타입에 통일하거나, 명시적 캐스팅범위 검증을 둔다. API 경계(파일 길이 off_t vs int, 배열 size_t vs 루프 int)에서 버그가 집중된다.

7. 부동소수: IEEE 754, 정밀도, NaN·Inf

7.1 IEEE 754 개요

일반적인 데스크톱/서버 FPU는 IEEE 754 binary32/64에 가깝다(세부: 반올림 모드, denorm, sNaN/qNaN). float유효 약 7~8자리, double15~16자리 십진법 상당(구현·문자 변환에 따라 다름)이다.

#include <math.h>
#include <stdio.h>
int main(void) {
    float x = 1.0e30f, y = 1.0e30f, z;
    z = (x * y) * 0.0f;  /* inf * 0 → NaN */
    printf("isfinite=%d, isnan=%d\n", isfinite(z), isnan(z));
    return 0;
}

7.2 NaN과 비교

  • NaN == NaNfalse다. isnan으로 검사한다.
  • x != x가 참이면 xNaN인 경우가 많다(신뢰성이 필요한 코드에서는 표준 매크로/함수를 쓴다).

7.3 FENV와 pragma

#pragma STDC FENV_ACCESS를 켜지 않은 상태에서 부동·FENV(반올림·예외 플래그)를 섞지 않으면 컴파일러가 FENV를 무시해도 된다는 전제로 최적화할 수 있다. 수치/게임/임베디드 FPU를 다루는 팀은 빌드 옵션·pragma·라이브러리 경계를 문서화하는 것이 좋다(원문 frontmatter의 요지).


8. typedef로 타입 별칭을 만드는 패턴

typedef새 타입을 만드는 것이 아니라 이름을 붙이는 것이다. 도메인·플랫폼 추상화·가독성에 유용하다.

#include <stdint.h>
typedef uint32_t ipv4_t;
typedef int (*cmp_fn_t)(const void *, const void *);

typedef struct {
    uint16_t w, h;
} size_px_t; /* C99 이후: typedef + 익명 struct 선호됨 */
  • 필드 이름 충돌을 피하려 tag(struct size_px)를 쓰기도 하고, API에서는 opaque 포인터 typedef struct Obj Obj;처럼 구체 정의를 .c에 숨긴다(구현 은닉, 다음 #05 연결).

  • 이름·상수는 대문자 스네이크(KIND_FOO)와 구분하거나, *_t 는 POSIX 예약이 있으니(_t 남용 주의) 팀 규칙이 필요하다.


9. enum 선언·사용·주의

열거형은 정수 상수 집합에 이름을 붙인다. 내부적으로는 int로 표현 가능한 값이어야 하며, 구체 표현·크기·부호는 구현과 컴파일 옵션에 따른다.

enum state { IDLE, RUNNING, ERROR = 100 };
enum state s = RUNNING;
switch (s) {
case RUNNING: break;
default: break;
}
  • ABI: gcc/clang-fshort-enums 등은 enum저장 크기를 바꾸고 ABI를 깨뜨릴 수 있으니, 전역 빌드에 일괄 적용하거나, 외부 API에는 int/int32_t로 고정하는 편이 안전하다.
  • C에서는 (스타일에 따라) enum 상수가 int섞여 쓰이기 쉬우므로, 강한 구분이 필요할 때는 태그를 쓴 struct로 감싸기, typedef로 의도를 드러내기, 검증 함수 한 곳으로 모으기 등을 습관화한다.

10. 배열 타입: 선언과 크기

int a[10];        /* 10개 int, 인덱스 0~9 */
char buf[1u << 20];  /* 1 MiB(플랫폼/스택 한계에 주의) */

size_t n = 10;
int *p = (int *)malloc(n * sizeof *p);
/* VLA(가변 길이 배열, C99): void f(int n) { int v[n]; } — C11 선택적, MSVC 제약 등 */
  • sizeof(배열 이름)은 배열이 객체로 보일 때만 전체 바이트. 매개변수 void f(int a[10])a는 사실상 포인터로 조정·sizeof는 포인터 크기가 된다(아래 11절).
  • 문자열: char s[] = "abc";는 널 종료 포함 4바이트(일반 8비트 char 가정)이다.

스택 대형 배열ulimit/스레드 스택/임베디드 스택 — 또는 alloca 대신 malloc·스택 크기 점검.


11. 포인터 타입: 규칙 개요

  • T *: “T 객체를 가리킨다”는 의미이며, 포인터 산술(+, -)은 T크기로 주소가 스케일된다(유효한 동일 배열 객체 밖으로 나가면 미정의 동작).
int x[3] = {1,2,3};
int *p = &x[0];
p += 1;  /* next int, sizeof(int)만큼 주소로 점프 */
  • void*: 제네릭 메모리 주소(제한된 캐스팅 규칙, 아래 12절).
  • char*·unsigned char*: 바이트 단위(대개 strict aliasing 예외, 표준 6.5, 구현·컴파일 옵션과 함께 #05).
  • NULL · 널 포인터 상수모든 포인터에 대입될 수 있다.

12. voidvoid*

  • void: “값이 없다”. 함수 반환이나 파라미터에서 “없음”을 나타낸다.
  • void*: 객체(또는 함수가 아닌) 주소를 담는 제네릭 포인터. 다른 객체 포인터와 암시적 상호 변환이(C에서) 합법인 경우가 많다(함수 포인터는 C 표준에 따라 같이 쓰면 안 되는 변환이 있다 — 경고/UB 주의).
void *raw = malloc(16);
if (!raw) return;
int *i = (int *)raw; /* void* → int* 명시 캐스팅 */

13. struct·union 타입(간략, 다음 심화 예고)

  • struct: 이름·순서가 있는 멤버 집합. padding으로 정렬(ABI). 직렬화·커널·네트워크는 pack pragma보다 memcpy+명시적 필드, _Static_assert(sizeof…)로 스키마를 고정하는 팀이 많다(원문 5절·아래 14절).

  • union: 같은 저장 공간을 다른 해석으로 — type punning제한된 경우만 안전(공통 초기멤버 등). memcpy·char 뷰가 안전·이식.

다음 시리즈 글(포인터·엄격 별칭)·연계하여 다루기로 한다.


14. 타입 호환성(호환의 개략)

  • 같은 태그·같은 멤버struct끼리는 선언·정의 일치가 없으면(불완전 vs 완전, 서로 다른 TU에서 불일치) 정의/연결(ODR 류) 문제가 난다.
  • 산술 변환 후의 비교/대입: 상위/하위 signed·unsigned·크기 불일치는 경고·버그의 원인.
  • 함수는 프로토타입(원형) 일치로 호출·정의·함수 포인터 대입이 호환된다 — K&R 옛 스타일(원형 없는 정의)은 피한다.

_Static_assert로 배열 길이·오프셋·sizeof 가정을 컴파일 시점에 고정한다.

#include <stdint.h>
struct pkt { uint8_t op; uint32_t v; } __attribute__((packed)); /* GNU 확장 예 — 이식은 주의 */
_Static_assert(sizeof(struct pkt) == 5, "pack 가정");

(실무에서는 packed 대신 명시 필드·명시 엔디안이 팀에 따라 선호되기도 한다.)


15. 실전: 타입 고르는 쪽지

  • 배열 길이·인덱스 — 루프는 size_t로 맞추는 편이 싸우는 일이 적다. 포인터 두 개 는 같은 배열 안에서만 정의돼서 ptrdiff_t 쓰는 쪽이 자연스럽다. intsize_t를 한 줄에 섞으면 위 “먼저” 절로 돌아간다.

  • 와이어 포맷·체크섬uint8_t랑 “이 바이트가 MSB” 같은 유틸(be32 류)로 박는 게, unsigned long에 기대는 것보다 낫다(플랫폼 long 폭이 제멋대로).

  • 시간·나노초int가 조용히 넘친다. int64_t / uint64_t 쪽이 마음이 편하다.

  • 일반 수치double이 기본. float는 누적하면 오차가 빨리 드난다. 임베디드는 성능/메모리 보면 float나 고정소수.

  • 불투명 핸들typedef struct Foo Foo; + Foo *void* 난사보다 읽기 좋다.

  • MMIOuint32_t+마스크+volatile. 동기화는 volatile이 아니다.

밤에 한 번씩 점검 — (1) size_t·음수 경계에서 비교·빼기, (2) 비트 수 가정은 INT32_MAX 같은 애로 확인, (3) long·enum·정렬이 빌드마다 같은지, (4) FPU/pragma 얘기는 주석이나 팀 위키에 박혀 있는지.


부록 A: limits.h·float.h — 구현이 밝히는 상수

표준이 “최소”만 요구하는 타입들의 실제 한계<limits.h>(정수)·<float.h>(부동) 매크로로 확인한다. 이 값들은 컴파일 타깃에 고정되며, 크로스 컴파일 시 호스트가 아니라 타깃 기준이 된다.

limits.hCHAR_BIT는 보통 8(한 char의 비트). SCHAR_MIN/SCHAR_MAXsigned char, UCHAR_MAXunsigned char 끝값. SHRT_*, INT_*, LONG_*, LLONG_*는 각각 short~long long 범위이고, long은 LP64/LLP64 때문에 “큰 long” 믿지 말고 LONG_MAX로 찍어보는 게 안전하다. CHAR_MIN/CHAR_MAX일반 char가 signed인지에 따라 SCHAR_* 쪽이거나 0/UCHAR_MAX 쪽이다. 이식 코드char 부호에 기대지 말고 signed char/uint8_t로 바이트 다루는 쪽이 덜 꼬인다.

#include <limits.h>
#include <stdio.h>
int main(void) {
    printf("CHAR_BIT=%d, INT_MAX=%d, LONG_MAX=%ld\n", CHAR_BIT, INT_MAX, (long)LONG_MAX);
    return 0;
}

float.hFLT_MANT_DIG 등으로 가수 비트 느낌을 잡을 수 있고, FLT_EPSILON / DBL_EPSILON는 1.0이랑 바로 옆 표현값 사이 간격(상대 오차 감)이다. FLT_MAX·DBL_MAX는 뜨는 수의 상한, HUGE_VAL*는 오버플로 근사, C99+에선 INFINITY·NANmath.h에 올 수 있다.

실무float 누적 합·필터는 오차 예산을 잡고, 임계 비교는 epsilon + 절대 허용을 섞는다. 실수 ==는 웬만하면 피한다.

#include <float.h>
#include <math.h>
#include <stdbool.h>

bool nearly_equal_double(double a, double b) {
    double scale = fmax(fabs(a), fmax(fabs(b), 1.0));
    return fabs(a - b) <= DBL_EPSILON * scale;
}

부록 B: ptrdiff_tsize_t — 차이와 함정

  • size_t: sizeof의 결과 타입. 개수·바이트 크기에 쓰인다. 부호 없음.
  • ptrdiff_t: 같은 배열 객체 안의 두 포인터 (p - q)의 타입. 부호 있음.

같은 배열 안이 아닌 포인터끼리 빼기는 미정의 동작이다. 루프에서 end - begin 형태는 한 덩어리 할당된 범위 안에서만 안전하다.

#include <stddef.h>
int sum_range(int *begin, int *end) {
    ptrdiff_t n = end - begin; /* 배열 조각이 확실할 때만 */
    int s = 0;
    for (ptrdiff_t i = 0; i < n; i++) {
        s += begin[i];
    }
    return s;
}

size_t로 인덱스를 쓰다가 음의 ptrdiff_t와 섞으면 다시 혼합 변환이 생기므로, “인덱스는 0 이상만 온다”는 전제를 타입으로도 드러내는 것이 좋다.


부록 C: 정렬(alignment)과 max_align_t

<stddef.h>max_align_t가장 엄격한 정렬을 요구하는 스칼라 타입으로, malloc이 반환하는 주소는 이 정렬에 맞춰진다(C 표준 요구). struct 멤버는 자연 정렬패딩으로 인해 크기가 “필드 크기의 합”과 다를 수 있다.

C11에서는 _Alignas·alignas(헤더에 따라)로 과도한 정렬을 요청할 수 있다(ABI·캐시 라인·SIMD용). 잘못된 정렬로 int를 읽으면(팩한 버퍼 등) 미정의 동작이 될 수 있으니, 네트워크 버퍼에서 파싱할 때는 바이트 단위 복사 후 사용하거나 memcpy로 안전한 객체에 옮긴다.


부록 D: _Generic(C11)으로 리터럴·분기 줄이기

타입별로 printf 서식을 나누거나, 동일 이름의 매크로/함수 래퍼를 만들 때 _Generic이 유용하다(한정적이지만, “타입에 따른 분기”를 매크로 한 곳에 모을 수 있다).

#include <inttypes.h>
#include <stdio.h>

#define PRINT_U32(x) _Generic((x), \
    uint32_t: printf("%" PRIu32 "\n", (x)), \
    default:   printf("(wrong type)\n") \
)

void demo(void) {
    uint32_t u = 42;
    PRINT_U32(u);
}

한계: 모든 조합을 나열해야 하고, 가변으로 넘어오는 추상화와는 잘 맞지 않는다. “API 경계에서 타입을 고정”하는 용도에 가깝다.


부록 E: 컴파일 경고로 타입 버그를 앞당겨 잡기

GCC/Clang 계열에서 특히 유용한 옵션 예:

  • -Wall: 기본 경고 묶음.
  • -Wextra: 추가(가끔 시끄럽다).
  • -Wconversion: 좁아지는 변환(할당·인자).
  • -Wsign-conversion: 부호 변환( intunsigned ).
  • -Wstrict-prototypes: void f() vs void f(void) 구분.

레거시 코드에 일괄 적용이 어렵다면, 새 파일부터라도 팀 표준으로 걸고, 경고를 노이즈 없이 줄이는 리팩터링을 병행한다. uint32_tint에 넣는 한 줄이 런타임이 아니라 리뷰·CI에서 잡히게 만드는 것이 목표다.


부록 F: 비트 필드(bit-field)와 이식성

struct 안의 : 3 같은 비트 필드는 레이아웃·부호·패딩이 구현에 크게 의존한다. 하드웨어 레지스터 맵을 그대로 옮길 때는 주석·데이터시트와 함께 uint32_t + 마스크·시프트로 쓰는 팀도 많다. 직렬 포맷에 비트 필드를 노출하면, 컴파일러·옵션 바뀔 때마다 깨질 수 있다.


부록 G: 통상 산술 변환 단계(정수·부동, 개념 요약)

아래는 개념적 순서이며, 세부는 ISO C 표준 조항을 따른다.

  1. 정수 승격: char·short 등 → int 또는 unsigned int.
  2. 정수 쌍: 두 피연산자가 같은 부호·크기가 될 때까지 상위 타입으로 맞춤( unsigned 혼합 시 부호 없는 쪽 규칙이 핵심 함정).
  3. 실수 혼합: float < double < long double 우선순위로 상위 실수형에 맞춤. 정수와 실수가 섞이면 실수로.

이 흐름을 머릿속에 두면, unsignedint 비교 한 줄이 왜 경고 없이 논리 오류를 내는지 설명할 수 있다(6절, 그리고 글 앞부분 실수담).


정수 승격·통상 산술 변환(압축)

int보다 작은 정수는 여러 연산에서 int/unsigned int먼저 올라간다. 이어 이항 연산은 같은 산술 타입으로 통상 산술 변환을 적용하며, unsignedsigned가 겹칠 때 한쪽으로 맞출 때 음의 값이 “큰” unsigned로 읽혀 비교가 뒤집힐 수 있다(위 2·4·6절). 내부적으로 SSA/중간코드에 확장(zext/sext) 이 명시될 수 있어, 핫 루프에도 누적 비용이 생긴다(원서 서술).

void scan(int *p, int n) {
    for (int i = 0; i < n; i++) { /* n이 size_t로 넘어오는 API와 섞이면 설계 점검 */
        p[i] = i;
    }
}

stdint·“바이트 크기”와 엔디안(복습)

int32_t있다는 보장이 아니라(플랫폼에 존재할 때), int_least32_t 등은 “최소” 의미. 네트워크/디스크에 쓰는 uint32_t엔디안별도htobe32 계열(플랫폼) 또는 수동 시프트로 해결(2절).


FENV·부동·pragma(복습)

#pragma STDC FENV_ACCESS OFF가 기본인 빌드에서는 부동/환경(반올림)을 뒤섞은 코드가 최적화 가정과 충돌할 수 있다. 수치/게임/특수 FPU 문서화(원문 4절).


구조체·패딩·_Static_assert(복습)

struct 필드 순서는 유지, 패딩은 구현. 시스템 헤더의 구조는 사실상 플랫폼 ABI의 일부다. 직렬·IPC는 offset/size 정적검사·명시적 레이아웃(앞 13·14절).


enum의 정수 폭(복습)

enum 저장 크기/옵션(예: short enum)은 ABI를 바꾼다. 전체 프로젝트 일관이 필요(위 5·9절).


내부 동작(개략)

입력(바이트·API 인자) → 파싱·승격/통상 변환이 삽입된 중간 표현 → ALU·FPU(부동) → (가능한) FENV/반올림부작용(I/O·MMIO) → 출력. C 타입은 이 파이프라인에서 “어떤 ·부호·엄격 별칭(포인터 뷰)”을 강제하는 얇은 계약이며, 명시하지 않은 곳이 승격·변환으로 메워진다.

flowchart TD
  A[소스/리터럴] --> B[정수 승격]
  B --> C[통상 산술 변환]
  C --> D{부동?}
  D -- 예 --> E[FPU·FENV]
  D -- 아니고 --> F[ALU]
  E --> G[부작용·저장]
  F --> G

요약

  • 기본 타입은 표준 최소만 약속하고, LP64/LLP64·long·size_t·포인터 정수를 혼동하면 재현이 깨진다.
  • 승격·통상 산술은 이항 연산·비교·인자 경계에 가장 많은 정수 오류를 설명한다.
  • const/volatile/restrict의미 계약이며, volatile ≠ 동기화, restrict = alias 아님의 약속.
  • 부동은 IEEE, NaN/Inf·FENV, 최적화와의 상호작용을 인지할 것.
  • typedef·enum·배열·void* 규칙·ABI·sizeof 함정예시로 찍어보고 _Static_assert로 박아두는 편이 낫다.
  • 다음 심화: #05 포인터·엄격 별칭에서 엄격 별칭·restrict·memcpy·동시성을 이어서 다룬다. 제어/함수: #03 · #04.

다음: #03 제어 흐름·goto·setjmp


자주 묻는 질문 (FAQ)

Q. unsignedsigned 섞으면 왜 골치 아프냐?

A. 통상 산술 변환으로 둘 다 같은 산술 타입으로 맞는데, signedunsigned 쪽으로 끌려가면 음수가 큰 양으로 읽혀 비교·빼기가 뒤집힌다. 그래서 한쪽 폭으로 통일하거나, API 경계에서 캐스팅·범위 체크를 박는 편이 낫다.

Q. uint8_t 쓰는데 왜 승격 얘기가 나오냐?

A. uint8_t는 보통 unsigned char 별칭이고, 승격 대상이다. 8비트만 도는 줄 알고 시프트·비교하면 꼬인다.

Q. 이어서 뭐 읽지?

A. 시리즈 목차 보고, #03·#04·#05로 가면 된다.


같이 보면 좋은 글 (내부 링크)


이 글에서 다루는 키워드 (관련 검색어)

C, 타입 시스템, 정수 승격, 구현 정의, ABI, stdint.h, const, volatile, restrict, 통상 산술 변환, IEEE 754, enum, typedef 등으로 검색하면 이 글과 연이 됩니다.