第七讲ADDA模块

第七讲ADDA模块

本讲总览

这一讲主要解决 4 个问题:

  • ADC 到底在做什么,为什么单片机明明处理数字量,却还能“读电压”。

  • CubeMX 里 ADC 的几个核心参数分别是什么意思,比如分辨率、对齐方式、采样时间。

  • 课堂里常见的 3 种采样思路:轮询、DMA 连续搬运、定时器触发 + DMA 块处理。

  • DAC 怎么配合 DMA 输出一条比较平滑的正弦波。

这篇笔记尽量保留课堂原代码思路,不改你上课时的写法,重点补两类内容:

  • 这段配置和代码现在在干什么

  • 为什么课堂上这里要这样配

复习时先抓住 6 个结论:

  • ADC 是把模拟量变成数字量,DAC 是把数字量再变回模拟量。

  • 12ADC 一共有 4096 个量化等级,对应数字范围通常是 0 ~ 4095

  • 参考电压 Vref 很重要,它决定“满量程”对应多少电压。

  • 轮询法最容易看懂,但最占 CPU

  • DMA 的价值不是“更高级”,而是“让 CPU 少管搬运,专注处理结果”。

  • DAC + DMA + 定时器 的本质,是按照固定节奏把查找表里的数据一项一项送出去。


AD 和 DA 分别是什么

  • ADCAnalog to Digital Converter,模数转换,把模拟电压转成数字值。

  • DACDigital to Analog Converter,数模转换,把数字值转成模拟电压。

可以先这样理解:

  • 传感器、电位器、麦克风这类外部世界给出来的,很多是模拟量

  • 单片机内部运算、判断、通信,更擅长处理的是数字量

所以:

  • 想“读”外面的电压,要靠 ADC

  • 想“输出”一个变化电压或波形,要靠 DAC


模拟 vs. 数字

想象一下调节收音机的音量旋钮和按计算器的数字键:

  • 模拟 (Analog):像音量旋钮,可以在一个范围内连续变化,有无限多个可能值。

  • 数字 (Digital):像计算器按键,只有有限的、离散的状态,比如 0、1、2...

ADC 的任务,就是把连续变化的模拟信号先“采样”,再“量化”为数字值。


分辨率

分辨率决定了 ADC 能把输入电压切成多少份,也就是它能区分多细的变化。

比如:

  • 8 位 ADC:2^8 = 256

  • 10 位 ADC:2^10 = 1024

  • 12 位 ADC:2^12 = 4096

所以一个 12 位 ADC:

  • 数字结果通常是 0 ~ 4095

  • 总共可以表示 4096 个量化等级

如果参考电压是 3.3V,那么 1 级大约对应:

 3.3 / 4096 ≈ 0.000805V ≈ 0.8mV

复习结论

  • 位数越高,能分得越细。

  • 课堂里最常见的是 12 位 ADC。

  • 分辨率高,不代表一切都更准,还要看参考电压、采样时间、噪声和硬件电路。


参考电压 Vref

参考电压可以理解成 ADC 测量时使用的“标尺上限”。

如果 Vref = 3.3V,那么 ADC 会把 0 ~ 3.3V 这一段映射成数字值。

常用关系可以记成:

 ADC结果 ≈ Vin / Vref × 4095
 Vin ≈ ADC结果 / 4095 × Vref

课堂代码里也常写成:

 voltage = (float)adc_val * 3.3f / 4096.0f;

这类写法本质上是在做“按 4096 级近似换算”,平时做电压显示完全够用。

在很多 STM32 单片机里:

  • Vref 往往和 VDDA 有关系

  • 如果 VDDA 不稳,ADC 结果也会跟着漂

为什么会漂

最核心的一句就是:

 ADC结果 ≈ Vin / Vref × 4095

所以 ADC 看的是输入电压和参考电压的比值,不是只看 Vin 本身。

如果很多板子上 Vref 实际上就和 VDDA 相关,那 VDDA 一变,分母就在变:

  • Vin 不变

  • Vref / VDDA 变了

  • 最后 ADC 数字结果就会漂

