RISC_V GPU skybox 系列之rtlsim运行测试(4)

RISC_V GPU skybox 系列之rtlsim运行测试(2-3)中,我们介绍了1-5部分内容,这里我们继续介绍。

1. 初始化退出码

// 见RISC_V GPU skybox 系列之rtlsim运行测试(2)

2. 解析命令行参数

// 见RISC_V GPU skybox 系列之rtlsim运行测试(2)

3. 创建内存模块和处理器

// 见RISC_V GPU skybox 系列之rtlsim运行测试(2)

4. 关联内存模块和处理器

// 见RISC_V GPU skybox 系列之rtlsim运行测试(3)

5. 设置基 DCR(Designated Control Register)

// 见RISC_V GPU skybox 系列之rtlsim运行测试(3)

6. 加载程序

// load program
{
    std::string program_ext(fileExtension(program));
    if (program_ext == "bin") {
        ram.loadBinImage(program, startup_addr);
    } else if (program_ext == "hex") {
        ram.loadHexImage(program);
    } else {
        std::cout << "*** error: only *.bin or *.hex images supported." << std::endl;
        return -1;
    }
}
  • 调用 fileExtension 函数获取程序文件的扩展名。
  • 如果扩展名是 bin,调用 ramloadBinImage 方法将二进制文件加载到内存中,起始地址为 startup_addr
  • 如果扩展名是 hex,调用 ramloadHexImage 方法将十六进制文件加载到内存中。
  • 如果扩展名不是 binhex,输出错误信息并返回 -1。

loadBinImage
// skybox/sim/common/mem.cpp

void RAM::loadBinImage(const char* filename, uint64_t destination) {
  std::ifstream ifs(filename);
  if (!ifs) {
    std::cout << "error: " << filename << " not found" << std::endl;
    std::abort();
  }

  ifs.seekg(0, ifs.end);
  size_t size = ifs.tellg();
  std::vector<uint8_t> content(size);
  ifs.seekg(0, ifs.beg);
  ifs.read((char*)content.data(), size);

  this->clear();
  this->write(content.data(), destination, size);
}

这个函数属于 RAM 类,用于从二进制文件中加载数据并写入 RAM 的指定地址。其主要步骤如下:

  1. 打开文件
    使用 std::ifstream ifs(filename) 打开指定文件,如果文件打开失败,则打印错误消息并调用 std::abort() 终止程序。

  2. 获取文件大小
    利用 ifs.seekg(0, ifs.end) 将文件指针移到文件末尾,再调用 ifs.tellg() 获取文件大小(以字节为单位)。

  3. 读取文件内容
    创建一个 std::vector 用于存储整个文件内容,并将文件指针重置到起始位置(ifs.seekg(0, ifs.beg))。然后调用 ifs.read() 将文件内容读入 vector 中。

  4. 清空内存
    调用 this->clear() 方法将内存清空,确保写入操作之前内存处于初始状态。

  5. 写入内存
    调用 this->write(content.data(), destination, size) 将刚读取的内容写入 RAM,从指定的 destination 地址开始,写入 size 字节。

这种实现方式保证了在加载新镜像前清空内存,并将二进制文件的全部内容准确写入指定内存地址,从而常用于仿真器或模拟器中加载程序镜像。

RAM::write

void RAM::write(const void* data, uint64_t addr, uint64_t size) {
  if (check_acl_ && acl_mngr_.check(addr, size, 0x2) == false) {
    throw BadAddress();
  }
  const uint8_t* d = (const uint8_t*)data;
  for (uint64_t i = 0; i < size; i++) {
    *this->get(addr + i) = d[i];
  }
}

这个函数实现了在 RAM 对象中写入一段数据,具体步骤如下:

  1. 访问控制检查
    如果成员变量 check_acl_ 为真,则通过 acl_mngr_.check(addr, size, 0x2) 检查写入权限(通常 0x2 表示写权限);如果检查失败,则抛出 BadAddress 异常。

  2. 数据写入
    将传入的 data 指针转换为字节指针,然后通过循环遍历每个字节:

    • 调用 this->get(addr + i) 获取 RAM 中对应地址的指针,
    • 将数据 d[i] 写入到该地址。

RAM::get


