3、心电信号采集任务设计:ADC采样配置、采样率选择(250Hz/500Hz)、DMA传输、数据缓冲管理

好,咱们进入心电监护系统里最核心的一环——信号采集。说实话,这部分要是没设计好,后面算法再牛也白搭。我见过不少项目,滤波做得花里胡哨,结果ADC采样抖得跟心电图似的,最后全得重来。

今天咱们就聊聊ADC采样怎么配、采样率选250还是500、DMA怎么传、数据缓冲怎么管。嗯,都是实战里踩过的坑。

3.1 ADC采样配置——别小看这一步

ADC配置,说白了就是告诉芯片:你要从哪个引脚采、采多快、用什么触发。我习惯先把GPIO配成模拟模式,再配ADC。

以STM32为例,我常用的配置是这样的:

// ADC初始化
void ADC_Init(void)
{
    // 1. 使能ADC时钟
    RCC->APB2ENR |= RCC_APB2ENR_ADC1EN;
    
    // 2. 配置采样时间——这个很关键
    // 采样时间越长,精度越高,但速度越慢
    ADC1->SMPR2 |= ADC_SMPR2_SMP0_2;  // 通道0,采样时间239.5个周期
    
    // 3. 配置分辨率——12位够用了
    ADC1->CR1 |= ADC_CR1_RES_0;  // 12位分辨率
    
    // 4. 使能ADC
    ADC1->CR2 |= ADC_CR2_ADON;
    
    // 5. 校准——这一步别省
    ADC1->CR2 |= ADC_CR2_CAL;
    while(ADC1->CR2 & ADC_CR2_CAL);
}

这里有个坑:采样时间别设太短。我曾经为了追求速度,把采样时间压到1.5个周期,结果采出来的信号噪声大得离谱。后来查手册才发现,心电信号源阻抗高,采样时间不够电容充不满。嗯,后来我改成239.5个周期,稳了。

我的经验:心电信号源阻抗通常在几MΩ级别,采样电容需要足够时间充电。建议采样时间不低于100个ADC时钟周期。

3.2 采样率选择——250Hz还是500Hz?

这个问题我经常被问到。你想想看,心电信号的主要频率成分在0.05Hz到100Hz之间。根据奈奎斯特定理,采样率至少要是最高频率的两倍,也就是200Hz。

那为什么还要250Hz和500Hz?

采样率 优点 缺点 适用场景
250Hz 数据量小,CPU负担轻,存储省 QRS波细节可能丢失 常规监护、Holter
500Hz 波形细节丰富,适合诊断 数据量大,处理压力大 急诊、运动心电

我个人习惯:能上500Hz就上500Hz。为什么?因为心电信号里有些细微的波形变化,比如P波、T波的切迹,250Hz下可能就糊了。我在做动态心电项目时,一开始用250Hz,结果医生反馈说有些早搏波形看不清。换成500Hz后,问题解决了。

但要注意,500Hz意味着每秒要处理500个采样点。如果系统里还有WiFi、显示、存储等任务,CPU压力会很大。这时候RTOS的任务调度就派上用场了。

注意:采样率不是越高越好。超过500Hz后,噪声和工频干扰也会被采进来,反而增加滤波难度。500Hz是个比较平衡的选择。

3.3 DMA传输——解放CPU的关键

ADC采完数据,怎么搬到内存里?最笨的办法是CPU一个个读。但你想,500Hz的采样率,每2ms就要读一次,CPU全耗在这上面了,其他任务还做不做?

所以,DMA是必须的。DMA说白了就是个硬件搬运工,它自己把ADC的数据搬到内存里,搬完了通知CPU一声就行。

我常用的DMA配置:

// DMA配置——双缓冲模式
void DMA_Config(void)
{
    // 1. 使能DMA时钟
    RCC->AHBENR |= RCC_AHBENR_DMA1EN;
    
    // 2. 配置DMA通道
    DMA1_Channel1->CPAR = (uint32_t)&(ADC1->DR);  // 外设地址:ADC数据寄存器
    DMA1_Channel1->CMAR = (uint32_t)adc_buffer;    // 内存地址:缓冲区
    DMA1_Channel1->CNDTR = BUFFER_SIZE;            // 传输长度
    
    // 3. 配置传输方向:外设到内存
    DMA1_Channel1->CCR |= DMA_CCR_DIR;
    
    // 4. 使能循环模式——这个很重要
    DMA1_Channel1->CCR |= DMA_CCR_CIRC;
    
    // 5. 使能传输完成中断
    DMA1_Channel1->CCR |= DMA_CCR_TCIE;
    
    // 6. 使能DMA
    DMA1_Channel1->CCR |= DMA_CCR_EN;
}

这里有个关键点:循环模式。DMA采完一轮数据后自动从头开始,这样就能连续不断地采集。配合双缓冲,效果更好。

3.4 数据缓冲管理——别让数据打架

数据采进来了,怎么管理?我见过最粗暴的做法:DMA直接往一个大数组里写,CPU读的时候也读同一个数组。结果呢?数据打架,读到的数据一半是新的、一半是旧的。

正确的做法是双缓冲。两个缓冲区,一个给DMA写,一个给CPU读,交替使用。

// 双缓冲结构
#define BUFFER_SIZE  512  // 每个缓冲区大小

uint16_t buffer1[BUFFER_SIZE];
uint16_t buffer2[BUFFER_SIZE];

volatile uint8_t current_buffer = 0;  // 0: buffer1在用, 1: buffer2在用

// DMA传输完成中断
void DMA1_Channel1_IRQHandler(void)
{
    if(DMA1->ISR & DMA_ISR_TCIF1)
    {
        // 切换缓冲区
        current_buffer ^= 1;
        
        // 通知任务处理数据
        osSemaphoreRelease(sem_adc_ready);
        
        // 清除中断标志
        DMA1->IFCR |= DMA_IFCR_CTCIF1;
    }
}

// 数据处理任务
void Task_ProcessADC(void const * argument)
{
    uint16_t *data_to_process;
    
    while(1)
    {
        // 等待DMA完成信号
        osSemaphoreWait(sem_adc_ready, osWaitForever);
        
        // 获取当前可读的缓冲区
        if(current_buffer == 0)
            data_to_process = buffer2;  // buffer1正在被DMA写
        else
            data_to_process = buffer1;  // buffer2正在被DMA写
            
        // 处理数据...
        ProcessECGData(data_to_process, BUFFER_SIZE);
    }
}
核心要点:DMA写一个缓冲区时,CPU读另一个缓冲区。两者互不干扰。这就是双缓冲的精髓。

3.5 避坑指南——我踩过的几个坑

最后分享几个实战中遇到的坑,希望能帮你少走弯路:

  • DMA优先级别设太低:我曾经把DMA优先级设成最低,结果被其他中断频繁打断,导致ADC数据丢失。后来改成中等优先级,问题解决。
  • 缓冲区大小要匹配采样率:500Hz下,如果缓冲区太小,CPU来不及处理,数据就会溢出。我一般设512个点,大约1秒的数据量,够用。
  • 别忘了ADC校准:每次上电后做一次校准,能有效消除芯片间的差异。我有个项目,同一批板子,有的采出来偏大、有的偏小,后来发现是没做校准。
  • 中断里别做复杂操作:DMA中断里只做信号通知,数据处理交给任务。否则中断时间太长,会影响其他实时任务。

好了,ADC采样这块就聊到这儿。下一节咱们聊聊心电信号预处理——滤波和基线漂移消除。那部分也挺有意思的。