How g++ injects -lstdc++

Source references in this chapter are to GCC 15.2.0 (releases/gcc-15.2.0).

You have a project of C object files. Nothing in it is C++. You link it with g++ because that is what the build system happens to use, and you add -lz because you need zlib. The link line that comes out has -lstdc++ on it, and — more disconcertingly — your -lm has moved. You typed it before -lz; it comes out after -lstdc++.

g++ is the same binary logic as gcc with one extra function called before any option is acted on. That function may rewrite the entire option array: insert options, delete them, and reorder them. Everything surprising about C++ linking happens in those few hundred lines, and none of it is in the manual.

This chapter reads gcc/cp/g++spec.cc end to end. Chapter 1.8 showed what the generic driver does with a link line; this is what has already happened to that line before the driver sees it.

One hook, called before anything

The generic driver decodes argv into an array of struct cl_decoded_option, and then — before acting on a single one of them — calls into the front end:

  /* Do language-specific adjustment/addition of flags.  */
  lang_specific_driver (&decoded_options, &decoded_options_count,
			&added_libraries);

Listing 11-1: the only hook this chapter is about (gcc/gcc.cc:4860-4862).

Note the addresses-of. All three arguments are out-parameters: the callee may replace the array wholesale, change its length, and report how many libraries it added. Every front end links a lang_specific_driver; C's is a no-op, and C++'s is gcc/cp/g++spec.cc. That is the entire difference between gcc and g++.

The consequence is worth stating before the mechanism: by the time the specs run, -lstdc++ is an ordinary -l, indistinguishable from one you typed. No spec mentions it. Grepping -dumpspecs for stdc++ finds nothing.

One variable with four values

The whole decision collapses into a single int, and its own comment is the clearest documentation that exists:

  /* What action to take for the c++ runtime library:
    -1  means we should not link it in.
     0  means we should link it if it is needed.
     1  means it is needed and should be linked in.
     2  means it is needed but should be linked statically.  */
  int library = 0;

Listing 11-2: gcc/cp/g++spec.cc:92-97.

The default is 0link it if needed — and the whole first pass over the options exists to move it off zero. Two things follow from the initial value being the "maybe" state rather than "no": most transitions are 0 → 1, and they are guarded by if (library == 0) so that an earlier -1 cannot be overwritten.

Alongside library there is a parallel array of per-argument bits, one int per option:

/* This bit is set if we saw a `-xfoo' language specification.  */
#define LANGSPEC	(1<<1)
/* This bit is set if they did `-lm' or `-lmath'.  */
#define MATHLIB		(1<<2)
/* This bit is set if they did `-lc'.  */
#define WITHLIBC	(1<<3)
/* Skip this option.  */
#define SKIPOPT		(1<<4)
/* Add -lstdc++exp for experimental features that need library support.  */
#define EXPERIMENTAL	(1<<5)

Listing 11-3: the per-argument bits (gcc/cp/g++spec.cc:26-35).

Four of those five are set and tested. EXPERIMENTAL is never used — the #define is its only occurrence in the file, at every release from 13.3.0 through 15.2.0. -fcontracts is tracked by a separate bool need_experimental instead. It is the first of two pieces of vestigial state you will meet in this chapter; both are worth knowing about only so that you stop looking for what sets them.

What turns injection on

Two paths reach library = 1, and both are broader than people expect.

Any -l you do not recognise

	case OPT_l:
	  if (strcmp (arg, MATH_LIBRARY) == 0)
	    {
	      args[i] |= MATHLIB;
	      need_math = 0;
	    }
	  else if (strcmp (arg, "c") == 0)
	    args[i] |= WITHLIBC;
	  else
	    /* Unrecognized libraries (e.g. -lfoo) may require libstdc++.  */
	    library = (library == 0) ? 1 : library;
	  break;

Listing 11-4: gcc/cp/g++spec.cc:178-189.

There are exactly two recognised libraries — MATH_LIBRARY ("m" by default) and "c". Everything else is an unknown, and an unknown might need the C++ runtime. So g++ -lz foo.o links libstdc++ because of -lz, on the strength of the source comment quoted above and nothing more.

