2023年10月

本教程已加入 Istio 系列:
https://istio.whuanle.cn

5,出入口网关

Istio 可以管理集群的出入口流量,当客户端访问集群内的应用时, Istio 可以将经过 istio-ingressgateway 的流量实现负载均衡和熔断等一系列功能。

可是,如果集群内的一个应用要访问 google.com ,那么我们可以给内部所有请求了 google.com 的流量设置负载均衡吗?答案是可以,Istio 提供了 istio-egressgateway 实现这种功能。因为 Pod 中的容器要访问网络时,会被 Envoy 拦截,Envoy 可以很容易地分析这些请求,然后通过一系列手段影响着请求的行为。

在本章中,将会简单说一下 istio-ingressgateway 和 istio-egressgateway。

istio-ingressgateway

入口网关指的是从外部经过 istio-ingressgateway 流入集群的流量,需要创建 Gateway 绑定流量。

关于 istio-ingressgateway 经过前面几章的学习,大家应该不陌生了。

istio-ingressgateway 由 Pod 和 Service 组成。 istio-ingressgateway 本身就是一个网关应用,你可以把它当作 Nginx、Apisix、Kong ,你可以从各种各种网关应用中找到与 istio-ingressgateway 类似的概念。

image-20230526194223740

image-20230526194154234

作为一个应用,它需要对外开放一些端口,只有当流量经过这些端口时, istio-ingressgateway 才会起作用。为了在 Kubernetes 中暴露端口, istio-ingressgateway 还有一个 Service 对象。

image-20230526194139225

有了 istio-ingressgateway 之后,我们可以通过 Istio Gateway 监控一些域名或IP,然后暴露集群内部的服务 。

Gateway 的概念跟 Nginx 有很多相似之处。

比如从配置上看, Gateway 跟 Nginx 如果要监控某个入口流量,它们的配置如下:

Nginx:

server {
    listen      80;
    server_name example.org www.example.org;
    #...
}

Gateway:

  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - example.org
    - www.example.org

这些配置指定了 Gateway 和 Nginx 只监控哪些流量。

紧接着,监控到指定入口的流量之后,需要将流量转发到集群内的应用中。

Nginx 可以直接在同一个配置文件里面设置:

server {
    listen      80;
    server_name example.org www.example.org;
    #...
}

location /some/path/ {
    proxy_pass http:/bookinfo:9080/;
}

而 Gateway 需要使用 VirtualService 指定流量转发到哪里,并且 VirtualService 还可以进一步筛选入口地址。

spec:
  hosts:
  - "www.example.org"
  gateways:
  # 绑定 Gateway
  - mygateway
  http:
    route:
    - destination:
        host: bookinfo
        port:
          number: 9080

所以总结起来,Istio 的做法是 Gateway 监控入口流量,通过 VirtualService 设置
流量进入的策略
,并指向 Service。而 DestinationRule 则定义了
流量流向 Pod 的策略

部署服务

下面我们将使用 httpbin 服务作为示例,如何一步步配置在外部访问 httpbin 服务。

首先部署一个 httpbin 服务,这个 httpbin 服务很简单,包含了 Service 和 Deployment 。

httpbin.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: httpbin
---
apiVersion: v1
kind: Service
metadata:
  name: httpbin
  labels:
    app: httpbin
    service: httpbin
spec:
  ports:
  - name: http
    port: 8000
    targetPort: 80
  selector:
    app: httpbin
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: httpbin
spec:
  replicas: 1
  selector:
    matchLabels:
      app: httpbin
      version: v1
  template:
    metadata:
      labels:
        app: httpbin
        version: v1
    spec:
      serviceAccountName: httpbin
      containers:
      - image: docker.io/kennethreitz/httpbin
        imagePullPolicy: IfNotPresent
        name: httpbin
        ports:
        - containerPort: 80
kubectl -n bookinfo apply -f httpbin.yaml

配置 Gateway

然后创建一个 Gateway ,指定监听哪些入口流量。

httpbin_gw.yaml

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: httpbin-gateway
spec:
  selector:
    istio: ingressgateway # use Istio default gateway implementation
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "httpbin.s1.whuanle.cn"
    - "*"

这一步为了大家能够通过域名更加直观地了解 Gateway,大家可以修改
httpbin.s1.whuanle.cn
替换为自己的域名。

然后在自己的电脑中打开
C:\Windows\System32\drivers\etc\hosts
增加一条记录 ,将 IP 指向自己的服务器。

image-20230515193213923

kubectl -n bookinfo apply -f httpbin_gw.yaml

现在,我们已经让 istio-ingressgateway 帮我们关注 httpbin.s1.whuanle.cn 这个地址,如果有人访问了 httpbin.s1.whuanle.cn,那么这个流量将会流入到 httpbin-gateway。

image-20230515190518804

接下来我们将要为 Gateway 配置服务地址,并配置外部允许访问的地址后缀。

配置 VistualService:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin
spec:
  hosts:
  - "*"
  gateways:
  - httpbin-gateway
  http:
  - match: 
    - uri:
        prefix: /status
    - uri:
        prefix: /delay 
    route:
    - destination:
        port:
          number: 8000
        host: httpbin

当 Gateway 和 VirtualService 端口只有一个时,不需要配置端口绑定。

kubectl -n bookinfo apply -f httpbin_vs.yaml

找到 istio-ingressgateway 对外暴露的端口。

kubectl get svc istio-ingressgateway  -n istio-system

1683287785674

httpbin 是一个 http 测试程序,我们可以通过使用
/status/{状态码}
获取对应的 http 请求状态。

例如:

image-20230505200437890

image-20230505200444999

image-20230515193314909
image-20230515193301641

如果我们不希望这个服务被外界访问到,我们可以先把
/status
删除。

kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin
spec:
  hosts:
  - "*"
  gateways:
  - httpbin-gateway
  http:
  - match:
    - uri:
        prefix: /delay
    route:
    - destination:
        port:
          number: 8000
        host: httpbin
EOF

此时你将无法访问
status
路径。但是我们还可以访问
/delay
路径。

httpbin 的
/delay
路径用于测试延迟 http 请求响应使用,
/delay/{秒数}
可以指定服务器在多久之后才会返回响应。

例如
http://192.168.3.150:32309/delay/5
将在 5 秒后响应。

httpbin 还有很多路由接口,我们可以通过 VirtualService 配置放通哪些路径。

image-20230505201156220

如果需要全部放通,可以使用:

kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin
spec:
  hosts:
  - "*"
  gateways:
  - httpbin-gateway
  http:
  - match:
    - uri:
        prefix: /
    route:
    - destination:
        port:
          number: 8000
        host: httpbin
        subset: v1
EOF

子版本

第四章中进行版本路由实验时使用到,可以将流量导入到不同的版本之中。

kubectl -n bookinfo apply -f - <<EOF
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: httpbin
spec:
  host: httpbin
  subsets:
  - name: v1
    labels:
      version: v1
EOF

首先是使用 DestinationRule 指向一个 Service:

  host: httpbin

当然,我们也可以写成

  host: httpbin.bookinfo.svc.cluster.local

通过 host 可以识别到对应的 Kubernetes Service,然后从 Service 对应的 Endpoints 中获得所有 Pod 列表。

image-20230515194105645

1684151025350

通过 Endpoints 获得所有 Pod 之后,查看每个 Pod 的描述信息。当有一个请求到达时,根据 DestinationRule 中的标签选择器,选择合适的 Pod 进行访问。

  - name: v1
    labels:
      version: v1

1684151265603

istio-egressgateway

istio-egressgateway 也是 Istio 中的一种组件,需要自行安装。安装 istio-egressgateway 命令:

helm install istio-egressgateway istio/gateway -n istio-system

在集群中,如果 A 应用访问的地址属于集群中的应用,那么 Istio 可以给这些请求注入各种行为,实现负载均衡和熔断等。

可是,如果集群内部要访问外部的一个服务时,需要配置访问地址,如 aaa.com,我们应该如何实现负载均衡和熔断这些功能呢?

image-20230515195151940

Istio ServiceEntry 是一种资源,允许将外部服务(即不在 Istio 服务网格中的服务)纳入Istio服务网格。通过将外部服务添加到网格,可以使用 Istio 的流量管理和策略功能来控制与这些外部服务的交互。

以下是一个ServiceEntry示例,将外部HTTP服务
www.google.com
添加到Istio服务网格:

apiVersion: networking.istio.io/v1alpha3  
kind: ServiceEntry  
metadata:  
  name: google
spec:  
  hosts:  
  - www.google.com  
  addresses:  
  - 192.168.1.1  
  ports:  
  - number: 80  
    name: http  
    protocol: HTTP  
  location: MESH_EXTERNAL  
  resolution: DNS  
  endpoints:  
  - address: "www.google.com"  
    ports:  
      http: 80  
    locality: "us-west1/zone1"  
  exportTo:  
  - "*"  

