引言

我们已经学习了总线协议, 知道CPU如何与设备通信

 

本次课内容: 以 “一生一芯”流片用的SoC为例, 通过RTFSC理解大家实现的CPU如何与设备交互

  • ROM
  • UART
  • flash
  • SDRAM
  • 中断系统
  • DMA/ChipLink/处理器集成

最简单的SoC

回顾: 自制freestanding运行时环境

一个面向RISC-V程序的, 只支持两条指令的简单freestanding运行时环境

  • 程序从地址0开始执行
  • addi指令
  • ebreak指令
    • 寄存器a0=0时, 输出寄存器a1低8位的字符
    • 寄存器a0=1时, 结束运行

 

在真实的SoC中, 上电后没有运行时环境帮助我们了

  • 需要真实硬件实现这些功能

存放程序的存储器

RAM是易失存储器(volatile memory), 上电时无有效数据

  • 如果上电时CPU无法执行指定的程序, 整个系统的行为是未定义的
  • 需要一种非易失存储器(non-volatile memory), 内容可在断电时保持

 

一个简单的方案: ROM

import java.nio.file.{Files, Paths}
val binpath = "/home/ysyx/am-kernels/kernels/hello/build/hello-riscv64-npc.bin"
val wordbits = 32
val bin = Files.readAllBytes(Paths.get(binpath))
val upSize = 1 << log2Ceil(bin.size)
val bingp = (bin ++ Seq.fill(upSize - bin.size)(0.toByte)).grouped(wordbits / 8)
def byteShift(x: Byte, y: BigInt) = (x.toInt & 0xff) | (y << 8)
val wordArray = bingp.map(_.foldRight(BigInt(0))(byteShift)).toSeq
val rom = VecInit(wordArray.map(x => x.U(wordbits.W)))
io.data := RegNext(rom(io.addr))

行为模型 - 综合成触发器, 基于触发器的行为搭建出ROM的行为

ASIC原语 - 使用晶体管直接搭建ROM, 有更优的面积和功耗

AXI-Lite接口的ROM

class AXILiteROM(binpath: String, wordbits: Int = 32) extends Module {
  val io = IO(new AXILite)

  import java.nio.file.{Files, Paths}
  val bin = Files.readAllBytes(Paths.get(binpath))
  val upSize = 1 << log2Ceil(bin.size)
  val bingp = (bin ++ Seq.fill(upSize - bin.size)(0.toByte)).grouped(wordbits / 8)
  def byteShift(x: Byte, y: BigInt) = (x.toInt & 0xff) | (y << 8)
  val wordArray = bingp.map(_.foldRight(BigInt(0))(byteShift)).toSeq
  val rom = VecInit(wordArray.map(x => x.U(wordbits.W)))

  val s_idle :: s_wait_rready :: Nil = Enum(2)
  val state = RegInit(s_idle)
  state := MuxLookup(state, s_idle, List(
    s_idle        -> Mux(io.ar.fire, s_wait_rready, s_idle),
    s_wait_rready -> Mux(io.r.fire, s_idle, s_wait_rready)
  ))

  def getAddr(x: UInt) = x(x.getWidth-1, log2Ceil(wordbits / 8))
  val romAddr = Mux(io.ar.fire, getAddr(io.ar.bits.addr), 0.U)
  io.r.bits.data := RegEnable(rom(romAddr)), io.ar.fire)
  io.r.bits.resp := 0.U
  io.ar.ready := (state === s_idle)
  io.r.valid  := (state === s_wait_rready)
}

输出设备 - UART16550

ysyxSoC项目提供了一些流片用的真实IP

我们可以来RTFSC

  • ysyxSoC/ysyx/perip/uart16550/rtl/uart_apb.v
    • 外壳是APB协议 - RTFM
    • 里面是wishbone协议
  • ysyxSoC/ysyx/perip/uart16550/rtl/uart_regs.v
    • 设备寄存器的读写译码
    • 部分寄存器地址相同, 通过读写方式和其他状态区分
    `define UART_REG_RB  `UART_ADDR_WIDTH'd0  // receiver buffer
    `define UART_REG_TR  `UART_ADDR_WIDTH'd0  // transmitter
    `define UART_REG_DL1 `UART_ADDR_WIDTH'd0  // Divisor latch bytes (1-2)

UART控制器的初始化

只需要设置正确的除数即可(我们不使用中断功能)

`define UART_REG_DL1 `UART_ADDR_WIDTH'd0  // Divisor latch bytes (1-2)
`define UART_REG_DL2 `UART_ADDR_WIDTH'd1
void uart_init() {
  // RTFM
  outb(UART_BASE + 0x3, 0x83); // LCR <= 8N1, DLA
  // DL = clk / (16 * baud rate) = 25MHz / (16 * 115200) = 13.56 = 14
  outb(UART_BASE + 0x1, 0);  // DLA.MSB
  outb(UART_BASE + 0x0, 14); // DLA.LSB
  outb(UART_BASE + 0x3, 0x3);  // LCR <= 8N1
}

 