-Xlinker and -Wl, do the same thing, with a comment that spells out the reasoning (:207-213): "Arguments that go directly to the linker might be .o files, or something, and so might cause libstdc++ to be needed."

Any input file that is not a header

This is the rule that folklore gets wrong. It is not "a C++ source file on the command line". It is a negative test on the filename:

	    /* If we don't know that this is a header file, we might
	       need to be linking in the libraries.  */
	    if (library == 0)
	      {
		if ((len <= 2 || strcmp (arg + (len - 2), ".H") != 0)
		    && (len <= 2 || strcmp (arg + (len - 2), ".h") != 0)
		    && (len <= 4 || strcmp (arg + (len - 4), ".hpp") != 0)
		    ...
		    && (len <= 3 || strcmp (arg + (len - 3), ".hh") != 0))
		  library = 1;
	      }

Listing 11-5: abridged from gcc/cp/g++spec.cc:277-291.

Nine header suffixes are checked — .H, .h, .hpp, .hp, .hxx, .h++, .HPP, .tcc, .hh — and anything else sets library = 1. There is no test for a C++ extension anywhere in the function. g++ foo.o, g++ foo.a, g++ foo.s all link the C++ runtime, because none of those is a header.

So the accurate rule is: g++ links libstdc++ unless you gave it nothing but headers, or explicitly told it not to. The driver is deliberately conservative, and the trade is sound — a missing -lstdc++ is a confusing undefined-symbol error, a spurious one costs a DT_NEEDED entry.

You can watch the two paths independently:

$ g++ -### foo.o        2>&1 | tr ' ' '\n' | grep -c stdc      # 1 — input file rule
$ g++ -### foo.hh       2>&1 | tr ' ' '\n' | grep -c stdc      # 0 — header, no link anyway
$ gcc -### foo.o -lz    2>&1 | tr ' ' '\n' | grep -c stdc      # 0 — gcc injects nothing

Listing 11-6: the injection rules, one command each.

What turns injection off

Chapter 1.10 covered the link-line half of -nostdlib. Here is its C++ half:

	case OPT_nostdlib__:
	  args[i] |= SKIPOPT;
	  /* FALLTHRU */
	case OPT_nostdlib:
	case OPT_nodefaultlibs:
	  library = -1;
	  break;

Listing 11-7: gcc/cp/g++spec.cc:170-176.

And separately, the options under which no link happens at all:

	case OPT_c:
	case OPT_r:
	case OPT_S:
	case OPT_E:
	case OPT_M:
	case OPT_MM:
	case OPT_fsyntax_only:
	  /* Don't specify libraries if we won't link, since that would
	     cause a warning.  */
	  library = -1;
	  break;

Listing 11-8: gcc/cp/g++spec.cc:215-225.

Listing 11-8 is why g++ -c foo.cc produces no unused-argument noise, and why -r lands at -1 even though it is absent from Listing 11-7.

Note what is not in either list: -nostartfiles. The truth table from Chapter 1.10 is asymmetric in its fourth column precisely because that column is decided here, by two case labels, rather than by the %{!…:} guards in LINK_COMMAND_SPEC.

-nostdlib++ is the one that vanishes. It takes SKIPOPT before falling through, which means the option is deleted from the array at :368-369 and never reaches the specs. So it removes the C++ runtime and leaves -lc and -lgcc completely alone — which is exactly what its name promises and what -nostdlib cannot do.

If you are on GCC 12 or earlier, -nostdlib++ does not exist. It arrived in GCC 13, along with -lstdc++exp. The same commit took this file from 436 to 460 lines, so every line number in this chapter differs before GCC 13 — and before GCC 12 the file is g++spec.c, not .cc.

The reordering nobody documents

Now the part that changed your command line. In the copy loop, two options are plucked out as they go past:

      /* Make sure -lstdc++ is before the math library, since libstdc++
	 itself uses those math routines.  */
      if (!saw_math && (args[i] & MATHLIB) && library > 0)
	{
	  --j;
	  saw_math = &decoded_options[i];
	}

      if (!saw_libc && (args[i] & WITHLIBC) && library > 0)
	{
	  --j;
	  saw_libc = &decoded_options[i];
	}

Listing 11-9: gcc/cp/g++spec.cc:326-338.

