HDL4SE:软件工程师学习Verilog语言(十四)

Posted 饶先宏

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HDL4SE:软件工程师学习Verilog语言(十四)相关的知识,希望对你有一定的参考价值。

14 RISC-V CPU初探

前面我们介绍了verilog语言的基本语法特征,并讨论了数字电路设计中常用的状态机和流水线结构,然后我们借鉴SystemC的做法,引入了HDL4SE建模语言,以及相应的仿真系统,当然同时提供了从verilog语言到这种建模语言的编译系统(部分功能尚未完成)。这个学习系列的最后一个小目标是实现一个RISC-V CPU核。本节先对RISC-V CPU进行初步的讨论,并实现了一个用HDL4SE语言写的周期级别的可以运行的模型,可以看作是一个RISC-V CPU的行为级的CModel。它虽然简单,然而可以用来验证编译工具链以及做一些RISC-V下的编程实践,并且因为简单,反而更加容易看懂,比较适合于初学者。这个模型不是RTL的,后面我们会逐步将它改造为全部用Verilog RTL实现。
前面我们用状态机做俄罗斯方块以及用流水线做GoogLeNet图像分类时,已经有一个感觉,就是其中的运算部件是比较浪费的,大部分时间都在空转,当然可以将它们设计成共享方式,但是这样做会大大增加设计的复杂程度。另外,设计起来还是非常麻烦,比如状态机设计,每个状态必须非常仔细地去设计在状态中需要完成的工作,并给出状态切换的条件,做个俄罗斯方块游戏,状态只有几个,还可以逐个精雕细琢,如果状态数目多了,设计的工作量就会非常大。如果状态数目成千上万,对设计者而言就是一个灾难了,此时如果还要考虑资源共享,那就几乎是不可完成的任务。
应该设计一种通用的状态机,就是给出一种与具体应用无关的状态表示,状态切换的规则,并构造一个固定的电路来实现,状态的切换不是靠硬编码来实现,而是通过控制数据来控制这个通用状态机的状态切换。这些控制数据存储在存储器中,通用状态机从存储器读控制数据,然后根据读出来的控制数据的内容,与当前状态进行计算后,得到新的状态,实现状态切换。通用状态机可以通过一些通用的外部信号来跟其他电路交换信息,可以设计特别的控制数据来采样通用状态机的输入信号或者生成输出信号。这样做的好处在于可以将状态机的状态数目设计得非常大,状态切换的规则则比较简单,状态机的功能由控制数据来决定,这样就把状态机的设计复杂度转移到控制数据的设计上去。控制数据的设计毕竟不是电路设计,可以用其他的工具来支撑,这样大大降低了应用系统的设计难度。
具体而言,在状态机中,状态一般用一组寄存器来实现,通用状态机也一样。由于通用状态机要从存储器读控制数据,因此通用状态机中都有一个寄存器用来表示当前读控制数据的位置,称为PC寄存器(Program Counter),当然通用状态机中还有其他寄存器,联合起来表示很大的状态空间。通用状态机的运行过程是:从PC寄存器指向的位置读入一个控制数据,控制数据可以分为几类:

  1. 控制数据中某些部分与通用状态机中的状态寄存器的某些指定位进行计算,结果用来修改状态寄存器的某些位,这些寄存器的位的位置,也是控制数据来指定的,控制数据的类型也编码在控制数据中。
  2. 控制数据指定状态寄存器的某些位进行计算,结果用来修改状态寄存器的某些位。其中的某些位以及计算功能也编码在控制数据中。
  3. 一般情况下一个控制数据读出并控制通用状态机进行状态转移后,会取下一个控制数据,也就是PC寄存器会自动递增到下一个控制数据的存储器位置,这样可以实现算法设计中要求的顺序结构。可以设计一类控制数据,计算的结果直接修改PC寄存器,这样可以实现算法设计中要求的分支,特别是带条件分支的功能。有了分支功能和顺序功能,我们就可以实现应用算法中的更多功能。
  4. 还有一类控制数据是用来将外部信号采样用来存储到内部状态寄存器中,或者是用来生成外部的信号。一种特殊的控制数据甚至是先生成一个输出信号,然后等一个时钟周期采样另外一组输入信号存储到状态寄存器中。
    CPU其实就是一个通用的状态机,其状态用它内部的寄存器的值来表达,这种将控制数据存储起来然后用来控制状态转移的结构称为冯诺伊曼计算机架构。比如RISC-V CPU,在32位基本配置下,有一个32位的PC寄存器和31个32位的通用寄存器来表示其状态,理论上可以表达的状态数目非常巨大,状态变量总共1024位,状态数目就是2的1024次方个,足以表达我们需要的最复杂的应用。
    并不是任意的数据都可以作为控制数据,控制数据有规定的格式,控制数据的规范就是CPU指令集规范。符合其指令集规范的数据可以控制这个状态机的状态切换,所谓状态切换就是其寄存器的改变。我们可以通过编制控制数据来控制这个状态机的运行,完成我们想实现的算法。控制数据中有对外I/O的类型,用来生成这个通用状态机与外围电路交互的信号,对外的I/O信号可以是数据读写信号,通过LOAD/STORE控制数据类型(指令类型)生成,可以是特殊的信号,比如中断信号,事先可以规定状态机在收到外部中断信号的状态切换方式即可。
    用通用状态机完成算法可以达到多个状态共享运算单元,另外将电路功能实现从纯粹的电路设计大量转到控制数据的设计上,控制数据系列,一般称为软件,极大地丰富了数字电路的使用场合。设计一个通用的状态机,使用不同的控制数据或者说软件,就可以得到实现完全不同的功能。这样才能将数字电路应用到人类活动的每个方方面面,现在大家手上捏个手机,就可以玩各种各样的游戏,做各种事情,当年为了玩俄罗斯方块游戏,得有个专门的俄罗斯方块掌机,为了听MP3,得有个专门得MP3机器,为了计算,得有个计算器,为了听新闻,得有个收音机,看视频,用VCD机…。

