Devin.KR

C++ 빌드 구조 - 헤더와 소스 분리, 링크 오류 읽기, CMake 개념 (C++ 객체와 자원 관리 11장)

개발자 조회 1

이 장에서 배우는 것

지금까지는 파일 하나에 모든 코드를 넣고 clang++ 파일.cpp 한 번으로 실행 파일을 만들었다. 실제 프로젝트는 수십에서 수천 개의 소스 파일로 이루어지고, 빌드는 컴파일링크 두 단계로 나뉜다. 이 구조를 모르면 "선언은 했는데 undefined symbol이 난다", "헤더에 함수를 넣었더니 duplicate symbol이 난다" 같은 오류 앞에서 손을 쓸 수 없다. 게임 엔진, 펌웨어, ROS 패키지 어디서든 가장 먼저 부딪히는 벽이다.

  • 로봇 주행 거리계(오도메트리) 코드를 헤더 하나와 소스 두 개로 나눈다.
  • 각 소스를 -c로 따로 컴파일하고 직접 링크해 본다. nm으로 오브젝트 파일 안의 기호를 들여다본다.
  • 링크 오류 두 가지(정의 없음, 정의 중복)와 헤더를 못 찾는 오류를 일부러 만들고 읽는다.
  • 같은 구조를 CMake로 표현하는 방법을 설명한다. CMake 설치 없이 읽을 수 있게 구성했고, 이 장의 검증은 clang++를 직접 불러서 했다.

문제 상황

신입 개발자가 주행 거리 계산 클래스를 odometry.h에 선언하고 odometry.cpp에 구현한 뒤, main.cpp에서 헤더를 포함해 썼다. 에디터에서는 빨간 줄이 하나도 없는데, 빌드하면 "Undefined symbols"가 나온다. 급한 마음에 구현을 전부 헤더로 옮겼더니 이번에는 다른 파일에서 같은 헤더를 포함하자 "duplicate symbol"이 난다. 두 오류는 반대처럼 보이지만 원인은 하나다. 컴파일러는 파일 하나씩만 보고, 파일들을 합치는 일은 링커가 한다는 사실을 모르는 것이다.

완성 코드

폴더 구조는 다음과 같다.

build_demo/
  CMakeLists.txt
  include/odometry.h
  src/odometry.cpp
  src/main.cpp

헤더 include/odometry.h:

#pragma once

#include <string>

namespace robot {

struct Pose {
    double x = 0.0;
    double y = 0.0;
};

class Odometry {
public:
    explicit Odometry(double wheel_radius_m);

    void add_ticks(int left, int right);
    Pose pose() const { return pose_; }
    std::string summary() const;

private:
    double meters_per_tick_;
    Pose pose_;
};

inline double to_cm(double meters) { return meters * 100.0; }

}  // namespace robot

구현 src/odometry.cpp:

#include "odometry.h"

#include <sstream>

namespace robot {

namespace {
constexpr int kTicksPerTurn = 1000;
constexpr double kPi = 3.141592653589793;
}  // namespace

Odometry::Odometry(double wheel_radius_m)
    : meters_per_tick_(2.0 * kPi * wheel_radius_m / kTicksPerTurn) {}

void Odometry::add_ticks(int left, int right) {
    // 직진만 다루는 단순화: 두 바퀴 평균만큼 x 로 전진
    pose_.x += (left + right) / 2.0 * meters_per_tick_;
}

std::string Odometry::summary() const {
    std::ostringstream out;
    out.precision(1);
    out << std::fixed << "x=" << to_cm(pose_.x) << "cm";
    return out.str();
}

}  // namespace robot

사용하는 쪽 src/main.cpp:

#include <iostream>

#include "odometry.h"

int main() {
    robot::Odometry odom(0.05);
    for (int i = 0; i < 3; ++i) odom.add_ticks(1000, 1000);
    std::cout << "3바퀴 뒤 " << odom.summary() << '\n';
}

줄별 해설

#pragma once

한 소스 파일 안에서 같은 헤더가 여러 번 포함되어도(다른 헤더를 거쳐 간접적으로) 한 번만 읽게 한다. 표준은 아니지만 주요 컴파일러가 모두 지원한다. 전통적인 방법은 #ifndef ODOMETRY_H / #define / #endif 로 감싸는 인클루드 가드다. 둘 다 한 소스 파일 안의 중복만 막는다. 서로 다른 소스 파일이 각자 헤더를 포함하는 것은 막지 않고, 막아서도 안 된다.

