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) + } + } +}