Python硬件描述语言miniHDL技术解析

51 阅读7分钟

miniHDL:基于Python的硬件描述语言DSL

硬件描述语言(如Verilog或VHDL)总是令人困惑。尽管它们本质上是另一种编程语言,但描述的是逻辑门连接方式而非动态计算。这可能只是个人技能问题。为了学习这门技术,作者决定从零开始构建miniHDL——一个基于Python的领域特定语言。

该HDL旨在生成确定性模拟器中的电路,支持编写比实际更简化的理想化电路。例如,时钟由非门连接而成,不关心时钟域交叉,无法表示未知X或Z状态。

miniHDL架构

miniHDL目前仅212行Python代码,定义两个类:Bit和Bits。Bits本质是Bit列表。Bit是基本原语,对应物理电子电路中单个位置的值,记录其值如何由其他Bit计算得出。

class Bit():
    signals = []
    def __init__(self, how):
        self.how = how
        self.signals.append(self)
        self.uid = len(self.signals)
    def __and__(a, b):
        return Bit(('&', a, b))
    def __xor__(a, b):
        return Bit(('^', a, b))
    def connect(self, other):
        self.how = other.uid

程序最后是"编译器",接收一组Bit并生成描述门电路连接关系的文本文件,由模拟器处理。模拟器维护每个门的状态数组,根据其他门状态更新。

def export_gates(f, signals):
    for i, signal in enumerate(signals):
        if signal.how[0] == 'VAR':
            f.write(f"out{i} = out{signal.how[1]}\n")
        elif signal.how[0] == '~':
            f.write(f"out{i} = ~out{signal.how[1].uid}\n")
        # 其他操作类似处理

结合这些,可以编写对应所需电路的Python代码。例如,左侧是简单纹波进位全加器的实现。当HDL编译器添加两个四位数字时,会生成以下电路(假设参数通过v[0..3], v[4..7], v[8]传入)。

当需要引入计算图中的循环依赖时,需显式构造Bit并在定义后设置其值。例如D触发器实现:

def dff_half(inp, clock):
    q = Bit()
    out = mux(clock, iftrue=inp, iffalse=q)
    q.connect(out)
    return q

def dff(inp, clock):
    q = dff_half(inp, ~clock)
    q = dff_half(q, clock)
    return q

D触发器是数字电路中的基本构建块,可存储一位信息。没有触发器,数字电路将是纯线性的——数据通过逻辑门从输入流向输出并快速稳定。触发器通过引入内存实现时序逻辑,使计数器、状态机以及最终使CPU成为可能的寄存器和程序计数器得以实现。

时钟生成通过创建一长串Bit实现,每个连接到下一个,末端放置NOT门:

def clock(latency):
    inv = ~out
    delays = [Bit() for _ in range(latency)]
    for a,b in zip(delays,delays[1:] + [delays[0]]):
        b.connect(a)
    return delays[-1]

miniHDL的最后组件是Bits类,将所有功能传递给内部的Bit对象列表:

class Bits:
    def __init__(self, bits):
        self.bits = bits
    # 其他方法实现...

从基础门到完整CPU

通过基本组件构建:计算用逻辑门、内存用触发器、时钟同步。CPU本质是重复执行简单循环的状态机:取指令、解码操作、执行操作、存储结果。每个步骤都可用基本组件构建。

设计简单完整的32位RISC CPU,需要关键组件:存储数据的寄存器文件、执行操作的ALU(算术逻辑单元)、跟踪执行指令的程序计数器、存储程序的指令内存。通过正确控制逻辑连接这些组件,得到可工作的处理器。

CPU架构概述

32位RISC风格处理器,所有寄存器和数据路径32位宽。16个通用寄存器R0-R15,每个指令可访问任何寄存器。指令格式使用固定32位指令,三种格式:寄存器-寄存器操作(如Rd = Rs OP Rt)、寄存器-立即数操作(如Rd = Rs OP常量)、分支操作(寄存器非零时跳转)。ALU支持操作包括ADD、SUB、AND、OR、XOR、NOT和位移。

指令编码使用第一个字节控制:位0-3指定ALU操作,位4选择寄存器-寄存器或寄存器-立即数模式,位5指示是否为分支指令。剩余24位编码操作数。

构建CPU组件

从n路多路复用器开始,查看n位条件并选择列表中对应元素:

def muxn(cond, choices):
    if len(choices) == 1: return choices[0]
    return muxn(Bits(cond[1:]),
                [mux(cond[0], b, a) for a,b in \
                zip(choices[::2], choices[1::2])])

程序ROM只是大型查找表,以当前程序计数器为参数,选择要返回的ROM值。通过将输入程序ROM位切片为32个单独位,放置大型多路复用器,给定当前程序计数器,检索该地址对应的32位指令。

寄存器文件函数保存16个寄存器,支持同时读取两个,写入一个:

def regfile(clock, read1, read2, write3, data, enable_write):
    registers = []
    for idx in range(16):
        ok = const(1)
        for bit in range(int(math.log(16, 2))):
            if (idx>>bit)&1:
                ok &= write3[bit]
            else:
                ok &= ~write3[bit]
        update = clock & enable_write & ok
        registers.append(dff(data, update, 32))
    out1 = muxn(read1, registers)
    out2 = muxn(read2, registers)
    return out1, out2, registers

最后组件ALU计算CPU要执行的操作,本质是大型n路多路复用器,在不同计算值间切换:加法、减法、布尔操作和位移。ALU输入总是一个寄存器,然后是另一个寄存器或常量,由指令操作码的第五位决定。

最后将ALU输出连接回寄存器数据输入(如果是寄存器写入指令),查看ALU输出决定下一条指令(如果是跳转指令)。

整个CPU约170行代码,足以完成有趣任务,如计算斐波那契数列或基本算术(乘、除、平方根计算)。明显缺少读取或写入更大内存的指令(但这不难实现,只需定义更大内存块)、允许任何间接操作的指令(如间接分支)或直接执行更复杂算术操作的指令(如乘法)。

高效电路仿真

simulator.py提供的模拟器(本页顶部显示)高效模拟任何给定电路中的门。朴素方法意味着逐个运行每个门,执行相应操作后保存结果。

虽然对小电路很有效,但大电路会变慢。因此实现了缓存机制,每次循环迭代只更新需要重新计算的门(因为至少一个输入已更改)。如果门的所有输入保持不变,输出也将相同。

不重新计算每次循环迭代中的每个门,而是跟踪上次哪些输入已更改,然后只重新计算前一轮输入更改的门。高效实现相当容易:只需维护存储下次需要更新的门的堆,每次通过电路时推入和弹出。在合理大电路上,平均提供100x-500x性能提升。

未来工作:VLSI布局/门放置

当前项目仅支持设计电路,输出只是说明哪些门应连接到其他门的文本文件。这足以满足下一篇文章的用途(暂时保密)。

但未来希望扩展miniHDL,包含实际将门物理布局到适当电路板上的代码。希望(可能在未来第3部分,但无承诺!)能自动将miniHDL程序转换为Gerber文件,然后制造自己的离散晶体管CPU。这可能需时,但似乎是值得尝试的有趣事情。

即将到来:第2部分

不想破坏惊喜,但在第2部分中将有这段代码的非常激动人心的用途。敬请期待。鼓励尝试用适合风格的任何HDL设计电路——自构建以来一直很有乐趣。