这两天做到PA2.3了,这里的代码涉及到AM和NEMU比较乱,想着写个随笔缕一下思路。
讲义中首先讲到

框架代码为映射定义了一个结构体类型IOMap(在nemu/include/device/map.h中定义), 包括名字, 映射的起始地址和结束地址, 映射的目标空间, 以及一个回调函数. 然后在nemu/src/device/io/map.c实现了映射的管理, 包括I/O空间的分配及其映射, 还有映射的访问接口.

所以我们可以认为每个IO都占用内存空间的一部分,结构体中内容都在注释中注明

typedef struct {
  const char *name;
  // we treat ioaddr_t as paddr_t here
  paddr_t low;     //start address
  paddr_t high;     // finish address
  void *space;      // reflect target space
  io_callback_t callback;   // call function
} IOMap;

用我自己的话概括讲义(可能理解的不太对),简而言之就是说:

讲义中提到两种映射关系,内存映射和端口映射。

  • 内存映射是单独开辟一段内存空间,然后当CPU访问这段物理内存时候,被重定向到需要访问的IO设备,所以访问IO设备本质上就是一次普通的访存。
  • 端口映射是采用专门的IO指令对设备进行访问,把设备地址称作端口号。专门的IO指令中会给出端口号,类似于CPU指令中的Rd。

了解了这个对应关系,那么地址映射本质上就是类似与一个数学函数罢了,每个单独的地址对应每个设备地址。拿时钟举例:

  1. 我们首先要初始化,也就是分配IO空间(需要把时钟的寄存器存到内存中总得开辟空间吧)初始化代码中分配了IO_SPACE_MAX的内存空间,然后将其指针保存在io_space和p_space变量中。所有后续的设备都将存到这个区间内。
void init_map() {
  io_space = malloc(IO_SPACE_MAX);
  assert(io_space);
  p_space = io_space;
}
  1. 分配RTC寄存器空间,别忘记每个设备都需要有个寄存器来存储设备状态,后续有设备指令读取的时候本质上就是读取这个设备寄存器的。
void init_timer() {
  rtc_port_base = (uint32_t *)new_space(8);
#ifdef CONFIG_HAS_PORT_IO
  add_pio_map("rtc", CONFIG_RTC_PORT, rtc_port_base, 8, rtc_io_handler);
#else
  add_mmio_map("rtc", CONFIG_RTC_MMIO, rtc_port_base, 8, rtc_io_handler);
#endif
  IFNDEF(CONFIG_TARGET_AM, add_alarm_handle(timer_intr));
}

这里就根据端口映射还是内存映射进行不同的映射关系的添加了。
先看内存映射的相关代码,代码判断添加映射的地址是否正确,是否超出正常的内存区域,同时是否覆盖了其他的设备地址,最后记录映射关系map[nr_map]。这个map本质上就是一个存储设备映射关系的数组。

