Skip to content
VLSI Mentor

C++ Part I · Module 24 · C++ Build and Project Architecture

Warnings, Standard Flags and Debug vs Optimized Builds

Compiler flags are the cheapest quality tool in C++ and the most often left at their defaults. The default is close to silent: the same file that produces twenty diagnostics under the most aggressive setting produces two with no flags at all, and the eighteen in between include a returned pointer to a destroyed local and a value that is read before it is written. So the first half of this chapter is a measurement of which flags earn their place against a file with known defects planted in it, because a recommendation you can check is worth more than a list you copy. The second half is about optimisation levels, and it is less about speed than about agreement. A program containing undefined behaviour does not have one wrong answer that the optimiser reveals; it has a different answer depending on whether the expression was executed by the hardware or computed by the compiler, and this chapter measures the same expression giving one value at every optimisation level when the compiler cannot see its arguments, and a value that varies between programs when it can. That is the real argument for keeping a debug and an optimised configuration both buildable at all times.

Intermediate19 min readC++Part IFundamentalsBuild

Part I · Module 24 · Chapter 24.7 · Warnings, Standard Flags and Debug vs Optimized Builds

1. The Standard Flag Comes First

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
clang++ -std=c++20 …

Without it you get the compiler's default dialect, which changes between releases and differs between compilers. That is a reproducibility problem rather than a feature problem: the same source compiled by two engineers can be two different languages. Pin the standard explicitly, in the build system, once.

-std=c++20 rather than -std=gnu++20 unless you want the GNU extensions; the gnu variants enable things that will not compile elsewhere, which is a portability cost you should take deliberately. Chapter 21.8 §1 is the pattern for checking what a toolchain actually supports instead of assuming — on this machine -std=c++23 also compiles, and Chapter 24.6 §1 lists what is and is not installed.

2. What Each Warning Flag Actually Catches

A single file with nine deliberately planted defects: a narrowing conversion, a shadowed parameter, an unused parameter, an ignored [[nodiscard]] result, a signed/unsigned comparison, a read of an uninitialised local, a returned pointer into a destroyed temporary, a shift that is undefined at one input, and an unused variable. Six flag sets, counting diagnostics:

flagswarningsdistinct kinds
none22
-Wall44
-Wall -Wextra66
-Wall -Wextra -Wpedantic66
… -Wshadow -Wconversion88
-Weverything2010

And which diagnostics each level adds:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  --- -Wall ---
      [-Wreturn-stack-address]
      [-Wuninitialized]
      [-Wunused-result]
      [-Wunused-variable]
  --- -Wall -Wextra ---
      [-Wsign-compare]
      [-Wunused-parameter]
  --- -Wall -Wextra -Wpedantic -Wshadow -Wconversion ---
      [-Wfloat-conversion]
      [-Wshadow]

Four conclusions, each of which is a recommendation you can verify rather than inherit.

The default is nearly silent. Two diagnostics for nine defects. A project with no warning flags is not a project with no warnings; it is a project that has not asked.

-Wall -Wextra is the floor and it is genuinely good. It found the returned dangling pointer, the uninitialised read, the ignored [[nodiscard]], the sign comparison and both unused entities — six of nine, including the two that are undefined behaviour.

-Wpedantic added nothing on this file. It is about standard conformance — non-standard extensions, not defects — so it is worth having for portability and it is not a bug-finding flag. Saying so is more useful than listing it as part of a magic incantation.

-Wshadow and -Wconversion each found a real defect that -Wextra did not, taking the total to eight. Both are commonly left off because they are noisy on existing code; both are worth enabling on new code, where the noise is a one-time cost.

-Weverything is clang-specific and is not a setting to build with. Twenty diagnostics from ten kinds includes padding reports, C++98 compatibility notes and documentation nits. It is a tool: run it occasionally, read the output, and promote the kinds that matter. Chapter 15.4 §10's rule applies — a count of a warning sweep is not a finding; read them.

One measurement note. The first version of this table was non-monotonic — four warnings at -Wall and three at -Wall -Wextra — which is impossible. The cause was passing the flag set through an unquoted shell variable: zsh does not word-split, so -Wall -Wextra arrived as one bogus argument and the count was of "unknown warning option" messages. Flags must be passed as an array. A count that goes down when you add a flag is evidence about your harness, not about the compiler.

3. Treat a Warning as a Defect Report

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
clang++ -std=c++20 -Wall -Wextra -Werror …
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
        5 errors generated.

-Werror converts the warnings into a failed build, and the number differs from the warning count because some diagnostics come in groups with notes.

The argument for it is not that every warning is a bug — it is that a warning nobody fixes is a warning nobody reads. A build that emits forty warnings has effectively zero, because the one new warning that matters is invisible in the scroll. -Werror is the mechanism that keeps the count at zero so that the forty-first is visible.