The --j is the removal. The option was already written to new_decoded_options[j] at the top of the loop; decrementing j means the next iteration overwrites it. The option is stashed in a pointer and re-emitted at the end, after the runtime:

  if (saw_math)
    new_decoded_options[j++] = *saw_math;
  else if (library > 0 && need_math)
    { ... generate_option (OPT_l, MATH_LIBRARY, ...) ... }
  if (saw_time)
    new_decoded_options[j++] = *saw_time;
  if (saw_libc)
    new_decoded_options[j++] = *saw_libc;

Listing 11-10: the re-emission (gcc/cp/g++spec.cc:430-443).

Three things to take from this.

The reordering is real and silent. g++ -lm foo.o links foo.o -lstdc++ -lm. On a target where everything is a static archive, ld resolves left to right and order determines which symbols get pulled in, so this is not cosmetic. The reason is correct — libstdc++.a has undefined references into libm — but the consequence is that you cannot put -lm before -lstdc++ through the g++ driver at all. If you need that, drive the link with gcc or ld and supply the C++ runtime yourself.

A missing -lm is synthesised. The else if branch adds one when you did not. It is gated on need_math, which is (MATH_LIBRARY[0] != '\0') at :129 — so a port that folds the maths routines into libc defines MATH_LIBRARY "" and no -lm appears. Check this before concluding that a new target's link line is too short.

The -lrt slot is dead code. saw_time is initialised to NULL at :118-119, read at Listing 11-10, and assigned nowhere in the file — there is no TIMELIB bit to go with MATHLIB and WITHLIBC. Verified identical at every release from 11.4.0 to 15.2.0. Exactly two of your options ever get hoisted: -lm and -lc. This is the second piece of vestigial state promised earlier.

$ g++ -### -lm -lz foo.o 2>&1 | tr ' ' '\n' | grep -E '^-l|stdc'

Listing 11-11: watch -lm come out after -lstdc++, and -lz stay where you put it.

-static-libstdc++ changes no path and no filename

The option does not affect the library name, the search order, or any -L. It wraps the one -l in a pair of linker state changes:

… -Wl,-Bstatic -lstdc++ -Wl,-Bdynamic -lm -lc

That is all it is: flip ld into archive-preference mode for exactly one library, then flip it back. The two halves are emitted around the -l:

#ifdef HAVE_LD_STATIC_DYNAMIC
      if (library > 1 && !static_link)
	{
	  generate_option (OPT_Wl_, LD_STATIC_OPTION, 1, CL_DRIVER,
			   &new_decoded_options[j]);
	  j++;
	}
#endif

Listing 11-12: the opening half (gcc/cp/g++spec.cc:384-391); the closing LD_DYNAMIC_OPTION is the mirror image at :421-428.

library > 1 is the 2 state, and !static_link skips the wrap under plain -static, where everything is archive-only already. The option that sets the state also deletes itself:

	case OPT_static_libstdc__:
	  library = library >= 0 ? 2 : library;
#ifdef HAVE_LD_STATIC_DYNAMIC
	  /* Remove -static-libstdc++ from the command only if target supports
	     LD_STATIC_DYNAMIC.  When not supported, it is left in so that a
	     back-end target can use outfile substitution.  */
	  args[i] |= SKIPOPT;
#endif
	  break;

Listing 11-13: gcc/cp/g++spec.cc:235-243.

Read the library >= 0 guard: g++ -c -static-libstdc++ stays at -1, because -c got there first. And read the #ifdef: on a target without HAVE_LD_STATIC_DYNAMIC the option is not deleted, so it survives into the specs for the port to act on. That conditional is why two targets disagree about whether the option appears in -v output.

Two consequences that bite in practice:

libstdc++.a has to exist in the same search path. Nothing about the search changed — only ld's preference between .a and .so. If you ship a cross toolchain with a shared-only libstdc++, this option silently gives you the shared one and the link succeeds.

The option does not appear in the link line on most targets. Do not grep -v output for -static-libstdc++; grep for Bstatic:

$ g++ -### -static-libstdc++ foo.o 2>&1 | tr ' ' '\n' | grep -E 'Bstatic|Bdynamic|stdc'

Listing 11-14: the option is gone; its effect is two -Wl, options.

