1. 问题现象与初步定位
先说结论:如果你在 STM32 Nucleo F446RE 上遇到 ADC 中断进不去、回调函数不执行、转换结果永远停在初始值这类问题,八成不是芯片坏了,而是你对 HAL 库的中断处理流程理解有偏差。这类问题我前后折腾了一个下午才彻底搞清楚,把关键点梳理出来,希望能帮你少走弯路。
我当时的场景是这样的:用 STM32CubeMX 配置 ADC1 的 IN0 通道,开启 ADC 全局中断,然后想让 ADC 每次转换完成都触发一次中断,在回调函数里读取转换值来做后续控制逻辑。配置完生成代码,烧进去,结果发现中断一次都没触发。最诡异的是,程序能跑,主循环正常,其他外设如串口也正常工作,唯独 ADC 中断像消失了一样。
排查过程中我试过几种方法:先检查 CubeMX 里是否勾选了 NVIC 的 ADC interrupt,确认勾了;再检查时钟树,ADC 时钟配置为 APB2 的 4 分频,没问题;甚至换了一个引脚重新配置。最终问题出在一个容易被忽略的细节上,后面会在第四节里拆开说。
实际上,这种“IRQ handler not working properly”的问题,在 STM32 家族里非常典型,网上搜索量一直很高,很多刚接触 HAL 库的人都会卡在这里。因为这个问题的表象看似简单,背后涉及的却是中断优先级分组、HAL 库回调机制、ADC 转换模式的选择以及中断标志位处理等一系列知识点。
2. STM32 ADC 中断的核心机制解析
2.1 规则组与注入组的区别,理解 ADC 中断的根源
在 STM32 的 ADC 模块里,中断源主要围绕两类转换来完成:规则组转换和注入组转换。规则组就是你最常用的普通通道转换,比如一组通道轮流采样;注入组类似于抢占式的转换,可以打断正在进行的规则组转换,一般用在需要高优先级采样的场景。
F446RE 的 ADC1 规则组转换对应的中断标志是EOC(End of Conversion),注入组对应的是JEOC(End of Injected Conversion)。此外还有模拟看门狗事件中断 AWD、溢出中断 OVR 等。这里最容易混淆的是:你以为开了“ADC 全局中断”就等于所有中断都处理了,实际上 HAL 库的HAL_ADC_IRQHandler会根据这些不同的标志位分发到不同的回调函数里。
如果你使用的是扫描模式(Scan Mode),那么在连续扫描多个通道时,每个通道转换结束都会产生 EOC 标志。但这里有个大坑:只有当DMA 被禁用且选择的是单次转换模式时,EOC 中断才会正常触发。如果你开启了连续转换模式(Continuous Conversion Mode),转换结果会不断刷新,EOC 持续置位,这时候如果代码没有正确处理,中断可能进入一个死循环式的反复触发,导致看起来就像中断“乱掉了”。
2.2 中断标志位的清与应用
很多人在中断服务函数里手动清标志位时踩坑,比如直接调用__HAL_ADC_CLEAR_FLAG(&hadc1, ADC_FLAG_EOC)。表面上看寄存器确实清零了,但实际上下一次转换结束又会重新置位,这是正常的。问题的关键在于你必须厘清什么时候该软件清除,什么时候硬件自动清除。
对于单次转换模式下的 EOC 中断,转换完成时硬件自动置位 EOC,读ADCx->DR寄存器会自动清除 EOC 标志。因此你在中断回调里直接读HAL_ADC_GetValue()是不会出问题的。但如果你在回调里先手动清了 EOC 再去读数据寄存器,数据依然读得出来,可下次中断能否触发,就得看你清理的时机对不对了。
我实测下来发现:HAL 库的HAL_ADC_IRQHandler内部会在处理完 EOC 后自动调用HAL_ADC_ConvCpltCallback,并且它自己会通过读寄存器的方式把 EOC 标志清掉。所以你如果又在回调函数里手动调了一次读寄存器或清标志,也不至于出错,但如果你在回调之外、主循环里也去读 ADC 值,就可能出现标志被提前清掉、中断丢失的情况。
2.3 中断优先级分组对 ADC 中断的影响
另外一个容易忽略的因素是中断优先级分组。默认 CubeMX 生成的代码会把优先级分组设置为NVIC_PRIORITYGROUP_4,即全部 4 位用于抢占优先级,0~15 级抢占优先级,无子优先级。在这种分组下,如果你的 ADC 中断抢占优先级设置得过高(数值小代表优先级高),而且中断服务函数里处理的逻辑过重,比如打印日志、延时、调用阻塞函数,就会导致其他中断响应变慢,甚至出现 ADC 中断反复被更高优先级中断打断以至于标志位混乱。
我记得有一次为了调试,直接在 ADC 中断回调里用HAL_UART_Transmit(&huart2, ...)发送调试数据。串口的波特率是 115200,发送几十个字节需要几毫秒,这样一次回调就占用了好几毫秒,导致后续 ADC 中断一直被阻塞,表现就是“ADC 采样率上不去”,甚至偶发中断不触发的假象。所以中断回调里千万别做重活,正确做法是只置一个标志位,把读值和后续处理放到主循环里。
3. CubMX 配置与代码实现详解
3.1 从 CubeMX 开始的正确配置流程
既然要彻底解决这个问题,我把 CubeMX 里的配置步骤完整列出来,你可以照着检查一遍自己的工程。我用的是 STM32CubeMX 6.x 版本,固件包版本 F4 1.27.1,不同版本界面略有差异但核心配置相同。
第一步,在 Pinout & Configuration 页面里找到 Analog 选项,点开 ADC1,把 IN0 勾选上。如果你的板子用的是其他引脚,比如 PA1 对应 IN1,就选对应的通道。
第二步,点击 ADC1 的 Mode 按钮,选择Single-ended Input。然后在 Configuration 面板里设置参数:
| 参数项 | 推荐值 | 说明 |
|---|---|---|
| Clock Prescaler | Asynchronous clock mode / 4 | ADC 时钟不能超过 36MHz,F446RE 的 APB2 是 90MHz,4 分频后为 22.5MHz,留足裕量 |
| Resolution | 12 bits | 默认即可 |
| Scan Conversion Mode | Disabled | 只转换一个通道时不开启 |
| Continuous Conversion Mode | Disabled | 单次转换模式,中断触发更容易控制 |
| DMA Continuous Requests | Disabled | 不用 DMA 就关掉 |
| End of Conversion Selection | EOC flag at end of single conversion | 每个通道转换结束即产生 EOC |
| Number Of Conversion | 1 | 只转换一个通道 |
第三步,在 NVIC Settings 标签页里,勾选ADC1 global interrupt,同时要注意看旁边的优先级数字。我建议设置为抢占优先级 5 或 6,不要设置成 0,否则会抢占系统滴答中断导致HAL_Delay出问题。
第四步,生成代码。CubeMX 会帮你初始化好MX_ADC1_Init和HAL_NVIC_EnableIRQ(ADC_IRQn),这些都不用改。关键是在你的用户代码里写启动转换的调用。
3.2 用户代码的关键实现
生成工程后,在main.c的main()函数里,你需要手动调用一次启动转换的函数。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); /* 启动第一次 ADC 转换 */ HAL_ADC_Start_IT(&hadc1); while (1) { /* 主循环不做重活,只监控标志位 */ } }看到这段代码,你可能会有疑问:HAL_ADC_Start_IT是不是只启动一次?其实它的作用是使能 ADC 并使能中断。在非连续转换模式下,每次转换完成后 ADC 会停止,你需要再次调用HAL_ADC_Start_IT才能开始下一次转换。所以一般的做法有两种:一种是每次转换完成回调里再启动下一次,形成“单次转换轮询式”的循环;另一种是开启连续转换模式,一次启动后一直采样。
如果是单次转换模式,需要在回调函数里再次启动:
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { uint16_t adc_val = HAL_ADC_GetValue(&hadc1); /* 处理 adc_val */ /* 关键:启动下一次转换 */ HAL_ADC_Start_IT(&hadc1); } }这里有个细节值得注意:HAL_ADC_GetValue返回的是一个 16 位的无符号整数,但实际有效位数取决于你配置的分辨率。12 位分辨率时,取值范围是 0~4095。当配置为 12 位时,ADC 数据寄存器中的低 4 位无效,左对齐和右对齐模式下读取到的数据会不一样。CubeMX 默认是右对齐,所以直接读取就是正确结果。如果你改成了左对齐,就需要把读到的值右移 4 位才是真实转换值。这个细节在我第一次调试时也踩过坑,读出来的数据满量程乱跳,怀疑人生。
3.3 如果你用了连续转换模式,得知道这套写法
某些场景下,比如音频采样、连续监测传感器波形,你需要 ADC 不停地采样。这时候可以开启连续转换模式,并且只需要在初始化后调用一次HAL_ADC_Start_IT,后续 ADC 就会不断转换并持续触发中断。
这里需要注意一个 HAL 库版本之间的差异:早期版本的 HAL 库在连续转换模式下,EOC 标志在中断回调读取HAL_ADC_GetValue后会被清除,转换仍然继续;但在某些版本里,HAL_ADC_Start_IT会导致 ADC 启动时立即产生一次中断,然后后续转换却不触发中断了,原因是启动时软件触发了一次转换,转换结束产生了 EOC,但由于中断还未使能,这一次标志被积压,之后中断使能了,这个旧标志又触发了一次多余的中断,反而扰乱了判断逻辑。
如果遇到这种“只触发一次中断,之后再也不触发”的怪现象,优先检查是不是连续转换模式下 EOC 标志没有被正确清除。我建议在回调函数里加上下面的清标志操作,虽然 HAL 库理论上会处理,但加一行能让你排除这个变量:
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { __HAL_ADC_CLEAR_FLAG(&hadc1, ADC_FLAG_EOC); uint16_t adc_val = HAL_ADC_GetValue(&hadc1); /* 业务处理 */ } }3.4 直接操作寄存器的排查法
如果你用的是标准外设库或者寄存器操作,判断逻辑会更直接。ADC 初始化完成后使能 ADC 和中断:
ADC1->CR2 |= ADC_CR2_ADON; // 使能 ADC1 ADC1->CR1 |= ADC_CR1_EOCIE; // 使能转换结束中断 ADC1->CR2 |= ADC_CR2_SWSTART; // 软件触发转换在中断服务函数里:
void ADC_IRQHandler(void) { if (ADC1->SR & ADC_SR_EOC) { uint16_t value = ADC1->DR; // 读 DR 自动清 EOC /* 处理 value */ } }这段代码可以在不借助 HAL 库的情况下验证你的 ADC 中断到底能不能触发。如果在寄存器操作下都能正常进中断,说明硬件没问题,问题就出在上层封装的使用逻辑上。这是我认为最有效的排查方法——绕开 HAL 库这个中间层,直达硬件验证。
4. 常见问题与排查技巧实录
4.1 中断进不去,先检查这几处
针对“IRQ handler not working properly”,我总结了一套排查步骤,按顺序操作基本能定位 90% 以上的问题。
第一,检查 NVIC 是否真的使能了 ADC 中断。在 CubeMX 里看是一回事,生成代码之后再看一眼stm32f4xx_hal_msp.c里的HAL_ADC_MspInit函数,确认里面有HAL_NVIC_EnableIRQ(ADC_IRQn)这行代码。我遇到过有人改了 CubeMX 配置但没重新生成代码,导致中断始终没被使能的情况。
第二,检查是否有其他代码修改了中断分组。比如你自己在某个外设初始化里调用了HAL_NVIC_SetPriorityGrouping,或者在启动代码里做了特殊处理,都会影响中断的抢占关系。F446RE 的默认中断分组是 CubeMX 在SystemClock_Config里调的,具体在HAL_InitTick里,别去乱动它。
第三,在中断服务函数入口处打断点。直接在看stm32f4xx_it.c文件里找到ADC_IRQHandler函数,在函数入口处打断点。如果程序正常运行却没有停在这个断点,说明中断硬件上就没有触发;如果停在这里但没有继续进入HAL_ADC_IRQHandler,说明中断服务函数里的代码被你自己改坏了。
第四,检查是否被其他更高优先级的中断阻塞。我前面提到过,如果在优先级 0 的中断回调里做了长时间阻塞操作,ADC 中断(优先级更低)会被无限期推迟。这时候表现为 ADC 中断“完全不工作”,但实际上是饿死了,不是硬件问题。
第五,检查 ADC 是否真的启动转换了。HAL_ADC_Start_IT这个函数名称虽然叫 Start,但它内部做了两件事:校准 ADC 和启动转换。如果在校准阶段就出错,函数返回HAL_ERROR,后续就没有转换产生。调试时留意HAL_ADC_Start_IT的返回值,如果返回错误,先检查 ADC 的校准状态,必要时手动调用HAL_ADCEx_Calibration_Start。
4.2 中断回调总是执行但值不对,问题出在读取时序
进了中断、回调也执行了,但读到的 ADC 值永远是 0,或者永远是上一次的值,这个问题也很常见,一般原因有两个。
第一个原因是读取时机太早。在HAL_ADC_ConvCpltCallback里,转换已经完成,EOC 标志已经置位,数据寄存器里已经有有效数据,此时读取是没问题的。但如果你在别的地方(比如 EXTI 中断回调里)尝试读取 ADC 的值,可能转换还没完成,读到的就是旧数据。解决办法是在读取之前检查 EOC 标志位,或者直接利用转换完成回调来读取。
第二个原因是多通道扫描模式下没有正确切换通道。如果你配置了多个通道扫描,但没有配置 DMA 来搬运数据,那么每次 EOC 中断只代表当前某个通道转换完成,中断回调里需要判断当前是哪个通道,并保存到对应的变量里。HAL 库不帮你做这个判断,需要自己实现。
uint16_t adc_values[4]; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { uint32_t ch = hadc->Instance->SQR3 & ADC_SQR3_SQ1; switch (ch) { case 0: // 通道 0 adc_values[0] = HAL_ADC_GetValue(&hadc1); break; case 1: // 通道 1 adc_values[1] = HAL_ADC_GetValue(&hadc1); break; /* ... */ } }这是我踩过的一个大坑:配置了两路 ADC 输入,分别接电位器和光敏电阻,满心以为每次中断都能拿到对应通道的值,结果发现第一次中断读到的地址是通道 0 的值,第二次还是通道 0 的值,后来才意识到扫描模式下必须手动维护通道索引。
4.3 中断触发过于频繁导致的“假死”问题
如果把连续转换模式和中断结合起来用,可能存在一个隐患:ADC 转换速度非常快,F446RE 的 ADC 在 22.5MHz 时钟下,12 位分辨率转换时间大约为 3.2us,也就是说理论上每秒可以触发约 30 万次中断。如果你的中断回调处理耗时超过 3.2us,CPU 就永远在处理中断,主循环直接被饿死,整个系统表现为“死机”。
我测试过:在回调里加一个 GPIO 翻转来输出方波,用示波器观察频率。开启连续转换模式、不做任何滤波处理,回调里翻转 GPIO,示波器显示的方波频率高达 150kHz 左右,此时 CPU 占用率几乎是 100%,串口发送、按键扫描全部失效。
遇到这种情况,建议改用以下任意一种方案:
- 降低 ADC 转换速度,比如把 ADC 时钟分频加大,或者把分辨率从 12 位降到 10 位;
- 开启 DMA 传输,多个采样值存到缓冲区后,利用 DMA 传输完成中断统一处理,而不是每个样本都触发一次 CPU 中断;
- 在中断回调里加一个简单的时间滤波,比如每隔 N 次转换才处理一次,虽然这个方案比较粗糙,但简单有效。
4.4 使用 ST-LINK 在线调试时的一个陷阱
调试 F446RE 时我还有一个体会:用 ST-LINK 在线仿真跑 ADC 中断程序,和在板上独立运行的效果可能不一样。原因在于仿真器会通过 SWD 接口周期性地暂停 CPU 来读取寄存器,这会干扰 ADC 的实时性,特别是当你设置了中断点后,ADC 中断可能被拖延,导致时序完全错乱。
所以,调试 ADC 中断相关代码时,尽量用串口打印、LED 翻转、DAC 输出等方式观察结果,而不是频繁打断点查看变量。这是我用惨痛教训换来的建议。有一次我以为找到了问题所在,在HAL_ADC_ConvCpltCallback里设置了断点,程序每次都能停住,看起来“正常工作”,但我一但单步执行,后续的转换中断就全乱了。最终拔掉 ST-LINK 直接上电运行,现象完全不同。
5. 一个经过验证的最小可用工程示例
为了避免你只看理论不动手,我提供一个完整可运行的最小工程代码框架。基于 CubeMX 生成的代码结构,在main.c的main函数和stm32f4xx_it.c里稍作修改即可。
main.c 的核心函数:
/* Private variables */ ADC_HandleTypeDef hadc1; uint16_t g_adc_value = 0; volatile uint8_t g_adc_flag = 0; /* ADC init function */ static void MX_ADC1_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; hadc1.Init.DMAContinuousRequests = DISABLE; hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; if (HAL_ADC_Init(&hadc1) != HAL_OK) { Error_Handler(); } } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); HAL_ADC_Start_IT(&hadc1); while (1) { if (g_adc_flag) { g_adc_flag = 0; /* 这里使用 g_adc_value 做处理 */ printf("ADC Value: %d\r\n", g_adc_value); } } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { g_adc_value = HAL_ADC_GetValue(&hadc1); g_adc_flag = 1; HAL_ADC_Start_IT(&hadc1); /* 启动下一次转换 */ } }注意事项:
printf重定向需要你自己实现fputc函数,F446RE 上可以用 ST-LINK 虚拟串口,或者通过板载的 USB 转串口芯片输出到电脑。这个细节在这里不展开,但你们在测试时要注意串口工具里的波特率要设置成和代码里一致。- 中断回调里只赋值两个变量,不做重活,不调用
HAL_UART_Transmit,不在中断里做数学运算,这些经验在前面已经提过。 - 当
ContinuousConvMode设为DISABLE时,每次转换完需要重新调用HAL_ADC_Start_IT。如果你忘了这一步,程序只会在启动时进一次中断,之后便一直安静地待着。这是很多“IRQ handler not working properly”问题的真实原因,不是没配置好,而是没有再次启动转换。 - 如果
ContinuousConvMode设为ENABLE,则第一次调用HAL_ADC_Start_IT之后就不要再在回调里启动下一次了,否则可能出现意想不到的重复启动。具体来说,连续模式下,ADC 会在转换完成后立即开始下一次转换,EOC 标志会周期性置位,中断自然持续触发,回调里再调用HAL_ADC_Start_IT实际上是在已经运行的 ADC 上重复执行启动,有些版本可能返回HAL_BUSY而直接忽略。
这是一个非常容易出错的细节,我特意把两种模式下的用法都列出来,你在工程里一定要先搞清楚自己的配置再动手改代码。
6. 关于中断回调中不能做的几件事
最后专门写一段,算是我反复踩坑后总结的经验。你在 ADC 中断回调(其实任何中断回调都一样)里,千万别做这几件事:
- 不要调用
HAL_Delay。它的实现依赖 SysTick 中断,如果你的 ADC 中断优先级比 SysTick 高,就会在这里死等;如果比 SysTick 低,理论上能工作,但延时期间其他中断无法响应,性能大打折扣。 - 不要在回调里调用耗时较长的库函数,比如串口发送大量数据、擦写 Flash、等待 I2C 通信完成。中断应该是“短平快”的,能置标志就置标志,能存变量就存变量,其余事情主循环做。
- 不要直接用 matlab 这种重型工具去解析实时数据。我见过有人把 ADC 采样值通过串口发到电脑,再用 Python 脚本做实时绘图,结果板子端的串口发送缓冲一满,中断回调就被阻塞,系统行为完全变形。数据要打印,但要控制速率,比如每秒发 20 个点足够看到趋势了。
- 不要在回调里动态分配内存。嵌入式环境下用
malloc本身就有风险,在中断上下文里动态分配更是危险操作,可能导致不可预测的混乱。
我自己的经验法则是:中断回调里的执行时间控制在 10us 以内。F446RE 主频 180MHz,一个简单的赋值语句只需要几个时钟周期,10us 相当于执行几千条指令,对绝大多数业务来说绰绰有余。超过这个量级就要反思是不是把太多事情塞进中断里了。
7. 更深层的避坑指南:从项目实战中提炼的几点心得
如果你已经按照上面的步骤排查过,ADC 中断依然“不工作”,我建议你从项目整体角度再审视一遍。以下几个方向是很多人忽略的:
电源和参考电压的影响。Nucleo F446RE 板上的 VDDA 和 VREF+ 默认是接到 3.3V 的,但如果你外接了传感器,传感器的输出电压范围超出了 0~3.3V,或者传感器和单片机不共地,ADC 采到的值就会异常。这种异常不直接表现为中断不触发,但会表现为值不对、波动大,进而让你误判是中断处理逻辑的问题。所以排查 ADC 问题的时候,先确认输入信号是否干净。
引脚复用的干扰。F446RE 的某些 ADC 输入引脚和调试接口引脚有复用,比如 PA0 还可能是 TIM2_CH1 等。如果你的初始化代码里在其他外设的 GPIO 配置中把这个引脚重映射了,ADC 转换就会失败。特别是连了外部按键或者 LED 的引脚,很容易被别的初始化函数改掉复用功能。
多通道扫描时的数据管理。我前面提到过扫描模式下手动判断通道索引。实际上更推荐的做法是直接用HAL_ADC_Start_DMA,这样多通道的结果会自动按顺序存到数组里,中断回调里只需要在一个半满或全满事件中统一处理。这种方式效率更高,代码也更简洁,很多热词搜索里提到的“stm32 adc多通道扫描循环采样dma”就是在讲这个场景。用 DMA 之后,CPU 负担大大降低,中断频繁导致的假死问题也会自然消失。
在实时性要求高的应用里,使用定时器触发 ADC 转换。如果你需要精确的采样间隔,比如音频采样、电机的电流环采样,软件触发是满足不了要求的。正确的做法是配置一个定时器(如 TIM2)输出 TRGO 事件,让硬件自动触发 ADC 转换,转换完成后再触发 DMA 搬运,整个过程不需要 CPU 干预。这就是论坛里常说的“adc定时器触发”方案。F446RE 的 ADC 外部触发源可以配置为 TIM1、TIM2、TIM3 等的 TRGO 事件,这在 CubeMX 的外设配置里都有选项。
我自己的项目里,做电机电流采样时就是用的这种方案:TIM1 的更新事件触发 ADC1 注入组转换,转换结果通过 DMA 传输到内存数组,DMA 传输完成中断里做 FOC 算法。整个流程里,EOC 中断只在启动阶段参与了一次,之后完全靠 DMA 中断来做节奏控制。可以这么说,只要你的需求里有一点“周期性采样”的影子,就不要再依赖软件触发和 EOC 中断了,硬件定时器触发加 DMA 才是正解。
8. 写在最后的调试心法
回到最初的问题:STM32 Nucleo F446RE 的 ADC IRQ handler not working properly。这类问题看似复杂,本质上是几个环节中的某一个没对上。我把排查顺序再压成一句话:
先看中断使能,再看转换启动,然后查回调函数是否在正确的文件里,最后用寄存器版本验证硬件。
如果这些步骤做完了还不能解决,99% 的情况是你对 HAL 库某个模式的理解有偏差,回到第四节里的对比表,逐项核对你的配置。还有一个土办法我想推荐给你:把工程简化到极致,只保留 ADC 中断和 GPIO 翻转,其他全部注释掉,让板子在最小系统里裸奔,然后用示波器观察 GPIO 翻转频率。如果这一步能出来波形,说明 ADC 中断链路是通的,剩下的就是在你的业务代码里找问题了。这种方法虽然土,但真的能帮你快速缩小排查范围。