STM32H743定时器触发ADC与DMA双缓冲实现高精度连续采样

发布时间:2026/10/28 23:25:28
STM32H743定时器触发ADC与DMA双缓冲实现高精度连续采样

简介:基于STM32H743单片机的高性能ADC采集方案源码包,面向嵌入式工程师、竞赛选手及进阶学习者,解决定时器触发与DMA双缓冲组合使用的工程实现问题。源码完整展示ADC、定时器、DMA的初始化与配置流程,包含双缓冲交替存储与安全切换策略、主循环数据处理逻辑,可有效提升采样一致性与CPU利用效率,适用于传感器数据采集、信号监测、实时控制等对时序要求较高的项目。压缩包共456个文件、约3.1MB,以c源文件、h头文件为主体,配合icf链接配置、s启动汇编、a库文件及hex可烧录镜像,以及uvprojx、ewp等Keil与IAR工程文件,便于直接打开、编译和移植。已有294人学习,代码模块化程度较高,开发者可灵活调整采样频率、分辨率、缓冲区大小等参数,也可参考其DMA与CPU缓冲区切换思路用于其他外设。整体而言是一份可直接参考和二次开发的高质量STM32H743实战源码。

1. 定时器触发 + DMA 双缓冲:这套组合解决的不只是 CPU 占用,而是采样时刻的确定性

做电机控制或者振动采集时,最烦的不是数据量大,而是采样间隔不可控。纯靠软件触发 ADC,一个定时器中断里多写几行判断,采样点就会出现几十微秒的抖动,波形看起来总像覆上了一层毛刺。STM32H743 的组合方案把“什么时候采样”交给硬件定时器,把“数据怎么搬”交给 DMA 双缓冲,CPU 只负责在缓冲区整段换班时处理数据。这条路径适合电力电子、数据采集卡、音频/振动分析这类要求连续采样且不能miss 项目的开发者。下面按我实际调过的工程顺序,从原理到代码,把这套方案讲透。

2. 为什么是定时器触发 + DMA 双缓冲:先搞清这套组合在解决什么问题

2.1 定时器触发的价值:采样间隔由硬件决定,而不是由代码决定

做高速数据采集的人会有一个直觉:ADC 可以用软件触发,定时器到了就调用HAL_ADC_Start,然后等转换完成。问题是两次触发之间的间隔受指令吞吐影响很大。同一个 while 循环里,上一轮处理数据耗时 3us,下一轮耗时 2.1us,那么实际采样周期就是浮动的。对电流环、PFC 这类强实时场景,采样时刻漂移直接转化为控制误差,波形里会出现不该有的谐波。

定时器触发 ADC 的原理是把“启动转换”这个动作从代码里移到硬件引脚事件上。H743 的高级/通用定时器可以输出 TRGO 触发脉冲,这个脉冲在定时器计数器送更新事件时产生,而计数器的节奏只由预分频 PSC 和重载值 ARR 决定,与 CPU 执行路径无关。因此定时器触发 ADC 的抖动通常能做到纳秒级,这在电机控制的相电流采样中很重要。

2.2 H743 的 ADC 触发链路:TIM TRGO -> EXTSEL -> 规则组

在 H743 上,ADC1/2/3 的规则组触发源都通过EXTSEL[2:0]位选择。CubeMX 里的选项已经把这些位封装成“External Trigger Conversion Source”,你选择 TIM2 Trigger Out event 等即可。但底层理解很有必要:ADC 的 EXTEN 位控制是否使用外部触发,以及触发极性(上升沿/下降沿);定时器侧需要开启主输出 TRGO,才能在更新事件时产生触发脉冲。

H743 的 ADC 内部时钟来自 PLL2P/PLL3P,而不是直接取 APB 时钟,这跟 F1 系列不一样。如果不先在时钟树里把 ADC 时钟压低,后面采样周期的计算会全部对不上。H743 的 ADC 有个硬性上限:当 VDDA 在 1.8V 左右时,ADC 时钟最高只能到 36MHz。这点放到第三章细说。

2.3 DMA 双缓冲:解决“来了就要处理,处理不及时就丢”的问题

