C 언어 시리즈 #01 — 기초와 실행 모델: 객체 표현·정렬·번역 단위

이 글의 핵심

이 글은 문법 요약이 아니라, 이후 포인터·호출 규약·전처리·링커 논의를 받쳐 줄 실행 모델의 바닥을 다룹니다. C가 소스를 어떻게 "객체"로 취급하는지, 정렬과 패딩이 왜 생기는지, 한 .c 파일이 왜 링커 관점에서는 불완전한지까지 번역 단위와 실행 모델로 설명합니다.

C 언어 시리즈 #01(입문·기초 편) | 전체 목차 | 이어서 읽기: #02 타입·변환·정수 표현

우선 재밌는 것부터 던질게. 밑에 지루한 이론이 있으니, 여기서 한 번 웃고 가면 된다.

먼저 돌아가는 거 하나

터미널에 숫자 두 개랑 연산자 넣으면 툭 뱉는 초간단 계산기. fgets로 한 줄 읽고 sscanf로 쪼개는 이유는, scanf만 열심히 쓰다가 입력 버퍼 꼬여서 울었던 날이 있어서… 정도로만 기억해 두면 돼.

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

int main(void)
{
    char line[64];
    double a, b;
    char op;

    printf("예: 3 + 4 이렇게 쳐봐\n");
    if (fgets(line, sizeof line, stdin) == NULL)
        return 1;
    if (sscanf(line, "%lf %c %lf", &a, &op, &b) != 3) {
        printf("형식이 이상한데?\n");
        return 1;
    }
    double r = 0.0;
    switch (op) {
    case '+': r = a + b; break;
    case '-': r = a - b; break;
    case '*': r = a * b; break;
    case '/':
        if (b == 0.0) { printf("0으로는 못 나눔\n"); return 1; }
        r = a / b; break;
    default:
        printf("그 연산자는 모름: %c\n", op);
        return 1;
    }
    printf("=> %.6g\n", r);
    return 0;
}

코드에서 짚고 갈 데가 몇 군데 있어. fgets는 줄 하나를 통째로 버퍼에 읽어 오고(끝의 줄바꿈 문자까지 같이 들어옴), sizeof line만큼만 쓰니까 입력이 길어도 버퍼 밖으로 넘치지 않아. 그다음 sscanf가 그 문자열을 쪼개는데, 반환값이 “성공적으로 채운 변수 개수” 라서 != 3으로 검사하면 3 + 처럼 반쯤 들어온 입력을 걸러낼 수 있어. 이 검사를 빼면 b에는 초기화 안 된 쓰레기 값이 남아 있는 채로 계산이 돌아가.

scanf("%d", &n)만 쓰는 방식이 왜 울게 만드냐면, 숫자 자리에 abc를 치면 scanf는 그 글자를 입력 버퍼에 그대로 남겨 둔 채 실패를 돌려줘. 이걸 반복문 안에서 다시 읽으면 같은 abc에서 계속 실패하니까 무한 루프가 돌아. 줄 단위로 읽고 나서 파싱하면 실패한 줄은 이미 버퍼에서 빠져 있으니까 이런 꼬임이 없어. 사소하지만 알아 둘 것 하나 더: 읽을 때 double은 %lf, 출력할 때 printf는 %f나 %g야. printf에 넘기는 float은 어차피 double로 승격되니까 둘 다 %f로 되지만, scanf는 주소를 받으니까 float*면 %f, double*면 %lf로 정확히 맞춰야 해. 틀리면 그게 바로 아래에서 말할 UB야.

이제 본론(?)으로.

처음 segfault 만났을 때

나 예전에 포인터도 모를 때 강의에 나온 예제를 반쯤 이해한 채로 scanf 쪽을 만져 보다가, 무언가 NULL이랑 엮인 채로 잘못 짠 코드를 돌렸는데, 화면에 에러 문구 대신 그냥 툭 하고 죽었어. “Segmentation fault”. 한국어로 말하자면 “그만… 여기는 내가 읽을 수 있는 메모리가 아니야” 같은 엄한 경고인데, 처음엔 그냥 컴퓨터가 싫은 느낌이야.

