x86-64 汇编基础 ---- 记读 《CS: APP》 通常情况下, 使用现代的优化编译器产生的代码至少与一个熟练的汇编语言程序员手工编写的代码一样有效 1. 看懂汇编码 1). 汇编码的格式 · ATT格式 这是GCC/ OBJDUMP和其它一些工具的常用格式, 由AT&T公司命名 使用命令gcc -S <file.c>输出的汇编码就是这种格式. 一段示例代码如下所示: movl (%rdi), %eax imull %eax, %eax movl %eax, (%rdi) ret 在《CS: APP》一书中, 使用ATT格式书写汇编码, 因此, 无特殊说明时, 下文的所有汇编码也是ATT格式. · Intel格式 Intel和Microsoft的文档中经常会出现这种格式的代码, 它个ATT格式的代码有些许不同, 如下示例: mov eax, DWORD PTR [rdi] imul eax, eax mov DWORD PTR [rdi], eax ret 使用命令gcc -S -masm=intel <file.c>以输出这种格式的代码 Introduction to x64 Assembly | Intel® Software 注意, 上述代码中的源操作数和目的操作数的位置发生了变化, 因此在分析代码时应注意汇编码的格式问题. 使用GCC编译器输出的汇编码中, 以 "." 开头的行是 伪指令 , 可以适当忽略. 2) 数据格式 在C语言中, 变量具有其"数据类型", 但是汇编码中的"变量"没有此概念(或者说, 没有"变量"的概念). 但是C语言中数据类型的概念在汇编语言中必须有与其相对应的机制, 在x86-64汇编中, 控制指令操作数的长度, 与C语言中的数据类型概念对应. 下表是摘自书中的C语言数据类型在x86-64中的大小: C声明 Intel 数据类型 汇编代码后缀 大小(字节) char 字节 b 1 short 字 w 2 int 双字 l 4 long 四字 q 8 char * 四字 q 8 float 单精度 s 4 double 双精度 l 8 在汇编码(ATT格式)中, 通过在指令后加"后缀"表明操作数的大小. (后缀见上表) 3) 操作数指示符 几乎每一条指令都有对应的操作数, 操作数的值有几种形式: 立即数/ 寄存器/ 内存, 其中, 立即数和寄存器可以直接参与运算, 但存放于内存中的数参与运算时, 需要访存操作(此时立即数或寄存器中的值是内存地址). 最完整的一种地址形式是 Imm(r_a, r_i, s) , 它的运算后有效地址为 Imm + R[r_a] + R[r_i] \cdot s, 例如, 8(%rax, %rdx, 4). ATT格式汇编中的操作数格式如下表所述, 其中, R[r]表示从寄存器组中取出寄存器r的值; M[addr]表示从内存的地址addr处取值. 类型 格式 操作数值 名称 立即数 $Imm Imm 立即数寻址 寄存器 r R[r] 寄存器寻址 存储器 Imm M[Imm] 绝对寻址 存储器 (r) M[R[r]] 间接寻址 存储器 Imm(r) M[Imm+R[r]] (基址 + 偏移量)寻址 存储器 (rb, ri) M[R[rb] + R[ri]] 变址寻址 存储数 Imm(rb, ri) M[Imm + R[rb] + R[ri]] 变址寻址 存储器 (, ri, s) M[R[ri] * s] 比例变址寻址 存储器 Imm(, ri, s) M[Imm + R[ri] * s] 比例变址寻址 存储数 (ra, ri, s) M[R[ra] + R[ri] * s] 比例变址寻址 存储器 Imm(ra, ri, s) M[Imm + R[ra] + R[ri] * s] 比例变址寻址 应当注意, 取值(尤其是从内存中取值)时的数据长度由指令(后缀)决定. 2. 通用寄存器 x86-64中的寄存器比RISC架构的CPU的寄存器少. 一个x86-64的CPU包含一组16个存储64位值的通用目的寄存器. 它们的名字都已%r开头, 不过由于指令集的历史演化, 它们的命名还有一些不同. 下图所述为这16个寄存器: 63~0位 31~0位 15~0位 7~0位 用途 %rax %eax %ax %al 返回值 %rbx %ebx %bx %bl 被调用者保存 %rcx %ecx %cx %cl 第4个参数 %rdx %edx %dx %dl 第3个参数 %rsi %esi %si %sil 第2个参数 %rdi %edi %di %dil 第1个参数 %rbp %ebp %bp %bpl 被调用者保存 %rsp %esp %sp %spl 栈指针 %r8 %r8d %r8w %r8b 第5个参数 %r9 %r9d %r9w %r9b 第6个参数 %r10 %r10d %r10w %r10b 调用者保存 %r11 %r11d %r11w %r11b 调用者保存 %r12 %r12d %r12w %r12b 被调用者保存 %r13 %r13d %r13w %r13b 被调用者保存 %r14 %r14d %r14w %r14b 被调用者保存 %r15 %r15d %r15w %r15b 被调用者保存 对于生成小于8字节结果的指令, 寄存器中剩下的字节遵循一下两条规则: 生成1字节和2字节数字的指令会保持剩下的字节不变; 生成4字节数字的指令会把高位4个字节置为0 3. 数据传送指令 最频繁使用的指令是将数据从一个位置复制到另一个位置的指令, <>中将许多不同的指令划分为"指令类", 每一类指令执行相同的操作, 只是操作数的大小不同. 最简单的数据传送指令是MOV类. 指令格式 指令功能 效果 MOV Src, Dest 传送 Src→Dest movb S,D 传送一个字节（8位） S->D movw S,D 传送一个字（16位） S->D movl S,D 传送双字（32位） S->D movq S,D 传送四字（32位扩展为64位） S->D movabsq I,R 传送绝对的四字（64位） I->R 应当注意, x86-64加了一条限制, 传送指令的两个操作数不能都指向内存位置. 因此, 将一个值从一个内存位置复制到另一个内存位置需要"先从内存加载到寄存器, 再从寄存器写入到内存". 关于movabsq: 常规的movq指令只能以表示为32位补码数字的立即数作为源操作数, 然后把这个值符号扩展得到64位的值, 放到目的位置. movabsq指令能够以任意64位立即数值作为源操作数, 并且只能以寄存器作为目的. 大多数情况下, MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置, 唯一例外的是movl指令以寄存器为目的时, 它会把该寄存器的高位4字节设置为0(如上"规则"所述) 在将较小值复制到较大目的(类似于强制类型转换)时, 可使用一下"带有填充"的数据移动指令: 零填充 指令格式 指令功能 效果 MOVZ Src, Dst 传送, 高位补零 Src → Dst movzbw movzbl movzbq movzwl movzwq 符号填充 指令格式 指令功能 效果 MOVS Src, Dst 传送, 高位以符号填充 Src → Dst movsbw movsbl movsbq movswl movswq movslq cltq 把%eax符号扩展到%rax 符号扩展(%eax)→%rax 4. 运算指令 加载有效地址 指令 效果 描述 leaq S, D D = &S 只运算地址, 不操作数据. leaq指令用于生成(运算)地址, 但是不对地址指向的目标做操作. 编译器会使用该指令进行一些算术运算. 例如: leaq 1(%rdi, %rsi, 4), %rax 执行后, %rax = 1 + %rdi + 4 * %rsi 算数运算 指令 效果 描述 INC D D++ 自增 DEC D D-- 自减 NEG D D = -D 取负 ADD S, D D += S 加法 SUB S, D D -= S 减法 IMUL S, D D *= S 乘法 特殊的算术操作 两个64位有符号或无符号整数相乘得到的乘积需要128位来表示, x86-64指令集对128位数的操作提供有限的支持. Intel 把16字节的数称为"八字" 指令 效果 描述 imulq S R[%rdx]: R[%rax] = S * R[%rax] 有符号全乘法 mulq S R[%rdx]: R[%rax] = S * R[%rax] 无符号全乘法 clto R[%rdx]: R[%rax] = 符号扩展(R[%rax]) 转换为八字 idivq S R[%rdx] = R[%rdx] : R[%rax] mod S; R[%rax] = R[%rdx] : R[%rax] / S 有符号除法 divq S R[%rdx] = R[%rdx] : R[%rax] mod S; R[%rdx] = R[%rdx] : R[%rax] / S 无符号除法 逻辑运算 指令 效果 描述 NOT D D = ~D 取补 XOR S, D D ^= S 异或(负负得正) OR S, D D |= S 或 AND S, D D &= S 与 SAL k, D D <<= k 左移 SHL k, D D <<= k 左移 SAR k, D D >>= k 算数右移 SHR k, D D >>= k 逻辑右移 5. 条件及跳转 条件码 除了整数寄存器, CPU还维护着一组单个位的条件码(Condition Code)寄存器, 它们描述了最近的算数或逻辑操作的属性, 可以检测这些寄存器来执行条件分支指令. 最常用的条件码有: CF: 进位标志 最近的操作使最高位产生了进位 ZF: 零标志 最近的操作得出的结果为0 SF: 符号标志 最近的操作得到的结果为负数 OF: 溢出标志 最近的操作导致了一个补码溢出 ---- 正溢出或负溢出 例如, 假设有一条 ADD指令完成等价于C表达式 t = a + b的功能, 这里a, b, t都是整型的. 寄存器 等效条件表达式 描述 CF (unsigned) t < (unsigned) a 无符号溢出 ZF (t == 0) 零 SF (t < 0) 负数 OF (a < 0 == b < 0) && (t < 0 != a < 0) 有符号溢出 生成条件码 ---- 比较/测试 虽然一般的运算指令也会设置条件码寄存器, 但是其会改变原数值, 有两类指令能够只设置条件码而不改变任何其它寄存器, 如下所示: 指令 等效比较表达式 描述 CMP S1, S2 S2 - S1 比较 cmpb cmpw cmpl cmpq TEST S1, S2 S1 & S2 测试 testb testw testl testq 访问条件码 条件码通常不会直接读取, 常用的使用方法有三种: 根据条件码的某种组合将一个字节设置为0或1 使用条件跳转指令跳转 有条件的传送数据 对于第一种情况, 使用如下所示的SET指令, SET指令的目的操作数是低位单字节寄存器元素之一或是一个字节的内存位置, 指令会将这个字节设置成0或者1 指令 同义名 效果 设置条件 (than zero) sete D setz D = ZF 相等/零 setne D setnz D = ~ZF 不等/非零 sets D D = SF 负数 setns D D = ~SF 非负数 setg D setnle D = ~(SF ^ OF) & ~ZF 大于 (有符号>) setge D setnl D = ~(SF ^ OF) 大于等于 (有符号>=) setl D setnge D = SF ^ OF 小于 (有符号<) setle D setng D = (SF ^ OF) | ZF 小于等于 (有符号<=) seta D setnbe D = ~CF & ~ZF 超过 (无符号>) setae D setnb D = ~CF 超过或相等 (无符号>=) setb D setnae D = CF 低于 (无符号<) setbe D setna D = CF |ZF 低于或等于 (无符号<=) 跳转指令JMP 指令 同义名 跳转条件 设置条件 (than zero) jmp Label 1 直接跳转 jmp *Operand 1 间接跳转 je Label setz ZF 相等/零 jne Label setnz ~ZF 不等/非零 js Label SF 负数 jns Label ~SF 非负数 jg Label jnle ~(SF ^ OF) & ~ZF 大于 (有符号>) jge Label jnl ~(SF ^ OF) 大于等于 (有符号>=) jl Label jnge SF ^ OF 小于 (有符号<) jle Label jng (SF ^ OF) | ZF 小于等于 (有符号<=) ja Label jnbe ~CF & ~ZF 超过 (无符号>) jae Label jnb ~CF 超过或相等 (无符号>=) jb Label jnae CF 低于 (无符号<) jbe Label jna CF |ZF 低于或等于 (无符号<=) 条件传送指令CMOV 指令 同义名 传送条件 设置条件 (than zero) cmove Src, Dst cmovz ZF 相等/零 cmovne Src, Dst cmovnz ~ZF 不等/非零 cmovs Src, Dst SF 负数 cmovns Src, Dst ~SF 非负数 cmovg Src, Dst cmovnle ~(SF ^ OF) & ~ZF 大于 (有符号>) cmovge Src, Dst cmovnl ~(SF ^ OF) 大于等于 (有符号>=) cmovl Src, Dst cmovnge SF ^ OF 小于 (有符号<) cmovle Src, Dst cmovng (SF ^ OF) | ZF 小于等于 (有符号<=) cmova Src, Dst cmovnbe ~CF & ~ZF 超过 (无符号>) cmovae Src, Dst cmovnb ~CF 超过或相等 (无符号>=) cmovb Src, Dst cmovnae CF 低于 (无符号<) cmovbe Src, Dst cmovna CF |ZF 低于或等于 (无符号<=) 可以看出, 在汇编代码中, 使用指令来区别对待不同数据类型的数据. 大多数情况下, 机器代码对于有符号和无符号两种情况都使用一样的指令, 这是因为许多算术运算对无符号和补码算术都有一样的位级行为. 有些情况需要用不同的指令来处理有符号和无符号操作, 例如, 使用不同版本的右移/除法和乘法指令, 以及不同的条件码组合. 关于条件传送: 源操作数可以是寄存器或是一个内存位置, 目的操作数是一个寄存器; 源和目的的值可以是16位/32位/64位, 但不可以是单字节(单字节有自己的指令SET), 汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度, 所以对所有的操作数长度, 都可以使用同一个的指令名字. 同条件跳转不同, 处理器无需预测测试的结果就可以执行条件传送, 处理器只是读源值(可以是从内存中, 因此应注意空指针调用), 检查条件码, 然后决定是否更新目的寄存器. 无论测试结果如何, 处理器都会对各分支求值, 然后再决定跳转, 但是应当注意, 当这些表达式可能产生错误条件或者副作用时, 就会导致非法的行为.例如: 使用条件传送也不总是会提高代码的效率. 例如, 分支表达式中需要大量的计算, 或者各分支并不是"平衡"的. 实验表明, 只有当两个(分支)表达式都很容易计算时, GCC才会使用条件传送, 即使是许多分支预测错误的开销会超过更复杂的计算. switch语句与跳转表 对比一份示例代码: C语言 void switch_eg(long x, long n, long *dest) { long val = x; switch (n) { case 100: val *= 13; break; case 102: val += 10; case 103: val += 11; case 104: case 106: val *= val; break; default: val = 0; } *dest = val; } 汇编 switch_eg: subq $100, %rsi cmpq &6, %rsi ja .L8 jmp *.L4(, %rsi, 8) .L3: leaq (%rdi, %rdi, 2), %rax leaq (%rdi, %rax, 4), %rdi jmp .L2 .L5: addq $10, %rdi .L6: addq $11, %rdi jmp .L2 .L7: imulq %rdi, %rdi jmp .L2 .L8: movl $0, %edi .L2: movq %rdi, (%rdx) ret 汇编等效C语言 void switch_eg_impl(long x, long n, long *dest) { static void *jt[7] = { &&loc_A, &&loc_def, &&loc_B, &&loc_C, &&loc_D, &&loc_def, &&loc_D }; unsigned long index = n - 100; long val; if (index > 6) goto loc_def; goto *jt[index]; loc_A: val = x * 13; goto done; loc_B: x = x + 10; loc_C: val = x + 11; goto done; loc_D: val = x * x; goto done; loc_def: val = 0; done: *dest = val; } 6. 栈内存 传递控制, 传递数据, 分配和释放内存 栈内存 栈内存向下生长 地址 栈 说明 ↑地址增大↓地址减小 ... 早期的帧 P的其它栈空间 调用函数P的帧 参数n ... 参数7 返回地址 被保存的寄存器(寄存器压栈) 正在执行的函数Q的帧 局部变量 栈指针%rsp 参数构造区 参数7 函数调用时, 有6个寄存器可以用于传递参数. 因此, 超过6个参数的函数调用便需要用到内存(栈)进行传递 被保存的寄存器(寄存器压栈) 寄存器组是唯一被所有过程(函数)共享的资源. 虽然在给定时刻只有一个过程是活动的, 但仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时, 被调用者不会覆盖调用者稍后会使用的寄存器. 为此, x86-64采用了一组统一的寄存器使用惯例, 所有的过程(程序或函数)都必须遵循. 根据惯例, 寄存器%rbx, %rbp, %r12 ... %r15被划分为被调用者保存寄存器. 当过程P调用过程Q时, Q必须保证这些寄存器的值在它返回时与被调用时一致. 因此一旦过程需要使用这些寄存器时, 都会进行"压栈"操作将其保存到内存, 待返回时"弹栈"将其取出. 调用者保存寄存器 除上述所描述的寄存器和栈指针%rsp外, 其余所有寄存器都分类为调用者保存寄存器. 顾名思义, 这部分寄存器在进行函数调用时应当有调用者自行保存(压栈). 2. 转移控制 将控制从函数P转移到函数Q只需要将PC设置为Q的代码的起始位置. 不过, 当稍后从Q返回的时候, 处理器必须记录好它继续P的执行的代码位置. 在 x86-64 机器中, 这个信息是由call指令自动记录的. 该指令会把返回地址压入栈中, 并将 PC 设置为Q的起始地址. 其中, 返回地址是"紧跟在call指令后的那条指令的地址". 对应的, ret指令会中栈中弹出该返回地址, 并把PC设置为该地址. 下面是call和ret指令的一般形式: 指令 描述 call Label 过程调用 call *Operand 过程调用 ret 返回 call指令的操作数是一个地址, 因此可以是一个Label标签, 也可以是一个代表地址的操作数 3. 栈上的局部存储 并不是所有时候局部变量都保存到寄存器中的, 有时候也需要栈内存来保存局部变量, 常见的情况包括: 寄存器不足以存放所有的本地数据 对一个局部变量使用地址运算符 & , 因此必须能够为它产生一个地址 某些局部变量是数组或结构, 因此必须能够通过数组或结构引用被访问到. 以下是一段函数调用的示例代码, 包括C语言源码和汇编码 C语言源码: long swap_add(long *xp, long *yp) { long x = *xp; long y = *yp; *xp = y; *yp = x; return x + y; } long caller() { long arg1 = 534; long arg2 = 1057; long sum = swap_add(&arg1, &arg2); long diff = arg1 - arg2; return sum * diff; } 汇编码: swap_add: movq (%rdi), %rdx movq (%rsi), %rax movq %rax, (%rdi) movq %rdx, (%rsi) addq %rdx, %rax ret caller: subq $16, %rsp 'Allocate 16 byte for stack frame movq $534, (%rsp) 'Store 534 in arg1 movq &1057, 8(%rsp) 'Store 1057 in arg2 leaq 8(%rsp), %rsi 'Compute $arg2 as second argument movq %rsp, %rdi 'Compute $arg1 as first argument call swap_add 'Call swap_add(&arg1, $arg2) movq (%rsp), %rdx 'Get arg1 subq 8(%rsp), %rdx 'Compute diff = arg1 - arg2 imulq %rdx, %rax 'Compute sum * diff addq $16, %rsp 'Deallocate stack frame ret 'Return 4. 分配和释放内存 数据对齐 7. 数组和复合结构 无论是数组还是结构, 其在内存中都是一段连续的空间. 因此, 在汇编码中, 反应数组和复合结构的代码其实是一段地址(指针运算) 数组 以下是一段循环计算矩阵乘积的C语言代码及其汇编码. 注意, 汇编码使用 -O1 选项优化(可是从中看出编译器优化技巧) C语言 #define N 16 typedef int fix_matrix[N][N]; int fix_prod_ele (fix_matrix A, fix_matrix B, long i, long k) { long j; int result = 0; for (j=0; j<N; j++) result += A[i][j] * B[j][k]; return result; } 汇编码 fix_prod_ele: salq $6, %rdx addq %rdx, %rdi salq $2, %rcx leaq (%rsi,%rcx), %rdx leaq 1024(%rsi,%rcx), %rsi movl $0, %eax .L2: movl (%rdi), %ecx imull (%rdx), %ecx addl %ecx, %eax addq $4, %rdi addq $64, %rdx cmpq %rsi, %rdx jne .L2 rep ret 结构体和联合体 8. 杂项 1. C语言中的指针 2. 内存越界引用和缓冲区溢出 3. 对抗缓冲区溢出攻击 栈随机化 对坑"栈随机化"的一种方式 ---- "空操作雪橇" 一堆nop, 用来加大在内存中占用的地址长度, 增大概率(几何概率模型) exploit code 只要PC能指向这里, 就可以执行到exploit code 栈破坏检测 由编译器实现 限制可执行代码区域 由CPU实现 支持变长栈帧 9. 浮点类型
记一次FydeOS安装尝试和浅尝Linux操作系统安装 贴一个FydeOS链接, 该系统就是个贴牌Chromium OS. 官网安装方式在这里不做考虑. 偶然看到原来官方还给了这么个wiki 本次尝试安装目标: 硬盘安装 不刻盘 磁盘单分区 系统镜像分析 官网下载的FydeOS是一个img镜像. 分析官网提供的安装方式可以猜测, 该镜像可能是一个磁盘的RAW镜像. file命令可以基本确定其为磁盘的RAW镜像, 因此可以使用loopback方式尝试将其挂载. file命令输出 fydeos.img: DOS/MBR boot sector; partition 1 : ID=0xee, start-CHS (0x0,0,2), end-CHS (0x3ff,255,63), startsector 1, 10092700 sectors 首先将其设置为loop设备 losetup [options] -f | <loopdev> <file> 使用fdisk查看其分区布局(可能是我的操作方式有误, 使用parted时打印的分区布局是错误的) Device Start End Sectors Size Type /dev/loop0p1 5898240 10092652 4194413 2G Microsoft bas /dev/loop0p2 20480 53247 32768 16M ChromeOS kern /dev/loop0p3 319488 5898239 5578752 2.7G ChromeOS root /dev/loop0p4 53248 86015 32768 16M ChromeOS kern /dev/loop0p5 315392 319487 4096 2M ChromeOS root /dev/loop0p6 16448 16448 1 512B ChromeOS kern /dev/loop0p7 16449 16449 1 512B ChromeOS root /dev/loop0p8 86016 118783 32768 16M Microsoft bas /dev/loop0p9 16450 16450 1 512B ChromeOS rese /dev/loop0p10 16451 16451 1 512B ChromeOS rese /dev/loop0p11 64 16447 16384 8M unknown /dev/loop0p12 249856 315391 65536 32M EFI System 有了这个分区布局, 就可以尝试挂载了. 当然也可以使用dd把各分区单独分出来. 我是使用了losetup配合偏移量将各分区分别设置为loopback设备, 再使用mount将其一一挂载的; 有其他挂载方式请随意. 其实通过虚拟机运行FydeOS可以发现, 能用到的分区其实只有root/ stateful/ efi三个分区. (FydeOS可以切换到tty的), 因此上述多个分区其实是没有必要的. 猜测可能是Google对旗下ChromeBook设置的保留分区. 在这里补一个Google里搜索到的ChromiumOS安装教程 启动引导分析 挂载EFI分区, 查看grub.cfg内容, 关键启动记录如下示 menuentry "Boot FydeOS image A with Intel graphics chip" { linux /syslinux/vmlinuz.A init=/sbin/init boot=local rootwait ro noresume noswap loglevel=7 noinitrd console= i915.modeset=1 nouveau.modeset=0 radeon.modeset=0 amdgpu.modeset=0 cros_efi cros_debug root=PARTUUID=1687817B-70DD-E64B-AA45-82566DA0EB8D } 到此基本上可以尝试安装了. 将上述提及到的三个分区在硬盘里安置好, 然后从grub.cfg中指定根文件分区的分区ID即可开机了. *查看分区ID: lsblk [选项] [<设备> ...] LVM支持 是的, 使用LVM真的是没事找事. 主要是想实现单硬盘分区, 不想为了一个ChromiumOS而把硬盘分区表弄得乱糟糟的. 由于在开机时内核(vmlinuz)不能识别LVM分区, 因此需要设法将LVM分区在内核启动过程中挂载. (据说现在新版内核已经可以直接挂载LVM分区了, 不过FydeOS这个貌似是不行的) 有两种方案: initramfs 使用内核的device-mapper驱动 在我尝试弄了好几种方式生成initramfs都启动失败后, 还是妥协使用了device-mapper驱动. 相关文档: ChromiumOS Initrd | ChromiumOS KernelParameters | ChromiumOS DeviceMapper 方案如下: 新建LVM分区 修改init启动文件 然而还不行 挂载了root/ stateful , 但是还是运行不正常 等有空再折腾折腾吧 说一说折腾initramfs的事(内核调试) 说是内核调试, 其实也没有那么牛批的啦 当内核崩溃的时候, 或者当内核加载后使用图形化启动界面的情况时, 如果开机失败了, 看不到日志就让人摸不着头脑了. 当然添加quiet启动参数可以打开日志, 但是我试了, 好像是不行(而且遇到内核崩溃重启还会导致屏幕内容消失). 所以, 打开虚拟机吧. qemu调试内核