uint8_t *RAM::get(uint64_t address) const {
  if (capacity_ != 0 && address >= capacity_) {
    throw OutOfRange();
  }
  uint32_t page_size   = 1 << page_bits_;
  uint32_t page_offset = address & (page_size - 1);
  uint64_t page_index  = address >> page_bits_;

  uint8_t* page;
  if (last_page_ && last_page_index_ == page_index) {
    page = last_page_;
  } else {
    auto it = pages_.find(page_index);
    if (it != pages_.end()) {
      page = it->second;
    } else {
      uint8_t *ptr = new uint8_t[page_size];
      // set uninitialized data to "baadf00d"
      for (uint32_t i = 0; i < page_size; ++i) {
        ptr[i] = (0xbaadf00d >> ((i & 0x3) * 8)) & 0xff;
      }
      pages_.emplace(page_index, ptr);
      page = ptr;
    }
    last_page_ = page;
    last_page_index_ = page_index;
  }

  return page + page_offset;
}

这个函数实现了对模拟 RAM 内存的分页访问,并返回给定地址所在的字节指针。具体来说,其主要功能和步骤如下:

  1. 容量检查

    • 如果 RAM 的总容量(capacity_)非零,并且传入的 address 超出容量范围,则抛出 OutOfRange 异常,防止越界访问。
  2. 页计算

    • 通过 page_bits_ 计算页大小:
      uint32_t page_size = 1 << page_bits_;
      
    • 计算当前地址在页内的偏移:
      uint32_t page_offset = address & (page_size - 1);
      
    • 计算页索引(即地址除以页大小):
      uint64_t page_index = address >> page_bits_;
      
  3. 页缓存机制

    • 首先检查上一次访问的页(last_page_)是否正好就是所请求的页(即 last_page_index_ == page_index),如果是,则直接返回该页中偏移处的指针。这种缓存可以加速局部连续访问的情况。
  4. 页查找与分配

    • 如果没有命中缓存,则在 pages_ 容器(通常是一个映射)中查找该页索引:

      • 如果找到,直接使用已有页。
      • 如果没有找到,则动态分配一个大小为 page_size 的新页,并使用预定义的模式(“baadf00d”重复填充)初始化内存,目的是标记未初始化的状态。然后将新页插入 pages_ 容器中。
    • 更新缓存变量 last_page_last_page_index_ 为当前页,以便下次快速访问。

  5. 返回地址指针

    • 最后,返回页指针加上页内偏移,即:
      return page + page_offset;
      

总之,该函数通过分页机制管理大地址空间,在内存未分配时按需分配,并利用局部缓存加速连续访问,确保安全且高效地返回 RAM 中对应地址的指针。

loadHexImage


void RAM::loadHexImage(const char* filename) {
  auto hti = [&](char c)->uint32_t {
    if (c >= 'A' && c <= 'F')
      return c - 'A' + 10;
    if (c >= 'a' && c <= 'f')
      return c - 'a' + 10;
    return c - '0';
  };

  auto hToI = [&](const char *c, uint32_t size)->uint32_t {
    uint32_t value = 0;
    for (uint32_t i = 0; i < size; i++) {
      value += hti(c[i]) << ((size - i - 1) * 4);
    }
    return value;
  };

  std::ifstream ifs(filename);
  if (!ifs) {
    std::cout << "error: " << filename << " not found" << std::endl;
    std::abort();
  }

  ifs.seekg(0, ifs.end);
  size_t size = ifs.tellg();
  std::vector<char> content(size);
  ifs.seekg(0, ifs.beg);
  ifs.read(content.data(), size);

  uint32_t offset = 0;
  char *line = content.data();

  this->clear();

  while (true) {
    if (line[0] == ':') {
      uint32_t byteCount = hToI(line + 1, 2);
      uint32_t nextAddr = hToI(line + 3, 4) + offset;
      uint32_t key = hToI(line + 7, 2);
      switch (key) {
      case 0:
        for (uint32_t i = 0; i < byteCount; i++) {
          uint32_t addr  = nextAddr + i;
          uint32_t value = hToI(line + 9 + i * 2, 2);
          *this->get(addr) = value;
        }
        break;
      case 2:
        offset = hToI(line + 9, 4) << 4;
        break;
      case 4:
        offset = hToI(line + 9, 4) << 16;
        break;
      default:
        break;
      }
    }
    while (*line != '\n' && size != 0) {
      ++line;
      --size;
    }
    if (size <= 1)
      break;
    ++line;
    --size;
  }
}

