vhost-user 分析1


2018-01-24

占個坑,准備下寫vhost-user的東西


vhost-user是vhost-kernel又回到用戶空間的實現,其基本思想和vhost-kernel很類似,不過之前在內核的部分現在有另外一個用戶進程代替,可能是snapp或者dpdk等。在網上看相關資料較少,就簡單介紹下。雖然和vhost-kernel實現的目標一致,但是具體的實現方式卻有所不同。vhost-user下,UNIX本地socket代替了之前kernel模式下的設備文件進行進程間的通信(qemu和vhost-user app),而通過mmap的方式把ram映射到vhost-user app的進程空間實現內存的共享。其他的部分和vhost-kernel原理基本一致。這種情況下一般qemu作為client,而vhost-user app作為server如DPDK。而本文對於vhost-user server端的分析主要也是基於DPDK源碼。本文主要分析涉及到的三個重要機制:qemu和vhost-user app的消息傳遞,guest memory和vhost-user app的共享,guest和vhost-user app的通知機制。

      

一、qemu和vhost-user app的消息傳遞

qemu和vhost-user app的消息傳遞是通過UNIX本地socket實現的,對應於kernel下每個ioctl的實現,這里vhost-user app必須對每個ioctl 提供自己的處理,DPDK下在vhost-user.c文件下的vhost_user_msg_handler函數,這里有一個核心的數據結構:VhostUserMsg,該結構是消息傳遞的載體,整個結構並不復雜

typedef struct VhostUserMsg {
    union {
        VhostUserRequest master;//qemu
        VhostUserSlaveRequest slave;//dpdk
    } request;

#define VHOST_USER_VERSION_MASK     0x3
#define VHOST_USER_REPLY_MASK       (0x1 << 2)
#define VHOST_USER_NEED_REPLY        (0x1 << 3)
    uint32_t flags;
    uint32_t size; /* the following payload size */
    union {
#define VHOST_USER_VRING_IDX_MASK   0xff
#define VHOST_USER_VRING_NOFD_MASK  (0x1<<8)
        uint64_t u64;
        struct vhost_vring_state state;
        struct vhost_vring_addr addr;
        VhostUserMemory memory;
        VhostUserLog    log;
        struct vhost_iotlb_msg iotlb;
    } payload;
    int fds[VHOST_MEMORY_MAX_NREGIONS];
} __attribute((packed)) VhostUserMsg;

 既然是傳遞消息,其中必須包含消息的種類、消息的內容、消息內容的大小。而這些也是該結構的主要部分,首個union便標志該消息的種類。接下來的Flags表明該消息本身的一些性質,如是否需要回復等。size就是payload的大小,接下來的union是具體的消息內容,最后的fds是關聯每一個memory RAM的fd數組。消息種類如下:

typedef enum VhostUserRequest {
    VHOST_USER_NONE = 0,
    VHOST_USER_GET_FEATURES = 1,
    VHOST_USER_SET_FEATURES = 2,
    VHOST_USER_SET_OWNER = 3,
    VHOST_USER_RESET_OWNER = 4,
    VHOST_USER_SET_MEM_TABLE = 5,
    VHOST_USER_SET_LOG_BASE = 6,
    VHOST_USER_SET_LOG_FD = 7,
    VHOST_USER_SET_VRING_NUM = 8,
    VHOST_USER_SET_VRING_ADDR = 9,
    VHOST_USER_SET_VRING_BASE = 10,
    VHOST_USER_GET_VRING_BASE = 11,
    VHOST_USER_SET_VRING_KICK = 12,
    VHOST_USER_SET_VRING_CALL = 13,
    VHOST_USER_SET_VRING_ERR = 14,
    VHOST_USER_GET_PROTOCOL_FEATURES = 15,
    VHOST_USER_SET_PROTOCOL_FEATURES = 16,
    VHOST_USER_GET_QUEUE_NUM = 17,
    VHOST_USER_SET_VRING_ENABLE = 18,
    VHOST_USER_SEND_RARP = 19,
    VHOST_USER_NET_SET_MTU = 20,
    VHOST_USER_SET_SLAVE_REQ_FD = 21,
    VHOST_USER_IOTLB_MSG = 22,
    VHOST_USER_MAX
} VhostUserRequest;

 到目前為止並不復雜,我們下面看下消息本身的初始化機制,socket-file的路徑會作為參數傳遞進來,在main函數中examples/vhost/,調用us_vhost_parse_socket_path對參數中的socket-fiile參數進行解析,保存在靜態數組socket_files中,而后在main函數中有一個for循環,針對每個socket-file,會調用rte_vhost_driver_register函數注冊vhost 驅動,該函數的核心功能就是為每個socket-fie創建本地socket,通過create_unix_socket函數。vhost中的socket結構通過create_unix_socket描述。在注冊驅動之后,會根據具體的特性設置features。在最后會通過rte_vhost_driver_start啟動vhost driver,該函數倒是值得一看:

int
rte_vhost_driver_start(const char *path)
{
    struct vhost_user_socket *vsocket;
    static pthread_t fdset_tid;

    pthread_mutex_lock(&vhost_user.mutex);
    vsocket = find_vhost_user_socket(path);
    pthread_mutex_unlock(&vhost_user.mutex);

    if (!vsocket)
        return -1;
    /*創建一個線程監聽fdset*/
    if (fdset_tid == 0) {
        int ret = pthread_create(&fdset_tid, NULL, fdset_event_dispatch,
                     &vhost_user.fdset);
        if (ret < 0)
            RTE_LOG(ERR, VHOST_CONFIG,
                "failed to create fdset handling thread");
    }

    if (vsocket->is_server)
        return vhost_user_start_server(vsocket);
    else
        return vhost_user_start_client(vsocket);
}

 函數參數是對應的socket-file的路徑,進入函數內部,首先便是根據路徑通過find_vhost_user_socket函數找到對應的vhost_user_socket結構,所有的vhost_user_socket以一個數組的形式保存在vhost_user數據結構中。接下來如果該socket確實存在,就創建一個線程,處理vhost-user的fd,這個作用我們后面再看,該線程綁定的函數為fdset_event_dispatch。這些工作完成后,就啟動該socket了,起始qemu和vhost可以互做server和client,一般情況下vhsot是作為server存在。所以這里就調用了vhost_user_start_server。這里就是我們常見的socket編程操作了,調用bind……然后listen……,沒什么好說的。后面調用了fdset_add函數,這是就是vhost處理消息fd的一個單獨的機制,

int
fdset_add(struct fdset *pfdset, int fd, fd_cb rcb, fd_cb wcb, void *dat)
{
    int i;

    if (pfdset == NULL || fd == -1)
        return -1;

    pthread_mutex_lock(&pfdset->fd_mutex);
    i = pfdset->num < MAX_FDS ? pfdset->num++ : -1;
    if (i == -1) {
        fdset_shrink_nolock(pfdset);
        i = pfdset->num < MAX_FDS ? pfdset->num++ : -1;
        if (i == -1) {
            pthread_mutex_unlock(&pfdset->fd_mutex);
            return -2;
        }
    }

    fdset_add_fd(pfdset, i, fd, rcb, wcb, dat);
    pthread_mutex_unlock(&pfdset->fd_mutex);

    return 0;
}

 簡單來說就是該函數為對應的fd注冊了一個處理函數,當該fd有信號時,就調用該函數,這里就是vhost_user_server_new_connection。具體是如何實現的呢?看下fdset_add_fd函數

static void
fdset_add_fd(struct fdset *pfdset, int idx, int fd,
    fd_cb rcb, fd_cb wcb, void *dat)
{
    struct fdentry *pfdentry = &pfdset->fd[idx];
    struct pollfd *pfd = &pfdset->rwfds[idx];

    pfdentry->fd  = fd;
    pfdentry->rcb = rcb;
    pfdentry->wcb = wcb;
    pfdentry->dat = dat;

    pfd->fd = fd;
    pfd->events  = rcb ? POLLIN : 0;
    pfd->events |= wcb ? POLLOUT : 0;
    pfd->revents = 0;
}

 這里分成了兩部分,一個是fdentry,一個是pollfd。前者保存具體的信息,后者用作poll操作,方便線程監聽fd。參數中函數指針為第三個參數,所以這里pfd->events就是POLLIN。那么在會到處理線程的處理函數fdset_event_dispatch中,該函數會監聽vhost_user.fdset中的rwfds,當某個fd有信號時,則進入處理流程

if (rcb && pfd->revents & (POLLIN | FDPOLLERR))
                rcb(fd, dat, &remove1);
