HDL4SE:软件工程师学习Verilog语言
Posted 饶先宏
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HDL4SE:软件工程师学习Verilog语言相关的知识,希望对你有一定的参考价值。
10 状态机
经过前面的学习,应该已经了解verilog的基本用法了。然而对于初学者,可能很奇怪的发现,似乎还是不会做什么东西,如果遇上一个比较复杂的问题,感觉还是无从下手。这是正常的,拿到驾照不敢上路的司机并不少见,音乐考试考了满分对着简谱还是唱不出来的学霸我也见过,通过了四六级面对老外照样说不出口的同学也大有人在。说简单点,就是缺乏实战训练。其实还有一个因素,就是缺乏一些比较高级的概念支撑。很多人听说我是学数学的来做工程师,忍不住安慰我,其实所有的工程都来源于数学…,然而就像所有的计算机程序都与图灵机的纸带程序等价,没看到谁用图灵机的纸带来编个游戏啊,工程有工程的基础,不是什么事情都从数学基础开始推导的,会背九九表的就能计算,其实不需要理解其中的数学基础。所以说缺乏训练,也缺乏一些工程上的概念,缺乏领域内成熟的方法支撑,对工程师而言,也是致命的。
本节我们介绍状态机的概念(State Machine),来支持一些算法和程序性的内容的RTL实现,还是通过把前面的俄罗斯方块游戏中的控制器逐步改成全部用verilog来实现,来体会verilog应用的开发过程。
其实某种意义上讲,做软件就是在做状态机,图灵机就是状态机嘛。把编译出来的程序放在内存中,运行到某个地址的代码时,我们就说现在计算机处于该状态,此时状态就是用CPU的PC寄存器的值。PC寄存器中的每个值就代表CPU的状态机状态,这个状态下要做的动作就是执行PC对应位置的指令。如果是执行跳转指令,则由指令来控制CPU状态机下一个状态,如果是执行其他指令,CPU就将状态自动步进到下一条指令位置。这个跟图灵机是一样的,读纸带,根据读到的内容和当前状态移动读写头,可能写纸带,然后切换状态,如此循环不已,直到到达停机条件。
在数字电路中,状态机也是一样用一个类似于PC寄存器一样的状态寄存器表示状态,然后在某个状态下面执行对应的组合电路计算并修改某些任务相关的寄存器,在某些条件下修改下一个状态,然后下一个周期又在新的状态下工作,这就i是状态机的基本概念。有了状态机的支持,我们就可以把任务就像做软件一样分解成一步一步执行的指令,然后将这些指令在状态机的控制下构成顺序,分支,循环等逻辑意义上的流程,最终完成需求规定的任务。
状态机之间可以相互嵌套,一个大的状态机执行过程中可以启动内部的状态机运行,就象软件中调用子程序一样。
讲太抽象了还是不落地啊,下面以前面俄罗斯方块游戏的控制器为例,逐步把它从c语言实现转换为用verilog实现。
10.1 顶层流程图
俄罗斯方块游戏的顶层流程图如下:
可以看到,俄罗斯方块游戏的控制器顶层状态机共有如下状态:
- 初始化,系统开始时进入这个状态,此时控制器初始化内部的状态,初始化显示屏幕内容,分数,级别,速度等。每次游戏玩到满屏时,会进入该状态重新开始游戏。
- 刷新到屏幕,每次屏幕内容更新,就需要将帧存中的内容刷新到显示屏幕上去
- 按键检测,检测是否有关心的按键,如果有按键则跳到按键处理,否则,跳到下移一行的管理
- 按键处理,有按键时跳到这里,对不同的键做出不同的处理这里是一个子状态机在控制运行,后面再详细描述
- 触底检测,判断当前块是否被底部的方块挡住,如果挡住,则进入当前块固化状态
- 当前块固化:将当前块固化在底板上相应位置
- 检测满行并消除:检测底板上的每一行是否满行,如果满行,则消除,根据消除的行数更新得分
- 生成新块:将前面生成的新块替换到当前块中,生成一个新的块,检测是否满屏,如果满屏则跳到初始化状态,否则跳到刷新到屏幕状态。
- 下移一行控制:没有按键时跳到该状态,此时增加内部一个计数器,内部根据当前速度计算一个计数器上限值,计数器到达该值时则跳到下移一行状态,否则跳到按键检测状态(此时屏幕不需要更新)。
- 下移动一行:判断是否能下移一行,如果可以,则下移一行,然后进入刷新到屏幕状态,否则进入当前块固化状态。
下面我们一步步将这个过程改为用verilog实现。
10.2 用ram基本单元来实现帧存
首先将控制器内部的帧存先用外部的ram来实现,而不是用c语言来实现。前面用c写游戏控制器时,其中的游戏面板数据放在c语言的一个数组中,总共是24行,每行16个方块,每个方块4位表示16种颜色,这样总共需要1536位,我们用64位宽的ram来存储,总共需要24个字,地址宽度为5即可。
用RAM实现时,我们用一读一写的RAM(当然可以用多个读写口的那种,不过一般FPGA和ASIC中一读一写的用得最多)。同时我们把刷新到屏幕这个模块放到verilog中来实现。我们将控制器中的当前块信息,下一块信息,得分,消除行数和速度等参数用端口连出来。为了将刷新到屏幕这个模块单独拿出来,我们还将状态变量以及状态控制信号也接出来,并且刷新到屏幕这个状态机完成后,也将完成信息送回到控制器模块中。由verilog模块来控制ram读写端口的共享。用c写的部分也调整内部逻辑,主要是存储器放到外部来了,这样一个周期只能读一个数据,因此控制方法变化比较大,c语言端也要改为全部由状态机方式编程了。
这样,用c语言写的游戏控制器接口的verilog定义如下:
(*
HDL4SE="LCOM",
CLSID="158fa52-ca8b-4551-9b87-fc7cff466e2a",
softmodule="hdl4se"
*)
module teris_ctrl
(
input wClk,
input nwReset,
output wWrite,
output [5:0] bWriteAddr,
output [63:0] bWriteData,
output [5:0] bReadAddr,
input [63:0] bReadData,
input [31:0] bKeyData,
input wStateComplete,
output wStateChange,
output [3:0] bState,
output [31:0] bScore,
output [31:0] bSpeed,
output [31:0] bLevel,
output [63:0] bNextBlock,
output [63:0] bCurBlock,
output [15:0] bCurBlockPos
);
endmodule
名字虽然与前一个版本一样,但是CLSID改了,因此编译器生成时其实生成的是新的版本。
注意到用c语言写的模块控制顶层状态机,因此它把状态机状态变化和当前状态通过端口接出来,用verilog写的代码来处理刷新到屏幕这个状态,这个状态处理完毕后通过wStateComplete信号通知顶层状态控制机。这里我们演示一种c语言与verilog语言分别写模块,然后协同工作,前面控制器完全用c写的,现在把帧存和刷新到屏幕的状态处理放在外面来了,在后面会将更多的状态放到外面用verilog语言来实现,直到全部用verilog语言实现。
刷新到屏幕用一个模块实现,接口定义如下:
module flushtodisp(
input wClk,
input [3:0] bCtrlState,
output wCtrlStateComplete,
output [5:0] bFlushReadAddr,
input [63:0] bFlushReadData,
output wWrite,
output [31:0] bWriteAddr,
output [31:0] bWriteData,
input [31:0] bCtrlSpeed,
input [31:0] bCtrlLevel,
input [31:0] bCtrlScore,
input [63:0] bNextBlock,
input [63:0] bCurBlock,
input [15:0] bCurBlockPos
);
endmodule
它接入了wClk,内部是一个时序逻辑实现的状态机,毕竟刷新到屏幕也不是一个周期内能够完成的。它接入了控制器的bCtrlState信号,表示目前控制器的状态机状态,如果状态机状态是ST_SLUSHTODISP(1),flushtodisp就开始工作,否则处于等待状态。flushtodisp开始工作后,通过bFlushReadAddr和bFlushReadData来读RAM,通过wWrite, wWriteAddr, bWriteData来写显示面板。这个模块也接入了控制器输出的Speed, Level, Score, NextBolck, CurrentBlock, CurrentBlockPos等信息,根据这些信息来生成游戏显示面板所需要的数据。
这样主控模块就比较简单了:
module main(
input wClk, nwReset,
output wWrite,
output [31:0] bWriteAddr,
output [31:0] bWriteData,
output [3:0] bWriteMask,
output wRead,
output [31:0] bReadAddr,
input [31:0] bReadData);
wire wram_Write;
wire [5:0] bram_WriteAddr;
wire [63:0] bram_WriteData;
wire [5:0] bram_ReadAddr;
wire [63:0] bram_ReadData;
/* 帧存存储器 */
hdl4se_ram1p #(64, 6) ram_0(
wClk,
wram_Write,
bram_WriteAddr,
bram_WriteData,
bram_ReadAddr,
bram_ReadData
);
/* 游戏控制器 */
wire wCtrlWrite;
wire [5:0] bCtrlWriteAddr;
wire [63:0] bCtrlWriteData;
wire [5:0] bCtrlReadAddr;
wire [63:0] bCtrlReadData;
wire [31:0] bCtrlKeyData;
wire wCtrlStateComplete;
wire wCtrlStateChange;
wire [3:0] bCtrlState;
wire [31:0] bCtrlSpeed;
wire [31:0] bCtrlLevel;
wire [31:0] bCtrlScore;
wire [63:0] bNextBlock;
wire [63:0] bCurBlock;
wire [15:0] bCurBlockPos;
teris_ctrl ctrl(wClk, nwReset, wCtrlWrite, bCtrlWriteAddr, bCtrlWriteData,
bCtrlReadAddr,bCtrlReadData, bCtrlKeyData,
wCtrlStateComplete, wCtrlStateChange, bCtrlState,
bCtrlScore, bCtrlSpeed, bCtrlLevel,
bNextBlock, bCurBlock, bCurBlockPos);
wire [5:0] bFlushReadAddr;
wire [63:0] bFlushReadData;
/* 屏幕刷新 */
flushtodisp flusher(wClk,
bCtrlState, wCtrlStateComplete,
bFlushReadAddr, bFlushReadData,
wWrite, bWriteAddr, bWriteData,
bCtrlSpeed, bCtrlLevel, bCtrlScore,
bNextBlock, bCurBlock, bCurBlockPos);
/* ram读写口仲裁 */
assign wram_Write = (bCtrlState == `ST_FLUSHTODISP) ? 1'b0 : wCtrlWrite; /*刷新模块不写ram*/
assign bram_WriteAddr = (bCtrlState == `ST_FLUSHTODISP) ? 6'b0 : bCtrlWriteAddr;
assign bram_WriteData = (bCtrlState == `ST_FLUSHTODISP) ? 64'b0 : bCtrlWriteData;
assign bram_ReadAddr = (bCtrlState == `ST_FLUSHTODISP) ? bFlushReadAddr : bCtrlReadAddr;
assign bCtrlReadData = bram_ReadData;
assign bFlushReadData = bram_ReadData;
/*我们一直在读按键的状态*/
assign wRead = 1'b1;
assign bReadAddr = 32'hF000_0000;
assign bCtrlKeyData = bReadData; /* 按键信息直接接入到控制器中去 */
endmodule
它使用了一个64位32个字的RAM来实现帧存,存放游戏面板中的方块信息。主控模块还实例化了一个c语言编写的游戏控制器实例,用来控制游戏的进程,并生成游戏面板中的方块信息写到RAM中,实例化了一个用verilog语言编写的刷新到显示的模块,在控制器的控制下,将RAM中的信息读出来,结合控制器输出的速度,级别,分数,下一块,当前块,当前块位置等信息,生成游戏显示面板所需要的信号。
主控模块还做了RAM读写口的仲裁,如果控制器状态机处在在ST_FLUSHTODISP状态,RAM就用刷新到显示模块的信号,否则就用游戏控制器的信号。
10.3 控制器模块的实现
值得注意的是,尽管控制器模块是用c语言实现的,但是由于要与刷新到显示模块共享RAM,内部也大幅度修改了实现方式。从架构上,完全改为用状态机方式实现了。我们定义了控制器状态机的状态如下:
enum {
ST_INIT, /*初始化状态*/
ST_FLUSHTODISP,/*刷新到显示*/
ST_CHECKKEY, /*检测按键*/
ST_CHECKBLOCKCANSETTO,/*测试当前块能否放在指定位置*/
ST_BLOCKWRITE,/*将当前块写到面板中,称为面板的一部分*/
ST_CHECKLINE, /*检测是否有全部由方块组成的行,并更新得分等信息*/
ST_COPYLINES, /*消除全部由方块组成的行*/
};
用c语言写状态机时,注意到状态机靠外部时钟推动,因此状态机状态转移控制代码放在IHDL4SEUnit接口函数ClkTick中:
static int terrisctrl1_hdl4se_unit_ClkTick(HOBJECT object)
{
sTerrisCtrl1* pobj;
unsigned int key;
unsigned int statecomplete;
pobj = (sTerrisCtrl1*)objectThis(object);
pobj->write = 0;
if (pobj->state == ST_INIT) {
/*
初始化状态,在c语言初始化分数,速度,级别等信息,并生成新的当前块和下一块
状态机中将显示面板中的数据全部写成0,生成一个空的面板,这里实现时在
每个时钟周期反复调用terrisctrl1_hdl4se_unit_Init函数,其实就是不断生成
写RAM的信号,将显示RAM清零。
*/
terrisctrl1_hdl4se_unit_Init(pobj);
if (pobj->genContext.complete) {
pobj->state = ST_FLUSHTODISP;
}
}
else if (pobj->state == ST_FLUSHTODISP) {
/*这个状态下,功能由外部的verilog代码来写,这里不断监视外部模块的statecomplete信号,
一旦该信号有效,表示刷新到显示的功能执行完毕,转到按键检测状态*/
objectCall3(pobj->statecomplete_unit, GetValue, pobj->statecomplete_index, 32, pobj->statecompletedata);
objectCall1(pobj->statecompletedata, GetUint32, &statecomplete);
if (statecomplete != 0)
pobj->state = ST_CHECKKEY;
}
else if (pobj->state == ST_CHECKKEY) {
/* 按键检测状态检测是否有外部送来的按键信息,如果有,则进入相应的处理,否则进入
terrisctrl1_hdl4se_unit_Tick判断是否要自动下移一行。
按键处理的通用过程是,得到按键后的方块位置和形状(比如旋转),进入判断块是否可以
放在该位置,如果能够放,则将方块位置和形状更改为新的位置和形状,如果不能,则忽略
这个按键信号。完成后进入到刷新到显示状态。当然会根据按键更新得分。
*/
objectCall3(pobj->keydata_unit, GetValue, pobj->keydata_index, 32, pobj->keydata);
if (!objectCall1(pobj->keydata, IsEQ, pobj->lastkeydata)) {
objectCall1(pobj->keydata, GetUint32, &key);
objectCall1(pobj->lastkeydata, Assign, pobj->keydata);
if (key & 1)
terrisctrl1_hdl4se_unit_PressKeyStart(pobj, TK_RIGHT);
if (key & 2)
terrisctrl1_hdl4se_unit_PressKeyStart(pobj, TK_LEFT);
if (key & 4)
terrisctrl1_hdl4se_unit_PressKeyStart(pobj, TK_DOWN);
if (key & 8)
terrisctrl1_hdl4se_unit_PressKeyStart(pobj, TK_TURNLEFT);
}
else {
/*如果没有按键信息,我们在下面的函数中递增一个计数器,如果这个计数器到达游戏
速度相关的阈值,则尝试将方块下移一行,如果不能下移,则表示方块已经触底,此时
进入将方块固化到底板的状态,否则下移一行,然后进入刷新到显示状态。
*/
terrisctrl1_hdl4se_unit_Tick(pobj);
}
}
else if (pobj->state == ST_CHECKBLOCKCANSETTO) {
/*
判断方块能否放在指定位置这个状态机比较特别,它可能从好几个地方进入,结束后需要
返回到相应的地方接着执行,就像调用一个子程序一样,因此我们特别设计了相关的控制
数据结构:
struct tagBlockCanSetToContext {
int nextstate;
int param;
int result;
int index;
int x;
int y;
int complete;
void (*BlockCanSetToPro)(sTerrisCtrl1 * pobj);
}blockCanSetToContext;
其中包括一个在状态完成后需要回调的一个函数和一个完成后需要到达的状态,
这里不断调用terrisctrl1_hdl4se_unit_BlockCanSetTo来对每个方块进行检
测,完成后设置状态机状态到指定值,然后调用回调函数。
*/
terrisctrl1_hdl4se_unit_BlockCanSetTo(pobj, ¤tblock, pobj->blockCanSetToContext.x, pobj->blockCanSetToContext.y);
if (pobj->blockCanSetToContext.complete) {
pobj->state = pobj->blockCanSetToContext.nextstate;
pobj->blockCanSetToContext.BlockCanSetToPro(pobj);
}
}
else if (pobj->state == ST_BLOCKWRITE) {
/*这个状态是发现方块无法往下移动一行的时候,将方块写入到底板的动作,
完成后进入到ST_CHECKLINE对每一行进行检测是否由全部由方块组成。
*/
terrisctrl1_hdl4se_unit_BlockWrite(pobj);
if (pobj->genContext.complete) {
pobj->state = ST_CHECKLINE;
pobj->genContext.complete = 0;
pobj->genContext.index = 0;
pobj->genContext.count = 0;
}
}
else if (pobj->state == ST_CHECKLINE) {
/*
该状态下检测每一行是否全部由方块组成,如果是,跳到ST_COPYLINES状态,
将这一行上面的所有行全部向下移动一行,否则进入刷新到屏幕状态刷新显示,
当然根据这一次检测中发现的全方块行的行数对得分等进行修改。
*/
terrisctrl1_hdl4se_unit_CheckLine(pobj);
}
else if (pobj->state == ST_COPYLINES) {
/*
该状态将全方块行上面的行都向下移动一行,最上面的行则清零,达到
消除一行的效果,完成后回到检测行的状态,继续检测是否有其他全方块行。
*/
terrisctrl1_hdl4se_unit_CopyLines(pobj);
}
else {
pobj->state = ST_FLUSHTODISP;
}
return 0;
}
用c语言按照状态机的方式来写程序,其实在工程中是经常用的,比如在串行通信协议的编写中,收到一个字符就修改当前的状态,看是否满足协议的要求,构成什么样的数据包。在编译中也比较常用,bison就是一个状态机,根据输入的token,来驱动状态机运行,执行某个状态下代码,这样就可以生成语法树等数据结构。
我们来看一个典型的过程:
static void terrisctrl1_hdl4se_unit_BlockCanSetTo(sTerrisCtrl1* pobj, TerrisBlock* pBlock, int x, int y)
{
#define RETURNRESULT(res) \\
do { \\
pobj->blockCanSetToContext.result = res; \\
pobj->blockCanSetToContext.complete = 1; \\
goto BlockCanSetTo_return; \\
} while (0)
int i;
int j;
int yy;
i = pobj->blockCanSetToContext.index / BLOCKSIZE;
yy = y - BLOCKSIZE / 2 + i + 1;
pobj->readaddr = YCOUNT - 1 - yy;
pobj->write = 0;
pobj->blockCanSetToContext.complete = 0;
if (pobj->blockCanSetToContext.index > 1) {
/*从进入这个状态的第二个周期开始进行判断,此时数据已经读入到端口上*/
i = (pobj->blockCanSetToContext.index-2) / BLOCKSIZE;
j = (pobj->blockCanSetToContext.index-2) % BLOCKSIZE;
if (pBlock->subblock[i][j] != 0) {
int xx, yy;
unsigned long long line;
xx = x - BLOCKSIZE / 2 + j;
yy = y - BLOCKSIZE / 2 + i;
if (yy < 0)
goto BlockCanSetTo_return;
if (yy >= PANELHEIGHT-1)
RETURNRESULT(0);
if (xx < 0)
RETURNRESULT(0);
if (xx >= PANELWIDTH)
RETURNRESULT(0);
objectCall3(pobj->readdata_unit, GetValue, pobj->readdata_index, 64, pobj->readdata);
objectCall1(pobj->readdata, GetUint64, &line);
line >>= xx * 4;
line &= 0xF;
if (line != 0)
RETURNRESULT(0);
}
}
if (pobj->blockCanSetToContext.index > BLOCKSIZE * BLOCKSIZE) {
pobj->blockCanSetToContext.complete = 1;
pobj->blockCanSetToContext.result = 1;
}
BlockCanSetTo_return :
pobj->blockCanSetToContext.index++;
return;
}
这个过程来判断块能否放在指定位置,相关的信息已经准备好在blockCanSetToContext中,我们的任务是对4x4的方块逐块进行检测,一方面是带颜色块必须在面板范围内,不能把带颜色小方块移动到屏幕外面去。这是靠它的坐标来判断的。另一方面是读出带颜色小方块位置的底板上是否有带颜色小块,如果有,则不能放在指定位置。16个小块都检测通过,则返回值是整个块可以放在指定位置。值得注意的是我们在这里生成的RAM读地址会延迟一拍送出去(verilog中的通过寄存器输出),然后读的值会再延迟一拍才回来,因此每一拍送的地址对应的值要两拍后才能回来。
用状态机方式编程,肯定比用通常方式编程要麻烦一些,编程思路也不一样了,然而这样做出来的模块更加接近硬件,能够与verilog编制的RTL代码协同工作,因此这种思路转换还是值得的。
10.4 刷新到显示模块的实现
刷新到显示模块是用verilog实现的,这里特别拿出来介绍,主要是考虑到原来是c语言实现的,现在用verilog语言实现了,可以前后对比以下,来体会用verilog语言写程序的不同,另一方面也为将来更多的模块用verilog实现打下一个基础。
该模块的代码如下:
module flushtodisp(
input wClk,
input [3:0] bCtrlState,
output wCtrlStateComplete,
output [5:0] bFlushReadAddr,
input [63:0] bFlushReadData,
output wWrite,
output [31:0] bWriteAddr,
output [31:0] bWriteData,
input [31:0] bCtrlSpeed,
input [31:0] bCtrlLevel,
input [31:0] bCtrlScore,
input [63:0] bNextBlock,
input [63:0] bCurBlock,
input [15:0] bCurBlockPos
);
wire [31:0] bNextBlockLo = bNextBlock[31:0];
wire [31:0] bNextBlockHi = bNextBlock[63:32];
wire [31:0] bCurBlockLo = bCurBlock[31:0];
wire [31:0] bCurBlockHi = bCurBlock[63:32];
wire [4:0] bCurBlockX = bCurBlockPos[4:0];
wire [4:0] bCurBlockY = bCurBlockPos[12:8];
/* 目前编译器还不支持reg和always块,因此直接用基本单元来做寄存器 */
wire HDL4SE:软件工程师学习Verilog语言(十四)