top of page
Adiuvo Engineering & Training logo
MicroZed Chronicles icon

MicroZed Chronicles: CIC Filters

  • 7 minutes ago
  • 8 min read

FPGA Horizons London- October 6th and 7th 2026 - get Tickets here.

The $99 Artix UltraScale+ Explorer Board - learn more here


Over the last few weeks we have looked at some of the basics of DSP in FPGA, from filters such as FIR and IIR to DFT and FFT.


Continuing this exploration of basic DSP elements there is a common challenge we often face in DSP, that is changing the sample rate of a signal.


Example of the need to change the sample rate include working with a delta-sigma modulator which outputs a one-bit stream at tens of MHz that we need to decimate down to a usable rate, interfacing a PDM MEMS microphone, or building the digital down-conversion chain of a software-defined radio. Of course, we do not always need to down convert the rate, we often need to interpolate a baseband signal up to the sample rate of a DAC.


One commonly considered solution is a low-pass FIR filter followed by a decimator (or a zero padder followed by a FIR for interpolation). However, at high sample rates and large rate-changes, the multipliers required by a FIR filter quickly become expensive. This can also lead to challenges in timing closure etc.


This is where the Cascaded Integrator-Comb (CIC) filter comes in. First described by Eugene Hogenauer in 1981, the CIC filter performs decimation or interpolation using nothing but adders, subtractors, and registers. No multipliers, no coefficient storage.

 That is why you will find CIC filters at the front end of many digital down-converter, sigma-delta ADC, and PDM microphone interface ever built. It is also why AMD provides the CIC Compiler IP in Vivado, but as always, creating a simple version on our own teaches us what all the parameters on the IP core actually do.


How the CIC Filter Works

The CIC filter combines two very simple elements:


The integrator is a single-pole accumulator running at the high sample rate:


y[n] = y[n-1] + x[n]


The comb is a differentiator running at the low sample rate, subtracting a delayed copy of its input:


y[n] = x[n] - x[n-M]


where M is the differential delay, normally 1 or 2.


For a decimator, N integrators run at the input rate, followed by a rate change of R, followed by N combs at the output rate. For an interpolator, the structure is mirrored: N combs at the input rate, a zero stuffer which inserts R-1 zeros between samples, then N integrators at the output rate.


The magic is that the cascade of N integrator/comb pairs around a rate change of R is exactly equivalent to a moving-average FIR filter with the transfer function:


H(z) = [ (1 - z^-RM) / (1 - z^-1) ]^N


That is a sinc-shaped (sin(x)/x) low-pass response raised to the power N, with nulls falling exactly at multiples of the output sample rate, precisely where aliases fold back into our passband after decimation.



The response above is for the N=3, R=8 filter we are going to build. The shaded regions show the alias bands: anything living there ends up in the passband after decimation, and the filter conveniently places its deepest attenuation right on top of them. Increasing N deepens the nulls at the cost of more passband droop; increasing M narrows the passband and moves the first null down in frequency.


Lets take a look at a simple implementation of the CIC filter in VHDL, first we need to understand a couple of key concepts which can be things which trip us up.


Bit growth. The integrators have a pole at DC: on their own they are unstable and will happily count off to infinity. The design works because we let them overflow. Provided we use two's complement arithmetic and size the internal word width according to Hogenauer's equation, the wrap-arounds in the integrators are exactly cancelled by the combs:


W = W_in + ceil( N log2(R M) )


For our example (16-bit input, N=3, R=8, M=1) that gives W = 16 + 9 = 25 bits. In the test bench you can see the integrator registers wrap around while the output remains perfect. Two's complement overflow here is not just tolerated, it is required; do not be tempted to add saturation logic inside the filter.


Gain. The DC gain of the decimator is (RM)^N, which is 512 in our case, while the interpolator's is (RM)^N / R = 64, thanks to the zero padder. If R is a power of two the gain is an exact power of two, so we recover unity gain simply by selecting the correct slice of the output word. For non-power-of-two rates, you take the next power of two up and accept a gain slightly below one.