串口终端也需要进行相应的配置

  • 例如minicom中的115200 8N1

UART控制器处理发送请求

void putch(char ch) {
  while (...) ; // wait until TX FIFO not full
  outb(UART_BASE + 0x0, ch);
}

写入TX队列:

  • 控制器进行写译码, 生成tf_push信号
  • tf_push信号控制数据写入TX队列

 

TX队列发送字符:

  • ysyxSoC/ysyx/perip/uart16550/rtl/uart_transmitter.v中的状态机
    • enable信号的控制下工作
    • enable信号的频率由除数决定, 最后控制字符发送的速率(波特率)

新问题1 - 多个slave

有ROM和UART, 怎么知道应该访问哪一个?

  • 回顾 - 内存映射I/O, 通过不同的内存地址来指示
  • crossbar(Xbar)模块根据请求的地址将请求转发给不同的下游模块
    • Xbar发现目标地址无设备时, resp信号返回decerr错误(译码错)
+-----+       +---------+       +------+       +-----+
| IFU | ----> |         |       |      | ----> | UART|   [0x1000_0000, 0x1000_0fff)
+-----+       |         |       |      |       +-----+
              | Arbiter | ----> | Xbar |
+-----+       |         |       |      |       +-----+
| LSU | ----> |         |       |      | ----> | ROM |   [0x3000_0000, 0x3fff_ffff)
+-----+       +---------+       +------+       +-----+
  • Arbiter和Xbar可以合并成多进多出的Xbar(也称Interconnect, 总线桥等)
  • 根据连接关系, IFU也可以访问UART
    • UART当作是读操作(可能会改变设备状态), CPU以为取到了指令
    • 所以如果裸机程序跑飞了, 后果可能难以想象 😂

新问题2 - 识别设备寄存器的访问范围

`define UART_REG_RB `UART_ADDR_WIDTH'd0  // receiver buffer
`define UART_REG_IE `UART_ADDR_WIDTH'd1  // Interrupt enable
`define UART_REG_II `UART_ADDR_WIDTH'd2  // Interrupt identification
`define UART_REG_LC `UART_ADDR_WIDTH'd3  // Line Control
araddr  --->
arvalid --->
<--- arready

如何通过AXI-Lite总线指定读出地址为0的设备寄存器?

  • araddr0x1000_0000? 读多长?

 

“读多长”在memory中没那么重要, 可以读4字节/8字节给CPU选

但在设备中, 读1字节和读4字节的行为不一样

  • 回顾设备模型: 访问设备寄存器会改变设备的状态!

总线需要细心地处理这个问题

AXI的窄传输

完整的AXI总线通过arsize/awsize信号处理上述问题

  • 实际访问的数据位宽(即axsize) < AXI总线的数据位宽, 称窄传输
    • 后者在硬件设计时静态决定
    • 前者在根据软件的访存指令位宽动态决定
  • arsize编码了真正的访问长度
    • 设备可以根据arsize得知软件需要访问的范围

 

AXI-Lite无法解决上述问题

  • AR通道中没有足够的信号编码 “读多长”的信息
  • 设备只能认为实际访问的数据位宽 = AXI-Lite总线的数据位宽
    • 所以并非所有设备都适合通过AXI-Lite接入
      • 若单次AXI-Lite访问会覆盖多个设备寄存器, 则设备状态会出错

更实用的SoC

ROM: 从制造时编程到现场可编程

val rom = VecInit(wordArray.map(x => x.U(wordbits.W)))
io.data := RegNext(rom(io.addr))

我们刚才的ROM是硬连线写死在芯片内的

  • 产商根据我们提供的版图(由RTL经过后端物理设计得到)制造芯片
  • 芯片制造后, ROM中存储的内容无法修改

 

随着材料技术的发展, 人们发明了现场可编程的flash存储器

  • 现场可编程 = 擦除 + 重新写入
  • 改动flash中存放的程序, 代价变得可接受

flash颗粒

型号W25Q128JV, RTFM

  • 内部就是一个设备控制器!
    • 24根地址线, 16MB存储单元
    • 分成256个64KB块, 每个块有16个4KB扇区, 每个扇区有16个256B页
    • 页是最小的擦除和写入单位
    • 字节是最小的读出单位, 支持随机读取
  • 外部通过SPI总线协议与其通信