헤더에는 선언, 소스에는 정의

헤더에는 "이런 클래스와 함수가 있다"는 선언을 둔다. Odometry(double), add_ticks, summary의 본문은 odometry.cpp에만 있다. main.cpp는 선언만 보고도 컴파일된다. 호출할 함수의 이름과 타입만 알면 "여기서 그 함수를 부른다"는 기록을 남길 수 있기 때문이다. 실제 주소는 링크 때 채워진다.

클래스 안에서 정의한 pose()inline double to_cm

예외가 두 가지 있다. 클래스 본문 안에 정의한 멤버 함수(pose())는 자동으로 inline이고, 헤더의 자유 함수에는 inline을 직접 붙였다. inline은 "여러 소스 파일에 같은 정의가 나타나도 하나로 취급하라"는 표시다. 짧은 함수나 10장의 템플릿처럼 본문이 헤더에 있어야 하는 코드는 이렇게 둔다.

익명 이름공간 namespace { ... }

kTicksPerTurn, kPiodometry.cpp 안에서만 쓰는 상수다. 익명 이름공간에 넣으면 이 파일 밖에서는 보이지 않아(내부 연결), 다른 파일의 같은 이름과 충돌하지 않는다. C의 파일 범위 static과 같은 역할이다.

namespace robot

모든 이름을 robot:: 아래에 두어 다른 라이브러리의 Pose, Odometry와 섞이지 않게 한다. 링크 오류 메시지에 robot::Odometry::add_ticks(int, int)처럼 전체 이름이 찍히는 것도 이 덕분이다.

실제 실행 결과

odometry.h 는 두 소스 파일에 포함된다. 각 소스는 따로 컴파일되어 오브젝트 파일이 되고, 링커가 두 오브젝트 파일을 합쳐 실행 파일 odom_demo 를 만든다.

먼저 두 소스를 각각 컴파일만 한다(-c). -Iinclude는 헤더를 찾을 폴더를 알려 준다.

$ cd build_demo && clang++ -std=c++20 -Wall -Wextra -Iinclude -c src/odometry.cpp -o odometry.o && clang++ -std=c++20 -Wall -Wextra -Iinclude -c src/main.cpp -o main.o && ls *.o
main.o
odometry.o

오브젝트 파일(.o) 두 개가 생겼다. 이제 링크해서 실행한다.

$ cd build_demo && clang++ main.o odometry.o -o odom_demo && ./odom_demo
3바퀴 뒤 x=94.2cm

바퀴 반지름 5cm, 1회전 1000틱이면 1000틱에 약 31.4cm를 가고, 세 번이면 94.2cm다. nm으로 odometry.o가 무엇을 정의하고 있는지 보자(T는 이 파일에 코드가 있는 기호).

$ cd build_demo && nm -C odometry.o | grep ' T robot::'
0000000000000064 T robot::Pose::Pose()
0000000000000470 T robot::Pose::Pose()
00000000000003e0 T robot::to_cm(double)
00000000000000c4 T robot::Odometry::add_ticks(int, int)
0000000000000090 T robot::Odometry::Odometry(double)
0000000000000000 T robot::Odometry::Odometry(double)
0000000000000108 T robot::Odometry::summary() const

main.o에 없는 세 함수의 정의가 여기 있다. 링커는 main.o의 "필요한 기호" 목록을 odometry.o의 "정의한 기호"로 채운다. Pose::Pose()to_cm처럼 헤더에서 온 인라인 함수도 보이는데, 같은 것이 main.o에도 있을 수 있고 링커가 하나만 남긴다.

오류 1: 정의가 없다

$ cd build_demo && clang++ main.o -o broken
Undefined symbols for architecture arm64:
  "robot::Odometry::add_ticks(int, int)", referenced from:
      _main in main.o
  "robot::Odometry::Odometry(double)", referenced from:
      _main in main.o
  "robot::Odometry::summary() const", referenced from:
      _main in main.o
ld: symbol(s) not found for architecture arm64
clang++: error: linker command failed with exit code 1 (use -v to see invocation)

odometry.o를 빼고 링크했다. 오류는 "어떤 기호가 없는데(robot::Odometry::add_ticks(int, int)), 누가 필요로 했는지(_main in main.o)"를 알려 준다. 컴파일은 이미 성공했다는 점이 중요하다. 이 오류가 나면 코드를 볼 것이 아니라 빌드 명령(또는 CMake의 소스 목록)을 봐야 한다. 함수를 선언만 하고 정의를 잊었거나, 정의의 매개변수 타입이 선언과 조금 달라도(intlong) 같은 오류가 난다.

