简介:基于STM32与HAL库的多通道数据采集与存储工程源码,面向毕业设计、课程设计及嵌入式项目开发,重点解决1、3通道ADC同步采集、DMA搬运、SPI读取加速度计、Flash断电存储等联动问题。工程以定时器触发固定3.2kHz采样频率,适合对数据采集实时性有要求的运动监测与振动分析场景。资源包共184个文件,整体9.72MB,以C/H源码、Keil工程文件(uvprojx/uvoptx)、HAL库驱动以及编译生成的axf/hex/map等为主,也有IOC配置文件便于在CubeMX中复现引脚与时钟配置。目前已吸引347人学习下载,代码经过严格测试,可直接参考并在其基础上做功能扩展。从内容预览看,工程内包含定时器、串口、SPI、ADC、Flash等HAL驱动模块,读者可借此熟悉HAL库下外设组合与中断/DMA联动逻辑,层次较为清晰,便于按需查阅与二次开发;对于需要快速搭建多通道采集链路的开发者,是一份可运行、可验证的完整示例。
1. 定时器触发3.2kHz采样:ADC、DMA、SPI、Flash怎么协同
3.2kHz固定采样率、3路ADC同时采集、DMA零中断搬运、SPI挂加速度计、Flash掉电存储,五件事拆开都不算难,合在同一颗STM32F103里跑稳定才是关键。这个工程把采样链路从CPU中剥了出来:TIM3更新事件触发ADC启动转换,DMA自动把三通道结果搬进内存,主循环只负责SPI读加速度计和Flash落盘,采样节奏严格锁在3.2kHz。这个组合在振动监测、姿态采集、便携式数据记录仪里是标准形态,源码包经过完整测试,课程设计或毕业设计拿来做底子很合适。下面按数据流方向拆解ADC+DMA链路、SPI加速度计读取、Flash存储三块,最后给实测验证方法。
2. ADC三通道+DMA循环搬运的时序链路
2.1 定时器触发为什么优于连续转换
ADC的ContinuousConvMode置ENABLE时,ADC会自己一轮接一轮转换,不需要外部干预。这种自走模式的短板是采样时刻不受控:软件拿到数据时,距离实际采样时刻已经过去了不确定的时间。对3.2kHz采样率来说,一个采样周期只有312.5us,时间误差到了20us,FFT分析就会出现明显频谱泄漏,振动特征频率会被噪声淹没。
定时器触发方案把ADC的启动信号和TIM3更新事件绑定在一起。TIM3溢出一次,ADC就完成一轮三通道转换,转换完成相对溢出时刻的偏差由采样时间和转换周期决定,是一个固定值。这样相邻采样点的时间间隔高度均匀,3.2kHz标称频率可以做到接近晶振精度。代价是ADC不能连续自走,两次触发之间必须留够转换时间。F103在72MHz下三个通道一轮转换约3.5us,相对于312.5us的触发间隔余量非常充分,不存在来不及转完的问题。
2.2 TIM3溢出频率与TRGO触发配置
定时器触发ADC走的是TRGO事件而不是中断,这在HAL库里的配置路径和普通定时器中断完全不同。TIM3计数溢出时,更新事件同步映射到TRGO引脚,ADC的外部触发输入端接收该信号。CubeMX生成工程时建议只用来配置时钟树和引脚复用,ADC与定时器的触发关系手动填写更可控。先初始化定时器:
TIM_HandleTypeDef htim3 = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 225 - 1; // 72MHz / 225 = 320kHz htim3.Init.Period = 100 - 1; // 320kHz / 100 = 3.2kHz htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim3); TIM_MasterConfigTypeDef sMasterConfig = {0}; sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; // 更新事件映射到TRGO sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig); HAL_TIM_Base_Start(&htim3);Prescaler是分频系数,72MHz主频先除以225得到320kHz的计数时钟;Period是自动重载值,计数器从0递增到99共100个计数,两者相除正好得到3.2kHz。MasterOutputTrigger选TIM_TRGO_UPDATE,含义是每次计数器上溢更新都输出一个触发脉冲,这个脉冲直接进ADC的外部触发通道。MasterSlaveMode保持DISABLE,避免定时器被其他主设备同步。HAL_TIM_Base_Start只负责让TIM3跑起来,ADC那边的触发映射在ADC初始化里做关联。
这里有个常见错误:把Prescaler和Period按配置串口波特率的思路去套,忽略了触发其实发生在更新时间上,结果算出的频率和预期差一倍。建议先用示波器量TIM3_CH1对应的PA6复用输出确认3.2kHz,再接ADC链路,否则两层配置叠加在一起时很难定位是哪边出了问题。
2.3 ADC扫描模式与DMA缓冲区初始化
三路模拟信号接到ADC1的IN0、IN1、IN2上。开启扫描模式后,一次外部触发会依次完成三个通道的转换,全部转完才回到空闲状态等待下一次触发。关键配置如下:
ADC_HandleTypeDef hadc1 = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; // 扫描模式,逐通道转换 hadc1.Init.ContinuousConvMode = DISABLE; // 由TIM3触发,不自走 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T3_TRGO; // 绑定TIM3的TRGO hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; // 12位结果右对齐 hadc1.Init.NbrOfConversion = 3; // 转换3个通道 HAL_ADC_Init(&hadc1); ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; // PA0 sConfig.Rank = 1; // 第1个转换的通道 sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; // 采样周期239.5个ADC时钟 HAL_ADC_ConfigChannel(&hadc1, &sConfig); // IN1(PA1, Rank=2)与IN2(PA2, Rank=3)只改Channel字段为 // ADC_CHANNEL_1、ADC_CHANNEL_2,其余保持一致ScanConvMode必须置ENABLE,否则只有Rank=1那一路会被转换,其他通道配置等于白设。ExternalTrigConv选的是TIM3的TRGO,意味着一轮三通道转换完全由定时器节奏驱动。采样时间用239.5周期,在72MHz ADC时钟下约3.3us,对常规电压信号足够;如果信号源内阻偏大,调大SampleTime能提高精度,代价是单轮转换时间变长。采样周期直接决定ADC内部采样电容的充电时间,取值过小时高阻抗源采出来的值会偏低,这是量电压不准时第一个要检查的点。
DMA部分需要特别注意,F103的ADC1固定挂在DMA1的通道1上,这个映射由硬件写死,换到DMA1的其他通道无效:
DMA_HandleTypeDef hdma_adc1 = {0}; hdma_adc1.Instance = DMA1_Channel1; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环模式 hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); uint16_t adc_buf[6]; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 6);Direction选PERIPH_TO_MEMORY,数据从ADC的数据寄存器流向RAM;PeriphInc必须关,因为源地址始终是同一个寄存器;MemInc必须开,让DMA依次写入adc_buf[0]到adc_buf[5]。对齐方式用HALFWORD,F103的ADC结果宽度12位,存在16位数据寄存器里,按半字搬运最合适,用BYTE会丢掉高4位。DMA_CIRCULAR是循环模式的核心,搬完6个半字后DMA的计数寄存器自动重载为6,全程不需要CPU重新发起传输。启动函数第三个参数是传输次数6,对应6个半字。
启动DMA前务必先调用HAL_ADCEx_Calibration_Start(&hadc1)做一次校准。F103的ADC内部有校准电路,不校准直接转换会有固定偏移,严重时输入接地读到的不是0。校准必须放在ADC使能之后、首次转换之前。
2.4 双缓冲与DMA计数器判断
DMA循环模式下,adc_buf[6]被DMA以6个半字为一轮持续覆盖。如果CPU无脑去读,很可能读到DMA正写到一半的数据。常见做法是把缓冲区开成两组,再利用__HAL_DMA_GET_COUNTER查看剩余传输量来判断该读哪一段:
uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_adc1); if (remain >= 3) { process_sample(&adc_buf[0]); // 前3个半字是完整的最新数据 } else { process_sample(&adc_buf[3]); // 后3个半字是完整的最新数据 }__HAL_DMA_GET_COUNTER返回的是还没搬完的半字数。当remain大于等于3时,第二组传输还没结束,数组前半段的3个半字属于已完成的一轮,可以安全读取;反之后半段安全。严格说在remain恰好等于3的边界会有一点竞争,对3.2kHz采样加轻量浮点处理来说影响很小;如果做高可靠性设计,把缓冲区扩成三组或直接在DMA传输完成中断里做数据镜像更稳妥。
| HAL库源文件 | 模块 | 关键接口 |
|---|---|---|
| stm32f1xx_hal_adc.c / stm32f1xx_hal_adc_ex.c | ADC采集与校准 | HAL_ADC_Start_DMA, HAL_ADCEx_Calibration_Start |
| stm32f1xx_hal_tim.c / stm32f1xx_hal_tim_ex.c | 定时器触发源 | HAL_TIMEx_MasterConfigSynchronization |
| stm32f1xx_hal_spi.c | SPI加速度计 | HAL_SPI_TransmitReceive |
| stm32f1xx_hal_flash_ex.c | Flash存储 | HAL_FLASHEx_Erase, HAL_FLASH_Program |
| stm32f1xx_hal_uart.c | 调试输出 | 串口DMA发送 |
3. SPI读取加速度计的HAL库实现
3.1 时钟极性和相位怎么定
SPI相比I2C的优势是速率和协议简单,没有地址应答和时钟拉伸,适合加速度计这种需要连续快速读三轴数据的场景。SPI是全双工同步串口,主从双方必须在时钟沿的定义上达成一致,这个一致就是CPOL和CPHA。绝大多数加速度计芯片支持模式3或模式0,对应CLK空闲电平和数据采样沿的差异。这里按模式3配置:
SPI_HandleTypeDef hspi2 = {0}; hspi2.Instance = SPI2; hspi2.Init.Mode = SPI_MODE_MASTER; hspi2.Init.Direction = SPI_DIRECTION_2LINES; hspi2.Init.DataSize = SPI_DATASIZE_8BIT; hspi2.Init.CLKPolarity = SPI_POLARITY_HIGH; // CPOL=1 hspi2.Init.CLKPhase = SPI_PHASE_2EDGE; // CPHA=1 hspi2.Init.NSS = SPI_NSS_SOFT; // 软件片选 hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; // 9MHz hspi2.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi2);CPOL=1表示空闲时CLK保持高电平,CPHA=1表示数据在第二个时钟沿被采样,也就是下降沿锁存数据。模式3和模式0在读取流程上没有本质区别,只是沿的定义反过来。NSS=SPI_NSS_SOFT最容易忽略:硬件NSS由SPI外设自动控制片选,总线上挂多个设备时会出现竞争;工程里用普通GPIO做软件片选,每个设备的CS独立控制,时序也更好排查。波特率预分频8对应9MHz时钟,接近大多数MEMS加速度计的SPI上限,信号质量差时降到DIV16即可。FirstBit固定MSB,SPI协议规定从最高位开始传。
3.2 单字节与多字节寄存器读取
加速度计的物理寄存器地址通常7位,最高位是读写标志。读操作把命令字节最高位置1。过程分两步:先拉低片选,发命令字节,此时MISO上是无效数据;再发一个哑字节提供时钟,寄存器内容从MISO上移出来。两次传输出现在同一次片选低电平期间,中间不能释放片选:
uint8_t spi_read_reg(SPI_HandleTypeDef *hspi, uint8_t reg, uint8_t *val) { uint8_t cmd = 0x80 | reg; uint8_t dummy = 0x00; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); // 第一步:发读命令,MISO上是无效数据 if (HAL_SPI_TransmitReceive(hspi, &cmd, &dummy, 1, 100) != HAL_OK) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return 1; } // 第二步:发哑字节,真正的寄存器值从MISO移出 if (HAL_SPI_TransmitReceive(hspi, &dummy, val, 1, 100) != HAL_OK) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return 1; } HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return 0; }HAL_SPI_TransmitReceive是同时收发,发送缓冲和接收缓冲各指向一个地址。为什么读一个字节要发两次:SPI没有独立的读时钟或写时钟,读操作必须靠主机持续提供时钟,从机的移位寄存器内容才会一位一位倒出来。第一次调用完成命令锁存,第二次调用完成数据搬移,两次合起来才是一次完整的单字节读。超时返回值HAL_TIMEOUT在9MHz时钟下几乎不可能出现,如果报错,优先查SPI引脚复用和GPIO速度等级,而不是加超时时间。
读三轴数据时加速度计通常支持地址自增。把X轴低字节寄存器作为起始地址,片选保持拉低,连续读6个字节。每次提供时钟后芯片内部地址自动加一,读出相邻寄存器:
uint8_t spi_read_multi(SPI_HandleTypeDef *hspi, uint8_t reg, uint8_t *buf, uint8_t len) { uint8_t cmd = 0x80 | reg; uint8_t dummy = 0x00; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi, &cmd, &dummy, 1, 100); // 命令锁存 for (uint8_t i = 0; i < len; i++) { // 片选不释放,地址指针自动+1,持续输出时钟 HAL_SPI_TransmitReceive(hspi, &dummy, &buf[i], 1, 100); } HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); return 0; }这段代码里for循环每轮都调用TransmitReceive,虽然发送内容是同一个哑字节,但从机端收到的是持续不断的SCK脉冲,MISO在每一个字节的8个时钟里依次位移出数据。当前循环少了对返回值的检查,如果总线被干扰,可能拿回全0xFF的数据;开发阶段为了调试方便可以保留这个简化写法,做产品时建议在循环里加一次错误判断。6个字节加1个命令共7次SPI传输,每次不到1us,对312.5us的采样周期开销很小。
3.3 拼装原始数据并换算成物理量
三轴加速度的原始输出一般是16位有符号数,寄存器排列多数为低字节在前。读回X、Y、Z的低高字节后拼装:
uint8_t raw[6]; spi_read_multi(&hspi2, ACCEL_OUT_X_L, raw, 6); int16_t acc_x = (int16_t)((raw[1] << 8) | raw[0]); int16_t acc_y = (int16_t)((raw[3] << 8) | raw[2]); int16_t acc_z = (int16_t)((raw[5] << 8) | raw[4]); float ax = acc_x / 16384.0f; // ±2g量程,灵敏度16384 LSB/g float ay = acc_y / 16384.0f; float az = acc_z / 16384.0f;字节拼装的类型坑值得单独说。raw是uint8_t数组,raw[1] << 8在C语言里先提升为int,若raw[1]最高位为1,左移8位后Bit15为1,按位或raw[0]得到的是无符号语义的中间值,最后强转int16_t才得到期望的负数。如果反过来把raw定义成int8_t,移位前就符号扩展到高8位,结果全乱。保持raw为uint8_t、最后一步强转,这个顺序是验证过的。灵敏度16384来自数据手册±2g量程定义,1g对应16384个LSB;改±4g量程时换成8192。判定数据是否正常有个快捷方法:芯片水平静止时,ax、ay接近0,az接近1g,三轴模长约等于1g。
提示:读回的加速度值如果始终是固定值比如0x7FFF或0x8000,先查SPI时钟频率是否超过芯片上限,再用示波器看MISO线上有没有实际数据翻转。很多所谓传感器坏了,其实只是CPOL或CPHA与从机期望不一致。
4. Flash掉电存储与数据落盘
4.1 F1系列Flash的页结构与地址规划
STM32F103的Flash按页管理,中容量产品每页1KB,大容量每页2KB。写入前必须擦除,擦除以页为最小单位,无法只清单个字节。这个硬件限制决定了存储方案的思路:一切以页为基本操作单元,数据先攒够一页再一次写入。
工程实践中在头文件里预留专用地址段。以256KB Flash为例,最后一页从0x0803F800开始,共1KB。数据页不要放在代码起始区域,避免一次越界写入把中断向量表覆盖掉。选最后一页还有一个实际好处:地址固定,调试时看程序跑飞的PC指针能快速判断异常是否发生在Flash写入前后。规划地址时让数据页和代码页隔开,中间留出一页余量,方便以后扩展Bootloader或固件升级逻辑。
4.2 擦除-编程流程的HAL实现
擦除和编程前必须解锁,操作结束后上锁。HAL库的接口成对出现:
#define DATA_PAGE_ADDR 0x0803F800UL #define DATA_END_ADDR (DATA_PAGE_ADDR + 1024) FLASH_EraseInitTypeDef erase = {0}; uint32_t page_err = 0; HAL_FLASH_Unlock(); erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.PageAddress = DATA_PAGE_ADDR; erase.NbPages = 1; // 只擦1页 if (HAL_FLASHEx_Erase(&erase, &page_err) != HAL_OK) { /* page_err记录擦除失败的页地址 */ } uint16_t *src = (uint16_t *)sample_buffer; for (uint16_t i = 0; i < 512; i++) { // 1KB页共512个半字 if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, DATA_PAGE_ADDR + i * 2, src[i]) != HAL_OK) { break; } } HAL_FLASH_Lock();TypeErase选PAGES表示按页擦除,PageAddress必须页对齐,NbPages为1只操作一页。HAL_FLASHEx_Erase第二个参数是输出型,返回出错页地址。编程用HALFWORD方式,F1的Flash编程最小宽度是16位半字,地址必须2字节对齐,循环里i*2保证了每个写地址都是偶数。为什么不用字节编程:F1系列的Flash编程宽度在硬件上就是半字,HAL_FLASH_Program在F1上只能传FLASH_TYPEPROGRAM_HALFWORD,写其他值会触发编程错误。数据来源是SRAM里的采样缓冲,先在内存里组装好,再一次性搬入Flash。
擦一页耗时几十毫秒,写512个半字累计也接近百毫秒级,期间CPU取指和中断全部阻塞。这个时间长到足以漏掉几十轮3.2kHz的DMA中断,所以Flash写入绝不能出现在采样主循环里,必须靠缓冲区把写频率降下来。
4.3 页轮换、掉电标志与SRAM缓冲
固定写同一页,几千次擦写后Flash寿命耗尽。3.2kHz采样即使攒够一页再写,如果每秒触发一次页写入,一天就是86400次擦写,几天就废掉一页。应对办法是页轮换:划出8页作为存储区,按顺序写入,写满一页索引加一,末尾回绕到首页。上电时扫描每页头部的标志字段确定当前写到哪一页。标志同时承担掉电保护职责:
#define STATE_READY 0xA5A5 // 开始写入前写入 #define STATE_DONE 0x5A5A // 整页写完后再写入 uint16_t state = *(volatile uint16_t *)(page_addr); if (state == STATE_DONE) { /* 完整页,读出数据 */ } else if (state == STATE_READY) { /* 写一半断电,整页丢弃复用 */ erase_page(page_addr); }写入顺序固定为:擦除整页,页首写STATE_READY,顺序写数据,最后一个半字位置写STATE_DONE。上电发现STATE_READY说明上次写入没走完,数据不完整,按垃圾页处理;看到STATE_DONE才认定完整。这个双标志机制成本极低,但能防止掉电恢复时拿到半截数据。
SRAM缓冲的作用是把Flash写入频率进一步压低。3.2kHz采样下每轮有效数据为三路ADC加三轴加速度共12字节,SRAM里开2KB缓冲能存约170轮,写入间隔拉到50ms以上,DMA中断被阻塞的概率大幅下降。缓冲采用双缓冲切换,一个在攒当前数据,另一个在上一次Flash写入中被消费,软件上用一个标志位轮转角色。最终效果是高频采样链路和低频Flash写入解耦,各跑各的节奏。
5. 3.2kHz采样率的实测验证与调试排错
5.1 GPIO翻转法验证采样节奏
先把业务处理逻辑清空,只在DMA传输完成回调里翻转一个调试GPIO,用示波器量这个引脚的波形频率。一个采样周期内GPIO翻转两次,所以示波器看到的方波频率是1.6kHz,对应采样周期312.5us。如果量到800Hz,说明回调函数执行时间超过了采样周期,DMA中断来不及响应导致漏采;如果频率不稳定或带毛刺,先检查TIM3的Prescaler和Period是否被后续代码意外改写。
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { HAL_GPIO_TogglePin(DEBUG_GPIO_Port, DEBUG_Pin); }5.2 中断优先级与Flash写入阻塞
整条采样链路从TIM3触发到ADC转换完成再到DMA搬运结束,CPU只在DMA中断回调的瞬间介入。这个回调里不要做SPI读取,更不要做Flash写入。SPI的TransmitReceive是阻塞调用,放在回调里等MISO数据会拖死后续DMA中断;Flash写入直接暂停CPU,后果更严重。正确分工是回调里只置一个数据就绪标志,主循环轮询到标志后再做SPI和Flash处理。中断优先级上保证DMA中断高于其他外设即可,TIM3不需要开中断,它的角色只是产生硬件触发信号。
5.3 通过UART-DMA导出数据核对
源码包工程名DMA-UART,说明调试通道走串口加DMA。把ADC三通道和加速度计六字节封装成结构体,每轮采样通过UART-DMA发送。这里有个吞吐量问题:3.2kHz每轮12字节等效38.4KB/s,115200波特率实际有效载荷只有约11.5KB/s,根本发不完。两个选择,把波特率提到460800,或者每N轮抽样发一帧。推荐抽样发送,对验证链路足够,也能留出带宽给其他调试信息。
typedef struct { uint16_t ch0, ch1, ch2; // ADC三通道 int16_t ax, ay, az; // 加速度计三轴 } sample_t; sample_t s; /* 主循环从DMA双缓冲取数填充s */ if (HAL_UART_GetState(&huart1) == HAL_UART_STATE_READY) { HAL_UART_Transmit_DMA(&huart1, (uint8_t *)&s, sizeof(s)); } else { /* 上一次DMA发送未完成,丢弃当前帧 */ }发送前检查串口状态这个细节很重要。HAL_UART_Transmit_DMA在DMA正忙时返回HAL_BUSY,直接调用会丢掉数据。加GetState判断后发送过程是完全非阻塞的,主循环立刻回到采样流程。核对数据时看三点:ADC三通道值是否随输入电压线性变化,加速度计静止时三轴模长是否稳定在16384附近即1g,两路数据在串口助手里能否按结构体正确对齐。三点都满足,3.2kHz采样链路就是通的。
本文还有配套的精品资源,点击获取