FPGA core for a HIPIMS (High Power Impulse Magnetron Sputtering) pulse generator: a timing-critical VHDL design on a Cyclone IV E (EP4CE6E22C8) that drives a cathode half-bridge, up to 8 oscilloscope trigger/probe channels, and an anode-side SiC half-bridge with driver fault handling — all configured at runtime over SPI by a companion STM32 microcontroller.
(Русская версия: README_ru.md)
Compiles clean through the full Quartus flow (quartus_map →
quartus_fit → quartus_asm → quartus_sta), flashed and verified on
real hardware together with the companion STM32 firmware: base pulse, a
negative-delay (pre-trigger) probe channel, the anode PWM channel with its
dead-time control, and fault handling have all been observed working on an
oscilloscope. Still bring-up stage — not all probe channels or Leg B have
been exercised on real hardware yet.
The system is split across two repositories on purpose:
- This repository (Quartus/VHDL, Cyclone IV E) — owns only what is actually timing-critical: the pulse generation core and an SPI slave register file. No UI, no persistence, nothing that isn't directly part of producing correctly-timed output signals.
- A separate STM32F103C8T6 ("Blue Pill") PlatformIO project
(
HiPIMS_Menu, not in this repo) — owns the LCD1602 menu, rotary encoder input, flash-based parameter persistence, and acts as the SPI master that configures this FPGA at runtime.
The UI (menu, LCD, encoder) was originally built entirely in VHDL on the FPGA. It worked and closed timing, but reached 54% LE usage and became code that was genuinely hard to safely modify by hand. Moving the UI to a microcontroller — where a menu system is cheap and ordinary C, not a timing-sensitive FSM competing for the same clock domain as the pulse core — dropped FPGA usage back down and made the split's boundary a simple one: the FPGA never has to be touched to change a menu, add a display, or add flash storage. Anything that isn't pulse timing belongs on the microcontroller side.
STM32 (SPI master) FPGA (SPI slave, this repo)
menu / encoder / LCD spi_slave_regs.vhd
flash-persisted config --SPI--> register file (36 regs)
CRC-8 + retry + status read |
v
pulse_generator.vhd
(shared time-base counter)
| | |
leg_interlock 8x anode_channel.vhd
(Leg A/Leg B) pulse_channel (SiC half-bridge,
| (probes) fault/reset,
v v own leg_interlock)
LEG_A_OUT/ PROBE_OUT[0..7] ANODE_PWM_H/L,
LEG_B_OUT ANODE_RST_A/B_N
- Base pulse (Leg A / Leg B half-bridge) — drives an IR2213-class gate driver. Only Leg A is currently wired up; Leg B exists in the design (its own delay/width registers, same shoot-through interlock) but is unused, reserved for a future second leg.
- Probe/scope trigger channels — up to 8 (
NUM_PROBE_CHANNELS, 5 currently wired), each independently enabled with its own delay (which may be negative, i.e. pre-trigger, down to −500µs) and width. All built from the samepulse_channelprimitive as the legs. - Anode channel — a second, independent half-bridge (SiC, e.g. driven through UCC21750/UCC21520-class isolated gate drivers) that runs a free-running PWM burst (configurable frequency/duty 50%/duration) gated to a window relative to the same shared start event as Leg A. Has its own dead-time-protected complementary output, two fault inputs, and two active-low reset-pulse outputs for the gate drivers' fault latches.
Every time-valued register is a raw 50 MHz clock cycle count — not
microseconds, not milliseconds. 1 LSB = 20 ns. raw_cycles = us * 50.
This is deliberate: any runtime multiply/divide by a non-power-of-two
constant on the FPGA synthesizes as an expensive lpm_mult/lpm_divide
megafunction (one early register-file design accidentally consumed the
entire DSP block budget this way). Unit conversion to human-readable
values is the STM32 firmware's job, where it is free.
Negative pre-trigger delays (Leg B, probes, the anode channel) are
resolved into an absolute position on an extended time-base window:
[−MARGIN_PRE, period + MARGIN_POST), MARGIN_PRE = MARGIN_POST = 25000
cycles (500 µs). MAX_PERIOD_CYCLES = 2^23 − 1 − MARGIN_PRE − MARGIN_POST = 8 338 607 cycles (~166.77 ms) is the safe ceiling for REG_PERIOD —
the FPGA does not validate this, the caller (STM32) must keep it in range.
- Enable and Start are two independent flags, not one.
REG_ENABLEgates pulse generation internally.REG_STARTonly drives a dedicated physical output pin — an external AND gate combines it with other permit signals before it can actually enable the power stage. They are not logically related to each other. - Leg A/Leg B shoot-through interlock (
leg_interlock.vhd) is a small, formally-reasoned mutual-exclusion block: by construction,leg_a_outandleg_b_outare never both1, and a leg only turns on after the other has been off for at least a configured dead time, Leg A having priority on a tie (proof sketch in the file's header comment). The anode channel reuses this exact block for its own H/L outputs (instantiated a second time) instead of writing new deadtime logic. - Anode dead time has a hardcoded, SPI-unreachable floor.
REG_ANODE_DEADTIMEonly ever adds to a fixed 300 ns minimum (MIN_DEAD_TIME_CYCLESgeneric inanode_channel.vhd) — it cannot reduce dead time below that floor, including at its power-on-reset default of 0. Without this, an unconfigured register file (all zeros after reset, before the STM32 ever writes anything) would default to zero dead time, a real shoot-through risk. - Anode driver fault immediately gates the whole generator, not just
the anode channel —
effective_enable = global_enable AND NOT fault_anyis used everywhereglobal_enablewould otherwise be wired (Leg A, Leg B, all probes). A fault on the anode drivers most likely correlates with an arc in the discharge; there is no reason to keep the cathode pulse alive while it's latched. - Fault recovery is manual-restart-only. The fault latch does not
clear itself once its cool-down (100 µs) + reset-pulse (2 µs) dwell
completes — clearing requires an explicit rising edge on raw
REG_ENABLE(0→1). An earlier version auto-cleared after the dwell, which raced the STM32's much slower SPI/software reaction time: the FPGA could silently relaunch generation before the firmware even saw the fault in the status byte. Requiring an edge makes "the next launch is issued by the operator, not automatically" true at the hardware level. A still-present physical fault immediately re-latches on the next attempt — expected, not a bug. - Any input pin that can be physically unconnected during bring-up gets
a defined pull.
ANODE_FAULT_A_N/ANODE_FAULT_B_Nare active-low with a weak pull-up, so "no gate driver wired up yet" reads as a deterministic "no fault" instead of an undefined, noise-susceptible floating level (this was a real regression once the fault latch above stopped self-clearing — a floating pin could latch the whole generator off permanently). - SPI receiver has a glitch filter, separate from (and in addition
to) the double-flop metastability synchronizer: a candidate value on
SCK/MOSI/CS_Nmust hold for 4 consecutive CLK50 cycles (80 ns) before being accepted, added after real switching noise from the anode channel was observed corrupting SPI framing on the bench.
STM32 is the SPI master, the FPGA is the slave. Mode 0 (CPOL=0, CPHA=0),
MSB-first. One register write is a 7-byte unit, and several units may
be sent back-to-back under one CS_N low pulse:
| Byte | Content |
|---|---|
| 0 | register address, 0..NUM_REGS-1 (36 registers). Out-of-range is accepted for framing but never committed. |
| 1–4 | 32-bit signed value, big-endian |
| 5 | CRC-8 over bytes 0–4. Poly x^8+x^2+x^1+1 (0x07), init 0x00, no reflection, no final XOR (the common "CRC-8"/SMBus PEC variant). On mismatch the write is dropped. |
| 6 | master sends a dummy byte while simultaneously reading a status byte back over MISO: bit0 = ACK (CRC matched and, if the address was in range, the write committed), bit1 = fault_a live, bit2 = fault_b live, bits 3–7 reserved. |
On NACK, the caller must re-send the same 7-byte unit after toggling
CS_N high and back low — the FPGA's bit/byte counters only reset on
CS_N high, so resending inside the same CS session cannot recover from
a framing desync caused by a glitch on SCK. The CS_N high pulse
between retries must be noticeably longer than the 80 ns glitch-filter
threshold. The FPGA never retries anything itself.
| Addr | Register | Notes |
|---|---|---|
| 0 | REG_ENABLE |
0/1, gates generation internally |
| 1 | REG_START |
0/1, drives the physical permit pin only; unrelated to ENABLE |
| 2 | REG_PERIOD |
raw cycles; keep ≤ MAX_PERIOD_CYCLES (8 338 607) |
| 3 | REG_LEGA_WIDTH |
raw cycles |
| 4 | REG_LEGB_ENABLE |
0/1 |
| 5 | REG_LEGB_DELAY |
signed raw cycles, relative to Leg A's start |
| 6 | REG_LEGB_WIDTH |
raw cycles |
| 7–30 | 8 probe channels × 3 | channel i (0–7): enable = 7+i*3, delay (signed) = 7+i*3+1, width = 7+i*3+2 |
| 31 | REG_ANODE_ENABLE |
0/1 |
| 32 | REG_ANODE_PERIOD |
raw cycles; PWM period = value + 1 |
| 33 | REG_ANODE_DELAY |
signed raw cycles, relative to the shared start event |
| 34 | REG_ANODE_DURATION |
raw cycles |
| 35 | REG_ANODE_DEADTIME |
raw cycles, addition on top of the fixed 300 ns floor — not the absolute value |
Board: a generic Chinese "SANKAI" EP4CE6E22C8N (TQFP144) breakout board; most FPGA pins are broken out to headers labeled with the real pin number on the silkscreen.
| Signal | Pin | Notes |
|---|---|---|
CLK50 |
24 | 50 MHz oscillator |
SPI_SCK |
144 | |
SPI_MOSI |
143 | |
SPI_MISO |
142 | |
SPI_CS_N |
141 | SPI signals share one contiguous pin run, bundled together on purpose for noise immunity |
LEG_A_OUT |
99 | |
LEG_B_OUT |
98 | unused leg, reserved |
PROBE_OUT[0..7] |
85, 86, 77, 83, 76, 28, 30, 31 | 5 currently wired |
START_OUT |
32 | physical permit pin |
ANODE_FAULT_A_N |
33 | input, active-low, weak pull-up |
ANODE_FAULT_B_N |
34 | input, active-low, weak pull-up |
ANODE_RST_A_N |
100 | output, active-low reset pulse |
ANODE_RST_B_N |
104 | output, active-low reset pulse |
ANODE_PWM_H |
138 | |
ANODE_PWM_L |
137 |
105/106/103 are free (formerly SPI, moved off 2026-08-18). The
Quartus fitter reserves pins 6, 8, 12, 13, 101 for Active Serial
configuration — avoid them for any new signal.
rtl/
reset_gen.vhd power-on reset generator
pulse_gen_pkg.vhd shared types/constants (time_t, delay_t, TIME_WIDTH, margins)
pulse_channel.vhd generic [start, start+width) window primitive
leg_interlock.vhd shoot-through-safe complementary drive (shared by legs + anode)
pulse_generator.vhd time-base counter, Leg A/B, probes, anode instantiation, effective_enable
spi_regs_pkg.vhd register address map, NUM_REGS
spi_slave_regs.vhd SPI slave: sync, glitch filter, CRC-8, MISO status byte
anode_channel.vhd anode PWM burst + fault/reset handling
Generator.vhd top-level: wires SPI registers into pulse_generator
i2c_master.vhd, lcd1602_pcf8574.vhd, quad_encoder.vhd,
menu_pkg.vhd, menu_fsm.vhd, menu_lcd_render.vhd
superseded by the STM32 split — kept on disk,
NOT part of the build (see Generator.qsf)
techdocs/ holds the working design log and is not tracked in this
repository (see .gitignore) — it's internal notes, not published
documentation.
Quartus Prime 24.1std, target EP4CE6E22C8. From the project root:
quartus_map Generator
quartus_fit Generator
quartus_asm Generator
quartus_sta Generator
Current build: 2552/6272 logic elements (41%), 0/30 DSP blocks, worst-case setup slack ≈8.5 ns (Slow 1200 mV 0°C model) on a 20 ns clock.
The STM32 firmware (menu, encoder, LCD, SPI master, flash persistence) lives in a separate PlatformIO project, not part of this repository: HiPIMS_Menu.