【问题标题】:Why I'm not following conventions for register-saving in an exception handler?为什么我不遵循异常处理程序中的寄存器保存约定?
【发布时间】:2021-05-20 19:26:25
【问题描述】:

在我的previous question 中,我在程序集中发布了这段代码(x86-64 att),它替换了无效操作码的处理程序(或者如果what_to_do 函数返回 0,则可以调用前一个):

.globl my_ili_handler

.text
.align 4, 0x90

my_ili_handler:

    movq (%rsp), %r8 # loading %rip from stack
    movb (%r8), %dil # reading first byte in the invalid opcode
    cmpb $0x0F, %dil
    jne function_call
    movb 1(%r8), %dil # else read the 2nd byte instead
    addq $1, %r8
    
function_call:
    addq $1, %r8
    pushq %rbp # save old %rbp
    movq %rsp, %rbp # move %rbp to top
    # %rax, %rdi, %rsi, %rdx, %rcx, %r8, %r9, %r10, %r11 caller saved.
    subq $8, %rsp
    pushq %r8 # backup %r8
    call what_to_do # unsigned int what_to_do(unsigned char magic)
    popq %r8 # restore %r8
    leave # push return address and jump
    
    cmpl $0, %eax
    je old_handler
    mov %eax, %edi # zero the upper part of %rdi
    addq $8, %rsp # pop old %rip from stack
    pushq %r8
    jmp end

old_handler:
    jmp *old_ili_handler(%rip)

end:
    iretq # go back to user space

你们中的许多人指出我没有遵循有关保存 r8 和 rdx 的指南,但我就是不明白为什么会这样?

但是:

  1. 那些寄存器是调用者保存的,我在调用 funciton_call 之前保存它们并再次加载它们,那有什么问题?

  2. 我不需要在调用 jmp 之前保存它们,这不是函数调用...

另外我应该如何在不破坏我的整个代码的情况下解决这个问题?


第一次编辑:

.globl my_ili_handler

.text
.align 4, 0x90

my_ili_handler:

    movq (%rsp), %r8 # loading %rip from stack
    movb (%r8), %dil # reading first byte in the invalid opcode
    cmpb $0x0F, %dil
    jne function_call
    movb 1(%r8), %dil # else read the 2nd byte instead
    addq $1, %r8
    
function_call:
    addq $1, %r8
    pushq %rbp # save old %rbp
    movq %rsp, %rbp # move %rbp to top
    # %rax, %rdi, %rsi, %rdx, %rcx, %r8, %r9, %r10, %r11 caller saved.
    subq $72, %rsp
    
    # backup all caller-saved registers
    pushq %rax
    pushq %rdi
    pushq %rsi
    pushq %rdx
    pushq %rcx
    pushq %r8
    pushq %r9
    pushq %r10
    pushq %r11
    
    call what_to_do # unsigned int what_to_do(unsigned char magic)
    
    # restore all caller-saved registers
    popq %r11
    popq %r10
    popq %r9
    popq %r8
    popq %rcx
    popq %rdx
    popq %rsi
    popq %rdi
    popq %rax
    
    leave # (mov %rbp, %rsp) & (pop %rbp) 
    
    cmpl $0, %eax
    je old_handler
    mov %eax, %edi # zero the upper part of %rdi
    addq $8, %rsp # pop old %rip from stack
    pushq %r8
    jmp end

old_handler:
    jmp *old_ili_handler(%rip)

end:
    iretq # go back to user space

【问题讨论】:

  • 如果标签在单词“function”中没有拼写错误,您的代码会更容易阅读,特别是因为您现在在另一条指令中引用了标签名称:/
  • 你正在编写一个异常处理程序,而不是一个普通的函数!将控制权转移给您的代码不会主动调用您,也不会期望 any 寄存器被修改。因此,您必须保留 所有 寄存器、标志和其他相关 CPU 状态,包括那些将在正常函数调用中“保存调用方”的状态。正常的调用约定不适用,因为“调用者”不与它们合作。
  • 但这并没有回答我什么时候应该备份它们以及什么时候恢复它们,另外我应该备份所有其他寄存器吗? (我知道 cmp 会影响一个寄存器,我也应该备份它吗?)
  • 您的编辑使问题中的代码变得荒谬(因为没有设置 R8),并使我的答案无效,因为您根本不再调用 C 函数。回滚。我看不出这种变化如何被描述为“修复”代码,因为它仍然修改了 R8 而不保存它。如果您尝试编写答案,请将其作为答案发布,而不是对问题的编辑。

标签: assembly x86-64 cpu-registers interrupt-handling


【解决方案1】:

您的中断处理程序不是函数。寄存器的整个传入状态(RSP 和 RFLAGS 除外)属于用户空间。

jmp *old_ili_handler(%rip) 最终会记录故障发生时用户空间的状态,因此您要避免扭曲信号处理程序或核心转储看到的用户空间状态。

您可以查看所有寄存器都是旧处理程序的尾调用的“参数”。(它也是一个中断处理程序,而不是一个函数,所以你 jmp 使用堆栈/寄存器来处理它处于与进入处理程序时的状态相匹配的状态,因此它的工作方式就像是直接从用户空间中的错误调用一样。)

