拓冰建站拓冰建站
首页 / 资讯中心 / 正文

手持按摩仪源码实战:状态机与PWM调压的嵌入式设计

简介基于C/C编写的手持按摩仪完整源码包面向嵌入式开发者和智能硬件爱好者适用于需要了解新唐003主控下按摩设备完整软硬件协同工作的场景。资源以zip压缩包形式提供整体约17.04MB内容覆盖按摩仪源码、光疗应用、微电压调节与充电管理四大核心模块。源码部分包含硬件驱动程序、揉捏/敲击等控制算法、用户操作接口及安全保护逻辑可用于学习电机转速方向控制、LED亮色调节以及电池涓流/恒流/恒压三阶段充电切换。微电压调节相关代码能帮助理解电量监测与电压稳定策略适合作为嵌入式项目参考或二次开发基础。目前已有366人学习下载适合具备一定C/C基础、希望深入新唐MCU开发的读者参考实践。1. 手持按摩仪源码用 C/C 做本质是一套按键与振动电机的状态机市面上能买到的手持按摩仪硬件成本其实很低一个偏心振动电机、一块锂电池、一个按键或触摸片、几十行代码就能让它转起来。但真正决定体验的是软件——按下开关不能“啪”地直接冲到最高档松手不能像断电一样立刻没力电池快没电时要降档提醒而不是突然停机这些全是固件代码在管。“手持按摩仪源码”这个标题里 C/C 指明了这是一套单片机工程通常在 STM32、GD32、CH32 这类 Cortex-M 核上跑。做这个方向的人主要是嵌入式入门开发者、做小家电方案的公司工程师以及选“嵌入式综合项目”做毕业设计的学生。这类项目有个反直觉的结论它不需要 RTOS不用上 Linux连模式切换都算不上什么算法核心就是一套围绕按键检测和 PWM 调压的状态机。下面把这套源码从分层到参数到烧录验证完整拆开。2. 源码先分层手持按摩仪固件的工程骨架与主循环提到嵌入式源码很多人第一反应是去看 Linux 内核源码或者复杂的外设驱动库但手持按摩仪这种小家电固件是另一个量级全部代码加起来通常不到 3000 行外设只有 GPIO、定时器、ADC 和 PWM。源码组织得好不好直接决定后期改档位、加指示灯逻辑时会不会把原有的按键处理改崩。2.1 三层结构bsp、driver、app源码不乱的前提我一般把这类项目分成三层对应三个文件夹bsp/ clock.c # 时钟树、SysTick 初始化 gpio.c # 引脚复用、上下拉配置 driver/ motor.c # 电机 PWM 输出、档位映射 button.c # 按键消抖、长短按识别 battery.c # 电池电压 ADC 采样与滤波 app/ main.c # 主循环、事件分发 state_machine.c # 关机/待机/工作状态切换bsp 层做的事情是寄存器级别的初始化比如把某引脚复用为定时器通道设置时钟分频driver 层把外设封装成函数比如motor_set_level(2)、button_get_event()app 层只负责业务逻辑它不直接操作寄存器。这样做的最大好处是换一颗 MCU 时bsp 和 driver 改动app 层完全不用碰。这里正好是 C 与 C 分工的典型场景。C 的类封装很适合 driver 层比如写一个Motor类内部持有定时器句柄、当前占空比、目标档位对外只暴露setLevel()和rampLoop()但在 Cortex-M0 这类低端核上要避免虚函数、STL 容器和动态内存分配这些特性会引入不确定的栈开销。状态机这一层我个人坚持用 C 写因为逻辑本身就是 switch-case用 C 的继承多态去建模反而是过度设计。结论是驱动层可以 C状态机层用 C 风格混合编译时用extern C包住中断回调函数即可。2.2 主循环加定时器事件不用 RTOS 的理由这类设备对实时性的要求其实很低按键响应几十毫秒内完成、PWM 占空比变化以 10ms 为粒度用户都感知不到。用裸机主循环加事件标志比引入 RTOS 更稳妥——线程切换、优先级反转、栈分配这些成本在 32KB Flash 的单片机上完全不划算。常见做法是开一个 1ms 的 SysTick 中断在中断里只做计数和置位事件标志主循环检测到标志后去扫描按键、采 ADCvolatile uint32_t g_tick_ms; volatile uint8_t g_event; #define EVENT_BUTTON (1u 0) #define EVENT_ADC (1u 1) void SysTick_Handler(void) { g_tick_ms; if ((g_tick_ms % 2u) 0u) { g_event | EVENT_BUTTON; /* 每 2ms 扫描一次按键 */ } if ((g_tick_ms % 50u) 0u) { g_event | EVENT_ADC; /* 每 50ms 采样一次电池电压 */ } } int main(void) { bsp_init(); motor_init(); button_init(); while (1) { if (g_event EVENT_BUTTON) { g_event ~EVENT_BUTTON; button_scan(); } if (g_event EVENT_ADC) { g_event ~EVENT_ADC; adc_sample_battery(); } } }中断里只干两件事累加 tick 和按位与设置事件标志不做按键滤波、不读 ADC、不调用printf。因为中断里一旦执行耗时逻辑主循环的 PWM 斜坡更新就会被延迟表现出来的症状是电机加速一顿一顿的。“2ms 扫一次按键”这个值不是拍脑袋定的——按键按下到稳定通常需要 5-20ms2ms 的采样周期配合后面的消抖算法能兼顾及时性和抗干扰。50ms 采一次电池是因为锂电池电压变化很慢采太快只会引入更多电机启停带来的噪声。2.3 状态机定义关机、待机、工作三态手持按摩仪的模式看起来多但抽象到状态层面只有三个关机、待机电机停转但系统在运行、工作电机按档位转动。状态转移完全由按键事件驱动状态进入条件状态行为离开条件关机上电或长按关机电机停转进入低功耗模式长按 2 秒进入待机待机长按开机电机停转等待按键短按进入工作长按关机工作从待机短按进入输出当前档位 PWM短按换挡长按关机无手持超时回待机对应代码typedef enum { ST_POWER_OFF, ST_STANDBY, ST_RUNNING } state_t; static state_t state ST_POWER_OFF; void state_machine_run(uint8_t long_press, uint8_t short_press) { switch (state) { case ST_POWER_OFF: if (long_press) { state ST_STANDBY; led_set(LED_ON); } break; case ST_STANDBY: if (short_press) { state ST_RUNNING; motor_set_level(MOTOR_LEVEL_1); } if (long_press) { state ST_POWER_OFF; motor_off(); } break; case ST_RUNNING: if (short_press) { motor_next_level(); /* 档位递增到最高档回到 1 档 */ } if (long_press) { state ST_POWER_OFF; motor_off(); } break; default: state ST_POWER_OFF; break; } }这段代码的关键点是“状态机本身不执行耗时操作”它只做两件事根据事件切换状态、调用驱动函数修改输出。长按和短按事件是由按键模块算好之后再喂给状态机的状态机自己不关心按键怎么消抖、按了多久。这样分层后如果产品经理要求增加“待机 30 秒自动关机”只需要在待机分支里加一个定时判断不需要碰按键代码。这就是“源码”值钱的地方——不是代码量大而是改动时只用动一处。3. PWM 控制参数与锂电池保护手持按摩仪振动质感的真正来源按摩仪的振动电机本质是一个带偏心轮的直流有刷电机转速由施加在电机两端的有效电压决定。电压越高偏心轮转得越快振幅和频率都变大。单片机没法直接输出连续可调的电压所以用 PWM 调压——固定频率下改变占空比等效电压跟着变。这一章把频率、占空比、电池采样的参数讲透振动质感和续航都藏在这里。3.1 偏心振动电机的 PWM 参数选型PWM 的频率不能随便设。低于 20kHz电机线圈和机械结构会把开关噪声变成人耳能听见的啸叫这就是很多廉价按摩仪“嗡鸣声明显”的原因太高也不行超过 50kHz 后 MOS 管的开关损耗明显上升在锂电池供电的小设备上会白白消耗电量。实际方案通常定在 20kHz-30kHz。STM32 上配置一个 20kHz、占空比分辨率为 100 级的 PWM 大致如下#define PWM_FREQ_HZ 20000u #define PWM_PERIOD 100u /* 占空比分辨率 0-100 */ static void motor_pwm_init(void) { GPIO_InitTypeDef io; TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; /* 1. 配置 PWM 输出引脚为定时器复用推挽 */ io.GPIO_Pin MOTOR_PWM_PIN; io.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(MOTOR_PWM_PORT, io); /* 2. 定时器计数周期 主频 / 分频 / 目标频率 */ tim.TIM_Prescaler 1u; tim.TIM_Period (uint16_t)(PWM_PERIOD - 1u); tim.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(MOTOR_TIM, tim); /* 3. 配置 PWM1 模式初始占空比 0 */ oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_Pulse 0u; oc.TIM_OutputState TIM_OutputState_Enable; TIM_OC1Init(MOTOR_TIM, oc); TIM_Cmd(MOTOR_TIM, ENABLE); }定时器的计数频率由TIM_Prescaler和TIM_Period共同决定计数频率 定时器时钟 / (分频 1) / (周期 1)。如果定时器时钟是 72MHz分频设 1周期设 99得到的频率是72MHz / 2 / 100 360kHz这太高了。实际上分频应该按72MHz / 20000Hz 3600的总分频比来拆比如预分频 35、周期 9972MHz / 36 / 100 20kHz。上面代码里的预分频值只是示例实际硬件上要根据选用的 MCU 时钟树重新计算。占空比档位的选择更讲究。偏芯电机有个特性占空比太低时电机转不起来线圈只会嗡嗡发热。我常用的四档参数是档位占空比振动表现场景1 档30%低频轻柔日常放松2 档50%中等力度久坐按摩3 档70%高强振动肌肉深层放松4 档100%最高转速剧烈运动后30% 是多数小型偏芯电机能稳定起转的下限再低就不可靠了。档位用查表法映射而不是让用户直接输入占空比是为了确保所有档位都落在电机稳定工作区间static const uint8_t level_duty[5] {0, 30, 50, 70, 100}; void motor_set_level(uint8_t level) { if (level 4u) level 1u; target_duty level_duty[level]; }需要注意的是单片机 IO 口直接驱动不了电机。PWM 信号必须经过三极管或 MOS 管驱动电路才能接到电机上源码里能控制的只是定时器的比较寄存器输出硬件上要有电机驱动电路代码里同时需要留一个MOTOR_ENABLE_PIN用于彻底切断电机电源。3.2 斜坡启动防止开机瞬间拉垮电池电压直接让占空比从 0 跳到 100%电机的启动电流会是额定电流的 5-8 倍。锂电池保护板通常有 2-3A 的过流保护瞬间大电流可能直接触发保护表现为“按开机键电机抖一下就没反应了”。解决办法是软件斜坡启动每次只把占空比加减 1每隔 10ms 执行一次从 0 到 100% 需要 1 秒左右。static uint8_t current_duty; static uint8_t target_duty; static uint8_t ramp_active; void motor_ramp_loop(void) { if (!ramp_active || current_duty target_duty) { return; } if (current_duty target_duty) { current_duty 1u; } else { current_duty - 1u; } TIM_SetCompare1(MOTOR_TIM, current_duty); }motor_ramp_loop()放在主循环里每 10ms 被调用一次。这里的参数值得细说步进值 1、间隔 10ms对应的升速速率是每秒 100 级。如果觉得电机加速太慢可以把间隔缩到 5ms如果把步进改成 2加速度翻倍但启动电流也会相应变大。这个参数没有统一标准我的经验是电机从静止到最高转速控制在 300ms-800ms 之间手感最好太短会有冲击感太长会让人以为机器坏了。3.3 锂电池电压采样与降档保护锂电池的放电特性是满电 4.2V标称 3.7V低于 3.0V 后继续放电会损伤电芯。问题是电机转动时电池电压会被拉低如果在这种情况下采样读到的电压比真实开路电压低不少可能出现“刚开机还有电一振动就报警关机”的假象。#define BAT_LOW_MV 3300u #define BAT_SHUT_MV 3000u static void adc_sample_battery(void) { uint32_t sum 0u; for (uint8_t i 0u; i 8u; i) { ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); sum ADC_GetConversionValue(ADC1); } /* 8 次均值再按参考电压和分压电阻比换算成毫伏 */ battery_mv (sum / 8u) * 1200u / 4096u; } void battery_monitor_loop(void) { adc_sample_battery(); if (battery_mv BAT_LOW_MV) { return; } if (battery_mv BAT_SHUT_MV) { motor_off(); system_sleep(); } else { /* 低电量时强制降到 1 档 */ motor_set_level(MOTOR_LEVEL_1); led_blink_slow(); } }8 次采样取平均是为了滤掉 ADC 本身的随机噪声1200 这个系数由分压电阻比决定换硬件要重算。还有一个容易踩的坑ADC 采样时刻要避开 PWM 输出高电平的区间。电机在 PWM 导通瞬间抽取大电流电池电压瞬时下跌此时采样读到的值会偏低几百毫伏。常见做法是把采样安排在主循环里并且让采样与 PWM 的占空比更新错开比如 PWM 每 10ms 更新一次ADC 在 PWM 更新后 2ms 再触发。这样采到的电压更接近电池的真实荷电状态。4. 手动按摩仪的按键手感消抖、长短按与防误触源码“手动按摩仪”的手动两个字在源码里对应的就是按键交互逻辑。这个设备的全部操作都靠一个或两个按键完成按键处理写得好不好直接决定用户对产品“手感”的评价按下有没有立刻响应、长按会不会误触、放在包里会不会自己开机。4.1 非阻塞消抖别用 delay 卡主循环机械按键按下和松开时触点会弹跳 5-20ms期间电平反复变化。最简单的方式是按下后延时 20ms 再读一次但这种方式有两个代价一是阻塞了主循环导致 PWM 斜坡更新卡顿二是没法同时处理长按逻辑。正确做法是用非阻塞消抖算法——连续在相同电平上稳定超过一定时间才认为按键状态有效#define BTN_DEBOUNCE_MS 15u static uint8_t btn_stable; static uint8_t btn_raw_prev; static uint32_t btn_debounce_tick; uint8_t button_get_raw(void) { return GPIO_ReadInputDataBit(BTN_PORT, BTN_PIN); } void button_scan(void) { uint8_t raw button_get_raw(); if (raw ! btn_raw_prev) { /* 电平发生了变化说明可能处于抖动中重置计时 */ btn_raw_prev raw; btn_debounce_tick 0u; return; } if (btn_debounce_tick BTN_DEBOUNCE_MS) { btn_debounce_tick; return; } /* 电平连续 15ms 不变认为按键稳定 */ btn_stable raw; }button_scan()在 2ms 的定时事件里被调用所以btn_debounce_tick每次自增代表 2ms。15ms 的消抖时长能覆盖绝大多数按键的弹跳区间同时又不会让快速连按被吞掉。这个算法跟阻塞延时消抖的关键区别在于它每一轮都立即返回主循环不会因为按键抖动而卡住状态机照常跑PWM 斜坡照常更新。4.2 长短按识别手动档位切换的交互核心有了稳定的按键状态下一步就要从一次按压动作中区分短按、长按和关机长按。以 2 秒为长短按分界超过 3 秒判定为关机#define LONG_PRESS_MS 2000u #define OFF_PRESS_MS 3000u typedef enum { KEY_NONE 0, KEY_SHORT, KEY_LONG, KEY_OFF } key_event_t; key_event_t button_get_event(void) { static uint8_t pressed 0u; static uint8_t long_sent 0u; static uint8_t off_sent 0u; static uint32_t press_start_ms 0u; key_event_t ev KEY_NONE; if (btn_stable BTN_DOWN) { if (!pressed) { /* 检测到按下记录起始时刻 */ pressed 1u; press_start_ms g_tick_ms; } else { uint32_t duration g_tick_ms - press_start_ms; if (duration OFF_PRESS_MS !off_sent) { off_sent 1u; ev KEY_OFF; /* 3 秒关机 */ } else if (duration LONG_PRESS_MS !long_sent) { long_sent 1u; ev KEY_LONG; /* 2 秒长按只触发一次 */ } } } else { if (pressed !long_sent !off_sent) { ev KEY_SHORT; /* 松开时未达到长按阈值 */ } pressed 0u; long_sent 0u; off_sent 0u; } return ev; }短按事件在“松开”时判定这与“按下时立即判定”有个微妙的体验差异如果按下 100ms 就松开用户可能希望立刻换挡但事件要等松开才发出来。为了让短按更跟手可以把判定提前到按下 50ms 后振动脉冲宽度足够确认不过那样会牺牲部分抗抖能力。我的取舍是短按在松开时判定但把消抖时间压缩到 15ms整体响应延迟在 100ms 内人几乎感知不到。长按事件只在阈值到达的那一刻返回一次不会每秒重复触发——这是long_sent标志的作用。三次事件分三个时长手动操作时不会出现“想换挡却直接把机关了”的误判。4.3 防误触放在包里不能自己开机手持按摩仪很容易被塞进包里和钥匙、硬币挤在一起如果按键做在侧面开机判定只有长按 2 秒还不够——包内挤压也可能触发。单纯靠长按并不可靠因为挤压的力度和时间都不可控。更稳的做法是“开机确认”或“手持检测”。开机确认的实现是第一次长按后进入一个 2 秒的确认窗口窗口内再短按一次才真正进入待机模式否则回到关机。这个交互在耳机仓、电动牙刷上已经普及用户学习成本低。另一个更贴合“手持”语义的方案是检测人手是否握持了机身。小家电上最便宜的手持检测是电容触摸触摸电极贴在机身握持区域人手触摸时电容变化超过阈值判定为被手持#define TOUCH_THRESHOLD 120u int hand_hold_detect(void) { uint16_t raw touch_read_raw(); return (raw TOUCH_THRESHOLD) ? 1 : 0; }在 RUNNING 状态下主循环每 500ms 调一次hand_hold_detect()连续 6 次检测到无手持约 3 秒就自动回待机关停电机void safety_check_loop(void) { static uint8_t no_hand_count 0u; if (state ! ST_RUNNING) { no_hand_count 0u; return; } if (!hand_hold_detect()) { no_hand_count; if (no_hand_count 6u) { no_hand_count 0u; state_machine_run(0u, 0u); /* 内部处理超时回待机 */ motor_set_level(MOTOR_LEVEL_0); } } else { no_hand_count 0u; } }3 秒的无手持延时是个平衡值太短会误判用户可能只是换了下手太长则失去安全意义。如果不想加触摸电极也可以用加速度传感器检测机身静止但成本和代码量都会上一个量级。触摸检测在资源受限的小家电上是最常见的方案。5. 从源码到板子工程目录、构建与一条串口验证技巧5.1 工程目录与 VS Code 构建配置写代码和把代码烧进板子是两回事。先看工程目录的组织方式hand-massage-fw/ ├── app/ │ ├── main.c │ └── state_machine.c ├── driver/ │ ├── motor.c │ ├── button.c │ └── battery.c ├── bsp/ │ ├── clock.c │ └── gpio.c ├── build/ │ └── Makefile └── README.md构建工具链通常用 arm-none-eabi-gcc 配合 Makefile 或 CMake。在 VS Code 里配置 C/C 环境时重点不是安装多少个插件而是把编译器路径、头文件路径、预定义宏三个地方配对。以 STM32F103 为例C/C 扩展的c_cpp_properties.json里要加上USE_STDPERIPH_DRIVER和STM32F10X_MD这两个宏否则标准外设库的头文件会被条件编译直接跳过代码里全是“头文件找不到”的红色波浪线。tasks.json 里最常见的坑是把构建命令直接写成gcc这句话在宿主机上没问题但交叉编译必须用arm-none-eabi-gcc并且带上-mcpucortex-m3 -mthumb这类目标架构参数。C 工程还要加-fno-exceptions -fno-rtti这两个选项能显著减小固件体积小家电场景用不到异常处理和运行时类型识别。如果 driver 层用了 C 类中断回调函数又必须用 C 链接记得在头文件里这样包一层#ifdef __cplusplus extern C { #endif void SysTick_Handler(void); void TIM1_UP_IRQHandler(void); #ifdef __cplusplus } #endif5.2 烧录、验证与串口日志定位法编译通过不代表逻辑正确。烧录用 OpenOCD 一行命令完成openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c program build/fw.elf verify reset exitinterface/stlink.cfg指定调试器型号target/stm32f1x.cfg指定目标芯片内核program后面的参数一次完成擦除、写入、校验和复位。如果手里是 DAPLink把第一个-f换成interface/cmsis-dap.cfg即可。板子能跑之后验证按键和 PWM 是否正确最有效的技巧是加一段串口日志在 PWM 斜坡完成的回调里把当前状态、目标占空比、电池电压三个关键值打成一行文本void motor_ramp_done_callback(void) { char buf[48]; snprintf(buf, sizeof(buf), %d %d %d\r\n, (int)state, (int)current_duty, (int)battery_mv); uart_send_string(buf); }然后在串口助手里观察输出。按下短按键日志切换顺序应该是1 30 3900这样的三连变长按关机时能看到状态先到0占空比从当前值逐步归零而不是直接跳变。这一行日志同时验证了状态机、PWM 斜坡和 ADC 采样三块逻辑比用示波器去勾 PWM 波形更直接。我习惯把日志函数放在斜坡完成的回调里而不是放在状态机事件处理里因为斜坡完成意味着驱动层真正执行完了命令等日志确认没问题后再用条件编译把snprintf关掉或降级成二进制帧。验证阶段看量值发布阶段砍开销这个顺序不要反。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门