在此示例中,我们创建了一个名为
httpbin-ext
的ServiceEntry资源。指定的主机为
httpbin.org
,端口号为80,协议为HTTP。此外,我们将
resolution
设置为
DNS
,将
location
设置为
MESH_EXTERNAL
,表示该服务位于网格之外。

要将此ServiceEntry应用到集群,请将其保存到一个YAML文件(例如:
httpbin-ext.yaml
),然后运行以下命令:

kubectl apply -f httpbin-ext.yaml  

现在,Istio 服务网格中的服务访问
www.google.com
时仍受Istio策略的控制。例如,可以为此 ServiceEntry 创建 VirtualService 以应用流量管理规则,或者为其创建 DestinationRule 以配置负载均衡和连接池设置。

spec
: 包含ServiceEntry的具体配置的对象。

  • hosts
    : 一个包含要导入的外部服务的主机名(FQDN)的列表。例如:
    ["httpbin.org"]
  • addresses
    : (可选)与外部服务关联的虚拟IP地址的列表。例如:
    ["192.168.1.1"]
  • ports: 一个描述外部服务使用的端口的列表。每个端口都有以下属性:
    • number
      : 端口号,例如:80。
    • name
      : 端口的名称,例如:
      http
    • protocol
      : 使用的协议,例如:
      HTTP

      TCP

      HTTPS
      等。
  • location
    : 服务的位置。可以是
    MESH_EXTERNAL
    (表示服务在网格外部)或
    MESH_INTERNAL
    (表示服务在网格内部,但不属于任何已知服务)。
  • resolution
    : 用于确定服务实例地址的解析方法。可以是
    NONE
    (默认值,表示不解析地址),
    STATIC
    (表示使用
    addresses
    字段中的IP地址),
    DNS
    (表示使用DNS解析主机名)或
    MESH_EXTERNAL
  • endpoints: (可选)外部服务的端点列表。每个端点都有以下属性:
    • address
      : 端点的IP地址或主机名。
    • ports
      : 一个包含端口名称和端口号的映射,例如:
      {"http": 8080}
    • labels
      : (可选)应用于端点的标签。
    • locality
      : (可选)端点的地理位置,例如:
      us-west1/zone1
  • exportTo
    : (可选)一个包含命名空间名称的列表,指定可以访问此ServiceEntry的命名空间。可以使用星号(
    *
    )表示所有命名空间。默认值为
    *
  • subjectAltNames
    : (可选)用于验证服务器证书主题替代名(SANs)的列表。

读者可以从官方文档中了解更多:

https://istio.io/latest/zh/docs/tasks/traffic-management/egress/egress-control/

NPCAP 库是一种用于在
Windows
平台上进行网络数据包捕获和分析的库。它是
WinPcap
库的一个分支,由
Nmap
开发团队开发,并在
Nmap
软件中使用。与
WinPcap
一样,NPCAP库提供了一些
API
,使开发人员可以轻松地在其应用程序中捕获和处理网络数据包。NPCAP库可以通过
WinPcap API
进行编程,因此现有的WinPcap应用程序可以轻松地迁移到NPCAP库上。

与WinPcap相比,NPCAP库具有更好的性能和可靠性,支持最新的操作系统和硬件。它还提供了对
802.11
无线网络的本机支持,并可以通过
Wireshark
等网络分析工具进行使用。 NPCAP库是在
MIT
许可证下发布的,因此可以在免费和商业软件中使用。

该工具包分为两部分组成驱动程序及SDK工具包,在使用本库进行抓包时需要读者自行安装对应版本的驱动程序,此处读者使用的版本是
npcap-1.55.exe
当下载后读者可自行点击下一步即可,当安装完成后即可看到如下图所示的提示信息;

当驱动程序安装完成后,读者就可以自行配置开发工具包到项目中,通常只需要将工具包内的
include

lib
库配置到项目中即可,如下图所示配置后自行应用保存即可。

接着我们来实现第一个功能,枚举当前主机中可以使用的网卡信息,该功能的实现主要依赖于
pcap_findalldevs_ex()
函数,该函数用于获取当前系统中可用的所有网络适配器的列表。

函数的原型声明如下:

int pcap_findalldevs_ex(const char *source, struct pcap_rmtauth *auth,
                        pcap_if_t **alldevsp, char *errbuf);

其中,参数含义如下:

  • source:指定远程接口的IP地址,或者为本地接口传入NULL。
  • auth:一个指向
    pcap_rmtauth
    结构来指定远程的IP和用户名。
  • alldevsp:一个指向指针,返回主机上可用的设备列表。
  • errbuf:一个用于存储错误信息的缓冲区。

该函数允许开发者通过一个结构来检索所有网络适配器的详细信息。它允许指定一个过滤器,以匹配用户定义的网络适配器和属性。此外,
pcap_findalldevs_ex()
还提供用于存储错误信息的结构体,以便在函数调用失败时提供错误信息。

该函数返回值-1表示失败;否则,返回值为0表示操作成功,并将返回所有可用的网络适配器和它们的详细信息。这些详细信息包括适配器的名称、描述、MAC地址、IP地址和子网掩码等,当读者使用枚举函数结束后需要自行调用
pcap_freealldevs
函数释放这个指针以避免内存泄漏。

以下是pcap_freealldevs函数原型声明:

void pcap_freealldevs(pcap_if_t *alldevs);

其中,
alldevs
参数是指向
pcap_if_t
类型结构体的指针,该类型结构体记录了当前主机上所有可用的网络接口的详细信息。
pcap_freealldevs()
会释放传入的
pcap_if_t
型链表,并将所有元素删除。

调用
pcap_freealldevs()
函数时需要传入之前通过
pcap_findalldevs()

pcap_findalldevs_ex()
函数获取到的的指向链表结构的指针作为参数。

当有了这两个函数作为条件,那么实现枚举网卡则变得很简单了,如下代码所示则是使用该工具包实现枚举的具体实现流程,读者可自行编译测试。

#include <iostream>
#include <winsock2.h>
#include <Windows.h>
#include <string>
#include <pcap.h>

#pragma comment(lib,"ws2_32.lib")
#pragma comment(lib, "packet.lib")
#pragma comment(lib, "wpcap.lib")

using namespace std;

// 输出线条
void PrintLine(int x)
{
  for (size_t i = 0; i < x; i++)
  {
    printf("-");
  }
  printf("\n");
}

// 枚举当前网卡
int enumAdapters()
{
  pcap_if_t *allAdapters;    // 所有网卡设备保存
  pcap_if_t *ptr;            // 用于遍历的指针
  int index = 0;
  char errbuf[PCAP_ERRBUF_SIZE];

  /* 获取本地机器设备列表 */
  if (pcap_findalldevs_ex(PCAP_SRC_IF_STRING, NULL, &allAdapters, errbuf) != -1)
  {
    PrintLine(100);
    printf("索引 \t 网卡名 \n");
    PrintLine(100);

    /* 打印网卡信息列表 */
    for (ptr = allAdapters; ptr != NULL; ptr = ptr->next)
    {
      ++index;
      if (ptr->description)
      {
        printf("[ %d ] \t [ %s ] \n", index - 1, ptr->description);
      }
    }
  }

  /* 不再需要设备列表了,释放它 */
  pcap_freealldevs(allAdapters);
  return index;
}
int main(int argc, char* argv[])
{
  enumAdapters();
  system("pause");
  return 0;
}

编译并以管理员身份运行程序,则读者可看到如下图所示输出结果,其中第一列为网卡索引编号,第二列为网卡名称;

当有了网卡编号后则读者就可以对特定编号进行抓包解析了,抓包功能的实现依赖于
pcap_open()
函数,该函数用于打开一个指定网络适配器并开始捕获网络数据包,函数的原型声明如下所示:

pcap_t *pcap_open(const char *source, int snaplen, int flags, int read_timeout, 
     struct pcap_rmtauth *auth, char *errbuf);

其参数含义如下:

  • source:要打开的网络接口的名称或者是保存在
    pcap_open_live()
    中获取的名称。
  • snaplen:设置捕获数据包的大小。
  • flags:设置捕获数据包的模式,在
    promiscuous
    控制器模式或非
    promiscuous
    模式下捕获。
  • read_timeout:设置阻塞读函数的超时时间以毫秒为单位。
  • auth:一个指向
    pcap_rmtauth
    结构,指定远程的IP和用户名。
  • errbuf:一个用于存储错误信息的缓冲区。

该函数返回一个指向
pcap_t
类型的指针,该类型结构提供了与网络适配器通信的接口,可以用于捕获数据包、关闭网络适配器及其他操作,读者在调用
pcap_open()
函数时,需要指定要打开的网络适配器的名称
source
,如果需要设置为混杂模式的话,需要设置
flags
参数为
PCAP_OPENFLAG_PROMISCUOUS
,此外
snaplen
参数用于设置捕获数据包的大小,
read_timeout
参数用于设置阻塞读函数的超时时间,
auth
参数则用于指定远程的
IP
和用户名,
errbuf
参数用于存储错误信息。如果该函数返回空,则表示未成功打开指定的网络适配器。

