QEMU异常引起Linux宿主机终端不回显

发布时间 2023-09-15 17:28:23作者: watsondd

问题现象

  通过SSH登陆到Linux宿主机。使用Bash脚本启动QEMU,脚本内容如下:

#!/bin/bash
qemu-system-arm -M virt,highmem=off -m 768 \
        -device qemu-xhci,addr=02,id=qemu-xhci \
        -device virtio-tablet,addr=03,display="virtio-tablet",id=virtio-tablet \
        -device pci-testdev,addr=04,id=pci-testdev0 \
        -device usb-tablet,display="usb-tablet",id=usb-tablel0 \
        -device pcie-root-port,addr=05,id=root-port,slot=1 jj\
        -device pcie-pci-bridge,addr=07,id="pci-bridgejjj\
        -device pxb-pcie,addr=06,bus_nr=3,id=pcie.1\
        -nographic \
        -s -S \
        -smp 2 \
        -kernel arch/arm/boot/zImage \
        -initrd output/buildroot/arm/virt/images/rootfs.cpio -append "loglevel=9"

通过Ctrl-A C进入QEMU monitor,查看GIC寄存器的内容,命令为x /x 0x08000008。出现如下错误,并退出QEMU。

./start_qemu.sh: line 14: 3011446 Segmentation fault      (core dumped) qemu-system-arm -M virt,highmem=off -m 768 -device qemu-xhci,addr=02,id=qemu-xhci -device virtio-tablet,addr=03,display="virtio-tablet",id=virtio-tablet -device pci-testdev,addr=04,id=pci-testdev0 -device usb-tablet,display="usb-tablet",id=usb-tablel0 -device pcie-root-port,addr=05,id=root-port,slot=1 -device pcie-pci-bridge,addr=07,id="pci-bridge0" -device pxb-pcie,addr=06,bus_nr=3,id=pcie.1 -nographic -s -S -smp 2 -kernel arch/arm/boot/zImage -initrd output/buildroot/arm/virt/images/rootfs.cpio -append "loglevel=9"

在SSH终端输入命令后,看不到回显的输入,但是相应命令可以执行成功。

  不是通过bash脚本启动QEMU,直接在登录终端的命令行输入上述QEMU命令。执行相同的操作,也出现Segmentation fault。在登录终端上输入命令后,可以看到回显的输入,也可以成功执行命令。

