Skip to content

Commit 0c47d24

Browse files
authored
Merge pull request #4 from rabarbra/v0.2.4
V0.2.4
2 parents 470608f + 74f02d7 commit 0c47d24

33 files changed

Lines changed: 1017 additions & 117 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ jobs:
4545
done
4646
} | tee -a "$GITHUB_STEP_SUMMARY"
4747
48+
- name: performance report (all views, startup, memory)
49+
run: go run ./tools/perfreport exex-full
50+
4851
- name: install mandoc
4952
run: sudo apt-get update && sudo apt-get install -y mandoc
5053

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ jobs:
2323
- name: Build release archives
2424
run: make release VERSION="${GITHUB_REF_NAME}"
2525

26+
# Record this release's performance (all views, startup, memory) in the run
27+
# summary, measured against the native build of exex itself.
28+
- name: Performance report
29+
run: make perf-report
30+
2631
- uses: softprops/action-gh-release@v2
2732
with:
2833
files: dist/*

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ BASHCOMPDIR ?= /usr/local/etc/bash_completion.d
1313
ZSHCOMPDIR ?= /usr/local/share/zsh/site-functions
1414
FISHCOMPDIR ?= /usr/local/share/fish/vendor_completions.d
1515

16-
.PHONY: build lite install install-man install-completions test test-cross lint-docs size-report clean release
16+
.PHONY: build lite install install-man install-completions test test-cross lint-docs size-report perf-report clean release
1717

1818
# Full build: includes Chroma syntax highlighting (source pane + asm colours).
1919
build:
@@ -56,6 +56,13 @@ lint-docs:
5656
size-report:
5757
@sh scripts/size-report.sh $(BINARY)
5858

59+
# Performance report: parse/startup cost, every -o view's render time and
60+
# allocation volume, and peak resident memory, measured against a sample binary
61+
# (default: exex itself — a realistic native object that is always present).
62+
# Override the target with SAMPLE=... ; appends to $GITHUB_STEP_SUMMARY in CI.
63+
perf-report: build
64+
@go run ./tools/perfreport $(if $(SAMPLE),$(SAMPLE),./$(BINARY))
65+
5966
# Slow, toolchain-dependent end-to-end test: cross-compile a tiny program with Go
6067
# and Zig for every target exex can read, then parse/disassemble each. Needs the
6168
# `go` and (for the Zig half) `zig` toolchains; missing targets are skipped.

docs/config.example.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@ behavior:
208208
# the all-rows toggle also abbreviates symbol names in the disasm/hex/pointer
209209
# annotations. Toggle/save live from the settings popup (,).
210210
abbrev_args: false
211+
# Disasm/hex display toggles (all default off; flip them live from the settings
212+
# popup with ',').
213+
# Hide the raw instruction-byte column in the disasm view (like objdump
214+
# --no-show-raw-insn), reclaiming width for the code and source pane.
215+
hide_disasm_bytes: false
216+
# Hide the symbol/target annotations in the disasm and hex views.
217+
hide_annotations: false
218+
# Separate instruction bytes with spaces ("01 00 00 14") instead of the compact
219+
# form ("01000014"), matching the `-o disasm` dump.
220+
spaced_disasm_bytes: false
211221
# Where the disasm view lands by default (and redirects to when asked to show
212222
# a non-executable address): lowest, text, main, start, or entry. Defaults to
213223
# lowest (the lowest executable address). Unresolvable choices fall back.

internal/binfile/macho.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,8 @@ func (f *File) loadMachOInfo(mf *macho.File) {
590590
}
591591

592592
f.machoLoadInfo(mf, in)
593-
in.Compiler = f.scanCompilerString()
593+
// The compiler banner scan pages through __cstring/__const, which is costly on
594+
// a cold open of a large Mach-O; defer it to f.Compiler() (first Info access).
594595
f.Info = in
595596
}
596597

@@ -827,6 +828,22 @@ func cstr(b []byte) string {
827828
return string(b)
828829
}
829830

831+
// Compiler returns the compiler banner ("Apple clang …", "GCC …", "rustc …"),
832+
// computed lazily on first call and cached. ELF fills Info.Compiler eagerly from
833+
// .comment during Open (cheap); Mach-O defers the section scan to here so a cold
834+
// Open doesn't page through __cstring/__const just to populate the Info view.
835+
func (f *File) Compiler() string {
836+
if f.Info == nil {
837+
return ""
838+
}
839+
f.compilerOnce.Do(func() {
840+
if f.Info.Compiler == "" {
841+
f.Info.Compiler = f.scanCompilerString()
842+
}
843+
})
844+
return f.Info.Compiler
845+
}
846+
830847
// scanCompilerString pulls a compiler banner out of likely string/comment
831848
// sections. Scanning the whole Mach-O image is expensive for large frameworks
832849
// and not needed for this optional metadata.

internal/binfile/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ type File struct {
182182
dwarf *dwarf.Data
183183
dwarfAvail bool // DWARF is present (cheap check); HasDWARF without parsing
184184
dwarfBuild func() *dwarf.Data // builds dwarf lazily on first line/source lookup
185+
compilerOnce sync.Once // guards the lazy compiler-banner scan (Mach-O)
185186
dwarfOnce sync.Once // guards the lazy DWARF decode
186187
lines []lineEntry // sorted by Addr (loaded lazily from dwarf)
187188
lineFiles []string // file-name table; lineEntry.File indexes into this
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package binfile
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
// BenchmarkOpen parses EXEX_BENCH_BIN; -benchmem/-memprofile/-cpuprofile
9+
// attribute the startup cost. Skipped unless the env var points at a real binary.
10+
func BenchmarkOpen(b *testing.B) {
11+
path := os.Getenv("EXEX_BENCH_BIN")
12+
if path == "" {
13+
b.Skip("set EXEX_BENCH_BIN to a real binary")
14+
}
15+
b.ReportAllocs()
16+
for range b.N {
17+
f, err := Open(path)
18+
if err != nil {
19+
b.Fatal(err)
20+
}
21+
_ = f
22+
}
23+
}

internal/config/config.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ type Behavior struct {
6464
// (template arguments and parameter lists hidden). `d` toggles all and `.`
6565
// toggles the row under the cursor for the session.
6666
AbbrevArgs bool `yaml:"abbrev_args"`
67+
// The three disasm/hex display toggles below are stored in the negative sense
68+
// ("hide"/"spaced") so the zero value reproduces the historical default — a
69+
// bool can't tell "absent" from "false", and Save writes every field
70+
// explicitly. The settings modal presents them in the positive sense.
71+
//
72+
// HideDisasmBytes drops the raw instruction-byte column in the disasm view
73+
// (like objdump --no-show-raw-insn), reclaiming width for the code/source.
74+
HideDisasmBytes bool `yaml:"hide_disasm_bytes"`
75+
// HideAnnotations drops the symbol/target annotations in the disasm and hex
76+
// views.
77+
HideAnnotations bool `yaml:"hide_annotations"`
78+
// SpacedDisasmBytes separates instruction bytes with spaces ("01 00 00 14")
79+
// instead of the compact form ("01000014"), matching the `-o disasm` dump.
80+
SpacedDisasmBytes bool `yaml:"spaced_disasm_bytes"`
6781
}
6882

6983
// Colors lists every visual element the user can re-skin. Empty strings mean
@@ -421,6 +435,9 @@ func Save(theme string, beh Behavior) (string, error) {
421435
yamlBool("tree_libs", beh.TreeLibs)
422436
yamlBool("tree_collapsed", beh.TreeCollapsed)
423437
yamlBool("abbrev_args", beh.AbbrevArgs)
438+
yamlBool("hide_disasm_bytes", beh.HideDisasmBytes)
439+
yamlBool("hide_annotations", beh.HideAnnotations)
440+
yamlBool("spaced_disasm_bytes", beh.SpacedDisasmBytes)
424441

425442
out, err := yaml.Marshal(&doc)
426443
if err != nil {

internal/disasm/decoder.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,20 @@ type Disassembler interface {
175175
Name() string
176176
}
177177

178+
// MaxInstLen returns the longest instruction encoding (in bytes) for an
179+
// architecture, used to size the byte column in disassembly views. Fixed-length
180+
// RISC ISAs need only their word size, so their column is much narrower than the
181+
// variable-length x86 cap; an unknown arch falls back to the x86 cap.
182+
func MaxInstLen(a Arch) int {
183+
switch a {
184+
case ArchARM64, ArchARM, ArchPPC64, ArchPPC64LE, ArchPPC, ArchPPCLE, ArchLoong64, ArchRISCV64:
185+
return 4 // fixed 4 bytes (RISC-V's compressed forms are 2, still ≤ 4)
186+
case ArchS390X:
187+
return 6 // 2, 4 or 6
188+
}
189+
return 8 // x86/x86-64 are variable-length; cap the column as before
190+
}
191+
178192
// For returns a single-instruction decoder for a supported architecture.
179193
func For(a Arch) (Disassembler, error) {
180194
switch a {
@@ -271,7 +285,7 @@ func (arm64d) Decode(code []byte, addr uint64) (Inst, error) {
271285
if err != nil {
272286
return Inst{}, err
273287
}
274-
text := resolveRelTargets(arm64asm.GNUSyntax(inst), addr)
288+
text := hexImmediates(resolveRelTargets(arm64asm.GNUSyntax(inst), addr))
275289
return Inst{Addr: addr, Bytes: code[:4], Text: text, Class: Classify(text)}, nil
276290
}
277291

@@ -373,7 +387,7 @@ func (armd) Decode(code []byte, addr uint64) (out Inst, err error) {
373387
if n == 0 || n > len(code) {
374388
n = 4
375389
}
376-
text := resolveRelTargets(armasm.GNUSyntax(inst), addr)
390+
text := hexImmediates(resolveRelTargets(armasm.GNUSyntax(inst), addr))
377391
return Inst{Addr: addr, Bytes: code[:n], Text: text, Class: Classify(text)}, nil
378392
}
379393

@@ -478,6 +492,12 @@ func (loong64d) Decode(code []byte, addr uint64) (out Inst, err error) {
478492
// signed displacement (e.g. ".+0xfffffffffffffc58" is a negative jump); uint64
479493
// wraparound makes "addr + value" land on the right byte either way.
480494
func resolveRelTargets(text string, addr uint64) string {
495+
// Fast path: only branch/PC-relative operands carry the ".+0x"/".-0x" form, so
496+
// the vast majority of instructions need no rewrite — skip the allocation.
497+
if !strings.Contains(text, ".+0x") && !strings.Contains(text, ".-0x") &&
498+
!strings.Contains(text, ".+0X") && !strings.Contains(text, ".-0X") {
499+
return text
500+
}
481501
var b strings.Builder
482502
for i := 0; i < len(text); {
483503
if text[i] == '.' && i+3 < len(text) &&
@@ -494,7 +514,7 @@ func resolveRelTargets(text string, addr uint64) string {
494514
if text[i+1] == '-' {
495515
target = addr - v
496516
}
497-
fmt.Fprintf(&b, "0x%x", target)
517+
appendHexTo(&b, target)
498518
i = j
499519
continue
500520
}
@@ -506,6 +526,66 @@ func resolveRelTargets(text string, addr uint64) string {
506526
return b.String()
507527
}
508528

529+
// hexImmediates rewrites the decimal immediates the ARM/ARM64 GNU syntaxers print
530+
// (e.g. memory offsets "[sp,#8]", "[sp,#-16]") into hex, so they read like objdump
531+
// ("[sp,#0x8]", "[sp,#-0x10]"). Immediates already in hex ("#0x40"), floats
532+
// ("#1.0") and any non-"#" use are left untouched; x86 immediates use "$" and are
533+
// already hex, so only the ARM-family decoders call this. The fast path returns
534+
// the input unchanged (no allocation) when there is no "#" to rewrite.
535+
func hexImmediates(s string) string {
536+
if strings.IndexByte(s, '#') < 0 {
537+
return s
538+
}
539+
var b strings.Builder
540+
b.Grow(len(s) + 8)
541+
for i := 0; i < len(s); {
542+
if s[i] != '#' {
543+
b.WriteByte(s[i])
544+
i++
545+
continue
546+
}
547+
j := i + 1
548+
neg := false
549+
if j < len(s) && (s[j] == '-' || s[j] == '+') {
550+
neg = s[j] == '-'
551+
j++
552+
}
553+
start := j
554+
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
555+
j++
556+
}
557+
// Leave it alone unless this is a plain decimal: no digits, an "0x" hex
558+
// prefix, or a "." float suffix all mean "not a decimal immediate".
559+
if start == j || (j < len(s) && (s[j] == 'x' || s[j] == 'X' || s[j] == '.')) {
560+
b.WriteByte('#')
561+
i++
562+
continue
563+
}
564+
v, err := strconv.ParseUint(s[start:j], 10, 64)
565+
if err != nil {
566+
b.WriteByte('#')
567+
i++
568+
continue
569+
}
570+
b.WriteByte('#')
571+
if neg {
572+
b.WriteByte('-')
573+
}
574+
appendHexTo(&b, v)
575+
i = j
576+
}
577+
return b.String()
578+
}
579+
580+
// appendHexTo writes "0x" + v in lowercase hex to b without fmt's interface
581+
// boxing — these decoders run on every instruction, so the dump/TUI decode paths
582+
// are allocation-sensitive. The scratch array stays on the stack.
583+
func appendHexTo(b *strings.Builder, v uint64) {
584+
b.WriteString("0x")
585+
var tmp [16]byte
586+
b.Write(strconv.AppendUint(tmp[:0], v, 16))
587+
}
588+
509589
// isHexDigit reports whether c is an ASCII hex digit.
510590
func isHexDigit(c byte) bool {
511591
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')

0 commit comments

Comments
 (0)