ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于STM32的ADC采样与互补PWM输出实战:从信号采集到电机控制

2026/8/29 22:41:28 拓冰建站 浏览量
基于STM32的ADC采样与互补PWM输出实战:从信号采集到电机控制 1. 项目概述从一道决赛题看嵌入式系统综合能力最近在整理历年蓝桥杯嵌入式赛事的真题翻到第六届决赛这道“电压测量与互补PWM信号输出”的题目感觉它非常典型几乎把嵌入式工程师日常开发中几个核心的“硬骨头”都串起来了。这道题不单单是考你会不会用ADC或者配置个定时器它考察的是一个完整的信号采集、处理、决策与控制的闭环系统设计能力。简单来说你需要用单片机测量一个外部模拟电压然后根据这个电压值的大小动态地生成两路互补的PWM波并且这两路波形的频率、占空比等参数还要满足特定的逻辑关系。这听起来是不是有点像一个小型的电机驱动控制器或者开关电源的雏形没错它的应用场景非常贴近工业控制。比如你可以想象它是一个简易的直流电机调速系统通过电位器产生可变电压设定目标速度电压值对应速度指令单片机采集这个指令然后生成驱动H桥的互补PWM信号来控制电机转速和方向。所以搞懂这道题不仅仅是应付比赛更是对实际项目开发能力的一次绝佳演练。无论你是正在备赛的学生还是想巩固STM32开发技能的工程师跟着我把这个项目的思路和代码细节捋一遍肯定会有不小的收获。2. 核心需求与硬件平台解析2.1 题目核心功能点拆解拿到题目第一步永远是仔细拆解需求把大问题分解成一个个可执行的小任务。这道题的核心功能可以清晰地分为前后两个部分电压测量部分这是系统的“感知”环节。你需要使用单片机内部的ADC模数转换器去采集一个来自外部的模拟电压信号。这个电压信号通常由开发板上的电位器产生范围一般是0-3.3V或0-5V取决于单片机供电。ADC采集到的是一个数字量比如12位精度下是0-4095你需要通过计算把它还原成真实的电压值。这部分的关键在于ADC的准确配置、采样时序的稳定性以及软件滤波算法的应用。互补PWM信号输出部分这是系统的“执行”环节。你需要使用定时器的高级功能生成两路频率相同、相位互补即一路为高电平时另一路为低电平、并且带死区时间的PWM波。这两路PWM的占空比需要根据前面测量到的电压值进行动态调整。通常题目会规定一个映射关系比如电压从0V到3.3V变化对应其中一路PWM的占空比从0%线性增加到100%而另一路则从100%线性减少到0%形成互补。死区时间的加入是为了防止在控制功率器件如MOSFET时上下桥臂同时导通造成短路。2.2 硬件平台与关键外设蓝桥杯嵌入式比赛长期采用基于STM32G431RBT6微控制器的CT117E开发板。对于这道题我们需要重点关注板上的以下几个资源ADC通道通常指定连接至电位器Rb2或类似标识的ADC引脚例如PA1或PA5。你需要查看原理图确认具体的连接关系。定时器与PWM输出引脚生成互补PWM需要用到定时器的高级控制功能。STM32G4的通用定时器如TIM1, TIM8和部分高级定时器支持互补输出。常用的引脚组合可能是PA8/PA9TIM1_CH1/TIM1_CH1N或PB13/PB14TIM1_CH1N/TIM1_CH2N需查证。这里有个大坑务必根据官方提供的板载资源列表和原理图确认哪两个引脚被引到了排针上可供使用。我曾经遇到过引脚冲突的情况比如计划用的引脚被LED或按键占用了。调试接口串口USART2用于打印调试信息将测量到的电压值发送到电脑端查看这对于验证ADC采样是否正确至关重要。注意在比赛或实际项目中最忌讳的就是想当然地认为引脚功能。动手编码前花10分钟仔细阅读硬件手册和原理图能省下后面数小时的调试时间。我习惯在工程里放一个pinout.md文件记录每个用到的引脚及其复用功能。3. 系统整体设计与软件架构3.1 设计思路与流程框图面对一个综合性的项目清晰的顶层设计能让你在编码时不迷失方向。我的整体思路遵循典型的嵌入式系统控制流程采样 - 处理 - 决策 - 输出。初始化系统上电后首先初始化所有必要的硬件外设。这包括系统时钟、GPIO、ADC、定时器用于PWM生成以及串口。初始化顺序有讲究一般先时钟后外设。循环主体触发ADC采样可以设置为定时器触发规则、精确也可以在主循环里软件触发。对于这道题在主循环里周期性地启动单次转换即可。读取与滤波获取ADC转换结果。为了抑制噪声通常不会只采样一次就采用。我会连续采样N次比如16次或32次然后进行排序去掉最大最小值再取平均中位值平均滤波法这样得到的电压值非常稳定。电压值计算根据ADC的参考电压通常是VDDA3.3V和分辨率12位4096将滤波后的数字量转换为实际电压值。公式很简单Voltage (ADC_Value / 4095.0) * Vref。占空比映射根据题目要求将电压值映射到PWM的占空比。例如如果电压范围是0-3.3V对应PWM占空比0%-100%。那么映射公式为DutyCycle (Voltage / 3.3) * 100。这个占空比将用于主输出通道如TIM_CH1。更新PWM输出将计算出的占空比数值写入定时器的捕获/比较寄存器CCR1。互补通道的占空比通常是100 - DutyCycle但注意互补通道的极性可能独立配置实际效果需要结合极性设置来看。更关键的是必须同时使能主输出和互补输出。调试输出通过串口将当前采样值、计算出的电压和占空比打印出来方便在PC端用串口助手观察。3.2 外设配置要点与工具选择对于STM32开发配置外设有两种主流方式标准外设库LL/HAL和直接寄存器操作。在蓝桥杯比赛中官方提供的工程模板通常基于HAL库。HAL库的优势是抽象程度高配置简单但代码效率稍低且需要理解其回调机制。ADC配置关键参数是分辨率12位、对齐方式右对齐、采样时间根据信号源阻抗设置电位器可以设置长一点以获得更稳定结果如239.5个周期。工作模式选择单次转换由软件触发。定时器PWM配置这是核心。时钟与预分频确定PWM的频率。频率 定时器时钟 / ((PSC1) * (ARR1))。例如如果系统时钟是170MHz想要一个10kHz的PWM可以设置PSC169ARR99这样频率就是170M / (170*100) 10kHz。计数模式通常为向上计数。通道配置需要配置两个通道一个为主输出如CH1一个为互补输出如CH1N。模式都设置为“PWM模式1”或“PWM模式2”区别在于输出极性。需要仔细理解“有效电平”的概念。死区时间在互补输出配置中必须启用死区时间Dead Time。死区时间寄存器DTG的值需要根据系统时钟周期来计算插入几十纳秒到几微秒的延迟确保一对互补信号不会重叠。这是硬件保护至关重要。刹车功能高级定时器通常有刹车Break输入用于紧急关闭PWM输出。在本题中可以不启用但了解其存在很有必要。我个人的习惯是先用STM32CubeMX进行图形化配置生成初始化代码框架这样能避免在繁琐的寄存器配置上出错尤其是时钟树和引脚复用。然后在生成的代码基础上编写应用程序逻辑。4. 核心模块实现与代码详解4.1 ADC电压采集模块实现ADC模块的稳定性和准确性是整个系统的基础。除了基本的配置软件层面的处理更为关键。// 示例代码片段 (基于HAL库) #define ADC_SAMPLE_TIMES 32 uint32_t adc_filtered_value 0; float measured_voltage 0.0f; void ADC_GetVoltage(void) { uint32_t adc_buffer[ADC_SAMPLE_TIMES]; uint32_t sum 0; uint32_t i, j, temp; // 1. 采集一组样本 for(i 0; i ADC_SAMPLE_TIMES; i) { HAL_ADC_Start(hadc1); if(HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { adc_buffer[i] HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); HAL_Delay(1); // 简单延时避免采样过密 } // 2. 冒泡排序用于后续的限幅平均滤波 for(i 0; i ADC_SAMPLE_TIMES-1; i) { for(j 0; j ADC_SAMPLE_TIMES-1-i; j) { if(adc_buffer[j] adc_buffer[j1]) { temp adc_buffer[j]; adc_buffer[j] adc_buffer[j1]; adc_buffer[j1] temp; } } } // 3. 去掉最大最小各4个值然后求平均 sum 0; for(i 4; i ADC_SAMPLE_TIMES-4; i) { sum adc_buffer[i]; } adc_filtered_value sum / (ADC_SAMPLE_TIMES - 8); // 4. 转换为电压值 (假设Vref3.3V, 12位ADC) measured_voltage (adc_filtered_value / 4095.0f) * 3.3f; }实操心得滤波算法选择这里使用了“限幅平均滤波”结合了中位值和平均值的优点能有效抑制脉冲干扰和周期性噪声。对于缓慢变化的电位器信号效果很好。采样时机在主循环中最好以固定的时间间隔例如每50ms调用一次采样函数而不是疯狂连续采样。这有助于系统稳定也符合控制周期性的思想。参考电压Vref3.3V是一个理想值。如果项目对精度要求极高需要测量实际的VDDA电压或者使用单片机内部的参考电压VREFINT进行校准。STM32G4内部有VREFINT通道可以读取其在ADC上的读数反向推算出实际的VDDA。4.2 互补PWM生成与动态更新配置定时器输出互补PWM是本题的难点。下面以TIM1的通道1和互补通道1N为例。// 在CubeMX配置或代码中初始化 // htim1.Init.Prescaler 169; // 预分频 // htim1.Init.Period 99; // 自动重载值对应10kHz // htim1.Init.CounterMode TIM_COUNTERMODE_UP; // htim1.Init.RepetitionCounter 0; // 重复计数器高级定时器特有 // 通道配置 // sConfigOC.OCMode TIM_OCMODE_PWM1; // sConfigOC.Pulse 0; // 初始占空比对应CCR1寄存器 // sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; // CH1输出高电平为有效电平 // sConfigOC.OCNPolarity TIM_OCNPOLARITY_HIGH; // CH1N输出高电平为有效电平 // sConfigOC.OCFastMode TIM_OCFAST_DISABLE; // sConfigOC.OCIdleState TIM_OCIDLESTATE_RESET; // sConfigOC.OCNIdleState TIM_OCNIDLESTATE_RESET; // 死区时间配置 (非常重要) // sBreakDeadTimeConfig.OffStateRunMode TIM_OSSR_DISABLE; // sBreakDeadTimeConfig.OffStateIDLEMode TIM_OSSI_DISABLE; // sBreakDeadTimeConfig.LockLevel TIM_LOCKLEVEL_OFF; // sBreakDeadTimeConfig.DeadTime 72; // 死区时间值需要计算 // sBreakDeadTimeConfig.BreakState TIM_BREAK_DISABLE; // sBreakDeadTimeConfig.BreakPolarity TIM_BREAKPOLARITY_HIGH; // sBreakDeadTimeConfig.BreakFilter 0; // sBreakDeadTimeConfig.AutomaticOutput TIM_AUTOMATICOUTPUT_DISABLE; // 启动PWM输出 HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(htim1, TIM_CHANNEL_1); // 启动互补通道 // 动态更新占空比的函数 void PWM_UpdateDutyCycle(float duty_cycle_percent) { // 确保占空比在0-100范围内 if(duty_cycle_percent 0.0f) duty_cycle_percent 0.0f; if(duty_cycle_percent 100.0f) duty_cycle_percent 100.0f; // 计算CCR寄存器的值 uint32_t pulse (uint32_t)((duty_cycle_percent / 100.0f) * (htim1.Init.Period 1)); // 更新主通道占空比 __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, pulse); // 互补通道的占空比通常是 Period - pulse但效果取决于极性设置。 // 更常见的做法是只设置主通道互补通道硬件自动生成互补信号。 // 但有些题目要求独立控制则需要显式设置CCR2等。 }死区时间计算详解 死区时间寄存器DTG是8位的但其编码方式特殊。以定时器时钟tDTS为例通常等于定时器输入时钟。若DTG[7:5]0xx死区时间 DTG[6:0] * tDTS。若DTG[7:5]10x死区时间 (64 DTG[5:0]) * tDTS * 2。若DTG[7:5]110死区时间 (32 DTG[4:0]) * tDTS * 8。若DTG[7:5]111死区时间 (32 DTG[4:0]) * tDTS * 16。假设tDTS 1/170MHz ≈ 5.88ns我们需要插入约500ns的死区时间。 500ns / 5.88ns ≈ 85。这个值落在第一个区间。所以可以设置DTG 85二进制01010101。在CubeMX中DeadTime参数直接填85即可它会自动处理位域。关键提醒互补PWM输出后一定要用示波器测量肉眼观察LED的亮度变化只能定性判断PWM有无输出但频率、占空比是否准确、互补信号是否真的互补、死区时间是否插入必须靠示波器验证。这是区分“以为对了”和“真的对了”的唯一标准。5. 系统集成与调试技巧实录5.1 主程序逻辑与闭环控制将各个模块组合起来形成完整的控制循环。我的main函数通常结构如下int main(void) { // HAL库初始化、系统时钟配置通常由CubeMX生成 HAL_Init(); SystemClock_Config(); // 外设初始化 MX_GPIO_Init(); MX_ADC1_Init(); MX_TIM1_Init(); MX_USART2_UART_Init(); // 启动外设 HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); HAL_TIMEx_PWMN_Start(htim1, TIM_CHANNEL_1); printf(System Start.\r\n); // 重定向printf到串口 float voltage 0; float duty 0; while (1) { // 1. 采集电压 ADC_GetVoltage(); voltage measured_voltage; // 2. 映射为占空比 (示例0-3.3V - 0%-100%) duty (voltage / 3.3f) * 100.0f; // 3. 更新PWM输出 PWM_UpdateDutyCycle(duty); // 4. 串口打印调试信息 (可降低打印频率避免刷屏) static uint32_t print_tick 0; if(HAL_GetTick() - print_tick 200) { // 每200ms打印一次 print_tick HAL_GetTick(); printf(ADC: %lu, Voltage: %.3fV, Duty: %.1f%%\r\n, adc_filtered_value, voltage, duty); } // 5. 简单延时控制循环周期 HAL_Delay(50); // 主循环周期约50ms } }这个循环清晰地体现了“感知-决策-执行”的过程。通过调整HAL_Delay的时间你可以改变系统的控制频率。5.2 调试过程中遇到的典型问题与解决在实际实现中几乎一定会遇到各种问题。下面是我踩过的一些坑和解决方法问题1ADC采样值跳动剧烈电压读数不稳。现象串口打印的电压值在小数点后两位不断跳动即使电位器不动。排查首先检查硬件确保电位器接触良好供电稳定。检查ADC采样时间是否太短。对于电位器这类高阻抗源需要较长的采样时间让内部采样电容充放电稳定。将采样周期Sampling Time从默认的1.5 Cycles改为239.5 Cycles效果立竿见影。软件滤波不够强。增加了采样次数从10次到32次并采用了更稳健的限幅平均滤波法。检查PCB布局模拟信号走线是否远离数字噪声源如时钟线、PWM输出线。问题2PWM输出正常但互补通道没有信号。现象用示波器看主通道有PWM波但互补通道一直是高电平或低电平。排查引脚复用检查这是最常见的原因。确认你配置的TIM_CHxN引脚是否正确映射到了你实际测量的排针引脚上。STM32的引脚复用功能很灵活容易配错。互补输出使能函数你调用HAL_TIM_PWM_Start启动了主通道但必须调用HAL_TIMEx_PWMN_Start来启动互补通道。这两个函数是独立的。刹车和死区配置检查刹车Break功能是否被意外使能这可能会强制关闭互补输出。确保死区时间配置没有错误极端的死区时间设置可能导致互补通道完全无输出。输出极性主通道和互补通道的极性OCPolarity和OCNPolarity设置组合决定了最终波形。如果设反了可能看起来像是没有输出。最稳妥的方法是结合示波器反复测试“高电平有效”和“低电平有效”几种组合。问题3死区时间插入后波形占空比不对了。现象设置占空比为50%但用示波器测量高电平时间明显小于半个周期。分析这是对死区时间工作原理的误解。死区时间是在一对互补信号跳变的边缘插入的短暂延迟。例如当主通道要从高变低、互补通道要从低变高时硬件会先让主通道变低然后延迟一个死区时间再让互补通道变高。这段延迟时间两个通道都处于无效电平通常是低电平。因此从整体上看有效的“高电平”时间被压缩了。这是正常且期望的行为它的目的是确保在任何瞬间上下两个功率管都不会同时导通。计算有效占空比时应以主通道的高电平时间为准。问题4更新PWM占空比后波形有时会“卡顿”或产生毛刺。现象在动态调整CCR寄存器值时用示波器观察到PWM波偶尔会丢失一个脉冲或产生非正常的窄脉冲。解决这是因为在定时器计数器CNT正在运行的时候直接写入CCR寄存器可能存在风险。最佳实践是使用定时器的“预装载”功能。在HAL库中__HAL_TIM_SET_COMPARE函数在大多数情况下是安全的因为它会检查并可能使用预装载机制。但为了绝对可靠可以在更新CCR前确保使用__HAL_TIM_SET_AUTORELOAD和__HAL_TIM_SET_COMPARE配合并利用TIM_CR1寄存器中的ARPE自动重装载预装载使能位。更简单的方法是在CubeMX中配置通道时勾选“Fast Mode”禁用并确保“Pulse”的设置是写入预装载寄存器。6. 功能验证与性能优化思考6.1 如何系统性地验证功能功能实现后不能只满足于“代码编译通过、LED会闪”。必须设计一套验证方案ADC线性度测试工具数字万用表、串口助手。方法旋转电位器在多个位置如0V, 1.0V, 2.0V, 3.3V暂停。用万用表测量电位器中间引脚的真实电压同时记录串口打印的软件计算电压。对比两者计算误差。理想情况下误差应小于0.1V。如果误差呈线性可能是参考电压不准如果非线性可能是ADC采样时间或滤波算法问题。PWM参数验证工具示波器必备。频率验证测量PWM波一个周期的时长换算成频率看是否与设定值如10kHz相符。占空比验证固定电压输入测量PWM高电平时间与周期的比值。改变电压观察占空比是否线性变化。互补性与死区验证这是关键。将示波器的两个探头分别接到主输出和互补输出通道设置为上升沿触发。放大波形跳变沿你应该能清晰地看到一段两个通道都是低电平或无效电平的区域这就是死区时间。用示波器的测量功能量出这个时间看是否与配置值吻合。系统联动测试缓慢旋转电位器观察示波器上两路PWM的占空比是否一增一减平滑变化。尝试快速拨动电位器观察PWM输出的响应速度和稳定性。这可以检验你的主循环周期和控制逻辑是否合理。6.2 从“实现”到“优化”的进阶思路当基本功能跑通后可以思考如何让它更“工业级”、更可靠ADC精度提升参考电压校准如前所述使用内部VREFINT通道校准VDDA。在代码初始化阶段读取VREFINT的ADC值已知其典型电压如1.212V反算出实际的VDDA用于后续所有电压计算。过采样与分辨率提升如果你需要高于12位的分辨率可以采用过采样技术。以4倍过采样为例连续采样4次累加结果右移1位除以2可以得到等效的13位分辨率数据能有效提高测量精度尤其是对微小变化的识别。软件校准在已知的零点和满量程点如0V和3.3V进行采样记录下ADC的原始值。在实际测量时使用两点校准公式进行线性插值可以消除ADC的偏移和增益误差。PWM输出稳定性与保护占空比更新同步确保在定时器计数器溢出更新事件时更新CCR值可以避免波形出现“毛刺”。HAL库的__HAL_TIM_SET_COMPARE函数通常会在底层处理同步问题但了解其原理有益无害。你可以通过使能更新中断在中断服务程序里更新CCR。加入软件刹车逻辑除了硬件刹车引脚可以在软件中设置保护条件。例如持续检测ADC值如果电压超过安全范围如大于3.5V立即强制停止PWM输出调用HAL_TIM_PWM_Stop并点亮报警LED。系统架构优化中断驱动 vs 轮询当前主循环是轮询方式。可以将ADC采样改为由定时器触发并在ADC转换完成中断中读取数据、计算电压、更新PWM。这样主循环就解放出来可以处理其他任务如按键扫描、显示刷新系统响应更及时。引入RTOS如果系统功能变得更复杂可以考虑引入FreeRTOS等实时操作系统。可以创建不同的任务例如一个高优先级任务专门处理ADC和PWM控制控制任务一个低优先级任务处理串口打印和人机交互。任务间通过队列传递电压数据这样模块间耦合更低代码更清晰。完成这个项目你收获的远不止是ADC和PWM的配置代码。你经历了一个完整的嵌入式软硬件开发流程需求分析、硬件认知、模块设计、编码实现、调试排错、功能验证。这种从问题到解决方案的完整闭环思维以及使用示波器、万用表等工具进行硬件调试的能力才是嵌入式工程师的核心价值所在。下次再遇到类似“根据XX传感器控制YY输出”的问题你完全可以自信地说这个框架我熟。