最近写了不少程序,记录一个软件上面的设计,目前是将解析好的数据通过 USB发送给上位机,其中数据打包的思路是把结构化的多通道数据转换为一维字节流,并加入“帧头和帧尾”以确保上位机能够准确断帧和对齐频道。
现在的数据属于连续的高频流数据,如果直接发送 ASCII 字符串(如使用 printf),会因为字符转换效率低、数据量翻倍而导致卡顿。因此,必须发送原始二进制字节(Binary Data)。
现在设计的数据包(共 38 个字节):
字节偏移 | 字段名称 | 数据类型 | 说明 |
|---|---|---|---|
0 ~ 1 | 帧头 (Header) | uint8_t | 固定为 0xAA, 0x55(用于上位机对齐查找包头) |
2 | 包计数器 (Counter) | uint8_t | 0 ~ 255 循环,用于上位机检测是否丢包 |
3 ~ 34 | 通道数据 (Payload) | int32_t × 8 | 8 个通道的 32 位脑电数据(8 × 4 = 32 字节) |
35 | 校验和 (Checksum) | uint8_t | 前 35 字节的累加和,用于检测数据传输错误 |
36 ~ 37 | 帧尾 (Tail) | uint8_t | 固定为 0x0D, 0x0A (\r\n,符合多数串口软件换行习惯) |
利用 C 语言结构体的内存连续性,可以直接打包:
// 强制编译器不进行内存对齐优化,确保结构体大小严格等于 38 字节
#pragma pack(1)
typedef struct {
uint8_t header[2]; // 帧头 0xAA, 0x55
uint8_t packet_cnt; // 包计数器
int32_t channels[8]; // 8通道数据 (32字节)
uint8_t checksum; // 校验和
uint8_t tail[2]; // 帧尾 0x0D, 0x0A
} EEG_Packet_t;
#pragma pack()
// 全局变量
EEG_Packet_t TxPacket;
uint8_t global_packet_counter = 0;
这段是数据的容器,要装在这里,接下来看数据的装载:
void Pack_And_Send_EEG(int32_t *parsed_channels)
{
// 装载帧头和帧尾
TxPacket.header[0] = 0xAA;
TxPacket.header[1] = 0x55;
TxPacket.tail[0] = 0x0D;
TxPacket.tail[1] = 0x0A;
// 装载计数器并自增
TxPacket.packet_cnt = global_packet_counter++;
// 复制解析好的 8 通道 int32_t 数据 (共 32 字节)
memcpy(TxPacket.channels, parsed_channels, sizeof(TxPacket.channels));
// 计算校验和 (从 header 到 channels 的所有字节累加)
uint8_t sum = 0;
uint8_t *ptr = (uint8_t *)&TxPacket;
for(int i = 0; i < 35; i++) // 前 35 个字节
{
sum += ptr[i];
}
TxPacket.checksum = sum;
/* 串口发送 (阻塞或 DMA 模式均可,这里以阻塞演示) */
// 参数:串口句柄, 结构体指针强转为uint8_t*, 整个包大小, 超时时间
HAL_UART_Transmit(&huart1, (uint8_t *)&TxPacket, sizeof(TxPacket), 10);
/*USB CDC 虚拟串口发送 */
// 需要包含 #include "usbd_cdc_if.h"
// CDC_Transmit_FS((uint8_t *)&TxPacket, sizeof(TxPacket));
}

看这个设计
一般也不写这个,但是数据实在太多了,有时候数据会卡死:
包计数器(Packet Counter)在底层的运行机制非常简单,可以用“排队报数”来形容;在MCU内部,它只是一个普通的 uint8_t(8位无符号整型)全局变量,每当单片机成功组装并发送完一个数据包,这个变量的值就 +1,因为 uint8_t 的最大值是 255,当它加到 255 之后,如果再加 1,由于硬件溢出,它会自动重新归零(变成 0);因此,它发送出去的序列看起来是这样的:0, 1, 2, 3 ... 254, 255, 0, 1, 2 ... 循环往复。
上位机在收到当前包的计数器(假设是 N)时,会把它存下来,并与下一个收到的包计数器(假设是 M)进行对比;正常情况是M = N + 1(或者当 N=255 时,M=0),这说明数据传输非常完美,中间没有丢失;如果刚才收到的包是 5,现在收到的包变成了 8,上位机通过简单的减法就可以知道:8 - 5 - 1 = 2,这意味着在传输过程中漏掉了 2 个包。
有了计数器上位机可以实时统计丢包率(丢包数 / 总包数),如果发现丢包率过高,系统可以提醒用户“请检查线缆连接”或“降低采样率”。
最重要的是保证时间轴的连续性,大多数波是一种随时间连续变化的,上位机画波形图时,是默认点与点之间的时间间隔是相等的;如果中间丢了 1 个包,而上位机不知道,就会直接把第 3 个包和第 1 个包连起来;反映在屏幕上,就是波形瞬间发生了一个非自然的“突变/抖动”,这会直接毁掉后面的滤波算法和频谱分析(如 FFT 傅里叶变换)。
我们上位机需要考虑这个问题,而且有大量的算力可以解决,但要注意:
丢包补偿只能恢复时间位置,不能恢复真实信号,插值数据必须始终带有“无效/估计”标记,不能伪装成真实采样值。