另一个需要注意的函数是
pcap_next_ex()
该函数用于从打开的指定网络适配器中读取下一个网络数据包,通常情况下此函数需要配合
pcap_open()
一起使用,其原型声明:

int pcap_next_ex(pcap_t *p, struct pcap_pkthdr **pkt_header, const u_char **pkt_data);

参数含义如下:

  • p:指向
    pcap_t
    类型结构体的指针,代表打开的网络适配器。
  • pkt_header:一个指向指向
    pcap_pkthdr
    类型的指针,该类型结构体包含有关当前数据包的元数据,例如时间戳、数据包长度、捕获到数据包的网络适配器接口等。
  • pkt_data:一个指向被捕获的数据包的指针。

它返回以下三种返回值之一:

  • 1:成功捕获一个数据包,
    pkt_header

    pkt_data
    则指向相关信息;
  • 0:在指定的时间内未捕获到任何数据包;
  • -1:发生错误,导致无法从网络适配器读取数据包。此时可以在
    errbuf
    参数中查找错误信息。

使用
pcap_next_ex()
函数时,需要提供一个指向
pcap_t
类型结构体的指针
p
用于确定要从哪个网络适配器读取数据包。如果读取数据包时成功,则将包的元数据存储在传递的
pcap_pkthdr
指针中,将指向捕获数据包的指针存储在
pkt_data
指针中。如果在指定的时间内未捕获到任何数据包,则函数返回0。如果在读取数据包时发生任何错误,则函数返回-1,并在
errbuf
参数中提供有关错误的详细信息。

当读者理解了上述两个关键函数的作用则就可以实现动态抓包功能,如下代码中的
MonitorAdapter
函数则是抓包的实现,该函数需要传入两个参数,参数1是需要抓包的网卡序列号,此处我们就使用7号,第二个参数表示需要解码的数据包类型,此处我们可以传入
ether
等用于解包,当然该函数还没有实现数据包的解析功能,这些功能的实现需要继续完善。

#include <iostream>
#include <winsock2.h>
#include <Windows.h>
#include <string>
#include <pcap.h>

#pragma comment(lib,"ws2_32.lib")
#pragma comment(lib, "packet.lib")
#pragma comment(lib, "wpcap.lib")

using namespace std;

// 选择网卡并根据不同参数解析数据包
void MonitorAdapter(int nChoose, char *Type)
{
  pcap_if_t *adapters;
  char errbuf[PCAP_ERRBUF_SIZE];

  if (pcap_findalldevs_ex(PCAP_SRC_IF_STRING, NULL, &adapters, errbuf) != -1)
  {
    // 找到指定的网卡
    for (int x = 0; x < nChoose - 1; ++x)
      adapters = adapters->next;

    // PCAP_OPENFLAG_PROMISCUOUS = 网卡设置为混杂模式
    // 1000 => 1000毫秒如果读不到数据直接返回超时
    pcap_t * handle = pcap_open(adapters->name, 65534, 1, PCAP_OPENFLAG_PROMISCUOUS, 0, 0);

    if (adapters == NULL)
      return;

    // printf("开始侦听: % \n", adapters->description);
    pcap_pkthdr *Packet_Header;    // 数据包头
    const u_char * Packet_Data;    // 数据本身
    int retValue;
    while ((retValue = pcap_next_ex(handle, &Packet_Header, &Packet_Data)) >= 0)
    {
      if (retValue == 0)
        continue;

      // printf("侦听长度: %d \n", Packet_Header->len);
      if (strcmp(Type, "ether") == 0)
      {
        PrintEtherHeader(Packet_Data);
      }
      if (strcmp(Type, "ip") == 0)
      {
        PrintIPHeader(Packet_Data);
      }
      if (strcmp(Type, "tcp") == 0)
      {
        PrintTCPHeader(Packet_Data);
      }
      if (strcmp(Type, "udp") == 0)
      {
        PrintUDPHeader(Packet_Data);
      }
      if (strcmp(Type, "icmp") == 0)
      {
        PrintICMPHeader(Packet_Data);
      }
      if (strcmp(Type, "http") == 0)
      {
        PrintHttpHeader(Packet_Data);
      }
      if (strcmp(Type, "arp") == 0)
      {
        PrintArpHeader(Packet_Data);
      }
    }
  }
}

int main(int argc, char* argv[])
{
  MonitorAdapter(7,"ether");
  system("pause");
  return 0;
}

当读者有了上述代码框架,则下一步就是依次实现
PrintEtherHeader
,
PrintIPHeader
,
PrintTCPHeader
,
PrintUDPHeader
,
PrintICMPHeader
,
PrintHttpHeader
,
PrintArpHeader
等函数,这些函数接收原始数据包
Packet_Data
类型,并将其转换为对应格式的数据包输出给用户,接下来我们将依次实现这些功能。

解码以太网层数据包

以太网数据包是一种在以太网上发送的数据包格式。它通常包括以太网头部和以太网数据部分。以下是它的各个部分的介绍:

  • 以太网头部:包括目标MAC地址、源MAC地址以及类型/长度字段。目标MAC地址和源MAC地址是6个字节的二进制数,分别表示数据包的目标和来源。类型/长度字段用于表示数据部分的长度或指定所使用的网络层协议。如果类型/长度字段小于等于1500,则指示数据部分的长度;否则,它表示使用的协议类型。

  • 以太网数据部分:包括所有的上层网络协议标头和数据。以太网数据部分的长度通常大于46个字节,并且最大长度为1500个字节。

以太网数据包通常用于在局域网上进行通信。使用以太网帧作为数据包格式,将数据包发送到这个网络上的所有设备。然后,目标设备根据目标MAC地址,接收和处理这些帧,其它设备会忽略这些帧。在以太网数据包中,目标MAC地址指的是数据包要发送到的目标设备的唯一MAC地址,而源MAC地址则指的是发送此消息的设备的MAC地址。

// 解码数据链路数据包 数据链路层为二层,解码时只需要封装一层ether以太网数据包头即可.
#define hcons(A) (((WORD)(A)&0xFF00)>>8) | (((WORD)(A)&0x00FF)<<8)

void PrintEtherHeader(const u_char * packetData)
{
  typedef struct ether_header
  {
    u_char ether_dhost[6];    // 目标地址
    u_char ether_shost[6];    // 源地址
    u_short ether_type;       // 以太网类型
  } ether_header;

  struct ether_header * eth_protocol;
  eth_protocol = (struct ether_header *)packetData;

  u_short ether_type = ntohs(eth_protocol->ether_type);  // 以太网类型
  u_char *ether_src = eth_protocol->ether_shost;         // 以太网原始MAC地址
  u_char *ether_dst = eth_protocol->ether_dhost;         // 以太网目标MAC地址

  printf("类型: 0x%x \t", ether_type);
  printf("原MAC地址: %02X:%02X:%02X:%02X:%02X:%02X \t",
    ether_src[0], ether_src[1], ether_src[2], ether_src[3], ether_src[4], ether_src[5]);
  printf("目标MAC地址: %02X:%02X:%02X:%02X:%02X:%02X \n",
    ether_dst[0], ether_dst[1], ether_dst[2], ether_dst[3], ether_dst[4], ether_dst[5]);
}

由于以太网太过于底层,所以解析以太网我们只能得到一些基本的网卡信息,如下图所示;

解码IP层数据包

IP(Internet Protocol)数据包是在TCP/IP(传输控制协议/互联网协议)协议栈中的第三层。它通常包括IP头部和数据部分两部分。

IP头部通常包括以下内容:

  • 版本号:表示所使用的IP协议版本号。
  • 头部长度:表示整个IP头部的长度。TCP/IP协议中的长度都以字节(byte)为单位计数。
  • 总长度:表示整个IP数据包的长度,包括头部和有效负载部分。
  • TTL:生存时间,用于限制路由器转发该数据包的次数。
  • 协议:表示上层使用的协议类型。
  • 源IP地址:发送该数据包的设备的IP地址。
  • 目标IP地址:发送该数据包的目标设备的IP地址。
  • 数据部分则是上层协议中传输的实际数据。

IP数据包是在网络层传输的,它的主要功能是为互联网中的各种应用程序之间提供包传输服务。它使用IP地址来确定数据包从哪里发出,以及数据包应该被路由到达目标设备。

在接收到IP数据包时,网络设备首先检查数据包头的目标IP地址,然后使用路由表来找到传输该数据包所需的下一个节点(下一跳),并将数据包传递到该节点。如果某个路由器无法将数据包传递到下一个节点,则该数据包将被丢弃。每个节点都会检查数据包的TTL值,并将其减少1。如果TTL值变为0,则数据包会被丢弃,以防止数据包在网络中循环。