The practical objections are real and have answers:

"It breaks the build on a new compiler." True, and the usual arrangement is -Werror in continuous integration and plain warnings in a developer's local build, so an upgrade is a CI failure to schedule rather than an interruption to whoever upgraded first.

"Third-party headers warn." Use -isystem instead of -I for them, which suppresses warnings from those headers while keeping them for yours. This is the single most useful flag for making -Werror tolerable.

"One warning is wrong for our case." Disable that one kind, in that one place, with a comment saying why — #pragma clang diagnostic push/ignored/pop — rather than lowering the global level.

4. The Optimisation Levels

flagmeansuse for
-O0no optimisation, fastest compile, best debuggingthe debug configuration
-O1light optimisationrarely chosen deliberately
-O2the standard optimised buildthe release configuration
-O3more aggressive, mostly inlining and vectorisationmeasure before preferring it to -O2
-Osoptimise for sizeconstrained targets
-Ogoptimise but keep debugging usablea good middle for day-to-day work

-g is orthogonal: it adds debug information and does not change the generated code, so -O2 -g is a legitimate and useful combination — an optimised build you can still get a stack trace out of. The usual mistake is assuming -g makes the program slower; it makes the binary larger.

What changes between -O0 and -O2 matters for debugging: variables are kept in registers and may be reported as optimised out, functions are inlined so frames disappear from backtraces, and the statement order you step through stops matching the source. That is the reason to keep both configurations rather than a reason to avoid optimisation.

5. Undefined Behaviour Does Not Have One Wrong Answer

This is the section that justifies maintaining two configurations, and it uses this curriculum's own defect — the register-mask helper from Chapter 21.2 §3, which is undefined when the width equals the type's width:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::uint32_t mask(int width, int pos)
{
    return ((1u << width) - 1u) << pos;      // UB when width == 32
}

Executed at run time, with the arguments behind a volatile so the compiler cannot see them — which is Chapter 21.2 §3's form:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  -O0  mask(0,32) = 0x00000000
  -O1  mask(0,32) = 0x00000000
  -O2  mask(0,32) = 0x00000000
  -O3  mask(0,32) = 0x00000000

The same answer at every level, and it has a mechanical explanation. The arm64 LSL instruction uses its shift amount modulo the register width, so 1u << 32 behaves like 1u << 0:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  1u << 32 (run time) = 0x00000001

One, minus one, is zero — hence the mask of 0x00000000 that Chapter 21.2 §3 reported. That figure is reproducible because it is the hardware's behaviour, and the hardware is consistent.

Constant-folded at compile time, with literal arguments at -O1 and above, the compiler evaluates an expression the standard gives no value to:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  literal  mask(32,0) = 0xEFB94100
  volatile mask(32,0) = 0x00000000

The same flag, the same function, two answers in one program. And the folded value is not a single wrong value either. It is identical across five rebuilds of one source file and across three runs of the resulting binary — so it is not random — and it differs between programs: 0xEFB94100 in two of the test files and 0x00000003 in a third, where the only difference was the surrounding code.

So the accurate statement is not "optimisation changes the answer". It is: undefined behaviour has no answer, and what you observe depends on whether the expression was executed by the hardware or computed by the compiler — a choice that the optimisation level makes on your behalf, differently for each call site depending on what it can see.

The sanitiser names it exactly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bugs.cpp:16:15: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'

Note what that transcript does not include: a non-zero exit status. The process printed the diagnostic and exited 0. -fsanitize=undefined reports by default and does not abort, which means a test harness checking only the exit code will pass a run that printed a UB report. -fno-sanitize-recover=all is the flag that makes it abort, and it is the one you want in a test configuration — this is Chapter 23.8 §2's floor condition in a different costume.

6. The Two Configurations, Concretely

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# debug: fast to build, pleasant to step through, checks on
-std=c++20 -O0 -g -Wall -Wextra -Wshadow -Wconversion \
  -fsanitize=address,undefined -fno-sanitize-recover=all \
  -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG

# release: what you ship, with assertions compiled out and symbols kept
-std=c++20 -O2 -g -DNDEBUG -Wall -Wextra

Six choices in there are deliberate.

-fsanitize=address,undefined in the debug build. These are the checks that found defects nothing else in this curriculum could. Chapter 20.3 §5 measured which bugs they catch and which they miss; they roughly double run time, which is why they live in the debug configuration.

-fno-sanitize-recover=all. §5's measurement is the reason: without it a UB report does not fail the run.

-D_LIBCPP_HARDENING_MODE=…DEBUG. Chapter 21.5 §6 measured this as the only tool that caught dereferencing an empty std::optional. It activates preconditions the library already contains.

-DNDEBUG only in release. Chapter 22.8 §2 measured assert disappearing entirely under it — the side effect went from 1 call to 0 — which is the intended behaviour and is why an assert must never carry one.