这个函数用于从一个 HEX 格式文件中加载数据到 RAM 中,其主要功能和步骤如下:

  1. 辅助函数定义

    • 定义了一个 lambda 函数 hti,用于将单个十六进制字符转换为对应的数值(例如 ‘A’ 转换为 10)。
    • 定义了另一个 lambda 函数 hToI,用于将一个由若干个十六进制字符构成的字符串转换为一个无符号整数。它通过对每个字符调用 hti 并按位移累加实现转换。
  2. 读取文件内容

    • 通过 ifstream 打开指定的文件,如果文件不存在,则输出错误信息并中止程序。
    • 获取文件大小,并读取整个文件内容到一个 std::vector 中。
  3. 初始化

    • 将一个局部变量 offset 初始化为 0,用于处理扩展地址记录(HEX 文件中记录地址高位部分)。
    • 调用 this->clear() 清空 RAM 中的内容,为加载新数据做准备。
  4. 解析 HEX 文件记录

    • 使用一个 while 循环逐行解析文件内容。每行以冒号 : 开头,表示一个 HEX 记录。
    • 解析记录时:
      • Byte Count:从冒号后 2 个字符解析出本记录的数据字节数。
      • Address:从第 3 个位置开始解析 4 个字符,将其加上当前的 offset 得到实际地址(nextAddr)。
      • Record Type (key):从第 7 个位置解析出 2 个字符,判断记录类型:
        • 类型 0(数据记录):遍历每个数据字节,从记录中解析出数据(每个数据占 2 个字符),并写入到对应地址(通过 this->get(addr) 获取对应 RAM 单元,再赋值)。
        • 类型 2:代表扩展段地址记录,将从记录中解析出的 4 个字符左移 4 位后,更新 offset
        • 类型 4:代表扩展线性地址记录,将解析出的 4 个字符左移 16 位更新 offset
        • 其他类型不做处理。
  5. 逐行遍历文件

    • 解析完一行记录后,函数通过一个内部循环将指针 line 移到下一行(以换行符为界),同时减少剩余字符数 size
    • 当文件内容读取完毕(size 小于等于 1)时,退出循环。

总之,该函数实现了一个基于 Intel HEX 文件格式的 RAM 镜像加载器,通过解析每条记录(数据记录和扩展地址记录),将文件中描述的数据写入到 RAM 对象的指定地址中,从而为后续仿真或系统启动提供内存镜像数据。

7. 输出调试信息(非调试模式下不执行)

#ifndef NDEBUG
std::cout << "[VXDRV] START: program=" << program << std::endl;
#endif

如果程序不是在调试模式下编译的,这行代码不会被执行。在调试模式下,输出程序开始运行的信息。

8. 运行模拟

// run simulation
processor.run();

调用 processorrun 方法,开始运行模拟程序。

void Processor::run() {
  impl_->run();
}

Impl类 run函数


  void run() {

  #ifndef NDEBUG
    std::cout << std::dec << timestamp << ": [sim] run()" << std::endl;
  #endif

    // start execution
    running_ = true;
    device_->reset = 0;

    // wait on device to go busy
    while (!device_->busy) {
      this->tick();
    }

    // wait on device to go idle
    while (device_->busy) {
      this->tick();
    }

    // reset device
    this->reset();

    this->cout_flush();
  }

这段代码是一个模拟器(或仿真平台)中运行程序的主流程,主要作用是启动并等待设备执行结束,然后复位设备并刷新输出。

  1. 调试打印
    在非发布(NDEBUG 未定义)模式下,输出当前时间戳和“[sim] run()”消息,方便调试和跟踪仿真运行状态。

  2. 启动执行

    • 将内部标志 running_ 设为 true,表示仿真开始运行。
    • 将设备的 reset 信号置为 0,解除复位,使设备开始执行。
  3. 等待设备进入忙碌状态

    • 第一个 while 循环不断调用 this->tick(),直到 device_->busy 为 true。也就是说,在设备开始执行之前,仿真系统不断推进时钟周期(tick)。
  4. 等待设备执行完成

    • 第二个 while 循环继续调用 this->tick(),直到 device_->busy 为 false,即等待设备从忙碌状态变为闲置,表明执行结束。
  5. 复位设备并刷新输出

    • 调用 this->reset() 对设备进行复位操作,准备下一轮仿真。
    • 调用 this->cout_flush() 刷新并输出所有累积的控制台输出(例如 console I/O 缓冲区)。

