Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions ebpftracer/elf.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ func (s *Symbol) ReturnOffsets() ([]int, error) {
return offsets, nil
}

// StackCheckEnd returns the offset of the first instruction past the
// function's stack check, or 0 if its prologue has none that is recognized.
// See stackCheckEnd.
func (s *Symbol) StackCheckEnd() (int, error) {
text, reader, err := s.f.getTextSectionAndReader()
if err != nil {
return 0, err
}
if s.value < text.Addr || s.size > text.Size || s.value-text.Addr > text.Size-s.size {
return 0, fmt.Errorf("symbol %s [%#x, +%d) is outside .text", s.name, s.value, s.size)
}
n := s.size
if n > stackCheckWindow {
n = stackCheckWindow
}
if _, err := reader.Seek(int64(s.value-text.Addr), io.SeekStart); err != nil {
return 0, err
}
b := make([]byte, n)
if _, err := io.ReadFull(reader, b); err != nil {
return 0, err
}
return stackCheckEnd(s.f.elf.Machine, b), nil
}

func (s *Symbol) AttachUprobe(exe *link.Executable, prog *ebpf.Program, pid uint32) (link.Link, error) {
return exe.Uprobe("", prog, &link.UprobeOptions{Address: s.Address(), PID: int(pid)})
}
Expand Down Expand Up @@ -174,6 +199,64 @@ func (f *ELFFile) Close() error {
return f.elf.Close()
}

// stackCheckWindow bounds the prologue scanned for the stack check: at most
// four instructions precede its branch.
const stackCheckWindow = 32

// stackCheckEnd returns the offset just past a Go function's stack check: the
// compare against the goroutine's stackguard0 and the branch to morestack
// that follows it. 0 if the prologue does not have that shape.
//
// When the stack has to grow, morestack copies it and restarts the function
// from its first instruction, so a probe at the entry fires twice for one
// call; a probe past the branch runs once the check has passed, exactly once
// per call. The argument registers are untouched up to there: the check only
// uses scratch registers (R12 on amd64, R16/R17 on arm64).
func stackCheckEnd(machine elf.Machine, instructions []byte) int {
switch machine {
case elf.EM_X86_64:
// CMPQ SP, 16(R14) or LEAQ -n(SP), R12; CMPQ R12, 16(R14), then JBE.
compared := false
for i, k := 0, 0; i < len(instructions) && k < 4; k++ {
ins, err := x86asm.Decode(instructions[i:], 64)
if err != nil {
return 0
}
i += ins.Len
switch {
case ins.Op == x86asm.CMP:
for _, a := range ins.Args {
if m, ok := a.(x86asm.Mem); ok && m.Base == x86asm.R14 && m.Disp == 16 {
compared = true
}
}
case ins.Op == x86asm.JBE && compared:
return i
}
}
case elf.EM_AARCH64:
// MOVD 16(g), R16; [SUB $n, RSP, R17;] CMP; BLS.
loaded := false
for i, k := 0, 0; i+4 <= len(instructions) && k < 4; i, k = i+4, k+1 {
ins, err := arm64asm.Decode(instructions[i:])
if err != nil {
return 0
}
switch {
case ins.Op == arm64asm.LDR:
if m, ok := ins.Args[1].(arm64asm.MemImmediate); ok && m.Base == arm64asm.RegSP(arm64asm.X28) {
loaded = true
}
case ins.Op == arm64asm.B && loaded:
if c, ok := ins.Args[0].(arm64asm.Cond); ok && c.Value == 9 { // LS
return i + 4
}
}
}
}
return 0
}

func getReturnOffsets(machine elf.Machine, instructions []byte) []int {
var res []int
switch machine {
Expand Down
29 changes: 29 additions & 0 deletions ebpftracer/stack_check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package ebpftracer

import (
"debug/elf"
"testing"
)

// Prologues of crypto/tls.(*Conn).Write and Read built by Go 1.26: a frame
// larger than the small-frame limit (LEA/SUB first) and a small one.
func TestStackCheckEnd(t *testing.T) {
for _, tc := range []struct {
name string
machine elf.Machine
code []byte
want int
}{
{"amd64 large frame", elf.EM_X86_64, []byte{0x4c, 0x8d, 0x64, 0x24, 0xb8, 0x4d, 0x3b, 0x66, 0x10, 0x0f, 0x86, 0x93, 0x07, 0x00, 0x00, 0x55, 0x48, 0x89, 0xe5}, 15},
{"amd64 small frame", elf.EM_X86_64, []byte{0x49, 0x3b, 0x66, 0x10, 0x0f, 0x86, 0x7a, 0x03, 0x00, 0x00, 0x55, 0x48, 0x89, 0xe5}, 10},
{"arm64 large frame", elf.EM_AARCH64, []byte{0x90, 0x0b, 0x40, 0xf9, 0xf1, 0x43, 0x01, 0xd1, 0x3f, 0x02, 0x10, 0xeb, 0xa9, 0x33, 0x00, 0x54, 0xfe, 0x0f, 0x13, 0xf8}, 16},
{"arm64 small frame", elf.EM_AARCH64, []byte{0x90, 0x0b, 0x40, 0xf9, 0xff, 0x63, 0x30, 0xeb, 0x09, 0x19, 0x00, 0x54, 0xfe, 0x0f, 0x19, 0xf8}, 12},
// No stack check (a NOSPLIT function): probe at the entry.
{"amd64 no check", elf.EM_X86_64, []byte{0x55, 0x48, 0x89, 0xe5, 0xc3}, 0},
{"arm64 no check", elf.EM_AARCH64, []byte{0xfe, 0x0f, 0x19, 0xf8, 0xc0, 0x03, 0x5f, 0xd6}, 0},
} {
if got := stackCheckEnd(tc.machine, tc.code); got != tc.want {
t.Errorf("%s: stackCheckEnd = %d, want %d", tc.name, got, tc.want)
}
}
}
6 changes: 6 additions & 0 deletions ebpftracer/symbol_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import (
type ProbeTarget struct {
Address uint64
ReturnOffsets []int
// StackCheckEnd is the offset past the function's stack check, where a
// probe fires once per call even if the stack grows (0: not found).
StackCheckEnd int
Found bool
}

Expand Down Expand Up @@ -156,6 +159,9 @@ func readProbeTargets(path string, names []string) (map[string]ProbeTarget, erro
if offsets, err := s.ReturnOffsets(); err == nil {
t.ReturnOffsets = offsets
}
if end, err := s.StackCheckEnd(); err == nil {
t.StackCheckEnd = end
}
targets[name] = t
}
return targets, nil
Expand Down
7 changes: 6 additions & 1 deletion ebpftracer/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,12 @@ func (t *Tracer) AttachGoTlsUprobes(pid uint32) (links []link.Link, isGolangApp
if !ws.Found {
continue
}
l, err := attachUprobeAt(exe, t.uprobes["go_crypto_tls_write_enter"], pid, ws.Address)
// Past the stack check, not at the entry: when the goroutine's stack
// has to grow, the function restarts from its entry and an entry probe
// sent the write twice. A duplicated write splices a copy of its bytes
// into the stream, which costs the HTTP/2 parser its frame alignment
// for the rest of the connection.
l, err := attachUprobeAt(exe, t.uprobes["go_crypto_tls_write_enter"], pid, ws.Address+uint64(ws.StackCheckEnd))
if err != nil {
closeLinks()
return fail(fmt.Sprintf("failed to attach write_enter uprobe for %s", writeSymbol), err)
Expand Down
Loading