ADC 只是把数据放在数据寄存器 DR 里,真正采集系统还要负责搬走。用 DMA 的意义不是省几个中断,而是让每个转换结果都在下一个转换完成之前被搬进内存。单缓冲状态下一块内存既要被 DMA 写,又要被 CPU 读,内存访问顺序稍乱,就会读到一半被覆盖。

双缓冲的思路是准备两块内存区:DMA 当前在写 Buffer A,CPU 同时处理 Buffer B 里的上一段数据;写满之后自动切换,DMA 去写 Buffer B,CPU 处理 Buffer A。采集和计算在时间上重叠了,理论上只要 CPU 处理一块数据的时间小于 DMA 写满一块的时间,就不会丢采样点。

具体实现有两种常见路径。一种是普通循环 DMA 配半传输中断/传输完成中断,把一块连续内存劈成两半来用;另一种是 H743 硬件 DMA 的双缓冲模式(DBM),DMA 自动在 M0AR 和 M1AR 两块地址间切换。两者各有优劣:软件方式兼容 H5/H7/F4 所有 DMA,链路简单;硬件方式中断次数更少,切换由 DMA 自身完成,适合采样率特别高的场景。

2.4 为什么不直接用 DMA?双缓冲下中断频率低一个数量级

如果只是“DMA 搬数据”,依然可以在每个 buffer 填满时进中断。但你可以想一下:10kHz 采样率,每个采样点进一次中断,那么每 100us 中断一次,CPU 什么也别干了。使用双缓冲后,buffer 长度设为 256 个点,那么中断频率降到 1kHz 左右,每 100ms 处理一次数据,CPU 有充足时间去跑控制算法或者协议栈。

所以这套组合真正的价值是让 CPU 和最频繁的琐碎事务解耦。不仅是 ADC,SPI 从机接收、串口 DMA 接收也常常用同样套路。下一章用 CubeMX 把触发链路搭出来,先解决能不能跑的问题。

3. 从 CubeMX 配置到初始化代码:把 10kHz 定时器触发 ADC 跑通

3.1 先处理 H743 的 ADC 时钟上限

H743 的 ADC 时钟源在 RCC 里单独配置,常在 PLL2 分频后获得。为了保证 ADC 时钟不超上限,我一般把 PLL2P 配成 36MHz,让 ADCCLK = 36MHz。在 CubeMX 的 Clock Configuration 面板里,可以看到 ADC 的时钟节点,只要超过 36MHz 就会标红。如果未来要调采样时间,也要在这里加减分频比。

/* 时钟树配置简例 */ void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; RCC_PeriphCLKInitTypeDef PeriphClkInitStruct = {0}; /* 系统主频 480MHz,由 SYSCLK 配置 */ RCC_OscInitStruct.PLL.PLLM = 5; RCC_OscInitStruct.PLL.PLLN = 192; RCC_OscInitStruct.PLL.PLLP = 2; HAL_RCC_OscConfig(&RCC_OscInitStruct); /* ADC12 时钟源选择 PLL2P,频率 36MHz */ PeriphClkInitStruct.PeriphClockSelection = RCC_PERIPHCLK_ADC12; PeriphClkInitStruct.Adc12ClockSelection = RCC_ADC12CLKSOURCE_PLL2P; PeriphClkInitStruct.RCC_PLL2.PLL2M = 6; PeriphClkInitStruct.RCC_PLL2.PLL2N = 72; PeriphClkInitStruct.RCC_PLL2.PLL2P = 2; /* 6/6*72/2 = 36MHz */ HAL_RCCEx_PeriphCLKConfig(&PeriphClkInitStruct); }

上面这段把 H743 的 480MHz 主频和 ADC 36MHz 时钟分开设置。如果工程里有 RTC、I2C 等外设,PeriphCLKInitStruct 里还可以继续加字段,注意 D1CPRE、HPRE 等总线的分频会影响定时器时钟,我在 3.2 节会带你核对 TIM 的输入时钟。

3.2 定时器参数计算:采样周期不是随便填的