例如上一包为254,当前包为1:
因此丢失2包,即255和0。
Python中可以写成:
distance = (current_seq - previous_seq) & 0xFF
lost_packets = distance - 1
这里的 & 0xFF 相当于对256取模。
上面写的是:
每当单片机成功组装并发送完一个数据包,计数器加1。
这句话不够严谨,更严谨的说法是:
每次采集系统生成一个应当发送的数据包时,就为它分配唯一的序号。
例如:
packet.seq = packet_counter++;
enqueue_tx_packet(&packet);
如果 MCU 内部发生以下问题:ADC采集完成,但发送队列已经满了;DMA发送任务来不及处理;某个数据包在 MCU 内部被覆盖;BLE发送接口返回忙,程序直接放弃该包;如果计数器只在“实际发送成功”后递增,上位机看到的序号仍然连续,反而无法发现 MCU 内部已经丢失过采样数据。
8位序号最多只能表示256种状态。如果一次断开时间太长,丢失超过255包,上位机无法知道究竟循环了多少次。
例如上一包为5,当前包仍为5,可能表示:收到重复包;恰好丢失了255包;设备重新启动;丢失了511包,有条件使用最低16位序号;更推荐32位
sample_index。
32位计数器即使采样率为1 kSPS,也可以连续运行约:
天
如果每个数据包包含多个采样点,持续时间还会更长。
假设采样率为:
采样周期:
真实时间应该由采样序号决定:
假设采样序列是:
100、101、102、105、106
说明103、104缺失;如果绘图程序只是把收到的数据依次放在相邻位置:
显示位置:0、1、2、3、4
真实位置:100、101、102、105、106
那么时间轴被压缩了8 ms。后续所有数据都会向前错位,因此真正需要恢复的是时间位置:
100、101、102、缺失、缺失、105、106
这里不一定必须填写一个数值,但必须保留两个缺失位置。
如果丢失采样点直接从序列里消失,后面的时间轴被压缩。此时数据已经不再是严格均匀采样:
而普通FFT默认:
因此直接进行FFT,频率和相位分析都会产生误差。
补0可以恢复样本数量和时间位置,但在信号中引入了突然下降到0的边沿。这会产生宽带频谱泄漏,并激励IIR滤波器的瞬态响应。
前值保持通常不会产生立刻跳到0的巨大阶跃,但会形成一段水平信号,并在真实数据恢复时产生跳变。
短缺口下通常比较平滑,但会人为生成一段近似直线,相当于局部低通处理,高频成分会被压低。
既然可以找到哪里缺了数据,那就可以补回来,这里讨论三个方案
公式为:
适合实时显示;缺口极短;信号变化相对缓慢;不能等待后一个样本到达;缺点是会产生平台;恢复时可能产生阶跃;高频信号误差较大;不能作为真实测量数据。
若两端真实值为 和 ,中间缺失 个点,则第 个插值为:
例如缺失两个点:
则插值分别为:
适合:短缺口;平滑显示;低频趋势恢复;离线处理或允许等待下一包的实时显示;但插值值是估算,不是真实测量;这个缺口越长,可信度越低;肌电等高频、随机性较强的信号不适合长区间插值;可能人为减少功率谱中的高频能量;可能制造不存在的斜坡,插值结果最好保留浮点数,避免额外量化,只有在最后输出到整数格式时才转换。
这是保存和科研分析中最可靠的基础方法:
data[missing_position] = np.nan
valid[missing_position] = False
明确:
这里没有测量数据。
有了 NaN,不能简单把NaN直接送进IIR滤波器,如果把 NaN 直接送入普通 IIR 滤波器:
y = filter.process(np.nan)
由于IIR滤波器有反馈状态,NaN可能进入内部状态,之后所有输出都变成NaN。
IIR滤波器一般形式:
一旦历史项中出现NaN,后续计算会持续受到污染。
不应该对所有长度的缺口都使用同一种插值,假设缺失时间为:
可以根据任务设置策略,如:
缺口 | 实时显示 | 原始保存 | 频谱/特征分析 |
|---|---|---|---|
1~2点 | 线性插值 | NaN+无效标记 | 可酌情使用,但保留标记 |
3~10点 | 插值但标色 | NaN+无效标记 | 通常排除该区域 |
较长缺口 | 显示断线 | NaN+无效标记 | 分段分析 |
连接中断 | 新建数据段 | 记录事件 | 不跨段滤波/FFT |
我们使用Python 模拟插值,以及研究缺失数据在频域会有什么影响,展示的时候使用分辨率高的 FFT。