오류 2: 헤더를 못 찾는다

$ cd build_demo && clang++ -std=c++20 -Wall -Wextra -c src/main.cpp
src/main.cpp:3:10: fatal error: 'odometry.h' file not found
    3 | #include "odometry.h"
      |          ^~~~~~~~~~~~
1 error generated.

-Iinclude를 빼고 컴파일했다. 이것은 링크가 아니라 컴파일 단계의 오류다. 헤더 검색 경로 문제다.

오류 3: 정의가 두 개다

이번에는 헤더에 inline 없이 함수 본문을 넣었다.

#pragma once

double deg_to_rad(double deg) { return deg * 3.141592653589793 / 180.0; }

arm.cppmain.cpp가 모두 이 헤더를 포함한다.

$ cd odr_demo && clang++ -std=c++20 -Wall -Wextra -c arm.cpp && clang++ -std=c++20 -Wall -Wextra -c main.cpp && clang++ arm.o main.o -o odr
duplicate symbol 'deg_to_rad(double)' in:
    odr_demo/arm.o
    odr_demo/main.o
ld: 1 duplicate symbols
clang++: error: linker command failed with exit code 1 (use -v to see invocation)

필요한 정의가 어느 오브젝트 파일에도 없으면 undefined symbol, 같은 정의가 두 오브젝트 파일에 있으면 duplicate symbol 이다. 둘 다 컴파일은 통과하고 링크에서 실패한다.

#pragma once가 있는데도 오류가 났다. #pragma once는 소스 파일 하나 안의 중복만 막고, 두 소스 파일은 각자 헤더를 한 번씩 포함해 각자 deg_to_rad의 정의를 가진다. C++의 단일 정의 규칙(ODR)은 일반 함수의 정의가 프로그램 전체에 하나만 있을 것을 요구한다. 고치는 방법은 둘이다. 헤더의 정의에 inline을 붙이거나, 선언만 헤더에 남기고 본문을 .cpp 하나로 옮긴다.

표 11-1. 빌드 오류를 단계별로 읽기

메시지단계보통의 원인먼저 볼 곳
file not found전처리·컴파일헤더 검색 경로 누락-I, target_include_directories
Undefined symbols / undefined reference링크정의가 있는 .o·라이브러리가 빠짐, 선언과 정의 불일치링크 목록, 매개변수 타입
duplicate symbol / multiple definition링크헤더에 inline 없는 정의헤더의 함수·전역 변수
템플릿 함수만 undefined링크템플릿 본문을 .cpp에 둠본문을 헤더로(10장)

(macOS의 링커는 "Undefined symbols", GNU 링커는 "undefined reference"라고 쓴다. 뜻은 같다.)

같은 구조를 CMake로

파일이 두세 개일 때는 명령을 직접 쳐도 되지만, 파일이 늘고 Windows·Linux·임베디드 크로스 컴파일까지 지원해야 하면 빌드 명령을 생성해 주는 도구가 필요하다. C++ 생태계의 사실상 표준이 CMake다. 위 명령들을 CMake로 적으면 이렇다.