定时器触发 ADC 的核心是 TRGO 更新事件。以 TIM2 为例,TIM2 挂在 APB1 上。H743 如果 SYS 480MHz,APB1 定时器时钟通常为 240MHz。CubeMX 里 Timer prescaler 的配置面板对应 PSC,Counter period 对应 ARR。产生一次触发脉冲要经过一次更新事件,触发周期公式为:

采样率 = TIM_CLK / ((PSC + 1) * (ARR + 1))

想要 10kHz,可以在 PSC=239、ARR=99 时得到 240MHz / (240 * 100) = 10kHz。ARR 太小会让脉冲间隔太近,要回头检查 ADC 转换时间是否允许。ADC 转换时间 = 采样周期 + 12.5 固定周期。在 36MHz 下,如果采样时间取 2.5 周期,总转换时间就是 15 个 ADC 时钟周期,约 417ns,远小于 100us,所以 10kHz 完全没问题。假如你做 5Msps,就必须把定时器周期压到 200ns,这时反而要关注 CTR 配置的等效分频是否够细。

CubeMX 里 TIM2 配置如下参数:

参数名称设置值作用
Prescaler (PSC)239定时器时钟分频
Counter ModeUp向上计数
Counter Period (ARR)99重载值,100 个计数后更新
Trigger Output (TRGO)Update Event每次更新产生触发脉冲
AutoReload PreloadEnable避免写 ARR 时产生瞬态

注意 TRGO 选择里常见有 OC1REF 等选项,但我们这里让更新事件直接作为触发源,最简单可靠。

3.3 配置 ADC 和 DMA 的步骤

在 CubeMX 里打开 ADC1,使能一个通道,我常用通道 IN0,注意 H743 的 ADC 引脚是模拟输入,不需要 GPIO alternate function,CubeMX 会自动配置 ASW。然后设置:

参数名称设置值说明
Scan Conversion ModeDisabled本次只采一个通道,不需要扫描
Continuous Conversion ModeDisabled不能打开,否则定时器触发会失效
Discontinuous Conversion ModeDisabled规则组不需要间断
External Trigger Conversion SourceTimer 2 Trigger Out选择 TIM2_TRGO
External Trigger Conversion EdgeRising Edge定时器更新脉冲上升沿触发
Sampling Time Common2.5 cycles36MHz 下足够
DMA Continuous RequestsEnable对应 DMA Circular Mode
Number Of Conversion1只采 IN0

这里“DMA Continuous Requests”是 H7 CubeMX 里的术语,它决定当一次转换完成后 DMA 是否继续响应后续请求。必须选 Enable,否则采集一段后 DMA 就停了。采样时间 2.5 cycles 适合信号阻抗不高的情况,如果你的信号源输出阻抗高,先改 16.5 cycles 甚至 64.5 cycles,避免 ADC 采样电容来不及充到稳定电压。

3.4 启动代码:HAL 调用顺序有讲究

CubeMX 生成代码后,启动顺序我建议是先启动定时器,再启动 ADC DMA。否则 ADC 已经开启但定时器还没跑,如果外部触发误触发一次,会从零开始多搬一个点。

/* main.c 中的启动片段 */ void Start_ADC_Acquisition(void) { /* 1. 先启动 ADC DMA,等待触发 */ HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf[0], ADC_BUF_LEN); /* 2. 启动定时器,开始产生触发脉冲 */ HAL_TIM_Base_Start(&htim2); } /* adc_dma_buf 定义两个缓冲,见第四章 */ uint16_t adc_dma_buf[2][ADC_BUF_LEN];

HAL_ADC_Start_DMA里传入的是 Buffer A 地址,长度是单个缓冲区的点数。HAL 内部会配置 DMA 的数据长度为ADC_BUF_LEN,每次传输数据宽度为半字(16bit),所以缓冲区类型必须是uint16_t。随后启动 TIM,TIM 更新事件就会周期性触发 ADC 转换,ADC 转换完成产生 DMA 请求,DMA 搬运数据到adc_dma_buf[0]。搬运长度到达ADC_BUF_LEN后,DMA 进入循环模式从头开始,同时产生传输完成中断。这时的双缓冲还只有一块物理内存,我们要在中断里把它“拆成两半”来用。

