Stripped Release Binaries With Debug Symbols

How to ship optimised, stripped binaries and still decode field crashes: -g kept separate from -O, post-link strip, build-ID symbol archives, timing…

Stripped Release Binaries With Debug Symbols
Written by TechnoLynx Published on 01 Nov 2026

You can ship an optimised, stripped binary and still debug a field crash against a separate debug file. Compile once at your production optimisation level with -g added, strip the debug sections after linking, and archive the unstripped debug file keyed by the binary’s build ID. Then verify by measurement that backtraces actually unwind and that timing did not move. Building with debug symbols and stripping in a final step is common practice in many C/C++ projects.

TechnoLynx puts this arrangement in place through the AI Porting & Deployment Pack, setting the flag policy, stripped binaries and build-ID-matched symbol archive on your hardware; the first step is framing your timing constraints and field-debugging requirement.

Does -g change the code you ship?

No. Adding -g to a release build does not change the machine code the device runs. Debug information sits in non-loadable ELF sections, so it is ignored when the symbol file is flashed and dropped when the ELF is converted to .bin or .hex. Memfault’s Interrupt blog demonstrates this directly. It compiles a program with and without -ggdb3, strips both, and gets bit-for-bit identical files once the build-ID note is also removed. The GCC manual states that -g can be combined with optimisation options.

That answers the runtime-cost question for -g on its own: the debug information is never loaded on the device. The cost is elsewhere. Debug sections make object files and executables much larger, and the compiler, linker and packaging steps all take longer to process them.

The real risk is the other differences a separate debug build brings. A different optimisation level produces a different executable, with different stack and heap use, different code size and different machine code. That can change timing and expose concurrency bugs (instruction reordering is the classic one) that only show up in the field. Different log verbosity can also shift timing and memory use sharply. So the recommendation is one optimisation setting for every build, and the Ohio Supercomputer Center’s tuning guide gives the same advice for profiling: use -g at the optimisation level you would normally ship.

-g takes a level that controls how much information is produced. Level 1 gives function descriptions and line tables, which is enough for backtraces, and level 2 (the default) adds locals and types for stepping through code. On a constrained target, -g1 is a reasonable floor if crash backtraces are all you need from the field.

How do you strip symbols from a shipped binary and still decode a crash dump from the field?

Strip as a separate post-link step and keep what you removed. The GNU objcopy documentation describes this as a “two part executable”. One part is a stripped binary for the device. The other is a debugging information file you only need when you debug. Stripping with -s leaves no unstripped copy, so it cannot produce the second, debug part.

Release procedure for a stripped binary plus archived symbols

  1. Compile with your production -O level plus -g, and link with -Wl,--build-id so the executable carries a .note.gnu.build-id section.
  2. Extract the debug sections: objcopy --only-keep-debug foo foo.debug. The result is a non-executable ELF that holds only the debug-related sections.
  3. Strip the shipped binary: objcopy --strip-debug foo (or strip -g foo).
  4. Reconnect the two: objcopy --add-gnu-debuglink=foo.debug foo. Run this from the directory that holds the debug file, with no directory components, because the file must exist at the path you give.
  5. Archive foo.debug indexed by build ID, as debuginfod does when it indexes build trees and debuginfo packages by build ID.
  6. Decode a real crash dump against the archive before you call the pipeline done.

The debuglink target does not have to be an --only-keep-debug file. objcopy also accepts linking the stripped binary to a full unstripped copy. At debug time, the debugger looks for the file in known locations: beside the executable, in a .debug subdirectory, or in a global directory such as /usr/lib/debug.

Keeping a debug-symbol file matched to each release build

The build ID ties a stripped binary to its separate debug file. GDB’s documentation describes it as a unique bit string held in a special ELF section. By default it is a 160-bit SHA1 value, and the identical value appears in the original binary, its stripped variant and the separate debug file. The section is copied by --only-keep-debug and survives stripping. Build ID support and the related debug-file fixes have been in GNU Binutils since version 2.18. The debuglink route also carries a 32-bit CRC of the debug file, which GDB uses to confirm that both came from the same build.

Field crash tooling depends on matching symbols to the exact binary. Breakpad expects binaries compiled with -g. Its dump_syms tool converts them to text symbol files, which go in a directory named by module and identifier, and minidump_stackwalk symbolicates minidumps against them. elfutils debuginfod indexes build trees or RPM/DEB archives by build ID and streams the matching debug data on request.

Firmware needs one extra step. Producing a .bin with objcopy strips most debug sections, so place .note.gnu.build-id in a ROM region through the linker script and print it over serial at boot. That lets you match a device in the field to the debug symbols of its build.

Embedded build paths are another trap: compilers write source paths into debug info, and -ffile-prefix-map=OLD=NEW (GCC 8 and later) rewrites those path prefixes.

Split DWARF when debug info swamps the build

On large codebases, -gsplit-dwarf moves most debug information into per-object .dwo files. The dwp tool then packages them into a single .dwp file. One LLVM build measurement found build time rose 39% for a debug build but only 25% with debug fission. Disk use went from 15 times the release build to 5 times.

Split DWARF has two limits. It keeps debug information in both files, so the .dwo is useless if you strip the matching executable. LLVM’s LLVM_USE_SPLIT_DWARF produces no .dwp and leaves the .dwo files out of the installation, so you need the original build tree. Split DWARF speeds builds but does not replace the strip-and-archive procedure above.

Does -O2 or -O3 make stack unwinding unreliable?

