- C++ 57.1%
- JavaScript 11.9%
- Shell 10.8%
- C 9.2%
- TypeScript 4.1%
- Other 6.9%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| elfs | ||
| frontends/vscode | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
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.pltrelocation - 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_STRINGSpool (.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 carriesframe 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
staticfunctions 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 -> @16is 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, sored::pointandblue::pointnever 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
--inlinesreport. 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) andtotal(inclusive - its full footprint with nested inlines included). A callee inlined differently reads ashelper self 16B -> 40B total 40B -> 96B sites 4 -> 7, and one that started/stopped being inlined reads as+/-with aN sites in M fns, AB self / BB totalsummary. The pair is the wrapper-vs-leaf signal:self 60B -> 0B total 60B -> 428Bsays 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 printsbuilt 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
fromcolumn 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