SPI总线

  • Serial Peripheral Interface
  • 一种串行总线协议, 采用主从设备架构
    • SCK - master发出的时钟信号
    • SS - slave select, master发出的slave选择信号
    • MOSI - master output slave input, master向slave通信的数据线
    • MISO - master input slave output, slave向master通信的数据线

  • 本质上是串/并之间的转换, 通过移位寄存器实现
    • 还支持设置时钟极性(clock polarity)和时钟相位(clock phase)
      • 适应不同种类的slave
    • ysyxSoC/ysyx/perip/spi/rtl/spi_shift.v

从flash颗粒中读出数据

根据手册给flash颗粒发送正确的指令序列

  • 8位指令03h表示读数据, 后面加24位的存储单元地址, 通过MOSI传输
  • 之后返回该存储单元的数据, 通过MISO传输
    • 若SCK持续, 则依次读出后续存储单元的内容
  • 根据FM设置SPI的时钟极性和相位
The code and address bits are latched on the rising edge of the CLK pin. After the
address is received, the data byte of the addressed memory location will be shifted
out on the DO pin at the falling edge of CLK with most significant bit (MSB) first. 

从flash颗粒中读出数据(2)

#define SPI_REG_TX0 0x00
#define SPI_REG_TX1 0x04
#define SPI_REG_RX0 0x00
#define SPI_REG_CTL 0x10
#define SPI_CTL_RD_ENDIAN   0x00004000
#define SPI_CTL_ASS         0x00002000
#define SPI_CTL_TX_NEGEDGE  0x00000400
#define SPI_CTL_GO          0x00000100

uint32_t flash_read(uint32_t addr) {
  outl(SPI_BASE + SPI_REG_TX0, 0);
  uint32_t cmd = 0x03000000 | (addr & 0xffffff);
  outl(SPI_BASE + SPI_REG_TX1, cmd);
  //               select slave #1 | sck = clk / 2 | 64 bit transfer |   auto-SS
  uint32_t ctl_word = (0x01 << 24) | (0x00 << 16)  |   (0x40 << 0)   | SPI_CTL_ASS |
  // read starting from MSB | MOSI changes on falling edge | start!
        SPI_CTL_RD_ENDIAN   |      SPI_CTL_TX_NEGEDGE      | SPI_CTL_GO;
  outl(SPI_BASE + SPI_REG_CTL, ctl_word);
  while (inl(SPI_BASE + SPI_REG_CTL) & SPI_CTL_GO); // wait until finish
  return inl(SPI_BASE + SPI_REG_RX0);  // return least 32 bit of slave output
}

问题: 上面的函数编译成指令, 烧写在flash颗粒里面, 如何读出?

  • 递归了 😂

XIP模式

想法: 硬件直接将CPU的取指请求翻译成flash颗粒的请求, 读出的内容作为CPU指令直接返回

  • XIP = eXecute In Place, 就地执行
    • 无需预先将CPU指令从flash颗粒中读到内存

 

解决方案: 用状态机实现flash_read()函数的功能!

  • ysyxSoC/ysyx/perip/spi/rtl/spi.v
    • 将16MB flash存储空间映射到[0x3000_0000, 0x3fff_ffff)
    • 若往该空间发送读请求, 则状态机进入XIP状态
      • 通过状态机实现: SPI控制器中寄存器的配置, 命令的发送, 等待完成(通过中断信号判断), 读出flash数据
    • XIP不支持写请求: flash中擦除和写入的最小单位是一个flash页

SDRAM

为了支持store指令的执行, 需要有一个支持随机写操作的存储器

  • SDRAM - Synchronous Dynamic Random Access Memory

SDRAM命令

ysyxSoC/ysyx/perip/sdram/rtl/sdram_axi_core.v

SDRAM控制器

  • SDRAM控制器通过状态机向SDRAM颗粒发送命令序列
  • 读: Idle -> Active(cmd:0011) -> Read(cmd:0101) -> Read Wait -> Idle
    • Active命令通过地址线给出行地址(row address)
    • Read命令通过地址线给出列地址(column address)
  • 写: Idle -> Active(cmd:0011) -> Write0(cmd:0100) -> Write1(cmd:0111) -> Idle
  • 计数器refresh_timer_q减到0时发送充电命令(cmd:0010)

 

将AXI读写请求翻译成上述命令序列

  • 地址映射: 25位, 最大支持32MB的SDRAM颗粒
25 24 23         10 9     2 
+----+-------------+-------+-+
|Bank|      Row    |  col  |0|
+----+-------------+-------+-+

从flash颗粒中加载程序

  • flash颗粒可读可执行, 一次返回32位数据/指令, 不能直接写
  • SDRAM颗粒可读可写可执行
    • 我们希望在SDRAM上运行程序

 

  1. 将程序链接到SDRAM所在的地址空间
    • 在四期中是[0xfc00_0000, 0xffff_ffff), 后续可能会修改, 以流片SoC的报告为准
    • 栈指针sp分配到上述空间

