kvmtool阅读笔记(五) | 终端&Console初始化

0X01 初始化串口和Console

在PC上,PC的总线都是并行的,因此串口出现了,负责并行和串行的转换。串口和处理器通过地址总线、数据线以及I/O控制总线相连。除此之外,还有一个中断线,串口收到数据时需要通知CPU,串口通过8259A向CPU发送中断请求。

一般访问外设的内存或者寄存器有2种方式

  1. 将外设的内存、寄存器映射到CPU的内存地址空间中,CPU访问外设如同访问自己的内存一样。即MMIO(Memory-mapped I/O)
  2. 使用专用的I/O指令

CPU访问串口使用后者,处理器向串口发送数据的基本步骤如下

  1. 处理器向地址总线写入串口地址,地址译码电路根据地址的3 ~ 9位,确定CPU意图访问哪个芯片,即片选,拉低对应的片选输出

  2. 锁存器锁存A0~A2,用来选择目的寄存器

  3. 处理器将数据送上数据总线

  4. 处理器拉低WR管脚,通知目标芯片读取数据

 

对于Guest写串口的操作,KVM需要截取到Guest向串口输出的信息,并且调用虚拟串口设备完成I/O操作。所以,KVM充当的角色之一类似地址译码电路,其需要根据out指令的I/O地址,判断出片选哪个外设。CPU在从Guest模式退 出到Host模式前,会填充VM-EXIT INFORMATION FIELDS字段,即为Host判断是什么原因导致VM exit提供依据。KVM首先从VMCS 中读出VM exit的原因,然后调用对应的处理函数。

 

KVM将截获的Guest的I/O相关的信息保存在了一个结构体kvm_run实例中,每个VCPU对应一个结构体kvm_run的实例。如果I/O没有在内核空间得到处理,那么还需要切换到用户空间进行模拟。采用内存映射的方式,即在创建VCPU时, 为kvm_run分配了一个页面,用户空间调用mmap函数把这个页面映射到用户空间。实现函数代码为

struct kvm_cpu *kvm_cpu__arch_init(struct kvm *kvm, unsigned long cpu_id) {

    struct kvm_cpu *vcpu = malloc(sizeof(struct kvm_cpu));
    int mmap_size;

    vcpu->kvm = kvm;

    if (!vcpu)
        return NULL;

    vcpu->cpu_id = cpu_id;
    vcpu->vcpu_fd = ioctl(vcpu->kvm->vm_fd, KVM_CREATE_VCPU, cpu_id);
    if (vcpu->vcpu_fd < 0)
        perror("KVM_CREATE_VCPU ioctl");
    
    mmap_size = ioctl(vcpu->kvm->sys_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
	if (mmap_size < 0)
		perror("KVM_GET_VCPU_MMAP_SIZE ioctl");
    
    vcpu->kvm_run = mmap(NULL, mmap_size, PROT_READ|PROT_WRITE, MAP_SHARED, vcpu->vcpu_fd, 0);
	if (vcpu->kvm_run == MAP_FAILED)
		perror("unable to mmap vcpu fd");
    

    return vcpu;
}

KVM用户空间实例需要把kvm_run映射到用户空间才可以访问Guest的I/O数据

 

MMIO

MMIO是PCI规范的一部分,I/O设备被映射到内存地址空间而不是 I/O空间。从处理器的角度来看,I/O映射到内存地址空间后,访问外设与访问内存一样,简化了程序设计。以MMIO方式访问外设时不使用 专用的访问外设的指令(out、outs、in、ins),是一种隐式的I/O访问,但是因为这些映射的地址空间是留给外设的,因此CPU将产生页面 异常,从而触发虚拟机退出,陷入VMM。trivial-kvm中在serial8250__init函数中进行终端结构题的配置。默认将ttyS0作为首选终端,serial_device结构体如下[2]

struct serial8250_device {
	struct device_header	dev_hdr;
	pthread_mutex_t mutex;
	u8			id;

	u32			iobase;
	u8			irq;
	u8			irq_state;
	int			txcnt;
	int			rxcnt;
	int			rxdone;
	char			txbuf[64];
	char			rxbuf[64];