// 解码IP数据包,IP层在数据链路层的下面, 解码时需要+14偏移值, 跳过数据链路层。
void PrintIPHeader(const u_char * packetData)
{
  typedef struct ip_header
  {
    char version : 4;
    char headerlength : 4;
    char cTOS;
    unsigned short totla_length;
    unsigned short identification;
    unsigned short flags_offset;
    char time_to_live;
    char Protocol;
    unsigned short check_sum;
    unsigned int SrcAddr;
    unsigned int DstAddr;
  }ip_header;

  struct ip_header *ip_protocol;

  // +14 跳过数据链路层
  ip_protocol = (struct ip_header *)(packetData + 14);
  SOCKADDR_IN Src_Addr, Dst_Addr = { 0 };

  u_short check_sum = ntohs(ip_protocol->check_sum);
  int ttl = ip_protocol->time_to_live;
  int proto = ip_protocol->Protocol;

  Src_Addr.sin_addr.s_addr = ip_protocol->SrcAddr;
  Dst_Addr.sin_addr.s_addr = ip_protocol->DstAddr;

  printf("源地址: %15s --> ", inet_ntoa(Src_Addr.sin_addr));
  printf("目标地址: %15s --> ", inet_ntoa(Dst_Addr.sin_addr));

  printf("校验和: %5X --> TTL: %4d --> 协议类型: ", check_sum, ttl);
  switch (ip_protocol->Protocol)
  {
  case 1: printf("ICMP \n"); break;
  case 2: printf("IGMP \n"); break;
  case 6: printf("TCP \n");  break;
  case 17: printf("UDP \n"); break;
  case 89: printf("OSPF \n"); break;
  default: printf("None \n"); break;
  }
}

针对IP层数据包的解析可能会较为复杂,因为
IP
协议上方可以包含
ICMP,IGMP,TCP,UDP,OSPF
等协议,在运行程序后读者会看到如下图所示的具体信息;

解码TCP层数据包

TCP(Transmission Control Protocol)层数据包是在TCP/IP(传输控制协议/互联网协议)协议栈中的第四层。它包括TCP头部和数据部分两个部分。

TCP头部通常包括以下内容:

  • 源端口号:表示发送该数据包的应用程序的端口号。
  • 目的端口号:表示接收该数据包的应用程序的端口号。
  • 序列号:用于将多个数据包排序,确保它们在正确的顺序中到达接收方应用程序。
  • 确认号:用于确认接收方已经成功收到序列号或最后一个被成功接收的数据包。
  • ACK和SYN标志:这些是TCP头部中的标志位,用于控制TCP连接的建立和关闭。
  • 窗口大小:用于控制数据流发送的速率,并确保不会发送太多的数据包,导致网络拥塞。
  • 校验和:用于校验TCP头部和数据部分是否被损坏或篡改。
  • 数据部分则是上层应用程序传递到TCP层的应用数据。

TCP是一个面向连接的协议,因此在发送数据之前,TCP会先在发送方和接收方之间建立连接。该连接建立的过程包括三次握手(three-way handshake)过程,分别是客户端发起连接请求、服务器发回确认、客户端再次发送确认。完成连接后,TCP协议根据确认号和序列号来控制数据包的传输次序和有效性(如ACK报文的确认和重传消息),以提供高效的数据传输服务。

当TCP数据包到达目标设备后,TCP层将在接收方重新组装TCP数据,将TCP报文分割成应用层可用的更小的数据块,并将其发送到目标应用程序。如果发送的TCP协议数据包未被正确地接收,则TCP协议将重新尝试发送丢失的数据包,以确保数据的完整性和正确性。

// 解码TCP数据包,需要先加14跳过数据链路层, 然后再加20跳过IP层。
void PrintTCPHeader(const unsigned char * packetData)
{
  typedef struct tcp_header
  {
    short SourPort;                 // 源端口号16bit
    short DestPort;                 // 目的端口号16bit
    unsigned int SequNum;           // 序列号32bit
    unsigned int AcknowledgeNum;    // 确认号32bit
    unsigned char reserved : 4, offset : 4; // 预留偏移

    unsigned char  flags;               // 标志 

    short WindowSize;               // 窗口大小16bit
    short CheckSum;                 // 检验和16bit
    short surgentPointer;           // 紧急数据偏移量16bit
  }tcp_header;

  struct tcp_header *tcp_protocol;
  // +14 跳过数据链路层 +20 跳过IP层
  tcp_protocol = (struct tcp_header *)(packetData + 14 + 20);

  u_short sport = ntohs(tcp_protocol->SourPort);
  u_short dport = ntohs(tcp_protocol->DestPort);
  int window = tcp_protocol->WindowSize;
  int flags = tcp_protocol->flags;

  printf("源端口: %6d --> 目标端口: %6d --> 窗口大小: %7d --> 标志: (%d)",
    sport, dport, window, flags);

  if (flags & 0x08) printf("PSH 数据传输\n");
  else if (flags & 0x10) printf("ACK 响应\n");
  else if (flags & 0x02) printf("SYN 建立连接\n");
  else if (flags & 0x20) printf("URG \n");
  else if (flags & 0x01) printf("FIN 关闭连接\n");
  else if (flags & 0x04) printf("RST 连接重置\n");
  else printf("None 未知\n");
}

针对TCP的解析也较为复杂,这是因为TCP协议存在多种状态值,如
PSH、ACK、SYN、URG、FIN

RST
这些都是
TCP
报文段中用于标识不同信息或状态的标志位。这些TCP标志位的含义如下:

  • PSH(Push):该标志位表示接收端应用程序应立即从接收缓存中读取数据。通常在发送方需要尽快将所有数据发送给接收方时使用。
  • ACK(Acknowledgment):该标志位表示应答。用于确认已经成功接收到别的TCP包。在TCP连接建立完成后,所有TCP报文段都必须设置ACK标志位。
  • SYN(Synchronous):该标志位用于建立TCP连接。指示请求建立一个连接,同时序列号以随机数ISN开始。发送SYN报文的一端会进入SYN_SENT状态。
  • URG(Urgent):该标志位表示紧急指针有效。它用于告知接收端在此报文段中存在紧急数据,紧急数据应该立即送达接收端的应用层。
  • FIN(Finish):此标志用于终止TCP连接。FIN标志位被置位的一端表明它已经发送完所有数据并要求释放连接。
  • RST(Reset):该标志用于重置TCP连接。当TCP连接尝试建立失败,或一个已关闭的套接字收到数据,都会发送带RST标志的数据包。

这些标志位的设置和使用可以帮助TCP在应用层和网络层之间进行可靠的通信,保证数据的传输和连接的建立以及关闭可以正确完成,我们工具同样可以解析这些不同的标志位情况,如下图所示;

解码UDP层数据包

UDP(User Datagram Protocol)层数据包是在TCP/IP(传输控制协议/互联网协议)协议栈中的第四层。它比TCP更简单,不保证数据包的位置和有效性,也不进行连接的建立和维护。UDP数据包仅包含UDP头部和数据部分。

UDP头部包括以下内容:

  • 源端口号:表示发起该数据包的应用程序的端口号。
  • 目的端口号:表示接收该数据包的应用程序的端口号。
  • 数据长度:表示数据包中包含的数据长度。
  • 校验和:用于校验UDP头部和数据部分是否被损坏或篡改。
  • 数据部分和TCP层数据包类似,是上层应用程序传递到UDP层的应用数据。

UDP协议的优点是传输开销小,速度快,延迟低,因为它不进行高负载的错误检查,也不进行连接建立和维护。但这也意味着数据包传输不可靠,不保证数据传输的完整性和正确性。如果未能正确地接收UDP数据包,则不会尝试重新发送丢失的数据包。UDP通常用于需要快速、简单、低延迟的应用程序,例如在线游戏、视频和音频流媒体等。

// UDP层与TCP层如出一辙,仅仅只是在结构体的定义解包是有少许的不同而已.
void PrintUDPHeader(const unsigned char * packetData)
{
  typedef struct udp_header
  {
    uint32_t sport;   // 源端口
    uint32_t dport;   // 目标端口
    uint8_t zero;     // 保留位
    uint8_t proto;    // 协议标识
    uint16_t datalen; // UDP数据长度
  }udp_header;

  struct udp_header *udp_protocol;
  // +14 跳过数据链路层 +20 跳过IP层
  udp_protocol = (struct udp_header *)(packetData + 14 + 20);

  u_short sport = ntohs(udp_protocol->sport);
  u_short dport = ntohs(udp_protocol->dport);
  u_short datalen = ntohs(udp_protocol->datalen);

  printf("源端口: %5d --> 目标端口: %5d --> 大小: %5d \n", sport, dport, datalen);
}

针对UDP协议的解析就变得很简单了,因为UDP是一种无状态协议所以只能得到源端口与目标端口,解析效果如下图所示;

解码ICMP层数据包

ICMP(Internet Control Message Protocol)层数据包是在TCP/IP协议栈中的第三层。它是一种控制协议,用于网络通信中的错误报告和网络状态查询。ICMP数据包通常不携带应用数据或有效载荷。

ICMP数据包通常包括以下类型的控制信息:

  • Echo Request/Reply: 用于网络连通性测试,例如ping命令(12/0)
  • Destination unreachable: 该类型的ICMP数据包用于向发送者传递对目标无法到达的消息(3/0、3/1、3/2、3/3、3/4、3/5、3/6、3/7、3/8、3/9、3/10)
  • Redirect: 用于告知发送方使用新的路由器来发送数据(5/0、5/1、5/2)
  • Time exceeded: 用于向发送方报告基于TTL值无法到达目的地,表示跃点数超过了最大限制(11/0、11/1)
  • Parameter problem: 用于向发送者报告转发器无法处理IP数据包中的某些字段(12/0)

