首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >低速协议系列:SPI.3(串并数据的 FPGA 演示)

低速协议系列:SPI.3(串并数据的 FPGA 演示)

作者头像
云深无际
发布2026-03-03 13:09:53
发布2026-03-03 13:09:53
1210
举报
文章被收录于专栏:云深之无迹云深之无迹

全部使用高云的 FPGA 原语来写,我也没有玩过别的;“用 Gowin 串并/并串 IO 原语把外部 QSPI(4bit) 数据采样后,重新编码成 FPGA 内部 1bit 串行数据流”的 demo 框架;重点是展示“编解码的感觉”:IO 原语负责高速采样/串并,逻辑负责打包,再用并串原语吐出内部串流

(也不是写 FPGA 代码什么的,就是找哪种说不出来的直觉,就像拼积木一样,把每个字节都把玩在手边)

要用到的两类 IO 原语

串转并IDES4(每条数据线 1:4 解串),每根 IO 线把“串行比特”按 FCLK 的 DDR 采样节奏抓下来,然后在 PCLK 这个较低速时钟边沿,一次性给 4bit 并行 (Q3..Q0),并转串OSER16(16:1 串化输出)

这个 demo 的“时钟设计”怎么配?

为了让 IDES4 和 OSER16都按手册的时钟比率工作;外部 QSPI 时钟qspi_sck(板子上来的 SCK)

给 IDES4:

FCLK_IN = qspi_sck

PCLK_IN = qspi_sck/2(用 PLL / CLKDIV 产生)

给 OSER16:我们希望 OSER16 的 PCLK 直接复用 PCLK_IN(方便跨域),那 OSER16 的 FCLK_OUT 必须满足 PCLK_IN = FCLK_OUT/8也就是:FCLK_OUT = 8 * PCLK_IN = 4 * qspi_sck

所以需要一个 PLL 产生:

clk_p = qspi_sck/2

clk_f4 = 4*qspi_sck

这只是 demo 的一种“让原语都按手册关系跑起来”的方案,也可以换成 OSER4/OSER8 等组合,但 OSER16 最直观:一次塞 16bit,吐成 1bit 串流。

数据路径怎么“编解码”

我们把外部 4 根 QSPI 数据线(IO0~IO3)分别解串:每根线 IDES4 输出 4bit-lane[i][3:0],一拍 clk_p(= qspi_sck/2)下来,我们就拿到 4 lanes × 4 bits = 16 bits,把这 16 bits 按照想要的顺序打包成 pack16,丢给 OSER16 串化,输出 serial_stream

Verilog demo

这里把 IO buffer 简化成 IBUF(也可以插 IODELAY)。 CALIB 先固定为 0(不做相位/bit 顺序滚动),IDES4 的 CALIB 用于“调整输出数据顺序”。 qspi_cs_n 只作为“帧有效”(不是完整 QSPI 协议)。 OSER16 的 FCLK/PCLK 比率按手册。