可以把它理解成:

  • Vref 像尺子

  • Vin 像被测长度

  • 如果尺子刻度自己在变,量出来的结果当然也会变

为什么 Vref 会漂移

常见原因就记这几个:

  • 电源本身有波动

  • 板上有噪声和纹波

  • 负载变化把 3.3V 拉动了

  • 温度变化让基准源或稳压器有偏移

复习结论

  • Vref 由硬件参考源决定,很多时候就是 VDDA

  • Vref 不稳,本质上就是 ADC 的“标尺”不稳。

  • Vref 决定 ADC 的测量范围上限。

  • 电源不稳、参考不稳,ADC 再高位数也不一定测得准。


采样率

采样率表示 ADC 每秒钟做多少次采样,单位常见为:

  • SPS

  • Hz

根据奈奎斯特采样定理:

  • 采样率至少要大于被测信号最高频率的 2

  • 否则会失真,甚至出现混叠

比如你想采一个变化比较快的波形:

  • 采样太慢,只能看到“几个点”

  • 采样足够快,波形轮廓才会更接近真实情况

复习结论

  • 采样率越高,越容易捕捉快速变化的模拟信号。

  • 但采样率越高,数据量也越大,后续处理压力也会上来。


硬件连接先记 4 件事

在真正配 ADC 之前,硬件上先确认下面几件事:

  • 模拟信号要接到真正支持 ADC 的引脚上。

  • 信号源的 GND 必须和开发板 GND 共地。

  • 输入电压不能超过芯片允许范围,通常不要高于 Vref / VDDA

  • 如果信号是电位器、传感器分压这类来源,先想一下它的输出阻抗高不高,这会直接影响后面采样时间怎么选。

复习结论

  • ADC 读不准,不一定是代码问题,很多时候是接线、共地、输入范围或信号源阻抗的问题。

ADC 工作过程的直观理解

下面这些图主要是在讲一件事:

  • 模拟信号本来是连续的

  • ADC 是按固定时刻去“取样”

  • 每次取样后再量化成一个离散的数字值

image-20260506234300996

image-20260506234346027

image-20260506234445006

image-20260506234535702


CubeMX 里的 ADC 基础配置

先把 ADC 引脚开出来

image-20260508202057420

先确认两件事:

  • 你用的是哪个 ADC,比如 ADC1

  • 你读的是哪个通道,对应到哪个引脚

分辨率

image-20260508202250478

这里决定结果有多少位。

初学阶段直接记:

  • 常用:12-bit

  • 返回值范围:0 ~ 4095

右对齐

image-20260508202336090

右对齐的意思不是“结果更准”,而是结果放在寄存器里的位置不同。

12 位 ADC 来说:

  • 右对齐更常用

  • 你直接读出来看数值更直观

比如测到十进制 1000,右对齐后就老老实实在低位部分,处理起来更顺手。

单次转换

image-20260508202553662

单次转换很好理解:

  • 你启动一次

  • 它转换一次

  • 读完结果结束

这类模式适合:

  • 偶尔测一次

  • 初学先把流程跑通

采样时间 / 转换周期

image-20260508202822846

这里要区分两个概念:

  • 采样时间:给内部采样电容“充电取样”的时间

  • 转换时间:把采到的电压变成数字结果的时间

更长的采样时间,通常更有利于高阻输入或信号源驱动能力弱的场景,因为采样会更稳定。

所以更稳妥的理解是:

  • 不是简单记成“越长越准”

  • 而是“采样时间太短时,某些输入源可能采不稳”

复习结论

  • 12-bit + 右对齐 + 单次转换 是最适合入门先跑通的组合之一。

  • 真正调不出 ADC 时,优先查:通道、引脚、触发方式、DMA、采样时间。


ADC 时钟 ADCCLK

老师课件里把这一点讲得比较细,这里顺手补上。

ADC 不是直接按主频工作的,它有自己的工作时钟。常见理解方式是:

 ADCCLK = ADC时钟源 / 预分频

所以你在 CubeMX 里除了点 ADC 本身的参数,还要留意:

  • 时钟源从哪来

  • 分频选了多少

  • 最终算出来的 ADCCLK 有没有超过芯片手册限制

