C 언어 시리즈 #10 — 컴파일 파이프라인·링커 심볼·정적·동적 링크

이 글의 핵심

컴파일은 되는데 링크 단계에서만 터지는 오류는 목적 파일에 무엇이 들어가고 링커가 외부 심볼을 어떻게 채우는지 알면 대부분 풀립니다. nm·readelf로 심볼 테이블을 읽는 법, -fPIC와 PLT/GOT가 런타임에 미치는 영향, 크로스 컴파일과 디버그·릴리스 빌드 차이, Make와 CMake 선택 기준까지 다룹니다.

시리즈 안내

#10 (막회) | 전체 목차 | 이전: #09 메모리

이번 글은 흔히 부딪치는 링크·빌드 이슈를 먼저 짚으며, 그다음 C 소스가 전처리 → 컴파일 → 어셈블 → 링크를 거쳐 실행 파일이나 공유 객체가 되는 과정, 정·동적 라이브러리, 플래그, 빌드 시스템, 크로스 컴파일로 이어집니다. 심볼·재배치·nm/readelf는 파이프라인과 엮어서 보면 이해가 빨라집니다.


먼저 짚어 보기: 링크·빌드에서 자주 터지는 것

undefined reference가 뜨면 정의를 안 넣은 경우도 있지만, 링크할 때 -l·.o 순서가 꼬인 경우가 더 많습니다. nm -u로 미해결 심볼이 남는지, 정의가 .c 한곳에 있는지 확인합니다. 중복 정의(multiple definition)는 전역·비static을 헤더에 잘못 넣어 둔 경우, 또는 같은 이름이 여러 .o에 뜨는 ODR/링크 규칙 위반이 흔한 원인입니다.

cannot find -lfoo라면 -L이 빠졌거나, 크로스 빌드에서 다른 툴체인 접두의 lib를 보고 있을 수 있습니다. file in wrong format이면 호스트로 만든 .o와 타깃용 .o가 한 링크 라인에 섞인 것입니다. file이나 readelf -h로 아키텍처·ELF 타입을 확인합니다. recompile with -fPIC는 공유 객체에 비-PIC 목적 파일이 끼어든 신호이므로, DSO·-shared 쪽은 -fPIC를 파이프라인 전체에 맞춥니다.

런타임에 error while loading shared libraries가 나오면 rpath, LD_LIBRARY_PATH, 배포 경로, 배포 서버와 빌드 머신의 DSO 뷰가 어긋난 경우를 의심합니다. ldd는 그 환경에서의 로더 시각이므로, CI와 프로덕션 양쪽에서 찍어 보는 것이 안전합니다. Windows와 Unix를 한 레포에 묶은 팀이라면 import .lib·__declspec·호출 규약 차이로 “맥/리눅스에선 됐는데” 같은 문제가 생깁니다. 툴체인 문서를 플랫폼마다 따로 정리하는 수밖에 없습니다.

version 'GLIBC_2.xx' not found처럼 타깃에서만 터진다면, 링크한 쪽 glibc가 더 새로운 경우가 많습니다. 구형 런타임에 맞추려면 구형 툴체인·컨테이너·정적 링크 정책(가능·허용 범위는 제품·보안팀과 협의)을 논의합니다. collect2: ld returned 1은 그 위쪽 몇 줄에 진짜 이유가 있으니 스크롤을 아끼지 마세요. warning: _FORTIFY_SOURCE requires optimization 류는 -O0에 _FORTIFY를 얹은 조합이므로 의도와 맞는지 CFLAGS를 다시 확인합니다. CI에서만 캐시된 .o가 이상하다면 헤더 변경이 의존 그래프에 잡히지 않은 경우이므로 clean·ninja -t clean·ccache 무효화 런북을 씁니다. $ORIGIN rpath를 Makefile에 넣을 때 셸이 $를 먹는 문제도 막판에 한 번씩 터집니다. 따옴표와 $$를 습관화하세요.


40분 빌드를 5분으로 줄인 썰 (핑계 없이 요약)

예전 팀 이야기인데, CI 클린 빌드가 40분 걸리던 때가 있었습니다. 원인은 한 가지가 아니었습니다. (1) 전처리·헤더 덩어리가 비대해 컴파일 단위마다 시간이 늘어났고, (2) Makefile이 의존 .d 없이 “대충” 돌다 보니 깨끗이 지우지 않으면 꼬이고 지우면 전부 다시 빌드했으며, (3) 링크 -l 순서를 사람이 손으로 적다가 케이스마다 undefined에 시간을 태웠고, (4) 병렬이 잘 먹히지 않는 규칙이 있었고, (5) ccache도 없이 매번 풀 컴파일을 했습니다. “한 번에” 고친 것은 아니고, 의존성을 -MMD -MP로 잡고, 생성기를 cmake -G Ninja 쪽으로 옮기고, 캐시·클린 정책을 정한 뒤에야 체감 시간이 5분대로 내려갔습니다. 숫자는 레포·머신마다 다르니 “우리 케이스”로만 봐 주세요. 중요한 것은 누가 40분을 견디느냐가 아니라, 병목이 전처리인지 링크인지 캐시인지를 나눠서 재는 것입니다.


CMake는 배우세요. Make는 레거시로 두세요

여기서는 의견을 분명히 적겠습니다. 새 프로젝트에서 “그냥 Makefile만”으로 끌고 가는 것은, 팀이 아주 작고 수명이 짧은 경우를 제외하면 기술 부채가 됩니다. CMake(또는 Meson처럼 메타 빌드가 IDE·Ninja·다중 OS에 걸쳐 일관되게 compile_commands를 생성하는 도구)는 한 번 배워 두는 편이 쌉니다. Make는 “누군가 이미 10년 쌓아 온 레거시”로 보는 것이 맞습니다. 최소 래퍼나 third_party가 Make만 쓴다면 그때만 맞춰 주면 됩니다. “재현 가능한 configure + build + test”를 한 커맨드로 고정하려면 손으로 짠 Makefile보다 생성기 + Ninja 조합이 팀 단위에서 낫다는 것이 제 결론입니다. (동의하지 않는다면 댓글로 의견을 주세요. 데이터를 가져오시면 더 좋습니다.)

내부 글: 빌드 시스템 비교 — Ninja·CMake 조합이 왜 대형 레포에서 사실상 기본인지는 그 글에서 이어집니다.


빌드 프로세스 개요

전형적인 GNU 툴체인(gcc 드라이버)이 수행하는 큰 흐름은 다음과 같습니다.

  1. 전처리(Preprocess) — 매크로 치환, 조건부 컴파일, #include로 번역 단위(translation unit)의 전처리된 C 소스를 만듭니다.
  2. 컴파일(Compile) — 구문·의미 분석, 중간 표현, 최적화(옵션에 따라)를 거쳐 어셈블리로 내보내거나 내부적으로 어셈블러로 넘깁니다.
  3. 어셈블(Assemble) — 어셈블리를 머신어로 바꾸고 재배치 정보·심볼 테이블이 붙은 목적 파일(.o, COFF/ELF 등)을 생성합니다.
  4. 링크(Link) — 여러 .o와 아카이브(.a) 또는 공유 객체(.so / Windows의 .lib+.dll)를 묶어 실행 파일 또는 공유 라이브러리(동적 링크용)를 만듭니다.

gcc는 위 단계를 한 번의 명령으로 호출하며, -E, -S, -c 등의 플래그로 중간 산출물만 뽑을 수 있게 합니다. 내부에서 실제로는 cc1, as, collect2(사실상 링커 ld 호출) 등이 엮입니다. 전체를 보고 싶다면 gcc -v로 실제 전달 인자를 확인하십시오(여기가 틀리면 “머신 A에선 됐는데”가 시작됩니다). -E만 뽑아 본 적이 있으신가요? 없다면 한 번 써 보세요. 헤더가 얼마나 늘어나는지 감이 바로 잡힙니다. 크로스 빌드나 복잡한 -Wl,이 꼬일 때, -v 로그는 “무엇이 실제로 실행되었는가”의 1차 증거입니다.

flowchart LR
  subgraph T["번역 단위(소스)"]
    A[.c + 헤더]
  end
  A -->|cpp / 전처리| B[전처리된 C]
  B -->|cc1 / 컴파일| C[어셈블리]
  C -->|as / 어셈블| D[.o]
  D -->|ld / 링크| E[실행 파일 또는 DSO]

