← 返回博客
FPGAECP5ULX3SHDMITMDSSD卡ESP32开源工具Verilog

开源 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,而且开源工具链支持最好

本篇目标:

  1. 用 openFPGALoader 烧录 ECP5
  2. 点亮 HDMI:640x480@60Hz DVI 时序,完整 TMDS encoder
  3. SD 卡 SPI 模式:从初始化到读取单个 sector
  4. ESP32 通过 SPI 与 FPGA 握手
  5. 三个外设同时运行,分析资源占用

本篇不覆盖:

  • DDR3/SDRAM 控制器(需要单独一篇)
  • USB(ECP5 有 USB 2.0 硬核,需要 tinyusb/litex-usb)
  • ESP32 固件(只设计 FPGA 侧协议)

1. ULX3S 硬件速览

ULX3S v3.1 — 板卡构成 PCB · CERN-OHL-W ECP5-85F LFE5U-85F · 85K LUT 156 DSP18 · 11 Mb BRAM 2× PLL · SERDES nextpnr-ecp5 / Trellis ESP32-WROOM-32 WiFi + Bluetooth 16 MB Flash JTAG 编程器 esp32ecp5 / openFPGALoader SDRAM 32 MB IS42S16160G 166 MHz · 16-bit litedram 控制器 2× HDMI Type A GPDI / TMDS 差分对 LVDS 1080p @ 60Hz litevideo 输出 USB-C / FT231X JTAG + UART 5V 供电 openocd / openFPGALoader GPIO 56 + PMOD ×4 + microSD + Audio Jack + 8× LED 3.3V LVCMOS · 差分对 .lpf 约束文件分配引脚 SPI/JTAG SDRAM bus GPDI UART user IO
ULX3S 以 ECP5-85F 为核心,板载 ESP32 提供 WiFi/JTAG,外加 SDRAM、双 HDMI 与丰富 GPIO,硬件原理图完全开源。
参数规格
FPGALattice ECP5-85F(LFE5U-85F)
LUT83,640(约 85K)
DSP18156 个
BRAM11 Mbit(EBR)
SDRAM32 MB,SDRAM(不是 DDR3)
板载 MCUESP32-WROOM-32(WiFi/BT,16 MB Flash)
HDMI双路 HDMI Type A(TMDS 差分对)
SD 卡microSD 槽,SPI 模式
USBFTDI 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%
模块LUTFFBRAMDSP
HDMI 时序 + 编码~1200~18000
SD 卡控制器~400~12000
ESP32 SPI Slave~200~8000
其他~100~3200
合计~1900~41200
ECP5-85F 总量836408364011Mbit156
利用率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 TMDSHDMI 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”
openFPGALoadertrabucayre/openFPGALoader
640x480@60Hz 时序参数VESA Monitor Timing Standard v1.0, 1996
ULX3S 示例项目集emard/ulx3s-examples