
很多刚接触VHDL的同学拿到一份代码第一反应就是从头到尾逐行读遇到看不懂的语法就翻书。我以前也这么干结果折腾一个小时代码没读完信心先没了一半。后来带我的工程师把文件一关问了我一个问题先别管那些语句这个模块对外到底有哪些引脚数据往哪边走我答不上来。他又问那这个程序里哪些是实体哪些是架构体我还是愣了一下。他说你连程序骨架都没拎出来看细节当然晕。这篇文章我想把VHDL基本程序框架这件事掰开揉碎讲清楚。适合谁看准备学FPGA、打算用VHDL做课程设计或者正在入门阶段自学的同学尤其是已经抄过几个代码、但一让自己从零写就开始发怵的人。内容会覆盖一个VHDL程序由哪几个部分组成、实体和架构体各自管什么事、库和程序包怎么用、一个能直接综合上板的例子以及我在实际开发里踩过的几个框架相关的大坑。读完以后你再拿到一份VHDL代码应该能在一分钟内把它拆成几大块而不是一头扎进语句细节里。1. VHDL程序框架的五个组成部分骨架先于逻辑1.1 一个完整的设计单元长什么样一个完整的VHDL程序不管功能多复杂最终都能拆成这几个设计单元库声明library、使用子句use、实体entity、架构体architecture还有可选但很少用到的配置configuration。这里的顺序不是随便排的它有很强的工程逻辑。库声明解决的是“我从哪里找工具”use子句解决的是“我要打开哪个工具箱”实体负责对外描述“这个硬件模块长什么样、外面怎么接”架构体负责回答“内部逻辑到底怎么实现的”。看一个最小程序二输入与门library ieee; use ieee.std_logic_1164.all; entity and2 is port ( a_i : in std_logic; b_i : in std_logic; y_o : out std_logic ); end entity and2; architecture rtl of and2 is begin y_o a_i and b_i; end architecture rtl;这个程序包含了两个设计单元entity and2和architecture rtl of and2。库和use子句属于前置声明配置没有出现因为默认配置就够用了。很多初学者有一个误区觉得把上面的代码抄下来背下来就学会了。其实关键不在代码本身而在于你要知道每一块为什么存在。实体是给模块“画外框”的架构体是给这个外框“填内脏”的。你先看到的是外框然后才是内脏。这个顺序和FPGA设计里“先定接口再写逻辑”的流程完全一致。1.2 为什么说理解框架比背语法更重要在FPGA开发里一个项目很少只是一个模块。顶层往往要例化好几个子模块子模块之间通过信号连接这种自顶向下的设计方式决定了你动手写代码之前必须先想清楚边界。框架的意义就在这里它逼你先定义“这个模块和外界怎么通信”再去想“内部怎么实现”。一个训练有素的工程师拿到需求以后第一件事不是打开编辑器噼里啪啦写代码而是先在纸上画模块框图标出输入输出信号。这个图落到VHDL里就是实体。后面所有逻辑实现都是往架构体里填内容。所以框架不是模板模板是死的框架是活的。模板让你抄作业框架让你理解硬件结构。把这一点想通后面遇到计数器、状态机、FIFO你都会发现它们只是架构体里的不同填法外层那个“壳”万变不离其宗。这也是这篇“FPGA之道”系列文章里VHDL基本程序框架值得单独拿出来讲的原因。2. 实体端口里藏着“设计边界”三件事2.1 端口方向四选一in/out/inout/buffer别拍脑袋实体里最重要就是端口定义。端口方向一旦选错轻则编译警告重则功能异常。VHDL给了四种模式in、out、inout、buffer它们对应的硬件含义完全不同。端口模式数据方向典型场景关键注意点in外部驱动内部可读时钟、复位、普通输入不能再从内部反向驱动out内部驱动外部可读普通输出内部不能再读它自身的当前值inout双向信号I2C数据线、SRAM数据总线需要处理高阻态Z和三态控制buffer内部可回读的输出理论上可回读的寄存输出实际工程很少用综合兼容性差最常见的坑出现在out端口上。比如你想实现一个计数器计数值既输出出去又参与内部比较。如果你把端口声明成out在架构体里写“if cnt 100”这种语句编译器直接报错因为out端口在内部不可读。很多人第一反应是用buffer因为教材上写了buffer可以回读。但我建议别用。原因后面第6章会细说这里先记住一句话无论是Altera还是Xilinx的主流工程代码里几乎看不到buffer端口更通用、更安全的做法是在内部定义一个信号用这个信号参与逻辑再把这个信号赋值给out端口。2.2 从bit到std_logic多值逻辑给仿真带来的自由度如果你看老教材会发现很多例子用bit类型bit只有0和1两种状态。实际开发里我强烈建议统一使用std_logic和std_logic_vector。原因是仿真时你需要表达的状态远远不止两个。比如地址总线在读写切换的瞬间可能处于高阻态需要Z有的信号没被驱动仿真器会显示U也就是未初始化某些无关项你可以写成X或者-。这些状态用bit根本表达不了而std_logic作为9值逻辑系统全都支持U 未初始化 X 未知 0 逻辑0 1 逻辑1 Z 高阻 W 弱未知 L 弱0 H 弱1 - 无关项所以正规工程里端口、信号声明几乎全部使用std_logic或者std_logic_vector。小心一点使用std_logic类型需要库支持所以代码开头必须有那两行library ieee; use ieee.std_logic_1164.all;如果没有这两行工具会告诉你std_logic类型未定义。很多刚入门的人把代码从网上复制下来头部的库就丢了第一眼看到的就是报错其实是这种基础问题。如果你用到无符号数运算、加法计数器还要引入numeric_std包里的unsigned和signed类型。std_logic_1164里不包含算术运算符这是另一个常被忽略的细节。2.3 generic参数让同一个实体适配不同位宽实体里除了port还有另外一个块叫generic。generic不是硬件输入它更像是参数配置表。在例化时通过generic map可以重新设置从而让同一个实体适配不同的位宽、不同的计数深度。举个例子。假设我要写一个数据宽度可配的寄存器模块entity reg_array is generic ( DATA_WIDTH : positive : 8 ); port ( clk_i : in std_logic; rst_n_i : in std_logic; data_i : in std_logic_vector(DATA_WIDTH - 1 downto 0); data_o : out std_logic_vector(DATA_WIDTH - 1 downto 0) ); end entity reg_array;这样例化时8位、16位、32位都可以直接复用这一个实体。generic用好的好处是你在仿真阶段可以把计数器位宽缩小、分频系数降低加速仿真上板时再恢复成真实参数代码不用改第二遍。3. 架构体才是真正的逻辑容器3.1 声明区和语句区的分工架构体的结构比实体更接近“程序”的感觉它分成两个区域声明区和语句区。architecture rtl of module is -- 声明区 signal cnt_r : unsigned(7 downto 0); constant INIT_VAL : integer : 0; begin -- 语句区 cnt_r cnt_r 1; end architecture rtl;声明区里可以放信号、常量、数据类型、函数声明、元件声明等。语句区是真正描述逻辑行为的地方这里的语句有一个很重要特点并行执行不是顺序执行。这和C语言完全不一样。C语言从上到下逐行执行VHDL的架构体语句区里所有语句在硬件上是同时存在的。哪怕你先写了一条赋值再写另一条赋值它们在综合后也是两个硬件逻辑并联不是按书写顺序先后触发。理解这个并行特性是读懂VHDL代码的关键一步。3.2 信号赋值不是即时的进程的“延迟更新”VHDL里最让软件思维的人头疼的就是信号赋值不是立刻生效。信号用赋值。在process里同样一个信号被赋值以后它在当前进程的这一次执行里并不会马上更新而要等进程结束后才统一生效。我举个例子想在时钟上升沿交换两个信号的值process (clk_i) begin if rising_edge(clk_i) then a_sig b_sig; b_sig a_sig; end if; end process;运行结果是什么a_sig拿到的是旧b_sigb_sig拿到的是旧a_sig两个值成功交换。这正好模拟了真实的寄存器更新行为在时钟沿到来那一刻所有寄存器同时采样输入端更新发生在同一时刻而不是先执行a再执行b。如果你用变量就会出问题。变量用:赋值它没有延迟中间结果立即可见。如果上面代码写反成process (clk_i) begin if rising_edge(clk_i) then a_var : b_var; b_var : a_var; end if; end process;执行完两步以后a_var等于旧b_varb_var也等于旧b_var值没有交换而是两个都变成了同一个值。这就是信号与变量最核心的本质差异。所以在框架里定义内部信号时你实际上是在定义一组随时钟沿更新的寄存器状态而不是普通软件里的中间变量。3.3 三种描述风格与例化从一张网表看结构化设计VHDL架构体里的实现风格大致分三种数据流风格、行为风格、结构化风格。数据流风格用连续赋值描述信号流向比如y_o a_i and b_i。它像是画了一张真值表或表达式适合做组合逻辑。行为风格更接近“算法描述”用process配合if、case实现。时序逻辑基本都用行为风格因为进程天然支持用rising_edge判断时钟边沿。结构化风格则是例化已有的模块把多个元件像搭积木一样连起来。这种风格常用于顶层设计比如例化一个PLL、一个FIFO、一个状态机模块。元件例化的写法是u_pll : entity work.pll_mem port map ( clk_in clk_i, clk_out clk_100m );三种风格不是互斥的实际工程里往往混着用。顶层用结构化风格例化各功能模块底层模块内部用数据流和行为风格。理解了这一点你对框架的认知就不再是一段代码而是一棵结构清晰的模块树。4. 库和程序包是重复利用的“弹药库”4.1 library、use和work三个容易混淆的概念架构建模里除了实体和架构体几乎每个文件开头都有library和use。这行代码解决的是“类型和函数从哪来”的问题。library指向的是编译好的资源库比如ieee库、std库还有你自己编译生成的work库。use则是从库里引用具体的程序包。打个比方library相当于一个仓库use相当于把仓库里某个工具盒打开放到桌面上。从VHDL语言定义来看std库包含standard和textio两个包standard包提供了bit、integer、boolean这些基础类型所以这些类型不需要额外use就能用。ieee库是我们实际开发里最常引用的库里面的std_logic_1164包提供了std_logic多值逻辑系统。而work库比较特殊它指向你当前工程编译后的资源你自己写的模块和package最终都会进work库所以例化本工程模块时不需要专门use。4.2 标准库包选择别把numeric_std和std_logic_arith混在一起使用ieee库时要注意常用的包有这么几个use ieee.std_logic_1164.all; -- std_logic类型定义 use ieee.numeric_std.all; -- 有符号/无符号数运算numeric_std是IEEE标准包提供了unsigned、signed类型以及对应的算术、比较、移位操作。现在Xilinx和Altera的新工程里都推荐用它。但很多老的教程、例程里会出现std_logic_arith、std_logic_unsigned、std_logic_signed这些非标准包。这些不是IEEE标准是Synopsys早年提供的虽然老工具和不少例程里用了但它们和numeric_std混用很容易产生重载冲突比如“operator cannot match types”。如果你要写新代码我的建议非常简单只使用std_logic_1164和numeric_std。不要为了将就老代码引入一堆非标准库。这能帮你避开很多莫名其妙的重载错误。4.3 自定义package封装常量与函数实际项目规模一大你会发现很多常量、状态枚举、公共函数散落在各个模块里。更好的做法是把它们集中到一个自己写的package中。比如package my_pkg is constant MAX_CNT : integer : 4095; type state_t is (idle, run, done); function max_val(a, b : integer) return integer; end package my_pkg; package body my_pkg is function max_val(a, b : integer) return integer is begin if a b then return a; else return b; end if; end function max_val; end package body my_pkg;使用时在文件头部写use work.my_pkg.all。这样不同模块之间共享常量、类型和函数代码重复率大幅下降。我自己的习惯是一个工程设立一个common_pkg.vhd放版本号、通用常量、状态机统一编码然后所有模块统一引用。这个习惯的收益在多人协作和大项目维护阶段尤其明显。5. LED闪烁计数器实战把框架走通一遍5.1 从需求到参数先算计数器位宽框架说得再多不如动手写一个完整例子。这里我以一个最常见的LED闪烁为例系统时钟50MHz要求LED每500ms翻转一次也就是亮500ms、灭500ms。先算计数器需要计数多少次计数次数 时钟频率 × 时间 50_000_000 × 0.5 25_000_00025_000_000小于2^2533_554_432所以理论上25位计数器就够。但工程里为了后续改时间参数方便我一般直接声明成32位无符号数省得每次改参数都要重算位宽。这种“留余量”的做法在模块化设计里很常见代价只是多几十个寄存器FPGA里通常毫不在乎。5.2 模块代码实体、架构、进程一次到位完整代码如下library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity led_blinker is generic ( CLK_FREQ_HZ : integer : 50_000_000; HALF_MS : integer : 500 ); port ( clk_i : in std_logic; rst_n_i : in std_logic; led_o : out std_logic ); end entity led_blinker; architecture rtl of led_blinker is constant CNT_MAX : integer : CLK_FREQ_HZ * HALF_MS / 1000 - 1; signal cnt_r : unsigned(31 downto 0); signal toggle_r : std_logic; begin process (clk_i, rst_n_i) is begin if rst_n_i 0 then cnt_r (others 0); toggle_r 0; elsif rising_edge(clk_i) then if cnt_r to_unsigned(CNT_MAX, cnt_rlength) then cnt_r (others 0); toggle_r not toggle_r; else cnt_r cnt_r 1; end if; end if; end process; led_o toggle_r; end architecture rtl;这个模块把前面讲的框架都装进来了库声明、use子句、带generic的entity、带信号声明的architecture、always式的时序进程。所有内部状态只有cnt_r和toggle_r对外只留一个时钟、一个复位、一个LED输出。代码里我特意保留了复位分支。很多初学者嫌复位麻烦把它删了结果综合出来后状态未知仿真输出一直抖动。异步复位、统一释放是FPGA设计里的基本功宁可多写两行也不要省这层保护。5.3 仿真验证的基本testbench写法写完模块不能直接上板至少要先做行为仿真。testbench本身也是VHDL框架只是实体没有输入输出端口架构体里例化被测模块然后给出激励。library ieee; use ieee.std_logic_1164.all; entity tb_led_blinker is end entity tb_led_blinker; architecture sim of tb_led_blinker is signal clk_r : std_logic : 0; signal rst_r : std_logic : 1; signal led_w : std_logic; begin clk_r not clk_r after 10 ns; -- 50MHz dut : entity work.led_blinker generic map ( CLK_FREQ_HZ 50_000_000, HALF_MS 2 ) port map ( clk_i clk_r, rst_n_i rst_r, led_o led_w ); process is begin wait for 100 ns; rst_r 0; wait for 100 ns; rst_r 1; wait; end process; end architecture sim;仿真时我故意把HALF_MS从500改成2这样不用等真的一秒钟就能观察到翻转。这就是generic带来的最大好处代码不用复制只需在仿真例化时覆盖参数。注意时钟生成语句。50MHz对应周期20ns半周期就是10ns所以after 10 ns翻转一次。testbench里的代码能综合吗不需要综合仿真代码属于host端只要仿真器能跑就行。5.4 约束与上板让设计真正跑起来仿真通过后还需要约束文件。以Xilinx Vivado为例XDC里至少要有时钟定义、时钟引脚位置、LED引脚位置和IO电平标准。create_clock -period 20.000 -name sys_clk [get_ports clk_i] set_property PACKAGE_PIN E3 [get_ports clk_i] set_property IOSTANDARD LVCMOS33 [get_ports clk_i] set_property PACKAGE_PIN F5 [get_ports led_o] set_property IOSTANDARD LVCMOS33 [get_ports led_o]具体引脚编号取决于开发板不同板卡差异很大。上板之前一定要查板卡的原理图或手册确认时钟引脚和LED引脚位置以及IO电平是1.8V还是3.3V别搞错电平标准否则可能烧器件。Altera/Intel工具里对应的是QSF文件写法类似。流程上都是创建工程→添加设计文件→添加约束文件→综合→实现→生成比特流→下载。框架熟悉以后这套流程基本是固定动作。6. 我在框架期就遇到的几个“隐形炸弹”6.1 buffer端口“能回读”却难综合前面我建议别用buffer这里具体说原因。buffer确实是VHDL标准里的端口模式也允许内部回读。但实际用起来你会发现几个问题首先例化buffer端口的模块时连接这个端口的信号也必须有某种特殊声明否则工具报错其次很多综合工具对buffer端口的支持不算好不同工具的处理结果不一致最后buffer端口不能直接连接普通out端口这个限制很容易在层级设计中爆雷。我的替代方案非常简单端口一律声明为out内部定义一个work信号逻辑里读写都指向内部信号最后把内部信号赋给out端口。architecture rtl of cnt_mod is signal cnt_int : unsigned(7 downto 0); begin cnt_out std_logic_vector(cnt_int); if cnt_int x64 then ... end architecture rtl;这样既满足内部回读又绕开了buffer的兼容性问题。这是所有成熟工程都在用的通用做法。6.2 敏感列表漏信号导致仿真和综合不一致行为风格的组合逻辑进程敏感列表如果不完整仿真时输出不会立即对输入变化作出反应但综合工具会按照完整组合逻辑来综合。结果就是仿真波形和实际硬件行为不一致这是新手最容易踩的隐蔽坑。看这个选择器process (sel) is begin if sel 0 then y a; else y b; end if; end process;敏感列表只有sel没有a和b。仿真时a变化了但进程不重新执行y不会立刻更新可综合出来的硬件里y永远等于a和b的组合结果。两种表现对不上排查起来很痛苦。现在VHDL-2008支持process(all)所有变量自动敏感很多新代码都在用。但老工程和老工具不认这时老老实实把信号全部列全或者干脆改成process(clk)这种时序逻辑敏感列表就永远只有时钟和复位问题自动消失。我的原则是组合逻辑用process(all)不支持2008就老老实实全列。6.3 分支不完整综合器送了个锁存器组合逻辑进程里if没有else或者case没有when others综合工具会推断出锁存器。大多数情况下这不是你想要的结果。比如process (a, b) is begin if a 1 then y b; end if; end process;a为0时y既不等于b也不是0而是保持上次的值这在硬件上就是一个锁存器。除非你确实要设计锁存器否则这就是危险信号。正确写法是补齐else要么令y为默认值要么在进程开头给所有输出赋默认值。process (a, b) is begin y 0; -- 默认赋值 if a 1 then y b; end if; end process;这种“先全量赋值再局部覆盖”的写法能很好地杜绝遗漏分支导致的锁存器。6.4 文件、实体、工程命名混乱DEBUG成本翻倍VHDL对大小写不敏感关键字小写大写都可以但标识符必须是字母开头不能以数字开头也不能和VHDL关键字同名。更重要的是工程组织层面的规范文件名和实体名保持一致。我之前接过一个同事留下的模块文件名叫module_a_fix_final.vhd里面实体叫bbb_top注释还写着这是某老版本改的我花了十五分钟才把文件和实体对应上。FPGA工程一旦复杂起来这种混乱会成倍放大。所以我的习惯是一个文件只放一个实体文件名和实体名完全一样顶层模块名与工程名或核心功能保持一致。同时端口命名尽量统一风格输入带_i输出带_o这虽然和框架语法无关但在团队协作里是真正的“隐形框架”。6.5 一个信号被两个进程赋值多驱动冲突架构体语句区是并行的这带来一个很重要的约束同一个信号只能在“一个”进程里赋值或者用一条并行语句驱动。如果你在两个进程里给同一个信号赋值就会产生多驱动冲突。多驱动的后果分两种情况仿真里std_logic类型有解析函数多个驱动会按规则解析出一个值结果可能不是你预期的综合时大多数工具直接报错让你改代码。这个错误在状态机组合逻辑混写的时候很常见尤其是你想在多个进程里为了“方便”更新同一个计数器。解决办法就是“单一驱动原则”每个信号只允许一个进程或一条语句赋值。如果多处需要更新同一信号应该把它收敛到一个进程里通过状态或标志位分支处理。这是VHDL框架里最接近底层硬件本质的一条纪律理解了它你才算真正开始用硬件的思路写代码。最后再分享一个我自己的小习惯学VHDL的框架阶段不要光看语法书也别急着大量抄代码。找一块开发板从一个LED闪烁、一个按键消抖这种最小题目开始自己在编辑器里敲完完整框架仿真再上板。敲过几次以后你会发现实体、架构体、库、进程这些概念长在脑子里了再去看任何VHDL代码都能一眼拆出骨架。这个“肌肉记忆”才是基本程序框架最有价值的收获。