프로젝트 관점에서 기억할 점은, “컴파일 단위”는 보통 .c 파일마다라는 점입니다. 따라서 한 .c는 다른 .c의 static 지역/파일 범위 심볼을 볼 수 없으며, 헤더의 선언과 정확히 한 곳의 정의로만 연결됩니다(일반적 ODR/링크 규칙). “컴파일은 됐는데 링크에서만 undefined reference”가 뜨는 이유는, 컴파일러는 “이 .c 안에서” 타입·선언이 맞으면 통과시키는데, 정의가 다른 번역 단위에 있으면 링커가 퍼즐을 맞출 때 비로소 드러나기 때문입니다.


전처리 단계

전처리기는 C 문법이 아니라 지시문을 처리합니다. #include는 텍스트 삽입, #define은 토큰 치환, #if/#ifdef는 조건에 따라 코드 블록을 제거합니다. 매크로는 타입 검사를 받지 않고, 인자가 여러 번 평가되거나 우선순위 실수(MACRO(a+b))가 끼어들기 쉬워서 디버깅을 어렵게 만드는 대표 원인입니다. 경고 옵션을 켜는 것만으로는 잡히지 않으므로, 복잡한 매크로 자체를 줄이는 편이 낫습니다.

-E 플래그는 전처리 결과만 stdout으로 냅니다. 헤더가 실제로 무엇을 끌어오는지, 매크로가 최종적으로 어떻게 펼쳐지는지 확인할 때 유용합니다.

# main.c에 대해 전처리 결과만 화면에 출력
gcc -E -o - main.c

# include 경로는 -I, 시스템 기본은 보통 /usr/include 등
gcc -E -I./include -o main.i main.c

전처리 산출물(.i)을 파일로 남기면, 빌드 캐시 없이 “지금 이 플래그/매크로 조합”에서 어떤 소스가 컴파일러에 넘어가는지 재현할 수 있습니다. CI에서만 터지는 문제는 환경별 -D 정의나 경로 차이가 흔한 원인이므로, gcc -E로 동일한 .i를 비교하는 기법이 자주 사용됩니다.

_POSIX_C_SOURCE나 _GNU_SOURCE 같은 기능 테스트 매크로는 모든 .c에서 일관되게 켜는 편이 안전합니다. 이 매크로들은 시스템 헤더를 하나라도 포함하기 전에 정의되어 있어야 효과가 있으므로, 소스 중간에 #define하기보다 컴파일 옵션 -D로 주입하거나 모든 .c가 맨 먼저 포함하는 공통 config.h에서 처리합니다.


컴파일 단계: C → 어셈블리

좁은 의미의 “컴파일”은 전처리된 C를 AST와 IR로 읽어 의미 분석을 마친 뒤, 어셈블리(또는 곧바로 머신코드)로 내보내는 단계입니다. GCC 계열에선 cc1이 담당하고, Clang은 clang -cc1이 비슷한 역할을 합니다. 최적화(-O0~-O3, -Os 등)는 이 단계에서 IR을 대상으로 일어나며, -g를 주면 이 단계에서 DWARF 디버그 정보도 함께 생성됩니다.

-S 플래그는 어셈블리(.s)만 냅니다. 성능이나 ABI 이슈(호출 규약, 정렬)를 어셈블리 수준에서 확인할 때 사용합니다.

gcc -S -O2 -o main.s main.c

어셈블리를 읽을 때 함수 엔트리/스택 할당이 플랫폼·ABI(예: x86-64 System V)에 따라 어떻게 생성되는지 보면, “스택이 왜 이 크기인가”, “alloca 뒤 스택 정렬” 같은 의문이 풀리는 경우가 많습니다. SIMD/벡터화(-O3 -march=…)는 여기서 루프가 벡터 명령으로 바뀌는지도 확인할 수 있습니다.

-fomit-frame-pointer는 프레임 포인터를 쓰지 않게 해서 프로파일러나 스택 트레이스의 품질을 떨어뜨릴 수 있습니다. x86-64 GCC에서는 -O1 이상이면 이미 기본으로 켜지므로, perf 같은 도구로 호출 스택을 제대로 보고 싶다면 오히려 -fno-omit-frame-pointer를 명시하는 경우가 많습니다.


어셈블 단계: 어셈블리 → 목적 파일(.o)

어셈블러(as)는 .s를 머신코드와 메타데이터로 바꿉니다. 목적 파일 포맷은 보통 ELF(리눅스 등) 또는 Mach-O, PE/COFF(Windows)입니다. 여기에 섹션(.text, .data, .rodata 등), 심볼, 재배치 엔트리가 들어갑니다.

-c 플래그는 “컴파일하고 어셈블하되 링크는 하지 않음”이므로, .c → .o에 가장 흔히 쓰는 옵션입니다.

gcc -c -O2 -g -o main.o main.c

.o 한 개는 “이 번역 단위”의 정의/미해결 외부 심볼을 담습니다. extern으로만 선언한 함수는 참조로 남으며, 정의가 있는 함수·전역은 정의로 잡힙니다. static 파일 범위 심볼은 다른 .o에 보이지 않는 내부 링크가 됩니다(링커 충돌을 줄이는 수단). FAQ의 질문도 이 맥락과 연결됩니다.

관측 도구:

  • nm — 심볼 목록(정의/미해결, 대소문자 등 옵션).
  • readelf -s / objdump -t — ELF 심볼 테이블 상세.
  • objdump -d — 디스어셈블(함수 본문 확인).
nm -C main.o
readelf -s main.o
objdump -d -M intel main.o

링킹 단계: 오브젝트 → 실행 파일

링커(ld, GCC에선 collect2 래퍼)는 여러 .o의 섹션을 병합하고, 심볼 참조를 정의된 주소로 고쳐 씁니다(재배치). 동적 링크를 쓰면 공유 라이브러리에 있는 함수는 실행 파일 안에서 미정의 심볼로 남고, 대신 PLT/GOT 항목과 동적 재배치가 만들어져 런타임에 로더가 실제 주소를 채웁니다.

미해결(unresolved): 링크 종료 시에도 정의를 찾지 못한 심볼이 있으면 실패합니다. undefined reference to 'foo'.

중복 정의: 같은 이름의 강한(strong) 심볼이 여러 .o에 있으면 링크가 실패합니다. C에서는 비-static 전역 객체나 함수를 헤더에 정의해 여러 .c가 포함하는 실수로 자주 터집니다. 특히 GCC 10부터 -fno-common이 기본이 되어, 예전에는 조용히 합쳐지던 int counter; 같은 초기화 없는 전역 정의도 이제 중복 정의 오류가 납니다. inline 함수와 약한(weak) 심볼은 툴체인·ABI 규칙이 따로 있습니다.

프로덕션 규칙(요지):

  • 헤더에는 extern 선언과 static inline처럼 여러 번 포함돼도 안전한 것만 둡니다.
  • 정의는 .c 한 곳에만 둡니다.
  • static으로 파일 범위를 캡슐화해 이름 충돌과 불필요한 노출을 줄입니다.

-save-temps=obj 등으로 중간 .i, .s를 남기면 어느 단계에서 이상이 생겼는지 추적하기 쉽습니다.

보안·운영: RELRO, BIND_NOW, 스택 카나리 등은 컴파일·링크·로더가 함께 만드는 결과입니다. 리눅스라면 readelf -l로 GNU_RELRO 세그먼트를, readelf -d로 BIND_NOW 플래그를 확인하고, checksec 같은 스크립트로 팀 표준을 점검합니다. 같은 소스라도 링크 플래그에 따라 공격 표면이 달라진다는 점을 기억하세요.


정적 라이브러리: .a 생성 및 사용

정적 아카이브(.a)는 여러 .o를 ar로 묶은 것입니다. 링커는 아카이브 전체를 넣지 않고, 그 시점까지 미해결인 심볼을 정의하는 멤버 .o만 끌어옵니다. 이 동작 때문에 링크 순서 문제가 생기는데, 아래 링커 플래그 절에서 다시 다룹니다.

.a 만들기(예: Linux):

gcc -c -O2 add.c
gcc -c -O2 sub.c
ar rcs libmath.a add.o sub.o

정적 링크로 실행 파일을 만들면 libmath.a에서 끌어온 머신코드가 실행 파일에 복사됩니다. 따라서 배포는 실행 파일만이면 되지만, 같은 코드가 여러 바이너리에 복제될 수 있습니다. 임베디드나 단일 바이너리가 중요한 환경에서 선호됩니다.

Windows 메모: MSVC 계열에선 정적 라이브러리도 *.lib이고 DLL의 import library도 *.lib라서 혼동하기 쉽습니다. 문서에 적을 때는 어느 쪽인지 구분해 두세요.


