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.
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
readycan 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 > PERIODsetsBAD_CFGand 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