	u8			dll; // Divisor Latch LOW
	u8			dlm;
	u8			iir;
	u8			ier;
	u8			fcr;
	u8			lcr; // Line Control Reg
	u8			mcr; // Modem Control Reg
	u8			lsr; // Line Status Reg
	u8			msr; // Modem Status Reg
	u8			scr;
};

device_header是所有设备共有的对象,基于红黑树,使得kvmtool能够快速定位到指定设备。同时设置默认devices,具体可以看kvmtool如下代码 https://github.com/christasa/trivial-kvm/blob/main/include/devices.h#L112。之后利用serial8250_mmio对8250的设备模拟来初始化VM的console端。

 

static void serial8250_mmio(struct kvm_cpu *vcpu, u64 addr, u8 *data, u32 len,
			    u8 is_write, void *ptr) {
	struct serial8250_device *dev = ptr;

	if (is_write)
		serial8250_out(dev, vcpu, addr - dev->iobase, data);
	else
		serial8250_in(dev, vcpu, addr - dev->iobase, data);
}

serial8250_mmio()首先会根据IO的方向选择对应处理函数, 调用时会计算端口的偏移。因此一个设备会映射多个端口, 不同端口具有不同的功能[3]。通过kvm__register_iotrap函数将device以红黑树的方式写入物理内存。serial8250_out是将Host物理内存中的内容data写入虚拟设备device的console中。同理serial8250_in函数将device设备的输入写入Host物理内存中。serial8250_out的解析参考知乎上对该函数对代码的解释[3]

//offset = 本次Guest访问的IO端口 - 设备的起始IO端口
static bool serial8250_out(struct serial8250_device* dev, struct kvm_cpu* vcpu, u16 offset, void* data)
{
    bool ret = true;
    char* addr = data;

    mutex_lock(&dev->mutex); //独占访问本设备

    switch (offset) {
    case UART_TX:   //操作的是数据传输寄存器
        if (dev->lcr & UART_LCR_DLAB) { //如果lcr设置了初始访问标志Divisor Latch Access Bit.
            dev->dll = ioport__read8(data); //那么这个数据传输要写入dll, 保存波特率除数的低位
            break;
        }

        /* Loopback mode */
        if (dev->mcr & UART_MCR_LOOP) { //如果mcr设置了Loopback标志
            if (dev->rxcnt < FIFO_LEN) {    //则向rxbuf中压入一个字符
                dev->rxbuf[dev->rxcnt++] = *addr;
                dev->lsr |= UART_LSR_DR;
            }
            break;
        }

        //对于大多数情况
        if (dev->txcnt < FIFO_LEN) {    //如果缓冲区还有位置
            dev->txbuf[dev->txcnt++] = *addr;   //则向输出缓冲区压入一个字符
            dev->lsr &= ~UART_LSR_TEMT;     //清除行状态寄存器lsr的 Transmitter empty标志
            if (dev->txcnt == FIFO_LEN / 2) //如果缓冲区使用了一半
                dev->lsr &= ~UART_LSR_THRE; //则清除lsr的Transmit-hold-register empty标志
            serial8250_flush_tx(vcpu->kvm, dev);        //刷新发送缓冲区
        } else {
            /* Should never happpen */
            dev->lsr &= ~(UART_LSR_TEMT | UART_LSR_THRE);
        }
        break;
        ...;
    }

    serial8250_update_irq(vcpu->kvm, dev);

    mutex_unlock(&dev->mutex);

    return ret;
}

 

0X02 创建终端terminal

创建终端原理比较简单,即hook原terminal的所有输入,stdin以及stdout保存四个deivces tty数组结构体上,具体见如下代码解释

static int term_fds[4][2]; // 初始化terminal fd

int term_init(struct kvm *kvm)
{
	struct termios term;
	int i, r;
    

    // 将四个devices的两个fd都设置为STDIN以及stdout
	for (i = 0; i < 4; i++)
		if (term_fds[i][0] == 0) {
			term_fds[i][0] = STDIN_FILENO;
			term_fds[i][1] = STDOUT_FILENO;
		}

	if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO))
		return 0;

	r = tcgetattr(STDIN_FILENO, &orig_term);
	if (r < 0) {
		printf("unable to save initial standard input settings\n");
		return r;
	}


	term = orig_term;
	term.c_iflag &= ~(ICRNL);
	term.c_lflag &= ~(ICANON | ECHO | ISIG);
	tcsetattr(STDIN_FILENO, TCSANOW, &term);


	/* Use our own blocking thread to read stdin, don't require a tick */
    // 使用线程启动terminal使其循环接受stdin以及打印stdout
	if(pthread_create(&term_poll_thread, NULL, term_poll_thread_loop, kvm))
		perror("Unable to create console input poll thread\n");

	signal(SIGTERM, term_sig_cleanup);
	atexit(term_cleanup);

	return 0;
}