总的来说,这个 run() 函数启动仿真后,依次推进仿真周期直到设备从空闲变为忙碌,再等待到设备完成执行,最后复位设备并刷新输出信息。这样保证了仿真程序能完整地启动、执行并结束,适合用于单次仿真运行。

这里的tick函数如下:


  void tick() {

    device_->clk = 0;
    this->eval();

    this->mem_bus_eval(0);
    this->dcr_bus_eval(0);

    device_->clk = 1;
    this->eval();

    this->mem_bus_eval(1);
    this->dcr_bus_eval(1);

    dram_sim_.tick();

    if (!dram_queue_.empty()) {
      auto mem_req = dram_queue_.front();
      if (dram_sim_.send_request(mem_req->write, mem_req->addr, 0, [](void* arg) {
        auto orig_req = reinterpret_cast<mem_req_t*>(arg);
        if (orig_req->ready) {
          delete orig_req;
        } else {
          orig_req->ready = true;
        }
      }, mem_req)) {
        dram_queue_.pop();
      }
    }

  #ifndef NDEBUG
    fflush(stdout);
  #endif
  }

dram_queue_更新
processor.cpp 文件里,dram_queue_ 是一个 std::queue 类型的队列,主要用于存储待发送到 DRAM 的内存请求。下面说明 dram_queue_ 的入队逻辑和出队逻辑。
mem_req_t定义,

  typedef struct {    
    bool ready;  
    std::array<uint8_t, MEM_BLOCK_SIZE> block;
    uint64_t addr;
    uint64_t tag;
    bool write;
  } mem_req_t;

push queue逻辑

dram_queue_ 的入队操作在内存读写请求处理时进行,以下是具体的入队逻辑:

写请求处理(AXI_BUS 模式)
if (device_->m_axi_wvalid[0]) {
  // ...
  auto mem_req = new mem_req_t();
  mem_req->tag   = device_->m_axi_awid[0];
  mem_req->addr  = device_->m_axi_awaddr[0];
  mem_req->write = true;
  mem_req->ready = false;
  pending_mem_reqs_.emplace_back(mem_req);

  // send dram request
  dram_queue_.push(mem_req);
}

device_->m_axi_wvalid[0]true 时,表明有写请求。此时会创建一个 mem_req_t 类型的内存请求对象,对其成员变量进行赋值,将该对象添加到 pending_mem_reqs_ 列表中,同时将其入队到 dram_queue_ 里。

读请求处理(AXI_BUS 模式)
else {
  // process reads
  auto mem_req = new mem_req_t();
  mem_req->tag  = device_->m_axi_arid[0];
  mem_req->addr = device_->m_axi_araddr[0];
  ram_->read(mem_req->block.data(), device_->m_axi_araddr[0], MEM_BLOCK_SIZE);
  mem_req->write = false;
  mem_req->ready = false;
  pending_mem_reqs_.emplace_back(mem_req);

  // send dram request
  dram_queue_.push(mem_req);
}

device_->m_axi_arvalid[0]true 时,表明有读请求。同样会创建一个 mem_req_t 类型的内存请求对象,对其成员变量进行赋值,从 ram_ 中读取数据到 mem_req->block 中,将该对象添加到 pending_mem_reqs_ 列表中,最后将其入队到 dram_queue_ 里。

写请求处理(非 AXI_BUS 模式)
if (device_->mem_req_valid && running_ && device_->mem_req_rw) {
  // ...
  auto mem_req = new mem_req_t();
  mem_req->tag   = device_->mem_req_tag;
  mem_req->addr  = byte_addr;
  mem_req->write = true;
  mem_req->ready = true;

  // send dram request
  dram_queue_.push(mem_req);
}

device_->mem_req_validtruedevice_->mem_req_rwtrue 时,表明有写请求。创建一个 mem_req_t 类型的内存请求对象,对其成员变量进行赋值,然后将该对象入队到 dram_queue_ 里。

读请求处理(非 AXI_BUS 模式)
else {
  // process reads
  auto mem_req = new mem_req_t();
  mem_req->tag   = device_->mem_req_tag;
  mem_req->addr  = byte_addr;
  mem_req->write = false;
  mem_req->ready = false;
  ram_->read(mem_req->block.data(), byte_addr, MEM_BLOCK_SIZE);
  pending_mem_reqs_.emplace_back(mem_req);

  // send dram request
  dram_queue_.push(mem_req);
}

