1. UART协议拆解与FPGA实现前的思路准备
1.1 从协议时序看懂串口通信的本质
UART(Universal Asynchronous Receiver/Transmitter)这个缩写,看到它你可能会想起PIC、STM32的USART外设,或者一块板子上的COM口,其实它本身并不是指某个协议标准,而是一类硬件电路的功能定义。真正跑在UART上的是RS232规范,或者更常见的TTL电平串口。把时钟、数据线和地连在一起,双方约定好波特率、数据位、校验位和停止位,数据就能以“异步串行”的方式逐位传输。
做FPGA时为什么要自己写UART,而不是直接用现成IP?原因有几个,一是UART本身足够简单,用有限状态机加一个波特率计数器就能搞定,不必为简单功能引入额外集成;二是需要灵活的定制,比如你想加FIFO缓冲、自动校验特定帧头、用多字节打包协议,手写代码反而更容易控制;另外,学好UART是理解FPGA跨时钟域、数据采样边界的重要敲门砖,后面再做SPI、I2C甚至PCIE时会顺畅得多。
先用生活中例子理解UART的工作方式:两个人在电话里一个数字一个数字地报数,双方约定每秒钟报一个数字,报之前先喊一声“注意开始”,报完后喊“结束”。UART就是这样一个约定,空闲时数据线保持高电平,发送方将数据线拉低一段时间表示起始位,随后一个bit一个bit地把数据送出去,送完可以选择是否加奇偶校验,最后拉高电平维持至少一个bit周期表示停止位。这个过程中关键是两件事:双方以相同的速率采样,以及什么时候开始采样。
1.2 波特率、系统时钟与分频计算的关系
波特率是每秒传输的bit数量,常见的是9600、115200等,这个数值最高是“每秒多少个二进制位”,所以8个数据位、1个起始位、1个停止位,没有校验时一帧占10个bit,115200波特率意味着每秒最多传输11520帧数据。注意这里不包括用户数据之间的软件帧间隔,也不包括你没有配置的校验位。
FPGA设计有一个基准时钟,通常是50MHz、100MHz,需要通过分频来产生波特率时钟。但你不会真的在代码里产生一个独立波特率时钟来驱动状态机——那是一个新手容易踩的坑,那样扇出、跨时钟区域会引发很多问题。更稳妥的写法是:系统用一个统一时钟,设计一个计数器,当计数到某个目标值时拉高一个脉冲,表示“此刻对应一个bit的中间时刻”,所有状态机在这个使能脉冲下工作。
分频计数器的计算很简单,比如你用的是50MHz系统时钟,要实现115200波特率,则每个bit对应时长是1/115200秒≈8.68微秒;在50MHz下计数的周期数是 50000000/115200,约等于434个时钟周期。设计时可以用公式:division_cnt = system_clock / baud_rate,如50MHz除以115200得434.027,因此取434即可。波特率为9600时,50MHz的周期计数为50000000/9600≈5208。在代码中,你可以把波特率设计成参数,通过调整参数来切换不同速率。
如果还要做更精细的控制,可以利用累加器方式来生成使能脉冲,而不是单一计数器,比如采用16 bit累加器的“小数分频”思路,来减少非整数的误差。实现逻辑上并不复杂,核心一句话:你只需要一个高有效一拍的脉冲,状态机接着它的节拍推进,而不是产生真正的分频时钟。
1.3 UART实现过程中几个绕不开的选择项
1.3.1 数据位、校验位和停止位的参数化
标准UART配置:8N1,意思就是8个数据位、无校验、1个停止位。这个配置对应的位流宽度是10个bit。如果你的项目收到外部设备的数据是7E1、8O1等,则在实现时建议把帧格式做成参数式,例如:
localparam DATA_WIDTH = 8; localparam PARITY_EN = 0; localparam PARITY_ODD = 0; localparam STOP_WIDTH = 1;TX状态机和RX状态机都基于这些参数定义帧宽度,这样你哪天接个别的设备,比如某些工业仪表使用8E1格式,只需修改参数而不改核心逻辑。
1.3.2 中间采样还是起始位判定后采样
接收端最容易出的问题就是起始位误判和采样点在数据变换边缘。UART是异步协议,接收方只知道空闲时为高电平,并不确知对方何时发出数据。为避免干扰,一般会在下降沿后继续等待半个bit周期,在bit中间位置再次确认仍然是低电平,才认为真正开始了起始位。后续每个数据位都在bit中间采样,这样可以最大程度规避信号上升沿、下降沿附近的抖动。接收器内部使用系统时钟的多个周期去检测下降沿,建议至少连续采样两次都为低,再去计数半bit时间。有些设计还会用更高的过采样倍数,例如用16倍波特率的时钟,在中间5、6、7、8采样点做多数表决,提升抗干扰能力。如果只是学习验证,1倍波特率精度配合半bit定位也够用。
1.3.3 FIFO是否需要
裸奔的RX在每一字节到达时产生一个脉冲,如果你的逻辑来不及处理就被下一字节覆盖,数据就丢。而很多传感器或无线模组一次发一串数据过来时,中间不可能停顿,因此建议在RX和TX之间适当加FIFO缓冲。最简单可以用FPGA内部的Block RAM实现同步FIFO,深度32或者64一般都够。如果你将来要接PC、拨号模块一类的设备,FIFO深度大小往往决定了嵌入式软件读取数据的喘息空间。FIFO在后文的具体工程中会给出设计思路。
2. 系统级架构与模块划分
2.1 整体框图和模块职责
抛开具体型号,通常我们的工程会划分为这几个模块:UART发送模块、UART接收模块、波特率发生器、接收FIFO、发送FIFO、顶层例化模块。也有人会写成把波特率使能内嵌进发送/接收模块内部,减少顶层连接。只要代码清晰,这两种方式都可以。从测试维护角度来说,我更倾向于把波特率发生器独立出来,这样仿真可以让波特率模块单独运行,快速验证分频参数是否会溢出。
时钟和复位策略上,最基础的单时钟单复位设计就够了。异步复位建议做成“异步复位、同步释放”的常规形式,在FPGA中不会遇到复杂问题。如果开发板上有专用复位芯片,采用按键复位也可以,但按键会产生毛刺,最好在顶层过滤几十毫秒。另外,如果主时钟接入的是PLL/MMCM的输出,需要注意复位释放时序是否满足要求,建议查看对应的原语参考手册,很多开发示例已经给出推荐的复位结构。
顶层例化端口一般会是这样:
module uart_wrapper #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 )( input wire clk, input wire rst_n, input wire rx, output wire tx );这里不把具体数据总线细化到顶层,是因为在样板工程里,我们或许会先做“收什么发什么”的回环验证,然后再接入自定义处理逻辑。把数据接口暴露在子模块,再把子模块封装起来,有助于你把接口代码与应用逻辑分开。
2.2 模块间接口的设计方法
串口模块内部接口,我建议采用简单的valid信号加ready握手。比如接收模块解析完一字节后拉高rx_valid一拍,同时把并行数据输出给FIFO的写使能;发送模块从FIFO读取数据,当FIFO非空时由它拉高fifo_rd_en读取一个字节,然后进入发送时序。这种设计比起无脑把状态机连在一起,最大的优势是后续如果替换成DMA、软核处理器总线,接口可以更方便地接入。
另一个容易忽略的接口是流控:PC上通常有RTS/CTS硬件流控,但很多FPGA开发板没有引出这些信号,所以板级通信时多采用“约定帧格式 + 软件等待”的方式。在模块内部,TX_FIFO深度不够时可以把状态反馈给上层应用。
我们在实现板载回环测试时,可以用Verilog把RX数据直接连到TX数据上,即rx_fifo读出的数据写入tx_fifo。这样仿真和板级上都可以用串口助手发送字符串,再观察PC收到的内容是否一致。
3. 在Verilog中落地UART发送与接收
3.1 波特率生成模块的核心写法
上面计算的那两个参数——整计数的目标值,以及半bit时刻的目标值——在生成时,会配合rx模块一起用。对于发送模块,只需要在状态机里用cnt <= cnt + 1判定到baud_cnt值就产生脉冲,直到发送完所有bit再回到idle。发送端即使有误差,也是连续发送所产生的误差累计,一般情况下不超过3%的波特率偏差都可以容忍,但对于发送端我们依然建议误差控制在1%以内。
在代码实现中,常用写法是用一个baud_en脉冲做多周期扩展:
reg [15:0] baud_cnt; reg baud_en; always @(posedge clk or negedge rst_n) if (!rst_n) begin baud_cnt <= 16'd0; baud_en <= 1'b0; end else if (baud_cnt == (CLK_FREQ/BAUD_RATE-1)) begin baud_cnt <= 16'd0; baud_en <= 1'b1; end else begin baud_cnt <= baud_cnt + 1'b1; baud_en <= 1'b0; end这个baud_en信号每过一个bit周期出现一拍。有一点要注意,如果你又在另一处设置posedge(clk) 且用if(baud_en)作为时序条件,这相当于在一个时钟周期里步进一个bit,逻辑清晰且不会出现多驱动。不要尝试用根时钟直接计数并切换一个分频时钟,因为那样会让整个状态机跑在门控时钟上,引发时序综合问题。
3.2 发送模块的状态机设计与代码
发送模块状态包括IDLE、START、DATA、PARITY和STOP。如果电平起点为高,当上层请求发送时,先将txd打低,此刻状态机进入START;然后依次发送bit0到bit7,这里需要注意比特顺序是LSB first。常见陷阱是代码把最高有效位先发,结果PC端收到的数据按字节位顺序调换,这会让显示内容乱了。
发送状态的移位逻辑:
reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] tx_data_buf; reg [3:0] state; parameter S_IDLE=4'd0, S_START=4'd1, S_DATA=4'd2, S_PARITY=4'd3, S_STOP=4'd4; always @(posedge clk or negedge rst_n) if (!rst_n) begin state <= S_IDLE; txd <= 1'b1; end else if (baud_en) begin case (state) S_IDLE: if (send_en) begin tx_data_buf <= tx_data_in; txd <= 1'b0; state <= S_START; end else begin txd <= 1'b1; end S_START: begin txd <= tx_data_buf[0]; bit_cnt <= 4'd0; state <= S_DATA; end S_DATA: begin if (bit_cnt == DATA_WIDTH) begin // 是否带校验 ${P2:txd <= ^tx_data_buf} // 这行是无校验的占位思路 state <= S_PARITY; end else begin txd <= tx_data_buf[bit_cnt]; bit_cnt <= bit_cnt + 1'b1; end end ... endcase end有些教程在发送时会把txd <= tx_data_buf[0]放在状态转换的前一拍,另一种会在进入DATA状态后依次发送tx_data_buf[bit_cnt],效果等价,只是要注意延迟一拍的问题,否则会多发或少发一个数据。发送完成之后要回到IDLE,再把txd拉高。如果在代码中为了清高把txd在I_IDLE默认置1,可能导致在START期间被覆盖,所以最好在每个状态下都给出txd的明确赋值,不建议使用锁存器式的记忆逻辑,否则容易产生毛刺。
3.3 接收模块的多点采样与位同步思路
接收端最难的是起始位判定。当rx线空闲为高,一旦检测到下降沿,并不一定是真正的起始位——有可能是干扰毛刺,也可能只是某一位的前沿。于是,最稳妥的方式是检测到下降沿后启动一个delay计数器,计数到半bit周期时再次采样rx电平,如果仍然为低,则认为这是一个真实起始位。这个半bit时间的计数实际上等于baud_div/2。之后继续每比特采样一次。
下面给出关键伪代码思想:
reg rx_d0, rx_d1, rx_negedge; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d0 <= 1'b1; rx_d1 <= 1'b1; end else begin rx_d0 <= rx; rx_d1 <= rx_d0; end end assign rx_negedge = rx_d1 & ~rx_d0; // 注意这里用打两拍后的信号更符合真实时序更严格地讲,跨时钟域采集进来的rx信号在FPGA内部属于异步输入,需要使用两级寄存器同步消除亚稳态。rx_d0是输入同步后的第一级,rx_d1是第二级,在没有做双锁存器同步的情况下直接将rx送入状态机,可能在时钟采样沿附近发生亚稳态。这个做法放在任何跨时钟边界都适用,不只在UART接收模块。
采样“中点”计数可以用一个计数器cnt_cycle,从0开始,当cnt_cycle==(BAUD_DIV/2)时,对rx线采样起始位中点;随后设置state=S_DATA,并用cnt_cycle继续累加,每到BAUD_DIV时采样下一个bit。根据数据的LSB顺序,byte_data[bit_cnt] <= rx_d1,移位完毕后做校验等逻辑。
3.4 RX模块的字节输出与空闲间隔处理
接收完停止位后,可生成rx_valid脉冲并输出并行数据。也可不立即输出,而是等待停止位结束后多等一点时间再输出脉冲,确保PC端不再有连续字节。“空闲检测”本身也是嵌入在状态机里的:当检测到停止位高电平后,如果继续有下降沿就表示下一帧过来,否则进入空闲状态。
数据帧之间如果出现很短的毛刺,接收状态机可能误判,因此有些人会要求高电平持续若干时钟周期后才能从IDLE状态被唤醒。也就是说,IDLE状态下持续检测到rx高电平超过一定时间才真正进入等待起始位。应用层如果收到错误字节,通常依靠上层协议校验和重发来兜底,单纯链路层再加更多错误检测并不划算。
3.5 用回环编译验证顶层设计
为快速验证UART收发模块是否对接成功,可以在顶层中添加一个简单回环逻辑:
wire rx_valid; wire [7:0] rx_data; assign tx_data_wire = rx_data; assign tx_send_en = rx_valid;发送模块内部可在IDLE中检测send_en,把rx_data直接移入TX发送移位。这样不需要上位机参与,你就可以通过仿真波形验证,只要外部同一个测试台发送一个字节,观察tx输出是不是在约10bit之后得到该字节即可。
4. Testbench仿真验证的完整流程
4.1 搭建可自检测的UART Testbench
仿真分两方向进行:一个是从FPGA的角度观察接收模块是否能把波形解析回数据,另一个是验证发送模块的波形是否符合串口助手工具的解析规则。写仿真模型时,可以直接用行为级模拟一个简单PC端发送器:等空闲结束后拉低若干微秒向rx发送字符,比如发送字母A即十六进制8‘h41。
推荐建立两个测试文件:tb_uart_tx和tb_uart_rx,分别单测。在tx模块测试中,你期望看到中间波形里txd先是1,然后在下一个波特率时钟点变为0,依次发送LSB。在rx模块测试中,直接生产一系列延迟,也可以手工生成一个可读字符串,发送给DUT的rx输入,然后检查rx_valid和rx_data。
一个经验是尽量减少在仿真中使用#xxx这种绝对延时去和DUT内部节拍硬性对齐。正确方法是用发送端的时钟来模拟pc端波形,例如定义real_baud_period等,用verilog中的forever语句产生波特率时钟翻转。这样即使改变仿真时钟频率,你的Testbench依然能正常工作。
参考sample:
localparam CLK_PERIOD = 20; // 50MHz reg clock; initial clock = 0; always #(CLK_PERIOD/2) clock = ~clock; task uart_write_byte; input [7:0] byte; integer i; begin tx_line = 1; #(BIT_TIME); tx_line = 0; // start #(BIT_TIME); for (i = 0; i < 8; i = i + 1) begin tx_line = byte[i]; #(BIT_TIME); end tx_line = 1; // stop #(BIT_TIME); end endtask然后你可以在initial块中连续写几个字节,驱动到uart_rx模块的rx端口,再通过$display打印接收数据。初次跑仿真时建议开超过10字节的时间长度,例如50MHz,115200波特率,一个字节约耗时86.8us,如果仿真10个字节,最好定义到1ms或更多;如果不能等这么久,就把波特率参数临时改高,例如把BAUD_RATE改成1000000,再用仿真模型的Bit_TIME保持一致,这样波形调试速度快得多。
4.2 使用覆盖率验证边界条件
链路是否完整,不只是“正常收发一次”这么简单。还要检查一个功能点,当数据线上的第一个下降沿是毛刺而非真正起始位时,状态机是否会回到空闲状态。测试时可以特别构造一个脉冲序列:把rx先拉低很短一个周期,又拉高,随后过一段时间再真正发送起始位,看rx模块是否忽略毛刺、只检测下一次下降沿。另一个边界是全1和全0的数据。全1数据的字节在传输时几乎没有低电平触发,接收端要保证接收后正确寄存,全0数据要注意停止位前高电平只有很短时间,或者是不是造成长时间低电平产生误判。
由于芯片内部无法真正做到绝对精确的采样,仿真可以用系统时钟同一时序,不存在异步误差,所以更要侧重逻辑流。若要做更严谨检验,需要模型延时或有专门的正弦抖动注入测试。对于FPGA的UART学习项目,上述几种情况已经足够。
5. FPGA板级调试的完整过程与经验
5.1 硬件连接和USB转UART芯片的选择
板级调试前必须了解硬件接口。FPGA板上的串口多数是TTL电平,不是PC的RS232电平,所以不能用一根普通直通线连PC。一般开发板均已内置USB转UART芯片,常见的有FT232R、CP2102、CH340等,板载物理层已经做到TTL到USB的电平转换,不过仍是根线缆的区别。接入PC后,如果出现驱动没有自动安装的情况,通常去官网下载对应驱动即可。
FT232和CP2102两种芯片的区别多在于驱动方式和稳定性。如果你使用的是类似Arduino或其他USB转TTL小板,在购买时一定要弄清模块标注的是“3.3V或5V逻辑”还是“RS232电平”,如果把RS232的负逻辑接在FPGA引脚上,轻则通信乱码,重则烧毁IO。
接线方面,不需要大家把设备想得太复杂。PC上的串口助手软件,比如XCOM、SSCOM或者古老的SecureCRT,其实差别不大,只是注意波特率、校验位、停止位设置要和FPGA内部参数一致。命令发送时,一般有两类交互方式:一类是ASCII字符串,另一类是Hex十六进制。调试UART环回时建议用Hex模式,发送0x55(二进制0101_0101)这样的数据,能很快从波形上看出每一位是否稳定。
5.2 环回测试失败排查步骤
首先是环回测试的失败排查清单:
出现完全无响应时,先测试PC驱动是否正常。插上USB转UART模块后,打开设备管理器查看COM端口号。如果你在Windows下看到设备有黄叹号,先重装驱动。然后把FPGA工程中的tx引脚和USB模块的RXD正确对应,不接反,检查电平是否共地。FPGA板子如果被独立供电,也需要与USB转UART模块共地,否则信号参考不在同一个平面,数据随时出错。
出现乱码或字节错位时,多半是波特率不匹配,或误差超过容限。判断方法:发送一个字符0x55,用逻辑分析仪或者示波器抓rx引脚,测量其第一位低电平时间是否和理论bit时间接近。如果位时间超出了百分之五偏移,则检查你的分频计算是否四舍五入,系统时钟是否真的如你设想的50MHz而不是12MHz或100MHz。
另一种常见问题是大量数据发过来后,中途出现丢包或卡死。这种情形通常在环回测试里表现为:连续发送一百个字节,有一部分没回显。原因是接发两端速率不匹配,或者没有背压控制。你应该想办法提高缓存能力,比如发送端加入FIFO。同时还需要结合协议去查是不是握手信号没处理好。这里强烈建议先跑“单字节环回”,再跑“100字节回环”,再跑“长包回环”,逐步捕获异常出现的位置。
5.3 使用逻辑分析仪辅助调试
逻辑分析仪是UART调试利器。在没有逻辑分析仪时,普通示波器也能在单通道模式下看波形,但触发条件比较难做。有条件的话可以直接选一款几百元的USB逻辑分析仪,配上sigrok PulseView软件,把通道接在rx或者tx上。抓数据的时候要把采样率设成至少波特率的16倍。逻辑分析仪的通道可能会对波形有轻微影响,但正常情形下对调试没什么影响。重要的是要看起始位位置与采样点的对齐状况。
很多人读逻辑分析仪波形时会犯一个误区:把示波器上看到的波形画成“低电平是1,高电平是0”,实际上UART空闲状态是逻辑1,对应电压高电平(TTL)时表现为高电压,所以起始位是一个拉低的动作。当你把解析出的8位数据与发送方向相反时,可以根据波特率设置中数据帧格式来检查frm id是否设反了。
5.4 把FPGA串口接入电脑的常见方式
搜索热词里有一个很有趣的问题:“宿主机Windows如何通过串口与VMware中Linux通信”。这说明很多嵌入式爱好者会用到Linux下的工具,其实原理上跟物理串口没有区别:宿主机建了一个虚拟串口,VMware把虚拟串口映射到物理串口或文件描述符,然后在Linux虚拟机里用minicom或picocom等工具操作ttyS0/ttyUSB0设备。
当USB转UART插到宿主机后,如果在VMware虚拟机里选“连接”时勾选了该串口,就会把原生的COM口切给VMware独占,宿主机上的串口助手反而打不开。这时你再把信号接FPGA的时候,数据通路其实是从FPGA经过板载USB转UART芯片转到主机的物理USB口,再由虚拟串口软件转发到虚拟机。这种方式在调试中确实很常见,要注意的是只让一个主机端程序在某个时刻打开该COM口,不要把串口助手和虚拟机串口同时开着,不然会看到大量打开失败或按时间片断的数据。
6. 常见问题速查与避坑清单
6.1 容易让新手困惑的几个问题汇总
我在不同项目里看到过不少踩坑的记录,也总结出一个高频问题表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 完全收不到数据 | rx引脚约束错误、共地缺失、电平不匹配 | 检查引脚分配与原理图,查看电压表 |
| 收发时多为错码 | 波特率误差超过2%、数据位顺序传反 | 使用逻辑分析仪测起始位宽度 |
| 回环测试时只有第一个字节正确 | 发送状态机无法连续接收、握手信号缺失 | 在仿真里连续发3字节并观察模块状态 |
| 接收端频繁输出0x00/0xFF | 空闲判定出错,停止位没等到就退出 | 检查停止位宽度设置,加长计数器 |
| 发送端偶发少一个字节 | FIFO读使能时序晚了一拍 | 检查fifo_empty和fifo_rd_en的时序关系 |
| 板上运行正常,但打印字符串时却把字符倒序 | 把ASCII的高位和低位理解反了 | 核实数据帧格式与串口工具设置 |
6.2 状态机进入异常状态的恢复策略
UART的线缆易受干扰,数据丢失或毛刺可能让接收状态机跳入未定义状态。如果在case语句中缺失default分支,状态机会莫名其妙卡住。所有状态机都应当有default,比如回到S_IDLE。同时,起始位检测不要把“低电平持续不到半个bit”的情况直接当成有效起始位,只有在中间采样验证为低时才进入数据接收,能够避免大多数毛刺问题。
“滴答式的死锁”也比较多:比如说设计者在某状态里没有等待baud_en跳变就把变量推进,导致状态机跑得非常快,外部看起来串口每隔远远小于预期时间发送几个字节。建议你在每一个时序步骤都严格依赖打一拍后的baud_en,不要在case语句内写一个为0时刻的嵌套分支。
6.3 接线和板卡层面的经验
FPGA引脚和USB转UART模块连接时,必须应用原理图确认哪个pin接到了哪个地方。我曾见过因为靠近排针的PIN没有正确约束,结果模拟时正常、实板无响应的情况。另一个常见的锅是LED或蜂鸣器占用了串口相同的引脚,这样在板级调试时会自动把另一个设备驱动起来,改变I/O口状态,导致串口数据无法读取。
调试的时候可以在FPGA工程里加一个调试用的计数器,每收到一个字节就把板载LED翻转一次。这样哪怕不开串口助手,也能从闪烁状态判断有没有连续字节到达,更直观地分离“PC端接收问题”和“FPGA发送模块问题”。
7. 我更推荐的数据通路演进方案
当你已经跑通简单的收发和环回,下一步往往不是写更多位的并行发送代码,而是考虑数据流的整体设计。如果项目里还需要与外部ADC、传感器交互,那应该先设计好总线上哪些数据需要上传、PC哪些指令要下发。推荐加一个指令解析模块。指令格式简单做几个字节比如帧头(0xAA)+命令字+数据+校验和。这在FPGA里实现不复杂,只要在rx_valid那拍进入移位寄存器,存够一帧长再解析即可。如果要继续增加可靠度,还可以加上CRC校验。FPGA上使用多个LUT去查表做CRC校验也常见,就不在这里展开。
后续还可以把UART连到微处理器软核,如使用MicroBlaze或RISC-V软核时,UART往往是通过AXI总线来控制的。但不管是纯逻辑还是软核,你在本文中掌握的基础模块仍然关键,把模块接口改成AXI-Lite总线即可。先从无总线接口的裸逻辑做起,反而能避开总线的复杂性,更专心搞懂UART的本质。
8. 个人体会:把UART当一块试金石
我做过的许多FPGA项目里,UART模块的体量可能只占百分之几,甚至某些项目会直接用成熟IP来例化,但它依然值得亲手写一遍。主要原因不是UART有多难,而是它把数字电路设计中几个最基本的问题都浓缩到了这么一小段代码里:波特率分频是时序设计练习;起始位判定是跨时钟域和异步信号处理的典型场景;收发包的valid信号对接是模块化设计的基本功;上位机和FPGA联调则是验证闭环思维的第一步。
在某次调试中,我还踩过一个“极有迷惑性”的坑:接收模块使用了两级触发器同步rx信号,但是在Testbench中直接把rx信号驱动成与主时钟同沿翻转,结果每个数据位都出现了亚稳态错误,后来才发现需要在Testbench中加入真正的异步行为。从那一刻起,凡是跨时钟域的输入,我都会在仿真里让它与主时钟完全不相关,并且坚持在代码里明确两拍同步。实践经验说话:UART写一遍绝不亏,跑通之后,你再去调试SPI、I2C,会发现很多思路都可以迁移,它们也都靠时钟边沿采样、移位寄存器、帧格式解析这些公共概念支撑。
这篇文章基本把UART从协议、分频计算、Verilog状态机实现、仿真到板级调试的主要问题都过了一遍。如果你照着这个思路在FPGA上实现并调通,后面再遇到CP2102或FT232之类的USB芯片、VMware里的Linux串口映射、逻辑分析仪抓波形等操作,处理起来都会有底气得多。可以把你调试过程中卡住的地方记录成一个小册子,常看常新,这类经验比很多平台上的结论性文字都更值钱。