一个很实用的关系是:

 总转换时间 ≈ 采样时间 + 固定转换时间

而固定转换时间本身又和 ADCCLK 有关。

所以:

  • ADCCLK 越高,单次转换通常越快

  • 但不能一味拉高,超过芯片允许上限会出问题

这里怎么记最省事

  • 先查芯片手册允许的 ADC 最大时钟。

  • 在不超限制的前提下,选一个比较稳妥的分频值。

  • 不要只盯着“快不快”,还要和采样时间一起看。


多通道扫描时,CubeMX 里还要多看 3 个点

如果你后面不只是读一个通道,而是一次要读多个通道,那么除了开 ADC 之外,还会多出几项关键配置。

1. Scan Conversion Mode

  • 单通道:通常 Disabled

  • 多通道顺序采样:通常 Enabled

一旦开了扫描模式,就要继续配置:

  • Number Of Conversion

  • 每个 Rank 对应哪个通道

  • 每个通道各自的 Sampling Time

也就是说,多通道不是“多勾几个引脚”就完了,而是要明确:

 第1个采谁 -> 第2个采谁 -> 第3个采谁

2. EOC Selection

这个在刚学时特别容易忽略。

它决定“什么时候算转换结束”:

  • End of single conversion:每转完一个通道就算一次结束

  • End of sequence conversion:整个扫描序列全部转完才算结束

如果你是:

  • 单通道轮询

  • 或者简单单次转换

前者更直观。

如果你是:

  • 多通道扫描

  • 配合 DMA 一次搬整组数据

那通常更适合:

 End of sequence conversion

3. Discontinuous Mode

这个模式不是最常用的,但知道它干什么就行:

  • 它可以把一个扫描序列拆成几小组

  • 每次触发只转其中一组

学习阶段先记:

  • 没特殊需求先不要开

  • 先把普通扫描模式吃透更重要


外部触发怎么理解

前面你看到的轮询法,本质上是软件自己调用:

HAL_ADC_Start(...)

这就是软件触发

但如果想让采样更稳定、更固定,就经常会改成:

  • 用定时器的某个事件来触发 ADC

老师课件里最核心的点是:

  • Timer x Trigger Out event (TRGO) 是最常用的触发源

  • 触发边沿通常选 Rising edge

什么时候用软件触发,什么时候用定时器触发

  • 轮询法、简单调试:软件触发更直接

  • 固定频率采样、配合 DMA:定时器触发更像正式方案

一句话区分

  • 软件触发:什么时候采样,由代码走到哪决定

  • 定时器触发:什么时候采样,由硬件定时节拍决定

所以如果你关心的是:

  • “能不能先读到值” → 软件触发足够

  • “是不是固定频率采到的” → 更应该看定时器触发

还有一个很容易踩的点:

  • 如果你已经准备用定时器外部触发

  • 那么 Continuous Conversion Mode 往往就不能再按“纯连续转换”那套想

更实用的理解是:

  • 想要“每到一个定时点只触发一次序列”,通常要让触发节奏由定时器接管

课堂里的头文件和模式切换

#include "adc_app.h"
#include "adc.h"
#include "math.h"
#include "dac.h"
#include "tim.h"
#include "usart_app.h"
#include "string.h"
#include "usart.h"

// 1 轮询
// 2 DMA连续转换
// 3 DMA TIM 多通道采集
#define ADC_MODE (1)

这段的核心意思就是:

  • 同一套工程里,保留 3 种实验方式

  • 通过修改 ADC_MODE,切换不同实现

这种写法很适合学习阶段:

  • 好对比

  • 不容易把 3 套思路完全写散


方法一:轮询

适合什么时候

  • 先学流程

  • 先看懂 ADC 最基础的使用

  • 采样频率不高,对实时性要求不强

代码

#if ADC_MODE == 1

__IO uint32_t adc_val;  // 用于存储计算后的 ADC 值
__IO float voltage;     // 用于存储计算后的电压值