이게 C가 친절하다/안 친절하다를 가르는 대목이기도 해. 파이썬이면 None 만지다 Traceback 뜨는 수준이었을 텐데, C는 너 믿고 돌리다가 한 방이기 때문이야. 따라서 scanf엔 & 꼭 붙이기, printf엔 서식이랑 인자 짝 맞추기, 이런 게 다 “UB(미정의 동작) 맞을 짓 하지 말자” 쪽의 생존 루틴이야. 아래 FAQ에도 비슷한 이야기 나와 있지.

segfault가 정확히 뭐냐면, 운영체제가 보내는 보호 장치야. 프로세스마다 가상 메모리 주소 공간이 있고, 그중 실제로 쓸 수 있게 매핑된 페이지만 읽고 쓸 수 있어. NULL(보통 주소 0) 근처나 이미 해제된 영역, 읽기 전용 페이지에 쓰려고 하면 CPU가 예외를 일으키고, 커널이 프로세스에 SIGSEGV 신호를 보내서 죽이는 거야. 그러니까 segfault는 오히려 운 좋은 경우야. 진짜 무서운 건 잘못된 포인터가 우연히 쓸 수 있는 다른 변수를 가리키는 경우인데, 그땐 아무 에러 없이 엉뚱한 값을 덮어쓰고 한참 뒤에 전혀 상관없는 곳에서 이상한 결과가 나와.

scanf("%d", n)처럼 &를 빠뜨린 게 정확히 그런 예야. n에 들어 있던 쓰레기 값을 주소로 믿고 거기에 쓰니까, 그 주소가 매핑 안 된 곳이면 segfault, 매핑된 곳이면 조용한 메모리 오염이 돼. 이런 걸 잡을 때 제일 먼저 쓸 도구는 gcc -g -fsanitize=address야. AddressSanitizer가 잘못된 접근이 일어나는 바로 그 줄에서 멈추고 스택 트레이스까지 찍어 주니까, “어딘가에서 죽는다” 수준에서 “몇 번째 줄에서 뭘 잘못 건드렸다”로 바로 좁혀져. 요즘 gcc/clang은 -Wall만 켜도 format '%d' expects argument of type 'int *', but argument 2 has type 'int' 경고로 이 실수를 컴파일할 때 알려 주니까, 경고를 무시하지 않는 게 첫 번째 생존 루틴이야.

segfault 썰은 여기까지만. 이제 딱 필요한 만큼만 지나가는 이론.

지루해지므로 짧게: C가 뭐냐

옛날 유닉스 짤 때 나온 시스템에 가깝게 짜는 언어야. OS, 임베디드, 네이티브 라이브러리, 그냥 “빨리, 예측 가능하게”를 원하는 데가 아직도 많이 씀. 툴은 Linux면 보통 gcc나 clang, 맥이면 clang, 윈도는 VS나 MSYS2 한 가지만 정해서 쓰는 게 정신 건강에 좋아. (경로·DLL 꼬이는 거 겪어보면 안다.)

Hello랑, 텍스트가 실행 파일이 되는 과정

#include <stdio.h>
int main(void) {
    printf("Hello, World!\n");
    return 0;
}

#include는 “여기 printf 있어” 알려주는 역할이고, main이 시작점, return 0은 “성공으로 끝냄” 정도.

.c가 실행 파일이 되는 건, 대략 전처리(헤더 뻥튀기) → 컴파일(기계어 비슷한 걸로) → 어셈블(목적 파일) → 링크(다른 .o·라이브러리랑 합침) 네 군데를 거친다고 보면 돼. 표로 안 쓰고 말하자면, 전처리는 #include·#define 처리, 컴파일은 문법·최적화, 링크는 printf 본문은 어딘가 libc에 있으므로 거기랑 이어 붙이기 쪽. 한 파일만으로 완벽한 프로그램이 아니며, 번역 단위(전처리 끝난 .c 하나) 마다 따로 컴파일된 다음 마지막에 합쳐지는 느낌.

네 단계는 옵션으로 하나씩 멈춰서 직접 볼 수 있어. 한 번 해 보면 “컴파일”이라는 말이 뭘 뭉뚱그리고 있었는지 확 와닿아.

gcc -E hello.c -o hello.i   # 전처리만: #include가 펼쳐진 결과 (수백~수천 줄)
gcc -S hello.i -o hello.s   # 컴파일: 어셈블리 코드
gcc -c hello.s -o hello.o   # 어셈블: 기계어가 든 목적 파일 (아직 실행 불가)
gcc hello.o -o hello        # 링크: libc의 printf와 연결해 실행 파일 생성

