在 C/C++ 中检测内存 IO 以进行硬件仿真

Instrumenting memory IO in C/C++ for hardware emulation

好的,关于什么和为什么的一些背景知识?

我想在桌面 linux 上编译和 运行 微控制器固件(裸机,没有 OS)。我不想写字节码解释器或二进制翻译器;我想编译原始源代码。 运行 作为标准 GUI 应用程序的 FW 具有许多优点,例如快速开发迭代、高级调试、自动化测试、压力测试等。我之前在几个项目中使用 AVR 微控制器完成过此操作,通常采用以下方法步骤:

前 3 个步骤很简单(AVR 的代码不多),最后一个步骤很棘手。 FW 中的一些结构最终在桌面版本中成为无限循环(例如,忙循环等待外围寄存器更改,或中断处理程序的内存更改),其他结构最终成为无操作(写入 MMIO,在真实系统上触发某些东西) ,并且将 FW 的主循环与 GUI 库的主循环融合也需要一些创造力。如果 FW 很好地分层,那么低级代码可以用胶水函数代替,而无需过多修改。

尽管整体行为会受到这些更改的影响,但我发现最终结果在很多情况下都非常有用。不幸的是,这种方法是侵入性的(FW 修改),并且胶水逻辑高度依赖于 FW 的体系结构(每次都需要重新发明)。

接近问题...

从 C/C++ 的角度来看,FW 和代码 运行ning 在适当的 OS 上最重要的区别是 MMIO。 MMIO访问有副作用,读和写的副作用不同。在桌面应用程序中,这个概念不存在(除非你从用户空间戳 HW)。如果可以在读取或写入内存位置时定义一个挂钩,那将启用适当的外围设备仿真,并且 FW 可以基本完整地编译。当然,这不能在 C++ 中完成,母语的整个目的就是反对这一点。但是内存调试器在检测的帮助下使用了相同的概念(跟踪内存访问 运行time)。

我有一些实现的想法,所以我的问题是你认为它们的可行性如何,或者有没有其他方法可以达到相同的结果?

  1. 根本没有检测。 x86 可以在访问内存位置时发出信号,调试器使用它来实现观察点(内存访问时中断)。作为概念证明,我创建了这个测试程序:

    #include <stdio.h>
    volatile int UDR;
    
    void read()  { printf("UDR read\n"); }
    void write() { printf("UDR write\n"); }
    
    int main()
    {
        UDR=1;
        printf("%i\n", UDR);
        return 0;
    }
    

    UDR是我要跟踪的MMIO寄存器,如果我运行在GDB下用下面的脚本编译程序:

    watch UDR
    commands
    call write()
    cont
    end
    
    rwatch UDR
    commands
    call read()
    cont
    end
    

    结果正是我想要的:

    UDR write
    UDR read
    1
    

    问题是我根本不知道这是否可扩展。据我所知,观察点是一种有限的硬件资源,但无法找出 x86 的限制。我可能需要不到 100 个。GDB 也支持软件观察点,但仅用于写入,因此它不能真正用于此目的。另一个缺点是代码只会 运行 在 GDB session.

  2. 运行时检测。如果我是正确的,Valgrind/libvex 会这样做:读取编译后的二进制文件并在内存访问位置(以及许多其他位置)插入检测代码。我可以编写新的 Valgrind 工具,该工具配置了地址和回调,如上面的 GDB 脚本,并在 Valgrind session 上执行应用程序。你觉得这可行吗?我找到了一些关于创建新工具的文档,但这似乎并不容易。

  3. 编译时间检测。 clang 和 gcc 中的内存和地址清理器就是这样工作的。这是一个由 2 部分组成的游戏,编译器发出经过检测的代码,并将一个消毒库(实施实际检查)链接到应用程序。我的想法是用执行上述回调的自己的实现替换 sanitizer 库,而不进行任何编译器修改(这可能超出了我的能力)。不幸的是,我没有找到太多关于检测代码和消毒程序库如何交互的文档,我只找到了描述检查器算法的论文。

我的问题就这些了,欢迎对任何主题发表任何评论。 :)

我没有时间回复你问题中的所有问题,但这可能会太长而无法发表评论...

