LinuxCP插件virtio与内核vhost

以下为LCP创建的接口对,VPP侧为物理接口port7,映射到Linux侧的为虚拟接口hostap1,接口hostap1作为vhost的后端存在。VPP侧接口tap1为前端的virtio接口。

vpp# show lcp
itf-pair: [0] port7 tap1 hostap1 24 type tap
vdp#
vdp# show interface
          Name       Idx    State  MTU (L3/IP4/IP6/MPLS)     Counter          Count
port7                 5      up          9000/0/0/0
tap1                  9      up          9000/0/0/0     
vpp#
vpp# quit
/ #
/ # ip -d link show hostap1
24: hostap1: <NO-CARRIER,BROADCAST,MULTICAST,PROMISC,UP> mtu 9000 qdisc mq state DOWN mode DEFAULT group default qlen 1000
    tun addrgenmode eui64 numtxqueues 256 numrxqueues 256

Linux内核发送报文的流程如下:

Linux kernel(hostap1) --> virtio-input(tap1) --> ethernet-input
                                                       |
                                                       |
               port7-output <-- linux-cp-xc-ip4 <-- ip4-input
                    |
                    |
                 port7-tx

VPP LCP发送报文到Linux内核:

dpdk-input(port7) --> ethernet-input --> ip4-input-no-checksum --> ip4-lookup
                                                                       |
                                                                       |
              |--- ip4-dvr-dpo <--ip4-punt-redirect <-- ip4-punt <-- ip4-local
              |
              |
       ip4-dvr-reinject --> tap1-output --> tap1-tx --> Linux kernel(hostap1) 

以下内容分三个部分:virtio/vhost相关初始化,发送和接收流程。

一. virtio/vhost相关初始化

VPP LCP插件中函数tap_create_if创建以上用到的所有设备并进行相应的初始化。首先,打开设备文件/dev/net/tun,创建Linux内核中的tap类型设备hostap1。

tap_create_if
    tfd = open ("/dev/net/tun", O_RDWR | O_NONBLOCK); //获得描述符29
	
    ioctl(tfd=29,TUNGETFEATURES);  //特性协商:必须的特性- IFF_VNET_HDR

    ifr.ifr_flags |= IFF_TAP;
    ifr.ifr_name = "hostap1";
    ioctl (tfd, TUNSETIFF, (void *) &ifr);   //创建LInux TAP设备hostap1.
    //设置virtio网络头部大小
    ioctl (tfd, TUNSETVNETHDRSZ, sizeof (virtio_net_hdr_v1_t))

    //设置发送缓存大小
    ioctl(vif->tap_fds[i], TUNSETSNDBUF,  INT_MAX)

tun设备函数tun_chr_open处理open操作,分配结构tun_file,进行初始化,最终保存在文件结构file的成员private_data中。

在这里插入图片描述

内核函数__tun_chr_ioctl处理TUNSETIFF调用,创建网络设备。tun设备此时具有一个队列(numqueues),tun->tfiles数组大小为256,最多支持256个队列。可再打开/devnet/tun设备,创建tun_file结构,添加到tun->tfiles数组,扩充tun设备的队列数量。

__tun_chr_ioctl
    tun_set_iff(struct net *net, struct file *file, struct ifreq *ifr)
        alloc_netdev_mqs(sizeof(struct tun_struct), name, tun_setup, MAX_TAP_QUEUES=256);
        tun_attach(tun, file, false)
        register_netdevice(tun->dev)

如下图,增加了tun_struct结构。

在这里插入图片描述

其次,打开vhost-net设备文件,创建vhost网络设备。可多次打开vhost-net设备,获得多个文件描述符,对应vhost设备的多个队列。

tap_create_if
    /* open as many vhost-net fds as required and set ownership */
    num_vhost_queues = clib_max (vif->num_rxqs, vif->num_txqs);
    for (i = 0; i < num_vhost_queues; i++) {
        vfd = open ("/dev/vhost-net", O_RDWR | O_NONBLOCK);
        vec_add1 (vif->vhost_fds, vfd);
		//内核将创建vhost内核线程,名称:vhost-$pid,pid为VPP的进程ID号。
		//多队列,或者多设备的情况,会创建多个相同名称的内核线程。
        ioctl(vfd, VHOST_SET_OWNER, 0);  
    }
    ioctl(vif->vhost_fds[0], VHOST_GET_FEATURES, &vif->remote_features);  //特性需要支持VIRTIO_F_VERSION_1