void add_mmio_map(const char *name, paddr_t addr, void *space, uint32_t len, io_callback_t callback) {
  assert(nr_map < NR_MAP);
  paddr_t left = addr, right = addr + len - 1;
  //报告接口区域重叠错误
  if (in_pmem(left) || in_pmem(right)) {
    report_mmio_overlap(name, left, right, "pmem", PMEM_LEFT, PMEM_RIGHT);
  }
  for (int i = 0; i < nr_map; i++) {
    if (left <= maps[i].high && right >= maps[i].low) {
      report_mmio_overlap(name, left, right, maps[i].name, maps[i].low, maps[i].high);
    }
  }

  maps[nr_map] = (IOMap){ .name = name, .low = addr, .high = addr + len - 1,
    .space = space, .callback = callback };
  Log("Add mmio map '%s' at [" FMT_PADDR ", " FMT_PADDR "]",
      maps[nr_map].name, maps[nr_map].low, maps[nr_map].high);

  nr_map ++;

端口映射也是同理,这里不再赘述。这里还有一个值得注意的点就是回调函数,add_mmio_map的最后一个参数就是回调函数,其中rtc_io_handler具体代码如下,函数实现了对设备寄存器(这里是时钟寄存器)的存操作,把当前时间存到时钟寄存器中,具体为什么用两个32位而不用一个64位看讲义就清楚啦。

//0号寄存器存储时间低32位,1号寄存器存储高32位时间
static void rtc_io_handler(uint32_t offset, int len, bool is_write) {
  assert(offset == 0 || offset == 4);
  if (!is_write && offset == 4) {
    uint64_t us = get_time();
    rtc_port_base[0] = (uint32_t)us;
    rtc_port_base[1] = us >> 32;
  }
}

当后续需要访问接口的时候采用po_read / mmio_read即可访问IO空间,本质上就是一次普通的内存读取。这里的invoke_callback来判断当前访问设备的对应状态数据是否为空,不为空的话就把数据赋值到寄存器中。然后进行一个内存读取就OK了。

static void invoke_callback(io_callback_t c, paddr_t offset, int len, bool is_write) {
  if (c != NULL) { c(offset, len, is_write); }
}

word_t map_read(paddr_t addr, int len, IOMap *map) {
  assert(len >= 1 && len <= 8);
  check_bound(map, addr);
  paddr_t offset = addr - map->low;
  invoke_callback(map->callback, offset, len, false); // prepare data to read
  word_t ret = host_read(map->space + offset, len);
  return ret;
}

word_t mmio_read(paddr_t addr, int len) {
  return map_read(addr, len, fetch_mmio_map(addr));
}

到这里NEMU相关代码结束,剩下的就是AM框架的代码结构了。讲义中提示可以看看$ISA.h 得到如下代码:

static inline uint8_t  inb(uintptr_t addr) { return *(volatile uint8_t  *)addr; }
static inline uint16_t inw(uintptr_t addr) { return *(volatile uint16_t *)addr; }
static inline uint32_t inl(uintptr_t addr) { return *(volatile uint32_t *)addr; }

static inline void outb(uintptr_t addr, uint8_t  data) { *(volatile uint8_t  *)addr = data; }
static inline void outw(uintptr_t addr, uint16_t data) { *(volatile uint16_t *)addr = data; }
static inline void outl(uintptr_t addr, uint32_t data) { *(volatile uint32_t *)addr = data; }

这里白话一点:in就是读数据 out就是写数据。通过in out来把设备地址的寄存器读写。例如AM需要读取当前时钟,那么我们就需要把之前NEMU实现的时钟寄存器数据读出来,注意这里都跟NEMU一致,采用32位实现

void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) {
  uint32_t low_time = inl(RTC_ADDR);                     //设备寄存器为64位 将64位功能拆分成两个32位设备寄存器
  uint32_t high_time = inl(RTC_ADDR+4);
  uptime->us = (uint64_t)low_time + (((uint64_t) high_time) << 32);
}

这两天又看了下代码,感觉之前写的还是不太清楚。所以想着在补充下。

static uint64_t uptime() { return io_read(AM_TIMER_UPTIME).us; }

可以看到上面代码是函数请求时间,用一次io_read 接口,从而得到时间。但是在AM中,这个接口对应这

__am_timer_uptim

这个函数,这个函数上面已经有提到,就是对内存RTC_ADDR进行读取。而这个内存地址存储的就是当前时间。存时间的操作在NEMU中已经实现了。
所以总流程就是:io_read->ioe_read->__am_time_update->inl(RTC_ADDR)->paddr_read->mmio_read->map_read。接下来的内容都在map_read函数中,详情可以看此函数。map_read时就开始调用回调函数rtc_io_handler了,在回调函数中会获取时间然后存到rtc_port_base中,这个rtc_port_base是可以被访问的空间,也就是map->space,然后用host_read读取map->space的内容得到时间。
AM通过端口读取时间,而NEMU将时间写到对应的地址供AM读取。