基于CPLD的UART核设计:从Verilog实现到硬件实测全解析

基于CPLD的UART核设计:从Verilog实现到硬件实测全解析 1. 项目概述与设计动机最近在做一个需要与上位机通信的小项目核心需求是把板载传感器采集的数据实时上传到电脑。市面上MCU基本都自带UART但这次手头正好有一片闲置的Altera MAX II系列的CPLDEPM240资源不多但够用就想着干脆用硬件描述语言HDL自己撸一个最精简的UART核出来。一来可以彻底吃透UART协议从物理层到数据链路层的每个细节二来也能为后续更复杂的串行协议如I2C、SPI自定义帧设计打个基础。这个自研的UART设计目标很明确实现9600bps固定波特率、8位数据位、无校验位、1位停止位的基本功能在CPLD内部完成全双工异步串行通信的收发逻辑。选择CPLD而非FPGA来做这件事主要是考虑到UART逻辑相对固定状态机规模小CPLD的确定性延时和上电即运行特性更符合“胶合逻辑”的定位。整个设计采用自顶向下的方法用Verilog HDL编写分为三个核心子模块波特率时钟生成模块baud_gen、接收模块uart_rx和发送模块uart_tx。本文将详细拆解每个模块的设计思路、关键代码、仿真技巧并分享从仿真到硬件实测的全流程踩坑记录。无论你是FPGA/CPLD的初学者还是想深入了解通信协议底层实现的工程师这篇基于实战的总结都能提供可直接复用的代码和避坑指南。2. 核心模块设计与原理解析UART通信的本质是在没有时钟线的情况下双方依靠预先约定好的波特率对数据线进行定时采样来实现数据同步。其核心挑战在于如何从看似随机的起始位下降沿中精准地找到每个数据位的中心点进行采样以避开信号边沿的抖动区域确保数据稳定。2.1 波特率发生器精准定时的基石波特率发生器是整个UART的“心跳”。它的任务是从系统主时钟如11.0592MHz中分频产生一个频率为16倍波特率的时钟信号clk16x。为什么是16倍这是UART标准里的经典设计。接收端利用这个高频时钟来监视数据线当检测到起始位后会在第7、8、9个clk16x周期附近连续采样如果多数为低电平则确认为有效的起始位而非噪声。之后每16个clk16x周期即一个位周期采样一次数据位这个采样点正好位于数据位的中间稳定性最高。计算分频系数的公式是关键分频系数 N 系统时钟频率 / (16 * 期望波特率)。以11.0592MHz晶振和9600bps为例N 11059200 / (16 * 9600) 72。这个计算之所以能得出整数正是因为11.0592MHz这个“魔法数字”能被常用波特率如1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200整除从而避免产生累积误差。注意晶振选型的学问。如果你用的系统时钟是12MHz计算9600bps的分频系数12000000/(16*9600) 78.125这不是整数。强行取整78或79会导致实际波特率存在误差当传输大量数据时误差累积可能导致采样错位。因此在通信系统中11.0592MHz、22.1184MHz等晶振是经典选择。如果必须使用其他频率的时钟则需要设计小数分频或使用更灵活的时钟管理单元如FPGA内的PLL。波特率发生器的Verilog实现是一个简单的计数器module baud_gen ( input wire clk, // 11.0592MHz 系统时钟 input wire rst_n, // 低电平复位 output reg clk16x // 16倍波特率时钟 ); parameter CLK_FREQ 11059200; // 系统时钟频率 parameter BAUD_RATE 9600; // 目标波特率 // 计算分频计数器最大值 localparam CNT_MAX CLK_FREQ / (16 * BAUD_RATE) - 1; reg [15:0] cnt; // 计数器宽度根据CNT_MAX调整 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; clk16x 0; end else begin if (cnt CNT_MAX) begin cnt 0; clk16x ~clk16x; // 每计数到最大值时钟翻转一次 end else begin cnt cnt 1; end end end endmodule这个模块产生一个占空比50%的clk16x时钟约153.6kHz。clk16x的每一个上升沿/下降沿都将作为接收和发送状态机推进的基准节拍。2.2 接收模块从比特流中捕捉数据接收模块uart_rx是设计中最精细的部分其核心是一个状态机负责完成起始位检测、数据位采样、停止位校验和输出数据锁存。状态机设计通常包含IDLE空闲、START_DET起始位检测、DATA_SAMPLE数据采样、STOP_BIT停止位检查四个状态。IDLE状态持续监控rxd线。当检测到rxd从高电平变为低电平时进入START_DET状态并启动一个计数器。START_DET状态这是抗噪声的关键。不会在一下降沿就确认起始位而是等待约半个位周期的时间对应clk16x的8个周期再次采样rxd。如果此时rxd仍为低电平则确认是有效的起始位转入DATA_SAMPLE状态如果变回高电平则认为是毛刺回到IDLE状态。DATA_SAMPLE状态确认起始位后计数器每计满16个clk16x周期就采样一次rxd线并将采样值移入接收移位寄存器。重复8次完成一个字节的接收。STOP_BIT状态采样完第8个数据位后再等待16个clk16x周期对停止位应为高电平进行采样。如果采样到高电平则帧格式正确将移位寄存器中的数据输出并产生一个时钟周期宽度的rx_done脉冲信号如果停止位为低则可能发生帧错误。采样点的选择为了获得最稳定的数据采样点应位于数据位时间的中心。在DATA_SAMPLE状态我们会在计数器计到7、8、9或8、9、10取决于设计时进行“多数表决”采样即采样三次取出现次数多的电平作为最终值这能进一步提高抗干扰能力。在本简化设计中为节省资源我们选择在计数器计到8即第8个clk16x周期正好是位中心时采样一次。module uart_rx ( input wire clk16x, // 16倍波特率时钟 input wire rst_n, input wire rxd, // 串行接收数据线 output reg [7:0] rx_data, // 接收到的并行数据 output reg rx_done // 接收完成脉冲 ); localparam ST_IDLE 2d0, ST_START 2d1, ST_DATA 2d2, ST_STOP 2d3; reg [1:0] state, next_state; reg [3:0] bit_cnt; // 位计数器 (0-15) reg [2:0] data_cnt; // 数据位计数器 (0-7) reg [7:0] rx_reg; // 接收移位寄存器 // 状态机第一段时序逻辑 always (posedge clk16x or negedge rst_n) begin if (!rst_n) state ST_IDLE; else state next_state; end // 状态机第二段组合逻辑决定状态转移 always (*) begin next_state state; case (state) ST_IDLE: if (!rxd) next_state ST_START; // 检测到下降沿 ST_START: if (bit_cnt 4d7) next_state ST_DATA; // 起始位确认 ST_DATA: if (data_cnt 3d7 bit_cnt 4d15) next_state ST_STOP; ST_STOP: if (bit_cnt 4d15) next_state ST_IDLE; endcase end // 状态机第三段时序逻辑输出与控制 always (posedge clk16x or negedge rst_n) begin if (!rst_n) begin bit_cnt 0; data_cnt 0; rx_reg 0; rx_data 0; rx_done 0; end else begin rx_done 0; // 默认拉低 case (state) ST_IDLE: begin bit_cnt 0; data_cnt 0; end ST_START: begin bit_cnt bit_cnt 1; end ST_DATA: begin bit_cnt bit_cnt 1; if (bit_cnt 4d7) begin // 在位中心采样 rx_reg {rxd, rx_reg[7:1]}; // 右移LSB先入 data_cnt data_cnt 1; end end ST_STOP: begin bit_cnt bit_cnt 1; if (bit_cnt 4d15) begin rx_data rx_reg; // 锁存数据 rx_done 1; // 产生完成脉冲 end end endcase end end endmodule2.3 发送模块将并行数据转化为串行流发送模块uart_tx的逻辑相对接收端更简单它是一个并串转换器并在数据帧前后添加起始位和停止位。其状态机通常包含IDLE、SEND_START、SEND_DATA、SEND_STOP状态。工作流程如下当发送使能信号tx_en有效时模块将待发送数据tx_data锁存到内部寄存器。进入SEND_START状态将txd线拉低一个位周期持续16个clk16x周期发送起始位。进入SEND_DATA状态从数据的最低位LSB开始依次将每一位放到txd线上每个位持续一个位周期。通常使用一个移位寄存器来实现。数据位发送完毕后进入SEND_STOP状态将txd线拉高至少一个位周期发送停止位。停止位发送完成后产生tx_done脉冲并回到IDLE状态。module uart_tx ( input wire clk16x, input wire rst_n, input wire tx_en, // 发送使能脉冲 input wire [7:0] tx_data, // 待发送并行数据 output reg txd, // 串行发送数据线 output reg tx_busy // 发送忙标志 ); localparam ST_IDLE 2d0, ST_START 2d1, ST_DATA 2d2, ST_STOP 2d3; reg [1:0] state, next_state; reg [3:0] bit_cnt; reg [2:0] data_cnt; reg [7:0] tx_reg; // 发送移位寄存器 always (posedge clk16x or negedge rst_n) begin if (!rst_n) state ST_IDLE; else state next_state; end always (*) begin next_state state; case (state) ST_IDLE: if (tx_en) next_state ST_START; ST_START: if (bit_cnt 4d15) next_state ST_DATA; ST_DATA: if (data_cnt 3d7 bit_cnt 4d15) next_state ST_STOP; ST_STOP: if (bit_cnt 4d15) next_state ST_IDLE; endcase end always (posedge clk16x or negedge rst_n) begin if (!rst_n) begin bit_cnt 0; data_cnt 0; tx_reg 0; txd 1b1; tx_busy 0; end else begin case (state) ST_IDLE: begin txd 1b1; // 空闲时为高电平 tx_busy 0; if (tx_en) begin tx_reg tx_data; // 锁存数据 tx_busy 1; end end ST_START: begin txd 1b0; // 发送起始位 bit_cnt bit_cnt 1; end ST_DATA: begin bit_cnt bit_cnt 1; if (bit_cnt 4d15) begin txd tx_reg[0]; // 发送最低位 tx_reg {1b0, tx_reg[7:1]}; // 右移 data_cnt data_cnt 1; end end ST_STOP: begin txd 1b1; // 发送停止位 bit_cnt bit_cnt 1; if (bit_cnt 4d15) tx_busy 0; // 发送完毕 end endcase end end endmodule3. 系统集成与功能仿真三个子模块完成后需要在一个顶层模块top_uart中将它们实例化并连接起来形成一个完整的、带环回测试功能的UART系统。环回测试Loopback是一种有效的自检方式将发送模块的输出txd直接连接到接收模块的输入rxd这样发送的数据会被自己立即接收回来便于验证收发链路的正确性。3.1 顶层模块设计与环回测试顶层模块主要完成以下工作实例化baud_gen、uart_rx、uart_tx。将uart_tx的txd信号引出到顶层端口同时也内部连接到uart_rx的rxd输入用于环回。添加一个简单的测试逻辑当接收模块完成一次接收rx_done有效时自动将接收到的数据rx_data送入发送模块并触发发送使能tx_en。这样任何从外部发送到本模块的数据都会被自动回传。提供外部rxd输入端口以便在实际使用时断开环回连接外部设备。module top_uart ( input wire sys_clk, // 11.0592MHz系统时钟 input wire rst_n, // 复位按键 input wire ext_rxd, // 外部串行输入连接PC或其他设备 output wire txd, // 串行输出 output wire rx_led, // 接收完成指示灯 output wire tx_led // 发送忙指示灯 ); wire clk16x; wire [7:0] rx_data; wire rx_done; wire tx_en; wire [7:0] tx_data; wire tx_busy; // 内部环回连接测试时使用。实际应用时可选择外部信号。 // wire loopback_rxd txd; // 环回模式 wire loopback_rxd ext_rxd; // 正常模式 // 实例化波特率发生器 baud_gen u_baud_gen ( .clk(sys_clk), .rst_n(rst_n), .clk16x(clk16x) ); // 实例化接收模块 uart_rx u_uart_rx ( .clk16x(clk16x), .rst_n(rst_n), .rxd(loopback_rxd), // 连接环回或外部信号 .rx_data(rx_data), .rx_done(rx_done) ); // 实例化发送模块 uart_tx u_uart_tx ( .clk16x(clk16x), .rst_n(rst_n), .tx_en(tx_en), .tx_data(tx_data), .txd(txd), .tx_busy(tx_busy) ); // 环回测试控制逻辑收到数据后自动发送回去 reg [7:0] data_to_send; reg send_flag; assign tx_en send_flag; assign tx_data data_to_send; always (posedge clk16x or negedge rst_n) begin if (!rst_n) begin send_flag 0; data_to_send 0; end else begin send_flag 0; // 默认拉低 if (rx_done) begin // 一旦接收完成 data_to_send rx_data; // 锁存接收到的数据 send_flag 1; // 产生一个时钟周期的发送使能脉冲 end end end // 指示灯驱动方便观察 assign rx_led rx_done; assign tx_led tx_busy; endmodule3.2 仿真测试平台搭建与波形分析仿真Simulation是数字设计验证的灵魂。我使用ModelSim或免费的EDA Playground在线环境进行仿真。需要编写一个测试平台Testbench文件其核心任务是产生时钟和复位信号。模拟上位机向UART发送数据即产生符合UART协议的rxd输入波形。监视UART的输出txd检查其是否符合预期。测试用例设计至少测试发送0x55二进制01010101和0xAA二进制10101010。这两个数据是“哨兵”数据0x55的位模式是0/1交替能很好地测试每个位的跳变0xAA则是另一种交替模式。还要测试边界情况如0x00和0xFF。在测试平台中需要精确模拟串口数据帧。例如发送0x558‘b01010101LSB先发起始位1位低电平。数据位1 (LSB), 0, 1, 0, 1, 0, 1, 0 (MSB)。停止位1位高电平。每个位的持续时间T_bit 1 / 9600 ≈ 104.17 us。在仿真中我们需要以这个时间间隔去改变rxd信号的值。仿真波形解读要点波特率时钟clk16x观察其频率是否为9600*16153.6kHz周期约为6.51us。接收过程在rxd出现下降沿后接收状态机应从IDLE进入START_DET。在DATA_SAMPLE状态bit_cnt应在0-15循环并在计数值为7或8时rx_reg移位寄存器的值应发生变化采样到正确的rxd值。8个数据位采样完成后进入STOP_BIT最后rx_done应产生一个正脉冲同时rx_data输出正确的0x55。发送过程当tx_en脉冲到来后tx_busy应拉高。观察txd线应依次出现1位低电平起始位、8位数据0x55LSB先出、1位高电平停止位。发送完成后tx_busy拉低。环回测试在顶层仿真中观察从rxd输入0x55经过一段时间延迟后是否能在txd上观察到完全相同的0x55波形。延迟时间等于接收一帧数据的时间加上发送一帧数据的时间大约为(181)*104.17us*2 ≈ 2.08ms。实操心得仿真中的时间管理。在Testbench中建议使用timescale 1ns/1ps指令并定义宏来表示位时间如define BIT_TIME 104166 // 104.166us in ns。这样在模拟发送数据位时可以用#BIT_TIME 来等待代码更清晰。另外一定要在波形图中将rx_data、tx_data等总线信号设置为十六进制Hex显示将state等状态机信号设置为符号名显示这样调试效率会高很多。4. 综合、布局布线与硬件实测仿真通过后下一步就是将设计变成硬件电路。这个过程包括综合Synthesis、布局布线Place Route、生成编程文件Programming File和下载Download。4.1 综合与约束我使用的是Quartus Prime Lite Edition对于Altera/Intel CPLD/FPGA。将三个模块的Verilog文件添加到工程中并指定顶层模块为top_uart。引脚分配Pin Assignment这是硬件连接的关键一步。必须根据你的实验板原理图将设计中的信号映射到CPLD的具体物理引脚上。sys_clk连接到有源晶振的输出引脚如11.0592MHz。rst_n连接到一个按键按下为低电平复位。ext_rxd连接到用于接收外部数据的引脚例如连接到一个电平转换芯片如MAX232的输出或直接连接USB转TTL模块的TX线。txd连接到用于发送数据的引脚例如连接到电平转换芯片的输入或USB转TTL模块的RX线。rx_led,tx_led连接到两个LED灯用于指示接收完成和发送忙状态。在Quartus的Pin Planner工具中完成这些映射。一个常见的坑是忘记分配时钟引脚。对于CPLD普通的I/O口可以输入时钟但最好查阅数据手册将时钟信号分配到专用的时钟输入引脚如GCLK以获得更好的时序性能。时序约束对于这个简单的设计在低速9600bps下即使不添加复杂的时序约束通常也能工作。但养成良好的习惯很重要。可以在Quartus的TimeQuest Timing Analyzer中创建一个基本的时钟约束create_clock -name sys_clk -period 90.7 [get_ports sys_clk]这里周期是11.0592MHz的倒数约90.7ns。这个约束告诉工具系统需要在这个频率下工作。4.2 下载与板级调试生成.pof或.sof文件后通过USB-Blaster或其他下载器将配置数据烧录到CPLD中。硬件连接将CPLD板的txd、rxd引脚通过一个USB转TTL模块如CH340、CP2102模块连接到电脑的USB口。务必确保共地即CPLD板的GND和USB转TTL模块的GND要连接在一起。上位机软件测试在电脑上打开串口调试助手如AccessPort、SSCOM、Putty等。选择正确的COM口设备管理器中查看。设置参数波特率9600数据位8停止位1无校验位。打开串口。在发送区输入字符或十六进制数据如55 AA点击发送。观察接收区。如果设计是环回模式你应该能立即收到自己发送的数据。如果设计是正常模式你需要用杜邦线将CPLD的txd和rxd短接或者编写一个简单的测试逻辑让CPLD收到特定数据后回复另一组数据。示波器/逻辑分析仪抓波形这是最直接的调试手段。用示波器探头同时测量txd和rxd线。当你从电脑发送一个字节如0x55时你应该能在rxd线上看到一个完整的UART帧低-高-低-高...。如果环回功能正常在稍后的txd线上你会看到一个完全相同的波形。通过测量起始位到停止位的总时间可以验证波特率是否准确。逻辑分析仪可以同时解码多个通道的UART信号并直接显示十六进制数值效率更高。4.3 实测中的常见问题与排查收不到任何数据或收到乱码检查电平确认你的CPLD I/O口电平是3.3V还是5VUSB转TTL模块的电平是否匹配。常见的CH340模块通常是3.3V/5V兼容但最好用万用表量一下TX、RX线的空闲电压应为高电平3.3V或5V。检查接线确认CPLD的txd接USB模块的rxdCPLD的rxd接USB模块的txd。这是最容易接反的地方。检查波特率用示波器测量位时间。9600bps下一个位的时间是104.17us。如果测量值偏差很大例如差了一倍很可能是波特率发生器分频系数计算错误或者系统时钟频率设置不对。检查复位确保复位信号在上电后处于无效状态高电平。有些板子按键按下是低电平复位松开后应该是高电平。只能收到第一个字节后续字节丢失或错位状态机未正确返回空闲态这是最常见的原因。仔细检查接收状态机在STOP_BIT状态完成后是否无条件地、干净地回到了IDLE状态并且所有计数器都被清零。在IDLE状态下是否在持续监测rxd线的下降沿。可以在仿真中连续发送两个字节观察状态机的跳转是否完整循环了两次。rx_done脉冲干扰确保rx_done信号只有一个时钟周期宽。如果它过宽可能会被误认为是多个完成信号导致后续逻辑错乱。发送数据不稳定偶尔出错时序违例虽然波特率很低但如果系统时钟到clk16x的路径存在较大延迟或者tx_busy等控制信号组合逻辑复杂可能在高速系统时钟下产生毛刺。在TimeQuest中查看是否有时序违例报告。解决方法可以是对跨时钟域的信号如果存在进行同步处理或者将一些组合逻辑改为时序逻辑打拍输出。电源噪声在CPLD的电源引脚附近增加一个0.1uF的退耦电容可以有效滤除高频噪声。环回测试正常但与PC通信不正常流控制问题有些串口调试助手默认开启了RTS/CTS硬件流控制或XON/XOFF软件流控制。如果你的设计不支持需要在软件端将其禁用。停止位长度确认你的设计产生的停止位是1位。有些设备或软件可能需要1.5位或2位停止位。本设计是标准的1位。踩坑记录关于仿真与现实的差距。在仿真中rxd输入信号是“干净”的下降沿是瞬间完成的。但在实际电路中按键抖动、线路噪声都会导致边沿不干净。这就是为什么在接收状态机中需要“起始位检测”状态等待多个周期进行确认。如果发现抗干扰能力弱可以增加确认的周期数例如从8个clk16x周期增加到10个或者在rxd信号进入CPLD引脚前先经过一个施密特触发器Schmitt Trigger进行整形很多CPLD的输入引脚本身就带有可选的施密特触发器功能可以在Quartus的Assignment Editor中设置。5. 设计优化与扩展思路一个基本的、固定的9600bps UART已经实现。但在实际项目中我们往往需要更灵活、更强大的功能。以下是一些优化和扩展方向5.1 参数化与波特率可配置将波特率、数据位宽、停止位数量、校验位类型等关键参数设计成模块的parameter或localparam。这样只需在实例化模块时修改参数就能快速适配不同的通信需求。例如将波特率发生器的分频系数计算逻辑封装成一个函数根据传入的CLK_FREQ和BAUD_RATE参数动态计算。module baud_gen #( parameter CLK_FREQ 11059200, parameter BAUD_RATE 9600 ) ( // ... 端口声明不变 ); function integer calc_divisor; input integer clk_freq, baud; calc_divisor clk_freq / (16 * baud) - 1; endfunction localparam CNT_MAX calc_divisor(CLK_FREQ, BAUD_RATE); // ... 其余代码不变 endmodule在顶层可以通过拨码开关或按键来动态选择波特率。例如用两个按键来增加或减少一个波特率索引值索引值对应一个预定义的波特率数组如[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200]然后将选中的波特率参数传递给baud_gen模块。注意动态改变分频系数时需要处理计数器重置的问题避免产生毛刺时钟。5.2 添加FIFO缓冲在发送和接收路径上加入FIFOFirst In, First Out缓冲区是提升实用性的关键。没有FIFO时如果上位机连续快速发送数据而CPLD的主处理逻辑来不及读取就会导致数据丢失。发送端亦然需要发送的数据可以先写入FIFO由发送模块自动从FIFO中取出数据发送。FIFO的深度需要根据系统数据吞吐量和处理速度来权衡。例如一个深度为8或16的FIFO就能很好地应对串口数据的突发。你可以用寄存器堆自己实现一个简单的同步FIFO也可以使用Quartus提供的IP核如SCFIFO。5.3 添加硬件流控RTS/CTS对于高速或大数据量传输流控是必需的。最简单的硬件流控是RTSRequest To Send和CTSClear To Send。当接收方FIFO快满时拉低RTS信号通知发送方“暂停发送”当接收方准备好后再拉高RTS。发送方在发送前检查对方的CTS信号是否为有效状态。在Verilog中这需要增加两个信号端口和相应的状态判断逻辑。5.4 系统集成应用示例这个自研的UART核可以作为一个可靠的通信外设集成到更大的CPLD/FPGA系统中。例如数据采集器将ADC模块采集的电压数据通过UART实时发送给PC。命令解析器接收PC发来的控制命令如字符串指令解析后控制CPLD内部的PWM模块调节LED亮度或控制步进电机驱动器。调试监控接口在复杂的FPGA设计中可以将内部的关键状态寄存器、计数器值通过UART打印出来实现一个简单的“printf”调试功能这比用在线逻辑分析仪SignalTap更节省资源且无需占用JTAG口。实现这些应用需要在UART顶层模块之外再编写一个“应用逻辑”模块。该模块负责从uart_rx读取命令数据进行解析例如比较收到的字符串是否为LED_ON然后执行相应操作控制一个GPIO输出高电平。同时它也可以将状态数据组织成字节流在适当的时候触发uart_tx的发送使能。两者之间通过简单的握手信号如data_valid,ready_for_next_data或FIFO进行通信。通过这个从零设计UART的过程我不仅巩固了状态机设计和时序分析的基本功更对异步串行通信的底层细节有了肌肉记忆般的理解。这种理解在调试各种奇怪的通信问题时显得尤为宝贵。下次当你遇到单片机串口通信乱码时你可能会首先想到去检查晶振频率和波特率计算而不是盲目地换代码——这就是底层硬件思维带来的优势。