14.1 RISC-V CPU的指令规范

RISC-V CPU是一个非常简洁的,又是扩展能力非常强的通用状态机设计。它的指令(控制数据)格式可以从16位到192位以上,可以提供非常丰富的表达能力。下面的示意图来自于risc-v-2.2的规范文件,展示了不同长度的指令如何编码。

一般而言在一个RISC-V CPU中只支持一种长度的指令。RISC-V的规范定义了一个最小集合,只能完成整数的部分运算。包括32位版本和64位版本。其32位版本用risc v RV32I表示。后面的I表示基本的整数运算。
RV32I配置包括一个32位pc寄存器,表示当前的指令存放的位置,还包括32个32位的通用寄存器,用来存放用户的数据,这些寄存器用x0–x31表示,其中x0是只读寄存器,并且硬件上设置为0,不能修改,因此无法表示状态。RV32I的指令长度为32位,并且要求每条指令的地址对齐到32位。其格式有如下几种:

按照前面的规则,其中opcode有7位,低两位固定为11,其他5位表示指令的类型,其中的[4:2]不能为111。rd是需要修改的寄存器编号,rs1/rs2是修改寄存器参与计算的寄存器编号, imm是指令中包括的立即数,也参与计算,一般代替rs2,在U-type中甚至覆盖了rs1部分。funct3/7是同一类指令中具体的功能编号。注意所有格式中rs1, rs2,rd在指令中的位置是一样的,这样可以在读出指令的同时就很方便的将涉及的寄存器的编号解析出来,电路可以尽早从寄存器文件中将源寄存器的值读出来参与计算,并判断指令能否立即执行(比如上条指令的目标寄存器是否是这条指令的源寄存器)。
RV32I配置下的指令包括以下几种:

  1. 立即数计算:opcode[6:2]=0x04,编码方式是I-type,具体的计算功能在funct3中,完成的功能是用12位立即数进行符号位扩展,然后与rs1表示的寄存器的值进行计算,计算结果存到rd代表的寄存器中。funct3编码的计算功能包括addi(加法), slti(小于), sltiu(无符号小于), xori(异或), ori(或), andi(与), slli(逻辑左移), srli(逻辑右移), srai(算术右移),总共有9个运算,其中在位移运算时,其实立即数中只有低5位参与计算,因此编码时有一位用来区别逻辑右移和算术右移,这样用3位funct3以及这一位可以表达9个运算。
  2. 寄存器计算:opcode[6:2]=0x0c,编码方式是R-type,具体的计算功能在funct3中,由于表达的功能比较多,因此func7中也表达了一部分功能。完成的功能是寄存器rs1,rs2参与计算,然后结果存储到rd中。funct3/funct7编码的计算功能包括:add/sub(加减), sll(左移), slt(比较), sltu(无符号比较), xor(异或), srl/sra(逻辑右移/逻辑左移), or(或), and(与)。
  3. lui指令:opcode[6:2] = 0x0d, 编码方式是U-type,用来将立即数存到rd指定的寄存器中,这个立即数就是指令数据低12位设置位0后得到的数据。
  4. auipc指令: opcode[6:2] = 0x05,编码方式是U-type,用来将立即数加上pc的值存放到rd指定的寄存器中,这个立即数就是指令数据低12位设置位0后得到的数据。这条指令用来生成与当前pc位置为基准的相对位置,可以支持所谓PIC(位置无关代码),这样编制出来的代码可以存放在存储器的任何地方,执行过程使用的都是相对地址。
  5. 无条件跳转指令,包括两条,jal指令,opcode[6:2]=0x1b,编码方式U-type,用来将imm表示的立即数进行符号扩展后与pc相加,作为新的pc值,实现跳转功能,值得注意的是imm的编码虽然从[31:12]中来,但并不是直接拿来使用,而是采用了交织编码的方式,具体请参考规范。由于其表示的最小粒度是2,这样20位立即数可以表达pc前后1MB地址范围,也就是说这条指令的跳转范围是离当前位置1MB的范围内。这条指令还同时将该指令的下一条指令的位置(PC+4)存放到rd表示的寄存器中。这样,可以用这条指令来实现函数调用的功能,函数调用其实就是将返回地址存起来(比如存放到x1寄存器中),然后跳到需要调用的函数起点,被调用的函数一般把这个返回地址存放在局部栈中,以便它能够继续调用其他函数甚至自我调用实现递归。jalr指令,opcode[6:2] = 0x19,编码方式为I-type,将imm表示的立即数进行符号扩展然后与rs1和pc相加,结果存放到pc中,同时将pc+4存放到rd表示的寄存器中。jalr与jal的差别是有一个源寄存器参与计算,这样就可以实现比如返回功能,将返回地址加载到通用寄存器中,然后使用jalr,就可以回到返回地址继续运行。
  6. 带条件跳转指令,opcode[6:2] = 0x18,编码方式S-type,这个格式的指令没有修改通用寄存器,因此rd用来表达立即数,其中参与运算的包括rs1, rs2, imm,funct3指定功能,实现beq, bne, blt, bge, bltu, bgeu 等功能,其中比较是在rs1和rs2之间进行的,注意有带符号和无符号的区别。总共有12位立即数进行符号扩展,以2字节的粒度提供pc前后4KB的跳转。如果比较的结果为真,pc被修改为pc+imm,实现带条件跳转功能。
  7. 读外部数据指令:opcode[6:2] = 0x00,编码方式I-type,读rs1+imm位置的外部数据到rd表示的目标寄存器中。func3表示读入的宽度(1,2,4字节),注意这里可以要求读入的数据地址对齐到读入的大小,但是也可以支持任意地址读入。
  8. 写数据指令:opcode[6:2] = 0x08, 编码方式S-type,这条指令不修改通用寄存器,写地址在rs1+imm中,写数据在rs2中,写宽度在funct3中,同样可以要求写地址对齐在写宽度,也可以支持任意地址写。
  9. 存储栅栏:opcode[6:2] = 0x03,fence/fence-i,用来实现存储系统的同步,在没有高速缓存的系统中,这条指令不做任何事情。
  10. 系统功能:opcode[6:2] = 0x1c,ecall, ebreak, csrrw, csrrs, csrrc, csrrwi, csrrsi, csrrci功能 ,最小系统中可以将这些指令实现为NOP。

