🖼 封面图摘自于该站点,版权归原作者所有,使用仅作学习展示用途。如有侵权,请联系我删除。
前言
最近做了电赛培训的一个小任务,其中有要求制作一个信号发生装置,要求能够产生不同频率和幅值的正弦波、三角波。矩形波。笔者做完之后感觉是很基础并且很有用的模块,遂在此分享一些经验。
DDS
首先介绍一下 DDS 。DDS(Direct Digital Synthesis,直接频率合成)是一种通过数字信号处理技术产生精确频率信号的方法。它常用于信号发生器中,能够提供广泛的频率范围、精确的频率控制、低相位噪声和高频率稳定性。DDS 广泛应用于通信、测试设备、雷达、电子测量等领域。
DDS 通过以下几个主要步骤生成一个高精度、可调频率的正弦波信号:
-
频率控制字(FCW)
通过频率控制字(FCW, Frequency Control Word)来确定输出信号的频率。频率控制字是一个与所需输出频率成比例的数字量。 -
相位累加器
相位累加器(Phase Accumulator)是 DDS 的核心部分,负责累加频率控制字,随着时间的推移,产生与时间成比例的相位信号。 -
查找表(LUT)
相位累加器的输出作为索引输入到查找表中,查找表存储了波形的离散数值(例如正弦波或方波)。根据相位值,查找表输出一个对应的波形数值。 -
数字到模拟转换(DAC)
查找表的输出值通过数字到模拟转换器(DAC)转化为模拟信号,最终得到所需的输出波形。
DDS 的核心思想就是在芯片中存储一个超长的波表,例如一个正弦波的相位-幅度表,且波表要有足够细密的步长。假设波表总数为 $N$ ,时钟频率为 $f_{MCLK}$ ,计数步长为 $m$,那么输出的信号频率为
$$ f_{out} = \frac{1}{\frac{1}{f_{MCLK}} \times \frac{N}{m}} $$可以看到在时钟频率和波表长度不变的情况下信号频率的分辨率与计数步长直接相关。一般 DDS 芯片中的波表都很长,且相位累加器的计数值极大,所以可以实现很高的分辨率。
STM32实现
显然STM32没有办法存储巨量的数据,我们只能完成一个简易版本的信号发生器,但是经过笔者实测,发生的信号质量其实不差。
输出信号要使用到 DAC 外设以及 DMA 数据传输,这些网上有很多教程且非常详细,笔者不在此赘述。配置好 DAC 和 DMA 之后,就可以开始编写我们的 DDS 信号发生程序了。
首先为了方便封装与调用,笔者声明了一个结构体用来存放当前发生的信号的参数
// DDS Type Define
typedef struct
{
uint8_t waveType; // 波形
uint32_t freq; // 频率
float amp; // 幅值
float duty; // 占空比,取值0~1
float offset; // 直流偏置电压
} DDS_TypeDef;
其次是一些宏定义和变量声明
#define LUT_LENGTH (uint32_t)256 // 查找表长度,即一个周期采样点
#define DAC_MAX_AMP (uint32_t)4095 // DAC寄存器写入最大值
#define DDS_MAX_AMP (float)3.30f // DDS输出最大幅值
#define NULL_DUTY 1 // 无占空比标志
#define TIM_INITIAL_CLK 168000000 // 定时器时钟
// Wave types listed below
enum
{
SINE_WAVE = 0, // 正弦波
SQUARE_WAVE = 1, // 方波
TRIANGLE_WAVE = 2, // 三角波
RECT_WAVE = 3, // 矩形波
};
在此暂时定义了查找表长度为 256,当然查找表长度越长,信号就越精确,但是同时要考虑到存储占用。我们不可能直接在程序中存几个很长的波表,所以只能在用的时候现算现用,因此还要考虑计算波表时占用 CPU 运行的时间。
接着生成查找表
void getNewWaveLUT(uint32_t length, uint32_t freq, float amplitude, uint8_t type, float duty, float offset)
{
switch(type){
// 正弦波
case SINE_WAVE:
{
float sin_step = 2.0f * 3.14159f / (float)(length-1);
for (uint16_t i = 0; i < length; ++i)
{
DDS_lut[i] = (uint16_t) (DAC_MAX_AMP * ((float)amplitude * (sinf(sin_step * (float)i) + 1) / 2) / (float)DDS_MAX_AMP);
}
break;
}
// 方波
case SQUARE_WAVE:
{
for(uint16_t i = 0; i < length / 2; ++i)
{
DDS_lut[i] = DAC_MAX_AMP * amplitude / DDS_MAX_AMP;
DDS_lut[i + (length / 2)] = 0;
}
break;
}
// 矩形波
case RECT_WAVE:
{
uint16_t flag = length * duty;
for(uint16_t i = 0; i < flag; ++i)
{
DDS_lut[i] = DAC_MAX_AMP * amplitude / DDS_MAX_AMP;
}
for(uint16_t i = flag; i < length; ++i)
{
DDS_lut[i] = 0;
}
break;
}
// 三角波
case TRIANGLE_WAVE:
{
uint16_t flag = length * duty;
uint16_t tri_step_1 = DAC_MAX_AMP * amplitude / DDS_MAX_AMP / flag;
uint16_t tri_step_2 = DAC_MAX_AMP * amplitude / DDS_MAX_AMP / (length - flag);
for(uint16_t i = 0; i < flag; ++i)
{
DDS_lut[i] = tri_step_1 * i;
}
for(uint16_t i = flag; i < length; ++i)
{
DDS_lut[i] = DDS_lut[flag - 1] - tri_step_2 * (i - flag);
}
break;
}
default: break;
}
}
这里要注意 STM32 的 DAC 模块只能输出 0~3.3V 的电压,所以我们要把信号的电压抬升到 0V 以上。同时 DAC 在0 和 3.3 两个端点处的输出有较大误差,所以导致信号波形失真,这个问题可以通过继续增加直流偏置电压,将信号最低值抬升至 0.2V 以上,即可避免信号失真,同时要控制信号幅度,最大值最好不要超过 3.0V 或 3.1V。
void setOffset(float offset)
{
uint16_t temp = offset / DDS_MAX_AMP * DAC_MAX_AMP;
for(uint16_t i = 0; i < LUT_LENGTH; ++i)
{
DDS_lut[i] += temp;
}
}
因为我们是现算现用,所以查找表中已经存储了信号的幅值、占空比等参数,接下来我们要配置输出信号的频率。采用定时器触发 DAC 输出转换,DAC 转换的时间非常短,可以忽略不计,那么信号的频率就与定时器的频率息息相关。事实上我们假设定时器频率为 $f_{CLK}$ ,查找表大小为 $length$ ,定时器每触发一次,从查找表中输出一个值,遍历完整个查找表后就生成了一个信号的完整周期,让 DMA 循环传输即可实现连续波形,此时信号的频率为
$$ f_{signal} = \frac{f_{CLK}}{length} $$我们现在想输入确定的信号频率,让时钟频率跟随改到相应的值,那么可以通过修改 ARR 和 PSC 实现,即
$$ f_{CLK} = \frac{f_{PCLK}}{(ARR + 1)(PSC + 1)}\\
f_{signal} = \frac{f_{PCLK}}{(ARR + 1)(PSC + 1)length} $$ 为了方便实现,我们可以固定 ARR 和 PSC 中的一个值,计算出另一个值,具体代码如下
// 设定频率,修改定时器寄存器参数,使arr值都稳定在三位数以上,减少误差
if (freq >= 1 && freq <= 100)
{
// 频率为1~100Hz,arr为312~31250
TIM8 -> PSC = 42 - 1;
TIM8 -> ARR = 2 * TIM_INITIAL_CLK / LUT_LENGTH / (TIM8 -> PSC + 1) / freq - 1;
}
else if (freq > 100 && freq <= 1000)
{
// 频率为100~1000Hz,arr为437~4375
TIM8 -> PSC = 3 - 1;
TIM8 -> ARR = 2 * TIM_INITIAL_CLK / LUT_LENGTH / (TIM8 -> PSC + 1) / freq - 1;
}
else if (freq > 1000)
{
// 频率为1kHz以上,最高可达到65kHz左右
TIM8 -> PSC = 0;
TIM8 -> ARR = 2 * TIM_INITIAL_CLK / LUT_LENGTH / (TIM8 -> PSC + 1) / freq - 1;
}
其中之所以还乘了一个2,是使用了一个小 trick :让 DMA 每次传输时为全字传输,而我们的查找表是 int16 类型的,也就是一次可以传输两个数据,这样可以减轻定时器时钟的负担,实现更高的频率要求。
接下来只要让 DMA 开始循环转运查找表,即可产生出连续的波形。
完整工程文件已上传至 GitHub 仓库,欢迎访问