嵌入式Linux中文站

开发一个 Linux 调试器(三):寄存器和内存


上一篇文章中,我们在调试器中加入了简单的地址断点。这一次,我们将给调试器加入读写寄存器和内存的功能,这样就可以在控制RIP,观察程序的状态,以及改变程序的行为了。

注册我们的寄存器

在我们正真的读取寄存器前,调试器需要知道一些关于x8664架构的相关知识。包括通用寄存器,专用寄存器以及浮点寄存器和向量寄存器。为了简单期间,我将省略后两者(浮点以及向量寄存器),当然如果你喜欢的话你可以选择去加入相关支持。x86_64架构也允许你用32,16或者8位的方式来访问64位寄存器,但是我将会一直使用64位的。由于简化了一些东西,所以对寄存器来说,我们只需要知道它的名字以及它在DWARF中的寄存器号,以及它被存储在ptrace返回的结构中什么位置就可以了。我选择用一个枚举来引用寄存器,然后来构建一个和ptrace中的寄存器结构顺序相同的全局寄存器描述符数组。

enum class reg {
    rax, rbx, rcx, rdx,
    rdi, rsi, rbp, rsp,
    r8,  r9,  r10, r11,
    r12, r13, r14, r15,
    rip, rflags,    cs,
    orig_rax, fs_base,
    gs_base,
    fs, gs, ss, ds, es
};

constexpr std::size_t n_registers = 27;

struct reg_descriptor {
    reg r;
    int dwarf_r;
    std::string name;
};

const std::array<reg_descriptor, n_registers> g_register_descriptors {{
    { reg::r15, 15, "r15" },
    { reg::r14, 14, "r14" },
    { reg::r13, 13, "r13" },
    { reg::r12, 12, "r12" },
    { reg::rbp, 6, "rbp" },
    { reg::rbx, 3, "rbx" },
    { reg::r11, 11, "r11" },
    { reg::r10, 10, "r10" },
    { reg::r9, 9, "r9" },
    { reg::r8, 8, "r8" },
    { reg::rax, 0, "rax" },
    { reg::rcx, 2, "rcx" },
    { reg::rdx, 1, "rdx" },
    { reg::rsi, 4, "rsi" },
    { reg::rdi, 5, "rdi" },
    { reg::orig_rax, -1, "orig_rax" },
    { reg::rip, -1, "rip" },
    { reg::cs, 51, "cs" },
    { reg::rflags, 49, "eflags" },
    { reg::rsp, 7, "rsp" },
    { reg::ss, 52, "ss" },
    { reg::fs_base, 58, "fs_base" },
    { reg::gs_base, 59, "gs_base" },
    { reg::ds, 53, "ds" },
    { reg::es, 50, "es" },
    { reg::fs, 54, "fs" },
    { reg::gs, 55, "gs" },
}};

一般你可以在/usr/include/sys/user.h找到关于寄存器相关的数据结构。如果你想自己去查看一番,DWARF寄存器号是根据System V x86_64 ABI这个规范来设置的。

现在,就可以写一大堆函数来与寄存器交互了。我们希望能够通过DWARF寄存器号来读取,写入,接收寄存器的值,并且可以通过命长来查找寄存器或者通过寄存器来查找名称。让我们从声明get_register_value函数开始吧:

uint64_t get_register_value(pid_t pid, reg r) {
    user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, nullptr, &regs);
    //...
}

同样的,ptrace给了我们一种简单的访问我们想要的数据的方式。只需构建一个user_regs_struct实例,然后和PTRACE_GETREGS请求一起传给ptrace即可。

现在,我们想根据被请求的寄存器读取regs。可以通过写一个繁杂的switch case结构,但是由于我们已经构建了g_register_descriptors这个表,表中的寄存器顺序和user_regs_struct完全一致,于是就可以通过索引来查找寄存器描述符,并且以uint64_t数组的方式来访问user_regs_struct

        auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                               [r](auto&& rd) { return rd.r == r; });//译注:此处是lambda表达式

        return *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors)));

转换到uint_64_t是安全的,因为user_regs_struct是标准的布局类型,但是我认为指针在算数运算上是unsigned byte(译注:实际上是signed byte,参考内核地址高20(intel架构)位全被置1)。现有编译器甚至对此没有警告,我比较懒,也不想多花心思了,但是如果你想保持最大可能的正确性就需要一个大的switch case了。

set_register_value也是一样的,我仅仅是写到相应位置,然后在最后写回寄存器:

void set_register_value(pid_t pid, reg r, uint64_t value) {
    user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, nullptr, &regs);
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [r](auto&& rd) { return rd.r == r; });

    *(reinterpret_cast<uint64_t*>(&regs) + (it - begin(g_register_descriptors))) = value;
    ptrace(PTRACE_SETREGS, pid, nullptr, &regs);
}

接下来就是通过DWARF寄存器号来查找相应的值了。这一次我会检查一个错误条件,以防万得到一些奇怪的DWARF信息:

uint64_t get_register_value_from_dwarf_register (pid_t pid, unsigned regnum) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [regnum](auto&& rd) { return rd.dwarf_r == regnum; });
    if (it == end(g_register_descriptors)) {
        throw std::out_of_range{"Unknown dwarf register"};
    }

    return get_register_value(pid, it->r);
}

差不多完成了,现在我们就有了下边看起来这样的寄存器值了:

std::string get_register_name(reg r) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [r](auto&& rd) { return rd.r == r; });
    return it->name;
}

reg get_register_from_name(const std::string& name) {
    auto it = std::find_if(begin(g_register_descriptors), end(g_register_descriptors),
                           [name](auto&& rd) { return rd.name == name; });
    return it->r;
}

最后,加一些简单的辅助函数来dump寄存器的内容:

void debugger::dump_registers() {
    for (const auto& rd : g_register_descriptors) {
        std::cout << rd.name << " 0x"
                  << std::setfill('0') << std::setw(16) << std::hex << get_register_value(m_pid, rd.r) << std::endl;
    }
}

如你所见,iostreams有一个非常简洁的接口,可以很好地输出十六进制数据。如果你喜欢,可以封装一些IO操作来避免混乱。

这些就足够支持我们在调试器其它部分处理寄存器了,现在,可以将其添加到UI中去了。

操作寄存器

我们需要做的就是将一个新的命令加入到handle_command函数中。在下边的代码示意中,用户可以通过输入register read rax或者register write rax 0x42以及其他的命令来操纵寄存器。

    else if (is_prefix(command, "register")) {
        if (is_prefix(args[1], "dump")) {
            dump_registers();
        }
        else if (is_prefix(args[1], "read")) {
            std::cout << get_register_value(m_pid, get_register_from_name(args[2])) << std::endl;
        }
        else if (is_prefix(args[1], "write")) {
            std::string val {args[3], 2}; //assume 0xVAL
            set_register_value(m_pid, get_register_from_name(args[2]), std::stol(val, 0, 16));
        }
    }

思路

在设置断点时,我们已经读取和写入内存,所以只需要添加一些函数来封装一下ptrace调用。

uint64_t debugger::read_memory(uint64_t address) {
    return ptrace(PTRACE_PEEKDATA, m_pid, address, nullptr);
}

void debugger::write_memory(uint64_t address, uint64_t value) {
    ptrace(PTRACE_POKEDATA, m_pid, address, value);
}

你可能希望一次添加对读取和写入大于WORD(16位)型数据的支持,只需通过在每次要读取另一个WORD时递增地址即可。同时也可以使用process_vm_readvprocess_vm_writev或者使用/proc/<pid>/mem来替代ptrace

现在,为我们的UI加入相关命令:

    else if(is_prefix(command, "memory")) {
        std::string addr {args[2], 2}; //assume 0xADDRESS

        if (is_prefix(args[1], "read")) {
            std::cout << std::hex << read_memory(std::stol(addr, 0, 16)) << std::endl;
        }
        if (is_prefix(args[1], "write")) {
            std::string val {args[3], 2}; //assume 0xVAL
            write_memory(std::stol(addr, 0, 16), std::stol(val, 0, 16));
        }
    }

修复continue_execution

 

 

110/5000

您是不是要找: Before we test out our changes, we’re now in a position to implement a more sane version of continue execution)

在测试更改之前,我们现在可以执行一个更加正确的版本的continue_execution。因为可以获取RIP,所以只需检查我们的断点保存结构来确定是否运行到了一个断点的位置。如果是,先禁止断点然后在继续运行前步过一次。

首先,为了清晰简洁,先添加几个辅助函数:

uint64_t debugger::get_pc() {
    return get_register_value(m_pid, reg::rip);
}

void debugger::set_pc(uint64_t pc) {
    set_register_value(m_pid, reg::rip, pc);
}

然后,可以写一个步过断点的函数:

void debugger::step_over_breakpoint() {
    // - 1 because execution will go past the breakpoint
    auto possible_breakpoint_location = get_pc() - 1;

    if (m_breakpoints.count(possible_breakpoint_location)) {
        auto& bp = m_breakpoints[possible_breakpoint_location];

        if (bp.is_enabled()) {
            auto previous_instruction_address = possible_breakpoint_location;
            set_pc(previous_instruction_address);

            bp.disable();
            ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
            wait_for_signal();
            bp.enable();
        }
    }
}

首先,检查此刻RIP所处的位置是不是被设置了断点,如果是,将RIP后退一个字节(译注:0xCC断点触发时0xCC本身已经被执行过了,所以停下的位置和下断点的位置差了一个字节,需要将RIP回拨一个字节),禁用断点(译注:将原始的指令数据写回来),单步步过此处原来的指令,然后重新设置断点(译注:再将0xCC写回去)R

wait_for_signal函数将封装一些常用的waitpid模式:

void debugger::wait_for_signal() {
    int wait_status;
    auto options = 0;
    waitpid(m_pid, &wait_status, options);
}

最后,重新写的continue_execution就像这样:

void debugger::continue_execution() {
    step_over_breakpoint();
    ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
    wait_for_signal();
}

测试

现在我们可以读取和修改寄存器,hello world程序于是就可以有一些乐子了。首先来测试一下在call指令上下断点,然后从断点处继续运行吧。应该可以看见Hello world已经被输出。乐子来了,在输出的那个call后边下一个断点,继续运行,然后将设置调用参数的代码的地址写入RIP并继续。你应该可以看见由于RIP被改变Hello world被输出了两次。以防你不知道在哪里设置断点,下边我给出我的objdump

0000000000400936 <main>:
  400936:    55                       push   rbp
  400937:    48 89 e5                 mov    rbp,rsp
  40093a:    be 35 0a 40 00           mov    esi,0x400a35
  40093f:    bf 60 10 60 00           mov    edi,0x601060
  400944:    e8 d7 fe ff ff           call   400820 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
  400949:    b8 00 00 00 00           mov    eax,0x0
  40094e:    5d                       pop    rbp
  40094f:    c3

你需要将RIP移回到0x40093a,以便对esiedi进行正确的赋值。

在下一篇文章中,我们将会首次探索一下DWARF信息,以及向调试器加入几种单步操作。之后,我们将有一个具备大部分功能的工具,可以通过代码来单步,设置断点到想要的地方去,修改数据以及更多功能。有问题,尽管在回复区提问!

本文永久更新链接:http://embeddedlinux.org.cn/emb-linux/system-development/201707/04-6924.html



分享:

评论