if (wcb && pfd->revents & (POLLOUT | FDPOLLERR))
                wcb(fd, dat, &remove2);

 這里的rcb便是前面針對fd注冊的回調函數。再次回到vhost_user_server_new_connection函數中,當某個fd有信號時,這里指對應socket-file的fd,則該函數被調用,建立連接,然后調用vhost_user_add_connection函數。既然連接已經建立,則需要對該連接進行vhost的一些設置了,包括創建virtio_net設備附加到連接上,設置device名字等等。而關鍵的一步是為該fd添加回調函數,剛才的回調函數用於建立連接,在連接建立后就需要設置函數處理socket的msg了,這里便是vhost_user_read_cb。到這里正式進入msg的部分。該函數中調用了vhost_user_msg_handler,而該函數正是處理socket msg的核心函數。到這里消息處理的部分便介紹完成了。

二、guest memory和vhost-user app的共享

雖然qemu和vhost通過socket建立了聯系,但是這信息量畢竟有限,重點是要傳遞的數據,難不成通過socket傳遞的??當然不是,如果這樣模式切換和數據復制估計會把系統撐死……這里主要也是用到共享內存的概念。核心機制和vhost-kernel類似,qemu也需要把guest的內存布局通過MSG傳遞給vhost-user,那么我們就從這里開始分析,在函數vhost_user_msg_handler中

    case VHOST_USER_SET_MEM_TABLE:
        ret = vhost_user_set_mem_table(dev, &msg);
        break;

 在分析函數之前我們先看下幾個數據結構

/*對應qemu端的region結構*/
typedef struct VhostUserMemoryRegion {
    uint64_t guest_phys_addr;//GPA of region
    uint64_t memory_size;    //size
    uint64_t userspace_addr;//HVA in qemu process
    uint64_t mmap_offset; //offset 
} VhostUserMemoryRegion;

typedef struct VhostUserMemory {
    uint32_t nregions;//region num
    uint32_t padding;
    VhostUserMemoryRegion regions[VHOST_MEMORY_MAX_NREGIONS];//All region 
} VhostUserMemory;

 在vhsot端,對應的數據結構為

struct rte_vhost_mem_region {
    uint64_t guest_phys_addr;//GPA of region
    uint64_t guest_user_addr;//HVA in qemu process
    uint64_t host_user_addr;//HVA in vhost-user
    uint64_t size;//size
    void     *mmap_addr;//mmap base Address
    uint64_t mmap_size;
    int fd;//relative fd of region
};

 意義都比較容易理解就不在多說,在virtio_net結構中保存有指向當前連接對應的memory結構rte_vhost_memory

struct rte_vhost_memory {
    uint32_t nregions;
    struct rte_vhost_mem_region regions[];
};

 OK,下面看代碼,代碼雖然較多,但是意義都比較容易理解,只看核心部分吧:

dev->mem = rte_zmalloc("vhost-mem-table", sizeof(struct rte_vhost_memory) +
        sizeof(struct rte_vhost_mem_region) * memory.nregions, 0);
    if (dev->mem == NULL) {
        RTE_LOG(ERR, VHOST_CONFIG,
            "(%d) failed to allocate memory for dev->mem\n",
            dev->vid);
        return -1;
    }
    /*region num*/
    dev->mem->nregions = memory.nregions;

    for (i = 0; i < memory.nregions; i++) {
        /*fd info*/
        fd  = pmsg->fds[i];
        reg = &dev->mem->regions[i];
        /*GPA of specific region*/
        reg->guest_phys_addr = memory.regions[i].guest_phys_addr;
        /*HVA in qemu address*/
        reg->guest_user_addr = memory.regions[i].userspace_addr;
        reg->size            = memory.regions[i].memory_size;
        reg->fd              = fd;
        /*offset in region*/
        mmap_offset = memory.regions[i].mmap_offset;
        mmap_size   = reg->size + mmap_offset;

        /* mmap() without flag of MAP_ANONYMOUS, should be called
         * with length argument aligned with hugepagesz at older
         * longterm version Linux, like 2.6.32 and 3.2.72, or
         * mmap() will fail with EINVAL.
         *
         * to avoid failure, make sure in caller to keep length
         * aligned.
         */
        alignment = get_blk_size(fd);
        if (alignment == (uint64_t)-1) {
            RTE_LOG(ERR, VHOST_CONFIG,
                "couldn't get hugepage size through fstat\n");
            goto err_mmap;
        }
        /*對齊*/
        mmap_size = RTE_ALIGN_CEIL(mmap_size, alignment);
        /*執行映射,這里就是本進程的虛擬地址了,為何能映射另一個進程的文件描述符呢?*/
        mmap_addr = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE,
                 MAP_SHARED | MAP_POPULATE, fd, 0);

        if (mmap_addr == MAP_FAILED) {
            RTE_LOG(ERR, VHOST_CONFIG,
                "mmap region %u failed.\n", i);
            goto err_mmap;
        }

        reg->mmap_addr = mmap_addr;
        reg->mmap_size = mmap_size;
        /*region Address in vhost process*/
        reg->host_user_addr = (uint64_t)(uintptr_t)mmap_addr +
                      mmap_offset;

        if (dev->dequeue_zero_copy)
            add_guest_pages(dev, reg, alignment);

        
    }

 首先就是為dev分配mem空間,由此我們也可以得到該結構的布局