4. 双缓冲的核心代码:从软件切换讲到硬件 DBM

4.1 最简单的双缓冲:循环 DMA 加半传输/完成中断

3.4 节启动后,DMA 会把 ADC 数据连续写入adc_dma_buf[0]这个数组。由于开的是循环模式,从数组头写到数组尾会立刻从头开始。如果我们把ADC_BUF_LEN定为 512,DMA 每写满 256 个点时触发半传输中断,表示数组前半段已经就绪;再写满后 256 个点时触发完成中断,表示后半段就绪。

这里没有两块独立内存,但其实效果等同于双缓冲:CPU 在前半段就绪时处理前半段,DMA 同时正在写后半段;等 CPU 处理完,后半段也就绪了。因为 DMA 是循环模式,后半段传输完成之后,DMA 又回到数组头,但此时 CPU 大概率还在处理后半段,触发源是否允许重进?这里有个重要前提:处理前半段的时间必须 < 后半段传输的时间,也就是总共 256 点的采集时间。否则 DMA 写完后半段回卷到头部时会覆盖还没处理完的旧数据。

回调函数的标准写法是这样:

#define ADC_BUF_LEN 512 uint16_t adc_dma_buf[ADC_BUF_LEN]; uint16_t *samples_to_process = NULL; volatile uint32_t current_segment = 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { /* 前半段 DMA 传输完成,当前数据位于 adc_dma_buf[0] ~ [255] */ samples_to_process = &adc_dma_buf[0]; current_segment = 0; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { /* 后半段 DMA 传输完成,当前数据位于 adc_dma_buf[256] ~ [511] */ samples_to_process = &adc_dma_buf[ADC_BUF_LEN / 2]; current_segment = 1; } void Process_Adc_Callback(void) { if (samples_to_process != NULL) { /* 处理采样段,注意处理耗时必须小于半个缓冲区时间 */ ProcessSamples(samples_to_process, ADC_BUF_LEN / 2); samples_to_process = NULL; } }

这段代码有几个关键点。samples_to_process作为“数据已就绪”的指示器,处理完要把指针清掉,防止下一次中断前同一个 buffer 被重复处理。HAL_ADC_ConvCpltCallback与HAL_ADC_ConvHalfCpltCallback是 HAL 库为 ADC DMA 预留的弱符号,你只要在应用层重定义即可,不用手动注册 IRQ。DMA 传输完成和半传输中断都会触发回调,但 HAL 会在中断里判断传输完成码,分别调用这两个函数,所以你无需区分长短。处理函数里最好不要做打印、flash 写入这类耗时操作,否则很容易跑不到samples_to_process = NULL就被下一次中断顶开。

4.2 H743 的硬件双缓冲:设置 DBM 位和 M0AR/M1AR

如果你追求更低的 CPU 中断频率,可以考虑 H743 DMA 自带的 DBM(Double Buffer Mode)。这个模式的思路是 DMA 拥有两套内存地址寄存器DMA_SxM0AR和DMA_SxM1AR,外设的 DMA 请求来时,DMA 永远只访问当前目标寄存器;当传输完成,硬件自动把目标寄存器切换成另一块地址。整个过程不产生额外中断,只是目标指针变了。

初始化时先按普通 DMA 配置,然后开启 DBM 寄存器位并设置第二个内存地址。以 H743 的 DMA1_Stream0 为例:

DMA_HandleTypeDef hdma_adc1; uint16_t adc_buf_a[ADC_BUF_LEN]; uint16_t adc_buf_b[ADC_BUF_LEN]; void HAL_ADC_MspInit(ADC_HandleTypeDef *hadc) { /* 中间省略 GPIO 和时钟使能代码 */ hdma_adc1.Instance = DMA1_Stream0; hdma_adc1.Init.Request = DMA_REQUEST_ADC1; 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; hdma_adc1.Init.FIFOMode = DMA_FIFOMODE_DISABLE; HAL_DMA_Init(&hdma_adc1); /* 开启硬件双缓冲 */ hdma_adc1.Instance->CR |= DMA_SxCR_DBM; /* 设置第二块缓冲区地址 */ hdma_adc1.Instance->M1AR = (uint32_t)adc_buf_b; }

