分析TCP连接建立过程,过去往往要借助tcpdump抓包或ss命令查看状态,但这些手段难以回答一个直接的问题:到底是哪个进程在什么时候向哪个端口发起了连接,返回了什么错误。eBPF允许在内核函数入口直接挂钩,把系统调用上下文与进程信息一并抓取。标题中的bpf_get_current_task_connect并不是内核自带的标准helper,实践中通常由bpf_get_current_task_btf或bpf_get_current_task封装而来,目的是在connect事件中快速拿到当前任务的结构体指针,进而关联进程名、PID等信息。本文围绕这一思路展开,介绍如何用eBPF分析TCP连接建立。

一、从用户态connect到内核挂载点
用户进程调用connect后,glibc会通过syscall进入内核,在x86_64架构上通常走到__x64_sys_connect,随后调用__sys_connect,再根据套接字类型分派到sock->ops->connect。对于TCP套接字,最终会调用inet_stream_connect,再进一步触发tcp_v4_connect或tcp_v6_connect。从这个调用链可以看出,可选的挂载点至少有入口函数__x64_sys_connect和TCP层函数tcp_v4_connect两种。前者能捕获所有地址族的connect调用,而且参数直接来自用户空间;后者更靠近TCP协议栈,能直接拿到已经建立好的struct sock结构,但只覆盖IPv4场景。
如果目标是分析所有TCP连接建立,同时希望记录发起进程信息,那么在系统调用入口挂载会更方便。此时可以用kprobe或kretprobe挂钩__x64_sys_connect,并在程序里调用bpf_get_current_task获取task_struct指针。如果内核启用了BTF,还可以用bpf_get_current_task_btf直接读取字段,这样就不需要手工计算偏移。标题里提到的bpf_get_current_task_connect可以理解为一个自定义封装宏,内部就是按需调用这些helper,把当前任务与connect上下文绑定在一起。下面这段代码展示了在系统调用入口获取进程名和PID的基本写法。
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#define TASK_COMM_LEN 16
SEC("kprobe/__x64_sys_connect")
int BPF_KPROBE(trace_connect_entry, int fd, struct sockaddr __user *addr, int addrlen)
{
char comm[TASK_COMM_LEN] = {};
__u32 pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&comm, sizeof(comm));
bpf_printk("connect entry: pid=%u comm=%s fd=%d", pid, comm, fd);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
这段代码使用了BPF_KPROBE宏来简化参数声明,它会自动从pt_regs中提取函数参数。第一个参数是文件描述符,第二个参数是用户空间地址指针,第三个参数是地址长度。程序把PID和进程名打印到内核跟踪日志,这样就能在trace_pipe中看到是谁发起了连接。当然,生产环境中不建议频繁使用bpf_printk,这里只是演示。
二、解析sockaddr与目标地址
在kprobe上下文中,系统调用的参数仍然是用户空间指针,不能直接在内核中解引用。必须使用专门的读取函数把用户空间内存拷贝到BPF栈上。新内核推荐使用bpf_probe_read_user,它明确表示从用户地址读取;旧内核可以使用bpf_probe_read,后者会根据地址自动判断,但语义不够清晰。如果挂载点是tcp_v4_connect,由于参数已经在内核态,则应该使用bpf_probe_read_kernel。这一点很容易混淆,选错读取函数会导致BPF程序验证失败或读取到垃圾数据。
对于IPv4连接,第二个参数指向的结构体是struct sockaddr_in,其中sin_family字段值为AF_INET,sin_port是网络字节序的端口号,sin_addr.s_addr是32位IPv4地址。对于IPv6连接,结构体是struct sockaddr_in6,端口字段位置相同,但地址为16字节。实际编写程序时需要先拷贝一个足够大的缓冲区,或者至少拷贝struct sockaddr的前两个字节判断地址族,再决定是否继续拷贝完整结构。下面这段代码演示了读取IPv4地址和端口的过程。
SEC("kprobe/__x64_sys_connect")
int BPF_KPROBE(trace_connect_addr, int fd, struct sockaddr __user *uaddr, int addrlen)
{
struct sockaddr_in addr = {};
if (!uaddr || addrlen < sizeof(struct sockaddr_in))
return 0;
long ret = bpf_probe_read_user(&addr, sizeof(addr), uaddr);
if (ret < 0)
return 0;
if (addr.sin_family == AF_INET) {
__u32 dst_ip = addr.sin_addr.s_addr;
__u16 dst_port = bpf_ntohs(addr.sin_port);
bpf_printk("connect IPv4 dst=%u:%u", dst_ip, dst_port);
}
return 0;
}
上述代码只处理了IPv4,因为struct sockaddr_in的大小是16字节,而addrlen可能大于16。如果连接目标是IPv6,sin_family会是AF_INET6,此时应该拷贝struct sockaddr_in6,再用__be32数组或逐字节打印地址。一个更健壮的做法是先拷贝struct sockaddr的第一部分判断sin_family,再根据地址族拷贝对应结构体,这样既能避免越界读取,也能同时覆盖IPv4和IPv6。
还要注意字节序问题。BPF程序运行在内核态,但sockaddr_in中的端口和地址字段都来自用户态,遵循网络字节序。端口用bpf_ntohs转换成主机字节序再打印或存储,否则输出的数字会不一致。IP地址可以直接按网络字节序存入map,也可以转换成点分十进制字符串,但后者涉及到格式化输出,在BPF中并不方便,通常把原始32位或128位地址交给用户态程序处理。
三、kretprobe记录返回值与耗时
connect系统调用可能返回0表示同步连接成功,也可能返回-EINPROGRESS表示非阻塞套接字正在建立连接,这是正常情况。其他常见返回值如-ECONNREFUSED表示对端拒绝连接,-ETIMEDOUT表示超时,-ENETUNREACH表示网络不可达。为了区分这些状态,可以在kretprobe中读取返回值,并结合入口处记录的时间戳计算整个connect调用的耗时。不过要注意,同步连接成功时三次握手已经在返回前完成,耗时包含握手时间;非阻塞连接返回-EINPROGRESS时,connect本身很快返回,真正的握手完成需要另行挂载tcp_finish_connect或相关状态机函数。
做法是在入口kprobe中把当前PID的起始时间戳存入一个BPF_MAP_TYPE_HASH,key是PID,value是纳秒时间戳。在kretprobe中根据PID查找起始时间,计算差值,然后删除该项,避免map无限增长。下面是一个简化实现。
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u32);
__type(value, __u64);
} start_time_map SEC(".maps");
SEC("kprobe/__x64_sys_connect")
int BPF_KPROBE(trace_connect_start, int fd, struct sockaddr __user *addr, int addrlen)
{
__u32 pid = bpf_get_current_pid_tgid() >> 32;
__u64 ts = bpf_ktime_get_ns();
bpf_map_update_elem(&start_time_map, &pid, &ts, BPF_ANY);
return 0;
}
SEC("kretprobe/__x64_sys_connect")
int BPF_KRETPROBE(trace_connect_exit, long ret)
{
__u32 pid = bpf_get_current_pid_tgid() >> 32;
__u64 *start = bpf_map_lookup_elem(&start_time_map, &pid);
if (start) {
__u64 delta = bpf_ktime_get_ns() - *start;
bpf_printk("connect ret=%ld pid=%u cost=%llu ns", ret, pid, delta);
bpf_map_delete_elem(&start_time_map, &pid);
}
return 0;
}
这段代码在入口把时间戳写入map,在退出时读取并计算耗时。由于PID可能出现重用,map中可能残留旧条目,但这里用BPF_ANY更新并删除已经足够应对大多数场景。更严谨的做法是同时保存文件描述符或进程启动时间作为联合key,避免PID重用导致的数据串扰。另外,bpf_ktime_get_ns返回的是单调时钟纳秒,适合计算时间差,不要用它来生成绝对时间戳。
如果只想关注失败的连接,可以在kretprobe中先判断ret是否小于0且不等于-EINPROGRESS,再输出事件或更新统计map。这样可以大幅减少数据量,把精力集中在异常连接上。对于非阻塞连接,还需要结合tcp_finish_connect或tcp_rcv_state_process来确认握手是否最终成功,这两个函数在TCP状态迁移时被调用,可以挂载kprobe来获取更精确的握手完成时间。
四、生产环境注意事项与替代方案
内核版本的差异是使用eBPF分析连接建立时最先遇到的问题。bpf_get_current_task_connect这个名称并没有出现在标准内核helper列表中,如果直接调用会导致编译错误。实际开发中应该使用bpf_get_current_task获取task_struct指针,或者在内核支持BTF的情况下使用bpf_get_current_task_btf,它返回一个带有BTF类型信息的指针,配合CO-RE技术可以一次编译、多内核运行。BPF程序对内核结构体字段的访问也要借助bpf_core_read等辅助宏,避免手工偏移在不同版本间失效。
性能方面,kprobe在高频系统调用上会有一定开销,connect虽然不像read/write那样频繁,但在大规模微服务场景下也可能数以万计每秒。为了降低影响,应该在BPF程序入口先做过滤,比如只关注特定目标端口、特定进程名或特定cgroup。可以使用bpf_get_current_comm比对进程名,也可以使用bpf_get_current_uid_gid过滤用户ID。另外,输出事件时避免使用bpf_printk,改用BPF_MAP_TYPE_RINGBUF或BPF_MAP_TYPE_PERF_EVENT_ARRAY将数据批量发送到用户态,减少内核日志开销。
如果希望更稳定地捕获connect事件,可以考虑使用raw tracepoint而不是kprobe。raw tracepoint挂在系统调用的稳定入口上,不依赖内核函数名变化,参数通过sys_enter_connect的trace_event_raw_sys_enter结构传递,且性能更好。对于快速验证想法,也可以先用bpftrace写一行脚本,例如挂载kprobe:__x64_sys_connect打印进程名和地址,确认效果后再用C语言编写正式的BPF程序。无论采用哪种方式,理解connect调用路径和地址读取规则都是准确分析TCP连接建立的前提。
总结来说,通过eBPF在connect入口和出口挂载程序,结合当前任务信息与sockaddr解析,可以完整回答“谁在什么时候连接了哪里,结果如何”。只要注意用户态指针读取、字节序转换和内核版本兼容性,就能构建出可靠且高效的TCP连接建立观测工具。
eBPFTCP连接建立bpf_get_current_task_connect修改时间:2026-09-24 21:38:08