简介:ESP32-CSI-Tool是一套从ESP32 Wi-Fi微控制器中提取通道状态信息(CSI)的开源工具,面向嵌入式开发者和Wi-Fi传感研究者,适用于无设备定位、人体活动识别、环境感知等场景。工具基于C开发,内置主动站点、主动AP和被动监听三个独立工程:前者可连接路由器或另一台ESP32并发送数据请求以接收CSI响应,次者可让设备作为热点收发数据,后者则静默监听指定信道(默认3信道)上的CSI帧;采集结果既输出到串口,也支持写入SD卡。压缩包共40个文件,以C源文件、头文件、Makefile构建脚本和Markdown文档为主,另附Python解析脚本(serial_append_time、parse_csi等)、CSV示例数据及BibTeX引用,方便后续分析和成果引用;整体仅67KB,结构紧凑。项目上线以来已有2681人学习,对需要快速搭建CSI采集链路的开发者极具参考价值。
1. 反直觉的开场:RSSI 做不到的事,CSI 可以
晚上十一点,我把一块 ESP32 开发板放在客厅角落,想写个脚本判断“客厅有没有人”。最开始我只用 RSSI(接收信号强度),把手机放在茶几上当信号源,日志里数值确实在跳。可第二天我拿着手机走到阳台,数值变化和前一天晚上有人在客厅走动时几乎一样;空调开了半小时,RSSI 也跟着往下掉。这个项目卡了很久,直到我把目标从“看信号强不强”换成“看信道状态信息(CSI)”。CSI 能给出 WiFi 在每个子载波上的幅度和相位,人的呼吸、踱步、开关门都会改变多径叠加的结果,而这些变化在 CSI 里远比在 RSSI 里清晰。ESP32-CSI-Tool 这个方向,讲的就是如何从启用 WiFi 的 ESP32 里把这份 CSI 提取出来、解析成能用的幅相序列,再做存在感知或动作识别。对做智能家居感知、边缘 AI、低成本无线感知原型验证的从业者来说,这条路能绕开摄像头和麦克风的隐私雷区,也能用几十元的硬件把“无人感知”“手势识别”这类需求跑通。
2. CSI 是什么,ESP32 交付给你什么数据
2.1 从 RSSI 到 CSI:一个数字为什么不够用
RSSI 本质上是把整条无线链路的所有衰减压成一个整数。直射路径、墙面反射、人体散射、多普勒频移,全部混在一起,最终只输出一个“信号强度”。在空旷场景下这个数字够用,但室内只要有人走动,接收端的信号就是几十条路径叠加的结果,不同路径的波峰波谷互相抵消,频率选择性衰落会让每个子载波上的衰减完全不同。这就像你用一只麦克风录一场多人对话,最后只告诉你“整体音量”是多少,而 CSI 相当于给了你每个说话人分频段的频谱。
CSI 的数学形态是每个子载波上的复信道响应 H(f),包含幅度响应和相位响应两部分。幅度告诉你这个频率上信号被衰减了多少,相位告诉你这个频率上的路径叠加偏移了多少。人体移动几厘米,某些子载波的相位就可能翻转,这是 RSSI 完全丢失的信息。
所以做无线感知的常见选择是:RSSI 只用来做粗粒度距离判断,CSI 用来做细粒度运动感知。你要提取的状态信息,正是这些随环境变化的复数序列。ESP32 的 WiFi 子系统在接收每一帧数据时,会顺带估计信道矩阵并输出到内存,问题只在于怎么把这些字节解释成可用的幅相序列。
2.2 ESP32 输出 CSI 的真实形态:从回调函数到内存里的字节
先理解数据从哪来。ESP32 的 WiFi 固件在收到合法帧时,会做信道估计,并把估计结果放到一块缓冲区里,然后通过注册好的回调函数交给你。回调里你会拿到两组信息:一组是接收元信息(rx_ctrl),包含 RSSI、数据率、信号模式、天线编号、帧长度等;另一组是 CSI 原始缓冲,按天线和子载波顺序排列的字节流。
我最初踩过一个坑:以为所有版本固件输出的 CSI 格式都一样,结果换了个批次后发现解析出来的相位全是乱的。后来我把第一帧数据打印成十六进制,才发现不同固件版本对每个子载波的存储方式有两种常见形态。一种是把幅度值直接编码成短整型,每个子载波占 2 字节,好处是解析快,适合只看信号强弱;另一种是存复数 I/Q 值,每个子载波占 4 字节,前 2 字节是虚部、后 2 字节是实部,带符号,适合做相位分析。使用前先用 hexdump 确认当前固件是哪一种,不要盲信文档。
| 字段 | 常见长度 | 含义 |
|---|---|---|
| 帧标志/类型 | 1~2 字节 | 标识这一帧 CSI 的类型,不同版本含义有差异 |
| 天线数据块 | 按天线数重复 | 每根天线一段,单天线板子只有一段 |
| 子载波数据 | 每子载波 2 或 4 字节 | 幅度型或复数 I/Q 型,决定后续解析方式 |
| 尾部填充 | 不定 | 对齐用,解析时直接跳过 |
在 20MHz 带宽下,FFT 出来的有效子载波通常能看到 52 个左右的可用数据子载波,加上导频和 DC 空置位,报文里出现的索引范围大致对应 -28 到 +28 这一段。不要假设所有 64 个 FFT 位置都能用,DC 子载波和边缘子载波经常是噪声。
2.3 设备选型:怎么判断手里的 ESP32 能不能干这活
不是所有带 WiFi 的 ESP32 型号在默认配置下都能给你 CSI。这个功能属于 WiFi 子系统的底层能力,芯片要支持,固件要开启,缺一不可。我的选型习惯是先确认手上的板子用的是哪颗芯片、对应 SDK 版本里有没有 CSI 接口,再谈别的。
| 芯片型号 | 定位 | 对 CSI 场景的建议 | 主要顾虑 |
|---|---|---|---|
| ESP32 原版 | 经典款,资料多 | 最适合起步,踩坑记录多 | SRAM 不算大,别开太多 WiFi 特性 |
| ESP32-S3 | 算力更强,适合本地推理 | CSI 采集 + 轻量模型可行 | 确认你的 SDK 版本已启用 CSI 选项 |
| ESP32-C3 | 低成本、小体积 | 适合传感器节点 | 资源紧张,采样率别拉太高 |
天线数量也要注意。多数 ESP32 开发板只有一根天线,意味着你拿到的是单天线 CSI,只能看到幅度和相位随时间的波动,无法做到达角估计。想靠相位差定位目标位置,得选双天线或外接射频开关的方案,复杂度会明显上升。我建议第一版先做单天线,把“能不能稳定提取”这件事验证完,再决定要不要上多天线。
另外,很多人会问 ESP8266 行不行。据我了解,它在常规固件里没有开放 CSI 接口,别在这上面浪费时间。做这个方向,ESP32 系列是性价比最高的起点。
3. 把 CSI 从 ESP32 里“抠”出来:最小固件与上报链路
3.1 使能 CSI 的最小代码改动
提取 CSI 的第一步是在固件里打开开关并注册回调。下面这段代码是我常用的一套最小配置,其中每个配置项都值得按你的场景调一遍。
#include "esp_wifi.h" // 回调:每收到一帧可估计信道的数据,WiFi 子系统就会调用这里 static void csi_rx_cb(void *ctx, wifi_csi_info_t *info) { if (info == NULL || info->buf == NULL) { return; } // rx_ctrl 里放着 rssi、rate、sig_mode、antenna 等接收元信息 // buf 是这一帧的 CSI 原始字节流,len 是字节数 process_csi_packet(&info->rx_ctrl, info->buf, info->len); } void csi_enable(void) { wifi_csi_config_t cfg = { .lltf_en = 1, // 长训练序列:建议开启,低频段估计更稳 .htltf_en = 1, // HT 长训练序列:HT20 模式下调这个 .stbc_htltf2_en = 0, // STBC 场景下很少用到,关掉省资源 .ltf_merge_en = 1, // 合并多段 LTF,频域响应更完整 .channel_filter_en = 0, // 关掉内部滤波,拿原始估计 .manu_scale = 0, // 手动缩放系数,默认不动 }; esp_wifi_set_csi_config(&cfg); // 写配置 esp_wifi_set_csi_rx_cb(csi_rx_cb, NULL); // 注册回调 esp_wifi_set_csi(true); // 打开 CSI 总开关 }逻辑说明:整个流程是“配置 → 注册 → 使能”三步。配置结构体里最关键的是channel_filter_en,我见过不少案例是开着滤波导致幅度被人为压平,动作特征全部消失。调试阶段一定先把它关掉。ltf_merge_en建议保持开启,合并多段训练序列能让信道估计更完整,代价是回调里拿到的数据会晚一点点,但这个延迟对感知场景无所谓。
参数说明:lltf_en和htltf_en分别对应 802.11n 里的长训练字段,你的 ESP32 如果工作在 HT20 模式,两个都开着没坏处。manu_scale是手动缩放系数,除非你发现幅度整体溢出或整体过小,否则保持 0。
回调里面千万不要做耗时操作,比如打印大段日志、格式化字符串、调用阻塞式发送。WiFi 任务对实时性敏感,回调阻塞会导致掉线或者丢帧,后面排错时你会非常痛苦。
3.2 数据上报:串口、UDP 还是先放内存
回调拿到 CSI 后,下一步是把数据送到电脑或服务器上做解析。这里有三条路,选哪条取决于你的采样率和容忍丢包的程度。
第一条路是串口直出。921600 波特率下大约每秒能传 90KB 左右,按每包 200 字节算,大概能支撑几百包每秒的采样率。好处是实现简单、调试方便;坏处是串口本身没有流控保障,处理不过来就会丢,而且 USB 转串口的稳定性偶尔会“抽风”,长时间跑容易掉线。
第二条路是 UDP 上报。开发板连上路由器,把 CSI 包封装成 UDP 报文发给电脑。我一般会每包只放一帧 CSI,包长控制在 1400 字节以内,避免 IP 分片。这样丢包粒度最小,电脑端按包序号就能补判断丢失率。
第三条路是先写进内存环形缓冲,再定时批量发送。回调里只做拷贝,把数据丢进队列,发送任务按固定间隔取走并发送。这是我在做高采样率时最常用的方案,能有效避免回调阻塞,也让网络抖动不会直接反噬 WiFi 任务。
// 伪代码:回调里只入队,发送交给独立任务 static void csi_rx_cb(void *ctx, wifi_csi_info_t *info) { // 拷贝到预分配缓冲区,记录长度和序号 ringbuf_push(g_csi_ring, info->buf, info->len); g_pkt_seq++; }逻辑说明:回调里做的事情越少越好,这里只有一个环形缓冲写入操作,不做任何格式化输出。发送任务定期从环形缓冲取数据,再走 UDP 发出。这样即使网络短暂抖动,数据也只是积压在缓冲里,不会让 WiFi 掉线。
参数说明:环形缓冲大小建议按你预期的采样率乘 2 秒来算。假设你打算跑 200 包每秒,每包约 200 字节,2 秒就是约 80KB,预留 128KB 比较宽裕。内存不够时优先降采样率,而不是缩小缓冲,否则抖动一来数据就被覆盖。
3.3 算一笔数据量的账:一秒钟到底多少字节
很多人把 CSI 使能后就开始狂采,结果电脑端 Wireshark 一开,几秒钟文件就上百兆。我建议动手前先算清这笔账。
| 采样包率 | 每包约 208 字节(单天线 + 52 子载波 + 元信息) | 每秒数据量 |
|---|---|---|
| 50 包/秒 | 约 10KB/秒 | 1 分钟约 0.6MB |
| 200 包/秒 | 约 42KB/秒 | 1 分钟约 2.5MB |
| 800 包/秒 | 约 166KB/秒 | 1 分钟约 10MB |
如果只想做人存在检测,50 包每秒其实已经够用,人的走动是慢变化,过高的采样率只会让数据量爆炸。想做手势识别,200 包每秒是起步,但要注意存储和实时解析的压力。1000 包每秒以上请慎重,这时候 WiFi 本身还要维持链路通信,CSI 采样和正常数据传输会抢资源,板子容易不稳定。
数据量的账算明白后,你就知道该配多大环形缓冲、用串口还是 UDP、电脑端要不要落盘。我的习惯是先在电脑端只保留特征(比如滑动窗口的方差和幅度均值),不落原始 CSI 包,这样采集半小时的数据量也就是几兆,后续分析压力小得多。
4. 数据落地与预处理:把原始 CSI 变成能用的幅相序列
4.1 写一个最小解析器:从缓冲到幅相
拿到一帧 CSI 字节流后,第一件事不是把它喂给深度学习模型,而是先用一个最小脚本确认格式、字节序、子载波排列。下面这段 Python 解析器是我固定的第一步,任何新固件我都会先跑它。
import math def parse_csi_packet(payload): # 大多数固件前 1~2 字节是帧类型/标志,含义随版本变化 # 调试时先打印 hex,确认当前固件的排列再决定跳过多少字节 pkt_type = payload[0] data = payload[2:] # 这里假设跳过了类型和标志字段 # 每个子载波按复数存储:虚部(int16) + 实部(int16),共 4 字节 # 如果你的固件存的是幅度短整型,把这里改成 2 字节即可 n_sc = len(data) // 4 csi = [] for i in range(n_sc): imag, real = struct.unpack_from("<hh", data, i * 4) csi.append(complex(real, imag)) amps = [abs(c) for c in csi] # 幅度序列 phases = [math.atan2(c.imag, c.real) for c in csi] # 相位序列 return amps, phases逻辑说明:这个脚本的核心是“先确认再解析”。不同固件版本对头字段的长度定义不一样,盲信文档会让你在相位分析时差 90 度甚至完全乱掉。我遇到过一次换固件后幅度正常、相位全错的翻车,就是因为我没做 hexdump 直接复用了解析器。
参数说明:<hh表示按小端序读两个带符号 16 位整数,这是大多数版本采用的字节序。如果你的板子输出的是大端序,这里要改成>hh。n_sc不要写死,按数据长度动态推断,这样换带宽配置时不用改代码。
4.2 三个必调参数:消抖、相位展开与滑动窗口
解析出幅度和相位只是第一步,原始数据噪声不小。我在实跑中发现三个参数最值得调,调好了数据才真正可用。
第一个是滑动平均窗口。窗口太小,毛刺压不掉;窗口太大,动作细节被抹平。我一般从 5 帧开始试,静止场景下观察幅度曲线的平稳度,有人在场景里走动时再确认动作特征是否还在。窗口设到 15 帧以上时,快速手势基本就看不出来了。
第二个是相位展开。atan2 的输出范围是 -π 到 π,目标移动几厘米就可能让相位跨过 ±π 边界,产生看起来像瞬间跳变的折线,这其实是相位包裹。用numpy.unwrap把相位展开成连续序列,再做差分才有意义。
import numpy as np def preprocess_csi(amp_matrix, phase_matrix, win=7): # amp_matrix: 时间 x 子载波的幅度矩阵 # phase_matrix: 时间 x 子载波的相位矩阵 amp_smooth = np.apply_along_axis( lambda x: np.convolve(x, np.ones(win) / win, mode="same"), axis=0, arr=amp_matrix ) phase_unwrapped = np.unwrap(phase_matrix, axis=0) # 沿时间轴展开 phase_diff = np.diff(phase_unwrapped, axis=0) # 帧间差分 return amp_smooth, phase_diff逻辑说明:幅度做滑窗平滑,相位做差分。滑窗平滑去掉高频毛刺,相位差分突出相对变化,减少静态环境的背景偏移影响。两路输出分别对应“信号有多强”和“变化有多快”,是后续阈值判断的原料。
参数说明:win=7是滑动平均窗口,按 200 包每秒的采样率,7 帧对应约 35ms 的平滑尺度,适合大部分人体动作。采样率翻倍时可以适当加大到 11 或 15;采样率减半时建议降到 3 到 5,避免动作特征被抹平。
第三个是归一化。固定环境静止时,幅度绝对值受自动增益控制影响会缓慢漂移,我见过波形在半小时里整体抬高又落回去的情况,像温度漂移一样。解决方式是取开机后前 3 秒静止数据作为基准,之后的每一帧都做背景差分,只保留相对变化。这样环境慢变不会影响后续判断。
4.3 选哪几个子载波:别全用也别只用最强的
不是所有子载波都适合做感知。边缘子载波靠近频带边界,信道估计本身就不稳,噪声占比高;最强的几个子载波容易受直射路径主导,人体反射的变化在强路径上占比小,反而感知不到动作。
实际中我一般取中间段子载波,比如把幅度按平均值排序后,取中位数附近 20 到 30 个子载波做平均。相位则用全部子载波做差分的均值,因为相位差分对每个子载波都敏感,平均能抵消部分随机噪声。如果你想更进一步,可以对子载波维度做 PCA 取第一主成分,那个分量通常对应最主要的动态反射路径,但这一步等基础流程稳定后再加。
5. 从能提取到能用:避坑与排查手册
5.1 数据质量相关的 5 个常见问题
第一个问题是数据时有时无。现象是电脑端很久收不到 CSI,偶尔冒出来几帧。原因多数是 ESP32 的 CSI 跟随接收帧触发,如果没有一个持续发包的对端,它自然无数据可采。解决方法是固定一个设备周期性 ping 或发 UDP 包给 ESP32,我习惯让手机或平板持续 ping,间隔 20ms 到 100ms 可调。
第二个问题是幅度长时间不动。无论人怎么走动,波形都像一条直线。原因通常是开了channel_filter_en,内部滤波把信道变化压平了。解决方法是回固件里把该字段置 0,重刷后重新采集对比。还有一种可能是你选错了子载波区间,边缘子载波本身响应弱,换中间段再看。
第三个问题是波形随环境缓慢漂移。现象是静止场景下幅度均值慢慢升高或降低,像数据里有条低频曲线。原因是射频前端的自动增益控制随环境噪声调整了增益,CSI 幅度也跟着变。解决办法是不要用绝对幅度做判断,改用短时间窗差分或背景归一化。这个问题我一开始没注意,一度以为固件丢了数据。
第四个问题是人动了但幅度波动和背景噪声一样大。这通常是人站在菲涅尔区盲区,或者被家具挡住了主要反射路径。解决方法是移动天线或开发板的位置,让动态路径和静态路径的叠加更明显。我常用的做法是让天线位置在房间对角线上距地 1 米左右,人活动区域在两者连线附近,效果最好。
第五个问题是数据里有等间隔突刺。现象是画热图时每隔固定时间出现一条亮线。原因可能是你的采集线程被其他任务打断,也可能是串口缓存满了丢字节导致解析错位。解决办法是改用 UDP 上报,增加环形缓冲,并在电脑端按包序号检查丢包率。等间隔突刺基本能靠这两个手段消除。
5.2 固件与工具链的 4 个翻车点
第一个翻车点是编译报错找不到wifi_csi_config_t。原因通常是 SDK 版本过旧,或者这个功能在配置菜单里还是实验项。解决方法是升级 SDK 到较新版本,并在构建配置里查找并启用 CSI 相关选项。不同版本开启方式不同,我先在配置里搜 CSI,搜到就打开,搜不到再升级。
第二个翻车点是打开 CSI 后 WiFi 连接不稳定或掉线。原因大多是回调函数里做了耗时操作,比如打印日志或同步 socket 发送。解决方法是让回调只做内存拷贝,所有上报逻辑放到独立任务里。我自己在这上面吃过亏,开着日志采了十分钟,板子彻底断开连接。
第三个翻车点是板子反复重启。原因是 CS I缓冲分配过大,叠加 WiFi 自身的缓冲,RAM 不够用。解决方法是调小环形缓冲,或者降低采样率。如果板子带外部 PSRAM 且地址能用,也可以把缓冲放到 PSRAM 里,但不是所有板子都支持,别默认它能用。
第四个翻车点是数据解析后子载波索引对不上。现象是同一根天线的数据被拆到了两块,或者相邻子载波的相位出现规律性跳变。原因是不同芯片批次或固件版本对子载波排列顺序有差异,也可能是天线顺序和你预期的不一致。解决方法是固定设备后先采集一组静态数据做索引校准,用正弦波之类的规律信号源确认每个索引的真实频率位置。
5.3 怎么验证链路是通的:一个 5 分钟的自检流程
我每次换设备或换环境后都会跑一遍这个流程,确认提取链路是通的,再继续往下做。第一步,固定天线位置,让一台手机持续 ping ESP32,采集 30 秒数据。第二步,画出幅度热图,横轴是子载波,纵轴是时间。静止场景下热图应该是平坦的纵向条纹,如果出现横向规律条纹,说明有周期性干扰源,比如空调或风扇。第三步,用手掌在距离天线 30 到 50 厘米处横向划过,看对应时间点的幅度和相位差是否出现明显波动。如果这一步没反应,说明数据链路有问题,先别急着调算法。第四步,对比同期 RSSI 的变化幅度,如果 CSI 波动明显但 RSSI 几乎不动,说明你提取到的确实是更细粒度的信道信息,链路就通了。
# 采集 30 秒并保存原始数据,采样率约 200 包/秒 # csi_collect.py 是电脑端接收脚本,UDP 端口 8888 python csi_collect.py --port 8888 --duration 30 --out static_30s.pcap逻辑说明:这步验证的关键是“静态基线 + 单次动态事件”。静态基线确认噪声水平,单次动态事件确认链路灵敏度。两步都通过,后续做任何感知算法才有基础。
参数说明:--duration 30表示采集 30 秒,太短统计意义不足,太长浪费存储。--out指定输出文件,建议命名时带上场景描述,方便后续回看。
6. 进阶:把 CSI 接到一个最小动作识别流水线上
提取链路稳定后,第一个值得做的事不是上深度学习,而是写一个基于阈值的最小动作识别。取一段固定窗口的幅度方差作为特征,人在动的时候方差显著增大,静止时方差贴着底部。这个基线就能完成无人值守告警,也能帮你确认“CSI 的变化和动作是同步的”这件事。
def detect_activity(amp_smooth, window=50, threshold=0.15): # amp_smooth: 平滑后的一维幅度序列(已做背景差分) activity_flags = [] for i in range(0, len(amp_smooth) - window, window // 2): seg = amp_smooth[i:i + window] var_val = np.var(seg) activity_flags.append(1 if var_val > threshold else 0) return activity_flags逻辑说明:滑动窗口步长取窗口的一半,让窗口之间有重叠,避免动作刚好落在两个窗口边界被漏判。方差阈值先按静止时的 5 倍作为起点,再结合实际误报情况往上调。这个脚本跑通后,你才知道自己的数据在时间分辨率和幅度灵敏度上的真实边界,比任何模型都靠谱。
采样率 200 包每秒、窗口 50 帧时,单次判断约 250ms 延迟,这个尺度足以支撑存在检测和粗粒度活动识别。想识别“挥手”“下蹲”这类具体动作,窗口要压到 20 帧以内,阈值按动作类型分开调。先把一个动作调通,再扩展第二个,不要同时铺开五六个动作,否则你根本不知道差异来自算法还是来自数据采集抖动。我过去更愿意在动手前先问自己一句“这个尺度下 CSI 的分辨率到底够不够”,这个习惯帮我避开了好几个夭折的项目。数据采集链路没验证清楚就调模型,等于在沙子上盖楼。希望帮到你。
本文还有配套的精品资源,点击获取