从flash颗粒中加载程序(2)

  1. 编写一个加载器, 功能是将程序搬运到SDRAM, 并跳转到SDRAM运行
    • 可以是binary加载器, 直接拷贝内容
    • 也可以是ELF加载器, 不必从flash中拷贝.bss节, 节省时间
      • memset()可以直接写入SDRAM, 无需读flash
    • 加载器的栈指针sp也需要分配到SDRAM空间
    • 注意目前flash只能读出4字节
      • 后续可以修改 😂
  2. 将待加载程序作为加载器数据段的一部分, 链接到flash XIP的地址空间, 并烧写到flash中
    • 参考Nanos-lite如何包含ramdisk
  3. 将CPU复位PC设置为flash XIP的起始地址

中断系统

RISC-V的3种中断

在M模式下均由真实的硬件触发

M模式 S模式
时钟中断 CLINT的计时器比较 mip.STIP
软件中断 CLINT的寄存器 mip.SSIP
外部中断 PLIC的中断线 PLIC的中断线或mip.SEIP

系统支持

  • Linux/RT-thread多线程调度 - 需要时钟中断
    • 不过即使不实现时钟中断, 也能启动Linux/RT-Thread
  • 处理器间的通信 - 需要软件中断
    • 运行单核OS无需实现
  • “高级”外设(如网卡等) - 需要外部中断
    • SoC提供的外设都比较简单, 无需实现外部中断也能运行

CLINT

RTFM: RISC-V的CLINT(Core Local INTerrupt controller)

 

一个非常简单的设备, 只有3个寄存器

  • mtime - 可读可写, 以恒定速率增加
  • mtimecmp - 可读可写, mtime >= mtimecmp时产生M模式时钟中断
  • msip - 可读可写, 写入1则触发M模式软件中断

 

很容易基于总线实现, 我们将它作为一个总线的作业留给大家

PLIC

一个外部中断的选择器(RTFM)

  • 当多个设备同时发起外部中断, PLIC负责从中选择一个, 并通过M模式外部中断通知CPU

CPU的中断响应过程

以M模式时钟中断为例, 其响应需要满足

  1. mstatus.MIE为1, 即CPU的全局中断使能位为1
  2. mie.MTIE为1, 即M模式时钟中断的使能位为1
  3. mip.MTIP为1, 即M模式时钟中断的等待位为1
    • 该位硬件上与mtime >= mtimecmp的比较结果相连
      • 因此设置一个较大的mtimecmp可以清除M模式时钟中断

 

  • 响应过程与异常处理类似
  • 其他中断同理
  • 还可以通过medelegmideleg将相应异常/中断委托给S模式处理
    • 具体可RTFM了解 “委托机制”

其他话题

DMA

Direct Memory Access

  • 一个memcpy()加速器, 帮助一些 “高级”的外设进行数据移动, 无需CPU参与
    • 例如网卡, SD卡
  • 硬件上的实现和大家在CPU中实现AXI master非常类似
    • 都是通过状态机控制总线信号进行读写操作
      • 其实flash也可以实现成支持DMA

实现AXI的跨片通信

  • 64位数据线 + 32位地址线的AXI总线共200+个信号
  • 直接传输需要耗费大量引脚
    • BGA高级封装方案 = $$$$$

 

ChipLink = 用数量较少的引脚将一个请求打包传送到对面后解包

  • 先将AXI转TileLink, 然后对TileLink请求分割成若干ChipLink请求包后跨片传输
    • 32位宽度的ChipLink = 70个信号
    • 8位宽度的ChipLink = 22个信号

简易CPU集成方案

为了提高集成度, 我们把多个同学的CPU集成到一个芯片中

  • 多CPU系统其实很复杂
  • 但多个同学的CPU没有必要同时工作 😂
    • 基于这点可大幅简化集成方案 - 通过开关静态选择CPU即可
    • 大家可以把自己的学号编码到一些ID CSR中, 方便识别
                   DIP
                   | |
+------+       +---------+
| CPU0 | ----> |         |
+------+       |         |
+------+       |         |
| CPU1 | ----> |====+    |
+------+       |    |    |
               |    +====| -----> SoC
+------+       |         |
| CPU2 | ----> |  switch |
+------+       |         |
+------+       |         |
| CPU3 | ----> |         |
+------+       +---------+

总结

计算机系统的最后一块拼图

SoC代码里面有很多宝藏

  • 通过RTFM, 我们了解了各种控制器如何与器件通信
  • 通过RTFSC, 我们看到了各种IP核的具体实现
    • 感谢这些不太复杂的开源IP
  • 总线将各种设备控制器连接起来, 为CPU提供访问设备的事务通道
  • CPU通过MMIO指令访问设备
  • 上面就是软件了…

 

现在你已经完全理解仙剑奇侠传如何在真实的SoC上运行!