4. 微控制器(MCU)数据采集:STM32/GD32 GPIO与ADC初始化、DMA传输配置、定时器触发采样、数据缓存策略

好,咱们进入正题。这一章讲的是MCU怎么把传感器的模拟信号变成数字量,再存起来。说白了,就是让芯片听话,把数据老老实实搬进内存。

我个人习惯把这一块分成四个步骤:GPIO和ADC初始化DMA传输配置定时器触发采样数据缓存策略。每一步都有坑,咱们一个一个说。

4.1 GPIO与ADC初始化——别让引脚闲着

先看GPIO。ADC的输入引脚必须配置成模拟模式。我见过有人把引脚配成推挽输出,结果ADC读数一直飘——嗯,那相当于把输出缓冲器接在了采样电容上,能准才怪。

以STM32F103为例,PA0作为ADC1的通道0:

// 使能GPIOA和ADC1时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE);

// 配置PA0为模拟输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;  // 模拟输入模式
GPIO_Init(GPIOA, &GPIO_InitStructure);

// ADC1初始化
ADC_InitTypeDef ADC_InitStructure;
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent;          // 独立模式
ADC_InitStructure.ADC_ScanConvMode = DISABLE;               // 单通道,不扫描
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE;         // 不连续转换(由定时器触发)
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC2; // 定时器2的CC2触发
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right;      // 右对齐
ADC_InitStructure.ADC_NbrOfChannel = 1;                     // 通道数
ADC_Init(ADC1, &ADC_InitStructure);

// 配置ADC通道,采样时间尽量长一点
ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5);

// 使能ADC
ADC_Cmd(ADC1, ENABLE);

// 校准
ADC_ResetCalibration(ADC1);
while(ADC_GetResetCalibrationStatus(ADC1));
ADC_StartCalibration(ADC1);
while(ADC_GetCalibrationStatus(ADC1));
我的经验:采样时间别设太短。55.5个周期是起步,如果传感器输出阻抗高,建议拉到239.5个周期。我曾经用高阻抗传感器只设了7.5个周期,结果ADC读数跳得像心电图——采样电容没充满电。

4.2 DMA传输配置——让CPU歇着

ADC转换完的数据,如果让CPU一个个去读,那CPU就啥也别干了。DMA就是干这个的——数据从ADC的数据寄存器直接搬到内存,CPU该干嘛干嘛。

配置DMA时要注意:传输方向是外设到内存,外设地址固定(ADC1->DR),内存地址递增(数组)。

#define ADC_BUFFER_SIZE  100
uint16_t adc_buffer[ADC_BUFFER_SIZE];

void DMA_Config(void)
{
    DMA_InitTypeDef DMA_InitStructure;

    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

    DMA_DeInit(DMA1_Channel1);
    DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&(ADC1->DR);  // ADC数据寄存器地址
    DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer;        // 内存缓冲区
    DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;                  // 外设到内存
    DMA_InitStructure.DMA_BufferSize = ADC_BUFFER_SIZE;                 // 传输次数
    DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;    // 外设地址不递增
    DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;             // 内存地址递增
    DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; // 16位
    DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
    DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;                     // 循环模式
    DMA_InitStructure.DMA_Priority = DMA_Priority_High;
    DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
    DMA_Init(DMA1_Channel1, &DMA_InitStructure);

    // 使能DMA
    DMA_Cmd(DMA1_Channel1, ENABLE);
}
注意:DMA的循环模式(Circular Mode)一定要开。不开的话,传完一轮就停了,你还得手动重启。我刚开始做项目时就忘了开,结果采了100个点就罢工了,查了半天才发现。

4.3 定时器触发采样——精准的时间控制

压传感器测量,采样间隔必须稳定。用软件延时?不靠谱。用定时器触发ADC,才是正经做法。

定时器产生PWM或更新事件,通过内部触发信号直接启动ADC转换。这样采样间隔由硬件保证,精度到微秒级。

