QEMU 中串口实现的代码分析

#+SETUPFILE: ~/Dropbox/Doc/Org_Templates/level-1.org

QEMU 中的串口实现分为两个部分, 一个是 QEMU 为 Guest 模拟的 串口设备, 称为前段, 是 Guest 操作的部分, 一个是 QEMU 怎么通知该串口与外部通信, 也就是 QEMU 怎么把串口的输入输出定向到其它设备, 称为后端.

本文档讨论 QEMU 重定向 串口输入输出到 stdio 的情况, 分析串口的模拟实现, 怎么和 stdio 通信, 其它情况类似.

注意, 此时的命令行参数为

$ qemu-kvm debian.img -serial stdio

创建后端 stdio

解析参数

从入口函数来看, 其实很简单, QEMU 解析到用户传递了 -serial stdio, 会调用 add_device_config 函数向 device_configs 链表 添加一个设备, 方便以后 索引使用

struct device_config {
    enum {
        DEV_USB,       /* -usbdevice     */
        DEV_BT,        /* -bt            */
        DEV_SERIAL,    /* -serial        */
        DEV_PARALLEL,  /* -parallel      */
        DEV_VIRTCON,   /* -virtioconsole */
        DEV_DEBUGCON,  /* -debugcon */
        DEV_GDB,       /* -gdb, -s */
    } type;
    const char *cmdline;
    Location loc;
    QTAILQ_ENTRY(device_config) next;
};
case QEMU_OPTION_serial:
    add_device_config(DEV_SERIAL, "stdio");

然后, 等到 QEMU 处理完命令行的解析之后, 它开始初始化各种模块, 比如串口后端的 初始化模块

static int serial_parse(const char *devname)
{
    /* ...... */
    serial_hds[index] = qemu_chr_new(label, devname, NULL);
    /* ...... */
}

int main(int argc, char **argv, char **envp)
{
    /* ...... */
    if (foreach_device_config(DEV_SERIAL, serial_parse) < 0)
        exit(1);
    /* ...... */
}

创建后端 stdio

通过上面的分析, 创建后端是由 qemu_chr_new() 完成的

CharDriverState *qemu_chr_new(const char *label, const char *filename, void (*init)(struct CharDriverState *s))
{
    /* ...... */
    CharDriverState *chr;
    opts = qemu_chr_parse_compat(label, filename);
    chr = qemu_chr_new_from_opts(opts, init);
    return chr;
    /* ...... */
}

首先, qemu_chr_parse_compatvm_config_groups 中找到 chardev 的链表, 然后创建一个名叫 “serial0” 的 qemu_chardev_opts 挂到该链表上