内核函数vhost_net_open分配vhost_net结构,进行相应初始化,最终保存到文件结构file的成员private_data中。

     vhost_dev_init   初始化vhost_net->dev结构
              vhost_poll_init 初始化vhost_net->vqs[0/1].vq.poll
      vhost_poll_init初始化vhost_net->poll[0/1]
     file->private_data = vhost_net.

如下为分配的vhost_net结构,其具有接收/发送两套队列结构vhost_net_virtqueue:
在这里插入图片描述
接下来,LCP进行发送和接收vring环的初始化。

tap_create_if
  for (i = 0; i < num_vhost_queues; i++) {
    if (i < vif->num_rxqs && (
	   args->error = virtio_vring_init (vm, vif, RX_QUEUE (i), args->rx_ring_sz)))
      goto error;

    if (i < vif->num_txqs && (
	   args->error = virtio_vring_init (vm, vif, TX_QUEUE (i), args->tx_ring_sz)))
      goto error;

发送和接收环使用相同的数据结构virtio_vring_t。如下为初始化的vif->rxq_vrings结构。vring->queue_id 标识rx和tx队列的索引,其中偶数为rx队列,奇数为tx队列。queue_id的最低1位对应于内核中vhost驱动中的vhost_net_virtqueue的索引:VHOST_NET_VQ_RX=0, VHOST_NET_VQ_TX=1。

在这里插入图片描述

如下为初始化的vif->txq_vrings结构。对于发送换,没有分配call_fd。

在这里插入图片描述
根据当前环境的配置情况(两个VPP线程:主线程和工作线程),tap_create_if初始化了3个队列,其中2个发送:vif->txq_vrings[2];一个接收:vif->rxq_vrings[1]。不同配置,发送和接收队列不相同。以下将所有队列的信息同步到内核的vhost-net驱动中。

以下为将vring数量同步到内核vhost驱动。

VHOST_SET_VRING_NUM(描述符30/31,TX/RX)
      描述符30对应RX和TX两个队列;描述符31仅有一个TX队列。
      vhost_net<30>->vqs[VHOST_NET_VQ_TX/VHOST_NET_VQ_RX].vq.num = 256/256;
      vhost_net<31>->vqs[VHOST_NET_VQ_TX].vq.num = 256;

将接收/发送vring的三个环结构desc/avail/used地址同步给内核vhost驱动。

VHOST_SET_VRING_ADDR
       描述符30对应两个队列id:0,1;描述符31对应一个队列id:2。
       vif->rxq_vrings[0].queue_id == 0
       vif->txq_vrings[0].queue_id == 1
       vif->txq_vrings[1].queue_id == 2

       addr.flags = 0;
       addr.desc_user_addr = pointer_to_uword (rxq/txq_vring->desc);
       addr.avail_user_addr = pointer_to_uword (rxq/txq_vring->avail);
       addr.used_user_addr = pointer_to_uword (rxq/txq_vring->used);

       将vif接口三个vring分配的desc/avail/used地址下发到内核vhost。
       vhost_net<30>->vqs[VHOST_NET_VQ_TX/VHOST_NET_VQ_RX].vq.<desc/avail/used> = txq/rxq_vring->desc/avail/used;
       vhost_net<31>->vqs[VHOST_NET_VQ_TX].vq.<desc/avail/used> = txq_vring->desc/avail/used;

以下将创建的call_fd和kick_fd同步给内核vhost驱动。

VHOST_SET_VRING_CALL
    tap_create_if中为发送vif->rxq_vrings[0]创建了call_fd和kick_fd,描述符分别为32和33。

    vhost_net<30>->vqs[VHOST_NET_VQ_RX].vq.call_ctx.ctx = eventfd_ctx_fdget(32)
    vhost_net<30>->vqs[VHOST_NET_VQ_RX].vq.kick = eventfd_fget(33)
             vhost_poll_start(&vq->poll, vq->kick);  内核vhost开始监听kick描述符。
        vhost_net<30/31>->vqs[VHOST_NET_VQ_TX].vq.call_ctx.ctx = NULL/NULL;

VHOST_SET_VRING_KICK
    vif->txq_vrings[0/1]两个发送vring不接收内核中断,没有创建call_fd(等于-1),创建的kick_fd描述符分别为34和35

    vhost_net<30>->vqs[VHOST_NET_VQ_TX].vq.kick = eventfd_fget(34)
            vhost_poll_start(&vq->poll, vq->kick);
    vhost_net<31>->vqs[VHOST_NET_VQ_TX].vq.kick = eventfd_fget(35)
            vhost_poll_start(&vq->poll, vq->kick);

以下将vhost_net与tap设备关联起来。vhost_net与tap设备建立了两个关联:a) vhost_net子结构保存了tap设备描述符;b) vhost_net的poll挂载在tap设备的等待队列上。