void adc_task(void) 
{
    HAL_ADC_Start(&hadc1);

    if (HAL_ADC_PollForConversion(&hadc1, 1000) == HAL_OK) 
    {
        adc_val = HAL_ADC_GetValue(&hadc1);
        voltage = (float)adc_val * 3.3f / 4096.0f; 

        my_printf(&huart1, "ADC Value: %lu, Voltage: %.2fV\n", adc_val, voltage);
    } 
    else 
    {
        // 转换超时或出错处理
    }
}

这段流程在干什么

  1. HAL_ADC_Start(&hadc1):启动一次 ADC 转换。

  2. HAL_ADC_PollForConversion(...):原地等,直到转换完成。

  3. HAL_ADC_GetValue(...):把结果读出来。

  4. 3.3V12 位分辨率换算成电压。

  5. 用串口把结果发出去。

这种方法的优缺点

  • 优点:最好懂,最适合入门。

  • 缺点:CPU 会一直等着 ADC,阻塞感很强。

一句话总结:

  • 轮询法是“ADC 没干完,我就不走”。

方法二:ADC + DMA 连续搬运

这类方法的核心思路

不要每采一次就让 CPU 去读一次寄存器,而是:

  • ADC 不断转换

  • DMA 自动把结果搬到数组里

  • CPU 过一会儿再统一处理

这样就能明显减轻 CPU 的搬运压力。

image-20260508211802384


为什么这里推荐配置成 word

image-20260508212245935

image-20260508212355202

image-20260508212433783

先记结论:

  • 课堂这套写法里,把 DMA 缓冲区定义成 uint32_t,再按 word 传输,是更省事也更不容易绕晕自己的配法。

原因主要有 3 个:

  1. HAL_ADC_Start_DMA() 这个接口本身就更偏向按 uint32_t * 去传地址。

  2. uint32_t 缓冲区时,内存对齐更自然,和很多例程写法一致。

  3. 虽然 ADC 有效数据常常只用低 12 位,但“存储容器”写宽一点,代码更统一。

这种方法在 CubeMX 里通常怎么配

如果你想做的是“ADC 一直采,DMA 一直搬,CPU 定时来读缓冲区”,那配置思路通常是:

  • Continuous Conversion ModeEnabled

  • DMA ModeCircular

  • Memory Increment:开启

  • Peripheral Increment:关闭

  • Data Width:常见推荐 Peripheral: WordMemory: Word

  • 一般不靠中断处理数据,而是主循环或定时任务自己来读缓冲区

也就是说,这一类方法的关键词就是:

连续转换 + Circular DMA + 后台搬运 + CPU定时处理

代码

#elif ADC_MODE == 2

#define ADC_DMA_BUFFER_SIZE 32
uint32_t adc_dma_buffer[ADC_DMA_BUFFER_SIZE];
__IO uint32_t adc_val;
__IO float voltage;

void adc_dma_init(void)
{
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buffer, ADC_DMA_BUFFER_SIZE);
}

void adc_task(void)
{
    uint32_t adc_sum = 0;
    
    for(uint16_t i = 0; i < ADC_DMA_BUFFER_SIZE; i++)
    {
        adc_sum += adc_dma_buffer[i];
    }
    
    adc_val = adc_sum / ADC_DMA_BUFFER_SIZE; 
    voltage = ((float)adc_val * 3.3f) / 4096.0f;

    my_printf(&huart1, "Average ADC: %lu, Voltage: %.2fV\n", adc_val, voltage);
}

这段代码现在在干什么

  • adc_dma_init():只负责把 ADC + DMA 跑起来。

  • adc_dma_buffer[]:DMA 自动往里面填采样结果。

  • adc_task():CPU 定期过来,把这一批数据求和再取平均。

这时候 CPU 的工作从“每次都去搬数据”变成了“用的时候来处理结果”。

为什么缓冲区定义成 uint32_t,不是 uint16_t

这里不是在说“ADC 结果有 32 位”,而是在说:

  • 这套 DMA/HAL 配法下,直接用 uint32_t 缓冲区更顺手、更统一。

如果你把缓冲区定义成 uint16_t,有时也能工作,但会带来两个问题:

  • 看代码的人容易混淆“DMA 宽度”和“ADC 有效位宽”

  • 某些例程里还要额外解释强转、对齐和搬运单位