hello.i를 열어 보면 stdio.h가 통째로 복사돼 들어와 있어서, 다섯 줄짜리 프로그램이 엄청 길어진 걸 볼 수 있어. 이게 C의 #include가 “모듈 가져오기”가 아니라 그냥 텍스트 복사-붙여넣기라는 증거고, 헤더를 잘못 설계하면 컴파일이 느려지거나 같은 정의가 여러 번 들어가는 문제가 생기는 이유야. hello.o에는 printf를 부르는 코드가 있지만 printf의 본문은 없어. nm hello.o를 쳐 보면 U printf(undefined)라고 나오는데, “이건 나중에 링커가 채워 줘”라는 표시야. 링크할 때 이 빈칸을 채울 정의를 못 찾으면 그 유명한 undefined reference to 'foo' 에러가 나. 컴파일은 통과했는데 링크에서 터지는 에러는 대부분 이 단계 문제라고 보면 돼.

빌드 예시는 이 정도.

gcc -Wall -Wextra -std=c11 -O0 -g -o hello hello.c

-Wall은 경고 좀 보자, -g는 나중에 디버거 잡을 때, -O0은 우선 느슨하게(디버깅 편하게). gdb에선 b main 걸고 r, n 정도만 써 봐도 “아 소스 라인에 멈추네”가 체감돼.

-Wall이 이름과 달리 “모든 경고”가 아니라는 것도 알아 둬. 흔한 실수 모음 정도라서 -Wextra를 같이 켜야 부호 있는/없는 정수 비교 같은 것까지 잡혀. -std=c11을 명시하는 이유는 컴파일러마다 기본 표준이 다르고, 기본값에는 GNU 확장이 섞여 있어서 “내 컴퓨터에선 되는데 다른 컴파일러에선 안 되는” 코드가 생기기 쉽기 때문이야. 그리고 -O0으로 멀쩡하던 프로그램이 -O2로 빌드하면 이상해지는 경우가 있는데, 이건 컴파일러 버그인 경우는 거의 없고 대부분 코드 안에 숨어 있던 UB가 최적화 때문에 드러난 거야. 그래서 테스트는 최적화 켠 빌드로도 꼭 한 번 돌려 보는 게 좋아.

문법, 진짜로 뼈대만

  • 변수: int n = 0; 처럼 초기화해 두는 습관이 좋다. const는 읽기 전용.
  • printf는 "%d"엔 int를, "%f" 쪽은 double 기대같이 서식이랑 타입 맞춰야 함. scanf("%d", &n) — 정수 담을 땐 주소 & 잊지 말기. 배열/포인터 예외는 다음 편.
  • if, for, while은 다른 언어랑 비슷한데, C는 조건이 0이면 거짓, 0 아니면 참. switch는 case 끝에 break 빼먹으면 아래로 주르륵 떨어질 수 있으니 그것도 함정.
  • 함수는 “위에 선언(prototype) 보이게” 해 두는 게 덜 아픔. 파일 안에서만 쓰는 애는 static 붙이는 것도 있음.
  • static 지역은 함수 들어갈 때마다 새로 생기는 게 아니라 한 번만 살고 끝까지 감. 전역은 최소로.

전처리기 #include / #define / #if도 쓰긴 쓰는데, 여기서 길게 늘어놓으면 백과사전 되니까 시리즈 다음 글에서 이어가면 돼. 대신 제목에 적어 둔 세 가지, 객체 표현·정렬·번역 단위는 여기서 바닥만 깔고 갈게.

객체 표현: 모든 값은 결국 바이트 줄

C 표준에서 “객체”는 클래스 인스턴스가 아니라 값을 담는 메모리 영역을 말해. int n도 객체고, 배열 원소 하나도 객체야. 그리고 모든 객체는 sizeof만큼의 unsigned char 바이트가 이어진 것으로 볼 수 있어(이걸 객체 표현이라고 불러). 직접 들여다보면 이래.

#include <stdio.h>