device_->mem_req_validtruedevice_->mem_req_rwfalse 时,表明有读请求。创建一个 mem_req_t 类型的内存请求对象,对其成员变量进行赋值,从 ram_ 中读取数据到 mem_req->block 中,将该对象添加到 pending_mem_reqs_ 列表中,最后将其入队到 dram_queue_ 里。

pop queue逻辑

dram_queue_ 的出队操作在 tick 函数中进行,以下是具体的出队逻辑:

void tick() {
  // ...
  if (!dram_queue_.empty()) {
    auto mem_req = dram_queue_.front();
    if (dram_sim_.send_request(mem_req->write, mem_req->addr, 0, [](void* arg) {
      auto orig_req = reinterpret_cast<mem_req_t*>(arg);
      if (orig_req->ready) {
        delete orig_req;
      } else {
        orig_req->ready = true;
      }
    }, mem_req)) {
      dram_queue_.pop();
    }
  }
  // ...
}

tick 函数里,若 dram_queue_ 不为空,会取出队列的队首元素 mem_req。接着调用 dram_sim_.send_request 函数尝试将该请求发送到 DRAM 模拟器。若发送成功,dram_sim_.send_request 函数返回 true,此时将队首元素出队。

  • 入队逻辑:在处理内存读写请求时,会创建 mem_req_t 类型的内存请求对象,将其添加到 pending_mem_reqs_ 列表中,并将其入队到 dram_queue_ 里。
  • 出队逻辑:在 tick 函数中,若 dram_queue_ 不为空,会尝试将队首元素发送到 DRAM 模拟器,若发送成功则将其出队。

9. 读取退出码

// read exitcode from @MPM.1
ram.read(&exitcode, (IO_MPM_ADDR + 8), 4);

从内存地址 IO_MPM_ADDR + 8 处读取 4 个字节的数据,并将其存储到 exitcode 变量中。

IO_MPM_ADDR
// skybox/hw/rtl/VX_config.vh

`ifndef IO_MPM_ADDR
`define IO_MPM_ADDR     (`IO_COUT_ADDR + `IO_COUT_SIZE)
`endif

`ifndef IO_COUT_ADDR
`define IO_COUT_ADDR    `IO_BASE_ADDR
`endif
`define IO_COUT_SIZE    64

`ifndef IO_BASE_ADDR
`define IO_BASE_ADDR    32'h00000040
`endif

要计算 IO_MPM_ADDR 的值,需要根据代码中定义的宏逐步推导。下面是推导过程:

  1. 确定 IO_BASE_ADDR 的值

    `ifndef IO_BASE_ADDR
    `define IO_BASE_ADDR    32'h00000040
    `endif
    

    由此可知,IO_BASE_ADDR 的值为 32'h00000040

  2. 确定 IO_COUT_ADDR 的值

    `ifndef IO_COUT_ADDR
    `define IO_COUT_ADDR    `IO_BASE_ADDR
    `endif
    

    因为 IO_COUT_ADDR 被定义为 IO_BASE_ADDR,所以 IO_COUT_ADDR 的值为 32'h00000040

  3. 确定 IO_COUT_SIZE 的值

    `define IO_COUT_SIZE    64
    

    可知 IO_COUT_SIZE 的值为 64

  4. 计算 IO_MPM_ADDR 的值

    `ifndef IO_MPM_ADDR
    `define IO_MPM_ADDR     (`IO_COUT_ADDR + `IO_COUT_SIZE)
    `endif
    

    IO_COUT_ADDR32'h00000040)和 IO_COUT_SIZE64)代入计算:

    IO_MPM_ADDR = IO_COUT_ADDR + IO_COUT_SIZE
                = 32'h00000040 + 64
                = 32'h00000040 + 32'h00000040
                = 32'h00000080
    

IO_MPM_ADDR 的值为 32'h00000080

10. 返回退出码

return exitcode;

返回 exitcode 作为程序的退出码。

综上所述,main 函数的主要执行流程是解析命令行参数、创建内存模块和处理器、设置寄存器、加载程序、运行模拟并最终返回退出码。

你可能感兴趣的:(skybox,_core,skybox,rtlsim)