在这里插入图片描述

VPP virtio信息与内核vhost同步之后,内核结构如下,变化主要体现在vhost_virtqueue结构中。

在这里插入图片描述

二. Linux vhost发送报文到VPP的virtio接口

tun设备发送函数如下,将报文添加到tun_files对应套接口的接收队列上(sk_receive_queue),唤醒等待队列中的wait项,这里有之前注册的vhost_net->poll[RX/TX].wait,发送和接口的wait都注册在这里。

tun_net_xmit(struct sk_buff *skb, struct net_device *dev)
        struct tun_struct *tun = netdev_priv(dev);
        int txq = skb->queue_mapping;
        struct tun_file *tfile;

        tfile = rcu_dereference(tun->tfiles[txq]);
        skb_queue_tail(&tfile->socket.sk->sk_receive_queue, skb);

        wake_up_interruptible_poll(&tfile->wq.wait, POLLIN | POLLRDNORM | POLLRDBAND);

对于POLLIN/POLLOUT,处理程序统一为 vhost_poll_wakeup。这里为POLLIN事件,对应上vhost_net->poll[VHOST_NET_VQ_RX].wait。调用其vhost_work_queue将work添加到vhost_dev设备的work_list链表,唤醒内核处理线程(vhost-$pid)。

vhost_poll_queue(vhost_net->poll[VHOST_NET_VQ_RX])
         vhost_work_queue(poll->dev, &poll->work);
                 list_add_tail(&work->node, &dev->work_list);
                 wake_up_process(dev->worker);
内核处理线程,这里work的处理函数为handle_rx_net->handle_rx。
vhost_worker(void *data)
          work->fn(work);

这里实际处理函数为handle_rx。

handle_rx
     struct vhost_net_virtqueue *nvq = &net->vqs[VHOST_NET_VQ_RX];
     struct vhost_virtqueue *vq = &nvq->vq;
     struct msghdr msg = { .msg_iov = vq->iov,}
     vhost_disable_notify(&net->dev, vq);    //禁止linux-cp插件的kick操作

     struct socket *sock = vq->private_data;  (tun设备描述符对应的套接口)
            get_rx_bufs
                 vhost_get_vq_desc  返回descriptor的索引
                       __get_user(ring_head,  &vq->avail->ring[last_avail_idx % vq->num]) //第一个可用描述符的索引。
                       __copy_from_user(&desc, vq->desc + i, sizeof desc);    // 将索引对应的描述符结构内容拷贝到desc中(struct vring_desc)。

                      //将描述符中指定的缓存地址和长度转成内核iov结构
                      translate_desc(vq, vhost64_to_cpu(vq, desc.addr), vhost32_to_cpu(vq, desc.len), iov + iov_count,  )
                      vq->last_avail_idx++;   /* On success, increment avail index. */

            //get_rx_bufs函数返回值为vring_used_elem结构的heads,其成员id为描述符索引,len为描述符缓存大小,另外返回headcount为heads的数量。
            heads[headcount].id = cpu_to_vhost32(vq, d);
            heads[headcount].len = cpu_to_vhost32(vq, len);
            return headcount;
      至此,根据描述符内容填充完整了msghdr结构的iov,调用recvmsg结构tun设备的数据。
      msg.msg_iovlen = in;
      err = sock->ops->recvmsg(NULL, sock, &msg, sock_len, MSG_DONTWAIT | MSG_TRUNC);   //tun_recvmsg
      vhost_add_used_and_signal_n(&net->dev, vq, vq->heads,   headcount);   //通知linux-cp的virtio设备数据准备完毕。