int main(void)
{
    unsigned int x = 0x01020304;
    const unsigned char *p = (const unsigned char *)&x;

    printf("sizeof(x) = %zu\n", sizeof x);
    for (size_t i = 0; i < sizeof x; i++)
        printf("%02x ", p[i]);
    printf("\n");   /* x86·ARM(리틀 엔디언): 04 03 02 01 */
    return 0;
}

같은 0x01020304인데 메모리에는 거꾸로 04 03 02 01이 들어가 있어. 가장 낮은 자리 바이트를 먼저 저장하는 리틀 엔디언 방식이라 그래. 한 컴퓨터 안에서는 아무 상관 없지만, 이 바이트를 파일에 그대로 쓰거나 네트워크로 보내서 다른 엔디언의 기계가 읽으면 값이 완전히 달라져. 그래서 네트워크 프로토콜은 바이트 순서를 정해 두고 htonl 같은 함수로 변환하는 거야. 여기서 unsigned char *로 바이트를 읽은 건 규칙상 허용된 방법이야. 반대로 float 변수의 주소를 int *로 캐스팅해서 읽는 건 타입 규칙(strict aliasing) 위반이라 UB고, 최적화 빌드에서 진짜로 엉뚱한 값이 나올 수 있어. 비트를 재해석하고 싶으면 memcpy로 복사하는 게 정석이야.

sizeof(int)가 4라는 것도 표준이 정한 게 아니야. 표준은 “int는 최소 16비트” 정도만 보장하고 실제 크기는 구현(컴파일러+플랫폼)이 정해. 요즘 흔한 64비트 환경에서 int는 4바이트지만 long은 Linux에서 8바이트, Windows에서 4바이트라서, long에 파일 크기나 포인터 값을 담는 코드가 Windows로 옮기면 깨지는 일이 실제로 흔해. 크기가 중요하면 <stdint.h>의 int32_t, uint64_t 같은 고정 폭 타입을 쓰면 돼. 이런 “구현이 정하고 문서화하는” 규칙이 구현 정의 동작이고, 아래 FAQ의 미정의 동작과는 급이 달라.

정렬과 패딩: 구조체가 생각보다 큰 이유

CPU는 보통 4바이트 int를 4의 배수 주소에서 읽을 때 가장 빠르고, 어떤 아키텍처는 배수가 아닌 주소에서 읽으면 아예 예외를 일으켜. 그래서 타입마다 정렬 요구(alignment) 가 있고, 컴파일러는 구조체 멤버 사이에 빈 바이트(패딩)를 끼워서 각 멤버가 자기 정렬에 맞는 위치에 오게 만들어.

#include <stdio.h>
#include <stddef.h>

struct A { char c; int i; char d; };   /* 보통 12바이트 */
struct B { int i; char c; char d; };   /* 보통 8바이트 */

int main(void)
{
    printf("A: %zu, B: %zu\n", sizeof(struct A), sizeof(struct B));
    printf("A.i offset: %zu\n", offsetof(struct A, i));  /* 보통 4 */
    printf("int align: %zu\n", _Alignof(int));           /* 보통 4 */
    return 0;
}

struct A는 c(1바이트) 뒤에 i를 4의 배수 위치에 두려고 3바이트를 비우고, 끝의 d 뒤에도 3바이트를 채워. 끝에도 패딩이 붙는 이유는 struct A 배열을 만들었을 때 다음 원소의 i도 정렬이 맞아야 하기 때문이야. 멤버 순서만 바꾼 struct B는 8바이트면 끝나. 구조체 하나에선 4바이트 차이지만 원소가 백만 개인 배열이면 4MB 차이고, 캐시에 들어가는 원소 수도 달라져. 그래서 큰 배열로 쓰는 구조체는 큰 멤버부터 선언하는 습관이 좋아.

패딩 때문에 생기는 함정도 있어. 구조체 두 개를 memcmp로 비교하면, 멤버 값은 같아도 패딩 바이트에 남은 쓰레기 값이 달라서 “다르다”고 나올 수 있어. 구조체를 통째로 파일에 fwrite하면 패딩과 엔디언까지 같이 저장되니까, 다른 컴파일러나 다른 플랫폼에서 읽으면 레이아웃이 안 맞을 수 있고. 멤버를 하나씩 직렬화하는 게 번거로워도 안전해. #pragma pack(1)으로 패딩을 없애는 방법도 있긴 한데, 정렬 안 된 멤버에 포인터로 접근하면 일부 아키텍처에서 크래시가 나거나 느려지니까 네트워크 패킷 헤더처럼 꼭 필요한 곳에만 써.

