Shipping a release binary with its full debug data baked in bloats it for no reason, and llvm-objcopy-20 splits the two apart cleanly. By the end you will have a repeatable workflow for copying an ELF executable, separating its debug data into its own file, attaching a debug-file reference, and converting a raw binary into an ELF object. The examples use llvm-objcopy-20 from Ubuntu's LLVM package version 20.1.8.
Allow about twenty minutes. You need a shell, llvm-20, a compiler for the first example, and enough free space for several copies of the input. Work in a scratch directory or a build output directory. None of the commands below need elevated privileges unless your input or destination is protected by filesystem permissions.
Start by checking the exact executable and package. This is read-only and safe to run on any machine:
$ command -v llvm-objcopy-20
/usr/bin/llvm-objcopy-20
$ llvm-objcopy-20 --version
llvm-objcopy, compatible with GNU objcopy
Ubuntu LLVM version 20.1.8
Optimized build.
$ dpkg-query -W -f='${Package} ${Version}\n' llvm-20
llvm-20 1:20.1.8~++20250804090239+87f0227cb601-1~exp1~20250804210352.139
The basic form is llvm-objcopy-20 [options] input [output]. With an output path, the input remains untouched. Without one, the command edits the input in place, so treat that omitted output as a destructive operation and never use it for a first run.
Checkpoint: confirm the version starts with 20.1 and that the command resolves to the binary you intended.
Create a small ELF executable with debug information. Swap in your own build artefact's paths when applying this to a real project:
$ mkdir -p /tmp/objcopy-demo
$ printf '%s\n' 'int main(void) { return 0; }' > /tmp/objcopy-demo/main.c
$ cc -g -O0 /tmp/objcopy-demo/main.c -o /tmp/objcopy-demo/app
$ llvm-readelf-20 -S /tmp/objcopy-demo/app | grep -E '\.debug|\.text|\.data'
[26] .debug_aranges ...
[27] .debug_info ...
[32] .text ...
Exact section numbers and offsets vary. The useful result is that the input is an ELF file and it contains .debug_* sections. Do not infer a file is safe to edit just from its name: check its type first with file or llvm-readelf-20.
Use --only-keep-debug to make a debug file, then a separate command to make a stripped executable. Both commands read the original, so a failed second command cannot destroy it:
$ llvm-objcopy-20 --only-keep-debug \
/tmp/objcopy-demo/app /tmp/objcopy-demo/app.debug
$ llvm-objcopy-20 --strip-debug \
/tmp/objcopy-demo/app /tmp/objcopy-demo/app.stripped
$ test -s /tmp/objcopy-demo/app.debug && test -s /tmp/objcopy-demo/app.stripped
$ llvm-readelf-20 -S /tmp/objcopy-demo/app.stripped | grep '\.debug' || echo 'debug sections removed'
debug sections removed
--strip-debug removes debug sections, but it is not the same as --strip-all. The latter removes far more symbol information and can make later diagnosis much harder. Keep the original and the separate .debug file until you have tested the release artefact properly.
Add a .gnu_debuglink section to a copy of the stripped file. Put the debug file where your debugger can actually find it, commonly beside the executable during a local test:
$ llvm-objcopy-20 --add-gnu-debuglink=/tmp/objcopy-demo/app.debug \
/tmp/objcopy-demo/app.stripped /tmp/objcopy-demo/app.release
$ llvm-readelf-20 -S /tmp/objcopy-demo/app.release | grep -E '\.debug|\.gnu_debuglink'
[29] .gnu_debuglink ...
$ /tmp/objcopy-demo/app.release
$ printf 'exit status: %s\n' "$?"
exit status: 0
The release executable carries the link section but no DWARF sections. The debug file is never embedded, it stays a separate file. Keep its name and contents stable after publishing the executable, and test your debugger's search path against the real deployment layout.
Warning: never replace a production binary in place while a service is using it. Write a new file, verify it, then use the service's documented deployment and restart procedure. To recover this example, fall back to app as the original, or copy it back over a failed test artefact once you have stopped anything using the destination.
Binary input embeds the file as an ELF relocatable object. The input format has to be explicit here because a raw file has no ELF header to autodetect from:
$ printf '%s\n' 'hello objcopy' > /tmp/objcopy-demo/data.bin
$ llvm-objcopy-20 -I binary -O elf64-x86-64 -B i386 \
/tmp/objcopy-demo/data.bin /tmp/objcopy-demo/data.o
$ llvm-readelf-20 -s /tmp/objcopy-demo/data.o | grep '_binary_'
_binary__tmp_objcopy_demo_data_bin_start
_binary__tmp_objcopy_demo_data_bin_end
_binary__tmp_objcopy_demo_data_bin_size
The generated symbol names come from the input path, with non-alphanumeric characters swapped for underscores. Code can use the start and end symbols to locate the embedded bytes. The -B i386 value is the binary architecture argument; it is accepted for compatibility and does not turn the data into executable machine code.
When the output target is binary, symbols and relocation information are discarded and the output becomes a raw memory image. Verify a conversion with cmp rather than trusting the exit status alone:
$ llvm-objcopy-20 -O binary /tmp/objcopy-demo/data.o /tmp/objcopy-demo/data.out
$ cmp /tmp/objcopy-demo/data.bin /tmp/objcopy-demo/data.out
$ echo 'binary round trip: identical'
binary round trip: identical
To extract one named section from an object, use --dump-section. It does not replace the normal input and output operation:
$ llvm-objcopy-20 --dump-section .data=/tmp/objcopy-demo/data.dump \
/tmp/objcopy-demo/data.o
$ cmp /tmp/objcopy-demo/data.bin /tmp/objcopy-demo/data.dump
$ echo 'dumped .data: identical'
dumped .data: identical
If cmp reports a difference, stop and inspect the section name, target format, and input path. Do not overwrite the source just to make the comparison pass. Also remember that --input-target and --target carry a documented issue in this installed manual: formats other than binary and ihex may be ignored while the tool guesses the input format anyway. Prefer a format-specific test and inspect the result with llvm-readelf-20.
.gnu_debuglink without .debug_* sections.cmp.