Loading header...

Building Peripherals

An FPGA peripheral is a reusable hardware block with a software-visible contract. GPIO, timers, PWM generators, UARTs, SPI masters, capture units, filters, counters, and custom accelerators all become easier to integrate when the register map, reset behavior, status flags, and timing assumptions are designed deliberately.

Learning Objectives

By the end of this lesson, you should be able to:

  • divide a peripheral into bus, register, core, and interrupt sections;
  • define readable control, status, and data registers;
  • specify write side effects and reset values;
  • avoid unsafe mid-cycle updates;
  • verify the hardware/software contract in simulation and on hardware.

A Peripheral Is a Contract

The RTL is only half of the design. Firmware needs to know what can be written, what can be read, when a write takes effect, how errors are reported, and which actions clear status bits. A good peripheral makes those behaviors explicit.

flowchart LR CPU["CPU or soft core"] --> BUS["Bus interface
address, write, read, ready"] BUS --> REGS["Register bank
CTRL STATUS CONFIG DATA"] REGS --> CORE["Core logic
PWM UART SPI filter"] CORE --> REGS CORE --> PINS["Pins or stream"] CORE --> IRQ["IRQ or event flag"]

Keep the bus adapter separate from the core. The core should usually know about clean strobes and configuration signals, not AXI, Wishbone, APB, or a custom bus handshake.

Register Map Design

Use aligned offsets, fixed reset values, short field names, and one clearing convention. The table below describes a simple PWM peripheral on a 100 MHz bus clock.

Offset Register Access Reset Fields Behavior
0x00 CTRL R/W 0x00000000 bit0 EN, bit1 IRQ_EN, bit2 LOAD Enables PWM and requests shadow load
0x04 PERIOD R/W 99999 bits31:0 period cycles PWM period is PERIOD + 1 cycles
0x08 DUTY R/W 0 bits31:0 high cycles Output high while count < DUTY
0x0C STATUS R/W1C 0x00000000 bit0 WRAP, bit1 BAD_CFG Write 1 to clear each flag

R/W1C means read/write-one-to-clear. If firmware writes 1 to STATUS[0], the WRAP flag clears. Writing 0 leaves it unchanged. Avoid read-clear registers unless the firmware team explicitly wants that behavior, because debugger reads can accidentally clear evidence.

Safe PWM Core

This core uses active configuration registers and shadow registers. Firmware can update the shadow values at any time, but the PWM output changes only at a period boundary. That prevents half-old, half-new cycles.

module pwm_core #(
    parameter WIDTH = 32
) (
    input  wire             clk,
    input  wire             rst,
    input  wire             enable,
    input  wire             load_shadow,
    input  wire [WIDTH-1:0] period_shadow,
    input  wire [WIDTH-1:0] duty_shadow,
    output reg              pwm,
    output reg              wrap,
    output reg              bad_cfg
);
    reg [WIDTH-1:0] count;
    reg [WIDTH-1:0] period_active;
    reg [WIDTH-1:0] duty_active;

    wire [WIDTH-1:0] next_period =
        (period_shadow == 0) ? {{(WIDTH-1){1'b0}}, 1'b1} : period_shadow;

    always @(posedge clk) begin
        if (rst) begin
            count         <= 0;
            period_active <= 32'd99999;
            duty_active   <= 0;
            pwm           <= 1'b0;
            wrap          <= 1'b0;
            bad_cfg       <= 1'b0;
        end else begin
            wrap <= 1'b0;

            if (!enable) begin
                count <= 0;
                pwm   <= 1'b0;
            end else if (count == period_active) begin
                count <= 0;
                wrap  <= 1'b1;

                if (load_shadow) begin
                    period_active <= next_period;
                    duty_active   <= (duty_shadow > next_period) ? next_period : duty_shadow;
                    bad_cfg       <= (duty_shadow > next_period);
                end
            end else begin
                count <= count + 1'b1;
            end

            pwm <= enable && (count < duty_active);
        end
    end
endmodule

For a 100 MHz clock and PERIOD = 99999, the PWM frequency is:

f_pwm = f_clk / (PERIOD + 1)
      = 100 MHz / 100000
      = 1 kHz

For DUTY = 25000, the duty ratio is approximately:

duty ratio = DUTY / (PERIOD + 1) = 25000 / 100000 = 25 %

Timing of a Shadow Load

The waveform is explanatory. It shows why loading active values only at wrap keeps the output predictable.

title "PWM shadow update timing"
time start=0 end=8 unit=cycles divisions=8

CLK: square label="clk" low=0 high=1 duty=50 cycles=8 color=#2563eb
WR: pulse label="bus write shadow" low=0 high=1 at=2 width=1 unit=logic color=#9333ea
WRAP: pulse label="period wrap" low=0 high=1 at=5 width=1 unit=logic color=#16a34a
LOAD: step label="active values change" low=0 high=1 at=5 unit=logic color=#dc2626

marker UPDATE at=2 label="firmware writes"
marker APPLY at=5 label="load at wrap"

Bus Interface Rules

A small register interface should still have disciplined behavior:

  • decode only valid word-aligned addresses;
  • return a defined value for reserved addresses;
  • ignore writes to read-only fields;
  • make status flags sticky until cleared;
  • synchronize external or core-domain events into the bus clock domain;
  • document whether ready can stall;
  • keep reset synchronous unless the surrounding system requires asynchronous reset.

For multi-clock peripherals, do not move a raw one-cycle pulse between clocks. Use a toggle synchronizer, handshake, asynchronous FIFO, or vendor-approved CDC primitive.

Worked Example: Status Flag Clearing

Assume STATUS[0] is WRAP. The core raises a one-cycle wrap pulse each period. The register bank stores a sticky flag:

always @(posedge bus_clk) begin
    if (rst) begin
        status_wrap <= 1'b0;
    end else begin
        if (wrap_sync)
            status_wrap <= 1'b1;
        if (bus_write_status && wdata[0])
            status_wrap <= 1'b0;
    end
end

This is simple, but order matters. In the code above, a clear in the same cycle as a new event clears the flag. Many designs prefer event priority so a simultaneous event is not lost:

if (bus_write_status && wdata[0])
    status_wrap <= 1'b0;
if (wrap_sync)
    status_wrap <= 1'b1;

Choose one policy and document it.

Verification Checklist

Before connecting firmware, test these cases:

  • reset values match the register map;
  • read-only bits ignore writes;
  • DUTY > PERIOD sets BAD_CFG and clamps or rejects the value as documented;
  • active PWM values change only at wrap;
  • status flags are sticky and clear with the chosen convention;
  • interrupt output follows STATUS & IRQ_EN;
  • invalid address reads and writes have defined behavior;
  • no unsynchronized CDC path exists between bus and peripheral clocks.

Common Mistakes

  • Mixing bus protocol state machines into every core.
  • Writing a register map after the RTL is already difficult to change.
  • Updating output-critical settings immediately and causing glitches.
  • Using read-clear status flags without warning firmware and debug users.
  • Forgetting reset values for writable registers.
  • Crossing between bus and core clocks with raw pulses.

Summary

A robust FPGA peripheral is a clean core plus a precise register contract. Separate the bus interface, register bank, core logic, and interrupt/status behavior. Define reset values, access rules, side effects, invalid configurations, and CDC assumptions before firmware depends on the block.

Next: Pipelining, Resource Use, and Optimization.

Further Reading

  • ARM AMBA APB and AXI4-Lite protocol specifications
  • OpenCores Wishbone B4 specification
  • AMD and Intel FPGA register-interface and CDC design guidelines
  • Clifford Wolf, Yosys documentation on synthesizable Verilog

Mind Map

mindmap root((FPGA Peripheral)) Core concept RTL plus contract Bus adapter Register bank Reusable core IRQ and status Applications GPIO PWM timer UART SPI Custom accelerator Sensor capture Formulas f_pwm equals f_clk over period plus 1 duty percent equals duty cycles over period plus 1 address offset word aligned Design rules Reset values documented W1C for sticky flags Shadow critical settings Separate bus and core Synchronize CDC events Practical checks Invalid addresses defined RO bits ignore writes IRQ equals status mask Load only at boundary Test reset map Common mistakes Hidden side effects Mid cycle glitches Lost status events Raw pulse across clocks Firmware guesses RTL