采样点数 : 92,160
缺失样本数/比例 : 792 / 0.8594%
记录长度理论频率间隔 : 1/T = 0.005556 Hz
Hann 窗 ENBW : 1.5/T = 0.008333 Hz
零填充 FFT 栅格间距 : fs/NFFT = 0.000488 Hz
注意:更细的 FFT 栅格不是更强的真实分辨能力;真实分辨率主要靠延长 T。
10 Hz附近主要谱峰:
Reference : [(10.123046875, -0.005393100133749951), (10.1728515625, -5.18754105913096), (10.10986328125, -31.439126135762386), (10.13623046875, -31.494161173770728)]
Zero fill : [(10.123046875, -0.06699910014441023), (10.1728515625, -5.216832638381734), (10.10986328125, -31.412882691863118), (10.13623046875, -32.08808645213892)]
ZOH : [(10.123046875, -0.03559907459472774), (10.1728515625, -5.205575543568598), (10.10986328125, -31.199437020777808), (10.13623046875, -31.503423386907578)]
Linear : [(10.123046875, -0.03330143431458907), (10.1728515625, -5.193685957757536), (10.10986328125, -31.188468711646827), (10.13623046875, -31.69564251215458)]
Deleted : [(10.126953125, -5.586233149229062), (10.18359375, -6.546209263501059), (10.154296875, -14.058907252136022), (10.24755859375, -14.16637288270421)]
采样点数 : 92,160
缺失样本数/比例 : 792 / 0.8594%
记录长度理论频率间隔 : 1/T = 0.005556 Hz
Hann 窗 ENBW : 1.5/T = 0.008333 Hz
零填充 FFT 栅格间距 : fs/NFFT = 0.000488 Hz
注意:更细的 FFT 栅格不是更强的真实分辨能力;真实分辨率主要靠延长 T。
10 Hz附近主要谱峰:
Reference : [(10.123046875, -0.005393100133749951), (10.1728515625, -5.18754105913096), (10.10986328125, -31.439126135762386), (10.13623046875, -31.494161173770728)]
Zero fill : [(10.123046875, -0.06699910014441023), (10.1728515625, -5.216832638381734), (10.10986328125, -31.412882691863118), (10.13623046875, -32.08808645213892)]
ZOH : [(10.123046875, -0.03559907459472774), (10.1728515625, -5.205575543568598), (10.10986328125, -31.199437020777808), (10.13623046875, -31.503423386907578)]
Linear : [(10.123046875, -0.03330143431458907), (10.1728515625, -5.193685957757536), (10.10986328125, -31.188468711646827), (10.13623046875, -31.69564251215458)]
Deleted : [(10.126953125, -5.586233149229062), (10.18359375, -6.546209263501059), (10.154296875, -14.058907252136022), (10.24755859375, -14.16637288270421)]
采用:
记录时间:
实际采样点数:
信号包含:
其中前两个信号只相差:
丢包按照每包8个采样点产生,包括:随机整包丢失;连续3包丢失;连续10包丢失;连续25包丢失。
最终缺失:
点
丢失率:
比较五种情况:完整参考数据;缺失位置补0;前值保持;线性插值;直接删除缺失点。