The exact spelling is configure-probed, in a case "$target" at gcc/configure.ac:4231-4250:

ValueTarget
-Bstatic / -BdynamicGNU ld, Solaris — the default
-bstatic / -bdynamicAIX
-aarchive_shared / -adefaultHP-UX (non-GNU ld)

with the three AC_DEFINEs at :4253-4259.

Which library, exactly

The name is a macro, so a port can change it:

#ifndef LIBSTDCXX
#define LIBSTDCXX "stdc++"
#endif
#ifndef LIBSTDCXX_PROFILE
#define LIBSTDCXX_PROFILE LIBSTDCXX
#endif
#ifndef LIBSTDCXX_STATIC
#define LIBSTDCXX_STATIC NULL
#endif

Listing 11-15: gcc/cp/g++spec.cc:44-52.

LIBSTDCXX_PROFILE is chosen when saw_profile_flag is set by -p or -pg (:407-410). LIBSTDCXX_STATIC is not a replacement but an extra library appended after the main one when linking statically (:413-419) — the hook an RTOS or bare-metal port uses to add its own support archive. If a link line on an unusual target does not say -lstdc++, one of these three macros is why.

-fcontracts adds -lstdc++exp ahead of the main library (:378-383), and — read the line numbers against Listing 11-12 — that happens before the -Wl,-Bstatic. So -static-libstdc++ does not cover libstdc++exp.

-stdlib=libc++ switches to clang's library, emitting -lc++ and -lc++abi (:392-406). The ABI library is skipped when the port sets LIBCXXABI to NULL, whose comment explains the case: a platform may forward the ABI library from libc++ or combine it some other way. The option only exists if GCC was configured for it — ENABLE_STDLIB_OPTION, which Chapter 1.12 covers.

The bookkeeping that stops g++ linking nothing

added_libraries is incremented for each synthesised -l and handed back through the third out-parameter of Listing 11-1. The generic driver uses it once:

  if (n_infiles == added_libraries)
    fatal_error (input_location, "no input files");

Listing 11-16: gcc/gcc.cc:8970-8971.

Without that count, plain g++ with no arguments would see the libraries it had just added itself, conclude it had inputs, and attempt a link. This is the only place the return value is used, and it is the reason lang_specific_driver has to report what it did rather than just doing it.

Documentation coverage

The options are documented — -static-libstdc++, -nostdlib++ and -stdlib= are all in the manual. The mechanism is not, anywhere:

  • The injection itself has no texi node. No document states that g++ adds -lstdc++, let alone under what conditions. The library state machine exists only as the source comment in Listing 11-2.
  • The -lfoo rule is undocumented, and it is the one that catches people: linking any third-party library through g++ guarantees the C++ runtime.
  • The input-file rule is undocumented and is a header-suffix blacklist, not a C++-source test. Listing 11-5 is the only statement of it.
  • The -lm / -lc hoist is undocumented. Nothing warns that g++ silently reorders your command line.
  • -static-libstdc++'s mechanism is undocumented. The option is described; that it becomes -Wl,-Bstatic-Wl,-Bdynamic and vanishes from the command line is not, and neither is the HAVE_LD_STATIC_DYNAMIC conditional that decides whether it vanishes.

This is the largest documentation gap in Part I, which is why the chapter quotes so much source: the comments in g++spec.cc are the specification.

Things that surprise people

gcc never injects anything. Link C++ objects with gcc and you get undefined std:: symbols. That is the whole practical difference between the two drivers at link time — not a different compiler, not different code generation.

g++ -lz foo.o links the C++ runtime even though nothing in it is C++.

Your -lm and -lc positions are not preserved. Everything else is.

-nostartfiles does not suppress -lstdc++. Two mechanisms, two lists.

A successful link says nothing about whether the program starts. Finding libstdc++.so.6 at run time is the dynamic loader's job, driven by DT_NEEDED, DT_RUNPATH and ld.so.conf — an entirely separate system from anything in this chapter. Which libstdc++ the -l resolved to at link time is Chapter 1.12.


All source references in this chapter are to GCC 15.2.0 (releases/gcc-15.2.0). Line numbers in other releases will differ; the surrounding code rarely does. Where behaviour itself changed across a major version, it is flagged inline.