代码语言:javascript
复制
// ------------------------------------------------------------
// Gowin IO-primitive demo:
//   External "QSPI-like" 4-lane data  -> IDES4 (per lane) -> pack16
//   pack16 -> OSER16 -> internal 1-bit serial stream
//
// Clock plan (recommended):
//   qspi_sck    : external clock in
//   clk_p       : qspi_sck/2        (for IDES4 PCLK, also OSER16 PCLK)
//   clk_f4      : 4*qspi_sck        (for OSER16 FCLK, so that PCLK=FCLK/8)
// ------------------------------------------------------------
module qspi_to_serial_demo (
    input  wire        rst_n,

    // external QSPI signals (only use data + sck + cs for demo)
    input  wire        qspi_sck,
    input  wire        qspi_cs_n,
    input  wire [3:0]  qspi_io,      // IO0..IO3

    // generated clocks (from PLL/clocking)
    input  wire        clk_p,        // = qspi_sck/2
    input  wire        clk_f4,       // = 4*qspi_sck

    output wire        serial_stream, // 1-bit internal stream out
    output reg         stream_valid   // validity marker (clk_p domain)
);

    wire reset_hi = ~rst_n;

    // --------------------------------------------------------
    // 1) Input buffers
    // --------------------------------------------------------
    wire [3:0] io_in;
    genvar gi;
    generate
        for (gi = 0; gi < 4; gi = gi + 1) begin : G_IBUF
            IBUF u_ibuf (
                .I(qspi_io[gi]),
                .O(io_in[gi])
            );
        end
    endgenerate

    // --------------------------------------------------------
    // 2) IDES4 per lane: 1 -> 4 deserialization
    //
    // UG289: PCLK usually = FCLK/2, ports D/FCLK/PCLK/CALIB/RESET -> Q3..Q0
    // --------------------------------------------------------
    wire [3:0] lane_q [0:3]; // lane_q[lane][3:0] = {Q3,Q2,Q1,Q0}

    generate
        for (gi = 0; gi < 4; gi = gi + 1) begin : G_IDES4
            wire q0, q1, q2, q3;
            IDES4 u_ides4 (
                .D     (io_in[gi]),
                .FCLK  (qspi_sck),
                .PCLK  (clk_p),
                .CALIB (1'b0),        // demo: no rotation
                .RESET (reset_hi),
                .Q0    (q0),
                .Q1    (q1),
                .Q2    (q2),
                .Q3    (q3)
            );
            defparam u_ides4.GSREN = "false";
            defparam u_ides4.LSREN = "true";

            assign lane_q[gi] = {q3, q2, q1, q0};
        end
    endgenerate

    // --------------------------------------------------------
    // 3) Pack 4 lanes * 4 bits -> 16 bits
    //
    // You can choose any mapping. Here is one intuitive mapping:
    //   pack16[ 3: 0] = IO0's 4 bits (Q3..Q0)
    //   pack16[ 7: 4] = IO1's 4 bits
    //   pack16[11: 8] = IO2's 4 bits
    //   pack16[15:12] = IO3's 4 bits
    //
    // If you want "time-major" ordering (interleave lanes per sample time),
    // change the concatenation order accordingly.
    // --------------------------------------------------------
    reg [15:0] pack16_reg;

    always @(posedge clk_p or posedge reset_hi) begin
        if (reset_hi) begin
            pack16_reg   <= 16'h0000;
            stream_valid <= 1'b0;
        end else begin
            // simple framing: only treat CS active-low as valid
            stream_valid <= ~qspi_cs_n;

            if (~qspi_cs_n) begin
                pack16_reg <= {
                    lane_q[3], // IO3
                    lane_q[2], // IO2
                    lane_q[1], // IO1
                    lane_q[0]  // IO0
                };
            end
        end
    end

    // --------------------------------------------------------
    // 4) OSER16: 16 -> 1 serialization
    //
    // UG289: OSER16 does 16:1, PCLK usually = FCLK/8
    // Here:
    //   PCLK = clk_p
    //   FCLK = clk_f4 (= 8*clk_p)
    // --------------------------------------------------------
    wire oser_q;

    OSER16 u_oser16 (
        .D0   (pack16_reg[0]),
        .D1   (pack16_reg[1]),
        .D2   (pack16_reg[2]),
        .D3   (pack16_reg[3]),
        .D4   (pack16_reg[4]),
        .D5   (pack16_reg[5]),
        .D6   (pack16_reg[6]),
        .D7   (pack16_reg[7]),
        .D8   (pack16_reg[8]),
        .D9   (pack16_reg[9]),
        .D10  (pack16_reg[10]),
        .D11  (pack16_reg[11]),
        .D12  (pack16_reg[12]),
        .D13  (pack16_reg[13]),
        .D14  (pack16_reg[14]),
        .D15  (pack16_reg[15]),
        .FCLK (clk_f4),
        .PCLK (clk_p),
        .RESET(reset_hi),
        .Q    (oser_q)
    );
    defparam u_oser16.GSREN = "false";
    defparam u_oser16.LSREN = "true";

    assign serial_stream = oser_q;

endmodule

这条链路本质是: I/O 物理采样 → 串并转换 → 编码(重排/打包/帧化) → 并串转换 → 内部串流

为什么选择 “IDES4 ×4 lanes” + “OSER16” 这对组合?

IDES4 是 1→4 的串转并原语,每条输入线输出 4bit 并行 Q3..Q0,并且它有 FCLK(高速位时钟)+ PCLK(低速字时钟)的结构,PCLK 通常是 FCLK 分频得到(文档对 PCLK=FCLK/2 给了明确关系)。

这正好符合“要展示的感觉”:外面每来一个高速 bit(更准确说:每个 FCLK 的有效边沿),内部就累积到移位寄存器;到了 PCLK 边沿,一次性吐出一坨并行 bit(4bit)这就是“解串”的直觉。

为什么 OSER16 适合演示“串化”

OSER16 是 16→1 的并转串原语,端口就是 D15..D0 + FCLK/PCLK,输出是 Q。并且文档里也给了它和时钟的典型关系(PCLK 由 FCLK 分频得到,OSER16 对应 /8 这种关系);在 PCLK 域里每拍准备 16bit,OSER16 在高速 FCLK 域里把它 1bit/节拍吐出去——非常直观。

外部 QSPI 有 4 根数据线 IO0..IO3。每根线经过 IDES4 解串得到 4bit,所以每个 PCLK 周期得到:

这 16bit 正好喂给 OSER16,一拍进去、下一段时间吐完。这就是 demo 的“结构美”。

时钟规划:为什么要两套时钟,为什么需要 PLL

原语都有两个时钟:

FCLK:高速串行位时钟(bit clock)

PCLK:低速并行字时钟(word clock)

IDES4 的时钟关系

IDES4 典型要求:

所以我们让FCLK_IDES = qspi_sckPCLK_IDES = clk_p = qspi_sck/2

OSER16 的时钟关系

OSER16 典型要求:

我们希望 OSER16 的 PCLK 直接复用 clk_p,因为 pack16_reg 就是在 clk_p 域产生的(这样不用跨时钟 FIFO)。

那就需要:

又因为 clk_p = qspi_sck/2,所以:

所以我们在 demo 里引入了 clk_f4 = 4*qspi_sck

另外不可能从一个外部 qspi_sck 直接“自然”得到:

/2 的 clk_p

×4 的 clk_f4

所以要用 PLL/DLL/时钟管理模块来做倍频分频(这在 FPGA 里是常规操作)。

中间“编码层”:pack16_reg 的意义

pack16_reg 不是随便拼,它代表“编码规则”

demo 里我用了最直觉的打包方式:

代码语言:javascript
复制
reg [15:0] pack16_reg;

    always @(posedge clk_p or posedge reset_hi) begin
        if (reset_hi) begin
            pack16_reg   <= 16'h0000;
            stream_valid <= 1'b0;
        end else begin
            // simple framing: only treat CS active-low as valid
            stream_valid <= ~qspi_cs_n;

            if (~qspi_cs_n) begin
                pack16_reg <= {
                    lane_q[3], // IO3
                    lane_q[2], // IO2
                    lane_q[1], // IO1
                    lane_q[0]  // IO0
                };
            end
        end
    end

pack16[3:0] = IO0 这拍吐出的 4bit

pack16[7:4] = IO1 的 4bit

pack16[11:8] = IO2 的 4bit

pack16[15:12]= IO3 的 4bit

而真实 QSPI 的数据语义更像每个时钟周期四条线同时贡献一个 nibble(4bit),时间方向推进。

那更像这种拼法:

第 0 个 nibble:{IO3_bit0, IO2_bit0, IO1_bit0, IO0_bit0}

第 1 个 nibble:{IO3_bit1, IO2_bit1, IO1_bit1, IO0_bit1}

第 2 个 nibble:...

第 3 个 nibble:...

打包成 16bit 就是 nibble0+nibble1+nibble2+nibble3,只要改代码里面 pack16_reg <= {...} 的拼接顺序,就能体现不同“编码风格”。

输出侧:OSER16 把 pack16_reg 串化成 1bit 流

代码语言:javascript
复制
// 4) OSER16: 16 -> 1 serialization
    //
    // UG289: OSER16 does 16:1, PCLK usually = FCLK/8
    // Here:
    //   PCLK = clk_p
    //   FCLK = clk_f4 (= 8*clk_p)
    // --------------------------------------------------------
    wire oser_q;

    OSER16 u_oser16 (
        .D0   (pack16_reg[0]),
        .D1   (pack16_reg[1]),
        .D2   (pack16_reg[2]),
        .D3   (pack16_reg[3]),
        .D4   (pack16_reg[4]),
        .D5   (pack16_reg[5]),
        .D6   (pack16_reg[6]),
        .D7   (pack16_reg[7]),
        .D8   (pack16_reg[8]),
        .D9   (pack16_reg[9]),
        .D10  (pack16_reg[10]),
        .D11  (pack16_reg[11]),
        .D12  (pack16_reg[12]),
        .D13  (pack16_reg[13]),
        .D14  (pack16_reg[14]),
        .D15  (pack16_reg[15]),
        .FCLK (clk_f4),
        .PCLK (clk_p),
        .RESET(reset_hi),
        .Q    (oser_q)
    );
    defparam u_oser16.GSREN = "false";
    defparam u_oser16.LSREN = "true";
    assign serial_stream = oser_q;

OSER16 的核心动作

clk_p 边沿,它把 D15..D0 装入内部并串转换寄存器,随后在 clk_f4 的节拍下,依次把这 16bit 从 Q 吐出来;这里OSER16 会更快地吐出数据,因为 clk_f4clk_p 的 8 倍(按我们时钟关系设计),所以:clk_p 来一拍装载 16bit,接下来 16bit 会在高速时钟里被吐完,所以在下一次 clk_p 装载前,吐数据的节拍必须匹配,否则会出现“吐不完/重复吐”的问题。

这个 demo 有什么可以学习的地方?

我知道这东西写的不好,但是我还是觉得有一些可以学习的地方。第一点就是高速采样和低速逻辑分离,在FCLK 域负责 IO 采样/吐比特;PCLK 域负责打包、协议、FIFO;另外并行化其实是为了降低内部处理速率,4 lanes × 解串,内部变成 16bit/拍,逻辑轻松很多。

后记

我的 FPGA 功夫不深,但是突然发现,如果单纯的说明 SPI 的数据流转,在这里使用 FPGA 的物理层实现分外的直观。某种程度,我觉得 FPGA 写出来比 MCU 爽。。。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-02-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云深之无迹 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 要用到的两类 IO 原语
  • 这个 demo 的“时钟设计”怎么配?
  • 数据路径怎么“编解码”
  • Verilog demo
  • 为什么选择 “IDES4 ×4 lanes” + “OSER16” 这对组合?
    • 为什么 OSER16 适合演示“串化”
  • 时钟规划:为什么要两套时钟,为什么需要 PLL
    • IDES4 的时钟关系
    • OSER16 的时钟关系
  • 中间“编码层”:pack16_reg 的意义
    • pack16_reg 不是随便拼,它代表“编码规则”
  • 输出侧:OSER16 把 pack16_reg 串化成 1bit 流
    • OSER16 的核心动作
  • 这个 demo 有什么可以学习的地方?
  • 后记
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档