函数vhost_add_used_and_signal_n通知linux-cp的virtio设备,数据准备完毕。

vhost_add_used_and_signal_n(&net->dev, vq, vq->heads,   headcount);  
     vhost_add_used_n(vq, heads, count);
     vhost_signal(dev, vq);

__vhost_add_used_n
     start = vq->last_used_idx % vq->num;
     used = vq->used->ring + start;

     __put_user(heads[0].id, &used->id)
     __put_user(heads[0].len, &used->len)
     vq->last_used_idx += count
     __put_user(cpu_to_vhost16(vq, vq->last_used_idx), &vq->used->idx)


vhost_signal(struct vhost_dev *dev, struct vhost_virtqueue *vq)
    vhost_notify
    eventfd_signal(vq->call_ctx, 1);

如下,内核vhost_virtqueue结构的变化。

在这里插入图片描述

VPP中函数virtio_input_node作为输入型节点处理接收到的报文。

virtio_input_node
     virtio_device_input_inline
            virtio_device_input_gso_inline    //接收处理报文
            virtio_refill_vring_split                  //重新填充接收描述符,当消耗的描述符数量超过总量1/8时,进行重新填充。

由于Linux内核将used->idx设置为1,vring记录的last_used_idx为0,表明内核使用了一个描述符。以下取出此描述符对应的vlib_buffer_t,进行处理。

virtio_device_input_gso_inline
      n_left = vring->used->idx - vring->last_used_idx;  
      slot = vring->used->ring[vring->last_used_idx & 255].id ;   //取出内核使用的vlib_buffer_t索引
       len = vring->used->ring[vring->last_used_idx & 255].len - hdr_sz;  //减去virtio头部长度,得到报文的实际长度。

       bi0 = vring->buffers[slot];
       vlib_buffer_t *b0 = vlib_get_buffer (vm, bi0);   //得到报文数据所在的vlib_buffer_t,开始对报文进行处理。


       vring->desc_in_use--;
       vring->last_used_idx++;   //由于接收到一个报文,消耗了一个描述符,desc_in_use变为255,last_used_idx增加为1。

处理完成之后,virtio接口vif的rxq_vrings变化如下:

在这里插入图片描述

三. VPP virtio接口发送报文到Linux内核

virtio接口的发送函数virtio_interface_tx_inline如下,如同与上一节,这里设计到的vring都是指vif结构中的txq_vring。这里主要是获取发送描述符,并填充发送数据。

virtio_interface_tx_inline
    virtio_interface_tx_split_gso_inline
        add_buffer_to_slot
        virtio_kick

add_buffer_to_slot
    vring_desc_t *d = &vring->desc[vring->desc_next]; //获得可用的发送描述符
    d.addr = pointer_to_uword (vlib_buffer_get_current (b))) - hdr_sz;   //vlib_buffer结构数据地址,减去virtio头部长度
    d.len = b->current_length + hdr_sz;  //数据长度加上virtio头部长度

    vring->buffers[vring->desc_next] = bi;   //保存待发送vlib_buffer_t的索引bi。
    vring->avail->ring[vring->avail->idx & mask] = vring->desc_next;

发送之后txq_vring结构变化如下:
在这里插入图片描述
发送第一个报文的变化对比如下:
在这里插入图片描述

内核函数handle_tx_kick调用handle_tx接收VPP virtio接口发送来的数据,发送给tap接口。

handle_tx_kick
    handle_tx
        vhost_net_virtqueue *nvq = &net->vqs[VHOST_NET_VQ_TX];
        struct msghdr msg = { .msg_iov = vq->iov };
    
        vhost_net_tx_get_vq_desc(net, vq, vq->iov, ARRAY_SIZE(vq->iov), &out, &in)
             vhost_get_vq_desc
                      head = vq->avail->ring[vq->last_avail_idx % vq->num];  //获得可用的发送描述符的索引。
                      translate_desc函数将描述符中的缓存地址和长度转换为内核iovec结构
         s = move_iovec_hdr(vq->iov, nvq->hdr, hdr_size, out);   //virtio头部数据保存到nvq->hdr, 去掉vq->iov中的virtio头部数据,
         msg.msg_iovlen = out;        //发送描述符的数量
         sock->ops->sendmsg(NULL, sock, &msg, len);      //tun_sendmsg
         vhost_add_used_and_signal(&net->dev, vq, head, 0);