동적 라이브러리: .so / DLL 생성 및 사용

공유 객체(리눅스 .so 등)는 프로세스마다 다른 주소에 매핑될 수 있으므로, 위치 독립 코드(PIC)로 빌드해야 합니다. x86-64에서는 -fPIC 없이 만든 목적 파일로 -shared 링크를 하면 대개 오류가 납니다.

공유 객체 빌드(개념):

gcc -fPIC -c module.c
gcc -shared -o libmodule.so module.o

동적 링크에서는 런타임 로더(ld-linux.so 등)가 DT_NEEDED에 적힌 DSO를 찾아 mmap한 뒤, GOT/PLT를 통해 호출을 해석합니다. 기본은 처음 호출될 때 주소를 찾는 지연(lazy) 바인딩이고, -z now나 LD_BIND_NOW를 쓰면 로드 시점에 한꺼번에 바인딩합니다. -fPIC 코드는 전역 접근이 GOT를 거치는 간접 참조가 되어 성능과 코드 크기에서 약간 손해를 보지만, DSO에서는 사실상 필수입니다.

Windows: __declspec(dllexport/dllimport), .dll과 import .lib로 링크하는 모델, MinGW와 MSVC의 차이가 따로 있습니다. 이 글은 Unix 중심이므로 Windows는 같은 개념을 다른 도구와 키워드로 표현한다고 이해하면 됩니다.

LD_PRELOAD와 심볼 인터포지션: 같은 이름의 동적 심볼이 여러 DSO에 있으면, 어느 쪽 구현이 쓰일지는 로드 순서와 검색 규칙이 정합니다. 디버깅이나 계측에 유용한 성질이지만 보안과 재현성에는 위험 요소라서, rpath와 라이브러리 버전을 명시적으로 관리하는 것이 실무의 답이 됩니다.

진단:

readelf -d libmodule.so
nm -D libmodule.so
ldd /path/to/your_binary

ldd는 명령을 실행한 머신의 로더가 보는 결과를 보여 주므로, 대상 머신과 환경이 다르면 오해할 수 있습니다. 실제 런타임 머신이나 같은 이미지에서 확인하세요.


컴파일러 플래그: -O, -g, -Wall, -Werror 등

-O0은 최적화를 거의 하지 않습니다(디버깅할 때 스택·변수가 익숙한 모양으로 남는 경우가 많지만, 이는 툴체인 구현에 달려 있습니다). -O2는 릴리스에서 가장 흔한 기본값에 가까우며, 루프·인라인 최적화를 무난하게 적용합니다. -O3는 더 공격적으로 최적화하므로 바이너리가 커지고 디버깅이 까다로워질 수 있습니다. -Os는 크기 우선이라 임베디드나 캐시 압박이 있을 때 쓰는 경우가 있습니다.

-g는 DWARF 같은 디버그 정보를 심습니다. strip으로 걷어 내거나, -g3로 매크로 정보까지 늘릴 수 있습니다(팀·도구에 따라). -Wall / -Wextra는 경고를 늘려 주고, -Werror는 경고를 곧장 오류로 바꿉니다. 도입하려면 툴체인·헤더·CI를 함께 고정해야 해서, -Werror=implicit처럼 일부만 켜는 팀도 많습니다. 전면 -Werror + 예외 뭉치보다는 점진적 도입이 실무에서 흔합니다. -std=c11 / gnu11은 표준 선택이며, gnu*는 GCC 확장을 열어 줍니다. -D로 매크로를 넣는 것은 CFLAGS 재현에 직접 쓰입니다. -fstack-protector* 계열은 스택 기반 공격에 대한 완화책이지만 성능과 코드가 달라집니다. -D_FORTIFY_SOURCE=2는 (환경·glibc·최적화 조합에 따라) libc 래핑 쪽 추가 검사로 이어질 수 있어, “-O0인데 FORTIFY만 켰다” 같은 경우 경고/동작이 기대와 다를 수 있습니다.

-Werror는 양날의 검입니다. 어떤 경고가 나오는지는 컴파일러 버전, 플랫폼, 시스템 헤더에 따라 달라지므로, 툴체인과 CFLAGS를 고정한 CI에서만 강제하는 팀이 많습니다. 컴파일러를 올리면 새 경고가 쏟아져 빌드가 막힐 수 있으니, 그때를 대비해 -Wno-… 예외를 두는 정책도 필요해질 수 있습니다.

릴리스에서도 -O2 -g를 함께 쓰는 경우가 흔합니다. 크래시가 나면 스택을 해석할 수 있어야 하기 때문입니다. 배포본은 strip으로 가볍게 하되, 먼저 objcopy --only-keep-debug로 디버그 정보를 별도 파일로 떼어 두었다가 크래시 덤프 분석에 쓰는 방식입니다.

export CFLAGS='-O2 -g -Wall -Wextra -Wpedantic -std=c11'

헤더 파일과 include path, -I

#include "foo.h"와 #include <foo.h>는 헤더를 찾는 순서가 다릅니다. 따옴표 형식은 현재 파일이 있는 디렉터리부터 찾고, 그다음 -I 경로와 시스템 경로로 넘어갑니다. GCC에서 -I로 준 경로는 시스템 기본 경로보다 먼저 검색되므로, 프로젝트 안에 stdio.h 같은 이름의 파일이 있으면 시스템 헤더 대신 그것이 포함되는 사고가 납니다.

gcc -I./include -I../thirdparty/include -c src/main.c

Makefile에서 CPPFLAGS(전처리 옵션: -I, -D)와 CFLAGS/CXXFLAGS(컴파일 옵션)를 나누는 관습은, 같은 전처리 옵션을 C 컴파일, C++ 컴파일, 의존성 생성(-M 계열)에 그대로 재사용하기 위해서입니다.

#include 경로 오염(보안): 신뢰할 수 없는 디렉터리를 -I 앞쪽에 넣는 빌드 스크립트는 악의적인 헤더가 끼어들 여지를 줍니다. CI나 서드파티 래퍼 스크립트에서는 include 경로를 절대 경로로 고정해 두는 편이 안전합니다.

링커 플래그: -L, -l, -Wl,

  • -lfoo는 이름 관례에 따라 libfoo.so 또는 libfoo.a를 찾습니다. 둘 다 있으면 GNU ld는 기본적으로 .so를 고릅니다.
  • -Lpath는 라이브러리 검색 경로를 추가합니다. GNU ld에서 -L은 위치와 상관없이 모든 -l에 적용되므로, 순서가 중요한 것은 -L이 아니라 -l과 .o의 상대 위치입니다.
gcc -o app main.o -L./build -lmylib -lm

링크 순서(중요): GNU ld는 명령줄을 왼쪽에서 오른쪽으로 한 번 훑으면서, 아카이브를 만나는 시점에 미해결 상태인 심볼만 채웁니다. 그래서 main.o가 libmylib.a의 심볼을 쓰려면 -lmylib이 main.o보다 뒤에 와야 합니다. 라이브러리를 분명히 링크했는데도 undefined reference가 난다면, --whole-archive 같은 옵션을 찾기 전에 순서부터 의심하세요.

-Wl,옵션: gcc가 링커에 그대로 넘기는 옵션이며, 쉼표가 인자 구분자로 쓰인다는 점에 주의해야 합니다. 예를 들어 -Wl,-rpath,'$ORIGIN/lib'는 실행 파일 기준 상대 경로를 rpath로 넣습니다. -Wl,-Map,link.map으로 맵 파일을 뽑으면 어떤 심볼이 어느 섹션에 배치됐는지 볼 수 있어, 크기 최적화나 이상한 링크 결과를 추적할 때 유용합니다. libc까지 포함하는 완전 정적 링크는 -static이지만, glibc는 정적 링크 시 getaddrinfo 같은 NSS 기능에 제약이 있고 라이선스 검토도 필요하므로 가능 여부를 먼저 확인해야 합니다. 일부 라이브러리만 정적으로 넣으려면 -Wl,-Bstatic과 -Wl,-Bdynamic을 번갈아 쓰는데, 뒤에 -Bdynamic으로 돌려놓지 않으면 시스템 라이브러리까지 정적으로 찾으려다 실패하니 한 옵션 세트로 정해 두는 편이 낫습니다.

readelf -d로 RPATH/RUNPATH가 기대한 값인지, 배포 환경의 정책(상대 경로 허용 여부 등)에 맞는지 확인하세요.