RISC-V的指令集可以在RV32I的基础上进行扩展,常用的扩展有:
M: 增加乘法和除法运算,记为RV32IM,其实就是alu指令增加了乘法,除法和求余数的功能
A: 增加原子操作,记为RV32IA,
F:增加32位浮点计算功能,记为RV32IMF(同时有M扩展)
D:增加64位浮点功能,记为RV32IMFD
每个扩展就增加一个字母表示。
如果是64位系统,则将32换为64,其中的所有寄存器都变为64位,部分指令扩展用来操作64位寄存器中的高32位。

14.2 RISC-V 软件工具链

GNU提供了RISC-V的软件工具集合,可以支持c/c++语言,汇编语言到RISC-V指令集的控制数据的编译链接,还提供了linux下和裸机下的标准c库和数学库。这些工具是开源的,可以通过git clone --recursive http://github.com/riscv/riscv-gnu-toolchain下载,其中用–recursive是因为这个工具集由很多个部分组成,用这个命令可以把所有相关的部分一次下载下来。github一般比较堵,可以用国内的镜像网站来下载,比如用csdn的(在linux下运行),同时需要下载一些支持软件,才能编译生成工具集合:

git clone --recursive https://codechina.csdn.net/mirrors/riscv/riscv-gnu-toolchain
sudo apt-get install autoconf automake autotools-dev curl python3 libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf libtool patchutils bc zlib1g-dev libexpat-dev gawk
export RISCV="/home/xxx/riscv-gnu-toolchain"
export PATH=$PATH:$RISCV/bin
./configure --prefix=$RISCV --with-arch=rv32im --with-abi=ilp32
make

