这一篇在干嘛?

在 FPGA 里造 CPU:指令集设计、Nios 软核、自定义指令,以及 T-RISC/LISA/PDSP 三个微处理器案例。 原书代码为 VHDL,本篇所有代码已改写为 Verilog

  • 第9章 微处理器设计
  • 9.1 微处理器设计概述
  • 9.2 微处理器的发展史
  • 9.3 指令集设计
    1. 隐式寻址
    1. 直接寻址
    1. 寄存器寻址
    1. 存储器寻址
    1. PDSP 专用寻址模式
    1. 栈机:零地址 CPU
    1. 累加器机:单地址 CPU
    1. 二地址 CPU
    1. 三地址 CPU
    1. 零地址、单地址、二地址和三地址 CPU 的比较
  • 例9.3 RISC寄存器文件
    1. ALU 指令
    1. 数据移动指令
    1. 程序流指令
  • 9.4 软件工具
  • 9.5 FPGA 微处理器内核
    1. Xilinx PowerPC
    1. Altera 的 ARM
  • 例9.4 Thumb指令编码
    1. Xilinx 与 Altera 器件上的 ARM Cortex-A9
    1. 8位处理器:Xilinx PicoBlaze
    1. 16 位处理器:Altera Nios
    1. 32位处理器:Xilinx MicroBlaze
  • 例 9.7 MicroBlaze 缓存配置
  • 9.6 案例研究
  • 例9.8 栈机
    1. LISA 18 位指令字 RISC 处理器
    1. LISA 可编程数字信号处理器
    1. LISA 真向量处理器
    1. LISA 处理器设计的比较
    1. 自定义逻辑模块的创建和集成
    1. 软件实现
    1. Altera 自定义逻辑模块的实例化
    1. 新的 CI 设计
    1. Nios 位反向性能结果
  • 9.7 练习
  • 9.2 按练习 9.1 所给分类方式对以下各项进行分类:

自测一下

第9章 微处理器设计

9.1 微处理器设计概述

提到”微处理器”(μP),很多人第一反应是拥有数亿晶体管的 Intel Itanium 这类高端 CPU。那么用 FPGA 能设计出这样的微处理器吗?坦率地说:单片 FPGA 做不出这种级别的高端处理器,但在低功耗、面向特定应用的微处理器方面,FPGA 大有可为。这里的关键在于一个取舍——微处理器用”硬布线方案的性能”换取了”门效率”:同样的算法,用软件跑(或让一个通用状态机去执行指令)速度往往较慢,但占用的硬件资源更少。所以用 FPGA 搭出来的微处理器,气质上更接近”微控制器”,而不是功能完整的 Pentium 或 VLIW 结构的 PDSP。

那为什么还要在 FPGA 里做微处理器呢?本章后续会讨论一个 DWT(离散小波变换)实现控制器的典型应用。有人会问:这种控制用 FSM(有限状态机)不就够了吗?没错——从根本上说,FPGA 微处理器就可以理解为”一个由程序存储器内容定义的 FSM”:FSM 的行为不再是硬布线写死的,而是由存储器中的指令决定,如图 9-1 所示。这样一来,改算法只需改程序,不必重改硬件。早期的 Xilinx PicoBlaze 处理器就叫 KCPSM(Ken Chapman 可编程状态机),名字里就带着这层”状态机血统”。


图 9-1 Xilinx KCPSM,也称作 PicoBlaze

完整的微处理器设计通常包含多个步骤:体系结构探索、指令集设计、开发工具等,本章将逐一展开。建议读者配合一些计算机体系结构教材(参考文献 [336-342])一起学习。在正式动手之前,先回顾一下微处理器是怎么一路发展过来的。

9.2 微处理器的发展史

微处理器通常分为三大类:多功能(CISC)处理器、精简指令集处理器(RISC)和可编程数字信号处理器(PDSP)。了解这三类的历史,有助于理解它们各自的指令集为什么长成那个样子。

9.2.1 多功能微处理器简史

1968 年,一个典型的多功能小型计算机是 16 位体系结构,单片电路板上要用大约 200 个 MSI 芯片(每个约 100 个晶体管)。当时的工程师提出了一个问题 [343]:能不能只用 150、80 甚至 25 个芯片造出一个完整的 CPU?

与此同时,Robert Noyce 和 Gordon Moore 离开 Fairchild 创办了一家以存储芯片为主的公司,先叫 NM 电子,后来更名为 Intel。1969 年,日本计算器厂商 Busicom 请 Intel 为其可编程计算器设计一套芯片。由于优秀 IC 设计人员稀缺,Intel 没有足够人力做 12 种不同的专用芯片,工程师 Ted Hoff 于是提议:做一种更通用的 4 芯片方案,指令从存储芯片中读取——带存储器的可编程状态机(PSM)就此诞生,也就是今天所说的微处理器。9 个月后,在 F. Faggin 协助下,Hoff 团队发布了 Intel 4004:一款用于 Busicom 计算器、可做 BCD 算术的 4 位 CPU。它采用 12 位程序地址、8 位指令,执行一条指令需要 5 个时钟周期。一个最小工作系统只需两块芯片:一个 4004 CPU 加一个程序 ROM。1 MHz 时钟下,每个数字以 80 ns 的速度完成多位 BCD 加法 [344]。

这里不妨算一笔账:一条指令 5 个周期、时钟 1 MHz,即每秒最多执行 20 万条指令。以今天的眼光看慢得可怜,但它把”一台计算机”塞进了两块芯片——这正是微处理器的意义所在。

Hoff 梦想把 4004 的应用从计算器扩展到数字仪表、出租车计价器、汽油泵、电梯控制、医疗设备、自动贩卖机等领域,于是说服 Intel 从 Busicom 手中买下芯片的知识产权。1971 年 5 月,Intel 以价格让步换来了向计算器以外市场销售 4004 的权利。另一个关键配角是 Intel 工程师 Dov Frohman-Bentchkovsky 发明的 EPROM:有了它,开发者可以随时编程、擦除重写,不必再依赖 IC 工厂耗时制造一次性 ROM,4004 开发系统的市场由此打开。

Intel 的客户需要更强的 CPU,于是有了能处理 4 位 BCD 算术之上运算的 8 位 CPU。8008 支持标准 RAM 和 ROM 器件,不再像 4004 那样需要定制存储器;1974 年的 8080 约 4500 个晶体管,修复了 8008 的若干缺陷;1978 年第一个 16 位微处理器 8086 问世;1982 年的 80286 性能约为 8086 的 6 倍;1985 年的 80386 是第一个支持多任务的微处理器,80387 加入算术协处理器加速浮点运算;1989 年的 80486 在包含协处理器的同时引入了指令缓存和流水线;1993 年奔腾系列诞生,拥有两条执行流水线(超标量体系结构);1997 年的奔腾 II 加入 MMX 指令,最多可并行执行 4 个类似 MAC 的向量操作;奔腾 3、奔腾 4 则引入超线程和 SSE 等加速音视频处理的技术;2006 年的 Itanium 已达双核、5.92 亿晶体管、9 MB 独立 L3 缓存。整个系列见表 9-1。

表 9-1 Intel 系列微处理器 [345](IA = 指令集体系结构)

名称发布年份MHzIA处理技术晶体管数量
400419710.108410μ2300
800819720.2810μ3500
80801974284500
808619785~101629K
8028619826~12.5161.5μ134K
80386198516~3332275K
80486198925~50320.8μ1.2M
奔腾199360~66320.8μ3.1M
奔腾II1997200~300320.25μ7.5M
奔腾31999650~1400320.25μ9.5M
奔腾420001300~3800320.18μ42M
Xeon20031400~3600640.09μ178M
Itanium 220041000~1600640.13μ592M

从这张表能直观看到”摩尔定律”的轨迹:时钟从 0.1 MHz 涨到数 GHz,晶体管从 2300 个涨到近 6 亿。从图 9-2 的各半导体公司收入对比也可以发现一个有趣的事实:Intel 仅凭微处理器这一个产品线,多年来稳居业界榜首;同样以微处理器为主业的 TI、Motorola/Freescale、AMD 收入低了很多;以存储器为主的三星、东芝虽然常年领先,也难以企及 Intel 的业绩。


图 9-2 顶级半导体公司的收入

9.2.2 RISC 微处理器简史

Intel 体系结构也常被称为复杂指令集计算机(CISC)。从最早的 CPU 开始,后续设计一直背负”兼容”包袱——要能运行老程序。摩尔定律允许 CISC 不断叠加新特性(数字协处理器、缓存、MMX、SSE 及支持它们的指令),但随着数据和程序字节宽度增加,兼容性是以牺牲性能为代价的。Intel 处理器指令繁多、寻址模式多样,手册动辄 700 页以上。

1980 年前后,加州大学伯克利分校的 Patterson 教授、IBM(后来的 PowerPC)以及斯坦福大学的 Hennessy 教授(后来研究 MIPS——无互锁流水线级微处理器)等人分析后得出结论:从性能角度看,CISC 存在诸多问题。伯克利的新一代处理器被命名为 RISC-1 和 RISC-2,因为它们最重要的特征就是指令和寻址模式数量有限,故称精简指令集计算机(RISC)。早期 RISC 与 CISC 的典型差异如下:

  • CISC 有丰富的指令集,RISC 通常支持少于 100 条指令。
  • RISC 指令字长固定(通常 32 位),CISC 字长可变——Intel 指令长度在 1 到 15 字节之间。
  • CISC 支持丰富的寻址模式,典型 RISC 只支持直接寻址和基于寄存器的寻址。
  • CISC 的 ALU 操作数可以来自指令字、寄存器或存储器;RISC 没有直接的存储器操作数,只允许在寄存器与存储器之间加载/存储,因此 RISC 又称”加载/存储体系结构”。
  • CISC 通过栈在子程序间传递参数和数据;RISC 有大量寄存器,用寄存器完成这种链接。

值得注意的是,到 20 世纪 90 年代初,两种体系结构都偏离了”纯理论形态”:如今的 CISC 也大量利用寄存器和深流水线;而 RISC(如 MIPS、PowerPC、SUN Sparc、DEC Alpha)已有几百条指令、有些还需多重循环,早已名不副实。

9.2.3 PDSP 简史

1980 年,Intel 发布了 2930——一种面向控制系统的模拟信号处理器,片内含 ADC 和 DAC,以类似 DSP 的方式实现算法,如图 9-3 所示。


图 9-3 2920 功能模块原理图 [346]

2920 具有 40×25 位的暂存存储器、192 字的程序 EPROM、移位器和 ALU,乘法靠一系列移位加减完成。

例 9.1:用 2920 计算乘法。 先用 CSD 编码表示常数 ,得到 (其中 表示 −1),然后执行下列指令(原书汇编指令,非 HDL,照录如下):

ADD Y, X, L01;
SUB Y, X, R03;
ADD Y, X, R07;
SUB Y, X, R10;

其中最后的操作数 Rxx(Lxx)表示在加/减之前,把第二个操作数右(左)移 xx 位。逐条展开这个例子:把 拆成 ,于是

即左移 1 位加 (得 ),再减去右移 3 位的 ,加上右移 7 位的 ,减去右移 10 位的 ——四次移位加减就完成了一次与常数 的乘法,完全不需要硬件乘法器。

一些常用宏函数在 2920 中的开销见表 9-2。

表 9-2 可用在 2920 中的部分宏模块

功能指令
常数相乘1~5
变量相乘10~26
三角波发生器6~10
阈值检测器2~4
正弦波发生器8~12
单一实极点2~6

可以看到变量相乘要 10~26 条指令——没有硬件乘法器的代价一目了然。虽然还不能把 2920 称为 PDSP,但它具备了 PDSP 的许多重要特征,并发展成 20 世纪 80 年代初的第一代 PDSP(TI 的 TMS320C10、NEC 的 μPD7720),其标志是哈佛体系结构(程序和数据分存于独立存储器)和占用大量芯片面积的硬布线乘法器,可在一个时钟周期内完成单次乘法。第二代 PDSP(约 1985 年,TMS320C25、MC56000、DSP16)实现单周期 MAC 并引入零开销循环。含 μP 特性的第三代(1988 年后,TMS320C30、96002、DSP32C)支持 IEEE 单精度浮点的单周期 32 位浮点乘法。第四代(约 1992 年,TMS320C40、C80)包含多核 MAC。1997 年后的最新一代(TMS320C60x、飞利浦 Trimedia、Motorola Starcore)是长指令字(VLIW)计算机。如今的 PDSP 还有 SIMD(ADSP-2126x SHARC)、超标量(Renesas SH77xxx)、矩阵数学引擎(Intrinsity、FastMath)或它们的混合——PDSP 体系结构已变得高度多样化。

从图 9-4(a) 可见,过去 20 年 PDSP 表现出色,2010 年已可达每秒 3 万亿条指令 [347];从图 9-4(b) 的市场份额看,TI 长期占据大头(Agere 是从前的 Lucent/AT&T,Freescale 是从前的 Motorola)。最近一些 PDSP 内核已可做 FPGA 设计,如 Cast 公司(www.cast.com)的 TI TMS32025 和 Motorola/Freescale 的 56000 内核。不过 PDSP 中的许多 I/O 单元(计时器、DMA、UART 等)通常不在 FPGA 内核中实现。


图 9-4(a) PDSP 处理器收入


图 9-4(b) PDSP 市场份额

9.3 指令集设计

指令集描述了微处理器可执行的行为集合。设计者的第一个问题是:“我的应用需要哪些算法操作?“例如 DSP 应用,重点往往是对快速加法和乘法的支持——用一串小移位加法来算乘法对繁重的 DSP 处理来说并不是好选择。除 ALU 操作外,还需要数据移动指令和程序流指令(如 branch 或 goto)。

指令集设计还依赖于底层体系结构,脱离硬件组件很难完整定义指令集。这项工作很复杂,最好分解成多个步骤,依次回答以下问题:

  1. 微处理器支持什么样的寻址模式?
  2. 基础数据流体系结构是什么,即一条指令包含几个操作数?
  3. 在哪里可以找到这些操作数(寄存器、存储器还是端口)?
  4. 可以支持什么类型的运算?
  5. 在哪里可以找到下一条指令?

9.3.1 寻址模式

寻址模式回答的是”如何定位运算的操作数”。CISC 可能支持十几种模式,而面向性能的设计(RISC、PDSP)会把模式限制在最常用的几种。下面逐一介绍。

1. 隐式寻址

隐式寻址中,操作数的位置不由指令显式给出,而是约定俗成,如图 9-5 所示。例如不带操作数的 ADD 用的是栈机——栈机所有算术运算都作用于栈顶前两个单元;又如 TMS320 PDSP 中的 ZAC 指令用于清空累加器。表 9-3 列出了一些典型例子。

表 9-3 不同微处理器的隐式寻址模式

指令说明μP
ZAT清空累加器和 T 寄存器TMS320C50
APAC将 P 寄存器的内容加到累加器并替换累加器的值TMS320C50
RET将 ra 寄存器的值加载到 PC 中Nios II
BRET将 b 状态复制到状态寄存器并将 ba 寄存器的值加载到 PC 中Nios II


图 9-5 隐式寻址、直接寻址和寄存器寻址

2. 直接寻址

直接寻址中,操作数(如常数)就包含在指令里。问题是指令字通常比完整数据字短几位,塞不下全长常数。常用解决办法有四种:

(a) 符号扩展。程序中的大部分常数(增量、循环计数)都很小,用不到全长;扩展时复制 MSB 而不是补零,这样 −1 的扩展才是正确的(见下面的 MPY-5 示例)。

(b) 分两次加载。用两条指令分别加载常数的低位和高位部分,再拼接成完整字长(见 LPH DAT0)。

(c) 双长度指令。用第二个字扩展默认长度,即可装下全精度常数(见 RPT 1111h)。

(d) 筒形移位分配。将常数按所需形式移位后对齐(见 ADD 11h, 2)。

表 9-4 列出了不同微处理器上的直接寻址示例。

表 9-4 不同微处理器的直接寻址模式

指令说明μP
CNTR=10;把循环计数设置为 10ADSP
MPY-5将 13 位常数符号扩展到 16 位,与 TREG0 寄存器相乘,结果存入 P 寄存器TMS320C50
LPH DAT0把存储器地址 DAT0 中的数据加载到 32 位乘积寄存器的上半部分TMS320C50
RPT 1111h重复下一条指令 次。这是双字指令,常数为 16 位TMS320C50
ADD 11h,2将 11h 移位 2 位后加到累加器中TMS320C50

为避免两次访问存储器,可以把方法 (a) 与 (b) 结合:只要保证高位加载发生在低位指令符号扩展之后即可。

3. 寄存器寻址

操作数从 CPU 内部寄存器取得,不做外部存储访问,如图 9-5 所示。表 9-5 给出了一些示例。

表 9-5 不同微处理器的寄存器寻址模式

指令说明μP
MR=MX0*MY0(RND)将 MX0 与 MY0 寄存器中的数相乘(带舍入)并存入 MR 寄存器ADSP
SUB sA,sB将 sA 减去 sB 并存回 sAPicoBlaze
XOR r6,r7,r8计算 r7 与 r8 的 XOR,结果存入 r6Nios II
LAR AR3,#05h用值 5 加载辅助寄存器 AR3TMS320C50
OR %i1,%i2对 i1、i2 做 OR,结果替换 i1Nios
SWAP %g2交换 32 位寄存器 g2 中的 16 位半字并放回 g2Nios

寄存器访问比内存访问更快、功耗更低,因此 RISC 计算机大量使用寄存器寻址:所有算术操作基本只在 CPU 寄存器内完成,对存储器的访问仅通过单独的加载/存储指令。

4. 存储器寻址

对存储器的直接、间接或混合访问是最常用的模式。直接存储器寻址中,指令字的一部分给出要访问的地址,如图 9-6 及 FETCH 示例所示。与直接常数寻址同样的问题再次出现:指令字里能放的地址位数不足以表达完整地址。解决办法是借助辅助寄存器:若把辅助寄存器加到直接地址上,称为基址寻址(如 LDBU 示例);若辅助寄存器只提供缺失的高位 MSB,称为分页寻址(如 AND 示例,访问页外数据需先更新页指针);由于基址寄存器代表完整地址,也可以完全不给出直接地址、只用寄存器访问,这就是间接寻址(如 PM(I6,M6))。


图 9-6 存储器寻址:直接、基址、页方式和间接寻址

表 9-6 列出了四种典型存储器寻址示例。

表 9-6 不同微处理器的存储器寻址模式

指令说明μP
FETCH sX,ss将临时 RAM 位置 ss 读取到寄存器 sX 中PicoBlaze
LDBU r6,100(r5)计算 100 与 r5 的和,并从这一地址把数据加载到 r6Nios II
AND DAT16设 9 位数据页寄存器指向第 4 页,则从内存位置 访问数据字并在累加器中做 ANDTMS320C50
PM(I6,M6)=AR以 I6 为地址把 AR 存入程序存储器;访问后按修正寄存器 M6 的值更新 I6ADSP

间接寻址只需要少量变址寄存器,可显著缩短指令字,因此是 PDSP 中最常用的方式;而 RISC 更偏爱基址寻址——用”基址+偏移”可以方便地访问阵列元素(如上面的 LDBU)。

5. PDSP 专用寻址模式

PDSP 之所以处理 DSP 算法比标准 GPP/RISC 更高效,关键在于三种专用寻址模式:

  • 自动增/减量寻址
  • 循环寻址
  • 位倒序寻址

前两种服务于卷积和相关运算,位倒序寻址用于 FFT。

(1)自动增/减量寻址。 卷积和相关本质上是重复的乘累加(MAC)运算,即两个向量的内积(参见式 (3-2))。每次 MAC 之后,必须更新数据/系数指针指向下一对操作数。PDSP 在每次存储器访问后自动执行指针增/减量——地址更新不占 CPU,由单独的地址寄存器文件完成,如图 9-7 所示,从而实现单循环 MAC。用 ADSP 汇编编写的 FIR滤波(原书汇编,照录):

CNTR = 256;
MR = 0, MX0 = DM(I0, M1), MY0(I4, M5)
DO FIR UNTIL CE;
FIR: MR= MR+MX0*MY0(SS), MX0=DM(I0, M1), MY0=PM(I4, M5)

初始化循环计数器 CNTR 以及来自数据存储器 DM 的 x 指针和来自程序存储器 PM 的 y 指针后,DO UNTIL 循环在一个时钟周期内完成一条 MAC 并同时加载两个数据,随后用 M1、M5 分别更新指针 I0、I4。注意这里的另一个 PDSP 特性——零开销循环:循环计数器的更新与判断在循环尾部自动完成,不需要额外周期,这与 GPP/RISC 需要专门指令处理循环不同。

(2)循环寻址。 先看 FIR 计算中数据排列的演化。计算第一个输出 时,数据 与系数 按如下方式对齐(同一列相乘后累加):

计算 时需要的数据是:

一种做法是每次 MAC 后移动每个数据字——TMS320 系列为此提供了 MACD 指令(MAC 加数据移动)[349],N 次 MACD 后整个向量 x 移动一个位置。另一种更聪明的观察是:其实系数不用动,把新样本 写到最旧样本 的位置即可:

| x[L] | x[1] | x[2] | … | x[L-2] | x[L-1] |

数据指针从 x[1] 开始照常工作,只有走到缓冲区结尾 x[L-1] 时才需要把指针绕回开头。设缓冲区由 4 个参数描述: = 缓冲器长度, = 当前地址, = 修改值(有符号数), = 缓冲器基址,则所需地址计算为:

这就是循环寻址:每次地址修改后都核对结果是否仍落在有效范围内。图 9-7 给出了 ADSP PDSP 按式 (9-1) 实现的地址生成器。


图 9-7 ADSP 可编程数字信号处理器中地址的生成

(3)位倒序寻址。 第 6 章讲过,基 2 FFT 的输入/输出呈位倒序次序(图 6-14)。用软件计算位倒序变址通常要很多时钟周期,因为每一位都要重排。PDSP 通过专用寻址模式硬件化了这个过程。

例 9.2: ADSP [350] 和 TMS320C50 [349] 都支持位倒序寻址。TMS320 系列的汇编示例(原书汇编,照录):

先把当前辅助寄存器减去 INDX 寄存器的内容,再对地址值执行位倒序以定位操作数;取回的数据字移位 8 位后加到累加器中。

表 9-7 总结了 PDSP 及其支持的寻址模式:所有 PDSP 都支持自动增/减量,大多数也支持循环缓冲器和位倒序寻址。

表 9-7 在卷积中有用的 PDSP 属性(© Springer 出版社文献 [5])

供应商类型累加位超级哈佛结构Modulo 地址位倒序硬件循环MAC 速度(MHz)
PDSP 16 位×16 位整型数乘法器
Analog DeviceADSP-21874052
Analog DeviceADSP-21csp014050
LucentDSP162036120
MotorolaDP561663660
NECμPD770154033
TITMS320F2063280
TITMS320C5132100
TITMS320C54940200
TITMS320C601402 MAC500
TITMS320C80322 MAC50
PDSP 24 位×24 位整型数乘法器
MotorolaDSP560115680
MotorolaDSP5630556100
PDSP 24 位×24 位浮点数乘法器
Analog DeviceSHARC 210618040
MotorolaDSP960029660
TITMS320C314060
TITMS320C404060

9.3.2 数据流:零地址、单地址、二地址和三地址设计

指令的典型汇编编码是”操作码 + 操作数”。一个 ALU 运算通常需要两个操作数,可能还要指明结果的存放位置。对程序员最自然的方式是一条指令含一个操作码加三个操作数,但这需要很长的指令字。算一笔账:假设 FPGA 嵌入式存储器有 1K 字,不考虑操作码的寻址方式至少需要 30 位;现代 CPU 直接寻址 4GB 需要 32 位地址,三操作数直接寻址的指令字至少要 96 位。所以,限制操作数数量可以缩短指令字、节约资源——零地址的栈机在这方面做到极致;另一种方式是用寄存器文件代替直接存储器访问,RISC 就是只允许加载/存储单个存储器操作数:对于 8 个寄存器的 CPU,只需 9 位就能规定三个(寄存器)操作数,代价是需要额外的指令在存储器和寄存器文件之间搬数据。

下面依次讨论零到三操作数设计。作为统一的对照,四种机器都要计算同一个表达式:

1. 栈机:零地址 CPU

零地址计算机怎么工作?回顾 9.3.1 节:操作数可以隐式给出。与 TI PDSP 把所有乘积固定放入乘积寄存器 P 类似,栈机的所有二操作数算术指令都作用于栈顶的两个元素 [351]。栈是后入先出(LIFO)结构:PUSH 放入的元素在 POP 时最先出来。式 (9-2) 的栈机计算过程见表 9-8,左侧是指令,右侧是各步执行后栈的内容(栈顶在左)。

表 9-8 栈运算指令

指令栈顶234
PUSH 55
PUSH aA5
SUB5 - a
PUSH bB5 - a
PUSH cCb5 - a
MULc × b5 - a
ADDc × b + 5 - a
POP d

所有算术运算(ADD、SUB、MUL)都用隐式的栈顶和次栈顶,这就是”零地址”;但存储器操作 PUSH 和 POP 仍需要一个操作数。

由于操作数先于运算出现,栈机代码称为后缀表示法(逆波兰表示法)。式 (9-2) 的中缀与后缀写法对照如下:

一些读者可能记得,HP41C 袖珍计算器用的正是后缀表示法,它也使用 4 个值的栈。图 9-8(a) 给出了这种体系结构。

2. 累加器机:单地址 CPU

给 CPU 加一个累加器,让它固定充当一个操作数的来源和结果的去处,算术运算形式为:

其中 □ 代表一种 ALU 运算(ADD、MUL、AND 等)。TI TMS320 系列的底层体系结构就是这种类型,如图 9-8(b) 所示。式 (9-2) 在 TMS320C50 [349] 上的汇编代码见表 9-9。

表 9-9 TMS320C50 的汇编程序代码

指令说明
ZAP清空 accu 和 P 寄存器
ADD 5h加 5 到 accu
SUB DAT1从 accu 减 DAT1
LT DAT2在 T 寄存器中加载 DAT2
MPY DAT3将 T 寄存器与 DAT3 相乘,结果存入 P 寄存器
APAC将 P 寄存器内容加到 accu
SACL DAT4将 accu 存储到地址 DAT4


图 9-8(a) 栈 CPU 体系结构


图 9-8(b) 累加器机体系结构

这一示例假定变量 ad 已映射到数据存储器 DAT1DAT4。比较栈机和累加器机可以得到两个结论:

  • 指令字的规模没有减小——栈机同样需要带操作数的 PUSH/POP。
  • 编码表达式的指令条数没有明显减少(累加器机 7 条,栈机 8 条)。

要显著减少指令条数,就得指望二操作数机了。

3. 二地址 CPU

二地址机允许规定两个独立操作数,目的操作数兼作第一个操作数,运算形式为:

其中 □ 代表一种 ALU 运算(SUB、DIV、NAND 等)。Xilinx PicoBlaze [335,352] 和 Altera 的 Nios 处理器 [353] 采用这种数据流,如图 9-9(a) 所示。受二操作数限制,这些机器使用 16 位指令字格式。式 (9-2) 在 PicoBlaze 上的汇编代码见表 9-10。

表 9-10 PicoBlaze 的汇编程序代码

指令说明
LOAD sD, sB;将寄存器 B 存入寄存器 D
MUL sD, sC;将 D 与寄存器 C 相乘
ADD sD, 5;加 5 到 D
SUB sD, sA;从 D 中减 A

注意运算顺序被重排了:先算 (对应 的顺序),以避免产生乘积的中间结果。另外要说明:PicoBlaze 实际没有单独的 MUL 运算,这段代码仅用于说明二操作数的编码法则。PicoBlaze 有 16 个 8 位寄存器;两个操作数(各 4 位)加 8 位常数,正好能把操作码和操作数放进一个 16 位指令字。可以看到,与栈机和累加器机相比,二操作数编码显著减少了运算条数。

4. 三地址 CPU

三地址机最灵活:两个源操作数和目的操作数可以各不相同,运算形式为:

大多数现代 RISC 计算机(PowerPC、MicroBlaze、Nios II)都采用这种编码 [354~356],但操作数通常是寄存器操作数,或至多一个操作数来自数据存储器。数据流如图 9-9(b) 所示。


图 9-9(a) 二地址 CPU 体系结构


图 9-9(b) 三地址机体系结构

三地址机的汇编编程简单直接。式 (9-2) 按 Nios II 指令编码见表 9-11。

表 9-11 式(9-2)中的算术示例用到的指令

指令说明
SUBI r4,r1,5;从 r1 中减 5,结果存入 r4
MUL r5,r2,r3;将 r2 与 r3 相乘,结果存入 r5
ADD r4,r4,r5;将 r4 与 r5 相加,结果存入 r4

这里假定寄存器 r1r4 保存变量 ad 的值。这是四种机器中最短的代码——只有 3 条指令,代价是指令字更长。就硬件实现而言,二地址机与三地址机没有明显差别,因为寄存器文件都需要相同的多路复用器和信号分离器。

5. 四种 CPU 的比较

对本节加以总结:

  • 栈机程序最长,但单条指令最短。
  • 即使栈机也需要带地址的单地址指令访问存储器。
  • 三地址机代码最短,但每条指令所需位数最大。
  • 用寄存器代替直接存储器地址可以缩短指令字。三地址机通常用两个寄存器加一个存储器操作数。
  • 加载/存储机只允许数据在存储器和寄存器之间移动,所有 ALU 运算都通过寄存器文件完成。
  • 大多数设计假设寄存器访问比存储器访问快——这对使用外部存储器的 CBIC 或 FPGA 成立。在 FPGA 中,寄存器文件内访问与嵌入式存储器访问的时间在同一量级,因此也可以选择用嵌入式(三端)存储器实现寄存器文件。

看起来没有”完美答案”,实践中各种寻址方式都在使用。为什么没有一种最佳数据流类型?因为这取决于设计目标。表 9-12 从多个维度做了对比。

表 9-12 0 至 3 操作数 CPU 的不同设计目标的比较

设计目标0 操作数123 操作数
汇编程序容易程度最差最佳
简单 C 编译器最佳最差
代码字数量最差最佳
指令长度最佳最差
直接范围最差最佳
快速操作数存取和解码最佳最差
硬件规模最佳最差

具体展开说:三地址汇编代码比栈机的大量 PUSH/POP 更易读易写,所以”汇编容易程度”三地址最佳;而栈机最适合简单 C 编译器——中缀表达式转后缀很机械,解析简单,但高效管理寄存器文件对编译器是艰巨任务。中间结果可以留在寄存器里,所以操作数越多”代码字数量”越少。指令长度与操作数数量成正比;指令字越短,能嵌入的常数也越短,就需要多次加载或双字指令(参见图 9-6 存储器寻址)。操作数越少,提取和解码越快——栈机总用栈的前两个元素,没有寄存器文件长 MUX/DEMUX 的延迟。硬件规模主要取决于寄存器文件:三操作数机要求最高,栈机最低,而 ALU 和控制单元的规模彼此接近。

总之,每种体系结构都有强项和弱项,必须与设计者的工具、技能、设计目标(规模/速度/功耗)以及开发工具(指令集仿真器、C 编译器)相匹配。

9.3.3 寄存器文件和存储器体系结构

计算机发展早期存储器非常昂贵,冯·诺依曼提出了著名的创新:把数据和程序放进同一存储器,如图 9-10(a) 所示——那个年代程序通常硬布线在 FSM 里,只有数据用 RAM。如今情况反转:存储器便宜了,但典型 RISC 计算机的主存访问远慢于 CPU 寄存器。于是三地址机面临取舍:三个操作数全部来自主存?还是只有部分来自主存?是否所有 ALU 运算都该由 CPU 寄存器执行(加载/存储体系结构的立场)?VAX PDP-11 是这方面的”冠军”,允许多个存储器和多个寄存器运算。

对 FPGA 设计还有一条额外约束:指令字数量通常以千计,若程序和数据混在同一存储器(冯·诺依曼方式),复用数据和程序字会浪费时间,因此 FPGA 微处理器应使用独立的程序存储器和数据存储器——这就是哈佛体系结构,如图 9-10(b) 所示。对 PDSP 来说还可以更进一步:系数和数据来自两个不同的数据存储器 x 和 y,累加结果保存在 CPU 寄存器中(回忆 FIR 滤波器的用法),程序放在第三个存储器——由于 DSP 算法往往很短,一些 PDSP(如 ADSP)用小规模缓存替代第三条总线:第一遍循环后指令已存入缓存,程序存储器可当第二个数据存储器用。这种三总线结构如图 9-10(c) 所示,称为”超级哈佛体系结构”。


图 9-10 存储器体系结构

GPP(如 Intel 奔腾)和 RISC 计算机通常采用存储器分层结构(寄存器 → L1/L2 缓存 → 主存 DRAM → CD-ROM/磁带等外部介质),为 CPU 提供连续数据流的同时使用较便宜的大容量存储器。这种存储系统的设计远比 FPGA 内部能实现的复杂。

从硬件角度看,CPU 可以拆成三个主要部分:

  • 控制路径,即有限状态机
  • ALU
  • 寄存器文件

三部分中寄存器文件的设计不难,但用 LE 实现时成本最高。因此要在”更多寄存器带来的好用性”与”寄存器文件的高实现成本”之间权衡(例如 32 个寄存器)。下面的例子给出典型 RISC 寄存器文件的编码。

例 9.3 RISC 寄存器文件

设计 RISC 寄存器文件时经常实现大量寄存器。为避免额外的清零指令或寄存器移动指令,通常把第一个寄存器永久置零。这看似浪费了一个寄存器,却实实在在简化了汇编编码,见表 9-13。

表 9-13 简化的指令

指令说明
ADD r3,r0,r0;将寄存器 r3 置零
ADD r4,r2,r0;将寄存器 r2 移到寄存器 r4
LDBU r5,100(r0);计算 100 与 r0=0 的和,并从这一地址把数据加载到 r5

请注意,这些伪指令只有在第一个寄存器 r0 恒为零的假设下才成立。

原书 VHDL 改写为 Verilog——下面是一个 W 位宽、N+1 个寄存器的通用寄存器文件(第一个寄存器恒为零):