ICMP数据包还用于其他用途,例如Multicast Listener Discovery(MLD)和Neighbor Discovery Protocol(NDP),用于组播和IPv6网络通信中。

ICMP数据报通常由操作系统或网络设备自动生成,并直接发送给操作系统或网络设备。然后,它们可以通过网络分析工具进行检测和诊断,以确定网络中的错误或故障。

// 解码ICMP数据包,在解包是需要同样需要跳过数据链路层和IP层, 然后再根据ICMP类型号解析, 常用的类型号为`type 8`它代表着发送和接收数据包的时间戳。
void PrintICMPHeader(const unsigned char * packetData)
{
  typedef struct icmp_header {
    uint8_t type;        // ICMP类型
    uint8_t code;        // 代码
    uint16_t checksum;   // 校验和
    uint16_t identification; // 标识
    uint16_t sequence;       // 序列号
    uint32_t init_time;      // 发起时间戳
    uint16_t recv_time;      // 接受时间戳
    uint16_t send_time;      // 传输时间戳
  }icmp_header;

  struct icmp_header *icmp_protocol;

  // +14 跳过数据链路层 +20 跳过IP层
  icmp_protocol = (struct icmp_header *)(packetData + 14 + 20);

  int type = icmp_protocol->type;
  int init_time = icmp_protocol->init_time;
  int send_time = icmp_protocol->send_time;
  int recv_time = icmp_protocol->recv_time;
  if (type == 8)
  {
    printf("发起时间戳: %d --> 传输时间戳: %d --> 接收时间戳: %d 方向: ",
      init_time, send_time, recv_time);

    switch (type)
    {
    case 0: printf("回显应答报文 \n"); break;
    case 8: printf("回显请求报文 \n"); break;
    default:break;
    }
  }
}

针对ICMP协议的解析也很简单在抓包时我们同样只能得到一些基本的信息,例如发送时间戳,传输时间戳,接收时间戳,以及报文方向等,这里的方向有两种一种是0代表回显应答,而8则代表回显请求,具体输出效果图如下所示;

解码HTTP层数据包

HTTP(Hypertext Transfer Protocol)层数据包是在TCP/IP协议栈中的第七层,它主要用于Web应用程序中的客户机和服务器之间的数据传输。HTTP数据包通常包括HTTP头部和数据部分两个部分。

HTTP头部通常包括以下内容:

  • 请求行:用于描述客户机发起的请求。
  • 响应行:用于描述服务器返回的响应。
  • 头部字段:用于向请求或响应添加额外的元数据信息,例如HTTP版本号、日期、内容类型等。
  • Cookie:用于在客户端和服务器之间来保存状态信息。
  • Cache-Control:用于客户端和服务器之间控制缓存的行为。
  • 数据部分是包含在HTTP请求或响应中的应用数据。

HTTP协议的工作方式是客户端向服务器发送HTTP请求,服务器通过HTTP响应返回请求结果。HTTP请求通常使用HTTP方法,如GET、POST、PUT、DELETE等,控制HTTP操作的类型和行为。HTTP响应通常包含HTTP状态码,如200、404、500等,以指示客户端请求结果的状态。

在实际的网络通信中,HTTP层数据包的格式和内容通常由应用程序或网络设备生成和分析,例如Web浏览器和Web服务器。

// 解码HTTP数据包,需要跳过数据链路层, IP层以及TCP层, 最后即可得到HTTP数据包协议头。
void PrintHttpHeader(const unsigned char * packetData)
{
  typedef struct tcp_port
  {
    unsigned short sport;
    unsigned short dport;
  }tcp_port;

  typedef struct http_header
  {
    char url[512];
  }http_header;

  struct tcp_port *tcp_protocol;
  struct http_header *http_protocol;

  tcp_protocol = (struct tcp_port *)(packetData + 14 + 20);
  int tcp_sport = ntohs(tcp_protocol->sport);
  int tcp_dport = ntohs(tcp_protocol->dport);

  if (tcp_sport == 80 || tcp_dport == 80)
  {
    // +14 跳过MAC层 +20 跳过IP层 +20 跳过TCP层
    http_protocol = (struct http_header *)(packetData + 14 + 20 + 20);
    printf("%s \n", http_protocol->url);
  }
}

针对HTTP协议的解析同样可以,但由于HTTP协议已经用的很少了所以这段代码也只能演示,在实战中一般会使用HTTPS,如下则是一个HTTP访问时捕获的数据包;

解码ARP层数据包

ARP(Address Resolution Protocol)层数据包是在TCP/IP协议栈中的第二层。ARP协议主要用于将网络层地址(如IP地址)映射到数据链路层地址(如MAC地址)。

ARP数据包通常包括以下内容:

  • ARP请求或响应:ARP请求用于获取与IP地址关联的MAC地址,而ARP响应用于提供目标MAC地址。
  • 发送者的MAC地址:发送ARP请求或响应的设备的MAC地址。
  • 发送者的IP地址:发送ARP请求或响应的设备的IP地址。
  • 目标的MAC地址:目标设备的MAC地址。
  • 目标的IP地址:目标设备的IP地址。

ARP协议工作的过程如下:

  • 发送者主机发送一个ARP请求,包含目标IP地址。
  • 网络中的所有设备都收到该ARP请求。
  • 如果有设备的IP地址与ARP请求中的目标IP地址匹配,该设备会回复ARP响应,包含自己的MAC地址。
  • 发送者主机使用响应中的MAC地址来与该设备通信。

ARP协议的工作主要是在本地网络中实现地址映射,主要包括确定哪个设备的MAC地址与特定的IP地址关联,以及应答IP地址转化成相应的MAC地址的映射请求。ARP通常用于以太网和WiFi网络中,以实现局域网内的设备通信。

// 解码ARP数据包
void PrintArpHeader(const unsigned char * packetData)
{
  typedef struct arp_header
  {
    uint16_t arp_hardware_type;
    uint16_t arp_protocol_type;
    uint8_t arp_hardware_length;
    uint8_t arp_protocol_length;
    uint16_t arp_operation_code;
    uint8_t arp_source_ethernet_address[6];
    uint8_t arp_source_ip_address[4];
    uint8_t arp_destination_ethernet_address[6];
    uint8_t arp_destination_ip_address[4];
  }arp_header;

  struct arp_header *arp_protocol;

  arp_protocol = (struct arp_header *)(packetData + 14);

  u_short hardware_type = ntohs(arp_protocol->arp_hardware_type);
  u_short protocol_type = ntohs(arp_protocol->arp_protocol_type);
  int arp_hardware_length = arp_protocol->arp_hardware_length;
  int arp_protocol_length = arp_protocol->arp_protocol_length;
  u_short operation_code = ntohs(arp_protocol->arp_operation_code);

  // 判读是否为ARP请求包
  if (arp_hardware_length == 6 && arp_protocol_length == 4)
  {
    printf("原MAC地址: ");
    for (int x = 0; x < 6; x++)
      printf("%x:", arp_protocol->arp_source_ethernet_address[x]);
    printf(" --> ");

    printf("目标MAC地址: ");
    for (int x = 0; x < 6; x++)
      printf("%x:", arp_protocol->arp_destination_ethernet_address[x]);
    printf(" --> ");

    switch (operation_code)
    {
    case 1: printf("ARP 请求 \n"); break;
    case 2: printf("ARP 应答 \n"); break;
    case 3: printf("RARP 请求 \n"); break;
    case 4: printf("RARP 应答 \n"); break;
    default: break;
    }
  }
}

解析ARP协议同样可以实现,ARP协议同样有多个状态,一般
1-2
代表请求与应答,
3-4
代表RARP反向请求与应答,ARP协议由于触发周期短所以读者可能很少捕捉到这类数据,如下图时读者捕捉到的一条完整的ARP协议状态;

本文作者: 王瑞
本文链接:
https://www.lyshark.com/post/526b8a6.html
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

Java中提供了原子操作,可以简单看一下AtomicInteger类中的一个典型的原子操作incrementAndGet(),表示对原子整数变量进行加操作,并返回新的值。实现如下:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

     public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
     }
}

在实现incrementAndGet()操作时,由于后续要执行CAS(compare and swap,比较并交换)操作,这个操作需要对旧值与某个地址处的值进行比较,但是在Java层无法操作地址,所以只能计算出某个字段在当前类实例中的偏移,然后在HotSpot VM中根据偏移转换为对应的地址。

调用的getAndAddInt()方法的实现如下:

public final int getAndAddInt(Object o, long offset, int delta) {
   int v;
   do {
        v = getIntVolatile(o, offset);
   } while (!compareAndSwapInt(o, offset, v, v + delta));
   return v;
}

其中的compareAndSwapInt()是native方法,对应的实现如下:

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

