96 실전의 C — 도구, 프로젝트, 그리고 영역
먼저 알아야 할 것
돌아보기
54장에서 여러 파일 프로그램은 각 파일을 목적 파일로 만들어 두었다가 링크만 다시 하는 것이 관행이라 했다. 파일이 수백 개인 프로젝트에서 그 일을 사람이 손으로 할 수는 없을 텐데 — 무엇이 대신하는가?
답. 빌드 도구다. 파일 사이의 의존 관계를 적어 두면, 무엇이 바뀌었는지 보고 다시 만들 것만 골라 만들어 준다. C 세계의 고전이자 여전한 표준이 make이고, 이 장이 그 도구부터 시작한다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 이 많은 도구를 언제 들여야 하는가?
- 그러면 나도 MISRA C를 따라야 하는가?
- 그러면 배우는 사람은 이 차이를 어떤 태도로 보아야 하는가?
- 이 책을 다 읽은 사람이 다음으로 만들어 볼 만한 것은 무엇인가?
96.1 make — 무엇을 다시 만들 것인가
make의 착상은 한 줄이다 — 결과물이 재료보다 오래됐으면 다시 만든다. 규칙을 적는 문법도 그 착상 그대로다:
hello: hello.c greet.c greet.h # 목표: 재료들
cc -Wall -Wextra -o hello hello.c greet.c # 만드는 법(탭으로 시작)make hello라고 부르면 파일들의 시각을 비교해, 필요할 때만 명령을 실행한다. 파일 하나만 고쳤을 때 전체를 다시 컴파일하지 않는 것 — 16장의 릴레이와 54장의 분할이 실무에서 시간을 아끼는 방식이 이것이다.
make는 1976년에 만들어져 지금도 쓰인다. 문법의 괴벽(명령 줄은 반드시 탭으로 시작해야 한다 — 만든 사람 스스로 “그때는 사용자가 열둘뿐이었다”고 회고한 실수다)까지 그대로 남았지만, “의존 관계를 적으면 필요한 것만 다시 만든다”는 착상은 이후 모든 빌드 도구의 뿌리가 됐다. 오늘의 큰 프로젝트는 CMake·Meson·Ninja 같은 도구를 얹어 쓰지만, 그 도구들이 결국 만들어 내는 것도 대개 make류의 규칙이다.
96.2 git — 무엇이 언제 바뀌었나
두 번째 필수 도구는 버전 관리다. 코드의 모든 변경을 기록해 두고, 언제든 되돌리고, 여럿이 같은 코드를 고쳐도 합칠 수 있게 하는 도구 — 오늘의 사실상 표준이 git이다.
일상의 명령은 다섯이면 시작할 수 있다.
$ git init # 이 폴더를 저장소로
$ git add hello.c # 기록할 변경을 고른다
$ git commit -m "첫 프로그램" # 이유와 함께 기록한다
$ git log # 지나온 기록을 본다
$ git diff # 지금 무엇이 바뀌었나git이 이 책의 주제와 이어지는 대목이 하나 있다 — git 자신이 순수 C로 쓰인 프로그램이다. 2005년 리누스 토르발스가 리눅스 커널 개발을 위해 며칠 만에 초기판을 만들었고, 지금은 세계에서 가장 널리 쓰이는 개발 도구가 됐다. 10장에서 본 CRLF 경고를 내는 그 도구가, 이 책이 가르친 언어로 만들어져 있는 것이다.
96.3 도구 상자의 나머지 — 빌드, 관측, 검사
make 와 git 은 시작일 뿐이다. 실무의 C 프로젝트는 대개 여러 도구를 겹쳐 쓰는데, 각각이 어떤 질문에 답하는 도구인지로 묶으면 외울 것이 없다.
96.3.1 ① 무엇을 어떻게 만들 것인가 — 빌드와 의존성
| 도구 | 하는 일 | 메모 |
|---|---|---|
make | 의존 관계를 보고 다시 만든다 | 고전이자 바탕 |
CMake | 빌드 파일을 생성한다 | 사실상의 표준. 여러 플랫폼·IDE 로 내보낸다 |
Ninja | 생성된 규칙을 아주 빠르게 실행 | CMake·Meson 의 뒷단 |
Meson | 읽기 쉬운 생성기 | Ninja 와 짝. 리눅스 프로젝트에서 인기 |
| Autotools | ./configure 계열 | 옛 유닉스 프로젝트에서 여전히 만난다 |
Bazel·xmake | 대규모·재현 가능 빌드 | 큰 조직·단일 저장소 |
ccache | 같은 컴파일을 다시 하지 않는다 | 큰 프로젝트에서 체감 효과가 크다 |
pkg-config | 라이브러리의 컴파일·링크 옵션을 알려 준다 | 유닉스 계열 |
vcpkg·Conan | 외부 라이브러리 받아 오기 | C 에는 표준 패키지 관리자가 없다 |
표 96.1
마지막 줄이 C 의 오랜 약점이다 — 언어에 딸린 공식 패키지 관리자가 없어서, 의존성을 어떻게 들여올지가 프로젝트마다 다르다(시스템 패키지, 소스 통째로 넣기(vendoring), 서브모듈, vcpkg·Conan 같은 도구). 이 책의 예제가 proven 을 저장소 안에 통째로 넣어 쓰는 것도 그 현실 때문이다.
96.3.2 ② 무엇이 잘못됐는가 — 디버거와 기록·재생
17장에서 gdb·lldb를 익혔다. 실무에는 그 곁에 두 부류가 더 있다.
- 플랫폼 디버거 — 윈도우의
WinDbg와 Visual Studio 디버거. 특히 WinDbg 의 시간 여행 디버깅(TTD)은 실행을 통째로 기록해 두고 뒤로 돌려 가며 볼 수 있다.
- 기록·재생 — 리눅스의 rr가 같은 일을 한다. 한 번 재현된 버그를 기록해 두면 그 실행을 몇 번이고 똑같이 되돌려 볼 수 있어서, “가끔만 재현되는” 버그에 특히 강하다. 17장에서 본 하이젠버그의 정공법이다.
- 크래시 사후 분석 — 코어 덤프(리눅스)와 미니덤프(윈도우)를 디버거로 열어 현장을 되짚는다. 릴리스 빌드도
-g로 만들어 디버그 정보를 따로 보관하는 관행(17장)이 여기서 값을 한다.
96.3.3 ③ 어디가 느린가 — 프로파일러
“짐작하지 말고 재라”는 것이 성능 작업의 첫 규칙이다.
| 도구 | 무엇을 재는가 | 어디서 |
|---|---|---|
perf | 표본 추출로 CPU 시간·캐시 미스·분기 실패 | 리눅스. 사실상 기본 |
Valgrind callgrind | 명령 수를 정확히 센다(느리지만 정밀) | 리눅스·macOS |
Valgrind cachegrind | 캐시 동작 모의 | 11장의 사다리를 눈으로 |
massif·heaptrack | 힙 사용량의 시간 변화 | 누수와 과다 할당(45장) |
| Intel VTune·AMD uProf | 마이크로아키텍처 수준 분석 | 벤더 도구 |
| Visual Studio 프로파일러·WPA | CPU·메모리·ETW 추적 | 윈도우 |
uftrace·Tracy | 함수 추적·프레임 단위 시각화 | 게임·실시간 코드 |
표 96.2
프로파일러를 쓰면 11·12장의 이야기가 숫자로 확인된다 — 캐시 미스가 많은 루프, 분기 예측이 빗나가는 조건문, 값비싼 할당(45장)이 어디에 몰려 있는지가 그대로 보인다.
96.3.4 ④ 코드가 옳은가 — 검사와 시험
- 정적 분석 —
clang-tidy,cppcheck, 그리고 컴파일러 자체의-fanalyzer(GCC). 상용으로는PC-lint Plus·PVS-Studio·Coverity. - 동적 검사 — 17장의 새니타이저(ASan·UBSan·TSan)와
valgrind --tool=memcheck. 전자는 다시 컴파일해야 하고 빠르며, 후자는 그대로 돌릴 수 있고 느리다.
- 덮개율(coverage) — gcov/lcov(GCC), llvm-cov(Clang). “시험이 어느 줄을 실제로 지나갔는가”를 보여 준다.
- 퍼징 —
libFuzzer,AFL++,honggfuzz. 무작위 입력을 쏟아부어 죽는 자리를 찾는다. 파서와 디코더처럼 바깥 입력을 먹는 코드에서 효과가 크다. - 단위 시험 — C 에는 표준 시험 틀이 없어
Unity·CppUTest·Criterion같은 라이브러리를 쓴다.
96.3.5 ⑤ 사람이 읽기 좋은가 — 서식과 문서
clang-format(서식 자동 정리), uncrustify, Doxygen(주석에서 문서 생성), 그리고 compile_commands.json(빌드 명령의 목록)이다. 마지막 것은 편집기의 자동완성(clangd)과 정적 분석 도구가 “이 파일이 어떤 옵션으로 컴파일되는지”를 알기 위해 읽는 파일이라, CMake 에서 한 줄로 켜 두는 것이 요즘 관행이다.
플랫폼 노트. 임베디드의 관측 도구 — 화면 없는 기계를 재는 법
97장에서 볼 도구들(프로브·OpenOCD·RTT·정적 분석) 위에, 성능과 동작을 재는 층이 하나 더 있다.
- 사이클 카운터 — Arm Cortex-M 의 DWT 사이클 카운터처럼 칩 안의 계수기를 읽어 구간의 클록 수를 직접 센다. 가장 정확하고 가장 싸다.
- GPIO 토글 — 재고 싶은 구간의 앞뒤에서 핀 하나를 올렸다 내리고, 그것을 97장의 로직 애널라이저로 본다. 소프트웨어 프로파일러가 없는 칩에서 지금도 널리 쓰이는 정공법이다.
- RTOS 추적 — SEGGER
SystemView, PercepioTracealyzer같은 도구가 태스크 전환·인터럽트·큐 대기를 시간축에 그려 준다. “왜 이 태스크가 늦는가”를 눈으로 보는 방법이다. - 스택 사용량 측정 — 83장에서 본 스택 페인팅, 그리고 정적 분석 도구의 최악 깊이 계산.
- 코드 크기 추적 — 97장의 맵 파일·
bloaty를 CI 에 걸어, 커밋마다 크기가 얼마나 늘었는지 지켜보는 관행이 흔하다. - 하드웨어 인 더 루프(HIL) — 실제 보드를 시험 장비에 물려 자동으로 돌리는 것. 시뮬레이터(QEMU·Renode)와 함께 임베디드 CI 의 두 축이다.
문. 이 많은 도구를 언제 들여야 하는가?
답. 순서가 있다. 처음부터 두는 것은 셋뿐이다 — 버전 관리(git), 빌드 도구 (make 나 CMake), 그리고 경고 최대치(-Wall -Wextra). 그다음은 필요가 생길 때 하나씩이다.
- 버그가 잦다 → 새니타이저와 단위 시험
- 느리다 → 프로파일러(짐작 금지)
- 사람이 늘었다 → 서식 도구와 CI
- 바깥 입력을 먹는다 → 퍼징
- 가끔만 재현된다 → 기록·재생(rr, TTD)
도구를 먼저 잔뜩 깔고 시작하면 대개 쓰지 않게 된다. 문제가 도구를 부르게 하는 것이 실무의 순서다.
96.4 오늘도 C로 도는 것들 — 그리고 왜
1장에서 “C는 어디에나 있다”고 했다. 이제 구체적인 이름과 이유를 적을 수 있다.
| 프로젝트 | 무엇 | 왜 C인가 |
|---|---|---|
| Linux 커널 | 운영체제의 심장 | 하드웨어를 직접 다뤄야 하고, 런타임 의존이 없어야 하며, 모든 아키텍처에 컴파일러가 있다 |
| SQLite | 세계에서 가장 많이 배포된 데이터베이스 | 어디에나 이식되어야 하고(휴대폰·항공기·브라우저), 단일 소스 파일로 배포하는 극단적 단순함이 필요했다 |
| FFmpeg | 영상·음성 처리의 표준 도구 | 성능이 전부인 코덱 작업, 하드웨어 가속 API와의 직접 연동 |
| Redis | 메모리 기반 데이터 저장소 | 예측 가능한 지연시간과 메모리 제어 — 11장의 사다리를 직접 관리한다 |
| curl | 인터넷 전송의 만능 도구 | 모든 플랫폼·모든 기기에 올라가야 한다(자동차·TV·우주선까지) |
| CPython·Ruby 등 | 다른 언어의 구현체 | 언어 런타임은 결국 가장 낮은 층이 필요하고, C 인터페이스가 생태계의 공용어다 |
| OpenSSL·zlib | 암호화·압축 라이브러리 | 모든 언어에서 불려야 하므로 — C ABI가 사실상의 만국 공통 인터페이스 |
표 96.3
공통점을 뽑으면 C를 고르는 이유가 네 가지로 정리된다.
- 가장 낮은 층에 서야 할 때 — 운영체제, 드라이버, 런타임.
- 어디에나 옮겨야 할 때 — 새 칩이 나오면 C 컴파일러부터 만들어진다 (4장의 이식성 혁명이 반세기째 유효하다).
- 성능과 자원을 손으로 통제해야 할 때 — 메모리 배치, 지연시간, 실행 파일 크기.
- 다른 모든 언어와 대화해야 할 때 — 파이썬·자바·러스트 어느 쪽에서든 C 함수는 부를 수 있다. C ABI가 언어들 사이의 국제 공용어다.
96.5 업계의 규약 — MISRA C와 그 이웃들
방금 본 목록 — 자동차, 항공, 의료, 발전소 — 을 다시 읽어 보면 공통점이 하나 있다. 틀리면 사람이 다치는 자리다. 그래서 이 산업들은 C를 쓰되 그냥 쓰지 않는다.
사람이 죽거나 큰돈이 걸린 자리에서는, 「C를 잘 쓰자」는 다짐만으로 부족하다. 그래서 산업마다 쓸 수 있는 C의 부분집합을 문서로 정해 놓았다. 12장의 회색지대와 52장의 정의되지 않은 동작이 실무에서 어떻게 다뤄지는지가 이 규약들에 그대로 적혀 있다.
96.5.1 MISRA C — 자동차에서 나와 업계 전체로
가장 널리 쓰이는 것이 MISRA C다. 이름은 그 유래에서 왔다 — Motor Industry Software Reliability Association, 영국의 자동차 업계 단체다.
| 무엇 | 내용 |
|---|---|
| 출발 | 1994년 「차량 탑재 소프트웨어 개발 지침」이 요구한 “표준화된 프로그래밍 언어의 제한된 부분집합” |
| 첫 판 | MISRA C:1998, 이어서 MISRA C:2004 「중요 시스템에서 C 언어를 쓰기 위한 지침」 |
| 현재 | MISRA C:2012(3판)와 그 개정 — MISRA C:2023은 3판의 두 번째 개정으로 C11·C18까지 다룬다 |
| 관리 | 2021년부터 독립 비영리 법인(The MISRA Consortium)이 관리 |
| 자리 | 자동차를 넘어 의료·철도·항공·방위 산업으로 퍼져, 안전·보안이 걸린 C 개발의 사실상의 표준 |
표 96.4
MISRA C의 규칙은 「하지 마라」의 목록처럼 보이지만, 그 구조가 더 중요하다.
첫째, 규칙에 등급이 있다.
| 등급 | 뜻 |
|---|---|
| Mandatory(필수) | 어길 수 없다 |
| Required(요구) | 어기려면 문서로 남긴 일탈 절차를 거쳐야 한다 |
| Advisory(권고) | 권한다. 따르지 않아도 되지만 이유를 적어 두는 편이 낫다 |
표 96.5
둘째, 「일탈」(deviation)이라는 정식 절차가 있다. 규칙을 어겨야 하는 자리가 실무에는 반드시 생기므로, MISRA는 어기지 마라가 아니라 어길 거면 이렇게 남겨라를 정해 두었다 — 무슨 규칙을, 어디서, 왜 어겼고, 그 대신 어떤 조치를 했는지를 문서에 적는다.
이 구조가 12장에서 말한 규율과 정확히 같은 사상이라는 점을 눈여겨볼 만하다. 선택을 금지하는 것이 아니라, 선택을 눈에 보이게 만드는 것이다.
96.5.2 이웃들 — 안전 규격과 코딩 규칙
MISRA C 하나만 있는 것이 아니다. 크게 두 갈래로 갈린다 — 어떻게 만들 것인가를 정하는 안전 규격과, 어떻게 쓸 것인가를 정하는 코딩 규칙이다.
| 이름 | 어디 | 무엇을 정하는가 |
|---|---|---|
| IEC 61508 | 산업 전반(기반 규격) | 기능 안전의 뼈대. 위험 수준을 SIL 1~4로 나눈다 |
| ISO 26262 | 자동차 | IEC 61508을 차량에 맞춘 것. 위험 수준은 ASIL A~D |
| DO-178C | 항공(탑재 소프트웨어) | 인증을 위한 개발·검증 절차. 등급 DAL A~E |
| IEC 62304 | 의료 기기 | 소프트웨어 생명주기. 등급 A~C |
| EN 50128 / EN 50657 | 철도 | 신호·차량 소프트웨어 |
| MISRA C | (산업 무관) | C의 안전한 부분집합 — 위 규격들이 자주 인용한다 |
| SEI CERT C | (보안 중심) | 규칙과 권고, 각각에 위험도 평가와 CWE 대응 |
| ISO/IEC TS 17961 | (도구 중심) | C 보안 코딩 규칙 — 분석기가 무엇을 잡아야 하는지 |
| JPL Power of Ten | 항공우주(NASA/JPL) | 열 개짜리 짧은 규칙. 재귀 금지·동적 할당 금지 등 |
| BARR-C | 임베디드 일반 | 읽기 좋은 코드 쪽에 무게. MISRA와 함께 쓰도록 설계됐다 |
표 96.6
안전 규격은 대개 언어의 부분집합을 쓰라고만 말하고 그 부분집합을 직접 정하지 않는다. 그래서 「ISO 26262를 따르려고 MISRA C를 쓴다」는 조합이 표준적이다.
96.5.3 무엇을 금지하는가 — 공통된 무늬
규약마다 항목은 다르지만, 금지되거나 강하게 제한되는 것들의 얼굴은 놀랄 만큼 닮았다. 그리고 그 목록이 이 책이 위험하다고 말해 온 것들과 겹친다.
| 자주 금지·제한되는 것 | 이 책의 어느 이야기인가 |
|---|---|
동적 할당(malloc/free) | 단편화와 예측 불가능한 시간(45·84장), 아레나·풀이 대안 |
| 재귀 | 스택을 얼마나 쓸지 알 수 없다(83장) |
| 정의되지 않은 동작에 기대는 코드 | 52장 전체 |
| 암묵적 변환에 기대는 수식 | 29장 — 명시적 캐스트를 요구한다 |
goto·다중 return·다중 break | 흐름을 한눈에 읽히게(31장) |
| 가변 인자 함수 | 타입 검사가 없다(58장) |
| 공용체를 통한 타입 펀닝 | 표현에 기대는 코드(48장) |
#define 매크로의 남용 | 전처리기는 타입을 모른다(57장) |
| 표준 라이브러리의 일부 | gets·atoi·strcpy 같은 것(64·65장) |
표 96.7
이 표를 거꾸로 읽으면 이 책의 요약이 된다 — 계약을 벗어나지 말고, 자원의 수명과 크기를 예측 가능하게 유지하고, 사람이 읽어 확인할 수 있게 쓰라.
문. 그러면 나도 MISRA C를 따라야 하는가?
답. 아니다 — 그 규약이 요구되는 자리에서 일한다면 따르는 것이고, 아니라면 따르지 않는다. 규약은 「좋은 코드」의 정의가 아니라 특정 위험에 대한 대가의 계약이기 때문이다. 동적 할당 금지가 자동차 제동 장치에서는 옳은 규칙이지만, 텍스트 편집기에서는 지킬 이유가 없다.
다만 규약을 읽어 보는 것은 어느 자리에서나 값이 있다. 규칙 하나하나에 「왜 이것을 금지하는가」가 붙어 있어서, 실무가 어디서 다쳐 왔는지의 목록으로 읽을 수 있기 때문이다. 이 책이 다룬 함정의 상당수가 그 목록에 이름을 올리고 있다.
세 가지는 규약을 따르지 않는 프로젝트에서도 그대로 훔쳐 올 만하다.
- 쓰지 않기로 한 것을 적어 둔다 — 팀의 「우리는 이것을 쓰지 않는다」 목록.
- 어길 때는 이유를 남긴다 — MISRA 일탈 절차를 줄인 형태다. 무엇을 얼마나 남길지는 12장의 사다리대로 경중에 따라 정한다.
- 도구가 검사하게 한다 — 사람이 기억하는 규칙은 지켜지지 않는다(이 장 앞에서 본 정적 분석기들).
실제 사례. 규약이 요구하는 진짜 비용
MISRA 같은 규약을 도입하면 비용이 세 갈래로 나타난다.
도구값과 시간. 규칙 검사는 사람이 하지 않는다 — 상용 정적 분석기(PC-lint Plus·Polyspace·Coverity 등)나 오픈 소스 도구가 검사한다. 큰 코드베이스에 처음 돌리면 경고가 수천 개 나오는 일이 흔하고, 그것을 분류하는 데만 몇 달이 걸리기도 한다.
코드가 길어진다. 암묵적 변환을 금지하면 캐스트가 늘고, 동적 할당을 금지하면 버퍼를 미리 잡는 코드가 생긴다. 「짧고 영리한 코드」의 자리가 줄어든다.
그리고 그 대가로 얻는 것은 「사고가 나지 않았다」는, 눈에 보이지 않는 결과다. 이 비대칭이 규약을 지키기 어렵게 만드는 진짜 이유다 — 지킨 덕은 보이지 않고, 지키느라 든 비용은 매일 보인다. 그래서 규약은 개인의 의지가 아니라 조직의 절차로 유지된다.
96.6 C와 C++ — 형제이지, 부모와 자식이 아니다
가장 흔한 오해를 여기서 정리한다. C는 C++의 부분집합이 아니다. C++은 “C에 객체지향과 템플릿을 더한 언어”가 아니라, C에서 갈라져 나와 따로 자란 다른 언어다.
역사를 짧게 보면 관계가 분명해진다. 1979년 비야네 스트롭스트룹이 C에 클래스를 얹은 “C with Classes”를 만들었고, 1983년 C++로 이름이 바뀌었다. 여기까지는 확장이 맞다. 그런데 그 뒤로 두 언어는 각자의 위원회에서 각자 표준을 만들며 갈라졌다 — C는 1989년 C89로, C++은 1998년 C++98로 처음 표준을 얻었고, 이후 서로를 참고하되 서로에게 종속되지 않았다.
그래서 오늘날 양쪽 모두에 상대에게 없는 것이 있다.
| C에 있고 C++에 없는 것 | C++에 있고 C에 없는 것 |
|---|---|
restrict 한정자 | 클래스·상속·가상 함수 |
| 가변 길이 배열(38장) | 템플릿과 그 위의 표준 라이브러리 |
| 유연 배열 멤버 | 참조(&)와 연산자 다중 정의 |
_Generic(58장) | 예외와 RAII |
| 순서 무관 지정 초기화 | 이름 공간(namespace) |
| 복합 리터럴(47장) | auto 타입 추론, 람다 |
표 96.8
96.7 C 코드를 C++ 컴파일러에 넣으면
말이 아니라 실물로 보인다. 아래는 완전히 올바른 C 코드다.
examples/ch96/ccompat.c
#include <stdio.h>
#include <stdlib.h>
/* 아래는 전부 올바른 C 코드다. 그런데 C++ 컴파일러에 그대로 넣으면
상당수가 컴파일되지 않는다. 이 예제는 C 로만 빌드된다. */
struct point { int x, y; };
/* ① C++ 의 예약어가 C 에서는 평범한 이름이다 */
static int class_of(int n) { return n / 10; }
int main(void)
{
/* ② void* 에서 다른 포인터로의 암묵 변환 — C 는 허용, C++ 는 거부 */
int *buf = malloc(4 * sizeof *buf);
if (!buf) return 1;
for (int i = 0; i < 4; i++) buf[i] = i * i;
/* ③ 문자 상수의 타입: C 에서는 int 다 */
printf("sizeof('a') = %zu (in C++ this is 1)\n", sizeof('a'));
/* ④ 순서를 바꾼 지정 초기화 — C99 는 허용 */
struct point p = { .y = 2, .x = 1 };
printf("point = (%d, %d)\n", p.x, p.y);
/* ⑤ 구조체 태그는 별도 이름 공간이라 struct 를 붙여야 한다 */
struct point q = p;
printf("copy = (%d, %d)\n", q.x, q.y);
printf("class_of(37) = %d\n", class_of(37));
printf("buf = %d %d %d %d\n", buf[0], buf[1], buf[2], buf[3]);
free(buf);
return 0;
}
실행 결과
sizeof('a') = 4 (in C++ this is 1)
point = (1, 2)
copy = (1, 2)
class_of(37) = 3
buf = 0 1 4 9
같은 파일을 g++ -std=c++20으로 컴파일하면 이렇게 된다.
error: invalid conversion from ‘void*’ to ‘int*’ [-fpermissive]
15 | int *buf = malloc(4 * sizeof *buf);
| ~~~~~~^~~~~~~~~~~~~~~~~
| void*
error: designator order for field ‘point::x’ does not match
declaration order in ‘point’
23 | struct point p = { .y = 2, .x = 1 };C가 허용하는 void *의 암묵 변환을 C++은 거부한다 — 그래서 C++에서는 malloc의 결과에 반드시 캐스트가 붙는다(그리고 그것이 C에서는 권하지 않는 관행이다. 거기서 “malloc의 반환값을 캐스트해야 하는가” 논쟁이 갈린다). 지정 초기화도 C++20이 받아들이긴 했지만 순서를 바꿀 수는 없다.
그 밖에 조용히 뜻이 달라지는 자리도 있다. 대표가 문자 상수다.
C : sizeof('a') = 4 (문자 상수의 타입이 int)
C++: sizeof('a') = 1 (타입이 char)컴파일은 양쪽 다 되는데 결과가 다르다 — 이런 자리가 가장 위험하다. class, new, template, this 같은 C++ 예약어를 C에서 변수 이름으로 쓴 코드도 그대로는 넘어가지 않는다.
실제 사례. 표준이 차이를 따로 적어 둔다
이 문제는 C++ 표준도 정식으로 다룬다. C++ 표준 문서에는 부속서 C (Annex C, Compatibility) 라는 절이 있어서, “C와 다른 점”을 조항 단위로 나열한다 — 문자 상수의 타입,void * 변환, 태그 이름 공간, 문자열 리터럴의 타입 같은 것들이다. 차이를 알고 싶으면 추측하는 대신 그 부속서를 펴는 것이 정확하다. 부속서가 존재한다는 사실 자체가 이 절의 논지를 증명한다 — 두 언어는 호환을 신경 써야 할 만큼 서로 다르다.96.8 C++ 쪽의 시간은 더 빨리 흐른다
1장에서 잠깐 말한 사정을 여기서 조금 더 적는다. C++은 2011년 이후 3년 주기로 표준을 낸다 — C++11, 14, 17, 20, 23, 26. 매 판마다 언어와 표준 라이브러리에 큰 기능이 들어오고, 그만큼 “현대적 C++”의 모습도 빠르게 바뀐다. 반면 C의 개정 주기는 훨씬 길고(C89 → C99 → C11 → C17 → C23), 들어오는 것도 대체로 작고 보수적이다.
이 속도 차이는 취향의 문제가 아니라 역할의 차이에서 온다. C는 운영체제와 펌웨어, 다른 언어들이 기대는 바닥층의 언어라 바뀌지 않는 것 자체가 기능이 된다. 30년 된 코드가 오늘도 컴파일되는 것, 그리고 어느 플랫폼에나 C 컴파일러가 있는 것이 그 대가로 얻은 자산이다.
문. 그러면 배우는 사람은 이 차이를 어떤 태도로 보아야 하는가?
답. 세 가지를 권한다.
첫째, “C++을 배우면 C는 저절로 안다”는 생각을 버린다. 반대도 마찬가지다. 겹치는 문법이 많아서 서로를 빨리 익히게 해 주는 것은 사실 이지만, 관용구와 사고방식은 꽤 다르다. C++의 습관(예외, RAII, 템플릿)을 C에 그대로 가져오면 어색한 코드가 되고, C의 습관(수동 관리, 매크로)을 C++에 가져오면 위험한 코드가 된다.
둘째, 어느 언어로 컴파일되는지를 항상 의식한다. .c와 .cpp는 확장자 차이가 아니라 언어의 차이다. 같은 파일이 두 언어에서 다른 뜻이 될 수 있다는 것을 아는 것만으로 사고의 절반이 예방된다.
셋째, 이 책에서 배운 것의 대부분은 어느 쪽에서도 유효하다. 기억의 사다리, 표현과 추상의 구분, 계약과 정의되지 않은 동작, 소유권과 수명 — 이것들은 문법이 아니라 기계와 언어 설계의 현실이다. C++를 배우러 가도 그 눈은 그대로 쓰인다.
96.9 섞어 쓰는 법 — extern "C"
실무에서는 두 언어를 섞는 일이 아주 흔하다. C로 짠 라이브러리를 C++ 프로그램이 부르는 경우가 대표적이다. 문제는 이름 장식(name mangling) 이다 — C++ 컴파일러는 오버로딩을 지원하려고 함수 이름에 매개변수 타입 정보를 섞어 목적 파일에 심는데(54장의 링크를 떠올리면 된다), C 컴파일러는 이름을 그대로 둔다. 그래서 그냥 이으면 링커가 짝을 찾지 못한다.
해법은 선언에 “이건 C 규약으로 다뤄라”라고 표시하는 것이다.
/* mylib.h — C 로 만든 라이브러리의 헤더 */
#ifndef MYLIB_H
#define MYLIB_H
#ifdef __cplusplus
extern "C" {
#endif
int mylib_add(int a, int b);
void mylib_reset(void);
#ifdef __cplusplus
}
#endif
#endif__cplusplus는 C++ 컴파일러만 정의하는 매크로다. 그래서 이 헤더는 C에서 읽으면 평범한 선언 둘이고, C++에서 읽으면 extern "C" { ... }로 감싸인 선언이 된다 — 헤더 하나로 두 언어를 모두 만족시키는 표준 관용구이고, 세상의 거의 모든 C 라이브러리 헤더가 이 모양을 하고 있다.
몇 가지 규칙을 함께 기억하면 된다.
extern "C"는 이름과 호출 규약에만 관여한다. 함수의 내용이나 타입 검사와는 무관하다.- 오버로딩된 함수는
extern "C"로 묶을 수 없다 — 이름이 하나뿐이어야 하기 때문이다. - C 헤더를 C++에서 쓸 때는 C++ 예약어(
class,new,template,namespace,this)를 매개변수 이름으로 쓰지 않는 것이 예의다. - 경계를 넘는 자료는 두 언어가 모두 이해하는 모양으로 유지한다 — 단순 구조체와 포인터, 그리고 명시적인 크기. C++의 클래스나 예외를 경계 너머로 던지지 않는다.
96.10 임베디드 — C의 본진
일상에서 눈에 안 띄지만 물량으로는 가장 큰 C의 영역이 임베디드다. 세탁기와 에어컨의 제어판, 자동차의 수십 개 제어 장치, 의료 기기, 드론과 로봇의 저수준 제어, 통신 모듈의 펌웨어 — 대부분이 C로 쓰인다.
이유는 그 환경의 제약에 있다. 메모리가 킬로바이트 단위이고, 운영체제가 없을 수도 있고(6장의 0번지가 인터럽트 벡터인 그 세계), 실행 시간이 예측 가능해야 하며(가비지 컬렉션 같은 예측 불가한 멈춤이 허용되지 않는다), 전력이 제한된다. C는 이런 환경에서 실행되지 않는 것에는 비용을 물리지 않는 언어라 자연스럽게 맞는다 — 표준 라이브러리 없이 쓸 수 있고(61장의 프리스탠딩), 생성되는 코드가 예측 가능하다.
임베디드의 C에는 이 책의 지식이 유난히 직접적으로 쓰인다 — 특정 주소를 장치 레지스터로 다루기(6장), volatile로 최적화를 막기(13장), 비트 연산으로 하드웨어 플래그를 조작하기(5·28장), 동적 할당을 아예 쓰지 않기(45장), 정렬과 표현을 의식하며 통신 프로토콜을 다루기(6·48장).
96.11 한계를 넘는 법 — 실전의 요령
C의 결핍은 분명하다 — 이름 공간이 없고, 제네릭이 없고, 자원 정리가 자동이 아니고, 안전이 강제되지 않는다. 실전은 이것을 언어 밖의 규율과 도구로 메운다. 이 책이 지나온 것들을 실무 지침으로 모으면 이렇다.
- 이름 공간이 없다 → 접두어 규약과 파일 수준
static, 그리고 심볼 가시성(55·56장. 정확히는 표준이 정한 이름 공간이 넷 있고, 새로 만들 수 없는 것이다). - 제네릭이 없다 → 매크로로 코드 생성(57장의
##, X 매크로), 또는void *와 크기·비교 함수를 받는 일반화(표준의qsort가 그 방식이다), 또는 C11의_Generic선택. - 자원 정리가 자동이 아니다 → 소유 규약을 이름과 문서로 명시하고 (45장), 한 함수의 정리 지점을 한 곳으로 모으는 관용구(정리 라벨로
goto하는 패턴은 커널에서 표준 관행이다). - 안전이 강제되지 않는다 → 그물을 겹친다(17장): 경고 최대, 새니타이저, 두 컴파일러 교차, 정적 분석 도구, 그리고 검사가 내장된 부품(제12부).
- 표준 라이브러리가 얇다 → 필요한 층을 고르거나 만든다. vendor로 들여와 빌드를 단순하게 유지하는 것이 작은 프로젝트의 정석이다.
- 큰 프로젝트의 복잡도 → 컴파일 단위를 작게 나누고 헤더로 계약을 드러내며(54장), 테스트와 빌드를 자동화한다(이 장의 make·git).
실제 사례. 섞어 쓰는 현실 — C는 사라지지 않고 층이 된다
오늘의 큰 시스템은 대개 한 언어로만 쓰이지 않는다. 성능이 필요한 핵심부는 C(또는 C++·Rust)로, 그 위의 논리는 파이썬·자바스크립트로 쓰는 층 구조가 일반적이다. 파이썬의 수치 계산 라이브러리가 빠른 이유는 그 밑이 C·포트란이기 때문이고, 브라우저가 영상을 재생하는 것도 밑에 C 코덱이 있기 때문이다. 1장에서 Rust와 Zig가 C의 영역을 잠식한다고 했지만, 실제로 벌어지는 일은 대체보다 공존에 가깝다 — 새 언어들도 기존 C 자산과 대화해야 하므로 C 인터페이스를 갖추고 태어난다. C를 읽고 쓸 줄 안다는 것은, 그래서 어느 층에서 일하든 아래층을 들여다볼 수 있다는 뜻이다.문. 이 책을 다 읽은 사람이 다음으로 만들어 볼 만한 것은 무엇인가?
답. 작고 끝이 있는 것이 좋다. 텍스트 파일을 읽어 통계를 내는 도구, 간단한 설정 파일 파서(91장의 스캐너가 그대로 쓰인다), 자료구조 하나를 직접 구현해 보기(46장의 연결 구조), 좋아하는 작은 C 프로젝트의 소스를 읽고 한 곳 고쳐 보기. 마지막 것이 특히 권할 만하다 — 남의 코드를 읽는 능력이 실무에서 쓰는 시간의 대부분을 차지하고, 이 책은 그 읽기를 위한 눈을 기르는 데 상당 부분을 썼다.
지면 밖의 지형까지 둘러보았다. 다음 장은 그중 한 구역을 확대한다 — C가 가장 깊이 뿌리내린 자리인 임베디드에서, 어떤 컴파일러와 어떤 연장을 들고 일하는지다. 그다음 마지막 장에서 이 책이 지나온 길을 되짚고 책을 닫는다.