十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32多通道ADC DMA采集与SPI Flash存储实现详解

STM32多通道ADC DMA采集与SPI Flash存储实现详解 简介这套基于STM32与HAL库的工程源码面向嵌入式开发者与高校学生围绕数据采集与存储主线实现了多通道ADC同步采集、DMA传输、SPI读取加速度计、Flash数据读写并通过定时器控制固定采样频率能够稳定达到3.2kHz采样率适合作为毕业设计、课程设计及项目开发的起点。资源包共184个文件约9.72MB以C/H源文件、Keil工程配置、IOC图形化文件以及编译生成的烧录文件为主便于阅读源码和烧录验证。目前已有347人学习使用。工程基于STM32F1系列编写对ADC与DMA联动、SPI通信时序、Flash读写接口等关键环节给出了完整实现并利用定时器触发采集降低CPU干预帮助理解从传感器数据采集、传输到掉电存储的完整流程。整体代码组织清晰、注释完整可在其基础上扩展更多传感器类型、调整采样率或增加数据上传功能。1. 多通道ADC同时采集为什么非要把DMA拉进来同时采1、3通道的模拟电压一边用SPI把加速度计的X/Y/Z读回来一边把数据按帧写进Flash。如果两条采集链路都放在主循环里阻塞执行ADC转换、SPI字节收发、Flash擦写会互相拖累看似“同时采集”实际上每路数据的采样间隔相差好几十微秒事后做波形对齐全是窟窿。把ADC配成扫描模式让DMA把转换结果自动搬运到内存主循环只负责SPI读取、滤波和落盘这是此类数据记录项目最常用的骨架。STM32HAL库使用到这个程度CubeMX生成的初始化只占一小部分真正要拿主意的是转换模式、采样周期、DMA数据宽度、Flash扇区地址这几处。适合做数据记录仪、姿态采集、设备状态监听的工程师也适合想把ADC-DMA-SPI-Flash四者在一块芯片上理顺的人。2. STM32HAL库配置ADC 1、3通道同时采集与DMA搬运2.1 转换模式扫描、连续、单次怎么配合STM32的常规转换组按“序列”工作。把通道1配成Rank 1通道3配成Rank 2序列长度NbrOfConversion设为2硬件就会先转通道1再转通道3一轮结束的状态由排序和中断事件标识。这个顺序不是随便定的它直接决定了DMA往内存里放数据的顺序第一块给通道1第二块给通道3。后面读缓存时这一点必须先立住。ScanConvMode必须设成ADC_SCAN_ENABLE序列转换才会逐Rank执行否则只有Rank 1生效。接着是连续模式的选择开了连续模式一轮CH1CH3结束后立刻开始下一轮不开则一轮转换后停在空闲态等软件再次触发。需要“不间断采集”连续模式和DMA环形模式Circular是一对组合需要“隔固定时间采一组”就用单次转换加定时器触发这在3.4节展开。2.2 CubeMX里要开的开关和容易漏的三处CubeMX中把ADC1的IN1、IN3勾上后Parameter Settings里有几个地方默认值不能直接用漏掉任何一个都会出现“只采一次”或“数据全不对”的现象。hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; /* 序列转换允许1和3依次转 */ hadc1.Init.ContinuousConvMode ENABLE; /* 一轮结束后立即开始下一轮 */ hadc1.Init.DiscontinuousConvMode DISABLE; /* 使用规则序列必须禁用间断模式 */ hadc1.Init.NbrOfConversion 2; /* 序列里有2个转换 */ hadc1.Init.DMAContinuousRequests ENABLE; /* DMA请求连续有效不能漏 */ ... sConfig.Channel ADC_CHANNEL_1; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_71CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); sConfig.Channel ADC_CHANNEL_3; sConfig.Rank 2; HAL_ADC_ConfigChannel(hadc1, sConfig);这段配置里最容易被忽视的是DMAContinuousRequests。它对应F103的DDS位置0时DMA只在最后一个通道转换结束后请求一次配合连续模式第二轮开始DMA就停止搬数现象是adc_buf只有前两个值在变。置1后DMA请求跟随每一轮转换持续发生。DMA本身也要选Circular模式内存地址自增打开方向PeripheralToMemory外设和内存宽度都保持HalfWord。启动顺序是另一个高频错点static uint16_t adc_buf[2]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, 2);HAL_ADC_Start_DMA内部会完成ADC使能和DMA启动调用它之后不要再调HAL_ADC_Start否则行为不可预期。停止用HAL_ADC_Stop_DMA即可。参数里的(uint32_t *)只是消警告缓冲区依然是uint16_tDMA数据宽度也不要去动它。提示如果发现只有第一次转换有数据先查DMAContinuousRequests和DMA的Circular模式这两项是F1系列最常见的漏配。2.3 采样周期和ADC时钟输出阻抗决定采样周期ADC时钟来自APB2F103的ADC最高跑14MHzCubeMX默认按PCLK2/6分频得到12MHz。总转换时间采样周期12.5个固定时钟周期采样周期越长对信号源输出阻抗的容忍度越高代价是单次转换变慢。采样周期(cycles)总转换时间(cycles)12MHz下耗时适用场合1.5141.17us运放缓冲后的低阻抗信号13.5262.17us普通电阻分压71.5847.0us传感器直接输出、阻抗十几kΩ239.525221us高阻传感器、缓变信号两个通道可以配不同采样周期比如通道3接了高阻输出就把71CYCLES_5换成239CYCLES_5。采样时间太短、源阻抗又高时采到的值会偏低且有跳动这不是DMA的错是内部采样电容没充满。简单经验输出阻抗在10kΩ以内用1.5或13.5周期几十kΩ以上直接上71.5周期先保证数值可靠再谈速度。3. ADC多通道DMA数据的三门必修课认通道、防撕裂、去毛刺3.1 按Rank顺序认通道DMA数据宽度别乱改DMA缓冲区里的数据顺序和Rank顺序完全一致。配置里Rank1是通道1Rank2是通道3那么adc_buf[0]永远属于通道1adc_buf[1]属于通道3。要求是三通道、四通道时同理NbrOfConversion改成对应数值缓冲区长度跟着变顺序依然按Rank排。常见错误是把DMA的Memory Data Width从HalfWord改成Word。F1的ADC数据寄存器只有16位有效改成Word宽度后DMA每次搬运把半个字当成高16位缓冲区里一半位置填0一半填值读出来的数大得离谱。保持PSIZE和MSIZE都是HalfWordADC对齐用默认的右对齐然后按uint16_t取数static volatile uint16_t adc_buf[2]; uint16_t ch1 adc_buf[0]; /* 通道1的值 0~4095 */ uint16_t ch3 adc_buf[1]; /* 通道3的值 0~4095 */注意上面这种直接读法在连续模式DMA环形模式下只能“大概”读到值严格要一致性的场景照下面处理。3.2 环形模式下的撕裂读两次读取打补丁连续采集时DMA每完成一轮转换就刷新两个半字。主循环里先读adc_buf[0]再读adc_buf[1]两次读之间DMA可能已经完成一轮更新于是通道1是第N轮的值、通道3是第N1轮的值这一对数据在时间上对不齐波形分析时就叫“撕裂”。两个半字各自是单次原子读不会读出一个半字的低8位和高8位混着两轮更新但“一对值”不保证同一轮。简单的打补丁是连续读两遍直到两遍结果一致uint16_t ch1_a, ch1_b, ch3_a, ch3_b; do { ch1_a adc_buf[0]; ch3_a adc_buf[1]; ch1_b adc_buf[0]; ch3_b adc_buf[1]; } while (ch1_a ! ch1_b || ch3_a ! ch3_b); /* 此时 ch1_a 和 ch3_a 来自同一轮转换 */绝大多数情况下一次循环就通过只有刚好卡在DMA更新窗口时多读一轮几十行长循环最多跑两三圈对实时性影响很小。这一招适合通道数少的场景通道一多或数据块变大建议直接用第6章的半缓冲双缓冲方式。3.3 每通道一组滑动平均毛刺用去极值兜底ADC原始值做滤波是必须步骤。滑动平均实现简单、对周期性工频干扰抑制效果好每通道分开维护一套累加和和环形数组避免两路互相污染#define ADC_FILTER_LEN 16 static uint32_t sum[2]; static uint16_t ring[2][ADC_FILTER_LEN]; static uint8_t pos[2]; static uint32_t adc_mavg(uint8_t ch, uint16_t raw) { sum[ch] - ring[ch][pos[ch]]; ring[ch][pos[ch]] raw; sum[ch] raw; pos[ch] (pos[ch] 1) % ADC_FILTER_LEN; return sum[ch] / ADC_FILTER_LEN; }调用时传通道号和原始值返回该通道滤波后的均值。注意滑动平均会引入约“滤波深度×采样周期”的群延迟16点平均在7us采样周期下意味着约112us的滞后做实时控制要权衡深度。如果信号里偶发脉冲尖刺滑动平均压不掉常见做法是“去极值平均”N个样本去掉最大最小值再平均处理工频干扰没有滑动平均好但抗突发毛刺更强两类滤波按信号特征选不必叠加。3.4 采样率要精准时用定时器触发替代连续模式连续模式下一轮转换结束立刻启动下一轮采样周期等于转换时间加上DMA和总线的抖动受中断影响会漂移。要让ADC和加速度计在时间轴上严格对齐把ADC的触发源从软件触发改成定时器触发用定时器周期卡住采样节奏hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.ExternalTrigConvEdge ADC_EXTERNALTRIGCONVEDGE_RISING;CubeMX里需要在TIM2的Trigger Output (TRGO)选择Update EventADC1的外触发源选Timer 2 Trigger Out。定时器分频和重装值决定采样率比如72MHz时钟经72分频再配周期125就是8kHz采样。此时DMA建议改回Normal模式每触发一次搬一轮数据为了下一轮还能触发要在HAL_ADC_ConvCpltCallback里重新调用一次HAL_ADC_Start_DMA把DMA的NDTR重装回去。这一步的开销很小换来的是每个采样间隔抖动不超过一个定时器时钟周期。4. SPI读加速度计的驱动框架时序、寄存器与数据拼装4.1 SPI和I2C的取舍以及片选为什么交给GPIOI2C和SPI是嵌入式里最常见的两类总线。I2C两根线、带设备地址、每字节都要应答400kHz就接近上限SPI四根线、主从直接收发、没有应答机制时钟可以推到几MHz甚至更高。加速度计要连续读6字节的XYZ数据SPI一次传输就把6个字节全部读完不用处理每字节的应答时序省下来的时间正好留给和ADC抢同一个主循环。I2C的优势是少两根线、能挂多设备但如果片选足够SPI在吞吐量和确定性上更合适。片选有两种做法硬件NSS和软件GPIO拉低。STM32的硬件NSS在多从机、多任务场景下很容易踩坑——NSS自动翻转的时机、主模式下的NSS配置、和SPI使能的耦合关系排查起来费时间。常用做法是把NSS设为软件控制CS引脚用普通GPIO输出手动拉低代表从机选中传输完拉高。#define ACCEL_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define ACCEL_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)4.2 命令字节决定读写ADXL345的最小驱动以ADXL345为例寄存器读写靠命令字节的高两位区分bit7置1表示读bit6置1表示寄存器地址自动递增低6位是寄存器地址写寄存器则bit7为0。先读器件ID确认通信建立0x00寄存器应返回0xE5读到0xFF多半是SPI模式或接线问题。void accel_write_reg(uint8_t reg, uint8_t val) { uint8_t tx[2] { reg, val }; uint8_t rx[2] { 0, 0 }; ACCEL_CS_LOW(); HAL_SPI_TransmitReceive(hspi1, tx, rx, 2, 10); ACCEL_CS_HIGH(); } void accel_read_xyz(int16_t *x, int16_t *y, int16_t *z) { uint8_t tx[7] { 0xF2, 0, 0, 0, 0, 0, 0 }; /* 0x80|0x40|0x32 读且递增 */ uint8_t rx[7] { 0 }; ACCEL_CS_LOW(); HAL_SPI_TransmitReceive(hspi1, tx, rx, 7, 10); ACCEL_CS_HIGH(); *x (int16_t)((uint16_t)(rx[1] | (rx[2] 8))); *y (int16_t)((uint16_t)(rx[3] | (rx[4] 8))); *z (int16_t)((uint16_t)(rx[5] | (rx[6] 8))); }初始化时用accel_write_reg(0x2D, 0x08)打开测量模式对应POWER_CTL寄存器。注意rx[0]是命令字节的冗余回复真正的数据从rx[1]开始。SPI是全双工发送命令的同一个字节周期里从机已经把MISO上的旧数据推出来了数据字节的收发节奏完全由主机时钟决定所以必须用一次TransmitReceive完成收发而不是先Transmit再Receive分开调用容易出现时钟间隙被严格时序的从机判错帧。4.3 数据拼装、量程换算与静止判据ADXL345输出是16位二进制补码低字节在前。rx[1]是DATAX0rx[2]是DATAX1拼装时先按uint16_t把两个字节合起来再转int16_t不能先转int8再移位。量程和分辨率挂钩默认±2g时是10位输出约4mg/LSB开启全分辨率后变13位灵敏度随量程变化。配置灵敏度1g时读数约±2g 10位默认4mg/LSB250±2g 13位全分辨率3.9mg/LSB256±16g 13位全分辨率31.2mg/LSB32验证姿态数据最简单的办法是平放和翻转电路板平放时Z轴读数接近上表数值X、Y接近0立起90度后重力转到对应轴。静止读数稳定且和表格对得上说明SPI时序、寄存器初始化、数据拼装三件事都对了再往后接滤波和落盘才有意义。5. Flash读写采集数据的地址规划、擦除与掉电校验5.1 地址规划数据区放最后几页别擦到代码STM32内部Flash的擦除粒度是页F103是1KB一页从0x08000000开始连续排布。代码和常量从低地址往上占数据区放在最高地址的页最安全。以64KB的F103为例最后一页是0x0800FC00工程编译完后打开.map文件查一下代码的末地址确认没有伸到这一页再把数据区起点定在这里。程序体积接近上限时把数据区放到0x0800F800倒数第二页也可以留一页冗余。F407没有页的概念改用扇区前4个扇区是16KB后面依次是64KB和128KB地址规划逻辑一样按扇区对齐。CubeMX的链接脚本里FLASH长度可以改小一页把数据区从链接地址里彻底隔离避免编译时占用。5.2 解锁、擦除、半字写入、上锁、读回Flash写操作只能把1写成0想把0恢复成1必须先擦除整页。所以每次开始新一轮日志前先擦掉目标页再按半字16位逐条写入。F103的最小编程单位是半字地址必须2字节对齐。void flash_erase_page(uint32_t page_addr) { FLASH_EraseInitTypeDef erase_cfg; uint32_t fail_addr 0; HAL_FLASH_Unlock(); erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress page_addr; erase_cfg.NbPages 1; if (HAL_FLASHEx_Erase(erase_cfg, fail_addr) ! HAL_OK) { /* 常见原因地址不在页边界、擦除期间供电跌落 */ } HAL_FLASH_Lock(); } void flash_write_words(uint32_t addr, const uint16_t *data, uint16_t n) { HAL_FLASH_Unlock(); for (uint16_t i 0; i n; i) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr 2u * i, data[i]) ! HAL_OK) { break; } } HAL_FLASH_Lock(); for (uint16_t i 0; i n; i) { if (*(volatile uint16_t *)(addr 2u * i) ! data[i]) { /* 回读不一致地址越界或写入期间异常 */ } } }HAL_FLASHEx_Erase的fail_addr参数在失败时返回出错地址比只看返回值有用。每次Program前必须解锁结束后上锁否则后续误操作可能把关键代码区改坏。写入后的回读校验不能省Flash写入对供电电压敏感主控在擦写瞬间拉高外设功耗时欠压会导致写入静默失败。5.3 帧结构、掉电续采与损坏帧丢弃采集数据落盘前先定义帧结构固定长度便于寻址和扫描。推荐按16字节一帧组织偏移字段字节数0帧同步字 0xA55A22帧序号24通道1滤波值26通道3滤波值28加速度计X210加速度计Y212加速度计Z214累加和2一页1KB正好放64帧。开机后从页首开始顺序扫描帧同步字匹配且累加和正确就算有效帧遇到第一个坏帧就停。掉电瞬间可能只写了半个帧这个帧的累加和必然对不上扫描时丢弃它从下一帧空位继续写。硬件上是“最后部分损坏”不是“整页报废”这个特性让掉电续采变得非常简单#define FRAME_WORDS 8u #define LOG_PAGE_WORDS 512u uint16_t flash_find_next_slot(uint32_t page_addr) { const uint16_t *p (const uint16_t *)page_addr; uint16_t idx 0; while (idx FRAME_WORDS LOG_PAGE_WORDS) { uint16_t sum 0; for (uint16_t i 0; i FRAME_WORDS - 1; i) { sum p[idx i]; } if (p[idx] ! 0xA55A || sum ! p[idx FRAME_WORDS - 1]) { break; } idx FRAME_WORDS; } return idx; /* 返回下一帧写入的起始半字偏移 */ }页擦写寿命通常上万次如果每分钟写一页撑不了几天。日志写入频繁时把数据区扩成多页当前页写满后擦下一页形成简单轮换再讲究一点在固定地址维护一个页序号上电时根据序号决定从哪页续采。6. 用DMA半传输回调做双缓冲让Flash落盘不打断采样6.1 半传输回调的双缓冲写法连续采集加DMA环形模式有一个天然特性缓冲区有一半时触发HAL_ADC_ConvHalfCpltCallback满一整圈时触发HAL_ADC_ConvCpltCallback。把缓冲区分成前后两半DMA正在写后半块时CPU处理前半块DMA回绕写前半块时CPU处理后半块这就是不需要DMA双缓冲模式的双缓冲调度。#define BLOCK_SAMPLES 64u static volatile uint16_t adc_circ[BLOCK_SAMPLES * 2 * 2]; /* 前后半块每块128半字 */ static volatile uint8_t block_ready; static uint16_t out_frame[FRAME_WORDS]; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_pack((uint16_t *)adc_circ, BLOCK_SAMPLES * 2); block_ready 1; } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_pack((uint16_t *)adc_circ BLOCK_SAMPLES * 2, BLOCK_SAMPLES * 2); block_ready 1; } }adc_pack做滤波、拼帧、算累加和然后把结果放进out_frame只许在回调里做这件事。回调是中断上下文不能在里面执行Flash擦除或写入那类耗时操作放到主循环里。主循环看到block_ready置位就把out_frame落盘然后清标志。Flash擦除一页要几毫秒这段时间DMA照样往后半块搬数据采样链路不丢点。注意回调处理时长必须小于半块缓冲的填充时间。按7us一个采样周期算64次转换约448us滤波加拼帧在这个预算内绰绰有余如果处理超预算数据块会被覆盖表现为帧序号跳号。6.2 验证链路的三条判据整套链路联调时用三个判据收尾比看波形更直接。第一CH1输入一个已知电压比如电阻分压给1.000V读出值应在1.0/3.3×4095≈1241附近偏差在±3以内说明ADC增益和滤波没做错。第二加速度计平放打印Z轴滤波值对照4.3节表格应在对应读数附近X、Y接近0说明SPI驱动和寄存器配置正确。第三写满一页后断电再上电帧序号应从上次中断处继续掉电损坏的最后一帧被丢弃而不是覆盖整页。帧打印走串口DMA时也可以照搬前半块/后半块的思路发送缓冲分成两半串口发送完成中断重装下一半主循环只负责填数据。这样ADC、SPI、Flash、串口四条链路共用一套缓冲轮换思路改动加速度计型号或增加采集通道时只需调整DMA长度、帧定义和滤波数组三处采样到落盘的骨架不用重搭。本文还有配套的精品资源点击获取
返回列表