内核vhost_virtqueue结构变化如下,

在这里插入图片描述

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【计算机视觉 | 图像分割】arxiv 计算机视觉关于图像分割的学术速递(7 月 3 日论文合集)

文章目录 一、分割|语义相关(4篇)1.1 SPAE: Semantic Pyramid AutoEncoder for Multimodal Generation with Frozen LLMs1.2 Achieving RGB-D level Segmentation Performance from a Single ToF Camera1.3 Topological Data Analysis Guided Segment Anything Model Prompt Op…

双非本大二上岸大厂——念念不忘,必有回响

⭐️前言⭐️ 博主就读于一所普通的学校&#xff08;双非本&#xff09;&#xff0c;在大二下学期3月份开始网上投递简历&#xff0c;历时近百余天&#xff0c;投递简历500&#xff0c;面试近40余场&#xff0c;最终在6月份学期末&#xff0c;斩获了两个大厂offer&#xff08;北…

开发框架前后端分离的好处是什么

关于将前端和后端保持在一起或分开&#xff0c;存在广泛的意见分歧。唯一重要的是&#xff0c;这两个组件对于开发成熟的应用程序都是必需的。 考虑&#xff1a;紧密耦合的前端和后端 许多人认为后端和前端的分离是一个坏主意&#xff0c;这两个角色之间没有太大区别。 以下…

MySQL-SQL存储过程/触发器详解(下)

♥️作者&#xff1a;小刘在C站 ♥️个人主页&#xff1a; 小刘主页 ♥️努力不一定有回报&#xff0c;但一定会有收获加油&#xff01;一起努力&#xff0c;共赴美好人生&#xff01; ♥️学习两年总结出的运维经验&#xff0c;以及思科模拟器全套网络实验教程。专栏&#xf…

计算机体系结构基础知识介绍之缓存性能的十大进阶优化之编译器优化和硬件预取(六)

优化七&#xff1a;编译器优化&#xff0c;降低miss率 处理器和主内存之间不断扩大的性能差距促使编译器编写者仔细检查内存层次结构&#xff0c;看看编译时优化是否可以提高性能。再次&#xff0c;研究分为指令缺失的改进和数据缺失的改进。接下来介绍的优化可以在许多现代编…

【图像识别】openCV基础知识

图像处理基础 一、使用OpenCV前要准备的工作1.先导入需要用到的库2.自定义&#xff0c;图片展示函数 二、开始学习常用函数1.生成随机整数①. 函数说明②.代码a. 二维灰度图b. 三维彩色图 ③.代码现象a. 二维灰度图b. 三维彩色图 2.通道的分离与合并①先导入一张图片② 将其RGB…

Python获取指定路径下所有文件的绝对路径

import osdef get_file_path_by_name(file_dir, format.JPG):获取指定路径下所有文件的绝对路径:param file_dir::return:L []for root, dirs, files in os.walk(file_dir): # 获取所有文件for file in files: # 遍历所有文件名if os.path.splitext(file)[1] format: L.ap…

typeScript(持续吐血版)

typeScript-02-进阶(TSVue3) 结合vue3来使用TypeScript 使用vite来创建vue3TS的项目 使用vite创建项目&#xff0c;并选择带ts的版本 npm create vitelatest my-vue-ts-app – --template vue-ts 参考链接&#xff1a;https://vuejs.org/guide/typescript/composition-api…

深度学习基础

1 机器学习、深度学习、人工智能 1.1 机器学习 机器学习是一门专门研究计算机怎样模拟或实现人类的学习行为&#xff0c;以获取新的知识或技能&#xff0c;重新组织已有的知识结构使之不断改善自身性能的学科。 基本步骤&#xff1a;获取数据、数据预处理、特征提取、特征选择…

FFmpeg5.0源码阅读—— avcodec_send_packetavcodec_receive_frame

摘要&#xff1a;本文主要描述了FFmpeg中用于解码的接口的具体调用流程&#xff0c;详细描述了该接口被调用时所作的具体工作。   关键字&#xff1a;ffmpeg、avcodec_send_packet、avcodec_receive_frame   读者须知&#xff1a;读者需要了解FFmpeg的基本使用流程&#xf…

