diff --git a/ebpftracer/elf.go b/ebpftracer/elf.go index 404d0c7..7d1875f 100644 --- a/ebpftracer/elf.go +++ b/ebpftracer/elf.go @@ -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)}) } @@ -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 { diff --git a/ebpftracer/stack_check_test.go b/ebpftracer/stack_check_test.go new file mode 100644 index 0000000..2b1d1ac --- /dev/null +++ b/ebpftracer/stack_check_test.go @@ -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) + } + } +} diff --git a/ebpftracer/symbol_cache.go b/ebpftracer/symbol_cache.go index 7d2b915..a6f7aa4 100644 --- a/ebpftracer/symbol_cache.go +++ b/ebpftracer/symbol_cache.go @@ -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 } @@ -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 diff --git a/ebpftracer/tls.go b/ebpftracer/tls.go index 0cf6018..a26ad70 100644 --- a/ebpftracer/tls.go +++ b/ebpftracer/tls.go @@ -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)