It can, because optimisation omits frame pointers on many targets. GCC’s optimisation documentation (GCC 6.4) says -O turns on -fomit-frame-pointer where that does not interfere with debugging, and warns that omitting the frame pointer “makes debugging impossible on some machines”. Many targets omit frame pointers at -O1 and above, so frame-pointer unwinding is not guaranteed across libraries. Identical code folding can disturb unwind stacks by replacing a function with an equivalent one under a different name. The same manual calls combining -flto with -g experimental.

Frame pointers are the classic way to unwind; the other way covered here is table-based unwinding. With tables, DWARF Call Frame Information is emitted as .eh_frame on ELF. With both -fno-asynchronous-unwind-tables and -fno-exceptions, GCC and Clang produce no .eh_frame, only .debug_frame at -g1 and above. Stripping debug sections then removes that .debug_frame too. Even good tables have gaps: linker-generated code such as PLT entries and thunks usually has no unwind coverage.

Published measurements put the cost of frame pointers low, but measure it on your own code. Brendan Gregg (2024) reports overhead of usually under 1% across libc and Java at Netflix, with one exception at 10%. Fedora’s change proposal for -fno-omit-frame-pointer measured a 2.4% slowdown compiling the kernel, 2% in Blender and 1 to 10% on CPython benchmarks. Those workloads are not a GPU acquisition loop, which is why the figure for your loop has to come from your target.

Debug info can still be wrong after optimisation. One study tested optimised clang/lldb builds by comparing debugger traces with unoptimised ones. They reported 23 unique bugs (16 in clang, 7 in lldb), and the backtrace bugs sat mostly in the debugger’s unwinder. One coverage study found that any optimisation beyond -O0 significantly degrades variable debug-info coverage. ASPLOS 2023 work on gcc and LLVM showed that incomplete debug info has no simple oracle to separate unavoidable loss from compiler defects. The research tests synthetic programs, not your toolchain. It shows that backtrace reliability is something you test, not something you assume.

Lowering the level changes the code you validated.

Debug info on the GPU side of the build

NVCC’s device debug behaves differently from GCC’s -g. Device debug (-G, the --device-debug option) adds generated code, not just metadata. The NVCC 12.8 documentation says stack-pointer bounds-checking code is generated automatically when --device-debug or --opt-level=0 is set. --suppress-debug-info drops debug symbols from the output but is ignored without the debug option. Because device debug adds bounds-checking code, kernels built with -G differ from the kernels a release build ships.

Toolchain upgrades reopen the question. NVIDIA states that the internal steps of an nvcc compilation phase may change with any CUDA Toolkit release. GCC’s LTO bytecode is version-locked to the compiler that produced it. Re-run the symbolication and timing checks whenever GCC or NVCC changes, not just when your code does. For profiling the acquisition loop itself, the wider practice of GPU performance engineering applies.

Will adding -g to a release build affect timing-sensitive CAN-bus code paths?

Not if -g is the only difference and every optimisation and codegen flag is identical. Even then, prove it with a binary comparison rather than an argument. The Interrupt example shows the method: strip both builds, remove the build-ID note, and diff. If the files match, the CAN-bus code is the same instructions, and timing cannot differ for build reasons.

LynxBenchAI gives you that measurement by benchmarking the timing-sensitive code paths on your target hardware, with and without the debug-related flags, so a regression shows up as a number rather than a guess; the first step is naming the CAN-bus paths whose timing you need confirmed.

If anything else differs (optimisation level, assertions, logging), treat timing as unverified until it is measured on target hardware with the real code paths exercised. That is because a different optimisation level gives a different executable. Also compare binaries, not source. Two builds of identical source are not evidence of identical object code.

What evidence do auditors need that the debug configuration matches the shipped release?

Object-level evidence matters, because the source alone does not show what will execute. DO-178C paragraph 6.4.4.2b allows structural coverage analysis on source unless the software is Level A and the compiler generates object code that is not directly traceable to source. In that case, additional verification on the object code is required. TASKING’s explanation notes that compilers legitimately reorder, merge and remove code, including defensive handlers. Evidence taken from source alone may not cover what actually runs.

Build difference Risk to timing and behaviour Evidence to present
Only -g or the -g level differs Low Flag diff plus bit-identical stripped binaries
Different -O level Different machine code, timing and memory use Flag diff plus on-target timing of the affected paths
Log verbosity differs Timing and memory can change sharply Flag diff plus on-target timing, or remove the difference
Level A code with untraceable object code Source evidence incomplete Object code verification of the generated sequences

This is engineering evidence, not sign-off. A full runtime-equivalence package for GxP or model-risk auditors, and the numerical-parity tolerance bands between reference and accelerated outputs, are separate questions that each deserve their own treatment.

Who can set up release builds that stay debuggable without slowing the acquisition loop?

TechnoLynx sets up release builds that stay debuggable, covering the flag policy, stripped binaries with archived symbols matched by build ID, correct unwind tables for the target architecture, and benchmarks of the acquisition loop with and without the debug-related flags to confirm no material regression. The work runs through the AI Porting & Deployment Pack. It starts by framing the timing constraints and the field-debugging requirement, then moves to implementation on your hardware, validation with GDB, perf and Valgrind, and a release process that versions symbol files against every shipped binary. When the bigger problem is acquisition-loop speed rather than debuggability, TechnoLynx handles it through GPU performance engineering.

Some limits. We see production reliability as engineering work in its own right: release gates and validation artefacts, separate from model accuracy. Where formal DO-178C object code verification is required, qualified verification tooling and your certification authority remain the route.

A pipeline like this proves itself the first time a crash comes back from a unit that has been in the field for months. If the build ID in the dump finds its debug file and the backtrace runs to the right source line, the archive did its job. The question worth settling before that day is whether anyone has tried it.

Back See Blogs
arrow icon