启动时用HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf_a, ADC_BUF_LEN);。HAL 内部会把adc_buf_a设置为 M0AR,并且初始化 DMA 半字节长度。因为之前已经手动开了 DBM 和 M1AR,所以传输开始后,DMA 在 M0AR 和 M1AR 之间自动交替。需要判断当前完成的是哪一块时,在 DMA 中断里读取 DMA 的 CT 位:

void DMA1_Stream0_IRQHandler(void) { uint32_t buf_index = (hdma_adc1.Instance->CR & DMA_SxCR_CT) ? 1 : 0; /* CT=1 表示当前 DMA 目标已经切到 M1AR,实际上刚完成的是 M0AR */ ProcessSamples(buf_index == 1 ? adc_buf_a : adc_buf_b, ADC_BUF_LEN); }

这里要特别小心中断处理时机。HAL 的 DMA 中断服务函数会在传输完成时清除标志,但 CT 位不会在中断里自动变化,它反映的是当下 DMA 正在写哪个 buffer。所以上面代码里buf_index != 存储地址,是“当前目标”,要看实际完成的是哪块,需要结合自己做“完成中断”语义。工程上最简单的做法是在中断回调里直接轮询 CT,再按轮询结果选择 buffer。这种硬件 DBM 方式对新手不友好,调试时容易翻车,建议先把 4.1 的半缓冲方案跑通,再考虑迁移到 DBM。

4.3 选型判断:哪种适合你的采样率

两者本质都是在 DMA 层面换 buffer。4.1 方案的好处是中断逻辑直观,半传输和完成中断分离,不知道自己在哪出问题的时候可以多打印两个回调的进入顺序。坏处是多了 1/2 缓冲中断,中断服务函数里判断和处理压力稍微大一点。

4.2 DBM 的好处是切换由硬件完成,DMA 控制器里有两块内存地址,不占用额外中断周期,适合吞吐量跑到 5Msps 以上的场景。缺点是寄存器位要手动改,HAL 库里大部分适配层没有对这个模式做友好的 API,内存地址也要 32 位对齐,容易踩坑。我个人一般先在 2Msps 以下用 4.1,高于 2Msps 再用 4.2。当然,最稳妥的还是先确认工程里 DSP 库、RTOS 的调度开销,别只追求中断频率低。

5. 必看的 5 个踩坑点:从能跑到跑得稳

5.1 集成阶段最容易翻车的三个坑

坑一:采集到的 ADC 值全是 0 或第一个值重复。

现象是 DMA 搬运正常,但数据处理看到固定某个值一直重复。原因是定时器触发后 ADC 转换还没完成,DMA 就把上一次 DR 寄存器的值搬走了。定时器的触发频率看起来很高,但 ADC 采样周期不足。解决方法是查定时器的触发周期是否满足转换时间,同时把 ADC 采样时间从 2.5 cycles 调到 16.5 cycles 再试。有的 H743 项目在初上电未做 ADC 校准时也会出现首段值和后续值偏差,建议每次初始化时调用HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED)。

坑二:DMA 只搬了一次,后续不再更新。

现象是回调进过一次,之后数据不变化。原因常常是 DMA 的 Mode 被配成 Normal,而不是 Circular。CubeMX 里 ADC 的 “DMA Continuous Requests” 必须使能,并且 DMA handle 里的Mode字段也要确认是DMA_CIRCULAR。如果用的是 4.2 硬件 DBM,则Mode设置应和 ADC 的 Continuous Requests 配合:不能设置DMA_NORMAL,否则 M0AR 到 M1AR 切换一次就停止。

坑三:半传输/完成中断回调没有触发。

现象是 main 里死等一个标志位,永远卡住。原因通常是中断向量没有在 NVIC 中使能,H743 上 DMA1_Stream0 和 ADC1 中断要至少在 CubeMX 勾选一项。或者 HAL 库的HAL_ADC_ConvCpltCallback与HAL_ADC_ConvHalfCpltCallback只在中断触发时调用,如果你在中断里处理过久,下一次中断被 NVIC 同优先级屏蔽,看起来就像没有触发。解决办法是把处理任务放到线程或低优先级循环里,中断只置位。

