从一段 SystemVerilog 代码看 FPGA 如何实现串并转换

在数字电路设计中,有一个看似简单却非常基础的问题:当数据通过一根信号线逐位到达时,如何将这些连续到来的二进制位组合成一个完整的数据字?

例如,一个 8 位数据 10110010,在并行电路中可以通过 8 根信号线同时传输;但在串行通信中,它需要按照规定的顺序,一个比特接着一个比特地传输。

在 FPGA 中,实现这种串行到并行的转换(Serial-to-Parallel Conversion),通常只需要一个移位寄存器(Shift Register)、一个时钟信号,以及少量控制逻辑。

更有意思的是,一条非常简短的 SystemVerilog 语句,实际上就可以描述整个移位过程。

这也是理解 FPGA 工作原理、时序逻辑和硬件描述语言的一扇很好的入门窗口。

一、一条 SystemVerilog 语句如何实现串并转换?

先来看一段代码:

logic [7:0] shift;

always_ff @(posedge clk) begin
    shift <= {shift[6:0], sdi};
end

其中:

  • shift 是一个 8 位寄存器,用于保存已经接收的数据。
  • clk 是时钟信号。
  • sdi 是串行输入数据,每次输入一个比特。
  • always_ff @(posedge clk) 表示在时钟上升沿执行寄存器更新。
  • <= 是非阻塞赋值运算符。
  • {shift[6:0], sdi} 是拼接操作。

真正值得关注的是:

shift <= {shift[6:0], sdi};

它的含义是:在每次时钟上升沿到来时,将寄存器原来的低 7 位向高位方向移动一位,并把新接收到的串行数据放到最低位。

例如,假设原来的寄存器内容为:

01011001

新到达的串行比特是 1,那么时钟上升沿之后,寄存器变成:

10110011

可以看到,原来的最高位 0 被移出,其他 7 位向左移动,新比特 1 进入最低位。

只需要重复这一过程,就能把逐位到来的串行数据装入寄存器。

8位移位寄存器的工作原理

二、8 个时钟周期,如何接收一个完整字节?

假设现在需要接收一个 8 位数据:

10110010

约定最高有效位(MSB)首先到达,并且每个时钟上升沿接收一位。

接收开始之前,移位寄存器清零。

整个过程如下:

时钟周期输入比特移位寄存器内容
初始—00000000
1100000001
2000000010
3100000101
4100001011
5000010110
6000101100
7101011001
8010110010

经过 8 次时钟上升沿之后,寄存器中的内容恰好就是原来的 8 位数据。

这说明,FPGA 不需要等到全部 8 位数据都到达后再进行复杂计算,而是在每个时钟周期完成一次简单的移位操作。

当第 8 位数据接收完成时,串行数据就自然变成了一个可以并行读取的字节。

串行数据经过8个时钟周期完成并行转换}

这里还有一个容易被忽略的问题:移位方向和串行数据的发送顺序必须匹配。

上述代码适用于最高有效位先到达的情况。如果最低有效位先到达,那么可以采用另一种移位方式:

shift <= {sdi, shift[7:1]};

它把新输入的数据放入最高位,其余数据向低位方向移动。

因此,决定最终数据是否正确的,不只是接收了多少个比特,还包括发送顺序、移位方向和采样时刻。

三、为什么这里使用非阻塞赋值?

对于有 C、C++ 或 Python 编程经验的人来说,SystemVerilog 中的 <= 容易引起误解。

在这里,它不是“小于等于”,而是非阻塞赋值(Non-blocking Assignment)。

在时序逻辑中,非阻塞赋值的核心特点是:

在同一个时钟事件触发的更新过程中,先根据寄存器的旧值计算右侧表达式,再统一更新寄存器的新值。

例如:

always_ff @(posedge clk) begin
    a <= input_data;
    b <= a;
    c <= b;
end

这段代码描述的是三个寄存器构成的流水线。

