This is only a preview of the September 2025 issue of Practical Electronics. You can view 0 of the 80 pages in the full issue. Articles in this series:
Items relevant to "Compact Hi-Fi Headphone Amplifier, part one":
Articles in this series:
Articles in this series:
Articles in this series:
Articles in this series:
Articles in this series:
|
Circuit Surgery
Regular clinic by Ian Bell
Topics in digital signal processing –
Implementing DSP on a microcontroller, part four
W
e are looking at various
topics related to digital signal
processing (DSP).
DSP covers a wide range of electronics
applications where signals are manipulated, analysed, generated, stored or
displayed as digital data but originate
from and/or are converted to real-world
signals for interaction with humans or
other parts of the physical world.
Fig.1 shows the key elements of a
generic DSP system with a signal path
from an analog input via digital processing to an analog output.
This does not necessarily represent
every DSP system (not all have all the
parts shown), but it serves as a reference for the various subsystems we
will look at.
In the previous few articles, we
have been working through an example microcontroller-b ased DSP
implementation. Before looking at the
implementation of a sinc filter, we discussed individual aspects of the system:
the DAC (digital-to-analog converter)
and ADC (analog-to-digital converter),
sample timing and data transfer.
Last month, we discussed the ADC
on the microcontroller we are using,
and developed code to transfer the ADC
readings to the DAC. This code creates
a replica of the ADC input waveform
on the DAC output. Initially, we used a
timer-driven interrupt routine to transfer data, but this proved to be slow,
running at a maximum sampling rate
of around 60kHz.
Far more efficient data transfer is
achieved using direct memory access
(DMA), in which independent hardware
(the DMA controller) moves data between
the microcontroller’s memory and peripherals such as the ADC and DAC. We
introduced the basics of DMA and used
it to implement ADC to DAC transfer.
Analog
In
Antialiasing
filter
Sample and
hold
This requires two buffers (contiguous
areas of memory) to hold data read from
the ADC and to be written to the DAC.
The buffers are ‘circular’; when the read
operation reaches the upper address
limit, it continues from the lower end
of the buffer’s address range.
The ADC-to-DAC transfer code simply
has to copy data from the ADC buffer
to the DAC buffer, coordinated with the
DMA transfer timing. The microcontroller’s software library provides two
callback functions that run when the
buffer is half-full and full.
Copying the data in these two functions means that code operations take
place on stable data in one half of the
buffer while the other half is being accessed by the DMA controller.
We concluded last month with the
code shown in Listing 1. This approach
provided a significant improvement in
speed – it could run far faster than the
DAC’s maximum conversion rate of
around 330kHz.
FIR filter recap
This month we will look at the implementation of a finite impulse response
(FIR) filter, specifically a windowed sinc
digital filter.
We discussed digital filters in detail
from December 2024 to May 2025, focusing on the theory, finding coefficient
values and simulating the filters in
LTspice.
As previously mentioned, our aim
here is to develop a basic implementation of the filter, without attempting to
use any advanced algorithmic or coding
techniques. We will not use library code
such as Arm’s Common Microcontroller
Software Interface Standard (CMSIS)
DSP library so that the processing operations are not hidden in calls to library
functions.
Digital
ADC
Digital
processing
Analog
DAC
Reconstruction
filter
The structure of an FIR filter is shown
in Fig.2; its output is obtained from a
weighted sum of the present and past
samples; the weights are the coefficients
(a) of the filter (collectively called the
kernel). The samples are written as a set
of indexed values with index n (input
x[n], output y[n]) referring to the most
recent sample.
The previous input is x[n – 1], the
one before that x[n – 2] and so on. In a
software implementation of a filter, the
samples are stored in system memory.
Storing the previous sample and using
it at the current time is equivalent to
passing if through a delay (Δt) of one
sample period (T).
For a filter with N coefficients, the
output value is calculated from the present
input sample and past (delayed) N – 1
inputs. The input delayed by i samples
(x[n – i]) is scaled by the coefficient a[i].
Mathematically, we are performing a
convolution of the kernel and input
signal (see Circuit Surgery, August
2024), which can be written as the
summation (Σ) of aix[n – i] for i from
0 to N – 1, ie:
N −1
y [ n ] = ∑ a [ i ] x [ n−i ]
i=0
a0
x(n)
×
Σ
y(n)
a1
Δt
x(n–1)
×
a1x(n–1)
Σ
a2
Δt
x(n–2)
×
a2x(n–2)
Σ
aN–1
Δt
Out
a0x(n)
Memory
x(n–N–1)
×
aNx(n–N–1)
Processing
Fig.1: a generic digital signal processing (DSP) system structure.
Fig.2: the structure of a finite impulse
response (FIR) filter.
48
Practical Electronics | September | 2025
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
/* USER CODE BEGIN 4 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc)
{
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_SET);
for (int i= 0; i < HALF_BUF_SIZE; i++)
{
DAC_buffer[i] = ADC_buffer[i];
}
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_RESET);
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_SET);
for (int i= HALF_BUF_SIZE; i < BUF_SIZE; i++)
{
DAC_buffer[i] = ADC_buffer[i];
}
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_RESET);
}
/* USER CODE END 4 */
Listing 1: the DMA callback functions we concluded with last month.
1
2
3
4
5
Result = 0;
for (int i = 0; i < COEFF_SIZE; i++)
{
Result = Result + Coefficient[i] * filter_input[n-i];
}
Listing 2: the FIR filter calculation for one output sample.
1
2
3
4
for (int i = 0; i < HALF_BUF_SIZE; i++)
{
filter_input[i] = (float) ADC_buffer[i];
}
5
6
7
8
for (int i = HALF_BUF_SIZE; i < BUF_SIZE; i++)
{
filter_input[i] = (float) ADC_buffer[i];
}
Listing 3: code snippets for converting and copying DAC data to the filter input buffer.
Lines 1-4 are for the lower half of the buffer, with lines 5-8 for the upper half.
For example, for four coefficients, the
FIR filter output for y[n] is given by:
y[n] = a[0] × x[n]
+
a[1] × x[n-1] +
a[2] × x[n-2] +
a[3] × x[n-3]
There are normally more coefficients,
but I wanted to keep this example simple.
Basic filter code
Coding the filter calculation in basic
form is straightforward. If we assume
that we have the coefficients in a floating-point array called Coefficient
and the input samples in a floating-point
array called filter_input, the code
snippet in Listing 2 performs the calculation for y[n], where n is the index of
the filter_input array corresponding
with the set of input samples (x[]) in the
equation above.
The code is generalised for any number
of coefficients using the #defined value
COEFF_SIZE value. The result is a
floating-point number holding the accumulated summation. After the loop,
the value of Result is the output sample
value for sample n (y[n]).
Practical Electronics | September | 2025
This code would be fine if we had the
whole waveform that we wanted to filter
in the filter_input array, and we were
performing one-off operation – we could
put the code in Listing 2 inside another
loop that runs through the input data
(stepping n in the outer loop) and writing the output to another array.
Things are not so simple for processing
a continuous signal from the DMA buffer,
which will be constantly changing as the
DMA controller writes to it and will not
contain the whole waveform. The mathematical operation is the same, but it takes
a bit more work to get the correct data
from the buffer to the calculation than
using the static array implied by Listing 2.
ADC DMA buffer operation
The filter code shares its basic structure
with the ADC-to-DAC transfer operation in
Fig.3: an
early ADC
buffer
snapshot.
Fig.4: the
first lowerhalf buffer
transfer.
ADC buffer
Listing 1. However, depending on the size
of the buffer relative to the number of coefficients, we may need to access the whole
buffer for one output sample calculation.
This is potentially a problem because
only half the buffer can be regarded as
stable at any time, with the other half
being overwritten by the DMA controller. We also have to convert the integer
data in the ADC buffer to floating-point
values for the calculations. Both problems can be solved by copying the buffer
contents to a separate floating-point array
at the start of the two callback functions.
Listing 3 shows code snippets for the
transfer and conversion of data from the
ADC buffer (ADC_buffer) to the filter
input buffer (filter_input). This code
comprises loops placed at the start of
the relevant callback function, like the
ADC to DAC transfer in Listing 1, with
the index range of the loop depending
on the callback (which half of the buffer
is being copied).
Similarly, the size of the ADC buffer (and
filter input buffer) is defined as BUF_SIZE.
The half-size of the buffer is also defined
as HALF_BUF_SIZE.
The ADC buffer holds 12-bit integer
values directly from the ADC via DMA,
which are converted to floating point
by the type-cast ((float)) in the copy
operation. After the copy, operations on
data in the whole filter input buffer can
be used in filter calculations, unlike the
ADC buffer, where only half the buffer
can be used at one time.
Data movement
To understand the code, it is helpful to
track the movement and usage of data in the
buffers in a small example – for the transfer
and conversion just discussed, and later for
the filter calculation. We will use a small
buffer with 10 elements for the example
(BUF_SIZE = 10, HALF_BUF_SIZE = 5).
Usually, a real implementation would
employ a larger buffer.
Fig.3 shows the state of the buffers after
the first three samples x[0], x[1] and x[2]
have been read into the memory at the
start of processing. In general, sample x[n]
is the latest sample and x[n – 1], x[n – 2]
etc. are earlier samples. At this stage,
the filter input buffer (filter_input)
is empty.
When the buffer is half-full (as in Fig.4),
the HAL_ADC_ConvHalfCpltCallback
function is called. The code in Listing 3
x[0]
x[1]
x[2]
ADC buffer
x[0]
x[1]
x[2]
x[3]
x[4]
Filter input buffer
x[0]
x[1]
x[2]
x[3]
x[4]
Filter input buffer
49
transfers data from the lower half
of the ADC buffer to the filter input
buffer, converting it from integer
to floating point as it does so. The
HAL_ADC_ConvHalfCpltCallback
function also processes data to implement
the filter, but this will be discussed later.
The DMA system continues to fill
the ADC buffer. This happens in parallel with the data transfer, with the
HAL_ADC_ConvHalfCpltCallback
function also performing data processing, because the DMA system is separate
from the processor running the code in
the function. Fig.5 shows a further two
samples in the ADC buffer.
When the buffer is full (see Fig.6), the
HAL_ADC_ConvCpltCallback function is called. This code transfers the
data from the upper half of ADC buffer
to the filter input buffer, converting it
from integer to floating point as it does
so (and performing processing) – it performs the same function as the half full
back, but on the other half of the buffer.
The DMA system writes values from
the ADC to the ADC buffer continuously. When it gets to the end of the buffer,
it returns to the start and overwrites the
old data (circular buffer operation). Fig.7
shows the situation after the first wraparound – samples x[10] and x[11] have
been written to the start of the buffer over
the old data. The filter input buffer continues to hold the older data.
Every time the buffer is half-full, the
HAL_ADC_ConvHalfCpltCallback
function transfers a new batch of data
from the lower half of the ADC buffer
into the corresponding part of the filter
input buffer (see Fig.8).
After the transfer, the filter input buffer
contains the BUF_SIZE most recent samples (10 samples in this case). However,
these are not in time-order through the
whole buffer (as can be seen in Fig.8).
The oldest samples are in the upper half
of the buffer (x[5] to x[9] in the example),
and the most recent samples are in the
lower half (x[10] to x[14] in the example).
This makes processing a range
of sample data within the function
HAL_ADC_ConvHalfCpltCallback
more complex than if they were sequential order.
Every time the buffer is full, the
HAL_ADC_ConvCpltCallback function transfers a new batch of data from
the upper half of the ADC buffer into
the corresponding part of the filter input
buffer (see Fig.9). After the transfer, the
filter input buffer contains the BUF_SIZE
most recent samples (10 samples in this
case) in time order through the buffer.
The fact that the samples are in time
order makes processing a sequence of
samples simpler than in the case for
half-full buffer.
50
Fig.5: more
ADC buffer
data arrives
in the ADC Filter input buffer
buffer.
Fig.6: the
first upper
half buffer
transfer.
x[0]
x[1]
x[2]
x[3]
x[4]
x[5]
x[6]
x[0]
x[1]
x[2]
x[3]
x[4]
ADC buffer
x[0]
x[1]
x[2]
x[3]
x[4]
x[5]
x[6]
x[7]
x[8]
x[9]
Filter input buffer
x[0]
x[1]
x[2]
x[3]
x[4]
x[5]
x[6]
x[7]
x[8]
x[9]
x[10]
x[11]
x[2]
x[3]
x[4]
x[5]
x[6]
x[7]
x[8]
x[9]
x[0]
x[1]
x[2]
x[3]
x[4]
x[5]
x[6]
x[7]
x[8]
x[9]
x[10]
x[11]
x[12]
x[13]
x[14]
x[5]
x[6]
x[7]
x[8]
x[9]
x[10]
x[11]
x[12]
x[13]
x[14]
x[5]
x[6]
x[7]
x[8]
x[9]
ADC buffer
x[10]
x[11]
x[12]
x[13]
x[14]
x[15]
x[16]
x[17]
x[18]
x[19]
Filter input buffer
x[10]
x[11]
x[12]
x[13]
x[14]
x[15]
x[16]
x[17]
x[18]
x[19]
Fig.7: the
ADC buffer
DMA unit
wraps
Filter input buffer
to the start.
ADC buffer
Fig.8: the
second halffull data
Filter input buffer
transfer.
Fig.9: the
second
bufferfull data
transfer.
Calculating y[n]
Index
Fig.10: how
the output
Coefficients
sample y[n]
Index
is calculated
Filter
input
buffer
from
input
samples
DAC buffer
x[n-3] to x[n].
Calculating y[n-1]
Fig.11:
output
y[n-1] is
calculated
from input
samples
x[n-4] to
x[n-1].
0
1
2
3
a[0]
a[1]
a[2]
a[3]
0
1
2
3
4
5
6
7
8
9
x[n-9]
x[n-8]
x[n-7]
x[n-6]
x[n-5]
x[n-4]
x[n-3]
x[n-2]
x[n-1]
x[n]
y[n-9]
y[n-8]
y[n-7]
y[n-6]
y[n-5]
y[n-4]
y[n-3]
y[n-2]
y[n-1]
y[n]
Index
0
1
2
3
Coefficients
a[0]
a[1]
a[2]
a[3]
Index
0
1
2
3
4
5
6
7
8
9
Filter input buffer
x[n-9]
x[n-8]
x[n-7]
x[n-6]
x[n-5]
x[n-4]
x[n-3]
x[n-2]
x[n-1]
x[n]
DAC buffer
y[n-9]
y[n-8]
y[n-7]
y[n-6]
y[n-5]
y[n-4]
y[n-3]
y[n-2]
y[n-1]
y[n]
To perform the FIR filter calculations,
it is necessary to access the correct data
from the filter in buffer and coefficient
arrays. In both callback functions, after
data is transferred to the filter input
buffer, it can be used, along with the coefficients array, to perform the FIR filter
calculations. We will look at the bufferfull case first, because, as just noted, this
is the more straightforward case.
Buffer full callback data
processing
After the transfer of data from the ADC
buffer in the buffer-full callback, the filter
input buffer contains the BUF_SIZE (10
in this case) most recent samples in order
(see Fig.10). The oldest sample (x[n – 9]
in this case) is in FilterInBuffer[0]
(index 0) and the most sample recent in
FilterInBuffer[BUF_SIZE-1] (index
9 in this case).
The FIR filter calculations for output
y[n] require the input values from sample
x[n] back to sample x[n – N + 1], where
N is the number of coefficients. For example, for four coefficients (a[0], a[1],
a[2] and a[3]) the calculation for y[n]
uses x[n], x[n – 1], x[n – 2] and x[n – 3],
as shown in Fig.10. The result is placed
in the DAC buffer ready for the DMA
system to send it to the DAC.
In the buffer-full callback function, it
is necessary to calculate the values for
all the output samples in the upper half
of the DAC buffer (y[n] down to y[n – 4]
in this case). Fig.11 shows the values required to calculate y[n – 1].
This pattern is repeated down to the final
value in the upper half of the DAC buffer
(y[n – 4] in this case), which is calculated
from x[n – 4] down to x[n – 7] – see Fig.12.
Some calculations (such as that for y[n – 4])
require data from the lower half of the
buffer as well as the upper half.
Calculating the value for the bottom
of the upper half of the DAC buffer uses
mainly values from the lower half of
the filter input buffer, specifically the
top COEFF_SIZE-1 values from that
Practical Electronics | September | 2025
Calculating y[n-4]
Fig.12:
output
y[n-4] is
calculated
from input
samples
x[n-7] to
x[n-4].
Index
0
1
2
3
Coefficients
a[0]
a[1]
a[2]
a[3]
Index
0
1
2
3
4
5
6
7
8
9
Filter input buffer
x[n-9]
x[n-8]
x[n-7]
x[n-6]
x[n-5]
x[n-4]
x[n-3]
x[n-2]
x[n-1]
x[n]
DAC buffer
Calculating
y[n]
y[n-9]
y[n-8]
y[n-7]
y[n-6]
y[n-5]
y[n-4]
y[n-3]
y[n-2]
y[n-1]
y[n]
Index
Fig.13:
Coefficients
calculating
the output
Index
sample
Filter input buffer
y[n] in the
half-full buffer
callback. Calculating
DAC y[n-1]
buffer
Index
Fig.14:
Coefficients
calculating
the output
Index
sample
Filter input buffer
y[n-1] in
the halffull buffer
DAC buffer
callback. Calculating y[n-4]
Index
Fig.15:
Coefficients
calculating
the output
Index
sample
Filter input buffer
y[n-4] in
the buffer-full
callback.
DAC buffer
1
2
3
4
5
6
7
8
9
0
1
2
3
a[0]
a[1]
a[2]
a[3]
0
1
2
3
4
5
6
7
8
9
x[n-4]
x[n-3]
x[n-2]
x[n-1]
x[n]
x[n-9]
x[n-8]
x[n-7]
x[n-6]
x[n-5]
y[n-4]
y[n-3]
y[n-2]
y[n-1]
y[n]
y[n-9]
y[n-8]
y[n-7]
y[n-6]
y[n-5]
0
1
2
3
a[0]
a[1]
a[2]
a[3]
0
1
2
3
4
5
6
7
8
9
x[n-4]
x[n-3]
x[n-2]
x[n-1]
x[n]
x[n-9]
x[n-8]
x[n-7]
x[n-6]
x[n-5]
y[n-4]
y[n-3]
y[n-2]
y[n-1]
y[n]
y[n-9]
y[n-8]
y[n-7]
y[n-6]
y[n-5]
0
1
2
3
a[0]
a[1]
a[2]
a[3]
0
1
2
3
4
5
6
7
8
9
x[n-4]
x[n-3]
x[n-2]
x[n-1]
x[n]
x[n-9]
x[n-8]
x[n-7]
x[n-6]
x[n-5]
y[n-4]
y[n-3]
y[n-2]
y[n-1]
y[n]
y[n-9]
y[n-8]
y[n-7]
y[n-6]
y[n-5]
for (int n = HALF_BUF_SIZE; n < BUF_SIZE; n++)
{
Result = 0;
for (int i = 0; i < COEFF_SIZE; i++)
{
Result = Result + Coefficient[i] * filter_input[n-i];
}
DAC_buffer[n] = (uint32_t)Result;
}
Listing 4: the filter calculations for a buffer-full callback.
half. This means that the maximum
number of coefficients we can have is
HALF_BUF_SIZE+1. Otherwise, we
would run off the bottom of the filter
input buffer.
In all cases for the full buffer callback, the input samples (x values) are
in a continuous block in the filter input
buffer. This makes it easy to iterate over
the input values required to calculate a
given output using a single for loop. The
data in the lower half of the filter input
buffer is stable and safe to use as long as
the full callback function completes running before the next half-full callback.
FIR filter code for buffer-full
callback
The buffer-full callback must calculate all the output values in the upper
half of the DAC buffer (indexes from
HALF_BUF_SIZE to BUF_SIZE-1). This
can be achieved with the code from Listing 2, with n running over the required
index range (see Listing 4).
Practical Electronics | September | 2025
After the calculation loop, each value
of Result (y values) is converted to an
integer and stored in the DAC buffer for
output to the DAC via DMA.
For the example buffers shown above
(BUF_SIZE = 10 and COEFF_SIZE = 4),
the code in Listing 4 performs the following FIR filter calculations, where a[i]
is the value at index i in the coefficient
array and b[i] is the value at index i in
the filter input buffer array.
y[n-4] = a[0]b[5] + a[1]b[4] + a[2]b[3] + a[3]b[2]
y[n-3] = a[0]b[6] + a[1]b[5] + a[2]b[4] + a[3]b[3]
y[n-2] = a[0]b[7] + a[1]b[6] + a[2]b[5] + a[3]b[4]
y[n-1] = a[0]b[8] + a[1]b[7] + a[2]b[6] + a[3]b[5]
y[n] = a[0]b[9] + a[1]b[8] + a[2]b[7] + a[3]b[6]
Buffer half-full callback FIR
calculation
Like the buffer-full callback, after the
transfer of data from the ADC buffer in
the half-full callback, the filter input
buffer contains the BUF_SIZE most recent
samples. However, the sample order is
more complex than in the buffer-full case.
Fig.13 shows the situation at a buffer
half-full callback. The filter input buffer
is in a state equivalent to that shown in
Fig.8, with n = 14.
The newest samples are in the first half
of the filter input buffer. The first half ends
with the newest input sample, x[n], in
filter_input[HALF_BUF_SIZE -1]
(index 4 in this case).
The oldest samples are in the second
half of the buffer; the second half
starts with the oldest sample being at
filter_input[HALF_BUF_SIZE]
(index 5 in this case) and runs in sequence to the final sample index at
filter_input[BUF_SIZE-1] (ie,
filter_input[9]). The next newest
sample is at index 0, at the bottom of
the first half.
The FIR filter calculations require the
same data from the filter input buffer as
the buffer-full callback, but the location
of the data in the filter input buffer is
different. Calculation for y[n] uses x[n],
x[n – 1], x[n – 2] and x[n – 3], and the
coefficients, as shown in Fig.13. This
is similar to the example in Fig.10, but
using lower-half data.
In the buffer half-full callback, it is
necessary to calculate the values for all
the output samples in the lower half of
the DAC buffer (x[n] down to x[n – 4] in
this case). The example in Fig.14 shows
the values used to calculate y[n – 1].
This pattern is repeated down to the
final value in the lower half of the DAC
buffer. For example, y[n – 4] is calculated
from x[n – 4] down to x[n – 7] and the
coefficients (see Fig.15). Some calculations (such as that for y[n – 4]) require
data from the upper half of the filter input
buffer as well as the lower half.
The data values used are the same
as for the buffer full callback, but finding the correct index to access the data
in the buffer is more complex because
the data is in two separate blocks in the
lower and upper halves.
In a similar way to the buffer full case,
data in the upper half of the filter input
buffer is stable and safe to use as long as
the half-full callback function completes
running before the next full callback. The
maximum number of coefficients is the
same for this case.
FIR filter code for buffer half-full
callback
The mathematics for the FIR filter calculation in the half-full callback function
is the same as for the full callback function, so the FIR filter calculation is the
same as the full buffer case (line 6 in
Listing 4). However, the code is more
complex because the buffer indices of
the data do not always form a continuous block. If the data is in two groups
51
(as in the y[n – 4] example above), two
loops are required.
The input buffer index is n – i for a
calculation of y[n] where i is the coefficient index. Therefore, if n – i is
greater than zero for the largest value
of i (the last value in the coefficient
loop), the set of input sample values
(x) used in the calculation will all be
in a continuous block in the lower
half of the buffer, so the same loop
code as the buffer-full case can be used
(Listing 4, lines 3 to 7).
The largest value of i is N – 1 (for N
coefficients), so the lowest index in a
potentially continuous block of sample
data is n – N + 1. If this index is zero or
above, then a simple loop can be used.
The code in Listing 5 finds the lowest
index (the integer Lowest_index in the
code, see line 2), and if this is greater than
or equal to zero, runs the calculation for
y[n] in the same way as the buffer-full
callback case.
If the value of Lowest_index calculated above is less than zero, some of
the input samples will be in the upper
half of the buffer. Two loops need to
be used, one to iterate over the values
in the lower half and the other for the
upper half (this is shown in Listing 6,
which is the “else” part of the “if” in
Listing 5).
The first loop needs to take the value
of the coefficient index (loop counter
i) from 0 to n (for y[n]), so the input
sample index n – i ranges down to
1
2
3
4
5
6
7
8
9
zero (see Listing 6, lines 3 to 6). The
value of i reached by this first loop
is used to determine the range of the
second loop.
The number of samples covered by
the second loop is equal to the number
of coefficients remaining after the first
loop. As n coefficients were covered in
the first loop, this number is N – n. In the
code, the integer Remaining_Samples
is used to hold this value and hence control iterations of the second loop (see
Listing 6, line 7).
The filter input sample values required by the second loop start at the
top of the filter input buffer (at index
BUFF_SIZE – 1) and range down to
BUFF_SIZE - Remaining_Samples.
Thus, the loop index (i) needs to run
from 1 to Remaining_Samples, and
the filter input buffer can be indexed
using BUFF_SIZE - i (see Listing 7,
lines 8 to 11).
At the same time, the coefficient index
has to range from n + 1 (the last coefficient index in the first loop was n) to N
– 1. In the code, we use a separate integer
in the loop (c), initialised to n + 1, and
incremented on each iteration, to index
the coefficients array.
After the calculation loop(s) (whichever is used), each value of Result
(output value y) is converted to an integer and stored in the DAC buffer for
output to the DAC via DMA. This is
the same as the buffer-full callback
(Listing 4, line 8).
Result = 0;
Lowest_index = n - COEFF_SIZE + 1;
if (Lowest_index >= 0)
{
for (int i = 0; i < COEFF_SIZE; i++)
{
Result = Result + Coefficient[i] * filter_input[n-i];
}
}
Listing 5: the single loop used when all necessary samples are in a continuous block.
1
2
3
4
5
6
7
8
9
for (int i = 0; i <= n; i++)
{
Result = Result + Coefficient[i] * filter_input[n-i];
}
Remaining_Samples = COEFF_SIZE - n;
for (int i = 1, c = n+1; i <= Remaining_Samples; i++, c++)
{
Result = Result + Coefficient[c] * filter_input[BUF_SIZE - i];
}
Listing 6: two loops used when the required samples are not continuous.
3.3V
Fig.16: the input
protection circuit
I added to the
microcontroller board
for testing the filter
implementation.
Input
R3
100Ω
In
C1
1µF
D1
BAT54
R1
100kΩ
Arduino_A5 to ADC
D2
BAT54
R2
100kΩ
GND
52
For the example buffers shown above,
the code performs the following FIR filter
calculations; the notation is as in the previous example:
y[n-4] = a[0]x[0] + a[1]x[9] + a[2]x[8] + a[3]x[7]
y[n-3] = a[0]x[1] + a[1]x[0] + a[2]x[9] + a[3]x[8]
y[n-2] = a[0]x[2] + a[1]x[1] + a[2]x[0] + a[3]x[9]
y[n-1] = a[0]x[3] + a[1]x[2] + a[2]x[1] + a[3]x[0]
y[n] = a[0]x[4] + a[1]x[3] + a[2]x[2] + a[3]x[1]
The first three of these require the two
loops; the last two use the single loop.
The majority of code for the filtering
operation is shown in Listing 7. This
includes the #defines of the buffer and
coefficient array sizes (lines 34 to 36); the
coefficient array initialisation (lines 68
to 79); declaration of the variables used
in the filter calculations (lines 81 to 86);
and the callback functions (lines 872 to
934), which include the snippets in Listings 3 to 6 discussed above.
The callbacks also control a timing
pulse, as in previous examples. Listing
7 does not include the DMA and timer
initialising in the User Code 2 section,
as this is the same as the example last
month.
Implementation details
The filter was implemented using a
new STM32CubeIDE project configured
in the same way as the ADC-to-DAC
DMA example from last month. An example filter was configured with a 1kHz
low-pass response using 37 coefficients
and sampling at 48kHz. The filter coefficients were obtained using the online
tool at fiiir.com (this site was introduced
in the May issue).
Last month, we discussed the ADC
input protection and DC shift circuit
with reference to LTspice simulations
that included parasitic capacitance and
a model of the ADC input. Fig.16 shows
the circuit I constructed, rather than the
model. I built it on an Arduino Uno Prototyping Shield (TSX00083).
The B-L475E-IOT01A development
board for the microcontroller provides
Arduino Uno V3 connectivity, so can
conveniently be used with Arduino
shield boards.
The Prototyping Shield has an array of
through-hole solder pads for mounting
components. Unlike stripboard, these
are not connected in rows, so the component leads and interconnect wires
are soldered together (after suitable
bending and cutting) on the underside
of the board.
The input to the protection circuit is
provided by a 3-way pin header (the
third pin provides for monitoring/measuring the signal, if required). A 2-way
pin header was connected to the row
of solder pads connected to ground
Practical Electronics | September | 2025
33
34
35
36
37
…
66
67
68
69
…
78
79
80
81
82
83
84
85
86
87
88
89
90
…
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
/* USER CODE BEGIN PD */
#define BUF_SIZE 76
#define HALF_BUF_SIZE 38
#define COEFF_SIZE 37 // Must be smaller than HALF_BUF_SIZE+2
/* USER CODE END PD */
Listing 7: the full code for the FIR
filter parts of the project.
/* USER CODE BEGIN PV */
// FIR filter coefficients, fc =1 kHz, fs = 48 kHz, rectangular window
const float Coefficient[COEFF_SIZE] = {
0.011167887305860052, 0.013267107833265871, 0.015387539289897745, 0.017509857539699709,
…
0.011167887305860052
};
float Result;
// Fir Filter output value
int Lowest_index;
// Used for buffer index tracking in DMA callback
int Remaining_Samples;
// Used for buffer index tracking in DMA callback
uint32_t ADC_buffer[BUF_SIZE]; // DMA ADC input buffer
uint32_t DAC_buffer[BUF_SIZE]; // DMA DAC output buffer
float filter_input[BUF_SIZE]; // input buffer for FIR calculations
uint32_t Prescaler = 1;
// Timer prescaler setting
float Period;
// Timer period in us
float Frequency = 48.0;
// Sample frequency in kHz
/* USER CODE END PV */
/* USER CODE BEGIN 4 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc)
{
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_SET); // Timing monitoring pulse on
// Copy DMA data to filter input buffer
for (int i = 0; i < HALF_BUF_SIZE; i++)
{
filter_input[i] = (float) ADC_buffer[i];
}
// Calculate FIR
for (int n = 0; n < HALF_BUF_SIZE; n++)
{
Result = 0;
Lowest_index = n - COEFF_SIZE + 1;
if (Lowest_index >= 0)
{
// All samples in contiguous block in lower half of buffer - simple loop
for (int i = 0; i < COEFF_SIZE; i++)
{
Result = Result + Coefficient[i] * filter_input[n-i];
}
} else {
// Samples not contiguous - need two loops
// First loop in lower half of buffer (index down to zero)
for (int i = 0; i <= n; i++)
{
Result = Result + Coefficient[i] * filter_input[n-i];
}
// Second loop in upper half of buffer (working back from the top)
Remaining_Samples = COEFF_SIZE - n; // Total samples minus number already done
for (int i = 1, c = n + 1; i <= Remaining_Samples; i++, c++)
{
Result = Result + Coefficient[c] * filter_input[BUF_SIZE - i];
}
}
// Copy result to DAC DMA buffer
DAC_buffer[n] = (uint32_t)Result;
}
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_RESET); // Timing monitoring pulse off
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
{
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_SET); // Timing monitoring pulse on
// Copy DMA data to filter input buffer
for (int i= HALF_BUF_SIZE; i < BUF_SIZE; i++)
{
filter_input[i] = (float) ADC_buffer[i];
}
// Calculate FIR
for (int n= HALF_BUF_SIZE; n < BUF_SIZE; n++)
{
Result = 0;
for (int i = 0; i < COEFF_SIZE; i++)
{
Result = Result + Coefficient[i] * filter_input[n-i];
}
// Copy result to DAC DMA buffer
DAC_buffer[n] = (uint32_t)Result;
}
HAL_GPIO_WritePin(GPIOB, ARD_D8_Pin, GPIO_PIN_RESET); // Timing monitoring pulse off
}
/* USER CODE END 4 */
Practical Electronics | September | 2025
53
www.poscope.com/epe
Fig.18: the 500Hz, 3.15V peak-to-peak input signal (blue) and the output signal (red).
Fig.19: the 1250Hz, 3.15V peak-to-peak input signal (blue) and output signal (red).
(visible on the right edge of the Prototyping Shield in Fig.17). This is useful for
connecting test instrument ground clips.
The BAT54 schottky diodes used in
the protection circuit are available in a
few formats, including single and dualdiode configurations in different SMD
packages (eg, SOT-23, SC-70 and SOD323). I chose the single diode BAT54WS-G
variant as it is the best fit to the 1.27mm
(0.05in/50 thou) pitch SMD solder pads
on the Prototyping Shield (these are intended for an SOIC integrated circuit).
As inputs containing frequencies above
the Nyquist rate were not going to be used
for testing, no anti-aliasing filter was provided at the input. A simple first-order
RC low-pass reconstruction filter with R
= 8.2kΩ and C = 8.2nF was used on the
DAC output. This has a cutoff frequency
of 2.4kHz (1 ÷ 2πRC).
At the Nyquist rate of 24kHz for the
example digital filter, the RC filter has an
attenuation of about 20dB (⅒th), which
is sufficient for testing with sinewave
inputs and a filter cutoff of 1kHz.
The filter was tested by applying
sinewaves at close to the maximum
amplitude (3.15V peak-to-peak, just
within the ADC range of 3.3V). This
confirmed that the gain dropped at frequencies above 1kHz. Two examples
are shown in Figs.18 & 19, which are
for 500Hz and 1250Hz, respectively.
Ideally, we need to plot the frequency
response. This could be done manually by
measuring the gain a various frequencies
using measurements, such as in Figs.18 &
19, but this would be a tedious process.
Next month, we will look at automated
frequency response measurement. PE
DAC output (D13)
Timing pulse (D8)
Input
Ground
pins
BAT54 diodes
- USB
- Ethernet
- Web server
- Modbus
- CNC (Mach3/4)
- IO
- PWM
- Encoders
- LCD
- Analog inputs
- Compact PLC
- up to 256
- up to 32
microsteps
microsteps
- 50 V / 6 A
- 30 V / 2.5 A
- USB configuration
- Isolated
PoScope Mega1+
PoScope Mega50
ADC
input
(A5)
Fig.17: the development board with the input
protection circuit added on a prototyping shield.
54
- up to 50MS/s
- resolution up to 12bit
- Lowest power consumption
- Smallest and lightest
- 7 in 1: Oscilloscope, FFT, X/Y,
Recorder, Logic Analyzer, Protocol
decoder, Signal generator
Practical Electronics | September | 2025
|