C 언어 시리즈 #02 — 타입·승격(usual arithmetic)·정수 표현과 패딩
이 글의 핵심
int보다 작은 타입이 왜 먼저 int로 올라가는지, 혼합 타입 연산이 왜 비용·오버플로 의미를 바꾸는지, 그리고 이것이 포인터 연산·비트 연산과 어떻게 충돌하는지 설명합니다.
시리즈 안내
#02 | 📋 전체 목차 | 이전: #01 기초·번역 단위 · 다음: #03 제어 흐름
C의 타입은 값에 붙은 설명이 아니라, 컴파일러가 연산마다 정수 승격과 통상 산술 변환을 끼워 넣는 규칙의 출발점입니다. 이 글은 size_t와 int의 혼합, unsigned 래핑, float 정밀도, 직렬화와 ABI 문제를 다룹니다. 용어 정의부터 시작하기보다 실제로 자주 생기는 버그를 먼저 보고, 그 원인이 되는 규칙을 뒤에서 정리합니다.
자주 생기는 버그부터
size_t로 거꾸로 도는 루프
인덱스는 int, 길이는 size_t인 조합은 흔한 버그의 출발점입니다. 가장 단순한 형태는 루프 변수를 size_t로 잡고 i >= 0까지 거꾸로 도는 코드입니다. unsigned 타입에는 음수가 없으므로 i >= 0은 항상 참이고, 0에서 --i를 하면 SIZE_MAX로 래핑되어 루프가 끝나지 않습니다.
/* i >= 0 은 unsigned에서 항상 참 */
void infinite_oops(int *p, size_t n) {
size_t i;
for (i = n; i >= 0; --i) { /* 게다가 첫 반복의 p[n]은 범위 밖 */
p[i] = 0;
}
}
/* 올바른 형태: 비교 후 감소 */
void clear_backward(int *p, size_t n) {
for (size_t i = n; i-- > 0; ) {
p[i] = 0;
}
}
int로 바꾸어 푸는 방법도 있지만 함정이 남습니다. 아래 코드에서 n == 0이면 n - 1은 SIZE_MAX가 되고, 이를 int로 변환하는 결과는 구현 정의입니다(일반적인 구현에서는 -1이 되어 루프가 돌지 않습니다). n이 INT_MAX보다 크면 변환 결과가 엉뚱한 값이 됩니다.
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 > INT_MAX이면 변환 결과가 구현 정의 값 */
}
}
int와 unsigned 비교
int와 unsigned int를 비교하면 int 쪽이 unsigned int로 변환됩니다. -1은 UINT_MAX가 되므로 아래 비교는 항상 거짓입니다. 이 변환은 모듈로 연산으로 정의되어 있어 구현에 따라 달라지지 않습니다.
int i = -1;
unsigned u = 10u;
if (i < u) {
/* 실행되지 않음: (unsigned)-1 == UINT_MAX */
} else {
/* 항상 여기로 옴 */
}
같은 이유로 int k와 size_t len의 k < len에서 k가 음수면, k가 아주 큰 size_t 값이 되어 조건이 거짓이 됩니다. 음수 인덱스를 걸러 내려던 검사가 조용히 다른 의미가 되는 것입니다. 인덱스와 길이는 한 타입으로 통일하고, int를 쓴다면 음수가 들어오지 않는다는 것을 API에서 검증하는 편이 안전합니다.
그 밖에 자주 보는 실수
uint8_t a = 200, b = 100;일 때a + b는 300입니다. 두 피연산자가int로 승격된 뒤 더해지기 때문입니다. 이 값을 다시uint8_t에 대입하면 256으로 나눈 나머지인 44가 됩니다. “8비트끼리 더한다”고 생각하면 어느 쪽 결과도 예상과 다릅니다.1/2는 정수 나눗셈이라 0입니다. 실수 결과가 필요하면1.0/2처럼 한쪽을 실수로 만듭니다.NaN은 자기 자신과도 같지 않으므로==로 검사할 수 없습니다.isnan을 씁니다.- 바이트 버퍼를
*(uint32_t *)buf로 읽으면 엔디안, 정렬, 엄격 별칭 문제가 한꺼번에 생깁니다. 시프트로 조립하거나memcpy로 옮깁니다. volatile은 스레드 동기화 수단이 아닙니다. 원자성과 메모리 순서가 필요하면<stdatomic.h>나 락을 씁니다.restrict로 선언한 인자에 겹치는 메모리를 넘기면 미정의 동작입니다. 겹칠 수 있는 복사는memmove를 씁니다.- Windows와 Linux에서 결과가 다르면
long의 폭(LLP64는 32비트, LP64는 64비트)을 먼저 의심합니다. - DLL이나 공유 라이브러리 경계에서
enum크기가 다르면 한쪽만-fshort-enums로 빌드했을 가능성이 큽니다. 외부 API에는int32_t같은 고정폭 타입을 씁니다.
기본 타입
C의 산술 타입은 정수와 부동소수로 나뉩니다. 정수에는 char, short, int, long, long long(C99)과 각각의 signed·unsigned 변형이 있습니다. char는 signed char, unsigned char와 구별되는 별도의 타입이며, 부호가 있는지는 구현이 정합니다. 문자 리터럴 'a'의 타입은 C에서 int입니다(C++에서는 char).
char와 signed char·unsigned char
char는 주소를 매길 수 있는 가장 작은 단위입니다. x86의 GCC에서는 보통 부호가 있고, ARM Linux에서는 보통 부호가 없습니다. 바이트 데이터를 일반 char에 담으면 0x80 이상의 값이 승격될 때 음수로 부호 확장될 수 있으므로, 네트워크·파일 I/O·버퍼에서는 unsigned char나 uint8_t를 쓰는 편이 안전합니다.
unsigned char buf[4];
/* ... 바이트 스트림을 buf에 수집 ... */
uint32_t u = (uint32_t)buf[0] << 24 | (uint32_t)buf[1] << 16
| (uint32_t)buf[2] << 8 | (uint32_t)buf[3];
여기서 (uint32_t) 캐스트는 빼면 안 됩니다. buf[0]은 int로 승격되므로, 캐스트 없이 buf[0] << 24를 하면 buf[0]이 0x80 이상일 때 int의 범위를 넘어 미정의 동작이 됩니다.
int, long, long long
int: 표준은 최소 16비트만 요구합니다. 현재의 데스크톱·서버 플랫폼(ILP32, LP64, LLP64)에서는 모두 32비트입니다.long: 최소 32비트입니다. 64비트 Windows(LLP64)에서는 32비트, 64비트 Linux·macOS(LP64)에서는 64비트입니다.long long: C99에서 추가되었고 최소 64비트입니다. 나노초 타임스탬프처럼 큰 값을 담을 때 씁니다.
#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;
}
float와 double
거의 모든 현대 플랫폼에서 float는 IEEE 754 binary32, double은 binary64입니다. float는 유효숫자가 십진 약 7자리라서 누적 합이나 행렬 연산에서 오차가 빨리 드러납니다. 소수점 리터럴 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 Linux에서는 80비트 확장 정밀도, MSVC에서는 double과 같은 64비트, 일부 ABI에서는 128비트 소프트웨어 구현으로 플랫폼마다 크게 다릅니다. 바이너리 호환이 중요한 데이터에는 쓰지 않는 편이 안전합니다.
정수 타입의 크기와 범위, stdint.h
표준은 CHAR_BIT(한 char의 비트 수, 최소 8)와 각 타입의 최소 범위만 정하고, 정확한 비트 수는 구현 정의로 둡니다. 실제 값은 <limits.h>와 <stdint.h>의 매크로로 확인합니다.
64비트 Linux·macOS(LP64)에서는 long이 64비트이고, 64비트 Windows(LLP64)에서는 32비트입니다. 두 모델 모두 void *와 size_t는 64비트, int는 32비트입니다. 따라서 포인터를 long에 담는 코드는 Windows에서 상위 32비트를 잃습니다. 포인터를 정수로 다뤄야 하면 uintptr_t를 씁니다.
<stdint.h> 고정폭·최소폭 타입
int32_t,uint32_t: 패딩 비트 없이 정확히 32비트인 타입입니다. 구현에 그런 타입이 있을 때만 정의되는 선택적 타입이지만, 현실의 플랫폼에서는 거의 항상 있습니다.int_least32_t: 32비트 이상인 타입 중 가장 작은 것입니다. 항상 정의됩니다.int_fast32_t: 32비트 이상이면서 구현이 빠르다고 판단한 타입입니다. 예를 들어 x86-64 glibc에서는 64비트지만, 캐시 사용량이 늘어 오히려 느려질 수도 있습니다.- 한계값은
INT32_MIN,UINT32_MAX같은 매크로로 제공됩니다.
#include <stdio.h>
#include <inttypes.h> /* PRId32 등, stdint.h 포함 */
int32_t a = -1;
uint32_t b = 2;
printf("a=%" PRId32 ", b=%" PRIu32 "\n", a, b);
와이어 포맷, 체크섬, 해시, 프로토콜 필드는 uint8_t·uint32_t와 명시적 엔디안 변환으로 고정해야 플랫폼마다 같은 결과가 나옵니다.
엔디안과 memcpy
같은 uint32_t 값도 메모리에 놓이는 바이트 순서는 little-endian과 big-endian에서 다릅니다. *(uint32_t *)bytes 같은 type punning은 엄격 별칭 규칙 위반과 정렬 위반이 될 수 있으므로, 앞의 예처럼 시프트로 조립하거나 memcpy로 정렬된 변수에 옮긴 뒤 바이트 순서를 바꿉니다. 별칭 규칙은 #05에서 자세히 다룹니다.
타입 한정자: const, volatile, restrict
const
const는 읽기 전용 의도를 컴파일러와 독자에게 알립니다. 포인터에서는 가리키는 대상과 포인터 자체에 각각 붙을 수 있으므로 선언을 오른쪽에서 왼쪽으로 읽는 습관이 필요합니다.
const int *p1; /* *p1 수정 금지, p1은 다른 int를 가리킬 수 있음 */
int *const p2; /* p2 자체는 다른 주소로 못 바꿈, *p2는 바꿀 수 있음 */
const int *const p3; /* 둘 다 고정 */
const int *는 “이 경로로는 수정하지 않겠다”는 약속일 뿐, 대상이 바뀌지 않는다는 보장이 아닙니다. 다른 포인터나 다른 스레드가 같은 객체를 바꿀 수 있으므로 컴파일러는 const 포인터를 통한 읽기를 마음대로 캐시하지 못합니다. 처음부터 const로 정의된 객체(static const int table[] 등)만 값이 바뀌지 않는다고 가정할 수 있습니다.
volatile
volatile은 해당 객체에 대한 모든 읽기와 쓰기를 소스에 적힌 대로 수행하라고 요구합니다. 메모리 맵 I/O 레지스터, 시그널 핸들러와 공유하는 volatile sig_atomic_t 플래그, setjmp 이후 수정되고 longjmp 뒤에 읽히는 지역 변수에 씁니다. 원자성과 스레드 간 순서는 보장하지 않으므로 스레드 동기화에는 <stdatomic.h>(C11)나 락이 필요합니다.
#define DEVICE_STATUS ((volatile uint32_t *)0x40010000u)
void wait_ready(void) {
while ((*DEVICE_STATUS & 1u) == 0) {
/* volatile이 없으면 컴파일러가 읽기를 한 번으로 줄여 무한 루프가 될 수 있음 */
}
}
restrict(C99)
restrict는 해당 포인터로 접근하는 객체를 다른 포인터로는 접근하지 않는다는 프로그래머의 약속입니다. 컴파일러는 이를 근거로 로드를 재사용하거나 벡터화할 수 있고, 약속이 거짓이면 미정의 동작입니다.
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];
}
}
C99부터 memcpy의 인자는 restrict로 선언되어 있고 memmove는 그렇지 않습니다. 영역이 겹칠 수 있으면 memmove를 써야 하는 이유가 시그니처에 그대로 드러나 있습니다.
형변환과 통상 산술 변환
암묵적 변환과 명시적 캐스팅
대입, 프로토타입이 있는 함수 호출의 인자 전달, 산술 연산에서 컴파일러는 필요한 변환을 암묵적으로 넣습니다. 캐스팅은 이 변환을 소스에 드러냅니다.
uint32_t x = 0xFFFFFFFFu;
int32_t y = (int32_t)x; /* 범위 밖 값의 signed 변환: 구현 정의(보통 -1) */
double d = (double)1 / 2; /* 0.5 */
캐스팅은 의도를 보여 주지만 경고를 숨기는 용도로 쓰이기도 합니다. unsigned나 size_t 경계를 넘는 캐스팅 앞에는 값의 범위를 먼저 검사하는 편이 안전합니다. 참고로 범위를 벗어난 값을 unsigned 타입으로 바꾸는 것은 모듈로 연산으로 정의되어 있지만, signed 타입으로 바꾸는 것은 구현 정의입니다.
통상 산술 변환(usual arithmetic conversions)
이항 산술·비교 연산 a op b에서 두 피연산자를 공통 타입으로 맞추는 규칙입니다. 순서는 다음과 같습니다.
- 한쪽이라도 부동소수 타입이면
long double>double>float순으로 더 높은 쪽 타입으로 맞춥니다. 정수 피연산자는 그 실수 타입으로 변환됩니다. - 둘 다 정수면 먼저 양쪽에 정수 승격(다음 절)을 적용합니다.
- 승격 후 부호가 같으면 순위(rank)가 높은 타입으로 맞춥니다.
- 부호가 다르고
unsigned쪽의 순위가 같거나 높으면unsigned타입으로 맞춥니다.int와unsigned int의 비교에서 -1이UINT_MAX가 되는 것이 이 경우입니다. signed쪽 순위가 더 높고 그 타입이unsigned쪽의 모든 값을 표현할 수 있으면signed타입으로 맞춥니다(LP64에서long과unsigned int).- 그렇지 않으면
signed타입에 대응하는unsigned타입으로 맞춥니다(LLP64에서long과unsigned int는 둘 다 32비트라unsigned long이 됨).
5번과 6번 때문에 같은 코드가 LP64와 LLP64에서 다른 결과를 낼 수 있습니다. long l = -1; unsigned u = 1;일 때 l < u는 Linux x86-64에서는 참이고 Windows x64에서는 거짓입니다.
정수 승격(integer promotions)
int보다 순위가 낮은 정수 타입(char, short, 그 unsigned 변형, 비트 필드 등)의 값은 대부분의 연산 전에 승격됩니다. 원래 타입의 모든 값을 int로 표현할 수 있으면 int로, 그렇지 않으면 unsigned int로 바뀝니다. int가 32비트인 플랫폼에서는 uint8_t와 uint16_t도 모두 int가 됩니다. C의 산술이 레지스터 폭에 맞춘 int 중심으로 설계되었기 때문입니다.
uint8_t a = 200u, b = 100u;
uint8_t c = a + b; /* a, b는 int로 승격 → 300, 대입하며 44로 잘림 */
uint16_t m = 0xFFFF;
uint32_t r = m * m; /* int로 승격된 65535 * 65535는 int 범위를 넘으므로 미정의 동작 */
마지막 줄이 승격의 대표적인 함정입니다. unsigned 타입끼리 곱했는데도 승격 결과가 signed int라서 오버플로가 미정의 동작이 됩니다. (uint32_t)m * m처럼 한쪽을 먼저 넓은 unsigned로 바꾸면 해결됩니다.
시프트 연산자는 통상 산술 변환을 하지 않고 각 피연산자에 승격만 적용하며, 결과 타입은 승격된 왼쪽 피연산자의 타입입니다. 그래서 uint8_t 값을 24비트 이상 왼쪽으로 밀면 int 시프트가 되어 부호 비트를 건드릴 수 있습니다. 시프트 횟수가 승격된 왼쪽 피연산자의 비트 수 이상이거나 음수여도 미정의 동작입니다.
부동소수: IEEE 754, 정밀도, NaN·Inf
IEEE 754 개요
일반적인 데스크톱·서버 FPU는 IEEE 754 binary32/binary64를 따릅니다(반올림 모드, 비정규수, quiet/signaling NaN 등 세부 동작 포함). float는 십진 약 7자리, double은 약 15~16자리의 유효숫자를 가집니다.
#include <math.h>
#include <stdio.h>
int main(void) {
float x = 1.0e30f, y = 1.0e30f, z;
z = (x * y) * 0.0f; /* float 범위를 넘어 inf, inf * 0 → NaN */
printf("isfinite=%d, isnan=%d\n", isfinite(z), isnan(z));
return 0;
}
이 결과는 FLT_EVAL_METHOD가 0인 환경(x86-64의 SSE 연산 등)을 가정합니다. 32비트 x87처럼 중간 계산을 더 넓은 정밀도로 하는 환경에서는 x * y가 오버플로하지 않아 결과가 0이 될 수 있습니다.
NaN과 비교
NaN은 자기 자신을 포함한 어떤 값과도 같지 않으므로 NaN == NaN은 거짓입니다. x != x로 검사하는 관용구도 있지만, -ffast-math처럼 NaN이 없다고 가정하는 옵션에서는 컴파일러가 이 비교를 상수로 접어 버리므로 isnan을 쓰는 편이 안전합니다.
FENV와 pragma
<fenv.h>로 반올림 모드를 바꾸거나 예외 플래그를 읽는 코드는 #pragma STDC FENV_ACCESS ON이 켜진 범위 안에 있어야 합니다. 이 pragma가 꺼져 있으면(대부분 기본값) 컴파일러는 반올림 모드가 기본값이고 아무도 예외 플래그를 읽지 않는다고 가정하고 상수 접기나 연산 재배치를 할 수 있습니다. GCC는 이 pragma를 지원하지 않고 무시하므로 -frounding-math 같은 옵션으로 대신합니다. 부동소수 환경을 바꾸는 코드가 있는 프로젝트라면 어떤 빌드 옵션과 pragma를 쓰는지 문서로 남겨 두는 것이 좋습니다.
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;
API에서는 typedef struct Obj Obj;처럼 불완전 타입만 공개하고 구조체 정의는 .c 파일에 숨기는 불투명 포인터 패턴을 자주 씁니다. 다만 _t로 끝나는 이름은 POSIX가 예약하고 있으므로, POSIX 환경용 코드라면 팀 규칙으로 접미사를 정해 두는 편이 좋습니다.
enum 선언과 주의점
열거형은 정수 상수 집합에 이름을 붙입니다. C17까지 열거 상수의 타입은 int이고 값도 int 범위여야 합니다. 열거형 변수 자체의 저장 타입은 구현이 char, signed int, unsigned int 중 값을 담을 수 있는 것으로 정합니다.
enum state { IDLE, RUNNING, ERROR = 100 };
enum state s = RUNNING;
switch (s) {
case RUNNING: break;
default: break;
}
GCC·Clang의 -fshort-enums는 enum의 저장 크기를 값 범위에 맞게 줄이므로 같은 구조체의 레이아웃이 바뀌고 ABI가 깨집니다. 프로젝트 전체에 같은 옵션을 쓰거나, 외부 API에는 int32_t 같은 고정폭 타입으로 값을 주고받습니다. C에서는 enum 값과 일반 int가 경고 없이 섞이므로, 값 검증을 한 함수로 모아 두면 잘못된 값이 퍼지는 것을 막을 수 있습니다.
배열 타입: 선언과 크기
int a[10]; /* int 10개, 인덱스 0~9 */
char buf[1u << 20]; /* 1 MiB: 지역 변수라면 스택 한계에 주의 */
size_t n = 10;
int *p = malloc(n * sizeof *p);
/* VLA(C99): void f(int n) { int v[n]; } — C11부터 선택 기능, MSVC는 미지원 */
sizeof를 배열 이름에 적용하면 배열 전체 바이트 수가 나옵니다. 하지만 매개변수 void f(int a[10])의 a는 포인터로 조정되므로 sizeof a는 포인터 크기입니다. char s[] = "abc";는 널 종료 문자를 포함해 char 4개입니다.
큰 배열을 지역 변수로 두면 기본 스택 크기(Linux 메인 스레드는 보통 8MiB, 부가 스레드나 임베디드는 훨씬 작음)를 넘을 수 있습니다. 크기가 크거나 실행 중에 정해지면 malloc으로 할당합니다.
포인터 타입 개요
T *는 T 객체를 가리키며, 포인터 산술은 sizeof(T) 단위로 주소를 움직입니다. 같은 배열 객체(와 그 끝 바로 다음 위치) 밖으로 나가는 산술은 미정의 동작입니다.
int x[3] = {1, 2, 3};
int *p = &x[0];
p += 1; /* 다음 int: 주소가 sizeof(int)만큼 증가 */
char *와 unsigned char *로는 어떤 객체의 바이트든 읽을 수 있습니다. 표준의 엄격 별칭 규칙(C11 6.5p7)에 문자 타입 예외가 있기 때문입니다. 널 포인터 상수(NULL, 0)는 어떤 포인터 타입에도 대입할 수 있습니다.
void와 void*
void는 값이 없음을 나타내며 함수 반환형이나 빈 매개변수 목록(f(void))에 씁니다. void *는 객체를 가리키는 범용 포인터입니다. C에서는 void *와 다른 객체 포인터 사이의 변환이 캐스트 없이 항상 허용되므로 malloc 결과에 캐스트가 필요 없습니다(C++에서는 필요). 함수 포인터와 void * 사이의 변환은 C 표준이 정의하지 않습니다. POSIX의 dlsym은 이 변환이 동작한다고 요구하지만 이식 가능한 C는 아닙니다.
void *raw = malloc(16);
if (!raw) return;
int *ip = raw; /* C에서는 암묵적 변환으로 충분 */
struct·union 타입
struct는 선언 순서대로 멤버를 배치하고, 각 멤버의 정렬 요구를 맞추기 위해 패딩을 넣습니다. 그래서 sizeof가 멤버 크기의 합보다 클 수 있습니다. 직렬화·IPC·네트워크 코드에서는 구조체를 그대로 쓰기보다 필드를 하나씩 바이트로 변환하고, 레이아웃 가정은 _Static_assert로 컴파일 시점에 검사합니다.
union은 같은 저장 공간을 여러 타입으로 해석합니다. C에서는 마지막에 쓴 멤버와 다른 멤버로 읽어 비트를 재해석하는 것이 허용되지만(C++에서는 미정의 동작), 결과 값은 표현에 의존합니다. 이식성이 필요하면 memcpy로 옮기는 편이 명확합니다.
타입 호환성
- 서로 다른 번역 단위에서 같은 이름의 구조체를 다르게 정의하면 두 타입은 호환되지 않으며, 이를 섞어 쓰면 미정의 동작입니다. 공통 헤더 하나에서 정의를 가져오는 이유입니다.
- 함수는 프로토타입이 있어야 인자 변환이 올바르게 적용됩니다.
void f()형태의 옛 선언은 C23 전까지 “인자 정보 없음”을 뜻하므로void f(void)로 씁니다.
#include <stdint.h>
struct pkt { uint8_t op; uint32_t v; } __attribute__((packed)); /* GNU 확장 */
_Static_assert(sizeof(struct pkt) == 5, "packed layout assumed");
packed는 컴파일러 확장이고, 정렬되지 않은 멤버의 주소를 일반 포인터로 넘기면 일부 아키텍처에서 오류가 납니다. 그래서 명시적 필드 변환을 선호하는 팀도 많습니다.
상황별 타입 선택
- 배열 길이와 인덱스에는
size_t를 씁니다. 두 포인터의 차는 같은 배열 안에서만 정의되고 타입은ptrdiff_t입니다. 한 식에int와size_t를 섞으면 앞에서 본 비교 문제가 생깁니다. - 와이어 포맷과 체크섬에는
uint8_t,uint32_t와 바이트 순서를 명시하는 변환 함수를 씁니다.unsigned long은 플랫폼마다 폭이 다릅니다. - 시간과 나노초 값은
int로는 금방 넘치므로int64_t를 씁니다. - 일반 수치 계산은
double을 기본으로 하고, 메모리나 SIMD 폭이 중요할 때만float나 고정소수점을 고려합니다. - 불투명 핸들은
typedef struct Foo Foo;와Foo *를 쓰면void *보다 타입 검사를 더 받을 수 있습니다. - MMIO 레지스터는
volatile uint32_t와 마스크·시프트로 접근합니다.
부록 A: limits.h·float.h
표준이 최솟값만 정한 타입들의 실제 한계는 <limits.h>(정수)와 <float.h>(부동소수) 매크로로 확인합니다. 이 값은 컴파일 대상 기준이므로 크로스 컴파일에서는 호스트가 아니라 타깃의 값이 들어갑니다.
<limits.h>에는 CHAR_BIT, SCHAR_MIN/SCHAR_MAX, UCHAR_MAX, SHRT_*, INT_*, LONG_*, LLONG_* 등이 있습니다. CHAR_MIN/CHAR_MAX는 일반 char가 부호 있는 타입이면 SCHAR_*와 같고, 아니면 0과 UCHAR_MAX입니다.
#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_MAX);
return 0;
}
<float.h>의 FLT_MANT_DIG는 가수 비트 수, FLT_EPSILON/DBL_EPSILON은 1.0과 그다음 표현 가능한 값의 차이, FLT_MAX/DBL_MAX는 최대 유한값입니다. INFINITY와 NAN은 C99부터 <math.h>에 있습니다.
실수 비교에는 == 대신 오차 허용 비교를 씁니다. 아래 함수는 값이 1보다 작을 때는 절대 오차, 클 때는 상대 오차로 비교합니다. 허용 범위는 계산 과정에서 쌓이는 오차에 맞게 DBL_EPSILON의 몇 배로 정해야 하며, 한 배는 대부분의 계산에 너무 빡빡합니다.
#include <float.h>
#include <math.h>
#include <stdbool.h>
bool nearly_equal_double(double a, double b, double ulps) {
double scale = fmax(fabs(a), fmax(fabs(b), 1.0));
return fabs(a - b) <= ulps * DBL_EPSILON * scale;
}
부록 B: ptrdiff_t와 size_t
size_t는 sizeof 결과의 타입이며 부호가 없습니다. ptrdiff_t는 같은 배열 안 두 포인터의 차(p - q) 타입이며 부호가 있습니다. 서로 다른 배열을 가리키는 포인터끼리 빼는 것은 미정의 동작입니다.
#include <stddef.h>
int sum_range(const int *begin, const 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(C11)는 기본 정렬 중 가장 엄격한 정렬을 가진 타입이며, malloc이 반환하는 주소는 이 정렬을 만족합니다. 구조체 멤버는 자연 정렬과 패딩 때문에 크기가 멤버 크기의 합과 다를 수 있습니다.
C11의 _Alignas(<stdalign.h>의 alignas 매크로, C23부터 키워드)로 캐시 라인이나 SIMD용으로 더 큰 정렬을 요청할 수 있습니다. 정렬되지 않은 주소에서 int를 읽는 것은 미정의 동작이므로, 네트워크 버퍼를 파싱할 때는 바이트 단위로 조립하거나 memcpy로 정렬된 변수에 옮긴 뒤 사용합니다.
부록 D: _Generic(C11)
_Generic은 제어 식의 타입에 따라 다른 식을 선택합니다. 타입별 printf 서식이나 같은 이름의 함수 래퍼를 매크로 한 곳에 모을 때 쓸 수 있습니다.
#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 | 기본 경고 묶음(-Wsign-compare는 C에서 -Wextra에 포함) |
-Wextra | 추가 경고, signed/unsigned 비교 포함 |
-Wconversion | 값이 바뀔 수 있는 좁아지는 변환 |
-Wsign-conversion | int와 unsigned 사이의 암묵적 부호 변환 |
-Wstrict-prototypes | void f()처럼 인자 정보 없는 선언 |
레거시 코드에 한꺼번에 적용하기 어렵다면 새 파일부터 적용하고, 경고를 줄이는 리팩터링을 함께 진행합니다. uint32_t를 int에 넣는 한 줄이 실행 중이 아니라 CI에서 잡히게 하는 것이 목표입니다.
부록 F: 비트 필드와 이식성
구조체의 : 3 같은 비트 필드는 배치 순서, 저장 단위, 일반 int 비트 필드의 부호가 모두 구현에 따라 다릅니다. 하드웨어 레지스터 맵이나 직렬 포맷을 비트 필드로 표현하면 컴파일러나 옵션이 바뀔 때 깨질 수 있으므로, uint32_t와 마스크·시프트로 다루는 팀이 많습니다.
다음 글: #03 제어 흐름·goto·setjmp
같이 보면 좋은 글
- C 언어 시리즈 #03 — 제어 흐름: 분기·스위치 테이블·setjmp와 스택
- C 언어 시리즈 #04 — 함수·스택 프레임·호출 규약(ABI)과 가변 인자
- C 언어 시리즈 #05 — 포인터 연산·엄격 별칭(strict aliasing)·유효성