From cc8f8c92bab6d25f51a8da2756117375b06ae032 Mon Sep 17 00:00:00 2001 From: Dylan Reimerink Date: Thu, 7 May 2026 16:45:12 +0200 Subject: [PATCH 1/2] Merge the `collection` and `program` commands in `list` The user experience of having a separate subcommands was suboptimal. Specifically when a user does not know the names of all available program in a collection. It is not easier to first run `stackwhere list /path/to/collection.o` to get the list of programs, and then append the program name to the command to get the stack listing of a specific program. We still keep this behind the `list` subcommand to allow for additional features under different subcommands in the future. Signed-off-by: Dylan Reimerink --- README.md | 11 +- cmd/stackwhere/collection.go | 114 ------------------ cmd/stackwhere/collection_test.go | 29 ----- cmd/stackwhere/{program.go => list.go} | 110 +++++++++++++++-- .../{program_test.go => list_test.go} | 22 ++++ cmd/stackwhere/main.go | 10 +- 6 files changed, 134 insertions(+), 162 deletions(-) delete mode 100644 cmd/stackwhere/collection.go delete mode 100644 cmd/stackwhere/collection_test.go rename cmd/stackwhere/{program.go => list.go} (65%) rename cmd/stackwhere/{program_test.go => list_test.go} (72%) diff --git a/README.md b/README.md index 7d2197b..d86d428 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ be stripped. ### Inspecting collections -The `collection` (alias `coll`/`c`) sub-command lists the peak stack usage for each program / function in the given -object file. +The `list` (alias `l`) sub-command lists the peak stack usage for each program / function in the given +only a object file. ``` -$ stackwhere collection {path to .o} +$ stackwhere list {path to .o} 128 bytes - my_example_program 80 bytes - my_other_program 20 bytes - small_function @@ -49,10 +49,11 @@ $ stackwhere collection {path to .o} ### Inspecting programs -The `program` (alias `prog`/`p`) sub-command lists the stack usage of a single program / function. +The `list` (alias `l`) sub-command lists a detailed per program listing when a program name is specified as the second +argument. ``` -$ stackwhere program {path to .o} {name of program} +$ stackwhere list {path to .o} {name of program} R10-16: 8 - example_var1 @ /some/path/to/your/sources/bpf.c:12 R10-24: diff --git a/cmd/stackwhere/collection.go b/cmd/stackwhere/collection.go deleted file mode 100644 index e9c6e74..0000000 --- a/cmd/stackwhere/collection.go +++ /dev/null @@ -1,114 +0,0 @@ -package main - -import ( - "fmt" - "maps" - "slices" - - "github.com/cilium/stackwhere/internal/dwarf" - "github.com/spf13/cobra" -) - -func collectionStackListCommand() *cobra.Command { - c := &cobra.Command{ - Use: "collection {collection}", - Aliases: []string{"coll", "c"}, - Short: "Prints the stack usage for each program in a collection.", - Long: "Prints the stack usage for each program in a given collection.", - Example: "stackwhere collection /path/to/collection.o", - Args: cobra.ExactArgs(1), - RunE: (&collectionStackList{}).runE, - } - - return c -} - -type collectionStackList struct{} - -func (csl *collectionStackList) runE(cmd *cobra.Command, args []string) error { - collectionPath := args[0] - - tree, err := dwarf.NewDWARFTree(collectionPath) - if err != nil { - return fmt.Errorf("failed to parse DWARF data: %w", err) - } - - stackUsagePerProgram := map[string]int64{} - for _, prog := range tree.ByType(dwarf.TagSubprogram) { - if !isBPFProgram(prog) { - continue - } - - stackUsagePerProgram[prog.Name()] = getProgramStackUsage(prog) - } - - // Sort by stack usage, largest first, and then by name, and print. - keys := slices.Collect(maps.Keys(stackUsagePerProgram)) - slices.SortFunc(keys, func(a, b string) int { - return int(stackUsagePerProgram[b] - stackUsagePerProgram[a]) - }) - for _, prog := range keys { - fmt.Printf("%3d bytes - %s\n", stackUsagePerProgram[prog], prog) - } - - return nil -} - -func getProgramStackUsage(prog *dwarf.Node) int64 { - largestOffset := int64(0) - lastSize := int64(0) - dwarf.VisitPrefixOrder(prog, func(n *dwarf.Node) { - // Only consider variables and function parameters since those are the things that can be stored on the stack. - if n.Entry().Tag != dwarf.TagVariable && n.Entry().Tag != dwarf.TagFormalParameter { - return - } - - // Find all stack offsets used by this variable. - offsets := stackOffsets(n) - if len(offsets) == 0 { - return - } - - // If this was the largest stack offset we've seen so far, then the total stack usage must be at least - // large enough to fit this variable. If this variable has the same largest offset as a previous variable, - // but is larger than that previous variable, then the total stack usage must be increased to fit this variable. - sz := n.ByteSize() - for _, offset := range offsets { - if offset > largestOffset { - largestOffset = offset - lastSize = sz - } - if offset == largestOffset && sz > lastSize { - lastSize = sz - } - } - }) - - // Stack usage is always a multiple of 8 bytes, so round up to the nearest multiple of 8. - stackUsage := largestOffset + lastSize - if stackUsage%8 != 0 { - stackUsage = ((stackUsage / 8) + 1) * 8 - } - - return stackUsage -} - -func isBPFProgram(n *dwarf.Node) bool { - if n.Entry().Tag != dwarf.TagSubprogram { - return false - } - - if n.Entry().Val(dwarf.AttrName) == nil { - return false - } - - if n.Entry().Val(dwarf.AttrInline) != nil { - return false - } - - if n.Entry().Val(dwarf.AttrType) == nil { - return false - } - - return true -} diff --git a/cmd/stackwhere/collection_test.go b/cmd/stackwhere/collection_test.go deleted file mode 100644 index faf002a..0000000 --- a/cmd/stackwhere/collection_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "testing" - - "github.com/cilium/stackwhere/internal/dwarf" -) - -func TestGetProgramStackUsage(t *testing.T) { - tree, err := dwarf.NewDWARFTree("../../testdata/basic.o") - if err != nil { - t.Fatalf("failed to parse DWARF data: %v", err) - } - - stackUsagePerProgram := map[string]int64{} - for _, prog := range tree.ByType(dwarf.TagSubprogram) { - if !isBPFProgram(prog) { - continue - } - - stackUsagePerProgram[prog.Name()] = getProgramStackUsage(prog) - } - - stackUsage := stackUsagePerProgram["cil_entry"] - expectedStackUsage := int64(56) - if stackUsage != expectedStackUsage { - t.Errorf("expected stack usage %d, got %d", expectedStackUsage, stackUsage) - } -} diff --git a/cmd/stackwhere/program.go b/cmd/stackwhere/list.go similarity index 65% rename from cmd/stackwhere/program.go rename to cmd/stackwhere/list.go index 08003e1..c9c82b3 100644 --- a/cmd/stackwhere/program.go +++ b/cmd/stackwhere/list.go @@ -11,14 +11,14 @@ import ( "github.com/spf13/cobra" ) -func programStackListCommand() *cobra.Command { +func listCommand() *cobra.Command { c := &cobra.Command{ - Use: "program {collection} {program}", - Aliases: []string{"prog", "p"}, - Short: "Prints the program stack listing for a given program.", - Long: "Prints the program stack listing for a given program.", - Example: "stackwhere program /path/to/collection.o my_program", - Args: cobra.ExactArgs(2), + Use: "list {collection} [program]", + Aliases: []string{"l"}, + Short: "Prints the stack usage of all programs, or the stack listing of a specific program.", + Long: "Prints the stack usage of all programs, or the stack listing of a specific program.", + Example: "stackwhere list /path/to/collection.o my_program", + Args: cobra.RangeArgs(1, 2), } flags := c.Flags() @@ -35,6 +35,14 @@ type programStackList struct { } func (psl *programStackList) runE(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + return psl.runListCollection(cmd, args) + } + + return psl.runListProgram(cmd, args) +} + +func (psl *programStackList) runListProgram(cmd *cobra.Command, args []string) error { collectionPath := args[0] functionName := args[1] @@ -206,3 +214,91 @@ func stackOffsets(n *dwarf.Node) []int64 { slices.Sort(offsets) return slices.Compact(offsets) } + +func (psl *programStackList) runListCollection(cmd *cobra.Command, args []string) error { + collectionPath := args[0] + + tree, err := dwarf.NewDWARFTree(collectionPath) + if err != nil { + return fmt.Errorf("failed to parse DWARF data: %w", err) + } + + stackUsagePerProgram := map[string]int64{} + for _, prog := range tree.ByType(dwarf.TagSubprogram) { + if !isBPFProgram(prog) { + continue + } + + stackUsagePerProgram[prog.Name()] = getProgramStackUsage(prog) + } + + // Sort by stack usage, largest first, and then by name, and print. + keys := slices.Collect(maps.Keys(stackUsagePerProgram)) + slices.SortFunc(keys, func(a, b string) int { + return int(stackUsagePerProgram[b] - stackUsagePerProgram[a]) + }) + for _, prog := range keys { + fmt.Printf("%3d bytes - %s\n", stackUsagePerProgram[prog], prog) + } + + return nil +} + +func getProgramStackUsage(prog *dwarf.Node) int64 { + largestOffset := int64(0) + lastSize := int64(0) + dwarf.VisitPrefixOrder(prog, func(n *dwarf.Node) { + // Only consider variables and function parameters since those are the things that can be stored on the stack. + if n.Entry().Tag != dwarf.TagVariable && n.Entry().Tag != dwarf.TagFormalParameter { + return + } + + // Find all stack offsets used by this variable. + offsets := stackOffsets(n) + if len(offsets) == 0 { + return + } + + // If this was the largest stack offset we've seen so far, then the total stack usage must be at least + // large enough to fit this variable. If this variable has the same largest offset as a previous variable, + // but is larger than that previous variable, then the total stack usage must be increased to fit this variable. + sz := n.ByteSize() + for _, offset := range offsets { + if offset > largestOffset { + largestOffset = offset + lastSize = sz + } + if offset == largestOffset && sz > lastSize { + lastSize = sz + } + } + }) + + // Stack usage is always a multiple of 8 bytes, so round up to the nearest multiple of 8. + stackUsage := largestOffset + lastSize + if stackUsage%8 != 0 { + stackUsage = ((stackUsage / 8) + 1) * 8 + } + + return stackUsage +} + +func isBPFProgram(n *dwarf.Node) bool { + if n.Entry().Tag != dwarf.TagSubprogram { + return false + } + + if n.Entry().Val(dwarf.AttrName) == nil { + return false + } + + if n.Entry().Val(dwarf.AttrInline) != nil { + return false + } + + if n.Entry().Val(dwarf.AttrType) == nil { + return false + } + + return true +} diff --git a/cmd/stackwhere/program_test.go b/cmd/stackwhere/list_test.go similarity index 72% rename from cmd/stackwhere/program_test.go rename to cmd/stackwhere/list_test.go index a1b4a74..67fbb4f 100644 --- a/cmd/stackwhere/program_test.go +++ b/cmd/stackwhere/list_test.go @@ -71,3 +71,25 @@ func TestGetStackSlotUsage(t *testing.T) { } } } + +func TestGetProgramStackUsage(t *testing.T) { + tree, err := dwarf.NewDWARFTree("../../testdata/basic.o") + if err != nil { + t.Fatalf("failed to parse DWARF data: %v", err) + } + + stackUsagePerProgram := map[string]int64{} + for _, prog := range tree.ByType(dwarf.TagSubprogram) { + if !isBPFProgram(prog) { + continue + } + + stackUsagePerProgram[prog.Name()] = getProgramStackUsage(prog) + } + + stackUsage := stackUsagePerProgram["cil_entry"] + expectedStackUsage := int64(56) + if stackUsage != expectedStackUsage { + t.Errorf("expected stack usage %d, got %d", expectedStackUsage, stackUsage) + } +} diff --git a/cmd/stackwhere/main.go b/cmd/stackwhere/main.go index 5c8fce6..f18c624 100644 --- a/cmd/stackwhere/main.go +++ b/cmd/stackwhere/main.go @@ -25,15 +25,11 @@ func root() *cobra.Command { }, ) - programStackListCmd := programStackListCommand() - programStackListCmd.GroupID = primaryGroupID - - collectionStackListCmd := collectionStackListCommand() - collectionStackListCmd.GroupID = primaryGroupID + listCmd := listCommand() + listCmd.GroupID = primaryGroupID c.AddCommand( - programStackListCmd, - collectionStackListCmd, + listCmd, versionCommand(), ) From 1d976b6bbf16aa35c6c82e05800d4a107836aa60 Mon Sep 17 00:00:00 2001 From: Dylan Reimerink Date: Thu, 7 May 2026 17:02:45 +0200 Subject: [PATCH 2/2] Add pretty-printed JSON output flag Added global `--json`/`-j` flag and made every command output JSON when the flag is set. The JSON output is pretty-printed for human inspection. This allows stackwhere to be used in scripts and pipelines. Signed-off-by: Dylan Reimerink --- README.md | 2 +- cmd/stackwhere/list.go | 79 ++++++++++++++++++++++++++----------- cmd/stackwhere/list_test.go | 2 +- cmd/stackwhere/main.go | 13 ++++++ cmd/stackwhere/version.go | 53 ++++++++++++++++++++++--- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index d86d428..694d085 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ be stripped. ### Inspecting collections The `list` (alias `l`) sub-command lists the peak stack usage for each program / function in the given -only a object file. +object file. ``` $ stackwhere list {path to .o} diff --git a/cmd/stackwhere/list.go b/cmd/stackwhere/list.go index c9c82b3..489810e 100644 --- a/cmd/stackwhere/list.go +++ b/cmd/stackwhere/list.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "maps" "slices" @@ -52,13 +53,22 @@ func (psl *programStackList) runListProgram(cmd *cobra.Command, args []string) e } usage := getStackSlotUsage(tree, functionName) - for _, slots := range usage { - fmt.Printf("R10-%d:\n", slots[0].offset) - for _, slot := range slots { - fmt.Printf(" %d - %s @ %s\n", slot.byteSize, slot.name, slot.fileCol) - if *psl.flagCallStack { - for _, entry := range slot.callstack { - fmt.Printf(" %s @ %s\n", entry.name, entry.fileCol) + if jsonOutput(cmd) { + e := json.NewEncoder(cmd.OutOrStdout()) + e.SetIndent("", " ") + if err := e.Encode(usage); err != nil { + return fmt.Errorf("failed to encode stack usage data to JSON: %w", err) + } + return nil + } else { + for _, slots := range usage { + fmt.Printf("R10-%d:\n", slots[0].Offset) + for _, slot := range slots { + fmt.Printf(" %d - %s @ %s\n", slot.ByteSize, slot.Name, slot.FileCol) + if *psl.flagCallStack { + for _, entry := range slot.Callstack { + fmt.Printf(" %s @ %s\n", entry.Name, entry.FileCol) + } } } } @@ -68,16 +78,16 @@ func (psl *programStackList) runListProgram(cmd *cobra.Command, args []string) e } type slotUsage struct { - offset int64 - name string - byteSize int64 - fileCol string - callstack []callStackEntry + Offset int64 `json:"offset"` + Name string `json:"name"` + ByteSize int64 `json:"byte_size"` + FileCol string `json:"file_col"` + Callstack []callStackEntry `json:"callstack,omitempty"` } type callStackEntry struct { - name string - fileCol string + Name string `json:"name"` + FileCol string `json:"file_col"` } // getStackSlotUsage returns a list of stack slots used by the given function, sorted by their offset from R10 (largest offset first). @@ -147,21 +157,21 @@ func getStackSlotUsage(tree *dwarf.Tree, functionName string) [][]slotUsage { continue } callstack = append(callstack, callStackEntry{ - name: parent.Name(), - fileCol: parent.FileCol(), + Name: parent.Name(), + FileCol: parent.FileCol(), }) } usage := []slotUsage{{ - offset: offset, - name: n.Name(), - byteSize: n.ByteSize(), - fileCol: n.FileCol(), - callstack: callstack, + Offset: offset, + Name: n.Name(), + ByteSize: n.ByteSize(), + FileCol: n.FileCol(), + Callstack: callstack, }} i, found := slices.BinarySearchFunc(result, usage, func(a, b []slotUsage) int { - return int(a[0].offset - b[0].offset) + return int(a[0].Offset - b[0].Offset) }) if found { result[i] = append(result[i], usage...) @@ -237,8 +247,31 @@ func (psl *programStackList) runListCollection(cmd *cobra.Command, args []string slices.SortFunc(keys, func(a, b string) int { return int(stackUsagePerProgram[b] - stackUsagePerProgram[a]) }) + + type programStackUsage struct { + Name string `json:"name"` + StackUsage int64 `json:"stack_usage"` + } + + out := []programStackUsage{} for _, prog := range keys { - fmt.Printf("%3d bytes - %s\n", stackUsagePerProgram[prog], prog) + out = append(out, programStackUsage{ + Name: prog, + StackUsage: stackUsagePerProgram[prog], + }) + } + + if jsonOutput(cmd) { + e := json.NewEncoder(cmd.OutOrStdout()) + e.SetIndent("", " ") + if err := e.Encode(out); err != nil { + return fmt.Errorf("failed to encode stack usage data to JSON: %w", err) + } + return nil + } else { + for _, prog := range keys { + fmt.Printf("%3d bytes - %s\n", stackUsagePerProgram[prog], prog) + } } return nil diff --git a/cmd/stackwhere/list_test.go b/cmd/stackwhere/list_test.go index 67fbb4f..dacfd4f 100644 --- a/cmd/stackwhere/list_test.go +++ b/cmd/stackwhere/list_test.go @@ -49,7 +49,7 @@ func TestGetStackSlotUsage(t *testing.T) { for _, group := range slotGroups { found := make(map[string]int64) for _, slot := range stackUsage[group.index] { - found[slot.name] = slot.byteSize + found[slot.Name] = slot.ByteSize } // Check all expected slots are present with correct size diff --git a/cmd/stackwhere/main.go b/cmd/stackwhere/main.go index f18c624..77a1937 100644 --- a/cmd/stackwhere/main.go +++ b/cmd/stackwhere/main.go @@ -12,11 +12,24 @@ func main() { } } +const jsonFlagName = "json" + +func jsonOutput(cmd *cobra.Command) bool { + jsonFlag, err := cmd.Flags().GetBool(jsonFlagName) + if err != nil { + return false + } + return jsonFlag +} + func root() *cobra.Command { c := &cobra.Command{ Use: "stackwhere", } + pFlags := c.PersistentFlags() + pFlags.BoolP(jsonFlagName, "j", false, "Output in JSON format") + const primaryGroupID = "primary" c.AddGroup( &cobra.Group{ diff --git a/cmd/stackwhere/version.go b/cmd/stackwhere/version.go index 650beae..a327167 100644 --- a/cmd/stackwhere/version.go +++ b/cmd/stackwhere/version.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "runtime" "time" @@ -21,18 +22,58 @@ func versionCommand() *cobra.Command { Use: "version", Short: "Prints the version of stackwhere and copyright information.", Long: "Prints the version of stackwhere and copyright information.", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("version:", versionString()) - fmt.Println("go:", goVersion()) - fmt.Println() - fmt.Println("Copyright (c) 2026 Cilium authors") - fmt.Println("Copyright (c) 2014 Derek Parker") + RunE: func(cmd *cobra.Command, args []string) error { + data := getVersionData() + if jsonOutput(cmd) { + e := json.NewEncoder(cmd.OutOrStdout()) + e.SetIndent("", " ") + if err := e.Encode(data); err != nil { + return err + } + + return nil + } + + w := cmd.OutOrStdout() + if _, err := fmt.Fprintln(w, "version:", data.Version); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "go:", data.GoVersion); err != nil { + return err + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + for _, c := range data.Copyright { + if _, err := fmt.Fprintln(w, c); err != nil { + return err + } + } + + return nil }, } return c } +type versionData struct { + Version string `json:"version"` + GoVersion string `json:"go_version"` + Copyright []string `json:"copyright"` +} + +func getVersionData() versionData { + return versionData{ + Version: versionString(), + GoVersion: goVersion(), + Copyright: []string{ + "Copyright (c) 2026 Cilium authors", + "Copyright (c) 2014 Derek Parker", + }, + } +} + func versionString() string { if date == "" { return "v0.0.0 (development build)"