『QEmu』使用 QIOChannel 进行 unix socket 通信

在 QEmu 中使用常规的 read(...)recv(...) 或者 write(...)send(...) 进行堵塞式IO读写有时候会无法得到预期的结果,这是因为 QEmu 使用基于 glib事件循环 的事件循环,所有的读写操作都应该统一在 QEmu 的框架中进行。
QEmu 的内部API较为复杂,存在多种不同封装级别的IO读写接口,主要有以下几种:

  1. qemu-socket :QEmu自己打包的用于规避平台差异性的常用API,就是常用系统调用套了层皮
  2. QIOChannel :QEmu基于glib库中的 GIOChannel 添加功能并重新封装的升级版本,完全通过QOM实现(QEmu面向对象模型)
  3. chardev :QEmu把IO读写封装形成的设备,即字符设备(chardev

因为需要开发一个新的 QEmu 设备,笔者研究了好几天的 QEmu 源码才搞明白怎么实现在 QEmu 的设备代码中使用 unix socket 和其他进程通信。在此记录相关细节以供各位参考。
注:在后文中涉及源代码路径时,如 qemu/sockets.h 实际上应该是 ${QEMU源码根目录}/include/qemu/sockets.h ,其他头文件类似;如 util/qemu-sockets.c 应该是 ${QEMU源码根目录}/util/qemu-sockets.c ,其他源码文件同理。

Unix Socket 通信原理概述

Unix Socket 是以文件的形式存在的套接字,主要用于本机的进程间通信。套接字(Socket)这个东西本身是以数据结构的形式存在的,文件只是访问这个套接字的媒介,因此首先是服务端程序 创建 了一个套接字(作为一个数据结构),然后将其 绑定 到一个指定的文件上,然后针对这个文件媒介进行 监听
试图通过这个套接字进行通信的客户端,首先需要通过这个文件 连接 到对应的套接字;客户端连接之后,正在监听文件变化的服务端就会捕捉到这个连接请求,通过 accept 函数从这个文件获得这个新连接的 文件描述符 。拿到文件描述符之后,就是Unix哲学 一切皆文件 那一套东西,可以直接用 read()/write()/send()/recv() 等 IO 读写函数进行正常的读写了。
因此,Unix Socket 通信的主要内容就是获取并维护 可用的文件描述符列表 ,然后往这些文件描述符进行读写。服务端程序打开 Unix Socket 文件(形如 unix:/tmp/qio_socket )就会获得一个代表这个文件的文件描述符,这个文件描述符是用于进一步获得新连接的客户端的文件描述符的媒介;通过 Unix Socket 获得的新连入的客户端的文件描述符即可用于与这些客户端通信。

Unix Socket 服务端设计

笔者的目标是实现多个QEmu进程间的通信,因此采用了星型通信模型:由一个服务端 listen Unix Socket 处理新的连接,多个客户端主动 connect 到 Unix Socket,由服务端实现信息的处理、分发和路由。为了方便理解后面的内容,有必要在此阐述我所设计的服务端的握手过程。
默认状态下,服务端程序会监听其所有可用的文件描述符。如果这些文件描述符接收到了新的内容:对于 socket 的文件描述符,服务端程序会用 accept() 函数从其上获得一个新连接的文件描述符;对于客户端的文件描述符,服务端需要对来自客户端的消息进行反应。
对于每个新连入的客户端,服务端程序会发送一句 Aloha 信息作为通信测试(客户端程序会对所有来自服务端程序的信息回复一个 RESPOND )。这就是全部的握手过程。
对于每个已连接的客户端,当它们发送 INT 的时候,服务端程序会把这条信息转发给所有其他的客户端。这样做是实现每个客户端发送的中断信号能够广播到所有客户端的效果。
对于每个已连接的客户端,当它们的文件描述符产生 动静recv() 函数只能读到 0 个字符的时候,就说明这个 动静 是客户端断开了连接,服务端就将它们的文件描述符移出监听列表。
服务端的主要代码如下:

int main(int argc, char *argv[])
{    
    int ret = 1;

    printf("*** Gua Unix Socket Server Launch ***\n");

	...
	
    struct sockaddr_un s_un;
    int shm_fd, sock_fd, maxfd = 5;
    sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
    s_un.sun_family = AF_UNIX;
    snprintf(s_un.sun_path, sizeof(s_un.sun_path), "/tmp/gua_socket");
    bind(sock_fd, (struct sockaddr *)&s_un, sizeof(s_un));
    listen(sock_fd, 2);

	// ---------------- 初始化 fd_set ---------------- 
    fd_set readfds;  
    FD_ZERO(&readfds);
    FD_SET(sock_fd, &readfds);
    // ---------------- 初始化 fd_set 结束 ----------------

	// ---------------- 初始化别的数据结构 ----------------
    int active_fdset[10] = {0},maxfds = 0;
    active_fdset[sock_fd] = 1; // 用来记录活跃的文件描述符
    int i = 0, j = 0;
    char buffer[100] = {0};
    int cfd[5] = {0}, cfdp = 0; 
    // ---------------- 初始化结束 ----------------

    while(1) {
        // ---------------- 维护 fd_set 开始 ----------------
        maxfds = 0;
        FD_ZERO(&readfds);
        for(i = 0; i < 10; i++)
            if(active_fdset[i])
                FD_SET(i, &readfds), maxfds = maxfds > i+1? maxfds:i+1;
        // ---------------- 维护 fd_set 结束 ----------------

		// ---------------- 用 SELECT 监听 fd_set ----------------
        int acti = select(maxfds, &readfds, NULL, NULL, NULL);
        // ---------------- SELECT 返回 ----------------

        for(i = 0; i < maxfds; i++) {
            if(FD_ISSET(i, &readfds)) {
                if(i == sock_fd) {
	                // ---------------- 给新连入的客户端分配文件描述符 ----------------
                    struct sockaddr_un unaddr;
                    socklen_t unaddr_len = sizeof(unaddr);
                    int new_fd = qemu_accept(i, (struct sockaddr *)&unaddr, sizeof(unaddr));
                    active_fdset[new_fd] = 1;
					// ---------------- 给新连入的客户端分配文件描述符结束 ----------------
					
                    // ---------------- 发送 Aloha 给新连入的客户端 ----------------
                    snprintf(buffer, 6, "Aloha\0");
                    printf("Sending %s to new_fd:%d\n", buffer, new_fd);
                    qemu_write_full(new_fd, buffer, 6);
					// ---------------- 发送 Aloha 给新连入的客户端结束 ----------------
                }else {
	                // ---------------- 处理已连入客户端的新信息 ----------------
                    ssize_t n = recv(i, buffer, 10, 0);
                    if(n > 0){ // 如果大于 0 说明是实际的字符串信息,这里仅将 "INT" 这样的信息转发给所有其他客户端
                        if(!strncmp(buffer, "INT", 3))
                            for(j = 0;j < maxfds; j++)
                                if(j != i && active_fdset[j])
                                    qemu_write_full(j, buffer, 5);
                    }else if(n <= 0){ // 如果小于等于 0 说明要么是断开连接要么是出错了,直接注销客户端
                        active_fdset[i] = 0;
                        close(i);
                        FD_CLR(i, &readfds);
                    }
                }
            }
        }
    }
    close(sock_fd);
    return 0;
}

select 函数是 Linux 平台独有的函数,可以同时监听多个文件描述符,其核心数据结构就是 fd_setfd_set 是一个类似 bitmap 的用 表示状态的数据结构,其中第 k 位为 1 时表示需要监听值为 k 的文件描述符。当 select 结束监听返回时,在 fd_set 中状态为 1 的文件描述符就存在新的可读信息。因为每次 fd_set 的内容都会改变,所以需要另外维护一个数据结构用来存储可用的文件描述符,并重置 fd_set

QEmu 内客户端设计

关于写在 QEmu设备代码 中的客户端代码,笔者尝试了 qemu-socketQIOChannel 两套实现,两者都能实现预期的效果,最终采用了后者(感觉这样比较符合它应有的样子)。尽管使用的 API 不一样,但进行的主要流程是一样的,都包括:

  1. 连接 Unix Socket 并得到对应的 文件描述符
  2. 将该 文件描述符 连同侦测到可读内容时触发的 处理函数 注册到 iohandler_ctx 事件循环中
  3. 处理函数 中读取 文件描述符 上的信息(一般是读走清空缓冲区,否则会再触发一次处理函数)

基于 qemu/sockets.h 提供的方法进行 socket 编程

笔者在前文中所用的 qemu-socket 一词实际上指的是这些代码的源文件是 QEmu 里的头文件 qemu/sockets.h 。这个头文件里面包含了很多来自 util/qemu-sockets.cutil/osdep.c 的函数声明,是进行 socket 编程较为常用的部分。
由于 QEmu 高度依赖 glib ,很多操作和常规的 Linux CLI 程序不太一样。因此 QEmu 在加入少许改进之后把具有相近行为的代码进行打包封装,形成了像 unix_listen()unix_connect() 这样的函数接口。它们的代码如下:

int unix_listen(const char *str, Error **errp)
{
    UnixSocketAddress *saddr;
    int sock;

    saddr = g_new0(UnixSocketAddress, 1);
    saddr->path = g_strdup(str);
    sock = unix_listen_saddr(saddr, 1, errp);
    qapi_free_UnixSocketAddress(saddr);
    return sock;
}

int unix_connect(const char *path, Error **errp)
{
    UnixSocketAddress *saddr;
    int sock;

    saddr = g_new0(UnixSocketAddress, 1);
    saddr->path = g_strdup(path);
    sock = unix_connect_saddr(saddr, errp);
    qapi_free_UnixSocketAddress(saddr);
    return sock;
}

从代码可以看出,这两个函数还暂未涉及 glib 事件循环的内容,只是稍作封装方便直接从 地址 直接得到 文件描述符

初始化

使用 qemu-socket 对 socket 进行初始化的代码如下:

static void gua_instance_init(Object *obj)
{
	GuaState *s = GUA_DEVICE(obj);
	
	...
	
	int fd = 0;
	Error *err = NULL;
	ssize_t reslen = 0;
	
	fd = unix_connect("/tmp/gua_socket", &err);
	s->socket_fd = fd; // 把文件描述符存进设备信息中,触发处理函数的时候会传递给处理函数
	qemu_set_fd_handler(fd, gua_read_handler, NULL, s); // 注意最后一个参数,这说明对于当前设备触发 handler 时会传递的是 s 这个指针
	reslen = qemu_write_full(fd, "SETUP\0", 6); // 向服务端打招呼
	
	...
}

其中 GuaState 是这里用作示例的 QEmu设备 存储信息用的数据结构,涉及到QEmu设备开发的部分这里就不过多赘述了。 /tmp/gua_socket 是示例的 socket 地址,得先由服务端创建并监听这个文件,客户端才能 connectgua_read_handler 是在 fd 可读时会触发的处理函数,代码见下; SETUP\0 是笔者设置的客户端成功连接之后会固定发送的初始信息。
这里似乎没有 注册到事件循环 的代码?不,是有的,被 qemu_set_fd_handler 封装起来了,其定义为:

// util/main-loop.c
void qemu_set_fd_handler(int fd,
                         IOHandler *fd_read,
                         IOHandler *fd_write,
                         void *opaque)
{
    iohandler_init();
    aio_set_fd_handler(iohandler_ctx, fd, fd_read, fd_write, NULL, NULL,
                       opaque);
}

其中 iohandler_ctx 就是专门处理IO读写事件的事件循环( 的上下文,严谨来说 )。使用 qemu_set_fd_handler 绑定的所有文件描述符都会由 aio_set_fd_handler 注册到 iohandler 事件循环上。对于事件循环来说,每个 文件描述符 有 POLLIN 事件和 POLLOUT 事件,分别代表一个文件描述符 可读可写 ;以及处理这两种事件的 处理函数(IOHandler) 。注册了处理函数,就说明文件描述符被加入事件循环进行关注了;把处理函数注册成 NULL ,就说明文件描述符在事件循环中不再被监听了(这里并不是 监听了不触发 ,而是 不被监听 ,虽然对于用户来说是一样的感觉)。

处理函数

笔者写的处理函数定义如下:

static void gua_read_handler(void *opaque)
{
    GuaState *s = opaque;
    char buffer[20] = {0};
    ssize_t ret = recv(s->socket_fd, buffer, 9, 0); // 设置读取最多 9 个字符到 buffer 中

    qemu_set_irq(s->irq, 1);
}

其中 opaque 就是前面在 qemu_set_fd_handler 最后一个参数位填入的内容,由于我们预先知道了它就是一个 GuaState* 因此可以直接强制格式转换,并利用其中的设备信息——之前预存的 文件描述符 。在这个处理函数中,笔者还设置了触发设备的线中断,这样 “设备接受到新的外部信息” 的这一信息也可以经由中断通知给 QEmu 所模拟的操作系统。由于这个时候文件描述符必然可读,使用 read() 等其他的读取API也可以,只要不导致堵塞就没问题。

通过 QIOChannel 进行 unix socket 通信

QIOChannel 是在 QEmu 面向对象模型(QOM)中进行创建和维护的一个类,设计思路应该来源于 glib 库中的 GIOChannel 。由 QOM 所维护的 对象 在使用前需要经过如下过程:

  1. 的初始化:会递归进行父类的初始化,然后进行自己的初始化(.class_init)。主要内容是实现 的成员函数指针的赋值,经过这一步就可以使用 的成员函数了。
  2. 实例 的初始化:会执行每个对象的 TypeInfo 数据结构里注册的 .instance_init 。主要内容是初始化这个对象,执行类似 构造函数 的操作。
    笔者在此之前基本对上面这一套理论烂熟于心了,但是实际遇到了还是对它们的执行感到有点困惑。研究了一晚上源代码以后,笔者现在认为应该是 在实例化对象的时候完成这一系列初始化进程 。具体来说,使用 qio_channel_socket_new 创建新的 QIOChannel 时会执行下面的语句:
// io/channel-socket.c
QIOChannelSocket *
qio_channel_socket_new(void)
{
    QIOChannelSocket *sioc;
    QIOChannel *ioc;

    sioc = QIO_CHANNEL_SOCKET(object_new(TYPE_QIO_CHANNEL_SOCKET));

	...

    return sioc;
}

object_new 这个函数里面就会完成从 的初始化到 实例 的初始化的全过程。当然QOM的部分也不是本文的重点,这里就一笔带过了。

初始化

使用 QIOChannel 的初始化代码如下:

static void gua_instance_init(Object *obj)
{
	GuaState *s = GUA_DEVICE(obj);
	
	...
	
	QIOChannelSocket *sioc = qio_channel_socket_new();
    QIOChannel* ioc = QIO_CHANNEL(sioc);
    SocketAddress* socket_addr = socket_parse("unix:/tmp/smi_socket", &err); // 注意,如果是 socket_parse 的话,它会期待带有协议头的地址
    ssize_t send_bytes = 0;
    
    qio_channel_socket_connect_sync(sioc, socket_addr, &err);
    ioc->read_ctx = iohandler_get_aio_context(); // read_ctx 这个成员只有 QIOChannel 才有, QIOChannelSocket 没有
    qio_channel_set_aio_fd_handler(ioc, ioc->read_ctx, gua_read_handler, NULL, NULL, s); // 可见大部分操作都是对 QIOChannelSocket 的父类 QIOChannel 进行的

	s->ioc = ioc; // 存储对应的信息

    send_bytes = qio_channel_write(ioc, "SETUP\0", 6, &err); // 打招呼
}

关于这部分代码需要注意 QIOChannelSocket 的默认初始化过程仅包含 fd = -1 ,因此 read_ctx 虽然基本就是确定的,但还是要自己动手进行赋值。为了更好地说明这部分内容,下面展示了 QIOChannelSocketQIOChannel 的定义:

// io/channel.h
struct QIOChannel {
    Object parent;
    unsigned int features; /* bitmask of QIOChannelFeatures */
    char *name;
    AioContext *read_ctx;
    Coroutine *read_coroutine;
    AioContext *write_ctx;
    Coroutine *write_coroutine;
    bool follow_coroutine_ctx;
#ifdef _WIN32
    HANDLE event; /* For use with GSource on Win32 */
#endif
};

// io/channel-socket.h
struct QIOChannelSocket {
    QIOChannel parent;
    int fd;
    struct sockaddr_storage localAddr;
    socklen_t localAddrLen;
    struct sockaddr_storage remoteAddr;
    socklen_t remoteAddrLen;
    ssize_t zero_copy_queued;
    ssize_t zero_copy_sent;
};

这里也可以略微看明白QOM实现继承的机理——如果初始化一个子类 QIOChannelSocket ,就会生成一个包含父类 QIOChannel 的一整段数据结构。需要访问父类成员的时候只需要用 ioc = QIO_CHANNEL(sioc) 这种代码就可以访问到子类中 QIOChannel parent 的这个父类数据空间。同时也可以发现这样做的缺点:要访问父类成员似乎有点绕啊。然后这个子类 QIOChannelSocket 的实例初始化函数如下:

// io/channel-socket.c
static void qio_channel_socket_init(Object *obj)
{
    QIOChannelSocket *ioc = QIO_CHANNEL_SOCKET(obj);
    ioc->fd = -1;
}

是的,它只初始化了 fd 一个东西。所以其他的部分都要自己手动设置一下。

处理函数

对应的,笔者编写的基于 QIOChannel 使用的处理函数代码如下:

static void gua_read_handler(void *opaque)
{
    GuaState *s = opaque;
    char buffer[20] = {0};
    Error *err = NULL;
    QIOChannel *ioc = s->ioc;
	ssize_t ret = 0;
	
	ret = qio_channel_read(ioc, buffer, 9, &err);

	... // 这里是处理读取到的数据
	
    ret = qio_channel_write(ioc, "RES\0", 4, &err); // 发送回应信息
    
    qemu_set_irq(s->irq, 1);
}

值得注意的是,这里用的 qio_channel_read/qio_channel_write 都是进行了一定程度封装的 QIOChannel 通用读写接口。 QIOChannel 有很多种来源,包括:

include/io
├── channel-buffer.h
├── channel-command.h
├── channel-file.h
├── channel.h
├── channel-null.h
├── channel-socket.h
├── channel-tls.h
├── channel-util.h
├── channel-watch.h
└── channel-websock.h 

我们前面所说的 channel-socket 的部分仅到创建完 QIOChannel 就结束了,后面的读写和管理是同其他类型的 QIOChannel 完全一样的。

posted @ 2024-10-30 15:22  μSsia  阅读(20)  评论(0编辑  收藏  举报