基于Zynq7020的Uart和PWM的Verilog HDL实现与系统集成

在现代嵌入式设计中,我们常常面临一个矛盾:一方面希望处理器能快速响应外部事件、精确控制硬件;另一方面又不希望CPU被低层次的轮询或定时任务拖垮。特别是在使用如Zynq-7020这类集成了ARM处理器与FPGA逻辑的异构平台时,如何合理划分软硬件职责,成为决定系统性能的关键。

以LED调光和串口通信为例——这两个看似简单的功能,若完全依赖软件实现,轻则占用大量CPU周期,重则因中断延迟导致控制抖动或数据丢失。而如果将它们下沉到可编程逻辑(PL)侧用纯Verilog实现,不仅释放了PS端资源,还能获得纳秒级精度的波形输出和稳定可靠的通信能力。这正是Zynq架构的魅力所在:不是简单地“用FPGA加速”,而是通过 精细的任务解耦 ,让每个部分各司其职。


UART和PWM虽然基础,但它们是通往复杂系统的起点。UART作为调试信息输出、传感器接入甚至协议转换的核心通道,要求高鲁棒性和兼容性;PWM则广泛应用于电机驱动、亮度调节乃至简易音频播放,对时序一致性极为敏感。本文不借助Xilinx IP Catalog中的现成模块,而是从零开始,用标准Verilog HDL构建这两个外设,并探讨其在Zynq-7020上的实际部署方式。

先看UART发送模块的设计思路。它的核心挑战在于 波特率生成的准确性 。假设系统时钟为50MHz,目标波特率为115200bps,则每比特持续时间为约8.68μs,对应分频系数为 50_000_000 / 115200 ≈ 434 。但由于除不尽,实际误差约为0.8%,仍在UART允许的±3%范围内。关键是要避免累积误差影响帧同步。

下面是一个简洁高效的UART发送器实现:

module uart_tx(
    input        clk,
    input        rst_n,
    input        tx_en,
    input [7:0]  tx_data,
    output reg   tx
);

parameter CLK_FREQ = 50_000_000;
parameter BAUD     = 115200;
localparam DIVIDER = CLK_FREQ / BAUD - 1;

reg [15:0] baud_count;
reg [3:0]  bit_count;
reg        tx_busy;
wire       baud_tick;

// 波特率发生器:计数至DIVIDER后归零,产生tick
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        baud_count <= 0;
    else if (baud_count >= DIVIDER)
        baud_count <= 0;
    else
        baud_count <= baud_count + 1;
end

assign baud_tick = (baud_count == DIVIDER);

// 主状态机:起始位 → 数据位 → 停止位
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        tx <= 1'b1;
        bit_count <= 0;
        tx_busy <= 0;
    end else begin
        if (tx_en && !tx_busy) begin
            tx <= 1'b0;         // 发送起始位(低电平)
            bit_count <= 0;
            tx_busy <= 1;
        end else if (tx_busy && baud_tick) begin
            case (bit_count)
                4'd0: tx <= tx_data[0];
                4'd1: tx <= tx_data[1];
                4'd2: tx <= tx_data[2];
                4'd3: tx <= tx_data[3];
                4'd4: tx <= tx_data[4];
                4'd5: tx <= tx_data[5];
                4'd6: tx <= tx_data[6];
                4'd7: tx <= tx_data[7];
                default: begin
                    tx <= 1'b1;  // 发送停止位
                    tx_busy <= 0;
                end
            endcase
            bit_count <= bit_count + 1;
        end
    end
end

endmodule

这段代码虽小,却体现了几个重要的工程考量:

  • 使用独立的 tx_busy 标志防止重复触发;
  • baud_tick 信号确保每次只推进一位,避免因计数器溢出偏差造成错位;
  • 状态转移集中在单一时钟块内完成,保证时序收敛。

当然,这是一个最简版本。在真实项目中,建议加入FIFO缓冲以支持连续发送,否则主控需严格等待 tx_busy 清零才能写入下一字节,效率较低。此外,接收端更复杂,需要检测下降沿启动采样、采用16倍超采样取中间值判决,还要处理奇偶校验和帧错误判断。

相比之下,PWM的实现要直观得多。其本质就是一个 数值比较器 :在一个固定周期内,当前计数值小于设定阈值时输出高电平,否则为低。这样形成的方波,平均电压正比于占空比。

以下是一个典型的8位边缘对齐PWM模块:

module pwm_gen(
    input      clk,
    input      rst_n,
    input [7:0] duty_cycle,
    output reg pwm_out
);

reg [7:0] counter;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        counter <= 0;
        pwm_out <= 0;
    end else begin
        counter <= counter + 1;
        pwm_out <= (counter < duty_cycle) ? 1'b1 : 1'b0;
    end
