一、前言背景
由于STM32H7的教程较少,以及LL库的教程较少,笔者就将自己的一些微薄见解发表于此,还望网友多多包涵。本笔记也参考了不少资料和文章,笔者只是拾人牙慧的小屁孩。
如果不使用DDS模块,仅通过修改定时器预分频系数(PSC)和自动重装载值(ARR)改变输出波表频率,想实现更加精确的频率步进确实很有限制。在了解STM32的双缓冲机制之后,决定使用DMA双缓冲搭配DDS算法的算法来实现波形发生。实现效果不仅波形好看,频率也相对准确。
二、原理概述
关于双缓冲的机制原理,可以到STM32H7解决DMA伪双缓存中的出现Cache问题,有笔者的相关介绍。
这里着重说一下DDS的原理概述:
DDS(Direct Digital Synthesizer)即数字合成器,是一种新型的频率合成技术,用于产生周期性的波形。到目前,从低频到上百MHz的正弦波、三角波产生等,绝大多数都采用DDS芯片完成,甚至可以说,真正实用的波形发生器,包括我们能买到的信号源,都是采用DDS实现的。
(一)频率控制
先假设DDS有一个固定的时钟ClkFreq,为36MHz。
然后再有一个正弦的“相位—幅度”的表,而且有非常细密的相位步长,就比如0.01°,对应也就是一个完整周期需要36000个点。
相位 | 幅度 |
---|---|
0.00 | 0.0000000 |
0.01 | 0.0001745 |
0.02 | 0.0003491 |
0.03 | 0.0005236 |
… | … |
359.98 | -0.000349 |
359.99 | -0.000175 |
我们再用10位DAC来描述正弦波,也就是把刚刚的“相位—幅度”进行量化:
数组下标 | 相位 | 幅度 | 10位DAC的值 |
---|---|---|---|
0 | 0.00 | 0.0000000 | 512 |
1 | 0.01 | 0.0001745 | 512 |
2 | 0.02 | 0.0003491 | 512 |
3 | 0.03 | 0.0005236 | 512 |
… | … | … | … |
11 | 0.11 | 0.0019199 | 512 |
12 | 0.12 | 0.0020944 | 513 |
… | … | … | … |
35998 | 359.98 | -0.000349 | 512 |
35999 | 359.99 | -0.000175 | 512 |
其中也就是把
s
i
n
(
0
°
)
sin(0°)
sin(0°)作为DAC的10位范围的中心值
2
10
/
2
=
512
2^{10}/2=512
210/2=512,而
s
i
n
(
90
°
)
sin(90°)
sin(90°)就是最大值
2
10
−
1
=
1023
2^{10}-1=1023
210−1=1023,而
s
i
n
(
270
°
)
sin(270°)
sin(270°)就应为最小值
0
0
0。
就马上要有同学问了,DAC如果仅有10位的话,波表作这么细,很多DAC值都是重复的,这样有什么意义呢?
这就是DDS频率控制的核心。
现在假设步进为M=1,则DAC以ClkFreq的频率为节拍,每经过1/ClkFreq的时间,便输出对应的DAC值。比如,时间为1/ClkFreq时,输出对应DAC值512;时间为2/ClkFreq时,输出对应DAC值512…可以想象在36000/ClkFreq的时间之后,输出了整个波表。当然,此表首尾衔接,之后便是又一个循环的开始,如此往复。
故,输出波形的频率为:
f
O
U
T
=
1
T
C
l
k
F
r
e
q
×
N
m
a
x
=
C
l
k
F
r
e
q
N
m
a
x
=
36
×
1
0
6
36000
=
1000
H
z
f_{OUT} = \frac{1}{T_{ClkFreq} \times N_{max} } = \frac{ClkFreq}{ N_{max} } = \frac{36 \times 10^{6}}{36000} = 1000 Hz
fOUT=TClkFreq×Nmax1=NmaxClkFreq=3600036×106=1000Hz
我们考虑将步进M = 2,则每经过1/ClkFreq,便输出对应的DAC值,只不过,每次读表时,要跳着读,每次前进2个单位进行读表,并对应输出。比如,时间为1/ClkFreq时,对应数组下标为0,输出对应DAC值512;时间为2/ClkFreq时,对应数组下标为2,输出对应DAC值512…可以想象在(36000/2)/ClkFreq的时间之后,输出了整个波表。也就是说M越大间隔越大,则就能在更短的时间内输出整个波表。
而且波表作这么细,损失这些点依旧能完整还原波形。
如果把步进叫做频率控制字
F
w
o
r
d
F_{word}
Fword(因为它的数值大小能控制输出信号的频率大小),那么完整的公式就是:
f
O
U
T
=
1
T
C
l
k
F
r
e
q
×
N
m
a
x
F
w
o
r
d
=
C
l
k
F
r
e
q
×
F
w
o
r
d
N
m
a
x
=
36
×
1
0
6
×
2
36000
=
2000
H
z
f_{OUT} = \frac{1}{T_{ClkFreq} \times \frac{ N_{max}}{F_{word}} } = \frac{ClkFreq \times F_{word}}{ N_{max} } = \frac{36 \times 10^{6} \times 2}{36000} = 2000 Hz
fOUT=TClkFreq×FwordNmax1=NmaxClkFreq×Fword=3600036×106×2=2000Hz
所以一个DDS能提供的最小分辨率来源于
Δ
F
w
o
r
d
\Delta\ F_{word}
Δ Fword的最小变化量
1
1
1,对应的频率变换
Δ
f
O
U
T
\Delta\ f_{OUT}
Δ fOUT:
Δ
f
O
U
T
=
C
l
k
F
r
e
q
×
Δ
F
w
o
r
d
N
m
a
x
=
C
l
k
F
r
e
q
N
m
a
x
\Delta f_{OUT} = \frac{ClkFreq \times \Delta F_{word}}{ N_{max} } = \frac{ClkFreq}{ N_{max} }
ΔfOUT=NmaxClkFreq×ΔFword=NmaxClkFreq
波表作这么细,还有一个优点就是样点总数 N m a x N_{max} Nmax除以 F w o r d F_{word} Fword是可以为不等于整数的。因为波表的细密,当样点总数 N m a x N_{max} Nmax除以 F w o r d F_{word} Fword不为整数时,将以一个新的点进行下一个周期而不一定是从 s i n ( 0 ° ) sin(0°) sin(0°),但 s i n ( 0.39 ° ) sin(0.39°) sin(0.39°)或者 s i n ( 0 ° ) sin(0°) sin(0°)的效果是看不出来的,并且也是满足首尾衔接的。这丝毫不会影响到总体频率上的呈现。
(二)相位控制
对于相位的调整,则非常简单了。只需要在每个取样点的序号上加上一个偏移量,便可实现相位的控制。比如,上面默认的是第1/ClkFreq时输出数组下标为0的数据,那么我们现在在第1/ClkFreq时输出数组下标为0 +
P
w
o
r
d
P_{word}
Pword的数据,则将相位左移了
P
w
o
r
d
N
m
a
x
×
360
°
\frac{P_{word}}{N_{max}} \times 360°
NmaxPword×360°度,这就是控制相位的原理。
这里的
P
w
o
r
d
P_{word}
Pword数值大小控制输出信号的相位偏移,主要用于相位的信号调制,我们叫做相位控制字。
三、程序实现
这里仅呈现相关核心代码,具体完整过程可以见末尾仓库。
配置相关的STM32CubeMX
定义相关变量:
DAC* pdac;
uint64_t clkFreq;
uint32_t F_WORD = 1; //频率控制字
uint32_t P_WORD = 0; //相位控制字
uint32_t ROM_Index = 0;
uint16_t* pROM;
uint32_t ROM_Num;
并给出接口函数
bool Set_WaveROM(const uint16_t* Buffer, uint32_t Num);
inline void Set_ClkFreq(uint64_t ClkFreq);
inline void Set_FWORD(uint32_t FWORD);
inline void Set_PWORD(uint32_t PWORD);
void Irq_DMA(void);
我们使能半满中断和全满中断:
/* DMA地址配置 */
LL_DMA_SetPeriphAddress(dma, dma_stream , LL_DAC_DMA_GetRegAddr(dac, channel, LL_DAC_DMA_REG_DATA_12BITS_RIGHT_ALIGNED));
LL_DAC_EnableDMAReq(dac, channel);
LL_DMA_SetMemoryAddress(dma, dma_stream, (uint32_t)(dac_dma_buffer.Data));
LL_DMA_SetDataLength(dma, dma_stream, dac_dma_buffer.Size);
/* DMA中断配置 */
Platform_DMA_ClearFlag_HT(dma, dma_stream);
Platform_DMA_ClearFlag_TC(dma, dma_stream);
LL_DMA_EnableIT_HT(dma, dma_stream);
LL_DMA_EnableIT_TC(dma, dma_stream);
/* DMA使能 */
LL_DMA_EnableStream(dma, dma_stream);
并编写最核心的void Irq_DMA(void)
:
void DDS::Irq_DMA(void) {
// 完成中断,处理ROM数组后半段
if(LL_DMA_IsEnabledIT_TC(pdac->dma, pdac->dma_stream) && PLATFORM_DMA_IsActiveFlag_TC(pdac->dma, pdac->dma_stream))
{
for(uint32_t i = 0; i < (pdac->dac_dma_buffer.Size / 2); i++)
{
pdac->dac_dma_buffer.Data[ROM_Index] = pROM[P_WORD];
ROM_Index++;
P_WORD+=F_WORD;
if (P_WORD >= ROM_Num) //仅溢出后才进行取模运算,优化时间
P_WORD %= ROM_Num;
if (ROM_Index == pdac->dac_dma_buffer.Size)
ROM_Index = 0;
}
Platform_DMA_ClearFlag_TC(pdac->dma, pdac->dma_stream);
}
// 半完成中断,处理ROM数组前半段
if(LL_DMA_IsEnabledIT_HT(pdac->dma, pdac->dma_stream) && PLATFORM_DMA_IsActiveFlag_HT(pdac->dma, pdac->dma_stream))
{
for(uint32_t i = 0; i < (pdac->dac_dma_buffer.Size / 2); i++)
{
pdac->dac_dma_buffer.Data[ROM_Index] = pROM[P_WORD];
ROM_Index++;
P_WORD+=F_WORD;
if (P_WORD >= ROM_Num)
P_WORD %= ROM_Num;
if (ROM_Index == pdac->dac_dma_buffer.Size)
ROM_Index = 0;
}
Platform_DMA_ClearFlag_HT(pdac->dma, pdac->dma_stream);
}
}
这里附上必要的解释:
首先,dac_dma_buffer.Data
为DMA传输的内存地址,在半满中断时对前半段写值,在完成中断时对后半段写值,实现双缓存高速刷新。(可能存在的问题为DMA传输时间过快,ROM数据过多,以导致未正确刷新完本次的dac_dma_buffer.Data
而波形失真)
其次,这里的设计为环形赋值:
举个例子来说
- DAC的DMA缓冲区只有3个数据
- 波表有8个数据
- 步进为2
它会自己形成一个周期函数,一个周期内实际DAC打点数取决于执行P_WORD %= ROM_Num;
之前的总循环次数。按照定时器+DAC+DMA的方式,输出波形频率等于定时器触发频率除以总点数,当然,我们也可以迁移同样的知识,执行P_WORD %= ROM_Num;
之前的总循环次数,也就是波表的总数除以步进,从上面的例子也可以看出
8
/
2
=
4
8/2 = 4
8/2=4次,换言之,就是一个周期实际打了4个点,所以它的输出频率:
f
O
U
T
=
C
l
k
F
r
e
q
×
F
w
o
r
d
N
m
a
x
=
C
l
k
F
r
e
q
×
2
8
=
C
l
k
F
r
e
q
4
f_{OUT} = \frac{ClkFreq \times F_{word}}{ N_{max} } = \frac{ClkFreq \times 2}{ 8 } = \frac{ClkFreq}{ 4 }
fOUT=NmaxClkFreq×Fword=8ClkFreq×2=4ClkFreq
我们发现,输出波形频率等于定时器触发频率除以总点数,也就是DDS的输出频率公式的核心。
最后再聊聊核心函数里一些看上去很笨的写法:
P_WORD+=F_WORD;
if (P_WORD >= ROM_Num)
P_WORD %= ROM_Num;
为什么不换成
P_WORD = (P_WORD + F_WORD) % ROM_Num;
因为这个中断相当看重时间效率,每次取模花费的时间已经严重影响到输出波形了。(实战检验)
使用案例
实例化DDS对象
编写Matlab脚本用于生成const
数组(const
数组会被放入Flash区,Flash区相对空间较大;当然,也可以指定放到RAM,加快运行效率,看具体情况取舍吧):
% 可更改的参数
num_points = 4096; % 点数
amplitude_mv = 3.0; % 振幅(伏)
% 生成正弦波数组
theta = linspace(0, 2*pi, num_points + 1);
U = amplitude_mv / 2;
scaled_sinusoidal_wave = round((U * sin(theta) + U) * 4095 / 3.3) + 300;
% 宏定义表示点数
fprintf('#ifndef DDS_ROM_WAVE_SIZE\n');
fprintf('#define DDS_ROM_WAVE_SIZE (%d)\n', num_points);
fprintf('#endif\n\n');
% 打印数组数据
fid = fopen('../Platform/Platform-lib/DSP/Waveform_Table/SinWave.h', 'w');
fprintf(fid, '#ifndef DDS_ROM_WAVE_SIZE\n');
fprintf(fid, '#define DDS_ROM_WAVE_SIZE (%d)\n', num_points);
fprintf(fid, '#endif\n\n');
fprintf(fid, 'const uint16_t SinWave_ROM[DDS_ROM_WAVE_SIZE] = {\n');
fprintf(fid, ' %d', scaled_sinusoidal_wave(1));
for i = 2:num_points
fprintf(fid, ', %d', scaled_sinusoidal_wave(i));
if mod(i, 10) == 0 % 每十个数换一行,可根据需要调整
fprintf(fid, '\n');
end
end
fprintf(fid, '};\n');
fclose(fid);
% 绘制图形
figure;
plot(scaled_sinusoidal_wave);
xlabel('数组下标');
ylabel('DAC量化值');
title('SinWave ROM');
static void DDS_WaveStart(void)
{
uint64_t clkFreq = 409600; //时钟
uint32_t FWORD = 10; //频率控制字
dds.Set_WaveROM(SinWave_ROM, DDS_ROM_WAVE_SIZE);
cout << "DDS分辨率为" << (double )(clkFreq)/DDS_ROM_WAVE_SIZE
<< "Hz \nF_out" << (double )(clkFreq * FWORD)/DDS_ROM_WAVE_SIZE <<"Hz \n";
dds.Set_ClkFreq(clkFreq);
dds.Set_FWORD(FWORD);
dds.Start();
}
串口输出结果为
DDS分辨率为100.000Hz
F_out1000.000Hz
实际效果为
附上存在RAM里的版本:
void generateSineWaveTable(uint32_t Num, uint16_t* Buffer, float Amplitude) //正弦波生成波表代码
{
Num++; // Increment to include the last point
float U = Amplitude / 2.0;
for (uint32_t i = 0; i < Num - 1; ++i)
{
Buffer[i] = (uint16_t)((U*sin((1.0 * i / (Num - 1)) * 2 * 3.1415926) + U) * 4095 / 3.3)+300;
}
}
#define DDS_ROM_WAVE_SIZE (262144)
uint16_t SinWave_ROM[DDS_ROM_WAVE_SIZE];
static void DDS_WaveStart(void)
{
generateSineWaveTable(DDS_ROM_WAVE_SIZE, SinWave_ROM, 3);
DDS_Config(dds, SinWave_ROM, DDS_ROM_WAVE_SIZE, 40000, 0).Start();
}
这个DDS_Config
其目的是想根据不同输出频率而变换DDS主频以提高分辨率:
DDS& DDS_Config(DDS& dds, const uint16_t* Buffer, uint32_t Num, uint32_t Freq, float Phase)
{
uint64_t clkFreq;
uint32_t FWORD;
if (Freq <= xxx)
clkFreq = yyy;
else if (Freq <= xxx)
clkFreq = yyy;
else if (Freq <= xxx)
clkFreq = yyy;
else
clkFreq = yyy;
FWORD = (double )Freq * Num / clkFreq;
dds.Set_WaveROM(Buffer, Num);
dds.Set_ClkFreq(clkFreq);
dds.Set_FWORD(FWORD);
dds.Set_PWORD((double )Phase/360 * Num);
return dds;
}
当然,STM32实现的DDS其最大的缺陷和定时器直接触发DMA的DAC打点类似,当DDS主频高上去之后,定时器设置DDS的主频就很难准确,同样频率也会精度下降,而且也受限片上DAC转换时间。
完整工程可见
Github: 笔者的STM32 C++库 持续开发中
参考资料
- STM32F407学习之DMA双缓冲模式HAL库实现
- 【边学边记_11】——DDS基本原理与FPGA实现
- 《新概念模拟电路5——源电路-信号源和电源》,作者:杨建国