Static Analysis in C++: Enforce Quality with Clang-Tidy &
이 글의 핵심
Integrate clang-tidy and Cppcheck into your C++ workflow. Configure .clang-tidy, generate compile_commands.json, run both tools in CI, and adopt incrementally on legacy codebases.
Why Static Analysis?
Tests verify behavior for inputs you thought of. Static analysis verifies structural properties of the code itself — without executing it. It catches bugs that exist on rarely-executed code paths, in unusual configurations, or in code that was never tested at all.
Common bugs caught before runtime:
- Use-after-move: accessing a moved-from object on the happy path looks fine, but calling it twice crashes
- Null pointer dereference: pointer checked on one branch but dereferenced on another
- Uninitialized variable: reads garbage in optimized builds even when debug builds zero-initialize
- Range-for copy:
for (auto item : container)copies every element whenconst auto&was intended - Resource leak: allocated but never freed on an error path
Finding these at CI time instead of in production is a significant reliability improvement.
The Two Tools and Why Both
Clang-Tidy is LLVM’s linter. It plugs into the Clang frontend and uses the same AST the compiler builds. This gives it deep understanding of C++ semantics — it can reason about move semantics, template instantiations, and type conversions. It has 400+ checks across categories: bugprone-*, modernize-*, performance-*, readability-*, cppcoreguidelines-*.
Cppcheck is a standalone analyzer that does its own parsing. It does not need a compilation database and runs on projects without Clang in the toolchain. It excels at flow-sensitive analysis: detecting null dereferences, out-of-bounds access, and integer overflow.
They catch different things. Running both increases coverage. The combination is the standard in serious C++ projects.
Setting Up Clang-Tidy
Generate compile_commands.json
Clang-Tidy needs to know your build flags (include paths, preprocessor definitions, language standard) to analyze each file correctly. CMake generates this with one flag:
cmake -B build -S . \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DCMAKE_BUILD_TYPE=Debug
The result is build/compile_commands.json. Some projects symlink it to the project root so editors find it automatically:
ln -sf build/compile_commands.json compile_commands.json
For non-CMake builds, bear can generate it by intercepting compiler invocations:
# Debian/Ubuntu
sudo apt-get install bear
bear -- make -j$(nproc)
The .clang-tidy Configuration File
Place .clang-tidy at the project root. Clang-Tidy searches upward from each file’s directory to find it:
# .clang-tidy
Checks: >
bugprone-*,
-bugprone-easily-swappable-parameters,
modernize-use-nullptr,
modernize-use-override,
modernize-avoid-bind,
performance-for-range-copy,
performance-unnecessary-copy-initialization,
readability-identifier-naming,
readability-avoid-const-params-in-decls
# Treat these checks as errors (block CI)
WarningsAsErrors: 'bugprone-*,performance-for-range-copy'
# Analyze headers in your project, not system/third-party headers
HeaderFilterRegex: '^(?!.*third_party).*\.h(pp)?$'
CheckOptions:
- key: readability-identifier-naming.ClassCase
value: CamelCase
- key: readability-identifier-naming.FunctionCase
value: camelCase
- key: readability-identifier-naming.MemberCase
value: lower_case
- key: readability-identifier-naming.MemberSuffix
value: '_'
Running Clang-Tidy
# Check a single file
clang-tidy -p build src/main.cpp
# Check all files in src/
clang-tidy -p build src/*.cpp
# Parallel (faster on multi-core)
run-clang-tidy -p build -j$(nproc) # from llvm-dev package
# Auto-fix: apply safe fixes (review the diff!)
clang-tidy -p build --fix src/*.cpp
git diff # review what was changed
What Each Check Category Finds
// bugprone-use-after-move
std::string s = "hello";
auto s2 = std::move(s);
std::cout << s.size() << '\n'; // WARNING: use-after-move — s is in valid but unspecified state
// performance-for-range-copy
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
for (auto name : names) // WARNING: copies each string
std::cout << name << '\n';
// Fix:
for (const auto& name : names) // no copy
std::cout << name << '\n';
// modernize-use-nullptr
int* ptr = NULL; // WARNING: use nullptr
int* ptr2 = nullptr; // OK
// modernize-use-override
class Base {
virtual void foo() {}
};
class Derived : public Base {
virtual void foo() {} // WARNING: use override
void foo() override {} // OK
};
// readability-identifier-naming (if configured)
class my_class { }; // WARNING: should be MyClass (CamelCase)
Setting Up Cppcheck
Cppcheck works without a compilation database and is simpler to run:
# Basic: check src/ directory with all checks enabled
cppcheck \
--enable=all \
--suppress=missingIncludeSystem \
--error-exitcode=1 \
-I include/ \
-j $(nproc) \
src/
# More targeted: specific enable categories
cppcheck \
--enable=warning,performance,portability \
--suppress=missingIncludeSystem \
--error-exitcode=1 \
src/
Enable categories:
warning— suspicious code that might be a bugstyle— code style violations (unused variables, etc.)performance— inefficient code patternsportability— code that may not work on all platformsinformation— informational messagesall— all of the above
What Cppcheck Finds
// Null pointer dereference
void process(int* data, int size) {
if (!data) {
printf("Error\n");
// Forgot to return!
}
for (int i = 0; i < size; ++i)
data[i] *= 2; // Cppcheck: dereference of null pointer 'data'
}
// Memory leak
void leak() {
int* p = new int[100];
if (someCondition())
return; // Cppcheck: memory leak: p
delete[] p;
}
// Out-of-bounds access
void oob() {
int arr[5];
for (int i = 0; i <= 5; ++i) // <= instead of <
arr[i] = i; // Cppcheck: out of bounds access
}
// Assignment in condition (common typo)
int x = 5;
if (x = 0) { // Cppcheck: found assignment in condition
printf("zero\n");
}
Suppressing False Positives
// Inline suppression — suppress for the next line
// cppcheck-suppress nullPointer
data->field = value;
// Suppress with comment justification (better)
// cppcheck-suppress useInitializationList // not applicable in this case
MyClass::MyClass() { }
Or a suppressions file for project-wide rules:
# suppressions.txt
missingIncludeSystem
# Suppress specific check in a specific file
uninitvar:src/legacy/old_code.cpp
cppcheck --suppressions-list=suppressions.txt src/
CI Integration with GitHub Actions
# .github/workflows/static-analysis.yml
name: Static Analysis
on:
pull_request:
paths:
- 'src/**'
- 'include/**'
- '.clang-tidy'
jobs:
clang-tidy:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install clang-tidy
run: sudo apt-get install -y clang-tidy cmake
- name: Configure CMake
run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=Debug
- name: Run clang-tidy (changed files only)
run: |
# Only check files changed in this PR — much faster than full scan
CHANGED=$(git diff --name-only origin/main...HEAD | grep '\.cpp$' || true)
if [ -z "$CHANGED" ]; then
echo "No C++ files changed"
exit 0
fi
clang-tidy -p build --warnings-as-errors='bugprone-*' $CHANGED
cppcheck:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install Cppcheck
run: sudo apt-get install -y cppcheck
- name: Run Cppcheck
run: |
cppcheck \
--enable=warning,performance \
--suppress=missingIncludeSystem \
--error-exitcode=1 \
-I include/ \
-j 2 \
src/
Analyzing Only Changed Files
Running the full analysis on a large codebase can take minutes. For PRs, analyze only the changed files:
# Get files changed vs main branch
CHANGED_CPP=$(git diff --name-only origin/main...HEAD | grep '\.cpp$')
if [ -n "$CHANGED_CPP" ]; then
clang-tidy -p build $CHANGED_CPP
fi
This reduces CI time from minutes to seconds for typical PRs.
Adopting Incrementally in Legacy Codebases
Turning on all checks at once on a legacy codebase generates thousands of warnings. This is overwhelming and makes CI fail immediately. The incremental approach:
Week 1: Enable bugprone-* only. Fix the real bugs (these are high-value). Suppress false positives with NOLINT(bugprone-easily-swappable-parameters).
Week 2-3: Add performance-for-range-copy and performance-unnecessary-copy-initialization. These are easy wins — the fix is usually adding const&.
Month 2: Add modernize-use-nullptr, modernize-use-override. Use --fix to apply automatically and review the diff.
Month 3+: Add readability-identifier-naming if your team agrees on naming conventions.
# Start with just the most impactful checks
clang-tidy -p build \
-checks='-*,bugprone-*,performance-for-range-copy' \
src/*.cpp
Excluding Third-Party Code
Never run static analysis on third-party headers or vendored code — the warnings are not yours to fix and they overwhelm real signal:
# In .clang-tidy
HeaderFilterRegex: '^(?!.*(third_party|vendor|external)).*\.(h|hpp)$'
# For Cppcheck: exclude directories
cppcheck --suppress=missingIncludeSystem \
-i third_party/ \
-i vendor/ \
src/
Editor Integration
Both tools integrate with major C++ editors:
VS Code (with C/C++ Extension):
clangdlanguage server reads.clang-tidyand shows warnings inline as you type- Install:
clangdextension +clangdbinary (sudo apt-get install clangd)
CLion:
- Built-in clang-tidy support reads your
.clang-tidyfile - Settings → Editor → Inspections → C/C++ → clang-tidy
Neovim (with LSP):
-- Using clangd as language server
require('lspconfig').clangd.setup({
cmd = {'clangd', '--clang-tidy'},
})
With editor integration, developers see warnings while writing code — not just in CI. This catches issues earlier and makes the CI gate feel less surprising.
Key Takeaways
- Clang-Tidy uses the Clang AST — deep C++ understanding, 400+ checks, auto-fix for many — requires
compile_commands.json - Cppcheck does its own parsing — no compilation database needed, strong on flow-sensitive bugs (null deref, OOB, leaks)
compile_commands.json: generate withcmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON; symlink to project root for editor support.clang-tidy: place at project root; useHeaderFilterRegexto exclude third-party headers- CI: check only changed files on PRs for fast feedback; full scan on main branch merges
- Incremental adoption: start with
bugprone-*, fix real bugs, then addperformance-*andmodernize-* - Suppress sparingly:
// NOLINT(check-name)or// cppcheck-suppressfor genuine false positives — document why - Editor integration:
clangdwith.clang-tidyshows warnings as you type — the fastest feedback loop
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Integrate clang-tidy (.clang-tidy, compile_commands) and Cppcheck into editors and CI. Fix use-after-move, leaks, and st… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 정적 분석 도구 | Clang-Tidy·Cppcheck·SonarQube [#53-5]
- Rust vs C++ 메모리 안전성 | 컴파일러 오류 차이 [#47-3]
- C++ 커스텀 컴파일러 패스 | Clang 플러그인·AST 변환·커스텀 진단 [#55-6]
이 글에서 다루는 키워드 (관련 검색어)
C++, Static Analysis, Clang-Tidy, Cppcheck, Code Quality, CI 등으로 검색하시면 이 글이 도움이 됩니다.