其中的RSICV目录要根据具体的情况设置,如果设置在系统目录中,后面的make就要用sudo make才能运行。编译成功后,在RISCV目录下就存放了编译器连接器汇编器等一系列可执行文件,也包括一些c库。
软件工具链是比较复杂的,如果需要自己扩展CPU指令,需要了解的东西就更多了,这里只是使用,不进行更多的讨论。有了这些工具,我们就能把c语言代码转换成RISC-V的指令,或者说是符合RISC-V指令规范的通用状态机控制数据序列。

14.3 RISC-V CPU的HDL4SE实现

CPU的设计是很复杂的,涉及到数字电路设计的很多方面,后面的章节会接触到,然而,如果只是做个能够运行RISC-V指令序列的状态机出来,却比较简单,本节描述了一个实现过程,不是RTL的,只能看成是一个CModel。

14.3.1 准备可执行文件

我们给这个CPU增加一个外设,就是前面我们做过的计数器的显示和键盘,通过对地址0xf0000000的读,可以读到键盘的状态,通过对地址0xf0000010–0xf0000018的写,我们可以控制数码管的显示,这样可以实现一个简单的计数器的功能。相应的c语言代码如下:

//counter 的c语言实现
//main.c
const unsigned int segcode[10] =

	0x3F,
	0x06,
	0x5B,//	8'b01011011, 
	0x4F,// 8'b01001111, 
	0x66,// 8'b01100110, 
	0x6d,// 8'b01101101, 
	0x7d,// 8'b01111101, 
	0x07,// 8'b00000111, 
	0x7f,// 8'b01111111, 
	0x6f,// 8'b01101111, 
;

unsigned int num2seg(unsigned int num)

	return segcode[num % 10];


int main(int argc, char* argv[])

	unsigned long long count, ctemp;
	int countit = 0;
	unsigned int* ledkey = (unsigned int*)0xF0000000;
	unsigned int* leddata = (unsigned int*)0xf0000010;
	count = 0;
	do 
		unsigned int key;
		key = *ledkey;
		if (key & 1) 
			count = 0;
		
		else if (key & 2) 
			countit = 0;
		
		else if (key & 4) 
			countit = 1;
		
		if (countit)
			count++;
		ctemp = count;
		leddata[0] = num2seg(ctemp) |
			((num2seg(ctemp / 10ll)) << 8) |
			((num2seg(ctemp / 100ll)) << 16) |
			((num2seg(ctemp / 1000ll)) << 24);
		ctemp /= 10000ll;
		leddata[1] = num2seg(ctemp) |
			((num2seg(ctemp / 10ll)) << 8) |
			((num2seg(ctemp / 100ll)) << 16) |
			((num2seg(ctemp / 1000ll)) << 24);
		ctemp /= 10000ll;
		leddata[2] = num2seg(ctemp) |
			((num2seg(ctemp / 10ll)) << 8);
	 while (1);
	return 1;


