Turning the runtime off
Source references in this chapter are to GCC 15.2.0 (releases/gcc-15.2.0).
You are bringing up firmware on a Cortex-M part. There is no operating system, no
glibc, and certainly no crt1.o that knows how to call main. So you reach for
-nostdlib, the link succeeds in the sense that ld produces a file, and then it
fails on an undefined reference to __aeabi_idiv — a symbol you have never typed,
from a library you thought you had just switched off.
-nostdlib is not one mechanism. It is three %{!…:} guards in one spec string,
plus one case label in the C++ driver, and it does not stop the compiler emitting
calls into libgcc. Everything it removes, you now owe. This chapter reads the
guards, derives the truth table from the nesting rather than from the manual, and
separates -nostdlib from the three options that sound like it and are not.
Chapter 1.8 built the full link line. This chapter takes it apart.
The four options, and where they act
Four options in the family, all declared in gcc/common.opt, all carrying the
single word Driver and nothing else:
nodefaultlibs
Driver
nostartfiles
Driver
nolibc
Driver
nostdlib
Driver
nostdlib++
Driver
Listing 10-1: the whole declaration of every option in this chapter
(gcc/common.opt:3782-3795).
Read what is missing: there is no Var(...), no help text, no
Init(...). An option with Driver and no Var has no C variable anywhere in
the compiler. It cannot be tested with an if. Its entire existence is as a
string that a %{!nostdlib:…} conditional can match against. That is why these
options compose so cleanly, and it is also why grepping the source for
flag_nostdlib finds nothing.
If you are on GCC 12 or earlier,
-nostdlib++does not exist. It was added in GCC 13; the option is absent fromgcc/common.optatreleases/gcc-11.4.0andreleases/gcc-12.3.0, and present fromreleases/gcc-13.3.0onward.-nolibcand-nostdlibgo back much further.
The truth table is the nesting
Three lines of LINK_COMMAND_SPEC carry all of it. You met the whole template as
Listing 8-1; here are just the guarded slots:
%{s} %{t} %{u*} %{z} %{Z} %{!nostdlib:%{!r:%{!nostartfiles:%S}}} \
...
%{!nostdlib:%{!r:%{!nodefaultlibs:%(link_ssp) %(link_gcc_c_sequence)}}}\
%{!nostdlib:%{!r:%{!nostartfiles:%E}}} %{T*}
Listing 10-2: the three guarded slots, at gcc/gcc.cc:1168,
:1176 and :1177.
Each slot is guarded three deep, and the outer two guards are the same on all three lines. Read them from the outside in and the behaviour falls out without consulting any documentation:
| Option | Suppresses %S (startfiles) | Suppresses %(link_gcc_c_sequence) | Suppresses %E (endfiles) |
|---|---|---|---|
-nostartfiles | yes | no | yes |
-nodefaultlibs | no | yes | no |
-nostdlib | yes | yes | yes |
-r | yes | yes | yes |
The two rows that catch people are the last two, and for the same reason: the outer
guards are %{!nostdlib:%{!r: on every line, so -r and -nostdlib are
indistinguishable here. A relocatable link never gets the runtime. If you have
been passing -r and wondering why crtbegin.o is missing, that is the whole
answer, and it is correct — a partial link must not drag in startup code that will
be linked again later.
The distinction between -nostartfiles and -nodefaultlibs is just which inner
guard sits on which line. -nostartfiles appears on the %S and %E lines;
-nodefaultlibs appears on the library line. Nothing enforces the symmetry — it is
literally which word was typed inside which %{!…:}.
Watching the guards fail
Every claim above is one -### away. -### prints the command lines the driver
would run and then stops, so you can diff its output against itself:
$ gcc -### hello.c 2>&1 | tr ' ' '\n' | grep -E 'crt|^-l' | sort > full
$ gcc -### -nostdlib hello.c 2>&1 | tr ' ' '\n' | grep -E 'crt|^-l' | sort > nostdlib
$ gcc -### -nostartfiles hello.c 2>&1 | tr ' ' '\n' | grep -E 'crt|^-l' | sort > nostart
$ gcc -### -nodefaultlibs hello.c 2>&1 | tr ' ' '\n' | grep -E 'crt|^-l' | sort > nodeflibs
$ diff full nostart # only crt* lines disappear
$ diff full nodeflibs # only -l* lines disappear
$ diff full nostdlib # both
$ gcc -### -r hello.c 2>&1 | tr ' ' '\n' | grep -cE 'crt|^-l' # 0, same as -nostdlib
Listing 10-3: deriving the truth table experimentally.
The sort matters, because -nodefaultlibs also changes the order of what is
left. What it does not do is change any search path: -print-search-dirs and
-print-file-name=crt1.o give identical answers with and without every option in
this chapter. These options remove text from a command line. They never touch
where the driver looks.
What each guard is actually gating
%S and %E are the startfile and endfile specs from Chapter 1.8. Recall that
their defaults are crt0-based and empty respectively
(gcc/gcc.cc:891-895 and :897-900), and the
familiar crt1.o crti.o crtbegin.o shape is the GNU-userspace override. So what
-nostartfiles costs you is target-specific — on glibc it is five objects, on a
target that never defined STARTFILE_SPEC it may be one.
The library slot is more interesting, because it is not -lc:
%G %{!nolibc:%L %G}
Listing 10-4: LINK_GCC_C_SEQUENCE_SPEC (gcc/gcc.cc:987-992).
%G is libgcc and %L is libc, so the sequence is -lgcc -lc -lgcc — the double
libgcc from Chapter 1.8. -nodefaultlibs removes the whole expression;
-nolibc removes only the middle, leaving -lgcc on both sides. That is the
option you want when you have your own libc but still want the compiler's helper
routines, and it is the one almost nobody knows about.
This text is byte-identical at every release from GCC 11 to 15.2.0, which makes it one of the safer things to memorise in the driver.
The -lgcc you cannot switch off
Here is the failure the chapter opened with, stated precisely.
-nostdlib removes -lgcc from the link line. It does not remove the calls to
libgcc that the compiler has already emitted into your object files. Those calls
appear because the target has no instruction for what you wrote: a 64-bit divide on
a 32-bit machine, a soft-float multiply, an unaligned wide load, a switch lowered
to a table helper. On Arm they are the __aeabi_* family; elsewhere __divdi3,
__udivsi3, __muldf3.
$ arm-none-eabi-gcc -c -O2 -mcpu=cortex-m0 div.c
$ arm-none-eabi-nm -u div.o | grep aeabi
Listing 10-5: the calls survive -nostdlib because they were emitted at compile
time, long before the link line existed.
So the working incantation on bare metal is almost never bare -nostdlib. It is
-nostdlib plus libgcc back by hand:
$ arm-none-eabi-gcc -nostdlib -T link.ld start.o main.o -lgcc
and -print-libgcc-file-name tells you which one you are getting:
$ arm-none-eabi-gcc -mcpu=cortex-m4 -mfloat-abi=hard -print-libgcc-file-name
That path runs through the whole multilib expansion of Chapter 1.7, so the answer
changes with your -mcpu and -mfloat-abi. A libgcc.a from the wrong variant
links and then misbehaves.
An undefined __aeabi_idiv or __udivsi3 after a -nostdlib link is the single
most common consequence of this chapter, and it is always the same fix.
The -lstdc++ column has a different mechanism
-lstdc++ is not in any spec. It is injected by the C++ driver before the specs
run at all, so it cannot be removed by a %{!…:} guard. The C++ driver checks the
options itself:
case OPT_nostdlib__:
args[i] |= SKIPOPT;
/* FALLTHRU */
case OPT_nostdlib:
case OPT_nodefaultlibs:
library = -1;
break;
Listing 10-6: the C++ side of the family (gcc/cp/g++spec.cc:170-176).
library = -1 means "never link the C++ runtime", per the state comment at
g++spec.cc:92-97. Three consequences that do not follow from the
spec guards:
-nostartfiles does not suppress -lstdc++. It is not in that case list.
The startfiles and the C++ runtime are decided by two unrelated pieces of code, and
this is where the symmetry of the truth table breaks.
-r still ends up at -1, by a different route. It is absent from Listing
10-6 but present a few lines further down, in the list alongside -c, -S and
-E (:215-225), whose comment is "Don't specify libraries if we
won't link, since that would cause a warning."
-nostdlib++ disappears from the command line. Note the SKIPOPT on the first
line, and the /* FALLTHRU */ — it takes the same library = -1 as -nostdlib,
and is then deleted from the option array. Unlike -nostdlib, it never reaches the
specs, so it drops the C++ runtime while leaving -lc and -lgcc untouched.
Chapter 1.11 reads that state machine in full.
Three options that sound related and are not
This is the confusion worth spending a page on, because the names invite it.
| Option | Stage | What it changes |
|---|---|---|
-nostdlib | link | text on the linker command line |
-ffreestanding | compile | what the compiler may assume about libc and main |
-nostdinc / -nostdinc++ | preprocess | the header search list |
They are fully independent, and each is implemented in a different part of the
compiler. You can #include <stdio.h> perfectly happily under -nostdlib and
fail only at link. You can pass -ffreestanding and watch the link line not
change at all.
-ffreestanding sets two flags, not one
Every secondhand account says -ffreestanding sets flag_hosted = 0. It sets two:
case OPT_ffreestanding:
value = !value;
/* Fall through. */
case OPT_fhosted:
flag_hosted = value;
flag_no_builtin = !value;
break;
Listing 10-7: gcc/c-family/c-opts.cc:498-504.
flag_hosted is what __STDC_HOSTED__ is defined from and what relaxes the
requirements on main. But flag_no_builtin is the flag that does the thing people
actually notice: it stops the compiler turning printf("x\n") into puts("x") or
memcpy into inline moves. Two further effects follow from it — loop pattern
recognition is disabled (:935-939) and the -Wmain default
flips (:947-954).
So -ffreestanding is closer to -fno-builtin than to -nostdlib, and if you
were expecting it to change your link line you were reading the name, not the code.
-nostdinc removes GCC's own headers too
-nostdinc sets a file-static bool:
case OPT_nostdinc:
std_inc = false;
break;
Listing 10-8: gcc/c-family/c-opts.cc:639-641.
which is passed through as the stdinc parameter of register_include_chains
(c-opts.cc:867-868), where it gates one call:
/* Finally chain on the standard directories. */
if (stdinc)
add_standard_paths (sysroot, iprefix, imultilib, cxx_stdinc);
Listing 10-9: the entire implementation of -nostdinc
(gcc/incpath.cc:512-514).
add_standard_paths is the function that walks cpp_include_defaults — the table
from Chapter 1.9 that contains both /usr/include and GCC's own
lib/gcc/<target>/<version>/include. One if skips the whole table. So
-nostdinc does not remove "the system headers"; it removes every standard
directory, including the one holding <stddef.h>, <stdint.h> and <stdarg.h>,
which Chapter 1.9 showed the compiler is required to provide.
Which is why the useful form is almost always this pair:
$ gcc -nostdinc -isystem "$(gcc -print-file-name=include)" -c foo.c
Listing 10-10: drop the system headers, keep the compiler's own.
-isystem adds to a chain that is merged after add_standard_paths would have run,
so it survives. Confirm with -v:
$ echo 'int main(void){return 0;}' | gcc -nostdinc -E -v -x c - 2>&1 | sed -n '/search starts here/,/End of search/p'
-nostdinc++ is the narrow one. It sets std_cxx_inc = false, which arrives as
cxx_stdinc and only suppresses entries whose cplusplus field is non-zero
(gcc/incpath.cc:176-177) — the C++ directories from
Chapter 1.9's table, and nothing else. The C headers stay.
What you owe once you have taken it away
Under -nostdlib you are responsible for four things that were previously
invisible:
- An entry point. The linker's default is
_start, notmain. Nothing now callsmain, sets up the stack, or zeroes.bss. - Static initialisation.
crtbegin.o/crtend.oare what walk the.init_array/.fini_arraylists. Without them, C++ constructors for file-scope objects and C__attribute__((constructor))functions never run, silently. - libgcc, by hand, as above.
- A libc, or the discipline not to need one.
-nolibcis the middle ground: keep-lgcc, drop-lc.
Points 1 and 2 are the ones that produce a program that links, runs, and does the wrong thing rather than one that fails to build.
Documentation coverage
The options themselves are documented, in the Link Options node
(gcc/doc/invoke.texi:19067, or info gcc 'Link Options'). What is
documented is what each one suppresses.
What is not documented anywhere:
- That they are pure spec conditionals with no C variable. The manual describes
the effect; nothing tells you the implementation is three
%{!…:}guards, which is the fact that lets you predict the interactions instead of memorising them. - That
-ris equivalent to-nostdlibfor all three slots. You can only get this by reading the%{!r:in Listing 10-2. - That
-nostdincremoves GCC's own headers as well as the system's. The help text says "standard system include directories", which reads as/usr/include. Listing 10-9 is the only place the truth is stated. - That
-ffreestandingsetsflag_no_builtin. The documented effect is aboutmainand the standard library; the built-in-recognition half is only in Listing 10-7. - That
-lstdc++is removed by a different mechanism entirely, and therefore that the truth table has an asymmetric fourth column. Nothing in the manual connects the two families.
Things that surprise people
These options change no search path. Removing -lc does not remove
<sysroot>/usr/lib from the search list. Chapter 1.8's rule holds: the driver
emits text, ld resolves it.
-nostdlib and -ffreestanding are frequently used together and do not overlap
at all. For a genuinely hermetic build you want all three families —
-nostdlib -ffreestanding -nostdinc — plus explicit -I, -L and --sysroot.
A missing constructor is a -nostartfiles bug. If your C++ globals are
uninitialised and nothing crashed, look for crtbegin.o on the link line before
looking anywhere else.
-nolibc exists. People reach for -nodefaultlibs and then add -lgcc back
by hand, which is exactly what -nolibc does for you in one option.
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.