开源 FPGA 06|ECP5 + ULX3S 实战:HDMI 输出 + SD 卡 + ESP32 协同
_ _ _ _ __ ____
| | | | | | |\ \/ / _ \
| | | | | | | \ /| |_) |
| |_| | |__| | / \| __/
\___/|____/ /_/\_\_|
ECP5 + ULX3S = 开源 FPGA 的旗舰体验
系列第 06 篇 · 目标器件:Lattice ECP5-85F(ULX3S v3.1) 工具版本:Yosys 0.40 / nextpnr-ecp5 0.7 / openFPGALoader 0.12 上一篇:cocotb 仿真:Python 写测试台
0. 这一篇要解决什么问题
iCE40 跑 UART 和点灯很爽,但它太小了——最大 7680 个 LUT。真实项目往往需要更大的器件:HDMI 输出、大块 SRAM、多个高速外设同时跑。
这一篇换板子,上 ULX3S——一块搭载 Lattice ECP5-85F 的开源开发板,85K LUT,有 HDMI、SD 卡、ESP32、USB、SDRAM,而且开源工具链支持最好。
本篇目标:
- 用 openFPGALoader 烧录 ECP5
- 点亮 HDMI:640x480@60Hz DVI 时序,完整 TMDS encoder
- SD 卡 SPI 模式:从初始化到读取单个 sector
- ESP32 通过 SPI 与 FPGA 握手
- 三个外设同时运行,分析资源占用
本篇不覆盖:
- DDR3/SDRAM 控制器(需要单独一篇)
- USB(ECP5 有 USB 2.0 硬核,需要 tinyusb/litex-usb)
- ESP32 固件(只设计 FPGA 侧协议)
1. ULX3S 硬件速览
| 参数 | 规格 |
|---|---|
| FPGA | Lattice ECP5-85F(LFE5U-85F) |
| LUT | 83,640(约 85K) |
| DSP18 | 156 个 |
| BRAM | 11 Mbit(EBR) |
| SDRAM | 32 MB,SDRAM(不是 DDR3) |
| 板载 MCU | ESP32-WROOM-32(WiFi/BT,16 MB Flash) |
| HDMI | 双路 HDMI Type A(TMDS 差分对) |
| SD 卡 | microSD 槽,SPI 模式 |
| USB | FTDI FT231XS(JTAG + UART)+ ECP5 USB 硬核 |
| 电源 | USB-C 5V,板载 3.3V/1.1V 稳压 |
| 开源 | 硬件原理图/PCB 完全开源(CERN-OHL-W) |
ECP5 在开源工具链里的地位:目前开源支持最好的中大规模 FPGA。
- Project Trellis(2018 年完成逆向工程)→ nextpnr-ecp5
- 支持 SERDES(高速串行接口)、PLL、DDR、差分 IO
- 硬件生产案例:LUNA USB 分析仪、Glasgow 接口探针、LiteX 官方参考板
2. 工具链与烧录
安装
# OSS CAD Suite 一键安装(推荐,包含所有工具)
wget https://github.com/YosysHQ/oss-cad-suite-build/releases/latest/download/oss-cad-suite-linux-x64-latest.tgz
tar xf oss-cad-suite-linux-x64-latest.tgz
source oss-cad-suite/environment
# 验证
yosys --version # Yosys 0.40
nextpnr-ecp5 --version # nextpnr-ecp5 -- Next Generation Place and Route (Version nextpnr-0.7)
# openFPGALoader(如果 OSS CAD Suite 没包含,单独装)
sudo apt install openFPGALoader
# 或
pip install openFPGALoader # 某些发行版
约束文件(.lpf)
ECP5 不用 .pcf,用 .lpf(Lattice Preference File):
# ulx3s_v31.lpf(节选)
LOCATE COMP "clk_25mhz" SITE "G2";
IOBUF PORT "clk_25mhz" IO_TYPE=LVCMOS33;
FREQUENCY PORT "clk_25mhz" 25 MHZ;
# HDMI(TMDS 差分对,必须用 LVDS/LVCMOS33D)
LOCATE COMP "hdmi_d[0]" SITE "A12";
LOCATE COMP "hdmi_dn[0]" SITE "A11";
IOBUF PORT "hdmi_d[0]" IO_TYPE=LVCMOS33D DRIVE=4;
IOBUF PORT "hdmi_dn[0]" IO_TYPE=LVCMOS33D DRIVE=4;
LOCATE COMP "hdmi_d[1]" SITE "B12";
LOCATE COMP "hdmi_dn[1]" SITE "C12";
IOBUF PORT "hdmi_d[1]" IO_TYPE=LVCMOS33D DRIVE=4;
IOBUF PORT "hdmi_dn[1]" IO_TYPE=LVCMOS33D DRIVE=4;
LOCATE COMP "hdmi_d[2]" SITE "D12";
LOCATE COMP "hdmi_dn[2]" SITE "E12";
IOBUF PORT "hdmi_d[2]" IO_TYPE=LVCMOS33D DRIVE=4;
IOBUF PORT "hdmi_dn[2]" IO_TYPE=LVCMOS33D DRIVE=4;
LOCATE COMP "hdmi_clk" SITE "C13";
LOCATE COMP "hdmi_clkn" SITE "D13";
IOBUF PORT "hdmi_clk" IO_TYPE=LVCMOS33D DRIVE=4;
IOBUF PORT "hdmi_clkn" IO_TYPE=LVCMOS33D DRIVE=4;
# SD 卡
LOCATE COMP "sd_clk" SITE "H2";
LOCATE COMP "sd_cmd" SITE "J1";
LOCATE COMP "sd_d[0]" SITE "J3";
LOCATE COMP "sd_d[3]" SITE "H1";
IOBUF PORT "sd_clk" IO_TYPE=LVCMOS33;
IOBUF PORT "sd_cmd" IO_TYPE=LVCMOS33;
IOBUF PORT "sd_d[0]" IO_TYPE=LVCMOS33;
IOBUF PORT "sd_d[3]" IO_TYPE=LVCMOS33;
综合、布局布线、烧录
# 综合(ECP5 目标)
yosys -p "
read_verilog -sv src/*.v;
synth_ecp5 -top top -json build/design.json
"
# 布局布线(ECP5-85F,CABGA381 封装)
nextpnr-ecp5 \
--85k \
--package CABGA381 \
--json build/design.json \
--lpf constraints/ulx3s_v31.lpf \
--textcfg build/design.config \
--freq 25 \
--placer heap \
2>&1 | tee build/pnr.log
# 打包 bitstream
ecppack --compress build/design.config build/design.bit
# 烧录(openFPGALoader,通过 FTDI JTAG)
openFPGALoader -b ulx3s build/design.bit
# 或用 fujprog(ULX3S 专用工具)
fujprog build/design.bit
3. HDMI 输出:TMDS Encoder
DVI/HDMI 时序基础
640x480@60Hz 的像素时钟是 25.175 MHz。DVI 使用 TMDS(Transition-Minimized Differential Signaling):每个颜色通道(R/G/B)把 8-bit 数据编码成 10-bit,然后以 10× 像素时钟速率(251.75 MHz)串行输出。
像素时钟: 25.175 MHz
TMDS 时钟: 25.175 × 10 = 251.75 MHz
水平时序 640x480@60Hz:
H Active = 640 像素
H Front Porch = 16
H Sync = 96
H Back Porch = 48
H Total = 800
垂直时序:
V Active = 480 行
V Front Porch = 10
V Sync = 2
V Back Porch = 33
V Total = 525
TMDS Encoder(完整 Verilog)
// tmds_encoder.v
// 将 8-bit 数据 + 控制信号编码成 10-bit TMDS 符号
// 参考: HDMI Spec 1.4, Section 5.4.2
module tmds_encoder (
input wire clk,
input wire [7:0] data_in, // 8-bit 像素数据
input wire [1:0] ctrl_in, // 控制信号(Hsync/Vsync)
input wire de, // data enable(有效像素区域)
output reg [9:0] tmds_out // 10-bit TMDS 编码输出
);
// 计算 data_in 中 1 的个数
function [3:0] cnt_ones;
input [7:0] d;
integer i;
begin
cnt_ones = 0;
for (i = 0; i < 8; i = i + 1)
cnt_ones = cnt_ones + d[i];
end
endfunction
wire [3:0] ones_in = cnt_ones(data_in);
// Stage 1: XNOR or XOR 编码(选择跳变少的方式)
reg [8:0] q_m;
always @(*) begin
// 使用 XOR 还是 XNOR 取决于 1 的个数
if (ones_in > 4 || (ones_in == 4 && data_in[0] == 0)) begin
// XNOR 模式(减少跳变)
q_m[0] = data_in[0];
q_m[1] = ~(q_m[0] ^ data_in[1]);
q_m[2] = ~(q_m[1] ^ data_in[2]);
q_m[3] = ~(q_m[2] ^ data_in[3]);
q_m[4] = ~(q_m[3] ^ data_in[4]);
q_m[5] = ~(q_m[4] ^ data_in[5]);
q_m[6] = ~(q_m[5] ^ data_in[6]);
q_m[7] = ~(q_m[6] ^ data_in[7]);
q_m[8] = 0; // 标记 XNOR 模式
end else begin
// XOR 模式
q_m[0] = data_in[0];
q_m[1] = q_m[0] ^ data_in[1];
q_m[2] = q_m[1] ^ data_in[2];
q_m[3] = q_m[2] ^ data_in[3];
q_m[4] = q_m[3] ^ data_in[4];
q_m[5] = q_m[4] ^ data_in[5];
q_m[6] = q_m[5] ^ data_in[6];
q_m[7] = q_m[6] ^ data_in[7];
q_m[8] = 1; // 标记 XOR 模式
end
end
// Stage 2: DC 平衡(disparity 控制)
reg signed [4:0] cnt; // 累积 disparity(-16 到 +15)
wire [3:0] ones_q_m;
assign ones_q_m = cnt_ones(q_m[7:0]);
wire signed [4:0] diff = $signed({1'b0, ones_q_m}) - $signed(5'd4);
always @(posedge clk) begin
if (!de) begin
// 消隐区间:输出控制符号
case (ctrl_in)
2'b00: tmds_out <= 10'b1101010100;
2'b01: tmds_out <= 10'b0010101011;
2'b10: tmds_out <= 10'b0101010100;
2'b11: tmds_out <= 10'b1010101011;
endcase
cnt <= 0;
end else begin
if (cnt == 0 || ones_q_m == 4) begin
tmds_out[9] <= ~q_m[8];
tmds_out[8] <= q_m[8];
tmds_out[7:0] <= q_m[8] ? q_m[7:0] : ~q_m[7:0];
if (!q_m[8])
cnt <= cnt - diff;
else
cnt <= cnt + diff;
end else begin
if ((cnt > 0 && ones_q_m > 4) ||
(cnt < 0 && ones_q_m < 4)) begin
tmds_out[9] <= 1;
tmds_out[8] <= q_m[8];
tmds_out[7:0] <= ~q_m[7:0];
cnt <= cnt + {3'b0, q_m[8], 1'b0} - diff;
end else begin
tmds_out[9] <= 0;
tmds_out[8] <= q_m[8];
tmds_out[7:0] <= q_m[7:0];
cnt <= cnt - {3'b0, ~q_m[8], 1'b0} + diff;
end
end
end
end
endmodule
ECP5 ODDRX2F TMDS Serializer
ECP5 有专用的 DDR 输出原语 ODDRX2F(4:1 serializer),配合 PLL 生成 5× 时钟:
// tmds_serializer.v
// ECP5 专用:用 ODDRX2F 实现 10:1 串行化(分两步:先 10→5,再 5→1)
module tmds_serializer (
input wire clk_pixel, // 25.175 MHz
input wire clk_5x, // 125.875 MHz(5×)
input wire rst,
input wire [9:0] tmds_d0, // 蓝 (B + Ctrl)
input wire [9:0] tmds_d1, // 绿 (G)
input wire [9:0] tmds_d2, // 红 (R)
output wire hdmi_clk, // TMDS 时钟(差分)
output wire hdmi_clkn,
output wire [2:0] hdmi_d, // TMDS 数据(差分 P)
output wire [2:0] hdmi_dn // TMDS 数据(差分 N)
);
// 使用 ECP5 ODDRX2F 原语进行 4:1 DDR 输出
// 10-bit TMDS → 2× ODDRX2F(每个输出 5 bit,交替时钟沿)
genvar i;
generate
for (i = 0; i < 3; i = i + 1) begin : ser_ch
wire [9:0] tmds_in = (i == 0) ? tmds_d0 :
(i == 1) ? tmds_d1 : tmds_d2;
// 使用 ECP5 ODDRX2F(4:1 DDR output register)
// 配合 ECLKSYNCB 和 DCCA,实现时钟域跨越
ODDRX2F ser_p (
.D0(tmds_in[0]), .D1(tmds_in[2]),
.D2(tmds_in[4]), .D3(tmds_in[6]),
.ECLK(clk_5x), .SCLK(clk_pixel), .RST(rst),
.Q(hdmi_d[i])
);
ODDRX2F ser_n (
.D0(~tmds_in[0]), .D1(~tmds_in[2]),
.D2(~tmds_in[4]), .D3(~tmds_in[6]),
.ECLK(clk_5x), .SCLK(clk_pixel), .RST(rst),
.Q(hdmi_dn[i])
);
end
endgenerate
// 时钟通道(输出固定 0101... 模式)
ODDRX2F clk_p (
.D0(1'b1), .D1(1'b0), .D2(1'b1), .D3(1'b0),
.ECLK(clk_5x), .SCLK(clk_pixel), .RST(rst),
.Q(hdmi_clk)
);
ODDRX2F clk_n (
.D0(1'b0), .D1(1'b1), .D2(1'b0), .D3(1'b1),
.ECLK(clk_5x), .SCLK(clk_pixel), .RST(rst),
.Q(hdmi_clkn)
);
endmodule
时序生成器
// hdmi_timing.v
// 640x480@60Hz DVI 时序生成
module hdmi_timing (
input wire clk_pixel,
output reg [9:0] hpos,
output reg [9:0] vpos,
output reg hsync,
output reg vsync,
output reg de // data enable
);
// 640x480@60Hz 参数
localparam H_ACTIVE = 640;
localparam H_FP = 16;
localparam H_SYNC = 96;
localparam H_BP = 48;
localparam H_TOTAL = H_ACTIVE + H_FP + H_SYNC + H_BP; // 800
localparam V_ACTIVE = 480;
localparam V_FP = 10;
localparam V_SYNC = 2;
localparam V_BP = 33;
localparam V_TOTAL = V_ACTIVE + V_FP + V_SYNC + V_BP; // 525
always @(posedge clk_pixel) begin
if (hpos == H_TOTAL - 1) begin
hpos <= 0;
if (vpos == V_TOTAL - 1)
vpos <= 0;
else
vpos <= vpos + 1;
end else
hpos <= hpos + 1;
hsync <= ~((hpos >= H_ACTIVE + H_FP) &&
(hpos < H_ACTIVE + H_FP + H_SYNC));
vsync <= ~((vpos >= V_ACTIVE + V_FP) &&
(vpos < V_ACTIVE + V_FP + V_SYNC));
de <= (hpos < H_ACTIVE) && (vpos < V_ACTIVE);
end
endmodule
🚧 避坑 #1:ECP5 TMDS 必须用 LVCMOS33D 差分 IO
如果你在 .lpf 里写
IO_TYPE=LVCMOS33(单端),TMDS 差分对的 N 端脚不会被驱动,输出是单端信号,显示器无法识别。必须用IO_TYPE=LVCMOS33D,P/N 两个引脚同时声明。另外DRIVE设为 4 mA(默认 8 mA 有时反而过驱动,导致过冲),640x480 不需要太大驱动电流。
4. SD 卡 SPI 模式:完整状态机
SD 卡 SPI 模式初始化顺序(关键命令):
CMD0 (GO_IDLE_STATE) → 复位到 SPI 模式
CMD8 (SEND_IF_COND) → 检查电压范围(V2.0 卡才有)
ACMD41 (SD_SEND_OP_COND) → 等待卡 ready(需先发 CMD55)
CMD58 (READ_OCR) → 读取 OCR,判断 SDHC/SDXC
CMD17 (READ_SINGLE_BLOCK) → 读取单个 512-byte sector
// sd_controller.v
// SD 卡 SPI 模式控制器:初始化 + 单 sector 读取
module sd_controller (
input wire clk, // 系统时钟(25 MHz)
input wire rst,
// SPI 接口
output reg sclk,
output reg mosi,
input wire miso,
output reg cs_n,
// 用户接口
input wire start_read, // 触发读 sector 0
output reg [7:0] data_out,
output reg data_valid,
output reg ready,
output reg error
);
// SPI 时钟分频(初始化时 <400kHz,正常 <25MHz)
// 25MHz / 64 = 390.6kHz(初始化)
reg [5:0] clk_div;
reg spi_clk_en;
always @(posedge clk) begin
clk_div <= clk_div + 1;
spi_clk_en <= (clk_div == 6'd63);
end
// 状态机
localparam IDLE = 4'd0;
localparam SEND_CMD0 = 4'd1;
localparam WAIT_CMD0 = 4'd2;
localparam SEND_CMD8 = 4'd3;
localparam WAIT_CMD8 = 4'd4;
localparam SEND_CMD55 = 4'd5;
localparam SEND_ACMD41 = 4'd6;
localparam WAIT_ACMD41 = 4'd7;
localparam SEND_CMD17 = 4'd8;
localparam WAIT_TOKEN = 4'd9;
localparam READ_DATA = 4'd10;
localparam DONE = 4'd11;
localparam ERROR_STATE = 4'd12;
reg [3:0] state;
reg [7:0] byte_out; // 当前发送的字节
reg [2:0] bit_cnt; // 0-7,当前发的 bit
reg [8:0] byte_cnt; // 已发送的字节数
reg [7:0] resp; // 收到的响应
reg [15:0] timeout_cnt;
// CMD0: 复位(CRC 必须为 0x95)
reg [47:0] cmd_buf;
localparam CMD0 = 48'h400000000095;
localparam CMD8 = 48'h48000001AA87; // VHS=1(2.7-3.6V),pattern=0xAA
localparam CMD55 = 48'h770000000065;
localparam ACMD41= 48'h694000000077; // HCS=1,支持 SDHC
localparam CMD17 = 48'h5100000000FF; // 读 LBA 0,CRC 不校验(0xFF)
task send_cmd;
input [47:0] cmd;
begin
cmd_buf <= cmd;
bit_cnt <= 7;
byte_cnt <= 0;
end
endtask
always @(posedge clk) begin
if (rst) begin
state <= IDLE;
cs_n <= 1;
ready <= 0;
error <= 0;
data_valid <= 0;
end else if (spi_clk_en) begin
data_valid <= 0;
case (state)
IDLE: begin
cs_n <= 1;
ready <= 1;
// 上电:发送 74 个时钟(CS=1)让卡进入 SPI 模式
if (byte_cnt < 10) begin
sclk <= ~sclk;
mosi <= 1;
byte_cnt <= byte_cnt + 1;
end else if (start_read || byte_cnt == 10) begin
cs_n <= 0;
ready <= 0;
state <= SEND_CMD0;
send_cmd(CMD0);
byte_cnt <= 0;
end
end
SEND_CMD0, SEND_CMD8, SEND_CMD55,
SEND_ACMD41, SEND_CMD17: begin
// 发送 48-bit 命令(MSB first)
sclk <= ~sclk;
if (sclk == 0) begin // 上升沿建立数据
mosi <= cmd_buf[47 - (byte_cnt*8 + (7-bit_cnt))];
if (bit_cnt == 0) begin
bit_cnt <= 7;
if (byte_cnt == 5) begin
// 命令发完,等响应
byte_cnt <= 0;
case (state)
SEND_CMD0: state <= WAIT_CMD0;
SEND_CMD8: state <= WAIT_CMD8;
SEND_ACMD41: state <= WAIT_ACMD41;
SEND_CMD17: state <= WAIT_TOKEN;
SEND_CMD55: begin
send_cmd(ACMD41);
state <= SEND_ACMD41;
end
endcase
end else
byte_cnt <= byte_cnt + 1;
end else
bit_cnt <= bit_cnt - 1;
end
end
WAIT_CMD0: begin
// 等待 R1 响应(0x01 = idle,bit7=0 表示有效)
sclk <= ~sclk;
if (sclk == 1) begin // 下降沿采样
resp <= {resp[6:0], miso};
if (bit_cnt == 0) begin
bit_cnt <= 7;
if (resp[7] == 0) begin // 有效响应
if (resp == 8'h01) begin
send_cmd(CMD8);
state <= SEND_CMD8;
end else begin
error <= 1;
state <= ERROR_STATE;
end
end
end else
bit_cnt <= bit_cnt - 1;
timeout_cnt <= timeout_cnt + 1;
if (timeout_cnt == 16'hFFFF) begin
error <= 1;
state <= ERROR_STATE;
end
end
end
WAIT_CMD8: begin
// CMD8 返回 R7(5 字节),忽略详细内容,只要不出错就继续
sclk <= ~sclk;
if (sclk == 1) begin
resp <= {resp[6:0], miso};
byte_cnt <= byte_cnt + 1;
if (byte_cnt == 39) begin // 5 字节 × 8 = 40 bits
send_cmd(CMD55);
state <= SEND_CMD55;
byte_cnt <= 0;
end
end
end
WAIT_ACMD41: begin
// 等 ACMD41 返回 0x00(卡初始化完成)
sclk <= ~sclk;
if (sclk == 1) begin
resp <= {resp[6:0], miso};
if (bit_cnt == 0) begin
bit_cnt <= 7;
if (resp[7] == 0) begin
if (resp == 8'h00) begin
// 卡 ready,发 CMD17 读 sector 0
send_cmd(CMD17);
state <= SEND_CMD17;
end else begin
// 还在初始化中,重发 CMD55+ACMD41
send_cmd(CMD55);
state <= SEND_CMD55;
timeout_cnt <= timeout_cnt + 1;
if (timeout_cnt == 16'hFFFF) begin
error <= 1;
state <= ERROR_STATE;
end
end
end
end else
bit_cnt <= bit_cnt - 1;
end
end
WAIT_TOKEN: begin
// 等待数据开始 token(0xFE)
sclk <= ~sclk;
if (sclk == 1) begin
resp <= {resp[6:0], miso};
byte_cnt <= byte_cnt + 1;
if (byte_cnt[2:0] == 7 && resp[6:0] == 7'b1111111
&& miso == 0) begin
// 收到 0xFE token
state <= READ_DATA;
byte_cnt <= 0;
bit_cnt <= 7;
end
if (byte_cnt == 16'hFFFF) begin
error <= 1; state <= ERROR_STATE;
end
end
end
READ_DATA: begin
// 读取 512 字节数据 + 2 字节 CRC
sclk <= ~sclk;
if (sclk == 1) begin
resp <= {resp[6:0], miso};
if (bit_cnt == 0) begin
bit_cnt <= 7;
if (byte_cnt < 512) begin
data_out <= {resp[6:0], miso};
data_valid <= 1;
end
byte_cnt <= byte_cnt + 1;
if (byte_cnt == 513) begin // 512 + 2 CRC
cs_n <= 1;
ready <= 1;
state <= DONE;
end
end else
bit_cnt <= bit_cnt - 1;
end
end
DONE: state <= IDLE;
ERROR_STATE: begin cs_n <= 1; ready <= 1; end
endcase
end
end
endmodule
🚧 避坑 #2:SD 卡初始化超时
SD 卡初始化阶段,ACMD41 可能需要重复发送数百次才返回 0x00。有些卡(特别是大容量 SDHC)需要最长 1 秒的初始化时间。如果你的超时计数器设得太短(比如 100 次),会在卡准备好之前放弃,报错退出。建议超时设为 500ms 以上(按 400kHz SPI 时钟计算,约 50,000 字节发送时间)。另外,
ACMD41命令必须先发CMD55(APP_CMD 前置),否则 SD 卡直接忽略。
5. ESP32 SPI 通信协议
ESP32 作为 SPI Master,FPGA 作为 Slave。协议设计:
ESP32 → FPGA SPI 握手协议
命令帧格式(8 字节):
Byte 0: CMD(0x01=写寄存器,0x02=读寄存器,0x03=burst write)
Byte 1: ADDR(寄存器地址,0x00-0xFF)
Byte 2-3: DATA(16-bit 数据,大端序)
Byte 4-7: 保留(0x00)
响应帧格式(8 字节):
Byte 0: STATUS(0x00=OK,0xE0=ERROR,0xBB=BUSY)
Byte 1: ADDR_ECHO(回显地址)
Byte 2-3: DATA(读响应时填数据)
Byte 4-7: 保留
// spi_slave.v
// ESP32 SPI 通信从机(CPOL=0, CPHA=0,SPI Mode 0)
module spi_slave (
input wire clk,
input wire rst,
// SPI 接口(来自 ESP32)
input wire spi_sck,
input wire spi_mosi,
output reg spi_miso,
input wire spi_cs_n,
// 内部寄存器接口
output reg [7:0] reg_addr,
output reg [15:0] reg_wdata,
output reg reg_we, // 写使能
input wire [15:0] reg_rdata, // 读数据
output reg xfer_done // 一帧传输完成
);
reg [5:0] bit_cnt; // 0-63(8 字节帧)
reg [7:0] rx_shift; // 接收移位寄存器
reg [63:0] rx_frame; // 完整帧缓冲
// SCK 边沿检测(同步到系统时钟域)
reg [2:0] sck_sync;
reg [2:0] cs_sync;
always @(posedge clk) begin
sck_sync <= {sck_sync[1:0], spi_sck};
cs_sync <= {cs_sync[1:0], spi_cs_n};
end
wire sck_rise = (sck_sync[2:1] == 2'b01);
wire sck_fall = (sck_sync[2:1] == 2'b10);
wire cs_active = ~cs_sync[1];
// 发送移位寄存器(MISO)
reg [63:0] tx_frame;
reg [7:0] tx_shift;
always @(posedge clk) begin
if (!cs_active) begin
bit_cnt <= 0;
reg_we <= 0;
xfer_done <= 0;
end else begin
xfer_done <= 0;
reg_we <= 0;
if (sck_rise) begin
// MOSI 采样(CPOL=0,CPHA=0:上升沿采样)
rx_shift <= {rx_shift[6:0], spi_mosi};
bit_cnt <= bit_cnt + 1;
if (bit_cnt[2:0] == 7) begin
// 每 8 bit 存一个字节
rx_frame <= {rx_frame[55:0], rx_shift[6:0], spi_mosi};
end
if (bit_cnt == 63) begin
// 完整 8 字节帧接收完毕
xfer_done <= 1;
// 解析命令
case (rx_frame[63:56]) // CMD 字节
8'h01: begin // 写寄存器
reg_addr <= rx_frame[55:48];
reg_wdata <= rx_frame[47:32];
reg_we <= 1;
end
8'h02: begin // 读寄存器
reg_addr <= rx_frame[55:48];
// 响应在下一帧 MISO 发出
end
endcase
// 准备响应帧
tx_frame <= {8'h00, rx_frame[55:48],
reg_rdata, 32'h00000000};
end
end
if (sck_fall) begin
// MISO 驱动(下降沿更新,上升沿稳定)
if (bit_cnt == 0)
tx_shift <= tx_frame[63:56];
else if (bit_cnt[2:0] == 0)
tx_shift <= tx_frame[63 - bit_cnt -: 8];
spi_miso <= tx_shift[7];
tx_shift <= {tx_shift[6:0], 1'b0};
end
end
end
endmodule
🚧 避坑 #3:ESP32 SPI CPOL/CPHA 配置
ESP32 Arduino 库的
SPI.begin()默认是 Mode 0(CPOL=0, CPHA=0)。但很多 FPGA 侧代码的示例使用 Mode 1(CPHA=1,下降沿采样)。两边模式不匹配,数据全是错的,而且没有明显错误提示——SCK 有波形,CS 也在拉低,只是数据偏移了一个时钟沿。建议在 ESP32 侧加一段环回测试:发送0xAA 0x55,验证 MISO 回来的数据正确后再做真实通信。
6. 三外设同时运行:资源占用
把 HDMI、SD 卡、SPI 三个模块集成到一个顶层,综合到 ECP5-85F:
yosys -p "
read_verilog src/top.v src/hdmi_timing.v src/tmds_encoder.v \
src/tmds_serializer.v src/sd_controller.v src/spi_slave.v;
synth_ecp5 -top top -json build/design.json
" 2>&1 | grep -A20 "=== top ==="
综合结果:
=== top ===
Number of wires: 4821
Number of cells: 2247
CCU2C 16 (进位链)
DCCA 2 (时钟缓冲)
DP16KD 4 (BRAM,2Kbit×2bit×4)
EHXPLLL 1 (PLL,生成 25MHz/125MHz)
FD1P3IX 412 (DFF)
LUT4 1198 (组合逻辑 LUT)
ODDRX2F 8 (DDR 输出,TMDS 用)
OFD1P3IX 12 (输出 DFF)
模块分解:
HDMI(timing + encoder × 3 + serializer): ~1200 LUT
SD 控制器(状态机 + SPI): ~400 LUT
ESP32 SPI Slave: ~200 LUT
顶层 + 胶合逻辑: ~100 LUT
─────────────────────────────────────────────────
总计: ~1900 LUT / 83640 = 2.3%
| 模块 | LUT | FF | BRAM | DSP |
|---|---|---|---|---|
| HDMI 时序 + 编码 | ~1200 | ~180 | 0 | 0 |
| SD 卡控制器 | ~400 | ~120 | 0 | 0 |
| ESP32 SPI Slave | ~200 | ~80 | 0 | 0 |
| 其他 | ~100 | ~32 | 0 | 0 |
| 合计 | ~1900 | ~412 | 0 | 0 |
| ECP5-85F 总量 | 83640 | 83640 | 11Mbit | 156 |
| 利用率 | 2.3% | 0.5% | 0% | 0% |
85K LUT 还有 97.7% 空着,随便玩。
7. 验证步骤
# 1. 安装工具链
source oss-cad-suite/environment
openFPGALoader --version
# 2. 克隆 ULX3S 约束文件
git clone https://github.com/emard/ulx3s.git
cp ulx3s/doc/constraints/ulx3s_v31.lpf constraints/
# 3. 综合 + 布局布线
make # 使用 Makefile 自动化
# 4. 烧录
openFPGALoader -b ulx3s build/design.bit
# 5. 验证 HDMI
# 用 HDMI 线连显示器,应看到 640x480 的彩条
# 如果没图像,检查 .lpf 里的 IO_TYPE=LVCMOS33D
# 6. 验证 SD 卡
# 插入格式化好的 FAT32 microSD
# 通过 UART(115200 baud)观察读取的第一个 sector 内容
# 7. 验证 ESP32 SPI
# ESP32 发送测试帧 0x01 0x01 0xAB 0xCD 0x00...
# FPGA LED 应反映寄存器 0x01 的值(0xABCD = on/off pattern)
8. 下一篇预告
下一篇我们换到国产 FPGA——高云 Tang Nano 9K(GW1NR-9C)。 同样是开源工具链(apicula + nextpnr-gowin),但有很多有意思的差异: 工具链成熟度、国产 FPGA 市场格局、PSRAM HyperBus 接口。
→ 开源 FPGA 07:国产 FPGA 上手,高云 Tang Nano 9K 全流程
参考资料
| 资源 | 链接 / 说明 |
|---|---|
| ULX3S 官方仓库 | emard/ulx3s,含原理图、约束、示例 |
| Project Trellis(ECP5 逆向工程) | YosysHQ/prjtrellis |
| HDMI Spec 1.4 TMDS | HDMI Specification v1.4a, Section 5.4.2(TMDS 编码算法) |
| ODDRX2F 原语 | Lattice ECP5 sysI/O Usage Guide(FPGA-TN-02032,2019) |
| SD 物理层规范 | SD Association: “SD Specifications Part 1 Physical Layer Simplified Specification v8.00” |
| openFPGALoader | trabucayre/openFPGALoader |
| 640x480@60Hz 时序参数 | VESA Monitor Timing Standard v1.0, 1996 |
| ULX3S 示例项目集 | emard/ulx3s-examples |