在以太网工程中, 我们使用了很多双端口RAM IP核用于数据的交换。更多关于双端口RAM的其它应用及使用原理,参见文章 Vivado下利用TDP RAM 实现乒乓Buffer实例,高速通信乒乓Buffer数据缓冲Verilog实现–发送端 和相应视频。双端口RAM在FPGA设计中会被经常使用,主要有以下几个优点:隔离不同的时钟域,隔离上下级的行为逻辑,实现带宽转换(利用面积换取速度),实现乒乓buffer和实现包结构存储(利用地址指针)。
- 隔离不同的时钟域:利用双端口RAM 实现不同时钟域的数据交换。 双端口a,b端可以使用不同的时钟来实现不同的数据转换。
- 隔离上下级的行为逻辑:利用双端口RAM ,可以处理不同的上下级行为逻辑, 在对a端操作时,只要专注a 端的行为逻辑, 不需要考虑b端数据的逻辑耦合。 同理在处理b端的行为逻辑时,也不需要考虑a端的行为。
- 实现带宽转换:利用双端口RAM 例化时的口线宽度不同,实现不同的数据速率。 这种方法是利用更改双端口RAM 口线宽度,利用面积来换取速度。 举例:a,b端都是100M时钟,a端口线为8-bit; b端口线为16-bit。很显然,b端口的数据吞吐率 是a端口的2倍。
- 实现乒乓buffer: 乒乓buffer是在数据通讯中经常使用的一种手段, 利用乒乓buffer 可以合理的利用FPGA资源,提高通讯的速度。也就是说在读取数据时, 写数据依然可以正常操作。
- 实现包结构存储:利用双端口的地址可以自由移动的特性,可以有效的写入或者读取数据。 这一点要比FIFO灵活得多, FIFO写入的数据将不能被更改, 同样,FIFO读取时只能顺序读取,不能随机地址读取。双端口正好利用了地址自由移动的特性, 可以有效的写入数据,当数据发生错误时,只要标识这些数据是作废的,就可以重复的使用地址空间了。同样的, 在读取双端口的数据时, 也是可以用跳跃地址的方式读取, 而不像FIFO只能顺序读取数据。
以太网工程中使用的双端口RAM如下图所示:

图1
目的:写入数据到双端口,写入的数据包的长度为64- 1514左右(bytes)。利用双端口实现数据的传输和时钟域的转换。这里我们使用乒乓buffer,每个buffer的深度为2048 bytes,所以每个buffer完全可以保证存储一个1514 bytes 的数据包。由于是乒乓buffer,在例化双端口时,双端口的容量为4096 bytes。
在以太网实验中使用的双端口RAM有以下三种,下文将分别对其进行详细的介绍。
1.使用a,b 端都是8-bit的双端口RAM实现数据传输
由于要存储1518 bytes的数据, 所以每个buffer的容量为2048 bytes,完全可以保证数据存储。 所以在数据存储时,正常情况下地址不会超过1518(每个地址,存储一个8bit的数据),就可以利用2046, 2047 这两个子地址,作为存储当前包的封口和长度标识(pkg_flag, length ),其中length表示数据包的字节长度。
乒乓buffer的使用:

图2
发送数据流从a端口写入:
双端口的a 端的地址有乒乓buffer 指针和子地址组成。 addra = {ida, addra_sub}; 其中ida 为乒乓buffer的指针, addra_sub 为当前乒乓buffer的子地址。
1)ida = 0(buffer 0), 跳转到2)
2)addra_sub = 2047, 从2047子地址中读取数据, 跳转到3)
3)如果数据的最高位为1 (data[7]), 表示这个buffer里面有 有效数据, 暂时不能被破坏,一直读取2047 这个子地址里面的数据。 直到数据的最高位为0,标识当前的buffer为空,表明当前buffer可以用来存储数据,跳转到4)
4)addra_sub = 0; 地址指针指向 子地址0 , 开始存储(写)数据, 每写1 byte 数据, 地址 加一(addra_sub = addra_sub + 1) 。 如果一个整包全部写入后,跳转到5)
5)addra_sub = 2046 , 写入数据length 的低8位【7:0】, 在addra_sub = 2047写入 数据length 的【11:8】 位, 同时在2047子地址的最高为写入 1(pkg_flag),表示这个包写入成功,跳转到6)
6)更改地址到 乒乓buffer 的buffer 1 上, ida = 1 (转换到buffer 1), 返回2)
数据流从b端口读出:
双端口RAM b端的地址由乒乓buffer 指针和子地址组成。 addrb = {idb, addrb_sub}; 其中idb 为乒乓buffer的指针, addrb_sub 为当前乒乓buffer的子地址。
1)idb = 0, 跳转到2)
2)addrb_sub = 2047, 从2047子地址中读取数据, 跳转到3)
3)如果数据的最高位为1 (data_out[7]), 表示这个buffer里面有 有效数据, length【11:8】 = data_out[3:0], 跳转到4);
如果数据的最高位为0,标识当前的buffer为空,一直读取数据,直到data_out[7] 为 1
4)addrb_sub = 2046, 从2046子地址中读取数据,length【7:0】 = data_out[7:0], 跳转到5)
5)addrb_sub = 0; 地址指针指向 子地址0 , 开始读取数据, 每读1 byte 数据, 地址 加一(addrb_sub = addrb_sub + 1) 。 当读取的数据长度等于 length,跳转到6)
6)addrb_sub = 2047,在这个子地址上回写数据 0(当前buffer 使用结束, 清除 pkg_flag 位), 跳转到7)
7)更改地址到 乒乓buffer 的buffer 1 上, idb = 1 , 返回2)
2.使用a,b 端都是16-bit的双端口RAM实现数据传输
由于要存储1500 bytes的数据, 所以每个buffer的容量为2048bytes,完全可以保证数据存储。 在数据存储时,地址永远不会超过1500,(每个地址,存储一个16-bit的数据),利用1023 这个子地址作为存储当前包的封口和长度标识(pkg_flag,3’b000, length 【11:0】),其中length表示数据包的字节长度。
乒乓buffer的使用:

图3
在数据流从a端口写入:
双端口的a 端的地址由乒乓buffer 指针和子地址组成。 addra = {ida, addra_sub}; 其中ida 为乒乓buffer的指针, addra_sub 为当前乒乓buffer的子地址。
1)ida = 0, 跳转到2)
2)addra_sub = 1023, 从1023子地址中读取数据, 跳转到3)
3)如果数据的最高位为1 (data[15]), 表示这个buffer里面有 有效数据, 暂时不能被破坏,一直读取1023 这个子地址里面的数据。 直到数据的最高位为0,标识当前的buffer为空,表明当前buffer可以用来存储数据,跳转到4)
4)addra_sub = 0; 地址指针指向 子地址0 , 开始存储(写)数据, 每次写2-byte 数据, 地址 加一(addra_sub = addra_sub + 1) 。 如果一个整包全部写入后,跳转到5)
5)将addra_sub = 1023 , 写入数据length 【11:0】, 同时在1023子地址的数据最高位写入 1(pkg_flag),表示这个包写入成功,跳转到6)
6)更改地址到 乒乓buffer 的buffer 1 上, ida = 1 , 返回2)
在数据流从b端口读出:
双端口RAM的b端的地址由乒乓buffer 指针和子地址组成。 addrb = {idb, addrb_sub}; 其中idb 为乒乓buffer的指针, addrb_sub 为当前乒乓buffer的子地址。
1)idb = 0, 跳转到2)
2)addrb_sub = 1023, 从1023子地址中读取数据, 跳转到3)
3)如果数据的最高位为1 (data_out[15]), 表示这个buffer里面有 有效数据, length【11:0】 = data_out[11:0], 跳转到4);如果数据的最高位为0,标识当前的buffer为空,一直读取当前1023地址,直到data_out[15] 为 1
4)addrb_sub = 0; 地址指针指向 子地址0 , 开始读取数据, 每读2-byte 数据, 地址 加一(addrb_sub = addrb_sub + 1) 。 当读取的数据长度等于 length,跳转到5)
5)addrb_sub = 1023,在这个子地址上回写数据 0(当前buffer 使用结束, 清除 pkg_flag 位), 跳转到6)
6)更改地址到 乒乓buffer 的buffer 1 上, idb = 1 , 返回2)
3.使用不等宽双端口RAM进行数据传输(a端是8-bit,b 端是16-bit)
由于要存储1500 bytes的数据, 所以每个buffer的容量为2048 bytes,完全可以保证数据存储。 所以在数据存储时,地址永远不会超过1500,(a端口,每个地址,可以写入8-bit的数据;b端口,每个地址可以读出16-bit ),这样,我们就利用2046, 2047 这两个子地址,作为存储(写)当前包的封口和长度标识(pkg_flag, length ),其中length表示数据包的字节长度。
乒乓buffer的使用:

图4
在数据流从a端口写入:
双端口RAM的a 端的地址由乒乓buffer 指针和子地址组成。 addra = {ida, addra_sub}; 其中ida 为乒乓buffer的指针, addra_sub 为当前乒乓buffer的子地址。
1)ida = 0, 跳转到2)
2)addra_sub = 2047, 从2047子地址中读取数据, 跳转到3)
3)如果数据的最高位为1 (data[7]), 表示这个buffer里面有 有效数据, 暂时不能被破坏,一直读取2047 这个子地址里面的数据。 直到数据的最高位为0,标识当前的buffer为空,这个buffer可以用来存储数据,跳转到4)
4)addra_sub = 0; 地址指针指向 子地址0 , 开始存储(写)数据, 每写1 byte 数据, 地址 加一(addra_sub = addra_sub + 1) 。 如果一个整包全部写入后,跳转到5)
5)检查写入的这些数据是否都是有效的。 举例: 比如写入一个整包的数据后才发现CRC检验是错误的, 代表整包是不正确的,跳转到2)。如果整包的数据都是有效的, 那么将addra_sub = 2046 , 写入数据length 的低8位【7:0】, 在addra_sub = 2047写入 数据length 的【11:8】 位, 同时在2047子地址的最高位写入 1(pkg_flag),表示这个包写入成功,跳转到6)
6)更改地址到 乒乓buffer 的buffer 1 上, ida = 1 , 返回2)
在数据流从b端口读出:
双端口RAM的b端的地址由乒乓buffer 指针和子地址组成。 addrb = {idb, addrb_sub}; 其中idb 为乒乓buffer的指针, addrb_sub 为当前乒乓buffer的子地址。
1)idb = 0, 跳转到2)
2)addrb_sub = 1023, 从1023子地址中读取数据, 跳转到3)
3)如果数据的最高位为1 (data_out[15]), 表示这个buffer里面有 有效数据, length【11:0】 = data_out[11:0],跳转到4);如果数据的最高位为0,标识当前的buffer为空,一直读取地址1023的数据,直到data_out[15] 为 1
4)addrb_sub = 0; 地址指针指向 子地址0 , 开始读取数据, 每读2-byte 数据, 地址 加一(addrb_sub = addrb_sub + 1) 。 当读取的数据长度等于 length,跳转到5)
5)addrb_sub = 1023,在这个子地址上回写数据 0(当前buffer 使用结束, 清除 pkg_flag 位), 跳转到6)
6)更改地址到 乒乓buffer 的buffer 1 上, idb = 1 , 返回2)
4.总结
从上面的3个例子可以看出, 写入端(a 端)写入数据时,并不需要关心b端的情况; 同时 读出端(b端)读取数据时,也不关心a端的写入情况。 即 a,b端都是可以独立工作的,从而实现行为上的分离; 同时 a,b端口的时钟是可以异步的, 这样就实现的异步时钟域之间的数据转换。