번역 단위: .c 파일 하나는 혼자선 반쪽

앞에서 “번역 단위 = 전처리 끝난 .c 하나”라고 했지? 컴파일러는 번역 단위 하나만 보고 목적 파일을 만들어. 다른 .c 파일에 뭐가 있는지는 전혀 몰라. 그래서 다른 파일의 함수를 부르려면 “이런 함수가 어딘가 있다”는 선언이 있어야 하고, 그게 헤더 파일의 역할이야. 정리하면 이래.

  • 선언: int add(int a, int b);, extern int counter; — 이름과 타입만 알려 줌. 여러 번 나와도 됨.
  • 정의: 함수 본문, int counter = 0; — 실제 메모리나 코드를 만듦. 프로그램 전체에서 딱 한 번.
  • static을 붙인 전역 함수·변수는 그 번역 단위 안에서만 보이는 이름(내부 링크)이 돼서, 다른 파일의 같은 이름과 충돌하지 않아.

이 규칙을 어기면 링커가 화를 내. 헤더에 int counter = 0;처럼 정의를 넣고 그 헤더를 .c 두 개가 include하면, 두 목적 파일에 같은 이름의 정의가 생겨서 multiple definition of 'counter' 에러가 나. 재밌는 건 초기값 없이 int counter;만 쓴 경우인데, 옛날 GCC는 이걸 “common 심볼”로 취급해서 여러 개를 알아서 하나로 합쳐 줬어. 그러다 GCC 10부터 기본값이 -fno-common으로 바뀌면서 멀쩡히 빌드되던 오래된 프로젝트들이 갑자기 링크 에러를 내기 시작했지. 올바른 방법은 헤더에는 extern int counter;(선언)만 두고, .c 파일 하나에만 int counter = 0;(정의)을 두는 거야.

파일 안에서만 쓰는 도우미 함수에 static을 붙이는 습관도 여기서 나와. 붙이지 않으면 모든 함수가 프로그램 전체에서 보이는 이름이 되니까, 서로 다른 파일에 helper라는 함수가 각각 있으면 링크할 때 충돌하거든. 그리고 static 함수는 다른 파일에서 부를 수 없다는 걸 컴파일러가 아니까 인라인이나 제거 같은 최적화도 더 과감하게 할 수 있어.

UB는 “죽는다”가 아니라 “뭐든 된다”

마지막으로 FAQ에 나오는 미정의 동작(UB) 이야기를 조금만. UB는 표준이 “이런 코드를 쓰면 결과를 책임 안 진다”고 선언한 경우야. 배열 범위 밖 접근, 초기화 안 된 변수 읽기, 부호 있는 정수 오버플로, NULL 역참조 같은 게 대표적이지. 핵심은 UB가 반드시 크래시를 뜻하지 않는다는 거야. 컴파일러는 “UB는 절대 일어나지 않는다”고 가정하고 최적화할 수 있어서, 예를 들어 if (x + 1 < x)로 오버플로를 검사하는 코드는 “부호 있는 덧셈은 넘치지 않으니 이 조건은 항상 거짓”이라며 검사 자체를 지워 버릴 수 있어. 디버그 빌드에선 되던 검사가 릴리스 빌드에서 사라지는 이유가 이거야.

그래서 C를 쓸 때는 “돌려 보니 되네”를 믿으면 안 되고, 도구의 도움을 받아야 해. 처음부터 습관으로 들이면 좋은 조합은 gcc -Wall -Wextra -g -fsanitize=address,undefined야. UndefinedBehaviorSanitizer가 정수 오버플로나 잘못된 시프트를 실행 중에 잡아서 runtime error: signed integer overflow 같은 메시지로 알려 주거든. 이 시리즈의 다음 편들에서 타입 승격, 포인터, 링커를 다룰 때도 계속 이 바닥 위에서 이야기할 거야. 더 깊은 “바닥” 이야기는 #02랑 종합 가이드 쪽에 이어져 있어.

이어 읽기: C 언어 시리즈 #02 — 타입·승격·정수 표현 · 목차

같이 보면 좋은 글