书接上回,我们讨论了如何使用 Unix 的 sysctl()
接口以及 Unix Domain Socket 来获取系统 network interface 的流量信息。
我们是从 Activity Monitor.app 开始的,这个 App 不仅能显示整体网卡的流量,还能分进程显示。这回我们还是在 macOS 上实验,看看有没有方法也跟他一样实现进程流量监控。
先说结论: 以我的微末道行,暂未发现靠谱且简单实现方案。有简单的,不靠谱;有靠谱的,不简单。😂
希望知道简单靠谱方案的读者朋友可以分享一下。
一、私有框架接口 NetworkStatistics.framework
使用 otool -l
我们可以看到 Activity Monitor.app 用了一个私有的系统库:
/System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics
这个库同时也用在了 macOS 的 nettop
命令上。所以如果我们直接调用这个库的 API 那就非常省时省力了。
使用 class-dump 把它的头文件 dump 出来:
class-dump /System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics
@interface NWStatisticsManager : NSObject
{}
- (BOOL)addAllUDP:(unsigned long long)arg1;
- (BOOL)addAllTCP:(unsigned long long)arg1;
这个可疑的类和接口想必就是我们要寻找的答案了。接下来就是凭经验观察接口猜想看看这些接口怎么用了。我实验过可以非常轻松地获得进程 pid
,进程名字 processName
,和对应的 rxBytes
, rtBytes
。
首先,把 dump 出来的头文件引入自己的工程,同时把 NetworkStatistics.framework 加入 Link Binary With Libraries 列表。这一步比较简单各位可以自行 Google。
我们以 TCP 为例看看如何使用它的接口:
NWStatisticsManager *mgr = [[NWStatisticsManager alloc] init];
mgr.delegate = self;
[mgr addAllTCP:0];
加完 source 之后会通过回调告诉你所有的 TCP 连接的建立和销毁:
@protocol NWStatisticsManagerDelegate <NSObject>
@optional
- (void)statisticsManager:(NWStatisticsManager *)arg1 didReceiveDirectSystemInformation:(NSDictionary *)arg2;
- (void)statisticsManager:(NWStatisticsManager *)arg1 didRemoveSource:(NWStatisticsSource *)arg2;
- (void)statisticsManager:(NWStatisticsManager *)arg1 didAddSource:(NWStatisticsSource *)arg2;
@end
我们获得 NWStatisticsSource
之后要加入它的 delegate
等待回调:
- (void)sourceDidReceiveCounts:(NWStatisticsSource *)arg1 {
NWStatisticsTCPSource *tcp = (NWStatisticsTCPSource *)arg1;
NWSTCPSnapshot *snapshot = [tcp currentSnapshot];
NSLog(@"NWStatisticsManager rx: %llu", snapshot.rxBytes);
NSLog(@"NWStatisticsManager tx: %llu", snapshot.txBytes);
NSLog(@"NWStatisticsManager processName: %@", snapshot.processName);
NSLog(@"NWStatisticsManager processID: %d", snapshot.processID);
}
有数据变化的时候这个回调会被 called 我们就可以愉快地获取各个进程的 tx/rx 数据了,不仅有 bytes, 还有 packets 数据。
但是正如前文所述,此法简单,却不靠谱。
NWStatisticsManager
作为一个非常上层的接口,经常变更。比如旧版本的接口就是 C 风格的:
void *NStatManagerCreate(CFAllocatorRef allocator, dispatch_queue_t queue, void (^)(void *));
void NStatManagerDestroy(void *manager);
void NStatSourceSetRemovedBlock(void *source, void (^)());
void NStatSourceSetCountsBlock(void *source, void (^)(CFDictionaryRef));
void NStatSourceSetDescriptionBlock(void *source, void (^)(CFDictionaryRef));
void NStatManagerAddAllTCP(void *manager);
void NStatManagerAddAllUDP(void *manager);
有兴趣的朋友可以参考这里: *OS Internals::User Space
接口变更就意味着一旦系统升级我们的代码就得跟着改,而且是从头猜一遍他的接口应该怎么用。又由于里面的实现是黑盒的,我们的猜想不一定对,所以很容易出现用错接口和 Crash。
二、私有内核接口 NStat
留意到 NetworkStatistics.framework
里面用到的数据结构有 nstat_msg_hdr
,据此我们猜测他用了内核的 nstat.h
里的接口。既然上层接口经常改,那么内核接口即使改应该也不会太频繁吧?直接上 nstat
可乎?
先说结论:相对比较靠谱,但是非常不简单。
我们需要的很多数据在内核代码里也被标记为 PRIVATE
:
#define PRIVATE
这些私有的数据结构和 API 都不会公开到 Xcode 能引用的头文件里,比如说最重要的文件 ntstat.h
整个都是 private。所以为了让 Xcode 能编译通过,我们得把这个头文件手动 copy 过来,附带的还有 tcp.h
, in_stat.h
, net_api_stats.h
等多个文件。
2.1 PF_SYSTEM socket 和 ioctl
跟上一篇讲 ppp connect 一样,我们需要创建一个 socket 跟内核进行 IPC 通信,不过这次不是用户空间的 AF_LOCAL
而是系统的 AF_SYSTEM
/PF_SYSTEM
。这是 Darwin XNU 专有的一种 Protocol Family,其他 Unix 系统并未实现。用于用户态的进程请求内核态进程的数据。
对于 PF_SYSTEM
类型的 socket,XNU 提供了两种协议,分别是: SYSPROTO_EVENT
和 SYSPROTO_CONTROL
。详情可参考: http://newosxbook.com/bonus/vol1ch16.html
SYSPROTO_EVENT
用于监听内核提供的事件,通过 kev_request
传参,创建后 WiFi 切换、扫描事件,IP 地址更新等各种事件都会通过 socket 消息通知过来。
SYSPROTO_CONTROL
这个就是我们要找的主角了。这个 sockect 给用户空间和 XNU 内核空间的 providers 进程提供了控制通道,一般在 kernel extension 用的比较多,用户空间的 App 几乎没用到。并且,接口全部没有文档。
SYSPROTO_CONTROL
的 providers 用反域名作为 ID,一般都是 Apple 自己的代码,所以是 com.apple
开头,NetworkStatistics.framework
用到的 provider 叫做 com.apple.network.statistics
。
我们需要使用 ioctl()
接口跟这个家伙通信,我们常用的 ifconfig
命令也是通过这个方法。
2.2 创建 socket 连接 ioctl provider
由于根本没有文档,所以如何创建并连接上这个东西就非常困难,对着 XNU 的 ntstat
实现代码看半天也没用,因为他是通过 ioctl
模块通信的。好在 Apple Open Source 有开源 netstat
的代码,我们可以通过它的代码学习一下,删掉错误处理之后代码如下:
struct sockaddr_ctl sc;
struct ctl_info ctl;
int fd;
// 创建一个 PF_SYSTEM socket, protocol 为 SYSPROTO_CONTROL,用于 ioctl() 函数
fd = socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL);
/* Get the control ID for statistics */
bzero(&ctl, sizeof(ctl));
strlcpy(ctl.ctl_name, NET_STAT_CONTROL_NAME, sizeof(ctl.ctl_name));
// 创建完 socket 之后要先调用 ioctl 获取 ctl_info,我们需要里面的 ctl_id 才能连接 socket
ioctl(fd, CTLIOCGINFO, &ctl)
/* Connect to the statistics control */
bzero(&sc, sizeof(sc));
sc.sc_len = sizeof(sc);
sc.sc_family = AF_SYSTEM;
sc.ss_sysaddr = SYSPROTO_CONTROL;
sc.sc_id = ctl.ctl_id;
sc.sc_unit = 0;
// 连接 socket
connect(fd, (struct sockaddr*)&sc, sc.sc_len)
/* Set socket to non-blocking operation */
// 使用 fcntl() 函数把 socket 读取设置为非阻塞读取
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK)
如此就成功创建了一个跟 "com.apple.network.statistics" 通信的 socket 了。
2.3 Add Source,获取网卡信息
接下来要发送 add source 请求,跟上面使用 NWStatisticsManager
的时候差不多。netstat
的源码是发一个 NSTAT_PROVIDER_IFNET
类型的请求:
nstat_msg_add_src_req *addreq;
nstat_msg_src_added *addedmsg;
nstat_ifnet_add_param *param;
char buffer[sizeof(*addreq) + sizeof(*param)];
ssize_t result;
const u_int32_t addreqsize =
offsetof(struct nstat_msg_add_src, param) + sizeof(*param);
/* Setup the add source request */
addreq = (nstat_msg_add_src_req *)buffer;
param = (nstat_ifnet_add_param*)addreq->param;
bzero(addreq, addreqsize);
addreq->hdr.context = (uintptr_t)&buffer;
addreq->hdr.type = NSTAT_MSG_TYPE_ADD_SRC; // 操作是 add source
addreq->provider = NSTAT_PROVIDER_IFNET; // 关注的是 ifnet,还可以关注 TCP/UDP 等多个 provider
bzero(param, sizeof(*param));
param->ifindex = ifparam->ifindex;
param->threshold = ifparam->threshold;
/* Send the add source request */
result = send(fd, addreq, addreqsize, 0);
发送后收到的请求如下:
addedmsg = (nstat_msg_src_added *)buffer;
result = recv(fd, addedmsg, sizeof(buffer), 0);
// addedmsg->hdr.type == NSTAT_MSG_TYPE_SRC_ADDED
// 这里我们收到了一个 source 指针,发送 `NSTAT_MSG_TYPE_GET_SRC_DESC` 请求时需要用到这个指针
outsrc = addedmsg->srcref;
检查 interface 状态的部分我们就不看了,也是一样发个请求收个消息,我们直接看 src descriptor 的。
nstat_msg_get_src_description *dreq;
nstat_msg_src_description *drsp;
char buffer[sizeof(*drsp) + sizeof(*ifdesc)];
ssize_t result;
const u_int32_t descsize =
offsetof(struct nstat_msg_src_description, data) +
sizeof(nstat_ifnet_descriptor);
dreq = (nstat_msg_get_src_description *)buffer;
bzero(dreq, sizeof(*dreq));
dreq->hdr.type = NSTAT_MSG_TYPE_GET_SRC_DESC;
dreq->srcref = srcref; // 这个就是刚才上一步收到的 source 指针
result = send(fd, dreq, sizeof(*dreq), 0);
// 这里接收到 nstat_msg_src_description 了
drsp = (nstat_msg_src_description *)buffer;
result = recv(fd, drsp, sizeof(buffer), 0);
// link_status_type 还可以判断是 WiFi 还是 cellular
// ifdesc.link_status.link_status_type ==
NSTAT_IFNET_DESC_LINK_STATUS_TYPE_WIFI
最后把 WiFi 信息打印一下:
en0: 17:38:02
interface state:
wifi status:
link_quality_metric: 0
ul_effective_bandwidth: 6695
ul_max_bandwidth: 237641040
ul_min_latency: -1
ul_effective_latency: 0
ul_max_latency: 0
ul_retxt_level: 4(high)
ul_bytes_lost: -1
ul_error_rate: 0
dl_effective_bandwidth: 2955
dl_max_bandwidth: 237641040
dl_min_latency: -1
dl_effective_latency: 0
dl_max_latency: 0
dl_error_rate: 8533
config_frequency: 2
config_multicast_rate: -1
scan_count: -1
scan_duration: -1
2.4 获取进程信息
netstat
命令没有打印所有进程信息,但是如果我们阅读 XNU 源码,这个 provider 支持返回 nstat_tcp_descriptor
这种数据,里面可是带了 pid
的。我们可以试着获取 TCP Descriptor 看看。
这里我还是只能靠经验瞎猜,同时阅读 XNU 关于 ntstat
的实现代码,没有特别好的方法。如果读者朋友有比较聪明的方法请分享一下,非常需要😂。
我们看到 nstat_tcp_descriptor
这个数据的 copy 在 nstat_tcp_copy_descriptor()
函数,这个函数的指针被赋值给 nstat_tcp_provider.nstat_copy_descriptor
。所以我们需要这个 tcp_provider
给我们这些信息。
所以我们猜测,先添加 tcp provider source,然后进行再获取他的 src description 就能获得这些数据。实验核心代码如下:
nstat_msg_add_all_srcs *addreq;
char buffer[sizeof(*addreq)];
ssize_t result;
const u_int32_t addreqsize = sizeof(struct nstat_msg_add_all_srcs);
/* Setup the add source request */
addreq = (nstat_msg_add_all_srcs *)buffer;
bzero(addreq, addreqsize);
addreq->hdr.length = sizeof(nstat_msg_add_all_srcs);
addreq->hdr.context = 3; // 随便填
addreq->hdr.type = NSTAT_MSG_TYPE_ADD_ALL_SRCS; // 所有 sources
addreq->provider = NSTAT_PROVIDER_TCP_KERNEL;
result = send(fd, addreq, addreqsize, 0);
一开始填 NSTAT_MSG_TYPE_SYSINFO_COUNTS
这个最大值,我一直收到 error。且确认就是在 nstat_control_begin_query()
函数里返回的 EAGAIN
错误码:
// man 2 intro | less -Ip EAGAIN
35 EAGAIN Resource temporarily unavailable. This is a temporary condi-
tion and later calls to the same routine may complete normally.
正准备放弃的时候,看到 libnstat 这个用 C++ 实现的库在这里填的参数是 2
。他的头文件定义是 NSTAT_PROVIDER_TCP = 2
但我看到的 XNU 头文件却把内核空间与用户空间分开了:
enum
{
NSTAT_PROVIDER_NONE = 0
,NSTAT_PROVIDER_ROUTE = 1
,NSTAT_PROVIDER_TCP_KERNEL = 2
,NSTAT_PROVIDER_TCP_USERLAND = 3
,NSTAT_PROVIDER_UDP_KERNEL = 4
,NSTAT_PROVIDER_UDP_USERLAND = 5
,NSTAT_PROVIDER_IFNET = 6
,NSTAT_PROVIDER_SYSINFO = 7
};
换成 NSTAT_PROVIDER_TCP_KERNEL
之后能成功连接上 socket,但是 get src description 却返回错误的数据。本想继续研究但是看到 libnstat 项目里针对不同版本的内核也用了不同的头文件和 cpp 实现,说明 Apple 对这部分代码的修改也还算比较频繁的。目前我使用的系统版本是 macOS Catalina 10.15 (19A583),xnu 版本是: 6153.11.26~2。libnstat 项目准备了 5 个不同版本的 nstat.h
文件,他的项目里最新的是 xnu-4570.1.46。所以有理由猜想是内核又更新了这部分代码,不过无论如何,到这一步已经可以证明结论:
使用 nstat.h
的接口,不仅非常复杂,而且也不靠谱。
三、小结
没想到 nstat 相关的内容也这么复杂,学习起来还是挺费劲的。本章我们通过 class-dump 私有库 NetworkStatistics.framework
的头文件接口,凭经验猜测和实验,用上了这个相对上层的接口,实现了网络包统计。
接着我们尝试往下一层,通过 ioctl()
接口,使用 PF_SYSTEM
这种 XNU 独有的 socket 跟内核通信,从 com.apple.network.statistics
这个 provider 那里读取网络统计信息。
但是这两种方法首先都使用到系统的私有方法,并且这两个东西历史上都有过比较大的 API 变动。framework 的接口好猜但变化频繁,nstat 的接口变化稍微少一点但是几乎没有文档,学习起来非常痛苦。
总而言之就是这两个方法都不靠谱,那么有没有其他更有意思的方法呢?下一篇我们来试试 BPF (Berkeley Packet Filter)。
P.S. 传 req 的时候我发现仅存可供参考的代码都没有传 hdr.length
,同时内核代码有一段注释,说为了兼容旧版 client 的实现,拿到 hdr.length
如果为空就补刀一下。所以是内核本来为了兼容旧版的补刀逻辑让现在新实现的人都不填 length 了。😂
内核系列文章
- macOS 内核之一个 App 如何运行起来
- macOS 内核之网络信息抓包(三)
- macOS 内核之网络信息抓包(二)
- macOS 内核之网络信息抓包(一)
- macOS 内核之系统如何启动?
- macOS 内核之内存占用信息
- macOS 内核之 CPU 占用率信息
- macOS 内核之 hw.epoch 是个什么东西?
- macOS 内核之从 I/O Kit 电量管理开始