From 8f991b7c8aa87fefcf64462f15d147259e39a523 Mon Sep 17 00:00:00 2001 From: Dylan Reimerink Date: Thu, 21 May 2026 17:08:11 +0200 Subject: [PATCH] Add `lifetimes` command to analyze stack variable lifetimes This works adds a new command which analyzes the lifetimes of stack variables. It outputs an SVG graph which show each stack offset on the vertical axis and the instructions on the horizontal axis. Colored bars represent a lifetime, where a lifetime is the time between the first write and the last read of a stack variable. The writes are marked by black dots and the reads are marked by white dots. The idea of this is that we should be able to see which lifetimes overlap and thus why more space was allocated. In theory showing us the places in the program where the least stack re-use was possible. Focusing on reducing stack usage at that point the the program should yield best results when reducing stack usage. The code itself works by doing static analysis of the assembly code to get basic blocks. Then visiting all basic blocks and walking the instructions, keeping track of register and stack state. Sort of like a very limited shitty verifier. We record all reads and writes to the stack, including those done by helper functions. Once we have all reads and writes, we iterate over all the reads and traverse the graph of basic blocks backwards, finding writes to the same stack offset. When we find them, we record a lifetime spanning the stack of basic blocks we traversed backwards. Overlapping lifetimes get merged since these are likely modifications of the same variable or variable reuse. Each lifetime gets its own color to allow users to see when a stack offset gets reused. Signed-off-by: Dylan Reimerink --- .gitignore | 1 + cmd/stackwhere/lifetimes.go | 1425 ++++++++++++++++++++++++++++++ cmd/stackwhere/lifetimes_test.go | 172 ++++ cmd/stackwhere/main.go | 4 + go.mod | 3 + go.sum | 7 + internal/analyze/blocks.go | 628 +++++++++++++ internal/analyze/blocks_test.go | 185 ++++ internal/analyze/util.go | 196 ++++ 9 files changed, 2621 insertions(+) create mode 100644 cmd/stackwhere/lifetimes.go create mode 100644 cmd/stackwhere/lifetimes_test.go create mode 100644 internal/analyze/blocks.go create mode 100644 internal/analyze/blocks_test.go create mode 100644 internal/analyze/util.go diff --git a/.gitignore b/.gitignore index 69dcb52..b7a3d2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ # Added by goreleaser init: dist/ +.vscode diff --git a/cmd/stackwhere/lifetimes.go b/cmd/stackwhere/lifetimes.go new file mode 100644 index 0000000..6831cef --- /dev/null +++ b/cmd/stackwhere/lifetimes.go @@ -0,0 +1,1425 @@ +package main + +import ( + "cmp" + "fmt" + "io" + "maps" + "os" + "slices" + "strings" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + "github.com/cilium/ebpf/btf" + "github.com/cilium/stackwhere/internal/analyze" + "github.com/spf13/cobra" +) + +func lifetimesCommand() *cobra.Command { + c := &cobra.Command{ + Use: "lifetimes {collection} {program} {svg output}", + Aliases: []string{"lt"}, + Short: "Prints stack-slot lifetimes for a program.", + Long: "Prints stack-slot lifetimes for a program.", + Example: "stackwhere lifetimes /path/to/collection.o my_program output.svg", + Args: cobra.ExactArgs(3), + } + + lc := lifetimesCmd{} + + flags := c.Flags() + lc.debug = flags.Bool("debug", false, "Print debugging information") + lc.dumpInstructions = flags.Bool("dump-instructions", false, "Dump instructions") + lc.dumpLifetimes = flags.Bool("dump-lifetimes", false, "Dump lifetimes") + lc.drawBBB = flags.Bool("draw-bbb", false, "Draw basic block boundaries in the output") + + c.RunE = lc.run + return c +} + +type Lifetime struct { + Intervals []LifetimeInterval +} + +func (lt *Lifetime) Add(it LifetimeInterval) { + idx, found := slices.BinarySearchFunc(lt.Intervals, it, func(a, b LifetimeInterval) int { + return cmp.Compare(a.Start, b.Start) + }) + if found { + existing := lt.Intervals[idx] + if existing.End != it.End { + lt.Intervals[idx].End = it.End + } + return + } + + lt.Intervals = slices.Insert(lt.Intervals, idx, it) +} + +func (lt Lifetime) String() string { + var sb strings.Builder + for i, it := range lt.Intervals { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(it.String()) + } + + return sb.String() +} + +type LifetimeInterval struct { + Start asm.RawInstructionOffset + End asm.RawInstructionOffset + BlockStart bool +} + +func (it LifetimeInterval) String() string { + var prefix string + if it.BlockStart { + prefix = "S" + } + return fmt.Sprintf("[%s%d; %d]", prefix, it.Start, it.End) +} + +func LTInterval(start, end asm.RawInstructionOffset, blockStart bool) LifetimeInterval { + return LifetimeInterval{Start: start, End: end, BlockStart: blockStart} +} + +type state struct { + regs [11]regState + stack map[int16]stackState + reachableWrites []rw +} + +func (s state) copy() state { + var copy state + copy.regs = s.regs + copy.stack = maps.Clone(s.stack) + copy.reachableWrites = slices.Clone(s.reachableWrites) + return copy +} + +type regState struct { + mapPtr *ebpf.MapSpec + + hasScalar bool + scalar int64 + + fpPtr bool + fpSetOff asm.RawInstructionOffset + fpOff int16 +} + +type stackState struct { + spilled regState +} + +type StackLifetime struct { + offset int16 + lifetime *Lifetime +} + +type lifetimesCmd struct { + // Dump visitor state, reachable writes, reachable reads, and discovered lifetimes + debug *bool + dumpInstructions *bool + dumpLifetimes *bool + drawBBB *bool +} + +func (lc *lifetimesCmd) run(cmd *cobra.Command, args []string) error { + dbgWriter := cmd.OutOrStdout() + + spec, err := ebpf.LoadCollectionSpec(args[0]) + if err != nil { + return fmt.Errorf("load ebpf collection: %w", err) + } + + fns := make(map[string]bpfFn) + for _, prog := range spec.Programs { + var cur bpfFn + iter := prog.Instructions.Iterate() + for iter.Next() { + if fn := btf.FuncMetadata(iter.Ins); fn != nil { + if cur.fn != nil { + fns[cur.fn.Name] = cur + } + + cur.fn = fn + cur.insns = asm.Instructions{} + } + + cur.insns = append(cur.insns, *iter.Ins) + } + + if cur.fn != nil { + fns[cur.fn.Name] = cur + } + } + + fn := fns[args[1]] + if fn.fn == nil { + return fmt.Errorf("program %s not found in collection", args[1]) + } + + blocks, err := analyze.MakeBlocks(fn.insns) + if err != nil { + return fmt.Errorf("make blocks: %w", err) + } + + if *lc.dumpInstructions || *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "=== Program instructions ===") + _, _ = fmt.Fprint(dbgWriter, blocks.Dump(fn.insns)) + } + + visitor := &visitor{ + dbgWriter: io.Discard, + spec: spec, + insns: fn.insns, + reachableWrites: make(map[*analyze.Block][]rw), + inStates: make(map[*analyze.Block]state), + outStates: make(map[*analyze.Block]state), + seenReads: make(map[rw]struct{}), + seenWrites: make(map[rw]struct{}), + } + if *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "=== Visitor state ===") + visitor.dbgWriter = dbgWriter + } + visitor.visit(blocks[0], state{stack: make(map[int16]stackState)}) + + if visitor.inaccurate { + _, _ = fmt.Fprintln(dbgWriter, "WARN: analysis may be inaccurate due to unhandled instructions or unknown helper functions, see debug log for details") + } + + if *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "\n=== Reachable writes per block ===") + for _, block := range blocks { + _, _ = fmt.Fprintf(dbgWriter, "%d:\n", block.ID) + for _, write := range visitor.reachableWrites[block] { + _, _ = fmt.Fprintf(dbgWriter, "\tBlock: %d, Offset: %d, RawIns: %d\n", write.Block.ID, write.Offset, write.RawIns) + } + } + + _, _ = fmt.Fprintln(dbgWriter, "\n=== Reads in program ===") + for _, read := range visitor.reads { + _, _ = fmt.Fprintf(dbgWriter, "\tBlock: %d, Offset: %d, RawIns: %d\n", read.Block.ID, read.Offset, read.RawIns) + } + } + + var results []StackLifetime + + if *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "\n=== Lifetime discovery ===") + } + for _, read := range visitor.reads { + if *lc.debug { + _, _ = fmt.Fprintf(dbgWriter, "Finding lifetimes for %d at %d\n", read.Offset, read.RawIns) + } + lt := &Lifetime{} + + visited := make(map[*analyze.Block]bool) + var visit func(block *analyze.Block, stack []*analyze.Block) + visit = func(block *analyze.Block, stack []*analyze.Block) { + if visited[block] { + return + } + visited[block] = true + + var reachableWritesForOffset []rw + for _, write := range visitor.reachableWrites[block] { + if write.Offset == read.Offset { + reachableWritesForOffset = append(reachableWritesForOffset, write) + } + } + + // No more reachable writes for this offset, no point in visiting further predecessors + if len(reachableWritesForOffset) == 0 { + return + } + + var writesInCur []rw + for _, write := range reachableWritesForOffset { + if write.Block == block { + writesInCur = append(writesInCur, write) + } + } + slices.SortFunc(writesInCur, func(a, b rw) int { + return cmp.Compare(a.RawIns, b.RawIns) + }) + + // If there is a write in the current block + if len(writesInCur) > 0 { + // if the current block is the same block as the read, add a single interval from the write to the read + if block == read.Block { + for _, write := range slices.Backward(writesInCur) { + if write.RawIns < read.RawIns { + lt.Add(LTInterval(write.RawIns, read.RawIns, false)) + + // No need to explore further, we have found the last write before the read + return + } + } + } else { + lastWrite := writesInCur[len(writesInCur)-1] + // From last write to end of block + lt.Add(LTInterval(lastWrite.RawIns, insRawOff(block, fn.insns, block.End), false)) + // From start of block to end of block for all blocks in the stack + for _, b := range slices.Backward(stack) { + lt.Add(LTInterval(b.Raw, insRawOff(b, fn.insns, b.End), true)) + } + // From start of block to read in the read block + lt.Add(LTInterval(read.Block.Raw, read.RawIns, true)) + + // No need to explore further, we have found the last write before the read + return + } + } + + for _, pred := range block.Predecessors { + visit(pred, append(stack, block)) + } + } + visit(read.Block, nil) + + if len(lt.Intervals) > 0 { + results = append(results, StackLifetime{ + offset: read.Offset, + lifetime: lt, + }) + + if *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, lt) + } + } + } + + slices.SortFunc(results, func(a, b StackLifetime) int { + if a.offset == b.offset { + return cmp.Compare(a.lifetime.Intervals[0].Start, b.lifetime.Intervals[0].Start) + } + + return cmp.Compare(a.offset, b.offset) + }) + + for i := 0; i < len(results); i++ { + for j := i + 1; j < len(results); j++ { + if results[i].offset != results[j].offset { + break + } + + overlaps := false + for _, it1 := range results[i].lifetime.Intervals { + for _, it2 := range results[j].lifetime.Intervals { + if it1.Start <= it2.End && it2.Start <= it1.End { + overlaps = true + break + } + } + } + if overlaps { + for _, it2 := range results[j].lifetime.Intervals { + results[i].lifetime.Add(it2) + } + results = slices.Delete(results, j, j+1) + j-- + } + } + } + + for _, slt := range results { + for i := len(slt.lifetime.Intervals) - 1; i > 0; i-- { + merge := slt.lifetime.Intervals[i-1].End == slt.lifetime.Intervals[i].Start + if slt.lifetime.Intervals[i-1].End == slt.lifetime.Intervals[i].Start-1 && slt.lifetime.Intervals[i].BlockStart { + merge = true + } + if merge { + slt.lifetime.Intervals[i-1].End = slt.lifetime.Intervals[i].End + slt.lifetime.Intervals = slices.Delete(slt.lifetime.Intervals, i, i+1) + } + } + if *lc.debug { + _, _ = fmt.Fprintf(dbgWriter, "%d : %v\n", slt.offset, slt.lifetime) + } + } + + if err := os.WriteFile(args[2], []byte(graphLifetimes(results, blocks, fn.insns, visitor.writes, visitor.reads, *lc.drawBBB)), 0644); err != nil { + return fmt.Errorf("write output file: %w", err) + } + + slices.SortFunc(results, func(sl1, sl2 StackLifetime) int { + sl1LifetimeLen := 0 + for _, it := range sl1.lifetime.Intervals { + sl1LifetimeLen += int(it.End - it.Start) + } + + sl2LifetimeLen := 0 + for _, it := range sl2.lifetime.Intervals { + sl2LifetimeLen += int(it.End - it.Start) + } + return cmp.Compare(sl2LifetimeLen, sl1LifetimeLen) + }) + + if *lc.dumpLifetimes || *lc.debug { + _, _ = fmt.Fprintln(dbgWriter, "\n=== Lifetimes ===") + lenPerOffset := make(map[int16]struct { + offset int16 + lifetimeLen int + }) + for _, slt := range results { + lifetimeLen := 0 + for _, it := range slt.lifetime.Intervals { + lifetimeLen += int(it.End - it.Start) + } + offLen := lenPerOffset[slt.offset] + offLen.offset = slt.offset + offLen.lifetimeLen += lifetimeLen + lenPerOffset[slt.offset] = offLen + _, _ = fmt.Fprintf(dbgWriter, "%d: %d %s\n", slt.offset, lifetimeLen, slt.lifetime) + } + + _, _ = fmt.Fprintln(dbgWriter, "\n=== Least packed offsets ===") + for _, lifeLen := range slices.SortedFunc(maps.Values(lenPerOffset), func(a, b struct { + offset int16 + lifetimeLen int + }) int { + return cmp.Compare(a.lifetimeLen, b.lifetimeLen) + }) { + _, _ = fmt.Fprintf(dbgWriter, "%d: %d\n", lifeLen.offset, lifeLen.lifetimeLen) + } + } + + return nil +} + +func insRawOff(block *analyze.Block, insns asm.Instructions, idx int) asm.RawInstructionOffset { + return block.Raw + asm.RawInstructionOffset(insns[block.Start:idx].Size()/asm.InstructionSize) +} + +// round down to nearest 8, since stack slots are 8 bytes +func roundToSlot(offset int16) int16 { + return offset &^ 7 +} + +// round up to nearest 8, then divide by 8 to get number of slots +func bytesToSlots(size int16) int16 { + return ((size + 7) &^ 7) / 8 +} + +type rw struct { + Offset int16 + Block *analyze.Block + RawIns asm.RawInstructionOffset +} + +type visitor struct { + dbgWriter io.Writer + inaccurate bool + + spec *ebpf.CollectionSpec + insns asm.Instructions + inStates map[*analyze.Block]state + outStates map[*analyze.Block]state + reachableWrites map[*analyze.Block][]rw + writes []rw + reads []rw + seenWrites map[rw]struct{} + seenReads map[rw]struct{} +} + +func (v *visitor) visit(entry *analyze.Block, initial state) { + v.inStates[entry] = initial.copy() + + queue := []*analyze.Block{entry} + inQueue := map[*analyze.Block]bool{entry: true} + + for len(queue) > 0 { + block := queue[0] + queue = queue[1:] + inQueue[block] = false + + out := v.transferBlock(block, v.inStates[block].copy()) + if prev, ok := v.outStates[block]; ok && statesEqual(prev, out) { + continue + } + + v.outStates[block] = out.copy() + v.reachableWrites[block] = slices.Clone(out.reachableWrites) + + for _, succ := range []*analyze.Block{block.Branch, block.Fthrough} { + if succ == nil { + continue + } + + nextIn, ok := v.inStates[succ] + if !ok { + v.inStates[succ] = out.copy() + if !inQueue[succ] { + queue = append(queue, succ) + inQueue[succ] = true + } + continue + } + + merged := mergeState(nextIn, out) + if statesEqual(nextIn, merged) { + continue + } + + v.inStates[succ] = merged + if !inQueue[succ] { + queue = append(queue, succ) + inQueue[succ] = true + } + } + } +} + +func (v *visitor) transferBlock(block *analyze.Block, state state) state { + for i := block.Start; i <= block.End; i++ { + _, _ = fmt.Fprintf(v.dbgWriter, "%d: %v\n\t", insRawOff(block, v.insns, i), v.insns[i]) + for j, s := range state.regs { + if j != 0 { + _, _ = fmt.Fprint(v.dbgWriter, ", ") + } + _, _ = fmt.Fprintf(v.dbgWriter, "R%d: ", j) + if s.fpPtr { + _, _ = fmt.Fprintf(v.dbgWriter, "fp %d", s.fpOff) + } else if s.mapPtr != nil { + _, _ = fmt.Fprintf(v.dbgWriter, "map %s", s.mapPtr.Name) + } else if s.hasScalar { + _, _ = fmt.Fprintf(v.dbgWriter, "scalar %d", s.scalar) + } else { + _, _ = fmt.Fprint(v.dbgWriter, "U") + } + } + _, _ = fmt.Fprint(v.dbgWriter, "\n\t") + printed := 0 + for _, off := range slices.Sorted(maps.Keys(state.stack)) { + s := state.stack[off] + + var desc string + if s.spilled.fpPtr { + desc = fmt.Sprintf("fp %d", s.spilled.fpOff) + } else if s.spilled.mapPtr != nil { + desc = fmt.Sprintf("map %s", s.spilled.mapPtr.Name) + } else if s.spilled.hasScalar { + desc = fmt.Sprintf("scalar %d", s.spilled.scalar) + } else { + continue + } + + if printed != 0 { + _, _ = fmt.Fprint(v.dbgWriter, ", ") + } + _, _ = fmt.Fprintf(v.dbgWriter, "SP[%d]: %s", off, desc) + printed++ + } + _, _ = fmt.Fprintln(v.dbgWriter) + + ins := &v.insns[i] + switch ins.OpCode.Class() { + case asm.ALUClass, asm.ALU64Class: + switch ins.OpCode.ALUOp() { + case asm.Mov: + // Register to register move + if ins.OpCode.Source() == asm.RegSource { + // Making a copy of R10 means a pointer to the frame pointer + if ins.Src == asm.R10 { + state.regs[ins.Dst] = regState{ + fpPtr: true, + fpSetOff: insRawOff(block, v.insns, i), + fpOff: 0, + } + continue + } + + // Otherwise state of one register is transferred to another + state.regs[ins.Dst] = state.regs[ins.Src] + continue + } + + state.regs[ins.Dst] = regState{ + hasScalar: true, + scalar: int64(ins.Constant), + } + + case asm.Add: + // Special case: when adding a constant to a copy of rfp, track its offset + if state.regs[ins.Dst].fpPtr && ins.OpCode.Source() == asm.ImmSource { + state.regs[ins.Dst].fpOff += int16(ins.Constant) + continue + } + + fallthrough + default: + if state.regs[ins.Dst].hasScalar { + var err error + if ins.OpCode.Source() == asm.ImmSource { + state.regs[ins.Dst].scalar, err = goAluOp(state.regs[ins.Dst].scalar, int64(ins.Constant), ins.OpCode.ALUOp(), ins.OpCode.Class() == asm.ALUClass) + if err != nil { + state.regs[ins.Dst].scalar = 0 + state.regs[ins.Dst].hasScalar = false + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: unsupported ALU operation at %d: %v\n", insRawOff(block, v.insns, i), err) + continue + } + continue + } + + if ins.OpCode.Source() == asm.RegSource && state.regs[ins.Src].hasScalar { + state.regs[ins.Dst].scalar, err = goAluOp(state.regs[ins.Dst].scalar, state.regs[ins.Src].scalar, ins.OpCode.ALUOp(), ins.OpCode.Class() == asm.ALUClass) + if err != nil { + state.regs[ins.Dst].scalar = 0 + state.regs[ins.Dst].hasScalar = false + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: unsupported ALU operation at %d: %v\n", insRawOff(block, v.insns, i), err) + continue + } + continue + } + + // If a scalar gets mixed with a non-scalar, we lose track of it + state.regs[ins.Dst].scalar = 0 + state.regs[ins.Dst].hasScalar = false + } + } + case asm.JumpClass, asm.Jump32Class: + if ins.OpCode.JumpOp() == asm.Call { + // A call can count as read or write of stack. + v.handleCall(&state, block, i) + + continue + } + case asm.StClass, asm.StXClass: + if ins.Dst != asm.R10 { + continue + } + + off := roundToSlot(ins.Offset) + size := int16(ins.OpCode.Size()) + v.writeStack(off, size, &state, block, i) + + // Track spilled value for later loads. Partial writes invalidate any known value. + st := state.stack[off] + if size < 8 { + st.spilled = regState{} + } else if ins.OpCode.Class() == asm.StXClass { + st.spilled = state.regs[ins.Src] + } else { + st.spilled = regState{hasScalar: true, scalar: int64(ins.Constant)} + } + state.stack[off] = st + + case asm.LdClass, asm.LdXClass: + + if ins.IsLoadFromMap() { + if ins.Reference() == "" { + _, _ = fmt.Fprintln(v.dbgWriter, "WARN: missing map reference") + } + mapSpec, found := v.spec.Maps[ins.Reference()] + if !found { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: map %s not found in collection\n", ins.Reference()) + continue + } + state.regs[ins.Dst] = regState{ + mapPtr: mapSpec, + } + continue + } + + if ins.Src != asm.R10 { + continue + } + + switch ins.OpCode.Mode() { + case asm.MemMode: + // take known value of stack and put it in the register + state.regs[ins.Dst] = state.stack[roundToSlot(ins.Offset)].spilled + case asm.ImmMode: + if ins.OpCode.Size() == asm.DWord { + state.regs[ins.Dst] = regState{ + hasScalar: true, + scalar: int64(ins.Constant), + } + } + } + + v.readStack(state, ins.Offset, block, i) + default: + continue + } + } + + return state +} + +func mergeState(a, b state) state { + var out state + for i := range out.regs { + out.regs[i] = mergeRegState(a.regs[i], b.regs[i]) + } + + out.stack = make(map[int16]stackState) + for _, off := range slices.Sorted(maps.Keys(a.stack)) { + out.stack[off] = a.stack[off] + } + for _, off := range slices.Sorted(maps.Keys(b.stack)) { + cur, found := out.stack[off] + if !found { + out.stack[off] = b.stack[off] + continue + } + + cur.spilled = mergeRegState(cur.spilled, b.stack[off].spilled) + out.stack[off] = cur + } + + out.reachableWrites = appendRWUnique(out.reachableWrites, a.reachableWrites...) + out.reachableWrites = appendRWUnique(out.reachableWrites, b.reachableWrites...) + + return out +} + +func mergeRegState(a, b regState) regState { + if a == b { + return a + } + + // Conflicting facts collapse to unknown to guarantee convergence. + return regState{} +} + +func statesEqual(a, b state) bool { + if a.regs != b.regs { + return false + } + + if len(a.stack) != len(b.stack) { + return false + } + for off, as := range a.stack { + bs, found := b.stack[off] + if !found || as != bs { + return false + } + } + + if len(a.reachableWrites) != len(b.reachableWrites) { + return false + } + bWrites := make(map[rw]struct{}, len(b.reachableWrites)) + for _, write := range b.reachableWrites { + bWrites[write] = struct{}{} + } + for _, write := range a.reachableWrites { + if _, found := bWrites[write]; !found { + return false + } + } + + return true +} + +func appendRWUnique(dst []rw, src ...rw) []rw { + if len(src) == 0 { + return dst + } + + seen := make(map[rw]struct{}, len(dst)+len(src)) + for _, item := range dst { + seen[item] = struct{}{} + } + for _, item := range src { + if _, found := seen[item]; found { + continue + } + dst = append(dst, item) + seen[item] = struct{}{} + } + + return dst +} + +var errUnsupportedALUOp = fmt.Errorf("unsupported ALU operation") +var errDivisionByZero = fmt.Errorf("division by zero") +var errModuloByZero = fmt.Errorf("modulo by zero") + +func goAluOp(a, b int64, op asm.ALUOp, b32 bool) (result int64, err error) { + switch op { + case asm.Add: + if b32 { + result = int64(uint32(a) + uint32(b)) + } else { + result = int64(uint64(a) + uint64(b)) + } + case asm.Sub: + if b32 { + result = int64(uint32(a) - uint32(b)) + } else { + result = int64(uint64(a) - uint64(b)) + } + case asm.Mul: + if b32 { + result = int64(uint32(a) * uint32(b)) + } else { + result = int64(uint64(a) * uint64(b)) + } + case asm.Div: + if b == 0 { + return 0, errDivisionByZero + } + if b32 { + result = int64(uint32(a) / uint32(b)) + } else { + result = int64(uint64(a) / uint64(b)) + } + case asm.SDiv: + if b == 0 { + return 0, errDivisionByZero + } + if b32 { + result = int64(int32(a) / int32(b)) + } else { + result = a / b + } + case asm.Or: + if b32 { + result = int64(uint32(a) | uint32(b)) + } else { + result = a | b + } + case asm.And: + if b32 { + result = int64(uint32(a) & uint32(b)) + } else { + result = a & b + } + case asm.LSh: + if b32 { + result = int64(uint32(a) << (uint(b) & 31)) + } else { + result = a << (uint(b) & 63) + } + case asm.RSh: + if b32 { + result = int64(uint32(a) >> (uint(b) & 31)) + } else { + result = a >> (uint(b) & 63) + } + case asm.Neg: + if b32 { + result = int64(-int32(a)) + } else { + result = -a + } + case asm.Mod: + if b == 0 { + return 0, errModuloByZero + } + if b32 { + result = int64(uint32(a) % uint32(b)) + } else { + result = int64(uint64(a) % uint64(b)) + } + case asm.SMod: + if b == 0 { + return 0, errModuloByZero + } + if b32 { + result = int64(int32(a) % int32(b)) + } else { + result = a % b + } + case asm.Xor: + if b32 { + result = int64(uint32(a) ^ uint32(b)) + } else { + result = a ^ b + } + case asm.ArSh: + if b32 { + result = int64(int32(a) >> (uint(b) & 31)) + } else { + result = a >> (uint(b) & 63) + } + default: + return 0, errUnsupportedALUOp + } + + return result, nil +} + +func (v *visitor) readStack(s state, offset int16, curBlock *analyze.Block, readInsIdx int) { + _, _ = fmt.Fprintf(v.dbgWriter, "Read stack, off: %d\n", offset) + _, found := s.stack[roundToSlot(offset)] + if !found { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: read of uninitialized stack slot %d at %d\n", roundToSlot(offset), insRawOff(curBlock, v.insns, readInsIdx)) + v.inaccurate = true + return + } + + read := rw{ + Offset: roundToSlot(offset), + Block: curBlock, + RawIns: insRawOff(curBlock, v.insns, readInsIdx), + } + if _, found := v.seenReads[read]; found { + return + } + v.seenReads[read] = struct{}{} + v.reads = append(v.reads, read) +} + +func (v *visitor) writeStack(offset int16, size int16, s *state, curBlock *analyze.Block, insIdx int) { + // Where there is pre-existing state in this slot and we are only writing to part of it + // We don't want to create a new lifetime, we want to extend the existing one. + if _, found := s.stack[offset]; found && size < 8 { + _, _ = fmt.Fprintf(v.dbgWriter, "Mod stack, off: %d\n", offset) + return + } + + _, _ = fmt.Fprintf(v.dbgWriter, "Write stack, off: %d\n", offset) + + s.stack[offset] = stackState{} + callW := rw{ + Offset: offset, + Block: curBlock, + RawIns: insRawOff(curBlock, v.insns, insIdx), + } + s.reachableWrites = appendRWUnique(s.reachableWrites, callW) + if _, found := v.seenWrites[callW]; found { + return + } + v.seenWrites[callW] = struct{}{} + v.writes = append(v.writes, callW) +} + +func (v *visitor) handleCall(s *state, curBlock *analyze.Block, insIdx int) { + ins := v.insns[insIdx] + + if ins.IsKfuncCall() { + // TODO handle kfuncs + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: kfunc call at %d, not handled\n", insRawOff(curBlock, v.insns, insIdx)) + v.inaccurate = true + return + } + + if !ins.IsBuiltinCall() { + // TODO handle static bpf to bpf calls (global funcs are separate programs) + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: bpf to bpf call at %d, not handled\n", insRawOff(curBlock, v.insns, insIdx)) + v.inaccurate = true + return + } + + clobberR0 := true + defer func() { + i := asm.R1 + if clobberR0 { + i = asm.R0 + } + for j := i; j <= asm.R5; j++ { + s.regs[j] = regState{} + } + }() + + // Maps are special, they do not have a size parameter, size is inferred from the map definition. + switch asm.BuiltinFunc(ins.Constant) { + case asm.FnMapLookupElem, asm.FnMapDeleteElem: + if s.regs[asm.R1].mapPtr == nil { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: map lookup with non-map pointer at %d\n", insRawOff(curBlock, v.insns, insIdx)) + return + } + + mapSpec := s.regs[asm.R1].mapPtr + keyPtr := s.regs[asm.R2] + if !keyPtr.fpPtr { + // Key is not located on the stack, this is allowed when the key is another map value. + return + } + + slots := bytesToSlots(int16(mapSpec.KeySize)) + for i := range slots { + v.readStack(*s, keyPtr.fpOff+i*8, curBlock, insIdx) + } + + if mapSpec.InnerMap != nil { + clobberR0 = false + s.regs[asm.R0] = regState{ + mapPtr: mapSpec.InnerMap, + } + } + + return + + case asm.FnMapUpdateElem: + if s.regs[asm.R1].mapPtr == nil { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: map lookup with non-map pointer at %d\n", insRawOff(curBlock, v.insns, insIdx)) + return + } + + mapSpec := s.regs[asm.R1].mapPtr + keyPtr := s.regs[asm.R2] + // Key is not located on the stack, this is allowed when the key is another map value. + if keyPtr.fpPtr { + slots := bytesToSlots(int16(mapSpec.KeySize)) + for i := range slots { + v.readStack(*s, keyPtr.fpOff+i*8, curBlock, insIdx) + } + } + + valuePtr := s.regs[asm.R3] + // Key is not located on the stack, this is allowed when the key is another map value. + if valuePtr.fpPtr { + slots := bytesToSlots(int16(mapSpec.ValueSize)) + for i := range slots { + v.readStack(*s, valuePtr.fpOff+i*8, curBlock, insIdx) + } + } + + return + case asm.FnSockMapUpdate, asm.FnSockHashUpdate, asm.FnMsgRedirectHash, asm.FnSkRedirectHash, + asm.FnSkSelectReuseport, + asm.FnMapPushElem, asm.FnMapPopElem, asm.FnMapPeekElem, + asm.FnSkStorageGet, asm.FnInodeStorageGet, asm.FnTaskStorageGet, asm.FnCgrpStorageGet, + asm.FnMapLookupPercpuElem: + // TODO implement map related helpers + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: map related helper %s at %d, not handled\n", asm.BuiltinFunc(ins.Constant), insRawOff(curBlock, v.insns, insIdx)) + v.inaccurate = true + return + case asm.FnDynptrFromMem, asm.FnRingbufReserveDynptr, asm.FnRingbufSubmitDynptr, + asm.FnRingbufDiscardDynptr, asm.FnDynptrRead, asm.FnDynptrWrite, asm.FnDynptrData: + // TODO implement dynamic pointer tracking, so reads/writes to dynamic pointer propagate to the underlying memory + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: dynamic pointer helper %s at %d, not handled\n", asm.BuiltinFunc(ins.Constant), insRawOff(curBlock, v.insns, insIdx)) + v.inaccurate = true + return + } + + argPairs, found := callArgMap[asm.BuiltinFunc(ins.Constant)] + if !found { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: unhandled call to unknown helper function %s at %d\n", asm.BuiltinFunc(ins.Constant), insRawOff(curBlock, v.insns, insIdx)) + v.inaccurate = true + return + } + + for _, argPair := range argPairs { + ptrState := s.regs[argPair.ptr] + if !ptrState.fpPtr { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: pointer argument is not a frame pointer at %d\n", insRawOff(curBlock, v.insns, insIdx)) + // Note: do not mark inaccurate here, since the pointer could be a map value, which is allowed to be non-frame pointer. + continue + } + + size := int64(0) + if argPair.sizeConst != 0 { + size = argPair.sizeConst + } else { + sizeState := s.regs[argPair.size] + if !sizeState.hasScalar { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: size argument is not a scalar at %d\n", insRawOff(curBlock, v.insns, insIdx)) + v.inaccurate = true + continue + } + + if sizeState.scalar > 512 { + _, _ = fmt.Fprintf(v.dbgWriter, "WARN: size argument is too large at %d\n", insRawOff(curBlock, v.insns, insIdx)) + v.inaccurate = true + continue + } + size = sizeState.scalar + } + + if argPair.rw&Read != 0 { + slots := bytesToSlots(int16(size)) + for i := range slots { + v.readStack(*s, roundToSlot(ptrState.fpOff)+int16(i*8), curBlock, insIdx) + } + } + if argPair.rw&Write != 0 { + slots := bytesToSlots(int16(size)) + for i := range slots { + off := roundToSlot(ptrState.fpOff) + int16(i*8) + v.writeStack(off, int16(size), s, curBlock, insIdx) + } + } + } +} + +type callArgRW int + +const ( + Read callArgRW = 1 << iota + Write + ReadWrite = Read | Write +) + +type callArgPair struct { + ptr asm.Register + size asm.Register + rw callArgRW + sizeConst int64 +} + +func PSPair(ptr, size asm.Register, rw callArgRW) callArgPair { + return callArgPair{ + ptr: ptr, + size: size, + rw: rw, + } +} + +func PConst(ptr asm.Register, size int64, rw callArgRW) callArgPair { + return callArgPair{ + ptr: ptr, + sizeConst: size, + rw: rw, + } +} + +var callArgMap = map[asm.BuiltinFunc][]callArgPair{ + asm.FnMapLookupElem: {}, // Covered by special case + asm.FnMapDeleteElem: {}, // Covered by special case + asm.FnMapUpdateElem: {}, // Covered by special case + asm.FnProbeRead: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnKtimeGetNs: {}, + asm.FnTracePrintk: {PSPair(asm.R1, asm.R2, Read)}, + asm.FnGetPrandomU32: {}, + asm.FnGetSmpProcessorId: {}, + asm.FnSkbStoreBytes: {PSPair(asm.R3, asm.R4, Read)}, + asm.FnL3CsumReplace: {}, + asm.FnL4CsumReplace: {}, + asm.FnTailCall: {}, + asm.FnCloneRedirect: {}, + asm.FnGetCurrentPidTgid: {}, + asm.FnGetCurrentUidGid: {}, + asm.FnGetCurrentComm: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnGetCgroupClassid: {}, + asm.FnSkbVlanPush: {}, + asm.FnSkbVlanPop: {}, + asm.FnSkbGetTunnelKey: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnSkbSetTunnelKey: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnPerfEventRead: {}, + asm.FnRedirect: {}, + asm.FnGetRouteRealm: {}, + asm.FnPerfEventOutput: {PSPair(asm.R4, asm.R5, Read)}, + asm.FnSkbLoadBytes: {PSPair(asm.R3, asm.R4, Write)}, + asm.FnGetStackid: {}, + asm.FnCsumDiff: {}, + asm.FnSkbGetTunnelOpt: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnSkbSetTunnelOpt: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnSkbChangeProto: {}, + asm.FnSkbChangeType: {}, + asm.FnSkbUnderCgroup: {}, + asm.FnGetHashRecalc: {}, + asm.FnGetCurrentTask: {}, + asm.FnProbeWriteUser: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnCurrentTaskUnderCgroup: {}, + asm.FnSkbChangeTail: {}, + asm.FnSkbPullData: {}, + asm.FnCsumUpdate: {}, + asm.FnSetHashInvalid: {}, + asm.FnGetNumaNodeId: {}, + asm.FnSkbChangeHead: {}, + asm.FnXdpAdjustHead: {}, + asm.FnProbeReadStr: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnGetSocketCookie: {}, + asm.FnGetSocketUid: {}, + asm.FnSetHash: {}, + asm.FnSetsockopt: {PSPair(asm.R4, asm.R5, Read)}, + asm.FnSkbAdjustRoom: {}, + asm.FnRedirectMap: {}, + asm.FnSkRedirectMap: {}, + asm.FnSockMapUpdate: {}, // Covered by special case + asm.FnXdpAdjustMeta: {}, + asm.FnPerfEventReadValue: {PSPair(asm.R3, asm.R4, Write)}, + asm.FnPerfProgReadValue: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnGetsockopt: {PSPair(asm.R4, asm.R5, Write)}, + asm.FnOverrideReturn: {}, + asm.FnSockOpsCbFlagsSet: {}, + asm.FnMsgRedirectMap: {}, + asm.FnMsgApplyBytes: {}, + asm.FnMsgCorkBytes: {}, + asm.FnMsgPullData: {}, + asm.FnBind: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnXdpAdjustTail: {}, + asm.FnSkbGetXfrmState: {PSPair(asm.R3, asm.R4, Write)}, + asm.FnGetStack: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnSkbLoadBytesRelative: {PSPair(asm.R3, asm.R4, Write)}, + asm.FnFibLookup: {PSPair(asm.R2, asm.R3, ReadWrite)}, + asm.FnSockHashUpdate: {}, // Handled by special case + asm.FnMsgRedirectHash: {}, + asm.FnSkRedirectHash: {}, + asm.FnLwtPushEncap: {PSPair(asm.R3, asm.R4, Read)}, + asm.FnLwtSeg6StoreBytes: {PSPair(asm.R3, asm.R4, Read)}, + asm.FnLwtSeg6AdjustSrh: {}, + asm.FnLwtSeg6Action: {PSPair(asm.R3, asm.R4, Write)}, + asm.FnRcRepeat: {}, + asm.FnRcKeydown: {}, + asm.FnSkbCgroupId: {}, + asm.FnGetCurrentCgroupId: {}, + asm.FnGetLocalStorage: {}, + asm.FnSkSelectReuseport: {}, // handled by special case + asm.FnSkbAncestorCgroupId: {}, + asm.FnSkLookupTcp: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnSkLookupUdp: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnSkRelease: {}, + asm.FnMapPushElem: {}, // handled by special case + asm.FnMapPopElem: {}, // handled by special case + asm.FnMapPeekElem: {}, // handled by special case + asm.FnMsgPushData: {}, + asm.FnMsgPopData: {}, + asm.FnRcPointerRel: {}, + asm.FnSpinLock: {}, + asm.FnSpinUnlock: {}, + asm.FnSkFullsock: {}, + asm.FnTcpSock: {}, + asm.FnSkbEcnSetCe: {}, + asm.FnGetListenerSock: {}, + asm.FnSkcLookupTcp: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnTcpCheckSyncookie: {PSPair(asm.R2, asm.R3, Read), PSPair(asm.R4, asm.R5, Read)}, + asm.FnSysctlGetName: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnSysctlGetCurrentValue: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnSysctlGetNewValue: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnSysctlSetNewValue: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnStrtol: {PSPair(asm.R1, asm.R2, Read), PConst(asm.R4, 4, Write)}, + asm.FnStrtoul: {PSPair(asm.R1, asm.R2, Read), PConst(asm.R4, 4, Write)}, + asm.FnSkStorageGet: {}, // handle by special case + asm.FnSkStorageDelete: {}, + asm.FnSendSignal: {}, + asm.FnTcpGenSyncookie: {PSPair(asm.R2, asm.R3, Read), PSPair(asm.R4, asm.R5, Read)}, + asm.FnSkbOutput: {PSPair(asm.R4, asm.R5, Read)}, + asm.FnProbeReadUser: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnProbeReadKernel: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnProbeReadUserStr: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnProbeReadKernelStr: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnTcpSendAck: {}, + asm.FnSendSignalThread: {}, + asm.FnJiffies64: {}, + asm.FnReadBranchRecords: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnGetNsCurrentPidTgid: {PSPair(asm.R3, asm.R4, Write)}, + asm.FnXdpOutput: {PSPair(asm.R4, asm.R5, Read)}, + asm.FnGetNetnsCookie: {}, + asm.FnGetCurrentAncestorCgroupId: {}, + asm.FnSkAssign: {}, + asm.FnKtimeGetBootNs: {}, + asm.FnSeqPrintf: {PSPair(asm.R2, asm.R3, Read), PSPair(asm.R4, asm.R5, Read)}, + asm.FnSeqWrite: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnSkCgroupId: {}, + asm.FnSkAncestorCgroupId: {}, + asm.FnRingbufOutput: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnRingbufReserve: {}, + asm.FnRingbufSubmit: {}, + asm.FnRingbufDiscard: {}, + asm.FnRingbufQuery: {}, + asm.FnCsumLevel: {}, + asm.FnSkcToTcp6Sock: {}, + asm.FnSkcToTcpSock: {}, + asm.FnSkcToTcpTimewaitSock: {}, + asm.FnSkcToTcpRequestSock: {}, + asm.FnSkcToUdp6Sock: {}, + asm.FnGetTaskStack: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnLoadHdrOpt: {PSPair(asm.R2, asm.R3, ReadWrite)}, + asm.FnStoreHdrOpt: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnReserveHdrOpt: {}, + asm.FnInodeStorageGet: {}, // handled by special case + asm.FnInodeStorageDelete: {}, + asm.FnDPath: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnCopyFromUser: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnSnprintfBtf: {PSPair(asm.R1, asm.R2, Write), PSPair(asm.R3, asm.R4, Read)}, + asm.FnSeqPrintfBtf: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnSkbCgroupClassid: {}, + asm.FnRedirectNeigh: {PSPair(asm.R2, asm.R3, ReadWrite)}, + asm.FnPerCpuPtr: {}, + asm.FnThisCpuPtr: {}, + asm.FnRedirectPeer: {}, + asm.FnTaskStorageGet: {}, // handled by special case + asm.FnTaskStorageDelete: {}, + asm.FnGetCurrentTaskBtf: {}, + asm.FnBprmOptsSet: {}, + asm.FnKtimeGetCoarseNs: {}, + asm.FnImaInodeHash: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnSockFromFile: {}, + asm.FnCheckMtu: {}, + asm.FnForEachMapElem: {}, + asm.FnSnprintf: {PSPair(asm.R1, asm.R2, Write), PSPair(asm.R4, asm.R5, Read)}, + asm.FnSysBpf: {PSPair(asm.R2, asm.R3, Read)}, + asm.FnBtfFindByNameKind: {PSPair(asm.R1, asm.R2, Read)}, + asm.FnSysClose: {}, + asm.FnTimerInit: {}, + asm.FnTimerSetCallback: {}, + asm.FnTimerStart: {}, + asm.FnTimerCancel: {}, + asm.FnGetFuncIp: {}, + asm.FnGetAttachCookie: {}, + asm.FnTaskPtRegs: {}, + asm.FnGetBranchSnapshot: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnTraceVprintk: {PSPair(asm.R1, asm.R2, Read), PSPair(asm.R3, asm.R4, Read)}, + asm.FnSkcToUnixSock: {}, + asm.FnKallsymsLookupName: {PSPair(asm.R1, asm.R2, Read), PConst(asm.R4, 8, Write)}, + asm.FnFindVma: {}, + asm.FnLoop: {}, + asm.FnStrncmp: {PSPair(asm.R1, asm.R2, Read), PSPair(asm.R3, asm.R2, Read)}, + asm.FnGetFuncArg: {PConst(asm.R3, 8, Write)}, + asm.FnGetFuncRet: {PConst(asm.R2, 8, Write)}, + asm.FnGetFuncArgCnt: {}, + asm.FnGetRetval: {}, + asm.FnSetRetval: {}, + asm.FnXdpGetBuffLen: {}, + asm.FnXdpLoadBytes: {PSPair(asm.R3, asm.R4, Write)}, + asm.FnXdpStoreBytes: {PSPair(asm.R3, asm.R4, Read)}, + asm.FnCopyFromUserTask: {PSPair(asm.R1, asm.R2, Write)}, + asm.FnSkbSetTstamp: {}, + asm.FnImaFileHash: {PSPair(asm.R2, asm.R3, Write)}, + asm.FnKptrXchg: {}, + asm.FnMapLookupPercpuElem: {}, // handled by special case + asm.FnSkcToMptcpSock: {}, + asm.FnDynptrFromMem: {}, // handled by special case + asm.FnRingbufReserveDynptr: {}, // handled by special case + asm.FnRingbufSubmitDynptr: {}, // handled by special case + asm.FnRingbufDiscardDynptr: {}, // handled by special case + asm.FnDynptrRead: {}, // handled by special case + asm.FnDynptrWrite: {}, // handled by special case + asm.FnDynptrData: {}, // handled by special case + asm.FnTcpRawGenSyncookieIpv4: {PConst(asm.R1, 20, Read), PSPair(asm.R2, asm.R3, Read)}, + asm.FnTcpRawGenSyncookieIpv6: {PConst(asm.R1, 40, Read), PSPair(asm.R2, asm.R3, Read)}, + asm.FnTcpRawCheckSyncookieIpv4: {PConst(asm.R1, 20, Read), PConst(asm.R2, 20, Read)}, + asm.FnTcpRawCheckSyncookieIpv6: {PConst(asm.R1, 40, Read), PConst(asm.R2, 20, Read)}, + asm.FnKtimeGetTaiNs: {}, + asm.FnUserRingbufDrain: {}, + asm.FnCgrpStorageGet: {}, // handled by special case + asm.FnCgrpStorageDelete: {}, +} + +func graphLifetimes(lifetimes []StackLifetime, blocks analyze.Blocks, insns asm.Instructions, writes []rw, reads []rw, drawBBB bool) string { + lifetimeByOffset := make(map[int16][]StackLifetime) + for _, lt := range lifetimes { + lifetimeByOffset[lt.offset] = append(lifetimeByOffset[lt.offset], lt) + } + + // Sort offsets ascending: most-negative (farthest from frame pointer) at top. + offsets := slices.Collect(maps.Keys(lifetimeByOffset)) + slices.SortFunc(offsets, func(a, b int16) int { return int(a) - int(b) }) + + const ( + cellW = 8 // pixels per instruction + rowH = 24 // pixels per stack slot row + marginLeft = 64 // space for offset labels + marginTop = 28 // space for instruction index labels + ) + + palette := []string{ + "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", + "#59a14f", "#edc948", "#b07aa1", "#ff9da7", + "#9c755f", "#bab0ac", + } + + nInsns := int(insRawOff(blocks[len(blocks)-1], insns, len(insns)-1) + 1) + nSlots := len(offsets) + totalW := marginLeft + nInsns*cellW + 1 + totalH := marginTop + nSlots*rowH + 1 + + var sb strings.Builder + fmt.Fprintf(&sb, ``, + totalW, totalH) + + // Row backgrounds and offset labels on the left. + for i, off := range offsets { + y := marginTop + i*rowH + bg := "#ffffff" + if i%2 != 0 { + bg = "#f5f5f5" + } + fmt.Fprintf(&sb, ``, + marginLeft, y, nInsns*cellW, rowH, bg) + fmt.Fprintf(&sb, `%d`, + marginLeft-4, y+rowH/2, off) + } + + // Vertical grid lines and instruction index tick labels. + step := 1 + switch { + case nInsns > 500: + step = 50 + case nInsns > 200: + step = 20 + case nInsns > 100: + step = 10 + case nInsns > 50: + step = 5 + } + for i := 0; i < nInsns; i += step { + x := marginLeft + i*cellW + fmt.Fprintf(&sb, ``, + x, marginTop, x, marginTop+nSlots*rowH) + fmt.Fprintf(&sb, `%d`, + x, marginTop-6, i) + } + + if drawBBB { + // Basic block boundaries at block entry points. + boundarySet := make(map[asm.RawInstructionOffset]struct{}, len(blocks)) + for _, block := range blocks { + boundarySet[block.Raw] = struct{}{} + } + + boundaries := slices.Collect(maps.Keys(boundarySet)) + slices.Sort(boundaries) + for _, boundary := range boundaries { + x := marginLeft + int(boundary)*cellW + fmt.Fprintf(&sb, ``, + x, marginTop, x, marginTop+nSlots*rowH) + } + } + + // Lifetime boxes: one color per lifetime, same color across all its intervals. + for i, off := range offsets { + y := marginTop + i*rowH + for li, lt := range lifetimeByOffset[off] { + color := palette[li%len(palette)] + for _, interval := range lt.lifetime.Intervals { + x := marginLeft + interval.Start*cellW + w := (interval.End - interval.Start + 1) * cellW + if w < 1 { + w = 1 + } + fmt.Fprintf(&sb, ``, + x, y+3, w, rowH-6, color) + } + } + } + + // Build offset-to-row index for marker placement. + offsetToRow := make(map[int16]int, len(offsets)) + for i, off := range offsets { + offsetToRow[off] = i + } + + // Write markers: filled black circles. + for _, w := range writes { + rowIdx, ok := offsetToRow[w.Offset] + if !ok { + continue + } + cx := marginLeft + int(w.RawIns)*cellW + cellW/2 + cy := marginTop + rowIdx*rowH + rowH/2 + fmt.Fprintf(&sb, ``, cx, cy) + } + + // Read markers: white circles with black stroke. + for _, r := range reads { + rowIdx, ok := offsetToRow[r.Offset] + if !ok { + continue + } + cx := marginLeft + int(r.RawIns)*cellW + cellW/2 + cy := marginTop + rowIdx*rowH + rowH/2 + fmt.Fprintf(&sb, ``, cx, cy) + } + + // Border around the chart area. + fmt.Fprintf(&sb, ``, + marginLeft, marginTop, nInsns*cellW, nSlots*rowH) + + fmt.Fprintf(&sb, ``) + return sb.String() +} diff --git a/cmd/stackwhere/lifetimes_test.go b/cmd/stackwhere/lifetimes_test.go new file mode 100644 index 0000000..e06179f --- /dev/null +++ b/cmd/stackwhere/lifetimes_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "io" + "testing" + + "github.com/cilium/ebpf/asm" + "github.com/cilium/stackwhere/internal/analyze" +) + +func runVisitor(t *testing.T, insns asm.Instructions) *visitor { + t.Helper() + + blocks, err := analyze.MakeBlocks(insns) + if err != nil { + t.Fatalf("make blocks: %v", err) + } + + v := &visitor{ + dbgWriter: io.Discard, + insns: insns, + reachableWrites: make(map[*analyze.Block][]rw), + inStates: make(map[*analyze.Block]state), + outStates: make(map[*analyze.Block]state), + seenReads: make(map[rw]struct{}), + seenWrites: make(map[rw]struct{}), + } + v.visit(blocks[0], state{stack: make(map[int16]stackState)}) + return v +} + +func hasRW(items []rw, offset int16, raw asm.RawInstructionOffset) bool { + for _, item := range items { + if item.Offset == offset && item.RawIns == raw { + return true + } + } + return false +} + +func TestVisitorRevisitsJoinWithDifferentIncomingState(t *testing.T) { + branch := asm.JEq.Imm(asm.R0, 0, "") + branch.Offset = 3 + branch = branch.WithSymbol("prog") + jmpToJoin := asm.Ja.Label("") + jmpToJoin.Offset = 1 + + insns := asm.Instructions{ + branch, + asm.Mov.Imm(asm.R1, 1), + asm.StoreMem(asm.R10, -8, asm.R1, asm.DWord), + jmpToJoin, + asm.Mov.Imm32(asm.R0, 0), + asm.LoadMem(asm.R1, asm.R10, -8, asm.DWord), + asm.Return(), + } + + v := runVisitor(t, insns) + + if !hasRW(v.writes, -8, 2) { + t.Fatalf("expected write at raw ins 2 for stack offset -8, got writes=%v", v.writes) + } + if !hasRW(v.reads, -8, 5) { + t.Fatalf("expected read at join block (raw ins 5) for stack offset -8, got reads=%v", v.reads) + } +} + +func TestVisitorRevisitsLoopHeaderWhenStateChanges(t *testing.T) { + branch := asm.JEq.Imm(asm.R0, 0, "") + branch.Offset = 3 + branch = branch.WithSymbol("prog") + jmpToLoopRead := asm.Ja.Label("") + jmpToLoopRead.Offset = 2 + jmpNoWriteToLoopRead := asm.Ja.Label("") + jmpNoWriteToLoopRead.Offset = 0 + backEdge := asm.Ja.Label("") + backEdge.Offset = -7 + + insns := asm.Instructions{ + branch, + asm.Mov.Imm(asm.R1, 1), + asm.StoreMem(asm.R10, -8, asm.R1, asm.DWord), + jmpToLoopRead, + asm.Mov.Imm32(asm.R3, 0), + jmpNoWriteToLoopRead, + asm.LoadMem(asm.R2, asm.R10, -8, asm.DWord), + backEdge, + asm.Return(), + } + + v := runVisitor(t, insns) + + if !hasRW(v.writes, -8, 2) { + t.Fatalf("expected loop write at raw ins 2 for stack offset -8, got writes=%v", v.writes) + } + if !hasRW(v.reads, -8, 6) { + t.Fatalf("expected read at loop join (raw ins 6) for stack offset -8, got reads=%v", v.reads) + } +} + +func TestLifetimeAddSortsAndUpdatesIntervals(t *testing.T) { + lt := &Lifetime{} + + lt.Add(LTInterval(asm.RawInstructionOffset(1<<30), asm.RawInstructionOffset(1<<30)+2, false)) + lt.Add(LTInterval(2, 3, false)) + lt.Add(LTInterval(10, 15, false)) + lt.Add(LTInterval(10, 18, false)) + + if len(lt.Intervals) != 3 { + t.Fatalf("expected 3 intervals, got %d", len(lt.Intervals)) + } + + if lt.Intervals[0].Start != 2 || lt.Intervals[1].Start != 10 || lt.Intervals[2].Start != asm.RawInstructionOffset(1<<30) { + t.Fatalf("intervals are not sorted by start: %+v", lt.Intervals) + } + + if lt.Intervals[1].End != 18 { + t.Fatalf("expected interval with start=10 to update end to 18, got %d", lt.Intervals[1].End) + } +} + +func TestGoAluOpDivisionAndModuloByZero(t *testing.T) { + tests := []struct { + name string + op asm.ALUOp + b32 bool + }{ + {name: "div 64", op: asm.Div, b32: false}, + {name: "div 32", op: asm.Div, b32: true}, + {name: "sdiv 64", op: asm.SDiv, b32: false}, + {name: "sdiv 32", op: asm.SDiv, b32: true}, + {name: "mod 64", op: asm.Mod, b32: false}, + {name: "mod 32", op: asm.Mod, b32: true}, + {name: "smod 64", op: asm.SMod, b32: false}, + {name: "smod 32", op: asm.SMod, b32: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := goAluOp(42, 0, tt.op, tt.b32) + if err == nil { + t.Fatalf("expected error for zero divisor with op=%v b32=%v", tt.op, tt.b32) + } + }) + } +} + +func TestGoAluOpNegativeShiftUsesMaskedCount(t *testing.T) { + res, err := goAluOp(1, -1, asm.LSh, true) + if err != nil { + t.Fatalf("unexpected error for LSh with negative shift: %v", err) + } + if res != int64(uint32(1)<<31) { + t.Fatalf("unexpected LSh result: got %d", res) + } + + res, err = goAluOp(int64(uint32(1)<<31), -1, asm.RSh, true) + if err != nil { + t.Fatalf("unexpected error for RSh with negative shift: %v", err) + } + if res != 1 { + t.Fatalf("unexpected RSh result: got %d", res) + } + + res, err = goAluOp(-8, -1, asm.ArSh, true) + if err != nil { + t.Fatalf("unexpected error for ArSh with negative shift: %v", err) + } + if res != -1 { + t.Fatalf("unexpected ArSh result: got %d", res) + } +} diff --git a/cmd/stackwhere/main.go b/cmd/stackwhere/main.go index 77a1937..e8e8153 100644 --- a/cmd/stackwhere/main.go +++ b/cmd/stackwhere/main.go @@ -41,8 +41,12 @@ func root() *cobra.Command { listCmd := listCommand() listCmd.GroupID = primaryGroupID + lifetimes := lifetimesCommand() + lifetimes.GroupID = primaryGroupID + c.AddCommand( listCmd, + lifetimes, versionCommand(), ) diff --git a/go.mod b/go.mod index 3776b47..632ce1e 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,13 @@ require ( github.com/cilium/ebpf v0.21.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.10.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e010cc9..f3454c6 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -20,9 +22,14 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/analyze/blocks.go b/internal/analyze/blocks.go new file mode 100644 index 0000000..c7195e5 --- /dev/null +++ b/internal/analyze/blocks.go @@ -0,0 +1,628 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package analyze + +import ( + "errors" + "fmt" + "slices" + "strings" + + "github.com/cilium/ebpf/asm" +) + +// leaderKey is used to store the leader metadata in an instruction's metadata. +type leaderKey struct{} + +// A leader is an instruction at the beginning of a basic block. +type leader struct { + // predecessors are instructions in other blocks that are always executed + // before this instruction. + predecessors []*asm.Instruction + + // block is the block that this instruction is the start of. + block *Block +} + +// setLeaderMeta sets the leader metadata for an instruction. This metadata +// is used to mark the start of a basic block and to store information about +// the block and its predecessors. +func setLeaderMeta(ins *asm.Instruction, meta *leader) { + ins.Metadata.Set(leaderKey{}, meta) +} + +// getLeaderMeta retrieves the leader metadata for an instruction. +func getLeaderMeta(ins *asm.Instruction) *leader { + val := ins.Metadata.Get(leaderKey{}) + meta, ok := val.(*leader) + if !ok { + return nil + } + return meta +} + +// setLeader idempotently sets the leader metadata for an instruction, returning +// the existing leader metadata if it already exists. +func setLeader(ins *asm.Instruction) *leader { + l := getLeaderMeta(ins) + if l == nil { + l = &leader{} + setLeaderMeta(ins, l) + } + return l +} + +// addPredecessors adds one or more predecessor instructions to the list of +// predecessors for the given instruction. If a predecessor is already in the +// list, it is not added again. +// +// This is used to track the control flow graph of the program, where each +// instruction can have multiple predecessors (i.e. it can be reached from +// multiple branches). Initializes the instruction's leader metadata if it does +// not exist yet. +func addPredecessors(ins *asm.Instruction, preds ...*asm.Instruction) { + l := setLeader(ins) + for _, pred := range preds { + if pred == nil { + continue + } + if !slices.Contains(l.predecessors, pred) { + l.predecessors = append(l.predecessors, pred) + } + } +} + +// edgeKey is used to store the edge metadata in an instruction's metadata. +type edgeKey struct{} + +// edge is a metadata structure that is associated with an instruction marking +// the end of a basic block. It can have a branch target (the target of a jump +// instruction) and a fallthrough target (the next instruction in the +// instruction stream that is executed if the branch is not taken). +type edge struct { + branch *asm.Instruction + fthrough *asm.Instruction + + block *Block +} + +// setEdgeMeta sets the edge metadata for an instruction. +func setEdgeMeta(ins *asm.Instruction, meta *edge) { + ins.Metadata.Set(edgeKey{}, meta) +} + +// getEdgeMeta retrieves the edge metadata for an instruction. +func getEdgeMeta(ins *asm.Instruction) *edge { + val := ins.Metadata.Get(edgeKey{}) + meta, ok := val.(*edge) + if !ok { + return nil + } + return meta +} + +// setEdge idempotently sets the edge metadata for an instruction, returning the +// existing edge metadata if it already exists. +func setEdge(ins *asm.Instruction) *edge { + e := getEdgeMeta(ins) + if e == nil { + e = &edge{} + setEdgeMeta(ins, e) + } + return e +} + +// setEdgeBranchTarget sets the branch target for an edge instruction. This is +// used to mark the target of a jump instruction that branches to another basic +// block. +func setEdgeBranchTarget(ins *asm.Instruction, target *asm.Instruction) { + e := setEdge(ins) + e.branch = target +} + +// setEdgeFallthrough sets the fallthrough target for an edge instruction. This +// is used to mark the next instruction in the instruction stream that is +// executed if the branch is not taken, typically the instruction immediately +// following the branch instruction. +func setEdgeFallthrough(ins *asm.Instruction, target *asm.Instruction) { + e := setEdge(ins) + e.fthrough = target +} + +// setEdgeExit marks an instruction as an edge with no branch target, and +// optionally marks the next instruction as a leader. This is used for exit +// instructions that terminate a basic block without branching to another block. +func setEdgeExit(ins, next *asm.Instruction) { + setEdge(ins) + if next != nil { + setLeader(next) + } +} + +// setBranchTarget creates a two-way association between both the branch +// instruction and its target instruction, as well as the target instruction and +// its natural predecessor. prev may be nil if the branch target is the first +// instruction in the program. +// +// This process creates two edges and a leader, updating existing metadata if +// the instructions were already marked as leaders or edges. +func setBranchTarget(branch, target, prev *asm.Instruction) { + // Associate the branch instruction with its target. The target becomes a + // leader, the branch instruction becomes an edge. + setEdgeBranchTarget(branch, target) + + // Create a reverse link from the branch target to both the branch (jump) + // instructions and the target's predecessor. + if canFallthrough(prev) { + // Creating a leader implicitly means making the instruction before it an + // edge with a fallthrough target. Associate the target with its predecessor. + setEdgeFallthrough(prev, target) + addPredecessors(target, branch, prev) + } else { + // If the instruction preceding the branch target cannot fall through (Ja, + // Exit), don't register it as a predecessor. + if prev != nil { + setEdge(prev) + } + addPredecessors(target, branch) + } +} + +// setBranchFallthrough sets the fallthrough target for a branch instruction. +func setBranchFallthrough(branch, fthrough *asm.Instruction) { + if fthrough == nil || !canFallthrough(branch) { + return + } + + setEdgeFallthrough(branch, fthrough) + addPredecessors(fthrough, branch) +} + +// A Block is a contiguous sequence of instructions that are executed together. +// Boundaries are defined by branching instructions. +// +// Blocks are attached to instructions via metadata and should not be modified +// after being created. +// +// It should never contain direct references to the original asm.Instructions +// since copying the ProgramSpec won't update pointers to the new copied insns. +// This is a problem when modifying instructions through +// [Blocks.LiveInstructions] after reachability analysis, since it would modify +// the original ProgramSpec's instructions. +type Block struct { + ID uint64 + Raw asm.RawInstructionOffset + Start, End int + + // If this block is the start of a function, sym is set to the function name. + sym string + + Predecessors []*Block + Branch *Block + Fthrough *Block + + // calls are blocks that are called from this block. + calls []*Block +} + +func (b *Block) leader(insns asm.Instructions) *leader { + if len(insns) == 0 { + return nil + } + return getLeaderMeta(&insns[b.Start]) +} + +func (b *Block) last(insns asm.Instructions) *asm.Instruction { + if len(insns) == 0 { + return nil + } + + if b.End >= len(insns) { + return nil + } + + return &insns[b.End] +} + +func (b *Block) edge(insns asm.Instructions) *edge { + last := b.last(insns) + if last == nil { + return nil + } + + return getEdgeMeta(last) +} + +func (b *Block) String() string { + return b.Dump(nil) +} + +func (b *Block) Dump(insns asm.Instructions) string { + var sb strings.Builder + + if b.sym != "" { + _, _ = fmt.Fprintf(&sb, "Function: %s\n", b.sym) + } + + sb.WriteString("Predecessors: [") + for i, from := range b.Predecessors { + if i > 0 { + _, _ = sb.WriteString(", ") + } + _, _ = fmt.Fprintf(&sb, "%d", from.ID) + } + _, _ = sb.WriteString("]\n") + _, _ = fmt.Fprintf(&sb, "Start: %d (raw %d), end: %d\n", b.Start, b.Raw, b.End) + _, _ = sb.WriteString("\n") + + if len(insns) != 0 { + sb.WriteString("Instructions:\n") + off := asm.RawInstructionOffset(b.Raw) + for i := b.Start; i <= b.End; i++ { + ins := &insns[i] + if ins.Symbol() != "" { + fmt.Fprintf(&sb, "\t%s:\n", ins.Symbol()) + } + if src := ins.Source(); src != nil { + line := strings.TrimSpace(src.String()) + if line != "" { + fmt.Fprintf(&sb, "\t%*s; %s\n", 4, " ", line) + } + } + fmt.Fprintf(&sb, "\t%*d: %v\n", 4, off, ins) + off += asm.RawInstructionOffset(ins.Size() / asm.InstructionSize) + } + sb.WriteString("\n") + } else { + sb.WriteString("Instructions: not provided, call Dump() with insns\n") + } + + if len(b.calls) > 0 { + sb.WriteString("Calls: [") + for i, callee := range b.calls { + if i > 0 { + _, _ = sb.WriteString(", ") + } + _, _ = fmt.Fprintf(&sb, "%d", callee.ID) + } + _, _ = sb.WriteString("]\n") + } + + if b.Branch != nil { + _, _ = sb.WriteString("Branch: ") + _, _ = fmt.Fprintf(&sb, "%d", b.Branch.ID) + _, _ = sb.WriteString("\n") + } + + if b.Fthrough != nil { + _, _ = sb.WriteString("Fallthrough: ") + _, _ = fmt.Fprintf(&sb, "%d", b.Fthrough.ID) + _, _ = sb.WriteString("\n") + } + + return sb.String() +} + +// getBlock retrieves the block associated with an instruction. It checks both +// the leader and edge metadata to find the block. If neither is found, it +// returns nil, indicating that the instruction forms neither the start nor end +// of a basic block. +func getBlock(ins *asm.Instruction) *Block { + l := getLeaderMeta(ins) + if l != nil { + return l.block + } + + e := getEdgeMeta(ins) + if e != nil { + return e.block + } + + return nil +} + +// Blocks is a list of basic blocks. +type Blocks []*Block + +func (bl Blocks) count() uint64 { + return uint64(len(bl)) +} + +func (bl *Blocks) add(b *Block) { + if b == nil { + return + } + + b.ID = uint64(bl.count()) + *bl = append(*bl, b) +} + +func (bl Blocks) first() *Block { + if len(bl) == 0 { + return nil + } + return bl[0] +} + +func (bl Blocks) String() string { + return bl.Dump(nil) +} + +func (bl Blocks) Dump(insns asm.Instructions) string { + var sb strings.Builder + for _, block := range bl { + _, _ = fmt.Fprintf(&sb, "\n=== Block %d ===\n", block.ID) + _, _ = sb.WriteString(block.Dump(insns)) + _, _ = sb.WriteString("\n") + } + return sb.String() +} + +// MakeBlocks returns a list of basic blocks of instructions that are always +// executed together. Multiple calls on the same insns will return the same +// Blocks object. +// +// Blocks are created by finding branches and jump targets in the given insns +// and cutting up the instruction stream accordingly. +func MakeBlocks(insns asm.Instructions) (Blocks, error) { + if len(insns) == 0 { + return nil, errors.New("insns is empty, cannot compute blocks") + } + + if blocks := loadBlocks(insns); blocks != nil { + return blocks, nil + } + + blocks, err := computeBlocks(insns) + if err != nil { + return nil, fmt.Errorf("computing blocks: %w", err) + } + + if err := storeBlocks(insns, blocks); err != nil { + return nil, fmt.Errorf("storing blocks: %w", err) + } + + return blocks, nil +} + +// computeBlocks computes the basic blocks from the given instruction stream. +func computeBlocks(insns asm.Instructions) (Blocks, error) { + if err := markBranches(insns); err != nil { + return nil, fmt.Errorf("marking branches: %w", err) + } + + blocks, callers, err := allocateBlocks(insns) + if err != nil { + return nil, fmt.Errorf("allocating blocks: %w", err) + } + + if err := connectBlocks(blocks, insns); err != nil { + return nil, fmt.Errorf("connecting blocks: %w", err) + } + + callers.connect(blocks) + + return blocks, nil +} + +// blocksKey is used to store Blocks in an instruction's metadata. +type blocksKey struct{} + +// storeBlocks associates the given Blocks with the first instruction in the +// given insns. +// +// If insns is empty, does nothing. +func storeBlocks(insns asm.Instructions, bl Blocks) error { + if len(insns) == 0 { + return errors.New("insns is empty, cannot store Blocks") + } + + insns[0].Metadata.Set(blocksKey{}, bl) + + return nil +} + +// loadBlocks retrieves the Blocks associated with the first instruction in the +// given insns. +// +// If no Blocks is present, returns nil. +func loadBlocks(insns asm.Instructions) Blocks { + if len(insns) == 0 { + return nil + } + + blocks, ok := insns[0].Metadata.Get(blocksKey{}).(Blocks) + if !ok { + return nil + } + + return blocks +} + +// markBranches identifies all jump targets in the instruction stream and marks +// branch and fallthrough edges accordingly. +// +// It performs two passes over the instruction stream: the first pass identifies +// all branch instructions and their raw jump targets, while the second pass +// resolves these raw targets to actual instruction pointers. +func markBranches(insns asm.Instructions) error { + var targets rawTargets + + i := insns.Iterate() + for i.Next() { + // Set a leader on symbols, as they mark the start of functions. + if sym := i.Ins.Symbol(); sym != "" { + setLeader(i.Ins) + } + + switch op := i.Ins.OpCode.JumpOp(); op { + // No branch, ignore instruction. + case asm.InvalidJumpOp, asm.Call: + continue + + // There may be more instructions or another program after an exit. Emit an + // edge with no branch or fallthrough and a leader on the next instruction, + // if any. + case asm.Exit: + setEdgeExit(i.Ins, next(i, insns)) + + // Regular branching instructions. + default: + raw, err := jumpTarget(i.Offset, i.Ins) + if err != nil { + return fmt.Errorf("determine jump target instruction offset: %w", err) + } + + setBranchFallthrough(i.Ins, next(i, insns)) + + // Queue the target by its raw instruction offset be updated with a branch + // instruction pointer during a second pass since we cannot perform random + // lookups of instructions by their raw offsets. + targets.add(i.Ins, raw) + } + } + + // Second pass for resolving jumps to their target instructions. This can only + // be done after all raw jump offsets have been identified, since jumps can be + // forward or backward. + i = insns.Iterate() + for i.Next() { + // Map the raw instruction offset to its logical instruction. In case of a + // jump to the first instruction, the target has no prior instruction and + // tgtPrev will be nil. + targets.resolve(i.Offset, i.Ins, previous(i, insns)) + } + + return nil +} + +// allocateBlocks returns a list of blocks based on leaders and edges identified +// in prior stages. It creates a new block whenever it encounters a leader +// instruction and finalizes the current one when it reaches an edge +// instruction. No blocks are pointing to each other yet, this is done in a +// subsequent step. +func allocateBlocks(insns asm.Instructions) (Blocks, bpfCallers, error) { + if len(insns) == 0 { + return nil, nil, errors.New("insns is empty, cannot allocate blocks") + } + + // Expect at least one block. + blocks := make(Blocks, 0, 1) + callers := make(bpfCallers) + + var block *Block + i := insns.Iterate() + for i.Next() { + // Roll over to the next block if this is a leader. + if nextBlock := maybeAllocateBlock(i); nextBlock != nil { + block = nextBlock + blocks.add(block) + } + + // Record function references in the current block. + callers.record(i.Ins, block) + + // Finalize the block if we've reached an edge. + maybeFinalizeBlock(block, i) + } + + if blocks.count() == 0 { + return nil, nil, errors.New("no blocks created, this is a bug") + } + + if start := blocks.first().Start; start != 0 { + return nil, nil, fmt.Errorf("first block starts at instruction index %d; this could be a bug, or the first insn is not a symbol", start) + } + + return blocks, callers, nil +} + +// maybeAllocateBlock allocates a new block for the instruction pointed to by +// the iterator if it is a leader instruction. If the instruction is not a +// leader, it returns nil. This is used to start a new basic block when +// encountering a leader instruction in the instruction stream. +func maybeAllocateBlock(i *asm.InstructionIterator) *Block { + l := getLeaderMeta(i.Ins) + if l == nil { + return nil + } + l.block = &Block{ + sym: i.Ins.Symbol(), + Start: i.Index, + Raw: i.Offset, + } + return l.block +} + +// maybeFinalizeBlock finalizes the current block by setting its end index and +// associating it with the edge metadata if the instruction is an edge. +// If the instruction is not an edge or the given block is nil, it does nothing. +func maybeFinalizeBlock(blk *Block, i *asm.InstructionIterator) { + e := getEdgeMeta(i.Ins) + if e == nil { + return + } + if blk == nil { + return + } + blk.End = i.Index + e.block = blk +} + +// connectBlocks connects the blocks in the given block list by setting their +// predecessors, branch and fallthrough targets based on the relationships +// between instructions identified in prior steps. Assumes that blocks have been +// allocated and that the leaders and edges have been tagged. +func connectBlocks(blocks Blocks, insns asm.Instructions) error { + if blocks.count() == 0 { + return errors.New("no blocks to connect, this is a bug") + } + + // Wire all blocks together by setting their predecessors, branch and + // fallthrough targets. + for _, blk := range blocks { + // Predecessors of the first instruction are the block's predecessors. + leader := blk.leader(insns) + if leader == nil { + return fmt.Errorf("block %d has no leader", blk.ID) + } + + blk.Predecessors = make([]*Block, 0, len(leader.predecessors)) + for _, pi := range leader.predecessors { + b := getBlock(pi) + if b == nil { + return fmt.Errorf("predecessor instruction %v has no block", pi) + } + blk.Predecessors = append(blk.Predecessors, b) + } + + // Branch/fthrough targets of the last instruction are the block's branch + // and fallthrough targets. + edge := blk.edge(insns) + if edge == nil { + return fmt.Errorf("block %d has no edge", blk.ID) + } + + if edge.branch != nil { + // If the edge has a branch target, set it as the block's branch target. + b := getBlock(edge.branch) + if b == nil { + return fmt.Errorf("branch target %v has no block", edge.branch) + } + blk.Branch = b + } + + if edge.fthrough != nil { + // If the edge has a fallthrough target, set it as the block's fallthrough + // target. + b := getBlock(edge.fthrough) + if b == nil { + return fmt.Errorf("fallthrough target %v has no block", edge.fthrough) + } + blk.Fthrough = b + } + } + + return nil +} diff --git a/internal/analyze/blocks_test.go b/internal/analyze/blocks_test.go new file mode 100644 index 0000000..da7efa4 --- /dev/null +++ b/internal/analyze/blocks_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package analyze + +import ( + "fmt" + "slices" + "testing" + + "github.com/cilium/ebpf/asm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func branchingProg(tb testing.TB, n int) asm.Instructions { + tb.Helper() + + // A program that ends up being cut into n blocks. + orig := make(asm.Instructions, 0, n) + for i := range n - 1 { + ins := asm.JEq.Imm(asm.R0, int32(i), "") + ins.Offset = 0 + orig = append(orig, ins) + } + orig[0] = orig[0].WithSymbol("prog") + orig = append(orig, asm.Return()) + + return orig +} + +func callingProg(tb testing.TB, n int) asm.Instructions { + tb.Helper() + + // Each function call results in a new block. + // Exit is in its own block at the end of the program. + // Each function gets a block as well. + orig := make(asm.Instructions, 0, (n*3)+1) + for i := range n { + orig = append(orig, asm.Call.Label(fmt.Sprintf("fn%d", i))) + } + orig[0] = orig[0].WithSymbol("prog") + orig = append(orig, asm.Return()) + + for i := range n { + fn := []asm.Instruction{ + asm.Mov.Imm(asm.R0, int32(i)).WithSymbol(fmt.Sprintf("fn%d", i)), + asm.Return(), + } + orig = append(orig, fn...) + } + + return orig +} + +func TestMakeBlocksSimple(t *testing.T) { + // A valid program with no branches. + insns := asm.Instructions{ + asm.Mov.Imm32(asm.R0, 0).WithSymbol("prog"), + asm.Return(), + } + + b, err := MakeBlocks(insns) + require.NoError(t, err) + + assert.EqualValues(t, 1, b.count()) + + block := b.first() + assert.EqualValues(t, 0, block.ID) + assert.Equal(t, 0, block.Start) + assert.Equal(t, 1, block.End) + assert.Empty(t, block.Predecessors) + assert.Nil(t, block.Branch) + assert.Nil(t, block.Fthrough) + + b2, err := MakeBlocks(insns) + require.NoError(t, err) + assert.Equal(t, b, b2) +} + +func TestMakeBlocksManyBranches(t *testing.T) { + insns := branchingProg(t, 1000) + + b, err := MakeBlocks(insns) + require.NoError(t, err) + + assert.EqualValues(t, 1000, b.count()) + + b2, err := MakeBlocks(insns) + require.NoError(t, err) + assert.Equal(t, b, b2) +} + +func TestMakeBlocksCalls(t *testing.T) { + // A valid program with calls. + insns := asm.Instructions{ + asm.Mov.Imm(asm.R0, 0).WithSymbol("prog"), + asm.Call.Label("fn"), + asm.Return(), + asm.Mov.Imm32(asm.R0, 0).WithSymbol("fn"), + asm.Return(), + } + + b, err := MakeBlocks(insns) + require.NoError(t, err) + + require.Len(t, b, 2) + + blk := b.first() + assert.Empty(t, blk.Predecessors) + assert.Nil(t, blk.Fthrough) + assert.Nil(t, blk.Branch) + assert.Equal(t, 0, blk.Start) + assert.Equal(t, 2, blk.End) + + blk = b[1] + assert.Empty(t, blk.Predecessors) + assert.Nil(t, blk.Fthrough) + assert.Nil(t, blk.Branch) + assert.Equal(t, 3, blk.Start) + assert.Equal(t, 4, blk.End) + + b2, err := MakeBlocks(insns) + require.NoError(t, err) + assert.Equal(t, b, b2) +} + +func TestMakeBlocksManyCalls(t *testing.T) { + insns := callingProg(t, 100) + + b, err := MakeBlocks(insns) + require.NoError(t, err) + require.Len(t, b, 101) + + b2, err := MakeBlocks(insns) + require.NoError(t, err) + assert.Equal(t, b, b2) +} + +func TestBlocksDump(t *testing.T) { + insns := branchingProg(t, 100) + + b, err := MakeBlocks(insns) + require.NoError(t, err) + + // Dump the blocks to a string and make sure it doesn't panic. + dump := b.Dump(insns) + + assert.NotEmpty(t, dump) +} + +func BenchmarkComputeBlocks(b *testing.B) { + b.ReportAllocs() + + // Program with a 1000 branches resulting in 1000 blocks. + orig := branchingProg(b, 1000) + + for b.Loop() { + b.StopTimer() + insns := slices.Clone(orig) + b.StartTimer() + + if _, err := computeBlocks(insns); err != nil { + b.Fatal("Error making block list:", err) + } + } +} + +func BenchmarkComputeBlocksCalls(b *testing.B) { + b.ReportAllocs() + + // Program with 500 calls to 500 functions, resulting in 1000 blocks (plus 1 + // for Exit). + orig := callingProg(b, 500) + + for b.Loop() { + b.StopTimer() + insns := slices.Clone(orig) + b.StartTimer() + + if _, err := computeBlocks(insns); err != nil { + b.Fatal("Error making block list:", err) + } + } +} diff --git a/internal/analyze/util.go b/internal/analyze/util.go new file mode 100644 index 0000000..790eeb3 --- /dev/null +++ b/internal/analyze/util.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package analyze + +import ( + "fmt" + "slices" + + "github.com/cilium/ebpf/asm" +) + +// A target is the destination of a jump instruction. It is initially known only +// by its raw instruction offset and is resolved to a logical instruction in a +// second pass. It also holds a list of branches (jump instructions) that target +// it. +// +// The raw instruction offset is the offset of the instruction in the raw +// bytecode, which is not necessarily the same as its index in +// [asm.Instructions] since some instructions can be larger than the standard +// instruction size (e.g. dword loads). +type target struct { + raw asm.RawInstructionOffset + branches []*asm.Instruction +} + +func (t *target) append(branch *asm.Instruction) { + t.branches = append(t.branches, branch) +} + +// rawTargets tracks jump target instructions by their raw instruction offsets +// and branch instructions pointing to it. +type rawTargets struct { + targets []target +} + +// add marks the given raw offset as the target of a jump instruction. If the +// offset was seen before, the jump is added to the existing target. +// +// Offsets are encountered in random order while iterating instructions, since +// jumps can go forward and backward, and sometimes jump over other branches. +// targets are kept in a sorted queue to allow efficient resolution later. +func (rt *rawTargets) add(jump *asm.Instruction, tgt asm.RawInstructionOffset) { + insertIdx, found := slices.BinarySearchFunc(rt.targets, tgt, func(t target, r asm.RawInstructionOffset) int { + if t.raw < r { + return -1 + } + if t.raw > r { + return 1 + } + return 0 + }) + + if found { + rt.targets[insertIdx].append(jump) + return + } + + rt.targets = slices.Insert( + rt.targets, + insertIdx, + target{ + raw: tgt, + branches: []*asm.Instruction{jump}, + }) +} + +// resolve needs to be called sequentially for every instruction in a program +// after all jump targets have been added using [rawTargets.add]. +// +// The given raw offset is matched against the first entry in rt. If it matches, +// all jumps pointing to this instruction get their branch targets updated to +// point to tgt. tgtPrev turns into an edge falling through to tgt. +// +// Resolved targets are popped from the head of the list. +func (rt *rawTargets) resolve(raw asm.RawInstructionOffset, tgt, tgtPrev *asm.Instruction) { + if len(rt.targets) == 0 { + return + } + + target := rt.targets[0] + if target.raw != raw { + return + } + + for _, branch := range target.branches { + setBranchTarget(branch, tgt, tgtPrev) + } + + rt.targets = rt.targets[1:] +} + +// previous returns the instruction preceding the current instruction in the +// iterator, or nil if there is none. +func previous(iter *asm.InstructionIterator, insns asm.Instructions) *asm.Instruction { + if iter.Index-1 >= 0 { + return &insns[iter.Index-1] + } + return nil +} + +// next returns the instruction following the current instruction in the +// iterator, or nil if there is none. +func next(iter *asm.InstructionIterator, insns asm.Instructions) *asm.Instruction { + if iter.Index+1 < len(insns) { + return &insns[iter.Index+1] + } + return nil +} + +// jumpTarget calculates the target of a jump instruction based on the current +// raw instruction offset and the offset or constant present in the instruction. +// It returns the target offset or an error if the instruction does not branch. +func jumpTarget(raw asm.RawInstructionOffset, ins *asm.Instruction) (asm.RawInstructionOffset, error) { + op := ins.OpCode + class := op.Class() + jump := op.JumpOp() + + // Only jump instructions cause a branch to another block. Execution ends at + // an exit instruction. And calls do not cause a branch, execution continues + // after the call. + if !class.IsJump() || jump == asm.Exit || jump == asm.Call { + return 0, fmt.Errorf("not a jump: %s", ins) + } + + // Jump target is the current offset + the instruction offset + 1 + target := int64(raw) + int64(ins.Offset) + 1 + // A jump32 + JA is a 'long jump' with an offset larger than a u16. This is + // encoded in the Constant field. + if class == asm.Jump32Class && jump == asm.Ja { + target = int64(raw) + ins.Constant + 1 + } + + if target < 0 { + return 0, fmt.Errorf("jump target before start of program: %d, raw: %d, insn: %s", target, raw, ins) + } + + return asm.RawInstructionOffset(target), nil +} + +// canFallthrough checks if execution can fall through to the next instruction. +// +// An instruction can fall through if it is not a jump instruction, or if it is +// a jump instruction other than a jump-always and an exit. +func canFallthrough(ins *asm.Instruction) bool { + if ins == nil { + return false + } + if ins.OpCode.JumpOp() == asm.Ja || + ins.OpCode.JumpOp() == asm.Exit { + return false + } + + return true +} + +// bpfCallers maps bpf2bpf function names to Blocks that refer to them. +type bpfCallers map[string][]*Block + +// record is to be called for each instruction in a block to record function +// references found in the instructions. +// +// Blocks that contain function references get their callees populated in a +// later call to [bpfCallers.connect]. +func (bc bpfCallers) record(ins *asm.Instruction, caller *Block) { + if !ins.IsFunctionReference() { + return + } + + if sym := ins.Reference(); sym != "" { + bc[sym] = append(bc[sym], caller) + } +} + +// connect populates the [Block.calls] field of all Blocks that contain function +// references with the corresponding callee Blocks. +func (bc bpfCallers) connect(blocks Blocks) { + for _, block := range blocks { + // Check if the block represents the start of a function. + callee := block.sym + if callee == "" { + continue + } + + // Find all callers of this function. + callers := bc[callee] + if callers == nil { + continue + } + + // Link callers to this callee block. + for _, caller := range callers { + caller.calls = append(caller.calls, block) + } + } +}