所以关于 "watch points" 在调试器中,他们使用调试寄存器,虽然你可以自己编写代码来使用这些寄存器(有 API 函数可以做到这一点 - 你需要在内核模式写入这些寄存器),正如您自己所说,您将 运行 超出寄存器。这个数字也比你的 100 低得多。在 x86 处理器中有 4 个调试位置寄存器,覆盖读取 and/or 写入 1-8 字节宽的位置。因此,如果您总共有少于 32 个字节的 IO space(分布在不超过 4 个块,每个块不超过 8 个字节),它将起作用。

选项 2 的问题是您需要保证您的 IO 寄存器使用的区域未用于您的应用程序中的其他内容。这可能是 "easy",如果所有的 IO 寄存器都在,比方说,在第一个 64KB 中。否则,你得想办法弄清楚是MMIO访问还是常规访问。除了编写您自己的 Valgrind 版本之外,您也不是一蹴而就的事情……即使您首先雇用了编写 valgrind 的人……

选项3在匹配地址方面与选项2有同样的问题。我的感觉是这对你帮助不大,你最好换一种方式来处理它。

我在使用过的各种芯片模拟器中看到的做法是将对真实硬件的访问修改为函数调用。您可以在 C++ 中通过类似 MSalters 描述的方法来做到这一点。

或者通过修改您的代码,这样您就可以:

MMIO_WRITE(UDR, 1);

然后让 MMIO_WRITE 转换为:

 #if REAL_HW
 MMIO_WRITE(x, y)   x = y
 #else
 MMIO_WRITE(x, y)  do_mmio_write(x, y)
 #endif

其中 do_mmio_write 能够理解地址以及它们在某种程度上的作用。

这当然是我在工作中使用的 GPU 模型如何对我们即将制作成硅的最新最好的 GPU 进行建模,我工作的前一家公司使用的模型就是这样的模型.