注意与int foo(int x){return bar(x);} 之类的函数的相似之处,它会编译为jmp bar 而不是call bar / ret。即一个优化的尾调用,只将 args 留在寄存器中。但同样,对于可以返回、传递信号或触发核心转储的异常处理程序,所有寄存器中的整个用户空间状态实际上都是一个参数。


一般来说,对于其他错误,例如页面错误,可以在解决问题后恢复用户空间,更重要的是不要破坏寄存器:而不是仅仅将错误信息输入核心转储(或破坏偶尔的程序通过 SIGILL 处理程序模拟丢失的指令),如果最终返回用户空间具有不同的寄存器值,您将破坏执行 add (%r8), %edi 的代码。事实上,您的代码现在有时会跳转到 iret,因此您可能会在修复后直接返回用户空间重试错误指令,所以您确实遇到了这个问题。

请注意,您实际上应该保存/恢复您的 call what_to_do 周围的所有调用破坏寄存器,因为它是一个遵循 C 调用约定的函数


例如安全代码可能看起来像这样。 (未经测试)。将 RIP 传递给 what_to_do 并让它返回新的 RIP 或 0 运行旧的处理程序可能更有意义。 (作为奖励,您不需要在该函数调用中保存任何额外的状态,只需保存用户空间状态。)

x86 指令在操作码之后的字节数是可变的,具体取决于寻址模式和立即数,因此仅将用户空间 RIP 增加 1 或 2 是没有意义的。或者如果第一个字节实际上是前缀如 reprex...

您可以拥有超过 2 个字节的非法指令,例如使用寄存器源(REX + opcode + modrm)编码的lea。或 66 66 0F 0B(UD2 前面的 2 个前缀)。因此,当它只查看 1 个字节时,可能会混淆您的函数。

但无论如何,我保留了您的原始指令长度解码,以显示使用保留调用的寄存器来记住整个调用中的某些内容,与保存用户空间的状态分开。

.globl my_ili_handler
.text
.p2align 4
my_ili_handler:
    push   %rbx    # save a call-preserved reg for our own use

    # %rax, %rdi, %rsi, %rdx, %rcx, %r8, %r9, %r10, %r11 are call-clobbered
    push   %rax
    push   %rcx
    push   %rdx
    push   %rsi
    push   %rdi
    push   %r8
    push   %r9
    push   %r10
    push   %r11

    mov   10*8(%rsp), %rbx   # loading user-space fault address from exception frame
                          # note the n*8(%rsp) since this is after n pushes; same address as (%rsp) on entry.

    movzbl (%rbx), %edi   # byte load of the invalid opcode
    inc    %rbx
    cmp    $0x0F, %edi    # check for 2-byte opcode escape byte
    jne function_call
    inc    %rbx
    movzbl (%rbx), %edi   # else read the 2nd byte instead
    
function_call:
   # RBX points to fault-address + 1 or 2 depending on seeing 0F.
   # Very primitive instruction-length decoding that ignores prefixes
   # and illegal forms of longer instructions with ModRM and/or immediate operands

    # subq $8, %rsp        # 16-byte stack alignment probably not needed in kernel, and I didn't check what the initial alignment was on entry vs. the number of pushes
    cld                    # C calling convention requires DF=0, user-space might have left DF=1
       # 64-bit mode can I think avoid worrying about DS and ES settings
    call what_to_do        # unsigned int what_to_do(unsigned char magic)
    
    cmpl $0, %eax
# now restore everything, before we either 
# run the old handler transparently  or  return to user-space with its regs unchanged

    pop   %r11
    pop   %r10
    pop   %r9
    pop   %r8

    pop   %rdi
    pop   %rsi
    pop   %rdx
    pop   %rcx
    pop   %rax

    je  run_old_handler
end:
    # mov %eax, %edi        # zero the upper part of %rdi.
         #  IDK what this was for.  Is user-space supposed to get this return value?
         # If so, only restore RAX in the other path instead of before the branch
         # and   add $8, %rsp   here instead.

    mov   %rbx, 8(%rsp)     # set the user-space RIP
    pop   %rbx              # restore our call-preserved register
    iretq                   # and return to user-space at the updated RIP
    
run_old_handler:
    pop   %rbx             # just restore RBX
    jmp *old_ili_handler(%rip)   # and run the old handler with all registers in identical state to entry to this handler.

【讨论】:

  • 我还不明白...我已经保存/恢复了调用 what_to_do 周围的所有调用破坏寄存器。那么又是什么问题呢?
  • @john:如果中断服务路由(例如,对于网卡)每次运行时都将 RAX 设置为 123,在任何中断的任何两条指令之间异步设置会发生什么?显然很糟糕,对吧?这就是您的异常处理程序正在执行的操作,除非仅在被非法指令调用时。
  • @john:另外,持有第一个参数的是 RDI,而不是 RDX。您正在加载到 DIL,而不是 DL。
  • 所以从你所说的我应该在调用 what_to_do 之前备份每个调用者保存的寄存器,因为它可能会改变他们中的任何人,我不希望这种情况发生?这对我来说听起来不正确......
  • 哇,我佩服彼得的耐心! :)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-06-24
  • 1970-01-01
  • 2012-08-26
  • 2016-08-26
  • 2017-10-07
  • 2012-01-02
  • 2021-08-09
相关资源
最近更新 更多