Makefile 작성: 기본과 패턴 규칙

최소 예:

CC = gcc
CFLAGS = -O2 -g -Wall -Wextra -std=c11
CPPFLAGS = -Iinclude
LDFLAGS = -Llib
LDLIBS = -lmy

OBJS = main.o module.o
TARGET = myapp

all: $(TARGET)

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

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

$@, $<, $^는 각각 타깃, 첫 번째 전제 조건, 전체 전제 조건 목록을 가리키는 자동 변수입니다. 파일 이름을 직접 반복해 적지 않아도 되므로 오타와 순서 실수를 줄여 줍니다.

clean과 all은 실제 파일이 아닌 관례적인 타깃이므로 .PHONY로 명시합니다. 그렇지 않으면 같은 이름의 파일이 생겼을 때 규칙이 실행되지 않습니다.

.PHONY: all clean
clean:
	rm -f $(TARGET) $(OBJS)

:= vs =: =로 정의한 변수는 참조될 때마다 다시 확장되므로, 안에 $(shell …)이 있으면 매번 실행되고 자기 자신을 참조하면 무한 재귀가 됩니다. CFLAGS처럼 정의 시점에 값을 고정하고 싶다면 :=가 낫습니다.

크로스 빌드에서는 리눅스 커널 빌드의 CROSS_COMPILE처럼 CC=aarch64-linux-gnu-gcc 식으로 툴 이름의 접두사만 바꿔 끼우는 구조가 재사용에 유리합니다.


의존성 관리: .d 파일 생성(자동 의존성)

.c가 포함하는 헤더가 바뀌면 그 .c는 다시 컴파일해야 합니다. 이 의존 관계를 Makefile에 손으로 적으면 금방 어긋나므로, 컴파일하면서 -MMD -MP로 .d 파일을 함께 만들게 합니다.

DEPDIR := .deps
CFLAGS += -MMD -MP -MF $(DEPDIR)/$*.d
  • -MMD: 사용자 헤더에 대한 의존만 기록하고 시스템 헤더 디렉터리의 헤더는 뺍니다. -MD는 시스템 헤더까지 넣으므로 .d가 커지고, 시스템 패키지를 업데이트할 때마다 전체 재빌드가 일어납니다.
  • -MP: 각 헤더에 대해 빈 규칙을 추가합니다. 헤더 파일을 지우거나 이름을 바꿨을 때 make가 “No rule to make target”으로 멈추지 않게 해 줍니다.
  • -MF: .d 파일을 쓸 경로를 지정합니다.

생성된 .d는 include로 끌어옵니다. 앞의 -는 첫 빌드처럼 .d가 아직 없을 때 오류를 내지 않게 합니다.

