全部使用高云的 FPGA 原语来写,我也没有玩过别的;“用 Gowin 串并/并串 IO 原语把外部 QSPI(4bit) 数据采样后,重新编码成 FPGA 内部 1bit 串行数据流”的 demo 框架;重点是展示“编解码的感觉”:IO 原语负责高速采样/串并,逻辑负责打包,再用并串原语吐出内部串流。
(也不是写 FPGA 代码什么的,就是找哪种说不出来的直觉,就像拼积木一样,把每个字节都把玩在手边)
串转并IDES4(每条数据线 1:4 解串),每根 IO 线把“串行比特”按 FCLK 的 DDR 采样节奏抓下来,然后在 PCLK 这个较低速时钟边沿,一次性给 4bit 并行 (Q3..Q0),并转串OSER16(16:1 串化输出)
为了让 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
这里把 IO buffer 简化成
IBUF(也可以插IODELAY)。CALIB先固定为 0(不做相位/bit 顺序滚动),IDES4 的 CALIB 用于“调整输出数据顺序”。qspi_cs_n只作为“帧有效”(不是完整 QSPI 协议)。 OSER16 的FCLK/PCLK比率按手册。
// ------------------------------------------------------------
// 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 是 1→4 的串转并原语,每条输入线输出 4bit 并行 Q3..Q0,并且它有 FCLK(高速位时钟)+ PCLK(低速字时钟)的结构,PCLK 通常是 FCLK 分频得到(文档对 PCLK=FCLK/2 给了明确关系)。
这正好符合“要展示的感觉”:外面每来一个高速 bit(更准确说:每个 FCLK 的有效边沿),内部就累积到移位寄存器;到了 PCLK 边沿,一次性吐出一坨并行 bit(4bit)这就是“解串”的直觉。
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 的“结构美”。
原语都有两个时钟:
FCLK:高速串行位时钟(bit clock)
PCLK:低速并行字时钟(word clock)
IDES4 典型要求:
所以我们让FCLK_IDES = qspi_sck,PCLK_IDES = clk_p = qspi_sck/2。
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 里是常规操作)。
demo 里我用了最直觉的打包方式:
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 <= {...} 的拼接顺序,就能体现不同“编码风格”。
// 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;
在 clk_p 边沿,它把 D15..D0 装入内部并串转换寄存器,随后在 clk_f4 的节拍下,依次把这 16bit 从 Q 吐出来;这里OSER16 会更快地吐出数据,因为 clk_f4 是 clk_p 的 8 倍(按我们时钟关系设计),所以:clk_p 来一拍装载 16bit,接下来 16bit 会在高速时钟里被吐完,所以在下一次 clk_p 装载前,吐数据的节拍必须匹配,否则会出现“吐不完/重复吐”的问题。
我知道这东西写的不好,但是我还是觉得有一些可以学习的地方。第一点就是高速采样和低速逻辑分离,在FCLK 域负责 IO 采样/吐比特;PCLK 域负责打包、协议、FIFO;另外并行化其实是为了降低内部处理速率,4 lanes × 解串,内部变成 16bit/拍,逻辑轻松很多。
我的 FPGA 功夫不深,但是突然发现,如果单纯的说明 SPI 的数据流转,在这里使用 FPGA 的物理层实现分外的直观。某种程度,我觉得 FPGA 写出来比 MCU 爽。。。