Copy and Trim ELF Debug Information with llvm-dwarfutil-18

llvm-dwarfutil-18 makes a checked copy of an ELF file, or splits it into a stripped executable plus a separate debug file. The normal operation reads one ELF input and writes one output, using the LLVM 18 tool installed on Ubuntu. Optional passes remove debug information made unreachable by linker section garbage collection, deduplicate compatible types, and verify the resulting DWARF.

Allow about fifteen minutes for a single binary. You need the llvm-18 package, a readable ELF file, and enough free space for at least one extra copy. These examples write only to a new directory under /tmp. They do not need sudo. Keep the original until you have tested the result.

1. Confirm the installed contract

Start with read-only checks. This machine has Ubuntu LLVM 18.1.3 from package version 1:18.1.3-1ubuntu1:

$ command -v llvm-dwarfutil-18
/usr/bin/llvm-dwarfutil-18
$ llvm-dwarfutil-18 --version
Ubuntu LLVM version 18.1.3
  Optimized build.
$ dpkg-query -W -f='${Package} ${Version}\n' llvm-18
llvm-18 1:18.1.3-1ubuntu1

The installed help is the most useful syntax reference for this exact build:

$ llvm-dwarfutil-18 --help
USAGE: llvm-dwarfutil-18 [options] <input file> <output file>

There is a small version-specific trap here. The compressed manpage describes --num-threads=<n>, while this installed binary's help shows --num-threads <threads> and the command rejects the equals form outright. Follow --help for the binary you are actually running, not the manpage from memory. The short -j spelling also takes a separate value.

Checkpoint: You have confirmed the path and version, and you know which two positional arguments the command requires.

2. Make a semantic copy first

Choose a destination that does not already contain anything valuable. The output path is a file, not a directory:

$ work=$(mktemp -d /tmp/dwarfutil.XXXXXX)
$ input=/path/to/program
$ test -r "$input" && file "$input"
$ llvm-dwarfutil-18 --verify "$input" "$work/program.copy"
$ printf 'exit status: %s\n' "$?"
exit status: 0
$ file "$work/program.copy"
/tmp/dwarfutil.XXXXXX/program.copy: ELF 64-bit LSB pie executable ...

Replace /path/to/program with an actual ELF executable or object that you own. --verify runs the DWARF verifier on the output. A zero status means the operation completed successfully; it is not a promise that the program's runtime behaviour or every source-level debugging workflow is unchanged.

Warning: Do not point the output at the original input. A failed transformation or a mistaken path can destroy your only copy. If you plan to adopt the result later, compare and test it first, then make a backup before any deliberate replacement:

$ cp --preserve=all "$input" "$input.before-dwarfutil"
$ cmp --silent "$work/program.copy" "$input"; printf 'cmp status: %s\n' "$?"
cmp status: 1

A non-zero cmp status is expected here, since debug information or other ELF sections changed. Keep the backup until you have confirmed the copy with your normal test suite. Removing that backup is irreversible and is not part of this guide.

3. Understand the default reductions

With no disabling options, this tool enables garbage collection and ODR deduplication by default. Garbage collection removes debug records associated with sections the linker discarded, including records that refer to tombstone addresses. ODR deduplication keeps the first definition of a duplicated type when the source language supports the One Definition Rule. Both can shrink debug size, but they change the debug information, so run the default on a disposable copy before you adopt it in a build pipeline.

The default tombstone mode is universal. The available values are bfd, maxpc, exec, and universal. Do not pick one merely because it sounds familiar: it describes how invalid address markers are recognised and should match the conventions used by the objects and linker you are processing.

To preserve the debug records these two passes would otherwise remove, disable them explicitly:

$ llvm-dwarfutil-18 --no-garbage-collection --no-odr-deduplication \
    --verify "$input" "$work/program.no-reduction"
$ printf 'exit status: %s\n' "$?"
exit status: 0

Checkpoint: Choose one policy and record it with the output. A later reader should be able to tell whether the copy used the default reductions or disabled them.

4. Produce a separate debug file

Use --separate-debug-file when you want a runnable file without its debug tables, plus a companion file that holds the output debug information:

$ llvm-dwarfutil-18 --separate-debug-file --verify \
    "$input" "$work/program.split"
$ printf 'exit status: %s\n' "$?"
exit status: 0
$ ls -l "$work"/program.split*
-rwxr-xr-x 1 user user ... /tmp/dwarfutil.XXXXXX/program.split
-rwxr-xr-x 1 user user ... /tmp/dwarfutil.XXXXXX/program.split.debug
$ file "$work"/program.split "$work"/program.split.debug

The observed naming convention appends .debug to the output path. Treat that companion as part of the result: moving only the stripped executable can lose the symbols your debugger needs later. The manpage describes this mode in terms of the equivalent llvm-objcopy operations, but letting llvm-dwarfutil run the sequence itself keeps its debug transformations together.

--no-separate-debug-file selects the single-output behaviour explicitly, which is useful in scripts where a configuration value might otherwise enable the split mode by accident:

$ llvm-dwarfutil-18 --no-separate-debug-file --verify \
    "$input" "$work/program.single"
$ test -e "$work/program.single" && echo 'single output exists'
single output exists

5. Control threads and diagnostics

The default maximum thread count is the number of cores on this build's machine. Set a limit when a build host is shared or memory is constrained. Use the installed syntax, with the value as a separate argument:

$ llvm-dwarfutil-18 -j 1 --verify \
    "$input" "$work/program.one-thread"
$ printf 'exit status: %s\n' "$?"
exit status: 0

--verbose enables logging and disables multi-thread mode. Useful when you are diagnosing a troublesome object, but it may produce different output volume and timing from a normal build. Capture it without treating the log as proof of a valid result:

$ llvm-dwarfutil-18 --verbose --verify \
    "$input" "$work/program.verbose" 2>"$work/dwarfutil.log"
$ printf 'exit status: %s\n' "$?"
exit status: 0

If an input is not supported, remember that this tool supports ELF and nothing else. Check the file type, path and read permission before changing options or reaching for elevated privileges:

$ file "$input"
$ test -r "$input" && echo readable
$ llvm-dwarfutil-18 --help | sed -n '1,35p'

A non-zero exit status means the operation failed. Leave the original untouched, inspect the diagnostic, and choose a fresh output path for the next attempt. Do not reuse a partially written output as if it were valid.

Done means