调用的Atomic::cmpxchg()函数的实现如下:

#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

inline jint Atomic::cmpxchg(jint exchange_value, volatile jint *dest, jint compare_value) {
    int mp = os::is_MP();
    __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
    : "=a" (exchange_value)
    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
    : "cc", "memory");
    return exchange_value;
}

如上在C++函数中内联了一段汇编程序。使用精练的汇编不但可以缩小目标代码的大小,还可以使用汇编来提高某些经常被卧调用的代码的性能。

内联汇编的基本格式如下:

__asm__ [__volatile__] ( 
assembler template            // 汇编代码模板 
  : [output operand list]       // 输出操作数列表
  : [input operand list]         // 输入操作数列表
  : [clobbered register list]   // 修改的寄存器列表
);

内联汇编可以将C++函数中相关信息通过输入操作数列表传送到汇编指令中,也可以通过输出操作数列表接收到由汇编指令执行后的输出值。下面详细介绍所一下Atomic::cmpxchg()函数中内联汇编的具体意思。

1、汇编代码模板:当操作系统为多核时,mp为true,此时会在cmpxhgl指令之前加一个lock前缀。因为cmpxhgl指令本身并不是原子的(cmpxhg解码为多个微指令,这些微指令加载、检查是否相等,然后根据比较结果存储或不存储新值),但是加lock前缀后就会变为原子的。cmpxhg的操作数可以是reg + reg,也可以是mem + reg,前者不需要lock,因为在同一个核上,寄存器只会有一套。只有cmpxhg mem, reg才可能会需要lock,这个lock是对多核有效的。使用的cmpxhgl指令有个后缀l,表示操作数是4字节大小。

2、输出操作数列表,=表示操作数在指令中是只写的(输出操作数),a表示将变量放入eax寄存器。在64位模式下,只有%rax可用,因为在执行内联汇编相关的指令时之前会自动保存%rax的值,这样避免重要数据丢失。

3、r表示将输入变量放入通用寄存器,也就是eax,ebx,ecx,edx,esi,edi中的一个。a同样表示eax寄存器。%1就是
exchange_value,
%3

dest,
%4
就是
mp。

4、在修改的寄存器列表中,cc表示编译器汇编代码会导致CPU状态位的改变,也就是eflags指示了CPU状态。这里由于执行cmpxhgl,所以会更改eflags的状态;memory告诉编译器汇编代码会读取或修改内存中某个地址存放的值。

在HotSpot的atomic.hpp中声明了许多原子操作,这些操作不但为Java层原子操作提供实现,也会在HotSpot内部经常使用。主要是因为CAS相对互斥量来说更加轻量级,效率更高,但是达到同样的目的时,实现也相对复杂了一些。下面就举几个小例子,如下:

1、CAS保证在多线程竞争下,通过指针碰撞分配TLAB

在分配TLAB时会通过CAS来保证并发安全。实际上采用CAS配合上失败重试的方式保证更新操作的原子性,如下:

inline HeapWord* ContiguousSpace::par_allocate_impl(
size_t size,
HeapWord* const  end_value
) {
  do {
    HeapWord* obj = top();
    // 当前的空闲空间足够分配时尝试分配
    if (pointer_delta(end_value, obj) >= size) {
      HeapWord* new_top = obj + size;
      HeapWord* result = (HeapWord*)Atomic::cmpxchg_ptr(new_top, top_addr(), obj);
      if (result == obj) {
        return obj; // 分配成功时返回,否则继续循环
      }
    } else {
      return NULL; // 没有足够空间时候返回
    }
  } while (true);
}

2、保证一个或多个共享变量的原子操作

首先说一下,CAS只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。

还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从 Java 1.5 开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。在HotSpot VM实现轻量级锁时,也会有类似的操作。MarkWord将多个变量拼接为了一个64位数,如下:

在偏向锁的实现过程中,需要同时判断thread、epoch及biased_lock值来确定接下来的逻辑时,就将这几个数看成了一个64位的数进行了原子操作。

3、CAS实现自旋等待

在HotSpot VM内部锁Monitor的实现过程中,使用CAS进行自旋等待,以避免上下文切换。在Monitor::ILock()函数中,如果产生锁竞争,当前线程会调用Monitor::TrySpin ()进行自旋等待。这里等待时间的选取非常关键,因为如果自旋时间长则浪费CPU时间,旋转短了又不能有效避免上下文切换。其中的等待时间与Marsaglia的xor-shift算法产生的伪随机数有直接关系,有兴趣的可自行研究。

4、原子更新变量保护代码段线程安全

多线程竞争时,可以保护一段代码同一时刻只有一个线程在执行。在Monitor中有一个volatile变量,如下:

ParkEvent * volatile _OnDeck

这个变量被HotSpot VM作者标注为内部锁,也就是借助它可实现一段代码保护。

当执行一段代码时,可以通过_OnDeck将NULL设置为_LBIT,在退出时将_OnDeck再次设置为_LBIT,这样其它的CAS就又可以执行这段被保护的代码了。如下:

void Monitor::IUnlock (bool RelaxAssert) {
...

 // 获取内部锁
 if (CASPTR (&_OnDeck, NULL, _LBIT) != UNS(NULL)) {
    return ;
  }

  // 确保同一时只有一个线程在执行这里的代码

 // 释放内部锁
  _OnDeck = NULL ;

}

CAS操作无处不在,只要用的好、用的巧,还是能极大减少互斥量的使用的。

手写Java虚拟机HotSpot已经录制一系列视频啦!有兴趣关注
B站

有对虚拟机、Java性能故障诊断与调优等感兴趣的人可以入群讨论。

引言

在之前的章节中,我们详细讲解了计算机系统中一些核心组成部分,如中央处理器(CPU)、内存、硬盘等。这些组件负责处理和存储数据,使得计算机能够有效地运行。然而,除了这些核心组件,计算机系统还包含许多其他重要的部分,其中之一就是输入输出设备。它们使得计算机能够与用户进行有效的沟通和交流,提升了计算机的实用性和用户体验。因此,在学习计算机系统的过程中,深入了解输入输出设备的原理和工作方式也非常重要。

输入输出设备

image

I/O接口,又被称为I/O控制器或设备控制器,是一种负责协调主机与外部设备之间数据传输的关键组件。其主要功能是管理数据的输入和输出,在数据传输过程中起到桥梁的作用。通过有效地控制和调度数据流,I/O接口能够实现高效的数据传输,提升系统的整体性能。

image

I/O控制器涵盖了多种多样的类型和标准。其中,针对不同外部设备的控制需求,相应的I/O接口也会制定相应的标准。举例来说,我们有用于控制USB设备的I/O接口,它提供了可靠的数据传输和连接性能,使得计算机可以方便地与各类USB设备进行通信。另外,还有用于控制SATA 3.0硬盘的I/O接口,它支持高速数据传输,并具备热插拔和高可靠性的特点。

image

image

如今,随着技术的不断进步和集成电路的发展,现代的I/O接口(芯片)也趋向于被集成到南桥芯片内部。

CPU如何控制I/O设备

程序直接控制和中断控制方式

image

  1. 首先,我们来讨论数据寄存器(Data Register)。当CPU需要将数据写入I/O设备时,例如打印内容为“GeekTime”,首先要将字符“G”发送给对应的I/O设备。
  2. 接下来是命令寄存器(Command Register)。CPU向打印机发送一个命令,告诉它开始打印工作。此时,打印机的控制电路将执行两个动作。首先,它会设置状态寄存器中的状态为not-ready,表示打印机正在工作中。然后,它会实际操作打印机进行打印。
  3. 而状态寄存器(Status Register)的作用是向CPU报告设备状态。当设备开始工作时,状态寄存器会告知CPU设备正在工作中。在这个阶段,CPU发送的任何数据或命令都是无效的。只有当前面的动作完成,状态寄存器重新变为ready状态时,CPU才能发送下一个字符或命令。

现在,让我们讨论快速的I/O设备(如磁盘),如果每准备好一个字就向CPU发送一次中断请求,会导致什么问题呢?

这种情况下,CPU需要花费大量的时间来处理中断服务程序,从而导致CPU利用率严重下降。每次中断请求都会打断CPU正在执行的任务,使其无法充分利用时间进行其他计算工作。这种频繁的中断请求会导致CPU的效率下降,影响系统的整体性能。

为了解决这个问题,可以采取一些优化措施。例如,可以引入缓冲区,将多个字节的数据一次性传输给CPU,减少中断请求的次数。另外,也可以使用DMA(直接内存访问)技术,将数据直接传输到内存,减轻CPU的负担。通过这些优化措施,可以提高CPU的利用率,提升系统的整体性能。

DMA存取方式

image

DMA控制方式是一种优化技术,它通过在主存和设备之间建立一条直接数据通路(DMA总线)来实现高速数据传输。在这种方式下,CPU只需向DMA接口发送读/写命令,并指示主存地址、设备地址以及读写数据的数量等参数,然后DMA控制器便会自动控制设备与主存之间的数据读写操作。

