CNI设计解读

news2024/9/23 5:27:00

何为cni?

kubernetes在设计网络方案的时候并没有设计统一的网络方案,只提供了统一的容器网络接口也就是所谓cni,这么做的目的就是为了遵循kubernets的核心理念OutOfTree,简单来讲就是专注于自身核心能力,将其他能力类似csi cni cri交给社区以及领域专家,这样一方面可以降低软件自身使用的复杂度,减小稳定性风险。

flannel cni设计

在一个pod生命周期中,cni主要调用3个方法分别是cmdAdd,cmdDel, cmdCheck,分别代表:创建容器时调用cmdAdd,销毁容器时调用cmdDel, 以及销毁前的检测cmdCheck,但是cmdCheck是在0.4.0之后添加的,对于目前常用的cni版本0.3.1来说并不支持。

整体链路为flnnel-cni->bridge(创建设备)->host-local(申请ip)->bridge(申请到的ip写入到网卡上并配置路由)

大致调用流程图如下:

流程详解

第一部分(kubelet)

创建流程

1. kubelet解析/etc/cni/net.d/10-flannel.conflist文件之后 根据文件里的plugins里的对象逐一执行插件并把结果传递给下一个插件继续执行 最后将结果缓存到/var/lib/cni/cache目录。

/etc/cni/net.d/10-flannel.conflist配置如下 分别调用两个插件:

      a. flannel插件主流程依次调用bridge以及host-local 创建虚拟网卡以及路由

      b.portmap插件主要针对配置hostPort的pod 为该pod通过iptables配置端口映射

{
  "name": "cbr0",
  "cniVersion": "0.3.1",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}
func (c *CNIConfig) AddNetworkList(ctx context.Context, list *NetworkConfigList, rt *RuntimeConf) (types.Result, error) {
   var err error
   var result types.Result
   for _, net := range list.Plugins {
      result, err = c.addNetwork(ctx, list.Name, list.CNIVersion, net, result, rt)
      if err != nil {
         return nil, err
      }
   }

   if err = setCachedResult(result, list.Name, rt); err != nil {
      return nil, fmt.Errorf("failed to set network %q cached result: %v", list.Name, err)
   }

   return result, nil
}

2. 通过10-flannel.conflist我们可以看到调用的第一个插件为flannel,那么kubelet将插件目录/opt/cni/bin/flannel传递给invoke.ExecPluginWithResult函数.

func (c *CNIConfig) addNetwork(ctx context.Context, name, cniVersion string, net *NetworkConfig, prevResult types.Result, rt *RuntimeConf) (types.Result, error) {
   c.ensureExec()
   pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path)
   if err != nil {
      return nil, err
   }

   newConf, err := buildOneConfig(name, cniVersion, net, prevResult, rt)
   if err != nil {
      return nil, err
   }

   return invoke.ExecPluginWithResult(ctx, pluginPath, newConf.Bytes, c.args("ADD", rt), c.exec)
}

3. invoke.ExecPluginWithResult函数里调用exec.ExecPlugin并传递参数执行相应的二进制文件

func ExecPluginWithResult(ctx context.Context, pluginPath string, netconf []byte, args CNIArgs, exec Exec) (types.Result, error) {
   if exec == nil {
      exec = defaultExec
   }

   stdoutBytes, err := exec.ExecPlugin(ctx, pluginPath, netconf, args.AsEnv())
   if err != nil {
      return nil, err
   }

   // Plugin must return result in same version as specified in netconf
   versionDecoder := &version.ConfigDecoder{}
   confVersion, err := versionDecoder.Decode(netconf)
   if err != nil {
      return nil, err
   }

   return version.NewResult(confVersion, stdoutBytes)
}

