A stricter subset of C++ for projects that want C-like compile times and binary size while keeping the productive syntax of modern C++. It also keeps the full set of C99 / C-style features (designated initializers, compound literals, VLAs) available, so you lose nothing from the C side either.
Note: C+ targets clang and GCC only. MSVC is not supported — it lacks direct equivalents for these flags, and its runtime surface and strip-down options diverge enough that this approach doesn't translate. Use a GCC-family toolchain.
clang deliberately adopted GCC's GNU C/C++ surface — they share the same
-f*flags, the__builtin_*intrinsics, and the GNU C/C++ extensions (__attribute__, statement expressions, designated initializers, compound literals, VLAs, etc.) — so a single C+ configuration works across both. MSVC supports none of this: it grew up on a separate lineage with its own intrinsics,__declspecin place of__attribute__, and no GNU-extension support, and never aimed for GNU compatibility — which is why this strip-down has no faithful translation there.Beyond the technical mismatch: any self-respecting programmer should make an effort to be Microsoft-free in every way possible. Sticking to clang/GCC and an open toolchain isn't just what C+ requires — it's the right default.
C++ pulls in a lot of runtime surface by default: exception unwinding tables, RTTI, libstdc++ symbols, semantic interposition, threadsafe-statics guards. Most of that is irrelevant for executables that:
- never throw exceptions
- never use
dynamic_castortypeid - run only in-process (no
.soplugin loading) - want predictable codegen
C+ strips the runtime surface but keeps the parts of C++ that produce code as cheap as C. Templates, namespaces, lambdas, default struct values, struct methods, header-only STL helpers (std::initializer_list, std::type_traits), and C++-specific attributes all remain available.
It also pulls the other direction: the C99 / C-style features that C++ accepts only as warned "extensions" — designated initializers (including out-of-order and array designators), compound literals, and VLAs — are deliberately re-enabled by silencing those warnings. The goal is a language that is a strict superset of the productive parts of both C and C++, not a compromise that drops features from either side. See C99 / C-style features.
The result: code that compiles about as fast as C and produces a binary about as small as C, as long as you stay away from heavyweight library facilities (containers, iostreams, exceptions, polymorphic class hierarchies).
These go in CXXFLAGS.
| Flag | Effect |
|---|---|
-fno-exceptions |
Disables exception support. Compiler still parses try / catch but emits no unwinding tables or throw machinery. |
-fno-rtti |
Disables runtime type info. No dynamic_cast, no typeid on polymorphic types. |
-fno-unwind-tables |
Drops .eh_frame stack-unwinding metadata. Safe with exceptions off, cuts binary size. |
-fno-asynchronous-unwind-tables |
Same as above for the async variant used by signal handlers. |
-fvisibility=hidden |
All symbols default to hidden. The right choice for an executable that exports nothing. |
-fno-semantic-interposition |
Tells the optimizer the binary's own functions cannot be replaced at runtime, enabling more aggressive inlining and devirtualization. |
-fno-math-errno |
math.h functions don't set errno on overflow. Allows vectorization of sinf / cosf / sqrtf loops. |
-fno-trapping-math |
FP ops don't raise traps. Matches typical shader-language semantics. |
These go in LDFLAGS.
| Flag | Effect |
|---|---|
-static-libgcc |
Statically links libgcc helpers. Removes libgcc_s.so.1 from the runtime dependency list. |
-nostdlib++ |
Drops libstdc++ from the link line entirely. Makes the next section's guardrail load-bearing. |
-fno-threadsafe-statics stays enabled. This is the structural guardrail: it exists to stop you from accidentally doing things that automatically create hidden mutexes. A thread-safe function-local static silently emits a guard around its lazy initialization — a hidden lock you never asked for — and keeping this flag on turns that into a link error instead of buried runtime synchronization.
A function-local static with runtime initialization (e.g. static auto x = compute();) makes the compiler emit calls to __cxa_guard_acquire, __cxa_guard_release, and __cxa_guard_abort. Those symbols live in libstdc++. Since -nostdlib++ drops libstdc++ from the link line, any code that introduces such a static fails to link with an unresolved-symbol error.
That failure is the point. It catches the common pattern of putting a heavy static inside a function, which quietly pulls in library state, atomic operations, and lazy initialization. The link error forces you to either lift the value to a constexpr or file-scope static (no runtime init, no guard emitted), or to confront whether the runtime init is really what you want.
- Templates and template metaprogramming.
- Namespaces.
- Lambdas, including generic and capturing.
- Range-based
forloops. - Default member initializers in structs / classes.
- Member functions, constructors, destructors.
auto,decltype, structured bindings.constexprevaluation,constinit,consteval.- Header-only STL helpers:
std::initializer_list,std::type_traits,std::numeric_limits, tuple-style metaprogramming, etc. - Attributes:
[[nodiscard]],[[likely]],[[unlikely]],[[gnu::format(printf, ...)]],[[gnu::always_inline]].
C+ leans toward C, and several genuinely useful C99/C-style constructs are accepted by clang and GCC in C++ mode but emit "extension" warnings by default. Suppressing that handful of warnings re-enables the full set of C99 functionality in C+ with no downside — the constructs already compile, the compiler is just being pedantic about portability you don't care about here.
The warnings to suppress:
| Flag | Re-enables |
|---|---|
-Wno-c99-designator |
C99 designated initializers, including out-of-order and nested/array designators that ISO C++ does not allow. |
-Wno-c23-extensions |
C23-era constructs used from C++ (e.g. #embed, newer literal forms). |
-Wno-vla-cxx-extension |
C99 variable-length arrays (T arr[n] with a runtime n). |
-Wno-address-of-temporary |
Taking the address of a compound literal / temporary (&(struct Foo){ ... }). |
-Wno-missing-field-initializers |
Aggregate / designated init that leaves trailing fields to zero-initialize. |
With those warnings off, the following C-style constructs are available in C+:
- Designated initializers —
Foo f = { .a = 1, .c = 3 };, including out-of-order and nested/array designators that ISO C++ otherwise rejects. - Compound literals —
&(Foo){ .a = 1 }, an unnamed object built in place (mind the lifetime warning below). - Variable-length arrays —
T arr[n]with a runtimen(cold paths only — see below). - Statement expressions —
({ ...; result; }), a GNU extension where a brace block yields the value of its last expression; ideal for multi-line macro bodies that need to return a value. __attribute__((...))— the GNU attribute syntax (packed,aligned,always_inline,cold,format, etc.), alongside the[[gnu::...]]spelling.__builtin_*intrinsics —__builtin_expect,__builtin_trap,__builtin_unreachable,__builtin_memcpy, and the rest of the GNU builtin set.- Other C idioms — anonymous structs/unions,
__typeof__,__restrict, and the usual libc surface.
All of these are shared by clang and GCC; MSVC supports none of them.
Warning — compound literal lifetime. A compound literal (
(struct Foo){ ... }) does not have the lifetime of its enclosing scope. It is a temporary whose lifetime ends at the end of the full expression it appears in — not at the end of the block, unlike a named local. Storing a pointer or reference to a compound literal and using it after that expression is a dangling-pointer use-after-free. If you need the object to outlive the statement, give it a name (a real local variable) instead of using a compound literal. This differs from C, where a compound literal at block scope has automatic storage duration lasting the whole enclosing block.
Warning — VLAs only on cold paths. Variable-length arrays are fine in cold-path setup/teardown code (init, enumeration, one-shot configuration) where the runtime size is genuinely dynamic and the convenience is worth it. Keep them out of hot paths: a VLA introduces a runtime-variable stack frame and
alloca-style bookkeeping that defeats the predictable codegen and breaks the optimizations (fixed frame layout, vectorization, inlining) that C+ exists to preserve. On any per-frame / per-iteration hot path use a fixed-sizeStaticArray, a preallocated buffer, or an arena instead. Rule of thumb: VLA inside aCreate*/Initialize*is OK; VLA inside hot per-frame work is not.
std::vector,std::string,std::map,std::unordered_map,std::function,std::shared_ptr, anything with runtime allocation backed by libstdc++.std::cout,std::cerr,std::cin, iostreams. Use<cstdio>instead.throw,try,catch. Will not work as intended.dynamic_cast,typeidon polymorphic types.- Function-local
static T x = runtime_init();. Lift to file-scope orconstexpr. - Most third-party C++ libraries that assume
-lstdc++is on the link line.
libc remains fully usable (<cstdio>, <cstdlib>, <cstring>, <cmath>, <ctime>, POSIX) and any C library.
Subtle but important: the STL headers are still included at compile time. <type_traits>, <initializer_list>, <utility>, etc. work because their content is header-only. -nostdlib++ only affects the link stage. So you can write std::is_same_v<T, U> freely; you just cannot use facilities that need libstdc++ at link time.
On clang, -stdlib=libstdc++ selects which standard-library implementation's headers to use. Default on Linux is typically libstdc++. Both libstdc++ and libc++ have substantial header-only subsets that are compatible with this setup.
The sections above give the principle. This is the per-header breakdown, verified by linking representative usage at -O0 under the flags above. Verify at -O0, never -O2: optimization deletes unused code before it references the runtime symbol, so -O2 reports false passes. Put the usage inside main, not at namespace scope.
A header is usable unless the code you instantiate hits one of three things -nostdlib++ does not provide:
- Heap allocation:
operator new/operator new[]/operator delete. - A throw path:
std::__throw_length_error,__throw_bad_alloc,__throw_logic_error,__throw_bad_function_call,__throw_system_error,__throw_out_of_range, etc. You never writethrowunder-fno-exceptions, but library inline templates contain throw expressions, and instantiating one references these symbols. - Out-of-line library symbols or static init: iostreams (
std::cout,ios_base::Init), locale.
Pure template / constexpr / inline headers with none of those are fully supported.
<type_traits> <utility> <initializer_list> <tuple> <array> <span>
<string_view>* <optional>* <variant>* <bit> <bitset> <limits>
<concepts> <compare> <numbers> <ratio> <algorithm>** <atomic>
<chrono> <version> <source_location>
C-compat wrappers, backed by libc/libm (already linked):
<cstdint> <cstddef> <cstring> <cmath> <cstdarg> <climits> <cfloat>
<cinttypes> <cstdlib>
* Supported except their throwing accessors: optional::value(), variant's std::get on type mismatch, string_view::at() / substr() on bad position pull std::__throw_*. Use *opt, std::get_if, operator[] instead.
** <algorithm>: min / max / clamp / sort over your own data are fine (no alloc, no throw). Note std::min is not a keyword; it needs <algorithm>, so include it explicitly rather than relying on a transitive include.
| Header | Supported | Fails to link |
|---|---|---|
<functional> |
std::invoke, std::ref, std::hash, std::less / plus, lambdas |
std::function (__throw_bad_function_call) |
<new> |
placement new, std::nothrow, std::launder |
global new[] / delete[] (no allocator) |
<memory> |
std::addressof, std::uninitialized_* on your own buffers |
make_unique / make_shared / unique_ptr<T[]> (operator new + __throw_bad_alloc) |
<vector> <string> <map> <unordered_map> <set> <deque> (allocate + throw); <iostream> <sstream> <fstream> (out-of-line symbols + static init); <mutex> (__throw_system_error); <regex> <locale>. Example: <vector> alone pulls __throw_length_error + __throw_bad_array_new_length + __throw_bad_alloc + operator new.
To use the allocating/throwing containers anyway, you must provide the missing pieces yourself: operator new / operator delete plus the handful of std::__throw_* functions (as [[noreturn]] to __builtin_trap()). That is a deliberate decision, not a default.
When you hit an unsupported facility, it is usually preferable to have an AI generate a small, purpose-built equivalent — a derivative container, string, or helper tailored to exactly what you need — rather than pulling in and linking a whole heavyweight library. A bespoke Vector that does only what your code requires is smaller, faster to compile, has no hidden allocation or throw paths, and stays within the C+ constraints by construction. Reach for a full third-party library only when the functionality is genuinely too large or subtle to reproduce.
Avoid std::string specifically. Beyond the allocation and throw paths it shares with the other containers, std::basic_string is an exponential template-instantiation minefield: it drags in char_traits, allocator machinery, and a web of inline templates whose instantiations balloon compile time and code size, and a single use tends to pull the whole graph in transitively. It is one of the worst offenders for the C-like compile times and binary size C+ is trying to preserve. Use <cstring> with plain char* buffers, a std::string_view over storage you own, or a small purpose-built string type instead.
Keep the build simple: use a Makefile, a build.sh, or a build.bat. C+ deliberately avoids meta build systems like CMake and Meson — they add a layer of indirection over the exact compiler and linker flags that this approach depends on being explicit and visible. A plain script or Makefile keeps the flags in front of you where they belong.
CPLUS := -fno-unwind-tables \
-fno-asynchronous-unwind-tables \
-fvisibility=hidden \
-fno-semantic-interposition \
-fno-math-errno \
-fno-trapping-math
# Re-enable C99 / C-style features (designated initializers, compound
# literals, VLAs) by silencing their "extension" warnings.
C99 := -Wno-c99-designator \
-Wno-c23-extensions \
-Wno-vla-cxx-extension \
-Wno-address-of-temporary \
-Wno-missing-field-initializers
LDPLUS := -static-libgcc \
-nostdlib++
CXXFLAGS += -std=c++23 -fno-exceptions -fno-rtti $(CPLUS) $(C99)
LDFLAGS += $(LDPLUS)(-fno-exceptions -fno-rtti are kept outside CPLUS because they are core language toggles, not strip-down decorations. Group them however suits your taste.)
#!/bin/sh
set -e
CPLUS="-fno-unwind-tables -fno-asynchronous-unwind-tables \
-fvisibility=hidden -fno-semantic-interposition \
-fno-math-errno -fno-trapping-math"
# Re-enable C99 / C-style features (designated initializers, compound literals, VLAs).
C99="-Wno-c99-designator -Wno-c23-extensions -Wno-vla-cxx-extension \
-Wno-address-of-temporary -Wno-missing-field-initializers"
LDPLUS="-static-libgcc -nostdlib++"
clang++ -std=c++23 -fno-exceptions -fno-rtti $CPLUS $C99 $LDPLUS \
-o your_target src/*.cpp@echo off
set CPLUS=-fno-unwind-tables -fno-asynchronous-unwind-tables ^
-fvisibility=hidden -fno-semantic-interposition ^
-fno-math-errno -fno-trapping-math
rem Re-enable C99 / C-style features (designated initializers, compound literals, VLAs).
set C99=-Wno-c99-designator -Wno-c23-extensions -Wno-vla-cxx-extension ^
-Wno-address-of-temporary -Wno-missing-field-initializers
set LDPLUS=-static-libgcc -nostdlib++
clang++ -std=c++23 -fno-exceptions -fno-rtti %CPLUS% %C99% %LDPLUS% ^
-o your_target.exe src\*.cpp