所以学习阶段更建议直接记住:

uint32_t adc_dma_buffer[ADC_DMA_BUFFER_SIZE];
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buffer, ADC_DMA_BUFFER_SIZE);

然后处理时,按有效低位去理解它就行。

ADC_DMA_BUFFER_SIZE 为什么不是直接写 sizeof(adc_dma_buffer)

这个问题很关键。

先给结论:

  • HAL_ADC_Start_DMA() 第 3 个参数要的是传输单元个数

  • sizeof(adc_dma_buffer) 得到的是字节数

如果:

uint32_t adc_dma_buffer[32];

那么:

sizeof(adc_dma_buffer) == 128

因为:

  • 32 个元素

  • 每个 uint32_t4 字节

  • 总字节数就是 128

但 DMA 这里想要的是“搬 32 个数据单元”,不是“搬 128 个字节”。

所以写:

#define ADC_DMA_BUFFER_SIZE 32

或者更通用一点写成:

#define ADC_DMA_BUFFER_SIZE (sizeof(adc_dma_buffer) / sizeof(adc_dma_buffer[0]))

都可以。

main 里初始化

image-20260508213352539

复习时记一句:

  • MX_ADC1_Init() 是把外设配置好

  • adc_dma_init() 才是真正把 DMA 采样跑起来


方法三:定时器触发 + DMA + 块处理

前面两种方法各有优劣:

  • 轮询法:简单,但效率低

  • DMA 连续法:省 CPU,但数据处理时机不够可控

如果你想要的是:

  • 按固定频率采样

  • 采一整块数据后再统一处理

那就可以上:

  • 定时器触发

  • DMA 搬运

  • 中断通知“这一块采完了”

这就是更像“正式采集系统”的思路。

image-20260508215531089

image-20260508215603763

image-20260508215644328

image-20260508215657366

image-20260508215722974

image-20260508215806100

image-20260508215915664

这一套流程怎么理解

可以按下面这条链去记:

 定时器到点 -> 触发 ADC -> ADC 结果进 DMA缓冲区 -> 一块数据采满 -> 中断置标志 -> 主循环处理

这一套在 CubeMX 里和方法二最大的区别

这点非常值得单独记一下。

方法二更像:

  • ADC 持续不断地转

  • DMA Circular 一直覆盖缓冲区

  • CPU 自己找时机去读

方法三更像:

  • 定时器决定采样节奏

  • ADC 按触发去执行一次次采样 / 一次次序列

  • DMA 先搬满一整块

  • 搬满后触发中断,通知 CPU “这一块好了”

所以在配置上常见会变成:

  • External Trigger Source:选某个 Timer x TRGO

  • External Trigger Edge:常用 Rising edge

  • Continuous Conversion Mode:通常 Disabled

  • DMA Mode:常用 Normal

  • 多通道时 Scan ModeEnabled

  • 多通道配合 DMAEOC Selection:更适合 End of sequence conversion

  • NVIC:启用对应的 DMA 中断,必要时启用 ADC global interrupt

一句话区分:

  • 方法二重在“持续搬运”

  • 方法三重在“整块采集、整块处理”

这段代码要特别看懂什么

 void adc_task(void)
 {
     // 一次数据转换 3 + 12.5 =15.5个ADC时钟周期
     // 一次数据转换 15.5 / 22.5 = 0.68us
     // 1000个数据转换需要 1000 * 10 = 10000us = 10ms
 //    while(!AdcConvEnd);
     if(AdcConvEnd)
     {
         for(uint16_t i = 0; i < BUFFER_SIZE / 2; i++)
         {
             dac_val_buffer[i] = adc_val_buffer[i * 2 + 1];
         }
         for(uint16_t i = 0; i < BUFFER_SIZE / 2; i++)
         {
             my_printf(&huart1, "{dac}%d\r\n", (int)dac_val_buffer[i]);
         }
         memset(dac_val_buffer, 0, sizeof(uint32_t) * (BUFFER_SIZE / 2));
         HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_val_buffer, BUFFER_SIZE);
         __HAL_DMA_DISABLE_IT(&hdma_adc1, DMA_IT_HT);
         AdcConvEnd = 0;
     }
 }