是的,您将不得不重写一些代码 - 理想情况下,您编写的代码中有特定的一小部分代码会触及实际硬件 [如果您想从一种类型的微控制器到另一种类型的微控制器,因为在这种情况下你也必须做更多的重写工作。

正如 Martin James 指出的那样,任何此类模拟的问题在于,如果您的实际模拟器不是很好,您 运行 就会进入 "compatibility problems" - 特别是硬件与软件竞赛条件下,您的软件与模拟硬件模型完全同步,但真实硬件将与软件异步执行操作,因此您对两个寄存器的两次读取现在将获得与软件模型不同的值,因为一些任意的东西在您的软件模型没有考虑到的真实硬件 - 现在您遇到了那些难得一见的严重错误之一,而且只出现在 "can't debug" 硬件变体上,绝不会出现在软件模型中。

看着 MSalters 的评论和 Mats 的回答,我显然把这个话题复杂化了。因为我可以访问源代码,所以有一些语言级别的功能可以比使用检测更容易地挂钩 MMIO 操作。我用一个极简主义的串行回声示例评估了建议的版本:

#include <avr/io.h>

void mainloop(volatile uint8_t* reg) {
    while(1) {
        loop_until_bit_is_set(UCSRA, RXC);
        uint8_t tmp = *reg;
        *reg = tmp+1;
        loop_until_bit_is_set(UCSRA,  TXC);
    }
}

int main(void) {
    UCSRB = _BV(RXEN) | _BV(TXEN);  // enable UART rx/tx
    UBRRL = 12;                     // 12: 38400 @8Mhz 0.2% error

    mainloop(&UDR);
}

它在串口上接收一个字节,并将该字节加一发送。它具有常见的 MMIO 寄存器用例,包括将寄存器指针传递给函数。

C 方式

在这种情况下,所有 MMIO 访问都用宏包装,这些宏最终对生产代码而言是空操作,但会在仿真中调用挂钩函数。寄存器是mmio8_t,它是一个非整数类型,所以忘记把宏放在适当的位置会导致编译时错误。

#if 0   // this is the Production mode
#include <avr/io.h>

//MMIO macros are no op in production
#define MMIO_READ(mmio_reg) mmio_reg
#define MMIO_WRITE(mmio_reg, data) mmio_reg=data

typedef volatile uint8_t mmio8_t;
#endif

#if 1   // this is the Emulation mode
#include <stdio.h>
#include <stdint.h>
// register bit definitions for UCSRA and UCSRB skipped to shorten code sample

struct st_mmio8 {
    const char * name;
    // uint8_t value;
    // emulation hooks for the register
};
typedef const struct st_mmio8 mmio8_t;

mmio8_t UCSRA = { "UCSRA" };    //these are THE mmio registers
mmio8_t UCSRB = { "UCSRB" };
mmio8_t UBRRL = { "UBRRL" };
mmio8_t UDR = { "UDR" };

// some bit magic taken from <avr/io.h>
#define _BV(bit) (1 << (bit))
#define bit_is_set(sfr, bit) (MMIO_READ(sfr) & _BV(bit))
#define loop_until_bit_is_set(sfr, bit) do { } while (bit_is_clear(sfr, bit))

uint8_t MMIO_READ(mmio8_t addr) {
    printf("MMIO_READ id: %s\n", addr.name);
    return _BV(RXC) | _BV(TXC);
}

void MMIO_WRITE(mmio8_t addr, uint8_t val) {
    printf("MMIO_WRITE id: %s\n", addr.name);
}
#endif

void mainloop(mmio8_t* reg) {
    while(1)     {
        loop_until_bit_is_set(UCSRA, RXC);
        uint8_t tmp = MMIO_READ(*reg);
        MMIO_WRITE(*reg, tmp+1);
        loop_until_bit_is_set(UCSRA,  TXC);
    }
}

int main(void) {
    MMIO_WRITE(UCSRB, _BV(RXEN) | _BV(TXEN));  // enable UART rx/tx
    MMIO_WRITE(UBRRL, 12);                     // 12: 38400 @8Mhz 0.2% error

    mainloop(&UDR);
}

正确hook了MMIO访问,但是代码的可读性降低了,尤其是习惯了原来风格的人。

C++ 风格

此选项依赖于 C++ 运算符重载。 mmio_t class 定义了类型转换赋值运算符来挂钩写入,以及转换运算符来挂钩读取:

#if 0   // this is the Production mode
#include <avr/io.h>
using mmio8_t = volatile uint8_t;
#endif

#if 0   // this is the Emulation mode
#include <stdint.h>
#include <iostream>
#include <string>
// register bit definitions for UCSRA and UCSRB skipped to shorten code sample

// some bit magic taken from <avr/io.h>
#define _BV(bit) (1 << (bit))
#define bit_is_set(sfr, bit) (sfr & _BV(bit))
#define loop_until_bit_is_set(sfr, bit) do { } while (bit_is_clear(sfr, bit))

template<typename T>
class mmio_t {
    public:
    mmio_t(const std::string& regname) : regname(regname) {}

    //this is a non-chainable assignment
    void operator=(T data) {
        std::cout << "mmio_write " << regname << std::endl;
    }

    operator T() {
        std::cout << "mmio_read " << regname << std::endl;
        return _BV(TXC) | _BV(RXC);
    }
    private:
    std::string regname;
    //T value;
    //std::function hooks for emulation code
};
using mmio8_t = mmio_t<uint8_t>;

mmio8_t UCSRA("UCSRA");
mmio8_t UCSRB("UCSRB");
mmio8_t UBRRL("UBRRL");
mmio8_t UDR("UDR");

#endif

void mainloop(mmio8_t* reg) {
    while(1) {
        loop_until_bit_is_set(UCSRA, RXC);
        uint8_t tmp = *reg;
        *reg = tmp+2;
        loop_until_bit_is_set(UCSRA, TXC);
    }
}

int main(void) {
    UCSRB = _BV(RXEN) | _BV(TXEN);  // enable UART rx/tx
    UBRRL = 12;                     // 12: 38400 @8Mhz 0.2% error

    mainloop(&UDR);
}

除了引入了 mmio8_t 类型外,代码与原始代码相同,并且正确捕获了操作。

虽然这些示例不完整或可能不是 100% 正确,但它们显示了每个版本的基本特征。感谢所有提示和想法!