做FPGA也有几年了被问得最多的入门项目除了流水灯就是UART串口通信。这玩意儿看起来简单不就是发个字节收个字节吗可真自己动手在板子上调通、把数据稳稳当当地收发不丢帧这里面的门道比想象中多不少。今天不聊虚的直接把我在工程里反复用过、踩过坑的UART实现方案掰开揉碎讲一遍。这篇内容适合正在学FPGA、想搞懂串口底层原理的朋友也适合那些已经跑通Demo但一上复杂应用就丢数据、乱码的开发者。读完你不仅能自己写出可用的UART收发模块还能明白波特率怎么算、采样点怎么选、FIFO怎么接遇到问题也知道往哪个方向排查。1. UART的本质与FPGA实现思路1.1 UART协议到底在传什么UART全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。它的核心特点就俩字异步收发双方之间没有独立的时钟线靠的是约定好的波特率Baud Rate和各自分频出来的时钟去对齐数据位。一个标准的UART帧长这样空闲时总线保持高电平要发数据时先拉低一个位时间这是起始位Start Bit紧接着是5到8位数据位通常我们用的是8位然后是可有可无的校验位Parity Bit最后是1到2位停止位Stop Bit必须拉高。所以一帧发下来最常看到的配置是8-N-1也就是8个数据位、无校验、1个停止位加上起始位一共10个bit。理解这个帧结构是后面所有设计的地基。很多刚入门的同学写接收逻辑时想不明白“为什么我收到的第一个字节是乱的”其实多半就是没有把起始位检测和后续采样点对齐搞清楚。1.2 为什么用FPGA实现UART有人会问串口这事儿单片机干得好好的51、STM32、ESP32都有现成外设为什么还要用FPGA来折腾一个很直接的原因是FPGA的优势在于灵活和并行。当成百上千路串口要同时收数据、或者串口协议需要跟其他高速逻辑做无缝对接时单片机的外设数量和处理能力就捉襟见肘了。用FPGA实现UART等于自己造了一个和逻辑时钟同源的专用外设想挂几个挂几个想怎么跟其他模块握手就怎么握手。另外从学习角度讲UART是FPGA入门最合适的练手项目之一。它麻雀虽小五脏俱全有分频器波特率生成、有时序控制状态机、有跨时钟域处理收发两端时钟不同、有FIFO缓冲的需求。把UART吃透了再去碰SPI、I2C、PCIe这些复杂协议思维路径是相通的。1.3 三种主流设计架构对比UART在FPGA里的实现方式大致有三条路线第一种是全同步方案系统时钟直接等于波特率的整数倍用计数器分频得到位时钟。这是教科书教的方法理解起来最直观适合教学。第二种是过采样方案用一个远高于波特率的时钟比如波特率的16倍或32倍去采样RX引脚通过检测跳变沿和多数表决来判定电平。这种方案抗干扰能力强是芯片级UART最常用的做法。第三种是IP核方案直接用厂商现成的UART IP比如Xilinx的AXI UART Lite。工程化最快但可定制性差你想在里面插个数据预处理逻辑就比较费劲。个人建议入门和学习用方案一工程上需要稳定可靠就上方案二被工期追着跑就直接用IP核。下面我重点讲前两种方案的具体实现因为这才是面试和工作里真正会被问到的东西。2. 核心模块设计与Verilog实现2.1 整体模块划分一个完整的UART通信系统在FPGA内部一般拆成几个模块来写波特率生成模块Baud Generator负责产生位时钟或采样时钟发送模块UART TX把并行数据转成串行帧发出去接收模块UART RX把串行数据恢复成并行数据FIFO缓冲模块跨时钟域缓存数据防止丢帧寄存器配置模块如果是带AXI接口的还需要这层但咱自己写可以省掉模块划分的原则是单一职责每个模块只干一件事。这样做的好处是debug的时候可以单测发现问题不用从头捋到尾。2.2 波特率生成计数器的艺术波特率生成是UART的第一个门槛也是最容易出错的地方。核心问题是怎么用你手上的系统时钟产生一个接近目标波特率的分频时钟信号假设板子上的系统时钟是50MHz想要产生9600bps的波特率分频系数就是50,000,000 / 9600约等于5208.33。因为是整数分频实际波特率是50,000,000 / 5208 ≈ 9600.61bps误差只有0.006%完全在可接受范围内通常串口容错在2%以内。parameter CLK_FREQ 50_000_000; parameter BAUD_RATE 9600; localparam DIV_COUNT CLK_FREQ / BAUD_RATE / 16; // 16倍过采样时的分频值这里有个常见坑直接用50MHz时钟去产生波特率时钟然后拿这个慢速时钟当触发沿去驱动发送逻辑这种做法在仿真里没问题但在实际FPGA里会埋下隐患。因为分频得到的时钟会使能信号而不是时钟树直接在always块里用“posedge baud_clk”这种写法会导致时序分析困难出现毛刺时定位问题极慢。我在工程里推荐的做法是始终使用系统时钟作为always块的触发用分频得到的使能信号baud_en来判断“这个时钟周期是不是该采一个点了”。reg [12:0] cnt; wire tx_baud_en; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 0; else if (cnt DIV_COUNT - 1) cnt 0; else cnt cnt 1; end assign tx_baud_en (cnt DIV_COUNT - 1);这个使能信号就是整个UART发送模块的心跳。后面所有状态跳转都以它为准。注意分频系数计算时不要直接四舍五入取整要结合采样方式选择。用16倍过采样时接收端的采样时钟是波特率的16倍因此接收模块的分频系数是发送模块的1/16。这个差异是新手最容易忽略的。2.3 发送模块状态机写起来不复杂但要稳发送模块的设计思路很直接一个状态机从IDLE开始按起始位、8个数据位、停止位的顺序把帧发出去。localparam IDLE 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3;核心逻辑可以这样组织状态机在每个baud_en有效时切换状态DATA状态下用一个内部计数器bit_cnt表示当前发到第几位。输出tx_line在IDLE和STOP时保持高电平START时拉低DATA时按发送数据的bit位输出。这里有一个细节很多人栽过数据位的发送顺序。UART是LSB first也就是最低位先发。如果你的并行数据是从FPGA内部总线来的比如一个8位的data_reg发的时候要发data_reg[0]然后[1]、[2]…直到[7]。反过来接收端拼数据时也是先收到的放最低位。搞反了这个顺序串口助手里收到的就是比特序错乱的数据表现为十六进制值完全不对比如0x01发出去变成0x80。发送模块还应该考虑一个busy信号告诉外界“我正在发数据你别往我这儿塞新的”。这个busy信号在上位机和逻辑交互时非常重要。很多丢帧问题不是因为UART本身出错而是发送端还没来得及发完上一帧下一帧就被外部逻辑强行覆盖了。2.4 接收模块采样点和起始位检测是关键接收模块是UART设计里最考验细节的地方比发送多两个难点一是怎么确定起始位的有效位置二是在何时采样数据位最稳妥。先说起始位检测。RX线空闲时是高电平当它从高变低时我们认为可能是一个起始位。但问题是如果线上有个毛刺或者别的模块拉低了一下就可能误判。所以工程上不会看到下降沿就直接认定起始位而是用16倍过采样或者是波特率时钟的多倍采样去持续观测。典型做法是检测到下降沿后等半个位时间再采样一次如果仍然是低电平才确定是真正的起始位。如果是毛刺等半个位时间后早就恢复高电平了就会把这个假起始位过滤掉。这个“半个位时间再确认”的思路正式名称叫中心采样。在16倍过采样时钟下一个位时间就是16个采样时钟周期。检测到下降沿后等8个周期再开始采样第一个数据位之后每隔16个周期采样一次正好采到每个数据位的正中心。在中心点采样距离前后沿都有半个位时间的距离抗信号抖动能力最强。接收的数据恢复逻辑也不复杂每个采样点拿到一个bit值按顺序从低到高拼装成8位数据。拼完数据位还要接着采停止位如果停止位不是高电平说明这一帧有误码可以置一个frame_err标志。always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data 8d0; rx_done 1b0; end else if (rx_state DATA sample_cnt 15) begin rx_data[bit_cnt] rx_line; end end这里还有个跨时域的坑。接收模块的输入rx_line来自FPGA外部引脚它跟你的系统时钟完全是异步的。直接用原始信号进去做下降沿检测有可能会采到亚稳态数据在时钟沿附近变化触发器输出进入不确定状态。工程上常规做法是先打两拍同步再送进逻辑处理。reg rx_line_0, rx_line_1, rx_line_2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_line_0 1b1; rx_line_1 1b1; rx_line_2 1b1; end else begin rx_line_0 rx_line; // 第一拍 rx_line_1 rx_line_0; // 第二拍此时信号已同步 rx_line_2 rx_line_1; // 用于边沿检测 end end assign rx_negedge rx_line_2 ~rx_line_1;这两三行代码就是面试里常被追问的“异步信号同步化处理”。别觉得麻烦少了它你的接收模块在高速率下跑起来可能出现概率性的乱码尤其波特率越高速率越明显。3. 集成与板级调试从仿真到实机3.1 顶层信号约束与引脚分配模块写完之后要把它接到FPGA的实际引脚上。这里有一个初学者很容易忽略的操作引脚约束Pin Assignment和电平标准I/O Standard设置。UART的TX和RX引脚分配在Xilinx系里是写XDC文件在Intel系里是做Pin Planner。关键不只是选哪个引脚还要正确设置电平标准。如果板子上串口芯片接口是3.3V的LVCMOS电平那约束文件里就得写LVCMOS33。如果选成LVCMOS18很可能出现信号识别不了或者驱动能力不足的问题。另外强烈建议在顶层模块里把外部输入信号比如RX引脚用IBUF原语或者直接靠综合工具自动推断处理一下确保引脚进来的信号有明确的输入缓冲。很多综合工具默认会帮你处理好但也有不处理的时候这时候信号质量会受到很大影响。3.2 回环测试最简单的冒烟验证在连接外部设备之前最快速的验证方法就是回环测试。用什么做回环一根杜邦线或者直接在代码里把TX模块的输出接到RX模块的输入。这个测试能验证什么能验证你的波特率生成、发送状态机、接收状态机、数据拼装逻辑整个链路是否自洽。如果自己的回环都收不到数据那就先别接外部设备瞎猜老老实实回去查代码。实际调试时我习惯在回环路径上插一个简单的计数器每收到一个字节就把收到的数据加1后再发出去。这样用串口助手发0x01能看到0x02回来就能直观确认链路工作正常。如果收到了0x03说明接收、发送模块里某处多处理了一个时钟周期数据错位了。3.3 用仿真验证时序正确性写FPGA不仿真等于闭眼开车。UART模块的仿真要点在于验证两个东西一是波特率使能信号的周期是否符合预期二是收发联合后的数据完整性。仿真时可以用模拟外部串口设备的激励就是写一个testbench里面生成一个标准UART波形喂给接收模块。比如要模拟发0xA510100101那RX线上的波形应该是空闲高、起始低、数据位低位先发1,0,1,0,0,1,0,1、停止位高。画出来总共10个bit每个bit的时间宽度根据波特率来算。// testbench模拟发送一帧0xA59600bps initial begin rx_line 1b1; #(5208*8) rx_line 1b0; // 起始位 #5208 rx_line 1b1; // bit0 1 #5208 rx_line 1b0; // bit1 0 // ... 省略后续bit end仿真看到rx_done信号拉高、rx_data等于0xA5说明接收逻辑正确。再把发送模块的输出通过testbench抓下来看波形里每个bit的位置和值是否符合预期。这里重点提醒一下仿真时间单位要事先设置好timescale 1ns/1ps如果系统时钟是20ns周期50MHz那9600bps的位时间是104.16us也就是104160ns。自己心里要有数仿真跑一个字节大约需要1ms左右如果跑的帧数多仿真时间会比较长属正常现象。3.4 上位机联调真实串口打通整个链路仿真通过之后就该跟真实的电脑串口对接了。把FPGA开发板的UART_TX接到USB转串口模块的RXDUART_RX接到TXDGND一定要共地。这个共地问题看着小儿科但真有不少同学栽在上面两个设备各自独立供电不共参考地导致TTL电平判断没有统一基准收到的全是乱码。上位机我用过的大概有三类一类是通用的串口助手比如友善串口助手、XCOM一类是调试工具里的串口模块像Putty、SecureCRT里的serial session还有一些是带脚本能力的工具比如Python的pyserial。第一轮联调优先用最笨的十六进制显示模式。别开ASCII显示因为ASCII模式下看到一个奇怪的十六进制值会映射成各种不可见字符根本判断不了数据是否正确。用十六进制模式发个0x5501010101如果收到0x55说明整个链路完全正常。0x55这个值很适合做测试因为它的比特流是01交替能暴露采样错位和位序反转的问题。如果直接收不到或者收错了排查顺序我一般是这样的先查电平用示波器或逻辑分析仪看TX引脚有没有波形起始位、数据位、停止位是否明显。没有示波器的话看板载LED如果LED在发数据时狂闪说明引脚确实有活动。再查参数波特率、数据位、停止位、校验位上位机和FPGA两端是否设置一致。这个检查最好放在最前面因为实在太多人栽倒了9600和115200默认值换来换去数据显示全是雪花。最后查代码仔细看发送状态机的跳转条件是不是在baud_en有效时才跳转。看接收状态的采样时刻是不是稳定在每个bit的中心点。这类问题只能回仿真里抓时序靠肉眼读代码反而慢。4. 常见问题与排查技巧实录4.1 乱码问题比特序和波特率两大元凶乱码是串口调试里最让人抓狂的现象没有之一。按照我的经验乱码九成以上是两个原因。头号原因是波特率对不上。上位机用9600收FPGA侧实际产生的波特率可能是10400也可能是19200。怎么查最好是把波特率产生逻辑里的分频参数打印出来或者用计数器引出调试信号用示波器看这个信号的频率。再不济写个testbench数一下baud_en每隔多少个时钟周期拉高一次跟理论值做对比。第二个原因是比特序错误。UART先发低位接收模块按先收到的放低位来拼。哪个环节写反了都会导致数值面目全非。0x55如果收成0xAA基本就是全帧反序了如果某几个bit错了但其他对多半是采样点飘了。4.2 丢数据问题FIFO不是可有可无跟着Demo一步步做到回环成功很多人会兴奋地开始写自己的项目比如用UART把ADC采样数据送到上位机。这时候就会遇到新问题数据一多就开始丢。丢数据的原因很直白上位机从串口读数据的速度远大于FPGA里数据产生的速度时也就算了是小于但反过来如果你的FPGA逻辑会一次性突发很多数据比如一帧图像、一段缓冲数据UART发送模块那一个个bit慢慢往外磨的速度根本跟不上后面新产生的数据没地方放就被覆盖了。解法就是加FIFO。发送侧加一个FIFO外部逻辑把要发的数据写进FIFOUART发送模块自己按自己的节奏从FIFO取数往外发。这样外部逻辑不需要关心串口发到哪了只要FIFO没满就可以只管写。接收侧同理。如果上位机一次发来一堆数据你的FPGA主逻辑处理不过来先让UART接收模块把数据送进FIFO主逻辑有空再来取。这里要记住一句话FIFO的深度要根据应用场景来定。只是偶尔传几个指令深度16或32足够传图像数据深度可能要512甚至2K。用Xilinx的话直接用FIFO Generator IP配置成standard FIFO读写时钟可以选同频也可以选异步。4.3 高速率下不稳定过采样和终端电阻的权衡有人把波特率往上调从9600调到921600发现在高速率下数据开始不稳定时好时坏。这是UART高速化的经典问题。先看代码层面高速率下波特率使能信号每一帧里的计数起点误差会被放大如果分频系数比较小比如系统时钟只有50MHz921600波特率下16倍过采样时每个采样时钟只有大约3.39个系统时钟周期计数分频的误差会非常显著。所以高速率下建议提高系统时钟频率或者改用专用时钟管理单元MMCM/PLL来产生高精度的采样时钟。再看物理层面TTL电平的UART线如果走线太长高速率下信号边沿会变缓采样点很可能采到上升沿或下降沿中间的不稳定区域。此时要么缩短走线要么把波特率降回来。另外USB转串口芯片的质量也很关键很多便宜的CH340模块在115200以上就开始出现丢字节不是你的FPGA代码有问题是转换器本身质量不过关。4.4 面试和工程里的加分项错误处理与扩展如果以上这些都搞定了UART这个项目其实还可以往里加一些细节让它从“课程设计”升格为“工程实践”。比如FIFO的上溢和下溢标志怎么处理接收模块里的frame error和overrun error标志怎么上报波特率能不能做成寄存器可配置运行中动态切换。再往上走还可以在这个基础上去理解RS485。RS485只是UART的物理层变法同样的帧结构通过差分信号传输并且多了方向控制引脚DE/RE。很多工业项目会用UART逻辑加个RS485收发芯片就实现了远距离、多点通信。理解了UARTRS485就只剩一个方向控制的问题。5. 写在最后关于学习路径的一点点建议我个人在实际调UART项目的过程中最大的感受是写RTL代码只占整个工作量的一小半真正的功夫在验证和调试。仿真里看着波形对上了板子一堆问题这种事太常见了。所以如果你正在入门FPGA别急着追求把代码跑通就完事试着把testbench写完整把异常情况比如起始位中间出现毛刺、停止位不是高电平的仿真也做了你会发现自己对UART的理解会深一个层次。另外一个小建议调试串口时养成记录的习惯。波特率、分频系数、数据格式、实测收发结果这些参数每次都可能不同记一笔能帮你在问题复现时快速定位原因而不是每次从零开始猜。UART这块内容说到底是FPGA里最基础的一环但它延伸出去的东西并不少。把今天的收发模块、FIFO缓冲、跨时钟域处理、异步信号同步这些点真正消化掉后面再去碰USB、以太网、PCIe这类复杂接口底层思路是完全一致的。从这个角度看把UART做好做的不是一个个小模块而是建立自己写时序逻辑和做板级调试的底子。