下面一個for循環對每個region先進行對應信息的復制,然后對該region的大小進行對其操作,接着通過mmap的方式對region關聯的fd進行映射,這里便得到了region在vhost端的虛擬地址,但是region中GPA對應的虛擬地址還需要在mmap得到的虛擬地址上加上offset,該值也是作為參數傳遞進來的。到此,設置memory Table的工作基本完成,看下地址翻譯過程呢?

/* Converts QEMU virtual address to Vhost virtual address. */
static uint64_t
qva_to_vva(struct virtio_net *dev, uint64_t qva)
{
    struct rte_vhost_mem_region *reg;
    uint32_t i;

    /* Find the region where the address lives. */
    for (i = 0; i < dev->mem->nregions; i++) {
        reg = &dev->mem->regions[i];

        if (qva >= reg->guest_user_addr &&
            qva <  reg->guest_user_addr + reg->size) {
            return qva - reg->guest_user_addr +
                   reg->host_user_addr;
        }
    }

    return 0;
}

相當簡單把,核心思想是先使用QVA確定在哪一個region,然后取地址在region中的偏移,加上該region在vhost-user映射的實際有效地址即reg->host_user_addr字段。這部分還有一個核心思想是fd的使用,vhost_user_set_mem_table直接從MSG中獲取到了fd,然后直接把FD進行mmap映射,這點一時間讓我難以理解,FD不是僅僅在進程內部有效么?怎么也可以共享了??通過向開源社區請教,感嘆自己的知識面實在狹窄,這是Unix下一種通用的傳遞描述符的方式,怎么說呢?就是進程A的描述符可以通過特定的調用傳遞給進程B,進程B在自己的描述符表中分配一個位置給該描述符指針,因此實際上進程B使用的並不是A的FD,而是自己描述符表中的FD,但是兩個進程的FD卻指向同一個描述符表,就像是增加了一個引用而已。后面會專門對該機制進行詳解,本文僅僅了解該作用即可。

三、vhost-user app的通知機制。

這里的通知機制和vhost kernel基本一致,都是通過eventfd的方式。因此這里就比較簡單了

 qemu端的代碼:

 file.fd = event_notifier_get_fd(virtio_queue_get_host_notifier(vvq));
 r = dev->vhost_ops->vhost_set_vring_kick(dev, &file);

 

 

static int vhost_user_set_vring_kick(struct vhost_dev *dev,
                                     struct vhost_vring_file *file)
{
    return vhost_set_vring_file(dev, VHOST_USER_SET_VRING_KICK, file);
}
static int vhost_set_vring_file(struct vhost_dev *dev,
                                VhostUserRequest request,
                                struct vhost_vring_file *file)
{
    int fds[VHOST_MEMORY_MAX_NREGIONS];
    size_t fd_num = 0;
    VhostUserMsg msg = {
        .request = request,
        .flags = VHOST_USER_VERSION,
        .payload.u64 = file->index & VHOST_USER_VRING_IDX_MASK,
        .size = sizeof(msg.payload.u64),
    };

    if (ioeventfd_enabled() && file->fd > 0) {
        fds[fd_num++] = file->fd;
    } else {
        msg.payload.u64 |= VHOST_USER_VRING_NOFD_MASK;
    }

    if (vhost_user_write(dev, &msg, fds, fd_num) < 0) {
        return -1;
    }

    return 0;
}

 可以看到這里實質上也是把eventfd的描述符傳遞給vhost-user。再看vhost-user端,在vhost_user_set_vring_kick中,關鍵的一句

vq->kickfd = file.fd;

 其實這里的通知機制和kernel下沒什么區別,不過是換到用戶空間對eventfd進行操作而已,這里暫時不討論了,后面有時間在補充!

 

 

以馬內利!

參考資料:

qemu 2.7 源碼

DPDK源碼


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM