简介:一套以STM32微控制器为核心的机器人假肢肌电项目资料,面向嵌入式开发、生物医学工程及机器人爱好者,重点解决如何采集人体肌电信号并驱动伺服电机模仿手势的工程问题。压缩包共包含十八个文件,整体大小约十一兆,其中有五个C语言源文件、六份PDF资料,以及多个传感器数据手册与滤波模块说明,覆盖从信号采集、滤波处理到电机驱动的完整开发链路。源码部分展示了双传感器采集、信号平滑滤波、控制器初始化和脉宽调制控制等关键模块;文档部分则提供参考手册、传感器规格和假肢结构说明,能帮助读者理解硬件选型依据与底层驱动原理。目前已有130人学习下载,适合具备C语言基础、希望深入实践嵌入式系统与生物信号处理的开发者,是一份兼顾理论讲解与实际工程的综合项目参考。
1. EMG假肢手项目,绕不开的不是电机而是信号调理
一个EMG假肢手的电路拓扑,和一个五自由度舵机玩具几乎一样:主控、电源、几路PWM、若干伺服电机。差距全在最上游那几百毫伏的信号上——肌肉收缩时皮肤表面的电位只有几十微伏到几毫伏,20Hz到500Hz以外全是噪声,伺服电机一动作,电流冲击还会顺着供电线窜回采样端。所以这个项目的功夫不在电机,而在信号调理、特征提取和手势状态切换这三段。下面按感知、决策、执行三条线拆开写。如果你有STM32裸机开发和C语言基本功,跑通整条链路要不了太久;想真正戴在手臂上稳定演示,还得在阈值和供电上多花几个小时。常见毕业设计里基于STM32的假肢手,核心也就是这件事:把五路舵机变成一只手,再用两路EMG信号告诉它该抓还是该放。
2. STM32上的EMG信号调理与特征提取
2.1 为什么EMG信号要先放大整流滤波,而不是直接接ADC
表面EMG是差分信号源,两个信号电极贴在肌肉肌腹处,读出的是皮肤表面的电位差。它的幅度范围大致在几十微伏到几毫伏,比STM32的ADC量程低得多:12位ADC、3.3V参考电压,每个LSB约0.8mV,原始信号直接接进去会被量化噪声淹没,软件里做多少倍增益都救不回来,因为低于量化台阶的细节已经丢了。因此在ADC之前必须做硬件放大,常见做法是用仪表放大器实现差分输入,增益设在500到1000倍。这样1mV的肌电信号被放大到0.5V到1V,落在ADC最灵敏的中段区间。
放大器之后还要做一次带通滤波。运动伪迹和电极极化电压集中在5Hz以下,会把放大器的动态范围占掉大半;工频和无线电干扰则在高频段进入放大器。我在第一版里直接买带放大和滤波的表面肌电模块,输出0到3.3V的模拟量,三根线接到ADC就能用,省掉一周的模拟前端调试时间。等整条链路跑通,回头再自己搭仪表放大电路也不迟,那时你已经知道每一级滤波应该放在哪、带宽设置多少才合适。
提示:自己搭前端时,两个信号电极要顺着肌肉走向贴,参考电极固定在手腕骨性位置;参考电极不要贴在肌肉上,否则共模信号会被当作差模信号放大,波形看起来像噪声一样乱跳。
2.2 让ADC采到可分析的肌电信号:采样率、参考电压与增益
EMG能量集中在20到500Hz,理论采样率1kHz就能满足奈奎斯特定理,我一般用2kHz,给数字滤波留出过渡带,也让特征窗口能取到整数个采样点。下面是一份可以直接照抄的参数表:
| 参数 | 数值 | 说明 |
|---|---|---|
| 采样率 | 2000 Hz | 覆盖EMG有效频段并留余量 |
| ADC位宽 | 12 bit | STM32F1系列原生支持 |
| 参考电压 | 3.3 V | VDDA要单独滤波,不能直接接电源 |
| 硬件增益 | 1000 | 1mV信号放大到约1V |
| 高通截止 | 20 Hz | 去掉运动伪迹和基线漂移 |
| 低通截止 | 500 Hz | 去掉高频噪声和混叠 |
ADC的参考电压直接决定量化分辨率。3.3V除以4096,每个LSB约0.8mV,放大后的信号最大接近1V,约占量程四分之一,既不会满幅削波,又有足够的信噪比。采样方式我习惯用定时器触发加DMA循环,CPU完全不用管数据搬运:
#define EMG_BUF_LEN 256 static uint16_t emg_raw[EMG_BUF_LEN]; void emg_start(void) { // TIM6 每 500us 触发一次 ADC,转换结果由DMA写入缓冲区 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)emg_raw, EMG_BUF_LEN); HAL_TIM_Base_Start(&htim6); }这里的定时器配置是72MHz主频、预分频71、自动重载值499,计数频率1MHz,周期正好500微秒,对应2kHz采样率。DMA模式必须设置成Circular循环,缓冲区写满后自动回到开头,否则采集一会儿就停了。第二个参数强制转换成uint32_t*是HAL库的接口限制,实际写入的是uint16_t值,高四位在12位模式下恒为0。
2.3 在C语言里实现带通滤波与MAV/RMS特征帧
ADC原始值里混着直流偏置、基线漂移和高频毛刺,直接算特征值会得到一个虚高的常数。常见做法是软件里先做一阶高通,把20Hz以下的基线成分切掉,再做全波整流,最后在滑动窗口内累计出包络。
#define HP_A 0.94f // 2kHz采样率下,对应截止频率约20Hz static float hp_x1 = 0.0f; static float hp_y1 = 0.0f; float emg_highpass(float input) { float y = HP_A * (hp_y1 + input - hp_x1); hp_x1 = input; hp_y1 = y; return y; }一阶高通的系数来源是RC滤波器的离散化:RC约8ms,采样周期0.5ms,a = RC / (RC + T) ≈ 0.94。这个滤波器在每个采样点上只有一次乘法和两次加法,放在中断里做都毫无压力。高通输出整流之后不需要再加数字低通,后面的特征窗口本身就是滑动平均,会把整流后的纹波平滑成一条缓慢变化的包络线。
窗口长度选择决定响应速度和稳定性的平衡。128ms窗口对应256个采样点,我让窗口每次滑动64ms,也就是50%重叠,特征更新率约15Hz。
typedef struct { float mav; float rms; uint16_t zc; } EmgFeature; void emg_extract_feature(const float *filtered, uint16_t len, EmgFeature *feat) { float sum_abs = 0.0f, sum_sq = 0.0f; uint16_t zc = 0; for (uint16_t i = 1; i < len; i++) { sum_abs += fabsf(filtered[i]); sum_sq += filtered[i] * filtered[i]; if (filtered[i - 1] < 0.0f && filtered[i] >= 0.0f) { zc++; } } feat->mav = sum_abs / (float)len; feat->rms = sqrtf(sum_sq / (float)len); feat->zc = zc; }MAV在中等力度时已经能清晰区分激活和静息,RMS对收缩峰值更敏感,高强度时区分度比MAV好。过零率暂时只做参考,不参与阈值判断,等到后面要区分捏合和握拳时,可以用它辅助判断收缩的平稳程度。这段代码用到了fabsf和sqrtf,记得包含math.h。
2.4 静息基线标定:给阈值一个不是拍脑袋的起点
把激活阈值写成固定值是EMG项目里最容易翻车的地方。换一个使用者、重新贴一次电极、或者手臂摆放姿势不同,基线噪声和信号幅度都会变,硬编码阈值在实验室能用,换个人就失灵。常见做法是上电后做一个短标定:手臂完全放松,采集200ms的滤波后数据,计算基线的均值和标准差,然后按四倍标准差定激活阈值。
typedef struct { float mean; float sigma; float active_th; float release_th; } Baseline; void calibrate_baseline(const float *samples, uint16_t len, Baseline *b) { float sum = 0.0f; for (uint16_t i = 0; i < len; i++) sum += samples[i]; b->mean = sum / (float)len; float var = 0.0f; for (uint16_t i = 0; i < len; i++) { float d = samples[i] - b->mean; var += d * d; } b->sigma = sqrtf(var / (float)len); b->active_th = b->mean + 4.0f * b->sigma; b->release_th = b->mean + 2.0f * b->sigma; }激活阈值取四倍标准差,理论上静息信号超过它的概率只有万分之三左右,误触发的可能性很低。释放阈值取两倍标准差,是为了制造迟滞:进入激活的门槛高,退出激活的门槛低,信号在临界区域抖动时通道状态保持稳定。标定时如果发现滤波后均值明显不为零,先检查传感器偏置、放大器和ADC参考电压,不要急着调阈值。EMG本身是交流信号,整流后静息均值应该接近零,均值偏高说明前面还有直流问题没解决。
3. 手势识别与状态机:两路EMG怎么变成稳定的手势意图
3.1 两通道分出手势:屈肌和伸肌的组合
电极贴放位置直接决定分类上限。通道1贴在前臂屈侧的肌腹处,握拳时指浅屈肌和掌长肌收缩明显,MAV升高;通道2贴在伸侧对应指总伸肌,伸掌时MAV升高;参考电极固定在手腕骨性位置。两个通道的高、低组合可以分出四类状态:
| 手势 | 通道1(屈侧) | 通道2(伸侧) | 执行目标 |
|---|---|---|---|
| 静息 | 低 | 低 | 保持当前角度 |
| 握拳 | 高 | 低 | 四指弯曲 |
| 伸掌 | 低 | 高 | 四指伸展 |
| 捏合 | 中高 | 低 | 拇指和食指对捏 |
握拳和捏合都由屈肌产生,区别在于收缩强度。所以判定时不能只用低、高两档,还要看通道1的MAV是中还是高,这需要再设一个中等强度的判定档位。捏合时肌肉动员的运动单位少,MAV大约只有握拳的六到七成,经验值是在激活阈值上加一档,具体倍数按实际测量标定。屈腕动作会让两块肌群同时收缩,两个通道同时变高,这个手势在两通道方案里很难区分,先避开不做,可靠性比覆盖面更重要。
3.2 阈值迟滞和连续帧稳定
单独一个特征帧不能作为切换依据。手势识别里最常见的故障是抖动手环式来回切换——肌肉力量在阈值附近轻微波动,一帧高一帧低,舵机就会在握拳和伸掌之间疯狂摆动。我一般用一个极小的状态机:每帧先算出原始手势,如果连续两帧一致才切换当前手势,否则保持原状。
typedef enum { GRIP_IDLE = 0, GRIP_FIST, GRIP_OPEN, GRIP_PINCH } GestureId; typedef struct { GestureId current; GestureId candidate; uint8_t keep_cnt; } GestureState; void gesture_update(GestureState *gs, GestureId raw) { if (raw == gs->candidate) { gs->keep_cnt++; if (gs->keep_cnt >= 2) { gs->current = raw; gs->keep_cnt = 0; } } else { gs->candidate = raw; gs->keep_cnt = 0; } }这个候选加确认的机制把单帧误判过滤掉:肌肉偶然的抽动最多影响一帧,下一帧立刻恢复原状,不会造成舵机动作跳变。代价是大约一帧到两帧的额外延迟,对假肢手来说完全在可接受范围内。
判定每帧原始手势之前,通道激活状态要单独维护,不能每帧直接拿最新MAV和阈值比大小。我单独维护一个active状态变量:
bool ch_active_update(EmgChannel *ch, float mav) { if (!ch->active && mav > ch->baseline.active_th) ch->active = true; if (ch->active && mav < ch->baseline.release_th) ch->active = false; return ch->active; }两个阈值构成迟滞回路,激活阈值和释放阈值之间的区间不会产生高速翻转,通道状态输出比原始MAV值稳定得多,15Hz的特征更新率不会让手势结果跟着抖。
3.3 手势到舵机角度的映射表
确认手势后,下一步是把它翻译成五路舵机的目标角度。五舵机假肢手把拇指简化成一个弯曲自由度,其余四指各一个。角度映射我直接写成常量表,每行对应一个手势,每列对应一根手指的期望角度。
typedef enum { F_THUMB = 0, F_INDEX, F_MIDDLE, F_RING, F_LITTLE } FingerId; /* 手势角度表:行是手势,列是手指,单位是度 */ static const float gesture_angles[4][5] = { /* GRIP_IDLE */ { 90.0f, 90.0f, 90.0f, 90.0f, 90.0f }, /* GRIP_FIST */ { 20.0f, 10.0f, 10.0f, 10.0f, 10.0f }, /* GRIP_OPEN */ {170.0f, 170.0f, 170.0f, 170.0f, 170.0f }, /* GRIP_PINCH */ { 90.0f, 15.0f, 160.0f, 160.0f, 160.0f }, }; void apply_gesture(GestureId g) { for (int i = 0; i < 5; i++) { set_finger((FingerId)i, gesture_angles[g][i]); } }捏合手势让拇指保持中立,食指弯曲到15度,中间三指微微弯曲,形成指尖对捏的姿态。表里的角度写的是舵机输出轴的目标角度,舵机装上假肢手后安装方向可能相反,这一点留到PWM映射层统一处理,不要在表里填负角度或者反向值,那样多调几次就会混乱。
4. 用STM32定时器输出多路PWM,驱动伺服电机手
4.1 舵机要的是0.5~2.5ms脉冲,不是任意占空比
机械臂里说的伺服电机分两类:工业伺服带编码器和驱动器,走脉冲串或总线控制,位置闭环在驱动器内部完成;假肢手用的舵机则是直流电机加减速箱加电位器反馈,全部集成在一个小塑料壳里,控制方式简单得多——20ms一个周期,高电平宽度决定输出轴角度。
| 脉宽 | 舵机角度 |
|---|---|
| 0.5ms | 0° |
| 1.0ms | 45° |
| 1.5ms | 90° |
| 2.0ms | 135° |
| 2.5ms | 180° |
高电平宽度和角度的对应关系由舵机内部电位器反馈电路决定,不是标准PWM占空比。把PWM频率配成1kHz是新手最容易犯的错误,舵机会发烫、抖动甚至冒烟。假肢手指尖的动作速度本来就要求不高,50Hz的刷新率远够用。
4.2 定时器分频与引脚映射:五路PWM的具体配置
STM32F103主频72MHz,要输出20ms周期的PWM,配置PSC=71后计数频率变成1MHz,ARR=19999,计数器每增加1正好是1微秒。0.5ms脉宽对应比较值500,2.5ms对应2500,角度计算非常直观。
引脚分配要避开ADC输入和调试接口。我常用的方案是TIM2加TIM3组合出五路:
| 定时器 | 通道 | 引脚 | 手指 |
|---|---|---|---|
| TIM2 | CH1 | PA0 | 拇指 |
| TIM2 | CH3 | PA2 | 食指 |
| TIM2 | CH4 | PA3 | 中指 |
| TIM3 | CH1 | PA6 | 无名指 |
| TIM3 | CH2 | PA7 | 小指 |
TIM2的CH2(PA1)特意留空不用,这样可以避免和附近外设的复用冲突。五个PWM通道同时启动时,定时器之间不需要同步,舵机对微秒级的相位差异根本不敏感。
4.3 角度到比较值映射与PWM初始化
在CubeMX里把预分频和自动重载值填好,PWM映射到对应引脚后,初始化代码只需要启动通道并让所有手指回到90度中立位。
void servos_init(void) { HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_3); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_4); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2); set_finger(F_THUMB, 90.0f); set_finger(F_INDEX, 90.0f); set_finger(F_MIDDLE, 90.0f); set_finger(F_RING, 90.0f); set_finger(F_LITTLE, 90.0f); }角度到比较值的换算集中在一个函数里:
#define CCR_MIN 500 /* 0.5ms 脉宽 */ #define CCR_MAX 2500 /* 2.5ms 脉宽 */ uint16_t angle_to_ccr(float deg) { if (deg < 0.0f) deg = 0.0f; if (deg > 180.0f) deg = 180.0f; return (uint16_t)(CCR_MIN + (deg / 180.0f) * (CCR_MAX - CCR_MIN)); } void set_finger(FingerId id, float deg) { uint16_t ccr = angle_to_ccr(deg); switch (id) { case F_THUMB: __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, ccr); break; case F_INDEX: __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, ccr); break; case F_MIDDLE: __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_4, ccr); break; case F_RING: __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, ccr); break; case F_LITTLE: __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, ccr); break; } }set_finger的switch看起来冗长,但好处是所有引脚映射关系集中在一个函数里,换板子或者调整通道时只改这一处。更新比较值的时机放在特征帧更新里,不要在ADC中断里逐路修改。若舵机在目标角度附近来回嗡鸣,可以让当前角度和目标角度的差值小于3度时跳过更新,减少舵机齿轮的磨损。
提示:角度范围两端要留出至少10度到20度的机械余量。廉价舵机持续顶着限位会堵转发热,电流上升后不仅损坏舵机,还会把供电电压拉低。
4.4 舵机供电独立:信号地和功率地必须共地
舵机同时启动的瞬时电流超过1A很常见,尤其是从静止状态突然加速的那一下。如果和信号电路共用同一路LDO,电压跌落会直接体现在ADC采样值上,肌电波形叠加周期性尖峰,特征值忽高忽低。常见做法是舵机使用独立电源,比如7.4V锂电池接BEC降压到5V或6V,信号电路单独用3.3V线性稳压,两者之间只保留一个共地连接点。单点共地既保证控制信号有参考地平面,又避免功率电流流过信号地产生干扰。
舵机电源端加一颗470微法以上的电解电容,放在舵机接线插座附近,用于吸收启停瞬间的电流冲击。调试阶段如果USB一插、舵机一动MCU就复位,优先怀疑电源而不是程序。USB口的供电能力只有500mA左右,假肢手的动力不要从USB取,USB只留给调试器和MCU供电。
5. 排错与验证:先查信号链,再查电机
5.1 把滤波前后的波形捞出来看
信号链路没有确认之前,不要去调分类阈值。上电后先让串口以特征帧速率输出MAV和RMS,在PC端用串口绘图器看波形。手动握拳时波形幅度应该随力度明显上升,松开后回到基线附近。
printf("mav:%d rms:%d\n", (int)(emg_feat.mav * 1000.0f), (int)(emg_feat.rms * 1000.0f));波形纹丝不动就查ADC配置和DMA是否启动;波形乱跳就查供电、参考电压和电极贴放;波形干净清楚只剩分类错误时,再去调迟滞阈值。这一步能省掉至少半天盲目排错的时间。
5.2 ST-Link连不上的踩坑流程
遇到no stm32 target found或者提示debug authentication相关的报错时,先看SWDIO、SWCLK、GND、3.3V四根线是否连接正确,再看目标板是不是没上电、NRST有没有被外接电路拉低。排除了物理连接之后,最常见的隐藏问题是程序里把SWD引脚重映射成了普通GPIO,下载器访问不到内核。处理办法是按住复位键后点击下载,在下载器开始连接的瞬间松开;如果不行,把BOOT0拉高重新上电,目标板进入ISP模式,用串口工具执行全片擦除,再恢复BOOT0并重新烧录。这个流程在舵机项目里几乎每台设备都遇到过,毕竟GPIO不够时最容易借用的就是PA13和PA14。
5.3 伺服手的第一轮全行程自检
整机联调前先跑一遍舵机自检,按顺序让每根手指完成90度到0度、再到180度、最后回到90度的往复。
void servo_self_test(void) { const FingerId fingers[5] = {F_THUMB, F_INDEX, F_MIDDLE, F_RING, F_LITTLE}; for (int i = 0; i < 5; i++) { set_finger(fingers[i], 90.0f); HAL_Delay(500); set_finger(fingers[i], 180.0f); HAL_Delay(500); set_finger(fingers[i], 90.0f); HAL_Delay(500); } }这轮自检能同时暴露线序、安装方向和机械干涉三类问题,也能验证供电余量是否足够,如果多点同时运动时出现复位或抽动,说明电源容量不足。若发现PWM通道和手指对应错位,直接调整set_finger里的通道映射,或者在映射表里统一翻转角度方向;角度方向问题不要在多个地方分别修复,统一在映射层改,后面的手势表才不会跟着乱。
本文还有配套的精品资源,点击获取