简介用于从启用 Wi-Fi 的 ESP32 微控制器中提取信道状态信息CSI的开源工具包面向物联网与无线感知方向的开发者和研究者。其核心目标是利用富含环境信息的 CSI 数据支持 Wi-Fi 传感、无设备定位等任务代码基于 C 语言与 ESP-IDF 编写并提供三种典型采集模式站点模式active_sta可连接路由器后主动收发数据包获取 CSIAP 模式active_ap可让设备自身作为热点供终端接入被动监听模式passive则无需建立连接即可侦听指定信道上的 CSI 帧采集结果支持串口输出与 SD 卡存储。压缩包共 40 个文件包含 C 源码、头文件、Makefile/CMake 构建配置、Python 解析脚本、示例 CSV 数据及 Markdown 说明文档整体仅 67KB轻量且便于移植。目录按照三种模式分别组织并带有组件化设计方便直接烧录运行或集成到自有 ESP-IDF 项目。目前已有 2681 人学习/下载适合有一定嵌入式基础、希望快速开展 CSI 数据采集与无线感知实验的开发者。1. 从 WiFi 信号里“看到”环境ESP32-CSI-Tool 到底能干什么一块几十块的 ESP32 开发板不装摄像头、不接雷达就能知道房间里有没有人走动、人坐在哪一侧、呼吸频率大概是多少——听起来像玄学但原理是实打实的物理层数据CSIChannel State Information信道状态信息。ESP32-CSI-Tool 这一类方案的核心就是把 ESP32 的 WiFi 网卡在接收数据包时顺带测得的信道频率响应提取出来通过串口交给上位机处理。和 RSSI 那种只给一个“平均信号强度”的粗粒度指标不同CSI 能给出几十个子载波各自的振幅和相位相当于把环境对无线信号的“影响指纹”完整采样下来。适合谁用想做无源感知、手势识别、人员存在检测又不想上头戴设备的嵌入式开发者、物联网从业者和无线感知方向的研究者。硬件门槛就一块 ESP32软件上则需要一点固件编译和信号处理的基础。这篇笔记不聊论文推导直接讲怎么把它跑起来、数据格式长什么样、调哪些参数、以及最容易翻车的几个坑。2. 为什么是 CSI 而不是 RSSI把理论基础打牢再动手2.1 RSSI 只有一条曲线CSI 是一整组频率响应WiFi 信号在室内传播时会经过直射、墙面反射、人体散射等多条路径接收端收到的其实是多条路径叠加后的合成信号。RSSI 是接收机对信号能量做的粗估计最后只给一个数值。这个数值对环境的敏感度极低人走过去RSSI 可能只波动 1~2 dB淹没在噪声里而 CSI 记录的是每个 OFDM 子载波上的幅度和相位响应相当于把 2.4GHz 频段内几十个频率点上的“信道传递函数”都测了一遍。人的移动会改变特定子载波上的多径叠加方式反映在振幅和相位上就是某些子载波明显上凸或下凹。这个细节RSSI 永远给不了。ESP32 在 20MHz 带宽下工作时CSI 数据通常对应 64 个子载波40MHz 带宽时对应 128 个。实际解析时每个子载波由 4 个字节表示低 16 位是实部I高 16 位是虚部Q振幅和相位都能从这个复数值里算出来。也就是说一根天线的一帧 CSI最少也是 64×4256 字节的原始数据两根天线就是 512 字节。相比 RSSI 那可怜的一个整数CSI 的信息量高出几个数量级。2.2 ESP32 产生 CSI 的链路不是“请求”来的是“附带”来的很多第一次接触 CSI 的人有个误区以为像读传感器一样调一个 API 就能拿数据。实际不是。CSI 是 WiFi 物理层在接收 802.11 数据帧时的一个副产物。帧到达网卡后接收机要做信道估计利用帧前导码里的长训练序列LTF来估计信道矩阵 H。这个 H 就是 CSI。ESP32 的 WiFi 驱动保留了这一接口只要在配置里打开 CSI 开关注册一个回调函数之后每收到一个数据帧回调里就会被塞进一组 CSI 数据。这就带来一个关键推论没有数据帧到达就没有 CSI。要么让 ESP32 处于 Station 模式并保持和路由器的流量往来要么让它监听空中的帧。所以做采集实验时第一步不是写算法而是保证“有包在收”。常见做法是把 ESP32 连上路由器然后让电脑或手机持续 ping 它的 IP或者把 ESP32 设成 AP让另一个终端设备连上来持续发流量。两种方式都行核心是让网卡持续产生接收事件。2.3 选型与天线数不是所有 ESP32 都适合你的场景硬件选型上原版 ESP32 和较新的几款 ESP32 系列芯片在官方 Wi-Fi 驱动中保留了 CSI 接口但不同型号的支持程度和子载波映射方式并不完全一致。我的习惯是先不急着买板子去官方组件仓库里搜esp_wifi_set_csi_config这个接口在当前芯片目标下有没有实际实现比看任何参数表都靠谱。天线数量直接影响 CSI 的数据量和能做的事。单天线的板子只能拿到一组振幅/相位序列做存在检测、呼吸模式识别勉强够但想区分“人从左往右走还是从右往左走”或者做角度类的估计至少需要两根天线因为不同空间位置的信号到达两根天线的相位差是不同的。开发板的选择上优先找带有 IPEX 接口、能外接天线的型号板载陶瓷天线在近场干扰下数据一致性较差调试时很难判断是环境变化还是天线自干扰。另外注意ESP32 的 CSI 数据里包含 AGC 相关的缩放信息不同板子的射频前端增益差异可能导致振幅基线整体偏移跨板子复用时需要做归一化。3. 把 ESP32-CSI-Tool 跑起来固件编译、烧录与最小采集配置3.1 环境准备IDF 工具链与工程目录结构动手之前先把软件环境装好。这套方案依赖厂商提供的 Wi-Fi 驱动接口所以我用的是官方 IDF 框架版本选当前仍在维护的 v5.x 分支。环境安装本身没什么特殊之处按官方文档装好工具链之后验证idf.py --version能正常输出即可。工程结构上我不会把采集代码直接堆在 main 里。常见做法是把串口输出、CSI 回调、数据缓存分成三个模块方便后续在数据链路里插入滤波或者改成 UDP 发送。目录大致长这样csi_collector/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── main.c # WiFi 初始化与任务创建 │ ├── csi_handler.c # CSI 回调与环形队列 │ └── uart_output.c # 串口协议封装与发送main 里只做初始化WiFi 启动、CSI 配置注册、串口配置、创建发送任务。回调里不做任何耗时操作只把数据拷贝进队列由独立任务负责通过 UART 发出。这样能最大限度减少丢帧。3.2 编译烧录的最小命令序列代码准备好之后编译和烧录就是标准的 IDF 流程。下面是最小命令序列cd csi_collector idf.py set-target esp32 idf.py menuconfig idf.py build idf.py -p /dev/ttyUSB0 flash monitor第一步设置编译目标芯片我这边用的是原版 ESP32所以是esp32如果你用的是其他型号改成对应 target 即可。menuconfig这一步不能跳过需要在图形界面里打开 CSI 开关。具体路径一般是Component config - Wi-Fi - WiFi CSI(Channel State Information)把Enable CSI勾上其他选项保持默认先跑通再说。build会完成全部编译第一次编译耗时较长正常现象。flash monitor是烧录并打开串口监视器退出监视器的快捷键是Ctrl]。打开 CSI 开关后idf.py会自动把相关头文件和库加进编译不需要手动改动 CMakeLists。这点对新手比较友好。3.3 初始化与回调CSI 配置参数怎么设跑通流程后核心代码就是 CSI 的配置和回调。下面是我常用的初始化片段#include esp_wifi.h #include esp_wifi_types.h // CSI 接收回调WiFi 任务上下文里执行 static void csi_cb(void *ctx, wifi_csi_info_t *info) { // 这里不能做串口输出或长时间处理 // 把数据整体拷进队列立即返回 csi_queue_push(info-buf, info-len, info-rx_ctrl); } void wifi_csi_init(void) { // 关闭省电模式保证流量持续到达 esp_wifi_set_ps(WIFI_PS_NONE); // 配置 CSI 计算方式 esp_wifi_csi_config_t cfg { .lltf_en 1, // 使用长训练序列计算 CSI .htltf_en 1, // 使用 HT 长训练序列 .stbc_htltf2_en 1, // STBC 场景下使用第二段 HT-LTF .ltf_merge 1, // 多段 LTF 结果合并 .channel_filter_type 0, // 信道滤波器类型默认 0 .manual_scale 0, // 0 表示使用硬件自动增益 }; esp_wifi_set_csi_config(cfg); esp_wifi_set_csi_rx_cb(csi_cb, NULL); esp_wifi_set_csi(true); }这里几个参数解释一下。lltf_en和htltf_en决定用哪些训练序列来估计信道一般保持开启数据量更完整。ltf_merge为 1 时会把多段训练序列的估计结果做合并降低噪声建议开着。channel_filter_type影响信道估计的平滑程度默认 0 在大多数场景下表现正常如果振幅曲线毛刺特别多可以尝试改成 1 对比一下。manual_scale默认 0 表示增益完全由硬件自动控制调试阶段先不要动它。注意初始化顺序先esp_wifi_start()启动 WiFi再调用esp_wifi_set_csi_config和esp_wifi_set_csi。反过来配置有时不生效而且不会报错只会表现为回调永远不触发。3.4 烧录后验证从串口看到数据才算跑通固件烧录后先别急着接上位机。在monitor里观察日志确认三件事WiFi 成功连接了目标 AP日志里有类似csi init done的提示取决于你自己打的日志当另一台设备 ping 板子 IP 时回调触发的打印开始滚动。如果回调打印出来了说明链路已经通了接下来只需要在 UART 发送任务里按帧格式把数据发出去。数据发送的波特率选择上我一般先用 115200 跑通逻辑。CSI 单天线单帧 256 字节加上帧头元数据大概 300 字节115200 波特率下理论每秒能传约 40 帧对存在检测类应用足够了。想追求更高吞吐再考虑升级到 460800但这时候就要开始注意串口线质量和缓冲区溢出问题具体坑在第五章展开。4. 解析 CSI 帧从十六进制字节流到振幅曲线4.1 帧结构先看清串口里到底发了什么把 CSI 数据从 ESP32 的 UART 发出来时需要自定义一个帧协议否则上位机没法从字节流里切分出每一帧。这类工具的帧结构大同小异通常由四部分组成帧头、长度字段、元信息区、CSI 数据区。我习惯的帧布局如下区域长度说明帧头2 字节固定 0xAA 0x55用于搜索帧起始数据长度2 字节从元信息开始到数据区结束的总字节数小端元信息区26 字节包含序号、RSSI、速率、带宽、天线数、时间戳等CSI 数据区变化每子载波 4 字节int16 实部 int16 虚部按天线分组元信息区的具体字段顺序各版本并不完全一致所以上位机解析时最好把这一整块当作“元信息 blob”先跳过只取其中的天线数和数据长度用。真正要精细解析之前用十六进制工具打开一帧数据手工数一遍偏移量比死记字段顺序可靠得多。这也是做接入时最花时间的一步但一次校准之后就不会再动。4.2 用 Python 写一个 CSI 帧解析器拿到串口数据后下一步是用 Python 把帧切出来并解析成振幅。下面是一个简化但能直接跑的解析器import struct import numpy as np FRAME_HEADER b\xaa\x55 def parse_csi_frames(data: bytes, ant_num1, sc_num64): frames [] pos 0 while pos len(data): # 搜索帧头 idx data.find(FRAME_HEADER, pos) if idx 0: break # 读取长度字段小端 body_len struct.unpack(H, data[idx2:idx4])[0] frame_end idx 4 body_len if frame_end len(data): break body data[idx4:frame_end] # 元信息区域固定 26 字节跳过 meta body[:26] # 剩余为 CSI 原始数据 csi_raw body[26:] # 每 4 字节 int16 实部 int16 虚部 iq np.frombuffer(csi_raw, dtypenp.int16).reshape(-1, 2) i iq[:, 0].astype(np.float32) q iq[:, 1].astype(np.float32) amp np.sqrt(i*i q*q) amp_db 20.0 * np.log10(amp 1e-9) # 按天线数量重组: (ant, subcarrier) mat amp_db.reshape(ant_num, sc_num) frames.append(mat) pos frame_end return frames逻辑说明先在字节流里搜索帧头0xAA 0x55找到后读取长度字段按长度切出完整一帧。元信息区固定跳过 26 字节剩余的 CSI 数据按每 4 字节一组解析成复数。振幅用sqrt(I^2 Q^2)转成 dB 方便画图和观察。最后按天线数量重组为二维矩阵每一行对应一根天线的全部子载波振幅。参数说明sc_num在 20MHz 带宽下配置为 6440MHz 下为 128ant_num要和实际板上天线数一致。这两个参数如果和实际数据不匹配reshape 后表现出的现象就是振幅曲线大量跳变、毫无规律此时第一件事不是怀疑电路或环境而是回头数帧长。4.3 把振幅曲线画出来确认解析没偏解析器写完后用一段真实采集的数据直接画图验证import matplotlib.pyplot as plt # frames 来自 parse_csi_frames 的返回值 one_frame frames[0] # shape: (ant, subcarrier) plt.figure(figsize(10, 4)) for a in range(one_frame.shape[0]): plt.plot(one_frame[a], labelfant{a1}) plt.xlabel(subcarrier index) plt.ylabel(amplitude (dB)) plt.legend() plt.grid(True) plt.show()正常解析出来的振幅曲线在子载波索引上是一条有起伏的连续曲线低频子载波和高频子载波之间可能会有几个 dB 的落差但整体是平滑的。如果画出来是一片锯齿状高频抖动或者多条天线曲线完全重合大概率是字节偏移错误或reshape参数不对。此时可以做一个快速定界实验把手放在天线上振幅曲线里应该有一批子载波明显下降放回去后恢复。如果手放上去毫无反应说明解析的数据根本不是 CSI 携带的敏感分量回到 4.2 检查元信息长度和数据区起始位置。5. 常见问题与排查清单4 个踩过最多次的坑5.1 串口 CRC 错误刷屏数据帧频繁丢失现象串口监视器里能看到数据但帧校验经常失败或者有大量 0xFF 填充字节混在数据流里。波特率调到 460800 后问题尤其明显。原因这是典型的 UART 缓冲溢出。CSI 回调把数据塞进队列如果发送任务来不及写串口或者串口外设的 FIFO 溢出字节就会被丢弃。此外劣质的 USB 转串口模块在高波特率下本身就容易丢字节。解决先降到 115200 验证链路是否稳定如果必须高波特率把 UART 的 TX 缓冲区调大并确认发送任务里没有阻塞操作。另外检查串口线长度超过 20cm 的杜邦线在高速下就会出问题。5.2 CSI 回调一直不触发串口没有任何数据现象固件烧录成功WiFi 也连上了路由器但csi_cb里的日志始终不打印。原因最常见的是打开 CSI 的配置发生在 WiFi 启动之前配置被静默丢弃。另一个原因是数据包没到 ESP32连上路由器不代表有下行流量没有入站帧就不会触发信道估计。解决确认esp_wifi_start()已完成再调用esp_wifi_set_csi_config和esp_wifi_set_csi(true)。然后在另一台设备上持续 ping ESP32 的 IP或者让 ESP32 自己定时发 UDP 包给路由器触发下行 ACK 也能产生帧。如果还不行在menuconfig里确认 CSI 开关确实勾选并检查编译日志里有没有包含 CSI 相关源文件。5.3 数据断断续续几秒钟才蹦出一帧现象前面的步骤都验证过数据也有但时间间隔极不均匀有时一秒几十帧有时八秒一帧。原因WiFi 省电模式在作怪。模块进入 modem sleep 后大部分时间网卡处于休眠状态只有 Beacon 周期才醒来接收自然没有持续的流量进来。解决初始化 WiFi 后调用一次esp_wifi_set_ps(WIFI_PS_NONE)关闭省电模式。注意这会让功耗从毫瓦级升到几十毫瓦级对电池供电的项目不友好但 CSI 采集本来就是连续监听场景功耗优先度本来就不高。5.4 换了个房间原来的运动检测阈值全部失效现象在实验台上调好的检测阈值换到另一间办公室之后误报率急剧上升静止状态也频繁触发。原因CSI 振幅是环境相关的绝对量。不同房间的反射面、家具位置、路由器摆放都会改变多径分布导致子载波振幅的基线整体偏移。写死的绝对阈值在新环境下完全失效。解决把检测算法改成“先采集静止基线再基于基线做差分或方差判断”。每次部署后先采集 2~3 分钟无人的 CSI计算每个子载波的平均振幅作为基线检测时用当前帧减去基线得到变化量再做阈值判断。也尽量固定带宽和信道HT20 和 HT40 下子载波数量不同跨配置复用的阈值没有意义。提示CSI 的绝对相位值在国内公开资料里常被直接用来做测距但实际它的随机抖动很大除非你对时间同步和硬件延迟有精确标定否则别把绝对相位当可靠输入。先吃透振幅再考虑相位差。6. 从采集到感知让 CSI 变成可用信号的三个进阶步骤CSI 数据只是原材料离“能用在产品里”还有一段距离。我的处理链路分为去噪、特征、决策三层。去噪这一步先对振幅做滑动平均窗口取 5~10 帧即可太大平滑掉人的动作特征太小滤不掉环境噪声。然后是特征层面最简单的运动检测特征就是“当前窗口与静止基线的方差”代码只有几句import numpy as np def motion_score(seq_amp, base_amp, window20): # seq_amp: 最近 N 帧振幅矩阵 (window, ant*sc) # base_amp: 静止基线 (ant*sc,) diff seq_amp - base_amp return float(np.mean(np.var(diff, axis0)))逻辑很简单静止时每帧 CSI 都贴近基线方差接近零人一旦移动受影响的子载波开始偏离基线方差显著抬升。阈值的设定方法不是拍脑袋而是在现场采集 5 分钟静止数据算一个分数分布取均值加 3 倍标准差作为阈值。换环境就重新采不要复用。在形态上更细的方向下一步是引入相位差。单天线相位不稳定但同一时刻两根天线同频点的相位差相对稳定当目标从一侧移动到另一侧时相位差的走势会发生规律性偏移。不过这一项需要硬件天线布局和标定只建议在振幅特征做扎实之后再进入。我自己的习惯是每换一个部署位置先采数据、看曲线、存基线把整个流程固化下来而不是直接套用上次的阈值。之前在某模拟项目里偷懒没重采基线结果隔天演示时静止状态疯狂误报当场翻车从那以后就再也没跳过这一步。采集工具本身不复杂真正的门槛是把环境变化和人的动作区分开这需要动手采数据、盯曲线、反复调参数。顺着这条调试路径走下来你会对无线信道有完全不同的体感希望帮到你。本文还有配套的精品资源点击获取