简介:便携式肌电信号交互采集及云端平台,基于C语言实现,面向毕业设计、课程设计及嵌入式物联网项目开发,适合电子、生物医学工程或计算机相关专业的学生及开发者。方案覆盖肌电信号放大、STM32F103 ADC连续采集、蓝牙5.0无线传输、安卓移动端动态显示与边缘计算,以及云端上传的完整链路;源码已经严格测试,可放心参考并在此基础之上二次开发。压缩包共1009个文件,包含561个C源文件、250个头文件、51个汇编文件、28个链接脚本和26个目标文件,同时附带Keil工程、QT界面工程、Android NDK配置及项目文档,整体约27.46MB,目录结构按功能模块划分,便于按需学习检索。目前已有181人学习。学习本项目可同时获得硬件选型与电路设计思路、STM32底层驱动与蓝牙通信实现、QT安卓端多线程交互设计,以及边缘计算与云端数据对接的完整项目经验,是一份综合性强、可直接落地的实用参考资料。
1. 为什么我把肌电信号采集做成移动端+边缘计算的组合
我在做便携式肌电(SEMG)交互设备时,最先遇到的不是信号放大,而是「采集端和显示端到底该放什么算力」。传统方案是把ADC数据直接塞给上位机,但便携设备的蓝牙带宽有限,频繁传原始波形既费电又容易卡顿。这个项目把C语言写的采集逻辑放在STM32F103上,用DMA连续扫描ADC,BLE 5.0把数据推到安卓平板,移动端做边缘计算,只把特征值和必要波形上传云端。你可以把它理解成「前端采集、边缘计算、云端存储」三段式架构,适合做毕业设计、课程设计,也适合想快速搭建康复评估或人机交互原型的开发者。下面我把每一层的关键代码和参数选择拆开讲,尤其注意DMA缓冲区长度和边缘计算里的滤波队列,这两个位置踩坑最多。
2. STM32F103采集链路:DMA连续扫描、滤波与蓝牙转发
2.1 硬件选型与信号链路
肌电信号的带宽通常在20Hz到500Hz,幅度只有几毫伏,所以前置放大器必须解决共模抑制和直流偏置问题。自研放大器输出数字量后,我直接接到STM32F103RDT6的ADC输入。蓝牙5.0模块选的是透传型,串口速率设为115200bps,在2.4GHz频段下用短包发送,这样安卓平板能稳定接收。整个硬件链路如下:
| 硬件模块 | 型号/规格 | 职责 |
|---|---|---|
| 肌电放大器 | 自研,增益可调,差分输入 | 放大SEMG信号,数字量输出 |
| 主控 | STM32F103RDT6,72MHz | DMA采集、状态机、串口控制 |
| 蓝牙模块 | BLE 5.0 透传模块 | 无线串口,推送到移动端 |
| 交互终端 | 安卓9.0平板/手机 | 动态显示、边缘计算、上传云端 |
主控用DMA而不是中断逐点采样,是为了保证采样间隔抖动在纳秒级。F103的ADC在12位分辨率下最高采样率约1MHz,实际SEMG采样率我只设了1kHz,每次DMA传输半字(16位),缓冲区大小设成1024,刚好覆盖1秒数据。这样CPU几乎不用参与搬运,可以在主循环里处理按键和OLED状态机。
2.2 DMA连续扫描ADC的C语言实现
采集核心代码用标准外设库写,重点在DMA循环模式配置。以下是我的初始化片段:
void ADC_DMA_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; DMA_InitTypeDef DMA_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_BUF_LEN; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); }DMA_Mode_Circular决定了缓冲区写满后自动回到起始地址,这是连续采样的关键。ADC_SampleTime_239Cycles5对应约4.7µs采样时间,配合1kHz的采样率绰绰有余。注意DMA_BufferSize必须和adc_buffer数组长度一致,我定义成uint16_t adc_buffer[ADC_BUF_LEN],ADC_BUF_LEN为1024。如果缓冲区太小,比如64,蓝牙传输SDK还没来得及读走一半数据,DMA就覆盖了旧数据,波形会出现周期性毛刺。
2.3 状态机按键去抖与OLED显示
采集端除了ADC,还要处理按键切换采集模式和待机模式。直接延时去抖会阻塞主循环,影响DMA数据读取。我用一个简单的四状态机:
typedef enum {KEY_IDLE, KEY_PRESS_DETECT, KEY_DEBOUNCE, KEY_RELEASE} KeyState; KeyState state = KEY_IDLE; uint8_t press_cnt = 0; void Key_Scan(void) { uint8_t level = GPIO_ReadInputDataBit(KEY_PORT, KEY_PIN); switch(state) { case KEY_IDLE: if(level == 0) { state = KEY_PRESS_DETECT; press_cnt = 0; } break; case KEY_PRESS_DETECT: if(level == 0) { if(++press_cnt >= 5) { // 连续5次采样都为低 state = KEY_DEBOUNCE; Mode_Change(); // 切换采集/待机 } } else { state = KEY_IDLE; } break; case KEY_DEBOUNCE: if(level == 1) state = KEY_RELEASE; break; case KEY_RELEASE: if(level == 1) state = KEY_IDLE; else state = KEY_DEBOUNCE; break; } }状态机的好处是每次调用只消耗固定几条指令,不阻塞DMA。OLED状态显示我用的是I2C接口,刷新率只有10Hz,只显示模式、电池电量和蓝牙连接状态,避免和ADC中断抢总线。这里需要强调:OLED显示不要放在DMA传输完成中断里,否则会使ADC数据搬运延时,我是在主循环里按100ms周期刷新的。
3. 移动端动态显示:QT5.12.3下的多线程与边缘计算
3.1 蓝牙5.0数据接收与解析
安卓端我用QT 5.12.3的Android for Arm64-v8a组件,蓝牙通过QBluetoothSocket接收。蓝牙透传模块发来的数据流是原始ADC值,每帧我定义为「帧头(0xAA) + 通道号 + 高度字节 + 低度字节 + 校验和」,共5字节。接收端在readyRead信号里按字节解析:
void BleReceiver::onReadyRead() { QByteArray data = socket->readAll(); rxBuffer.append(data); while(rxBuffer.size() >= 5) { if((uint8_t)rxBuffer[0] != 0xAA) { rxBuffer.remove(0, 1); continue; } uint8_t sum = (uint8_t)rxBuffer[1] + (uint8_t)rxBuffer[2] + (uint8_t)rxBuffer[3]; if(sum != (uint8_t)rxBuffer[4]) { rxBuffer.remove(0, 1); continue; } uint16_t adc = ((uint8_t)rxBuffer[2] << 8) | (uint8_t)rxBuffer[3]; emit adcReady(adc); rxBuffer.remove(0, 5); } }rxBuffer是QByteArray,用于缓存不完整包。校验失败只丢一个字节,重新寻找帧头,这样即使蓝牙丢了一个包,下一个完整帧还能对齐。注意这里不能用QDataStream的固定包长读取,因为蓝牙流式通道没有消息边界。
3.2 时域/频域特征计算:边缘计算的核心
边缘计算的意义在于,移动端不需要把每个ADC原始值都上传云端。以1kHz采样率、24小时连续运行为例,原始数据量约172MB,而上传RMS、均值、过零率等特征值,一天还不到1MB。我实现了三个C语言函数,通过JNI或QT的C++封装调用。
首先是滑动窗口RMS(均方根),用来表征肌肉收缩强度:
// winsize 窗口长度,一般取20~50对应20~50ms float calc_rms(const float *buf, int start, int winsize) { float sum = 0.0f; for(int i=0; i<winsize; i++) { float v = buf[start + i]; sum += v * v; } return sqrtf(sum / winsize); }RMS计算每20ms更新一次,正好匹配肌肉发力到收缩的生理延时。接着是过零率,用于区分静息和收缩。由于噪声会导致微小抖动使信号反复穿越零点,我设置了一个阈值带:
int calc_zcr(const float *buf, int len, float threshold) { int zcr = 0; for(int i=1; i<len; i++) { if((buf[i-1] > threshold && buf[i] < -threshold) || (buf[i-1] < -threshold && buf[i] > threshold)) { zcr++; } } return zcr; }阈值设成信号满量程的5%,这个值在滤波后效果最好。我在QT里的边缘计算线程维护一个QVector<float>环形队列,每收到一个ADC值就push_back,超过1024就pop_front,RMS和ZCR都在队列上计算。这样既不阻塞UI线程,也能保证实时性。
3.3 多线程与UI刷新隔离
蓝牙接收、边缘计算和UI刷新必须分开。我用QThread派生一个EdgeWorker类,蓝牙收到数据后通过信号槽发给EdgeWorker,EdgeWorker处理完再发射featureReady信号给主界面。但QT的信号槽如果连接类型是默认的Auto,跨线程会排队,数据量大会积压。我明确设置了连接类型为Qt::DirectConnection,并保证EdgeWorker内部使用mutex保护环形队列:
// 主线程中连接 connect(bleReceiver, &BleReceiver::adcReady, edgeWorker, &EdgeWorker::processData, Qt::DirectConnection);DirectConnection让adcReady信号直接在工作线程的上下文中执行processData,省掉事件循环调度。但要注意bleReceiver对象本身在哪个线程?我统一把蓝牙接收器也move到worker线程,这样整个链路都在一个线程里跑,没有竞争。UI端用queuedConnection接收特征值,每50ms刷新一次曲线。
4. 云端平台:数据上传与可视化的协议落地
4.1 数据协议:JSON只有一层最省心
移动端计算出的特征值要上传云端,我用JSON格式,因为服务端解析方便。但嵌套层级太多会导致解析慢,对移动端电量不友好。我的上传payload只保留两层:
{ "device_id": "semg_001", "timestamp": 1716192000, "channel": 1, "features": { "rms": 0.42, "zcr": 23, "mean": 0.03 }, "wave": [120, 125, 128, 110] }wave字段只在用户主动抓拍时带上,默认只传512个点,避免每帧都传。服务端我先用Flask搭建,表结构如下:
CREATE TABLE semg_features ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, timestamp INTEGER NOT NULL, channel INTEGER NOT NULL, rms REAL, zcr INTEGER, mean REAL, wave TEXT );wave用逗号分隔字符串存储,查询时再拆。如果数据量大了,再换时序数据库。这个表足够毕业设计或小规模原型的查询需求。
4.2 移动端上传实现(HTTP + MQTT两种方案)
QT 5.12.3自带QNetworkAccessManager,上传JSON很方便。但设备网络不稳定时,TCP长连接更可靠,我项目里选了HiveMQ的MQTT开源server,移动端用QMQTT客户端库。下面是HTTP上传的简化版:
void CloudUploader::uploadFeature(const QJsonObject &json) { QNetworkRequest request(QUrl("http://your-server/api/upload")); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); QJsonDocument doc(json); QNetworkReply *reply = manager->post(request, doc.toJson(QJsonDocument::Compact)); connect(reply, &QNetworkReply::finished, this, [=]() { if(reply->error() == QNetworkReply::NoError) { qDebug() << "upload ok"; } else { qDebug() << "upload error" << reply->errorString(); } reply->deleteLater(); }); }上传失败时,我把未上传的JSON按时间戳存入本地SQLite,启动时检查是否有未同步数据,然后补传。这就是常见的离线缓存策略,但注意SQLite写入频率不要太高,每5秒批量写一次,避免损耗闪存。
4.3 云端接口与数据查询
服务端我是用Flask + SQLite实测的,接口接收POST后解析JSON,核心逻辑只有几行:
@app.route('/api/upload', methods=['POST']) def upload(): data = request.get_json() # 插入数据库 conn.execute( "INSERT INTO semg_features (device_id, timestamp, channel, rms, zcr, mean, wave) VALUES (?,?,?,?,?,?,?)", (data['device_id'], data['timestamp'], data['channel'], data['features']['rms'], data['features']['zcr'], data['features']['mean'], json.dumps(data['wave'])) ) conn.commit() return {'status': 'ok'}查询接口设计成/api/features?device_id=semg_001&start=...&end=...,返回JSON数组,前端用ECharts画趋势曲线。移动端为了省电,只在WiFi环境下做自动上传,蜂窝网络下只上传RMS和ZCR,wave字段不上传。
5. 从测试到延展:采样率、缓冲区与NDK版本匹配
5.1 采样率和缓冲区的匹配
很多同学在F103上把采样率设到8kHz,然后发现蓝牙传不过来。BLE透传模块的有效吞吐约2KB/s(115200波特率下大约1.1KB/s),8kHz × 2字节 = 16KB/s,完全超过蓝牙承载。所以我坚持1kHz半字采样,每秒2KB数据,蓝牙透传还有约200B/s的余量做帧头校验。如果你要更高的采样率,必须在电路板上增加DMA半满中断,每传一半就启动蓝牙DMA搬运,但这样CPU占用会上升。另外ADC时钟要设置在12MHz以下,因为F103在12位模式下ADCCLK最高14MHz,超过会采样不准确。
5.2 蓝牙丢包与数据连续性
蓝牙在2.4GHz环境下偶尔丢包是正常的,但丢包会造成波形缺口。我的方案是在移动端检测帧序号,如果发现序号跳变,就启动一个线性插值填充缺失点。插值代码很简单:
// 两个有效点之间插值N个点,保持时间轴连续 void interpolate_missing(uint16_t *output, uint16_t prev, uint16_t next, int missing) { for(int i=1; i<=missing; i++) { output[i-1] = prev + (next - prev) * i / (missing + 1); } }插值只适合短间隙,如果连续丢包超过10个,我会标记这一段数据不可用,在上传云端时打上"quality": "degraded"标记。实际测试中,BLE 5.0在10米距离、无遮挡下丢包率小于0.5%,所以插值基本不会触发。
5.3 NDK版本与C库函数
最后提醒一下NDK版本。QT 5.12.3的Android组件要求NDK r19,如果你用r21或更高,链接C标准库时会出现__android_log_print未定义的错误。我用的android-ndk-r19c-windows-x86_64刚好匹配。另外,在JNI层保存ADC数组指针时,要确保C语言内存管理不出问题。我分配的是静态数组,不动态malloc,避免在Java和C++间传递复杂类型导致内存泄漏。
如果你想扩展这个项目,建议先跑通「采集1秒波形 -> 边缘RMS -> HTTP上传 -> 云端查询」的最小链路,再加频域分析(FFT)或手势识别。频域分析可以在移动端用Arm的CMSIS-DSP库,F103上已经自带了libarm_cortexM3l_math.a,直接用即可。但要注意FFT长度选256或512,对应频率分辨率约4Hz和2Hz,能覆盖SEMG的50Hz~150Hz主频段。
本文还有配套的精品资源,点击获取