An ELF binary diffing and visualization tool
  • C++ 57.1%
  • JavaScript 11.9%
  • Shell 10.8%
  • C 9.2%
  • TypeScript 4.1%
  • Other 6.9%
Find a file
Ivan Klikovac d6c1d76d0f
All checks were successful
CI / build-test (push) Successful in 2m25s
VS Code: bundle the self-contained engine in the VSIX
2026-06-29 01:20:52 +00:00
.forgejo/workflows Add VS Code plugin frontend (#2) 2026-06-28 18:46:57 +02:00
elfs Add VS Code plugin frontend (#2) 2026-06-28 18:46:57 +02:00
frontends/vscode VS Code: bundle the self-contained engine in the VSIX 2026-06-29 01:20:52 +00:00
src build: self-contained make release (static deps + embedded SFPI objdump) 2026-06-29 01:15:32 +00:00
tests test: cover the serve source op 2026-06-28 17:49:18 +00:00
tools build: self-contained make release (static deps + embedded SFPI objdump) 2026-06-29 01:15:32 +00:00
.gitignore VS Code: bundle the self-contained engine in the VSIX 2026-06-29 01:20:52 +00:00
LICENSE LICENSE 2026-06-10 22:32:58 +00:00
Makefile VS Code: bundle the self-contained engine in the VSIX 2026-06-29 01:20:52 +00:00
README.md Add VS Code plugin frontend (#2) 2026-06-28 18:46:57 +02:00

elfsight

A terminal visualizer and semantic diff tool for ELF files.

The only required build dependency is ncursesw, disassembly is done by shelling out to objdump.

Build

Needs a C++20 compiler and ncursesw (via pkg-config). If elfutils' libdw is found, a DWARF view is built in (compilation units, producer/flags, concrete functions with inline-expansion counts, and addr2line-style source positions on function symbols); without it elfsight builds the same minus that view. make NODW=1 forces it off. The Inlines view/report (--inlines, and the --diff Inlines tab) depends on libdw to find inlined functions, so without it that half is empty and the view says so explicitly rather than implying nothing was inlined - its out-of-line IPA clone half (from the symbol table) still works.

make            # builds build/elfsight
make run        # build + open the TUI on /bin/ls
make dump       # build + print the text report for /bin/ls
make DEBUG=1    # -Og -ggdb3 with ASan/UBSan

Usage

elfsight <file>           # interactive TUI
elfsight --dump <file>    # static text report (also used automatically when piped)
elfsight --dump --no-color <file>
elfsight --diff <a> <b>   # compare two ELF files (TUI; text when piped or with -d)
elfsight --diff -d <a> <b>      # force the text diff
elfsight --map foo.map <file>   # linker map integration
elfsight --section .got <file>  # decode one section's payload (the TUI's "enter", as text)
elfsight --cfg <sym> <file>     # ASCII control-flow graph of a function
elfsight --inlines <file>       # inlined callees and out-of-line IPA clones
elfsight --json <file>          # the whole model as JSON (composes with the modes above)
elfsight --json --diff <a> <b>  # the diff as JSON
elfsight --serve-json           # JSON request/response server over stdin/stdout

JSON output (the frontend contract)

--json writes the same model the text report and TUI render, but as a stable, versioned JSON document - the interface out-of-process frontends (e.g. an editor extension) consume, so a new UI is a layer over this rather than a fork of the engine. It composes with the other modes: --json alone emits the full file model, and with --diff/--section/--cfg/--inlines it emits that view (a --cfg document is the control-flow graph as a real graph - blocks, successors, and typed instruction tokens - not the ASCII rendering, so a frontend lays it out itself).

Conventions a consumer relies on: every document carries "elfsight_schema" (an integer version) and a "kind"; 64-bit addresses, offsets and sizes are hex strings ("0x401000") to survive JavaScript's 2^53 number limit, while counts and indices are JSON numbers; arrays are always present (empty when there's nothing); and compiler-internal symbols the text report hides are kept and tagged "internal": true (with a per-table "hidden_count"), leaving the filtering choice to the frontend.

--serve-json is the same contract for a long-lived frontend: instead of re-parsing on every invocation, it holds one ELF open and answers a stream of requests, one JSON request object per input line and one JSON response document per output line. It greets with {"kind":"hello","serve":true,...}, then serves {"op":"open","path":...} (-> the full model), {"op":"section",...}, {"op":"cfg","sym":...}, {"op":"inlines"}, and {"op":"close"}; a bad request yields an {"kind":"error"} document rather than breaking the stream. So only the first touch of a binary is cold. See src/include/serve.h for the full protocol.

The $ELFSIGHT_OBJDUMP environment variable overrides the objdump used for disassembly (the Code view and the --diff code diff). You can set it to a cross-objdump that understands the target machine (and any potential ISA extensions). By default, elfsight picks a toolchain objdump by ELF machine, and falls back to the host objdump if it doesn't find one.

TUI keys

Navigation is familiar for those who use GNU Emacs:

key action
tab / S-tab cycle view
C-f C-b / -> <- switch view
C-n C-p / v ^ move selection
C-v M-v page down / up
M-< M-> jump to top / bottom
C-s incremental search (see below)
M-. follow the reference under the selection (see below)
M-, return from the last follow
enter inspect a section's contents (Sections view); follow elsewhere
? conceptual help for this view
q quit

M-. (or RET, in most contexts) follows the reference under the selection. It jumps to the row in another view that the current object points at, as an editor would to a definition. The edges it knows:

  • Symbol -> its defining section, and its DWARF function (when debug info is loaded)
  • Relocation -> its symbol and the section it patches
  • Segment -> its sections
  • Section -> its containing segments
  • PLT stub -> its .rela.plt relocation
  • Note or Layout region -> its section
  • DWARF function -> its disassembly, and its symbol-table entry

M-, returns to where you jumped from.

Sections

In the Sections view, hitting Enter drills into the selected section to show its decoded payload:

  • .init_array, .fini_array, .preinit_array: function pointers resolved to symbol names (looking through a relative/symbolic relocation when the on-disk pointer is zero, as in a PIE or a relocatable object)
  • .got/.got.plt: each slot paired with the symbol its relocation binds
  • executable sections (.text, .plt, .init, ...): disassembled to address/instruction pairs
  • string tables and any SHF_STRINGS pool (.strtab/.dynstr/.shstrtab, ...): offset/string pairs
  • .interp/.comment: text
  • .note.*: the section's notes decoded as in the Notes view (ABI tag, GNU properties, ...)
  • data sections (.data, .rodata, .bss, ...): a variable map: each defined symbol that lands in the section
  • .gnu_debuglink/.gnu_debugaltlink: debug filename plus CRC-32 / build-id.

Any other section falls back to a raw hex dump.

Diff mode

elfsight --diff <a> <b> compares two ELF files, a (before) against b (after). It opens an interactive TUI by default, or a text report when piped or given -d.

The Code category is a semantic diff of each function's disassembly. Each instruction stream is normalized to erase address noise (branch/call targets become symbol+offset, and a function that was merely relocated reads as unchanged). A change that is only register allocation or instruction scheduling is recognized as equivalent via a per-function CFG plus value-numbered block signatures. Only functions whose normalized code actually differs are listed.

Code semantic diffing currently only exists for RISC-V.

On RISC-V, elfsight also recognizes Tensix coprocessor ops assembled at runtime - a 32-bit op word built in a GPR (lui/addi/ori) and stored to the memory-mapped instruction buffer at 0xFFE40000 reads as plain integer code, but is the real payload. The materializing lui is annotated with the decoded op (; tensix REPLAY (0x041401ec) - with runtime params when low bits are OR'd in), the issuing store with ; tensix issue REPLAY, and a changed function's summary carries the delta (tensix -REPLAY +ZEROACC). The same annotation appears in the section drill-in disassembly. The opcode table is the Blackhole/Wormhole union from tt-metal's ckernel_ops.h; annotations are display-only and never affect verdicts.

When both files carry debug info (and elfsight was built with libdw), the diff mines DWARF for extra signal:

  • changed instructions are annotated with their source position (; file.c:42), and each changed function summarizes the lines it touches (touches brisc.cc:354-372, noc.c:407)
  • per-function inlining changes show as inlining +memcpy -memset(x2)
  • a function present on only one side is explained when the debug info can prove the non-mystery: (now inlined into callers in b) / (was inlined into callers in a)
  • stack frame sizes from the call-frame info (.debug_frame/.eh_frame) are compared: a changed function carries frame 96 -> 288B, and a function whose code is equivalent but whose frame size moved is listed anyway - the register allocator spilled differently, which the equivalence verdict would otherwise hide (and firmware stack budgets care regardless)
  • same-named static functions from different translation units are paired by their DWARF declaration file instead of symbol-table order
  • functions the symbol table lacks (or reports with size 0) are recovered from their DWARF PC ranges
  • a Types category diffs the record layouts (struct/class/union byte size and member offsets) of every type named on both sides - silent ABI drift like struct config size 8B -> 24B .mode @4 -> @16 is reported by name; types present on one side only are deliberately ignored (which types get DWARF entries just tracks usage). Type names are namespace/class-qualified, so red::point and blue::point never cross-match.
  • a DWARF units category diffs each compilation unit's metadata: the producer string is reduced to its word-level delta - so two ten-line GCC invocations read as producer -O0 -> -O2 - plus language and DWARF version changes, and added/removed units.
  • an Inlines category diffs what the optimizer did with each function, the two-sided form of the single-file --inlines report. Inlined callees (from the DWARF inline DIEs) are matched by name and carry two byte figures borrowed from profilers: self (exclusive - code the inline owns in its own right, each byte credited to the innermost inline covering it) and total (inclusive - its full footprint with nested inlines included). A callee inlined differently reads as helper self 16B -> 40B total 40B -> 96B sites 4 -> 7, and one that started/stopped being inlined reads as +/- with a N sites in M fns, AB self / BB total summary. The pair is the wrapper-vs-leaf signal: self 60B -> 0B total 60B -> 428B says an inline flipped from a leaf (its own code) into a pure pass-through wrapper because its helper got inlined underneath it. Out-of-line GCC IPA clones (.constprop/.isra/..., recovered from the symbol table - so this half works without libdw) show appeared/vanished specializations and changed size/call counts. Because the inlined-callee half needs libdw, the category prints built without libdw - inlined-callee comparison unavailable (only out-of-line IPA clones shown) when elfsight was built without it, rather than silently looking empty.

Independent of DWARF, the Header category also reports .riscv.attributes ISA-string changes (a different -march/extension set) and .comment compiler-stamp changes.

elfsight also checks each binary on its own for definition drift: one type name carrying two different layouts across compilation units - an ODR violation in C++, header or LTO skew worth seeing in any language. Conflicts are flagged in the single-file DWARF view and --dump (! ctx defined with differing layouts: o1.c vs o2.c (size 8B vs 24B)), and noted in the diff's Types category since they make that name's cross-binary match ambiguous. Function-local types and anonymous-namespace types are exempt (same name, different layout is expected there).

A Data category byte-compares the initializer of every object symbol matched by name (.data/.rodata/... contents - a changed constant in a config table is invisible to the section and symbol diffs when the size stays the same). Relocation-covered words are masked out first: an address baked into data moves with layout, so a pointer table whose targets merely relocated is tallied (N objects differ only in relocated (address) words), not listed. Listed objects show their changed byte ranges as a - @+12 28 / + @+12 63 hunk, with the variable's declaration position when debug info is present.

Linker map (--map)

Passing a linker map (ld -Map=foo.map, or -Wl,-Map=) alongside the ELF (elfsight --map foo.map <file>) shows:

  • Contributors: output bytes attributed per input object, sorted by size, so you can see what actually pulls weight in the binary (*fill* padding is one folded row; gold's linker-synthesized sections show as (linker-generated))
  • Archives: which static library members were pulled in and the reference (file + symbol) that pulled each
  • Discarded: input sections dropped by --gc-sections, which are gone from the ELF entirely and so can't be seen any other way
  • A from column in Symbols naming each symbol's defining input object
  • The input object breakdown of that output section, in address order, alongside the hex/decoded payload (hit Enter).

MAP CHECK (in Contributors / --dump) cross-validates the map against the ELF: every output section the map names should exist in the ELF at the same address and size. A mismatch means the two disagree (could be a stale map, a post-link strip/objcopy, or a linker bug).

With --diff, pass --map twice (one per file, in order) to add a Contributors category that attributes the size delta to input objects. That tells you which object is responsible if a section grew.

Both GNU ld.bfd and gold maps are understood. The map text is human-readable with no stable schema, so parsing is best-effort and tolerant: an unusual map yields partial attribution (noted as a warning) rather than failing.

Design

elf.h          vendored ELF constants
model          normalized, endianness-agnostic model + enum names
parser         raw ELF struct parser (ELF32/64, LSB/MSB)
dwarfinfo      DWARF model via elfutils libdw (optional, independent of parser)
layout         offset-ordered file map: gaps, overlaps, coverage
map            linker map parser
diff           two-ELF structural diff engine (UI-agnostic)
disasm         objdump shell-out, tokenized into role-tagged instructions
cfg            per-function control-flow graph
canon          register canonicalization via def-use value numbering
codediff       semantic code diff (address-normalized + CFG-equivalent)
report         --dump text report + --diff text rendering
tui            ncurses UI
main.cpp       arg parsing, TTY detection