end

endmodule

这个设计利用了自然回滚特性——当 counter 达到255后再加1会自动变为0,形成模256循环。因此PWM频率由公式 $ f_{pwm} = \frac{f_{clk}}{256} $ 决定。例如在50MHz时钟下,输出频率约为195.3kHz,适合用于开关电源或高速LED调光。

但要注意,这种“自由运行+比较”的结构存在一个问题: 占空比更新时机不可控 。如果在计数中途修改 duty_cycle ,可能导致当前周期出现异常脉冲,引发电机震动或灯光闪烁。解决办法是引入双缓冲机制:设置影子寄存器,在计数器归零时统一更新比较值,保证跳变边沿干净。

进一步扩展时,可以共享同一个计数器,驱动多个比较单元,从而实现多路PWM输出。比如三相逆变器所需的互补PWM,只需增加上下桥臂的死区控制逻辑即可。这类高级应用虽不在本文范围,但基础框架已清晰可见。


那么,这些模块如何与Zynq的ARM处理器协同工作?典型架构如下:

+------------------+       AXI Lite        +---------------+
| ARM Cortex-A9    |<--------------------->| Register Map  |
| (PS - 处理器系统) |                       | (Memory Mapped)|
+------------------+                       +---------------+
                             ||                     ||
                         Interrupt             GPIO Out
                             \/                     \/
                   +------------------+    +------------------+
                   | UART_RX/TX Logic |    | PWM Output Pins  |
                   |   (Verilog HDL)  |    | (to LED/Motor)   |
                   +------------------+    +------------------+

具体流程是:

  1. 在Vivado中创建Block Design,启用ZYNQ Processing System IP;
  2. 配置MIO引脚为UART0,用于Linux调试输出;
  3. 将自定义的UART和PWM模块封装为AXI Lite从设备,挂接到PS的HP或GP接口;
  4. 分配寄存器地址空间,例如:
    - 0x43C0_0000 : PWM占空比寄存器
    - 0x43C0_0004 : UART发送数据/状态寄存器
  5. 编译生成比特流并烧录FPGA;
  6. 在SDK或PetaLinux中编写驱动程序,通过 ioremap() 映射物理地址,读写寄存器控制外设。

举个例子,想让LED缓慢呼吸,可以在裸机环境中这样操作:

volatile uint32_t *pwm_reg = (uint32_t *)0x43C00000;
for (int i = 0; i <= 255; i++) {
    *pwm_reg = i;
    usleep(1000);  // 每步延时1ms
}

此时,CPU几乎不参与波形生成过程,仅负责配置参数。真正的PWM信号由FPGA硬逻辑持续输出,不受操作系统调度、中断延迟等影响。即使系统负载飙升,LED亮度依然平稳。

同样,当传感器通过自定义UART上传数据时,也可以选择是否启用中断。比如在接收到完整一帧后拉高一个IRQ信号,通知CPU进行处理。这种方式相比轮询大幅降低功耗,尤其适合电池供电设备。


这种设计解决了许多传统嵌入式开发中的痛点:

  • CPU负载过高 ?把周期性任务交给PL,CPU专注业务逻辑;
  • 实时性不足 ?硬件PWM边沿抖动远小于定时器中断;
  • 原生UART不够用 ?在PL里再“造”几个,成本几乎为零;
  • 特殊协议支持难 ?自己写状态机,灵活适配Modbus、DMX512等非标格式。

当然,也有一些细节需要注意:

  • 所有时钟应走全局网络(Global Clock Buffer),避免局部布线带来的偏斜;
  • 复位信号务必同步化,防止亚稳态传播引发未知行为;
  • 对EMI敏感的应用,可改用中心对齐PWM模式,减少谐波噪声;
  • PCB布局上,PWM输出线远离模拟信号和晶振,必要时加磁珠滤波;
  • 调试阶段强烈建议插入ILA(Integrated Logic Analyzer)核,实时抓取内部信号,比示波器更高效。

最终你会发现,掌握这种“自己造轮子”的能力,远不止于实现两个外设那么简单。它让你真正理解了数字系统的工作原理:从时钟域划分、状态机设计,到总线协议交互、软硬件协同。而这正是高级FPGA工程师与普通使用者之间的分水岭。

未来还可以在此基础上做更多延伸:比如给UART加上DMA引擎,实现千兆级数据吞吐;或者构建带反馈闭环的PWM控制系统,结合ADC采样实现恒流驱动;甚至搭建AXI Stream流水线,用于图像预处理或音频编解码。

Zynq的价值,不在于它有多少现成IP,而在于它提供了一个 可深度定制的计算架构 。当你不再满足于调用API,而是开始思考“能不能让它做得更好”,你就已经踏上了通往系统级设计的大门。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