cmake_minimum_required(VERSION 3.20)
project(odometry_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(odometry src/odometry.cpp)
target_include_directories(odometry PUBLIC include)
target_compile_options(odometry PRIVATE -Wall -Wextra)

add_executable(odom_demo src/main.cpp)
target_link_libraries(odom_demo PRIVATE odometry)

option(ENABLE_SANITIZERS "sanitizer 로 빌드" OFF)
if(ENABLE_SANITIZERS)
  target_compile_options(odometry PUBLIC -fsanitize=address,undefined -fno-omit-frame-pointer)
  target_link_options(odometry PUBLIC -fsanitize=address,undefined)
endif()
  • add_library(odometry src/odometry.cpp)odometry.cpp를 컴파일해 라이브러리 대상 odometry를 만든다. 위의 odometry.o에 해당한다.
  • target_include_directories(odometry PUBLIC include)-Iinclude에 해당한다. PUBLIC은 "이 라이브러리를 쓰는 대상에게도 이 경로를 전달하라"는 뜻이라, odom_demo를 컴파일할 때도 자동으로 붙는다.
  • add_executable + target_link_librariesmain.cpp를 컴파일하고 odometry와 링크한다. 오류 1은 이 줄을 빼먹었을 때 나는 오류다.
  • option(ENABLE_SANITIZERS ...) — 12장의 검사 빌드를 켜고 끄는 스위치다.

CMake가 설치된 환경이라면 cmake -S build_demo -B build(설정)와 cmake --build build(빌드) 두 명령으로 위 과정을 대신한다. 이 책의 검증 환경에는 CMake를 설치하지 않았으므로 이 두 명령의 출력은 싣지 않는다. 실행 결과로 실은 것은 모두 clang++를 직접 부른 결과다.

표 11-2. 손으로 친 명령과 CMake 명령의 대응

손으로 친 명령CMakeLists.txt
-std=c++20set(CMAKE_CXX_STANDARD 20)
-Iincludetarget_include_directories(... PUBLIC include)
clang++ -c src/odometry.cppadd_library(odometry src/odometry.cpp)
clang++ main.o odometry.o -o odom_demoadd_executable + target_link_libraries
-Wall -Wextratarget_compile_options(... PRIVATE -Wall -Wextra)

실무에서 자주 틀리는 것

1. 헤더에 전역 변수를 정의한다

int frame_count = 0;을 헤더에 쓰면 함수와 똑같이 duplicate symbol이 난다. 헤더에는 extern int frame_count;(선언)를 두고 .cpp 하나에 정의하거나, C++17의 inline int frame_count = 0;을 쓴다.

2. 헤더에 using namespace std;

그 헤더를 포함한 모든 파일에 std의 이름이 쏟아져 들어간다. 이름 충돌이 헤더를 포함한 쪽에서 엉뚱하게 터진다. 헤더에서는 항상 std::를 붙여 쓴다.

3. 헤더가 필요한 것을 스스로 포함하지 않는다

odometry.hstd::string을 쓰므로 <string>을 직접 포함했다. 빼먹어도 main.cpp가 우연히 먼저 <iostream>을 포함했다면 컴파일되지만, 포함 순서가 바뀌는 순간 깨진다. 헤더는 혼자 포함해도 컴파일되어야 한다.

4. 링크 오류를 코드에서 찾는다

표 11-1처럼 링크 오류는 대개 빌드 설정 문제다. 오류 메시지의 기호 이름을 nm 출력에서 찾아 "어느 .o에 있어야 하는가"부터 확인한다.

연습 문제

  1. 바퀴 반지름 0.05m, 한 바퀴 1000틱일 때 add_ticks(1000, 1000)을 세 번 부르면 summary()는 몇 cm를 보여 주는가? 계산 과정을 써라.
  2. to_cm은 헤더에 본문이 있는데도 odometry.cppmain.cpp(또는 다른 소스) 양쪽에서 써도 duplicate symbol이 나지 않는다. 이유는 무엇인가?
  3. odr_demo의 duplicate symbol을 고치는 방법 두 가지를 쓰고, 각 방법이 어떤 경우에 알맞은지 설명하라.

정답

  1. 한 바퀴 거리 = 2 × π × 0.05 ≈ 0.31416m. 1000틱이 한 바퀴이므로 한 번 호출에 두 바퀴 평균 1000틱 = 0.31416m, 세 번이면 0.94248m = 94.2cm(소수점 한 자리)다. 실행 결과의 x=94.2cm와 같고, 검증 프로그램에서 94.2477cm와의 차이가 0.01 미만인 것을 확인했다.
  2. inline이 붙어 있기 때문이다. inline 함수는 여러 번역 단위에 같은 정의가 있어도 되고, 링커가 하나만 남긴다. 검증 프로그램(answers/ch11.cpp)이 to_cm을 쓰면서 odometry.cpp와 함께 링크되는 것을 확인했다.
  3. (가) 헤더의 정의에 inline을 붙인다. 짧고 자주 불려 인라인 확장 이득이 있는 함수, 템플릿에 알맞다. (나) 헤더에는 double deg_to_rad(double deg); 선언만 두고 본문을 units.cpp 하나로 옮긴다. 본문이 길거나, 본문을 바꿀 때마다 헤더를 포함한 모든 파일이 다시 컴파일되는 것을 피하고 싶을 때 알맞다.

마지막 장에서는 지금까지 책 곳곳에서 본 "조용히 틀리는" 버그들을 실행 중에 잡아내는 도구, sanitizer와 정적 분석을 정리한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.