MySQL 主从复制[异步 同步 半同步复制] 读写分离 优化 (非常重要)

MySQL 主从复制 1、什么是读写分离&#xff1f; 读写分离&#xff0c;基本的原理是让主数据库处理事务性增、改、删操作&#xff08;INSERT、UPDATE、DELETE&#xff09;&#xff0c;而从数据库处理SELECT查询操作。数据库复制被用来把事务性操作导致的变更同步到集群中的从数据…

计算机网络概述(三)

常见的计算机网络体系结构 OSI体系结构&#xff1a; 物理层→数据链路层→网络层→运输层→会话层→表示层→应用层 TCP/IP体系结构&#xff1a; 网络接口层→网际层→运输层→应用层 一般用户的设备都有TCP/IP协议用于连接因特网&#xff0c;TCP/IP的网络接口层并没有规定使用…

【Redis】秒杀业务设计、悲观锁与乐观锁

1 全局ID生成器 一些情境下&#xff0c;使用数据库的ID自增将会产生一些问题。 一方面&#xff0c;自增ID规律性明显&#xff0c;可能被猜测出来并产生一些漏洞另一方面&#xff0c;当数据量很大很大很大时&#xff0c;单表数据量可能会受到限制&#xff0c;需要分表&#xf…

网络编程5——TCP协议的五大效率机制:滑动窗口+流量控制+拥塞控制+延时应答+捎带应答

文章目录 前言一、TCP协议段与机制TCP协议的特点TCP报头结构TCP协议的机制与特性 二、TCP协议的 滑动窗口机制 三、TCP协议的 流量控制机制 四、TCP协议的 拥塞控制机制 五、TCP协议的 延时应答机制 六、TCP协议的 捎带应答机制 总结 前言 本人是一个普通程序猿!分享一点自己的…

RabbitMQ在SpringBoot中的高级应用(2)

过期时间 1.单独的设置队列的存活时间,队列中的所有消息的过期时间一样 Bean//创建交换机public DirectExchange ttlQueueExchange(){// 交换机名称 是否持久化 是否自动删除return new DirectExchange("ttl_queue_log",true,false);}Bean//创建队列publ…

吴恩达ChatGPT《LangChain for LLM Application Development》笔记

基于 LangChain 的 LLM 应用开发 1. 介绍 现在&#xff0c;使用 Prompt 可以快速开发一个应用程序&#xff0c;但是一个应用程序可能需要多次写Prompt&#xff0c;并对 LLM 的输出结果进行解析。因此&#xff0c;需要编写很多胶水代码。 Harrison Chase 创建的 LangChain 框…

需求分析引言:架构漫谈(五)架构师成长之路

我研发领域也从事了一些年&#xff0c;期间也做过一些架构设计工作&#xff0c;包括C#单体转型为Java微服务、Python单体转型为Java微服务等&#xff0c; 也尝试着从自己的经验角度&#xff0c;来汇总一些知识点&#xff0c;同时描述一下如何成长为一个合格的软件架构师&#x…

基于SpringBoot+Vue+微信小程序的电影平台

✌全网粉丝20W,csdn特邀作者、博客专家、CSDN新星计划导师、java领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345;文末获取项目下载方式&#x1f345; 一、项目背景介绍&#xff1a; 研究背景&#xff1a;…

Docker 中的 .NET 异常了怎么抓 Dump (转载)

一、背景 1. 讲故事 有很多朋友跟我说&#xff0c;在 Windows 上看过你文章知道了怎么抓 Crash, CPU爆高&#xff0c;内存暴涨 等各种Dump&#xff0c;为什么你没有写在 Docker 中如何抓的相关文章呢&#xff1f;瞧不上吗&#xff1f; 哈哈&#xff0c;在DUMP的分析旅程中&a…

提升工作效率:推荐几款实用的Mac项目管理工具!

在当今软件和技术高度发达的时代&#xff0c;项目管理依然是一项非常重要的任务。现在&#xff0c;有越来越多的人喜欢使用mac电脑进行项目管理&#xff0c;因为mac众所周知的稳定性和使用便捷性。但问题是&#xff0c;mac系统自带的项目管理工具并不是非常完美&#xff0c;因此…