// W x L bit register file, first register is hardwired to zero.
module reg_file #(
    parameter W = 7,   // 位宽-1(即数据 8 位)
    parameter N = 15   // 寄存器个数-1(即 16 个寄存器)
)(
    input  wire         clk,      // 系统时钟
    input  wire         reset,    // 异步复位
    input  wire         reg_ena,  // 写使能,高有效
    input  wire [W:0]   data,     // 写入数据
    input  wire [3:0]   rd,       // 写地址
    input  wire [3:0]   rs,       // 读地址 1
    input  wire [3:0]   rt,       // 读地址 2
    output wire [W:0]   s,        // 读出数据 1
    output wire [W:0]   t         // 读出数据 2
);
 
    reg [W:0] r [0:N];   // 寄存器堆
    integer k;
 
    // 写端口:同步写,异步清零;寄存器 0 不允许被改写
    always @(posedge clk or posedge reset) begin
        if (reset) begin
            for (k = 0; k <= N; k = k + 1)
                r[k] <= {(W+1){1'b0}};
        end else if (reg_ena && rd > 0) begin
            r[rd] <= data;
        end
    end
 
    // 两个组合读端口(读寄存器 0 时返回 0)
    assign s = (rs > 0) ? r[rs] : {(W+1){1'b0}};
    assign t = (rt > 0) ? r[rt] : {(W+1){1'b0}};
 
endmodule

时钟进程负责把输入 data 写入寄存器堆,注意 rd 为 0 时禁止写入,因为寄存器 0 永远为零;两个连续赋值语句对应原书第二个进程中的两个解码器,为 ALU 读出两个源操作数,读地址为 0 时直接给出 0。该设计用 226 个 LE,不使用嵌入式乘法器和 M9K;由于寄存器到寄存器之间没有路径,无法测量时序性能 Fmax。

对照图 9-11 的仿真结果理解行为:输入 data(配合写地址 rd)被连续写入寄存器堆;输出 s 由 rs 设为寄存器 2,输出 t 由 rt 设为寄存器 3。可以观察到寄存器写使能在 600 ns 到 800 ns 之间无效,因此寄存器 2 没有被值 12 覆盖;而使能在 800 ns 时再次变高,新值 14 写入了寄存器 3——这从 t 信号的跳变可以看到。信号图最后显示的是寄存器堆的内部变量 r。


图 9-11 寄存器文件的仿真结果

图 9-12 显示了寄存器个数从 4~32、位宽为 8/16/24/32 时各种配置的 LE 消耗,可以据此为容量和成本做折中。


图 9-12 不同寄存器文件配置的 LE

Xilinx 的 FPGA 还可以把 LE 用作双端存储器(参见第 1 章图 1-11)。而对 Altera 的 FPGA,唯一的省 LE 途径是把寄存器文件放进嵌入式存储器:可用一个三端存储器或两个嵌入式双端存储器模块——两个存储器写入相同的数据,从各自端口分别读出两个源操作数。Nios 微处理器就利用这一原则大幅降低了 LE 数量:只用较低的第 015(或 031)个寄存器,或者像 Nios 那样给寄存器开”窗口”。窗口可以移动,例如调用子例程时不必在栈或存储器中保存寄存器现场,直接切换到新窗口即可。

不过从时序角度还有一个问题:BlockRAM 是同步存储器,不能在同一个时钟沿上同时完成”取当前地址的数据供解码用”和”按当前信号写入新值”——也就是不能用同一时钟沿既读旧值又写新值。解决办法是:用上升沿提供要读取的操作数地址,再用下降沿存储新值并设置写使能,一拍拆成两个半拍使用。

9.3.4 操作支持

大多数计算机的指令至少属于以下三类之一:算术/逻辑单元(ALU)操作、数据移动和程序控制。这里只需简要记住分类即可。基本数据类型通常是多位整型(8、16、32 位);某些更高级的处理器还使用 32 位或 64 位 IEEE 浮点数据类型(参见 2.2.3 节)。

ALU 指令

微处理器真正”干活”的部分是 ALU(算术逻辑单元),它执行的三类运算是:算术运算、逻辑运算和移位运算。初学者可以这样理解:如果把一条 DSP 算法拆开看,底层几乎全由这三类指令堆叠而成。

先看双操作数指令。最基本的三个是加法(ADD)、减法(SUB)和乘法(MUL),再加上乘-累加(MAC)——MAC 是数字滤波、卷积、FFT 的灵魂操作,一条指令同时完成”乘”和”累加”两步。单操作数指令里至少要支持绝对值(ABS)和取反(NEG)。那除法呢?大多数处理器并不提供单周期除法指令,因为阵列除法器电路规模太大,硬件代价极高。实际做法是用一串”移位—减法—比较”的指令序列来模拟除法,逐步逼近商,如图 2-27 所示。

为什么移位运算如此重要?在 b 位整数运算中,两个 b 位数相乘的结果最大会达到 2b 位——比如两个 16 位数相乘得到 32 位结果。想让结果回到原来的位宽,就必须移位截断。TI 的 TMS320 系列 PDSP 把移位器隐含在运算通路里,也有的处理器把移位作为单独指令。通常除了逻辑移位还支持算术移位(即移位时保留符号位,做符号扩展)、循环移位,浮点格式还要求能检查指数(统计符号位数量)。

表 9-14 列出了不同微处理器的算术和移位运算示例,可以看出各家风格差异很大:

表 9-14 不同微处理器的算术和移位运算

指令说明μP
ADD *,8,AR3"*"表示辅助存储器指针 ARP 指向 8 个地址寄存器中用作存储器访问的那个。这一位置的字在加到累加器之前先左移 8 位。指令执行完毕后,ARP 指针指向下一条指令所用的地址 AR3TMS320C50
MACD Coeff,Y系数和 Y 相乘,结果存储在乘积寄存器 P 中,然后将 P 移动一个位置TMS320C50
NABS r3,r4在 r3 中存储 r4 的负绝对值PowerPC
DIV r3,r2,r1这条指令将 r2 除以 r1 并将商存储在寄存器 r3 中Nios II
SH=SRr OR ASHIFT 5右移 SR 寄存器 5 位并使用符号扩展ADSP

逻辑运算在简单 DSP 算法(滤波、FFT)里用得不多,但在密码学、差错校正等复杂系统里必不可少:AND、OR、NOT 是基础,差错校正算法还会用到 EXOR 和 EQUIV。这里有个省钱技巧:如果指令数量紧张,可以只实现 NAND(或 NOR)这一个运算,其他所有布尔运算都能由它派生出来——这正是练习 1.1 讨论过的通用函数思想。

数据移动指令

数据本身不会自己走到 ALU 面前,必须有一条指令负责”搬运”。由于现代处理器地址空间大、又追求性能,绝大多数计算机采用 RISC 加载/存储体系结构:ALU 运算只允许操作寄存器里的数据,想用存储器里的数,必须先用加载指令取到寄存器——存储器定位不可能成为 ALU 运算的一部分。这与 VAX PDP-11 允许一条指令的所有操作数都直接来自存储器的通用方法形成鲜明对比。

PDSP 走了一条稍微不同的路:它的数据访问绝大多数通过间接寻址完成。原因在于典型的 PDSP(如 ADSP 和 TMS320)有独立的存储器地址生成单元,能自动完成减量/增量和模寻址(环绕地址),如图 9-7 所示。关键优势是:这些地址计算与 CPU 运算并行进行,不额外占用 CPU 时钟周期。滤波器逐点扫过缓冲区时,地址自动加一、扫到队尾自动绕回队头,正好匹配 FIR 循环缓冲的访问模式。

表 9-15 列出了不同微处理器的数据移动指令:

表 9-15 不同微处理器的数据移动指令

指令说明μP
st [%fp],%g1将寄存器 g1 存储在 fp 寄存器中规定的存储器位置Nios
LWZ R5,DMA将 32 位数据从 DMA 规定的存储器位置移动到寄存器 R5PowerPC
MX0=DM(I2,M1)从数据存储器将由地址寄存器 I2 指向的字加载到寄存器 MX0 中,然后 I2 递增 M1ADSP
IN STAT, DA3从外部端口地址 3 读取字并且将数据存储在新位置 STATTMS320

程序流指令

有了算术和搬运还不够,程序还需要”控制流”:把指令分组实现循环、调用子例程、跳转到特定位置,或者干脆让处理器进入空闲状态等待中断——中断一来,就说明有新数据到达需要处理。

PDSP 中最值得关注的硬件特性是零开销循环。先想想普通循环为什么”贵”:循环结束处处理器要递减循环计数器、判断是否到达循环末尾,没到就跳回循环体开头。这一套判断至少要 4 条指令。而典型 PDSP 算法(如 FIR 滤波器)的循环体往往只有 1 条指令——单次 MAC。算一笔账:循环体 1 条指令、循环控制 4 条指令,意味着 80% 的时间花在了循环管理上,真正的运算只占 20%!这显然不可接受。

解决办法有两种。一种是 TMS320C10 的极端方案:提供 RPT imm 指令,让紧跟的下一条指令硬件级重复 imm+1 次,循环控制完全交给硬件。较新的 PDSP(如 ADSP 和 TMS320C50)则支持更长的循环和多层嵌套循环。相比之下,RISC 计算机的循环体通常不会这么短,循环开销不构成瓶颈;RISC 还采用延迟分支槽技术,避免了流水线中的 NOP 空泡。

零开销循环的硬件逻辑原理是:初始化时规定循环次数和循环结束标记(或循环内指令条数,参见 ADSP 示例);之后控制单元一边执行运算,一边并行检查下一条指令是否仍在循环体范围内——若不是,就自动把循环体第一条指令装入指令寄存器继续执行。整个过程不需要额外的时钟周期。从表 9-7 的概述可知,所有第二代 PDSP 都支持这一功能。

表 9-16 列出了不同微处理器的程序流指令:

表 9-16 不同微处理器的程序流指令

指令说明μP
CALL FIR调用在标记 FIR 处开始的子例程TMS32010
BUN r1如果前面浮点运算中有一个值或多个值为 NAN,就跳转到在寄存器 r1 中存储的位置PowerPC
RET子例程结束后,用存储在寄存器 ra 中的值加载 PC(程序计数器)Nios II
RPT #7h重复执行下一条指令 7+1=8 次。由于常数值较小,因此这是一条只有一个字的指令TMS320C50
CNTR = 10;DO L UNTIL CE;从一条指令到标记 L 之间重复循环,直到计数器 CNTR 溢出ADSP

下一次操作的定位

一个看似自然的想法:能否在每条指令里附带”第 4 个操作数”,直接写明下一条指令的地址,从而省去 PC 递加?理论上可行,但实际上几乎所有指令都是顺序执行的(只有跳转类指令例外),这个字段 99% 的情况下都是冗余的——白白浪费指令字宽度。所以今天的商用微处理器没有采用这一概念。

唯一的例外是设计”只包含一条指令”的基本 RISC 计算机(见练习 9.12)时,指令字中必须包含下一地址,或者最好包含与当前指令的相对偏移量。

软件工具

一颗微处理器能不能推广,硬核性能只是一半,另一半在软件工具生态。根据 Altera 的 Nios 在线网络研讨会,Nios 开发系统获得巨大成功的主要原因之一就是:除了微处理器本身,还提供全套软件工具,包括在 IP 模块参数化的同时自动生成的基于 GCC 的 C 编译器。

网上可以找到许多免费的微处理器内核,例如 OPENCORES.ORG、免费 IP 项目 free-ip.com、FPGA CPU(fpgacpu.org)。但其中大多数缺乏完整的开发工具集,实际用处有限。一个理想的开发工具集(最佳情况下)应当包括:

  • 汇编程序、链接器和加载程序/基本终端程序
  • 指令集仿真器
  • C 编译器

图 9-13 展示了这些开发工具对应的不同抽象层次。想自己构建这些工具,可以考虑 LISA(Language for Instruction Set Architecture)——它最初由德国亚琛工业大学集成信号处理系统研究所开发,现已是 Synopsys 的商业产品,能自动生成汇编程序和指令集仿真器,只需很少的附加规范就能半自动生成 C 编译器(9.5.2 节将回顾这一设计流程)。


图 9-13 编程的模型和工具

为什么强调工具链?因为编写编译器非常耗时:一个好的 C 编译器大约需要 50 个人整整工作一年。好在 GNU 项目提供了三个免费实用程序,可以大幅加快编译器开发:

  • Flex:扫描程序(词法分析程序)生成器,能辨认文本中的结构,作用类似 UNIX 实用程序 grep 和行编辑器 sed,用于处理单个模式。
  • Bison:一个 YACC(Yet Another Compiler-Compiler)兼容的分析程序生成器,允许用巴科斯-诺尔范式描述一种语法,文本中一旦找到匹配的表达式就启动对应操作。
  • gcc:GNU C 编译器本身,R. Stallman 编撰的指导手册说明了如何为实际(或计划构建)的微处理器改编 C 编译器。

这三个工具都可以在 GNU 出版许可条款下免费获得,本书学习资料的 μP 目录中包含了这三份文档以及许多有用示例。

词法分析

能在文本中辨别词法模式的程序叫扫描程序(scanner)。Flex 就是生成这类扫描程序的工具,它最初与 AT&T 的 Lex 兼容。工作流程是:你写一个输入文件(扩展名通常为 *.l),Flex 读入后生成可编译的 C 源代码。

编译环境可以跨平台组合:在 UNIX/Linux 下用 GNU 工具生成扫描程序,再拿到 MS-DOS PC 上编译,这样它就能与运行在 PC 上的 Quartus II 软件配合使用。Flex 生成的默认输出文件名是 lex.yy.c,可用选项 -oNAME.C 改名(注意 UNIX 下 -o 和文件名之间没有空格)。例如从 Flex 输入文件 simple.l 出发生成名为 simple.exe 的扫描程序只需两步:

flex -osimple.c simple.l
gcc -o simple.exe simple.c

一个非常短的输入文件会生成大约 1500 行 C 代码(约 35KB),可见这个实用程序帮了大忙。Flex 输入文件的格式分三部分,由两个百分号分隔:

%{
C header and defines come here
%}
definitions ...
%%
rules ...
%%
user C code ...

三个部分分别是 C 头文件和定义区、定义区、规则区和用户 C 代码区。下面是一个最简示例:

/* A simple flex example */
%{
    /* C-header and definitions */
#include <stdlib.h> /* needed for malloc, exit etc **/
#define YY_MAIN 1
%}
%%.
./\\n ECHO; /* Rule section */
%% 
/* User code here */
int yywrap(void) { return 1; }

如果希望 Flex 自己提供主例程,就定义 YY_MAIN;也可以手写一个基本主例程:

main() { lex(); }

最重要的是规则部分:先指定模式,后指定操作。模式可以是除新行以外的任意字符,\n 表示新行,竖线(|)表示”或”组合。操作 ECHO 把每个字符转发到标准输出,因此这个扫描程序的行为类似 more、type 或 cat 实用程序。两个容易踩的坑:Flex 区分列——只有规则部分的模式可以从第一列开始,注释都不行;模式和操作之间要留一个空格,多个操作用花括号括起来。

Flex 使用的专用符号中,点(.)匹配任何字符,\n 匹配新行。表 9-17 列出了最常用的符号,它们与 grep 和 sed 的正则表达式符号相同:

表 9-17 规定模式的示例

模式匹配
a字符 a
a{1,3}1 至 3 个 a,也就是 a|aa|aaa
a|b|ca、b 和 c 字符中的任意一个字符
[a-c]a、b 和 c 字符中的任意一个字符,也就是 a|b|c
ab*a 和 0 个或多个 b,也就是 a|ab|abb|abbb ...
ab+a 和 1 个或多个 b,也就是 ab|abb|abbb ...
a\+b字符串 a+b
[\t\n]+一个或多个空格、制表符或新行
^L行首必须是 L
[^a-b]a、b 或 c 之外的任意字符

掌握了这些模式,就能构建一个实用的扫描程序。原书示例对一个 VHDL 风格的硬件描述语言做词法分析,这里我们将原书的 VHDL 改写为 Verilog:扫描程序识别 Verilog 文件中的关键字、标识符、赋值符、定界符和注释。以下是与原书 d_ff VHDL 示例等价的 Verilog 测试文件(被扫描的目标):

原书 VHDL 改写为 Verilog:

// 与原书 d_ff VHDL 示例等价的 Verilog 触发器
module d_ff (
    input wire clk,   // 时钟
    input wire d,     // 数据输入
    output reg q      // 数据输出
);
    always @(posedge clk) begin
        q <= d;
    end
endmodule

相应的 Flex 文件 vhdlex.l 中,关键字集合需从 VHDL 的 ENTITY/ARCHITECTURE/PROCESS 等替换为 Verilog 的 module、input、output、always、begin、end 等,赋值符规则要同时识别 ”<=“(阻塞/非阻塞赋值均用到的符号)与 ”=“,注释符从 ”—” 改为 ”//“。规则部分的核心形式不变:

/* Lexical analysis for a toy Verilog-like language */
%{
#include <stdio.h>
#include <stdlib.h>
%}
 
DIGIT    [0-9]
ID    [a-z][a-z0-9_]*
GENERIC    [A-Z]
DELIMITER   [; ,()](:)
COMMENT    "//"[^\n]*
LABEL    [a-zA-Z][a-zA-Z0-9]*[:]
%%
 
{DIGIT}+ { printf("An integer: %s (%d)\n", yytext, atoi( yytext)); }
 
MODULE|INPUT|OUTPUT|WIRE|REG|ALWAYS|POSEDGE|BEGIN|END|ENDMODULE {
    printf("A keyword: %s\n", yytext); }
 
{ID}    printf("An identifier: %s\n", yytext);
"<="    printf("An assignment: %s\n", yytext);
"="    printf("Equal condition: %s\n", yytext);
{DELIMITER}printf("A delimiter: %s\n", yytext);
{LABEL}    printf("A label: %s\n", yytext);
 
"+"|"-"|"*"|"/"    printf("An operator: %s\n", yytext);
{COMMENT} printf("A comment: %s\n", yytext);
[ \t\n]+ /* eat up whitespace */
. printf("Unrecognized character: %s\n", yytext);
int yywrap(void) { return 1; }
 
main( argc, argv )
int argc;
char **argv;
{
    ++argv, --argc; /* skip over program name */
    if ( argc > 0 )
    yyin = fopen( argv[0], "r" );
    else 
    yyin = stdin;
    yylex();
}

UNIX 下的编译步骤是:

flex -overilog.c verilog.l
gcc -o verilog.exe verilog.c

用 verilog.exe < d_ff.v 调用扫描程序,处理上面的 Verilog 示例文件,会得到如下输出(词素逐个被分类):

A keyword: module
An identifier: d_ff
A delimiter: (
A keyword: input
A keyword: wire
An identifier: clk
A delimiter: ,
...
A comment: //时钟
A keyword: output
A keyword: reg
An identifier: q
A delimiter: ;
A keyword: always
A delimiter: @
A delimiter: (
A keyword: posedge
An identifier: clk
A delimiter: )
A keyword: begin
An identifier: q
An assignment: <=
An identifier: d
A delimiter: ;
A keyword: end
A keyword: endmodule

接下来是更有挑战性的任务:构造 asm2mif 转换程序——读入汇编代码,输出能加载到 Quartus II 模块存储器的 MIF 文件。为简单起见,栈机使用 16 种操作(按操作码排序):

ADD, NEG, SUB, OPAND, OPOR, INV, MUL, POP, PUSHI, PUSH, SCAN, PRINT, CNE, CEQ, CJP, JMP

难点在哪里?汇编代码允许使用前向引用标记——标号 L01 可能在被 CJP 引用之后才定义,所以必须做两遍扫描(two-pass):第一阶段列出所有变量和标号及其所在代码行,并为每个变量分配一个存储位置;第二阶段把汇编代码逐行翻译为 MIF 代码。MIF 文件头部声明数据格式,之后每行是地址、操作和可能的操作数;借助每行末尾的注释(”—“)还能显示原始汇编代码。这个两阶段扫描程序的 Flex 输入文件如下(用变量 pp 区分当前处于预处理阶段还是第二阶段,add_symbol() 和 lookup_symbol() 把标号和变量存入符号表——对标号记录其出现的指令行号,对变量则分配一个递增的存储地址):

/* Scanner for assembler to MIF file converter */
%{
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <errno.h>
#include <stdlib.h>
#include <time.h>
#include <ctype.h>
#define DEBUG 0
int state =0; /* end of line prints out IW */
int icount =0; /* number of instructions */
int vcount =0; /* number of variables */
int pp =1; /** preprocessor flag **/
char opis[6], lblis[4], immis[4];
struct inst {int adr; char opc; int imm; char *txt;} iw;
struct init {char *name; char code;} op_table[20] = {
    "ADD", '0', "NEG", '1', "SUB", '2',
    "OPAND", '3', "OPOR", '4', "INV", '5',
    "MUL", '6', "POP", '7', "PUSHI", '8',
    "PUSH", '9', "SCAN", 'a', "PRINT", 'b',
    "CNE", 'c', "CEQ", 'd', "CJP", 'e',
    "JMP", 'f', 0, 0 };
FILE *fid;
int add_symbol(int value, char *symbol);
int lookup_symbol(char *symbol);
void list_symbols();
void conv2hex(int value, int Width);
char lookup_opc(char *opc);
%}
DIGIT [0-9]
VAR [a-z][a-z0-9_]*
COMMENT "--"[^\n]*
LABEL L[0-9]+[:]
GOTO L[0-9]+\n
%%
\n {if (pp) printf("-- end of line \n");
else { if ((state==2) && (pp==0))
/* print out an instruction at end of line */
{conv2hex(iw.adr,8);printf(" : %c",iw.opc);
conv2hex(iw.imm,8);
printf("; -- %s %s\n",opis,immis);} 
state=0;iw.imm=0;
}}
{DIGIT}+ { if (pp) printf("-- An integer: %s (%d)\n",
yytext, atoi( yytext ) );
else {iw.imm=atoi( yytext );state=2;
strcpy(immis,yytext);} }
POP|PUSH|PUSHI|CJP|JMP {
    if (pp)
    printf("-- %d) Instruction with operand: %s\n",
icount++, yytext );
else { state=1; iw.adr=icount++;
    iw.opc=lookup_opc(yytext);}}
CNE|CEQ|SCAN|PRINT|ADD|NEG|SUB|OPAND|OPOR|INV|MUL {
    if (pp) printf("-- %d) ALU Instruction: %s\n", icount++, yytext );
    else { state=2; iw.opc=lookup_opc (yytext);
    iw.adr=icount++; strcpy(immis," ");}}
{VAR} { if (pp) {printf("-- An identifier: %s\n", yytext );add_symbol(vcount, yytext);} else {state=2;iw.imm=lookup_symbol(yytext);};}
{LABEL} { if (pp) {printf("-- A label: %s lenth=%d Icount=%d\n", yytext , yyleng, icount); add_symbol(icount, yytext);} } 
{GOTO} {if (pp) printf("-- A goto label: %s\n", yytext );
    else {state=2;sprintf(lblis,"%s:",yytext);
    iw.imm=lookup_symbol(lblis);strcpy(immis,yytext);} } 
{COMMENT} {if (pp) printf("-- A comment: %s\n", yytext);} [ \t]+ /* eat up whitespace */
. printf("Unrecognized character: %s\n", yytext );
%%
int yywrap(void) { return 1; }
 
int main( argc, argv )
int argc;
char **argv;
{
    ++argv, --argc; /* skip over program name */
    if ( argc > 0 )
    yyin = fopen( argv[0], "r" );
    else 
{ printf("No input file -> EXIT\n"); exit(1);} 
    printf("--- First path though file ---\n");
    yylex();
    if (yyin != NULL) fclose(yyin);
    pp=0;
    printf("\n-- This is the T-RISC program with ");
    printf("%d lines and %d variables\n",icount,vcount);
    icount=0;
    printf("-- for the book DSP with FPGAs\n");
    printf("-- Copyright (c) Uwe Meyer-Baese\n");
    printf("-- WIDTH = 12; DEPTH = 256;\n");
    if (DEBUG) list_symbols();
    printf("ADDRESS_RADIX = hex; DATA_RADIX = hex;\n\n");
    printf("CONTENT BEGIN\n");
    printf("[0..FF] : F00; -- ");
    printf("Set all address from 0 to 255 => JUMP 0\n");
    if (DEBUG) printf("--- Second path through file ---\n");
    yyin = fopen(argv[0], "r");
    yylex();
    printf("END;\n");
}
 
/* define a linked list of symbols */
struct symbol { char *symbol_name; int symbol_value;
    struct symbol *next; };
 
struct symbol *symbol_list; /*first element in symbol list */
 
extern void *malloc();
 
int add_symbol(int value, char *symbol)
{
    struct symbol *wp;
 
    if (lookup_symbol(symbol) >= 0) {
    printf("-- Warning: symbol %s already defined \n", symbol);
    return 0;
    }
    wp = (struct symbol *) malloc(sizeof(struct symbol));
    wp->next = symbol_list;
    wp->symbol_name = (char *) malloc(strlen(symbol)+1);
    strcpy(wp->symbol_name, symbol);
    if (symbol[0] != 'L') vcount++;
    wp->symbol_value = value;
    symbol_list = wp;
    return 1;    /* it worked */
}
 
int lookup_symbol(char *symbol)
{
    struct symbol *wp = symbol_list;
    for(; wp; wp = wp->next) {
    if (strcmp(wp->symbol_name, symbol) == 0)
    {if (DEBUG)
    printf("-- Found symbol %s value is: %d\n",symbol,
    wp->symbol_value);
    return wp->symbol_value;}
    }
    if (DEBUG) printf("-- Symbol %s not found!!\n",symbol);
    return -1;/* not found */
}
 
char lookup_opc(char *opc)
{ int k;
    strcpy(opis,opc);
    for (k=0; op_table[k].name !=0; k++)
    if (strcmp(opc,op_table[k].name)==0)
    return (op_table[k].code);
    printf("***** Ups, no opcode for: %s --> exit \n",opc);
    exit(1);
}
 
void list_symbols()
{
    struct symbol *wp = symbol_list;
    printf("--- Print the Symbol list: ---\n");
    for(; wp; wp = wp->next)
    if (wp->symbol_name[0]=='L') {
    printf("-- Label : %s line = %d\n",
    wp->symbol_name, wp->symbol_value);
    } else {
    printf("-- Variable : %s memory @ %d\n",
    wp->symbol_name, wp->symbol_value);
    }
}
 
/* convert an integer into hex of given width */
void conv2hex(int value, int Width)
{
    int    W, k, t;
    extern FILE *fid;
    t = value;
    for (k = Width - 4; k >= 0; k-=4) {
    W = (t >> k) % 16; printf("%1x", W);
    }
}

两个标签和两个变量对应的符号表输出如下(打开调试模式 define DEBUG 1 时显示):

...
--- Print the Symbol list: ---
-- Label : L01: line =17
-- Label : L00: line =4
-- Variable : k memory @ 1
-- Variable: x memory @ 0
...

编译和运行代码的 UNIX 指令:

flex -oasm2mif.c asm2mif.l
gcc -o asm2mif.exe asm2mif.c
asm2mif.exe factorial.asm

栈机的阶乘程序 factorial.asm 如下:

PUSHI 1
POP x
SCAN
POP k
L00: PUSH k
PUSHI 1
CNE
CJP L01
PUSH x
PUSH k
MUL
POP x
PUSH k
PUSHI 1
SUB
POP k
JMP L00
L01: PUSH x
PRINT

先算一笔账验证这个程序的正确性:程序先读入 k,然后在循环中反复执行 x = x * k、k = k - 1,直到 k 等于 1——这正是阶乘的定义。共 19 行汇编、2 个变量。asm2mif 的输出如下:

-- This is the T-RISC program with 19 lines and 2 variables
-- for the book DSP with FPGAs
-- Copyright (c) Uwe Meyer-Baese
WIDTH = 12;
DEPTH = 256;
ADDRESS_RADIX = hex;
DATA_RADIX = hex;
 
CONTENT BEGIN
[0..FF] : F00; --Set address from 0 to 255 => JUMP 0
00 : 801; -- PUSHI 1
01 : 700; -- POP x
02 : a00; -- SCAN
03 : 701; -- POP k
04 : 901; -- PUSH k
05 : 801; -- PUSHI 1
06 : c00; -- CNE
07 : e11; -- CJP L01
08 : 900; -- PUSH x
09 : 901; -- PUSH k
0a : 600; -- MUL
0b : 700; -- POP x
0c : 901; -- PUSH k
0d : 801; -- PUSHI 1
0e : 200; -- SUB
0f : 701; -- POP k
10 : f04; -- JMP L00
11 : 900; -- PUSH x
12 : b00; -- PRINT
END;

逐行对照可以理解指令编码格式:高 4 位是操作码(如 8 对应 PUSHI、f 对应 JMP),低 8 位是立即数或标号地址(如 CJP L01 译为 e11,11 正是标号 L01 所在行)。最后一行 JMP L00 译为 f04——标号 L00 定义在第 4 行,与符号表记录完全一致。这个 MIF 文件可以直接加载到栈机的存储器中,9.5.2 节将设计这台栈机。

分析程序的开发

从 YACC 的全名”Yet Another Compiler-Compiler(另一个编译器的编译器)“就能看出它的定位:为每个新微处理器自动生成语法分析程序。GNU 的等价工具 Bison 允许我们用语法规则描述一门语言。

为什么不继续用 Flex?关键在于递归。考虑表达式 a+b、a+b+c、a+b+c+d……如果用 Flex,每个代数表达式都要单独定义模式和操作——即使限制运算次数和操作数数量,模式的组合数也会爆炸。而语法规则允许递归定义,一条规则覆盖无穷多情况。

Bison 采用巴科斯-诺尔范式(Backus Normal Form,用于指定 Algol 60 语言)。语法规则由终结符和非终结符构成:终结符用关键字 %token 指定,非终结符通过自己的定义声明。Bison 为每个标记分配数字代码,期望由词法分析程序(如 Flex)提供这些代码。语法规则采用 LALR(Look-Ahead Left Recursive,先行左递归)分析方法。一条典型规则如下:

Expression : NUMBER '+' NUMBER { $$ = $1 + $3; }

这条规则说:一个表达式由两个数字中间夹一个加号构成,可归约为单个表达式。花括号里的操作含义是:把值栈的第 1 个和第 3 个元素相加(第 2 个是加号),结果压回值栈。在内部,分析程序用一个 FSM 分析代码:每读到一个识别的标记就压入内部栈并切换到下一状态,这叫移位(shift);当发现某条规则的所有符号都已凑齐,就把操作应用到值栈并对分析栈进行归约(reduce)。这就是”移位归约分析程序”名称的由来。

Bison 输入文件(扩展名 *.y)同样分三部分——这并非偶然,Lex 和 YACC 都是 AT&T 同事开发的,二者协作非常顺畅:

%{
    C header and declarations come here
%}
 
Bison definitions ...
%%
Grammar rules ...
%%
User C code ...

下面构建第一个 Bison 示例 add2.y——一个只能做加法的计算器:

/* Infix notation add two calculator */
%{
#define YYSTYPE double
#include <math.h>
void yyerror(char *);
%}
 
/* BISON declarations */
%token NUMBER
%left '+'
 
%% /* Grammar rules and actions follows */
program :    /* empty */
    | program exp '\n'    { printf(" %lf\n", $2); }
    ;
 
exp    : NUMBER    { $$ = $1; }
    | NUMBER '+' NUMBER    { $$ = $1 + $3; }
    ;
%% /* Additional C-code goes here */
#include <ctype.h>
int yylex(void)
{ int c;
    /* skip white space and tabs */
    while ((c = getchar()) == ' ' || c == '\t');
    /* process numbers */
    if (c == '.' || isdigit(c)) {
    ungetc(c, stdin);
    scanf("%lf", &yylval);
    return NUMBER;
    }
    /* Return end-of-file */
    if (c == EOF) return(0);
    /* Return single chars */
    return(c);
}
 
/* Called by yyparse on error */
void yyerror(char *s) { printf("%s\n", s); }
 
int main(void) { return yyparse(); }

比纯语法多出的部分:NUMBER 标记允许单个数字成为有效表达式;program 规则让分析程序可以接受一个声明列表而不只是一个声明。C 代码部分内嵌了简易词法分析——跳过空格、读操作数;每次 Bison 需要符号时就调用 yylex 例程,遇到分析错误则调用 yyerror。主例程只需一条 return yyparse()。编译运行:

bison -o -v add2.c add2.y
gcc -o add2.exe add2.c -lm

运行效果——一次计算两个浮点数的加法:

user: add2.exe
user: 2+3
add2: 5.000000
user: 3.4+5.7
add2: 9.100000

加上 -v 选项后还会生成一个输出文件,内含所有规则、FSM 状态机信息和移位归约冲突/多义性列表。add2.output 的关键内容如下:

Grammar
rule 1 program -> /* empty */
rule 2 program -> program exp '\n'
rule 3 exp -> NUMBER
rule 4 exp -> NUMBER '+' NUMBER
 
Terminals, with rules where they appear
$ (-1)
'\n' (10) 2
'+' (43) 4
error (256)
NUMBER (257) 3 4
 
Nonterminals, with rules where they appear
program (6)
on left : 1 2, on right: 2
exp (7)
on left : 3 4, on right: 2
 
state 0
$default reduce using rule 1 (program)
program go to state 1
 
state 1
program -> program . exp '\n' (rule 2)
$ go to state 7
NUMBER shift, and go to state 2
exp go to state 3
 
state 2
exp -> NUMBER . (rule 3)
exp -> NUMBER . '+' NUMBER (rule 4)
'+' shift, and go to state 4
$default reduce using rule 3 (exp)
 
state 3
program -> program exp . '\n' (rule 2)
'\n' shift, and go to state 5
 
state 4
exp -> NUMBER '+' . NUMBER (rule 4)
NUMBER shift, and go to state 6
 
state 5
program -> program exp '\n' . (rule 2)
$default reduce using rule 2 (program)
 
state 6
exp -> NUMBER '+' NUMBER . (rule 4)
$default reduce using rule 4 (exp)
 
state 7
$ go to state 8
 
state 8
$default accept

如何阅读这个文件?开头是规则列表和终结符列表——终结符 NUMBER 被分配标记值 257。调试输入文件或多义性问题时,这里是第一检查点。FSM 共 8 个状态:移位发生在第 1、2、4 个状态,分别对应第一个数、加运算、第二个数;归约发生在第 6 个状态。

这个小计算器的局限很明显。试减法会报 parse error:

user: add2.exe
user: 7-2
add2: parse error

试三个操作数也不行:

user: 2+3+4
add2: parse error

要增强功能,需要:递归语法规则、运算符 *、/、-、^、以及允许指定变量的符号表(符号表的 C 代码可参见文献 [364]、[368]、[369])。增强版词法分析如下——除了整型数标记,还加入 VARIABLE(用小写单字符 a~z 表示变量):

/* Infix calculator with symbol table, error recovery and power-of */
%{
    #include "ytab.h"
    #include <stdlib.h>
    void yyerror(char *);
%}
 
%%
 
[a-z] { yylval = *yytext - 'a';
return VARIABLE; }
 
[0-9]+ { yylval = atoi(yytext);
return INTEGER; }
 
[-+()^=/*\n] { return *yytext; }
 
[\t] ; /* skip whitespace */
 
. yyerror("Unknown character");
 
%% 
 
int yywrap(void) { return 1; }

yytext 和 yylval 是每个标记相关联的文本和值。表 9-18 汇总了 Flex↔Bison 通信中使用的特殊函数和变量:

表 9-18 Flex↔Bison 通信中使用的特殊函数和变量,完整的列表请参阅附录 A

意义
char *yytext标记文本
file *yyinFlex 输入文件
file *yyoutECHO 的 Flex 文件目的地
int yylength标记长度
int yylex(void)为了申请标记,分析程序调用的例程
int yylval标记值
int yywrap(void)到文件末尾时 Flex 调用的例程
void yyparse()主分析程序例程
void yyerror(char *s)遇到错误时 yyparse 调用的例程

更高级的计算器 calc.y 的语法如下:

%{
    #include <stdio.h>
    #include <math.h>
    #define YYSTYPE int
    void yyerror(char *);
    int yylex(void);
    int symtable[26];
%}
 
%token INTEGER VARIABLE
%left '+' '-'
%left '*'
%left NEG /* Negation, i.e. unary minus */
%right '^' /* exponentiation */
 
%%
 
program:
    program statement '\n'
    /* NULL */
;
 
statement:
    expression { printf("%d\n", $1); }
    | VARIABLE '=' expression { symtable[$1] = $3; }
;
 
expression:
    INTEGER
    | VARIABLE { $$ = symtable[$1]; }
    | expression '+' expression { $$ = $1 + $3; }
    | expression '-' expression { $$ = $1 - $3; }
    | expression '*' expression { $$ = $1 * $3; }
    | expression '/' expression {
    if ($3) $$ = $1 / $3;
    else { $$=1; yyerror("Division by zero!\n"); }
    }
    /* Exponentiation */
    | expression '^' expression { $$ = pow($1, $3); }
    /* Unary minus */
    | '-' expression %prec NEG { $$ = -$2; }
    | '(' expression ')' { $$ = $2; }
    ;
%%
 
void yyerror(char *s) { fprintf(stderr, "%s\n", s); }
int main(void) { yyparse(); }

这一语法里有几个新内容。其一,用 %left 和 %right 声明结合律:希望 2-3-5 按 (左结合)而不是 (右结合)计算;而指数 ^ 用右结合,因为 分组。其二,优先级:写在标记列表后面的运算符优先级更高,”*” 列在 ”+” 之后,所以乘法优先于加法—— 计算而不是 。若不指定优先级,遇到 这类项,FSM 会不知道该归约还是移位,报告大量移位归约冲突。其三,除法规则针对除零引入了错误处理。没有它,除零会直接击垮计算器:

user: 10/0
calc: Floating exception (core dumped)

生成一个庞大的核心转储文件。有了错误处理,行为平稳得多,而且计算器能继续运行:

user: 30/3
calc: 10
user: 10/0
calc: Division by zero !

现在表达式是递归定义的(表达式由表达式+运算+表达式组成),实测几个例子体会结合律和优先级的用法:

user: 2+3*5
calc: 17
user: 1-2-5
calc: -6
user: x=3*10
user: y=2*5-9
user: x+y
calc: 31
user: #
calc: Unknown character
calc: parse error

变量赋值与求值都正常工作(x+y = 30+1 = 31);任何特殊未知字符都会使计算器停止。本书学习资料提供了 C 源代码和可执行程序供测试。

最后看一下 Flex↔Bison 的完整编译流程。先运行 Bison 让它把期望的标记类型导出到头文件:

bison -y -d -o ytab.c calc.y

生成 ytab.c、ytab.output 和 ytab.h,其中 ytab.h 包含标记值定义:

#ifndef YYSTYPE
#define YYSTYPE int
#endif
#define INTEGER 257
#define VARIABLE 258
#define NEG 259
extern YYSTYPE yylval;

然后运行 Flex 生成词法分析程序 lexyy.c:

flex -olexyy.c calc.l

最后编译链接两个 C 文件得到 calc.exe(-lm 是数学库选项,pow 等高级函数需要它):

gcc -c ytab.c lexyy.c
gcc ytab.o lexyy.o -o calc.exe -lm

有了这些基础,就能完成更具挑战性的任务:编写 c2asm 程序,从简单的类 C 语言生成汇编代码(三地址计算机的代码见文献 [370];栈机版本的全部步骤见文献 [368])。以阶乘为例,类 C 输入文件如下:

x=1;
scan k;
while (k != 1) {
    x = x * k;
    k = k - 1;
}
print x;

这个程序从输入端(如 UP2 或 DE2 开发板上的 8 引脚双列直插开关)读取数据,计算阶乘并输出到两位七段数码管显示器。运行本书学习资料 uP 目录下的 c2asm.exe,得到的汇编代码如下:

PUSHI1
POPx
SCAN
POPk
L00:PUSHk
PUSHI1
CNE
CJPL01
PUSHx
PUSHk
MUL
POPx
PUSHk
PUSHI1
SUB
POPk
JMPL00
L01:PUSHx
PRINT

注意 c2asm 生成的这段汇编与我们手写的 factorial.asm 完全一致——while 循环被翻译成了 CJP 条件跳出加 JMP 回跳的标准模式。再用 asm2mif 生成 MIF 文件,程序就能加载进栈机运行了。所需要做的全部工作就是设计一台栈机,下一节讨论如何设计。

剩下的挑战是为 PDSP 设计好的 C 编译器。难点在于 PDSP 的专用寄存器和计算单元(如地址生成器)难以被通用编译器高效利用。德国亚琛工业大学开发的 DSPstone 基准用 15 个典型 PDSP 例程(从简单 MAC 到复杂 FFT)评估 C 编译器生成的代码,并与手写汇编对比。图 9-14 的结果表明:对 AT&T、Motorola 和 Analog 的 PDSP,基于 GCC 的编译器生成的代码比优化汇编平均低 9.58 倍。为什么明知低效还用可重定目标编译器(GCC、LCC)?因为高性能编译器开发通常需要 20 到 50 人年,对多数项目工作量过大;而 PDSP 种类繁多,每种都需要不同的开发工具集。折中方案是采用可重定目标编译器,以未经优化的代码换取更短的开发时间。更进一步的解决方案是 ACE(Associated Compiler Expert,辅助编译器专家)——一个高度灵活、容易重定目标的编译器开发系统,能为多种 PDSP 创建高质量高性能的编译器,并且已针对从高级语言派生的中间代码表示实现了多种优化。


图 9-14 DSPstone 项目的 15 个基准

FPGA 微处理器内核

近年来传统微处理器不断增强 DSP 能力:Intel Pentium 和 SUN SPARC 增添了 MMX 多媒体指令扩展和 VIS 指令集扩展,使图形和矩阵乘法操作更高效。表 9-19 总结了传统微处理器的 DSP 增强:

表 9-19 DSP 对传统微处理器的补充

公司产品关键 DSP 补充
ARMARM9E单循环 MAC
FujitsuSPARClite 系列整数 MAC 和多媒体辅助
IBMPowerPC 系列整数 MAC
IDT79RC4650(MIPS)整数 MAC
MIPS TechnologiesMIPS64 5Kc单循环整数 MAC
Hewlett-PackardPA_8000 系列MPEG 解码的寄存器
IntelPentium III流式 SIMD 扩展
MotorolaPowerPC G4向量处理器
SUN MicrosystemsUltraSPARC 系列VIZ 图像指令集

这些 RISC 微处理器构成了 FPGA 设计中硬内核和软内核处理器的基础。CISC 处理器一般不用在嵌入式 FPGA 应用中。厂商的选择:Xilinx 用 PowerPC 作为成功的 Virtex II PRO FPGA 系列的基础(含 1 至 4 个 PowerPC RISC 处理器);Altera 在 Excalibur FPGA 系列中使用 ARM 处理器——移动电话等嵌入式应用中最著名的 RISC 内核之一。不过这些 Altera FPGA 虽仍可用,但不再推荐用于新设计:处理器性能依赖制造工艺,而基于 ARM 的 FPGA 不是用新技术制造的。实测表明,Altera 的软内核 Nios II 处理器已达到与基于 ARM922T 的硬内核处理器大约相同的性能。Xilinx 还提供 32 位 RISC 软内核 MicroBlaze 和 8 位 PicoBlaze 处理器(Xilinx 没有 16 位软内核——而 16 位恰是 DSP 算法的适宜位宽,9.5.2 节将设计这样的处理器)。最新的高性能选择是 ARM Cortex-A9 双处理器,可用于 Altera 的 Arria V 和 Cyclone V 器件以及 Xilinx 的 ZYNQ-7000 器件。

表 9-20 概述了 FPGA 硬内核和软内核微处理器按 Dhrystone MIPS(D-MIPS)测量的性能。选 D-MIPS 而不用计算机体系结构文献中常用的 SPEC 基准,是因为 D-MIPS 是一个集合而非简短基准——某些 SPEC 基准(尤其在软内核处理器上)可能根本无法运行。

表 9-20 Dhrystone 微处理器性能

微处理器名称所用器件速度(MHz)测量的 D-MIPS硬/软(H/S)
Nios II/eStratix33050S
MicroBlazeSpartan-3(-4)8568S
MicroBlazeVirtex-II PRO-7150125S
V1 ColdFireStratix145135S
ARM Cortex-M1Stratix200160S
Nios II/sStratix270170S
ARM922TExcalibur200210H
MIPS32Stratix290300S
Nios II/fStratix290340S
PPC405Virtex-4 FX450700H
ARM Cortex-A9Arria/Cyclone/Zynq8004000H

从表 9-20 可以直观感受硬内核与软内核的差距:软内核(S 标记)的 D-MIPS 多在 50~340 之间,而硬内核(H 标记)PPC405 达到 700,ARM Cortex-A9 更是高达 4000——差距主要来自专用硅片工艺与通用可编程逻辑资源的时钟频率差异。

硬内核微处理器

硬内核微处理器虽然不像软内核那样灵活(无法修改内部结构、不能随意增删外设),但吸引力同样明显:核心面积相对较小,因此时钟速率和 Dhrystone MIPS 速率更高。历史脉络:过去 Xilinx 支持 IBM 和 Motorola 的 PowerPC 系列,Altera 使用 ARM922T 内核(很多嵌入式应用如移动电话中的标准内核);近年来两个厂商都推出了基于 ARM 的 Cortex-A9 微处理器器件。接下来简要了解这三种体系结构。

1. Xilinx PowerPC:嵌在 FPGA 里的”真”处理器

前面我们讨论的软内核(如 PicoBlaze、MicroBlaze)都是用 FPGA 的逻辑资源”搭”出来的处理器。而硬内核则是把一颗真正的 CPU 电路直接固化在芯片制造阶段——它不占用 FPGA 的可编程逻辑资源,速度和效率都远超软内核。这就是”不用硬内核会怎样”的答案:同样的任务,软内核往往只能跑到几十 MHz,而硬内核可以跑到几百 MHz。

用在 Virtex-II PRO 器件中的 Xilinx 硬内核是一颗功能完整的 PowerPC 405 内核。它是一颗 RISC 微处理器,采用 32 位哈佛体系结构(程序存储器和数据存储器分开、各有独立总线),包括如下功能单元,如图 9-15 所示:

  • 指令缓存和数据缓存,大小均为 16KB
  • 内存管理单元(MMU),带有 64 个入口的转换旁视缓冲器(TLB)
  • 提取和解码单元
  • 执行单元,带有 32 个 32 位通用寄存器、ALU 和 MAC
  • 定时器
  • 调试逻辑


图 9-15 Xilinx Virtex-II PRO 器件所用的 PPC405 内核

先算一笔缓存容量的账。PPC405 的指令和数据缓存都是 16KB,组织成 256 条线(每线 32 字节),验证一下:。这是单路缓存的容量,两路组关联结构下合计即为 16KB。由于哈佛结构中数据和程序分属两套缓存,整体表现接近 4 路组关联缓存的性能。缓存组织方式与更高的运行速度,正是 PPC405 优于软内核的主要原因;缓存大小虽可随特定应用调整,但通常按直接映射方式组织(可参阅原书练习 9.29)。

405 内核的关键功能可以归纳如下:

  • 嵌入式 450+ MHz 哈佛体系结构内核
  • 5 级数据通路流水线
  • 16KB 双路组关联指令缓存
  • 16KB 双路组关联数据缓存
  • 硬件乘法/除法单元

具体到各功能单元:指令缓存单元(ICU)每个时钟周期通过一条 64 位总线传递 1 或 2 条指令——一个周期取两条指令,正是后面”分支预测”能发挥作用的前提。数据缓存单元(DCU)每周期可传递 1、2、3、4 或 8 字节。执行单元(EXU)是单发射单元,内含 32 个 32 位通用寄存器(GPR)组成的寄存器文件、一个 ALU 以及一个用硬件执行全部整数指令的 MAC 单元。与典型 RISC 处理器一样,它采用加载/存储结构:所有 ALU 读写和 MAC 操作都只通过 GPR 完成,访存必须用专门的加载/存储指令。MMU 让 PPC405 能寻址 4GB 地址空间;为避免每次都查页表,用一个缓存来跟踪近期使用的地址映射,即 64 入口的 TLB。此外 PPC 还包含 3 个 64 位定时器:可编程间隔定时器(PIT)、固定间隔定时器(FIT)和看门狗定时器(WDT)。内核的所有资源都可通过调试逻辑访问,并支持 ROM 监视器、JTAG 调试器和指令跟踪工具。另一个软内核通常不具备的功能是分支预测:通常假定采用负位移的分支(即向后跳转的循环)。更多细节可参阅原书文献 [376]。

辅助处理单元 APU:给硬内核”加外挂”

Virtex-4 FX 器件的 PowerPC 增加了一个重要功能——辅助处理单元(APU)。它的意义在于:把你自己设计的硬件加速器和 CPU 流水线直接连在一起,让 CPU 像”调用一条指令”那样使用加速器。主要功能如下:

  • 支持用户自定义指令
  • 单条指令可传输多达 4 个 32 位字的数据
  • 可构建浮点或通用协处理器
  • 支持自主指令,即不引起流水线停顿
  • 32 位指令宽度、64 位数据通路
  • 4 周期的缓存线传输

APU 与 PowerPC 流水线直接连接,汇编程序或 C 代码可以直接访问这一单元。实测效果非常可观:对浮点 FIR 滤波器,软件模拟表明性能提高约 20 倍;一个 16 位整数的 8×8 像素 2D-IDCT 模块通过 APU 连接,速度同样提高约 20 倍。作为对比:如果把同样的 IDCT 硬件改用 PowerPC 局部总线连接,系统性能反而会下降——原因是局部总线的优先级开销,加上该模块需要大量的 32 位加载/存储指令。这个对比说明:协处理器接得”近”(流水线级)比接得”远”(总线级)快得多。

2. Altera 的 ARM:ARM922T 硬内核

Altera 走的是另一条路:在 Excalibur FPGA 系列中嵌入 ARM922T 硬内核。它包括 ARM9TDMI 内核、指令和数据缓存、存储管理单元(MMU)、调试逻辑、一个 AMBA 总线接口和一个协处理器接口。关键功能总结如下:

  • 嵌入式 200MHz(210 Dhrystone MIPS)哈佛体系结构内核
  • 5 级数据通路流水线
  • 8KB 64 路组关联指令缓存
  • 8KB 64 路组关联数据缓存
  • 硬件乘法单元
  • 低功耗 0.8 mW/MHz;小面积 6.55 mm²
  • 3 操作数 32 位指令
  • 2 操作数 16 位 Thumb 指令

这些嵌入式处理器的 MMU 和缓存体系结构比软内核高级复杂得多,这也是 Dhrystone MIPS 速率高于同频软内核的原因。ARM922T 采用每行 8 字的缓存线结构,指令和数据缓存都是 64 路组关联。它还带一个具有 16 个数据字和 4 位地址的写缓冲区,用来避免缓存缺失时写入操作造成的停顿。MMU 可映射 1KB~1MB 页面大小的存储器,并为数据和指令各配一个独立的 64 入口 TLB。

这里还要认识一条总线标准:先进的微处理器总线体系结构(AMBA)是嵌入式系统中最常用的总线体系结构,ARM922T 原生支持它。

ARM9TDMI 内核如图 9-16 中的灰色区域所示。它同时支持标准 32 位指令和更短的 Thumb 指令集——后者只用 16 位,允许把两条指令压缩在一个 32 位存储字内,从而减小代码体积。内核采用 5 级流水线,顺序为:(1) 提取,(2) 解码和寄存器读取,(3) 执行,(4) 存储器访问和乘法完成,(5) 写入寄存器。CPU 含 31 个通用寄存器,一次只可见 16 个,其余寄存器为上下文切换保留(例如中断处理)。其中第 15 号寄存器用作 PC(程序计数器),第 14 号保存子例程调用的返回地址,第 13 号通常用作栈指针。除寄存器外,内核还包括 ALU、桶式移位器和硬件乘法器。下面通过例子仔细研究 Thumb 指令编码。


图 9-16 ARM922T 总体体系结构 [378],深灰色部分是 ARM9TDMI 内核的内部体系结构 [379]

例 9.4 Thumb 指令编码

Thumb 的核心思想是:用更少的位表达”大部分”常用操作,代价是寄存器数量和寻址灵活性的缩减。下面逐条对比同一指令的 32 位编码与 Thumb 编码。

第一条指令把 8 位立即数加到一个寄存器中,结果存回同一寄存器,即 。32 位编码为:

| 1110 | 00101001 | | | 0000 | |

对应的 Thumb 编码保持 8 位立即数不变,但两个寄存器必须是同一个,且寄存器选择从 16 个减少到 8 个(3 位字段):

| 00110 | | |

注意省了哪些位:操作码从 12 位缩到 5 位,因为”源=目标”这个约束把两个 4 位寄存器字段压成了一个 3 位字段。

第二条是算术右移(ASR)指令,它允许有符号数除以 2 的幂(右移一位相当于除以 2,符号位保持不变)。移位次数由 5 位立即数指定, 是目标寄存器, 是源寄存器,即 。32 位编码中 16 个寄存器都可用:

| 1110 | 00011011 | | | | 100 | |

(SBZ 表示”应为零”的保留字段。)Thumb 编码中源和目标只能是前 8 个寄存器:

| 00010 | | | |

第三条是乘法。乘法只生成 32 位结果,在 Thumb 中源寄存器和目标寄存器必须相同,即 。Thumb 编码:

| 010000 | 1101 | | |

等价的 32 位编码为:

| 1110 | 00000001 | | | | 1001 | |

从这三条指令可以总结出规律:16 位 Thumb 指令集保留了 32 位 ISA 的大部分功能,但大多数操作退化为二操作数格式——必须共享一个操作数,寄存器数量从 16 减少到 8。

有些更复杂的指令在 Thumb 中干脆没有对应物。典型如乘-累加指令 MLA(在 ARM922T 中实际上是 4 操作数运算)。

例 9.5 MLA 指令的编码

计算 的 32 位编码如下:

| | 0000001 | S | | | | 1001 | |

可以看到这是 4 操作数运算(两个乘数、一个加数、一个目标),4×4 位寄存器字段加上条件码和功能位已经把 32 位填满,因此在 16 位 Thumb ISA 中没有任何指令格式能容纳它。

MLA(也叫 MAC)没有进入 16 位 Thumb 指令集相当可惜,因为 DSP 运算中到处都是乘-累加。这也提醒我们:选处理器时,指令集对目标算法的适配程度是实打实的性能差距。

3. Xilinx 与 Altera 器件上的 ARM Cortex-A9

时间推进到双核时代:Xilinx 和 Altera 都推出了包含 ARM Cortex-A9 双核处理器的新器件系列。Altera 的 Arria V 与 Cyclone V 器件,以及 Xilinx 的 Zynq-7000 器件,都集成了 A9 双核。双方使用的 Cortex-A9 版本几乎相同,因此选择哪家供应商,关键往往在器件附带的其他功能和硬 IP 上:Xilinx 器件具备双 12 位 1 MSPS ADC 和更大的片上内存(可能允许在片上引导操作系统);Altera 系列则拥有更快的收发器速度(高达 100Gbps)以及更多的逻辑资源(LE 和乘法器)。

图 9-17 展示了 ARM Cortex-A9 内核。它具备现代 32 位微处理器的标准功能:

  • 800MHz 双核处理器
  • 每 MHz 2.5 DMIPS 的双发射超标量流水线
  • 32KB 指令 + 32KB 数据的 L1 4 路组关联高速缓存
  • 共享 512KB、8 路组关联的 L2 缓存
  • 32 位定时器与看门狗

还有一系列先进功能:

  • 动态分支预测
  • 带有推测执行的乱序多发射指令队列
  • 从 32 个体系结构寄存器到 56 个物理寄存器的寄存器重命名
  • 面向 128 位 SIMD 处理的 NEON 媒体处理加速器
  • 支持 +、-、×、/ 和平方根的单/双精度浮点单元
  • 用于代码压缩的 Thumb-2 技术
  • 32 位/64 位/128 位可配置的 AMBA AXI 接口
  • 支持 CAN、I²C、USB、Ethernet、SPI、JTAG 等多种 I/O 标准
  • 与 L1/L2 缓存协同保证数据一致性的 MMU

其中”乱序执行 + 寄存器重命名”值得初学者留意:程序写的是 32 个逻辑寄存器,硬件内部实际有 56 个物理寄存器,重命名机制消除了假的数据相关,让不相干的指令并行执行——这就是每 MHz 能达到 2.5 DMIPS 的原因。操作系统支持方面,开源系统有 Linux、Android 2.3 和 FreeRTOS;商业系统有 WindRiver Linux、VxWorks、iVeia Android 或 Xilinx PetaLinux。


图 9-17 ARM Cortex-A9 整体体系结构 [380]

9.5.2 软内核微处理器

看完了硬内核,回到软内核。Altera 和 Xilinx 都提供自家的专用软内核,它们的共同设计哲学是:不试图复制行业标准处理器,而是充分利用现有 FPGA 的特殊硬件元件来压缩面积、提高速度。例如,Xilinx PicoBlaze 利用 LE 可用作双端口 RAM 的特性,把处理器的面积做得极小;Altera 的 Nios 处理器用 M4K 存储模块代替寄存器文件,从而省下大量 LE。

最常用的基于 FPGA 的行业标准处理器,则由第三方供应商通过 FPGA 供应商合作计划提供,常见的有 Motorola 的 68HC11、Microchip 的 PIC、Texas Instruments 的 TMS320C25 等。下面依次细看这些软内核。

1. 8 位处理器:Xilinx PicoBlaze

为什么 8 位微控制器至今仍是销量冠军?市场数据给出了答案:每年销售约 30 亿个 8 位控制器,而 4 位和 16/32 位控制器加起来只有约 10 亿。原因很朴素——4 位控制器达不到要求的性能,16/32 位控制器又太贵。许多指令集都有对应的 8 位软内核,如 Intel 的 8080/8051、Zilog 的 Z80、Microchip 的 PIC 系列、MOS Technology 的 6502(早期 Apple 和 Atari 计算机的明星)、Motorola/Freescale 的 68HC11、Atmel AVR 等。当前控制器完整列表可查阅 www.edn.com/microprocessor 网站。

微控制器市场最重要的驱动力是汽车和家电。例如一辆汽车里只有音响和引擎控制需要高性能微控制器,其余 50 多个微控制器都用在电动后视镜、气囊、速度表、门锁这类”够用就好”的设备上。Xilinx PicoBlaze 正是为这类应用而生:它提供一个免费且无版权费的开发平台,汇编器/链接器/加载器和源代码都不收费。它为 Xilinx 器件深度优化(很多函数的底层 LUT 实现类似 ALU,寄存器文件使用双端口存储器——这使它很难移植到 Altera 器件上),内核极小。关键功能与性能数据(依器件系列而异)如下,参见图 9-18:

  • 16 字节宽的通用数据寄存器
  • 256~1024 条指令字的程序空间
  • 带进位和零标志的字节宽 ALU 运算
  • 64 字节内部暂存 RAM
  • 256 个输入/输出端口
  • CALL/RETURN 栈的 4~31 个位置
  • 每条指令需要两个时钟周期
  • 性能从 CoolRunner-II 的 21 MIPS 到 Virtex-4 的 100 MIPS
  • 指令长度 16~18 位
  • 8~16 个 8 位寄存器
  • 占用 76~96 个切片(Virtex/Spartan),或在 CoolRunner-II 中占 212 个宏单元

还有 Francesco Poderico 编写的免费 C 编译器,同样免版权费,可从 www.xilinx.com 下载。


图 9-18 Xilinx 的 PicoBlaze(即 KCPSM 内核)

例 9.6 用 C 编译器编译 DSPstone 基准

看一段最简单的 C 代码在 PicoBlaze 上会变成什么:

// DSPstone benchmark 1
char a, b, c, d;
void main()
{ d = c + a * b; }

C 编译器将其编译成如下 PicoBlaze 汇编代码(原书给出的是编译器输出,此处为整理后的等价汇编清单):

;**********************************************************************
; Picoblaze Small C Compiler for Xilinx PicoBlaze
; Picoblaze C Compiler for PicoBlaze, Version alpha 1.7.7
;**********************************************************************
NAMEREG sf, XL
NAMEREG se, YL
NAMEREG sd, ZL
NAMEREG sc, XH
NAMEREG sa, ZH
NAMEREG sb, TMP
NAMEREG s9, SH
NAMEREG s8, SL
NAMEREG s7, KH
NAMEREG s6, KL
NAMEREG s5, TMP2
CONSTANT a, ff
CONSTANT b, fe
CONSTANT c, fd
CONSTANT d, fc
LOAD YL, fc
JUMP _main
; // DSPstone benchmark 1
; char a, b, c, d;
; void main() {
_main:
; d = c + a*b;
        INPUT  ZL, _c          ; 读入变量 c
        SUB    YL, 01          ; 指针减 1
        OUTPUT ZL, (YL)        ; 压栈保存
        INPUT  ZL, _a          ; 读入变量 a
        SUB    YL, 01
        OUTPUT ZL, (YL)
        INPUT  ZL, _b          ; 读入变量 b
        INPUT  XL, (YL)        ; 恢复 a
        ADD    YL, 01
        LOAD   XH, XL          ; 把 a 扩展成 16 位有符号数(高 8 位补符号)
        AND    XH, 80
        JUMP   Z, L2
        LOAD   XH, ff
L2:     LOAD   ZH, ZL          ; 把 b 扩展成 16 位有符号数
        AND    ZH, 80
        JUMP   Z, L3
        LOAD   ZH, ff
L3:     CALL   _sign_mult      ; 调用符号乘法子程序
        INPUT  XL, (YL)
        ADD    YL, 01
        ADD    XL, ZL          ; 加上 c
        OUTPUT XL, _d          ; 存回 d
_end_main: JUMP _end_main      ; end of program!
; MULT SUBROUTINE —— 用移位-加法实现 16 位乘法
_mult:
        LOAD  TMP, 0f
        LOAD  SL, XL
        LOAD  SH, XH
        LOAD  XL, 00
        LOAD  XH, 00
_m1:    SR0   ZH
        SRA   ZL
        JUMP  NC, _m2
        ADD   XL, SL           ; 乘数该位为 1,累加被乘数
        ADDCY XH, SH
_m2:    SL0   SL
        SLA   SH
        SUB   TMP, 01
        JUMP  NZ, _m1
        LOAD  ZL, XL
        LOAD  ZH, XH
        RETURN
_sign_mult:                     ; 处理符号:负数先取补码
        LOAD  TMP2, 00
        LOAD  TMP, XH
        AND   TMP, 80
        JUMP  Z, _check_member2
        LOAD  TMP2, 01
        XOR   XL, ff
        XOR   XH, ff
        ADD   XL, 01
        ADDCY XH, 00
_check_member2:
        LOAD  TMP, ZH
        AND   TMP, 80
        JUMP  Z, _do_mult
        XOR   TMP2, 01
        XOR   ZL, ff
        XOR   ZH, ff
        ADD   ZL, 01
        ADDCY ZH, 00
_do_mult:
        CALL  _mult
        AND   TMP2, 01
        JUMP  NZ, _invert_mult
        RETURN
_invert_mult:                   ; 结果为负,再取补码
        XOR   XL, ff
        XOR   XH, ff
        ADD   XL, 01
        ADDCY XH, 00
        RETURN
; 0 error(s) in compilation

仔细数一数这条一行 C 语句的代价:编译器用掉了大量指令;更糟的是,PicoBlaze 像大多数 8 位微控制器一样没有硬件乘法器,乘法要靠一长串”移位 + 条件加法”实现(上面的 _mult 子程序循环 16 次),外加符号处理,速度进一步被拖垮。结论很清楚:8 位软内核适合控制类任务,不适合计算密集的 DSP。

Altera 虽不提供自己的 8 位微控制器,但其 AMPP 合作方支持多种指令集软核,如 8081、Z80、68HC11、PIC 和 8051(参见表 9-21)。Xilinx 器件上除 PicoBlaze 外也支持 8051、68HC11 和 PIC ISA。

表 9-21 汇总了各 8 位软核的资源占用与速度,供应商缩写:DI = Dolphin Integration(法国);CI = CAST 有限公司(美国新泽西);DCD = Digital Core Design(波兰)。

微处理器名称所用器件LE/切片BRAM/M9K速度(MHz)供应商
C8081EP1S10-520613108CI
CZ80CPUEP1C6-6389782CI
DF6811CPUStratix-72220473DCD
DFPIC1655XCyclone-II-6663N/A91DCD
DR8051Cyclone-II-62250N/A93DCD
Flip8051XC2VP4-71034N/A62DI
DP8051Spartan-III-51100N/A73DCD
DF6811CPUSpartan-III-51312N/A73DCD
DFPIC1655XSpartan-III-5386352DCD
PicoBlazeSpartan-III96188Xilinx

注意表格的最后一行:PicoBlaze 只用约 96 个切片(底层优化后仅 177 个 4 输入 LUT)和 1 个 BlockRAM 就实现了完整 8 位 CPU,是 Xilinx 器件上最小、最快的 8 位微控制器——比表中的第三方内核小一个数量级。

2. 16 位处理器:Altera Nios

Nios 嵌入式处理器是数据通路为 16 位或 32 位的可配置 RISC 处理器。它的卖点是”可配置”:可以用任意数量的外围设备构建 Nios 嵌入式系统。图 9-19 显示了 Nios 处理器的 SOPC Builder 32 位标准配置。


图 9-19 SOPC Nios 32 位标准处理器模板

表 9-22 给出了 Nios 基础内核的尺寸以及常用 IP 外围设备,这些外围设备与标准 Nios 处理器集成在一起,构成完整的微处理单元。大多数外围设备都可以参数化以符合特定应用,并可在单个系统中多次例化;用户自定义逻辑也能与 Nios 集成,交付独一无二的微处理器。用 Altera 的 SOPC Builder 工具,几分钟就能完成自定义微处理器的创建,综合后可运行在任何 Altera FPGA 上。除表中列出的 IP 内核外,SOPC Builder 还支持来自 Altera 及其兆函数合作计划(AMPP)的 IP 内核。

表 9-22 Nios 内核和外围尺寸(LE 数量及嵌入式阵列模块 M9K)

单元LEM9K
16 位数据通路 Nios9502
32 位数据通路 Nios12503
UART,固定波特率170
定时器244
串行外围接口 SPI:8 位主,1 位从103
SPI:8 位主,2 位从108
通用 I/O:32 位,三态138
SDRAM 控制器380
外部存储器/外围设备:32 位110
外部存储器/外围设备:16 位85

流水线:从”好预测”到”高性能”

版本低于 2.0 的 Nios 采用 3 级流水线(加载、解码、执行),每条指令占用可预测的一段时间——这对实时系统非常友好。版本 2.0 及以上改用 5 级流水线和复杂的提取逻辑,包括互锁与冒险管理。流水线逻辑对程序员隐藏了这些细节,但也使执行时间更难通过指令条数来分析,因为每条指令的延迟依赖于前后指令、操作数、存储位置等诸多因素。Altera 为实际延迟可能很大的指令提供了最佳估计,典型指令的最小时钟周期数见表 9-23。

表 9-23 5 级流水线 Nios 处理器的 Altera 时钟周期估计 [381]

函数名称存储位置时钟周期注释
ASR、ASRI、LSL、LSLI、LSR、LSRI1移位操作
MUL216×16→32 位
JMP、CALL2控制流
LD、ST片上2加载和存储
TRET3返回函数
TRAP4保持处理器
LD、ST片外4加载和存储

从表中能读出一个重要规律:同一条 LD/ST 指令,访问片上存储器要 2 个周期,访问片外存储器要 4 个周期——存储器放在哪里,对性能影响巨大(这一点在后面 MicroBlaze 的例 9.7 中还会量化验证)。

自定义指令:Nios 的独门武器

由于具备自定义指令功能,Altera Nios 有别于市场上的其他软内核方案,如图 9-20 所示。自定义指令的思路是:在硬件中实现标准指令集里没有的复杂操作,把软件中需要很多条指令才能完成的”单指令宏”压缩为一条指令。自定义指令既可以实现单周期(组合逻辑)操作,也可以实现多周期(时序)操作;而且用户添加的自定义指令还能访问 Nios 系统外的存储器和逻辑。作为本书案例研究的一部分,后面将研究基 2 FFT 和 DCT 所需的位反序操作的自定义实现——这正是自定义指令的典型用武之地。

由于 FPGA 器件的密度范围宽、Nios 嵌入式系统规模小,系统设计者可以把复杂问题拆成多个小任务,用多个 Nios 处理器并行处理;这些处理器可以通过种类丰富的外围设备灵活定制,比构建一个非常复杂的单微处理器系统简单得多。以低成本器件为目标,定制的嵌入式系统能以业内的最低成本实现。


图 9-20 Nios 处理器的自定义指令功能

图 9-21 所示的 Nios 处理器内核具有流水线的通用 RISC 体系结构 [353, 382~385]。32 位处理器拥有一条 16 位指令总线和一条 32 位数据总线。寄存器文件可以配置成 128、256 或 512 个寄存器,但通过软件利用滑动窗口机制一次只能把其中 32 个作为通用寄存器访问——大量内部寄存器用于加速子例程调用和局部变量访问(函数调用时窗口整体滑动,省去了保存/恢复现场的指令)。CPU 模板通常还配置指令缓存和数据缓存来提高性能。Nios 指令集可配置以提升软件性能,还可通过添加自定义指令或使用处理器模板提供的预定义指令集扩展进行修改。

表 9-24 列出了优化 Nios 处理器的三个预定义乘法器选项,以及各自的周期数和规模:

  • MUL 指令:包含硬件 16 位 × 16 位整数乘法器
  • MSTEP 指令:提供在一个时钟周期内执行一步 16×16 位乘法的硬件
  • 软件乘法:用 C 运行时库,通过移位序列和加法指令实现整数乘法

表 9-24 Nios 处理器内核乘法器选项

乘法选项32 位乘积所需时钟周期硬件工作量
软件800
MSTEP1814~24 个 LE
MUL3427~462 个 LE

三个选项都可用于实现 16×16 位乘法,额外硬件工作量随总体处理器体系结构不同而变化。这张表是”面积换速度”最直观的教材:软件方案零成本但要 80 个周期,MUL 方案花 400 多个 LE 把周期数砍到 3。如果算法里乘法是热点(DSP 几乎总是如此),这 400 个 LE 花得非常值。


图 9-21 Nios 处理器内核

其他的 16/24 位微处理器由 IP 供应商提供,它们重构了标准的 PDSP(Motorola 56000、TI TMS320C25)或 GPP(Motorola 68000)。表 9-25 总结了可用内核及其所需资源,供应商:CI = CAST 有限公司(美国新泽西);DCD = 数字核设计公司(波兰)。

表 9-25 FPGA 16/24 位 ISA 支持

微处理器名称所用器件LE/片BRAM速度(MHz)供应商
C32025TXStratix II391618 M4K68CI
C68000Stratix V4429114CI
D68000Cyclone-66604n/a44DCD
C80186ECStratix IV8042 LEs90CI
C322025 PDSPKintex-7983 slices159CI
D68000Virtex-II PRO-73415n/a65DCD

3. 32 位处理器:Xilinx MicroBlaze

最后以 Xilinx MicroBlaze 为例研究一个 32 位软内核。MicroBlaze 是哈佛体系结构的 32 位 RISC 处理器(数据与指令各一套总线),有 3~5 级流水线。标准配置的关键功能如下:

  • 区域优化型:3 级流水线内核,性能 1.03 DMIPS/MHz
  • 性能优化型:5 级流水线内核,性能 1.38 DMIPS/MHz
  • ALU、移位器和 32×32 的寄存器文件是标准配置


图 9-22 Xilinx 的 MicroBlaze 软内核体系结构内核

生成时可选的配置项包括:

  • 筒状移位器
  • 阵列乘法器
  • 除法器
  • 支持加、减、乘、除和比较的浮点单元
  • 2KB~64KB 的数据缓存
  • 2KB~64KB 的指令缓存

5 级流水线按如下步骤执行:(1) 提取,(2) 解码,(3) 执行,(4) 存储器访问,(5) 写回。数据和指令缓存采用直接映射结构,可以按 4 字或 8 字的行访问;用一个(或多个)BlockRAM 存储数据,另一个 BlockRAM 存储标记。下面研究一个典型的缓存配置。

例 9.7 MicroBlaze 缓存配置

目标:只用两个 BlockRAM 设计 1 个缓存。先算”账本”:Spartan-3 与 Spartan-6 的 BlockRAM 容量为 16Kbit(即 2KB)。对 32 位字宽,单个 BlockRAM 可存 个字。比 2KB 更小的缓存并不能真正节省资源,所以 2KB 就是可用的最小缓存。

接下来决定每行放 4 个字还是 8 个字。通常每行 8 字可以寻址更大的外部存储器,但每行 4 字的解码器更快,因此先选每行 4 字,看看能寻址多大的外部存储器:

  • 缓存共 512 字,每行 4 字 → 共 行 → 标记存储器存 128 个标记;
  • 标记 BlockRAM 需配置成 的存储器;
  • 每行需要 1 个有效位,每字 1 个有效位共 4 个,于是标记最多可用 位;
  • 外部存储器地址空间 = 27 位标记 + 缓存内部寻址 2KB 所需的 11 个低位( 字 = 2KB,其中行地址 7 位 + 行内字地址 2 位 + 字节地址 2 位);
  • 合计 位地址,即 的可寻址空间。

这远大于实际的主存储器,甚至大于 MicroBlaze 本身的 32 位地址空间——说明两个 BlockRAM 的缓存配置在地址空间上绰绰有余。实际应用中(用 13 个标记位寻址 Nexys 开发板的 16MB 空间)的配置如图 9-23 所示。


图 9-23 MicroBlaze 缓存对 16MB 存储器的配置

Xilinx 的顶级器件允许使用 64KB 缓存。此时光缓存数据就需要 32 个 BlockRAM()。如果再用 BlockRAM 存标记,又要决定每行 4 字还是 8 字。以每行 4 字为例:64KB = 16K 字 → 行,需要 的标记配置。因为要 4 个字有效位加 1 个行有效位,单个 BlockRAM(36 位宽)不够用,需要两个 BlockRAM 存标记。两个 BlockRAM 提供的标记位是 位?不对——按每标记存储器的组织,标记用两个 BlockRAM 时每个标记可用 3 位以上,即主存储器大小为 512KB;而且每增加一个标记 BlockRAM,可寻址主存就扩大 16 倍( 倍)。其他配置方案可参阅原书练习 9.33 和 9.34。

有了缓存,MicroBlaze 还剩一个问题:什么存储器配置才能获得最优性能?Fletcher [386] 用 Dhrystone 基准评估了这一问题,结果见表 9-26。对于几乎所有嵌入式应用(程序和数据保存在外部 SRAM、FPGA 内只驻留处理器),增加片上数据缓存和/或程序缓存都会提高性能(表中第 2 行 vs 第 1 行:7.139 → 29.13 DMIPS)。但真正的大招是:把整个主存储器都放进 FPGA 内部。数据放片内 BRAM 后达到 47.81 DMIPS;数据和程序都放片内则达到 59.78 DMIPS——比”外部 SRAM + 双缓存”还高一倍。要注意,表 9-26 的 Dhrystone 基准足够小,能整个放进 FPGA 的 BlockRAM;传统计算机中的 SPEC 基准可比它大得多,不可能这样做。这再次印证了表 9-23 的结论:片内存储访问快得多,缓存只是”够不着片内大存储”时的补救。

表 9-26 不同存储器组织的 DMIPS 比较(D = 数据;I = 程序存储器)

外部SRAM D外部SRAM I缓存 D缓存 IBRAM DBRAM ILEDMIPS
----87187.139
--907629.13
----881247.81
----871859.78

另一种 32 位微处理器由 Altera 和 IP 供应商提供(如 Motorola 68000 的重构版)。Altera Nios II 有三个版本:快速型为 6 级流水线,性能 1.13 DMIPS/MHz;标准型为 5 级流水线,性能 0.64 DMIPS/MHz;经济型内核最小、仅 1 级流水线,性能 0.15 DMIPS/MHz。表 9-27 总结了可用内核及其资源(供应商:CI = CAST 有限公司;N/A = 未知数据)。

表 9-27 FPGA 32 位 ISA 支持

微处理器名称所用器件面积BRAM/M9K速度供应商
C68000-AHBStratix II4053 ALUT598 MHzCI
Nios-II 快速型Stratix IV900 ALMN/A340 DMIPSAltera
Nios-II 标准型Stratix V700 ALMN/A170 DMIPSAltera
Nios-II 经济型Stratix V350 ALMN/A50 DMIPSAltera
C68000-AHBVirtex-61466 slices5125 MHzCI

9.6 案例研究

最后学习三个更详细的设计项目。第一个是完整的零地址(即栈机)HDL 设计,它使用 9.4 节开发的汇编器和 C 编译器;第二个是基于 LISA 的 DWT 处理器设计,表明用几条 LISA 操作就能构建从简单微处理器到实际向量处理器的各种设计项目 [387];最后一个案例揭示自定义 DSP 模块如何与 Altera 的 Nios 处理器紧密耦合,并讨论 FFT 蝶形处理器的选择与软硬件优化 [388, 389]。

9.6.1 T-RISC 栈处理器

设计思路:为什么选栈机

从最简单的栈机开始。栈机又叫零地址机——ALU 指令不写任何寄存器地址,操作数隐含在栈顶。但在指令中仍需一些位来定义直接操作数(如存储器地址、立即数)。设计选择:8 位数据、4 位指令,这样一条 12 位指令可定义 16 条指令,其高 4 位是操作码、低 8 位是操作数,在仿真中一个半字节序列很容易表示。指令集的划分如下:

  • 7 条 ALU 指令(操作码 0~6),使用栈顶元素 TOS(Top Of the Stack)和第二个元素(如适用):
    • 4 种算术运算:ADD、SUB、MUL、INV
    • 3 种逻辑运算:OPAND、OPOR、OPNOT
  • 5 条数据移动指令,把数据移入或移出栈顶:
    • POP <var>:把 TOS 的数据字存入存储位置 var
    • PUSH <var>:从存储器加载数据字 var 并压入 TOS
    • PUSHI <imm>:把立即值 imm 压入 TOS
    • SCAN:从输入端口读取 8 位数据并压入 TOS
    • PRINT:从 TOS 取出 8 位数据写到输出端口
  • 4 条程序控制指令:
    • CNE 和 CEQ:比较栈顶两个元素,相应地设置跳转控制寄存器 JC
    • CJP <imm>:若 JC 为真,则 PC 加载立即值 imm
    • JMP <imm>:PC 加载立即值 imm

时序:为什么要两个时钟周期

由于 Cyclone II 器件只能实现同步存储器,输入数据或地址必须先经过寄存器。这意味着”更新 PC → 用 PC 读程序存储器 → 解码 → 执行”不可能在一个时钟周期内全部完成,最少要两个时钟周期(一升一降两个沿各干一件事)。图 9-24(a) 给出了实现时序:

  1. PC 在第一个下降沿更新;
  2. 更新后的 PC 作为程序存储器地址输入,程序存储器的输出数据在下一个上升沿存入 PROM 输出寄存器;
  3. 指令被解码后:对 POP 操作,在下一个下降沿把 TOS 存入数据存储器;对任意 ALU 或 PUSH 操作,数据经 ALU 路由,并在下一上升沿存入 TOS 寄存器,同时完成栈的更新。

栈深度方面,流行的 HP41 袖珍计算器使用的 4 值栈就足够了。正因为栈这么短,用 4 个寄存器实现比用 LIFO 的 M9K 存储模块更简单高效。以上时序适用于除控制流指令外的所有指令。条件跳转比较特殊:比较结果必须先存入 JC 寄存器,所以要多花一个时钟周期——第一步更新 JC 寄存器,下一时钟周期才根据 JC 更新 PC,如图 9-24(b) 所示。


(b) 条件跳转指令所用的二指令序列的时序
图 9-24 T-RISC 操作的时序

例 9.8 栈机的 Verilog 实现

原书 VHDL 改写为等价的 Verilog HDL(可综合风格)。下面的代码实现了这个 4 值栈机,程序存储器中预置了计算阶乘的测试程序:

// Title: T-RISC stack machine 4/e
// Description: T-RISC 顶层控制通路(三相位单时钟设计)
// 零地址(栈机)类型指令字,栈深 4 字
// 原书 VHDL 改写为 Verilog
module trisc0
  #(parameter WA = 7,          // 地址位宽 -1
            WD = 7)            // 数据位宽 -1
  (input  wire       clk,      // 系统时钟
   input  wire       reset,    // 异步复位
   output wire       jc_out,   // 跳转条件标志
   output wire       me_ena,   // 存储器使能
   input  wire [7:0] iport,    // 输入端口
   output reg  [7:0] oport,    // 输出端口
   output wire [7:0] s0_out,   // 栈寄存器 0(栈顶)
   output wire [7:0] s1_out,   // 栈寄存器 1
   output wire [7:0] dmd_in,   // 数据存储器读数据(可视)
   output wire [7:0] dmd_out,  // 数据存储器写数据(可视)
   output wire [7:0] pc_out,   // 程序计数器
   output wire [7:0] dma_out,  // 数据存储器地址(写)
   output wire [7:0] dma_in,   // 数据存储器地址(读)
   output wire [7:0] ir_imm,   // 立即数
   output wire [3:0] op_code); // 操作码
 
  // 指令操作码定义(16 条指令)
  localparam [3:0] add  = 4'd0,  sub  = 4'd1,
                   mul  = 4'd2,  inv  = 4'd3,
                   opand= 4'd4,  opor = 4'd5,
                   neg  = 4'd6,  pop  = 4'd7,
                   push = 4'd8,  pushi= 4'd9,
                   scan = 4'd10, print= 4'd11,
                   ceq  = 4'd12, cne  = 4'd13,
                   cjp  = 4'd14, jmp  = 4'd15;
 
  // 程序 ROM 定义与内容(阶乘测试程序)
  reg [11:0] rom [0:19];
  initial begin
    rom[ 0]=12'h801; rom[ 1]=12'h700; rom[ 2]=12'ha00;
    rom[ 3]=12'h701; rom[ 4]=12'h901; rom[ 5]=12'h801;
    rom[ 6]=12'hc00; rom[ 7]=12'he11; rom[ 8]=12'h900;
    rom[ 9]=12'h901; rom[10]=12'h600; rom[11]=12'h700;
    rom[12]=12'h901; rom[13]=12'h801; rom[14]=12'h200;
    rom[15]=12'h701; rom[16]=12'hf04; rom[17]=12'h900;
    rom[18]=12'hb00; rom[19]=12'hf00;
  end
 
  // 数据存储器定义
  reg [7:0] dram [0:(1<<(WA+1))-1];
 
  reg [7:0]  pc;                     // 程序计数器
  reg        jc;                     // 跳转条件寄存器
  reg        mem_ena;                // 存储器写使能
  reg [11:0] pmd;                    // 程序存储器输出寄存器
  wire[11:0] ir    = pmd;            // 指令寄存器
  wire[3:0]  op    = ir[11:8];       // 操作码(指令译码)
  wire[7:0]  dma   = ir[7:0];        // 数据存储器地址
  wire[7:0]  imm   = ir[7:0];        // 立即操作数
  wire[7:0]  dmd   = dram[dma];      // 数据存储器读数据
 
  reg [7:0] s0, s1, s2, s3;          // 4 值栈
  integer   idma;                    // 整数型地址索引
  reg [2*WD+1:0] temp;               // 乘法临时变量
 
  // P1: 处理器 FSM —— 存储器使能与 PC/JC 更新
  always @(op) begin
    case (op)                        // 除分支外都写存储器
      pop:    mem_ena = 1'b1;
      default: mem_ena = 1'b0;
    endcase
  end
 
  always @(posedge clk or posedge reset) begin
    if (reset) pc <= 8'd0;
    else begin
      if (((op == cjp) && !jc) || (op == jmp))
        pc <= imm;                   // 加载跳转目标
      else
        pc <= pc + 8'd1;             // 顺序执行
    end
  end
 
  always @(posedge clk or posedge reset) begin
    if (reset) jc <= 1'b0;
    else jc <= ((op == ceq) && (s0 == s1)) ||
               ((op == cne) && (s0 != s1));
  end
 
  // 程序 ROM:下降沿更新 PC,下一上升沿锁存指令
  always @(posedge clk or posedge reset) begin
    if (reset) pmd <= 12'd0;
    else       pmd <= rom[pc];       // 从 ROM 读取
  end
 
  // 数据 RAM:POP 在下降沿写入
  always @(negedge clk) begin
    idma = dma;                      // 地址转整数索引
    if (mem_ena) dram[idma] <= s0;   // 写入 RAM
  end
 
  // P3: 栈与 ALU 操作(所有算术/逻辑/栈更新集中于此)
  always @(posedge clk or posedge reset) begin
    if (reset) begin
      s0 <= 8'd0; s1 <= 8'd0;
      s2 <= 8'd0; s3 <= 8'd0;
      oport <= 8'd0;
    end else begin
      case (op)                      // 栈的移位操作
        push, pushi, scan:
          begin s3<=s2; s2<=s1; s1<=s0; end  // 压栈类
        cjp, jmp, inv, neg: ;        // 分支类不动栈
        default:                     // 弹栈类:其余指令
          begin s1<=s2; s2<=s3; s3<=8'd0; end
      endcase
      case (op)                      // 指定栈顶运算
        add:   s0 <= s0 + s1;
        neg:   s0 <= -s0;
        sub:   s0 <= s1 - s0;
        opand: s0 <= s0 & s1;
        opor:  s0 <= s0 | s1;
        inv:   s0 <= ~s0;
        mul:   begin
                 temp = s0 * s1;
                 s0  <= temp[WD:0];  // 截取低 8 位
               end
        pop:   s0 <= s1;
        push:  s0 <= dmd;
        pushi: s0 <= imm;
        scan:  s0 <= iport;
        print: begin oport <= s0; s0 <= s1; end
        default: s0 <= 8'd0;
      endcase
    end
  end
 
  // 额外的测试引脚(可视化输出)
  assign pc_out  = pc;   assign ir_imm  = imm;
  assign op_code = op;   // 程序信息
  assign jc_out  = jc;   assign me_ena  = mem_ena; // 控制信号
  assign s0_out  = s0;   assign s1_out  = s1;      // 栈顶两元素
  assign dmd_out = dmd;  assign dma_out = dma;     // 数据存储器 I/O
  assign dma_in  = dma;  assign dmd_in  = dmd;
 
endmodule

代码的组织顺序是:先是通用参数定义(实体即模块端口和测试引脚),随后把 16 条指令的操作码以参数形式列出。第一个 always 块是控制处理器的有限状态机(FSM);程序和数据存储器分别用 ROM 阵列和 RAM 阵列实现;包括栈更新在内的所有运算都集中在最后一个 always 块(相当于 ALU)中。用常数命名操作码的好处是代码非常直观易读。该设计使用 171 个 LE、一个 M9K(VHDL 编码)或两个 M9K(Verilog 编码)和 1 个嵌入式乘法器,用 TimeQuest 缓慢 85C 模型评估的时序性能 Fmax = 92.66MHz。

仿真验证:阶乘程序的仿真结果如图 9-25 所示。程序开始时先从 iport 加载输入值,然后开始阶乘计算:先判断循环变量是否大于 1,若是则把 x 乘以 k,随后 k 递减并跳回循环开头。两次循环后程序结束,阶乘结果 被传送到 oport。


图 9-25 阶乘示例的 T-RISC 仿真

9.6.2 LISA 小波处理器的设计

为什么需要 ESL 和 LISA

与算法的直接硬件实现相比,用微处理器实现是更经济的 FPGA 资源利用方式,微处理器也因此成为近年 FPGA 供应商最重要的 IP 模块:Altera 报告仅前三年就销售了 1 万套 Nios 开发系统,Xilinx 报告 MicroBlaze 的下载数量更大。

新一代设计工具让软件开发人员能把算法表达式直接植入 FPGA 硬件,而无须学习传统硬件设计技术。这类工具和方法统称为电子系统级(ESL)设计——从比主流 HDL 更高的抽象层次出发进行系统设计与验证。其中,指令集体系结构语言(LISA)允许设计人员只用几个 LISA 操作就精确指定处理器的指令行为或周期行为,然后用工具生成器和探查器分析、探索体系结构(参见图 9-26),再通过自动合成的 VHDL 或 Verilog 代码确定速度、规模和功耗参数。ESL 工具存在已久,过去大家认为它们主要服务于 ASIC 设计流程;但在 65nm 工艺 ASIC 掩膜费高达 400 万美元的背景下,基于 FPGA 的设计数量正在迅速增加。事实上,越来越多的 ESL 工具商(Celoxica、Codetronix、Synopsys、Binachip、Impulse Accelerated、Mimosys 等)都把主要精力转向了可编程逻辑器件。

如今大多数微处理器用在嵌入式系统中,这并不奇怪:一台典型的家用电脑只有一颗高性能微处理器,但家里可能有几十个嵌入式系统(影音设备、家电、通信设备),一辆现代汽车通常有 50 多个微处理器。嵌入式处理器通常由小团队在短时间内针对市场需求开发,处理器设计自动化因此非常重要。设计流程一般是:先在远高于指令集的抽象层次上做几个体系结构探索周期,找到最佳的硬件/软件划分,再通过现有硬件综合工具在 FPGA 上实现。但配套的软件开发和分析工具通常要手写——这是迄今为止嵌入式处理器设计成本高、效率低的根源。LISA 处理器设计平台(LPDP)最初由德国亚琛理工大学集成信号处理系统研究所开发,现为 Synopsys 公司产品。它以非常创新的方式解决了上述问题(见图 9-26):LISA 语言支持以分析为基础的处理器模型逐步细化,精度可达单个周期,并能生成 VHDL 或 Verilog 的 RTL 综合模型,从而完美避免了传统设计流程中不可避免的模型不一致问题。从简单 RISC 到高度复杂的 VLIW 处理器,都已经在 FPGA(用 LPDP)和基于单元的 ASIC 上成功实现。


图 9-26 LISA 开发工具:(左) 反汇编程序;(中) 存储器监视器和流水线分析;(右) 文件和寄存器窗口

Synopsys 提供 14 种不同的模型,其中 7 个用作培训材料的教学模型;有些模型有多个版本,如 QSIP_X 模型就有十多种不同设计。4 个初始模型用作新体系结构的启动框架,另有 3 种 IP 模型对应传统体系结构。所有模型都精确到指令,且大多数是哈佛类型 RISC 模型,同样精确到周期,流水线级数在 3~5 级之间。模型覆盖所有类型的现代处理器:从简单 RISC(QSIP)、PDSP(如 LT_DSP_32p3)、VLIW(LT_VLIW_32p4),到特殊处理器(如 16~4096 点的 FFT 处理器 LT_FFT_48p3)。表 9-28 列出了部分示例模型的属性。

表 9-28 LISA 示例模型(CC = 由 C 编译器生成)

名称CC流水线级数说明
QSIP_X3哈佛 RISC 体系结构;12 个不同的教学版本;单周期 ALU;流水线和零流水线版本
LT_DSP_32p33带 MAC 的单周期 ALU;零开销循环;32 位指令;24 位数据通路;48 位累加器
LT_VIEW_p44类似 QSIP 的 ISA;并行加载/存储;并行算术指令

LISA 18 位指令字 RISC 处理器:NanoBlaze

Xilinx 提供 32 位的 MicroBlaze 和 8 位的 PicoBlaze,但缺一个 DSP 算法常用的 16 位(或 24 位)处理器。下面就基于 LPDP 设计这样一台 16 位 RISC 计算机。由于 16 位处理器恰好填补 MicroBlaze 和 PicoBlaze 之间的空白,将其命名为 NanoBlaze。

设计从 LISA 2.0 的 QSIP_12 模型(3 级流水线 RISC 教学设计)出发,扩展 ISA 使其更适合 FPGA。一个关键的 FPGA 特性约束:Xilinx FPGA 的 BlockRAM 都是 18 位宽,所以指令字也应设计为 18 位——用 BlockRAM 时指令字短于 18 位纯属浪费。同时,QSIP 模型中的字节宽度访问应改为统一的 18 位指令和数据访问,这一转换涉及指令计数器、存储器配置的 *.cmd 文件、step_cycle 以及数据装载指令 LDL、LDH、LDR。NanoBlaze 支持的指令如下:

  • 算术/逻辑单元(ALU)指令:
    • ADD:3 操作数加法(两个源操作数、一个目标操作数)
    • MUL:3 操作数乘法,乘积只保留低于 16 位的部分
  • 数据移动指令:
    • LDL:用常数值加载数据字的低 8 位
    • LDH:用常数值加载数据字的高 8 位
    • LDR:从存储器加载到寄存器,存储位置可用常量显式指定或经通用寄存器间接指定
    • STR:把寄存器内容存入存储器,位置同样可显式或间接指定
  • 程序控制指令:
    • BC:条件分支,检查(循环)寄存器是否为零
    • B:无条件转移
    • BDS:延迟分支——条件 BC 满足时,BDS 之后的那条指令也会执行(用来填充流水线延迟槽)

DWT RISC 处理器的基本指令集由这 9 条指令组成,用 28 种 LISA 操作设计而成。执行流水线级中指令的编码如图 9-27 所示。


图 9-27 NanoBlaze 指令集体系结构

NanoBlaze 可以在 FPGA 中综合并实现。根据所用存储器类型(基于 CLB 或 BlockRAM)的不同,综合结果见表 9-29(器件为 Xilinx XC3S1000-4ft256)。

表 9-29 NanoBlaze 在 Xilinx 器件上的综合结果

参数使用基于 CLB 的 RAM使用 BlockRAM
切片18961893
4 输入 LUT34433602
乘法器11
BlockRAM02
总门数32 986162 471
时钟周期13.293 ns13.538 ns
Fmax75.2 MHz73.9 MHz

例 9.9:DWT 处理器的指令剖析——找到瓶颈

如果用这台 RISC 处理器实现图 5-57 所示的长度为 8 的 DWT 处理器,需要两个长度为 8 的滤波器 ,每产生一对输出采样需要 16 次乘法和 14 次加法。对 100 个采样做 2 倍向下采样后,DWT 滤波器频带的运算量为 次乘法和 次加法。

图 9-28 的指令分析(LISA Operation Profile)显示:实际的乘法数量正是预期的 800 次(MUL 一行 Calls=800),但加法指令(ADD)的数量是 2850 次——是预期 700 次的 4 倍多!为什么会这样?

LISA Operation Profile
Name Calls Calls/Total Calls/Max Calls/Min
LDL   261   0.66%  4.13%
LDH   309   0.78%  4.89%
LDR  1600   4.04% 25.30%
STR   200   0.51%  3.16%
ADD  2850   7.20% 45.07%
MUL   800   2.02% 12.65%
BC      0   0.00%  0.00%
B       0   0.00%  0.00%
BDS   150   0.38%  2.37%
No Pipe AG in pipe EX in pipe FD in pipe

图 9-28 100 点且长度为 8 的双通道 DWT 的 NanoBlaze 操作分析(含存储器初始化,共 300 条指令)

原因有二。其一,大量的加法用于更新存储器指针寄存器——在 RISC 中,地址计算也走通用 ALU;其二,总共执行了 1600 次 LDR 加载操作(占总周期的 25.3%,是单项最高)。这两个观察指向同一个结论:如果像 PDSP 中的 MAC 运算那样,采用自动递增的间接存储器访问(读数据的同时指针自动加一),指针更新加法和大批加载操作就能大幅削减,速度将从根本上得到提升。这正是专用 PDSP 数据通路(带自动递增地址寄存器)与通用 RISC 的本质差别,也是后面案例研究中”Nios + 自定义 FFT 蝶形单元”紧耦合设计要解决的问题。

2. LISA 可编程数字信号处理器(DSP18)

上一节用 LISA 生成的 NanoBlaze 已经能跑 DWT 程序了,但只要仔细看一下指令流,就会发现一个明显的瓶颈:真正”干活”的运算很少,大量指令都花在了取数和更新地址指针上。本节就来看如何给处理器加上 DSP 味道的指令,把它变成一台真正的可编程数字信号处理器 DSP18。

为什么需要 MAC 指令

先回顾一下 NanoBlaze 上一次乘累加要写的汇编代码:

; use pointer R[2] and R[3] to load operands
LDR R[8], R[2]
LDR R[9], R[3]
; increment register pointer using R[1]=1
; multiply and add result in R[4] and avoid data hazards
ADD R[2], R[2], R[1]
MUL R[7], R[8], R[9]
ADD R[3], R[3], R[1]
ADD R[4], R[4], R[7]

这 6 条指令做的事情其实很机械:用指针 R[2]、R[3] 各取一个数,把指针加一,两数相乘,再把乘积累加进 R[4]。问题在于其中只有 1 条是乘法,剩下 5 条全是”搬运”和”数数”。DSP 算法(滤波、卷积、小波)本质上都是对线性数据阵列(向量)做重复运算,存储器指针几乎总是按固定步长递增或递减前进——既然如此,为什么不让硬件自动完成后递增,把 6 条指令压缩成 1 条呢?

这就是经典 MAC(Multiply-Accumulate,乘累加)指令的由来:

; load and multiply the values from pointer R[2] and R[3],
; and add the product to register R[4]
MAC R[4], R[3], R[2]

一条 MAC 同时完成:按 R[2]、R[3] 间接取两个操作数、相乘、累加到 R[4]。要支持它,指令集(图 9-29 给出了 DSP18 在 NanoBlaze ISA 基础上的全部加法)需要两个关键扩展:

  • 两次间接寻址:一条指令要同时取两个操作数,就必须有两个地址生成单元,并且数据存储器要有双输出端口——在一个时钟周期内完成两次读操作。没有双端口,MAC 就得拆成两条指令,加速效果大打折扣。
  • 新增 MAC 的 LISA 操作:告诉 LISA 编译器这条指令的编码、语法和行为。


图 9-30 MAC 运算的 DSP18 测试平台

用 LISA 描述 MAC 指令

在 LISA 中,一条指令由几部分构成。MAC 的 LISA 操作如下:

/* This LISA operation implements the instruction MAC. */
/* It accumulates the product of two register and stores */
/* the result in a destination register. */
OPERATION MAC IN pipe.EX
{
    DECLARE
    {
    REFERENCE address;
    REFERENCE reg;
    }
    CODING { 0b01101 }
    SYNTAX { "MAC" }
    BEHAVIOR
    {
    short tmp1, tmp2, s1, s2; /*Temporary */
    short tmp_reg;
    short res;
 
    tmp_reg = reg;
 
    s1 = (data_mem[EX.IN.ar] & (char)0xffff);
    s2 = (data_mem[EX.IN.ar1] & (char)0xffff);
    res = tmp_reg + s1 *s2;
 
    #pragma analyze (off)
    printf("%04X * %04X + %04X = %04X\n", s1, s2, tmp_reg, res)
 
    #pragma analyze (on)
    reg=res;

逐块理解一下:DECLARE 部分声明引用了其他 LISA 操作中已定义的元素(地址和寄存器);CODING 给出操作码 0b01101SYNTAX 规定汇编书写形式就是 MACBEHAVIOR 才是真正的行为描述——从两个间接地址 arar1 读出操作数 s1s2,计算 res = tmp_reg + s1*s2,写回目标寄存器。中间那对 printf 很有意思:它只出现在仿真阶段,不生成任何硬件,作用是把每次 MAC 的运算过程打印到调试器窗口里。也就是说,不改一行硬件,就能在图 9-30 下方那个窗口实时监视 MAC 的输入输出——这正是 LISA 这类”处理器描述语言”相比直接写 HDL 的效率优势:仿真观测点和指令行为写在一起。

验证与综合结果

继续在 LISA 环境中向下流程走,就能合成出这台新处理器——我们叫它 DSP18。由于指令集里加入了 PDSP 式的功能,可以用 ModelSim 跑测试平台仿真。

例 9.10 用 ModelSim 验证生成的 HDL 代码功能。LISA 工具流(LPDP)会自动生成所有必需的 HDL 代码(VHDL 或 Verilog)和仿真脚本(ModelSim 的 *.do 文件)。测试数据取 x = [1, 2, 3],g = [10, 20, 40],MAC 的累加过程一步步展开是:

(1) MAC = 1 × 10 = 10

(2) MAC = 2×20 = 40 => 40+10 = 50

即三次乘法的结果 10、40、120 逐次累加,最终 R[4] 中应得到 170。图 9-30 的 ModelSim 波形中,寄存器信号 reg_r_4 正是按 10 → 50 → 170 的顺序推进的,功能正确。

接下来看 MAC 对程序长度的实际影响。同样写 100 点、滤波器长度为 8 的 DWT,800 次 MAC 运算成为绝对主力,显式加法和存储器操作大幅减少:指令总数从 NanoBlaze 的 5870 条降到 DSP18 的 1968 条(约 1/3)。图 9-31 给出了 DWT 示例的操作分析(含存储器初始化的 300 条指令):MAC 占 800 次(占总指令数 5.20%,占该类最大比例 33.04%),而 NanoBlaze 里占大头的取数类指令被 MAC 吸收掉了。


图 9-32 VMIPS 向量处理器

代价是硬件变大、变慢了:DSP18 比 NanoBlaze 规模更大、寻址模式更复杂,基于 CLB 的分布式 RAM 实现时整体时钟性能降到 39 MHz,用 BlockRAM 时为 51 MHz。表 9-30 是 XC3S1000-4ft256 Spartan-3 器件上的两种存储器配置对比:

参数基于 CLB RAM 的 DSP18BlockRAM 的 DSP18
切片31452679
4 输入 LUT60535183
乘法器22
BlockRAM02
总门数81 509177 203
时钟周期25.542 ns19.565 ns
39.15 MHz51.11 MHz

注意一个规律:换成 BlockRAM 后切片反而更少(嵌入式存储器替代了分布式 LUT 逻辑),虽然总门数统计值变大,但时钟周期从 25.5 ns 缩短到 19.6 ns。这正是选择存储器实现方式时要权衡的典型取舍。

3. LISA 真向量处理器(TVP)

从 ILP 困境到向量思想

通用 CPU 这些年的改进路线是:挖掘指令级并行(ILP)、加大片上缓存和浮点单元、推测性分支执行、提高主频。但出现了一个特殊问题:跟踪所有在执行指令之间依赖关系的逻辑,其规模随指令数量平方增长。于是从 2002 年起这些改进明显放缓,业界转向在同一管芯上放多个 CPU,而不是继续提频。可多核意味着程序员要写并行代码,效率未必高。

向量处理器提供了另一条路——它在 ILP 计算机出现之前很久就已商用成功(如 Cray、NEC 和 Fujitsu VP100),用深层流水线驱动多个功能单元,直接提供操作”向量”(数据的线性阵列)的高级指令。典型特征有四条:

  • 向量阵列有专用的加载/存储单元;
  • 功能单元高度流水线化;
  • 风险(冒险)控制最小化;
  • 一条向量指令替换了单条指令的完整循环。

图 9-32 是一个试验性向量处理器 VMIPS——流行 MIPS 计算机的向量扩展,2001 年推出:8 个向量寄存器(每个 64 个元素)、一个加载/存储单元加 5 个算术单元、单通道、500 MHz。以 DSPstone 第二个基准 为例,在 VMIPS 里只需一条指令:MULV.D V1,V2,V3——向量 V2 和 V3 的元素逐对相乘,结果放入向量寄存器 V1。

传统向量处理器的局限

不过图 9-32 也暴露了真相:典型向量处理器只是对程序员看上去像向量机。内部每种运算通常只有一个浮点算术单元,向量乘法或加法仍需 N 个时钟周期(不计初始化)。多通道(每周期多次浮点运算)极少见:近 30 年历史上只有 NEC SX/5(1998 年起,16 通道)和 Fujitsu VPP5000(1999 年起)超过 10 条通道,通道数与寄存器元素数之比也只有约 3%(SX/5 每向量 512 个元素配 16 条通道)。限制的根本原因是 64 位浮点单元的管芯规模太大。

更麻烦的是,传统向量指令对 DSP 运算的支持很有限:

  1. 内积不被支持。DSP 中最常见的不是逐元素相乘,而是内积:

乘法可以逐元素并行,但求总和需要把所有乘积在加法器树中累加,向量指令通常不做这件事。

  1. 向量寄存器不能(循环)移位。比如 FIR 应用这一步用元素 ,下一步就要 。PDSP 用循环寻址轻松解决,向量处理器却往往要重新加载整个向量。


图 9-33 真向量处理器(True Vector Processor, TVP)指令集加法

FPGA 上的”真向量”改进

FPGA 恰好能补齐这两个短板,图 9-33 给出了 TVP 在指令集上的三项加法:

  • 增加向量移位指令 VSXYVSGH:从数据存储器加载两个字,对数据(X、Y)或系数(G、H)向量寄存器做移位,把两个新值放到第一个位置——等价于 PDSP 的循环寻址,彻底免去重新加载整向量;
  • 增加向量乘法指令 VMUL:现代 FPGA 最多可有 512 个嵌入式乘法器,因此向量里有多少元素就能放多少乘法器。VMUL 一次执行 次乘法,把乘积放入两个乘积向量寄存器 P 和 Q;
  • 增加向量求和(内积)指令 VAPVAQ:把(乘积)寄存器向量中的所有元素一次性累加。


图 9-34 真向量处理器体系结构

我们把它称为真向量处理器(True Vector Processor, TVP),因为向量运算不再是”一串单独乘法的伪装”——所有运算是真正并行执行的(图 9-34)。代价直观:双通道、长度为 8 的小波处理器需要 16 个嵌入式乘法器。低成本的 Nexys Digilent 大学开发板上的 Spartan-3 器件 XC3S1000-4ft256 有 24 个 18 位×18 位嵌入式乘法器,够 TVP 用了。

加法器树:把串行加法变成并行

内积和(inner product sum)的速度关键在横向加法:对有 L 个元素的向量寄存器,朴素做法要做 L−1 次串行加法。更好的做法是二进制加法器树。VAP 指令的 LISA 代码示例如下:

/* Vector scalar add of all P register */
OPERATION VAP IN pipe.EX {
DECLARE
{
REFERENCE dst;
}
CODING { 0b100101 }
SYNTAX { "VAP" }
BEHAVIOR
{short t1,t2,t3,t4,t5,t6,t7;
t1 = P[0] + P[1];
t2 = P[2] + P[3];
t3 = P[4] + P[5];
t4 = P[6] + P[7];
t5 = t1 + t2;
t6 = t3 + t4;
t7 = t5 + t6;
dst = t7;
}

前 4 条加法(t1~t4)在同一层并行发生,t5、t6 在第二层,t7 在第三层——最差情况延迟从 7 次加法降到 3 次( 层)。行为描述里的写法直接映射到硬件结构,这正是 LISA 的好处之一。

TVP 跑 DWT 有多省

用 TVP 的 ISA 实现长度为 8 的 DWT,内层循环只剩 9 条指令。DWT 的二倍向下采样意味着向量 X 和 Y 对每个新输出采样要移位两次,所以循环开头有两条 VSXY:

_loop:
VSXY R[2],R[3]
VSXY R[2],R[3]
VMUL
VAP R[4]
VAQ R[5]
STR R[4], R[6]
STR R[5], R[6]
BDS @_loop, R[7]
; next instruction is in the branch delay slot
SUB R[7],R[7],R[1]

每轮循环:两次移位送入新样本、一次 VMUL 并行算出 16 个乘积、VAP/VAQ 各用 3 层加法树完成低通和高通两路内积、两条 STR 存结果、BDS 循环减跳转(注意分支延迟槽里塞了一条 SUB 更新计数器)。与 DSP18 相比指令总数进一步下降,从图 9-35 的操作分析可见,TVP 总指令数只有 479 条。


图 9-35 长度为 8 的双通道 DWT 的 TVP 操作分析

资源与速度的代价见表 9-31:并行度上去了,切片、LUT、乘法器全面增加,最大工作频率反而更低——典型的”以面积换速度(吞吐量)“。

参数仅有的微处理器带有 BlockRAM
切片49074993
4 输入 LUT88509226
乘法器1818
BlockRAM02
总门数141 158274 463
时钟周期22.082 ns20.799 ns
45.3 MHz48.1 MHz

4. LISA 处理器设计的比较

现在把三种设计放到同一把尺子下:长度为 8 的 DWT,比较规模、速度和总吞吐量(MSPS,每秒百万采样)。表 9-32 汇总了关键综合属性,所用器件为 Nexys Digilent 大学开发板上的 Spartan-3 XC3S1000-4ft256(见 http://www.digilentinc.com/ ),有 7680 个切片、15360 个 4 输入 LUT、24 个嵌入式乘法器和 24 个 BlockRAM(每个 18 Kbit)。顺带一个有用的估算经验值:用基于 CLB 的分布式 RAM 实现存储器时, 的存储器约需 800 个 4 输入 LUT, 的约需 120 个。

参数NanoBlazeDSP18TVP
LISA 运算283240
程序存储器
数据存储器
BRAM222
门数162 471177 203274 463
MHz73.951.1148.1

先算清算法本身的账:实现图 5-57 的长度为 8 的 DWT,每个输出采样对需要两个长度为 8 的滤波器 ,即 16 次乘法和 14 次加法。100 次采样、输出减半的情况下,计算量是 次乘法、 次加法,也就是 800 次 MAC 调用。但 NanoBlaze 的指令分析显示,实际程序里还有大量 LDR 和 ADD——因为更新存储器指针也得用通用 ALU 算。三种设计的具体指令构成见表 9-33:

参数NanoBlazeDSP18TVP
LDL2592597
LDH2082087
LDR160000
VSXY107
VSGH8
VMUL50
VAP50
VAQ50
MAC8000
STR100100100
ADD26503000
SUB5050
MUL80000
BC000
B000
BDS505050
合计56671767479
时钟周期76.2854.9844.72
MSPS1.353.119.54

逐列对比非常有说服力:NanoBlaze 的 1600 条 LDR 和 2650 条 ADD 几乎全是”无效功”;DSP18 用 MAC 把它们归零,虽然时钟频率从 73.9 降到 51.11 MHz,但总吞吐量反而提高约 2 倍(1.35 → 3.11 MSPS);TVP 则用向量指令把整个内积压进两个时钟周期,总吞吐量达到 9.54 MSPS——比 NanoBlaze 高 8 倍,比单核 MAC 的 DSP18 高 4 倍。结论很清晰:提升吞吐量的关键不是提频,而是减少每输出采样所需的指令数

最后把三种基于 LISA 的处理器和一种直接 RNS 多相硬件实现(4217 个 LE,155 MSPS)放在一起(图 9-36):从 NanoBlaze 到 TVP 性能有大幅提升,但直接映射到硬件的实现仍然比任何微处理器方案快一个数量级。不过别忘了可编程性的价值——硬件体系结构只能实现一种固定配置,而 TVP 这样的软件体系结构可以实现多种不同算法。选哪条路,取决于你牺牲灵活性换性能,还是牺牲性能换灵活性。


图 9-36 基于 LISA 的处理器和直接 RNS 多相实现的比较

9.6.3 Nios 自定义指令设计

位反向:为什么它是 CI 的绝佳案例

第 6 章讲过,FFT 和 DCT 算法的输入或输出以位反向(bit-reversal)顺序出现,如图 9-37 的长度为 8 信号流图所示。把索引写成二进制就能看明白:,即所有位的位置整个翻转,存储器位置 6 和 3 需要交换值。这种纯按位操作用软件做又慢又啰嗦,用一小块组合逻辑做又快又省——所以它正是”自定义指令”(Custom Instruction, CI)案例研究的理想对象。


图 9-37 长度为 8 的信号流程图,输出数据 X[k] 以位反向顺序出现

做 CI 设计的起点,可以用 TERASIC 的 Nios II 设计实例或 Altera 大学项目提供的计算机系统。Altera 大学程序计算机随多个 Quartus II 版本提供,支持 DE0、DE1、DE2、DE2-70、DE2-115 等多块 TERASIC 板卡,分两类:基础计算机(包含开关、LED、SRAM 和 DRAM 内存)和媒体计算机(额外含若干音频、图像处理 IP 块)。本案例用基础计算机即可,还能缩短编译时间。实践建议:用与设计工具和开发板匹配的媒体/基础计算机版本起步,然后添加 CI 文件——这比试图修改另一个系统容易得多。

CI 适用的边界

现代软核处理器允许通过外部总线把自定义指令和相关逻辑紧密集成进内核,没有长延时。但有几个与处理器和算法相关的问题要心里有数:

  • 对 Xilinx PicoBlaze 或 Nios II/e 这类”慢”处理器,CI 能带来最大改进;对 Nios II/f 这类高流水线处理器,改进可能不显著,CI 甚至可能让流水线处理器更慢。
  • 只有当硬件实现本身快速紧凑时,才期望算法有大改进。位反向(DCT、FFT 或开关盒加密算法里都需要)就是 CI 带来大改进的好例子;反例是 256 点 FFT 用蝶形处理器只能提高 45%~77%——如果 CI 里投入大量设计努力只换来相对自定义算法电路的小改进,就不划算了。

Nios 在 SOPC Builder 和 Qsys 环境中支持多种类型的 CI。这里关注 Qsys(只有它在未来的 Quartus 版本中被继续支持),正在使用的基端口如图 9-38(b) 所示。Nios II 允许五种 CI 类型:

  1. 纯组合电路功能:三个端口,没有时钟;
  2. 固定多周期:增加一个 clock 输入,要求在指定数量的周期后完成计算;
  3. 可变多周期:使用附加的 done 端口指示操作完成;
  4. 扩展型 CI:带一个 8 位端口 n,允许复用不同输出端口;
  5. 内部寄存器文件型:三个 I/O 端口各自带 32 个字的寄存器文件。


图 9-38 (a) 为 Nios ALU 增加自定义逻辑;(b) 自定义逻辑模块的物理端口

1. 自定义逻辑模块的创建和集成

先把 Altera 基础计算机重命名为新项目 DE2_115_CI_Computer,需要改名并修改 *.qsf*.vhd*.qpf 文件的名称和内容,可能还要把名为 nios_system 的组件目录复制进项目。HDL 与其他组件文件由 Qsys 放在 nios_system→synthesis→submodules 下,以后可以修改。具体流程:启动 Qsys,单击 New…,从 Template 菜单选 Combinational(组合逻辑)模板——位反向不需要时钟,正合适;然后在 Files 下用 Verilog 添加组件描述。本例实现 4 位、8 位、12 位、16 位四种宽度的位反向,通过 datab 端口传入位宽 b 来选择。注意 Qsys 对端口名称有约定(dataa、datab、result),可能需要按它的要求修改。原书给出的 VHDL 自定义指令模板改写为等价的 Verilog HDL(可综合风格)如下:

// Verilog Custom Instruction for bit-reversal (Combinational type)
// 原书 VHDL 改写为 Verilog
module nios_system_CI_SWAP_0 (
    input  wire        clk,               // 组合型 CI 不使用时钟
    input  wire [31:0] ncs_cis0_dataa,    // 操作数 A(必需)
    input  wire [31:0] ncs_cis0_datab,    // 操作数 B(可选):位宽选择
    output wire [31:0] ncs_cis0_result    // 结果(必需)
);
 
    function [31:0] bitrev;
        input [31:0] a;
        input [4:0]  b;      // 要反向的位数 4/8/12/16
        integer k;
        begin
            bitrev = 32'd0;
            for (k = 0; k < b; k = k + 1)
                bitrev[k] = a[b-1-k];   // 位序翻转
        end
    endfunction
 
    assign ncs_cis0_result = bitrev(ncs_cis0_dataa, ncs_cis0_datab[4:0]);
 
endmodule

这段逻辑是纯组合电路:bitrev 函数按选择的位宽 b,把输入 a 的第 k 位接到结果的第 b−1−k 位上,实现位序完全翻转。因为没有任何时序元件,综合后就是一个纯布线网络,运行速度极快。图 9-39 的 ModelSim 仿真展示了 4、8、12、16 位四种位反向结果:可以清楚看到半字节位置的变化,而半字节内部则是位反向(如 )。


图 9-39 位反向操作的 MODELSIM 仿真

为评估 Nios 自定义指令的收益,我们比较三种实现:软件实现、使用 Altera 自带的 SWAP CI、以及上面自定义的四种位宽 CI。

2. 软件实现

软件版位反向取决于待反向的值 a 和字中的位数 b,两者都要传给交换函数:

int SW_BITSWAP(int a, int b) { int lsb, k, r=0;
    int t=a;
    for (k=0;k<b;k++)
    {
    lsb = t & 1; // take LSB
    r = r*2 + lsb; // add lsb and shift left
    t >>= 1; // shift to right by one bit
    }
    return(r);
}

思路是逐位处理:每轮取出 t 的最低位 lsb,把结果 r 左移一位(乘 2)再把 lsb 拼进去,同时 t 右移一位丢掉已处理的位。循环 b 次后,r 就是从最低位开始重新拼出来的反向值。功能正确,但每轮循环都是好几条指令,对 CPU 来说很昂贵。

3. Altera 自定义逻辑模块的实例化

Altera 在 CI 库里提供了一个现成的 32 位反向模块 Bitswap(图 9-40):在 Qsys 中右击它,用 Add 加进 CI 计算机系统即可。但注意它是固定 32 位的位反向——如果只想反向 b 位,就得在 32 位反向之后再把结果移到正确位置(在 C 里做 >> (32-b)),这需要几个额外的时钟周期,开销取决于所用的 Nios 处理器类型。想避免额外移位,就该自己设计一个新 CI,直接支持全部四种位宽。


图 9-40 Altera CI 库和 Qsys 中设计的 CI 计算机

4. 新的 CI 设计

把新 CI 加入 CI 计算机系统后,软件侧只需要一条指令调用,不再需要任何额外移位:

a_swap = ALT_CI_CI_SWAP_0(a, b);

对比一下三条路线:软件函数是一整个循环;Altera CI 是”硬件反向 + 软件移位”两步;自定义 CI 是单条指令一步到位——位宽选择直接做进硬件里。

5. Nios 位反向性能结果

测量方法要先交代:Nios 处理器的时钟定时器 alt_nticks() 在工作状态下通常每秒约 100 个周期,为了测量有意义,单次测量的周期数应在 100 左右,即任何算法至少要跑 1 秒。对三种算法各测 次位反向操作的时间,结果绘于图 9-41(a)。


图 9-41 CI 转换操作的性能:(a) 个转换操作所需时间 (b) CI 速率提升因子

加速比按式 (9-8) 计算:

从图 9-41(b) 可见:带移位附加需求的 Altera CI 达到 619 倍改进;而我们自己的 CI 因为不需要额外移位,相对纯软件实现了 929 倍加速。这印证了前面的判断——把”移位对齐”这类多余步骤消掉,CI 的收益还能再上一个台阶。

完整的测试软件见本书学习资料的 my_swap.c,也可用于测试 SW 或 CI。它分三部分:第一部分从十六进制模式 0x12345678 开始,展示三种算法在四种位宽下的位反向结果;第二部分做时序测量;第三部分用 DE2 开发板的 LED 和开关做位反向的互动实验。核心循环如下:

#define switches (volatile short *) 0x10000040
#define leds (short *) 0x10000040
...
b=16; while (1) { /* run forever */
    a = switches; / read the switch value*/
    //a_swap = SW_BITSWAP(a,b);
    a_swap = ALT_CI_NIOS_CUSTOM_INSTR_BITSWAP_0(a);
    a_swap >>=32-b; // For 32-bit Altera type swap
    //a_swap = ALT_CI_CI_SWAP_0(a,b);
    *leds = a_swap; /* Display on LEDs */
}

先从 system.h 得到 LED 与开关的 I/O 地址,定义为 16 位短整数指针;然后 while 循环永续运行:读开关值 a,计算三种位交换算法之一(代码里用注释切换),结果显示在红色 LED 上。拨动开关、看 LED 的即时反向输出——这就是自定义指令最直观的验证方式。

缓存映射推演与 PREP 基准电路:从习题到 Verilog 实现

本单元来自第 9 章末尾的习题区,涉及两大块内容:一是缓存(Cache)的地址映射、容量与开销计算,以及 MicroBlaze 缓存所需 BlockRAM 数量的估算;二是两个经典的 PREP 基准电路——16 位可预置递增计数器(基准 7)和微处理器系统的存储器地址译码器(基准 9)。这些题目看似零散,其实都在回答同一个问题:当你真的要在 FPGA 里搭一个微处理器系统时,存储器和控制逻辑是怎么一回事。下面按小节逐一展开,所有原书 VHDL 代码均改写为等价的 Verilog HDL。

缓存地址映射的手工推演

为什么要亲手推一遍缓存表

缓存的核心矛盾是:主存很大、缓存很小,主存的数据块放到缓存的哪个位置、放不下时替换谁,决定了命中率。教材给出了三种典型映射方式:

  • 直接映射:主存地址取模,只能放唯一一行。硬件最省,但冲突多。
  • 全相联:可以放到任意空行。最灵活,但比较器最多。
  • 组相联:折中方案,先按地址选组,组内任意放。

纸上推演一遍访问序列,是理解这些差异最快的方式。习题 9.30 给出的访问序列是:2、6、3、2、2、3——准确说是 2、6、3、2、3 共 5 次访问,让我们分别用全相联和两路组相联推演。

全相联映射:从第一个空位开始放(表 9-41)

表 9-41 的结构是”地址 → 缓存内容”,缓存有 4 行(编号 0~3)。规则是:命中则直接用;缺失则装入第一个未使用的位置。推演过程如下:

步骤访问地址结果行0行1行2行3
12缺失,装入行02
26缺失,装入行126
33缺失,装入行2263
42命中263
53命中263

5 次访问命中 2 次,命中率 。注意这里因为序列很短、缓存足够大,根本没触发替换;如果序列再加一个 7,前 4 行都满了,就得按替换策略(如 LRU)腾位置。

两路组相联:先分组、组内放空位(表 9-42)

表 9-42 把 4 个缓存行分成 2 组,每组 2 路(way0 / way1),每路都有自己的标志(tag)。主存地址先用 选组,再在组内找空位或命中项。推演如下:

步骤地址组号 (地址 mod 2)结果组0 way0组0 way1组1 way0组1 way1
120缺失,装组0 way02
260缺失,装组0 way126
331缺失,装组1 way0263
420命中(组0)263
531命中(组1)263

命中率同样是

两种方式在短序列上结果相同,但代价结构完全不同:全相联需要把地址与所有 4 行的标志同时比较(4 路比较器),组相联每组只需比较 2 路。这就是”组相联用更少的硬件换取接近全相联的灵活性”的直观体现。若把这个推演换成更长的序列(比如习题 9.29 的原始序列),就会看到替换策略开始起作用,组相联的命中率优势才会显现。

不做这个推演会怎样

很多初学者会把”缓存命中率”当成一个纯理论指标。但一旦亲手填过表,你会立刻明白:命中率取决于访问序列和映射方式的匹配程度,而且映射方式直接决定了芯片里比较器的数量和宽度——这在 FPGA 上就是实打实的 LUT 资源。

缓存总容量与开销计算

为什么缓存”8KB”实际不止 8KB

缓存除了存数据,还要给每一行存一个标志(tag)和有效位(valid bit),用来判断”这行数据对应主存的哪一块”。标志存储不存数据却占硅片面积,这部分就是系统开销(overhead)。习题 9.31 就要求把这笔账算清楚。设缓存为直接映射、每行 1 个字(32 位 = 4 字节),地址宽度 32 位。

8KB 缓存的完整推导(习题 9.31)

(a) 缓存中有多少字?

(b) 缓存中有多少标志位?

2048 行需要 11 位行索引(),每个字内有 2 字节偏移()。32 位地址中剩下的就是标志:

再加 1 位有效位,每行共 位标志信息。

(c) 缓存的总容量是多少?

(d) 系统开销百分比

4KB 缓存重复一遍(习题 9.32)

同样的流程换成 4KB 数据:

  • 字数: 字;
  • 索引 10 位,字节偏移 2 位,标志 位,加有效位共 21 位;
  • 标志存储 bit B KB;
  • 总容量 KB;
  • 开销

观察:缓存变小后开销比例反而略升。原因是数据容量减半时标志数量也减半,但每行标志位宽反而增加 1 位(索引少了 1 位,标志就得多 1 位),所以标志存储缩减得比数据慢。这不是 bug,是地址位守恒的必然结果。

MicroBlaze 缓存的 BlockRAM 估算

把缓存映射到 FPGA 的物理资源

例 9.7 讨论了 MicroBlaze 的缓存如何用 FPGA 内部的 BlockRAM 实现。FPGA 的 BlockRAM 是固定大小的硬核存储块(例如 Xilinx 的 18Kb 块,即 2KB),设计者的任务是把”数据阵列”和”标志阵列”分别塞进整数个 BlockRAM 里——不能塞半块,所以要向上取整。

主存 64KB、缓存 4KB、每行 8 字(习题 9.33)

(a) 存数据需要多少 BlockRAM?

(b) 存标志需要多少 BlockRAM?

先算行数:每行 8 字 B,故

主存 64KB 对应 16 位地址。字节偏移 5 位、行索引 7 位,标志为:

加 1 位有效位,标志存储 bit,远小于一块 18Kb BlockRAM,因此只需 1 块

(c) 最大可寻址主存容量?

标志位宽决定了还能往上扩展多少主存。当前方案下地址共 位,所以最大主存就是

与题目给定一致——也就是说这套标志位宽刚好”吃满”了 64KB 主存,想扩主存就得加宽标志。

主存 16KB、缓存 2KB、每行 4 字(习题 9.34)

  • (a) 数据: 块 BlockRAM。
  • (b) 行数 行(索引 7 位);主存 16KB 对应 14 位地址;字节偏移 4 位;标志 位,加有效位共 4 位;标志存储 bit,1 块 BlockRAM 足够。
  • (c) 地址 位,最大主存

两题的规律:数据阵列决定 BlockRAM 下限,标志阵列通常很小但必须单独占块;而”最大可寻址主存”永远等于 ,做缓存扩展规划时这一步不能省。

PREP 基准 7:16 位可预置递增计数器

电路是什么、为什么拿它当基准

PREP(Programmable Electronics Performance Corporation)基准是一组小型标准电路,用来横向比较不同 FPGA/综合选项的面积与速度。基准 7(等价于基准 8)是一个 16 位二进制递增计数器,带四个控制信号:

  • rst:异步复位,低电平有效
  • ce:时钟使能,高电平有效,为 0 时计数器保持;
  • ld:同步加载,高电平有效,把 d[15:0] 置入计数器;
  • clk:上升沿触发。

它常用于微处理器系统中的定时/计数外设。功能真值表如表 9-43 所示:

clkrstldceq[15:0]
×0××0000(异步清零)
11×d[15:0](加载)
100不变
101加 1

图 9-42(c) 的仿真波形依次验证了:初值计数到 5、ld 加载测试、490ns 处的异步复位测试,以及 700~800ns 之间通过 ce 禁止计数。

原书 VHDL 改写为 Verilog

单级设计的 Verilog 实现如下(可综合风格):

// PREP 基准 7:16 位可预置递增计数器
module prep7 (
    input  wire        clk,   // 时钟,上升沿触发
    input  wire        rst,   // 异步复位,低电平有效
    input  wire        ce,    // 时钟使能,高电平有效
    input  wire        ld,    // 同步加载,高电平有效
    input  wire [15:0] d,     // 并行加载数据
    output reg  [15:0] q      // 计数值
);
    always @(posedge clk or negedge rst) begin
        if (!rst)
            q <= 16'h0000;        // 异步清零
        else if (ld)
            q <= d;               // 同步加载优先于使能
        else if (ce)
            q <= q + 16'd1;       // 计数
        // ce=0 且 ld=0 时保持不变(无需显式语句)
    end
endmodule

要点只有三处:敏感表用 posedge clk or negedge rst 表达异步复位;ld 的优先级高于 cece 无效时不写赋值,综合器自动生成保持逻辑。

时序性能与综合选项(9.35(b))

用 Quartus II 的 TimeQuest 缓慢 85C 模型测定时序电路性能 Fmax,并统计资源(LE、乘法器、M4K/M9K)。操作上:在 Assignments → EDA Tool Settings → Analysis & Synthesis Settings 中把 Synthesis Optimization Technique 分别设为 Speed / Balanced / Area,各编译一次,对比 Fmax 与 LE 数。目标器件分别为:

  • (b1) Cyclone IV E 系列的 EP4CE115F29C7;
  • (b2) Cyclone II 系列的 EP2C35F672C6;
  • (b3) MAX7000S 系列的 EPM7128LC84-7。

结论规律是通用的:Speed 选项 Fmax 最高但 LE 略多,Area 选项 LE 最少但 Fmax 下降,Balanced 居中。对这种纯计数器电路,关键路径就是 16 位加 1 的进位链,Speed 选项会让综合器插入更快的进位结构。注意本设计不含乘法器和存储块,M4K/M9K 和乘法器资源应为 0——这本身就是检验综合结果是否合理的一个快速判据。

多级原理图设计(9.35(c)、(d))

图 9-42(b) 的多级方案把 16 位计数器拆成若干个 4 位子计数器级联:低 4 位平时自行计数,进位输出作为高一级的使能。级联的好处是每个子模块的逻辑更浅,便于布局工具优化;代价是进位要穿过每一级。级数最多的方案就是把 16 位拆成 4 级。用 (b) 中选出的最优综合选项,对相同三个器件重复 Fmax 与资源测量:

  • (d1) EP4CE115F29C7、(d2) EP2C35F672C6、(d3) EPM7128LC84-7。

比较单级与多级的 Fmax,就能直观看到”逻辑分级”与”综合优化”这两条提速路径各自贡献了多少。

原书 VHDL 改写为 Verilog 后,一个 4 位子计数器级联单元可以这样写:

// 基准 7 多级方案中的 4 位子计数器
module prep7_stage (
    input  wire       clk, rst, ld, en_in,
    input  wire [3:0] d,
    output wire [3:0] q,
    output wire       en_out   // 进位/借位使能,接上一级
);
    reg [3:0] cnt;
    assign q      = cnt;
    assign en_out = en_in && (cnt == 4'hF);  // 本级计满且被使能时向上一级进位
    always @(posedge clk or negedge rst) begin
        if (!rst)      cnt <= 4'h0;
        else if (ld)   cnt <= d;
        else if (en_in) cnt <= cnt + 4'd1;
    end
endmodule

用 4 个 prep7_stage 级联(en_in 依次连接,最低级的 en_in 接顶层 ce)即可得到 16 位计数器,这与图 9-42(b) 的多级原理图一一对应。

PREP 基准 9:微处理器系统的存储器地址译码器

电路是什么

基准 9 是微处理器系统里最常见的存储器地址译码器:处理器给出 16 位地址 a[15:0] 和地址选通 as,译码器判断这次访问落在哪个外设的地址窗口内,输出 8 位独热码 q[7:0];若地址不落在任何窗口内,则置总线错误信号 be 有效。所有输出经过寄存器,由 clk 上升沿触发;rst 为低电平有效的异步复位。行为真值表如表 9-44:

rstasclkA(十六进制)q[7:0](二进制)be
0×××000000000
10×000000000
110×q[7:0](保持)be(保持)
11FFFF~F000000000010
11EFFF~E800000000100
11E7FF~E400000001000
11E3FF~E300000010000
11E2FF~E2C0000100000
11E2BF~E2B0001000000
11E2AF~E2AC010000000
11E2AA100000000
11E2AA~0000000000001

理解这张表的钥匙是地址窗口互不重叠且从高到低排列:最高窗口是 F000FFFF(第一外设),往下依次是 E800EFFF、E400~E7FF……最小的有效窗口只有一个地址 E2AA;E2AA 以下的全部地址都被判为总线错误。教材特别提醒:be 最初按”as 无效时保存”定义,但仿真结果显示的行为略有差异,编码应尽量匹配仿真结果而不是原始真值表——这是工程上的常见处理:以波形为准修真值表。

原书 VHDL 改写为 Verilog

译码逻辑本质是一串区间比较,用 if-else if 链从高地址往低地址判断即可:

// PREP 基准 9:带总线错误的存储器地址译码器
module prep9 (
    input  wire        clk,    // 时钟,上升沿触发
    input  wire        rst_n,  // 异步复位,低电平有效
    input  wire        as,     // 地址选通
    input  wire [15:0] a,      // 16 位地址
    output reg  [7:0]  q,      // 独热码外设选择
    output reg         be      // 总线错误
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            q  <= 8'h00;
            be <= 1'b0;
        end else if (!as) begin
            q  <= 8'h00;          // as 无效:清零输出
            be <= 1'b0;
        end else begin
            q  <= 8'h00;          // 默认值:无外设被选中
            be <= 1'b0;
            if (a >= 16'hF000)                     q <= 8'b0000_0001;
            else if (a >= 16'hE800)                q <= 8'b0000_0010;
            else if (a >= 16'hE400)                q <= 8'b0000_0100;
            else if (a >= 16'hE300)                q <= 8'b0000_1000;
            else if (a >= 16'hE2C0)                q <= 8'b0001_0000;
            else if (a >= 16'hE2B0)                q <= 8'b0010_0000;
            else if (a >= 16'hE2AC)                q <= 8'b0100_0000;
            else if (a ==  16'hE2AA)               q <= 8'b1000_0000;
            else                                   be <= 1'b1;
        end
    end
endmodule

注意两个易错点:其一,每个窗口的下边界就是上一个窗口的上边界减一(如 E800 是 EFFF 之下、E7FF 之上),所以只需比较 >= 下边界;其二,默认值必须先给 q=0、be=0,否则 if 链会生成锁存器,且 be 在无匹配时的置位逻辑不会正确触发。

时序性能与综合选项(9.36(b)、(c)、(d))

与基准 7 相同的流程:单级设计分别用 Speed / Balanced / Area 三种综合优化选项编译,用 TimeQuest 缓慢 85C 模型测 Fmax,统计 LE、乘法器和 M4K/M9K;在 EP4CE115F29C7、EP2C35F672C6、EPM7128LC84-7 三个器件上仿真比较。本电路的关键路径是 16 位地址的多级区间比较加 8 位寄存器,Speed 选项会优先重排比较链。

随后按图 9-43(b) 设计级数最多的多级原理图——典型做法是把地址比较按窗口分组、分组之间再寄存一级,从而把”一次比较 8 个窗口”的深组合逻辑拆浅;对 (d1)~(d3) 三个器件用 (b) 中最优综合选项重复测量,对比单级/多级在 Fmax 与 LE 上的差异。

这两个基准电路对初学者的意义

基准 7 教的是时序元件的规范写法(异步复位、使能、同步加载的优先级),基准 9 教的是组合译码加输出寄存(微处理器总线接口的标配结构)。两者都不大,但把它们在三种综合选项、三种器件下完整跑一遍”设计 → 编译 → 时序分析 → 资源统计”,你就掌握了评估任何 FPGA 数字电路的最小工作闭环。之后遇到 MicroBlaze/Nios 这类软核处理器的外设接入问题,地址译码器就是你要写的第一个自定义组件。


图 9-42(a) PREP 基准 7 的单级设计框图


图 9-42(b) PREP 基准 7 的多级原理图


图 9-42(c) PREP 基准 7 检测功能的测试平台仿真波形


图 9-43(a) PREP 基准 9 的单级设计框图


图 9-43(b) PREP 基准 9 的多级原理图


图 9-43(c) PREP 基准 9 检测功能的测试平台仿真波形