The price we pay for all this efficiency is passband droop. The sinc^N response is not flat, drooping towards the passband edge: around -0.16 dB at the frequencies used in our test bench, but much worse near the edge of the usable band. In a real receive chain the CIC is therefore followed by a small compensation FIR (an inverse-sinc filter) running at the low rate, where multipliers are cheap.


The Decimator in VHDL


The full source is in cic_decimator.vhd; the entity is parameterised by generics so you can experiment with the number of stages and the rate.


entity cic_decimator is
  generic (
    STAGES : positive := 3;   -- N : number of integrator/comb pairs
    RATE   : positive := 8;   -- R : decimation ratio
    IN_W   : positive := 16;  -- input sample width
    OUT_W  : positive := 16   -- output sample width
  );
  port (
    clk     : in  std_logic;
    rst     : in  std_logic;
    s_data  : in  std_logic_vector(IN_W-1 downto 0);
    s_valid : in  std_logic;
    m_data  : out std_logic_vector(OUT_W-1 downto 0);
    m_valid : out std_logic
  );
end entity cic_decimator;

The internal width is computed directly from Hogenauer's equation at elaboration time; math_real is OK here as it is only used for constants:


constant GROWTH : natural := integer(ceil(real(STAGES) * log2(real(RATE))));
constant W      : natural := IN_W + GROWTH;
type stage_array is array (1 to STAGES) of signed(W-1 downto 0);

The integrator section is a simple registered cascade running at the input rate. Because each stage is a signal, every stage adds one register, which is exactly what we want for timing closure at high sample rates:


integrators : process(clk)
begin
  if rising_edge(clk) then
    if rst = '1' then
      integ <= (others => (others => '0'));
    elsif s_valid = '1' then
      integ(1) <= integ(1) + resize(signed(s_data), W);
      for i in 2 to STAGES loop
        integ(i) <= integ(i) + integ(i-1);
      end loop;
    end if;
  end if;
end process;

The rate change keeps one sample in every RATE, generating a valid pulse at the low rate:

if s_valid = '1' then
  if smpl_cnt = RATE-1 then
    smpl_cnt  <= 0;
    dec_data  <= integ(STAGES);
    dec_valid <= '1';
  else
    smpl_cnt <= smpl_cnt + 1;
  end if;
end if;

The combs then run only when a decimated sample arrives, so the whole comb section runs at one-eighth of the clock rate in our example. A valid pipeline tracks the samples through the stages:


vld_p(1) <= dec_valid;
if dec_valid = '1' then
  dly(1)  <= dec_data;
  comb(1) <= dec_data - dly(1);
end if;
for i in 2 to STAGES loop
  vld_p(i) <= vld_p(i-1);
  if vld_p(i-1) = '1' then
    dly(i)  <= comb(i-1);
    comb(i) <= comb(i-1) - dly(i);
  end if;
end loop;

Finally, the output takes the top OUT_W bits, which for a power-of-two R removes the 2^GROWTH gain and restores unity:


m_data  <= std_logic_vector(comb(STAGES)(W-1 downto W-OUT_W));
m_valid <= vld_p(STAGES);

Note that simply truncating the LSBs like this does throw away information; for demanding applications Hogenauer's paper also shows how to prune bits progressively through the stages and how rounding at the output improves the noise floor. For most applications the straight truncation shown here is acceptable.



The Interpolator


The interpolator (cic_interpolator.vhd) mirrors the structure: the combs come first, running once per input sample, and the integrators run on every clock. The zero padding is efficient achieved as  stuffing a zero into an accumulator means simply not adding anything:


integrators : process(clk)
begin
  if rising_edge(clk) then
    if rst = '1' then
      integ <= (others => (others => '0'));
      run   <= '0';
    else
      if comb_v(STAGES) = '1' then
        run      <= '1';
        integ(1) <= integ(1) + comb(STAGES);
      end if;                              -- else : add zero (stuffing)
      for i in 2 to STAGES loop
        integ(i) <= integ(i) + integ(i-1);
      end loop;
    end if;
  end if;
