资讯动态

STM32实战:多通道ADC+DMA+SPI加速度计与Flash存储详细方案

发布时间:2026/9/16 15:47:43 来源:尧图企业网站定制
简介这是一份以STM32与HAL库为核心的嵌入式采集系统完整工程源码面向毕业设计、课程设计及实际项目开发人群重点解决多通道ADC同步采样、DMA传输、SPI读取加速度计以及Flash数据存储等常见需求。工程采用定时器触发固定采集频率最高可达3.2kHz/s适合需要对振动、倾角等信号做连续采样的应用场景。压缩包共184个文件大小约9.72MB主要包含头文件与源文件.h/.c、编译生成的目标文件和可执行文件.o/.axf/.hex、Keil工程配置.uvprojx/.uvoptx以及CubeMX初始化文件.ioc便于直接打开、编译和二次修改。不仅覆盖了HAL库底层的ADC、SPI、TIM、Flash驱动模块还提供了完整的构建与调试工程源码已经过严格测试稳定性有保障。目前已有347人学习下载可在此基础上直接扩展通信协议或增加传感器通道节省前期搭建时间。1. 为什么这四件事要放在同一个工程里一个STM32采集系统里同时出现多通道ADC、DMA、SPI加速度计和Flash存储通常意味着你在做带数据记录功能的小型仪表或便携设备两路模拟传感器负责电压、温度或压力加速度计负责姿态测量数据需要掉电不丢失。以前我习惯在轮询里读ADC结果主循环经常被占用一次SPI传输刚开始时ADC结果已经溢出数据对不上。多通道ADC的名字容易误导人因为STM32的ADC本质上是“扫描”而不是“同时转换”。标题里的“1、3通道同时采集”实际工程中是通过规则序列和DMA来近似实现的两个通道依次转换DMA结果看起来像同一批数据。下面以STM32F103为例按“ADCDMA → SPI加速度计 → Flash存储 → 整体集成”的顺序把关键配置和代码拆开讲。这套组合并不需要高端单片机F103C8T6即可跑通。读者如果有一定HAL库基础只需要跟着参数表和代码把CubeMX配置对齐然后处理几个边界问题就能稳定运行。2. 配置ADC通道1和通道3的DMA传输在设计采集流程前先理解“多通道ADC同时采集”这件事的真实含义。STM32的ADC只有一个转换单元不可能真正并行采样只能是“依次转换”当ADC启动后它会按照Rank顺序对规则序列里的通道逐个转换。标题里的“同时采集”在工程里一般通过两件事近似一是把两个通道放进同一个规则序列开启扫描模式二是使用DMA每次转换完成自动把结果搬到内存。如果这里配置错了最常见的结果是只采到第一个通道第二个通道永远为0或者DMA只产生一轮数据就停住。2.1 在CubeMX里把两个通道排进同一个规则序列打开STM32CubeMX选择ADC1在Channels里依次添加ADC_IN1和ADC_IN3。以STM32F103C8T6为例对应引脚是PA1和PA3。添加顺序会决定Rank序号第一个添加的成为Rank 1第二个成为Rank 2也就是规则转换的先后顺序。这个顺序决定了DMA缓冲区里数据的排列先转的先放。这里有一个容易忽略的点ADC_SampleTime采样周期是每个通道单独配置的。假如PA1接的是高阻抗信号源PA3接的是运放输出两者需要的采样周期可能不同。在CubeMX里的Rank列表中可以分别给每个Rank配置采样周期比如把Rank1设为28.5周期Rank2设为55.5周期。采样周期越大结果越稳定但总的转换时间变长。单个通道转换时间的近似公式是(采样周期 12.5周期) / ADC时钟频率12.5是逐次逼近的固定周期数。F103的ADC时钟默认不要超过14MHz所以如果ADCCLK设为12MHz28.5周期的通道总耗时约(28.512.5)/12MHz ≈ 3.4µs。如果你需要固定的采样率可以按最慢通道的转换时间来估算一轮扫描的总时间。参数配置成下表这样基本不会出错参数项推荐值说明Scan Conversion ModeEnabled多通道时必选否则只转换Rank1Continuous Conversion ModeEnabled让规则通道反复转换配合DMA实现持续采集Discontinuous Conversion ModeDisabled多通道扫描时不要开开了会变成按组间断转换DMA Continuous RequestsEnabled保证上一轮DMA传输完成后下一轮转换完成会立刻触发新的DMA请求Number Of Conversion2对应通道1和通道3只在规则通道组里体现表里的DMA Continuous Requests是很多人漏掉的一项。如果它处于Disabled即使ADC的Continuous模式打开DMA也可能只传一轮就停因为DMA传输结束后完成标志被置位后续的DMA请求不会自动重新开启。开启之后硬件会在每次转换序列结束后自动重新触发DMA传输你就不需要反复调用启动函数了。2.2 用HAL库代码启动DMA并缓存两路结果CubeMX生成代码后ADC的初始化已经在MX_ADC1_Init()里完成但还需要在用户代码里启动DMA传输。最常见做法是在main函数初始化后加上这段// ADC与DMA共享的缓冲区每个元素按32位存放 __align(4) uint32_t adc1_raw[2]; // 启动ADCDMA传输length2对应两个通道 HAL_ADC_Start_DMA(hadc1, adc1_raw, 2);第一行用__align(4)对齐是因为DMA传输对缓冲区地址有对齐要求在F103上通常要求4字节对齐尤其是当DMA配置为字传输时。如果不对齐低端芯片可能直接跑飞或数据错位。第二行的最后一个参数是DMA缓冲区的长度这里必须和ADC规则序列的通道数一致写1会只得到第一个通道写3则DMA会等待一个不存在的第三个通道转换完成导致最后一轮数据迟滞。启动后当ADC完成一轮转换DMA会把两个32位结果依次写入adc1_raw[0]和adc1_raw[1]。F103的ADC数据寄存器里实际有效数据只占低16位所以主循环里这样取电压uint16_t ch1_raw (uint16_t)(adc1_raw[0] 0xFFFF); uint16_t ch3_raw (uint16_t)(adc1_raw[1] 0xFFFF); float voltage1 ch1_raw * 3.3f / 4095.0f; float voltage3 ch3_raw * 3.3f / 4095.0f;注意这个乘法和除法在F103上会占一些主循环时间因为Cortex-M3没有浮点运算单元。数据量大的时候可以用查表或者把3.3和4096做成16位定标后输出整数。整个过程不需要手动清标志HAL库的HAL_ADC_IRQHandler会管理标志位。2.3 数据不同步Overrun、采样周期与DMA的配合多通道DMA最常见的报错表现是调试时看adc1_raw[]只有第一个数据在变化第二个始终是旧值。排查顺序是先看HAL_ADC_Start_DMA的缓冲区长度是否和通道数一致再查DMA Continuous Requests是否打开最后看ADC全局中断是否在CubeMX中被勾选。开ADC的全局中断通常比不开稳因为HAL_ADC_IRQHandler负责清除Overrun标志一旦发生Overrun后续转换就不会再更新DMA。如果你关闭了ADC中断Overrun标志会一直挂在状态寄存器里即使DMA配置正确也不再传输。另一个影响同步的因素是采样周期。如果两个通道的信号源内阻差异大采样周期过短会导致内部采样电容充电不完全两路读数都会偏低。从ADC模块的角度看DMA拿到的数据是“依次转换”的结果并不是真正同一时刻的采样二者间最多差一个通道的转换时间。对多数压力、温度采集任务来说这个误差可以接受如果要求严格同步需要改用双重ADC模式例如ADC1和ADC2同时对同一个通道采样并由主ADC产生同步触发。标题里如果表达的是“ADC1和ADC3两个外设同时采集”那就走多ADC同步模式而不是简单地放进同一个规则序列。F103没有ADC3F1系列一般只有ADC1、ADC2选型时要提前注意。3. 用硬件SPI读加速度计接线、寄存器时序和HAL收发多通道ADC数据只解决了模拟量输入问题但如果设备需要感知姿态就需要数字接口的传感器。加速度计常见接口有I2C和SPI标题明确写了SPI因此优先采用硬件SPI外设。相比GPIO软件模拟硬件SPI的优势是时钟稳定、收发不占CPU时间。选择加速度计芯片时推荐选寄存器地址连续、支持多字节读的型号例如ADXL345或LIS3DH两者都能在8bit SPI模式下用同一套收发逻辑。3.1 CubeMX里的SPI引脚和模式选择以ADXL345为例SPI有4根线SCLK、MOSI、MISO、CS。单片机作为主机CS引脚一般用普通GPIO控制而不是STM32硬件NSS。原因是硬件NSS在F103上有自动拉高拉低的行为容易和传感器的多字节读时序冲突用软件CS能精确控制整个读取过程的起始和结束。在CubeMX中SPI1引脚默认对应PA5SCK、PA6MISO、PA7MOSI把CS分配到任意空闲GPIO比如PA4并设为Output Push Pull、默认高电平。SPI参数按下表设置参数项推荐值说明Frame FormatMotorolaADXL345兼容标准SPI选MotorolaData Size8 bit寄存器地址和数据都是8位First BitMSB FirstSPI协议要求高位在前Prescaler16使SCLK频率接近且不超过芯片上限CPOLLowADXL345时钟极性为低电平空闲CPHA1 Edge芯片在第一个时钟边沿采样Prescaler的取值需要和系统时钟配合。ADXL345的SPI最高频率约5MHz如果HCLK是72MHzPrescaler选16后SCLK为4.5MHz符合要求。CPOL/CPHA需要从传感器的datasheet时序图确认一般对应“CPOL0, CPHA1”或者SPI Mode 3。凭感觉配的话读出来的设备ID全是0xFF。3.2 初始化序列先读ID再设置量程和测量模式拿到芯片后不要急着读数据先读一次设备ID寄存器确认SPI时序通没通。ADXL345的DEVID地址是0x00固定值是0xE5。在CubeMX生成的代码基础上可以这样写uint8_t devid 0; uint8_t read_cmd 0x80 | 0x00; // 单个读最高位表示读 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, read_cmd, devid, 1, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);这里读回来的是设备在收到地址字节时返回的无效数据真正的ID需要在CS保持低电平期间再补一个字节时钟才能读到。所以更保险的做法是一次传输两个字节先发地址再发0xFF取第二个字节作为ID。这也是后面多字节读的基础。接下来配置加速度计的测量范围ADXL345的DATA_FORMAT地址是0x31把高两位设为0表示±2g分辨率10位如果要±16g就写入0x03。再通过POWER_CTL寄存器0x2D写入0x08进入测量模式。这两步都使用上面类似的单字节写时序唯一区别是第一个字节不带0x80标志。3.3 读加速度计数据的HAL代码一次CS低电平内完成写地址和读数据SPI读操作和I2C不同I2C有独立的START/STOP信号SPI则没有。SPI的MISO/MOSI是全双工同步传输主机要读数据就必须发时钟时钟边沿会推着移位寄存器把MISO上的数据送回来。这意味着“读寄存器”不能只发一次地址再等数据而要在同一CS低电平期间持续发送空字节来产生时钟。下面这段代码用HAL_SPI_TransmitReceive一次完成整个ADXL345的多字节读取uint8_t spi_read_buf[7]; uint8_t spi_write_buf[7]; uint8_t reg 0x32; // ADXL345_DATAX0 spi_write_buf[0] 0x80 | 0x40 | reg; // 读指令: 最高位1表示read, 第2位1表示多字节 for (int i 1; i 7; i) { spi_write_buf[i] 0x00; // 填充时钟产生字节 } HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, spi_write_buf, spi_read_buf, 7, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);这段代码的逻辑是先把寄存器地址和读标识拼在发送缓冲区的第一个字节后面六个字节填0然后在CS低电平期间一次性收发7个字节。spi_read_buf[0]是从机在接收到地址字节时返回的无效数据spi_read_buf[1]到spi_read_buf[6]才是DATAX0/1、DATAY0/1、DATAZ0/1六个数据字节顺序和ADXL345的数据寄存器地址一致。之后把每两个字节合成一个有符号16位数int16_t accel_x ((int16_t)spi_read_buf[2] 8) | spi_read_buf[1]; int16_t accel_y ((int16_t)spi_read_buf[4] 8) | spi_read_buf[3]; int16_t accel_z ((int16_t)spi_read_buf[6] 8) | spi_read_buf[5];这里用0x80 | 0x40 | reg而不是单独的0x80 | reg是因为ADXL345的SPI多字节读写需要拉高寄存器地址的第6位。如果忘记置位0x40芯片只会返回一个字节的数据后面的数据全部是0寄存器地址不会自动增加。3.4 软件片选与I2C/SPI的实际差异前面强调CS用GPIO控制原因在于STM32的硬件NSS在HAL库的HAL_SPI_TransmitReceive调用结束后可能会提前释放CS导致一次事务被拆成多段。如果你使用的是硬件CS发送地址和读取数据必须保证在同一个CS周期内否则加速度计会认为访问结束多字节读读不出来。软件GPIO方式可以保证整个8字节帧期间CS保持低并且代码可移植到其他MCU。I2C和SPI的差别在加速度计场景里很直观I2C只用两根线但读操作还需要CPU参与时钟仲裁SPI有独立时钟线主机不给时钟从机就完全沉默天然适合批量数据读取。缺点是引脚占用多且片选必须自己管。实际项目里如果主控I2C资源紧张或者I2C设备挂得太多优先把加速度计放到SPI上能省掉不少时序问题。4. 把采集结果写进片上Flash页擦除、HAL接口和掉电恢复ADC和加速度计数据采上来以后需要决定是否保存。STM32片上Flash的优点是不需要外接存储器缺点是擦写次数和写入粒度有限制。合理做法是每隔一段时间记录一条带有时间戳和状态字的数据而不是每次采集都写Flash。本章用F103的最后一个页作为存储区介绍HAL库的擦写过程并给出一个可扩展的存储帧格式。4.1 先看数据手册规划页大小和起始地址F103的Flash分为多页C8T6容量是64KB页大小为1KB如果是F103RCT6Flash容量256KB前128页大小为1K后面部分页为2K。所以在写代码前必须查看对应型号的Flash编程手册不能照抄网页里的地址。一般预留最后一页作为数据区这样即使存储区被写坏程序区也不受影响。常见做法是在原文件顶部定义基地址#define DATA_FLASH_PAGE_NUM 63 // 最后一页按具体型号调整 #define DATA_FLASH_ADDR (0x08000000 DATA_FLASH_PAGE_NUM * 1024)如果你使用的是F407等大容量芯片页面和扇区大小完全不同页擦除接口要换成扇区擦除起始地址需要按扇区边界对齐。HAL库提供了HAL_FLASHEx_Erase入参结构体里有一个Banks字段在F103上留0在F4上可能要指定FLASH_BANK_1。4.2 写入前的擦除和半字对齐Flash的写入规则是“只能将1写成0不能将0写成1”所以反复写之前必须擦除擦除会把整页恢复成全0xFF。F103的最小编程单位在HAL库中可以用FLASH_TYPEPROGRAM_HALFWORD半字也可以按字写但无论选哪种地址必须对齐到对应单位。下面是一个完整的写入函数#define FRAME_LEN 64 // 以半字为单位 uint16_t store_buf[FRAME_LEN]; void save_data_to_flash(void) { HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase_cfg {0}; erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress DATA_FLASH_ADDR; erase_cfg.NbPages 1; uint32_t page_err 0; HAL_FLASHEx_Erase(erase_cfg, page_err); for (int i 0; i FRAME_LEN; i) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, DATA_FLASH_ADDR i * 2, store_buf[i]); } HAL_FLASH_Lock(); }逻辑是先解锁Flash然后定义擦除结构体TypeErase设为页擦除PageAddress是存储区起始地址NbPages为1。HAL_FLASHEx_Erase会返回错误页号如果page_err不等于0xFFFFFFFF说明擦除失败通常是地址越界或Flash正忙。擦除之后按半字循环写入。写入时不能超过Flash数据总线宽度F103选半字比较稳妥。4.3 用带幻数的存储帧替代裸数组直接往store_buf[0]写数字虽然能读但工程上一般会加一个简单的存储帧头目的是掉电恢复时能判断数据是否完整。常见做法是用结构体描述#pragma pack(push,1) typedef struct { uint32_t magic; // 0xA5A5A5A5 uint32_t seq; // 序号 uint16_t adc1; // ADC通道1 uint16_t adc3; // ADC通道3 int16_t accel[3]; uint8_t crc8; } SensorFrame; #pragma pack(pop)magic字段用于启动时扫描整页Flash如果读到0xA5A5A5A5说明该地址有有效数据seq记录帧序号用于观察Flash是否被正确覆盖crc8校验数据在写入或掉电过程中是否损坏。写Flash前先计算crc8再整体拷贝进store_buf读取时依次扫描整页检查magic和crc如果校验失败就跳到下一个对齐地址。这样做的目的是解决“裸存数据读出来一片混乱”的边界问题。如果嫌crc8计算复杂也可以用简单的求和校验效果差一些但实现简单。重点是一帧数据里必须包含有效标记否则掉电后无法区分“从未写入”的全0xFF和新数据。数据量变大后还可以在帧里加入擦写次数字段判断当前页是否到了寿命上限。5. 整合数据流的技巧用DMA半满中断分帧串口打印调试前三章逐一提到了ADC、SPI和Flash的使用方式整合时不能每次读加速度计就写一次Flash否则Flash寿命很快耗尽。常用做法是把传感器数据放进环形缓冲区缓存一定时间后再批量写入。这里分享一个常用技巧利用DMA传输的“半满”和“全满”回调把一次DMA传输拆成两段处理避免数据覆盖。比如ADC的DMA缓冲区长度为8个32位单元开启半传输完成中断和传输完成中断后HAL回调里可以用参数判断是前一段还是后一段void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { process_adc_chunk(0); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { process_adc_chunk(1); }process_adc_chunk里再调用读SPI加速度计并把数据填到同一个发送结构体。注意DMA是循环模式所以回调触发后不需要重新启动DMA。这个设计的核心好处是ADC数据的搬运完全由DMA接管CPU只需要在固定的半帧时间内处理固定数量的数据时机稳定不会随着主循环任务抖动。调试时可以用逻辑分析仪看SPI CS时序和SCK频率确认传感器ID是否正常也可以用串口把帧数据dump到PC上观察ADC原始值是否在预期范围内。如果遇到error: no stm32 target found多半是ST-Link和目标板接线问题先把调试器连好再调代码。最后验证建议按三步走先单独调SPI读芯片ID再测ADC单通道DMA最后打开Flash写保护相关功能。写Flash完成后整板断电上电读回magic字段看能不能恢复到上一次数据。这一步通过后再在实际环境中长时间跑采集测试。用外部复位按钮模拟掉电能快速验证帧格式设计和Flash写入是否可靠。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价