ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA底层探秘:手把手用LUT2原语实现任意2输入逻辑函数

FPGA底层探秘:手把手用LUT2原语实现任意2输入逻辑函数 写Verilog写了好几年一直有一种“表面会写、内里发虚”的感觉。assign和always用得很熟状态机、FIFO、计数器都能写但一遇到综合器的底层行为就抓瞎。直到我把LUT2这个最小原语彻底吃透很多之前想不通的问题才突然通了。这篇文章就用手把手的方式带你把LUT2原语用起来用真值表实现任意2输入逻辑函数并把我踩过的“位序写反”这类坑全部讲清楚。适合刚学完Verilog基础语法、想深挖FPGA底层原理的初学者也适合平时只写RTL、没怎么碰过原语的工程师用来补底层知识。1. 为什么非要折腾LUT2先看懂FPGA的“查表”本质很多人把FPGA当成一个“可编程电路板”觉得里面什么门都有想用什么就接什么。这个理解不能说错但没有触及真正的底层。要想明白LUT2原语到底在做什么得先搞清楚FPGA为什么能做到“可编程”。1.1 查找表为什么“查表”就能算逻辑FPGA算逻辑本质上不是用一堆晶体管搭出与门、或门电路然后去计算而是直接查一张真值表。我打个比方。你手里有一份“学生姓名到学号”的对照表有人问你张三的学号是多少你不需要重新推演他入学时的流水号规则只需要在表里找到“张三”那一行把学号念出来就行。LUTLook-Up Table干的就是这件事输入信号是“地址”INIT参数里存的是“表内容”输出信号就是查表得到的“结果”。拿LUT2来说它只有两个输入I0和I1这两个输入组合起来正好能产生4种情况00、01、10、11。所以LUT2的真值表只有4行存储内容自然也只有4位。这个“4位”就是LUT2原语里INIT参数位宽的来源。再往深一层想一个n输入的查找表真值表有2^n行能实现的逻辑函数数量是2的2^n次方种。2输入LUT能实现16种不同的2输入逻辑函数3输入LUT能实现256种。这就是为什么有人说LUT是FPGA的“万能积木”——同一颗LUT换个表内容就换个功能而且是物理上重新配置SRAM位不需要改电路连线。1.2 原语和assign的差别一个直接指定一个交给工具我们平时写assign y a b;综合器拿到这个逻辑后会自己决定怎么映射到LUT。它可能选LUT2也可能因为周围有其它逻辑把多个函数合并到一个更大尺寸的LUT比如LUT6里。这个过程的优点是自动化程度高、代码写起来轻松缺点是你完全不知道“具体用在了哪个LUT上”对底层没有掌控感。LUT2原语的用法则完全不同。它是厂商提供的一个可以直接例化的底层模块。你写LUT2 #(.INIT(4h8)) u_inst (.O(y), .I0(a), .I1(b));就相当于直接告诉综合器这里别给我优化就用一个2输入查找表输入接a、b输出接y表内容我已经填好了。这两种风格没有绝对的好坏。用原语的优点是指定明确、便于做时序优化和网表定位缺点是代码可移植性差换一家FPGA厂商可能就不认了。学习阶段用原语最大的价值是它能让你亲眼看到“真值表是怎么变成硬件的”建立底层直觉。1.3 为什么用LUT2入门而非LUT6现在主流FPGA里物理实现的更多是LUT6Xilinx 7系列之后一个CLB里的查找表单元基本都是6输入。那入门为什么偏偏选LUT2因为LUT2的真值表只有4行INIT参数只有4位你可以在一张A5纸上把整个逻辑画完也能心算验证。LUT6的INIT参数是64位真要手算转换真值表两位数就够人烦的了。而且2输入布尔逻辑是所有复杂组合逻辑的“原子”寄存器、加法器、状态机里的每一个组合逻辑节点拆到最后都是一堆2输入或3输入的LUT级联。把LUT2搞透再看LUT4、LUT6无非是真值表变长、INIT变宽原理完全一样。2. 手把手实例化LUT2从接口到最小工程知道原理之后最关键的就是动手。这一章我直接从代码入手先把Xilinx环境下LUT2原语的接口、参数和完整工程写出来。2.1 LUT2原语的接口和参数说明在Vivado里LUT2原语的例化模板是这样的LUT2 #( .INIT(4h0) ) u_lut2 ( .O (O), // 1-bit output .I0(I0), // 1-bit input .I1(I1) // 1-bit input );端口有三个I0、I1是输入O是输出。参数只有一个INIT这是LUT的表内容位宽固定为4位。注意几个细节参数名必须写大写的INIT。Verilog本身是大小写敏感的原语的参数名由厂商库定义用错大小写综合直接报错。I1在地址计算里是高比特位I0是低比特位。这个顺序决定了对PDF文档和下面真值表转换的理解一开始就记住可以省去很多麻烦。INIT默认是4h0不写也能综合但输出会恒为0。实际用的时候一定要根据真值表重新算。提示Xilinx官方文档里LUT原语还有LUT3、LUT4、LUT5、LUT6接口逻辑完全一样只是INIT位宽变成8、16、32、64位。学会了LUT2其它就是加宽。2.2 最小设计代码一个模块实现四种2输入逻辑为了验证LUT2到底能不能实现逻辑函数我写了一个最小模块里面用四个LUT2分别实现与门、或门、异或门、与非门。这样同一个工程里能同时看到4种不同INIT参数的实际效果。module lut2_demo( input wire a, input wire b, output wire y_and, output wire y_or, output wire y_xor, output wire y_nand ); // y a b LUT2 #(.INIT(4h8)) u_and (.O(y_and), .I0(a), .I1(b)); // y a | b LUT2 #(.INIT(4hE)) u_or (.O(y_or), .I0(a), .I1(b)); // y a ^ b LUT2 #(.INIT(4h6)) u_xor (.O(y_xor), .I0(a), .I1(b)); // y ~(a b) LUT2 #(.INIT(4h7)) u_nand (.O(y_nand), .I0(a), .I1(b)); endmodule这段代码里标注的注释“y a b”是我根据INIT值推算出来的。初学者可以先不深究等到下一章自己动手转一次真值表就会发现这些数值完全对应。为了防止综合工具在优化过程中把我们的LUT2原语合并掉可以在模块声明处加综合属性(* DONT_TOUCH true *) module lut2_demo( ... );DONT_TOUCH这个属性告诉综合器和布局布线工具不要动这个模块内部的结构保持原样。不过一般情况下直接例化原语都会被保留加这个属性主要是图个安心。2.3 在Vivado中综合、仿真和一键查看网表工程步骤不复杂在Vivado里新建一个RTL工程选择你手头开发板的FPGA型号我这里用的是Artix-7系列。把上面的lut2_demo.v添加进工程。新建一个testbench内容如下。timescale 1ns/1ps module tb_lut2_demo; reg a, b; wire y_and, y_or, y_xor, y_nand; lut2_demo u_dut( .a (a), .b (b), .y_and (y_and), .y_or (y_or), .y_xor (y_xor), .y_nand(y_nand) ); initial begin a 0; b 0; #100; a 0; b 1; #100; a 1; b 0; #100; a 1; b 1; #100; $finish; end endmodule先跑行为仿真观察四个输出信号在四种输入组合下的值应该正好对应四个逻辑门的真值表。之后点击综合等综合完成后打开Synthesized Design里的Schematic视图直接搜索LUT2你就能看到原语被放进网表的样子I0、I1、O和INIT参数都清楚写在上面。有一个地方要提前说明如果你打开的是Implementation之后的Device视图看到的很可能是LUT6而不是LUT2。这不是出错。7系列FPGA物理上每个查找表单元都是LUT6LUT2原语最终会被放到LUT6的一部分里。综合后的Schematic里看的是逻辑原语实现后的物理视图里看的是底层硬件两者视角不同。3. 真值表转INIT参数的“反直觉”点位序、拼接与五步法这一章是整篇文章的核心。很多人在网上抄了一些LUT原语代码能跑通但一遇到自己设计真值表就不会填INIT了。问题基本都出在“位序”上。3.1 INIT与真值表行的对应关系这里最容易错LUT2的真值表有4行按地址从小到大排列地址由{I1, I0}组成地址I1I0输出000y0101y1210y2311y3INIT参数的4位与这4行输出的对应关系是INIT[0] y0对应地址0也就是I10、I00INIT[1] y1对应地址1也就是I10、I01INIT[2] y2对应地址2也就是I11、I00INIT[3] y3对应地址3也就是I11、I01。为什么INIT[0]必须对应“输入全0”的那一行因为地址就是多路选择器的选择信号当地址为0时选中第0个存储位地址为3时选中第3个存储位。这是按硬件电路的自然规律来的不是谁随手规定的。很多人在这里栽跟头习惯把真值表按阅读顺序从上到下抄成位串结果第一行的输出被写进了最高位整个函数面目全非。3.2 手工转换示例与非门、异或门、同或门我用三个例子把转换过程完整走一遍。第一个是与非门y ~(I1 I0)。真值表I1I0y001011101110按顺序标出y01, y11, y21, y30。接着按对应关系放到位上bit01, bit11, bit21, bit30。最后拼成一个4位二进制数时注意我们习惯写的位串是从高到低所以结果是{bit3, bit2, bit1, bit0} {0, 1, 1, 1} 4b0111。改成十六进制就是4h7。这正是我在第二节代码里给u_nand填的值。第二个是异或门y I1 ^ I0。真值表I1I0y000011101110y00, y11, y21, y30对应bit00, bit11, bit21, bit30拼接得到4b0110也就是4h6。第三个是同或门y ~(I1 ^ I0)。真值表I1I0y001010100111y01, y10, y20, y31拼接得到4b1001也就是4h9。这几个例子走完我发现一个实用的小规律真值表的输出列从下往上读得到的二进制串就是INIT值。与非门输出列从下往上是0、1、1、1异或门是0、1、1、0同或门是1、0、0、1正好跟拼接结果一致。这个口诀比记“bit0对应哪一行”好记多了。为了方便查用我把2输入LUT最常用的8种函数整理成一张表逻辑功能输出列(00 01 10 11)INIT二进制INIT十六进制恒00 0 0 000000与门0 0 0 110008或门0 1 1 11110E与非门1 1 1 001117或非门1 0 0 000011异或门0 1 1 001106同或门1 0 0 110019恒11 1 1 11111F这张表里的“输出列”是按地址00、01、10、11的顺序排的也就是真值表从上到下的顺序。用的时候要记住这一列的最后一位对应INIT的最高位。3.3 可复制的五步转换法任何2输入逻辑函数哪怕是随机写一个布尔表达式都可以机械地用下面五步转成INIT列真值表按{I1, I0}从00到11的四列顺序算出每行的输出值y0、y1、y2、y3。定对应位y0写进INIT的bit0y1写进INIT的bit1y2写进INIT的bit2y3写进INIT的bit3。拼接位串用{bit3, bit2, bit1, bit0}的顺序组成4位二进制数。转成十六进制4位二进制对应一位十六进制直接填进INIT参数。验证写好testbench遍历四种输入组合把仿真输出和真值表逐行对比。第五步验证往往是很多新手容易跳过的。如果你的INIT填错了仿真结果里一般会有一行或两行输出不对这时候不要瞎猜把四个输入组合的波形逐一对应真值表看很快就能定位到具体是哪一位反了。4. 上板实测用LED验证每一个逻辑函数仿真通过只能说明逻辑关系对要真正感受到LUT2是在硬件里工作的还是得把比特流烧到板子上。这一章记录我实际测试的过程和现象。4.1 添加引脚约束前的关键准备很多人卡在“代码写对了但板上不工作”这步其实九成是引脚约束搞错了。XDC约束里需要设置的是PACKAGE_PIN和IOSTANDARD两项。以一块常见的Artix-7开发板为例如果把a、b接到两个拨码开关四个输出接到四个LED约束文件大概长这样set_property PACKAGE_PIN F15 [get_ports a] set_property IOSTANDARD LVCMOS33 [get_ports a] set_property PACKAGE_PIN G15 [get_ports b] set_property IOSTANDARD LVCMOS33 [get_ports b] set_property PACKAGE_PIN H5 [get_ports y_and] set_property IOSTANDARD LVCMOS33 [get_ports y_and] set_property PACKAGE_PIN H6 [get_ports y_or] set_property IOSTANDARD LVCMOS33 [get_ports y_or] set_property PACKAGE_PIN H7 [get_ports y_xor] set_property IOSTANDARD LVCMOS33 [get_ports y_xor] set_property PACKAGE_PIN H8 [get_ports y_nand] set_property IOSTANDARD LVCMOS33 [get_ports y_nand]注意上面这些引脚名只是示例不同板卡的原理图差别很大一定要去查你自己板子的原理图确定拨码开关和LED的真实网络名。另外如果板子上的LED是低电平点亮逻辑那输出y0时灯亮y1时灯灭不要搞反。有一点值得提醒拨码开关通常没有抖动问题非常适合做组合逻辑实验。按实验里四个输出的INIT是按“高电平有效”设计的拨到1那一侧对应输入1。如果你用按键得先确认按住和松开分别是什么电平否则很容易得出“为什么灯不亮”的错误结论。4.2 实测过程拨码开关与LED真值表对照上板流程很简单综合、实现、生成比特流、下载到FPGA然后把两个拨码开关拨到不同位置观察四个LED。我实测下来拨动开关时最需要“睁大眼睛”的是与非门那个灯。与门的LED只有两个拨码都拨到高电平时才亮很好判断或门的LED只要有一个是高电平就亮异或门的LED在两个拨码状态不同时亮相同时灭与非门的LED则刚好是与门的反相平时都亮着只有两个都拨到高电平时才灭。我在这里卡过一次看到与非门LED大多数时候亮着第一反应是“是不是INIT写错了”。后来拿示波器一量输出物理电平完全正确只是人的直觉习惯把“灯亮”当成“结果为1”对“结果为0导致灯灭”的行为模式不熟悉。做这类验证实验最好先列一张真值表每拨一次开关就在表格里勾一个勾不要凭脑子记。4.3 组合逻辑毛刺和按键抖动的观察测试过程里还有一个值得解释的现象快速拨动开关或者按下按键时个别LED偶尔会闪一下甚至在理论上不该亮的组合瞬间亮了一下。这不是LUT本身出错了。组合逻辑没有任何记忆能力输出只取决于当前输入的组合。但现实中两个输入信号a和b不可能同时变化总有一个先到、一个后到。假设当前状态是00要切换到11如果a先变化中间就会短暂出现10的状态LUT会按照10这个组合瞬间产生一个输出。这个短暂的、非预期的输出就是毛刺glitch术语叫静态冒险。LUT内部的传输延迟很短但逻辑门外部的走线延迟会让毛刺被外面看到。拨码开关本身的机械抖动也会在切换时制造多次高低电平跳变。如果后面接的是寄存器而不是LED而且毛刺恰好落在时钟采样沿附近就会导致功能错误。这就是为什么真实工程里对异步输入信号要做同步处理对组合逻辑输出要用寄存器打一拍。在我们的LUT2验证实验里不太需要消除毛刺但知道这个现象背后的原理能为之后学习跨时钟域和时序收敛打好基础。5. 我踩过的LUT2相关坑位序写反、原语差异与自动化技巧最后这一部分我把自己实际开发中踩过的坑和积累的小技巧集中写出来。有些坑属于“抄一次就会了但第一次一定错”的类型提前告诉你能省下不少排查时间。5.1 位序写反仿真正确上板错的典型事故有次我根据一个在线逻辑工具生成的函数列直接在代码里写INIT(4b1101)。仿真时我照着工具给出的真值表手算怎么看都跟仿真结果对不上。折腾了半小时才发现工具输出列的第一行是“输入全0”对应的输出而INIT的第一位对应的是“输入全1”那一行。等我把整个4位列反过来功能立马就对了。这个坑在LUT3、LUT4里更隐蔽因为位宽变成8位、16位之后错两位很难一眼看出来。我的排查经验是遇到仿真和预期不符第一反应不应该是改逻辑而是打开testbench波形把输入的每一种组合都跑到然后对着真值表逐行核对。哪一行错就把INIT的那一位单独揪出来检查是不是高低位放反了。还有一种同样隐蔽的错法把I1和I0接反了。LUT2只有两个输入接反了之后INIT也要跟着对称变换否则功能不对。处理方式就是在代码里严格按“I1是高地址位”的约定来写约束和注释时不要出现“第一输入、第二输入”这种模糊说法。5.2 原语与平台差异换芯片后代码不通用LUT2是Xilinx专有的原语。Vivado和ISE里都能用但换成Lattice、Intel或者国产FPGA就不一定有同名原语。Lattice有LUT4Intel/Altera则更常使用lcell_comb这类原语接口和参数含义都不相同。这带来一个很现实的问题学了LUT2是不是以后所有代码都应该尽量用原语我的建议刚好相反。产品代码里能写RTL就写RTL让综合器自己去选LUT结构和尺寸只有在特殊场景比如需要锁定某条关键路径、避免综合工具过度合并逻辑时才手动例化原语。如果一定要在产品里用最好把原语封装在一个独立的底层模块里今后换平台只改那一个文件其它逻辑不用动。另外一个Xilinx平台内部的细节7系列里综合后的Schematic能看到LUT2原语但布局布线完成后物理资源视图里看到的是LUT6。这是因为物理单元是LUT6LUT2只是占用了其中2个输入。如果你在Device视图里找不到LUT2而着急以为工程没编进去那只是视角问题逻辑上并没有错。5.3 用脚本自动生成INIT参数手工转换真值表偶尔漏一位很正常所以我后来习惯用脚本自动算INIT。Python处理这件事非常顺手思路是按地址从小到大把输出值放成数组然后从低到高移位相加# 真值表按地址0~3排列地址由 {I1, I0} 组成 # 这里以异或门为例00-0, 01-1, 10-1, 11-0 func_bits [0, 1, 1, 0] init 0 for addr in range(4): init | func_bits[addr] addr print(fINIT 4b{init:04b} 4h{init:X})运行结果会输出INIT 4b0110 4h6跟前面手工算的异或门结果一致。这个脚本最大的好处是不容易犯位序错误因为移位方向和INIT的bit位是对应的。如果不想用外部脚本也可以在Verilog代码里用带注释的localparam来辅助说明localparam [3:0] TRUTH_NAND {1b0, 1b1, 1b1, 1b1}; // y3~y0: 0_1_1_1这种写法至少让看代码的人知道你是有意把真值表从上到下排成y3到y0的不会产生歧义。最后还有一个实用小建议拿到一个陌生的真值表时先不急着算INIT把输出列“从下往上读”的那串二进制数直接转成十六进制这个值填进去大概率是对的。这招在调试阶段特别快配合testbench波形验证一套流程下来基本不会出错。用LUT2原语入门表面上是学一个不起眼的小单元实际上是在建立“真值表到物理硬件”的直接映射感。有了这种感觉以后再翻时序报告、分析网表、做资源优化很多概念都能串起来了。手边有板子的话把文中的几个INIT值改一改再试几个自己写的真值表这个知识点就彻底内化了。
返回列表