这段代码里最容易忽略的一点是:

 dac_val_buffer[i] = adc_val_buffer[i * 2 + 1];

这通常说明当前 DMA 缓冲区里存的是多通道交错数据

比如如果你开了两个通道:

 ch1, ch2, ch1, ch2, ch1, ch2 ...

那么:

  • i * 2 可能取的是第一个通道

  • i * 2 + 1 可能取的是第二个通道

所以这里本质上是在:

  • 从交错排列的 ADC 原始数据里

  • 把其中一个通道单独抽出来

__HAL_DMA_DISABLE_IT(&hdma_adc1, DMA_IT_HT); 是什么

这行是在关闭 DMA 的半传输中断

意思就是:

  • 我不想采到一半就被打断处理

  • 我只关心“整块缓冲区采满”的时刻

这对于“按块处理”很常见。

老师课件里还专门提到:

  • HT 是半传输中断

  • TC 是全传输完成中断

如果你后面想做更高实时性的“乒乓缓冲”处理,才会开始认真用:

  • HAL_ADC_ConvHalfCpltCallback(...)

  • HAL_ADC_ConvCpltCallback(...)

而你当前这套课堂代码,更接近“先关掉半传输,只在整块完成后处理”。

这种方法的特点

  • 优点:采样节奏更稳定,适合固定频率采集。

  • 优点:更适合后续做滤波、波形分析、批量串口输出。

  • 缺点:理解门槛更高,要同时想清楚定时器、ADC、DMA、中断这 4 个东西怎么配合。

一句话总结:

  • 这套方案更像真正项目里会用的采集框架。

触发定时器怎么配,先抓 3 个参数

不管你是定时器触发 ADC,还是定时器触发 DAC,核心都绕不开这 3 个参数:

  • PSC:预分频

  • ARR:自动重装载值

  • TRGO:触发输出事件选择

一个最常见的关系是:

Counter Clock = Timer Clock / (PSC + 1)
Update Event Frequency = Counter Clock / (ARR + 1)

然后再把:

TRGO = Update Event

送给 ADCDAC 当触发源。

复习结论

  • 你想要多高的采样频率或波形更新频率,本质上就是在定 PSCARR

  • TRGO 如果没配对,ADC/DAC 外部触发那边就算选了定时器也不会按预期工作。


DAC + DMA 输出正弦波

前面一直在讲“把模拟量读进来”,这里换个方向,讲“把数字量送出去变成模拟波形”。

先补 4 个 CubeMX 里的配置理解点:

1. 输出缓冲 Output Buffer

一般先记结论:

  • 大多数时候建议 Enable

因为它的作用主要是:

  • 提高驱动能力

  • 降低输出阻抗

  • 让输出电压更稳

除非你有很特殊的功耗或负载要求,不然先开着更省心。

2. 触发源 Trigger

如果你只是想手动给一个固定电压:

  • 可以走软件触发

如果你想让 DAC 稳定、连续地按节奏吐出波形:

  • 通常选某个 Timer x Trigger Out event

这也是你文里 TIM6 + DAC + DMA 那套方案真正成立的关键。

3. 内建波形和自定义波形不要混着想

有些 STM32DAC 自带:

  • 噪声波

  • 三角波

但如果你现在要输出的是:

  • 正弦波

  • 任意波形

那重点就不是开内建波形,而是:

  • 准备查找表

  • DMA 去搬

也就是说:

  • 内建波形适合简单演示

  • 查找表 + DMA 适合你现在这类正弦波输出

4. DAC DMA 为什么常用 Half Word

这里和 ADC DMA 不一样,老师课件里给的建议非常明确:

  • ADC DMA 常见推荐 Word

  • DAC DMA 如果是 12 位输出查找表,常见推荐 Half Word

原因很简单:

  • 你的波形表通常是 uint16_t

  • DAC12 位数据寄存器写入也正好适合 16 位宽度去搬