每个时钟上升沿:

  • a 接收当前的 input_data。
  • b 接收更新前的 a。
  • c 接收更新前的 b。

这并不是让数据在一个时钟周期内依次穿过三个寄存器,而是让数据每经过一个时钟周期,向后传递一级。

从硬件角度看,三个寄存器在同一个时钟边沿进行状态更新。

对于串并转换中的代码:

shift <= {shift[6:0], sdi};

也是同样的道理。

右侧的 shift[6:0] 使用更新前的寄存器内容,然后生成下一次需要保存的 8 位数据。

非阻塞赋值与寄存器同步更新

这也揭示了 SystemVerilog 与普通软件编程语言的重要区别。

SystemVerilog 描述的主要是硬件电路的结构和随时间变化的行为,而不是简单地指挥处理器逐条执行程序。

四、从移位寄存器到完整的串并转换模块

前面的代码虽然实现了移位,却还有一个问题:

FPGA 如何知道已经接收完 8 个比特?

如果没有额外控制,移位寄存器会在每次时钟上升沿持续移位。即使已经收到完整字节,后续数据仍然会继续覆盖原有内容。

因此,实际设计中通常还需要一个比特计数器,以及一个表示数据有效的信号。

下面是一个简化的 8 位串并转换模块:

module serial_to_parallel (
    input  logic       clk,
    input  logic       rst_n,
    input  logic       sdi,
    input  logic       sdi_valid,

    output logic [7:0] data_out,
    output logic       data_valid
);

    logic [7:0] shift;
    logic [2:0] bit_count;

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            shift      <= 8'b0;
            bit_count  <= 3'd0;
            data_out   <= 8'b0;
            data_valid <= 1'b0;
        end
        else begin
            data_valid <= 1'b0;

            if (sdi_valid) begin
                shift <= {shift[6:0], sdi};

                if (bit_count == 3'd7) begin
                    data_out <= {shift[6:0], sdi};
                    data_valid <= 1'b1;
                    bit_count <= 3'd0;
                end
                else begin
                    bit_count <= bit_count + 3'd1;
                end
            end
        end
    end

endmodule

这个模块比最初的移位代码多了几个功能。

首先,sdi_valid 控制当前输入比特是否有效。只有它为高电平时,模块才接收数据并增加计数。这允许输入数据之间出现空闲时钟周期。

其次,bit_count 记录已经接收的有效比特数量。由于一共只有 8 个位置,因此 3 位计数器就足够了。

第三,当 bit_count 等于 7 时,意味着当前输入的是第 8 个比特。这时将完整数据写入 data_out,同时将 data_valid 置为高电平。

这里有一个细节:

data_out <= {shift[6:0], sdi};

为什么不直接写成:

data_out <= shift;

原因就在于前面讨论的非阻塞赋值。

在第 8 个比特到达的时钟上升沿,shift 的更新还没有生效。如果直接使用 shift,那么 data_out 得到的是尚未包含第 8 个输入比特的旧数据。

因此,需要使用拼接表达式,将当前刚刚到达的比特包含进来。

这正是一个典型的时序逻辑问题:理解代码的关键,不仅是知道每条语句做什么,还要知道它在什么时刻生效。

上述示例假定 sdi 和 sdi_valid 已满足接收时钟域的时序要求,8 个有效比特连续组成一个字节;如果发生中途丢位、重新同步或帧错位,还需要额外的协议控制逻辑。

五、FPGA 最终会把这些代码变成什么电路?

软件程序通常由处理器逐条执行,而 FPGA 的开发过程不同。

SystemVerilog 代码经过综合工具处理后,会被转换成由寄存器、逻辑门和其他硬件资源构成的电路网络,也就是网表。

对于前面的串并转换模块,综合工具通常会识别出几类基本结构:

SystemVerilog 描述对应的硬件功能
logic [7:0] shift8 位移位寄存器
logic [2:0] bit_count3 位计数器
if (sdi_valid)寄存器更新使能逻辑
bit_count == 3'd7计数比较逻辑
data_out8 位输出寄存器
data_valid输出有效标志寄存器

需要说明的是,最终的实现未必与源代码中的寄存器和逻辑门一一对应。

综合工具会进行逻辑优化,随后通过布局布线,将逻辑映射到具体 FPGA 芯片的可配置逻辑资源中。

对这个示例来说,最终硬件主要是一组触发器以及少量组合逻辑。整个串并转换过程不需要处理器逐位搬运数据。

SystemVerilog代码经过综合映射为FPGA硬件电路

六、如何通过仿真验证串并转换是否正确?

编写 RTL 代码之后,并不意味着设计已经正确。

在 FPGA 开发中,通常需要先通过仿真验证功能,再考虑综合、时序约束以及实际硬件测试。

例如,可以使用 Verilator、Questa 等工具进行 SystemVerilog 仿真,并通过 GTKWave 等波形查看工具分析输出。

对于本文的模块,至少需要验证以下几种情况:

  1. 完整接收: 输入 10110010,确认第 8 个有效比特接收后,data_out 为 10110010。
  2. 间断接收: 在两个输入比特之间插入空闲时钟周期,确认计数器不会错误增加。
  3. 连续接收: 连续输入两个字节,确认输出顺序正确,每个字节完成时各产生一次有效信号。
  4. 复位处理: 在接收过程中触发复位,确认计数器、移位寄存器和输出状态恢复初始值。

仿真时尤其需要观察 data_valid 与 data_out 的关系。

对于上述设计,接收第 8 个有效比特的时钟上升沿之后,data_out 更新为完整字节,data_valid 同时变为高电平。

这两个输出会在下一个时钟上升沿之前保持稳定。下游同步逻辑通常在下一次时钟上升沿采样到这个有效标志和对应的数据。

需要注意,仿真验证的是 RTL 的预期行为。实际 FPGA 中还要考虑建立时间、保持时间、时钟偏差以及异步输入等问题。如果串行输入来自不同的时钟域,就不能直接假设它能被本地时钟稳定采样;应根据实际接口采用适当的同步或采样设计。

七、串并转换背后,其实是 FPGA 的基本工作方式

从最初的一行代码:

shift <= {shift[6:0], sdi};

到完整的串并转换模块,我们实际上接触到了 FPGA 开发中的几个核心概念。

第一,FPGA 中的许多数据处理操作,可以通过寄存器之间的数据移动和组合逻辑完成,不一定需要复杂的运算单元。

第二,时钟决定了寄存器何时更新。只有理解时钟边沿、旧值与新值之间的关系,才能正确设计时序电路。

第三,硬件描述语言中的代码并不等同于软件执行步骤。开发人员编写的是电路行为描述,而综合工具负责将这些描述转化成硬件结构。

第四,一个真正可用的模块,除了完成核心数据变换,还需要解决数据有效性、边界识别、复位和异常处理等问题。

串并转换可以说是 FPGA 中最简单的功能模块之一,却广泛存在于串行通信、传感器接口、数据采集以及各种数字信号处理系统中。

理解它,不仅能帮助我们掌握移位寄存器,还能建立理解 FPGA 时序逻辑的基本思维方式:

在 FPGA 中,重要的并不只是数据进行了什么运算,更是数据在第几个时钟周期、经过哪一级寄存器、以什么状态进入下一段电路。

而这,正是 FPGA 设计与普通软件编程最根本的区别之一。


参考资料

  1. SystemVerilog Primer — UC Berkeley EECS 151
    https://eecs151.org/fpga/lab1/docs/pg-4-systemverilog/
  2. Verilator User Guide — Overview
    https://verilator.org/guide/latest/overview.html
  3. Verilator User Guide — Example Create-Binary Execution
    https://verilator.org/guide/latest/example_binary.html

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

Are you human? Please solve:Captcha