目录

使用STM32实现DDS

🖼 封面图摘自于该站点,版权归原作者所有,使用仅作学习展示用途。如有侵权,请联系我删除。


前言

   最近在做电赛培训中的一个小任务,其中要求制作一个信号发生装置,能够产生不同频率和幅值的正弦波、三角波和矩形波。做完之后,我觉得这是一个很基础、但也非常实用的模块,因此想把实现过程中的一些经验整理出来,供后续参考。

DDS

   先简单介绍一下 DDS。DDS(Direct Digital Synthesis,直接频率合成)是一种利用数字信号处理技术产生精确频率信号的方法。它常用于信号发生器中,具有频率范围宽、控制精确、相位噪声低、频率稳定性高等优点,因此被广泛应用于通信、测试设备、雷达和电子测量等领域。

  DDS 通过以下几个主要步骤生成一个高精度、可调频率的正弦波信号:

  1. 频率控制字(FCW)
    通过频率控制字(FCW, Frequency Control Word)来确定输出信号的频率。频率控制字是一个与所需输出频率成比例的数字量。

  2. 相位累加器
    相位累加器(Phase Accumulator)是 DDS 的核心部分,负责累加频率控制字,随着时间的推移,产生与时间成比例的相位信号。

  3. 查找表(LUT)
    相位累加器的输出作为索引输入到查找表中,查找表存储了波形的离散数值(例如正弦波或方波)。根据相位值,查找表输出一个对应的波形数值。

  4. 数字到模拟转换(DAC)
    查找表的输出值经过数字到模拟转换器(DAC)后,被转化为模拟信号,最终得到所需的输出波形。

   DDS 的核心思想,是在芯片中存储一个足够长的波表,例如正弦波的“相位-幅值”查找表,并让表项之间的步长足够细密。假设波表长度为 $N$,时钟频率为 $f_{MCLK}$,计数步长为 $m$,那么输出信号频率为

$$ f_{out} = \frac{1}{\frac{1}{f_{MCLK}} \times \frac{N}{m}} $$

可以看到在时钟频率和波表长度不变的情况下信号频率的分辨率与计数步长直接相关。一般 DDS 芯片中的波表都很长,且相位累加器的计数值极大,所以可以实现很高的分辨率。 可以看到,在时钟频率和波表长度固定的情况下,信号频率的分辨率与计数步长直接相关。一般 DDS 芯片中的波表都比较长,相位累加器的计数范围也很大,因此能够实现很高的频率分辨率。

STM32实现

   显然,STM32 无法像专用 DDS 芯片那样存储巨量波表数据,因此这里实现的是一个简化版的信号发生器。不过从实际测试结果来看,生成信号的质量并不差。

   输出信号时需要用到 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 在 0V 和 3.3V 两个端点附近的输出误差往往较大,容易引起波形失真。一个比较直接的处理办法,是继续增加直流偏置电压,将信号最低值抬升到 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,是因为这里用了一个小技巧:让 DMA 每次采用全字传输,而查找表中的数据类型是 int16,也就是说一次传输可以带走两个数据。这样可以在一定程度上减轻定时器时钟的压力,从而支持更高的输出频率。

   最后,只需要让 DMA 开始循环搬运查找表,就可以持续输出连续波形了。