C 언어 시리즈 #10 — 컴파일 파이프라인·링커 심볼·정적·동적 링크
이 글의 핵심
.o에 무엇이 들어가는지, 링커가 외부 심볼을 어떻게 채우는지, 왜 “컴파일은 됐는데 링크에서만” 터지는지, -fPIC·-shared·rpath가 런타임에 어떤 영향을 주는지 설명합니다.
시리즈 안내
#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 조합이 왜 대형 레포에 기본에 가깝는지는 거기 흐름으로 이어진다.
1. 빌드 프로세스 개요
전형적인 GNU 툴체인(gcc 드라이버)이 수행하는 큰 흐름은 다음과 같습니다.
- 전처리(Preprocess) — 매크로 치환,
조건부 컴파일,#include로 번역 단위(translation unit)의 전처리된 C 소스를 만듭니다. - 컴파일(Compile) — 구문·의미 분석, 중간 표현, 최적화(옵션에 따라)를 거쳐 어셈블리로 내보내거나 내부적으로 어셈블러로 넘깁니다.
- 어셈블(Assemble) — 어셈블리를 머신어로 바꾸고 재배치 정보·심볼 테이블이 붙은 목적 파일(
.o, COFF/ELF 등)을 생성합니다. - 링크(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 안에서” 타입·선언이 맞으면 통과시키는데, 정의가 다른 번역 단위에 있으면 링커가 퍼즐을 맞출 때 비로소 드러나기 때문입니다.
2. 전처리 단계
전처리기는 C 문법이 아니라 지시문을 처리합니다. #include는 텍스트 삽입, #define은 토큰 치환, #if/#ifdef는 조건에 따라 코드 블록을 제거합니다. 매크로가 컴파일러 최적화와 충돌하거나, 우선순위 실수(MACRO(a+b))로 디버깅이 어려워지는 대표 케이스이므로, 디버그 빌드에서는 -Wall뿐 아니라 불필요한 복잡 매크로를 줄이는 것이 낫습니다.
-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 앞에서 일관되게 켜는 편이 안전합니다. 일부는 <features.h> 이전에 정의되어야 하므로, 컴파일 옵션 -D로 주입하거나, 공통 config.h에서 처리합니다.
3. 컴파일 단계: C → 어셈블리
“컴파일”이란, 전처리된 C를 AST·IR로 읽고, 세맨틱을 맞춘 뒤, 어셈블리(또는 직접 머신코드)로 내보내는 단계를 말합니다. GCC 계열에선 cc1이 담당합니다(클랭은 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는 프레임 포인터를 쓰지 않게 해 프로파일/스택 트레이스 품질을 떨어뜨릴 수 있습니다(플랫폼·최적화에 따라). 릴리스에서만 제한적으로 켜는 팀이 많습니다.
4. 어셈블 단계: 어셈블리 → 목적 파일(.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
5. 링킹 단계: 오브젝트 → 실행 파일
링커(ld, GCC에선 collect2 래퍼)는 여러 .o의 섹션을 병합하고, 심볼 참조를 정의된 주소에 연결(재배치)합니다. 런타임에 동적 링크를 쓰는 경우, 실행 파일 안에는 undefined로 남는 심볼이 PLT/재배치로 동적 심볼에 훅이 걸릴 수 있습니다(플랫폼·옵션에 따름).
미해결(unresolved): 링크 종료 시에도 정의를 찾지 못한 심볼이 있으면 실패합니다. undefined reference to 'foo'.
중복 정의: 동일 강한(strong) 심볼이 여러 .o에 있으면 충돌(보통)합니다. C에서는 전역 동일 이름의 비-static 객체/함수를 여러 곳에 두는 실수로 자주 터집니다. inline, 약한(weak) 심볼, 일부 특수 경우는 툴체인·ABI 규칙이 따로 있습니다.
프로덕션 규칙(요지):
- 헤더에는
extern선언과static inline등 ODR/링크에 안전한 패턴만. - 정의는
.c한 곳에(템플릿이 아닌 C에서 특히). static으로 파일 범위를 캡슐화해 이름 충돌·노출을 줄인다.
-save-temps=obj 등으로 중간 .i, .s를 남기면, “어느 단계에서” 이상이 생겼는지 병리학적으로 추적하기 쉽습니다.
보안·운영(요약): RELRO, BIND_NOW, 스택 카나리 등은 컴파일+링크+로더 조합의 문제입니다(배포 대상이 리눅스라면 readelf -l로 동적 섹션, checksec류 스크립트로 팀 표준을 점검). 본문 초점은 빌드 흐름이지만, 같은 소스라도 링크 플래그에 따라 공격면이 달라질 수 있음을 기억하세요.
6. 정적 라이브러리: .a 생성 및 사용
정적 아카이브(.a)는 여러 .o를 ar로 묶은 것이며, 링크 시 필요한 .o만 끌어옵니다(대개 심볼이 해결에 기여하는 멤버만, 구현/버전·링커 모드에 따라 다름 — 관습적으로 “아카이브는 심볼을 채울 때까지 훑는다”고 이해하고, 순서 이슈는 아래 10절).
.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이고, import library로 동일 확장이 쓰여 혼동되기 쉬우므로, 문서에선 툴체인별로 구분하세요(Unix와 의미가 다를 수 있음).
7. 동적 라이브러리: .so / DLL 생성 및 사용
공유 객체(리눅스 .so 등)는 다른 프로세스/바이너리가 메모리에 맵할 수 있는 Position Independent 코드가 권장·요구됩니다(대개 -fPIC).
공유 객체 빌드(개념):
gcc -fPIC -c module.c
gcc -shared -o libmodule.so module.o
동적 링크는 런타임 로더(ld-linux.so 등)가 DT_NEEDED에 적힌 DSO를 찾아 mmap한 뒤, GOT/PLT를 통해(일반적 lazy 바인딩) 호출을 해석합니다(플랫폼·옵션에 따라 즉시 바인딩). -fPIC는 GOT로 간접 참조가 늘어 성능/코드 크기 트레이드오프가 있으나, DSO엔 사실상 필수에 가깝습니다.
Windows: __declspec(dllexport/dllimport)가 필요한 경로, .dll + import .lib로 링크하는 모델, MinGW/MSVC 차이 — 본 가이드는 Unix centric이며 Windows는 “같은 개념, 다른 도구/키워드”로 이해하세요.
LD_PRELOAD / 심볼 인터포지션(요지): 동일 이름의 동적 심볼이 어떤 DSO에서 오느냐는 로드 순·검색 규칙에 영향을 받습니다(보안/디버그에서 “치환”이 가능). FAQ의 rpath/버전 관리는 이런 재현성을 지키는 실무 답이 됩니다.
진단:
readelf -d libmodule.so
nm -D libmodule.so
ldd /path/to/your_binary
ldd는 빌드/배포 환경의 로더 뷰를 보여, 대상 머신과 달르면 오해의 소지가 있으니 CI에서 실제 런타임 머신으로 확인하세요.
8. 컴파일러 플래그: -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로만 강제하는 팀이 많습니다. -Werror + 컴파일러 업그레이드는 경고 쇄도로 빌드가 막힐 수 있으니, CXXFLAGS/CFLAGS에 -Wno-… 예외를 남기는 정책이 필요해질 수 있습니다.
-g + -O2 조합(“릴리스에 재현이 가능한” 스택)은 흔합니다. strip으로 심볼을 제거하되 .debug 별도 분리(objcopy --only-keep-debug) 후, 배포·크래시 덤프 심볼리케이션에 쓰는 팀도 있습니다.
export CFLAGS='-O2 -g -Wall -Wextra -Wpedantic -std=c11'
9. 헤더 파일과 include path, -I
#include "foo.h"는 검색 순서가 " / <>에 따라 달리 정의됩니다(표준+구현). -I로 주는 경로는 시스템 기본보다 먼저 끼어듭니다(툴체인·버전에 따름, 문서로 확인). 그래서 같은 이름의 “자체 stdio.h”를 실수로 먹는 사고는 -I 순서/경로의 전형적 함정입니다.
gcc -I./include -I../thirdparty/include -c src/main.c
CPPFLAGS(전처리)와 CFLAGS/CXXFLAGS를 구분하는 Makefile 관습은, 헤더-only 의존이 바뀌었을 때 재컴파일 범위를 끌기 좋다는 뜻에서도 쓰입니다(12절).
#include 경로 인젝션 방지(보안): 신뢰할 수 없는 디렉터리를 -I 앞에 넣는 빌드 스크립트는 악의적 헤더로 코드 오염이 가능 — CI·제3자 래퍼는 절대 경로와 고정을 권장합니다.
10. 링커 플래그: -L, -l, -Wl,
-lfoo는libfoo.so/libfoo.a를 찾습니다(이름 관례).-Lpath는 라이브러리 검색 경로(-l앞에 있어야 효과가 있는 케이스가 많습니다 — 순서 주의!).
gcc -o app main.o -L./build -lmylib -lm
링크 순서(중요): main.o가 libmylib.a의 심볼을 참조하려면, 흔히 main.o 다음에 -l을 둡니다(아카이브가 뒤에 있어야 멤버가 뽑히는 “고전” 모델). undefined reference가 “라이브러리는 링크했는데” 상황이면, 빈 (--whole-archive 등) 없이 순서를 먼저 의심하세요.
-Wl,옵션: gcc에 링커로 직접 넘기는 전달(쉼표 이스케이프 규칙 주의). 예: -Wl,-rpath,'$ORIGIN/lib'. rpath를 박는 목적이면 위처럼 -Wl,-rpath,…로, 배포 상대 경로($ORIGIN 등)를 문서에 같이 써 둔다. 맵 파일이 필요하면 -Wl,-Map,link.map 류로 “어떤 심볼이 어느 섹션에 갔는지”를 뽑는다(디버그·최적화 이슈). 완전 정적 링크(예: libc까지)는 -static인데, 가능/허용은 환경·보안·라이선스에 달렸다. DSO와 정적 혼용은 -Bstatic / -Bdynamic 쪽을 툴체인이 어떻게 해석하느냐에 따라 갈리니, 팀에서 한 옵션 세트로 못 박는 편이 낫다.
readelf -d로 rpath/runpath가 기대와 맞는지, 배포 서버 보안팀의 정책(예: runpath의 상대/절대)에 맞춰 확인하세요.
11. 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 =: 재귀적 확장(=)이 의도치 않게 끊임없이 평가될 수 있으니, CFLAGS처럼 한 번 고정하는 경우 :=가 선호됩니다(팀 스타일).
크로스 시에는 CC=aarch64-linux-gnu-gcc 식으로 툴 프리픽스만 갈아끼우는 Kbuild 스타일이 재사용에 유리합니다(13절).
12. 의존성 관리: .d 파일 생성(자동 의존성)
C 빌드에서 .c는 #include가 바뀌면 다시 컴파일해야 합니다. 수동 Makefile 의존은 깨지기 쉬우니, 컴파일러가 -MMD -MP로 .d를 동시에 만들게 합니다.
DEPDIR := .deps
CFLAGS += -MMD -MP -MF $(DEPDIR)/$*.d
-MMD: 사용자#include에 대한 의존만(시스템<>는 제외 —-MD는 시스템도 포함, 환경에 따라 “너무 잦은 재빌드” 유발).-MP: 헤더가 없어질 때 빈 규칙을 끼워make가 끊기지 않게 도와줌(깨끗한git작업흐름에 유효).-MF: 출력 경로 지정(조합이 까다로우면-M계열 단독 목표로make에서 분리).
include로 .d를 끌어옵니다:
-include $(wildcard .deps/*.d)
구체적
$(DEPDIR)/$*.d패턴은 빌드 루트/소스 트리에 따라 조정하세요(서브디렉터리면VPATH·%위치·-MT).
13. 크로스 컴파일(다른 플랫폼 타겟팅)
호스트(빌드 머신) vs 타깃(실행/배포 머신)이 다를 때, 크로스 GCC + sysroot가 핵심입니다.
# 예시: aarch64 리눅스 GNU 툴체인
aarch64-linux-gnu-gcc --sysroot=/opt/sysroots/aarch64 -c main.c
--sysroot: 타깃의 헤더·라이브러리 루트를 격리(호스트libc누락·오염 방지).-B,-L,-I도 sysroot 하위로 일관되게(패키징: Yocto/Buildroot, 컨테이너, 직접 chroot).- 링커 스크립트/기본 DSO는 툴체인 문서를 따름(임베디드 GNU ld의 기본
*.ld).
CMAKE_TOOLCHAIN_FILE(CMake)로 CC/CXX, sysroot, 플래그를 한 파일에 박는 방식이 흔합니다(14절). QEMU user/system + gdbserver는 팀이 통합할 때 “호스트는 x86, 타깃은 ARM” 재현에 유효합니다.
자주 나는 이슈: sizeof(long)/포인터 폭, 엔디안, char signedness, pthread, ABI 안정(libstdc++·glibc 버전) — “컴파일은 되는데 타깃에서만” 계열.
14. 빌드 시스템: Make vs CMake(비교)
작은 단일 디렉터리 Makefile은 곧 돌릴 수 있다—학습·과제엔 이게 편하다. 그러다 규칙·플랫폼·VPATH·서브프로젝트가 쌓이면 같은 걸 세 번씩 복붙하거나, .d·크로스·플래그가 따로 논으로 가기 쉽다. CMake는 CMakeLists.txt와 한 번 구성(생성) 단계를 겪는 대신, Ninja/VS/Xcode 같은 Generator 뒤에 팀 표준이 실린다. 의존 그래프는 Make에선 수동 + -MMD로 버티거나 팀 관례에 맡기고, CMake 쪽은 대상(타깃) 단위로 옵션이 붙는 쪽(프로젝트가 어떻게 쓰느냐에 따름). 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)
15. 디버그 vs 릴리스 빌드(최적화 레벨, -g, assert)
- Debug:
-O0또는-Og,-g3, 매크로DEBUG. 스텝/변수 관측이 쉬움(구현/ABI에 한함). - Release:
-O2/-O3,NDEBUG로assert제거(관례), PGO/LTO는 별 루틴(팀/도구). - 혼합:
assert·로그·추가 검사는 빌드 타입에 따라ifdef— “릴리스에만” 켜는TRACE도 팀 정책으로.
# Debug
make CFLAGS='-O0 -g3 -DDEBUG' ...
# Release
make CFLAGS='-O2 -DNDEBUG' ...
-ffast-math: 부동동등·errno·NaN 의미가 달질 수 있어 수치 코드는 팀 수학 리뷰 하에. UBSan/ASan는 “디버그+CI”에 넣는 경우가 늘고 있습니다(빌드/실행 비용).
16. 일반적 빌드 오류(같은 내용, 디버그 순서만)
앞 첫 절에서 흔한 증상은 이미 훑었다. 링크·빌드가 터질 때 권장 순서만 다시 박는다(표 말고 절차).
- 최소 재현 (단일
gcc명령). -Werroroff + 에러/경고 1건만 isolate.-M/-E차이로 전처리/경로 분리(9절).nm/readelf/ldd로 심볼/동적 뷰(5, 7, 10절).- CI에 같은 Docker/컨테이너로 동일
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할 수 있습니다. -###는 실행은 하지 않고 인자만 출력하는 변종이 있어(구현·버전), 스크립트 생성기로 쓰기도 합니다.
-pipe와 임시 파일
기본적으로 gcc는 단계 사이에 임시 파일을 쓰거나 파이프로 연결합니다. -pipe는 파이프를 선호해 디스크 I/O를 줄이지만, 매우 큰 번역 단위에서는 메모리 압박을 줄 수 있어, CI에서만 OOM이 난다면 이 옵션 조합을 의심할 수 있습니다(드문 케이스).
-x로 언어 강제
확장자가 비정상이거나 중간 산출물만 다시 컴파일할 때 -x c, -x assembler-with-cpp 등으로 입력 언어를 지정합니다. 크로스 스크립트나 제너레이터가 이상한 이름을 내보내면 -x가 필요해집니다.
부록 B — 전처리 심화: 매크로·헤더·가시성
매크로 인자와 괄호 함정
#define SQR(x) ((x)*(x))처럼 인자 전체를 괄호로 감싸는 습관은 a+b 치환 시 연산자 우선순위 사고를 막습니다. 반대로 토큰 붙이기(##)와 문자열화(#)는 디버그 매크로에서 유용하지만, 표준 C의 inline/static inline 함수로 대체 가능하면 타입 검사 측면에서 낫습니다.
시스템 헤더와 기능 매크로
man feature_test_macros (리눅스)에는 어떤 매크로를 얼마나 일찍 켜야 open(2)의 어떤 플래그가 보이는지가 정리되어 있습니다. 크로스 sysroot와 호스트의 glibc 버전이 다르면, 같은 소스라도 선언이 달라 “컴파일은 되는데 경고/크기가 이상하다”가 됩니다. 이때 -D_DEFAULT_SOURCE 등 일괄 프로파일을 팀에서 정하는 경우가 많습니다.
#include 중첩 깊이
과도한 헤더 트리는 전처리 시간과 -M 의존 그래프를 부풀립니다. 전방 선언(forward declaration)과 최소 헤더 원칙은 빌드 시간에도 직결됩니다.
부록 C — 컴파일·어셈블: IR, 인라인, LTO
인라인과 링크 단위
static inline 함수는 헤더에 정의를 두는 패턴이 흔합니다. 비static 인라인(C99 extern inline 규칙 등)은 툴체인·모드에 따라 미묘하므로, 팀 안에서는 한 가지 관례로 통일하는 것이 안전합니다. “ODR 유사” 문제는 동일 심볼이 서로 다른 의미로 링크되는 류의 버그로 이어질 수 있습니다.
LTO(Link Time Optimization)
-flto(Thin LTO: -flto=thin 등)는 목적 파일에 IR 비트를 남기고 링크 시에 걸쳐 최적화합니다. 장점 — 인라인·DCE가 번역 단위를 넘나듦. 단점 — 빌드 시간·메모리, 디버그 난이도, 툴체인 버전에 민감(아티팩트 호환). 릴리스 파이프라인에 도입 시 동일 버전의 ar/ranlib/gcc/ld를 묶어 고정(pinning)하는 팀이 많습니다.
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
섹션 단위로 오브젝트를 쪼개 죽은 코드/데이터를 링크 단계에서 걷어내 크기를 줄입니다. 단 악의적 main 미참조만으로 지워지면 안 되는 심볼이 있을 수 있어(플러그인·콜백 테이블), __attribute__((used)) 등 팀 규칙이 필요합니다.
부록 D — 목적 파일 안: 재배치·심볼(요약)
재배치(relocation)는 “이 주소는 아직 모른다”는 자리표시자입니다. 링커가 최종 로드 주소(또는 PIE/DSO의 상대 오프셋)를 정하면, call, 절대 주소 로드, GOT 엔트리가 고쳐집니다. readelf -r로 어떤 타입의 재배치가 있는지 볼 수 있습니다.
심볼 바인딩:
- STB_GLOBAL — 보통 강한 정의·참조.
- STB_WEAK — 기본 구현 제공·대체 가능 등(툴체인·ABI).
- 지역(local) — 다른
.o와 충돌하지 않음.
objcopy로 심볼만 다루거나, 스트립 수준을 조절해 배포 바이너리의 역공을 어렵게 하기도 합니다(디버그는 별도 패키지 권장).
부록 E — 정적 링크 심화: ar, ranlib, --whole-archive
ar rcs의 r/c/s**는 **삽입·생성**과 **심볼 인덱스(랜덤 액세스용)** 를 만듭니다. 오래된 시스템에선 ranlib libfoo.a를 **별도** 돌리기도 하지만, 요즘 ar s`에 통합된 경우가 많습니다.
고전 GNU ld 스타일의 함정: 정적 아카이브는 심볼이 필요할 때 멤버 .o를 당겨옵니다. 그래서 서로 의존하는 libA.a·libB.a는 한쪽이 다른 쪽 심볼을 끌기 전에 “대기” 상태가 생겨, 같은 라이브러리를 (...) 를 반복하거나 그룹 옵션(--start-group/--end-group, GNU ld)으로 감쌀 수 있습니다(성능/가독성 측면에선 의존 그래프 정리가 근본).
--whole-archive: 아카이브 멤버 전부를 강제로 링크할 때(드물게 플러그인 등). 끝낼 때 -Wl,--no-whole-archive로 범위를 닫는 습관이 필요해, Makefile에 주석을 권장합니다.
gcc -o app main.o -Wl,--whole-archive -lmyplugins -Wl,--no-whole-archive
부록 F — 동적 링크: soname, SONAME, DT_NEEDED
리눅스에서 libfoo.so.1.2.3 식 파일명은 배포·호환 정책의 일부입니다(프로젝트마다 상이). SONAME(readelf -d)은 “동적 링커가 어느 이름을 기대하나”이고, ldd 출력과 같이 봅니다. ABI 깨짐 시 soname을 올리는 소권(소버전) 규율이 중요합니다.
-fvisibility=hidden(GCC)로 기본 export를 줄이고, __attribute__((visibility("default")))로 API만 열면 링크 표면이 줄어 DSO 로딩·심볼 충돌이 완화됩니다(팀/플랫폼 정책).
부록 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로 타깃의 .pc를 가리킵니다. 수동으로 -I/-L을 나열하다 버전이 엇갈리는 사고는 빈번하므로, CMake의 pkg_check_modules 등이 팀 생태에서 쓰입니다(14절).
부록 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 -Rr / --debug는 규칙이 왜 특정 타깃에 매칭됐는지 추적할 때 쓰입니다(규칙이 많아질수록 가치).
부록 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를 짚지 않게 막는 데 효과적이나, 호스트 툴이 필요한 코드젠 단계가 있으면 예외가 생깁니다(프로젝트에 따라 NEVER/BOTH로 조절). 정확한 값은 대상 SDK 문서에 따릅니다.
cmake -B build -DCMAKE_TOOLCHAIN_FILE=arm-gnu.cmake 후 cmake --build build — Ninja가 생성되면 병렬·증분이 Make 대비 체감될 수 있습니다(14절·내부 링크).
부록 J — 디버그/릴리스와 관측 도구(추가)
소스와 어셈블리를 맞춰 보고 싶으면 objdump -S나 gdb의 layout asm이 대표고, -g가 있어야 “여기 C 줄”이 어느 기계어에 대응하는지 보기 편하다. 최적화로 변수가 사라졌다고 느껴질 땐 -O0로 한 번, 아니면 volatile이나(신중히) 디스어셈블로 “왜 틀리지” 류를 좁힌다. UBSan/ASan는 -fsanitize=undefined / address 같이 런타임에 오버레이를 심는 쪽이라 빌드·실행 비용이 있다. perf·valgrind는 호스트에서 프로파일·메모리 이슈를 잡는 데 쓰기 쉬운데, 임베디드 타깃이면 원격 gdb·샘플링·보드 쪽 전용 도구 쪽으로 간다(환경이 다르면 “맥/리눅스에서 잡힌 것”이 곧 타깃은 아님).
-D_FORTIFY_SOURCE=2 + -O 조합(환경)은 일부 glibc 경로에 compile-time/런타임 검사를 붙이지만, 툴체인·#include 순서에 따라 기대와 다를 수 있어, 팀에서 CFLAGS 고정이 중요합니다.
부록 K — 추가 문제해결(빌드/링크) 한 번 더
version 'GLIBC_2.xx' not found는 타깃 런타임이 구형인데, 링크는 최신 glibc 쪽에 맞춰진 경우에 잘 뜬다. 구 컨테이너/툴체인으로 아래 링크, 정적/혼용 정책, 도커에 낡은 루트fs를 쓰는 팀 루트가 있다. relocation R_X86_64_32S 류는 -fPIC 누락(64비트 DSO)일 때—-shared와 -fPIC를 DSO 쪽 관례에 맞출 것. DSO가 빈 .so만 남으면 링크에서 -shared를 빼먹었거나, .o만 넘기고 ld 라인이 틀어진 케이스다. collect2: ld returned 1는 한 줄 위—스크롤이 답. _FORTIFY_SOURCE가 최적화를 요구한다는 경고면 -O0+FORTIFY 조합을 의도했는지 본다(아니면 -O를 붙이거나 매크로를 정리). CI만 캐시 .o 꼬임이면 Clean / ninja -t clean / 헤더 의존 다시 확인. rpath에 $ORIGIN을 쓰는데 쉘이 $를 먹으면 ''나 Makefile에서 $$로 이스케이프—앞 첫 절이랑 겹쳐도, 티켓에 복붙해 두기 좋다.
부록 L — 관측 도구 빠른 참조(nm, readelf, objdump, strings)
다른 사람의 바이너리나 CI 산출물을 볼 때 자주 쓰는 조합입니다. 옵션·출력은 버전에 따라 다를 수 있으므로 man을 병행하세요.
nm — 심볼 한눈에
nm -C --defined-only app— 정의만, C++는-C로 디맹글(본 글은 C지만 혼합 빌드에 유효).nm -u app— 미해결(아직 동적으로 채울 외부).nm -D libfoo.so— 동적 심볼 테이블(-D).
출력의 첫 열은 가상 주소(목적/실행이냐에 따라 0일 수 있음), 플래그, 섹션, 이름 순인 경우가 많습니다.
readelf — ELF 구조
readelf -h a.out— ELF 헤더(클래스 32/64, 엔디안, 타입 EXEC/DYN/REL).readelf -l— 프로그램 헤더(세그먼트, INTERP, DYNAMIC).readelf -d— 동적 섹션(NEEDED,RPATH,RUNPATH,SONAME).
PIE인지, DSO인지 먼저 readelf -h로 구분하면 다음 질문(왜 ldd가 이렇게 보이나)이 좁혀집니다.
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) 쪽(9절과 엮인다). LDFLAGS는 링크 단계(주로 -L, -Wl,…)고, LDLIBS는 -l 나열을 맨 뒤에 두는 관습이 흔하다(10절 순서 이슈랑 겹침). PKG_CONFIG_PATH는 .pc를 추가로 찾는 경로, PKG_CONFIG_SYSROOT_DIR는 크로스에서 sysroot 앞에 붙이는 쪽(부록 G 맥락). PATH는 이름이 같은 gcc가 여러 개일 때 어느 것이 잡히느냐를 결정한다—which gcc + 버전을 티켓에 쓰자.
CI에서는 env | sort를 아티팩트로 남겨 재현에 쓰는 팀이 있습니다(비밀은 마스킹).
부록 N — clang 드라이버와 GNU와의 차이(개략)
많은 플래그는 GCC와 동일/유사하지만, 기본 stdlib·기본 링크 동작(특히 macOS·Xcode 툴체인)은 다릅니다. CI에 둘 다 넣는 경우 같은 CMakeLists.txt라도 생성 커맨드가 달라, “맥에선 됐는데” 류의 원인이 됩니다(표준·경고 기본값 포함).
- FreeBSD/NetBSD 등은 기본이
cc·clang인 경우가 많아, 반드시gcc로만 고정하지 않는 빌드가 이식성에 유리. -stdlib=libstdc++/libc++,-fuse-ld=lld,--target=…(크로스) 등은 팀에서 한 페이지에 모아두면 온보딩에 도움됩니다.
부록 O — Ninja와 단일 compile_commands.json
CMake + Ninja는 병렬·의존이 정교해 대형 C/C++ 레포에서 사실 표준에 가깝습니다. cmake -B build -G Ninja 후 ninja -C build.
compile_commands.json(컴파일 DB)은 clangd, IDE, 정적 분석이 동일 플래그로 인덱싱할 때 쓰입니다(경로/매크로 일치). Bear/intercept-build는 non-CMake Makefile에서도 생성을 돕습니다(도구·버전 확인).
부록 P — “컴파일은 됐는데” 체크리스트(1페이지)
- 이름 맹글링 없는 C인가? (본 글) —
_Z…아님. extern "C"경계 — C++에서 C API를 쓰는 지점만 일관되게.- 같은
sizeof/ 정렬 — 멀티 아키 헤더에서#pragma pack/ alignas 혼용 여부. inline/static/ 일반 — 팀 규칙 한 장.- 링크 마지막에 수학/스레딩 —
-lm위치,-lpthread는 리눅스에서 때로는 링커/버전에 따라-pthread선호(정책·표준진화 — 문서가 정답).
이 다섯 가지는 맨 앞 “흔한 증상” 절·16절 디버그 순서·부록 K를 같이 보면, 초보가 아닌 엔지니어에게도 “또 이거” 류의 시간을 줄여 줍니다.
부록 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
실제 배포에선 libworker.so.1 같은 이름을 같이 맞추는 ln -s 루틴이 더해집니다(부록 F). 애플리케이션 링크 시에는 libworker.so를 가리키는 개발용 심볼릭과 soname을 혼동하지 않게 문서화하세요.
테스트 실행과 LD_LIBRARY_PATH (개발 전용)
LD_LIBRARY_PATH="$(pwd)/lib" ./bin/app
운영에선 rpath·절대 경로·OS 패키지로 환경 주입을 피하는 편이 지속에 유리합니다(보안·재현). $ORIGIN 상대 rpath는 이동 가능한 tarball 배포에 자주 쓰입니다(10절).
부록 R — 약어·용어(이 글에서 쓰는 뜻)
TU(translation unit)는 보통 한 .c가 전처리로 펼쳐진 한 덩어리다. DSO(dynamic shared object)는 공유 .so 류를 가리킬 때 쓰는 말. PIE는 실행 파일이 주소를 매번 달리 잡을 수 있게(PIE) 링크된 케이스. PIC는 위치에 독립한 머신코드—DSO에 넣을 때 익숙하다. PLT/GOT은 절차·전역 간접 테이블 쪽(동적 심볼, 지연 바인딩, 플랫폼에 따라). ABI는 호출 규약·레이아웃·이름 규칙(맹글링은 C++). 시리즈 #04랑 잇는 게 좋다. RELRO는 재배치 끝난 뒤 일부를 읽기전용으로 잠가 GOT 덮어쓰기를 어렵게 하는 쪽(보안·플랫폼). LTO는 링크 단계에서 걸쳐 최적화(부록 C). soname은 동적 링커가 DSO 이름으로 호환 단서를 잡는 쪽(부록 F).
부록 S — 보안·하드닝(빌드 플래그) 한 스크린 더
아래는 “개괄”이며, 제품/플랫폼·감사 기준에 맞춰 팀이 골라야 합니다. 성능/호환 트레이드오프가 큼.
스택 쪽이면 -fstack-protector-strong 류—핫 함수에만 쓰는 쪽 등 구현·팀 정책이 갈린다. PIE는 컴파일 -fPIE + 링커 -pie로 ASLR 그림(환경)과 같이 말이 나온다. relro/now는 ld에 -z relro·-z now (또는 gcc에서 -Wl,…로) 넘기는 쪽—GOT 덮기 난이도·로딩 비용이 플랫폼마다 다르다. FORTIFY는 _FORTIFY_SOURCE + 최적화가 같이 쓰이는 케이스가 있고(8·부록 J), -O0만 켰을 때 효과·경고가 기대와 다를 수 있다. CFI는 클랭 쪽 -fsanitize=cfi 류로, LTO와 묶는 경로가 따로(툴·버전).
checksec류 스크립트는 바이너리에 PIE/RELRO/… 를 요약해 주지만, 툴이 낡으면 오해의 소지 — 최신 문서로 한 번 검증하세요.
부록 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(--build-id)를 켜 크래시 덤프와 심볼을 짝짓는 파이프라인은 리눅스 배포의 팀 표준에 자주 등장합니다(도구: debuginfod 등, 환경 의존).
부록 U — Windows·MinGW·MSYS2 메모(요약)
이 글의 기본은 ELF + GNU/Clang 쪽입니다. Windows·MinGW를 같이 쓰는 팀은 아래를 별도 원페이지로 두는 경우가 많습니다.
.a— 정적(Unix와 이름은 같을 수 있으나, 툴체인·COFF/PE 포맷).- import
.lib—.dll에 들어 있는 심볼을 링크 타임에 묶기 위한 스텁 역할을 하는 라이브러리(MSVC/MinGW에서 동적 링크 맥락). __declspec(dllexport)/dllimport— 내보내기·가져오기를 명시해 링크 오류를 빨리 드러냄.--out-implib(MinGW) — 공유 빌드 시 import library를 같이 쓰기 쉽게 생성..def— export 목록을 텍스트로 적는 대안(팀/레거시 정책).
Win ↔ Unix를 한 CI에서 돌릴 때는 쉘(sh vs cmd), 경로 구분자, 백슬래시 이스케이프가 엮여 “한 표로 정리”가 어렵습니다. 플랫폼마다 README 조각이나 cmake --preset으로 빌드 입장을 분리해 두는 편이 분쟁이 적습니다.
재현 가능한 한 줄(개념)
- 리눅스/Unix:
docker이미지에gcc버전·apt·CFLAGS고정 — 빌드 핑거프린트 공유. - Windows 팀: Visual Studio·WinSDK·
cmake --preset버전 고정 — 호스트 차이를 스크립트 안에 봉인.
부록 V — ccache·증분·클린 빌드(실무)
ccache는 동일한 컴파일 명령줄과 동일에 가까운 전처리 결과를 다시 만날 때, 캐시에 남은 산출물을 재사용합니다(해시·정책은 ccache 문서 참고). CI 캐시 히트율이 높으면 빌드 시간이 크게 줄지만, 헤더나 CFLAGS 변경이 캐시 키에 충분히 반영되지 않는 설정이면 낡은 .o가 섞이는 원인이 될 수 있으니, “이상한 증분”이 의심될 때 ccache -C, ninja -t clean 또는 make clean 경로를 팀 런북에 적어 둡니다.
clean만으로 부족하면 build/·out/ 같은 아티팩트 디렉터리를 통째로 지우고 전체 다시 맞춥니다(대형 모노레포는 시간이 들므로, “어느 폴더만 지울지” 단계를 문서화). git clean -fdx는 미추적 파일이 지워지니 필독 후 실행합니다.
distcc, Icecream(icecc) 등 분산 컴파일은 다른 차원의 가속입니다. 빌드 호스트마다 gcc -v, 라이브러리 버전이 어긋리면 “로컬에선 되는데” 류가 남는 경우가 있으므로, 동일 Docker/이미지나 툴체인 고정이 앞서야 합니다.
부록 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로 돌리고, 마지막에 한 번만 링크하는 패턴(11절·부록 Q)이 추적에 유리합니다.
-fuse-ld=gold, lld 등 링커 백엔드를 바꾸는 옵션을 쓰는 팀은, 이유와 고정해 둔 버전을 README에 한두 줄 남기는 것이, 이후 “링크만 이상”한 이슈를 끝까지 쫓을 때 시간을 아껴 줍니다.
부록 X — 팀이 문서에 적어 두면 좋은 CFLAGS/LDFLAGS 한 스냅
재현을 위한 최소 습관입니다. 릴리스 파이프라인에 그대로 넣을 수 있는 한 블록을 유지합니다.
- 컴파일러 전체 버전:
gcc --version첫 줄 + 빌드 ID(배포 시점에 고정). CFLAGS:-O,-g,-D,-std=…,-fPIC여부(DSO·PIE 정책).- 링크:
-L/-l,-Wl,…,-static사용 여부, rpath 정책. - sysroot/크로스: 툴체인 프리픽스,
--sysroot경로, 타깃 triplet.
Autotools(./configure + make) 스타일 레거시에는 config.log 앞부분이 힌트이고, CMake는 CMakeCache.txt에 같은 정보가 모읍니다. 민감한 경로·내부 절대경로는 마스킹한 뒤 문서에 올리는 습관이 안전합니다.
참고(표준·문서): C 언어 규약은 ISO/IEC 9899 문서에 있고, 링크·아카이브·동적 로더의 세부는 플랫폼 ABI·툴체인 매뉴얼(man ld, man gcc, Binutils 매뉴얼)이 정답에 가깝습니다. 표준이 없는 동작(예: weak 심볼 해석의 모서리)은 사용 중인 툴체인 버전의 설명이 최우선입니다.
- System V x86-64 ABI(호출 규약·정렬) — 시리즈 #04 링크와 함께 읽을 것.
man7계 —man 7 feature_test_macros등(리눅스) 기능 매크로 맵.
정리하면, 1~16절은 빌드 전 과정을 덮고, 부록 K·L과 16절·앞 첫 절은 장애 시 먼저 북마크해 두기 좋습니다. 팀 위키에 “undefined reference면 링크 순서”, “file in wrong format이면 호스트·타깃 .o 혼입”처럼 한 줄 치트시트를 올려 두면 온보딩에도 도움이 됩니다.
gcc -v로 한 번에 드라이버에 넘어가는 인자 전부를 확인한다..i/.s/.o중 한 단계만 다시 뽑아 보면, 어느 서브툴에서 깨졌는지 좁힌다..d자동 의존이 없는Makefile이면, 오래된.o혼입을 의심하고clean후 전체 재빌드한다.- 크로스에서는
file·readelf로 기대한 아키텍처·ELF인지 검증한다. - 배포 바이너리에 대해
readelf -d로NEEDED·RPATH스냅샷을 티켓·런북에 남기면 재현이 쉬워진다.
참고: man gcc 옵션 목록은 방대하므로, 실제로 쓰는 플래그만 docs/toolchain.md에 정리해 두고, 컴파일러 버전을 올릴 때마다 같이 리뷰하는 팀이 흔합니다.
배포 전에 make 또는 ninja 로그 앞부분의 gcc -v 출력 한 블록을 티켓에 첨부하면, “어느 머신”이 아니라 “어느 플래그”로 원인을 좁히는 데 도움이 됩니다.
COLLECT_GCC_OPTIONS=…가 함께 나오면, 드라이버에 전달된 옵션을 한 줄로 파악하기 쉬운 경우가 많다. 형식은 GCC 버전에 따라 다를 수 있다(gcc -v 전체 로그 일부).
(이상 116절·부록 AX.) 빌드 이슈 티켓에는 절 번호만 적어도(예: 「10절 링크 순서」) 같은 맥락으로 답하기 쉽다. 분량 목표가 아니라, 각 단계 산출물이 지키는 계약을 읽는 습관이 이 글의 목표다.
시리즈 맺음: 체인 사고
C 품질은 문법과 함께 컴파일러, 링커, OS 로더, ABI에 의해 결정됩니다. 이 글은 빌드를 “보이는 커맨드”가 아니라, “어떤 산출물이 다음 단계의 계약인가”의 연속으로 읽는 지도이자, 팀 재현과 배포에 바로 쓰는 체크리스트이기를 바랍니다.
관련 — 시리즈·심화: C 언어 완벽 가이드 · 동적 메모리 · C++ 링킹(정적/동적·undefined) · 빌드 시스템 비교
내부 동작(압축): 파이프라인·심볼
앞의 절을 “관측 포인트”로 압축하면 다음과 같습니다. 불변식 — 각 단계는 이전 산출물을 계약으로 가정하고, 심볼/재배치는 링크·로드에서 완성됩니다. 결정성 — CFLAGS/LDFLAGS/경로가 달리면 동일 소스도 다른 바이너리(해시)가 됩니다(재현 빌드). 경계 비용 — 전처리의 거대 inline/매크로, -O3 벡터화의 메모리, DSO의 GOT/PLT와 명령 캐시.
flowchart TD P[전처리: 매크로·include] --> C1[컴파일: IR/최적화] C1 --> A[어셈블: .o + 심볼+재배치] A --> L[링크: 주소/DSO/아카이브] L --> B[실행/로딩+동적해석]
FAQ (본문 보강)
Q. static 전역이 링커에 보이지 않는다는 뜻은?
A. static 파일 범위 심볼은 내부 링크로, 다른 번역 단위에 export되지 않습니다(일반). 그래서 이름이 같아도 (서로 다른 .c끼리) 맞닿지 않는 것이 정상이며(구현/표준 세부는 C 표준/ABI 참조), “헤더에 넣는 static 함수”는 인클루드 시 번역 단위마다 복제될 수 있어 ODR/코드 팽창을 관리하세요(인라인과 비슷한 주의).
Q. LD_PRELOAD는 왜 CI에서 금지인가?
A. 동일 심볼이 다른 구현으로 바꿔 끼워질 수 있어, 시험/보안/재현 측면에서 취약합니다. rpath, 툴체인 고정(pinning), LD_LIBRARY_PATH 절제는 팀 런북에 쓰입니다.
Q. nm이 T와 U는 무엇인가?
A.(GNU nm 관례) T는 text에 정의, U는 undefined(링크/동적), 소문자는 local 류(플래그/플랫폼). 툴체인 man nm을 항상 기준에 두세요.
같이 보면 좋은 글 (내부)
키워드: C, 링커, 심볼, 동적 링크, 빌드 시스템, gcc, make, cmake, rpath, PIC, ar, so.