三个参数如下示例:

 pluginPath: /opt/cni/bin/flannel
  NetConf: types.NetConf{
    CNIVersion: "0.3.1",
    Name: "cbr0",
    Type: "flannel",
    IPAM: types.IPAM{
      Type: "",
    },
    DNS: types.DNS{
      Nameservers: nil,
      Domain: "",
      Search: nil,
      Options: nil,
    },
  },
  Delegate: map[string]interface {}{
    "hairpinMode": true,
    "isDefaultGateway": true,
  },
}
args.AsEnv():  设置CNI_COMMAND,CNI_CONTAINERID,CNI_NETNS,CNI_ARGS,CNI_IFNAME,CNI_PATH几个环境变量给到插件
二进制程序通过PluginMain里的getCmdArgsFromEnv函数将环境变量解析成CmdArgs之后传递给cmdAdd,cmdDel,cmdCheck

4. 所有插件执行完成之后调用setCachedResult将结果写入到/var/lib/cni/cache/results目录下

删除流程

1.类似于创建流程,整体流程如下:

TearDownPod -> plugin.deleteFromNetwork -> cniNet.DelNetworkList -> delNetwork -> invoke.ExecPluginWithoutResult

从这里我们看到基本的流程和创建流程类似 唯一不一致的地方是我们在c.args("DEL", rt) 传入的是DEL而不是ADD。也就是说我们在这里判断我们执行插件的函数是cmdAdd还是cmdDel

2. 最后删除/var/lib/cni/cache/results下的对应文件,/var/lib/cni/cache/results的目的是为了提供cmdcheck用于检测是否合规

第二部分(flannel-cni)

        flannel-cni总共实现了两个方法cmdAdd以及cmdDel,通过PluginMain注册cmdAdd,cmdDel两个方法,并在PluginMain里讲传递过来的参数解析成cmdargs传递给cmdAdd,cmdDel其实现的能力如下:

cmdAdd:

1. 通过loadFlannelNetConf解析cmdargs里的StdinData,内容为NetConf结构体,并配置SubnetFile(/run/flannel/subnet.env)以及DataDir(/var/lib/cni/flannel)

2. 通过loadFlannelSubnetEnv解析由flannel生成的/run/flannel/subnet.env文件

3. 将/run/flannel/subnet.env里面的参数 渲染到由loadFlannelNetConf生成的出来的结构体中,也就是hairpinMode(发夹模式 可以让数据流量从同一个点位进出)以及isDefaultGateway(是否生成pod容器的默认网关)两个参数:

types.NetConf{
    CNIVersion: "0.3.1",
    Name: "cbr0",
    Type: "bridge",
    Mtu: "1450",
    HairpinMode: true,
    IpMasq: false,
    IsDefaultGateway: true,
    IsGateway: true,
    IPAM: types.IPAM{
      "type":   "host-local",
      "subnet": 10.244.0.1/24,
      "routes": []types.Route{
        types.Route{
         Dst: 10.244.0.0/16,
        },
      },
    }
}

 4. delegateAdd通过type字段找到后续需要执行的插件名称 并通过saveScratchNetConf方法将以上结果保存到/var/lib/cni/flannel目录下并在invoke.DelegateAddDelegate里通过type字段来判断执行下一个插件的名称(bridge),并将之前的结果以及args.env传递给下一个插件(bridge),该结果通过下一个插件的StdinData获取到.

func delegateAdd(cid, dataDir string, netconf map[string]interface{}) error {
   netconfBytes, err := json.Marshal(netconf)
   if err != nil {
      return fmt.Errorf("error serializing delegate netconf: %v", err)
   }

   // save the rendered netconf for cmdDel
   if err = saveScratchNetConf(cid, dataDir, netconfBytes); err != nil {
      return err
   }

   result, err := invoke.DelegateAdd(netconf["type"].(string), netconfBytes)
   if err != nil {
      return err
   }

   return result.Print()
}

cmdDEL:

1.通过loadFlannelNetConf解析cmdargs里的StdinData,内容为NetConf结构体,并配置SubnetFile(/run/flannel/subnet.env)以及DataDir(/var/lib/cni/flannel)

2. 通过CNI_ARGS获取到容器的id 通过consumeScratchNetConf函数读取/var/lib/cni/flannel/$containerId里面的配置类似,读取之后移除该文件。