程序使用:
因此FFT频率栅格间距为:
这个数很小,但它主要来自零填充,只代表频谱曲线计算得更密。
真正由采集时间决定的基本频率间隔为:
使用Hann窗后,其等效噪声带宽约为:
因此:
0.000488 Hz:频谱显示栅格;0.00556 Hz:记录长度对应的基本分辨尺度;0.00833 Hz:Hann窗的ENBW。
不能把大点数零填充误认为真正提高了频率分辨率。真正提高分辨率,主要依靠延长采集时间。

完整信号检测到:
设置信号 | FFT检测谱峰 |
|---|---|
10.123 Hz | 10.12305 Hz |
10.173 Hz | 10.17285 Hz |
频率误差只有FFT栅格量级,两个相隔0.05 Hz的信号能够清楚分开,这说明180秒记录足以分辨它们,因为:
如果要分辨相隔0.001 Hz的两个信号,仅仅把FFT改成几百万点不够。记录时间至少应达到约:
实际考虑窗函数,通常还需要更长。
把完整信号写成 ,有效性掩码写成 :
数据有效数据缺失
补零后的信号实际是:
时域相乘对应频域卷积:
如果没有丢包, 恒等于1,它的频谱主要集中在直流位置,不会明显扩散原信号频谱。
发生丢包后,掩码中出现很多缺口, 会包含宽带频率成分。原本集中的单音谱线与它卷积后,会出现:主谱峰幅度下降;谱峰附近出现裙边;宽带噪声底升高;产生不存在的伪频率分量;连续突发缺口产生类似 sinc 的频谱结构。
因此,即使丢失比例不到1%,高动态范围频谱也能清楚看到影响。
缺失区域RMSE结果:
方法 | 缺失区域RMSE | 全记录RMSE |
|---|---|---|
补零 | 0.8172 | 0.07576 |
前值保持 | 0.7470 | 0.06925 |
线性插值 | 0.5280 | 0.04895 |
在本次信号条件下,线性插值最好,因为它利用了缺口前后的两个真实值。
但这不意味着线性插值能恢复真实高频信号。它只是在两个端点之间构造直线:
其中 为缺失点数;缺口越长,真实信号越可能在缺口内发生多次振荡,直线插值与真实值的差别就越大。
补零相当于直接使用缺失掩码:
主要问题是:信号突然跳到0;缺口结束时又突然跳回真实值;两个边沿产生宽带频谱;单音幅度被略微压低;噪声底升高。
本次仿真中,10.123 Hz幅度从:
下降到:
大约下降:
虽然主谱峰变化不大,但在参考信号较弱、动态范围较高时,新增的宽带泄漏会遮盖微弱分量。
前值保持为:
它不会像补零一样立即跳到0,但会形成一段水平线;缺口结束时,保持值与当前真实值之间仍可能产生阶跃。对低频或缓慢变化信号比较适合;能用于实时处理,因为不需要等待未来数据;对高频信号产生幅度和相位误差;长缺口会形成明显平台;缺口恢复边沿会带来高频泄漏。
仿真中,前值保持比补零的时域RMSE稍低,但频谱误差仍很明显。
线性插值比补零和前值保持平滑,因此通常减少突变导致的宽带泄漏,仿真中,10.123 Hz幅度变为:
相对完整参考值的误差约:
10.173 Hz幅度几乎保持不变:
但是线性插值本质上具有低通倾向:缺失区被替换成直线;高频细节被消除;高频功率通常被低估;相位信息不再真实;长缺口可能生成不存在的慢斜坡。线性插值适合:实时波形显示;极短缺口;低频趋势观察;不要求缺失区真实频域特征的场景。
原始数据长度为:
删除缺失点后变为:
如果仍按512 Hz采样率解释,程序会认为记录长度为:
但真实过程持续了180秒。
相当于把180秒发生的信号压缩到了178.45秒,平均时间压缩比例为:
所以频率会整体向上偏移。仿真中10.123 Hz附近的主峰移动到约:
同时出现:10.1543 Hz;10.1836 Hz;10.2476 Hz;等多个伪峰或严重变形的峰。

这里没有严格按照平均压缩比例整体平移,是因为缺失点不是均匀删除,而是在不同时间发生随机和突发删除,所以时间轴是分段跳变,而不是均匀缩放。
结论是:
绝对不要删除缺失点后,再假定剩余样本仍然是等间隔采样。
上位机算力大,可以任意插值,另外高点数只能让FFT失真看得更细,但不能把插值数据变回真实测量数据。