end process;

The only other difference is the output slice: because the interpolator gain is R^(N-1) rather than R^N, we shift by six bits rather than nine:


constant GAIN_BITS : natural := integer(ceil(real(STAGES-1) * log2(real(RATE))));

m_data <= std_logic_vector(integ(STAGES)(OUT_W+GAIN_BITS-1 downto GAIN_BITS));

Verification


Both test benches (tb_cic_decimator.vhd, tb_cic_interpolator.vhd) are self-checking and written in VHDL-2008, so they run in Vivado XSIM, GHDL, Questa, or any other modern simulator:

ghdl -a --std=08 cic_decimator.vhd tb_cic_decimator.vhd
ghdl -e --std=08 tb_cic_decimator
ghdl -r --std=08 tb_cic_decimator

Each test bench applies two stimuli. The first is a DC level of 1000: once the filter has settled, every single output sample is checked against the expected value, and because the gain removal is exact for a power-of-two R, the check is for equality, not tolerance.


This test is more demanding than it looks: by the end of it the second and third integrators have wrapped around many times, so passing proves the two's complement modulo arithmetic and the Hogenauer word sizing are doing their jobs.


The second stimulus is a full-scale-ish sine wave placed well inside the passband, with the samples logged to CSV so we can plot them. The test bench checks that the output amplitude matches the amplitude predicted by the sinc^N droop equation.


The decimator output (delay-aligned for the plot) tracks the input sine with an amplitude of 19,570 against the theoretical 19,624 from the droop equation; the small difference is simply because with 16 output samples per cycle, no sample lands exactly on the peak.

The interpolator is where the CIC really shows off: eight smooth output samples for every input sample, with a measured amplitude of 19,904 against a predicted 19,905. Not bad for a filter with no multipliers in it at all.


All of the code is available here


Wrap Up


CIC filters give us large, efficient rate changes for the cost of 2N adders and a handful of registers, and they map beautifully onto FPGA fabric. The things to keep in mind are few but important: size the internal word width using Hogenauer's equation, let the integrators wrap (two's complement arithmetic is mandatory), remember the (RM)^N gain when you slice the output, and budget for a small compensation FIR if your application is sensitive to passband droop. From there, the CIC pairs naturally with half-band filters to build complete, resource-efficient decimation and interpolation chains, which sounds like a good topic for a future blog.


FPGA Conference

FPGA Horizons London- October 6th and 7th 2026 - get Tickets here.


FPGA Journal

Read about cutting edge FPGA developments, in the FPGA Horizons Journal or contribute an article.


Workshops and Webinars:

If you enjoyed the blog why not take a look at the free webinars, workshops and training courses we have created over the years. Highlights include:



Boards

Get an Adiuvo development board:

  • Adiuvo Embedded System Development board - Embedded System Development Board

  • Adiuvo Embedded System Tile - Low Risk way to add a FPGA to your design.

  • SpaceWire CODEC - SpaceWire CODEC, digital download, AXIS Interfaces

  • SpaceWire RMAP Initiator - SpaceWire RMAP Initiator,  digital download, AXIS & AXI4 Interfaces

  • SpaceWire RMAP Target - SpaceWire Target, digital download, AXI4 and AXIS Interfaces

  • Other Adiuvo Boards & Projects.


Embedded System Book   

Do you want to know more about designing embedded systems from scratch? Check out our book on creating embedded systems. This book will walk you through all the stages of requirements, architecture, component selection, schematics, layout, and FPGA / software design. We designed and manufactured the board at the heart of the book! The schematics and layout are available in Altium here.  Learn more about the board (see previous blogs on Bring up, DDR validation, USB, Sensors) and view the schematics here.


All words in this blog were written by a human.





bottom of page