본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Computer Science

GCC 컴파일과 링크

작성 2026. 10. 14.추가
목차9개 항목
출처와 참고

팔만코딩경(42Seoul 커뮤니티 위키)의 「GCC 컴파일러 개념 정리」, 「Libft 과제에서 만든 라이브러리(libft.a)를 사용해보자」, 「공유 라이브러리를 링크하는 방법」, 「헤더 중복 방지 기법 세 가지(#pragma once)」, 「POSIX cc, C 컴파일러」, 「컴파일만하면 다 해주는게 아니다.?! (어셈블러 찍먹)」을 GCC 공식 문서와 함께 재구성한 노트다. 「단계별로 멈추기」 절의 목적 파일과 실행 파일의 차이는 강민철의 「혼자 공부하는 컴퓨터 구조+운영체제」 강의(한빛미디어)를 바탕으로 보강했다. 「링커와 로더」 절은 fru1tworld/cs-basic 모의 면접 질문 목록을 점검해 빠진 개념을 보강했다.

컴파일러 드라이버

gcc나 clang은 컴파일러 하나가 아니라 드라이버다. 전처리기, 컴파일러 본체, 어셈블러(as), 링커(ld)를 차례로 불러 소스를 실행 파일로 만든다. 4단계 흐름 자체는 C 기초에 있고, 이 노트는 각 단계를 옵션으로 다루는 법을 정리한다.

GCC는 GNU Compiler Collection의 약자로, 자유 소프트웨어 운동을 이끈 GNU 프로젝트의 컴파일러 모음이다. macOS에서 gcc나 cc를 실행하면 실제로는 Apple Clang이 돈다. 옵션은 대부분 호환된다. POSIX는 표준 C 컴파일러 명령으로 c99/c17 유틸리티를 규정하고, 유닉스 계열에서 cc는 시스템 기본 C 컴파일러를 가리키는 관례적 이름이다.

단계별로 멈추기

옵션멈추는 지점결과
-E전처리 후매크로가 펼쳐지고 헤더가 붙은 코드(표준 출력)
-S컴파일 후어셈블리 .s
-c어셈블 후오브젝트 .o (링크하지 않음)
(없음)링크까지실행 파일 (-o로 이름 지정, 기본 a.out)

-c 없이 main이 없는 파일을 컴파일하면 링커가 undefined symbol: _main 오류를 낸다. 여러 파일을 따로 -c로 컴파일한 뒤 오브젝트끼리 링크하는 것이 분할 컴파일이고, make는 바뀐 파일만 다시 -c 한다.

-E 결과를 열어 보면 #include <stdio.h> 한 줄이 헤더 전체(printf 선언 포함)로 펼쳐져 수백 줄이 된다. -c로 만든 .o는 이미 기계어라 문자열 리터럴("hello")도 바이트로 들어 있지만, 목적 파일은 실행 파일이 아니다. main.c가 helper.c의 add()를 부르면 main.o에는 add의 주소가 비어 있는 미해결 심볼이 남는다(nm main.o에서 U로 표시). 링커가 main.o와 helper.o, 표준 라이브러리를 묶으며 이 자리를 채워야 비로소 실행할 수 있다.

-S로 뽑은 어셈블리를 고쳐 cc main.s로 다시 빌드할 수도 있다. 컴파일러가 만든 코드를 읽어 최적화가 어떻게 되었는지 확인할 때 주로 쓴다. 온라인 도구 Compiler Explorer(godbolt.org)가 같은 일을 브라우저에서 해 준다.

자주 쓰는 옵션

분류옵션뜻
경고-Wall -Wextra흔한 실수 경고를 켠다
-Werror경고를 오류로 취급
-pedantic표준을 벗어난 확장 사용을 경고
표준-std=c17, -std=c23따를 C 표준. GCC 15부터 기본값은 gnu23
전처리-Idir헤더 검색 경로 추가
-DNAME=value매크로 정의 (-DBUFFER_SIZE=42)
링크-Ldir라이브러리 검색 경로 추가
-lnamelibname.a 또는 libname.so를 링크
디버깅-g디버깅 심볼 포함 → C 디버깅 도구
-fsanitize=address,undefined런타임 메모리·UB 검사
최적화-O0 -O2 -O3 -Os최적화 수준. 디버깅할 때는 -O0
의존성-MMD -MPMakefile용 헤더 의존성 파일 생성

라이브러리

라이브러리는 다른 프로그램에 링크되도록 미리 컴파일해 둔 함수 모음이다. 언제 실행 파일에 붙느냐에 따라 둘로 나뉜다.

구분정적 라이브러리공유(동적) 라이브러리
확장자.a(유닉스), .lib(윈도).so(리눅스), .dylib(macOS), .dll(윈도)
붙는 시점링크할 때 필요한 오브젝트를 실행 파일에 복사실행할 때 로더가 메모리에 올려 연결
장점배포가 단순, 실행 환경에 의존하지 않음실행 파일이 작고, 여러 프로세스가 메모리를 공유, 라이브러리만 교체해 업데이트
단점실행 파일이 커지고 라이브러리 수정 시 다시 링크실행 환경에 맞는 버전이 있어야 함
# 정적 라이브러리: 오브젝트를 아카이브로 묶는다
cc -c ft_strlen.c ft_memset.c
ar rcs libft.a ft_strlen.o ft_memset.o   # r: 추가/교체, c: 조용히 생성, s: 심볼 인덱스
 
# 공유 라이브러리: 위치 독립 코드로 컴파일
cc -fPIC -c ft_strlen.c ft_memset.c
cc -shared -o libft.so ft_strlen.o ft_memset.o
 
# 사용: 헤더는 -I, 라이브러리는 -L과 -l (앞의 lib와 확장자를 뗀 이름)
cc main.c -Iinclude -L. -lft -o app

링크 순서

정적 라이브러리를 링크할 때 링커는 명령줄을 왼쪽에서 오른쪽으로 한 번 훑는다. 아카이브를 만나는 순간 "지금까지 정의되지 않은 심볼"을 채울 오브젝트만 꺼낸다. 그래서 쓰는 쪽이 앞, 제공하는 쪽이 뒤여야 한다.

cc main.o -lftprintf -lft   # 성공: main → ftprintf → ft 순으로 의존
cc main.o -lft -lftprintf   # 실패: ft를 지나간 뒤에야 ftprintf가 ft의 함수를 찾음

공유 라이브러리를 실행할 때

링크에 성공해도 실행 시 로더가 라이브러리를 못 찾으면 cannot open shared object file(리눅스)이나 Library not loaded(macOS) 오류가 난다. 로더는 기본 경로(/usr/lib 등)와 실행 파일에 기록된 경로를 찾는다.

방법설명
rpath 기록cc main.o -L. -lft -Wl,-rpath,'$ORIGIN'(리눅스) 또는 -Wl,-rpath,@executable_path(macOS)
환경 변수LD_LIBRARY_PATH(리눅스), DYLD_LIBRARY_PATH(macOS, SIP 보호 대상에선 무시됨). 임시 확인용
시스템 경로에 설치/usr/local/lib에 두고 리눅스는 ldconfig

ldd app(리눅스)이나 otool -L app(macOS)으로 실행 파일이 어떤 공유 라이브러리를 요구하는지 확인한다. 동적 링킹된 함수는 호출 자리에 작은 stub(ELF에서는 PLT 항목)을 거친다. 처음 호출될 때 실제 주소를 찾아 GOT에 채우고, 이후 호출은 바로 간다(지연 바인딩). 라이브러리 코드 페이지는 물리 메모리에 한 벌만 올라가 여러 프로세스가 공유한다(메모리 관리).

링커와 로더

구분링커 (ld)로더
언제빌드할 때실행할 때
입력 → 출력목적 파일 여러 개와 라이브러리 → 실행 파일실행 파일 → 메모리 위의 프로세스 이미지
하는 일심볼 해석(미해결 심볼을 정의와 짝짓는다), 재배치(각 목적 파일의 코드·데이터를 한 주소 공간에 배치하고 주소 참조를 고친다)실행 파일의 세그먼트를 주소 공간에 매핑하고, 스택에 인자와 환경 변수를 놓고, 공유 라이브러리를 적재·연결한 뒤 진입점으로 점프한다
대표 오류undefined reference, multiple definitioncannot open shared object file

리눅스 ELF에서 로더의 일은 커널과 사용자 공간 프로그램이 나눠 맡는다.

flowchart TB
  fk["fork()<br/>부모를 복제해 자식 프로세스를 만든다. 아직 같은 프로그램이다"]
  subgraph ex["execve(): 커널이 로더 1단계를 맡는다"]
    e1["기존 주소 공간을 버리고, ELF 헤더대로 코드·데이터 세그먼트를 mmap한다<br/>(실제 내용은 요구 페이징으로 처음 접근할 때 읽힌다)"]
    e2["스택에 argc, argv, envp, 보조 벡터를 놓는다"]
    e3["PT_INTERP에 적힌 동적 로더(/lib64/ld-linux-x86-64.so.2)를 매핑하고 그쪽으로 점프"]
    e1 --> e2 --> e3
  end
  subgraph ld["ld.so: 사용자 공간의 동적 로더가 2단계를 맡는다"]
    l1["필요한 .so를 찾아 매핑하고 재배치(GOT 채우기), 초기화 함수 실행"]
  end
  st["_start"]
  mn["main"]
  fk --> e1
  e3 --> l1
  l1 --> st --> mn
  • 그래서 「fork·exec로 프로세스를 적재한다」와 「로더가 적재한다」는 같은 이야기다. fork가 프로세스라는 그릇을 만들고, exec가 커널의 로더를 불러 그 그릇에 새 프로그램을 채운다(프로세스와 스레드). 정적으로 링크한 실행 파일은 PT_INTERP가 없어 커널이 곧바로 진입점으로 보낸다.
  • fork 없이 적재하는 길도 있다. posix_spawn은 생성과 적재를 한 번에 하고(glibc는 내부적으로 clone + exec), Windows의 CreateProcess도 마찬가지다. 실행 중에 라이브러리를 더 올리는 dlopen은 ld.so가 맡는 실행 중 적재다.
  • 언어 구현마다 실행 전 단계가 어떻게 다른지(네이티브 컴파일, 바이트코드 + JIT, 인터프리터)는 프로그램이 실행되기까지에 있다.

헤더 중복 포함 방지

헤더가 다른 헤더를 포함하다 보면 같은 헤더가 한 번역 단위에 두 번 들어가 구조체 재정의 오류가 난다.

/* include guard: 모든 컴파일러에서 동작하는 표준 방식 */
#ifndef LIBFT_H
# define LIBFT_H
/* 선언들 */
#endif
 
/* #pragma once: 한 줄로 같은 효과. 표준은 아니지만 GCC·Clang·MSVC 모두 지원 */
#pragma once

#pragma once는 매크로 이름 충돌 걱정이 없고 짧다. 다만 같은 파일이 심볼릭 링크나 복사본으로 다른 경로에 있으면 다른 파일로 볼 수 있다. 이식성이 가장 중요하면 include guard를 쓰고, 둘을 함께 쓰는 프로젝트도 있다.

정리

  • gcc/clang은 드라이버다. -E -S -c로 단계마다 결과를 볼 수 있다.
  • 개발할 때는 -Wall -Wextra -g -fsanitize=address,undefined -O0, 배포는 -O2가 기본 조합이다.
  • 정적 라이브러리는 ar rcs, 공유 라이브러리는 -fPIC -shared로 만든다.
  • 정적 링크는 의존하는 쪽을 앞에 둔다. 공유 라이브러리는 실행 시 탐색 경로까지 챙긴다.

관련 노트: Makefile과 make, C 기초: 컴파일, 자료형, 배열과 문자열, C의 스코프와 링키지, C의 역사와 표준

원본 기록9

댓글

GitHub 계정으로 댓글을 남길 수 있어요.