所以对 DAC + DMA 这套来说,更顺手的理解是:

Memory: Half Word
Peripheral: Half Word

再配合:

  • Memory Increment:开启

  • Peripheral Increment:关闭

  • Mode:循环输出波形时常用 Circular

核心思路是:

  1. 先在内存里准备一张正弦波查找表

  2. 定时器按固定频率触发

  3. DMA 每次把一个点送到 DAC

  4. 所有点连起来,就形成了一条平滑波形

image-20260508224309801

image-20260508224409816

image-20260508224636006

image-20260508224721640

image-20260508225049521

生成正弦波查找表

#define SINE_SAMPLES 100
#define DAC_MAX_VALUE 4095

uint16_t SineWave[SINE_SAMPLES];

void Generate_Sine_Wave(uint16_t* buffer, uint32_t samples, uint16_t amplitude, float phase_shift)
{
  float step = 2.0f * 3.14159f / samples; 
  
  for(uint32_t i = 0; i < samples; i++)
  {
    float sine_value = sinf(i * step + phase_shift);

    buffer[i] = (uint16_t)((sine_value * amplitude) + (DAC_MAX_VALUE / 2.0f));
    
    if (buffer[i] > DAC_MAX_VALUE) buffer[i] = DAC_MAX_VALUE;
  }
}

这段函数做的事非常单纯:

  • sinf(...) 先算出标准正弦值

  • 再把范围从 -1 ~ 1 映射到 0 ~ 4095

  • 最后把每个点存进 SineWave[]

也就是说:

  • SineWave[] 不是随便的数组

  • 它本质上就是一张“波形样本表”

初始化

void dac_sin_init(void)
{
    Generate_Sine_Wave(SineWave, SINE_SAMPLES, DAC_MAX_VALUE / 2, 0.0f);
    HAL_TIM_Base_Start(&htim6);
    HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)SineWave, SINE_SAMPLES, DAC_ALIGN_12B_R);
}

这 3 行分别在干什么

  1. Generate_Sine_Wave(...)
    先把正弦波数据准备好。

  2. HAL_TIM_Base_Start(&htim6)
    启动一个“定时节拍器”。

  3. HAL_DAC_Start_DMA(...)
    让 DMA 按节拍把查找表数据不断送给 DAC。

输出频率怎么理解

一个很好记的近似关系:

输出波形频率 ≈ 定时器触发频率 / SINE_SAMPLES

比如:

  • 定时器每秒触发 10000

  • 一张表有 100 个点

那么输出频率大约就是:

10000 / 100 = 100Hz

所以你后面想改波形频率,通常会从两个地方下手:

  • 改定时器触发频率

  • 改一个周期内的采样点数

复习结论

  • DAC 自己不会“凭空画波”,它只是按你给的数据输出电压。

  • 查找表越密、定时器节拍越稳,输出波形看起来越平滑。


三种方法怎么选

  • 方法一:轮询
    最适合先学会、先跑通、偶尔测一次。

  • 方法二:ADC + DMA 连续搬运
    最适合一直采、减少 CPU 搬运、做平均值或持续监测。

  • 方法三:定时器触发 + DMA + 块处理
    最适合固定频率采样、整块分析、波形采集和更正式的项目方案。

如果只想记最顺手的判断:

  • 先学会:用轮询

  • 先实用:用连续 DMA

  • 先专业:用定时器触发 + DMA


本讲最后串起来

这一讲可以按下面这条主线记:

  • ADC 负责把外部模拟量采进来

  • DMA 负责减少 CPU 搬运压力

  • 定时器 负责给采样或输出提供稳定节奏

  • DAC 负责把数字表格重新变成模拟波形

如果只想记最实用的学习顺序,可以按这个顺序复习:

  1. 先吃透 ADC 的单次轮询

  2. 再看 ADC + DMA 为什么省 CPU

  3. 再理解“定时器触发 + DMA + 块处理”为什么更像正式方案

  4. 最后把 DAC + DMA 当成“反过来播放数组里的波形”

这样 ADDA 这一讲就不只是“会点按钮配 CubeMX”,而是真的知道每一层在干什么。