具体而言,当CPU发出读/写命令后,DMA控制器会通过DMA总线直接访问设备,并将数据传输到主存或从主存传输到设备。为了保证数据的完整性,在每传输一整块数据(如1KB)后,DMA控制器才会向CPU发出一次中断请求,通知CPU数据传输已完成。这样,CPU就能够及时处理其他任务,而不需要等待每个数据传输的完成。

通道控制方式

image

有的商用中型机、大型机可能会接上超多的I/0设备。如果都让CPU来直接管理这些设备的数据传输和操作,那么CPU的负担将会非常沉重,无法充分发挥其处理计算任务的能力。为了解决这个问题,引入了通道的概念。

通道是具有特殊功能的处理器,可以将CPU与I/0设备之间的通信过程进行统一管理。通道有自己的数据寄存器、命令寄存器和状态寄存器,可以完成与I/0设备之间的数据传输、设备控制和错误处理等任务。CPU只需要发送指令给通道,然后通道会自动完成与设备之间的数据传输和操作,不再需要CPU的直接参与。

image

通道可以被理解为是一种功能较为有限的处理器,类似于“弱鸡版的CPU”,它能够识别并执行一系列通道指令,通常这些指令的种类和功能比较单一。

信号和地址

一旦我们理解了实际的I/O设备和接口之间的关系,就会面临一个新的问题:CPU到底需要向总线发送怎样的命令,才能与I/O接口上的设备进行通信呢?

在计算机系统中,CPU和I/O设备之间的通信是通过CPU支持的特定机器指令来实现的。这些机器指令被称为I/O指令,用于控制和操作I/O设备。CPU可以使用这些指令来发送命令、读取数据、写入数据以及查询设备状态等操作。

为了简化已经足够复杂的CPU,计算机系统将I/O设备的各个寄存器和I/O设备内部的内存地址都映射到主内存地址空间中。主内存的地址空间中会为不同的I/O设备预留一段一段的内存地址。当CPU想要与这些I/O设备进行通信时,它会向这些预留的地址发送数据。

而我们的I/O设备会监控地址线,并在CPU向其发送数据时,将传输过来的数据接入到对应设备的寄存器和内存中。无论是向I/O设备发送命令、查询设备状态还是传输数据,CPU都可以通过这种方式进行。这种方式被称为内存映射I/O(Memory-Mapped I/O,简称MMIO)。

image

CPU 和 I/O 设备之间的通信

在计算机系统中,CPU和I/O设备之间的通信是通过以下方式来解决的。

首先,在I/O设备一侧,我们将I/O设备分成可以与CPU通信的接口电路和实际的I/O设备本身。接口电路包含状态寄存器、命令寄存器、数据寄存器、数据缓冲区和设备内存等组件。接口电路通过总线与CPU通信,接收来自CPU的指令和数据。接口电路中的控制电路会解码接收到的指令,并实际操作相应的硬件设备。

而在CPU一侧,CPU通过访问内存地址或端口地址进行数据的传输和读取。对于CPU来说,它并不直接与特定设备进行交互,而是通过读写特定的内存地址或端口地址来实现与I/O设备的通信。在软件层面,通过定义传输命令和数据的方式,而不是引入新的指令,来实际操作对应的I/O硬件。这样,CPU可以通过与接口电路的通信来与I/O设备进行交互,完成输入和输出的功能。

总结

本文主要介绍了计算机系统中输入输出设备的原理和工作方式。输入输出设备是计算机系统中的重要组成部分,它们使得计算机能够与用户进行有效的沟通和交流。文章介绍了I/O接口的功能和作用,以及不同类型的I/O接口的标准。同时,还介绍了CPU如何控制I/O设备的程序直接控制和中断控制方式,以及使用DMA和通道控制方式进行优化的方法。最后,文章解释了CPU和I/O设备之间的通信方式。通过深入了解输入输出设备的原理和工作方式,可以更好地理解计算机系统的工作原理,提升系统的整体性能。

1: Bean在Spring容器中是如何存储和定义的

Bean在Spring中的定义是_
org.springframework.beans.factory.config.BeanDefinition
_接口,BeanDefinition里面存储的就是我们编写的Java类在Spring中的元数据,包括了以下主要的元数据信息:

1:
Scope
(Bean类型):包括了单例Bean(Singleton)和多实例Bean(Prototype)

2:
BeanClass
: Bean的Class类型

3:
LazyInit
:Bean是否需要延迟加载

4:
AutowireMode
:自动注入类型

5:
DependsOn
:Bean所依赖的其他Bean的名称,Spring会先初始化依赖的Bean

6:
PropertyValues
:Bean的成员变量属性值

7:
InitMethodName
:Bean的初始化方法名称

8:
DestroyMethodName
:Bean的销毁方法名称

同时
BeanDefinition
是存储到_
org.springframework.beans.factory.support.DefaultListableBeanFactory
类中维护的
BeanDefinitionMap
_中的,源码如下:

Map<String, BeanDefinition> beanDefinitionMap = new ConcurrentHashMap<>(256);

了解了
BeanDefinition
的基础信息和存储位置后,接下来看看创建好的Bean实例是存储在什么地方的,创建好的Bean是存储在:_
org.springframework.beans.factory.support.DefaultSingletonBeanRegistry
_类中的

_
singletonObjects
_中的,Key是Bean的名称,Value就是创建好的Bean实例:

Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

了解了基本信息之后,就可以带着下面两个
关键问题
去分析Spring Bean的生命周期了:

1:Java类是如何被 Spring 扫描从而变成 BeanDefinition 的?

2:BeanDefinition是如何被 Spring 加工创建成我们可以直接使用的 Bean实例的?

2:Java类是如何被Spring扫描成为BeanDefinition的?

在Spring中定义Bean的方式有非常多,例如使用XML文件、注解,包括:
@Component

@Service

@Configuration
等,下面就以
@Component注解
为例来探究Spring是如何扫描我们的Bean的。我们知道使用
@Component
注解来标记Bean是需要配合
@ComponentScan
注解来使用的,而我们的主启动类上标注的
@SpringBootApplication
注解中就默认继承了
@ComponentScan注解

@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication

所以最初的问题就转化成了
@ComponentScan
注解是如何在Spring中运作的

2.1 @ComponentScan注解是如何运作的

在Spring框架中,这个注解对应的处理类是_
ComponentScanAnnotationParser
,这个类的
parse
_方法是主要的处理逻辑,这个方法简要处理逻辑如下:

1:获取@ComponentScan
注解中的
basePackage
属性,若没有则
默认为该注解所标注类所在的包路径

2:使用
ClassPathBeanDefinitionScanner