5.2 运行阶段才暴露的两个坑

坑四:高采样率下数据段间有 1 个点重复。

双缓冲长度是 256 点,上一段最后一个点和下一段第一个点如果采样间隔受到定时器抖动影响,会出现重复点。你检查HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback回调里处理的数据切片,半缓冲风格下 DMA 从数组头回卷,回卷时可能多写同一位置。解决方法是把 dma buffer 稍微扩大一个存储位,或者直接改用硬件 DBM,让 DMA 切换目标时天然不覆盖已稳定的旧区。

坑五:DMA 测速发现实际采样率比计算值低很多。

这是定时器时钟源的选择问题。H743 上 TIM2 挂在 APB1 上,如果 APB1 分频器不等于 1,定时器时钟往往是 APB1 的 2 倍。CubeMX Clock 面板里你看到 APB1 定时器时钟 240MHz,就直接代入公式,但如果你手动配置 RCC 时D1PPRE等没选对,最终 TIM 输入时钟可能与 UI 不同步。另一个原因是 TIM2 的 TRGO 触发 ADC 时,ADC 外部触发极性选成 Falling Edge,导致只有计数周期结束时才触发,于是触发频率减半。遇到采样率减半,优先查看htim2.Instance->PSC和ARR寄存器,用调试器 watch 一下CNT跑动是否正常。

6. 收尾:如何验证这套方案真的做对了,以及两个进阶方向

验证这套方案是否真的做对,最好的办法是用信号源给 ADC 通道输入一个已知频率的正弦波,比如 1kHz、幅度 2V。然后通过串口 DMAT 把处理结果发到电脑,用 Python 或类似工具取前 200 个点画图,数一下一个周期内跨了多少个采样点。如果采样率设的是 10kHz,那么一个 1kHz 周期内应看到 10 个点;如果少了或者不规则,再回头查定时器分频和触发极性。不要只看平均值,必须看单点波形。

更快速的是测 DMA 搬运速率。在HAL_ADC_ConvHalfCpltCallback前翻转一个 GPIO,用逻辑分析仪量翻转周期,这个周期 = 半缓冲点数 / 采样率。比如 256 点、半缓冲 128 点、10kHz 采样率,翻转周期应为 12.8ms。如果周期偏大,说明 DMA 或中断路径上有额外阻塞;偏小则可能设置了错误的触发源。这种 GPIO 翻转测速法在 DMA 驱动调试中也是通用的。

进阶建议一:多通道 ADC 采样。H743 的 ADC 支持规则组扫描,你把 Scan Conversion Mode 使能并配置为多通道,DMA 会在一次转换序列里把几个通道的值按顺序搬出。此时双缓冲回调里处理的数据不再是单通道连续波形,而是交错格式,比如[ch0, ch1, ch0, ch1]。在解析通道前要先做通道分离函数,否则画面会出现相邻通道串扰。切换通道时的 ADC 稳定时间要注意,开关通道之间会产生毛刺,解决方式是给每个采样时间至少多留 5 周期。

进阶建议二:尝试 TIM DMA burst,即定时器触发 DMA 写多个定时器寄存器,做同步波形输出。这套 ADC 采集方案稍加改动,可以用定时器更新事件的 DMA 请求触发 D/A 输出,做闭环波形发生器。不过那不是本文主题,只提醒遇到 H743 的 TIM DMA 引擎时,不要和 ADC 触发的 TRGO 混用,两者会争夺同一个 DMA stream。

我第一次在这块板子上做这套组合时,半传输中断回调写错了 buffer 边界,导致前 128 个点和后 128 个点相互覆盖,波形图上出现周期性的台阶,折腾了大半天。后来养成了一个习惯:每次改完 DMA 配置先在中断里读一次源地址寄存器和目标寄存器,确认M0AR/M1AR指向的地址符合预期。这个习惯救过我很多次,也希望帮到你。

本文还有配套的精品资源,点击获取