这段代码应该是比较容易看懂的,这里不多做解释。我们用前面描述的工具链来编译它:

riscv32-unknown-elf-gcc main.c -o test.elf

生成的运行文件test.elf是一种比较复杂的包括执行代码与数据,各种符号,各种信息的文件,从中我们可以看到内部的信息(通过readelf应用程序):

riscv32-unknown-elf-readelf -a test.elf

ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           RISC-V
  Version:                           0x1
  Entry point address:               0x1008c
  Start of program headers:          52 (bytes into file)
  Start of section headers:          15464 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         2
  Size of section headers:           40 (bytes)
  Number of section headers:         22
  Section header string table index: 21
......
......

其中值得注意的是Entry point address: 0x1008c,表示这个程序的入口地址在0x1008c处,注意elf文件中的代码是可以重定向的,一般而言编译出来的代码使用的地址都是相对地址,可以在任何位置运行,然而为了仿真时能够很方便地与代码对照,我们就按照这个内存布局来运行好了。
代码反汇编出来是这样的:

riscv32-unknown-elf-objdump -d test.elf

test.elf:     file format elf32-littleriscv


Disassembly of section .text:

00010074 <register_fini>:
   10074:	00000793          	addi	x15,x0,0
   10078:	00078863          	beq	x15,x0,10088 <register_fini+0x14>
   1007c:	00011537          	lui	x10,0x11
   10080:	aec50513          	addi	x10,x10,-1300 # 10aec <__libc_fini_array>
   10084:	2c50006f          	jal	x0,10b48 <atexit>
   10088:	00008067          	jalr	x0,0(x1)

0001008c <_start>:
   1008c:	00001197          	auipc	x3,0x1
   10090:	7ac18193          	addi	x3,x3,1964 # 11838 <__global_pointer$>
   10094:	c3418513          	addi	x10,x3,-972 # 1146c <completed.1>
   10098:	c5018613          	addi	x12,x3,-944 # 11488 <__BSS_END__>
   1009c:	40a60633          	sub	x12,x12,x10
   100a0:	00000593          	addi	x11,x0,0
   100a4:	04d000ef          	jal	x1,108f0 <memset>
   100a8:	00001517          	auipc	x10,0x1
   100ac:	aa050513          	addi	x10,x10,-1376 # 10b48 <atexit>
   100b0:	00050863          	beq	x10,x0,100c0 <_start+0x34>
   100b4:	00001517          	auipc	x10,0x1
   100b8:	a3850513          	addi	x10,x10,-1480 # 10aec <__libc_fini_array>
   100bc:	28d000ef          	jal	x1,10b48 <atexit>
   100c0:	794000ef          	jal	x1,10854 <__libc_init_array>
   100c4:	00012503          	lw	x10,0(x2)
   100c8:	00410593          	addi	x11,x2,4
   100cc:	00000613          	addi	x12,x0,0
   100d0:	0ac000ef          	jal	x1,1017c <main>
   100d4:	7500006f          	jal	x0,10824 <exit>

000100d8 <__do_global_dtors_aux>:
   100d8:	ff010113          	addi	x2,x2,-16
   ......

代码在内存中的起始点是0x00010074,运行入口点是0x0001008c<_start>。
进入程序后,首先是调用了memset,将BSS段清零(就是代码中声明的static变量所在的位置),然后调用atexit安装exit例程,在调用libc_init_array初始化c库内部的数据结构,然后进入main, 执行应用程序。
我们的应用程序可以通过编程序直接调入elf文件,但是这样太复杂,把眼球都吸引到如何调入elf文件上去了,我们用objcopy例程直接将elf文件转换成verilog中能够读入的格式:

riscv32-unknown-elf-objcopy -O verilog test.elf test.cod
得到的test.cod文件如下:
@00010074
93 07 00 00 63 88 07 00 37 15 01 00 13 05 C5 BB
6F 00 ......
@00010CFC
3F 00 00 00 06 00 00 00 5B 00 00 00 4F 00 00 00
66 00 00 00 6D 00 00 00 7D 00 00 00 07 00 00 00
7F 00 00 00 ......
@00011000
10 00 00 00 00 00 00 00 03 7A 52 00 01 7C 01 01
1B 0D 02 00 10 00 00 00 18 00 00 00 A8 F4 FF FF
30 04 00 00 00 00 00 00 00 00 00 00
@0001102C
74 00 01 00 1C 01 01 00
@00011034
D8 00 01 00
@00011038
00 00 00 00 24 13 ......
@00011460
38 10 01 00 00 00 00 00 38 10 01 00

这个格式就比较简单了,将elf文件分成若干段,每一段用@地址行开始,后面跟着数据。到此,我们就准备好要运行的数据文件了。

14.3.2 顶层模型

我们将使用一个RISC-V CPU的核和一个counter中使用过的ledui的模型来构造顶层模型,用verilog语言写出来就是:

/* riscv_core.v */

(* 
  HDL4SE="LCOM", 
  CLSID="638E8BC3-B0E0-41DC-9EDD-D35A39FD8051", 
  softmodule="hdl4se" 
*) 
module riscv_core(
    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);
endmodule


/* riscv-sim.v */

`include "riscv_core.v"

(* 
  HDL4SE="LCOM", 
  CLSID="2925e2cf-dd49-4155-b31d-41d48f0f98dc", 
  softmodule="hdl4se" 
*) 
module digitled(
    input wClk, nwReset,
    input wWrite,
    input [31:0] bWriteAddr,
    input [31:0] bWriteData,
    input [3:0]  bWriteMask,
    input wRead,
    input [31:0] bReadAddr,
    output [31:0]  bReadData);
endmodule

module top(input wClk, nwReset);
    wire wWrite, wRead;
    wire [31:0] bWriteAddr, bWriteData, bReadAddr, bReadData;
    wire [3:0]  bWriteMask;

    digitled led(wClk, nwReset, wWrite, bWriteAddr, bWriteData, bWriteMask, wRead, bReadAddr, bReadData);
	riscv_core core(wClk, nwReset, wWrite, bWriteAddr, bWriteData, bWriteMask, wRead, bReadAddr, bReadData);
endmodule

其中riscv_core用一个LCOM的描述,表示这个模型是用软件实现的,将来我们再修改riscv_core.v文件,将它用verilog语言实现。digitled则直接使用counter实例程序中现成城的。这个verilog语言描述编译后,得到我们的顶层c语言设计文件如下:

/*
*  Created by HDL4SE @ Fri Aug 20 08:57:41 2021
*  Don't edit it.
*/

#include "stdlib.h"
#include "stdio.h"
#include "string.h"

#include "object.h"
#include "dlist.h"
#include "bignumber.h"
#include "hdl4secell.h"
#include "conststring.h"
#include "verilog_parsetree.h"


/* Module top */

#define M_ID(id) top##id

IDLIST
	VID(wClk),
	VID(nwReset),
	VID(bReadData),
	VID(wWrite),
	VID(bWriteAddr),
	VID(bWriteData),
	VID(bWriteMask),
	VID(wRead),
	VID(bReadAddr),
END_IDLIST

GEN_MODULE_DECLARE
END_GEN_MODULE_DECLARE

GEN_MODULE_INIT
	PORT_IN (wClk, 1);
	PORT_IN (nwReset, 1);
	WIRE(bReadData, 32);
	WIRE(wWrite, 1);
	WIRE(bWriteAddr, 32);
	WIRE(bWriteData, 32);
	WIRE(bWriteMask, 4);
	WIRE(wRead, 1);
	WIRE(bReadAddr, 32)HDL4SE:软件工程师学习Verilog语言(十四)

HDL4SE:软件工程师学习Verilog语言

HDL4SE:软件工程师学习Verilog语言

HDL4SE:软件工程师学习Verilog语言

HDL4SE:软件工程师学习Verilog语言

HDL4SE:软件工程师学习Verilog语言