-g in both. A release binary without debug information produces a backtrace of hexadecimal addresses from the one crash you cannot reproduce.

The same warning flags in both. If they differ, one configuration accumulates warnings the other would have failed on, and the first person to switch configurations inherits all of them.

And one rule about the pair: both must be buildable at any moment. A release-only defect is usually an assert with a side effect, a variable used only inside #ifndef NDEBUG, or the UB in §5 landing differently. A debug-only defect is usually a sanitiser finding you have been ignoring. Neither is findable if one configuration has not compiled for three weeks, which is why building both in continuous integration matters more than building either one faster.

7. Flags Worth Knowing Beyond the Basics

flagwhat it doeswhen
-isystemtreat a directory's headers as system headers, suppressing their warningsessential with -Werror and third-party code
-fvisibility=hiddendo not export symbols by defaultany shared library — Chapter 24.6 §6
-MMD -MPemit header dependencies for the build systemalways — Chapter 24.8 §2
-fno-omit-frame-pointerkeep frame pointers so profilers can walk the stackany build you will profile
-ffunction-sections -Wl,--gc-sectionsdiscard unreferenced functions at linksize-constrained builds (Linux)
-dead_stripthe same idea on this platformsize-constrained builds (Apple)
-fno-rtti, -fno-exceptionsremove language featuresonly where a platform demands it, knowing what breaks
-march=nativeoptimise for this machine's instruction setnever for a binary you ship
-ffast-mathpermit unsafe floating-point rewritesessentially never in engineering code

The last two deserve their warnings. -march=native produces a binary that may fault with an illegal instruction on an older machine — a failure that appears at the customer and not in your tests. -ffast-math licenses the compiler to assume no NaNs and no infinities and to reassociate floating-point arithmetic, which changes results; for anything that reports numbers, Chapter 23.2 §5's round-trip discipline and this flag are in direct conflict.

8. The Table

the questionthe answermeasured
how many of 9 defects does the default find2§2
-Wall4§2
-Wall -Wextra6, including both UB cases§2
what does -Wpedantic addnothing on this file — it is a conformance flag§2
what do -Wshadow -Wconversion add2 more, to 8§2
should I build with -Weverythingno — 20 warnings, 10 kinds; use it as a tool§2
what does -Werror do here5 errors generated.§3
does -g slow the programno — it enlarges the binary§4
does -O2 reveal the "real" answer to UBno — there is no answer§5
what does the UB give when executed0x00000000 at every level, from arm64's modulo shift§5
and when folded0xEFB94100 in one program, 0x00000003 in another§5
does UBSan fail the runno — it exits 0 without -fno-sanitize-recover=all§5

9. What to Actually Do

  • Pin -std= explicitly in the build system, and prefer the non-gnu variant.
  • Make -Wall -Wextra the floor. Measured: six of nine planted defects, including the dangling return and the uninitialised read.
  • Add -Wshadow and -Wconversion on new code — two more defects — and know that -Wpedantic is for conformance, not bugs.
  • Use -Werror in CI and plain warnings locally, with -isystem for third-party headers and a scoped #pragma for a genuine exception.
  • Run -Weverything occasionally and read it; never build with it and never count it.
  • Keep two configurations, both buildable at all times, with the same warning flags in each.
  • Put -fsanitize=address,undefined and -fno-sanitize-recover=all in the debug build. Without the second flag a UB report exits 0.
  • Turn on _LIBCPP_HARDENING_MODE in the debug build — Chapter 21.5 §6 measured it as the only tool that caught one defect.
  • -DNDEBUG only in release, and never put a side effect in an assert.
  • -g in both configurations, including release.
  • Never ship -march=native, and avoid -ffast-math in anything that reports numbers.
  • Treat a behavioural difference between the two configurations as a UB report, not as a curiosity.

10. Mistakes Worth Making Once

Building with the default warning level. Two diagnostics for nine defects.

Copying a flag list without checking it. -Wpedantic is in every recommended incantation and found nothing here.

Counting warnings through an unquoted shell variable. Measured a decrease from adding a flag, which is impossible.

-Weverything in a build. Padding reports and C++98 compatibility notes, forever.

-Werror with third-party headers on -I. Use -isystem.

Lowering the global warning level for one false positive. A scoped #pragma costs three lines.

Trusting -fsanitize=undefined to fail a test. It exits 0 by default.

Assuming -O0 shows the "true" behaviour of UB. It shows the hardware's behaviour, which is one of several observations, not the truth.

Depending on a folded UB value because it is reproducible. It is reproducible per source file and differs between programs.

Letting one configuration go unbuilt. Both failure classes become invisible.

-march=native in a shipped binary. An illegal instruction at the customer.

11. Interview Relevance

