简介:基于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 中断,在中断里只做计数和置位事件标志,主循环检测到标志后去扫描按键、采 ADC:
volatile 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-20ms,2ms 的采样周期配合后面的消抖算法能兼顾及时性和抗干扰。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、周期 99,72MHz / 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,并且带上-mcpu=cortex-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 exit"interface/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关掉或降级成二进制帧。验证阶段看量值,发布阶段砍开销,这个顺序不要反。
本文还有配套的精品资源,点击获取