# cat 0e39bc1c61f18e1af93bc6f455097fcfe04872d590c313eccf4d3a4f7de224d2
{"cniVersion":"0.3.1","hairpinMode":true,"ipMasq":false,"ipam":{"routes":[{"dst":"10.220.0.0/16"}],"subnet":"10.220.9.0/24","type":"host-local"},"isDefaultGateway":true,"isGateway":true,"mtu":1450,"name":"cbr0","type":"bridge"}

3. 将从/var/lib/cni/flannel/$containerId读取出来的内容 发给下一个插件(bridge)的CmdDel函数

第三部分(bridge cni)

cmdAdd部分

1. loadNetConf函数作用是读取flannel cni传递过来的StdinData 并设置BrName参数为cni0 组合成新的NetConf对象

2. 通过setupBridge函数里的ensureBridge函数创建名称为cni0的bridge虚拟桥接网卡

3. 通过GetNS获取该pod容器所在的网络名称空间id,args.Netns类似pid所在的net文件类似/proc/18649/ns/net

4. 通过setupVeth在改网络namespace里创建veth网卡对,网络namespace端为eth0 宿主机端为veth***(该名称由RandomVethName函数生成,并将veth加入到宿主机端namespace下。之后配置宿主机端端veth网卡hairpin mode。

5. 之后通过ipam.ExecAdd往下ipam(IP地址管理器)里获取ip地址,这里ipam类型为host-local,host-local的插件的规则从第四部分详解。

6. 获取到ip的结构为:

{
   "ip4": {
     "ip": "10.244.0.2",
     "gateway": "10.244.0.1"
   },
   "dns": {}
}

7. 通过calcGatewy来获取网关详情 这里主要以IsDefaultGW参数来判断是否给pod容器加默认网关

8. 之后在ConfigureIface函数里进入到上述的网络namespace配置eth0网卡的ip地址并根据calcGatewy的结果增加默认路由

9. 之后在ensureBridgeAddr函数里配置cni0网卡的ip地址,网卡mac地址,并在enableIPForward开启ipv4转发(/proc/sys/net/ipv4/ip_forward)。

10.之后在SetupIPMasq函数里配置地址伪装的iptables规则

CmdDEL部分

1. 通过loadNetConf读取flannel插件传递的过来的StdinData

2. 将flannel插件传递的过来的StdinData传递给IPAM插件去释放分配给该pod的ip地址,这里的IPAM从StdinData里可以看到 调用的是host-local插件。

3. 切换到该容器的network namespace之后删除虚拟网卡信息,并且如果开启了地址伪装功能,删除对应的iptables规则

第四部分(host-local cni)

cmdAdd部分

1.通过LoadIPAMConfig函数 将bridge传过来的StdinData以及args.env生成IPAMConfig对象

2. 之后通过allocator.Get根据传递过来的range进行ip地址分配,这里也可以指定ip地址分配,如果指定ip地址那么会校验ip地址是否合规,如果合规将分配的地址存储到本地磁盘下(/var/lib/cni/networks/cbr0/)以ip为文件名称 内容为容器id,并把最后获取到的ip地址存储到/var/lib/cni/networks/cbr0/last_reserved_ip.0,这么做的目的是保证分配的地址不冲突。

3. 迭代器GetIter的主要逻辑是基于LastReservedIP(这个数据保存在一个文件中)和ip range,找到下一个可分配的IP并返回,获取规则是避免LastReservedIP里的ip 在LastReservedIP的基础上+1 作为分配的ip 当然也会通过range检测分配的ip是否在合规范围内。

4. 核心函数GetIter 生成出迭代器对象,并通过lastReservedIP来配置cur这个参数的值,Next函数根据cur的值来判断下一个可分配的ip地址

5.之后通过Reserve方法将ip地址和容器id进行绑定存储到/var/lib/cni/networks/cbr0/并更新last_reserved_ip.0文件,最后将结果返回给bridge插件。

func (a *IPAllocator) GetIter() (*RangeIter, error) {
   iter := RangeIter{
      rangeset: a.rangeset,
   }

   // Round-robin by trying to allocate from the last reserved IP + 1
   startFromLastReservedIP := false

   // We might get a last reserved IP that is wrong if the range indexes changed.
   // This is not critical, we just lose round-robin this one time.
   lastReservedIP, err := a.store.LastReservedIP(a.rangeID)
   if err != nil && !os.IsNotExist(err) {
      log.Printf("Error retrieving last reserved ip: %v", err)
   } else if lastReservedIP != nil {
      startFromLastReservedIP = a.rangeset.Contains(lastReservedIP)
   }

   // Find the range in the set with this IP
   if startFromLastReservedIP {
      for i, r := range *a.rangeset {
         if r.Contains(lastReservedIP) {
            iter.rangeIdx = i
            iter.startRange = i

            // We advance the cursor on every Next(), so the first call
            // to next() will return lastReservedIP + 1
            iter.cur = lastReservedIP
            break
         }
      }
   } else {
      iter.rangeIdx = 0
      iter.startRange = 0
      iter.startIP = (*a.rangeset)[0].RangeStart
   }
   return &iter, nil
}
func (i *RangeIter) Next() (*net.IPNet, net.IP) {
   r := (*i.rangeset)[i.rangeIdx]

   // If this is the first time iterating and we're not starting in the middle
   // of the range, then start at rangeStart, which is inclusive
   if i.cur == nil {
      i.cur = r.RangeStart
      i.startIP = i.cur
      if i.cur.Equal(r.Gateway) {
         return i.Next()
      }
      return &net.IPNet{IP: i.cur, Mask: r.Subnet.Mask}, r.Gateway
   }

   // If we've reached the end of this range, we need to advance the range
   // RangeEnd is inclusive as well
   if i.cur.Equal(r.RangeEnd) {
      i.rangeIdx += 1
      i.rangeIdx %= len(*i.rangeset)
      r = (*i.rangeset)[i.rangeIdx]

      i.cur = r.RangeStart
   } else {
      i.cur = ip.NextIP(i.cur)
   }

   if i.startIP == nil {
      i.startIP = i.cur
   } else if i.rangeIdx == i.startRange && i.cur.Equal(i.startIP) {
      // IF we've looped back to where we started, give up
      return nil, nil
   }

   if i.cur.Equal(r.Gateway) {
      return i.Next()
   }

   return &net.IPNet{IP: i.cur, Mask: r.Subnet.Mask}, r.Gateway
}

cmdDel部分

1. 通过LoadIPAMConfig读取bridge传递的StdinData内容

2. disk.New出生后存储,默认的目录是/var/lib/cni/networks/{name},后续通过store操作数据

3. 通过ipAllocator.Release释放ip 并通过ReleaseByID函数循环读取/var/lib/cni/networks/cbr0目录下的文件,如果文件内容匹配容器id那么就移除该文件。

func (s *Store) ReleaseByID(id string) error {
   err := filepath.Walk(s.dataDir, func(path string, info os.FileInfo, err error) error {
      if err != nil || info.IsDir() {
         return nil
      }
      data, err := ioutil.ReadFile(path)
      if err != nil {
         return nil
      }
      if strings.TrimSpace(string(data)) == strings.TrimSpace(id) {
         if err := os.Remove(path); err != nil {
            return nil
         }
      }
      return nil
   })
   return err
}

第五部分(portmap cni)

cmdAdd部分

最后flannel-cni返回给kubelet插件的结构体如下 并把结果放到prevResult里交给portmap插件 配置案例如下:

{
        "name": "cbr0",
        "cniVersion": "0.3.1",
        "runtimeConfig": {
                "portMappings": [{
                        "hostPort": 801,
                        "containerPort": 80,
                        "protocol": "tcp"
                }]
        },
        "prevResult": {
                "cniVersion": "1.0.0",
                "interfaces": [{
                        "name": "eth0",
                        "sandbox": "/proc/20202/ns/net"
                }],
                "ips": [{
                        "interface": 2,
                        "address": "10.244.0.136/24",
                        "version": "4"
                }],
                "routes": [{
                        "dst": "0.0.0.0/0",
                        "gw": "10.244.0.1"
                }],
                "dns": {}
        }
}

2. parseConfig函数解析上面的结构体以及接口名称通常为eth0,在这里主要判断结构体内容是否合规 是否需要进行端口映射

3. 通过forwardPorts生成并在宿主机加入iptables规则映射规则DNT以及SNAT默认情况下地址伪装是需要配置snat规则,这里配置dnat主要是为了对需要地址伪装的流量进行打标记

cmdDEL部分

1. 通过parseConfig解析StdinData以及ifname

2. 获取args.ContainerID

3. 通过unforwardPorts删除对应的iptables端口映射规则dnat以及snat

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/32715.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

使用albumentations对coco进行数据增强

数据增强的必要性 目前几乎所有描述最先进的图像识别模型的论文都使用了基本的增强技术 深度神经网络需要大量的训练数据来获得良好的结果,并防止过度拟合,然而要获得足够的训练样本往往非常困难,多种原因可能使得收集足够的数据非常困难&a…

【计算机毕业设计】27.仓库管理系统源码

一、系统截图(需要演示视频可以私聊) 摘 要 网络的广泛应用给生活带来了十分的便利。所以把仓库管理与现在网络相结合,利用JSP技术建设仓库管理系统,实现仓库管理系统的信息化。则对于进一步提高公司的发展,丰富仓库管…

户外运动耳机推荐、十大户外运动耳机品牌推荐排名清单

最近南方的天气有点秋高气爽,这样的天气要说最适合进行什么运动,那户外徒步肯定是最佳选择,在这样适宜的天气下去拥抱大自然,体验户外山野环境的美好绝对是个很棒的过程!但是一个人的长时间徒步多少还是会少了些味道&a…

408 | 大纲知识点考点冲刺 复习整理 ——【计网】第三章 数据链路层

自用冲刺笔记整理。 部分图片来自王道。 加油ヾ(◍∇◍)ノ゙ (一)数据链路层的功能 结点: 主机、 路由器。帧 : 链路层的协议数据单元, 封装网络层数据报。其主要作用是加强物理层传输原始比特流的功能,将物理层提供的可能出错的物理连接改造成为逻辑上无差错的数据链路,…

11.24Spring学习第四天

整合Mybatis(重点) 步骤 1.引入依赖 <!--引入相关依赖--><!-- spring jdbc --><dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId><version>${spring.version}</version></…

在字节跳动做了5年软件测试,12月无情被辞,想给划水的兄弟提个醒...

前言 先简单交代一下背景吧&#xff0c;某不知名 985 的本硕&#xff0c;17 年毕业加入字节&#xff0c;以“人员优化”的名义无情被裁员&#xff0c;之后跳槽到了有赞&#xff0c;一直从事软件测试的工作。之前没有实习经历&#xff0c;算是5年的工作经验吧。 这5年之间完成…

如何在数据库只保存oss上的文件名, 当查询数据时根据字段的文件名, 获取oss的公网访问地址,并对字段内容重写

如何在数据库只保存oss上的文件名, 当查询数据时根据字段的文件名, 获取oss的公网访问地址,并对字段内容重写. 有这样一个需求, 图片上传到oss 上, 返回文件名和公网访问地址, 但是要求数据库中只存储文件名称. 有两个目的: 数据库只存储文件名称, 方便后期oss 上数据迁移到其他…

面试官:在 Java 中 new 一个对象的流程是怎样的?彻底被问懵了。。

对象怎么创建&#xff0c;这个太熟悉了&#xff0c;new一下(其实还有很多途径&#xff0c;比如反射、反序列化、clone等&#xff0c;这里拿最简单的new来讲)&#xff1a; Dog dog new Dog();我们总是习惯于固定语句的执行&#xff0c;却对于背后的实现过程缺乏认知&#xff0…

[附源码]java毕业设计医院门诊信息管理系统

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybatis Maven Vue 等等组成&#xff0c;B/S模式 M…

【多线程 (二)】线程安全问题、同步代码块、同步方法、Lock锁、死锁

文章目录线程安全问题前言2.1多线程模拟卖票出现的问题2.2卖票案例中出现的问题分析2.3同步代码块解决数据安全问题2.4同步方法解决数据安全问题2.5Lock锁2.6死锁总结线程安全问题 前言 之前我们讲了多线程的基础知识&#xff0c;但是在我们解决实际问题中会遇到一些错误&…

接口自动化测试实战之智能场景如何攻破

智能场景的意思就是怎么样才能让接口自动化智能化&#xff0c;让使用接口框架的人越来越没有要求&#xff0c;大街上随便拉一个人来&#xff0c;一分钟了解框架的使用&#xff0c;就能完美地去完成接口自动化测试。 1.找出公司要求我们测试的接口的共同点 假设有10个接口&…

【附源码】计算机毕业设计JAVA移动电商网站

【附源码】计算机毕业设计JAVA移动电商网站 目运行 环境项配置&#xff1a; Jdk1.8 Tomcat8.5 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; JAVA mybati…

(一)进程与线程

黑马程序员深入学习Java并发编程&#xff0c;JUC并发编程全套教程_哔哩哔哩_bilibili 一、进程与线程&#xff08;P5&#xff09; 1. 进程 &#xff08;1&#xff09;程序由指令和数据组成&#xff0c;但这些指令要运行&#xff0c;数据要读写&#xff0c;就必须将指令加载至…

查阅标准文档以及effective c++作者文笔 真正搞懂万能引用和引用折叠以及完美转发

在解释任何东西以前 我都必须要强调 我们为什么需要这个东西 如果一个东西我们都是不需要的 那么我们解释他干嘛? 假定你彻底了解了一个东西 但是你并不知道你为什么需要他 他能解决什么问题 那你仅仅就只是背了一段理论性的东西 对于你本人的成长毫无用处 这里我们一次性讲懂…

sqli-labs/Less-58

这一关只有五次机会了 哎怎么办啊 那就只能找出每轮的共同点 这一关肯定不能一轮就完成所有的操作 至少得分个两轮进行操作才可以 前一轮进行注入类型的获取 后一轮进行各种爆破操作 分配好了 首先去判断一下注入类型是否属于数字型注入 输入如下 id1 and 12 回显如下 不属于…

Web大学生网页作业成品 基于HTML+CSS+JavaScript---个人介绍5页 带视频 带报告

⛵ 源码获取 文末联系 ✈ Web前端开发技术 描述 网页设计题材&#xff0c;DIVCSS 布局制作,HTMLCSS网页设计期末课程大作业 | ‍个人博客网站 | ‍个人主页介绍 | 个人简介 | 个人博客设计制作 | 等网站的设计与制作 | 大学生个人HTML网页设计作品 | HTML期末大学生网页设计作业…

大规模 Spring Cloud 微服务无损上下线探索与实践

作者&#xff1a;十眠 “从一次常见的发布说起&#xff0c;在云上某个系统应用发布时&#xff0c;重启阶段会导致较大数量的 OpenAPI、上游业务的请求响应时间明显增加甚至超时失败。随着业务的发展&#xff0c;用户数和调用数越来越多&#xff0c;该系统又一直保持一周发布二…

CAD特殊符号,你不一定会!!!

在CAD软件中&#xff0c;有时候会输入一些特殊的符号。比如在标明高低差的时候会输入“”号&#xff0c;在标明管子或者钢筋的直径为输入直径符号“”&#xff0c;为了标明角度值需要输入符号“”&#xff0c;那么这些符号怎么快速的绘制出来呢&#xff1f;我们一起用CAD梦想画…

专利解析|多维建模结合AI识别商品特征的方法

企业采购数字化转型的背景 国家“十四五”规划纲要提出要推进产业数字化转型&#xff0c;在供给侧结构性改革大背景下&#xff0c;国家出台了《企业数字化采购实施指南》&#xff0c;大大促进了企业采购电商化的发展。企业电商化采购能提高企业的采购效率、加快物流速度、降低…

m基于QPSK调制解调的无线图像传输matlab仿真,包括扩频解扩均衡等模块

目录 1.算法描述 2.仿真效果预览 3.MATLAB部分代码预览 4.完整MATLAB程序 1.算法描述 软件无线电在无线通信领域被称为是自模拟通信过渡到数字通信之后的又一次革命&#xff0c;在军用和民用方面都有着广阔的应用。它是一种新的无线通信技术&#xff0c;基于通用的可编程的…