The frozen curriculum marks this chapter High. "What warning flags do you use?" is the common opener, and almost everyone answers with a list. The better answer is a list plus evidence: -Wall -Wextra as the floor because it caught six of nine planted defects including a returned dangling pointer and an uninitialised read, -Wshadow and -Wconversion for two more, -Wpedantic as a conformance flag rather than a bug-finding one, and -Weverything as a tool you read rather than a setting you build with. Adding -Werror in CI with -isystem for third-party headers shows you have operated it, not just enabled it.

"Why keep a debug build?" The strong answer is the §5 measurement. A program with UB does not have a hidden right answer that -O0 reveals; the two configurations are two different observations, and a program that behaves differently under them has told you something specific. Being able to say that the executed form gave 0x00000000 at every level because arm64 shifts modulo 32, while the folded form gave different values in different programs, is a level of precision that is rare.

The detail worth keeping in reserve is that -fsanitize=undefined exits 0 unless you add -fno-sanitize-recover=all — which is the difference between a gate that works and one that reports success.

12. What You Should Be Able to Say Now

  • That -std= must be pinned, because the default dialect varies by release and compiler.
  • The measured coverage of six flag sets against nine planted defects: 2, 4, 6, 6, 8, 20 — with -Wall -Wextra the floor and -Wpedantic adding nothing.
  • That -Wpedantic is about conformance, and -Weverything is a tool rather than a setting.
  • That -Werror's justification is that warnings nobody fixes are warnings nobody reads, and how to make it workable: -isystem, CI-only, scoped pragmas.
  • That -g does not change generated code, so -O2 -g is a sensible release build.
  • That undefined behaviour has no answer: executed, the mask gave 0x00000000 at every level because arm64's LSL shifts modulo 32; folded, it gave 0xEFB94100 in one program and 0x00000003 in another, stable per source and varying between programs.
  • That the two configurations are therefore two independent observations, and a behavioural difference between them is a UB report.
  • That -fsanitize=undefined exits 0 by default, and -fno-sanitize-recover=all is what makes it a gate.
  • Why both configurations must stay buildable, and the failure classes each one alone cannot see.
  • That a non-monotonic measurement is evidence about your harness — zsh does not word-split a flag string.

13. Exercises

Exercise 1 — the table. Plant the nine defects, run all six flag sets, and reproduce the counts. Pass the flags as an array; then pass them as an unquoted variable and watch the count go down.

Exercise 2 — the UB split. Build §5's mask with literal arguments and with volatile arguments at -O0 through -O3. Record all eight results. Then move the function into a different program and re-record the folded value.

Exercise 3 — the sanitiser's exit code. Run the UB program under -fsanitize=undefined and check $?. Add -fno-sanitize-recover=all and check again. Then write a shell gate that would have passed the first and fails the second.

Exercise 4 — -Werror in anger. Turn on -Werror in a real project. Move third-party includes to -isystem. Fix or scope-suppress what remains, and count how many were real.

Exercise 5 — the two configurations. Build a project both ways with the same warning flags. Find a construct — an assert with a side effect is easiest — that behaves differently, and explain which configuration is lying.

14. Summary

  • Pin -std= explicitly; the default dialect varies between releases and compilers.
  • Measured against nine planted defects: default 2, -Wall 4, -Wall -Wextra 6, adding -Wpedantic 6 (nothing), adding -Wshadow -Wconversion 8, -Weverything 20 warnings across 10 kinds. -Wall -Wextra is the floor; -Wpedantic is a conformance flag; -Weverything is a tool to read, not a setting to build with.
  • A non-monotonic count is a broken harness. The first run of that table showed 4 then 3, because zsh does not word-split an unquoted flag string.
  • -Werror keeps the warning count at zero so the next one is visible — 5 errors generated. on the same file. Make it workable with -isystem, CI-only enforcement, and scoped pragmas.
  • Undefined behaviour has no answer. Executed with arguments the optimiser cannot see, the mask gave 0x00000000 at -O0, -O1, -O2 and -O3, because arm64's LSL shifts modulo 32 — 1u << 32 is 1, minus 1 is 0. Constant-folded with literal arguments at -O1+, it gave 0xEFB94100 in two programs and 0x00000003 in a third: stable per source file, different between programs.
  • So the two configurations are two independent observations, and a behavioural difference between them is a specific, serious signal rather than a curiosity. Both must stay buildable.
  • -fsanitize=undefined names the defect exactly and exits 0. -fno-sanitize-recover=all is what turns the report into a gate — the module's fourth instance of a finding that existed and was not part of the result.
  • Debug: -O0 -g, sanitisers with no recovery, _LIBCPP_HARDENING_MODE. Release: -O2 -g -DNDEBUG. Same warning flags in both. Never ship -march=native.

Chapter 24.8 takes all of these flags and dependencies and asks a build system to hold them, which is where a hand-written command line stops scaling.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.