scanCandidateComponents
方法扫描
classpath:+basePackage+
/*.class**下的所有类资源文件

3:最后循环判断扫描的所有类资源文件,判断是否包含@Component注解,若有则将这些类注册到beandefinitionMap中

自此,我们代码里写的Java类,就被Spring扫描成BeanDefinition存储到了BeanDefinitionMap中了,扫描的细节大家可以去看看这个类的源码

3:Spring如何创建我们的Bean实例的

Spring把我们编写的Java类扫描成BeanDefinition之后,就会开始创建我们的Bean实例了,Spring将创建Bean的方法交给了_
org.springframework.beans.factory.support.AbstractBeanFactory#getBean
_方法,接下来就来看看
getBean
方法是如何创建Bean的

getBean
方法的调用逻辑如下:getBean--> doGetBean --> createBean --> doCreateBean,最终Spring会使用
org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#doCreateBean
方法来创建Bean,创建Bean实例的主要逻辑分为了四个部分:
创建Bean实例,填充Bean属性,初始化Bean,销毁Bean
,接下来我们分别对这个四个部分进行探究

3.1 创建Bean实例

创建Bean实例的方法入口如下:

org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory# __ createBeanInstance

if (instanceWrapper == null) {
    instanceWrapper = createBeanInstance(beanName, mbd, args);
}

这个方法的主要逻辑是:推断出创建该Bean的构造器方法和参数,然后使用Java反射去创建Bean实例

3.2 填充Bean属性值populateBean方法

在这个方法中,主要是解析Bean需要注入的成员属性,然后将这些属性注入到该Bean中,如果该Bean有依赖的其他Bean则会优先去创建依赖的Bean,然后返回来继续创建该Bean,
注意这里就会产生Bean创建的循环依赖问题,在本文的第6节中会详细说明

3.4:初始化Bean(initializeBean方法)

初始化Bean主要包括了四个部分:

3.4.1:invokeAwareMethods

在这个方法中主要调用实现的Aware接口中的方法,包括了BeanNameAware.setBeanName,BeanClassLoaderAware.setBeanClassLoader,BeanFactoryAware.setBeanFactory,这三个方法

Aware接口的功能:通过调用Aware接口中的set方法,将Spring容器中对应的Bean注入到正在创建的Bean中

3.4.2:调用前置处理方法:applyBeanPostProcessorsBeforeInitialization

在这个方法中主要是获取Spring容器中所有实现了_
org.springframework.beans.factory.config.BeanPostProcessor
接口的的实现类,然后循环调用
postProcessBeforeInitialization
_方法来加工正在创建的Bean

所以在这个方法中我们可以自定义_
BeanPostProcessor
_来扩展Bean的功能,实现自己的加工逻辑

3.4.3:调用Bean相关的初始化方法:

3.4.3.1 如果是InitializingBean则调用afterPropertiesSet方法

在这个流程中,Spring框架会判断正在创建的Bean是否实现了InitializingBean接口,如果实现了就会调用_
afterPropertiesSet
_方法来执行代码逻辑。

3.4.3.2 调用自定义初始化方法:initMethod

在这个流程中主要调用我们自定义的初始化方法,例如在xml文件中配置的_
init-method和destory-method
或者使用注解配置的
@Bean(initMethod = "initMethod", destroyMethod = "destroyMethod")
_ 方法

3.4.3.3:调用后置处理方法:applyBeanPostProcessorsAfterInitialization

在这个方法中主要是获取Spring容器中所有实现了_
org.springframework.beans.factory.config.BeanPostProcessor
接口的的实现类,然后循环调用
postProcessAfterInitialization
_来加工正在创建的Bean

在这个方法中我们可以自定义_
BeanPostProcessor
_来扩展Bean的功能,实现自己的加工逻辑

4:注册Bean销毁方法

在这里主要是注册Bean销毁时Spring回掉的方法例如:

1:
xml文件中配置的
destroy-method方法
或者_
@Bean
注解中配置的
destroyMethod
_方法

2:_
org.springframework.beans.factory.DisposableBean
接口中的
destory
_方法

5:总结

到这里,从我们编写的Java类到Spring容器中可使用的Bean实例的创建过程就完整的梳理完成了,了解Bean的创建过程能够使我们更加熟悉Bean的使用方法,同时我们也可以在创建Bean的过程中新增自己的处理逻辑,从而实现将自己的组件接入Spring框架

6:Spring循环依赖的解决方法

Spring在创建Bean实例的时候,有时避免不了我们编写的Java类存在互相依赖的情况,如果Spring对这种互相依赖的情况不做处理,那么就会产生创建Bean实例的死循环问题,所以Spring对于这种情况必须特殊处理,下面就来探究Spring是如何巧妙处理Bean之间的循环依赖问题

6.1 暴露钩子方法
getEarlyBeanReference

首先对于单实例类型的Bean来说,Spring在创建Bean的时候,会提前暴露一个钩子方法来获取这个正在创建中的Bean的地址引用,其代码如下:

如上面的代码所示,此时会在_
singletonFactories
这个Map中提前储存这个钩子方法
singletonFactory
_,从而能够提前对外暴露这个Bean的地址引用,
那么为什么获取地址引用需要包装成复杂的方法呢?下面会解释

6.2 其他Bean获取提前暴露的Bean的地址引用

当其他Bean需要依赖正在创建中的Bean的时候,就会调用getSingleton方法来获取需要的Bean的地址引用

如上诉代码所示,在获取Bean的时候会从三个地方来获取

1:
singletonObjects
:这个是存放
已经完全创建完成
的Bean实例的Map

2:
earlySingletonObjects
:这个是存放用
提前暴露的钩子方法创建好
的Bean实例的Map

3:
singletonFactories
:这个是用来存放钩子方法的Map

当获取依赖的Bean的时候,就会调用钩子方法
getEarlyBeanReference
来获取提前暴露的Bean的引用,这个方法的源码如下:

protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
        for (BeanPostProcessor bp : getBeanPostProcessors()) {
            if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
                SmartInstantiationAwareBeanPostProcessor ibp = (SmartInstantiationAwareBeanPostProcessor) bp;
                exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
            }
        }
      }
    return exposedObject;
}



如上面的源码所示,这个方法主要是需要调用
SmartInstantiationAwareBeanPostProcessor

getEarlyBeanReference
方法来提前处理一下尚未创建完成的Bean,而
getEarlyBeanReference
方法有逻辑的实现类只有一个**org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator,**这个类就是创建Aop代理的类,其代码如下:

public Object getEarlyBeanReference(Object bean, String beanName) {
    Object cacheKey = this.getCacheKey(bean.getClass(), beanName);
    //提前标记这个bean已经创建过代理对象了
    this.earlyProxyReferences.put(cacheKey, bean);
    //按条件创建代理对象
    return this.wrapIfNecessary(bean, beanName, cacheKey);
}



如上面的代码所示,这段代码的主要目标就是判断提前暴露的Bean是否需要做动态代理,需要的话就会返回提前暴露的Bean的动态代理对象

那么这里为什么要去判断是否需要动态代理呢?考虑下面这种情况

1:如果这里不返回这个Bean的动态代理对象,但是这个Bean在后续的初始化流程中会存在动态代理:

举例:这里假设A依赖B,B又依赖A,此时B正在获取A提前暴露的引用,如果这时将A本身的地址引用返回给B,那么B里面就会保存A原始的地址引用,当B创建完成后,程序返回去创建A时,结果A在初始化的流程(initializingBean)中发生了动态代理,那么这时Spring容器中实际使用的是A的动态代理对象,而B却持有了原始A的引用,那么这时容器中就会存在A原始的引用以及A的动态代理的引用,从而产生歧义,这就是为什么需要提前去判断是否需要创建动态代理的原因,__ 这个原因的问题在于填充属性populateBean流程在初始化流程(initializingBean)之前,而创建动态代理的过程在初始化流程中

6.3 判断Bean的地址是否发生变化

Spring在Bean初始化之后,又判断了一下Bean初始化之后的地址是否发生了变化,其代码逻辑如下所示:

if (earlySingletonExposure) {
    Object earlySingletonReference = getSingleton(beanName, false);
    //判断是否触发了提前创建bean的逻辑(getEarlyBeanReference)
    //如果有其他bean触发了提前创建bean的逻辑,那么这里就不为null
    if (earlySingletonReference != null) {
        //判断引用地址是否发生了变化
        if (exposedObject == bean) {
            exposedObject = earlySingletonReference;
	}
    }
}



那么这里为什么需要在初始化之后继续判断Bean的地址是否发生了变化呢?

这是因为,如果存在循环依赖,同时Bean在初始化的流程(initializingBean)中
又发生了额外的动态代理
,例如,除了在**getEarlyBeanReference中发生的动态代理之外,还有额外的动态代理发生了,也就是发生了两次动态代理,那么这时Bean的地址与getEarlyBeanReference流程中产生的Bean的地址就不一样了,**这时如果不处理这种情况,又会出现Spring容器中同时存在两种不同的引用对象,又会造成歧义,所以Spring需要避免这种情况的存在

6.4 如果Bean地址发生变化则判断是否存在强依赖的Bean

Spring在Bean的创建过程中如果出现了上诉6.3节的情况时,Spring采取了下面的方法进行处理:

else if (!this.allowRawInjectionDespiteWrapping && hasDependentBean(beanName)) {
    //获取该Bean依赖的Bean
    String[] dependentBeans = getDependentBeans(beanName);
    Set<String> actualDependentBeans = new LinkedHashSet<>(dependentBeans.length);
    //去除因为类型检查而创建的Bean(doGetBean方法typeCheckOnly参数来控制)
    for (String dependentBean : dependentBeans) {
        if (!removeSingletonIfCreatedForTypeCheckOnly(dependentBean)) {
	    actualDependentBeans.add(dependentBean);
        }
    }
    //如果去除因为类型检查而创建的bean之外还存在依赖的bean
    //(这些剩下的bean就是spring实际需要使用的)那么就会抛出异常,阻止问题出现
    if (!actualDependentBeans.isEmpty()) {
	throw new BeanCurrentlyInCreationException(beanName,
            "Bean with name '" + beanName + "' has been injected into other beans [" +
            StringUtils.collectionToCommaDelimitedString(actualDependentBeans) +
	    "] in its raw version as part of a circular reference, but has eventually been " +
            "wrapped. This means that said other beans do not use the final version of the " +
            "bean. This is often the result of over-eager type matching - consider using " +
            "'getBeanNamesForType' with the 'allowEagerInit' flag turned off, for example.");
    }
}



上面这段代码就是Spring处理上诉情况的逻辑,
首先明确的是Spring不允许上诉情况发生
,Spring对于Bean的引用地址发生变化的情况,Spring首先会判断依赖的Bean是否已经完全创建完毕,如果存在完全创建完成的Bean就会直接抛出异常,因为这些完全创建完成的依赖Bean中持有的引用已经是旧地址引用了

具体的处理逻辑是:
先拿到该Bean所有依赖的Bean,然后从这些Bean中排除仅仅是因为类型检查而创建的Bean,如果排除这些Bean之后,还有依赖的Bean,那么这些Bean就是可能存在循环依赖并且是强依赖的Bean
(这些Bean中持有的引用地址是老地址,所以会存在问题)
,Spring就会及时抛出异常,避免发生问题

作者:京东零售 钟磊

来源:京东云开发者社区 自猿其说Tech 转载请注明来源