-include $(wildcard .deps/*.d)

구체적 $(DEPDIR)/$*.d 패턴은 빌드 루트/소스 트리에 따라 조정하세요(서브디렉터리면 VPATH·% 위치·-MT).


크로스 컴파일(다른 플랫폼 타겟팅)

빌드하는 호스트와 실행할 타깃이 다를 때는 크로스 GCC와 sysroot가 핵심입니다.

# 예시: aarch64 리눅스 GNU 툴체인
aarch64-linux-gnu-gcc --sysroot=/opt/sysroots/aarch64 -c main.c
  • --sysroot: 타깃의 헤더와 라이브러리를 찾을 루트를 지정해, 호스트의 libc나 헤더가 섞여 들어가는 것을 막습니다.
  • 추가로 주는 -L, -I도 sysroot 안쪽을 가리키게 맞춥니다. Yocto나 Buildroot 같은 도구는 이 sysroot를 만들어 주는 역할을 합니다.
  • 링커 스크립트와 기본 라이브러리는 툴체인 문서를 따릅니다. 베어메탈 임베디드라면 보드용 *.ld 스크립트가 따로 있습니다.

CMake에서는 CMAKE_TOOLCHAIN_FILE에 컴파일러, sysroot, 플래그를 한 파일로 모아 두는 방식이 흔합니다(아래 부록 I). x86 호스트에서 ARM 바이너리를 확인하려면 QEMU user 모드로 실행하거나, QEMU와 gdbserver를 묶어 디버깅할 수 있습니다.

자주 나는 이슈: sizeof(long)과 포인터 폭, 엔디안, char의 부호 여부(ARM 리눅스에서는 기본이 unsigned), 그리고 libstdc++·glibc 버전 차이입니다. 모두 컴파일은 되는데 타깃에서만 문제가 드러나는 부류입니다.


빌드 시스템: Make vs CMake(비교)

작은 단일 디렉터리 Makefile은 바로 돌릴 수 있어 학습·과제에는 편합니다. 그러다 규칙·플랫폼·VPATH·서브프로젝트가 쌓이면 같은 것을 세 번씩 복붙하거나, .d·크로스·플래그가 따로 노는 상태가 되기 쉽습니다. CMake는 CMakeLists.txt와 한 번의 구성(생성) 단계를 거치는 대신, Ninja/VS/Xcode 같은 Generator 뒤에 팀 표준을 실을 수 있습니다. Make에서는 의존 그래프를 손으로 적거나 -MMD에 기대야 하지만, CMake에서는 헤더 의존성을 생성기가 알아서 처리하고 include 경로나 옵션을 타깃 단위로 붙일 수 있습니다. find_package·pkg-config·config 모듈 생태계도 CMake 쪽에 더 자연스럽게 녹아듭니다.

결론(실무): 위에서 이미 썼듯이 저는 제품/라이브러리라면 CMake·Meson 류 메타 빌드를 권하고, Make는 짧은 도구나 물려받은 레거시에 가깝다고 봅니다. 그래도 엔지니어링 가치의 중심은 같습니다. 빌드가 configure + build + test를 한 커맨드로 재현 가능하게 고정했느냐 아니냐입니다. 앞에 붙는 것이 make든 cmake든 상관없습니다.

내부 글: 빌드 시스템 비교 — Ninja 등과의 조합도 같이 읽으면 흐름이 이어집니다.

CMake 1-파일 스케치:

cmake_minimum_required(VERSION 3.16)
project(demo C)
add_executable(demo main.c)
target_compile_features(demo PRIVATE c_std_11)

디버그 vs 릴리스 빌드(최적화 레벨, -g, assert)

  • Debug: -O0 또는 -Og, -g3, DEBUG 매크로. 한 줄씩 실행하며 변수를 관찰하기 쉽습니다.
  • Release: -O2/-O3, NDEBUG를 정의해 assert를 제거합니다. PGO나 LTO는 별도 단계로 추가합니다.
  • 혼합: 로그나 추가 검사를 어느 빌드 타입에서 켤지는 #ifdef로 나누고, 그 기준을 팀 정책으로 정해 둡니다.
# Debug
make CFLAGS='-O0 -g3 -DDEBUG' ...

# Release
make CFLAGS='-O2 -DNDEBUG' ...

-ffast-math: NaN과 무한대가 없다고 가정하고 연산 순서를 바꾸는 것을 허용하므로, isnan() 검사가 사라지거나 결과가 비트 단위로 달라질 수 있습니다. 수치 코드에는 결과를 검증한 뒤에만 적용하세요. UBSan/ASan은 디버그 빌드와 CI에 넣는 경우가 늘고 있지만, 실행 속도와 메모리 비용이 있다는 점은 감안해야 합니다.


일반적 빌드 오류(같은 내용, 디버그 순서만)

흔한 증상은 글 맨 앞에서 훑었으니, 여기서는 링크나 빌드가 깨졌을 때 좁혀 가는 순서만 적습니다.

  1. 빌드 시스템을 빼고 gcc 명령 하나로 최소 재현을 만듭니다.
  2. -Werror를 끄고 에러 한 건만 남겨 원인을 분리합니다.
  3. -E나 -M 출력을 정상 환경과 비교해 전처리나 include 경로 문제인지 가립니다.
  4. nm, readelf, ldd로 심볼과 동적 의존성을 확인합니다.
  5. CI와 로컬이 같은 컨테이너 이미지를 쓰는지, gcc -v 로그가 같은지 확인합니다.

부록 A — 단계별로 파이프라인 따라가기(-v, 중간 파일)

한 줄짜리 gcc main.c -o app 뒤에는 수십 개의 인자가 링커·어셈블러로 전달됩니다. 문제가 났을 때는 “어느 서브프로그램이 실패했는지”를 먼저 가릅니다.

# 드라이버가 실제로 부르는 것을 자세히
gcc -v -save-temps=obj -O2 -g -o app main.c

-save-temps=obj는 전처리 결과(.i), 어셈블리(.s), 목적 파일(.o)을 출력 파일과 같은 디렉터리에 남깁니다. CI에서 .i를 아티팩트로 보관해 두면 로컬 전처리 결과와 diff할 수 있습니다. -###는 -v와 비슷하지만 실제로 실행하지는 않고 실행될 명령만 출력합니다.

-pipe와 임시 파일

기본적으로 gcc는 단계 사이에 임시 파일을 씁니다. -pipe를 주면 파이프로 연결해 디스크 I/O를 줄이지만, 매우 큰 번역 단위에서는 메모리를 더 쓰게 되므로 메모리가 작은 CI 러너에서만 OOM이 난다면 의심해 볼 만합니다.

-x로 언어 강제

확장자가 비정상이거나 중간 산출물만 다시 컴파일할 때 -x c, -x assembler-with-cpp 등으로 입력 언어를 지정합니다. 크로스 스크립트나 제너레이터가 이상한 이름을 내보내면 -x가 필요해집니다.


부록 B — 전처리 심화: 매크로·헤더·가시성

매크로 인자와 괄호 함정

#define SQR(x) ((x)*(x))처럼 인자와 전체를 괄호로 감싸는 습관은 a+b를 넘겼을 때의 연산자 우선순위 사고를 막습니다. 그래도 SQR(i++)처럼 인자가 두 번 평가되는 문제는 괄호로 막을 수 없습니다. 토큰 붙이기(##)와 문자열화(#)는 디버그 매크로에서 유용하지만, static inline 함수로 대체할 수 있다면 타입 검사도 받고 인자도 한 번만 평가되므로 그쪽이 낫습니다.

시스템 헤더와 기능 매크로

man feature_test_macros (리눅스)에는 어떤 매크로를 얼마나 일찍 켜야 open(2)의 어떤 플래그가 보이는지가 정리되어 있습니다. 크로스 sysroot와 호스트의 glibc 버전이 다르면 같은 소스라도 보이는 선언이 달라져서, 컴파일은 되는데 경고나 구조체 크기가 다르게 나오는 일이 생깁니다. 그래서 -D_DEFAULT_SOURCE나 -D_GNU_SOURCE처럼 어떤 기능 매크로를 켤지 팀에서 하나로 정해 두는 경우가 많습니다.

#include 중첩 깊이

과도한 헤더 트리는 전처리 시간과 -M 의존 그래프를 부풀립니다. 전방 선언(forward declaration)과 최소 헤더 원칙은 빌드 시간에도 직결됩니다.


부록 C — 컴파일·어셈블: IR, 인라인, LTO

인라인과 링크 단위

static inline 함수는 헤더에 정의를 두는 패턴이 흔합니다. 비-static inline은 C99 규칙과 옛 GNU89 규칙의 의미가 서로 반대라서, 한쪽 규칙으로 작성한 코드를 다른 모드로 빌드하면 정의가 없다는 링크 오류나 중복 정의가 납니다. 팀 안에서는 static inline 하나로 통일하는 편이 안전합니다.

-flto는 목적 파일에 컴파일러의 중간 표현(IR)을 남겨 두었다가 링크할 때 전체를 한꺼번에 최적화합니다(Clang은 -flto=thin으로 병렬성이 좋은 ThinLTO를 쓸 수 있습니다). 번역 단위를 넘어 인라인과 죽은 코드 제거가 가능해지는 것이 장점이고, 링크 시간과 메모리가 늘고 디버깅이 어려워지는 것이 단점입니다. IR 형식은 컴파일러 버전에 묶여 있어서 다른 버전으로 만든 LTO 목적 파일은 섞을 수 없습니다. GCC에서 LTO 목적 파일로 정적 라이브러리를 만들 때는 ar 대신 플러그인을 읽는 gcc-ar/gcc-ranlib를 써야 심볼 인덱스가 제대로 만들어집니다.

gcc -c -O2 -flto -g mod1.c
gcc -c -O2 -flto -g mod2.c
gcc -flto -O2 -o app mod1.o mod2.o

-ffunction-sections / -fdata-sections + --gc-sections

함수와 데이터를 각각 별도 섹션에 넣고, 링크 단계에서 어디서도 참조되지 않는 섹션을 걷어내 크기를 줄입니다. 다만 코드에서 직접 참조하지 않지만 남아 있어야 하는 심볼(링커 섹션에 모아 두는 플러그인 등록 테이블, 인터럽트 벡터 등)까지 지워질 수 있습니다. __attribute__((used))는 컴파일러가 지우는 것만 막으므로, 링커 단계에서 지키려면 링커 스크립트의 KEEP()이나 최신 툴체인의 __attribute__((retain))을 써야 합니다.


부록 D — 목적 파일 안: 재배치·심볼(요약)

재배치(relocation)는 “이 주소는 아직 모른다”는 자리표시자입니다. 링커가 최종 주소(PIE나 DSO라면 기준 주소로부터의 오프셋)를 정하면 call 대상, 절대 주소 로드, GOT 엔트리가 고쳐집니다. readelf -r로 어떤 타입의 재배치가 있는지 볼 수 있습니다.

심볼 바인딩:

  • STB_GLOBAL — 다른 .o에서 보이는 일반적인 정의와 참조.
  • STB_WEAK — 같은 이름의 강한 정의가 있으면 그쪽에 양보합니다. 기본 구현을 제공하고 사용자가 덮어쓰게 할 때 씁니다.
  • STB_LOCAL — static 심볼처럼 해당 .o 안에서만 보여 다른 .o와 충돌하지 않습니다.

objcopy로 특정 심볼을 지우거나 이름을 바꿀 수 있고, strip 수준을 조절해 배포 바이너리의 리버스 엔지니어링을 어렵게 하기도 합니다. 디버그 정보는 별도 파일이나 패키지로 보관하는 편이 좋습니다.


부록 E — 정적 링크 심화: ar, ranlib, --whole-archive

ar rcs에서 r은 멤버를 넣거나 교체하고, c는 아카이브가 없을 때 경고 없이 새로 만들며, s는 링커가 빠르게 찾을 수 있도록 심볼 인덱스를 씁니다. 오래된 시스템에선 ranlib libfoo.a를 따로 돌렸지만, 지금은 ar s가 같은 일을 합니다.

GNU ld의 고전적인 함정도 여기서 나옵니다. 정적 아카이브는 그 시점에 필요한 심볼이 있을 때만 멤버 .o를 당겨오고, 지나간 아카이브는 다시 보지 않습니다. 그래서 libA.a와 libB.a가 서로의 심볼을 쓰는 순환 의존이 있으면 어느 순서로 적어도 한쪽이 미해결로 남습니다. 해결책은 -lA -lB -lA처럼 같은 라이브러리를 한 번 더 적거나, -Wl,--start-group -lA -lB -Wl,--end-group으로 묶어 해결될 때까지 반복 탐색하게 하는 것입니다. 다만 그룹은 링크를 느리게 하므로, 근본적으로는 순환 의존 자체를 없애는 편이 낫습니다.

--whole-archive: 참조 여부와 관계없이 아카이브 멤버 전부를 링크에 넣습니다. 생성자 함수나 링커 섹션으로 스스로 등록하는 플러그인처럼, 아무도 직접 참조하지 않는 멤버가 필요할 때 씁니다. 뒤에 -Wl,--no-whole-archive로 범위를 닫지 않으면 이후의 시스템 라이브러리까지 전부 끌려 들어가 중복 정의가 나므로, 반드시 쌍으로 적습니다.

gcc -o app main.o -Wl,--whole-archive -lmyplugins -Wl,--no-whole-archive

부록 F — 동적 링크: soname, SONAME, DT_NEEDED

리눅스에서 libfoo.so.1.2.3 같은 파일명은 배포와 호환성 정책의 일부입니다. readelf -d로 보이는 SONAME(예: libfoo.so.1)은 이 라이브러리에 링크한 실행 파일의 DT_NEEDED에 기록되는 이름이라, 런타임에 로더가 찾는 것은 실제 파일명이 아니라 이 이름입니다. 따라서 ABI가 깨지는 변경을 하면 SONAME의 메이저 번호를 올려야 기존 실행 파일이 호환되지 않는 새 버전을 잘못 로드하지 않습니다.

GCC에서 -fvisibility=hidden으로 기본 export를 막고 공개 API에만 __attribute__((visibility("default")))를 붙이면, 동적 심볼 테이블이 작아져 로딩이 빨라지고 다른 라이브러리와의 심볼 충돌도 줄어듭니다.


부록 G — pkg-config와 컴파일/링크 인자

많은 오픈소스 라이브러리는 .pc 파일로 -I / -L / -l를 한 번에 내보냅니다.

pkg-config --cflags --libs libpng
# 예: -I/usr/include/libpng16 ... -lpng16 -lz

gcc main.c -o app $(pkg-config --cflags --libs libssl)

크로스 빌드에서는 PKG_CONFIG_SYSROOT_DIR과 PKG_CONFIG_PATH(또는 PKG_CONFIG_LIBDIR)로 타깃의 .pc를 가리킵니다. -I/-L을 손으로 나열하다 헤더와 라이브러리 버전이 엇갈리는 사고가 흔하므로, CMake에서도 pkg_check_modules로 .pc를 읽게 하는 경우가 많습니다.


부록 H — Makefile 완성 예(디렉터리·.d·VPATH 아이디어)

.c가 한 디렉터리에 모인 경우의 최소 실전형입니다.

CC      := gcc
CPPFLAGS := -D_GNU_SOURCE -Iinclude
CFLAGS  := -O2 -g -Wall -Wextra -std=c11
LDFLAGS :=
LDLIBS  :=

DEPDIR  := .deps
$(shell mkdir -p $(DEPDIR) >/dev/null)

SRCS := main.c util.c
OBJS := $(SRCS:.c=.o)
DEPS := $(SRCS:%.c=$(DEPDIR)/%.d)

all: myapp

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

%.o: %.c
	@mkdir -p $(DEPDIR) $(dir $(DEPDIR)/$*.d)
	$(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -MF $(DEPDIR)/$*.d -c -o $@ $<

-include $(DEPS)

.PHONY: all clean
clean:
	rm -f myapp $(OBJS) $(DEPS)

소스가 서브디렉터리에 있다면 VPATH := src를 쓰거나, SRCS를 src/main.c 형태로 적고 $(notdir …) 등으로 목적 파일 경로를 조정합니다. 여기서는 개념만 보였으니 실제 트리에 맞게 고쳐 쓰세요.

규칙이 왜 특정 타깃에 매칭됐는지 추적하려면 make --debug=b나 make -d를 씁니다. make -rR은 내장 규칙과 내장 변수를 꺼서, 의도하지 않은 암묵 규칙이 끼어드는 것을 배제할 때 유용합니다.


부록 I — 크로스 컴파일: CMake 툴체인 파일 스케치

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

set(CMAKE_C_COMPILER   aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)

# 크로스 루트(예: sysroot)
set(CMAKE_SYSROOT /opt/sysroots/aarch64)
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

ONLY는 호스트의 /usr/lib를 실수로 찾지 않게 막는 데 효과적입니다. 다만 코드 생성기처럼 빌드 중에 호스트에서 실행할 프로그램을 찾아야 한다면 CMAKE_FIND_ROOT_PATH_MODE_PROGRAM은 보통 NEVER로 둡니다. 정확한 값은 대상 SDK 문서를 따릅니다.

사용할 때는 cmake -B build -DCMAKE_TOOLCHAIN_FILE=arm-gnu.cmake로 구성하고 cmake --build build로 빌드합니다. 여기에 -G Ninja를 주면 병렬 빌드와 증분 빌드가 Make보다 빠르게 느껴지는 경우가 많습니다.


부록 J — 디버그/릴리스와 관측 도구(추가)

소스와 어셈블리를 맞춰 보고 싶다면 objdump -S나 gdb의 layout asm이 대표적이고, -g가 있어야 “이 C 줄”이 어느 기계어에 대응하는지 보기 편합니다. 디버거에서 변수가 <optimized out>으로 보이면 -O0이나 -Og로 다시 빌드해 보거나, 디스어셈블해서 실제로 무엇이 남았는지 확인합니다. 관찰을 위해 volatile을 붙이는 방법도 있지만 동작 자체를 바꾸므로 임시로만 씁니다. UBSan/ASan은 -fsanitize=undefined / address처럼 런타임에 계측 코드를 심는 방식이라 빌드·실행 비용이 있습니다. perf·valgrind는 호스트에서 프로파일·메모리 이슈를 잡는 데 쓰기 쉽지만, 임베디드 타깃이라면 원격 gdb·샘플링·보드 전용 도구를 써야 합니다(환경이 다르면 “맥/리눅스에서 잡힌 것”이 곧 타깃의 문제는 아닙니다).

-D_FORTIFY_SOURCE=2를 최적화와 함께 쓰면 memcpy, sprintf 같은 일부 glibc 함수가 버퍼 크기를 아는 경우 검사 버전으로 바뀌어, 컴파일 시점 경고와 런타임 중단이 추가됩니다. 최적화 없이 쓰면 효과가 없으므로 CFLAGS를 팀에서 고정해 두는 것이 중요합니다.


부록 K — 맨 앞에서 다루지 않은 링크 오류 두 가지

relocation R_X86_64_32S against ... can not be used when making a shared object 같은 메시지는 -fPIC 없이 컴파일한 목적 파일이 공유 라이브러리에 들어갔다는 뜻입니다. 해당 .o를 만든 컴파일 단계에 -fPIC를 추가해야 하며, 링크 단계에만 붙여서는 해결되지 않습니다. 정적 라이브러리를 나중에 .so에 넣을 계획이라면 그 .a도 처음부터 -fPIC로 빌드해 두어야 합니다.

.so를 만들었는데 file로 보니 공유 객체가 아니라 실행 파일이거나 main이 없다는 오류가 난다면, 링크 명령에서 -shared를 빠뜨린 경우입니다.


부록 L — 관측 도구 빠른 참조(nm, readelf, objdump, strings)

다른 사람의 바이너리나 CI 산출물을 볼 때 자주 쓰는 조합입니다. 옵션과 출력은 버전에 따라 다를 수 있으니 man도 함께 확인하세요.

nm — 심볼 한눈에

  • nm -C --defined-only app — 정의된 심볼만 보여 줍니다. -C는 C++ 이름을 디맹글하므로 C/C++ 혼합 빌드에서 유용합니다.
  • nm -u app — 미해결 심볼, 즉 링크나 로드 때 외부에서 채워야 하는 심볼입니다.
  • nm -D libfoo.so — 일반 심볼 테이블 대신 동적 심볼 테이블을 봅니다. strip된 .so에서도 보입니다.

출력은 주소, 심볼 타입 문자(T, U, D, B 등), 이름 순입니다. 미해결 심볼은 주소 칸이 비어 있습니다.

readelf — ELF 구조

  • readelf -h a.out — ELF 헤더(클래스 32/64, 엔디안, 타입 EXEC/DYN/REL).
  • readelf -l — 프로그램 헤더(세그먼트, INTERP, DYNAMIC).
  • readelf -d — 동적 섹션(NEEDED, RPATH, RUNPATH, SONAME).

PIE 실행 파일도 ELF 타입은 DYN으로 나오므로, readelf -h의 타입과 readelf -l의 INTERP 유무를 함께 보면 실행 파일인지 라이브러리인지 구분할 수 있습니다.

objdump — 디스어셈블·섹션

  • objdump -d -M intel app — 인텔 문법 디스어셈블(리눅스 as 기본은 AT&T가 흔함).
  • objdump -h app — 섹션 목록과 크기.
  • objdump -t — 심볼(간이 nm).

strings — 포함된 상수 문자열

하드코딩된 경로·URL·버전이 남아 있는지 빠르게 스캔합니다(보안 리뷰·릴리스 산출물 확인). 민감 정보가 문자열로 박혀 있으면 제거 정책이 필요합니다.


부록 M — 환경 변수로 툴체인 주입

CC는 C 컴파일러 명령으로 make의 관례입니다. CXX는 C++용입니다. CFLAGS/CXXFLAGS는 컴파일에 직접 쓰이고, CPPFLAGS는 전처리(-I, -D) 쪽입니다. LDFLAGS는 링크 단계 옵션(주로 -L, -Wl,…)이고, LDLIBS는 -l 목록으로 링크 명령 맨 뒤에 붙습니다. 이렇게 나누어야 앞에서 본 링크 순서 문제를 피할 수 있습니다. PKG_CONFIG_PATH는 .pc를 추가로 찾는 경로이고, PKG_CONFIG_SYSROOT_DIR은 크로스 빌드에서 .pc가 내놓는 경로 앞에 sysroot를 붙이는 설정입니다. PATH는 이름이 같은 gcc가 여러 개일 때 어느 것이 잡히는지를 결정합니다. which gcc + 버전을 티켓에 적어 두세요.

CI에서는 env | sort를 아티팩트로 남겨 재현에 쓰는 팀이 있습니다(비밀은 마스킹).


부록 N — clang 드라이버와 GNU와의 차이(개략)

많은 플래그는 GCC와 같거나 비슷하지만, 기본 C++ 표준 라이브러리와 기본 링커 동작(특히 macOS의 Xcode 툴체인)은 다릅니다. 기본 C 표준이나 기본으로 켜지는 경고도 차이가 있어서, CI에서 두 컴파일러를 모두 돌리면 같은 CMakeLists.txt에서도 결과가 갈리곤 합니다.

  • FreeBSD와 macOS는 기본 cc가 Clang입니다. 빌드 스크립트에 gcc를 하드코딩하지 말고 $(CC)를 쓰는 편이 이식성에 유리합니다.
  • -stdlib=libstdc++/libc++, -fuse-ld=lld, 크로스 빌드용 --target=… 같은 Clang 전용 옵션은 팀 문서 한 곳에 모아 두면 새로 온 사람이 헤매지 않습니다.

부록 O — Ninja와 단일 compile_commands.json

CMake와 Ninja 조합은 병렬 빌드와 의존성 추적이 정교해서 대형 C/C++ 레포에서 사실상 표준에 가깝습니다. cmake -B build -G Ninja로 구성한 뒤 ninja -C build로 빌드합니다.

compile_commands.json(컴파일 데이터베이스)은 clangd, IDE, 정적 분석 도구가 실제 빌드와 같은 include 경로와 매크로로 코드를 해석하게 해 줍니다. CMake에서는 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON으로 만들고, 순수 Makefile 프로젝트에서는 Bear나 intercept-build로 빌드를 가로채 생성할 수 있습니다.


부록 P — “컴파일은 됐는데” 체크리스트(1페이지)

  1. 미해결 심볼 이름이 _Z…로 시작한다면 C++ 맹글링된 이름입니다. C 라이브러리를 C++에서 부르는데 헤더에 extern "C"가 빠진 경우가 대부분입니다.
  2. extern "C"는 C++에서 C API를 선언하는 헤더 경계에서 일관되게 씌웁니다. 보통 #ifdef __cplusplus로 감쌉니다.
  3. 구조체를 주고받는 양쪽이 같은 sizeof와 정렬을 보는지 확인합니다. 한쪽에만 #pragma pack이 적용되면 링크는 되지만 런타임에 값이 어긋납니다.
  4. 헤더에 두는 함수는 static inline으로 통일하는 등, inline과 static 사용 규칙을 팀에서 하나로 정합니다.
  5. -lm 같은 라이브러리는 이를 참조하는 .o보다 뒤에 둡니다. 스레드는 -lpthread보다 -pthread를 쓰는 편이 안전한데, 전처리 매크로와 링크 옵션을 함께 설정해 주기 때문입니다(glibc 2.34부터는 libpthread가 libc에 합쳐졌습니다).

부록 Q — 예제 디렉터리 레이아웃과 “한 번에” vs “단계별” 빌드

아래는 설명용 가상 트리입니다. 실제 팀은 src/, include/, test/, third_party/ 등 규약을 둡니다.

demo/
  include/
    api.h
  src/
    main.c
    worker.c
  lib/
    (빌드 산출물은 보통 .gitignore)

한 번에 실행 파일

cd demo
gcc -Iinclude -O2 -g -std=c11 -o bin/app src/main.c src/worker.c -lm

단계별(교육·디버깅)

gcc -E -Iinclude -o /tmp/main.i src/main.c
gcc -S -O2 -Iinclude -o /tmp/main.s src/main.c
gcc -c -O2 -g -Iinclude -o build/main.o src/main.c
gcc -c -O2 -g -Iinclude -o build/worker.o src/worker.c
gcc -O2 -g -o bin/app build/main.o build/worker.o -lm

정적 라이브러리로 worker만 묶는다면:

gcc -c -O2 -g -Iinclude -o build/worker.o src/worker.c
ar rcs lib/libworker.a build/worker.o
gcc -O2 -g -Iinclude -o bin/app src/main.c -Llib -lworker -lm
# 또는 main.o 를 먼저: gcc ... build/main.o -Llib -lworker -lm

공유 객체로 나누면(간단한 예):

gcc -fPIC -c -O2 -g -Iinclude -o build/worker.o src/worker.c
gcc -shared -Wl,-soname,libworker.so.1 -o lib/libworker.so.1.0.0 build/worker.o

실제 배포에선 SONAME인 libworker.so.1과 개발용 libworker.so를 실제 파일에 연결하는 ln -s 단계가 더해집니다(부록 F). 링크할 때 -lworker가 찾는 것은 개발용 libworker.so이고, 런타임에 로더가 찾는 것은 libworker.so.1이라는 점을 혼동하지 않게 적어 두세요.

테스트 실행과 LD_LIBRARY_PATH (개발 전용)

LD_LIBRARY_PATH="$(pwd)/lib" ./bin/app

운영 환경에서는 LD_LIBRARY_PATH에 기대기보다 rpath, 표준 설치 경로, OS 패키지로 라이브러리를 찾게 하는 편이 보안과 재현성 면에서 낫습니다. $ORIGIN 기반 상대 rpath는 압축을 풀어 아무 위치에나 두는 tarball 배포에서 자주 쓰입니다.


부록 R — 약어·용어(이 글에서 쓰는 뜻)

TU(translation unit)는 보통 한 .c가 전처리로 펼쳐진 한 덩어리입니다. DSO(dynamic shared object)는 공유 .so 류를 가리키는 말입니다. PIE는 실행 파일이 주소를 매번 다르게 잡을 수 있도록 링크된 경우입니다. PIC는 위치에 독립적인 머신코드로, DSO에 넣을 때 필요합니다. PLT(Procedure Linkage Table)와 GOT(Global Offset Table)는 동적 심볼의 주소를 간접적으로 참조하기 위한 테이블로, 지연 바인딩도 이를 통해 이루어집니다. ABI는 호출 규약·레이아웃·이름 규칙(맹글링은 C++)을 말하며, 시리즈 #04와 이어서 보면 좋습니다. RELRO는 재배치가 끝난 뒤 일부 영역을 읽기 전용으로 잠가 GOT 덮어쓰기를 어렵게 하는 기법입니다(보안·플랫폼). LTO는 링크 단계에 걸친 최적화입니다(부록 C). soname은 동적 링커가 DSO 이름으로 호환성 단서를 잡는 데 쓰입니다(부록 F).


부록 S — 보안·하드닝(빌드 플래그) 한 스크린 더

아래는 개괄이며, 어떤 것을 켤지는 제품과 플랫폼, 감사 기준에 맞춰 골라야 합니다. 성능과 호환성 트레이드오프가 있습니다.

스택 보호에는 -fstack-protector-strong을 많이 씁니다. 배열이나 지역 변수 주소를 쓰는 함수에만 카나리를 넣어서, 모든 함수에 넣는 -fstack-protector-all보다 비용이 적습니다. PIE는 컴파일할 때 -fPIE, 링크할 때 -pie를 주며, 운영체제의 ASLR과 함께 실행 파일 주소를 무작위화합니다. RELRO와 즉시 바인딩은 -Wl,-z,relro,-z,now로 켜는데, 로드 뒤 GOT를 읽기 전용으로 만들어 덮어쓰기 공격을 막는 대신 시작할 때 모든 심볼을 바인딩하는 비용이 듭니다. _FORTIFY_SOURCE는 최적화와 함께 써야 효과가 있습니다(부록 J). Clang의 -fsanitize=cfi는 LTO가 필요합니다.

checksec 같은 스크립트는 바이너리의 PIE, RELRO, 카나리 적용 여부를 요약해 주지만, 스크립트가 오래되면 새 플래그를 잘못 판정할 수 있으니 readelf 결과로 한 번 교차 확인하세요.


부록 T — strip / 분리 디버그 심볼(흐름)

배포 바이너리에서는 디버그 정보를 빼고, 내부에는 .debug 파일을 보관하는 흐름입니다.

gcc -O2 -g -o app main.c
objcopy --only-keep-debug app app.debug
strip --strip-debug app
# 추후: eu-unstrip / build-id 기반 / 패키지 매니저 — 팀 **방식** 택일

빌드 ID(-Wl,--build-id)를 넣어 두면 크래시 덤프와 디버그 파일을 해시로 짝지을 수 있어서, 리눅스 배포판과 debuginfod가 이 방식을 씁니다. 분리한 뒤에는 objcopy --add-gnu-debuglink=app.debug app으로 바이너리에 디버그 파일 위치를 기록해 두면 gdb가 자동으로 찾아 줍니다.


부록 U — Windows·MinGW·MSYS2 메모(요약)

이 글의 기본은 ELF + GNU/Clang 쪽입니다. Windows·MinGW를 같이 쓰는 팀은 아래를 별도 원페이지로 두는 경우가 많습니다.

  1. .a — MinGW의 정적 라이브러리입니다. 확장자는 Unix와 같지만 안에 든 목적 파일은 COFF/PE 형식입니다.
  2. import .lib — .dll의 함수를 링크 타임에 연결하기 위한 스텁 라이브러리입니다. 실제 코드는 DLL에 있습니다.
  3. __declspec(dllexport) / dllimport — 내보내기와 가져오기를 명시합니다. Windows에서는 명시하지 않은 함수가 DLL 밖으로 보이지 않습니다.
  4. -Wl,--out-implib(MinGW) — DLL을 빌드하면서 import library도 함께 만듭니다.
  5. .def — export 목록을 텍스트 파일로 적는 방법으로, __declspec을 쓰지 않는 레거시 코드에서 씁니다.

Windows와 Unix를 한 CI에서 돌리면 셸(sh와 cmd), 경로 구분자, 백슬래시 이스케이프가 서로 엮여 하나의 스크립트로 정리하기 어렵습니다. 플랫폼마다 cmake --preset을 따로 두어 빌드 진입점을 분리하는 편이 덜 꼬입니다.

재현 가능한 빌드 환경

  • 리눅스/Unix: Docker 이미지에 gcc 버전, 설치 패키지, CFLAGS를 고정해 모두가 같은 이미지로 빌드합니다.
  • Windows: Visual Studio와 Windows SDK 버전, cmake --preset을 고정해 호스트마다 다른 부분을 스크립트 안에 가둡니다.

부록 V — ccache·증분·클린 빌드(실무)

ccache는 컴파일 명령줄과 전처리 결과를 해시해서, 같은 입력을 다시 만나면 캐시에 저장된 .o를 돌려줍니다. CI에서 캐시 히트율이 높으면 빌드 시간이 크게 줄어듭니다. 다만 sloppiness 같은 설정으로 해시 조건을 느슨하게 했다면 낡은 .o가 섞일 수 있으니, 증분 빌드가 이상할 때 쓸 ccache -C, ninja -t clean, make clean 절차를 팀 런북에 적어 둡니다.

clean만으로 부족하면 build/나 out/ 같은 빌드 디렉터리를 통째로 지우고 처음부터 다시 빌드합니다. 대형 모노레포라면 시간이 오래 걸리므로 어느 디렉터리만 지우면 되는지 문서화해 두면 좋습니다. git clean -fdx는 추적하지 않는 파일을 모두 지우므로, 로컬 설정 파일까지 사라진다는 점을 알고 실행하세요.

distcc나 Icecream(icecc) 같은 분산 컴파일은 여러 머신에 컴파일을 나눠 빌드를 가속합니다. 빌드 호스트마다 gcc 버전이나 헤더가 다르면 결과가 머신마다 달라질 수 있으므로, 같은 컨테이너 이미지나 고정된 툴체인을 먼저 갖춰야 합니다.


부록 W — 단일 .c에서 파이프라인만 실험해 보기

교육이나 POC에서 자주 쓰는 명령을 모았습니다. 임의의 hello.c에 대해:

# 1) 전처리
gcc -E hello.c -o hello.i

# 2) 어셈블리만(전처리+컴파일)
gcc -S -O2 hello.c -o hello.s

# 3) 목적 파일(링크는 안 함)
gcc -c -O2 -g hello.c -o hello.o

# 4) 링크만(이미 .o가 있을 때)
gcc -o hello hello.o

hello.i → cc1 → as → hello.o → ld 흐름이 익숙해지면, 어느 단계에서 오류가 났는지 gcc -v 로그로 좁히기 쉬워집니다. 여러 .c가 있는 실제 프로젝트에서는 각 .c를 -c로 .o까지 만들고 마지막에 한 번만 링크하는 방식(부록 Q)이 문제를 추적하기에 유리합니다.

-fuse-ld=gold나 -fuse-ld=lld로 링커를 바꿔 쓴다면, 바꾼 이유와 버전을 README에 한두 줄 남겨 두세요. 링커마다 아카이브 탐색 순서나 기본 옵션이 조금씩 달라서, 나중에 링크 단계에서만 생기는 문제를 쫓을 때 그 기록이 시간을 아껴 줍니다.


부록 X — 팀이 문서에 적어 두면 좋은 빌드 정보

재현을 위해 릴리스마다 남겨 둘 최소한의 정보입니다.

  • 컴파일러 버전: gcc --version 첫 줄.
  • CFLAGS: -O, -g, -D, -std=…, -fPIC/-fPIE 여부.
  • 링크: -L/-l, -Wl,…, -static 사용 여부, rpath 정책.
  • 크로스 빌드: 툴체인 접두사, --sysroot 경로, 타깃 triplet.

Autotools(./configure + make) 기반 레거시에서는 config.log 앞부분에, CMake에서는 CMakeCache.txt에 이 정보가 모여 있습니다. 내부 절대 경로 같은 민감한 정보는 가린 뒤 공유하세요. 이슈를 올릴 때 gcc -v 출력에 나오는 COLLECT_GCC_OPTIONS=… 줄을 첨부하면, 드라이버가 실제로 받은 옵션을 한 줄로 볼 수 있어 어느 머신이 아니라 어느 플래그가 원인인지 좁히기 쉽습니다.

참고 문서: C 언어 규칙은 ISO/IEC 9899에 있고, 링크·아카이브·동적 로더의 세부는 플랫폼 ABI와 툴체인 매뉴얼(man ld, man gcc, Binutils 매뉴얼)이 기준입니다. 표준이 정하지 않은 동작(예: weak 심볼 해석의 경계 사례)은 사용 중인 툴체인 버전의 문서를 우선합니다.

  • System V x86-64 ABI(호출 규약·정렬) — 시리즈 #04와 함께 읽으면 좋습니다.
  • man 7 feature_test_macros — 리눅스 기능 테스트 매크로 정리.

시리즈 맺음: 체인 사고

C 프로그램의 동작은 문법만이 아니라 컴파일러, 링커, OS 로더, ABI가 함께 결정합니다. 빌드를 눈에 보이는 명령 한 줄이 아니라, 각 단계의 산출물이 다음 단계와 맺는 계약의 연속으로 읽는 습관이 생기면 링크 오류를 훨씬 빨리 좁힐 수 있습니다.

관련 — 시리즈·심화: C 언어에서 버그가 생기는 자리 · 동적 메모리 · C++ 컴파일 과정 4단계: 전처리·컴파일·어셈블·링킹과 undefined reference가 나는 이유 · 빌드 시스템 비교


FAQ (본문 보강)

Q. static 전역이 링커에 보이지 않는다는 것은 무슨 뜻인가요?

A. static 파일 범위 심볼은 내부 링크를 가져서 다른 번역 단위에 노출되지 않습니다. 그래서 서로 다른 .c에 같은 이름의 static 함수가 있어도 충돌하지 않습니다. 반대로 헤더에 static 함수를 정의하면 그 헤더를 포함한 .c마다 별도의 복사본이 생기므로, 크기가 큰 함수라면 코드 크기가 늘어난다는 점을 감안하세요.

Q. LD_PRELOAD는 왜 CI에서 금지하나요?

A. 같은 이름의 심볼을 다른 구현으로 바꿔 끼울 수 있어서, 테스트 결과가 환경에 따라 달라지고 보안상으로도 취약해지기 때문입니다. 대신 rpath와 툴체인 버전을 고정하고 LD_LIBRARY_PATH 사용을 최소화합니다.

Q. nm의 T와 U는 무엇인가요?

A. GNU nm 기준으로 T는 text(코드) 섹션에 정의된 심볼, U는 이 파일에 정의가 없어 링크나 로드 때 채워야 하는 심볼입니다. 같은 문자의 소문자(t, d 등)는 static처럼 파일 안에서만 보이는 local 심볼을 뜻합니다. 전체 목록은 man nm을 기준으로 삼으세요.


같이 보면 좋은 글 (내부)