问题原因

  关于访问GIC出现SIGSEGV的问题,当启用SMP时,就会有这样的问题,如果将-smp 2去掉,就不会出现Segmentation fault的错误。之前已经有过类似的报告, SIGSEGV when reading ARM GIC registers through GDB stub (#124) · Issues · QEMU / QEMU · GitLab。 GIC中有些寄存器是和CPU相关的,需要通过函数gic_get_current_cpu获取到当前CPU的编号。而通过gdb或者QEMU monitor访问GIC时,缺失了相应信息,会访问空指针,因此就出现了内存错误。虽然有人提出了通过MxTxAttrs所带信息中的requester_id来确定发起访问的CPU,但是这个改动还没有合并到QEMU的主干中。

  使用Bash脚本启动QEMU,出现Segmentation fault后,命令不回显的原因,是因为QEMU会更改TTY的配置,在发生Segmentation fault时,QEMU进程直接被终结了,没有机会恢复TTY的配置。之后,作为QEMU的父进程,Bash脚本所在的进程也退出了。登录Shell没有得到QEMU是被信号终结的通知,也没有恢复TTY的配置。

  直接在登录终端输入命令启动QEMU,出现Segmentation fault后,命令回显的原因,是因为在发生Segmentation fault时,QEMU进程直接被终结了。之后,作为QEMU的父进程,登录Shell知道QEMU是被信号终结的,自行恢复了TTY的配置。

调查过程

  为了行文方便,假设始终在pts0终端上运行QEMU。在另外一个终端pts1上,执行其它命令。

  因为输入的命令可以被成功执行,所以可以认为pts0是收到了相应的字符,但是没有回显出来。在pts0上输入stty echo命令后,就可以回显出相应命令了。因此怀疑出问题时是TTY的配置被更改了。

  出现Segmentation fault时,进程就会接收到SIGSEGV信号,先调查QEMU进程的终结方式是否会对结果有影响。在pts1上通过kill命令发送SIGKILL信号,终结QEMU进程,现象没有变化。如果是通过Bash脚本启动的QEMU,进程被终结后,输入的命令不会回显。如果是直接输入的QEMU命令,进程被终结后,输入的命令会回显。如果发送SIGTERM信号终结QEMU进程,无论是Bash脚本启动方式,还是直接输入命令方式,输入的命令都会回显。由此可以看出,在收到SIGTERM信号后,QEMU进程有机会自己恢复TTY配置。而收到SIGKILL或SIGSEGV后,QEMU进程没有机会恢复TTY配置。那么问题就在于,通过命令行启动QEMU,而被SIGKILL终结后,TTY配置是怎么变回可以回显的了。

  使用stty命令可以得到终端的配置。不回显时,不方便在pts0上输入命令,因此在pts1终端上输入stty -F /dev/pts/0来查看pts0的TTY配置。为了验证这个命令是否能正确显示出pts0的配置,同时在pts0和pts1上输入这个命令,却得到了不一样的配置。在pts0上,看到echo是使能的。而在pts1上,看到echo是关闭的。通过gdb追踪内核中的调用过程,特别地设置了tty_mode_ioctltty_set_termios两个断点,可以看到输入命令前,pts0和pts1的配置都没有置上回显示标志。而在pts0上调用这个命令时,多出了tty_set_termios的调用,将pts0的回显标志置上了。在pts1上调用这个命令时,就没有调用这个函数将pts0的回显标志置上,却将pts1的回显标志置上了。因此,得到的结论是,在某个终端输入时,登录Shell会自行将本终端的回显标志置上。是不是在通过命令行启动QEMU时,pts0的回显标志在某个时间点被置上了呢?

  将QEMU中修改TTY配置的代码拷贝出来,重新编写出一个简单程序来模拟QEMU修改TTY的行为。然后,再编写一个程序,首先生成一个子进程,用于执行修改TTY配置的程序。然后,父进程发送信号,终结子进程。等待子进程退出后,一种选择是父进程调用kill函数终结自己,一种选择是父进程自然返回。父进程终结自己,就相当于使用命令行直接启动QEMU,然后QEMU被终结。父进程自然返回,就相当于使用Bash脚本启动QEMU,然后QEMU被终结,Bash脚本自己退出。

  再使用gdb跟踪上述两种情况下的内核调用过程,可以看到在父进程终结自己后,有一次tty_set_termios的调用,将TTY的回显打开;然后再调用tty_mode_ioctl获取TTY的配置;最后再调用tty_set_termios将回显标志清除。而如果父进程是自然返回的话,在父进程返回后,只看到两次tty_mode_ioctl调用来获取TTY的配置,此时的配置就是被修改后的,回显被关闭的配置,然后再调用tty_set_termios来设置。

  观察到如果父进程终结自己的话,终端上会显示出“Terminated”或“Killed”字符串。如果是自然返回,就没有这个字符串。Why does bash show 'Terminated' after killing a process? - Unix & Linux Stack Exchange解释了登录Shell什么时候会显示这个字符串。使用wait(2) - Linux manual page (man7.org)这个函数可以检查子进程是否被信号终结。但是父进程的再上一级父进程是不能直接检测到当前线程是否被信号终结的。

  第一种情况,通过Bash脚本启动QEMU,Bash脚本是QEMU的父进程,而登录Shell是Bash脚本的父进程。第二种情况,通过命令行直接输入命令启动QEMU,登录Shell是QEMU的直接父进程。对于第一种情况的话,QEMU被终结时,只是其父进程检测到了,其父进程自然退出时,登录Shell没有任何特殊动作,还是沿用了之前的TTY配置。对于第二种情况,QEMU被终结时,登录Shell检测到了,并打印出相应字符串,应该就是在此时将TTY的回显标志置上了。

  这里还有一个没有解释的问题,是TTY的配置默认是不回显的,正常情况下在有输入时就会使能回显。那么在第一种情况发生之后,再进行输入的话,为什么不使能回显?有些情况下,用户就是希望更改TTY配置,例如stty命令。对于第一种情况,因为Bash脚本是正常退出的,所以登录Shell就认为这是一个用户期望更改的TTY配置,也不会主动使能回显了。

  进一步地,登录Shell作为父进程时,检测到QEMU被信号终结时,恢复了TTY配置。为什么Bash脚本却不恢复TTY配置呢?两者运行的不应该都是Bash程序吗?对于这个问题,进行了多种测试,并得到如下表格。第一列是Bash脚本内容;第二列在登录Shell中输入的命令容;第三列是测试程序./a.out的父进程名;第四列是终结a.out的方式;第五列是进程终结后,输入字符是否回显的结果。表中的方括号,表示-c这个选项是可选的。如果在启动方式和父进程名中,都有方括号,则表示启动方式带了-c参数,父进程名也会相应带有-c。如果只在启动方式中有方括号,则表示启动方式是否带-c参数无关,父进程名都是一样的。测试程序a.out则更改TTY的配置为不回显后,进入死循环,不处理任何信号。

  如果父进程是登录Shell或者/bin/bash -i形式,无论以哪种方式终结程序,最后都能回显。如果父进程是/bin/bash [-c]形式,则以Ctrl-C终结的话,可以回显;以Kill信号方式终结的话,不可以回显。如果父进程是非登录Shell的-bash形式,则以Ctrl-C或Kill信号方式终结,都不可以回显。

Bash脚本内容 启动方式 父进程名 终结方式 现象
- ./a.out
或者
/bin/bash -c ./a.out
-bash(登录shell) Ctrl-C
Kill
回显
#!/bin/bash
./a.out
/bin/bash -i ./test.sh /bin/bash -i ./test.sh Ctrl-C
kill
回显
###!/bin/bash
./a.out
/bin/bash -i ./test.sh /bin/bash -i ./test.sh Ctrl-C
kill
回显
#!/bin/bash
./a.out
./test.sh
或者
/bin/bash [-c] ./test.sh
/bin/bash ./test.sh Ctrl-C 回显
###!/bin/bash
./a.out
/bin/bash [-c] ./test.sh /bin/bash [-c] ./test.sh Ctrl-C 回显
#!/bin/bash
./a.out
./test.sh
或者
/bin/bash [-c] ./test.sh
/bin/bash ./test.sh Kill 不回显
###!/bin/bash
./a.out
/bin/bash [-c] ./test.sh /bin/bash [-c] ./test.sh Kill 不回显
###!/bin/bash
./a.out
./test.sh -bash(非登录Shell,
上一级父进程才是
登录Shell)
Ctrl-C
kill
不回显

  综上所述,进程被终结后,TTY配置是否会被恢复为之前的值,取决于父进程的处理方式。而Bash的处理方式又和其启动参数相关。在gnu_bash/jobs.c中,找到了一些和TTY配置相关的描述。这里先暂时只根据表面现象总结出一些规律,其后面更深层的逻辑关系则暂时没有弄清楚。

/* When we end a job abnormally, or if we stop a job, we set the tty to the
   state kept in here.  When a job ends normally, we set the state in here
   to the state of the tty. */

static TTYSTRUCT shell_tty_info;