diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e3d6dc1..277cdc6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -459,15 +459,15 @@ Sources and Libs now mark availability and filter on it (`v` cycles the filter): `v` cycles all → on-disk → in-cache (`libAvail` via `explorer.ResolveLibPath` / `IsDyldSharedCacheLib`). -## 30. Extract syscalls +## ✅ 30. Extract syscalls Extract all the syscalls used in binary -## 31. Extract pathes +## ✅ 31. Extract pathes Extract all the pathes from strings -## 32. Search +## ✅ 32. Search 0x000106b6 should match with $0x106b6 @@ -519,13 +519,13 @@ Info view: arch + bits + endianness · OS/ABI (ELF `OSABI`; Mach-O — decode only counted) · static/dynamic/PIE · interpreter · needed-library count · CPU baseline (from #34). -## 36. Find-anything quick jump +## ✅ 36. Find-anything quick jump Broaden the goto modal (#…/`g`) beyond symbols + addresses to also rank sections and strings, so one keystroke finds *any* named thing in the binary and jumps to it — a single fuzzy "jump to anything" entry point. -## 37. Architecture cleanup (internal) +## ✅ 37. Architecture cleanup (internal) Reduce duplication and the cache-invalidation bug surface in `internal/ui`: diff --git a/docs/hyper.sh b/docs/hyper.sh index 430fbb4..b37041b 100755 --- a/docs/hyper.sh +++ b/docs/hyper.sh @@ -1,29 +1,30 @@ #!/bin/sh BIN=${1:-/bin/ls} +EXEX=${2:-exex} echo "\n============" hyperfine \ --warmup 5 \ - "exex '$BIN' -o strings" \ + "$EXEX '$BIN' -o strings" \ "strings -a -t x '$BIN'" echo "\n============" hyperfine \ --warmup 5 \ - "exex '$BIN' -o syms" \ + "$EXEX '$BIN' -o syms" \ "nm -C -n -a '$BIN'" echo "\n============" hyperfine --warmup 5 \ - "exex '$BIN' -o sections" \ + "$EXEX '$BIN' -o sections" \ "objdump -h '$BIN'" echo "\n============" hyperfine \ - "exex '$BIN' -o disasm" \ + "$EXEX '$BIN' -o disasm" \ "objdump -d '$BIN'" \ "otool -tvV '$BIN'" echo "\n============" hyperfine \ - "exex '$BIN' -o disasm-all" \ + "$EXEX '$BIN' -o disasm-all" \ "objdump -D '$BIN'" diff --git a/internal/binfile/elf.go b/internal/binfile/elf.go index 04739e4..cb34a6f 100644 --- a/internal/binfile/elf.go +++ b/internal/binfile/elf.go @@ -73,6 +73,18 @@ func elfDebugLink(ef *elf.File) string { return "" } +func (f *File) elfHasDWARF(ef *elf.File) bool { + if f.debugPath != "" || elfDebugLink(ef) != "" { + return true + } + for _, s := range ef.Sections { + if strings.HasPrefix(s.Name, ".debug") || strings.HasPrefix(s.Name, ".zdebug") { + return true + } + } + return false +} + // loadELF parses f.raw as an ELF object and populates the neutral model. func (f *File) loadELF() error { ef, err := elf.NewFile(bytes.NewReader(f.raw)) @@ -136,6 +148,48 @@ func (f *File) loadELF() error { f.synthetic = base > 0 } + for _, p := range ef.Progs { + paddr := p.Paddr + if paddr == p.Vaddr { + paddr = 0 // only carry a physical address when it differs + } + f.Segments = append(f.Segments, Segment{ + Name: strings.TrimPrefix(p.Type.String(), "PT_"), + Addr: p.Vaddr, + PhysAddr: paddr, + Size: p.Memsz, + Offset: p.Off, + FileSize: p.Filesz, + Align: p.Align, + R: p.Flags&elf.PF_R != 0, + W: p.Flags&elf.PF_W != 0, + X: p.Flags&elf.PF_X != 0, + }) + } + // Section load addresses (LMA): map each section through the PT_LOAD segment + // whose file bytes contain it. LMA = p_paddr + (sh_offset - p_offset). Only + // recorded when it differs from the virtual address (higher-half kernels etc.). + for i := range f.Sections { + s := &f.Sections[i] + if s.Addr == 0 || s.FileSize == 0 { + continue + } + for _, p := range ef.Progs { + if p.Type != elf.PT_LOAD || p.Paddr == p.Vaddr || p.Filesz == 0 { + continue + } + if s.Offset >= p.Off && s.Offset < p.Off+p.Filesz { + if lma := p.Paddr + (s.Offset - p.Off); lma != s.Addr { + s.PhysAddr = lma + } + break + } + } + } + if f.layoutOnly { + return nil + } + staticSyms, _ := ef.Symbols() dynSyms, _ := ef.DynamicSymbols() type symKey struct { @@ -181,53 +235,19 @@ func (f *File) loadELF() error { } f.appendELFImportSymbols(ef) - for _, p := range ef.Progs { - paddr := p.Paddr - if paddr == p.Vaddr { - paddr = 0 // only carry a physical address when it differs - } - f.Segments = append(f.Segments, Segment{ - Name: strings.TrimPrefix(p.Type.String(), "PT_"), - Addr: p.Vaddr, - PhysAddr: paddr, - Size: p.Memsz, - Offset: p.Off, - FileSize: p.Filesz, - Align: p.Align, - R: p.Flags&elf.PF_R != 0, - W: p.Flags&elf.PF_W != 0, - X: p.Flags&elf.PF_X != 0, - }) - } - // Section load addresses (LMA): map each section through the PT_LOAD segment - // whose file bytes contain it. LMA = p_paddr + (sh_offset - p_offset). Only - // recorded when it differs from the virtual address (higher-half kernels etc.). - for i := range f.Sections { - s := &f.Sections[i] - if s.Addr == 0 || s.FileSize == 0 { - continue - } - for _, p := range ef.Progs { - if p.Type != elf.PT_LOAD || p.Paddr == p.Vaddr || p.Filesz == 0 { - continue - } - if s.Offset >= p.Off && s.Offset < p.Off+p.Filesz { - if lma := p.Paddr + (s.Offset - p.Off); lma != s.Addr { - s.PhysAddr = lma - } - break - } - } - } - - if d := f.elfDWARF(ef); d != nil { - f.dwarf = d // line table decoded lazily on first source lookup + if f.elfHasDWARF(ef) { + f.dwarfAvail = true + f.dwarfBuild = func() *dwarf.Data { return f.elfDWARF(ef) } } f.loadELFInfo(ef) f.header = f.elfHeaderInfo(ef) f.rawHeader = f.elfRawHeader(ef) - f.relocBuild = func() []Reloc { return f.elfRelocs(ef) } + f.relocAvail = elfHasRelocs(ef) + f.relocAvailSet = true + if f.relocAvail { + f.relocBuild = func() []Reloc { return f.elfRelocs(ef) } + } return nil } @@ -573,7 +593,7 @@ func (f *File) elfHeaderInfo(ef *elf.File) []string { fmt.Sprintf("Entry: 0x%x", h.Entry), fmt.Sprintf("Sections: %d", len(f.Sections)), fmt.Sprintf("Symbols: %d", len(f.Symbols)), - fmt.Sprintf("DWARF info: %v", f.dwarf != nil), + fmt.Sprintf("DWARF info: %v", f.HasDWARF()), } } diff --git a/internal/binfile/image.go b/internal/binfile/image.go index e28eab9..c0b3818 100644 --- a/internal/binfile/image.go +++ b/internal/binfile/image.go @@ -340,10 +340,13 @@ func (f *File) AddrDisassemblable(addr uint64) bool { // HasExecCode reports whether the file has any executable section to disassemble // in the normal (exec-only) image — false for most relocatable object files. func (f *File) HasExecCode() bool { - if f.execImage == nil { - f.execImage = f.buildImage(func(s *Section) bool { return s.Exec }) + for i := range f.Sections { + s := &f.Sections[i] + if s.Alloc && s.Exec && s.Size != 0 && s.FileSize != 0 { + return true + } } - return len(f.execImage.Regions) > 0 + return false } // IncludeInDisasmAll reports whether a section belongs in a disasm-all sweep. diff --git a/internal/binfile/loader.go b/internal/binfile/loader.go index 010c7e3..bb82ef3 100644 --- a/internal/binfile/loader.go +++ b/internal/binfile/loader.go @@ -6,8 +6,9 @@ import "fmt" type Option func(*openOptions) type openOptions struct { - debugPath string - arch string + debugPath string + arch string + layoutOnly bool } // WithDebugPath points the loader at an explicit external debug-symbols file or @@ -24,26 +25,34 @@ func WithArch(name string) Option { return func(o *openOptions) { o.arch = name } } +// WithLayoutOnly loads just the container layout: architecture, entry, sections, +// segments and raw bytes. It skips symbols, imports, relocations, DWARF and the +// overview fields, for views that do not need them. +func WithLayoutOnly() Option { + return func(o *openOptions) { o.layoutOnly = true } +} + // Open reads path, detects its container format, and builds the neutral model. func Open(path string, opts ...Option) (*File, error) { var o openOptions for _, opt := range opts { opt(&o) } - // mapFile mmaps the file where that's safe (always on Linux; on macOS only - // when the Mach-O carries no code signature, since mmap'ing a signed binary - // gets the process SIGKILL'd), otherwise it reads the file into the heap. + // mapFile mmaps the file where that's supported, otherwise it reads the file + // into the heap. On macOS the mmap path uses MAP_RESILIENT_CODESIGN so signed + // Mach-O pages do not SIGKILL the reader if code-signing validation fails. raw, closer, err := mapFile(path) if err != nil { return nil, err } f := &File{ - Path: path, - debugPath: o.debugPath, - reqArch: o.arch, - raw: raw, - unmap: closer, - sources: map[string][]string{}, + Path: path, + debugPath: o.debugPath, + reqArch: o.arch, + layoutOnly: o.layoutOnly, + raw: raw, + unmap: closer, + sources: map[string][]string{}, } if err := f.load(); err != nil { f.Close() @@ -87,8 +96,10 @@ func (f *File) load() error { default: return fmt.Errorf("unrecognised file format (not ELF, Mach-O, or PE)") } - f.finalizeSymbols() - f.computeOverview() + if !f.layoutOnly { + f.finalizeSymbols() + f.computeOverview() + } return nil } diff --git a/internal/binfile/macho.go b/internal/binfile/macho.go index 006dc93..0383662 100644 --- a/internal/binfile/macho.go +++ b/internal/binfile/macho.go @@ -16,12 +16,14 @@ import ( // Mach-O magic numbers (thin, both byte orders, plus the fat headers). const ( - machoMagic32 = 0xfeedface - machoMagic64 = 0xfeedfacf - machoCigam32 = 0xcefaedfe - machoCigam64 = 0xcffaedfe - machoFatMagic = 0xcafebabe - machoFatMagic2 = 0xbebafeca + machoMagic32 = 0xfeedface + machoMagic64 = 0xfeedfacf + machoCigam32 = 0xcefaedfe + machoCigam64 = 0xcffaedfe + machoFatMagic = 0xcafebabe + machoFatMagic2 = 0xbebafeca + machoFatMagic64 = 0xcafebabf + machoFatCigam64 = 0xbfbafeca ) // VM protection bits (mach/vm_prot.h). @@ -62,7 +64,7 @@ func isMachO(raw []byte) bool { switch binary.BigEndian.Uint32(raw) { case machoMagic32, machoMagic64, machoCigam32, machoCigam64: return true - case machoFatMagic, machoFatMagic2: + case machoFatMagic, machoFatMagic2, machoFatMagic64, machoFatCigam64: return isFatMachO(raw) } return false @@ -80,7 +82,7 @@ func isFatMachO(raw []byte) bool { // means fat Mach-O; anything larger is a .class (or not fat at all). n := binary.BigEndian.Uint32(raw[4:8]) return n >= 1 && n <= 0x14 - case machoFatMagic2: + case machoFatMagic2, machoFatMagic64, machoFatCigam64: // Byte-swapped fat magic (FAT_CIGAM) — not a Java class. return true } @@ -128,6 +130,9 @@ func machoArchName(c macho.Cpu, sub uint32) string { // For universal ("fat") binaries it selects the slice named by f.reqArch, else // the host architecture's slice, else the first. func (f *File) loadMachO() error { + if f.layoutOnly { + return f.loadMachOLayout() + } mf, base, arches, chosen, err := parseMachO(f.raw, f.reqArch) if err != nil { return err @@ -203,6 +208,7 @@ func (f *File) loadMachO() error { sec.Flags = neutralFlags(sec.Alloc, write, exec) f.Sections = append(f.Sections, sec) } + synthBase, origSecAddr := f.applySyntheticRelocatableSections(machoSectionAligns(mf.Sections)) if mf.Symtab != nil { for _, s := range mf.Symtab.Syms { @@ -224,8 +230,18 @@ func (f *File) loadMachO() error { } } addr := s.Value + realOff := s.Value if !defined { addr = 0 + realOff = 0 + } else if f.synthetic { + idx := int(s.Sect) - 1 + if idx >= 0 && idx < len(f.Sections) && f.Sections[idx].SynthAddr { + if s.Value >= origSecAddr[idx] { + realOff = s.Value - origSecAddr[idx] + } + addr = synthBase[idx] + realOff + } } f.Symbols = append(f.Symbols, Symbol{ Name: s.Name, @@ -233,6 +249,7 @@ func (f *File) loadMachO() error { Kind: kind, Bind: machoBind(s), Section: secName, + RealOff: realOff, }) } } @@ -248,7 +265,18 @@ func (f *File) loadMachO() error { f.dwarfAvail = f.machoHasDWARF(mf) f.header = f.machoHeaderInfo(mf) f.rawHeader = f.machoRawHeader(mf) - f.relocs = machoRelocs(mf, base) // eager: needs mf.Symtab, dropped below + f.relocAvail = machoHasRelocs(mf) + f.relocAvailSet = true + if f.relocAvail { + var symNames []string + if mf.Symtab != nil { + symNames = make([]string, len(mf.Symtab.Syms)) + for i := range mf.Symtab.Syms { + symNames[i] = mf.Symtab.Syms[i].Name + } + } + f.relocBuild = func() []Reloc { return machoRelocs(mf, base, symNames) } + } // Defer the DWARF decode (abbrev/section parse — a big slice of Open for debug // binaries) to the first source/line lookup. mf is retained for that, but its @@ -259,6 +287,333 @@ func (f *File) loadMachO() error { return nil } +func machoSectionAligns(sections []*macho.Section) []uint64 { + aligns := make([]uint64, len(sections)) + for i, s := range sections { + aligns[i] = uint64(1) + if s.Align > 0 && s.Align < 63 { + aligns[i] = uint64(1) << s.Align + } + } + return aligns +} + +func (f *File) applySyntheticRelocatableSections(aligns []uint64) (synthBase []uint64, origAddr []uint64) { + synthBase = make([]uint64, len(f.Sections)) + origAddr = make([]uint64, len(f.Sections)) + if !f.relocatable { + return synthBase, origAddr + } + var base uint64 + for i := range f.Sections { + s := &f.Sections[i] + origAddr[i] = s.Addr + if s.Size == 0 { + continue + } + align := uint64(1) + if i < len(aligns) && aligns[i] > 1 { + align = aligns[i] + } + base = alignUpPow2(base, align) + synthBase[i] = base + s.Addr = base + s.SynthAddr = true + s.Alloc = true + base += s.Size + } + f.synthetic = base > 0 + return synthBase, origAddr +} + +func alignUpPow2(v, align uint64) uint64 { + if align <= 1 { + return v + } + return (v + align - 1) &^ (align - 1) +} + +type machoLayoutHeader struct { + base uint64 + limit uint64 + cmdOff uint64 + ncmds uint32 + sizeofcmds uint32 + cpu macho.Cpu + sub uint32 + typ macho.Type + bits int + bo binary.ByteOrder +} + +func (f *File) loadMachOLayout() error { + h, arches, chosen, err := parseMachOLayoutHeader(f.raw, f.reqArch) + if err != nil { + return err + } + if len(arches) > 1 { + f.FatArchInfos = arches + f.FatArches = make([]string, len(arches)) + for i, a := range arches { + f.FatArches[i] = a.Name + } + } + f.FatArch = chosen + f.Format = FormatMachO + f.relocatable = h.typ == macho.TypeObj + f.arch = machoArch(h.cpu) + if h.bits == 64 { + f.addrWidth = 16 + } else { + f.addrWidth = 8 + } + + cmdStart := h.cmdOff + cmdEnd := cmdStart + uint64(h.sizeofcmds) + if cmdStart > h.limit || cmdEnd > h.limit || cmdEnd < cmdStart { + return fmt.Errorf("truncated Mach-O load commands") + } + var textAddr, entryOff uint64 + haveEntry := false + for off, i := cmdStart, uint32(0); i < h.ncmds; i++ { + if off+8 > cmdEnd { + return fmt.Errorf("truncated Mach-O load command") + } + cmd := h.bo.Uint32(f.raw[off:]) + cmdSize := uint64(h.bo.Uint32(f.raw[off+4:])) + if cmdSize < 8 || off+cmdSize > cmdEnd || off+cmdSize < off { + return fmt.Errorf("invalid Mach-O load command size") + } + lc := f.raw[off : off+cmdSize] + switch cmd { + case lcSegment, lcSegment64: + if err := f.parseMachOSegmentLayout(h, lc); err != nil { + return err + } + if len(lc) >= 24 && machoName(lc[8:24]) == "__TEXT" { + if cmd == lcSegment64 && len(lc) >= 32 { + textAddr = h.bo.Uint64(lc[24:32]) + } else if cmd == lcSegment && len(lc) >= 28 { + textAddr = uint64(h.bo.Uint32(lc[24:28])) + } + } + case lcMain: + if len(lc) >= 16 { + entryOff = h.bo.Uint64(lc[8:16]) + haveEntry = true + } + } + off += cmdSize + } + if haveEntry { + f.entry = textAddr + entryOff + } else if h.typ == macho.TypeExec { + for _, s := range f.Sections { + if s.Name == "__text" { + f.entry = s.Addr + break + } + } + } + if h.typ == macho.TypeObj { + f.applySyntheticRelocatableSections(nil) + } + f.header = []string{ + fmt.Sprintf("Path: %s", f.Path), + fmt.Sprintf("Format: %s", f.Format), + fmt.Sprintf("CPU: %s", h.cpu), + fmt.Sprintf("Type: %s", h.typ), + fmt.Sprintf("64-bit: %v", h.bits == 64), + fmt.Sprintf("Entry: 0x%x", f.entry), + fmt.Sprintf("Sections: %d", len(f.Sections)), + "Symbols: 0", + "DWARF info: false", + } + return nil +} + +func parseMachOLayoutHeader(raw []byte, want string) (machoLayoutHeader, []FatArchInfo, string, error) { + base := uint64(0) + limit := uint64(len(raw)) + var arches []FatArchInfo + chosen := "" + if isFatMachO(raw) { + fas, err := parseFatMachOHeaders(raw) + if err != nil { + return machoLayoutHeader{}, nil, "", err + } + for _, fa := range fas { + arches = append(arches, FatArchInfo{ + Name: machoArchName(fa.cpu, fa.sub), + Type: fa.typ, + Bits: fa.bits, + Offset: fa.offset, + Size: fa.size, + }) + } + fa := pickFatArchLite(fas, want) + end := fa.offset + fa.size + if fa.offset > uint64(len(raw)) || end > uint64(len(raw)) || end < fa.offset { + return machoLayoutHeader{}, nil, "", fmt.Errorf("fat Mach-O slice %s is out of range", machoArchName(fa.cpu, fa.sub)) + } + base = fa.offset + limit = end + chosen = machoArchName(fa.cpu, fa.sub) + } + h, err := parseMachOThinHeader(raw, base, limit) + if err != nil { + return machoLayoutHeader{}, nil, "", err + } + if chosen == "" { + chosen = machoArchName(h.cpu, h.sub) + } + return h, arches, chosen, nil +} + +func parseMachOThinHeader(raw []byte, base, limit uint64) (machoLayoutHeader, error) { + if base > limit || limit > uint64(len(raw)) || limit-base < 28 { + return machoLayoutHeader{}, fmt.Errorf("truncated Mach-O header") + } + magic := binary.BigEndian.Uint32(raw[base:]) + var bo binary.ByteOrder = binary.BigEndian + bits := 32 + hdrSize := uint64(28) + switch magic { + case machoMagic64: + bits = 64 + hdrSize = 32 + case machoMagic32: + case machoCigam64: + bo = binary.LittleEndian + bits = 64 + hdrSize = 32 + case machoCigam32: + bo = binary.LittleEndian + default: + return machoLayoutHeader{}, fmt.Errorf("unrecognised Mach-O magic 0x%x", magic) + } + if limit-base < hdrSize { + return machoLayoutHeader{}, fmt.Errorf("truncated Mach-O header") + } + return machoLayoutHeader{ + base: base, + limit: limit, + cmdOff: base + hdrSize, + ncmds: bo.Uint32(raw[base+16:]), + sizeofcmds: bo.Uint32(raw[base+20:]), + cpu: macho.Cpu(bo.Uint32(raw[base+4:])), + sub: bo.Uint32(raw[base+8:]), + typ: macho.Type(bo.Uint32(raw[base+12:])), + bits: bits, + bo: bo, + }, nil +} + +func (f *File) parseMachOSegmentLayout(h machoLayoutHeader, lc []byte) error { + cmd := h.bo.Uint32(lc) + segName := machoName(lc[8:24]) + var vmaddr, vmsize, fileoff, filesize uint64 + var initprot, nsects uint32 + secOff := 0 + secSize := 0 + switch cmd { + case lcSegment64: + if len(lc) < 72 { + return fmt.Errorf("truncated Mach-O segment") + } + vmaddr = h.bo.Uint64(lc[24:32]) + vmsize = h.bo.Uint64(lc[32:40]) + fileoff = h.bo.Uint64(lc[40:48]) + filesize = h.bo.Uint64(lc[48:56]) + initprot = h.bo.Uint32(lc[60:64]) + nsects = h.bo.Uint32(lc[64:68]) + secOff, secSize = 72, 80 + case lcSegment: + if len(lc) < 56 { + return fmt.Errorf("truncated Mach-O segment") + } + vmaddr = uint64(h.bo.Uint32(lc[24:28])) + vmsize = uint64(h.bo.Uint32(lc[28:32])) + fileoff = uint64(h.bo.Uint32(lc[32:36])) + filesize = uint64(h.bo.Uint32(lc[36:40])) + initprot = h.bo.Uint32(lc[44:48]) + nsects = h.bo.Uint32(lc[48:52]) + secOff, secSize = 56, 68 + } + f.Segments = append(f.Segments, Segment{ + Name: segName, + Addr: vmaddr, + Size: vmsize, + Offset: h.base + fileoff, + FileSize: filesize, + R: initprot&vmProtRead != 0, + W: initprot&vmProtWrite != 0, + X: initprot&vmProtExecute != 0, + }) + if secOff+int(nsects)*secSize > len(lc) { + return fmt.Errorf("truncated Mach-O section table") + } + write := initprot&vmProtWrite != 0 + for i := 0; i < int(nsects); i++ { + sec := lc[secOff+i*secSize:] + name := machoName(sec[:16]) + seg := machoName(sec[16:32]) + if seg == "" { + seg = segName + } + var addr, size uint64 + var offset, flags uint32 + if cmd == lcSegment64 { + addr = h.bo.Uint64(sec[32:40]) + size = h.bo.Uint64(sec[40:48]) + offset = h.bo.Uint32(sec[48:52]) + flags = h.bo.Uint32(sec[64:68]) + } else { + addr = uint64(h.bo.Uint32(sec[32:36])) + size = uint64(h.bo.Uint32(sec[36:40])) + offset = h.bo.Uint32(sec[40:44]) + flags = h.bo.Uint32(sec[56:60]) + } + exec := flags&(machoAttrPureInstr|machoAttrSomeInstr) != 0 + zerofill := isZerofill(flags) + fileSize := size + if zerofill { + fileSize = 0 + } + s := Section{ + Name: name, + Addr: addr, + Size: size, + Offset: h.base + uint64(offset), + FileSize: fileSize, + TypeName: seg, + Category: machoCategoryFields(name, seg, addr, exec, write, zerofill), + Alloc: addr != 0, + Exec: exec, + Write: write, + } + s.Flags = neutralFlags(s.Alloc, write, exec) + f.Sections = append(f.Sections, s) + } + return nil +} + +func machoName(b []byte) string { + if i := bytes.IndexByte(b, 0); i >= 0 { + b = b[:i] + } + return string(b) +} + +func machoHasRelocs(mf *macho.File) bool { + for _, s := range mf.Sections { + if len(s.Relocs) > 0 { + return true + } + } + return false +} + // machoHasDWARF reports whether DWARF is available without parsing it: an // embedded __DWARF segment, an explicit --debug path, or a companion .dSYM. func (f *File) machoHasDWARF(mf *macho.File) bool { @@ -283,42 +638,143 @@ func (f *File) machoHasDWARF(mf *macho.File) bool { // of every architecture (nil for thin), and the chosen slice's name. want selects // a fat slice by name (e.g. "x86_64"); "" picks the host arch, else the first. func parseMachO(raw []byte, want string) (mf *macho.File, base uint64, arches []FatArchInfo, chosen string, err error) { - ra := bytes.NewReader(raw) if isFatMachO(raw) { - ff, ferr := macho.NewFatFile(ra) + fas, ferr := parseFatMachOHeaders(raw) if ferr != nil { return nil, 0, nil, "", ferr } - if len(ff.Arches) == 0 { + if len(fas) == 0 { return nil, 0, nil, "", fmt.Errorf("fat Mach-O has no architectures") } - for _, fa := range ff.Arches { - bits := 32 - if fa.File != nil && fa.File.Magic == macho.Magic64 { - bits = 64 - } - typ := "" - if fa.File != nil { - typ = fa.File.Type.String() - } + for _, fa := range fas { arches = append(arches, FatArchInfo{ - Name: machoArchName(fa.Cpu, fa.SubCpu), - Type: typ, - Bits: bits, - Offset: uint64(fa.Offset), - Size: uint64(fa.Size), + Name: machoArchName(fa.cpu, fa.sub), + Type: fa.typ, + Bits: fa.bits, + Offset: fa.offset, + Size: fa.size, }) } - fa := pickFatArch(ff, want) - return fa.File, uint64(fa.Offset), arches, machoArchName(fa.Cpu, fa.SubCpu), nil + fa := pickFatArchLite(fas, want) + end := fa.offset + fa.size + if fa.offset > uint64(len(raw)) || end > uint64(len(raw)) || end < fa.offset { + return nil, 0, nil, "", fmt.Errorf("fat Mach-O slice %s is out of range", machoArchName(fa.cpu, fa.sub)) + } + mf, err = macho.NewFile(bytes.NewReader(raw[fa.offset:end])) + if err != nil { + return nil, 0, nil, "", err + } + return mf, fa.offset, arches, machoArchName(fa.cpu, fa.sub), nil } - mf, err = macho.NewFile(ra) + mf, err = macho.NewFile(bytes.NewReader(raw)) if err != nil { return nil, 0, nil, "", err } return mf, 0, nil, machoArchName(mf.Cpu, mf.SubCpu), nil } +type fatMachOArch struct { + cpu macho.Cpu + sub uint32 + offset uint64 + size uint64 + typ string + bits int +} + +// parseFatMachOHeaders reads just the universal header and each slice's fixed +// Mach-O header. Unlike debug/macho.NewFatFile, it does not instantiate every +// slice and therefore does not parse every slice's symbol table just to list the +// available architectures. +func parseFatMachOHeaders(raw []byte) ([]fatMachOArch, error) { + if len(raw) < 8 { + return nil, fmt.Errorf("truncated fat Mach-O header") + } + magic := binary.BigEndian.Uint32(raw) + var bo binary.ByteOrder = binary.BigEndian + entrySize := 20 + arch64 := false + switch magic { + case machoFatMagic: + case machoFatMagic2: + bo = binary.LittleEndian + case machoFatMagic64: + entrySize = 32 + arch64 = true + case machoFatCigam64: + bo = binary.LittleEndian + entrySize = 32 + arch64 = true + default: + return nil, fmt.Errorf("not a fat Mach-O") + } + n := bo.Uint32(raw[4:8]) + if n == 0 || n > 0x1000 { + return nil, fmt.Errorf("invalid fat Mach-O architecture count %d", n) + } + if 8+int(n)*entrySize > len(raw) { + return nil, fmt.Errorf("truncated fat Mach-O architecture table") + } + out := make([]fatMachOArch, 0, n) + for i := 0; i < int(n); i++ { + off := 8 + i*entrySize + fa := fatMachOArch{ + cpu: macho.Cpu(bo.Uint32(raw[off:])), + sub: bo.Uint32(raw[off+4:]), + bits: 32, + } + if arch64 { + fa.offset = bo.Uint64(raw[off+8:]) + fa.size = bo.Uint64(raw[off+16:]) + } else { + fa.offset = uint64(bo.Uint32(raw[off+8:])) + fa.size = uint64(bo.Uint32(raw[off+12:])) + } + fa.bits, fa.typ = machoSliceSummary(raw, fa.offset) + out = append(out, fa) + } + return out, nil +} + +func machoSliceSummary(raw []byte, off uint64) (bits int, typ string) { + bits = 32 + if off > uint64(len(raw)) || uint64(len(raw))-off < 16 { + return bits, "" + } + magic := binary.BigEndian.Uint32(raw[off:]) + var bo binary.ByteOrder = binary.BigEndian + switch magic { + case machoMagic64: + bits = 64 + case machoMagic32: + case machoCigam64: + bits = 64 + bo = binary.LittleEndian + case machoCigam32: + bo = binary.LittleEndian + default: + return bits, "" + } + return bits, macho.Type(bo.Uint32(raw[off+12:])).String() +} + +func pickFatArchLite(arches []fatMachOArch, want string) fatMachOArch { + if want != "" { + for _, fa := range arches { + if machoArchName(fa.cpu, fa.sub) == want { + return fa + } + } + } + host := hostCPU() + for _, fa := range arches { + if fa.cpu == host { + return fa + } + } + return arches[0] +} + // machoDWARF returns DWARF for the binary: embedded if present, otherwise from // a companion .dSYM bundle next to the file. On macOS the linker leaves debug // info in a separate .dSYM rather than the executable, so without this @@ -407,23 +863,6 @@ func (f *File) dsymDebugCandidates(base string) []string { } } -func pickFatArch(ff *macho.FatFile, want string) macho.FatArch { - if want != "" { - for _, fa := range ff.Arches { - if machoArchName(fa.Cpu, fa.SubCpu) == want { - return fa - } - } - } - host := hostCPU() - for _, fa := range ff.Arches { - if fa.Cpu == host { - return fa - } - } - return ff.Arches[0] -} - func hostCPU() macho.Cpu { switch runtime.GOARCH { case "amd64": @@ -467,7 +906,15 @@ func isZerofill(flags uint32) bool { } func machoCategory(s *macho.Section, seg *macho.Segment, exec, write, zerofill bool) SectionCategory { - if seg != nil && (seg.Name == "__DWARF" || strings.Contains(strings.ToLower(s.Name), "debug")) { + segName := "" + if seg != nil { + segName = seg.Name + } + return machoCategoryFields(s.Name, segName, s.Addr, exec, write, zerofill) +} + +func machoCategoryFields(secName, segName string, addr uint64, exec, write, zerofill bool) SectionCategory { + if segName == "__DWARF" || strings.Contains(strings.ToLower(secName), "debug") { return CatDebug } if exec { @@ -479,7 +926,7 @@ func machoCategory(s *macho.Section, seg *macho.Segment, exec, write, zerofill b if write { return CatData } - if s.Addr != 0 { + if addr != 0 { return CatRodata } return CatOther diff --git a/internal/binfile/macho_test.go b/internal/binfile/macho_test.go index 578a7fa..f8900f8 100644 --- a/internal/binfile/macho_test.go +++ b/internal/binfile/macho_test.go @@ -1,6 +1,7 @@ package binfile import ( + "encoding/binary" "os" "path/filepath" "runtime" @@ -52,6 +53,49 @@ func TestOpenSystemMachO(t *testing.T) { len(f.Raw()), f.VAImage().Len(), f.ExecImage().Len()) } +func TestMachOLayoutOnlyMatchesFullLayout(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("system Mach-O binary only available on macOS") + } + const path = "/bin/ls" + if _, err := os.Stat(path); err != nil { + t.Skipf("%s not present", path) + } + full, err := Open(path) + if err != nil { + t.Fatalf("Open full: %v", err) + } + defer full.Close() + lite, err := Open(path, WithLayoutOnly()) + if err != nil { + t.Fatalf("Open layout-only: %v", err) + } + defer lite.Close() + if lite.Format != full.Format || lite.Arch() != full.Arch() || lite.Entry() != full.Entry() { + t.Fatalf("identity mismatch: lite %s/%d/0x%x full %s/%d/0x%x", + lite.Format, lite.Arch(), lite.Entry(), full.Format, full.Arch(), full.Entry()) + } + if len(lite.Symbols) != 0 { + t.Fatalf("layout-only loaded %d symbols, want 0", len(lite.Symbols)) + } + if len(lite.Sections) != len(full.Sections) || len(lite.Segments) != len(full.Segments) { + t.Fatalf("layout size mismatch: sections %d/%d segments %d/%d", + len(lite.Sections), len(full.Sections), len(lite.Segments), len(full.Segments)) + } + for i := range full.Sections { + got, want := lite.Sections[i], full.Sections[i] + if got != want { + t.Fatalf("section %d mismatch:\n got %#v\n want %#v", i, got, want) + } + } + for i := range full.Segments { + got, want := lite.Segments[i], full.Segments[i] + if got != want { + t.Fatalf("segment %d mismatch:\n got %#v\n want %#v", i, got, want) + } + } +} + // TestMachODylibInfo guards that a Mach-O dylib reports no entry point and no // interpreter (only executables have those), and is treated as position- // independent. Regression for an earlier bug that invented an __text "entry" and @@ -93,6 +137,127 @@ func TestMachODylibInfo(t *testing.T) { } } +func TestMachORelocatableObjectUsesSyntheticSectionAddresses(t *testing.T) { + f, err := OpenBytes("tiny.o", tinyMachOObject64()) + if err != nil { + t.Fatalf("OpenBytes Mach-O object: %v", err) + } + if !f.IsRelocatable() { + t.Fatal("Mach-O object was not marked relocatable") + } + if !f.SyntheticAddrs() { + t.Fatal("Mach-O object did not get synthetic addresses") + } + if len(f.Sections) != 2 { + t.Fatalf("sections = %d, want 2", len(f.Sections)) + } + text, data := f.Sections[0], f.Sections[1] + if !text.SynthAddr || !data.SynthAddr || !text.Alloc || !data.Alloc { + t.Fatalf("synthetic/alloc flags not set: text=%#v data=%#v", text, data) + } + if text.Addr == data.Addr { + t.Fatalf("section addresses still collide at 0x%x", text.Addr) + } + if len(f.Symbols) != 2 { + t.Fatalf("symbols = %d, want 2", len(f.Symbols)) + } + var textSym, dataSym Symbol + for _, sym := range f.Symbols { + switch sym.Name { + case "_textSym": + textSym = sym + case "_dataSym": + dataSym = sym + } + } + if textSym.Section != "__text" || dataSym.Section != "__data" { + t.Fatalf("symbol sections: text=%#v data=%#v", textSym, dataSym) + } + if textSym.Addr != text.Addr || dataSym.Addr != data.Addr { + t.Fatalf("symbol addrs = 0x%x/0x%x, want section addrs 0x%x/0x%x", textSym.Addr, dataSym.Addr, text.Addr, data.Addr) + } + if sym, ok := f.SymbolAt(textSym.Addr); !ok || sym.Name != "_textSym" { + t.Fatalf("SymbolAt(text) = %#v, %v; want _textSym", sym, ok) + } + if sym, ok := f.SymbolAt(dataSym.Addr); !ok || sym.Name != "_dataSym" { + t.Fatalf("SymbolAt(data) = %#v, %v; want _dataSym", sym, ok) + } + if _, ok := f.ExecImage().PosForAddr(text.Addr); !ok { + t.Fatalf("exec image cannot locate synthetic text address 0x%x", text.Addr) + } +} + +func tinyMachOObject64() []byte { + const ( + headerSize = 32 + segSize = 72 + 2*80 + symCmdSize = 24 + cmdSize = segSize + symCmdSize + dataOff = headerSize + cmdSize + symOff = dataOff + 8 + strOff = symOff + 2*16 + lcSymtab = 0x2 + cpuAMD64 = 0x01000007 + ) + strs := []byte{0} + textName := uint32(len(strs)) + strs = append(strs, []byte("_textSym\x00")...) + dataName := uint32(len(strs)) + strs = append(strs, []byte("_dataSym\x00")...) + raw := make([]byte, strOff+len(strs)) + bo := binary.LittleEndian + + bo.PutUint32(raw[0:], machoMagic64) + bo.PutUint32(raw[4:], cpuAMD64) + bo.PutUint32(raw[8:], 3) // CPU_SUBTYPE_X86_64_ALL + bo.PutUint32(raw[12:], 1) // MH_OBJECT + bo.PutUint32(raw[16:], 2) // ncmds + bo.PutUint32(raw[20:], cmdSize) + + off := headerSize + bo.PutUint32(raw[off:], lcSegment64) + bo.PutUint32(raw[off+4:], segSize) + bo.PutUint64(raw[off+32:], 8) // vmsize + bo.PutUint64(raw[off+40:], dataOff) + bo.PutUint64(raw[off+48:], 8) // filesize + bo.PutUint32(raw[off+56:], vmProtRead|vmProtExecute) + bo.PutUint32(raw[off+60:], vmProtRead|vmProtExecute) + bo.PutUint32(raw[off+64:], 2) // nsects + + sec := off + 72 + copy(raw[sec:], "__text") + copy(raw[sec+16:], "__TEXT") + bo.PutUint64(raw[sec+40:], 4) + bo.PutUint32(raw[sec+48:], dataOff) + bo.PutUint32(raw[sec+52:], 2) + bo.PutUint32(raw[sec+64:], machoAttrPureInstr|machoAttrSomeInstr) + + sec += 80 + copy(raw[sec:], "__data") + copy(raw[sec+16:], "__DATA") + bo.PutUint64(raw[sec+40:], 4) + bo.PutUint32(raw[sec+48:], dataOff+4) + bo.PutUint32(raw[sec+52:], 2) + + off += segSize + bo.PutUint32(raw[off:], lcSymtab) + bo.PutUint32(raw[off+4:], symCmdSize) + bo.PutUint32(raw[off+8:], symOff) + bo.PutUint32(raw[off+12:], 2) + bo.PutUint32(raw[off+16:], strOff) + bo.PutUint32(raw[off+20:], uint32(len(strs))) + + copy(raw[dataOff:], []byte{0x90, 0x90, 0x90, 0xc3, 1, 2, 3, 4}) + bo.PutUint32(raw[symOff:], textName) + raw[symOff+4] = nSect | nExt + raw[symOff+5] = 1 + bo.PutUint32(raw[symOff+16:], dataName) + raw[symOff+20] = nSect | nExt + raw[symOff+21] = 2 + copy(raw[strOff:], strs) + return raw +} + // TestFatMagicVsJavaClass guards the 0xCAFEBABE ambiguity: a fat Mach-O has a // small architecture count, while a Java .class has minor/major version (major // >= 45) where the count would be, so it must not be detected as Mach-O. @@ -106,3 +271,42 @@ func TestFatMagicVsJavaClass(t *testing.T) { t.Fatal("a Java .class must not be detected as (fat) Mach-O") } } + +func TestMachOLayoutRejectsWrappingFatOffset(t *testing.T) { + raw := make([]byte, 8+32) + binary.BigEndian.PutUint32(raw[0:], machoFatMagic64) + binary.BigEndian.PutUint32(raw[4:], 1) + binary.BigEndian.PutUint64(raw[16:], ^uint64(0)-4) + binary.BigEndian.PutUint64(raw[24:], 16) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("parseMachOLayoutHeader panicked on wrapping fat offset: %v", r) + } + }() + if _, _, _, err := parseMachOLayoutHeader(raw, ""); err == nil { + t.Fatal("parseMachOLayoutHeader succeeded for out-of-range fat slice") + } +} + +func TestMachOLayoutBoundsLoadCommandsToSelectedFatSlice(t *testing.T) { + const off = 64 + const size = 32 + raw := make([]byte, off+size+8) + binary.BigEndian.PutUint32(raw[0:], machoFatMagic) + binary.BigEndian.PutUint32(raw[4:], 1) + binary.BigEndian.PutUint32(raw[16:], off) + binary.BigEndian.PutUint32(raw[20:], size) + + binary.BigEndian.PutUint32(raw[off:], machoMagic64) + binary.BigEndian.PutUint32(raw[off+12:], uint32(2)) // executable + binary.BigEndian.PutUint32(raw[off+16:], 1) // ncmds + binary.BigEndian.PutUint32(raw[off+20:], 8) // sizeofcmds extends past the slice + binary.BigEndian.PutUint32(raw[off+size:], lcMain) + binary.BigEndian.PutUint32(raw[off+size+4:], 8) + + f := &File{raw: raw} + if err := f.loadMachOLayout(); err == nil { + t.Fatal("loadMachOLayout succeeded after reading load commands outside the selected fat slice") + } +} diff --git a/internal/binfile/model.go b/internal/binfile/model.go index b9bf29d..fad7dcc 100644 --- a/internal/binfile/model.go +++ b/internal/binfile/model.go @@ -171,13 +171,14 @@ type File struct { FatArch string FatArchInfos []FatArchInfo - debugPath string // explicit external debug-symbols path (--debug), or "" - reqArch string // requested fat-Mach-O slice (--arch), or "" - raw []byte // entire file contents (mmap'd or read; raw view + section data) - unmap func() error // releases the mapping backing raw (nil-safe via Close) - arch arch.Arch - entry uint64 - addrWidth int // hex digits in a printed address (8 or 16), set from the bitness + debugPath string // explicit external debug-symbols path (--debug), or "" + reqArch string // requested fat-Mach-O slice (--arch), or "" + raw []byte // entire file contents (mmap'd or read; raw view + section data) + unmap func() error // releases the mapping backing raw (nil-safe via Close) + layoutOnly bool // only architecture/sections/segments/raw were loaded + arch arch.Arch + entry uint64 + addrWidth int // hex digits in a printed address (8 or 16), set from the bitness // compactAddr narrows AddrHexWidth to 8 digits for a 64-bit binary whose every // address fits in 32 bits (set from the "compact addresses" preference). It only // affects the *printed* width — PointerBytes keeps the true word size. dispWidth @@ -190,15 +191,18 @@ type File struct { maxAddr uint64 // highest meaningful address (lazy; for compactAddr) maxAddrOnce sync.Once // guards the maxAddr scan header []string - rawHeader []HeaderField // raw container-header fields (Header sub-view) + rawHeader []HeaderField // raw container-header fields (Header sub-view) relocs []Reloc // relocation entries (built lazily) relocBuild func() []Reloc // builds relocs on first Relocations() call relocOnce sync.Once // guards the lazy relocation build + relocAvail bool // cheap load-time indication that relocations exist + relocAvailSet bool // relocAvail was populated by the format loader relocsByAddr []Reloc // relocs sorted by Offset, for address lookup (lazy) relocSortOnce sync.Once // guards the sorted-reloc build - symByAddr []Symbol // sorted by Addr + symByAddr []int // indices into Symbols, sorted by Addr + symMaxEnd []uint64 // prefix maximum symbol end by symByAddr position lowerName []string // lazily-built lowercased Symbols[i].Name (for filtering) lowerDemangled []string // lazily-built lowercased Symbols[i].Demangled @@ -209,6 +213,7 @@ type File struct { dwarfOnce sync.Once // guards the lazy DWARF decode lines []lineEntry // sorted by Addr (loaded lazily from dwarf) lineFiles []string // file-name table; lineEntry.File indexes into this + sourceFiles []string // sorted DWARF source filenames, without line rows linesOnce sync.Once // guards the lazy line-table decode indexOnce sync.Once // builds line lookup indexes from lines lineCols map[lineKey][]int // distinct DWARF columns by source file:line @@ -218,13 +223,13 @@ type File struct { sources map[string][]string // resolved file -> lines sourceExists map[string]bool // resolved file -> exists on disk (cheap presence) - vaImage *Image // all mapped sections, in VA order (lazy) - execImage *Image // executable sections only, in VA order (lazy) - allImage *Image // every section with file content (disasm-all), lazy - disasmAll bool // ExecImage returns allImage (disassemble all sections) - synthetic bool // section/symbol addresses are a synthetic layout (relocatable object) - relocatable bool // a relocatable object (ELF ET_REL / Mach-O MH_OBJECT) - strings []StringEntry // printable strings, extracted lazily + vaImage *Image // all mapped sections, in VA order (lazy) + execImage *Image // executable sections only, in VA order (lazy) + allImage *Image // every section with file content (disasm-all), lazy + disasmAll bool // ExecImage returns allImage (disassemble all sections) + synthetic bool // section/symbol addresses are a synthetic layout (relocatable object) + relocatable bool // a relocatable object (ELF ET_REL / Mach-O MH_OBJECT) + strings []StringEntry // printable strings, extracted lazily } // lineEntry maps a code address to a source location. File is an index into @@ -248,13 +253,12 @@ type lineKey struct { // table, so callers run it separately (ComputeDemangled/ApplyDemangled) off the // critical path; until then Display() falls back to the raw name. func (f *File) finalizeSymbols() { - // Parallel chunk-sort + k-way merge; falls back to a plain sort for small - // tables. One of the larger Open costs on big symbol tables. + // One of the larger Open costs on big symbol tables. sortSymbolsByName(f.Symbols) addrIdx := make([]int, 0, len(f.Symbols)) for i, s := range f.Symbols { - if s.Addr != 0 { + if s.Addr != 0 || (f.synthetic && s.Section != "") { addrIdx = append(addrIdx, i) } } @@ -275,33 +279,85 @@ func (f *File) finalizeSymbols() { f.inferSymbolSizes(addrIdx) sortAddrIdx() - f.symByAddr = make([]Symbol, 0, len(addrIdx)) - for _, idx := range addrIdx { - f.symByAddr = append(f.symByAddr, f.Symbols[idx]) + f.symByAddr = addrIdx + f.buildSymbolMaxEnds() +} + +func (f *File) buildSymbolMaxEnds() { + f.symMaxEnd = make([]uint64, len(f.symByAddr)) + var maxEnd uint64 + for i, idx := range f.symByAddr { + end := symbolLookupEnd(f.Symbols[idx]) + if end > maxEnd { + maxEnd = end + } + f.symMaxEnd[i] = maxEnd + } +} + +func symbolLookupEnd(s Symbol) uint64 { + if s.Size == 0 { + return s.Addr + } + end := s.Addr + s.Size + if end < s.Addr { + return ^uint64(0) } + return end } // inferSymbolSizes gives zero-sized symbols an extent reaching to the next // symbol at a higher address (clamped to the containing section's end). Symbols // that already carry a size are left untouched. func (f *File) inferSymbolSizes(addrIdx []int) { - for i, idx := range addrIdx { + explicitSize := make([]bool, len(f.Symbols)) + for i := range f.Symbols { + explicitSize[i] = f.Symbols[i].Size != 0 + } + + var secs []*Section + for i := range f.Sections { + if f.Sections[i].Alloc && f.Sections[i].Size != 0 { + secs = append(secs, &f.Sections[i]) + } + } + sort.Slice(secs, func(i, j int) bool { return secs[i].Addr < secs[j].Addr }) + sectionEnd := func(addr uint64) uint64 { + i := sort.Search(len(secs), func(i int) bool { return secs[i].Addr > addr }) + if i == 0 { + return 0 + } + s := secs[i-1] + if addr >= s.Addr && addr < s.Addr+s.Size { + return s.Addr + s.Size + } + return 0 + } + + var groupAddr, nextAddr uint64 + haveGroup := false + for i := len(addrIdx) - 1; i >= 0; i-- { + idx := addrIdx[i] + addr := f.Symbols[idx].Addr + if !haveGroup { + groupAddr = addr + haveGroup = true + } else if addr != groupAddr { + nextAddr = groupAddr + groupAddr = addr + } if f.Symbols[idx].Size != 0 { continue } - addr := f.Symbols[idx].Addr - var next uint64 - for j := i + 1; j < len(addrIdx); j++ { - candidate := f.Symbols[addrIdx[j]].Addr - if candidate > addr { - next = candidate - break - } + // If another symbol at this exact address already carries an extent, keep + // this alias exact-only instead of inferring a competing, often larger range. + if hasExplicitSameAddr(f.Symbols, explicitSize, addrIdx, i) { + continue } - if sec := f.SectionAt(addr); sec != nil { - secEnd := sec.Addr + sec.Size - if next == 0 || next > secEnd { - next = secEnd + next := nextAddr + if end := sectionEnd(addr); end != 0 { + if next == 0 || next > end { + next = end } } if next > addr { @@ -310,6 +366,21 @@ func (f *File) inferSymbolSizes(addrIdx []int) { } } +func hasExplicitSameAddr(symbols []Symbol, explicitSize []bool, addrIdx []int, pos int) bool { + addr := symbols[addrIdx[pos]].Addr + for i := pos - 1; i >= 0 && symbols[addrIdx[i]].Addr == addr; i-- { + if explicitSize[addrIdx[i]] { + return true + } + } + for i := pos + 1; i < len(addrIdx) && symbols[addrIdx[i]].Addr == addr; i++ { + if explicitSize[addrIdx[i]] { + return true + } + } + return false +} + // ComputeDemangled returns the demangled form of every symbol name, indexed // like f.Symbols ("" when a name isn't mangled). It only reads names, so it is // safe to run on a background goroutine; apply the result with ApplyDemangled @@ -340,23 +411,14 @@ func (f *File) ComputeDemangled() []string { return out } -// ApplyDemangled stores the result of ComputeDemangled onto the symbols (and the -// address-indexed copies). Run it on the File's owning goroutine. +// ApplyDemangled stores the result of ComputeDemangled onto the symbols. Run it +// on the File's owning goroutine. func (f *File) ApplyDemangled(d []string) { if len(d) != len(f.Symbols) { return } - byName := make(map[string]string, len(d)) for i := range f.Symbols { f.Symbols[i].Demangled = d[i] - if d[i] != "" { - byName[f.Symbols[i].Name] = d[i] - } - } - for i := range f.symByAddr { - if dm, ok := byName[f.symByAddr[i].Name]; ok { - f.symByAddr[i].Demangled = dm - } } // Demangled names just changed; drop the lowercased filter index so it is // rebuilt (with the demangled forms) on the next filter. @@ -371,9 +433,6 @@ func (f *File) ClearDemangled() { for i := range f.Symbols { f.Symbols[i].Demangled = "" } - for i := range f.symByAddr { - f.symByAddr[i].Demangled = "" - } f.lowerName, f.lowerDemangled = nil, nil } @@ -418,6 +477,7 @@ func (f *File) ensureDWARF() { if f.dwarf == nil && f.dwarfBuild != nil { f.dwarf = f.dwarfBuild() } + f.dwarfBuild = nil }) } @@ -552,13 +612,68 @@ func loadLines(d *dwarf.Data) ([]lineEntry, []string) { return out, files } +func sourceFilesOnly(d *dwarf.Data) []string { + seen := map[string]bool{} + r := d.Reader() + for { + cu, err := r.Next() + if err != nil || cu == nil { + break + } + if cu.Tag != dwarf.TagCompileUnit { + r.SkipChildren() + continue + } + lr, err := d.LineReader(cu) + if err != nil || lr == nil { + r.SkipChildren() + continue + } + addFiles := func() { + for _, file := range lr.Files() { + if file != nil && file.Name != "" { + seen[file.Name] = true + } + } + } + addFiles() + var le dwarf.LineEntry + for { + if err := lr.Next(&le); err != nil { + break + } + if le.File != nil && le.File.Name != "" { + seen[le.File.Name] = true + } + } + addFiles() + r.SkipChildren() + } + out := make([]string, 0, len(seen)) + for name := range seen { + out = append(out, name) + } + sort.Strings(out) + return out +} + // SourceFiles returns the sorted, de-duplicated set of source files referenced -// by the DWARF line table. +// by the DWARF line table. It reads only the per-CU file tables and does not +// build the full address-sorted line-entry table used by source lookup. func (f *File) SourceFiles() []string { - f.lineEntries() // populates f.lineFiles (already de-duplicated by interning) - out := make([]string, len(f.lineFiles)) - copy(out, f.lineFiles) - sort.Strings(out) + if f.sourceFiles == nil { + f.ensureDWARF() + if f.sourceFiles == nil && f.dwarf != nil { + f.sourceFiles = sourceFilesOnly(f.dwarf) + } else if f.sourceFiles == nil && len(f.lineFiles) > 0 { + f.sourceFiles = append([]string(nil), f.lineFiles...) + sort.Strings(f.sourceFiles) + } else if f.sourceFiles == nil { + f.sourceFiles = []string{} + } + } + out := make([]string, len(f.sourceFiles)) + copy(out, f.sourceFiles) return out } @@ -626,21 +741,37 @@ func (f *File) SymbolAt(addr uint64) (Symbol, bool) { if len(f.symByAddr) == 0 { return Symbol{}, false } - i := sort.Search(len(f.symByAddr), func(i int) bool { return f.symByAddr[i].Addr > addr }) - if i == 0 { - return Symbol{}, false + i := sort.Search(len(f.symByAddr), func(i int) bool { return f.Symbols[f.symByAddr[i]].Addr > addr }) + for i > 0 { + groupEnd := i + groupAddr := f.Symbols[f.symByAddr[groupEnd-1]].Addr + if groupAddr != addr && len(f.symMaxEnd) == len(f.symByAddr) && f.symMaxEnd[groupEnd-1] <= addr { + break + } + groupStart := groupEnd - 1 + for groupStart > 0 && f.Symbols[f.symByAddr[groupStart-1]].Addr == groupAddr { + groupStart-- + } + for j := groupStart; j < groupEnd; j++ { + s := f.Symbols[f.symByAddr[j]] + if symbolCoversAddr(s, addr) { + return s, true + } + } + i = groupStart } - s := f.symByAddr[i-1] + return Symbol{}, false +} + +func symbolCoversAddr(s Symbol, addr uint64) bool { if s.Size == 0 { - if s.Addr == addr { - return s, true - } - return Symbol{}, false + return s.Addr == addr } - if addr >= s.Addr && addr < s.Addr+s.Size { - return s, true + end := s.Addr + s.Size + if end < s.Addr { + return addr >= s.Addr } - return Symbol{}, false + return addr >= s.Addr && addr < end } // SymbolsInRange returns address-indexed symbols that overlap [from, to). @@ -648,17 +779,10 @@ func (f *File) SymbolsInRange(from uint64, to uint64) []Symbol { if len(f.symByAddr) == 0 || to <= from { return []Symbol{} } - i := sort.Search(len(f.symByAddr), func(i int) bool { return f.symByAddr[i].Addr >= from }) - if i > 0 { - prev := f.symByAddr[i-1] - prevEnd := prev.Addr + prev.Size - if prev.Size > 0 && (prevEnd < prev.Addr || prevEnd > from) { - i-- - } - } res := []Symbol{} + i := f.symbolRangeStart(from) for ; i < len(f.symByAddr); i++ { - s := f.symByAddr[i] + s := f.Symbols[f.symByAddr[i]] if s.Addr >= to { break } @@ -679,13 +803,59 @@ func (f *File) SymbolsInRange(from uint64, to uint64) []Symbol { return res } +// SymbolRangeIter walks address-indexed symbols that overlap a half-open address +// range without allocating a result slice. +type SymbolRangeIter struct { + f *File + i int + to uint64 +} + +func (f *File) SymbolRangeIter(from uint64, to uint64) SymbolRangeIter { + if len(f.symByAddr) == 0 || to <= from { + return SymbolRangeIter{} + } + return SymbolRangeIter{f: f, i: f.symbolRangeStart(from), to: to} +} + +func (it *SymbolRangeIter) Next() (Symbol, bool) { + if it.f == nil { + return Symbol{}, false + } + for ; it.i < len(it.f.symByAddr); it.i++ { + s := it.f.Symbols[it.f.symByAddr[it.i]] + if s.Addr >= it.to { + return Symbol{}, false + } + if s.Size == 0 { + it.i++ + return s, true + } + it.i++ + return s, true + } + return Symbol{}, false +} + +func (f *File) symbolRangeStart(from uint64) int { + i := sort.Search(len(f.symByAddr), func(i int) bool { return f.Symbols[f.symByAddr[i]].Addr >= from }) + if i > 0 { + prev := f.Symbols[f.symByAddr[i-1]] + prevEnd := prev.Addr + prev.Size + if prev.Size > 0 && (prevEnd < prev.Addr || prevEnd > from) { + i-- + } + } + return i +} + // NextSymbol returns the first symbol (by address) strictly after addr that // satisfies pred (a nil pred accepts any symbol). symByAddr is sorted by Addr, // so it binary-searches to the first candidate and scans only from there. func (f *File) NextSymbol(addr uint64, pred func(Symbol) bool) (Symbol, bool) { - i := sort.Search(len(f.symByAddr), func(i int) bool { return f.symByAddr[i].Addr > addr }) + i := sort.Search(len(f.symByAddr), func(i int) bool { return f.Symbols[f.symByAddr[i]].Addr > addr }) for ; i < len(f.symByAddr); i++ { - if s := f.symByAddr[i]; pred == nil || pred(s) { + if s := f.Symbols[f.symByAddr[i]]; pred == nil || pred(s) { return s, true } } @@ -696,9 +866,9 @@ func (f *File) NextSymbol(addr uint64, pred func(Symbol) bool) (Symbol, bool) { // satisfies pred (a nil pred accepts any symbol). symByAddr is sorted by Addr, // so it binary-searches to the last candidate and scans only from there. func (f *File) PrevSymbol(addr uint64, pred func(Symbol) bool) (Symbol, bool) { - i := sort.Search(len(f.symByAddr), func(i int) bool { return f.symByAddr[i].Addr >= addr }) + i := sort.Search(len(f.symByAddr), func(i int) bool { return f.Symbols[f.symByAddr[i]].Addr >= addr }) for i--; i >= 0; i-- { - if s := f.symByAddr[i]; pred == nil || pred(s) { + if s := f.Symbols[f.symByAddr[i]]; pred == nil || pred(s) { return s, true } } diff --git a/internal/binfile/parse_bench_test.go b/internal/binfile/parse_bench_test.go index 812fbe3..ce8bd25 100644 --- a/internal/binfile/parse_bench_test.go +++ b/internal/binfile/parse_bench_test.go @@ -18,6 +18,27 @@ func BenchmarkOpen(b *testing.B) { if err != nil { b.Fatal(err) } - _ = f + if err := f.Close(); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkOpenLayout measures the cheap metadata/raw path used by -o sections, +// segments, strings, and cpu-features. +func BenchmarkOpenLayout(b *testing.B) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + b.Skip("set EXEX_BENCH_BIN to a real binary") + } + b.ReportAllocs() + for range b.N { + f, err := Open(path, WithLayoutOnly()) + if err != nil { + b.Fatal(err) + } + if err := f.Close(); err != nil { + b.Fatal(err) + } } } diff --git a/internal/binfile/pe.go b/internal/binfile/pe.go index 63de13f..8e27c43 100644 --- a/internal/binfile/pe.go +++ b/internal/binfile/pe.go @@ -82,6 +82,9 @@ func (f *File) loadPE() error { sec.Flags = neutralFlags(true, write, exec) f.Sections = append(f.Sections, sec) } + if f.layoutOnly { + return nil + } // COFF symbols (present in MinGW/gcc images; MSVC images are usually // stripped, with debug info in a separate PDB). Value is treated as an RVA. @@ -120,14 +123,19 @@ func (f *File) loadPE() error { // filters work — mirroring appendELFImportSymbols / machoImportSymbols. f.loadPEImports(pf, imageBase) - if d := peDWARF(pf); d != nil { - f.dwarf = d // line table decoded lazily on first source lookup + if peHasDWARF(pf) { + f.dwarfAvail = true + f.dwarfBuild = func() *dwarf.Data { return peDWARF(pf) } } f.loadPEInfo(pf, dllChars) f.header = f.peHeaderInfo(pf) f.rawHeader = f.peRawHeader(pf) - f.relocBuild = func() []Reloc { return peRelocs(pf, imageBase) } + f.relocAvail = peHasRelocs(pf) + f.relocAvailSet = true + if f.relocAvail { + f.relocBuild = func() []Reloc { return peRelocs(pf, imageBase) } + } return nil } @@ -297,6 +305,15 @@ func peDWARF(pf *pe.File) *dwarf.Data { return nil } +func peHasDWARF(pf *pe.File) bool { + for _, s := range pf.Sections { + if strings.HasPrefix(s.Name, ".debug") || strings.HasPrefix(s.Name, ".zdebug") { + return true + } + } + return false +} + func (f *File) loadPEInfo(pf *pe.File, dllChars uint32) { in := &Info{} if libs, err := pf.ImportedLibraries(); err == nil { @@ -342,6 +359,6 @@ func (f *File) peHeaderInfo(pf *pe.File) []string { fmt.Sprintf("Entry: 0x%x", f.entry), fmt.Sprintf("Sections: %d", len(f.Sections)), fmt.Sprintf("Symbols: %d", len(f.Symbols)), - fmt.Sprintf("DWARF info: %v", f.dwarf != nil), + fmt.Sprintf("DWARF info: %v", f.HasDWARF()), } } diff --git a/internal/binfile/reloc.go b/internal/binfile/reloc.go index a431b60..cbe52aa 100644 --- a/internal/binfile/reloc.go +++ b/internal/binfile/reloc.go @@ -35,14 +35,24 @@ func (f *File) Relocations() []Reloc { f.relocOnce.Do(func() { if f.relocBuild != nil { f.relocs = f.relocBuild() + f.relocBuild = nil } }) return f.relocs } -// HasRelocs reports whether the binary has any relocation entries (cheap after -// the first Relocations() build; triggers it otherwise). -func (f *File) HasRelocs() bool { return len(f.Relocations()) > 0 } +// HasRelocs reports whether the binary has any relocation entries. When the +// loader could determine that no relocation data exists, this stays cheap and +// does not force the lazy relocation build. +func (f *File) HasRelocs() bool { + if len(f.relocs) > 0 { + return true + } + if f.relocAvailSet && !f.relocAvail { + return false + } + return len(f.Relocations()) > 0 +} // IsRelocatable reports whether the file is a relocatable object (ELF ET_REL / // Mach-O MH_OBJECT) — a cheap flag set at load. Only such files carry relocations @@ -69,6 +79,15 @@ func (f *File) RelocsInRange(lo, hi uint64) []Reloc { return out } +func elfHasRelocs(ef *elf.File) bool { + for _, s := range ef.Sections { + if (s.Type == elf.SHT_RELA || s.Type == elf.SHT_REL) && s.Size > 0 { + return true + } + } + return false +} + // elfRelocs decodes every SHT_REL / SHT_RELA section into the neutral model, // resolving each entry's type name (per machine) and target symbol (via the // reloc section's linked symbol table). @@ -196,13 +215,10 @@ func elfRelocTypeNamer(m elf.Machine) func(uint32) string { // machoRelocs collects the per-section relocations the standard library parses // (chiefly object files; linked images use dyld bind/rebase or chained fixups, -// which it does not decode). Built eagerly while mf.Symtab is still live so -// external entries can be named. base is the slice's virtual-address base. -func machoRelocs(mf *macho.File, base uint64) []Reloc { - var syms []macho.Symbol - if mf.Symtab != nil { - syms = mf.Symtab.Syms - } +// which it does not decode). symNames is indexed like the original Mach-O symtab; +// only names are retained so the bulky parsed symtab can be dropped after load. +// base is the slice's virtual-address base. +func machoRelocs(mf *macho.File, base uint64, symNames []string) []Reloc { nameType := machoRelocTypeNamer(mf.Cpu) var out []Reloc for _, s := range mf.Sections { @@ -212,8 +228,8 @@ func machoRelocs(mf *macho.File, base uint64) []Reloc { Type: nameType(rl.Type), Section: s.Seg + "," + s.Name, } - if rl.Extern && int(rl.Value) < len(syms) { - r.Sym = syms[rl.Value].Name + if rl.Extern && int(rl.Value) < len(symNames) { + r.Sym = symNames[rl.Value] } out = append(out, r) } @@ -221,6 +237,11 @@ func machoRelocs(mf *macho.File, base uint64) []Reloc { return out } +func peHasRelocs(pf *pe.File) bool { + _, size, ok := peDataDirectory(pf, 5) // IMAGE_DIRECTORY_ENTRY_BASERELOC + return ok && size > 0 +} + // machoRelocTypeNamer maps a Mach-O relocation type to a short name for the two // arches this tool most often disassembles; others fall back to a numeric form. func machoRelocTypeNamer(cpu macho.Cpu) func(uint8) string { diff --git a/internal/binfile/reloc_test.go b/internal/binfile/reloc_test.go index 72934cb..6686e3b 100644 --- a/internal/binfile/reloc_test.go +++ b/internal/binfile/reloc_test.go @@ -1,6 +1,7 @@ package binfile import ( + "debug/macho" "encoding/binary" "testing" ) @@ -37,3 +38,75 @@ func TestIsELFMappingSymbol(t *testing.T) { } } } + +func TestHasRelocsUsesAvailabilityBeforeBuild(t *testing.T) { + built := false + f := &File{ + relocAvailSet: true, + relocBuild: func() []Reloc { + built = true + return []Reloc{{Offset: 1}} + }, + } + if f.HasRelocs() { + t.Fatal("HasRelocs should be false when the loader knows no reloc data exists") + } + if built { + t.Fatal("HasRelocs should not force relocation build when availability is false") + } + + f = &File{ + relocAvail: true, + relocAvailSet: true, + relocBuild: func() []Reloc { + built = true + return []Reloc{{Offset: 2}} + }, + } + built = false + if !f.HasRelocs() { + t.Fatal("HasRelocs should build and report relocations when availability is true") + } + if !built { + t.Fatal("HasRelocs should build relocation rows when availability is true") + } + + f = &File{relocBuild: func() []Reloc { + built = true + return []Reloc{{Offset: 3}} + }} + built = false + if !f.HasRelocs() { + t.Fatal("HasRelocs should preserve lazy-build behavior when availability is unknown") + } + if !built { + t.Fatal("HasRelocs should build when availability is unknown") + } +} + +func TestMachORelocsUseRetainedSymbolNames(t *testing.T) { + mf := &macho.File{ + FileHeader: macho.FileHeader{Cpu: macho.CpuArm64}, + Sections: []*macho.Section{{ + SectionHeader: macho.SectionHeader{Name: "__text", Seg: "__TEXT", Addr: 0x100}, + Relocs: []macho.Reloc{{Addr: 4, Value: 1, Type: 2, Extern: true}}, + }}, + } + rs := machoRelocs(mf, 0x1000, []string{"_unused", "_target"}) + if len(rs) != 1 { + t.Fatalf("relocs = %d, want 1", len(rs)) + } + r := rs[0] + if r.Offset != 0x1104 || r.Type != "ARM64_RELOC_BRANCH26" || r.Section != "__TEXT,__text" || r.Sym != "_target" { + t.Fatalf("reloc = %+v, want offset 0x1104 type ARM64_RELOC_BRANCH26 section __TEXT,__text sym _target", r) + } +} + +func TestMachOHasRelocs(t *testing.T) { + if machoHasRelocs(&macho.File{Sections: []*macho.Section{{}}}) { + t.Fatal("empty Mach-O sections should not report relocs") + } + if !machoHasRelocs(&macho.File{Sections: []*macho.Section{{Relocs: []macho.Reloc{{Addr: 1}}}}}) { + t.Fatal("Mach-O section relocs should be detected") + } +} diff --git a/internal/binfile/strings.go b/internal/binfile/strings.go index 744ecb2..9760c4d 100644 --- a/internal/binfile/strings.go +++ b/internal/binfile/strings.go @@ -1,6 +1,7 @@ package binfile import ( + "io" "runtime" "sort" "sync" @@ -46,6 +47,11 @@ func NewRawFile(raw []byte) *File { return &File{raw: raw} } // minString is the shortest run of printable bytes reported as a string. const minString = 4 +const ( + parallelStringScanMin = 1 << 20 + parallelStringScanChunk = 128 << 10 +) + // Strings scans the whole file for runs of printable ASCII at least minString // bytes long. The result is cached. Each entry is mapped back to a virtual // address / section when its offset falls inside a section's file bytes. @@ -101,8 +107,12 @@ func (f *File) extractStrings() []StringEntry { // belongs to the previous chunk and is skipped; a run still open at hi is // followed past the boundary so it is captured whole exactly once. func (f *File) extractStringsRange(data []byte, secs []*Section, lo, hi int) []StringEntry { + return f.extractStringsRangeInto(data, secs, lo, hi, make([]StringEntry, 0, 4096)) +} + +func (f *File) extractStringsRangeInto(data []byte, secs []*Section, lo, hi int, out []StringEntry) []StringEntry { printable := func(b byte) bool { return b >= 0x20 && b < 0x7f } - out := make([]StringEntry, 0, 4096) // a typical section holds thousands of runs + out = out[:0] start := -1 flush := func(end int) { if start >= 0 && end-start >= minString { @@ -143,6 +153,97 @@ func (f *File) extractStringsRange(data []byte, secs []*Section, lo, hi int) []S return out } +// ScanStrings walks printable strings in file order and calls emit for each one, +// without populating the retained Strings cache. Small inputs stream directly; +// large inputs scan chunks in parallel but emit them in file order using reusable +// per-worker buffers, so memory is bounded by worker count rather than file size. +func (f *File) ScanStrings(emit func(StringEntry) error) error { + data := f.raw + secs := f.fileSectionsByOffset() + chunks := (len(data) + parallelStringScanChunk - 1) / parallelStringScanChunk + workers := min(runtime.GOMAXPROCS(0), chunks) + if len(data) < parallelStringScanMin || workers <= 1 { + return f.scanStringsSequential(data, secs, emit) + } + return f.scanStringsParallel(data, secs, workers, emit) +} + +func (f *File) scanStringsSequential(data []byte, secs []*Section, emit func(StringEntry) error) error { + printable := func(b byte) bool { return b >= 0x20 && b < 0x7f } + start := -1 + flush := func(end int) error { + if start >= 0 && end-start >= minString { + e := StringEntry{Offset: uint64(start), Len: uint32(end - start)} + if sec := sectionAtSortedOffset(secs, uint64(start)); sec != nil { + e.Section = sec.Name + if sec.Alloc { + e.Addr = sec.Addr + (uint64(start) - sec.Offset) + e.HasAddr = true + } + } + if err := emit(e); err != nil { + return err + } + } + start = -1 + return nil + } + for i, b := range data { + if printable(b) { + if start < 0 { + start = i + } + continue + } + if err := flush(i); err != nil { + if err == io.ErrClosedPipe { + return nil + } + return err + } + } + if err := flush(len(data)); err != nil && err != io.ErrClosedPipe { + return err + } + return nil +} + +func (f *File) scanStringsParallel(data []byte, secs []*Section, workers int, emit func(StringEntry) error) error { + chunks := (len(data) + parallelStringScanChunk - 1) / parallelStringScanChunk + bufs := make([][]StringEntry, workers) + results := make([][]StringEntry, workers) + for base := 0; base < chunks; base += workers { + batch := min(workers, chunks-base) + var wg sync.WaitGroup + for j := 0; j < batch; j++ { + chunk := base + j + lo := chunk * parallelStringScanChunk + hi := min(lo+parallelStringScanChunk, len(data)) + buf := bufs[j] + wg.Add(1) + go func(j, lo, hi int, buf []StringEntry) { + defer wg.Done() + results[j] = f.extractStringsRangeInto(data, secs, lo, hi, buf) + }(j, lo, hi, buf) + } + wg.Wait() + for j := 0; j < batch; j++ { + rs := results[j] + for _, e := range rs { + if err := emit(e); err != nil { + if err == io.ErrClosedPipe { + return nil + } + return err + } + } + bufs[j] = rs[:0] + results[j] = nil + } + } + return nil +} + // fileSectionsByOffset returns the sections that occupy file bytes, sorted by // file offset, for binary-searched offset→section lookups. func (f *File) fileSectionsByOffset() []*Section { diff --git a/internal/binfile/strings_test.go b/internal/binfile/strings_test.go index 17210e0..191596c 100644 --- a/internal/binfile/strings_test.go +++ b/internal/binfile/strings_test.go @@ -1,6 +1,9 @@ package binfile -import "testing" +import ( + "io" + "testing" +) func TestExtractStrings(t *testing.T) { // "ab" is below minString and must be dropped; the two long runs must be @@ -41,6 +44,70 @@ func TestStringsCachesExtraction(t *testing.T) { } } +func TestScanStringsDoesNotPopulateCache(t *testing.T) { + f := &File{raw: []byte("hello\x00world\x00tiny")} + var got []string + if err := f.ScanStrings(func(e StringEntry) error { + got = append(got, f.StringText(e)) + return nil + }); err != nil { + t.Fatalf("ScanStrings: %v", err) + } + if len(got) != 3 || got[0] != "hello" || got[1] != "world" || got[2] != "tiny" { + t.Fatalf("ScanStrings = %#v, want hello/world/tiny", got) + } + if f.strings != nil { + t.Fatalf("ScanStrings populated cache: %#v", f.strings) + } +} + +func TestScanStringsParallelOrderedAndNoCache(t *testing.T) { + raw := make([]byte, parallelStringScanChunk*3+128) + copy(raw[10:], "first") + copy(raw[parallelStringScanChunk-2:], "boundary") + copy(raw[parallelStringScanChunk+20:], "second") + f := &File{raw: raw} + var got []string + if err := f.scanStringsParallel(raw, nil, 2, func(e StringEntry) error { + got = append(got, f.StringText(e)) + return nil + }); err != nil { + t.Fatalf("scanStringsParallel: %v", err) + } + want := []string{"first", "boundary", "second"} + if len(got) != len(want) { + t.Fatalf("strings = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("strings = %#v, want %#v", got, want) + } + } + if f.strings != nil { + t.Fatalf("parallel scan populated cache: %#v", f.strings) + } +} + +func TestScanStringsParallelClosedPipe(t *testing.T) { + raw := make([]byte, parallelStringScanChunk*2+128) + copy(raw[0:], "first") + copy(raw[parallelStringScanChunk+10:], "second") + f := &File{raw: raw} + n := 0 + if err := f.scanStringsParallel(raw, nil, 2, func(StringEntry) error { + n++ + return io.ErrClosedPipe + }); err != nil { + t.Fatalf("closed pipe should be clean, got %v", err) + } + if n != 1 { + t.Fatalf("emitted %d entries before closed pipe, want 1", n) + } + if f.strings != nil { + t.Fatalf("parallel closed-pipe scan populated cache: %#v", f.strings) + } +} + func TestExtractStringsMapsAddress(t *testing.T) { // A section whose file bytes cover the string maps it to a virtual address. raw := make([]byte, 64) diff --git a/internal/binfile/symbols_test.go b/internal/binfile/symbols_test.go index 1efcafb..32d781f 100644 --- a/internal/binfile/symbols_test.go +++ b/internal/binfile/symbols_test.go @@ -28,6 +28,30 @@ func TestFinalizeSymbolsInfersSizesOnCanonicalSymbols(t *testing.T) { } } +func TestSymbolAtPrefersSizedSymbolOverSameAddressAlias(t *testing.T) { + f := &File{ + Sections: []Section{{Name: ".text", Addr: 0x1000, Size: 0x100, Alloc: true, Exec: true}}, + Symbols: []Symbol{ + {Name: "alias", Addr: 0x1000, Kind: SymOther}, + {Name: "func", Addr: 0x1000, Size: 0x40, Kind: SymFunc}, + {Name: "next", Addr: 0x1080, Kind: SymFunc}, + }, + } + + f.finalizeSymbols() + + for _, sym := range f.Symbols { + if sym.Name == "alias" && sym.Size != 0 { + t.Fatalf("alias size = 0x%x, want exact-only alias", sym.Size) + } + } + for _, addr := range []uint64{0x1000, 0x1010} { + if sym, ok := f.SymbolAt(addr); !ok || sym.Name != "func" { + t.Fatalf("SymbolAt(0x%x) = %#v, %v; want func", addr, sym, ok) + } + } +} + func TestSymbolDisplay(t *testing.T) { if got := (Symbol{Name: "_Z3foov", Demangled: "foo()"}).Display(); got != "foo()" { t.Fatalf("Display demangled = %q, want foo()", got) @@ -38,11 +62,12 @@ func TestSymbolDisplay(t *testing.T) { } func TestSymbolsInRangeHandlesBoundsAndOverlaps(t *testing.T) { - f := &File{symByAddr: []Symbol{ + f := &File{Symbols: []Symbol{ {Name: "a", Addr: 0x1000, Size: 0x10}, {Name: "b", Addr: 0x1020}, {Name: "c", Addr: 0x1030, Size: 0x08}, }} + f.symByAddr = []int{0, 1, 2} tests := []struct { name string @@ -68,16 +93,34 @@ func TestSymbolsInRangeHandlesBoundsAndOverlaps(t *testing.T) { t.Fatalf("symbol %d = %q, want %q", i, got[i].Name, want) } } + it := f.SymbolRangeIter(tt.from, tt.to) + var iterGot []Symbol + for { + sym, ok := it.Next() + if !ok { + break + } + iterGot = append(iterGot, sym) + } + if len(iterGot) != len(got) { + t.Fatalf("SymbolRangeIter length = %d, want %d (%#v)", len(iterGot), len(got), iterGot) + } + for i := range got { + if iterGot[i] != got[i] { + t.Fatalf("iterator symbol %d = %#v, want %#v", i, iterGot[i], got[i]) + } + } }) } } func TestNextPrevSymbol(t *testing.T) { - f := &File{symByAddr: []Symbol{ + f := &File{Symbols: []Symbol{ {Name: "a", Addr: 0x1000, Kind: SymFunc}, {Name: "b", Addr: 0x1010, Kind: SymObject}, {Name: "c", Addr: 0x1020, Kind: SymFunc}, }} + f.symByAddr = []int{0, 1, 2} if got, ok := f.NextSymbol(0x1000, nil); !ok || got.Name != "b" { t.Fatalf("NextSymbol = %#v, %v; want b", got, ok) } diff --git a/internal/binfile/symsort.go b/internal/binfile/symsort.go index 050a2e2..a498647 100644 --- a/internal/binfile/symsort.go +++ b/internal/binfile/symsort.go @@ -1,78 +1,15 @@ package binfile import ( - "container/heap" - "runtime" "slices" "strings" - "sync" ) -// sortSymbolsByName sorts syms by Name in place. For large tables it sorts -// per-CPU chunks concurrently, then k-way merges them — a noticeable win at -// startup, where the symbol sort is one of the biggest Open costs on binaries -// with hundreds of thousands of symbols. +// sortSymbolsByName sorts syms by Name in place. Keeping this as a true in-place +// sort avoids a second Symbol-sized backing array on binaries with hundreds of +// thousands of symbols; the allocator/GC cost of that temporary outweighed the +// old parallel merge's CPU win on large debug builds. func sortSymbolsByName(syms []Symbol) { byName := func(a, b Symbol) int { return strings.Compare(a.Name, b.Name) } - n := len(syms) - w := min(max(runtime.NumCPU(), 1), 16) - if n < 1<<13 || w == 1 { - slices.SortFunc(syms, byName) - return - } - - chunk := (n + w - 1) / w - runs := make([][]Symbol, 0, w) - var wg sync.WaitGroup - for lo := 0; lo < n; lo += chunk { - r := syms[lo:min(lo+chunk, n)] - runs = append(runs, r) - wg.Add(1) - go func(r []Symbol) { defer wg.Done(); slices.SortFunc(r, byName) }(r) - } - wg.Wait() - - // k-way merge the sorted runs into a temporary, then copy back so the symbol - // slice keeps its backing (symByAddr et al. are built from it afterward). - out := make([]Symbol, 0, n) - h := &symRunHeap{runs: runs, pos: make([]int, len(runs))} - for i := range runs { - if len(runs[i]) > 0 { - h.order = append(h.order, i) - } - } - heap.Init(h) - for h.Len() > 0 { - r := h.order[0] - out = append(out, runs[r][h.pos[r]]) - h.pos[r]++ - if h.pos[r] < len(runs[r]) { - heap.Fix(h, 0) - } else { - heap.Pop(h) - } - } - copy(syms, out) -} - -// symRunHeap is a min-heap over the head element of each not-yet-exhausted run, -// keyed by symbol name, for the k-way merge above. -type symRunHeap struct { - runs [][]Symbol - pos []int // current index within each run - order []int // heap of run indices still in play -} - -func (h *symRunHeap) Len() int { return len(h.order) } -func (h *symRunHeap) Less(i, j int) bool { - ri, rj := h.order[i], h.order[j] - return h.runs[ri][h.pos[ri]].Name < h.runs[rj][h.pos[rj]].Name -} -func (h *symRunHeap) Swap(i, j int) { h.order[i], h.order[j] = h.order[j], h.order[i] } -func (h *symRunHeap) Push(x any) { h.order = append(h.order, x.(int)) } -func (h *symRunHeap) Pop() any { - m := len(h.order) - 1 - v := h.order[m] - h.order = h.order[:m] - return v + slices.SortFunc(syms, byName) } diff --git a/internal/cpufeat/cpufeat.go b/internal/cpufeat/cpufeat.go index f0b20d7..6953dc1 100644 --- a/internal/cpufeat/cpufeat.go +++ b/internal/cpufeat/cpufeat.go @@ -12,9 +12,29 @@ import "strings" func Mnemonic(text string) (op, operands string) { text = strings.TrimSpace(text) if i := strings.IndexAny(text, " \t"); i >= 0 { - return strings.ToLower(text[:i]), text[i+1:] + return lowerASCII(text[:i]), text[i+1:] } - return strings.ToLower(text), "" + return lowerASCII(text), "" +} + +func lowerASCII(s string) string { + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + b := []byte(s) + b[i] = c + ('a' - 'A') + for j := i + 1; j < len(b); j++ { + if b[j] >= 'A' && b[j] <= 'Z' { + b[j] += 'a' - 'A' + } + } + return string(b) + } + if c >= 0x80 { + return strings.ToLower(s) + } + } + return s } // X86 reports the feature an x86/x86-64 instruction requires beyond the baseline, diff --git a/internal/disasm/decoder.go b/internal/disasm/decoder.go index e7969ea..412dba6 100644 --- a/internal/disasm/decoder.go +++ b/internal/disasm/decoder.go @@ -96,7 +96,7 @@ func Classify(text string) InstClass { if sp >= 0 { op = text[:sp] } - op = strings.ToLower(op) + op = lowerASCII(op) // "blr" is ambiguous: ARM64 "blr " is an indirect call, but PowerPC "blr" // (no operand) is branch-to-link-register, i.e. a return. Disambiguate by @@ -165,6 +165,26 @@ func hasMnemonicPrefix(op string, prefixes []string) bool { return false } +func lowerASCII(s string) string { + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + b := []byte(s) + b[i] = c + ('a' - 'A') + for j := i + 1; j < len(b); j++ { + if b[j] >= 'A' && b[j] <= 'Z' { + b[j] += 'a' - 'A' + } + } + return string(b) + } + if c >= 0x80 { + return strings.ToLower(s) + } + } + return s +} + // Disassembler decodes a single instruction at code[0] for VM address addr. // On failure the caller should advance by Step() bytes and try again. type Disassembler interface { @@ -369,7 +389,7 @@ func resolveRiscvBranch(text string, addr uint64) string { if sp < 0 { return text } - op := strings.ToLower(text[:sp]) + op := lowerASCII(text[:sp]) branch := op == "jal" || op == "j" if !branch { switch Classify(text) { @@ -571,10 +591,12 @@ func hexImmediates(s string) string { return s } var b strings.Builder - b.Grow(len(s) + 8) + changed := false for i := 0; i < len(s); { if s[i] != '#' { - b.WriteByte(s[i]) + if changed { + b.WriteByte(s[i]) + } i++ continue } @@ -591,16 +613,25 @@ func hexImmediates(s string) string { // Leave it alone unless this is a plain decimal: no digits, an "0x" hex // prefix, or a "." float suffix all mean "not a decimal immediate". if start == j || (j < len(s) && (s[j] == 'x' || s[j] == 'X' || s[j] == '.')) { - b.WriteByte('#') + if changed { + b.WriteByte('#') + } i++ continue } v, err := strconv.ParseUint(s[start:j], 10, 64) if err != nil { - b.WriteByte('#') + if changed { + b.WriteByte('#') + } i++ continue } + if !changed { + b.Grow(len(s) + 8) + b.WriteString(s[:i]) + changed = true + } b.WriteByte('#') if neg { b.WriteByte('-') @@ -608,6 +639,9 @@ func hexImmediates(s string) string { appendHexTo(&b, v) i = j } + if !changed { + return s + } return b.String() } diff --git a/internal/dump/cpufeatures.go b/internal/dump/cpufeatures.go index cdd0a61..07cd152 100644 --- a/internal/dump/cpufeatures.go +++ b/internal/dump/cpufeatures.go @@ -7,6 +7,9 @@ import ( "strings" "sync" + "golang.org/x/arch/arm64/arm64asm" + "golang.org/x/arch/x86/x86asm" + "github.com/rabarbra/exex/internal/arch" "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/cpufeat" @@ -47,16 +50,87 @@ type chunkFeatures struct { // counts instructions at or past the chunk's real start so the overlap isn't // double-counted. func ScanCPUFeatures(f *binfile.File) (CPUFeatureSet, error) { + return ScanCPUFeaturesCancel(f, nil) +} + +// ScanCPUFeaturesCancel is ScanCPUFeatures with an optional cancellation channel. +// When done is closed, workers stop decoding as soon as they observe it. The UI +// still guards stale results by sequence number; cancellation is to stop wasting +// CPU after the user dismisses/supersedes the scan. +func ScanCPUFeaturesCancel(f *binfile.File, done <-chan struct{}) (CPUFeatureSet, error) { classify := cpuClassifier(f.Arch()) if classify == nil { return CPUFeatureSet{}, fmt.Errorf("CPU-feature detection is not supported for %s", f.Arch()) } + if f.Arch() == arch.ArchARM64 { + return scanCPUFeaturesARM64(f, done), nil + } + if f.Arch() == arch.ArchAMD64 || f.Arch() == arch.ArchX86 { + return scanCPUFeaturesX86(f, done), nil + } dis, err := disasm.For(f.Arch()) if err != nil || dis == nil { return CPUFeatureSet{}, fmt.Errorf("no disassembler for this architecture") } raw := f.Raw() + tasks := cpuFeatureTasks(f, raw) + + parts := make([]chunkFeatures, len(tasks)) + workers := max(min(runtime.GOMAXPROCS(0), len(tasks)), 1) + sem := make(chan struct{}, workers) + var wg sync.WaitGroup + for i, tk := range tasks { + if scanCancelled(done) { + break + } + wg.Add(1) + sem <- struct{}{} + go func(i int, tk chunkTask) { + defer wg.Done() + defer func() { <-sem }() + cf := chunkFeatures{counts: map[string]int{}, first: map[string]uint64{}} + disasm.RangeFunc(dis, raw[tk.lo:tk.hi], tk.baseVA, func(in disasm.Inst) bool { + if scanCancelled(done) { + return false + } + if in.Addr < tk.emitVA { + return true // re-sync lead — already counted by the previous chunk + } + if in.Addr >= tk.emitEndVA { + return false + } + cf.total++ + if feat := classify(in.Text); feat != "" { + if cf.counts[feat] == 0 || in.Addr < cf.first[feat] { + cf.first[feat] = in.Addr + } + cf.counts[feat]++ + } + return true + }) + parts[i] = cf + }(i, tk) + } + wg.Wait() + + set := CPUFeatureSet{Counts: map[string]int{}, FirstUse: map[string]uint64{}} + for _, cf := range parts { + set.Total += cf.total + for feat, n := range cf.counts { + if set.Counts[feat] == 0 || cf.first[feat] < set.FirstUse[feat] { + set.FirstUse[feat] = cf.first[feat] + } + set.Counts[feat] += n + } + } + if f.Arch() == arch.ArchX86 || f.Arch() == arch.ArchAMD64 { + set.Baseline = cpufeat.BaselineX86(set.Counts) + } + return set, nil +} + +func cpuFeatureTasks(f *binfile.File, raw []byte) []chunkTask { var tasks []chunkTask for _, s := range f.Sections { if !s.Exec || s.FileSize == 0 { @@ -68,37 +142,59 @@ func ScanCPUFeatures(f *binfile.File) (CPUFeatureSet, error) { hi := min(p+dumpScanChunk, secEnd) lo := max(secOff, p-dumpScanLead) tasks = append(tasks, chunkTask{ - lo: lo, hi: hi, - baseVA: s.Addr + uint64(lo-secOff), - emitVA: s.Addr + uint64(p-secOff), + lo: lo, + hi: hi, + baseVA: s.Addr + uint64(lo-secOff), + emitVA: s.Addr + uint64(p-secOff), + emitEndVA: s.Addr + uint64(hi-secOff), }) } } + return tasks +} +func scanCPUFeaturesARM64(f *binfile.File, done <-chan struct{}) CPUFeatureSet { + raw := f.Raw() + tasks := cpuFeatureTasks(f, raw) parts := make([]chunkFeatures, len(tasks)) workers := max(min(runtime.GOMAXPROCS(0), len(tasks)), 1) sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, tk := range tasks { + if scanCancelled(done) { + break + } wg.Add(1) sem <- struct{}{} go func(i int, tk chunkTask) { defer wg.Done() defer func() { <-sem }() cf := chunkFeatures{counts: map[string]int{}, first: map[string]uint64{}} - disasm.RangeFunc(dis, raw[tk.lo:tk.hi], tk.baseVA, func(in disasm.Inst) bool { - if in.Addr < tk.emitVA { - return true // re-sync lead — already counted by the previous chunk + code := raw[tk.lo:tk.hi] + start := int((4 - tk.baseVA%4) % 4) + for off := start; off+4 <= len(code); off += 4 { + if scanCancelled(done) { + break + } + addr := tk.baseVA + uint64(off) + if addr < tk.emitVA { + continue + } + if addr >= tk.emitEndVA { + break } cf.total++ - if feat := classify(in.Text); feat != "" { - if cf.counts[feat] == 0 || in.Addr < cf.first[feat] { - cf.first[feat] = in.Addr + inst, err := arm64asm.Decode(code[off:]) + if err != nil { + continue + } + if feat := classifyARM64Inst(inst); feat != "" { + if cf.counts[feat] == 0 || addr < cf.first[feat] { + cf.first[feat] = addr } cf.counts[feat]++ } - return true - }) + } parts[i] = cf }(i, tk) } @@ -114,10 +210,259 @@ func ScanCPUFeatures(f *binfile.File) (CPUFeatureSet, error) { set.Counts[feat] += n } } - if f.Arch() == arch.ArchX86 || f.Arch() == arch.ArchAMD64 { - set.Baseline = cpufeat.BaselineX86(set.Counts) + return set +} + +func scanCPUFeaturesX86(f *binfile.File, done <-chan struct{}) CPUFeatureSet { + raw := f.Raw() + tasks := cpuFeatureTasks(f, raw) + parts := make([]chunkFeatures, len(tasks)) + workers := max(min(runtime.GOMAXPROCS(0), len(tasks)), 1) + sem := make(chan struct{}, workers) + mode := 64 + if f.Arch() == arch.ArchX86 { + mode = 32 + } + var wg sync.WaitGroup + for i, tk := range tasks { + if scanCancelled(done) { + break + } + wg.Add(1) + sem <- struct{}{} + go func(i int, tk chunkTask) { + defer wg.Done() + defer func() { <-sem }() + cf := chunkFeatures{counts: map[string]int{}, first: map[string]uint64{}} + code := raw[tk.lo:tk.hi] + for p := 0; p < len(code); { + if scanCancelled(done) { + break + } + addr := tk.baseVA + uint64(p) + inst, err := decodeX86Raw(code[p:], mode) + if err != nil || inst.Len == 0 { + if p+1 > len(code) { + break + } + if addr >= tk.emitVA && addr < tk.emitEndVA { + cf.total++ + } + p++ + continue + } + if addr >= tk.emitEndVA { + break + } + if addr >= tk.emitVA { + cf.total++ + if feat := classifyX86Inst(inst); feat != "" { + if cf.counts[feat] == 0 || addr < cf.first[feat] { + cf.first[feat] = addr + } + cf.counts[feat]++ + } + } + p += inst.Len + } + parts[i] = cf + }(i, tk) + } + wg.Wait() + + set := CPUFeatureSet{Counts: map[string]int{}, FirstUse: map[string]uint64{}} + for _, cf := range parts { + set.Total += cf.total + for feat, n := range cf.counts { + if set.Counts[feat] == 0 || cf.first[feat] < set.FirstUse[feat] { + set.FirstUse[feat] = cf.first[feat] + } + set.Counts[feat] += n + } + } + set.Baseline = cpufeat.BaselineX86(set.Counts) + return set +} + +func decodeX86Raw(code []byte, mode int) (inst x86asm.Inst, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("x86 decode panic: %v", r) + } + }() + return x86asm.Decode(code, mode) +} + +func classifyX86Inst(inst x86asm.Inst) string { + op := inst.Op.String() + if len(op) > 1 && (op[0] == 'V' || op[0] == 'v') && hasX86VectorOrMaskArg(inst) { + switch { + case hasX86ZMMOrMaskArg(inst): + return "AVX-512" + case hasPrefixFoldASCII(op, "vfmadd") || hasPrefixFoldASCII(op, "vfmsub") || + hasPrefixFoldASCII(op, "vfnmadd") || hasPrefixFoldASCII(op, "vfnmsub"): + return "FMA" + case hasPrefixFoldASCII(op, "vcvtph2ps") || hasPrefixFoldASCII(op, "vcvtps2ph"): + return "F16C" + case hasPrefixFoldASCII(op, "vaes"): + return "AES" + case hasPrefixFoldASCII(op, "vpclmul"): + return "PCLMUL" + case hasX86YMMArg(inst) && (hasPrefixFoldASCII(op, "vp") || hasPrefixFoldASCII(op, "vperm") || + containsFoldASCII(op, "broadcast") || containsFoldASCII(op, "gather") || containsFoldASCII(op, "blendd")): + return "AVX2" + } + return "AVX" + } + + switch { + case eqFoldASCII(op, "popcnt"): + return "POPCNT" + case hasPrefixFoldASCII(op, "crc32"): + return "SSE4.2" + case eqFoldASCII(op, "pcmpgtq") || hasPrefixFoldASCII(op, "pcmpistr") || hasPrefixFoldASCII(op, "pcmpestr"): + return "SSE4.2" + case hasPrefixFoldASCII(op, "pmovsx") || hasPrefixFoldASCII(op, "pmovzx") || eqFoldASCII(op, "ptest") || + eqFoldASCII(op, "pmuldq") || eqFoldASCII(op, "pmulld") || hasPrefixFoldASCII(op, "blend") || hasPrefixFoldASCII(op, "round") || + eqFoldASCII(op, "dpps") || eqFoldASCII(op, "dppd") || eqFoldASCII(op, "mpsadbw") || eqFoldASCII(op, "packusdw") || + hasPrefixFoldASCII(op, "pmaxs") || hasPrefixFoldASCII(op, "pmaxu") || + hasPrefixFoldASCII(op, "pmins") || hasPrefixFoldASCII(op, "pminu") || + eqFoldASCII(op, "phminposuw") || eqFoldASCII(op, "insertps") || eqFoldASCII(op, "extractps"): + return "SSE4.1" + case eqFoldASCII(op, "pshufb") || hasPrefixFoldASCII(op, "phadd") || hasPrefixFoldASCII(op, "phsub") || + eqFoldASCII(op, "pmaddubsw") || hasPrefixFoldASCII(op, "palignr") || hasPrefixFoldASCII(op, "psign") || + hasPrefixFoldASCII(op, "pabs") || eqFoldASCII(op, "pmulhrsw"): + return "SSSE3" + case eqFoldASCII(op, "addsubps") || eqFoldASCII(op, "addsubpd") || hasPrefixFoldASCII(op, "hadd") || hasPrefixFoldASCII(op, "hsub") || + eqFoldASCII(op, "movddup") || eqFoldASCII(op, "movshdup") || eqFoldASCII(op, "movsldup") || eqFoldASCII(op, "lddqu"): + return "SSE3" + case hasPrefixFoldASCII(op, "aes"): + return "AES" + case hasPrefixFoldASCII(op, "sha1") || hasPrefixFoldASCII(op, "sha256"): + return "SHA" + case eqFoldASCII(op, "rdrand"): + return "RDRAND" + case eqFoldASCII(op, "rdseed"): + return "RDSEED" + case eqFoldASCII(op, "movbe"): + return "MOVBE" + case eqFoldASCII(op, "andn") || eqFoldASCII(op, "bextr") || hasPrefixFoldASCII(op, "bls") || eqFoldASCII(op, "tzcnt"): + return "BMI1" + case eqFoldASCII(op, "bzhi") || eqFoldASCII(op, "mulx") || eqFoldASCII(op, "pdep") || eqFoldASCII(op, "pext") || eqFoldASCII(op, "rorx") || + eqFoldASCII(op, "sarx") || eqFoldASCII(op, "shlx") || eqFoldASCII(op, "shrx"): + return "BMI2" + case eqFoldASCII(op, "lzcnt"): + return "ABM" + case eqFoldASCII(op, "adcx") || eqFoldASCII(op, "adox"): + return "ADX" + } + return "" +} + +func hasX86VectorOrMaskArg(inst x86asm.Inst) bool { + for _, arg := range inst.Args { + if reg, ok := arg.(x86asm.Reg); ok && ((reg >= x86asm.X0 && reg <= x86asm.Z31) || (reg >= x86asm.K0 && reg <= x86asm.K7)) { + return true + } + } + return false +} + +func hasX86ZMMOrMaskArg(inst x86asm.Inst) bool { + for _, arg := range inst.Args { + if reg, ok := arg.(x86asm.Reg); ok && ((reg >= x86asm.Z0 && reg <= x86asm.Z31) || (reg >= x86asm.K0 && reg <= x86asm.K7)) { + return true + } + } + return false +} + +func hasX86YMMArg(inst x86asm.Inst) bool { + for _, arg := range inst.Args { + if reg, ok := arg.(x86asm.Reg); ok && reg >= x86asm.Y0 && reg <= x86asm.Y31 { + return true + } + } + return false +} + +func classifyARM64Inst(inst arm64asm.Inst) string { + op := inst.Op.String() + switch { + case hasPrefixFoldASCII(op, "aes"): + return "AES (crypto)" + case hasPrefixFoldASCII(op, "sha1") || hasPrefixFoldASCII(op, "sha256") || hasPrefixFoldASCII(op, "sha512"): + return "SHA (crypto)" + case hasPrefixFoldASCII(op, "pmull"): + return "PMULL (crypto)" + case hasPrefixFoldASCII(op, "crc32"): + return "CRC32" + case hasPrefixFoldASCII(op, "cas") || hasPrefixFoldASCII(op, "ldadd") || hasPrefixFoldASCII(op, "stadd") || + hasPrefixFoldASCII(op, "swp") || hasPrefixFoldASCII(op, "ldset") || hasPrefixFoldASCII(op, "stset") || + hasPrefixFoldASCII(op, "ldclr") || hasPrefixFoldASCII(op, "stclr") || hasPrefixFoldASCII(op, "ldeor") || + hasPrefixFoldASCII(op, "ldsmax") || hasPrefixFoldASCII(op, "ldsmin") || + hasPrefixFoldASCII(op, "ldumax") || hasPrefixFoldASCII(op, "ldumin"): + return "LSE atomics" + case hasPrefixFoldASCII(op, "pac") || hasPrefixFoldASCII(op, "aut") || eqFoldASCII(op, "xpaci") || eqFoldASCII(op, "xpacd") || + eqFoldASCII(op, "braa") || eqFoldASCII(op, "brab") || eqFoldASCII(op, "blraa") || eqFoldASCII(op, "blrab") || + eqFoldASCII(op, "retaa") || eqFoldASCII(op, "retab"): + return "Pointer auth" + case eqFoldASCII(op, "sdot") || eqFoldASCII(op, "udot"): + return "DotProd" + case eqFoldASCII(op, "bfdot") || hasPrefixFoldASCII(op, "bfmla") || eqFoldASCII(op, "bfmmla"): + return "BF16" + } + for _, arg := range inst.Args { + switch arg.(type) { + case arm64asm.RegisterWithArrangement, arm64asm.RegisterWithArrangementAndIndex: + return "NEON/ASIMD" + } + } + return "" +} + +func eqFoldASCII(s, lower string) bool { + return len(s) == len(lower) && hasPrefixFoldASCII(s, lower) +} + +func hasPrefixFoldASCII(s, lower string) bool { + if len(s) < len(lower) { + return false + } + for i := 0; i < len(lower); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + if c != lower[i] { + return false + } + } + return true +} + +func containsFoldASCII(s, lower string) bool { + if len(lower) == 0 { + return true + } + for i := 0; i+len(lower) <= len(s); i++ { + if hasPrefixFoldASCII(s[i:], lower) { + return true + } + } + return false +} + +func scanCancelled(done <-chan struct{}) bool { + if done == nil { + return false + } + select { + case <-done: + return true + default: + return false } - return set, nil } // SortedFeatures returns the used features in display order (then by count). diff --git a/internal/dump/cpufeatures_test.go b/internal/dump/cpufeatures_test.go new file mode 100644 index 0000000..d551076 --- /dev/null +++ b/internal/dump/cpufeatures_test.go @@ -0,0 +1,200 @@ +package dump + +import ( + "encoding/binary" + "os" + "reflect" + "testing" + + "golang.org/x/arch/arm64/arm64asm" + "golang.org/x/arch/x86/x86asm" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/cpufeat" + "github.com/rabarbra/exex/internal/disasm" +) + +func TestClassifyX86InstMatchesTextClassifierSamples(t *testing.T) { + cases := []struct { + name string + code []byte + expects bool + }{ + {"popcnt", []byte{0xf3, 0x48, 0x0f, 0xb8, 0xc0}, true}, + {"aesenc", []byte{0x66, 0x0f, 0x38, 0xdc, 0xc1}, true}, + {"pclmulqdq-currently-unclassified", []byte{0x66, 0x0f, 0x3a, 0x44, 0xc1, 0x00}, false}, + {"vaddps", []byte{0xc5, 0xf4, 0x58, 0xc2}, true}, + {"vpaddd", []byte{0xc5, 0xf5, 0xfe, 0xc2}, true}, + {"rdrand", []byte{0x0f, 0xc7, 0xf0}, true}, + {"lzcnt", []byte{0xf3, 0x0f, 0xbd, 0xc0}, true}, + {"tzcnt", []byte{0xf3, 0x0f, 0xbc, 0xc0}, true}, + {"movbe", []byte{0x0f, 0x38, 0xf0, 0x00}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + inst, err := x86asm.Decode(c.code, 64) + if err != nil { + t.Fatalf("Decode: %v", err) + } + text := x86asm.GNUSyntax(inst, 0x1000, nil) + got := classifyX86Inst(inst) + want := cpufeat.X86(text) + if got != want { + t.Fatalf("%s: classifyX86Inst=%q, text classifier=%q (%s)", c.name, got, want, text) + } + if c.expects && want == "" { + t.Fatalf("%s: sample did not exercise a CPU feature (%s)", c.name, text) + } + }) + } +} + +func TestScanCPUFeaturesX86MatchesTextPathOnBenchBinary(t *testing.T) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + t.Skip("set EXEX_BENCH_BIN to an x86/amd64 binary") + } + f, err := binfile.Open(path) + if err != nil { + t.Fatalf("Open(%s): %v", path, err) + } + defer f.Close() + if f.Arch() != disasm.ArchAMD64 && f.Arch() != disasm.ArchX86 { + t.Skipf("%s is %s, not x86/amd64", path, f.Arch()) + } + + got := scanCPUFeaturesX86(f, nil) + want := scanCPUFeaturesTextForTest(t, f) + if got.Total != want.Total || got.Baseline != want.Baseline || + !reflect.DeepEqual(got.Counts, want.Counts) || !reflect.DeepEqual(got.FirstUse, want.FirstUse) { + t.Fatalf("raw x86 scan mismatch\n got: total=%d baseline=%q counts=%v first=%v\n want: total=%d baseline=%q counts=%v first=%v", + got.Total, got.Baseline, got.Counts, got.FirstUse, + want.Total, want.Baseline, want.Counts, want.FirstUse) + } +} + +func TestClassifyX86InstMatchesTextClassifierOnBenchBinary(t *testing.T) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + t.Skip("set EXEX_BENCH_BIN to an x86/amd64 binary") + } + f, err := binfile.Open(path) + if err != nil { + t.Fatalf("Open(%s): %v", path, err) + } + defer f.Close() + mode := 64 + if f.Arch() == disasm.ArchX86 { + mode = 32 + } else if f.Arch() != disasm.ArchAMD64 { + t.Skipf("%s is %s, not x86/amd64", path, f.Arch()) + } + raw := f.Raw() + checked := 0 + for _, s := range f.Sections { + if !s.Exec || s.FileSize == 0 { + continue + } + secOff := int(s.Offset) + secEnd := min(secOff+int(s.FileSize), len(raw)) + for p := secOff; p < secEnd; { + inst, err := decodeX86Raw(raw[p:secEnd], mode) + if err != nil || inst.Len == 0 { + p++ + continue + } + addr := s.Addr + uint64(p-secOff) + text := x86asm.GNUSyntax(inst, addr, nil) + got := classifyX86Inst(inst) + want := cpufeat.X86(text) + if got != want { + t.Fatalf("0x%x %s: classifyX86Inst=%q, text classifier=%q", addr, text, got, want) + } + checked++ + p += inst.Len + } + } + if checked == 0 { + t.Fatal("no x86 instructions decoded") + } +} + +func scanCPUFeaturesTextForTest(t *testing.T, f *binfile.File) CPUFeatureSet { + t.Helper() + classify := cpuClassifier(f.Arch()) + dis, err := disasm.For(f.Arch()) + if err != nil || dis == nil { + t.Fatalf("disassembler: %v", err) + } + raw := f.Raw() + set := CPUFeatureSet{Counts: map[string]int{}, FirstUse: map[string]uint64{}} + for _, tk := range cpuFeatureTasks(f, raw) { + disasm.RangeFunc(dis, raw[tk.lo:tk.hi], tk.baseVA, func(in disasm.Inst) bool { + if in.Addr < tk.emitVA { + return true + } + if in.Addr >= tk.emitEndVA { + return false + } + set.Total++ + if feat := classify(in.Text); feat != "" { + if set.Counts[feat] == 0 || in.Addr < set.FirstUse[feat] { + set.FirstUse[feat] = in.Addr + } + set.Counts[feat]++ + } + return true + }) + } + set.Baseline = cpufeat.BaselineX86(set.Counts) + return set +} + +func TestClassifyARM64InstMatchesTextClassifierOnSelf(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Skip("no test executable path") + } + f, err := binfile.Open(exe) + if err != nil { + t.Skipf("open self: %v", err) + } + defer f.Close() + if f.Arch() != disasm.ArchARM64 { + t.Skip("self is not arm64") + } + + raw := f.Raw() + decoded := 0 + for _, s := range f.Sections { + if !s.Exec || s.FileSize == 0 { + continue + } + start := int(s.Offset) + end := int(s.Offset + s.FileSize) + if start < 0 || start >= len(raw) { + continue + } + if end > len(raw) { + end = len(raw) + } + align := int((4 - s.Addr%4) % 4) + for off := start + align; off+4 <= end; off += 4 { + inst, err := arm64asm.Decode(raw[off:]) + if err != nil { + continue + } + decoded++ + got := classifyARM64Inst(inst) + want := cpufeat.ARM64(arm64asm.GNUSyntax(inst)) + if got != want { + addr := s.Addr + uint64(off-start) + word := binary.LittleEndian.Uint32(raw[off:]) + t.Fatalf("0x%x %08x %s: classifyARM64Inst=%q, text classifier=%q", addr, word, arm64asm.GNUSyntax(inst), got, want) + } + } + } + if decoded == 0 { + t.Skip("no ARM64 instructions decoded from self") + } +} diff --git a/internal/dump/dump.go b/internal/dump/dump.go index 4859021..a5f8b94 100644 --- a/internal/dump/dump.go +++ b/internal/dump/dump.go @@ -63,6 +63,17 @@ func ViewNeedsDemangle(name string) bool { return false } +// ViewNeedsLayoutOnly reports whether a view can be rendered from the cheap +// layout model (sections/segments/raw bytes) without symbols, imports, relocs, +// DWARF, or computed overview fields. +func ViewNeedsLayoutOnly(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "sections", "segments", "strings", "cpu-features", "cpufeatures", "features": + return true + } + return false +} + // View dumps a named view as plain text, or errors for an unknown name. func View(f *binfile.File, name string) (string, error) { switch strings.ToLower(strings.TrimSpace(name)) { @@ -150,8 +161,31 @@ func DisasmTo(w io.Writer, f *binfile.File, all bool) error { } fmt.Fprintf(bw, "Disassembly of section %s:\n", s.Name) stop := false + secEndAddr := s.Addr + s.FileSize + if secEndAddr < s.Addr { + secEndAddr = ^uint64(0) + } + syms := f.SymbolRangeIter(s.Addr, secEndAddr) + nextSym, haveSym := syms.Next() disasm.RangeFunc(dis, raw[s.Offset:end], s.Addr, func(in disasm.Inst) bool { - if sym, ok := f.SymbolAt(in.Addr); ok && sym.Addr == in.Addr { + for haveSym && nextSym.Addr < in.Addr { + nextSym, haveSym = syms.Next() + } + if haveSym && nextSym.Addr == in.Addr { + sym := nextSym + for { + var ok bool + nextSym, ok = syms.Next() + if !ok { + haveSym = false + break + } + haveSym = true + if nextSym.Addr != in.Addr { + break + } + sym = nextSym + } buf = append(buf[:0], '\n') buf = appendHexPad(buf, in.Addr, addrW) buf = append(buf, " <"...) @@ -387,12 +421,19 @@ func appendLeftStr(dst []byte, s string, width int) []byte { func Strings(f *binfile.File) string { entries := f.Strings() var b strings.Builder + addrW := f.AddrHexWidth() size := 0 for i := range entries { - size += f.AddrHexWidth() + 5 + int(entries[i].Len) + 1 + size += addrW + 5 + int(entries[i].Len) + 1 } b.Grow(size) - _ = StringsTo(&b, f) + line := make([]byte, 0, addrW+5) + for _, e := range entries { + line = appendStringLinePrefix(line[:0], e, addrW) + b.Write(line) + b.Write(f.StringBytes(e)) + b.WriteByte('\n') + } return b.String() } @@ -405,32 +446,35 @@ func StringsTo(w io.Writer, f *binfile.File) error { bw := bufio.NewWriter(w) defer bw.Flush() var line []byte - for _, e := range f.Strings() { - line = line[:0] - if e.HasAddr { - line = append(line, '0', 'x') - line = appendHexPad(line, e.Addr, addrW) - } else { - // "@0x" + offset left-justified in addrW hex columns (matches "%-*x"). - line = append(line, '@', '0', 'x') - n := len(line) - line = appendHexPad(line, e.Offset, 0) - for w := len(line) - n; w < addrW; w++ { - line = append(line, ' ') - } - } - line = append(line, ' ', ' ') + return f.ScanStrings(func(e binfile.StringEntry) error { + line = appendStringLinePrefix(line[:0], e, addrW) if _, err := bw.Write(line); err != nil { - return nil + return io.ErrClosedPipe } if _, err := bw.Write(f.StringBytes(e)); err != nil { - return nil + return io.ErrClosedPipe } if err := bw.WriteByte('\n'); err != nil { - return nil + return io.ErrClosedPipe + } + return nil + }) +} + +func appendStringLinePrefix(dst []byte, e binfile.StringEntry, addrW int) []byte { + if e.HasAddr { + dst = append(dst, '0', 'x') + dst = appendHexPad(dst, e.Addr, addrW) + } else { + // "@0x" + offset left-justified in addrW hex columns (matches "%-*x"). + dst = append(dst, '@', '0', 'x') + n := len(dst) + dst = appendHexPad(dst, e.Offset, 0) + for w := len(dst) - n; w < addrW; w++ { + dst = append(dst, ' ') } } - return nil + return append(dst, ' ', ' ') } // StreamView writes a view straight to w when it has a streaming form (the large @@ -453,6 +497,11 @@ func Sources(f *binfile.File) string { return "no source files (needs DWARF debug info)\n" } var b strings.Builder + size := 0 + for _, s := range files { + size += len(s) + 1 + } + b.Grow(size) for _, s := range files { b.WriteString(s) b.WriteByte('\n') diff --git a/internal/dump/perf_bench_test.go b/internal/dump/perf_bench_test.go index ccc7b0d..6ffd3e6 100644 --- a/internal/dump/perf_bench_test.go +++ b/internal/dump/perf_bench_test.go @@ -63,11 +63,30 @@ func BenchmarkSyscalls(b *testing.B) { b.Fatal(err) } b.ReportAllocs() + b.ResetTimer() for range b.N { _ = Syscalls(f, false) } } +func BenchmarkCPUFeatures(b *testing.B) { + path := os.Getenv("EXEX_BENCH_BIN") + if path == "" { + b.Skip("set EXEX_BENCH_BIN to a real binary") + } + f, err := binfile.Open(path, binfile.WithLayoutOnly()) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := ScanCPUFeatures(f); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkStringsDump(b *testing.B) { path := os.Getenv("EXEX_BENCH_BIN") if path == "" { @@ -75,11 +94,14 @@ func BenchmarkStringsDump(b *testing.B) { } b.ReportAllocs() for range b.N { - f, err := binfile.Open(path) + f, err := binfile.Open(path, binfile.WithLayoutOnly()) if err != nil { b.Fatal(err) } - _ = Strings(f) + if err := StringsTo(io.Discard, f); err != nil { + b.Fatal(err) + } + f.Close() } } diff --git a/internal/dump/syscalls.go b/internal/dump/syscalls.go index 4f61293..26a3dde 100644 --- a/internal/dump/syscalls.go +++ b/internal/dump/syscalls.go @@ -376,13 +376,27 @@ func SyscallsFull(f *binfile.File) string { // the number of objects scanned, and notes about libraries that couldn't be // scanned. Shared by the `syscalls-full` dump and the TUI's full scope. func CollectSyscallsFull(f *binfile.File) (sites []SyscallSite, objects int, notes []string) { - sites = append(sites, scanObject(f, "this binary")...) + return CollectSyscallsFullCancel(f, nil) +} + +// CollectSyscallsFullCancel is CollectSyscallsFull with cooperative cancellation. +// When done is closed it stops scheduling new library/object work and asks active +// decode workers to exit early. Partial results may be returned; UI callers guard +// cancelled results by sequence/file identity. +func CollectSyscallsFullCancel(f *binfile.File, done <-chan struct{}) (sites []SyscallSite, objects int, notes []string) { + if scanCancelled(done) { + return sites, objects, notes + } + sites = append(sites, scanObjectCancel(f, "this binary", done)...) objects = 1 - if f.Info == nil { + if f.Info == nil || scanCancelled(done) { return sites, objects, notes } seen := map[string]bool{} for _, lib := range f.Info.DynamicLibs { + if scanCancelled(done) { + break + } path, ok := explorer.ResolveLibPath(lib, f.Path, f.Info, nil) if !ok { notes = append(notes, "· "+lib+" — not resolved on disk") @@ -397,7 +411,7 @@ func CollectSyscallsFull(f *binfile.File) (sites []SyscallSite, objects int, not notes = append(notes, "· "+lib+" — open failed: "+err.Error()) continue } - sites = append(sites, scanObject(lf, lib)...) + sites = append(sites, scanObjectCancel(lf, lib, done)...) objects++ lf.Close() } @@ -441,11 +455,15 @@ func SyscallsArchive(members []binfile.ArchiveMember, full bool) string { // scanObject collects one object's syscall sites, tagging each with origin. func scanObject(f *binfile.File, origin string) []SyscallSite { + return scanObjectCancel(f, origin, nil) +} + +func scanObjectCancel(f *binfile.File, origin string, done <-chan struct{}) []SyscallSite { dis, err := disasm.For(f.Arch()) if err != nil || dis == nil { return nil } - sites := collectSyscalls(f, dis) + sites := collectSyscallsCancel(f, dis, done) for i := range sites { sites[i].Origin = origin } @@ -641,12 +659,14 @@ const ( dumpScanLead = 1 << 10 ) -// chunkTask is one unit of parallel work: decode raw[lo:hi] (lead-in included), -// but only emit sites at or after emitVA so each site is produced exactly once. +// chunkTask is one unit of parallel work: decode raw[lo:hi] (resync overlap +// included), but only emit sites in [emitVA, emitEndVA) so each site is produced +// exactly once. type chunkTask struct { - lo, hi int // file-byte range to decode - baseVA uint64 // virtual address of raw[lo] - emitVA uint64 // emit sites with Addr >= this (chunk's real start) + lo, hi int // file-byte range to decode, including resync overlap + baseVA uint64 // virtual address of raw[lo] + emitVA uint64 // emit sites with Addr >= this (chunk's real start) + emitEndVA uint64 // emit sites with Addr < this (chunk's real end) } // collectSyscalls scans every executable section for syscall sites, decoding @@ -655,6 +675,10 @@ type chunkTask struct { // vDSO symbols — the common case — so a large stripped binary's scan is just a // cheap mnemonic test per instruction. func collectSyscalls(f *binfile.File, dis disasm.Disassembler) []SyscallSite { + return collectSyscallsCancel(f, dis, nil) +} + +func collectSyscallsCancel(f *binfile.File, dis disasm.Disassembler, done <-chan struct{}) []SyscallSite { var secs []binfile.Section for _, s := range f.Sections { // Exec section with file bytes. Addr may be 0 in a relocatable object @@ -677,12 +701,15 @@ func collectSyscalls(f *binfile.File, dis disasm.Disassembler) []SyscallSite { secEnd = len(raw) } for p := secOff; p < secEnd; p += dumpScanChunk { - hi := min(p+dumpScanChunk, secEnd) + emitEnd := min(p+dumpScanChunk, secEnd) + hi := min(emitEnd+dumpScanLead, secEnd) lo := max(secOff, p-dumpScanLead) tasks = append(tasks, chunkTask{ - lo: lo, hi: hi, - baseVA: s.Addr + uint64(lo-secOff), - emitVA: s.Addr + uint64(p-secOff), + lo: lo, + hi: hi, + baseVA: s.Addr + uint64(lo-secOff), + emitVA: s.Addr + uint64(p-secOff), + emitEndVA: s.Addr + uint64(emitEnd-secOff), }) } } @@ -692,26 +719,38 @@ func collectSyscalls(f *binfile.File, dis disasm.Disassembler) []SyscallSite { sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, tk := range tasks { + if scanCancelled(done) { + break + } wg.Add(1) sem <- struct{}{} go func(i int, tk chunkTask) { defer wg.Done() defer func() { <-sem }() - code := raw[tk.lo:tk.hi] - // Fixed-width arch with no vDSO heuristic: locate svc sites by their byte - // encoding and decode only a window at each — never the whole chunk. - if instLen := syscallInstLen(a); instLen > 0 && symAt == nil { - results[i] = scanChunkLocalized(dis, code, tk.baseVA, tk.emitVA, a, instLen, f) + if scanCancelled(done) { return } + code := raw[tk.lo:tk.hi] + if symAt == nil { + // With no vDSO call heuristic to run, locate syscall opcodes by byte + // signature and decode only a bounded window around each candidate. + if instLen := syscallInstLen(a); instLen > 0 { + results[i] = scanChunkLocalized(dis, code, tk.baseVA, tk.emitVA, tk.emitEndVA, a, instLen, f, done) + return + } + if a == disasm.ArchAMD64 || a == disasm.ArchX86 { + results[i] = scanChunkX86Localized(dis, code, tk.baseVA, tk.emitVA, tk.emitEndVA, a, f, done) + return + } + } // Otherwise skip a chunk that can't contain a syscall (the opcode byte // patterns are present in every real syscall/trampoline encoding), then // fully decode the rest — needed for variable-length x86 and the vDSO // call heuristic. - if !chunkHasSyscallCandidate(code, a) { + if !chunkHasSyscallCandidate(code, a, done) { return } - results[i] = scanChunk(dis, code, tk.baseVA, tk.emitVA, a, symAt, f) + results[i] = scanChunk(dis, code, tk.baseVA, tk.emitVA, tk.emitEndVA, a, symAt, f, done) }(i, tk) } wg.Wait() @@ -739,12 +778,15 @@ func collectSyscalls(f *binfile.File, dis disasm.Disassembler) []SyscallSite { // arm64/arm scan for the svc encoding at any offset (an unaligned coincidence just // decodes the chunk — still correct, never a miss). Other arches have no compact // signature and are always decoded. -func chunkHasSyscallCandidate(code []byte, a disasm.Arch) bool { +func chunkHasSyscallCandidate(code []byte, a disasm.Arch, done <-chan struct{}) bool { + if scanCancelled(done) { + return false + } switch a { case disasm.ArchAMD64, disasm.ArchX86: - // syscall (0f 05), sysenter (0f 34), int 0x80 (cd 80), gs-indirect call such + // syscall (0f 05), sysenter (0f 34), int 0x80/0x2e, gs-indirect call such // as the i386 vsyscall trampoline (65 ff …). - for _, p := range [][]byte{{0x0f, 0x05}, {0x0f, 0x34}, {0xcd, 0x80}, {0x65, 0xff}} { + for _, p := range [][]byte{{0x0f, 0x05}, {0x0f, 0x34}, {0xcd, 0x80}, {0xcd, 0x2e}, {0x65, 0xff}} { if bytes.Index(code, p) >= 0 { return true } @@ -753,6 +795,9 @@ func chunkHasSyscallCandidate(code []byte, a disasm.Arch) bool { case disasm.ArchARM64: // svc #imm16 = 0xd4000001 | (imm<<5). for i := 0; i+4 <= len(code); i++ { + if i&0xfff == 0 && scanCancelled(done) { + return false + } if binary.LittleEndian.Uint32(code[i:])&0xffe0001f == 0xd4000001 { return true } @@ -761,6 +806,9 @@ func chunkHasSyscallCandidate(code []byte, a disasm.Arch) bool { case disasm.ArchARM: // ARM-mode SVC: cond·1111·imm24 → opcode bits 27..24 all set. for i := 0; i+4 <= len(code); i++ { + if i&0xfff == 0 && scanCancelled(done) { + return false + } if binary.LittleEndian.Uint32(code[i:])&0x0f000000 == 0x0f000000 { return true } @@ -770,7 +818,6 @@ func chunkHasSyscallCandidate(code []byte, a disasm.Arch) bool { return true } - // syscallInstLen returns the fixed instruction width for arches whose syscall // instruction (svc) can be located by a byte test, enabling a localized scan that // decodes only a window around each site rather than the whole chunk. 0 means the @@ -803,11 +850,14 @@ func isSvcEncoding(b []byte, a disasm.Arch) bool { // so the (overwhelming majority of) non-syscall instructions are never decoded or // text-formatted. Used when there is no vDSO heuristic to run (the common case); // the full scanChunk is kept for variable-length arches and vDSO-bearing binaries. -func scanChunkLocalized(dis disasm.Disassembler, code []byte, baseVA, emitVA uint64, a disasm.Arch, instLen int, f *binfile.File) []SyscallSite { +func scanChunkLocalized(dis disasm.Disassembler, code []byte, baseVA, emitVA, emitEndVA uint64, a disasm.Arch, instLen int, f *binfile.File, done <-chan struct{}) []SyscallSite { var out []SyscallSite il := uint64(instLen) start := int((il - baseVA%il) % il) // align the scan to instruction boundaries for i := start; i+instLen <= len(code); i += instLen { + if scanCancelled(done) { + break + } if !isSvcEncoding(code[i:], a) { continue } @@ -815,6 +865,9 @@ func scanChunkLocalized(dis disasm.Disassembler, code []byte, baseVA, emitVA uin if va < emitVA { continue } + if va >= emitEndVA { + break + } lo := i - syscallScanBack*instLen if lo < start { lo = start @@ -839,13 +892,122 @@ func scanChunkLocalized(dis disasm.Disassembler, code []byte, baseVA, emitVA uin return out } -// scanChunk decodes one chunk and returns its syscall sites (those at or after -// emitVA), recovering each number from a bounded ring of preceding instructions. -func scanChunk(dis disasm.Disassembler, code []byte, baseVA, emitVA uint64, a disasm.Arch, - symAt func(uint64) (binfile.Symbol, bool), f *binfile.File) []SyscallSite { +func scanChunkX86Localized(dis disasm.Disassembler, code []byte, baseVA, emitVA, emitEndVA uint64, a disasm.Arch, f *binfile.File, done <-chan struct{}) []SyscallSite { + var out []SyscallSite + for _, off := range x86SyscallCandidateOffsets(code, done) { + if scanCancelled(done) { + break + } + va := baseVA + uint64(off) + if va < emitVA { + continue + } + if va >= emitEndVA { + break + } + site, vdso, num, hasNum, ok := decodeX86Candidate(dis, code, baseVA, off, a, done) + if !ok { + continue // opcode bytes inside another instruction + } + hit := SyscallSite{Addr: va, Text: strings.TrimSpace(site.Text), VDSO: vdso} + if sm, ok := f.SymbolAt(va); ok { + hit.Sym = sm.Display() + } + if hasNum { + hit.Num, hit.HasNum = num, true + } + out = append(out, hit) + } + return out +} + +func decodeX86Candidate(dis disasm.Disassembler, code []byte, baseVA uint64, off int, a disasm.Arch, done <-chan struct{}) (disasm.Inst, bool, int64, bool, bool) { + // An x86 instruction is at most 15 bytes. Try every possible previous + // instruction start in that window; a true linear instruction boundary must + // have a decode path that reaches the candidate offset exactly. This avoids + // both arbitrary-window resync misses and obvious false positives in immediates. + lo := off - 15 + if lo < 0 { + lo = 0 + } + hi := min(off+16, len(code)) + if hi <= off { + return disasm.Inst{}, false, 0, false, false + } + va := baseVA + uint64(off) + for start := lo; start < off || (off == 0 && start == 0); start++ { + if scanCancelled(done) { + return disasm.Inst{}, false, 0, false, false + } + var prev [syscallScanBack]disasm.Inst + prevN := 0 + var site disasm.Inst + var siteVDSO bool + var num int64 + var hasNum, found bool + disasm.RangeFunc(dis, code[start:hi], baseVA+uint64(start), func(in disasm.Inst) bool { + if scanCancelled(done) { + return false + } + if in.Addr > va { + return false + } + if in.Addr == va { + if ok, vdso := ClassifySyscallSite(in, nil); ok { + site, siteVDSO, found = in, vdso, true + if !vdso { + num, hasNum = ResolveSyscallNum(prev[:prevN], a) + } + } + return false + } + if prevN == syscallScanBack { + copy(prev[:], prev[1:]) + prev[prevN-1] = in + } else { + prev[prevN] = in + prevN++ + } + return true + }) + if found { + return site, siteVDSO, num, hasNum, true + } + } + return disasm.Inst{}, false, 0, false, false +} + +func x86SyscallCandidateOffsets(code []byte, done <-chan struct{}) []int { + var out []int + for i := 0; i+1 < len(code); i++ { + if i&0xfff == 0 && scanCancelled(done) { + return out + } + switch { + case code[i] == 0x0f && (code[i+1] == 0x05 || code[i+1] == 0x34): // syscall/sysenter + case code[i] == 0xcd && (code[i+1] == 0x80 || code[i+1] == 0x2e): // int 0x80/0x2e + case code[i] == 0x65 && code[i+1] == 0xff: // i386 %gs vsyscall trampoline call + default: + continue + } + out = append(out, i) + } + return out +} + +// scanChunk decodes one chunk and returns its syscall sites within the chunk's +// real range, recovering each number from a bounded ring of preceding instructions. +func scanChunk(dis disasm.Disassembler, code []byte, baseVA, emitVA, emitEndVA uint64, a disasm.Arch, + symAt func(uint64) (binfile.Symbol, bool), f *binfile.File, done <-chan struct{}) []SyscallSite { var out []SyscallSite recent := make([]disasm.Inst, 0, syscallScanBack) disasm.RangeFunc(dis, code, baseVA, func(in disasm.Inst) bool { + if scanCancelled(done) { + return false + } + if in.Addr >= emitEndVA { + return false + } if in.Addr >= emitVA { if site, vdso := ClassifySyscallSite(in, symAt); site { hit := SyscallSite{Addr: in.Addr, Text: strings.TrimSpace(in.Text), VDSO: vdso} diff --git a/internal/dump/syscalls_test.go b/internal/dump/syscalls_test.go index 8dfc8bd..3783c53 100644 --- a/internal/dump/syscalls_test.go +++ b/internal/dump/syscalls_test.go @@ -134,6 +134,75 @@ func TestVsyscallTrampoline(t *testing.T) { } } +func TestScanChunkX86Localized(t *testing.T) { + dis, err := disasm.For(disasm.ArchAMD64) + if err != nil { + t.Fatal(err) + } + // mov $0x3c,%eax; syscall + code := []byte{0xb8, 0x3c, 0x00, 0x00, 0x00, 0x0f, 0x05} + sites := scanChunkX86Localized(dis, code, 0x1000, 0x1000, 0x1000+uint64(len(code)), disasm.ArchAMD64, &binfile.File{}, nil) + if len(sites) != 1 { + t.Fatalf("sites = %d, want 1 (%#v)", len(sites), sites) + } + if sites[0].Addr != 0x1005 || !sites[0].HasNum || sites[0].Num != 60 || !strings.Contains(sites[0].Text, "syscall") { + t.Fatalf("site = %+v, want syscall at 0x1005 with #60", sites[0]) + } + + // The same opcode bytes inside a mov immediate are not an instruction boundary. + code = []byte{0xb8, 0x0f, 0x05, 0x00, 0x00, 0xc3} + if sites := scanChunkX86Localized(dis, code, 0x2000, 0x2000, 0x2000+uint64(len(code)), disasm.ArchAMD64, &binfile.File{}, nil); len(sites) != 0 { + t.Fatalf("false positive sites = %#v, want none", sites) + } +} + +func TestScanChunkX86LocalizedChunkBoundary(t *testing.T) { + dis, err := disasm.For(disasm.ArchAMD64) + if err != nil { + t.Fatal(err) + } + raw := make([]byte, dumpScanChunk+dumpScanLead) + boundary := dumpScanChunk - 1 + raw[boundary-1] = 0x90 // nop: makes boundary a valid linear instruction start + raw[boundary], raw[boundary+1] = 0x0f, 0x05 + + // The first chunk includes a small trailing overlap, so a syscall that starts + // at the logical last byte of the chunk is still decoded and emitted there. + sites := scanChunkX86Localized(dis, raw[:dumpScanChunk+dumpScanLead], 0, 0, dumpScanChunk, disasm.ArchAMD64, &binfile.File{}, nil) + if len(sites) != 1 || sites[0].Addr != uint64(boundary) { + t.Fatalf("boundary sites = %+v, want one syscall at %#x", sites, boundary) + } + + // The next chunk sees the same bytes in its leading overlap, but must not emit + // them again because the instruction starts before emitVA. + leadBase := dumpScanChunk - dumpScanLead + sites = scanChunkX86Localized(dis, raw[leadBase:], uint64(leadBase), dumpScanChunk, uint64(len(raw)), disasm.ArchAMD64, &binfile.File{}, nil) + if len(sites) != 0 { + t.Fatalf("leading-overlap duplicate sites = %+v, want none", sites) + } +} + +func TestScanChunkX86LocalizedTrailingOverlapDoesNotEmitNextChunk(t *testing.T) { + dis, err := disasm.For(disasm.ArchAMD64) + if err != nil { + t.Fatal(err) + } + raw := make([]byte, dumpScanChunk+dumpScanLead) + next := dumpScanChunk + 1 + raw[next-1] = 0x90 // nop: makes next a valid linear instruction start + raw[next], raw[next+1] = 0x0f, 0x05 + + if sites := scanChunkX86Localized(dis, raw[:], 0, 0, dumpScanChunk, disasm.ArchAMD64, &binfile.File{}, nil); len(sites) != 0 { + t.Fatalf("trailing-overlap sites = %+v, want none before emitEndVA", sites) + } + + leadBase := dumpScanChunk - dumpScanLead + sites := scanChunkX86Localized(dis, raw[leadBase:], uint64(leadBase), dumpScanChunk, uint64(len(raw)), disasm.ArchAMD64, &binfile.File{}, nil) + if len(sites) != 1 || sites[0].Addr != uint64(next) { + t.Fatalf("next-chunk sites = %+v, want one syscall at %#x", sites, next) + } +} + func TestResolveStopsAtWriterAndCall(t *testing.T) { insts := func(texts ...string) []disasm.Inst { out := make([]disasm.Inst, len(texts)) diff --git a/internal/explorer/disasm_service.go b/internal/explorer/disasm_service.go index 9997c9e..f804fb3 100644 --- a/internal/explorer/disasm_service.go +++ b/internal/explorer/disasm_service.go @@ -256,15 +256,21 @@ func (s *DisasmService) decodeAcross(img *binfile.Image, decodeStart, end, visib if len(data) == 0 { break } - for _, in := range disasm.Range(s.dis, data, img.AddrAt(p), 0) { + stop := false + disasm.RangeFunc(s.dis, data, img.AddrAt(p), func(in disasm.Inst) bool { off := r.Off + int(in.Addr-r.Addr) // this instruction's image offset if off < visibleStart { - continue // resync context before the visible window + return true // resync context before the visible window } if off >= end { - break + stop = true + return false } out = append(out, in) + return true + }) + if stop { + break } p = regEnd } diff --git a/internal/explorer/navigation.go b/internal/explorer/navigation.go index f0cac04..63637a3 100644 --- a/internal/explorer/navigation.go +++ b/internal/explorer/navigation.go @@ -16,8 +16,10 @@ func DefaultExecAddr(f *binfile.File, strategy string) uint64 { return 0 } inExec := func(a uint64) bool { - _, ok := f.ExecImage().PosForAddr(a) - return ok + if s := f.SectionAt(a); s != nil { + return s.Exec && s.FileSize != 0 + } + return false } try := func(s string) (uint64, bool) { switch s { @@ -38,8 +40,20 @@ func DefaultExecAddr(f *binfile.File, strategy string) uint64 { return a, true } case "lowest": - if im := f.ExecImage(); len(im.Regions) > 0 { - return im.Regions[0].Addr, true + var best uint64 + ok := false + for i := range f.Sections { + s := &f.Sections[i] + if !s.Alloc || !s.Exec || s.Size == 0 || s.FileSize == 0 { + continue + } + if !ok || s.Addr < best { + best = s.Addr + ok = true + } + } + if ok { + return best, true } } return 0, false @@ -60,7 +74,7 @@ func symbolAddr(f *binfile.File, names ...string) (uint64, bool) { } for _, s := range f.Symbols { if want[s.Name] { - if _, ok := f.ExecImage().PosForAddr(s.Addr); ok { + if sec := f.SectionAt(s.Addr); sec != nil && sec.Exec && sec.FileSize != 0 { return s.Addr, true } } @@ -72,7 +86,7 @@ func symbolAddr(f *binfile.File, names ...string) (uint64, bool) { func execSectionAddr(f *binfile.File, names ...string) (uint64, bool) { for i := range f.Sections { s := &f.Sections[i] - if !s.Exec || s.Size == 0 { + if !s.Exec || s.Size == 0 || s.FileSize == 0 { continue } for _, n := range names { diff --git a/internal/ui/async_test.go b/internal/ui/async_test.go new file mode 100644 index 0000000..b104d36 --- /dev/null +++ b/internal/ui/async_test.go @@ -0,0 +1,86 @@ +package ui + +import ( + "testing" + + "github.com/rabarbra/exex/internal/binfile" + "github.com/rabarbra/exex/internal/disasm" + "github.com/rabarbra/exex/internal/dump" +) + +func TestAsyncMessagesIgnoreStaleFile(t *testing.T) { + oldFile := &binfile.File{Symbols: []binfile.Symbol{{Name: "old"}}} + curFile := &binfile.File{Symbols: []binfile.Symbol{{Name: "cur"}}} + + m := &Model{file: curFile} + model, _ := m.Update(demangleDoneMsg{file: oldFile, names: []string{"stale"}}) + m = model.(*Model) + if got := curFile.Symbols[0].Demangled; got != "" { + t.Fatalf("stale demangle mutated current file: %q", got) + } + + m.disasmDecoding = true + m.disasmPendingAddr = 0x1000 + if _, _ = m.handleDisasmReady(disasmReadyMsg{file: oldFile, addr: 0x1000, insts: []disasm.Inst{{Addr: 0x1000}}}); !m.disasmDecoding || len(m.disasmInst) != 0 { + t.Fatalf("stale disasm ready was applied: decoding=%v insts=%d", m.disasmDecoding, len(m.disasmInst)) + } + + m.searchRunning = true + m.searchSeq = 1 + if _, _ = m.handleDisasmSearchProgress(disasmSearchProgressMsg{file: oldFile, seq: 1, done: true}); !m.searchRunning { + t.Fatal("stale disasm search progress stopped current search") + } + + m.xrefRunning = true + m.xrefSeq = 1 + if _, _ = m.handleXrefDone(xrefDoneMsg{file: oldFile, seq: 1, target: 0x1000, hits: []xrefHit{{addr: 0x1000}}}); !m.xrefRunning || m.xrefActive { + t.Fatalf("stale xref result was applied: running=%v active=%v", m.xrefRunning, m.xrefActive) + } + + m.syscallRunning = true + m.syscallSeq = 1 + if _, _ = m.handleSyscallDone(syscallDoneMsg{file: oldFile, seq: 1, sites: []dump.SyscallSite{{Addr: 0x1000}}}); !m.syscallRunning || m.syscallActive { + t.Fatalf("stale syscall result was applied: running=%v active=%v", m.syscallRunning, m.syscallActive) + } + + m.cpufeatRunning = true + m.cpufeatSeq = 1 + if _, _ = m.handleCPUFeatDone(cpufeatDoneMsg{file: oldFile, seq: 1, set: dump.CPUFeatureSet{Counts: map[string]int{"AVX": 1}}}); !m.cpufeatRunning || m.cpufeatDone { + t.Fatalf("stale CPU-feature result was applied: running=%v done=%v", m.cpufeatRunning, m.cpufeatDone) + } + + m.syscallFullRunning = true + m.syscallFullSeq = 1 + if _, _ = m.handleSyscallFullDone(syscallFullDoneMsg{file: oldFile, seq: 1, sites: []dump.SyscallSite{{Addr: 0x1000}}, objs: 2}); !m.syscallFullRunning || m.syscallFullDone { + t.Fatalf("stale full syscall result was applied: running=%v done=%v", m.syscallFullRunning, m.syscallFullDone) + } +} + +func TestCancelSyscallFullScanClosesChannelAndIgnoresLateResult(t *testing.T) { + f := &binfile.File{} + done := make(chan struct{}) + m := &Model{file: f} + m.syscallFullRunning = true + m.syscallFullSeq = 7 + m.syscallFullCancel = done + + m.cancelSyscallFullScan() + if m.syscallFullRunning { + t.Fatal("full syscall scan still marked running after cancel") + } + if m.syscallFullCancel != nil { + t.Fatal("full syscall cancel channel still retained after cancel") + } + if m.syscallFullSeq != 8 { + t.Fatalf("full syscall seq = %d, want 8", m.syscallFullSeq) + } + select { + case <-done: + default: + t.Fatal("full syscall cancel channel was not closed") + } + + if _, _ = m.handleSyscallFullDone(syscallFullDoneMsg{file: f, seq: 7, sites: []dump.SyscallSite{{Addr: 0x1000}}, objs: 2}); m.syscallFullDone || len(m.syscallFull) != 0 { + t.Fatalf("late cancelled full syscall result was applied: done=%v sites=%d", m.syscallFullDone, len(m.syscallFull)) + } +} diff --git a/internal/ui/chrome.go b/internal/ui/chrome.go index d016e2e..37b539c 100644 --- a/internal/ui/chrome.go +++ b/internal/ui/chrome.go @@ -590,6 +590,8 @@ func (m *Model) switchMode(md mode) tea.Cmd { m.ensureHex() case modeRaw: m.ensureRaw() + case modeSymbols: + m.ensureSymbols() case modeStrings: m.ensureStrings() case modeSources: diff --git a/internal/ui/cpufeatures.go b/internal/ui/cpufeatures.go index f8d747b..abc3c2f 100644 --- a/internal/ui/cpufeatures.go +++ b/internal/ui/cpufeatures.go @@ -13,6 +13,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" + "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/dump" ) @@ -25,12 +26,15 @@ type cpufeatState struct { cpufeatFeats []string // feature names in display order cpufeatSel int cpufeatTop int + cpufeatDone bool + cpufeatCancel chan struct{} } type cpufeatDoneMsg struct { - seq int - set dump.CPUFeatureSet - err error + file *binfile.File + seq int + set dump.CPUFeatureSet + err error } // startCPUFeatScan kicks off a CPU-feature scan (no-op + status when unsupported). @@ -39,30 +43,37 @@ func (m *Model) startCPUFeatScan() tea.Cmd { m.setStatus("no disassembler for this architecture", true) return nil } + if m.cpufeatDone { + m.openCPUFeatModal(m.cpufeatSet) + m.setStatus(fmt.Sprintf("%d CPU features (cached)", len(m.cpufeatFeats)), false) + return nil + } + m.stopCPUFeatScan() m.cpufeatSeq++ m.cpufeatRunning = true seq := m.cpufeatSeq file := m.file + done := make(chan struct{}) + m.cpufeatCancel = done m.setStatus("scanning for CPU features … (Esc cancels)", false) return func() tea.Msg { - set, err := dump.ScanCPUFeatures(file) - return cpufeatDoneMsg{seq: seq, set: set, err: err} + set, err := dump.ScanCPUFeaturesCancel(file, done) + return cpufeatDoneMsg{file: file, seq: seq, set: set, err: err} } } func (m *Model) handleCPUFeatDone(msg cpufeatDoneMsg) (tea.Model, tea.Cmd) { - if !m.cpufeatRunning || msg.seq != m.cpufeatSeq { + if msg.file != m.file || !m.cpufeatRunning || msg.seq != m.cpufeatSeq { return m, nil } m.cpufeatRunning = false + m.cpufeatCancel = nil if msg.err != nil { m.setStatus(msg.err.Error(), true) return m, nil } - m.cpufeatSet = msg.set - m.cpufeatFeats = msg.set.SortedFeatures() - m.cpufeatSel, m.cpufeatTop = 0, 0 - m.cpufeatActive = true + m.cpufeatDone = true + m.openCPUFeatModal(msg.set) base := "" if msg.set.Baseline != "" { base = " · " + msg.set.Baseline @@ -71,12 +82,27 @@ func (m *Model) handleCPUFeatDone(msg cpufeatDoneMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) openCPUFeatModal(set dump.CPUFeatureSet) { + m.cpufeatSet = set + m.cpufeatFeats = set.SortedFeatures() + m.cpufeatSel, m.cpufeatTop = 0, 0 + m.cpufeatActive = true +} + func (m *Model) cancelCPUFeat() { m.cpufeatSeq++ m.cpufeatRunning = false + m.stopCPUFeatScan() m.setStatus("CPU-feature scan cancelled", false) } +func (m *Model) stopCPUFeatScan() { + if m.cpufeatCancel != nil { + close(m.cpufeatCancel) + m.cpufeatCancel = nil + } +} + // updateCPUFeatModal: navigate the feature list, Enter jumps to the first use of // the selected feature, Esc closes. func (m *Model) updateCPUFeatModal(key string) (tea.Model, tea.Cmd) { diff --git a/internal/ui/disasm_decode.go b/internal/ui/disasm_decode.go index b3f28d7..8f2057e 100644 --- a/internal/ui/disasm_decode.go +++ b/internal/ui/disasm_decode.go @@ -28,6 +28,7 @@ func (m *Model) functionInsts(sym binfile.Symbol) []disasm.Inst { // disasmReadyMsg carries the finished decode from the background worker. type disasmReadyMsg struct { + file *binfile.File addr uint64 posLo int posHi int @@ -100,10 +101,11 @@ func (m *Model) ensureDisasm() bool { // delivers it as a disasmReadyMsg. func (m *Model) decodeDisasmCmd(addr uint64) tea.Cmd { svc := m.disasmService() + file := m.file before := svc.LeadBytes() return func() tea.Msg { win, insts := svc.DecodeAt(addr, before) - return disasmReadyMsg{addr: addr, posLo: win.Start, posHi: win.End, insts: insts} + return disasmReadyMsg{file: file, addr: addr, posLo: win.Start, posHi: win.End, insts: insts} } } diff --git a/internal/ui/goto.go b/internal/ui/goto.go index 356c897..c3b2008 100644 --- a/internal/ui/goto.go +++ b/internal/ui/goto.go @@ -2,7 +2,6 @@ package ui import ( "fmt" - "sort" "strconv" "strings" @@ -134,14 +133,21 @@ func (m *Model) appendAddrTarget(val string) { } // appendSymbolMatches ranks symbols (raw + demangled name) exact → prefix → -// substring and appends them. +// substring and appends bounded buckets. The palette never shows more than +// gotoMaxResults entries, so keep at most that many per rank instead of +// collecting/sorting every match on large symbol tables for every keystroke. func (m *Model) appendSymbolMatches(needle string) { + remain := gotoMaxResults - len(m.gotoResults) + if remain <= 0 { + return + } lowerName, lowerDem := m.file.LowerNames() - type ranked struct { - t gotoTarget - rank int + var exact, prefix, substr []gotoTarget + add := func(dst *[]gotoTarget, t gotoTarget) { + if len(*dst) < remain { + *dst = append(*dst, t) + } } - var matches []ranked for i := range m.file.Symbols { s := m.file.Symbols[i] if s.Addr == 0 { @@ -151,26 +157,27 @@ func (m *Model) appendSymbolMatches(needle string) { if !strings.Contains(name, needle) && (dem == "" || !strings.Contains(dem, needle)) { continue } - rank := 2 + t := gotoTarget{kind: gkSymbol, label: s.Display(), addr: s.Addr, sym: s, hasAddr: true} switch { case name == needle || dem == needle: - rank = 0 + add(&exact, t) + if len(exact) >= remain { + goto flush + } case strings.HasPrefix(name, needle) || strings.HasPrefix(dem, needle): - rank = 1 + add(&prefix, t) + default: + add(&substr, t) } - matches = append(matches, ranked{gotoTarget{kind: gkSymbol, label: s.Display(), addr: s.Addr, sym: s, hasAddr: true}, rank}) } - sort.SliceStable(matches, func(i, j int) bool { - if matches[i].rank != matches[j].rank { - return matches[i].rank < matches[j].rank - } - return matches[i].t.label < matches[j].t.label - }) - for _, mt := range matches { - if len(m.gotoResults) >= gotoMaxResults { - break +flush: + for _, bucket := range [][]gotoTarget{exact, prefix, substr} { + for _, t := range bucket { + if len(m.gotoResults) >= gotoMaxResults { + return + } + m.gotoResults = append(m.gotoResults, t) } - m.gotoResults = append(m.gotoResults, mt.t) } } diff --git a/internal/ui/goto_test.go b/internal/ui/goto_test.go index c76dce7..2bc68fc 100644 --- a/internal/ui/goto_test.go +++ b/internal/ui/goto_test.go @@ -28,6 +28,42 @@ func TestResolveSymbolGoto(t *testing.T) { } } +func TestAppendSymbolMatchesRanksBeforeTableOrder(t *testing.T) { + m := &Model{file: &binfile.File{Symbols: []binfile.Symbol{ + {Name: "zzz_target", Addr: 0x3000}, + {Name: "target_helper", Addr: 0x2000}, + {Name: "target", Addr: 0x1000}, + }}} + m.appendSymbolMatches("target") + if got := len(m.gotoResults); got != 3 { + t.Fatalf("matches = %d, want 3", got) + } + want := []string{"target", "target_helper", "zzz_target"} + for i, w := range want { + if got := m.gotoResults[i].label; got != w { + t.Fatalf("result[%d] = %q, want %q", i, got, w) + } + } +} + +func TestAppendSymbolMatchesFlushesWhenExactBucketFillsCap(t *testing.T) { + m := &Model{file: &binfile.File{Symbols: []binfile.Symbol{ + {Name: "needle", Addr: 0x1000}, + {Name: "needle", Addr: 0x2000}, + {Name: "needle", Addr: 0x3000}, + }}} + m.gotoResults = make([]gotoTarget, gotoMaxResults-2) + m.appendSymbolMatches("needle") + if got := len(m.gotoResults); got != gotoMaxResults { + t.Fatalf("matches after bounded append = %d, want %d", got, gotoMaxResults) + } + for i := gotoMaxResults - 2; i < gotoMaxResults; i++ { + if got := m.gotoResults[i].label; got != "needle" { + t.Fatalf("result[%d] = %q, want needle", i, got) + } + } +} + func TestStartupGotoMultipleMatchesOpensSymbols(t *testing.T) { m := &Model{ theme: DefaultTheme(), diff --git a/internal/ui/key_dispatch.go b/internal/ui/key_dispatch.go index e015fcc..1a1b2f3 100644 --- a/internal/ui/key_dispatch.go +++ b/internal/ui/key_dispatch.go @@ -76,6 +76,11 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cancelSyscall() return m, nil } + if m.syscallFullRunning && key == "esc" { + m.cancelSyscallFullScan() + m.setStatus("syscall scan cancelled", false) + return m, nil + } if m.cpufeatRunning && key == "esc" { m.cancelCPUFeat() return m, nil @@ -431,7 +436,7 @@ func (m *Model) captureActiveFilter(key string, msg tea.KeyMsg) (tea.Cmd, bool) case modeLibs: return filterCapture(&m.libsFilter, key, msg, m.buildLibRows) case modeRelocs: - return filterCapture(&m.libsFilter, key, msg, m.recomputeRelocs) + return filterCapture(&m.relocFilter, key, msg, m.recomputeRelocs) case modeSources: if m.srcFile == "" { return filterCapture(&m.sourcesFilter, key, msg, m.recomputeSourceFiles) diff --git a/internal/ui/model.go b/internal/ui/model.go index dbbdc62..eece673 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -140,6 +140,7 @@ type symbolsState struct { symbolsFiltering bool // whether a search filter is currently narrowing the tree symbolsRoots []*treeNode // built tree; cached so collapse toggles only re-flatten symbolsRows []treeRow // flattened visible rows (tree nodes + leaves), nav/render unit + symbolsReady bool // rows/tree have been built at least once symbolsTreeInit bool // collapse-default applied once symbolsByDisplay []int // all symbol indices sorted by Display(); built lazily demangledNames []string // cached ComputeDemangled result, for the live demangle toggle @@ -318,18 +319,22 @@ type libsState struct { libsAvailKind map[string]availKind libsFilter textinput.Model // name search (the `/` filter) libsSortDesc bool // reverse the (name) sort +} - relocCur int // cursor in the relocation table - relocTop int // viewport top of the relocation table - relocFiltered []int // indices into file.Relocations() after the filter - relocSort relocSortField // sort field for the relocation table - relocSortDesc bool // reverse the relocation sort - relocTypeOn bool // type-name facet filter active - relocType string // the relocation type it restricts to - relocTypes []string // distinct types, for cycling - relocSecOn bool // section facet filter active - relocSec string // the section it restricts to - relocSecs []string // distinct sections, for cycling +// relocsState stores cursor, filter and cache state for the Relocations view. +type relocsState struct { + relocCur int // cursor in the relocation table + relocTop int // viewport top of the relocation table + relocFilter textinput.Model // symbol/type/section search (the `/` filter) + relocFiltered []int // indices into file.Relocations() after the filter + relocSort relocSortField // sort field for the relocation table + relocSortDesc bool // reverse the relocation sort + relocTypeOn bool // type-name facet filter active + relocType string // the relocation type it restricts to + relocTypes []string // distinct types, for cycling + relocSecOn bool // section facet filter active + relocSec string // the section it restricts to + relocSecs []string // distinct sections, for cycling relocRowCache rowMemo[rowCacheKey, string] } @@ -507,6 +512,7 @@ type searchState struct { searchActive bool searchQuery string searchSeq int + searchCancel chan struct{} searchRunning bool searchCancelable bool searchResults disasmSearchCache @@ -573,6 +579,7 @@ type Model struct { hexState rawState libsState + relocsState stringsState sourcesState interactionState diff --git a/internal/ui/new.go b/internal/ui/new.go index 19638fa..1df80ae 100644 --- a/internal/ui/new.go +++ b/internal/ui/new.go @@ -32,6 +32,7 @@ func New(f *binfile.File, opts ...Options) (*Model, error) { srcFilter := newPromptInput("type to filter…", "/ ") strFilter := newPromptInput("type to filter…", "/ ") libFilter := newPromptInput("type to filter…", "/ ") + relocFilter := newPromptInput("symbol · type · section", "/ ") gotoInput := newPromptInput("0x401000 or symbol name", "→ ") searchInput := newPromptInput("hex bytes (de ad be ef) or text", "/ ") sysFilter := newPromptInput("name · #num · symbol", "/ ") @@ -73,6 +74,9 @@ func New(f *binfile.File, opts ...Options) (*Model, error) { libsTree: cfg.Behavior.TreeLibs, libsFilter: libFilter, }, + relocsState: relocsState{ + relocFilter: relocFilter, + }, gotoState: gotoState{ gotoInput: gotoInput, }, @@ -92,7 +96,6 @@ func New(f *binfile.File, opts ...Options) (*Model, error) { keyState: newKeyState(cfg.Keys), } m.keyLog = os.Getenv("EXEX_KEYLOG") == "1" - m.recomputeSymbols() m.buildSectionFacets() m.recomputeSections() diff --git a/internal/ui/newviews_test.go b/internal/ui/newviews_test.go index fd9230d..7742ed2 100644 --- a/internal/ui/newviews_test.go +++ b/internal/ui/newviews_test.go @@ -56,10 +56,19 @@ func TestLibsRelocsMode(t *testing.T) { for _, r := range "JUMP" { h.press(string(r)) } + if h.m().relocFilter.Value() == "" { + t.Error("relocs filter did not capture typed text") + } + if h.m().libsFilter.Value() != "" { + t.Errorf("relocs filter leaked into libs filter: %q", h.m().libsFilter.Value()) + } if len(h.m().relocFiltered) > full { t.Errorf("filter grew the list: %d -> %d", full, len(h.m().relocFiltered)) } h.press("esc") // clear filter + if h.m().relocFilter.Value() != "" { + t.Errorf("esc did not clear relocs filter: %q", h.m().relocFilter.Value()) + } // s cycles the sort field; r reverses it. srt0 := h.m().relocSort diff --git a/internal/ui/render_bench_test.go b/internal/ui/render_bench_test.go index dcddf36..3f36377 100644 --- a/internal/ui/render_bench_test.go +++ b/internal/ui/render_bench_test.go @@ -46,3 +46,4 @@ func benchView(b *testing.B, md mode) { func BenchmarkViewDisasm(b *testing.B) { benchView(b, modeDisasm) } func BenchmarkViewHex(b *testing.B) { benchView(b, modeHex) } func BenchmarkViewSymbols(b *testing.B) { benchView(b, modeSymbols) } +func BenchmarkViewRelocs(b *testing.B) { benchView(b, modeRelocs) } diff --git a/internal/ui/search.go b/internal/ui/search.go index 703cf3f..5a0e1ac 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -159,9 +159,17 @@ func (m *Model) cancelSearch(status string) { m.searchSeq++ m.searchRunning = false m.searchCancelable = false + m.stopDisasmSearch() m.setStatus(status, false) } +func (m *Model) stopDisasmSearch() { + if m.searchCancel != nil { + close(m.searchCancel) + m.searchCancel = nil + } +} + func (m *Model) searchBytesAt(data byteSource, cur int, forward, inclusive bool) int { pat := searchutil.ParsePattern(m.searchQuery, m.searchMode) if len(pat) == 0 { diff --git a/internal/ui/search_disasm.go b/internal/ui/search_disasm.go index 94e37d3..21f2aaf 100644 --- a/internal/ui/search_disasm.go +++ b/internal/ui/search_disasm.go @@ -27,6 +27,7 @@ type disasmSearchHit struct { } type disasmSearchStep struct { + file *binfile.File seq int label string query string @@ -36,9 +37,11 @@ type disasmSearchStep struct { total int chunk int base int + cancel <-chan struct{} } type disasmSearchProgressMsg struct { + file *binfile.File seq int forward bool next disasmSearchStep @@ -216,9 +219,13 @@ func (m *Model) startDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { return nil } m.searchSeq++ + m.stopDisasmSearch() m.searchRunning = true m.searchCancelable = true + done := make(chan struct{}) + m.searchCancel = done step := disasmSearchStep{ + file: m.file, seq: m.searchSeq, label: m.searchQuery, query: canonicalSearchQuery(m.searchQuery), @@ -227,6 +234,7 @@ func (m *Model) startDisasmSearch(forward, inclusive, fromCursor bool) tea.Cmd { total: img.Len(), chunk: m.disasmSearchChunkBytes(), base: pos, + cancel: done, } if !fromCursor { if forward { @@ -356,12 +364,24 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { file := m.file svc := m.disasmService() query := step.query + queryASCII := true + for i := 0; i < len(query); i++ { + if query[i] >= 0x80 { + queryASCII = false + break + } + } + matchText := func(s string) bool { + if queryASCII { + return containsFold(s, query) + } + return strings.Contains(strings.ToLower(s), query) + } match := func(instText string, addr uint64) bool { - if strings.Contains(strings.ToLower(instText), query) { + if matchText(instText) { return true } - if sym, ok := file.SymbolAt(addr); ok && sym.Addr == addr && - strings.Contains(strings.ToLower(sym.Display()), query) { + if sym, ok := file.SymbolAt(addr); ok && sym.Addr == addr && matchText(sym.Display()) { return true } return false @@ -373,13 +393,16 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { hits []disasmSearchHit } return func() tea.Msg { + if scanCancelled(step.cancel) { + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, done: true} + } batch := svc.SearchBatchChunks() if batch < 1 { batch = 1 } if step.forward { if step.logical >= img.Len() { - return disasmSearchProgressMsg{seq: step.seq, forward: step.forward, next: step, scannedLo: step.logical, scannedHi: step.logical, done: true} + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, next: step, scannedLo: step.logical, scannedHi: step.logical, done: true} } var wins []binfile.Window logical := step.logical @@ -393,11 +416,17 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { sem := make(chan struct{}, limit) var wg sync.WaitGroup for i, win := range wins { + if scanCancelled(step.cancel) { + break + } wg.Add(1) sem <- struct{}{} go func(i int, win binfile.Window) { defer wg.Done() defer func() { <-sem }() + if scanCancelled(step.cancel) { + return + } insts := svc.DecodeWindow(win) results[i] = chunkResult{order: i, win: win, insts: insts} startPos := step.logical @@ -405,6 +434,9 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { startPos = win.Start } for j, inst := range insts { + if scanCancelled(step.cancel) { + return + } instPos, ok := img.PosForAddr(inst.Addr) if !ok || instPos < startPos { continue @@ -423,14 +455,14 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { } } if len(found) > 0 { - return disasmSearchProgressMsg{seq: step.seq, forward: step.forward, hit: &found[0], found: found, scannedLo: step.logical, scannedHi: logical, done: true} + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, hit: &found[0], found: found, scannedLo: step.logical, scannedHi: logical, done: true} } next := step next.logical = logical - return disasmSearchProgressMsg{seq: step.seq, forward: step.forward, next: next, scannedLo: step.logical, scannedHi: logical, status: fmt.Sprintf("searching disasm for %q (%d%%, Esc cancels)", step.label, 100*min(next.logical, next.total)/max(1, next.total))} + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, next: next, scannedLo: step.logical, scannedHi: logical, status: fmt.Sprintf("searching disasm for %q (%d%%, Esc cancels)", step.label, 100*min(next.logical, next.total)/max(1, next.total))} } if step.logical <= 0 { - return disasmSearchProgressMsg{seq: step.seq, forward: step.forward, next: step, scannedLo: step.logical, scannedHi: step.logical, done: true} + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, next: step, scannedLo: step.logical, scannedHi: step.logical, done: true} } var wins []binfile.Window logical := step.logical @@ -445,11 +477,17 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { sem := make(chan struct{}, limit) var wg sync.WaitGroup for i, win := range wins { + if scanCancelled(step.cancel) { + break + } wg.Add(1) sem <- struct{}{} go func(i int, win binfile.Window) { defer wg.Done() defer func() { <-sem }() + if scanCancelled(step.cancel) { + return + } insts := svc.DecodeWindow(win) results[i] = chunkResult{order: i, win: win, insts: insts} endPos := step.logical @@ -457,6 +495,9 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { endPos = win.End } for j := len(insts) - 1; j >= 0; j-- { + if scanCancelled(step.cancel) { + return + } inst := insts[j] instPos, ok := img.PosForAddr(inst.Addr) if !ok || instPos >= endPos { @@ -476,11 +517,11 @@ func (m *Model) searchDisasmStepCmd(step disasmSearchStep) tea.Cmd { } } if len(found) > 0 { - return disasmSearchProgressMsg{seq: step.seq, forward: step.forward, hit: &found[0], found: found, scannedLo: logical, scannedHi: step.logical, done: true} + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, hit: &found[0], found: found, scannedLo: logical, scannedHi: step.logical, done: true} } next := step next.logical = logical progress := 100 * (step.total - max(0, next.logical)) / max(1, step.total) - return disasmSearchProgressMsg{seq: step.seq, forward: step.forward, next: next, scannedLo: logical, scannedHi: step.logical, status: fmt.Sprintf("searching disasm for %q (%d%%, Esc cancels)", step.label, progress)} + return disasmSearchProgressMsg{file: step.file, seq: step.seq, forward: step.forward, next: next, scannedLo: logical, scannedHi: step.logical, status: fmt.Sprintf("searching disasm for %q (%d%%, Esc cancels)", step.label, progress)} } } diff --git a/internal/ui/syscalls.go b/internal/ui/syscalls.go index 14ba007..aec4ae8 100644 --- a/internal/ui/syscalls.go +++ b/internal/ui/syscalls.go @@ -21,6 +21,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/rabarbra/exex/internal/binfile" "github.com/rabarbra/exex/internal/dump" ) @@ -76,7 +77,9 @@ type syscallState struct { syscallActive bool // results modal open syscallRunning bool // background scan in flight syscallSeq int // guards against stale async results + syscallCancel chan struct{} syscallResults []dump.SyscallSite + syscallCached map[bool][]dump.SyscallSite // key: file.DisasmAll() syscallScope syscallScope syscallShown []syscallRow // rows for the active scope, rebuilt on scan/scope/sort/filter change syscallSel int @@ -86,11 +89,11 @@ type syscallState struct { syscallSort syscallSortKey syscallSortDesc bool syscallFilter textinput.Model - syscallFiltering bool // filter input focused (typing edits it) - syscallTotal int // rows in the active scope before the text filter - syscallFnLo uint64 // function-under-cursor range, to mark/pre-select its sites - syscallFnHi uint64 - syscallFnName string + syscallFiltering bool // filter input focused (typing edits it) + syscallTotal int // rows in the active scope before the text filter + syscallFnLo uint64 // function-under-cursor range, to mark/pre-select its sites + syscallFnHi uint64 + syscallFnName string // Full scope (binary + linked libraries), scanned lazily off-thread. syscallFull []dump.SyscallSite @@ -99,6 +102,7 @@ type syscallState struct { syscallFullDone bool syscallFullRunning bool syscallFullSeq int + syscallFullCancel chan struct{} } // syscallRow is one displayed line: a representative site and, in unique scope, @@ -345,6 +349,7 @@ func (m *Model) syscallLegend() string { // syscallDoneMsg delivers a finished syscall scan. type syscallDoneMsg struct { + file *binfile.File seq int sites []dump.SyscallSite } @@ -362,15 +367,29 @@ func (m *Model) startSyscallScan() tea.Cmd { m.syscallFnLo, m.syscallFnHi = sym.Addr, sym.Addr+sym.Size m.syscallFnName = sym.Display() } + m.stopSyscallScan() m.syscallSeq++ + m.syscallRunning = false + all := m.file.DisasmAll() + if sites, ok := m.syscallCached[all]; ok { + if len(sites) == 0 { + m.setStatus("no syscalls found (cached)", true) + return nil + } + m.openSyscallResults(sites) + m.setSyscallStatus(sites) + return nil + } m.syscallRunning = true + done := make(chan struct{}) + m.syscallCancel = done m.setStatus("scanning for syscalls … (Esc cancels)", false) - return m.syscallScanCmd(m.syscallSeq) + return m.syscallScanCmd(m.syscallSeq, done) } // syscallScanCmd decodes the executable image in parallel chunks (reusing the // decode cache) off the UI goroutine and collects syscall sites. -func (m *Model) syscallScanCmd(seq int) tea.Cmd { +func (m *Model) syscallScanCmd(seq int, done <-chan struct{}) tea.Cmd { svc := m.disasmService() img := m.file.ExecImage() file := m.file @@ -397,14 +416,23 @@ func (m *Model) syscallScanCmd(seq int) tea.Cmd { sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, start := range starts { + if scanCancelled(done) { + break + } wg.Add(1) sem <- struct{}{} go func(i, start int) { defer wg.Done() defer func() { <-sem }() var hits []dump.SyscallSite + if scanCancelled(done) { + return + } decoded := svc.DecodeRange(start, chunk, syscallLead) for p, inst := range decoded { + if scanCancelled(done) { + return + } ok, vdso := dump.ClassifySyscallSite(inst, symAt) if !ok { continue @@ -461,29 +489,40 @@ func (m *Model) syscallScanCmd(seq int) tea.Cmd { } } } - return syscallDoneMsg{seq: seq, sites: sites} + return syscallDoneMsg{file: file, seq: seq, sites: sites} } } // handleSyscallDone stores a finished scan and opens the modal (or reports none), // landing the selection on the first site inside the function under the cursor. func (m *Model) handleSyscallDone(msg syscallDoneMsg) (tea.Model, tea.Cmd) { - if !m.syscallRunning || msg.seq != m.syscallSeq { + if msg.file != m.file || !m.syscallRunning || msg.seq != m.syscallSeq { return m, nil // cancelled or superseded } m.syscallRunning = false + m.syscallCancel = nil + if m.syscallCached == nil { + m.syscallCached = map[bool][]dump.SyscallSite{} + } + m.syscallCached[m.file.DisasmAll()] = msg.sites if len(msg.sites) == 0 { m.setStatus("no syscalls found", true) return m, nil } - m.syscallResults = msg.sites + m.openSyscallResults(msg.sites) + m.setSyscallStatus(msg.sites) + return m, nil +} + +func (m *Model) openSyscallResults(sites []dump.SyscallSite) { + m.syscallResults = sites m.syscallSel = 0 m.syscallTop = 0 m.syscallFilter.SetValue("") // a fresh scan starts unfiltered m.syscallFilter.Blur() m.syscallFiltering = false inFn := 0 - for _, s := range msg.sites { + for _, s := range sites { if m.inFunc(s.Addr) { inFn++ } @@ -497,27 +536,45 @@ func (m *Model) handleSyscallDone(msg syscallDoneMsg) (tea.Model, tea.Cmd) { } m.rebuildSyscallRows() m.syscallActive = true +} + +func (m *Model) setSyscallStatus(sites []dump.SyscallSite) { capped := "" - if len(msg.sites) >= syscallMaxHits { + if len(sites) >= syscallMaxHits { capped = "+" } + inFn := 0 + for _, s := range sites { + if m.inFunc(s.Addr) { + inFn++ + } + } if inFn > 0 && m.syscallFnName != "" { - m.setStatus(fmt.Sprintf("%d%s syscalls · %d in %s (t: scope)", len(msg.sites), capped, inFn, m.syscallFnName), false) + m.setStatus(fmt.Sprintf("%d%s syscalls · %d in %s (t: scope)", len(sites), capped, inFn, m.syscallFnName), false) } else { - m.setStatus(fmt.Sprintf("%d%s syscalls (t: scope)", len(msg.sites), capped), false) + m.setStatus(fmt.Sprintf("%d%s syscalls (t: scope)", len(sites), capped), false) } - return m, nil } // cancelSyscall abandons an in-flight scan (its result is ignored by seq). func (m *Model) cancelSyscall() { m.syscallSeq++ m.syscallRunning = false + m.stopSyscallScan() + m.cancelSyscallFullScan() m.setStatus("syscall scan cancelled", false) } +func (m *Model) stopSyscallScan() { + if m.syscallCancel != nil { + close(m.syscallCancel) + m.syscallCancel = nil + } +} + // syscallFullDoneMsg delivers a finished full (binary + libs) scan. type syscallFullDoneMsg struct { + file *binfile.File seq int sites []dump.SyscallSite objs int @@ -528,23 +585,42 @@ type syscallFullDoneMsg struct { // goroutine (opening and decoding each library is I/O- and CPU-heavy, so it must // not block rendering). The result feeds the modal's full scope. func (m *Model) startSyscallFullScan() tea.Cmd { + m.stopSyscallFullScan() m.syscallFullSeq++ m.syscallFullRunning = true seq := m.syscallFullSeq file := m.file + done := make(chan struct{}) + m.syscallFullCancel = done return func() tea.Msg { - sites, objs, notes := dump.CollectSyscallsFull(file) - return syscallFullDoneMsg{seq: seq, sites: sites, objs: objs, notes: notes} + sites, objs, notes := dump.CollectSyscallsFullCancel(file, done) + return syscallFullDoneMsg{file: file, seq: seq, sites: sites, objs: objs, notes: notes} + } +} + +func (m *Model) stopSyscallFullScan() { + if m.syscallFullCancel != nil { + close(m.syscallFullCancel) + m.syscallFullCancel = nil + } +} + +func (m *Model) cancelSyscallFullScan() { + if m.syscallFullRunning || m.syscallFullCancel != nil { + m.syscallFullSeq++ + m.syscallFullRunning = false + m.stopSyscallFullScan() } } // handleSyscallFullDone stores a finished full scan and refreshes the rows if the // modal is still in full scope. func (m *Model) handleSyscallFullDone(msg syscallFullDoneMsg) (tea.Model, tea.Cmd) { - if msg.seq != m.syscallFullSeq { + if msg.file != m.file || !m.syscallFullRunning || msg.seq != m.syscallFullSeq { return m, nil // superseded } m.syscallFullRunning = false + m.syscallFullCancel = nil m.syscallFullDone = true m.syscallFull = msg.sites m.syscallFullObjs = msg.objs @@ -601,7 +677,11 @@ func (m *Model) updateSyscallModal(msg tea.KeyMsg, key string) (tea.Model, tea.C m.syscallFiltering = true return m, m.syscallFilter.Focus() case "t": + oldScope := m.syscallScope m.syscallScope = (m.syscallScope + 1) % sysScopeCount + if oldScope == sysScopeFull && m.syscallScope != sysScopeFull { + m.cancelSyscallFullScan() + } m.syscallSel, m.syscallTop = 0, 0 m.rebuildSyscallRows() m.setStatus("syscalls: "+m.scopeLabel(), false) @@ -645,6 +725,7 @@ func (m *Model) syscallJump() (tea.Model, tea.Cmd) { return m, nil } m.syscallActive = false + m.cancelSyscallFullScan() m.loadDisasmAt(site.Addr) return m, nil } diff --git a/internal/ui/update.go b/internal/ui/update.go index 9f2ccc0..2a3eeb4 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/atotto/clipboard" + "github.com/rabarbra/exex/internal/binfile" ) func (m *Model) Init() tea.Cmd { @@ -22,19 +23,20 @@ func (m *Model) Init() tea.Cmd { if m.disasmDecoding && !m.disasmBuilt && m.dis != nil { cmds = append(cmds, m.decodeDisasmCmd(m.disasmPendingAddr)) } - // Pre-warm the deferred work (disasm decode, DWARF/line tables) right after the - // first frame so opening those views is instant. The cmd returns immediately, - // so its prewarmMsg is processed once the initial render is on screen. + // Pre-warm the initial disasm window right after the first frame. This keeps + // startup responsive while making the view ready for the common next action. cmds = append(cmds, func() tea.Msg { return prewarmMsg{} }) return tea.Batch(cmds...) } -// prewarmMsg fires just after the first render to kick the deferred background -// work (so it's ready before the user navigates to it). +// prewarmMsg fires just after the first render to kick the deferred disasm decode +// in the background, so opening the Disasm view is usually instant without +// blocking the initial screen. type prewarmMsg struct{} -// handlePrewarm starts the deferred disasm decode and DWARF/line-table build in -// the background, unless already done/in-flight (e.g. the default view is disasm). +// handlePrewarm starts the deferred disasm decode in the background, unless +// already done/in-flight (e.g. the default view is disasm). DWARF/source parsing +// remains on-demand because it is large and only source-aware views need it. func (m *Model) handlePrewarm() (tea.Model, tea.Cmd) { var cmds []tea.Cmd if m.dis != nil && !m.disasmBuilt && !m.disasmDecoding { @@ -42,10 +44,6 @@ func (m *Model) handlePrewarm() (tea.Model, tea.Cmd) { m.disasmPendingAddr = m.disasmInitAddr cmds = append(cmds, m.decodeDisasmCmd(m.disasmInitAddr)) } - if m.file.HasDWARF() { - f := m.file - cmds = append(cmds, func() tea.Msg { f.WarmDebugInfo(); return nil }) - } return m, tea.Batch(cmds...) } @@ -100,6 +98,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case demangleDoneMsg: + if msg.file != m.file { + return m, nil + } // Background demangle finished: keep the computed names so the setting can // be toggled later without recomputing, and apply them unless the user has // turned demangling off. @@ -175,7 +176,7 @@ func (m *Model) resize(width, height int) { func (m *Model) handleDisasmReady(msg disasmReadyMsg) (tea.Model, tea.Cmd) { // Ignore late delivery if a synchronous jump already loaded a newer span. - if !m.disasmDecoding || msg.addr != m.disasmPendingAddr { + if (msg.file != nil && msg.file != m.file) || !m.disasmDecoding || msg.addr != m.disasmPendingAddr { return m, nil } m.disasmInst = msg.insts @@ -203,7 +204,7 @@ func (m *Model) handleDisasmReady(msg disasmReadyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleDisasmSearchProgress(msg disasmSearchProgressMsg) (tea.Model, tea.Cmd) { - if !m.searchRunning || msg.seq != m.searchSeq { + if msg.file != m.file || !m.searchRunning || msg.seq != m.searchSeq { return m, nil } m.cacheDisasmSearchHits(msg.found, msg.forward) @@ -211,6 +212,7 @@ func (m *Model) handleDisasmSearchProgress(msg disasmSearchProgressMsg) (tea.Mod if msg.done { m.searchRunning = false m.searchCancelable = false + m.searchCancel = nil if msg.hit != nil { m.setDisasmWindow(msg.hit.win, msg.hit.insts) m.disasmCur = msg.hit.idx @@ -238,7 +240,10 @@ func (m *Model) handleDisasmSearchProgress(msg disasmSearchProgressMsg) (tea.Mod } // demangleDoneMsg carries the result of the background symbol demangle. -type demangleDoneMsg struct{ names []string } +type demangleDoneMsg struct { + file *binfile.File + names []string +} // applyDemangledNames stores the demangled names onto the symbol table, then // invalidates the now-stale display order, tree and name-keyed caches. @@ -256,8 +261,36 @@ func (m *Model) invalidateSymbolNameState() { m.symbolsByDisplay = nil m.symbolsTreeInit = false m.symbolsCollapsed = nil - m.recomputeSymbols() + if m.symbolsReady { + m.recomputeSymbols() + } m.clearSymbolNameCaches() + m.refreshModalSymbolNames() +} + +func (m *Model) refreshModalSymbolNames() { + m.xrefCache = nil + if m.xrefActive { + m.xrefLabel = m.xrefLabelForTarget(m.xrefTarget) + for i := range m.xrefResults { + m.xrefResults[i].sym = m.symbolDisplayAt(m.xrefResults[i].addr) + } + m.rebuildXrefRows() + } + m.syscallCached = nil + if m.syscallActive { + for i := range m.syscallResults { + m.syscallResults[i].Sym = m.symbolDisplayAt(m.syscallResults[i].Addr) + } + m.rebuildSyscallRows() + } +} + +func (m *Model) symbolDisplayAt(addr uint64) string { + if sym, ok := m.file.SymbolAt(addr); ok { + return sym.Display() + } + return "" } // toggleDemangle flips the demangle preference and applies it live: re-applying @@ -282,7 +315,7 @@ func (m *Model) toggleDemangle() { // shows up immediately (with raw names) instead of blocking on startup. func (m *Model) demangleCmd() tea.Cmd { f := m.file - return func() tea.Msg { return demangleDoneMsg{names: f.ComputeDemangled()} } + return func() tea.Msg { return demangleDoneMsg{file: f, names: f.ComputeDemangled()} } } // copyToClipboard puts text on the system clipboard and reports success or diff --git a/internal/ui/view_disasm.go b/internal/ui/view_disasm.go index 946b25c..22839ca 100644 --- a/internal/ui/view_disasm.go +++ b/internal/ui/view_disasm.go @@ -171,6 +171,7 @@ func (m *Model) toggleDisasmAll() { // byte image so a re-decode rebuilds them against the new one. Used when the // disasm image changes underfoot (disasm-all toggle). func (m *Model) resetDisasmImageState() { + m.invalidateDisasmDerivedJobs() m.disasmSvc = nil // rebuilt over the new ExecImage() m.disasmInst = nil m.disasmBuilt = false @@ -183,6 +184,25 @@ func (m *Model) resetDisasmImageState() { m.clearDisasmDisplayCaches() } +func (m *Model) invalidateDisasmDerivedJobs() { + if m.searchRunning || m.searchCancel != nil { + m.searchSeq++ + m.searchRunning = false + m.searchCancelable = false + m.stopDisasmSearch() + } + if m.xrefRunning || m.xrefCancel != nil { + m.xrefSeq++ + m.xrefRunning = false + m.stopXrefScan() + } + if m.syscallRunning || m.syscallCancel != nil { + m.syscallSeq++ + m.syscallRunning = false + m.stopSyscallScan() + } +} + // copyFunctionDisasm copies the disassembly of the function under the cursor to // the clipboard as plain "addr: bytes text" lines — the natural unit for bug // reports, diffs and pasting into an LLM. The range comes from the symbol extent. diff --git a/internal/ui/view_relocs.go b/internal/ui/view_relocs.go index bfd78ae..316654d 100644 --- a/internal/ui/view_relocs.go +++ b/internal/ui/view_relocs.go @@ -57,7 +57,7 @@ func (m *Model) cycleLibsMode() string { // section) and the text filter (matching symbol, type or section). func (m *Model) recomputeRelocs() { rels := m.file.Relocations() - needle := strings.ToLower(m.libsFilter.Value()) + needle := strings.ToLower(m.relocFilter.Value()) m.relocFiltered = m.relocFiltered[:0] for i := range rels { if m.relocTypeOn && rels[i].Type != m.relocType { @@ -178,12 +178,12 @@ func (m *Model) updateRelocs(key string) (tea.Model, tea.Cmd) { m.setStatus("reloc section filter: all", false) } case "/": - m.libsFilter.Focus() + m.relocFilter.Focus() case "esc": - dirty := m.libsFilter.Value() != "" || m.libsFilter.Focused() || m.relocTypeOn || m.relocSecOn + dirty := m.relocFilter.Value() != "" || m.relocFilter.Focused() || m.relocTypeOn || m.relocSecOn if dirty { - m.libsFilter.SetValue("") - m.libsFilter.Blur() + m.relocFilter.SetValue("") + m.relocFilter.Blur() m.relocTypeOn, m.relocSecOn = false, false m.relocCur, m.relocTop = 0, 0 m.recomputeRelocs() @@ -233,8 +233,8 @@ func (m *Model) renderRelocs() string { addrW := m.file.AddrHexWidth() var filterRow string - if m.libsFilter.Focused() { - filterRow = m.libsFilter.View() + if m.relocFilter.Focused() { + filterRow = m.relocFilter.View() } else { tf, sf := "all", "all" if m.relocTypeOn { @@ -245,7 +245,7 @@ func (m *Model) renderRelocs() string { } filterRow = m.theme.footerStyle.Render(fmt.Sprintf( "/ %s relocations (%d / %d) s: sort:%s %s type:%s %s sec:%s", - m.libsFilter.Value(), len(m.relocFiltered), len(rels), m.relocSort.String(), + m.relocFilter.Value(), len(m.relocFiltered), len(rels), m.relocSort.String(), ctrlKeys("t"), tf, ctrlKeys("s"), sf)) } desc := m.relocSortDesc diff --git a/internal/ui/view_symbols.go b/internal/ui/view_symbols.go index b7f4a6b..3dc0eed 100644 --- a/internal/ui/view_symbols.go +++ b/internal/ui/view_symbols.go @@ -361,6 +361,7 @@ func (sc symbolScope) includes(s binfile.Symbol) bool { // recomputeSymbols rebuilds the filtered set and the flattened visible rows from // the current filter, sort, and (in tree mode) collapse state. func (m *Model) recomputeSymbols() { + m.symbolsReady = true m.clearSymbolCaches() needle := strings.ToLower(m.symbolsFilter.Value()) // Entering a search auto-expands the tree so matches aren't hidden under @@ -377,7 +378,10 @@ func (m *Model) recomputeSymbols() { } m.symbolsFiltering = filtering } - lowerName, lowerDem := m.file.LowerNames() + var lowerName, lowerDem []string + if needle != "" { + lowerName, lowerDem = m.file.LowerNames() + } m.symbolsFiltered = m.symbolsFiltered[:0] scan := func(i int) { s := m.file.Symbols[i] @@ -393,8 +397,7 @@ func (m *Model) recomputeSymbols() { if m.symbolsLib != "" && s.Library != m.symbolsLib { return } - if needle == "" || - strings.Contains(lowerName[i], needle) || + if needle == "" || strings.Contains(lowerName[i], needle) || (lowerDem[i] != "" && strings.Contains(lowerDem[i], needle)) { m.symbolsFiltered = append(m.symbolsFiltered, i) } @@ -419,6 +422,12 @@ func (m *Model) recomputeSymbols() { } } +func (m *Model) ensureSymbols() { + if !m.symbolsReady { + m.recomputeSymbols() + } +} + // applySymbolSort orders symbolsFiltered by the active field, ascending by // default and reversed when symbolsSortDesc is set. Name order is already // established (ascending) by scanning in display order (see recomputeSymbols), so @@ -825,6 +834,7 @@ func (m *Model) openSymbol(sym binfile.Symbol) { } func (m *Model) renderSymbols() string { + m.ensureSymbols() bodyH := m.bodyHeight() if bodyH < 3 { bodyH = 3 diff --git a/internal/ui/xref.go b/internal/ui/xref.go index 719122e..b763696 100644 --- a/internal/ui/xref.go +++ b/internal/ui/xref.go @@ -21,6 +21,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/rabarbra/exex/internal/binfile" ) // xrefMaxHits caps how many references are collected (the modal scrolls). @@ -31,6 +32,18 @@ const xrefMaxHits = 500 // arm64/riscv instruction alignment. const xrefLead = 1 << 10 +func scanCancelled(done <-chan struct{}) bool { + if done == nil { + return false + } + select { + case <-done: + return true + default: + return false + } +} + // xrefHit is one referencing instruction. type xrefHit struct { addr uint64 // address of the instruction making the reference @@ -43,6 +56,7 @@ type xrefState struct { xrefActive bool // results modal open xrefRunning bool // background scan in flight xrefSeq int // guards against stale async results + xrefCancel chan struct{} xrefTarget uint64 xrefLabel string // display name of the target (symbol or 0x…) xrefResults []xrefHit @@ -56,6 +70,17 @@ type xrefState struct { xrefFilter textinput.Model xrefFiltering bool xrefTotal int // results before the text filter + xrefCache map[xrefCacheKey]xrefCacheEntry +} + +type xrefCacheKey struct { + target uint64 + all bool +} + +type xrefCacheEntry struct { + label string + hits []xrefHit } // xrefSortKey selects how the modal orders references. @@ -81,6 +106,7 @@ func (k xrefSortKey) String() string { // xrefDoneMsg delivers a finished cross-reference scan. type xrefDoneMsg struct { + file *binfile.File seq int target uint64 hits []xrefHit @@ -95,6 +121,32 @@ func (m *Model) startXrefScan() tea.Cmd { return nil } target := m.disasmInst[m.disasmCur].Addr + label := m.xrefLabelForTarget(target) + m.stopXrefScan() + m.xrefSeq++ + m.xrefRunning = false + key := xrefCacheKey{target: target, all: m.file.DisasmAll()} + if cached, ok := m.xrefCache[key]; ok { + m.xrefTarget = target + m.xrefLabel = cached.label + if len(cached.hits) == 0 { + m.setStatus("no references to "+cached.label+" (cached)", true) + return nil + } + m.openXrefResults(cached.hits) + m.setStatus(fmt.Sprintf("%d references to %s (cached)", len(cached.hits), cached.label), false) + return nil + } + m.xrefRunning = true + m.xrefTarget = target + m.xrefLabel = label + done := make(chan struct{}) + m.xrefCancel = done + m.setStatus("finding references to "+label+" … (Esc cancels)", false) + return m.xrefScanCmd(target, m.xrefSeq, done) +} + +func (m *Model) xrefLabelForTarget(target uint64) string { label := fmt.Sprintf("0x%x", target) if sym, ok := m.file.SymbolAt(target); ok { if off := target - sym.Addr; off == 0 { @@ -103,17 +155,12 @@ func (m *Model) startXrefScan() tea.Cmd { label = fmt.Sprintf("%s+0x%x", sym.Display(), off) } } - m.xrefSeq++ - m.xrefRunning = true - m.xrefTarget = target - m.xrefLabel = label - m.setStatus("finding references to "+label+" … (Esc cancels)", false) - return m.xrefScanCmd(target, m.xrefSeq) + return label } // xrefScanCmd decodes the whole executable image in chunks (reusing the decode // cache) off the UI goroutine and collects instructions that reference target. -func (m *Model) xrefScanCmd(target uint64, seq int) tea.Cmd { +func (m *Model) xrefScanCmd(target uint64, seq int, done <-chan struct{}) tea.Cmd { svc := m.disasmService() img := m.file.ExecImage() file := m.file @@ -143,13 +190,22 @@ func (m *Model) xrefScanCmd(target uint64, seq int) tea.Cmd { sem := make(chan struct{}, workers) var wg sync.WaitGroup for i, start := range starts { + if scanCancelled(done) { + break + } wg.Add(1) sem <- struct{}{} go func(i, start int) { defer wg.Done() defer func() { <-sem }() var hits []xrefHit + if scanCancelled(done) { + return + } for _, inst := range svc.DecodeRange(start, chunk, xrefLead) { + if scanCancelled(done) { + return + } if !instReferences(inst.Text, target) { continue } @@ -184,7 +240,7 @@ func (m *Model) xrefScanCmd(target uint64, seq int) tea.Cmd { if len(hits) > xrefMaxHits { hits = hits[:xrefMaxHits] } - return xrefDoneMsg{seq: seq, target: target, hits: hits} + return xrefDoneMsg{file: file, seq: seq, target: target, hits: hits} } } @@ -205,15 +261,31 @@ func instReferences(text string, target uint64) bool { // handleXrefDone stores a finished scan and opens the modal (or reports none). func (m *Model) handleXrefDone(msg xrefDoneMsg) (tea.Model, tea.Cmd) { - if !m.xrefRunning || msg.seq != m.xrefSeq { + if msg.file != m.file || !m.xrefRunning || msg.seq != m.xrefSeq { return m, nil // cancelled or superseded } m.xrefRunning = false + m.xrefCancel = nil + key := xrefCacheKey{target: msg.target, all: m.file.DisasmAll()} + if m.xrefCache == nil { + m.xrefCache = map[xrefCacheKey]xrefCacheEntry{} + } + m.xrefCache[key] = xrefCacheEntry{label: m.xrefLabel, hits: msg.hits} if len(msg.hits) == 0 { m.setStatus("no references to "+m.xrefLabel, true) return m, nil } - m.xrefResults = msg.hits + m.openXrefResults(msg.hits) + capped := "" + if len(msg.hits) >= xrefMaxHits { + capped = "+" + } + m.setStatus(fmt.Sprintf("%d%s references to %s", len(msg.hits), capped, m.xrefLabel), false) + return m, nil +} + +func (m *Model) openXrefResults(hits []xrefHit) { + m.xrefResults = hits m.xrefSel = 0 m.xrefTop = 0 m.ensureXrefFilter() @@ -222,21 +294,23 @@ func (m *Model) handleXrefDone(msg xrefDoneMsg) (tea.Model, tea.Cmd) { m.xrefFiltering = false m.rebuildXrefRows() m.xrefActive = true - capped := "" - if len(msg.hits) >= xrefMaxHits { - capped = "+" - } - m.setStatus(fmt.Sprintf("%d%s references to %s", len(msg.hits), capped, m.xrefLabel), false) - return m, nil } // cancelXref abandons an in-flight scan (its result is ignored by seq). func (m *Model) cancelXref() { m.xrefSeq++ m.xrefRunning = false + m.stopXrefScan() m.setStatus("xref search cancelled", false) } +func (m *Model) stopXrefScan() { + if m.xrefCancel != nil { + close(m.xrefCancel) + m.xrefCancel = nil + } +} + // ensureXrefFilter guarantees the filter input is fully constructed before it is // focused or rendered (so a model built without New() can't panic). func (m *Model) ensureXrefFilter() { diff --git a/main.go b/main.go index 94d985b..9bddf10 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,9 @@ func main() { if archName != "" { openOpts = append(openOpts, binfile.WithArch(archName)) } + if outputMode && dump.ViewNeedsLayoutOnly(outputView) { + openOpts = append(openOpts, binfile.WithLayoutOnly()) + } f, err := binfile.Open(path, openOpts...) if err != nil { // A static-library (ar) archive isn't a single object. `-o syscalls` scans diff --git a/tools/perfreport/main.go b/tools/perfreport/main.go index 848b750..bde91c3 100644 --- a/tools/perfreport/main.go +++ b/tools/perfreport/main.go @@ -47,51 +47,64 @@ func main() { } // Parse/startup: re-open each run so the timing covers a cold load, then keep - // the last file for the view measurements. - var f *binfile.File + // the last file only for retained-heap and warm interactive render measurements. + var loaded *binfile.File parse := measure(*runs, func() { + if loaded != nil { + loaded.Close() + } var e error - f, e = binfile.Open(path) + loaded, e = binfile.Open(path) if e != nil { fmt.Fprintf(os.Stderr, "perfreport: open %s: %v\n", path, e) os.Exit(1) } }) + defer loaded.Close() // Memory retained by a loaded binary — the interactive footprint floor. runtime.GC() var held runtime.MemStats runtime.ReadMemStats(&held) - // Demangling is the prep every buffered view shares (main.go runs it before - // dump.View), so account for it as its own stage rather than blaming a view. - demangle := measure(*runs, func() { f.ApplyDemangled(f.ComputeDemangled()) }) + // Demangling is measured on a fresh loaded file each run, excluding the open + // itself, so repeated ApplyDemangled calls do not warm/mutate one shared File. + demangle := measurePrepared(*runs, + func() any { return mustOpen(path) }, + func(v any) { v.(*binfile.File).ApplyDemangled(v.(*binfile.File).ComputeDemangled()) }, + func(v any) { v.(*binfile.File).Close() }, + ) type row struct { stage string stat stat } rows := []row{ - {"parse (startup)", parse}, - {"demangle", demangle}, + {"parse (cold open)", parse}, + {"demangle (fresh file)", demangle}, } for _, v := range nonDisasmViews { - rows = append(rows, row{"view: " + v, measure(*runs, func() { - if _, err := dump.View(f, v); err != nil { - fmt.Fprintf(os.Stderr, "perfreport: view %s: %v\n", v, err) - os.Exit(1) - } - })}) + view := v + rows = append(rows, row{"CLI view cold-cache: " + view, measurePrepared(*runs, + func() any { return mustOpenForView(path, view) }, + func(v any) { runView(v.(*binfile.File), view) }, + func(v any) { v.(*binfile.File).Close() }, + )}) } for _, d := range []struct { name string all bool }{{"disasm", false}, {"disasm-all", true}} { - rows = append(rows, row{"view: " + d.name, measure(*runs, func() { - if err := dump.DisasmTo(io.Discard, f, d.all); err != nil { - fmt.Fprintf(os.Stderr, "perfreport: %s: %v\n", d.name, err) - os.Exit(1) - } - })}) + dis := d + rows = append(rows, row{"CLI view cold-cache: " + dis.name, measurePrepared(*runs, + func() any { return mustOpen(path) }, + func(v any) { + if err := dump.DisasmTo(io.Discard, v.(*binfile.File), dis.all); err != nil { + fmt.Fprintf(os.Stderr, "perfreport: %s: %v\n", dis.name, err) + os.Exit(1) + } + }, + func(v any) { v.(*binfile.File).Close() }, + )}) } // TUI startup: building the model is the interactive launch cost (the event @@ -100,24 +113,28 @@ func main() { if err != nil { cfg = &config.Config{} } - tui := measure(*runs, func() { - if _, err := ui.New(f, ui.Options{Config: cfg}); err != nil { - fmt.Fprintf(os.Stderr, "perfreport: ui.New: %v\n", err) - os.Exit(1) - } - }) - rows = append(rows, row{"TUI startup (ui.New)", tui}) + tui := measurePrepared(*runs, + func() any { return mustOpen(path) }, + func(v any) { + if _, err := ui.New(v.(*binfile.File), ui.Options{Config: cfg}); err != nil { + fmt.Fprintf(os.Stderr, "perfreport: ui.New: %v\n", err) + os.Exit(1) + } + }, + func(v any) { v.(*binfile.File).Close() }, + ) + rows = append(rows, row{"TUI startup cold-model (ui.New)", tui}) // Per-view interactive render cost (a full 160×48 frame, decode completed). - for _, v := range ui.RenderViewStats(f, 160, 48, *runs) { - rows = append(rows, row{"TUI view: " + v.View, stat{dur: v.Dur, alloc: v.Alloc}}) + for _, v := range ui.RenderViewStats(loaded, 160, 48, *runs) { + rows = append(rows, row{"TUI view warm-cache: " + v.View, stat{dur: v.Dur, alloc: v.Alloc}}) } peak := peakRSS() var b strings.Builder fmt.Fprintf(&b, "### Performance (sample: %s, %s)\n\n", path, humanBytes(uint64(info.Size()))) - fmt.Fprintf(&b, "Best of %d runs. Alloc is bytes allocated to do the stage; retained-after-load heap is %s.\n\n", + fmt.Fprintf(&b, "Best of %d runs. CLI views use a fresh prepared File per run (open/demangle setup excluded); TUI views are warm-cache repeated renders. Alloc is bytes allocated to do the stage; retained-after-load heap is %s.\n\n", *runs, humanBytes(held.HeapAlloc)) b.WriteString("| stage | time | alloc |\n| --- | ---: | ---: |\n") for _, r := range rows { @@ -146,6 +163,9 @@ type stat struct { // scheduler/GC noise) and separately records the bytes it allocates on one clean // run. func measure(runs int, fn func()) stat { + if runs < 1 { + runs = 1 + } best := time.Duration(1<<63 - 1) for range runs { t := time.Now() @@ -162,6 +182,65 @@ func measure(runs int, fn func()) stat { return stat{dur: best, alloc: m1.TotalAlloc - m0.TotalAlloc} } +func measurePrepared(runs int, setup func() any, fn func(any), cleanup func(any)) stat { + if runs < 1 { + runs = 1 + } + best := time.Duration(1<<63 - 1) + for range runs { + v := setup() + t := time.Now() + fn(v) + if d := time.Since(t); d < best { + best = d + } + cleanup(v) + } + v := setup() + var m0, m1 runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&m0) + fn(v) + runtime.ReadMemStats(&m1) + cleanup(v) + return stat{dur: best, alloc: m1.TotalAlloc - m0.TotalAlloc} +} + +func mustOpen(path string, opts ...binfile.Option) *binfile.File { + f, err := binfile.Open(path, opts...) + if err != nil { + fmt.Fprintf(os.Stderr, "perfreport: open %s: %v\n", path, err) + os.Exit(1) + } + return f +} + +func mustOpenForView(path, view string) *binfile.File { + var opts []binfile.Option + if dump.ViewNeedsLayoutOnly(view) { + opts = append(opts, binfile.WithLayoutOnly()) + } + f := mustOpen(path, opts...) + if dump.ViewNeedsDemangle(view) { + f.ApplyDemangled(f.ComputeDemangled()) + } + return f +} + +func runView(f *binfile.File, view string) { + if streamed, err := dump.StreamView(io.Discard, f, view); streamed { + if err != nil { + fmt.Fprintf(os.Stderr, "perfreport: view %s: %v\n", view, err) + os.Exit(1) + } + return + } + if _, err := dump.View(f, view); err != nil { + fmt.Fprintf(os.Stderr, "perfreport: view %s: %v\n", view, err) + os.Exit(1) + } +} + // peakRSS returns the process's peak resident set size. On Linux it reads VmHWM // from /proc (true peak RSS); elsewhere it falls back to the Go runtime's Sys // estimate, which is a ceiling rather than a measured peak.