// 定时器2配置,产生100kHz的触发信号(10us一次)
void TIM2_Config(void)
{
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;

    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

    TIM_TimeBaseStructure.TIM_Period = 719;          // 72MHz / (720) = 100kHz
    TIM_TimeBaseStructure.TIM_Prescaler = 0;          // 不分频
    TIM_TimeBaseStructure.TIM_ClockDivision = 0;
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);

    // 选择输出比较通道2作为触发源
    TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update);

    TIM_Cmd(TIM2, ENABLE);
}

然后在ADC初始化里,把触发源设为ADC_ExternalTrigConv_T2_CC2。这样定时器每更新一次,ADC就自动转换一次,DMA自动搬走数据。

核心逻辑:定时器产生稳定脉冲 → 触发ADC采样 → ADC转换完成 → DMA自动搬运 → 数据存入缓冲区。整个过程CPU零干预。

4.4 数据缓存策略——别让数据打架

DMA一直在往缓冲区写数据,主循环或中断在读取数据。如果读写指针撞上了,数据就乱了。

我常用的策略是双缓冲(也叫乒乓缓冲)。两个缓冲区,DMA写一个,CPU读另一个,满了就交换。

#define HALF_BUFFER_SIZE  50
uint16_t adc_buffer[ADC_BUFFER_SIZE];  // 总大小100,分成两个半区

// 在DMA半传输中断和传输完成中断中切换标志
volatile uint8_t buffer_ready = 0;

void DMA1_Channel1_IRQHandler(void)
{
    if(DMA_GetITStatus(DMA1_IT_TC1))  // 传输完成(后半区写满)
    {
        buffer_ready = 2;  // 后半区数据就绪
        DMA_ClearITPendingBit(DMA1_IT_TC1);
    }
    if(DMA_GetITStatus(DMA1_IT_HT1))  // 半传输完成(前半区写满)
    {
        buffer_ready = 1;  // 前半区数据就绪
        DMA_ClearITPendingBit(DMA1_IT_HT1);
    }
}

// 主循环中处理
void ProcessADCData(void)
{
    uint16_t *pData;
    uint16_t len;

    if(buffer_ready == 1)
    {
        pData = adc_buffer;                    // 前半区
        len = HALF_BUFFER_SIZE;
        buffer_ready = 0;
        // 处理数据...
    }
    else if(buffer_ready == 2)
    {
        pData = adc_buffer + HALF_BUFFER_SIZE; // 后半区
        len = HALF_BUFFER_SIZE;
        buffer_ready = 0;
        // 处理数据...
    }
}
避坑指南:我曾经在单缓冲区里直接处理数据,结果DMA把新数据覆盖了还没处理完的旧数据,波形上出现毛刺。换成双缓冲后,问题彻底解决。另外,中断里只做标志位切换,别做复杂运算,否则会影响下一次DMA传输。

4.5 整体流程与知识体系

下面这张图,把整个数据采集链路串起来了。你对照着看,思路会清晰很多。

压传感器高精度数据采集流程 压传感器 模拟信号 GPIO + ADC 模拟输入配置 采样时间设置 DMA传输 外设→内存 循环模式 双缓冲 前半区 后半区 乒乓切换 定时器触发 稳定采样间隔 数据流向:传感器 → ADC → DMA → 双缓冲 → CPU处理 关键点总结 ① GPIO配成模拟模式,采样时间要足够 ② DMA循环模式,半传输+传输完成中断 ③ 定时器触发保证采样间隔稳定

4.6 常见问题与排查思路

现象 可能原因 解决办法
ADC读数全为0 GPIO未配成模拟模式;ADC未使能 检查GPIO_Mode;检查ADC_Cmd
数据跳动大 采样时间太短;电源噪声 增大采样时间;加去耦电容
DMA只传一次就停 DMA模式未设Circular 设置DMA_Mode_Circular
数据错位 缓冲区读写冲突 改用双缓冲策略

嗯,这一章的内容就这些。GPIO和ADC初始化是基础,DMA让传输自动化,定时器保证采样节奏,双缓冲解决数据冲突。把这四步走通了,数据采集的骨架就搭起来了。

实际项目中,你可能还会遇到采样率不够、DMA中断响应慢等问题。到时候再根据具体场景调参数就好。核心思路不变——硬件能干的,别让软件干;中断里能少干的,别多干。


公众号:蓝海资料掘金营,微信deep3321