QemuOpts *qemu_chr_parse_compat(const char *label, const char *filename)
{
    /* ...... */
    opts = qemu_opts_create(qemu_find_opts("chardev"), label, 1, &local_err);

    /* ...... */
    return opts;

然后, 使用上面创建的 opts 创建真正的 stdio 后端

CharDriverState *qemu_chr_new_from_opts(QemuOpts *opts,
                                    void (*init)(struct CharDriverState *s))
{
    CharDriverState *chr;

    /**
     * 从 backend_table 中找到 CharDriverState:
     * { .name = "stdio",     .open = qemu_chr_open_win_stdio }
     */
    for (i = 0; i < ARRAY_SIZE(backend_table); i++) {
        if (strcmp(backend_table[i].name, qemu_opt_get(opts, "backend")) == 0)
            break;
    }

    /* 调用 qemu_chr_open_stdio() 初始化设备 */
    chr = backend_table[i].open(opts);
    /* 插入到 chardevs 中, 这个链表是一个全局的字符设备链表 */
    QTAILQ_INSERT_TAIL(&chardevs, chr, next);

    return chr;
}

初始化 stdio

static CharDriverState *qemu_chr_open_stdio(QemuOpts *opts)
{
    CharDriverState *chr;

    /* 从标准输入0, 输出1创建一个字符设备, 在里面会将写函数的 callback 指向
       fd_chr_write */
    chr = qemu_chr_open_fd(0, 1);

    /* 主要是向 main loop event 注册标准输入的读函数 */
    qemu_set_fd_handler2(0, stdio_read_poll, stdio_read, NULL, chr);
    return chr;
}

至此, stdio 的创建就完成了

串口模拟实现

串口是通过 QOM(QEMU Object Model) 实现的, QOM 的讨论超出了本文的范围, 下面只讨论 isa-serial 相关的实现.

isa-serial class 定义在 serial.c 中:

在初始化 machine 的时候(vl.c 中的 machine->init()), 会自动调用 serial_isa_init() 来初始化串口设备.

类的初始化

在初始化 isa-serial 的时候, 会把 serial_isa_init 赋值给 该类的 init 指针, 这个其实可以理解为该类的构造函数, 在创建实例的时候, 自动调用该函数.

/* 类初始化函数 */
static void serial_isa_class_initfn(ObjectClass *klass, void *data)
{
    ic->init = serial_isa_initfn;
}

实例的初始化

实例的初始化用上面提到的 serial_isa_initfn 函数完成

实例的初始化稍微复杂一些:

  • 首先设置波特率
  • 然后是中断的初始化, isa_init_irq
  • 然后调用一个叫 serial_init_core 的函数, 该函数初始化定时器, 将 它的后端(这里是stdio)添加到监听事件的loop中, 这个后端就是上面创建 的 stdio, 通过构造函数的参数传递进来
  • 最后会向 ISA Bus 注册 I/O 端口. 这个过程分两部, 首先申请一个 8 Bits 的内存, 然后调用 isa_register_ioport 把这部分内存作为 I/O port, 基地址 为 { 0x3f8, 0x2f8, 0x3e8, 0x2e8 } 中的一个, 在这个 I/O port 上, 还会 注册两个回调函数, serial_ioport_readserial_ioport_write, 其中 read 会在 guest 请求读 I/O 端口的时候触发, write 会在 guest 写 I/O 的时候 触发.

两个设备通信

Guest 能访问模拟的串口, Guest 发往该串口的任何数据都会被 QEMU 捕获到, 然后 QEMU 会把真实的数据(除去控制信息外的数据)发往后端设备(这里是 stdio).

相反的, stdio 发往串口的数据也会被 QEMU 捕获, 然后 QEMU 会引发一个中断, 通知 Guest 有新的数据, 把数据送往串口.

下面分两种情况具体讨论:

Guest 发送数据

由上面的分析, 在串口初始化的时候, 在 I/O 端口注册了 serial_ioport_write 的函数, 当这个 I/O 端口有数据写入的时候, 会自动调用该函数.

  • 首先, 判断是否是第 0 bit, 根据协议, 0 bit 代表 THR, 将数据用 fifo_put 函数放入队列中缓存
  • 然后, 触发一个硬件中断, 其实由于我们用的是 stdio 后端, 无用.
  • QEMU 会最后会调用 serial_xmit 函数来进行下一步的处理, 在该函数中, 首先 用 fifo_get 把缓存中的数据取出来, 然后发给调用 qemu_chr_fe_write=(最终 调用 write(1, buf, len)) 在stdio中显示出来, 关键在于 =qemu_chr_fe_write 中传递的参数 s->chr, 这是在初始化串口实例的时候传递的后端设备的指针(stdio)

Guest 获取数据

类似 Guest 发送数据, QEMU 捕获到 I/O 端口上 Guest 请求读数据, 自动调用 serial_ioport_read() 函数把数据从缓存中读出来发给 Guest.

  • 首先, 判断是否是第 0 bit, 根据协议, 0 bit 代表 RBR, 用 fifo_get 函数 把数据从缓存中读出来
  • 然后, 触发一个硬件中断
  • 返回从缓存中读出的数据

stdio 发送数据

  • 首先, main_loop 监听到 stdio 有数据可读, 调用 stdio 后端注册的 stdio_read 函数读取数据, 最后调用 qemu_chr_be_write, 发送数据
  • qemu_chr_be_write 最终会调用 serial_receive1 函数来获取该数据, 这是通过 串口初始化的时候以下代码实现的.
  • 最后 serial_receive1 把数据保存在缓存中, 触发一个中断
qemu_chr_add_handlers(s->chr, serial_can_receive1, serial_receive1,
                      serial_event, s);

stdio 接收数据

其实就是 Guest 发送数据后, 接受的过程, 见前面的分析

打赏一个呗

取消

感谢您的支持,我会继续努力的!

扫码支持
扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