利用线程启动term_poll_thread_loop函数使其循环接受stdin的输入和将结果输入到stdout

void *term_poll_thread_loop(void *param)
{
	struct pollfd fds[4];
	struct kvm *kvm = (struct kvm *) param;
	int i;

	kvm__set_thread_name("term-poll");

	for (i = 0; i < 4; i++) {
		fds[i].fd = term_fds[i][0];
		fds[i].events = POLLIN;
		fds[i].revents = 0;
	}

	while (1) {
		/* Poll with infinite timeout */
		if(poll(fds, 4, -1) < 1)
			break;
        // 持续接受输入输出
		kvm__arch_read_term(kvm);
        // 主要有term_getc
	}

	return NULL;
}

int term_getc(struct kvm *kvm, int term) {
	int term_got_escape = 0;
	unsigned char c;

	if (read_in_full_terminal(term_fds[term][0], &c, 1) < 0)
		return -1;

	if (term_got_escape) {
		term_got_escape = 0;
		if (c == 'x') {
			if (kvm->cpus[0] && kvm->cpus[0]->thread != 0)
				pthread_kill(kvm->cpus[0]->thread, SIGRTMIN);
		}
		if (c == 0x01)
			return c;
	}

	if (c == 0x01) {
		term_got_escape = 1;
		return -1;
	}

	return c;
}

 

值得注意的是serial8250_update_irqserial8250__receive函数,将stdin的字符送入8520设备。

 

static void serial8250_update_irq(struct kvm *kvm, struct serial8250_device *dev) {
    u8 iir = 0;
    
    // 重置rx寄存器
    if (dev->lcr & UART_FCR_CLEAR_RCVR) {
        dev->lcr &= ~UART_FCR_CLEAR_RCVR;
        dev->rxcnt = dev->rxdone = 0;
        dev->lsr &= ~UART_LSR_DR;
    }
    
    // 重置tx寄存器
    if (dev->lcr & UART_FCR_CLEAR_XMIT) {
        dev->lcr &= ~UART_FCR_CLEAR_XMIT;
        dev->txcnt = 0;
        dev->lsr |= UART_LSR_TEMT | UART_LSR_THRE;
    }

    if ((dev->ier & UART_IER_RDI) && (dev->lsr & UART_LSR_DR))
        iir |= UART_IIR_RDI;

    if ((dev->ier & UART_IER_THRI) && (dev->lsr & UART_LSR_TEMT))
        iir |= UART_IIR_THRI;

    if (!iir) {
        dev->iir = UART_IIR_NO_INT;
        if (dev->irq_state)
            kvm__irq_line(kvm, dev->irq, 0);
    } else {
        dev->iir = iir;
        if (!dev->irq_state)
            kvm__irq_line(kvm, dev->irq, 1);
    }
    dev->irq_state = iir;

    if (!(dev->ier & UART_IER_THRI))
        serial8250_flush_tx(kvm, dev); // 刷新设备同时使用term_put将stdin输入到8520中
}

 

之后会在virtio虚拟化中进行irq PCI总线的学习

 

0XFF Reference

  1. Inside the Linux Virtualization Principle and Implementation
  2. https://www.techedge.com.au/tech/8250tec.htm
  3. https://zhuanlan.zhihu.com/p/583203148