写在前面
这是一篇可以照着做出来的工程笔记。全文按真实开发顺序展开:先讲为什么要这么选硬件,再讲每一行驱动代码是怎么写出来的、为什么这么写、写错了会怎样,最后讲手机端怎么算指标、怎么出报告。
全文配套的工程源码包含两个可直接打开的工程:
• firmware/:ESP-IDF v5.3 工程,idf.py build 直接编译烧录;
• android/:Android Studio / Gradle 工程,含已编译好的 release APK。
建议阅读方式:第一遍通读建立全局观,第二遍打开源码对照着看,第三遍自己改参数做实验。
第一章 项目定义:到底要做一台什么设备
1.1 需求拆解
| 需求原文 | 量化后的技术指标 | 对应实现 |
|---|---|---|
| 三向加速度采集 | 三轴 X/Y/Z,采样率 ≥ 1000 Hz | ADXL355 + SPI 10MHz 轮询 |
| 0.003 g 分辨率 | 单 LSB ≤ 3 mg | ±2.048g 量程,3.9 µg/LSB |
| 带 GPS + 惯导定位 | 输出经纬度、速度、定位质量 | ATGM336H NMEA 解析 |
| 电池续航 12 小时+ | 整机平均功耗 ≤ 电池容量/12 | 20000mAh + 低功耗策略 |
| 无线连手机/Pad | 手机能实时收到 1000Hz 数据 | WiFi AP + TCP 流式推送 |
| 自动算指标 | 平稳性、舒适度、晃车抖车 | Sperling Wz、EN12299 加权 RMS、Jerk |
| 按标准出报告 | GB/T 5599、EN 12299 | PDF 报告 + CSV 原始数据 |
| 便携、一键操作 | 上电即工作,无需按键配置 | AP 自动创建 + 自动采集 |
项目开发使用的全部软件工具,资料包已经上传到网盘:https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink
1.2 先想清楚两个工程问题
问题一:1000Hz × 3 轴,数据量有多大?
1000 帧/秒 × 3 轴 × 4 字节(float) ≈ 12 KB/s
一年 12 小时连续采集的原始 CSV 约:
12 KB/s × 3600 × 12 ≈ 500 MB
看起来很吓人,但实际上用 32GB TF 卡可以存 700 小时以上,完全够用。真正的瓶颈不是存储,而是无线传输带宽和手机端解析开销。
问题二:为什么不能直接把 1000Hz 原始数据一条条发出去?
如果每采一个点就发一个小包,一秒要发 1000 次 TCP,每次都有 TCP 头 + JSON 头开销,实际带宽放大 30 倍以上,手机端还会因为频繁唤醒 CPU 而卡顿掉电。
所以本项目的协议设计是:设备端攒批(batch),每 50ms 发一包,一包里带 50~200 个采样点。这样传输开销降到 1/50,手机端每 50ms 处理一次,既能实时画波形,又不浪费电。
这是整个项目第一个"设计决策",后面所有代码都围绕它展开。
1.3 成品形态
第二章 硬件设计:每一颗料为什么选它
2.1 主控:ESP32-S3-WROOM-1-N16R8
选它而不是 ESP32、ESP32-C3,理由有三条,都很实在:
| 对比项 | ESP32 | ESP32-C3 | ESP32-S3 |
|---|---|---|---|
| 内核 | Xtensa LX6 双核 240MHz | RISC-V 单核 160MHz | Xtensa LX7 双核 240MHz |
| SRAM | 520KB | 400KB | 512KB + 8MB PSRAM |
| Flash | 通常 4MB | 4MB | 16MB |
| WiFi | b/g/n | b/g/n | b/g/n |
| USB | 外置串口芯片 | 内置 | 内置 USB CDC |
关键点:
1. 双核:一个核专心做 1000Hz 采样(实时性要求高),另一个核跑 WiFi 协议栈和 SD 卡写入(耗时长、允许被打断)。单核芯片上这两件事会互相拖累,导致采样抖动。
2. N16R8 后缀含义:N = 16MB Flash,R8 = 8MB Octal PSRAM。PSRAM 用来放大缓冲,WiFi 协议栈本身也要吃掉上百 KB。
3. 内置 USB CDC:板子上不用焊 CH340/CP2102,直接一根 Type-C 线就能烧录和看日志,少一个故障点。
2.2 加速度计:ADXL355 凭什么能到 0.003g
先看需求:分辨率 0.003 g。
单 LSB 大小 = 满量程 / 2^位数
噪声底 ≈ 噪声密度 × √带宽
ADXL355 的关键参数:
| 参数 | 数值 | 说明 |
|---|---|---|
| ADC 位数 | 20 bit | 比常见 12/14 bit 芯片高 6~8 位 |
| 量程 | ±2.048 / ±4.096 / ±8.192 g | 三档可切 |
| ±2g 档灵敏度 | 3.9 µg/LSB | 即 0.0000039 g |
| 噪声密度 | 22.5 µg/√Hz | 低噪声工业级 |
| 带宽 | 最高 4kHz(ODR 可配) | 1000Hz 采样绰绰有余 |
| 接口 | SPI,最高 10 MHz | 9 字节读取仅需 7.2µs |
算一遍给你看:
单 LSB = 3.9 µg = 0.0000039 g → 远小于要求的 0.003 g
在 1000Hz 采样、100Hz 有效带宽下:
噪声 RMS ≈ 22.5 µg/√Hz × √100Hz ≈ 225 µg ≈ 0.000225 g
也就是噪声比最小分辨率还小一个数量级,完全满足指标,且有余量。
为什么不用最常见的 MPU6050 / LIS3DH?它们 16bit、±2g 档 LSB ≈ 61µg,虽然数字上也够,但噪声密度高、温漂大、零偏稳定性差,做"检测仪"(要出报告)会被质疑精度。ADXL355 是真正的工业级器件,这也是为什么要"美国进口工业 MEMS"。
注意:ADXL355 是 ±2g 档才 3.9µg/LSB。如果车辆振动大(比如卡车、工程车),应切到 ±4g 或 ±8g,此时 LSB 变为 7.8 / 15.6 µg,仍然远超 3mg 要求。
2.3 GPS:ATGM336H
- • 支持 GPS + 北斗 + GLONASS 多系统,城市道路定位更稳;• 串口 NMEA 0183 输出,9600 bps,接线只需 TX/RX 两根(我们只收不发);• 冷启动 30 秒左右,热启动 1 秒。
本项目用 GPS 干什么? 不是做导航,而是给振动数据打"位置标签"——报告里要知道这段振动发生在哪条路、什么速度下。所以只需要 RMC(位置+速度)和 GGA(定位质量+卫星数)两帧。
2.4 电源:IP5306 + 20000mAh
IP5306 是一颗高度集成的移动电源管理 IC:
| 功能 | 说明 |
|---|---|
| 充电 | 支持 5V/2A 线性充电,Type-C 输入 |
| 升压 | 内部 Boost 到 5V,供系统使用 |
| 电量显示 | 原生支持 4 颗 LED 指示(LED1~LED4) |
| 按键 | 短按开机 / 长按关机 |
续航估算(这是最容易翻车的地方,必须算):
ESP32-S3 + WiFi AP 常开:平均约 120~180 mA
ADXL355:约 0.2 mA(低功耗模式)/ 1 mA(高性能)
ATGM336H:约 25~30 mA
LED 指示:约 10 mA
合计:约 180~220 mA(取 200mA 计算)
20000 mAh = 20 Ah
续航 = 20 Ah / 0.2 A = 100 小时(理论值)
按 70% 有效容量:约 70 小时
结论:12 小时要求非常轻松。真正的续航杀手是"WiFi 一直满功率发射",所以代码里做了处理:没有客户端连接时不发送数据,只做本地记录。
2.5 完整 BOM
| 序号 | 物料 | 规格 | 数量 | 备注 |
|---|---|---|---|---|
| 1 | ESP32-S3-WROOM-1-N16R8 | 16MB Flash / 8MB PSRAM | 1 | 主控 |
| 2 | ADXL355 模块 | SPI 接口,含去耦电容 | 1 | 注意 3.3V 供电 |
| 3 | ATGM336H 模块 | 含陶瓷天线 + IPEX | 1 | 天线要朝上、远离金属 |
| 4 | 聚合物锂电池 | 3.7V 20000mAh | 1 | 带保护板 |
| 5 | IP5306 模块 | 充电+升压+4LED | 1 | 或自绘 PCB |
| 6 | Type-C 母座 | 16P 贴片 | 1 | 充电 |
| 7 | TF 卡座 | 自弹式 SPI | 1 | 32GB Class10 |
| 8 | LED | 0805 红/绿/蓝 | 5 | 4 电量 + 1 RGB |
| 9 | 电阻 | 200kΩ / 100kΩ / 1kΩ | 若干 | 分压、限流 |
| 10 | 电容 | 0.1µF / 10µF | 若干 | 去耦 |
| 11 | 外壳 | 3D 打印或铝合金 | 1 | 内部加配重与磁吸 |
2.6 接线表(照着插线就行)
ADXL355(SPI2)
| ESP32-S3 | ADXL355 | 说明 |
|---|---|---|
| GPIO11 | SDI / MOSI | 主机发、从机收 |
| GPIO13 | SDO / MISO | 从机发、主机收 |
| GPIO12 | SCLK | 时钟,10MHz |
| GPIO10 | CS | 片选,低有效 |
| 3V3 | VDD | 必须 3.3V,不能 5V |
| GND | GND | 共地 |
ATGM336H(UART1)
| ESP32-S3 | ATGM336H | 说明 |
|---|---|---|
| GPIO43 (TX) | RXD | 交叉连接 |
| GPIO44 (RX) | TXD | 交叉连接 |
| 3V3 | VCC | 3.3V |
| GND | GND | 共地 |
坑点:GPS 模块的 TX 电平有的是 3.3V,有的模块标 5V 兼容。接之前用万用表量一下 TX 空闲电平,>3.6V 要加电平转换,否则会打坏 ESP32 的 GPIO。
TF 卡(SPI3)
| ESP32-S3 | TF 卡座 | 说明 |
|---|---|---|
| GPIO35 | MOSI | 数据输入 |
| GPIO37 | MISO | 数据输出 |
| GPIO36 | SCLK | 时钟 |
| GPIO38 | CS | 片选 |
| 3V3 | VDD | 3.3V |
| GND | GND | 共地 |
为什么要用 SPI3 而不是和 ADXL355 共用 SPI2?
ESP-IDF 里spi_bus_initialize()对同一个 host 只能调用一次。两个模块共用一个 host 时,必须只初始化一次总线、挂两个 device。初学者最容易在这里踩坑:两次初始化直接报ESP_ERR_INVALID_STATE。本项目干脆把 SD 卡放到 SPI3,代码最简单,也不影响性能(SD 卡 SPI 时钟本来就只有几 MHz)。
电池检测与 LED
| ESP32-S3 | 连接 | 说明 |
|---|---|---|
| GPIO4 | 分压中点(200k 上 / 100k 下) | ADC1_CH3 |
| GPIO14~17 | 4 颗电量灯(串 1kΩ) | 每 25% 亮一颗 |
| GPIO18/19/20 | RGB 状态灯 R/G/B | 共阴,串 1kΩ |
2.7 安装方位约定(非常重要)
振动检测的方向性极强,装反了指标全废。本项目的约定:
X 轴:车辆前进方向(纵向);•
Y 轴:车辆横向(左右);•
Z 轴:垂直方向(上下,静止时应读到 ≈ +1g)。
所以设备外壳上要刻一个箭头,安装时箭头朝车头。静止时打开 APP,应看到 Z 轴约 1.0g、X/Y 约 0g——这是最快的自检方法。
第三章 开发环境搭建:让工程一次编译通过
3.1 安装 ESP-IDF v5.3
Windows 推荐用官方离线安装器(避免网络问题):
1. 打开乐鑫官网下载 esp-idf-tools-setup-online-*.exe;
2. 安装时选择 v5.3版本,安装路径不要包含中文和空格(例如 D:espv5.3);
3. 安装器会自动装好 Python、Git、工具链,并生成 export.bat。
安装完成后,在开始菜单找到 "ESP-IDF 5.3 PowerShell",输入:
idf.py --version
看到 ESP-IDF v5.3 就说明环境 OK。
3.2 VSCode 配置
1. 安装扩展 Espressif IDF(乐鑫官方);
2. Ctrl+Shift+P → 输入 ESP-IDF: Configure ESP-IDF extension;
3. 选择 Use existing setup,路径指向刚才安装的 D:espv5.3esp-idf;
4. 配置完成后,底部状态栏会出现芯片型号、串口、编译按钮。
提示:工程根目录下的
.vscode/settings.json建议加上"idf.flashType": "UART",避免 USB-JTAG 模式和串口模式混淆。
3.3 工程目录结构
firmware/
├── CMakeLists.txt ← 顶层构建入口
├── sdkconfig.defaults ← 默认配置(首次编译自动生成 sdkconfig)
├── partitions.csv ← 自定义分区表(16MB Flash)
├── main/
│ ├── CMakeLists.txt ← 声明 main 组件
│ └── main.c ← 应用入口 app_main()
└── components/ ← 自建组件(IDF 会自动扫描)
├── adxl355/
│ ├── CMakeLists.txt
│ ├── include/adxl355.h ← 对外头文件
│ └── adxl355.c
├── gps/
├── sdcard/
├── wifi_tcp/
├── battery/
└── led/
为什么用 components 目录而不是全塞进 main?
ESP-IDF 的构建系统会把 components/ 下每个子目录当成一个独立组件:自动编译、自动导出 include 路径、可被单独复用。这样驱动代码和业务逻辑解耦,将来换个项目直接拷走整个 adxl355/ 文件夹就能用。
3.4 三个关键配置文件逐行讲解
3.4.1 顶层 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
# 这一行必须最先执行,它引入 ESP-IDF 的构建规则
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# 工程名,会决定生成的 bin 文件名
project(vibration_detector)
坑点:
project()必须写在include(...)之后。写反了会报project command is not a script之类的怪错误。
3.4.2 main/CMakeLists.txt
idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES
adxl355 gps sdcard wifi_tcp battery led
driver nvs_flash esp_event esp_netif esp_wifi freertos log
)
SRCS:本组件要编译的源文件;
INCLUDE_DIRS:本组件对外暴露的头文件目录;
REQUIRES:依赖的其他组件名。
漏写会在链接阶段报 undefined reference to adxl355_init——这是新手最常见的报错,遇到就回来检查这一行。
3.4.3 partitions.csv(16MB Flash 分区)
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 0x400000,
storage, data, fat, 0x410000, 0xBF0000,
| 分区 | 用途 |
|---|---|
| nvs | WiFi 校准参数、用户配置(必须保留) |
| phy_init | RF 校准数据 |
| factory | 应用固件,给了 4MB,非常充裕 |
| storage | 这是片内 Flash 的数据区,本项目实际把数据写 TF 卡,这里留作扩展(比如存配置、离线缓存) |
注意:
Offset必须对齐到 4KB(0x1000)。写错会导致烧录成功但启动死机。
3.4.4 sdkconfig.defaults 里最关键的 6 项
CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y # N16R8 的 16MB
CONFIG_SPIRAM=y # 打开 PSRAM
CONFIG_SPIRAM_MODE_OCT=y # R8 是 Octal 模式,不是 Quad
CONFIG_FREERTOS_HZ=1000 # 1ms 一个 tick,才能做 1ms 延时
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
最容易翻车的一行:
CONFIG_SPIRAM_MODE_OCT。如果写成 Quad,启动会卡在SPI SRAM初始化,串口疯狂重启。N16R8 的 "R8" 一定要配 Octal。
3.5 第一次编译
cd firmware
idf.py set-target esp32s3 # 只在第一次需要
idf.py build
期望输出结尾:
Project build complete. To flash, run:
idf.py flash
如果报错,先看本文第十七章"常见问题速查",90% 的编译错误都在那里。
第四章 固件整体架构:任务怎么划分
4.1 数据流全景
┌────────────┐
│ ADXL355 │ 1000Hz
└─────┬──────┘
│ SPI 读 9 字节
┌─────▼──────┐
│ task_acc │ Core1, prio5
└─────┬──────┘
│ ring_push ×2
┌─────────────┴─────────────┐
│ │
┌───────▼────────┐ ┌─────────▼───────┐
│ g_ring_sd │ │ g_ring_tcp │
└───────┬────────┘ └─────────┬───────┘
│ │
┌───────▼────────┐ ┌─────────▼───────┐
│ task_sdcard │ │ task_tcp_send │
│ Core0, prio4 │ │ Core1, prio4 │
└───────┬────────┘ └─────────┬───────┘
│ │
CSV 文件 JSON → WiFi → APP
┌────────────┐ ┌────────────┐ ┌────────────┐
│ ATGM336H │──▶│ task_gps │──▶│ g_gps_data │──▶ TCP / SD 共用
└────────────┘ └────────────┘ └────────────┘
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 电池 ADC │──▶│ task_status│──▶│ LED 指示 │
└────────────┘ └────────────┘ └────────────┘
4.2 任务分工与优先级
| 任务 | 核心 | 优先级 | 栈大小 | 周期 | 职责 |
|---|---|---|---|---|---|
task_acc |
Core 1 | 5 | 4096 | 1ms | 读传感器、入队 |
task_tcp_send |
Core 1 | 4 | 8192 | 50ms | 攒批、拼 JSON、发 TCP |
task_sdcard |
Core 0 | 4 | 8192 | 20ms | 出队、写 CSV、刷盘 |
task_gps |
Core 0 | 3 | 4096 | 200ms | 读 UART、解析 NMEA |
task_status |
Core 0 | 2 | 3072 | 1s | 读电池、刷 LED |
main |
默认 | 1 | 8192 | 1s | 空闲 |
优先级分配逻辑:
- • 采样任务优先级最高(5),因为它对时间最敏感,晚 1ms 就会丢一个点;• TCP 和 SD 都是"数据搬运工",优先级次之(4);• GPS 是低频任务,200ms 一次,优先级 3;• LED 刷新慢,优先级最低(2)。
为什么把采样和 TCP 放同一个核?
采样和推送是"生产者-消费者",放同核可以减少跨核锁竞争;而 SD 写入涉及文件系统,可能阻塞几十毫秒,放到另一个核(Core 0)就不会拖累采样。
4.3 为什么用"两个环形队列"
这是本项目一个容易被忽略但非常致命的设计。
错误做法(很多教程的写法):
// 只有一个队列, TCP 和 SD 都从这个队列里取
uint32_t n1 = ring_pop(&ring, tcp_batch, 200); // TCP 先取走
uint32_t n2 = ring_pop(&ring, sd_batch, 256); // SD 只能取到剩下的
结果:两个消费者在抢同一份数据。TCP 取走 200 个,SD 就少 200 个,CSV 记录出现大量空洞;反过来 SD 先取,手机端就掉点。实测表现是"手机波形看着正常,但导出的 CSV 有断续"——非常难排查。
正确做法:采集任务把同一个采样点复制成两份,分别投递到两个队列:
ring_push(&g_ring_sd, &s);
ring_push(&g_ring_tcp, &s);
每个消费者各自消费自己的队列,互不干扰。代价是内存翻倍:
ACC_RING_SIZE 2048 × sizeof(acc_sample_t) 28 字节 × 2 路 ≈ 114 KB
ESP32-S3 内部 SRAM 512KB,放得下;即使再大也可以放到 PSRAM。
4.4 acc_sample_t 结构体设计
typedef struct {
uint32_t seq; // 序号, 从 0 递增, 用于检测丢包
int32_t x, y, z; // 20-bit 原始值, 用于二次计算/标定
float gx, gy, gz; // 已换算的物理量 (g)
uint32_t timestamp_ms; // 毫秒时间戳
} acc_sample_t;
为什么同时存原始值和 g 值?
gx/gy/gz 给 APP 直接算指标,省一次换算;
x/y/z 保留原始 LSB,将来做灵敏度标定或面法校准时需要原始数据,否则只能重采。
结构体大小 28 字节,正好对齐 4 字节,不会产生 padding 浪费(这是 C 语言里一个常见细节,字段顺序按 4 字节类型排即可)。
4.5 app_main 初始化顺序(顺序错了会死机)
void app_main(void)
{
nvs_flash_init(); // ① WiFi 依赖 NVS, 必须最先
ring_init(&g_ring_sd); // ② 队列
ring_init(&g_ring_tcp);
g_gps_mutex = xSemaphoreCreateMutex();
led_init(); // ③ 先点灯, 用户能看到"活着"
battery_init();
adxl355_init(); // ④ 传感器
adxl355_read_id(); // 校验 0xED
wifi_tcp_init_ap(...); // ⑤ 起 AP
vTaskDelay(1000); // 等 netif 就绪
wifi_tcp_start_server(8080); // ⑥ 起 TCP
xTaskCreatePinnedToCore(...); // ⑦ 最后创建业务任务
while (1) vTaskDelay(1000);
}
三个顺序细节,都是踩过的坑:
1. NVS 必须最先:WiFi 驱动初始化时要从 NVS 读校准数据,没初始化会报 ESP_ERR_NVS_NOT_INITIALIZED。
2. AP 起完要等一下再开 socket:esp_wifi_start() 是异步的,netif 还没拿到 IP 就 bind,可能返回 ERR_INPROGRESS。加 1 秒延时最省事。
3. 任务最后创建:如果先创建采集任务,它可能在你还没初始化 ADC/传感器时就开始跑,导致 NULL 指针。
第五章 ADXL355 驱动:1000Hz 采样的核心
这一章是全文技术含量最高的部分,建议对照 components/adxl355/adxl355.c 逐段看。
5.1 先搞懂 SPI 通信格式
ADXL355 的 SPI 是标准 4 线模式(Mode 0:CPOL=0,CPHA=0),第一个字节是命令:
bit7 ─────────── bit1 │ bit0
寄存器地址 (7位) │ R/W
R/W = 0 → 写
R/W = 1 → 读
所以:
写寄存器: 首字节 = (reg << 1) | 0x00
读寄存器: 首字节 = (reg << 1) | 0x01
举例:读 PARTID(地址 0x02):
(0x02 << 1) | 0x01 = 0x05
主机发送 0x05,从机在第二个字节把 ID 送回来。
常见错误:直接把地址当命令发(发 0x02),结果读到的是寄存器 0x01 的数据,永远对不上 ID。
5.2 寄存器地图(只列本项目用到的)
| 地址 | 名称 | 作用 |
|---|---|---|
| 0x00 | DEVID_AD | 器件 ID 0xAD |
| 0x02 | PARTID | 产品 ID 0xED,用于校验 |
| 0x04 | STATUS | bit0 = DATA_RDY |
| 0x05 | FIFO_ENTRIES | FIFO 中待读样本数 |
| 0x08~0x0A | XDATA3/2/1 | X 轴 20bit 数据 |
| 0x0B~0x0D | YDATA3/2/1 | Y 轴 |
| 0x0E~0x10 | ZDATA3/2/1 | Z 轴 |
| 0x28 | FILTER | bit[3:0]=ODR,bit[6:4]=HPF |
| 0x2C | RANGE | bit[1:0]=量程 |
| 0x2D | POWER_CTL | bit0=1 待机,bit0=0 测量 |
| 0x2F | RESET | 写 0x52 软复位 |
5.3 写寄存器
static esp_err_t adxl355_write_reg(uint8_t reg, uint8_t val)
{
uint8_t tx[2];
tx[0] = (reg << 1) | 0x00; // 命令:写
tx[1] = val; // 数据
spi_transaction_t t = {
.length = 16, // 2 字节 = 16 bit
.tx_buffer = tx,
};
return spi_device_polling_transmit(s_spi, &t);
}
spi_device_polling_transmit:轮询式发送,函数返回时传输已完成,不需要回调。对于这种小包、高频次的场景,比中断/队列方式更简单可靠。
.length 单位是 bit 而不是 byte,这里 2 字节写 16。
5.4 读寄存器与多字节连续读
ADXL355 支持"地址自增":读首字节后,后续字节自动是下一个寄存器。所以读 X/Y/Z 三个轴(9 个字节)只需一次事务:
static esp_err_t adxl355_read_bytes(uint8_t reg, uint8_t *buf, int len)
{
uint8_t tx[16] = {0};
uint8_t rx[16] = {0};
if (len > 15) return ESP_ERR_INVALID_SIZE;
tx[0] = (reg << 1) | 0x01; // 命令:读起始地址
spi_transaction_t t = {
.length = (len + 1) * 8, // 命令 1 字节 + 数据 len 字节
.tx_buffer = tx,
.rx_buffer = rx,
};
esp_err_t err = spi_device_polling_transmit(s_spi, &t);
if (err == ESP_OK) {
memcpy(buf, &rx[1], len); // rx[0] 是命令期间的无效数据, 丢弃
}
return err;
}
关键点 1:
rx[0]是发命令时 MISO 上的数据,无意义,必须跳过。
关键点 2:先把 9 个字节一次性读出来再解析。如果分三次读(每次读 3 字节),中间可能跨采样点,导致 X/Y/Z 不是同一个时刻的数据(这叫"数据撕裂")。1000Hz 下这个问题很容易出现。
关键点 3:10MHz 时钟读 10 字节约 8µs,1ms 采样周期完全够。
5.5 20-bit 数据解码(最容易写错的地方)
ADXL355 每个轴是 20 位有符号数,分布在 3 个字节里,但低 4 位是无效的:
XDATA3: D19 D18 D17 D16 D15 D14 D13 D12
XDATA2: D11 D10 D9 D8 D7 D6 D5 D4
XDATA1: D3 D2 D1 D0 0 0 0 0 ← 低 4 位补零
所以拼接公式:
uint32_t ux = ((uint32_t)buf[0] << 12) // D19..D12 左移 12 位
| ((uint32_t)buf[1] << 4) // D11..D4 左移 4 位
| ((uint32_t)buf[2] >> 4); // D3..D0 取高 4 位
得到的 ux 是 20 位无符号数(0 ~ 0xFFFFF)。要转成负数,需要符号扩展:
static int32_t sign_extend_20bit(uint32_t v)
{
if (v & 0x80000) { // 第 19 位是符号位
return (int32_t)(v | 0xFFF00000); // 高 12 位全补 1
}
return (int32_t)v; // 正数直接返回
}
为什么要补 0xFFF00000?
32 位有符号数的高 12 位代表符号,20 位负数扩展后高 12 位必须全为 1,否则 -1 会被解析成 0x000FFFFF(= +1048575),数据完全错乱。
验证方法:把设备静止平放,理论上 Z 轴应读到约 +256000(因为 1g / 3.9µg ≈ 256410 LSB)。如果读到 -1040000 之类的怪值,就是符号扩展写错了。
5.6 量程与换算
typedef enum {
ADXL355_RANGE_2G = 0x01, // ±2.048g
ADXL355_RANGE_4G = 0x02, // ±4.096g
ADXL355_RANGE_8G = 0x03, // ±8.192g
} adxl355_range_t;
对应灵敏度:
| 量程 | 灵敏度 | 满量程对应 LSB |
|---|---|---|
| ±2.048g | 3.9 µg/LSB | ±524288 |
| ±4.096g | 7.8 µg/LSB | ±524288 |
| ±8.192g | 15.6 µg/LSB | ±524288 |
注意一个反直觉的点:三档量程的"满量程 LSB 数"是一样的,都是 20 位能表示的范围;变的是 LSB 代表的物理量。所以换量程时必须同步改换算系数,否则数值会差 2 倍/4 倍。
float adxl355_raw_to_g(int32_t raw)
{
float scale_ug = 0;
switch (s_range) {
case ADXL355_RANGE_2G: scale_ug = 3.9f; break;
case ADXL355_RANGE_4G: scale_ug = 7.8f; break;
case ADXL355_RANGE_8G: scale_ug = 15.6f; break;
}
return raw * scale_ug * 1e-6f; // µg → g
}
5.7 输出数据率 ODR:怎么配出 1000Hz
FILTER 寄存器(0x28):
bit7 ─────── bit4 │ bit3 ─────── bit0
HPF 高角频率 │ ODR 选择
ODR 对应表(HPF 旁路时):
| FILTER[3:0] | ODR |
|---|---|
| 0x0 | 4000 Hz |
| 0x1 | 2000 Hz |
| 0x2 | 1000 Hz |
| 0x3 | 500 Hz |
| 0x4 | 250 Hz |
| 0x5 | 125 Hz |
| 0x6 | 62.5 Hz |
| 0x7 | 31.25 Hz |
esp_err_t adxl355_set_filter(uint8_t filter_val)
{
uint8_t v = (filter_val & 0x0F); // 高 4 位写 0 => HPF 旁路
return adxl355_write_reg(ADXL355_REG_FILTER, v);
}
// 调用: adxl355_set_filter(0x02); → 1000Hz
为什么 HPF 要旁路?
高通滤波器会滤掉低频(接近直流的重力分量)。做"振动"检测时我们通常希望保留低频成分(比如车体缓慢晃动、0.5Hz 的悬挂共振),所以旁路 HPF,后期在 APP 端用软件去直流(减均值)即可,更灵活。
5.8 初始化流程:复位 → 校验 → 配置 → 进测量模式
esp_err_t adxl355_init(void)
{
/* ① 初始化 SPI 总线 */
spi_bus_config_t bus = {
.mosi_io_num = ADXL355_PIN_MOSI,
.miso_io_num = ADXL355_PIN_MISO,
.sclk_io_num = ADXL355_PIN_SCLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 64,
};
spi_bus_initialize(ADXL355_SPI_HOST, &bus, SPI_DMA_CH_AUTO);
/* ② 挂载从设备 */
spi_device_interface_config_t dev = {
.clock_speed_hz = 10 * 1000 * 1000,
.mode = 0,
.spics_io_num = ADXL355_PIN_CS,
.queue_size = 4,
};
spi_bus_add_device(ADXL355_SPI_HOST, &dev, &s_spi);
/* ③ 软复位, 保证寄存器是已知状态 */
adxl355_write_reg(ADXL355_REG_RESET, 0x52);
vTaskDelay(pdMS_TO_TICKS(10)); // 复位后必须等 >5ms
/* ④ 校验 ID, 读不到说明接线错/供电错/SPI 模式错 */
uint8_t id;
adxl355_read_reg(ADXL355_REG_PARTID, &id);
if (id != 0xED) {
ESP_LOGE(TAG, "ID 校验失败: 0x%02X", id);
return ESP_ERR_NOT_FOUND;
}
/* ⑤ 配置量程与滤波 */
adxl355_set_range(ADXL355_RANGE_2G);
adxl355_set_filter(0x02); // 1000Hz
/* ⑥ 退出待机, 进入测量模式 */
uint8_t pwr;
adxl355_read_reg(ADXL355_REG_POWER_CTL, &pwr);
pwr &= ~0x01; // bit0 清 0 = 测量
adxl355_write_reg(ADXL355_REG_POWER_CTL, pwr);
return ESP_OK;
}
六步里最容易漏的是第 ③ 步和第 ⑥ 步:
- • 不复位:如果芯片之前被其它程序配置过,量程可能是 ±8g,你按 ±2g 换算,数值差 4 倍;• 不进测量模式:POWER_CTL 默认是待机,读完永远是 0,很多人以为是硬件坏了。
5.9 采样循环为什么用 vTaskDelay(1)
static void task_acc(void *arg)
{
adxl355_set_filter(0x02);
uint32_t seq = 0;
while (g_running) {
int32_t x, y, z;
if (adxl355_read_xyz(&x, &y, &z) == ESP_OK) {
acc_sample_t s = {
.seq = seq++,
.x = x, .y = y, .z = z,
.gx = adxl355_raw_to_g(x),
.gy = adxl355_raw_to_g(y),
.gz = adxl355_raw_to_g(z),
.timestamp_ms = esp_log_timestamp(),
};
ring_push(&g_ring_sd, &s);
ring_push(&g_ring_tcp, &s);
}
vTaskDelay(pdMS_TO_TICKS(1));
}
}
坦诚地说,这不是"硬实时"采样。vTaskDelay(1) 的实际周期 = 1ms + 任务调度开销(通常 0.1~0.5ms 抖动)。所以在振动剧烈时会有轻微的时间不均匀,FFT 出来的频谱会有少量展宽。
但为什么本教程仍然用它? 三个理由:
- 1. 1000Hz 的抖动在 0.2ms 量级,相对周期 1ms 是 20%,对 0.5~20Hz 的车辆振动分析影响很小(最高分析频率才 20Hz,采样率是它的 50 倍);2. 代码极简,便于理解,教学价值高;3. 真正需要精密频谱时,可以用下节的 FIFO 方案,几行代码就能升级。
5.10 进阶:用 FIFO + 数据就绪位做到"真 1000Hz"
ADXL355 内部有 32 深度 FIFO,且 ODR 由芯片自己的时钟产生,精度比 MCU 延时高两个数量级。做法:
// 配置 FIFO 水位: FIFO_SAMPLES(0x29) 低 7 位 = 水位值
// 例如水位 16: 每累计 16 个样本把 DATA_RDY 置高
while (1) {
uint8_t status, entries;
adxl355_read_reg(0x04, &status);
if (status & 0x01) { // DATA_RDY
adxl355_read_reg(0x05, &entries);
// 从 FIFO_DATA(0x11) 连续读出 3×entries 字节, 每 3 字节是一个轴的 20bit
for (int i = 0; i < entries; i++) {
uint8_t raw[3];
adxl355_read_bytes(0x11, raw, 3); // 注意: FIFO 数据是 20bit 未对齐格式
// 解析 + 入队
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
FIFO 读取的坑:FIFO 里的数据格式与 XDATA 寄存器不同(没有 4 位补零对齐),需要按数据手册 Figure 35 对齐。如果你不熟悉,建议先用本文的基础方案,等整个系统跑通后再优化。
5.11 噪声与零偏:能不能直接出报告
真实检测仪必须处理两个非理想因素:
零偏(Offset):静止时 X/Y 应该读 0,但实际可能有 ±0.01~0.05g 的固定偏移。APP 端用"减均值"能消除,这是最省事的办法:
removeMean(x); removeMean(y); removeMean(z) // 见第十四章
温漂:ADXL355 零偏温漂约 ±0.5 mg/°C,从 20°C 到 40°C 可能漂 10mg。对于"相对振动"指标(Wz、RMS)影响很小,因为减均值后温漂被吃掉了。
真正需要绝对精度时,做六面法标定(见第十六章)。
第六章 GPS 驱动:NMEA 报文解析
6.1 NMEA 0183 是什么
GPS 模块上电后,会不停地通过串口输出"句子",每个句子一行,以 $ 开头、以 *校验和 结尾:
$GNRMC,083559.00,A,3958.7324,N,11619.4413,E,0.05,,210624,,,A*6C
$GNGGA,083559.00,3958.7324,N,11619.4413,E,1,12,0.9,48.2,M,-9.4,M,,*5A
-
- •
GN = 多系统(GPS+北斗+GLONASS 融合);
GP = 纯 GPS;
• 字段用逗号分隔,空字段表示无效;
• 本项目只解析两帧:RMC(定位+速度)和 GGA(质量+卫星数)。
6.2 RMC 逐字段拆解
以这行为例:
$GNRMC,083559.00,A,3958.7324,N,11619.4413,E,0.05,,210624,,,A*6C
[0] [1] [2] [3] [4] [5] [6] [7][8][9] [10..]
| 下标 | 内容 | 含义 |
|---|---|---|
| 0 | $GNRMC |
报文头 |
| 1 | 083559.00 |
UTC 时间 HHMMSS.ss |
| 2 | A |
定位状态:A=有效,V=无效 |
| 3 | 3958.7324 |
纬度 ddmm.mmmm |
| 4 | N |
北纬/南纬 |
| 5 | 11619.4413 |
经度 dddmm.mmmm |
| 6 | E |
东经/西经 |
| 7 | 0.05 |
地速,单位节(knots) |
| 8 | 空 | 航向,单位度 |
| 9 | 210624 |
日期 DDMMYY |
关键判断:第 2 个字段如果不等于
A,说明还没定位成功,此时经纬度全是 0,必须丢弃,否则你会把设备标在几内亚湾(0,0)。
6.3 GGA 逐字段拆解
$GNGGA,083559.00,3958.7324,N,11619.4413,E,1,12,0.9,48.2,M,-9.4,M,,*5A
[0] [1] [2] [3] [4] [5] [6][7][8] [9] ...
| 下标 | 内容 | 含义 |
|---|---|---|
| 6 | 1 |
定位质量:0=无,1=GPS,2=DGPS,4=RTK |
| 7 | 12 |
卫星数 |
| 9 | 48.2 |
海拔高度(米) |
6.4 代码实现:通用字段提取函数
先用一个通用函数按逗号切分,比 strtok 更可控(strtok 会破坏原字符串,且不支持空字段):
/* 提取第 idx 个逗号分隔字段 (0-based), 支持空字段 */
static int get_field(const char *sentence, int idx, char *out, int maxlen)
{
int field = 0, start = 0, i = 0;
for (; sentence[i]; i++) {
if (sentence[i] == ',') {
if (field == idx) {
int len = i - start;
if (len >= maxlen) len = maxlen - 1;
strncpy(out, sentence + start, len);
out[len] = 0;
return len;
}
field++;
start = i + 1;
}
}
/* 最后一个字段(没有后续逗号) */
if (field == idx) {
int len = i - start;
if (len >= maxlen) len = maxlen - 1;
strncpy(out, sentence + start, len);
out[len] = 0;
return len;
}
return -1;
}
6.5 经纬度格式转换(必考知识点)
NMEA 的纬度是 ddmm.mmmm(度分格式),必须转成十进制度:
static double nmea_to_degrees(const char *raw, char hemi)
{
double val = atof(raw); // 3958.7324
int deg = (int)(val / 100); // 39 (度)
double min = val - deg * 100; // 58.7324 (分)
double dec = deg + min / 60.0; // 39 + 0.978873 = 39.978873
if (hemi == 'S' || hemi == 'W') dec = -dec; // 南纬/西经为负
return dec;
}
手算一遍:
3958.7324 → 39 度 58.7324 分
58.7324 / 60 = 0.9788733
39 + 0.9788733 = 39.9788733 ← 北京天安门附近, 正确
常见错误:直接用
atof(raw) / 100,得到 39.58,看起来"差不多",实际差了 44 公里。
6.6 报文解析状态机
static void parse_nmea(const char *line)
{
char f[32];
if (strncmp(line, "$GNRMC", 6) == 0 || strncmp(line, "$GPRMC", 6) == 0) {
get_field(line, 1, f, sizeof(f)); // UTC 时间
strncpy(s_last.utc_time, f, sizeof(s_last.utc_time) - 1);
char status = 'V';
if (get_field(line, 2, f, sizeof(f)) > 0) status = f[0];
if (status != 'A') return; // 未定位, 直接丢弃
char lat_s[32], lon_s[32], ns, ew;
get_field(line, 3, lat_s, sizeof(lat_s));
get_field(line, 4, f, sizeof(f)); ns = f[0];
get_field(line, 5, lon_s, sizeof(lon_s));
get_field(line, 6, f, sizeof(f)); ew = f[0];
s_last.lat = nmea_to_degrees(lat_s, ns);
s_last.lon = nmea_to_degrees(lon_s, ew);
get_field(line, 7, f, sizeof(f)); s_last.speed_knots = atof(f);
get_field(line, 8, f, sizeof(f)); s_last.course = atof(f);
s_last.valid = 1;
} else if (strncmp(line, "$GNGGA", 6) == 0 || strncmp(line, "$GPGGA", 6) == 0) {
get_field(line, 6, f, sizeof(f)); s_last.fix = atoi(f);
get_field(line, 7, f, sizeof(f)); s_last.sats = atoi(f);
get_field(line, 9, f, sizeof(f)); s_last.altitude = atof(f);
s_last.valid = 1;
}
}
为什么要同时匹配 GN 和 GP 前缀? 模块在不同状态下输出前缀会变(搜星阶段可能输出
$GPGGA,稳定后变$GNGGA)。只匹配一种,就会出现"定位好了但数据不更新"的诡异现象。
6.7 UART 初始化与行缓冲接收
esp_err_t gps_init(void)
{
uart_config_t cfg = {
.baud_rate = 9600,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
uart_driver_install(GPS_UART_PORT, 2048 * 2, 0, 0, NULL, 0);
uart_param_config(GPS_UART_PORT, &cfg);
uart_set_pin(GPS_UART_PORT, 43, 44, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
return ESP_OK;
}
接收时用"逐字节喂入行缓冲"的方式,遇到 r/n 就解析一行:
esp_err_t gps_read(gps_data_t *out)
{
uint8_t buf[512];
static char line_buf[256];
static int line_pos = 0;
int len = uart_read_bytes(GPS_UART_PORT, buf, sizeof(buf), pdMS_TO_TICKS(10));
for (int i = 0; i < len; i++) {
char c = buf[i];
if (c == 'n' || c == 'r') {
if (line_pos > 0) {
line_buf[line_pos] = 0;
parse_nmea(line_buf);
line_pos = 0;
}
} else if (line_pos < (int)sizeof(line_buf) - 1) {
line_buf[line_pos++] = c;
}
}
*out = s_last;
return s_last.valid ? ESP_OK : ESP_ERR_NOT_FINISHED;
}
为什么要行缓冲? 一次
uart_read_bytes读到的数据可能包含半条报文(串口是流式的)。直接对整块 buffer 解析会读到截断残句,导致解析失败。
6.8 验证方法
烧录后打开串口监视器,应该看到类似日志;也可以在 task_gps 里临时打印:
ESP_LOGI("GPS", "lat=%.6f lon=%.6f spd=%.2f sats=%d fix=%d",
d.lat, d.lon, d.speed_knots, d.sats, d.fix);
指标含义:
| 现象 | 判断 |
|---|---|
fix=0, sats=0 |
没搜到星,检查天线、是否在室内 |
sats 从 0 慢慢涨到 8+ |
正常搜星过程 |
fix=1, sats>=6 |
定位可用 |
| 经纬度全是 0 | RMC 状态位不是 A,被正确丢弃了 |
第七章 WiFi AP + TCP:把数据送出去
7.1 三种联网方案怎么选
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| AP 模式(本项目) | 不需要路由器,手机直连,现场即用 | 手机连上后上不了网 | 车载、野外检测 |
| STA 模式 | 可接入现有 WiFi,能连云 | 依赖现场网络 | 固定场所 |
| BLE | 功耗低 | 带宽小(<100KB/s),1000Hz 不够 | 低频传感器 |
本项目选 AP 模式:设备自己当热点,手机连上即用,不依赖任何现场条件,这就是需求里"一键操作"的实现方式。
7.2 WiFi 初始化四步
esp_err_t wifi_tcp_init_ap(const char *ssid, const char *password)
{
ESP_ERROR_CHECK(esp_netif_init()); // ① 网络协议栈
ESP_ERROR_CHECK(esp_event_loop_create_default()); // ② 默认事件循环
esp_netif_create_default_wifi_ap(); // ③ 创建 AP 网卡
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg)); // ④ WiFi 驱动
wifi_config_t wc = {0};
strncpy((char *)wc.ap.ssid, ssid, sizeof(wc.ap.ssid) - 1);
strncpy((char *)wc.ap.password, password, sizeof(wc.ap.password) - 1);
wc.ap.ssid_len = strlen(ssid);
wc.ap.max_connection = 4;
wc.ap.authmode = WIFI_AUTH_WPA2_PSK;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wc));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_wifi_start());
return ESP_OK;
}
这几行的作用,一个一个说清楚:
| 步骤 | 作用 | 漏了会怎样 |
|---|---|---|
esp_netif_init |
初始化 TCP/IP 协议栈(lwIP) | socket 全部失败 |
esp_event_loop_create_default |
创建事件循环 | WiFi 事件收不到,无法感知连接 |
create_default_wifi_ap |
创建 AP 网络接口,分配默认 IP 192.168.4.1 | 手机连上但没有 IP |
esp_wifi_init |
加载驱动、分配 WiFi 任务 | 后续配置全部返回错误 |
密码规则:WPA2 密码必须 8~63 位。少于 8 位会报
ESP_ERR_WIFI_PASSWORD,或者必须改成开放式(WIFI_AUTH_OPEN)。
7.3 事件回调:知道谁连上了
static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
{
if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
wifi_event_ap_staconnected_t *e = data;
ESP_LOGI(TAG, "手机接入: " MACSTR, MAC2STR(e->mac));
} else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STADISCONNECTED) {
wifi_event_ap_stadisconnected_t *e = data;
ESP_LOGI(TAG, "手机断开: " MACSTR, MAC2STR(e->mac));
}
}
注意:WiFi 连上 ≠ TCP 连上。手机连上热点只代表链路通了,还要 APP 主动发起 TCP 连接。这两件事分开调试,能快速定位问题:如果看不到"手机接入"日志,是 WiFi 问题;看到了但没有数据,是 TCP/APP 问题。
7.4 TCP Server 的完整实现
static void tcp_server_task(void *arg)
{
uint16_t port = (uint16_t)(uintptr_t)arg;
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr.s_addr = htonl(INADDR_ANY),
};
s_server_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
int opt = 1;
setsockopt(s_server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(s_server_fd, (struct sockaddr *)&addr, sizeof(addr));
listen(s_server_fd, 1);
while (1) {
struct sockaddr_in caddr;
socklen_t clen = sizeof(caddr);
int fd = accept(s_server_fd, (struct sockaddr *)&caddr, &clen);
if (fd < 0) { vTaskDelay(pdMS_TO_TICKS(100)); continue; }
if (s_client_fd >= 0) close(s_client_fd); // 只允许一个客户端
s_client_fd = fd;
uint8_t rbuf[128];
while (1) {
int r = recv(fd, rbuf, sizeof(rbuf), 0);
if (r <= 0) break; // 返回 0 = 对方关闭
}
close(fd);
s_client_fd = -1;
}
}
四个细节:
1. SO_REUSEADDR:APP 反复断连重连时,避免 bind: Address already in use。
2. htons(port):网络字节序是大端,ESP32 是小端,必须转换。忘了写会 bind 到错误的端口。
3. recv 返回 0 表示对方正常关闭,返回 -1 才是错误。只判断 r < 0 会导致死循环。
4. s_client_fd = -1 必须在所有退出路径上执行,否则发送任务会一直往一个已关闭的 fd 写数据。
7.5 发送接口与并发安全
bool wifi_tcp_client_connected(void) { return s_client_fd >= 0; }
int wifi_tcp_send(const uint8_t *data, int len)
{
if (s_client_fd < 0) return -1;
return send(s_client_fd, data, len, 0);
}
并发风险:s_client_fd 会被 TCP 任务修改、被发送任务读取。虽然 int 读写是原子的,但仍存在"读到有效值但刚好被 close"的窗口。工程上可以接受(send 会返回 -1,重试即可),如果要严格,就加互斥锁包住。
7.6 数据协议设计(这一节决定 APP 好不好写)
每包一条 JSON,以 n 结尾:
{"type":"vib","count":50,
"gps":{"lat":39.978873,"lon":116.324021,"spd":0.05,"sats":12,"fix":1},
"bat":87,
"samples":[[1000,0.01234,-0.00321,0.98123],
[1001,0.01567,-0.00298,0.98099],
...]}
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
type |
string | 固定 "vib",便于将来扩展其它报文(如 "cmd"、"ack") |
count |
int | 本包采样点数(实际写入数,不是打算写的数) |
gps |
object | 本包对应的 GPS 快照 |
bat |
int | 电池百分比 |
samples |
array | 每项 [seq, gx, gy, gz],用数组不用对象是为了省 40% 带宽 |
为什么是"行分隔"而不是固定长度包头?
• APP 端 readLine() 一行一个对象,解析极简单;
• 断线重连后不会因为半个包头而解析错乱;
• 调试时可以直接 telnet 连上去肉眼看数据。
带宽核算(最坏情况 200 点/包):
每点约 "[123456,0.01234,-0.00321,0.98123]" ≈ 38 字节
200 点 ≈ 7600 字节 + 头部约 120 字节 ≈ 7.7 KB / 50ms
瞬时带宽 ≈ 154 KB/s ≈ 1.2 Mbps
ESP32-S3 的 WiFi 实测 TCP 吞吐可达 10~20 Mbps,余量充足。但如果手机端处理慢(比如低端机),发送会阻塞,此时环形队列会满、开始丢旧数据——这就是为什么 ring_push 里要记录 dropped 计数,方便排查。
7.7 JSON 拼装的正确写法
这是最容易出现"非法 JSON"的地方。看下面这段(本项目 main.c 的实际实现):
int off = snprintf(send_buf, sizeof(send_buf),
"{"type":"vib","count":%" PRIu32 ","
""gps":{"lat":%.6f,"lon":%.6f,"spd":%.2f,"sats":%d,"fix":%d},"
""bat":%d,"samples":[",
n, g.lat, g.lon, g.speed_knots, g.sats, g.fix, g_battery_percent);
int written = 0;
for (uint32_t i = 0; i < n; i++) {
int room = (int)sizeof(send_buf) - off - 3; // 预留 "]}n"
if (room < 40) break;
int w = (written == 0)
? snprintf(send_buf + off, room, "[%" PRIu32 ",%.5f,%.5f,%.5f]",
batch[i].seq, batch[i].gx, batch[i].gy, batch[i].gz)
: snprintf(send_buf + off, room, ",[%" PRIu32 ",%.5f,%.5f,%.5f]",
batch[i].seq, batch[i].gx, batch[i].gy, batch[i].gz);
if (w <= 0 || w >= room) break; // 放不下就停, 保证 JSON 合法
off += w;
written++;
}
send_buf[off++] = ']';
send_buf[off++] = '}';
send_buf[off++] = 'n';
三个容易写错的地方:
错误写法 A:先写逗号后判断
for (i = 0; i < n; i++) {
w = snprintf(..., "[...]", ...);
off += w;
if (i < n - 1) send_buf[off++] = ','; // 最后一个不加逗号
}
问题:如果循环中途 break(缓冲区满),上一个元素已经加了逗号,接着写 ],得到
[..., [123,0.1,0.2,0.3], ] ← 尾部多一个逗号, 非法 JSON
错误写法 B:把 count 写成打算发的数量
snprintf(header, ..., n, ...); // n = 取出的数量
如果后面因为缓冲区满只写了 written 个,APP 按 count 去校验就会以为丢包。本项目的 count 虽然写在头部,但 APP 是用 samples.size 遍历的(见第十三章),不会出错;更严谨的做法是先用数组统计再写头部。
正确写法(本项目):逗号前置(written == 0 时不加逗号),并且每次写入前检查剩余空间,写失败就立刻停,保证数组括号永远配平。
提示:ESP-IDF 默认关闭
NEWLIB_NANO_FORMAT,%f可用。如果 sdkconfig 里打开了 nano 格式,%f会输出乱码或空,遇到"数字全是 0"就检查这个选项。
第八章 SD 卡记录:断电也不丢数据
8.1 为什么选 SPI 模式而不是 SDMMC
| 模式 | 引脚 | 速度 | 接线复杂度 |
|---|---|---|---|
| SDMMC(4 线) | 6 根(CLK/CMD/D0~D3) | 高速,可达 20MB/s | 高,且占用固定 GPIO |
| SPI(本项目) | 4 根(CS/MOSI/MISO/SCLK) | 中等,约 1~3MB/s | 低,任意 GPIO |
本项目数据量只有 12 KB/s,SPI 模式绰绰有余,而且能用任意引脚,布线灵活。
8.2 挂载代码逐行
esp_err_t sdcard_mount(void)
{
if (s_mounted) return ESP_OK;
esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
.format_if_mount_failed = false, // 千万别设 true!
.max_files = 8,
.allocation_unit_size = 16 * 1024, // 簇大小, 影响小文件读写效率
};
sdmmc_host_t host = SDSPI_HOST_DEFAULT();
host.slot = SPI3_HOST; // 用 SPI3, 避开 ADXL355 的 SPI2
spi_bus_config_t bus = {
.mosi_io_num = SD_PIN_MOSI,
.miso_io_num = SD_PIN_MISO,
.sclk_io_num = SD_PIN_SCLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 4096,
};
spi_bus_initialize(host.slot, &bus, SPI_DMA_CH_AUTO);
sdspi_device_config_t slot = SDSPI_DEVICE_CONFIG_DEFAULT();
slot.gpio_cs = SD_PIN_CS;
slot.host_id = SPI3_HOST;
return esp_vfs_fat_sdspi_mount("/sdcard", &host, &slot, &mount_cfg, &s_card);
}
format_if_mount_failed必须是 false。设成 true 时,只要卡里文件系统不认识(比如 FAT32 但分区表异常),ESP32 就会直接格式化整张卡。用户插一张存着照片的卡进来,照片全没了——这是会造成真实数据损失的配置项。
8.3 文件命名与写入策略
char fname[64];
snprintf(fname, sizeof(fname), "%s/vib_%08u.csv", "/sdcard",
(unsigned)(esp_log_timestamp() / 1000));
FILE *fp = fopen(fname, "w");
fprintf(fp, "seq,timestamp_ms,gx,gy,gz,lat,lon,speed_knots,nsats,fixn");
为什么每秒 fflush 一次?
FATFS 有文件缓存,不 flush 的话数据可能还留在 RAM 里。一旦设备突然断电(车上最常见),最后几秒到几十秒的数据全部丢失。每秒刷一次,最多丢 1 秒。
代价:每秒写一次 Flash,对 TF 卡寿命有影响,但 12 小时检测只有 4 万次写入,现代 TF 卡寿命在十万次量级,可以接受。如果追求极致寿命,可以改为每 5 秒刷一次。
8.4 记录任务
static void task_sdcard(void *arg)
{
if (sdcard_mount() != ESP_OK) { g_sd_ok = false; vTaskDelete(NULL); return; }
FILE *fp = fopen(fname, "w");
if (!fp) { g_sd_ok = false; sdcard_unmount(); vTaskDelete(NULL); return; }
acc_sample_t batch[256];
TickType_t last_flush = xTaskGetTickCount();
while (g_running) {
uint32_t n = ring_pop_batch(&g_ring_sd, batch, 256);
if (n > 0) {
gps_data_t g;
xSemaphoreTake(g_gps_mutex, portMAX_DELAY);
g = g_gps_data; // 整批共用一次 GPS 快照
xSemaphoreGive(g_gps_mutex);
for (uint32_t i = 0; i < n; i++) {
fprintf(fp, "%u,%u,%.6f,%.6f,%.6f,%.6f,%.6f,%.2f,%d,%dn",
batch[i].seq, batch[i].timestamp_ms,
batch[i].gx, batch[i].gy, batch[i].gz,
g.lat, g.lon, g.speed_knots, g.sats, g.fix);
}
}
if (xTaskGetTickCount() - last_flush >= pdMS_TO_TICKS(1000)) {
fflush(fp);
last_flush = xTaskGetTickCount();
}
vTaskDelay(pdMS_TO_TICKS(20));
}
fclose(fp);
sdcard_unmount();
}
性能细节:一次取 256 个点、批量
fprintf,比"来一个写一个"快得多,因为文件系统写操作有固定开销。GPS 快照每批只取一次,避免 256 次加锁。注意:GPS 只有 5Hz,所以同一批 256 个点(0.25 秒)共用同一个 GPS 值是合理的,误差小于 0.2 秒。
8.5 数据怎么用
拔卡插电脑,用 Excel / Python 打开 CSV:
import pandas as pd
df = pd.read_csv("vib_00001234.csv")
print(df.head())
print("采样点数:", len(df))
print("估算采样率:", len(df) / ((df.timestamp_ms.iloc[-1] - df.timestamp_ms.iloc[0]) / 1000), "Hz")
验证采样率:如果算出来是 1000Hz 左右(±2%),说明采集链路正常;如果是 800Hz 或 1200Hz,说明 vTaskDelay(1) 的节拍被其它任务拖慢了,把 task_acc 优先级再调高、或改用 FIFO 方案。
第九章 电源管理与指示灯
9.1 IP5306 的两种用法
IP5306 自带 4 个 LED 驱动脚(LED1~LED4,低电平点亮),可以直接接 4 颗灯做电量显示,不需要 MCU 参与。
但本项目仍然用 ESP32 的 GPIO 驱动这 4 颗灯,原因:
- 1. 可以在 APP 里同步显示电量(需要 MCU 读到电压);2. 实现"低电量慢闪"等自定义效果;3. 板子上 LED 和 IP5306 的 LED 脚可能物理冲突,用 MCU 复用更灵活。
如果你用 IP5306 自带 LED,就把 led_set_battery() 换成空实现,省 4 个 GPIO。
9.2 电池电压采样
分压电路:
Vbat ──[200kΩ]──┬──[100kΩ]── GND
│
GPIO4 (ADC)
分压比:
Vadc = Vbat × 100k / (200k + 100k) = Vbat / 3
Vbat = Vadc × 3
4.2V 满电时 ADC 端只有 1.4V,安全落在 ADC 0~3.3V 量程内。
9.3 ADC 初始化(ESP-IDF v5.x 的新 API)
esp_err_t battery_init(void)
{
adc_oneshot_unit_init_cfg_t cfg = { .unit_id = ADC_UNIT_1 };
adc_oneshot_new_unit(&cfg, &s_adc);
s_adc_chan = ADC_CHANNEL_3; // GPIO4 = ADC1_CH3
adc_oneshot_chan_cfg_t ch = {
.bitwidth = ADC_BITWIDTH_12,
.atten = ADC_ATTEN_DB_12, // 量程 0~3.3V
};
adc_oneshot_config_channel(s_adc, s_adc_chan, &ch);
adc_cali_line_fitting_config_t cali_cfg = {
.unit_id = ADC_UNIT_1,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_12,
};
adc_cali_create_scheme_line_fitting(&cali_cfg, &s_cali);
return ESP_OK;
}
v5.x 与 v4.x 的差别:v4 用
adc1_config_width/channel_atten,v5 改成oneshot+cali对象。很多老教程的代码在 v5.3 编译不过,就是这个原因。
ADC_ATTEN_DB_12的实际量程约 0~3.1V(不是标称 3.3V),所以 4.2V 电池经 1/3 分压后约 1.4V,余量足够。
9.4 多次采样求平均
float battery_get_voltage(void)
{
int sum = 0, raw = 0, mv = 0;
for (int i = 0; i < 8; i++) {
if (adc_oneshot_read(s_adc, s_adc_chan, &raw) == ESP_OK) sum += raw;
vTaskDelay(pdMS_TO_TICKS(2));
}
raw = sum / 8;
if (s_cali) adc_cali_raw_to_voltage(s_cali, raw, &mv);
return mv * 3.0f / 1000.0f; // mV → V, 再乘分压比
}
为什么要采 8 次? 锂电池带负载时电压有纹动,单次采样可能误差 ±5%。8 次平均后误差降到 ±1% 以内,否则电量显示会来回跳。
9.5 电量百分比映射
线性映射最简单,但锂电池的放电曲线不是线性的:
int battery_get_percent(void)
{
float v = battery_get_voltage();
if (v <= 3.0f) return 0;
if (v >= 4.2f) return 100;
return (int)((v - 3.0f) / 1.2f * 100.0f);
}
更准确的做法(可选):查表分段插值
| 电压 | 电量 |
|---|---|
| 4.20V | 100% |
| 4.00V | 80% |
| 3.85V | 50% |
| 3.75V | 25% |
| 3.60V | 10% |
| 3.30V | 3% |
| 3.00V | 0% |
改起来很简单,加个数组插值即可。线性映射在 3.7~3.9V 区间会显示偏低(实际还有 70%,显示只有 60%),这是常见现象。
9.6 LED 驱动
void led_set_battery(int percent)
{
int n = (percent + 24) / 25; // 0-24→1, 25-49→2, 50-74→3, 75+→4
if (n < 1) n = 1;
if (n > 4) n = 4;
for (int i = 0; i < 4; i++)
gpio_set_level(s_bat_pins[i], (i < n) ? 1 : 0);
}
(percent + 24) / 25是整数运算实现"向上取整除法"的小技巧。等价于ceil(percent / 25.0),但不用浮点。
状态灯用"独立任务 + 延时翻转"实现闪烁:
static void led_blink_task(void *arg)
{
while (1) {
if (s_blink_ms > 0) {
for (int i = 0; i < 3; i++) gpio_set_level(s_rgb_pins[i], s_rgb[i] ? 1 : 0);
vTaskDelay(pdMS_TO_TICKS(s_blink_ms));
for (int i = 0; i < 3; i++) gpio_set_level(s_rgb_pins[i], 0);
vTaskDelay(pdMS_TO_TICKS(s_blink_ms));
} else {
vTaskDelay(pdMS_TO_TICKS(100));
}
}
}
状态灯语义(写在外壳或说明书上):
| 颜色/频率 | 含义 |
|---|---|
| 绿色慢闪(500ms) | 正常工作,SD 卡已挂载 |
| 红色快闪(100ms) | 异常:SD 卡挂载/写入失败 |
| 常亮红 | 系统异常(预留) |
| 4 颗电量灯常亮 | 当前电量分档显示 |
注意:这里的 RGB 是"亮/灭"二值控制,不是 PWM 真彩色。如果要调亮度,得用 LEDC 外设,本教程为简化没做。
第十章 FreeRTOS 调度:多任务的坑都在这里
10.1 为什么 1ms 延时会被拖慢
vTaskDelay(1) 的真实行为:把任务从运行态放到延时列表,1 个 tick 后变回就绪态,然后等调度器轮到它。
期望: 每 1.000ms 采一次
实际: 1.000ms + 调度延迟(0.05~0.5ms) + 被高优先级任务抢占的时间
本项目把所有任务的优先级排好之后,实测平均采样周期约 1.0~1.05ms,误差 5% 以内,满足需求。如果超过 1.15ms,就要检查是不是有任务优先级比采样任务还高、或者有长时间的临界区。
10.2 栈大小怎么定
| 任务 | 栈 | 依据 |
|---|---|---|
| task_acc | 4096 | 只用局部变量 + SPI 调用 |
| task_tcp_send | 8192 | 有一个 8KB 的 send_buf 吗?注意:局部大数组在栈上会爆栈 |
| task_sdcard | 8192 | FILE* + fprintf 内部 buffer |
重要:
char send_buf[8192]是局部变量,会占 8KB 栈!所以task_tcp_send的栈必须 ≥ 8192 + 余量。更稳妥的做法是把它改成static或heap_caps_malloc,避免栈溢出——栈溢出在 ESP32 上表现为***ERROR*** A stack overflow in task tcp has been detected,然后重启。
推荐改法(可选优化):
static char s_send_buf[8192]; // 放到静态区, 不占栈
10.3 互斥锁的正确使用
// 写
xSemaphoreTake(g_gps_mutex, portMAX_DELAY);
g_gps_data = d;
xSemaphoreGive(g_gps_mutex);
// 读
xSemaphoreTake(g_gps_mutex, portMAX_DELAY);
g = g_gps_data; // 整体拷贝一份快照
xSemaphoreGive(g_gps_mutex);
// 注意: 用完立刻释放, 不要"拿着锁做耗时的事"
错误写法:
xSemaphoreTake(m, portMAX_DELAY);
for (i = 0; i < 256; i++) {
fprintf(fp, "...", g_gps_data.lat); // 拿着锁写文件, 阻塞 GPS 任务 30ms
}
xSemaphoreGive(m);
这会拖慢 GPS 任务,还可能引发优先级反转。正确做法是取快照,释放锁后再用。
10.4 优先级反转与看门狗
优先级反转:低优先级任务长期持有锁,导致高优先级任务等待。本项目用 portMAX_DELAY 等锁,理论上有风险,但因为临界区极短(只做结构体拷贝),实测无问题。
任务看门狗(TWDT):ESP-IDF 默认开启,vTaskDelay 会给看门狗喂狗,所以不会误触发。但如果你在任务里写 while(1){} 死循环且不自旋让出,5 秒后会被看门狗重启。
10.5 丢数据怎么看
static volatile uint32_t dropped; // 每个 ring 自带计数
如果怀疑丢数据,加一行日志:
ESP_LOGI(TAG, "ring_sd dropped=%u, ring_tcp dropped=%u",
g_ring_sd.dropped, g_ring_tcp.dropped);
| 现象 | 原因 | 对策 |
|---|---|---|
| tcp dropped 增长 | 手机端解析慢 / WiFi 拥塞 | 减小 TCP_BATCH_MAX、降低推送频率 |
| sd dropped 增长 | TF 卡慢(假卡/Class4) | 换 Class10 卡、增大 ring 深度 |
| 两者都不增长但 CSV 有空洞 | 之前版本的"双消费者抢队列"问题 | 升级到双 ring 版本 |
第十一章 编译、烧录与串口调试
11.1 完整命令流程
cd D:AI工具_代码工作目录VehicleVibrationDetectorfirmware
idf.py set-target esp32s3 # 首次
idf.py build # 编译
idf.py -p COM5 flash # 烧录 (COM5 换成你的端口)
idf.py -p COM5 monitor # 看日志, Ctrl+] 退出
一行搞定烧录 + 监视:
idf.py -p COM5 flash monitor
11.2 怎么找串口
设备管理器 → 端口(COM 和 LPT),看到 USB JTAG/serial debug unit 或 CH340 就是它。
ESP32-S3 原生 USB 会出现两个串口:一个是 JTAG 调试口,一个是 CDC 数据口。烧录用
flash时如果报 "Wrong boot mode",就换另一个口再试。
11.3 正常启动日志长什么样
I (312) cpu_start: Multicore app
I (322) cpu_start: Pro cpu start user code
I (642) VIB_MAIN: ===== 车辆振动检测仪 v1.0 =====
I (652) LED: LED 初始化完成
I (672) BATT: 电池 ADC 初始化完成 (GPIO4, CH3)
I (693) ADXL355: ADXL355 检测成功, PARTID=0xED
I (703) ADXL355: ADXL355 初始化完成, 量程=±2.048g
I (903) WIFI_TCP: WiFi AP 启动: SSID="VibDetector_ESP32" PASS="12345678"
I (1903) WIFI_TCP: TCP Server 监听端口 8080, 等待客户端...
I (1913) VIB_MAIN: 所有任务已启动, 热点 SSID=VibDetector_ESP32 密码=12345678
I (2913) WIFI_TCP: STA 接入: 3c:xx:xx:xx:xx:xx, AID=1
I (3913) WIFI_TCP: TCP 客户端已连接: 192.168.4.2:52346
每一条日志都是一次自检:如果卡在 ADXL355 检测成功 之前,就是 SPI 接线或供电问题;如果卡在 AP 启动,就是 NVS 或 WiFi 配置问题。
11.4 常见编译错误对照表
| 报错 | 原因 | 解决 |
|---|---|---|
undefined reference to 'adxl355_init' |
main 的 CMakeLists 漏写 REQUIRES | 加 adxl355 到 REQUIRES |
fatal error: driver/spi_master.h: No such file |
组件的 CMakeLists 没 REQUIRES driver | 加 driver |
ESP_ERR_INVALID_STATE at spi_bus_initialize |
同一 host 初始化两次 | 换 host,或只初始化一次 |
region 'iram0_0_seg' overflowed |
RAM 不够(静态占用太多) | 减小环形缓冲、优化大数组 |
Project uses CMake 3.x but idf requires... |
版本不匹配 | 检查 IDF_PATH 指向 v5.3 |
A stack overflow in task tcp |
栈不够 | 加大栈(见 10.2) |
Guru Meditation Error: LoadProhibited |
空指针(常见于任务先于初始化启动) | 检查 app_main 顺序 |
11.5 烧录后连不上的排查顺序
按这个顺序排查,一步到位,不要跳步:
1. 手机能不能搜到 VibDetector_ESP32? 搜不到 → 设备没启动/AP 没起来 → 看串口日志。
2. 能连上热点但 APP 连不上? → 检查 IP 是不是 192.168.4.1(有些手机连上后会提示"无网络",选择"保持连接")。
3. APP 显示已连接但没数据? → 看串口有没有 TCP 客户端已连接;有但没数据 → 检查 task_acc 是否在跑、ring_push 是否被锁死。
4. 有数据但波形是直线? → 传感器没进测量模式,或者 Z 轴读到固定值 → 检查 POWER_CTL 配置。
第十二章 Android 工程搭建:从空目录到第一个 APK
固件写完,设备就能说话了,但说的是一串 JSON,没人听得懂。手机 APP 要做三件事:听(连上 TCP、按行拆包)、算(在手机上跑指标算法)、说(出一份能发出去的 PDF 报告)。
这一章我们先把"能编译"这件事彻底解决。Android 工程是整套项目里最容易卡住新人的地方——不是代码难,而是JDK 版本、SDK 版本、Gradle 版本、仓库配置这四件事只要有一件错位,报错信息就完全是另一个故事。
12.1 环境清单:四个版本号必须对得上
我先列出本项目实际使用的版本组合,这是经过验证的一组:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 17(OpenJDK 17.0.2) | AGP 8.x 强制要求 JDK 17,用 JDK 8 会直接报 "Unsupported class file major version" |
| Android Gradle Plugin (AGP) | 8.1.4 | 决定 Gradle 版本的最低下限 |
| Gradle | 8.2 | 与 AGP 8.1.4 匹配(AGP 8.1 要求 Gradle ≥ 8.0) |
| compileSdk | 34 | 建议直接用 34,不要用 preview 版 |
| build-tools | 34.0.0 | 与 compileSdk 对齐 |
| Kotlin | 1.9.x(随 AGP 内置) | 不需要单独装 Kotlin 插件版本 |
关于 JDK 的一个工程习惯:不要往系统环境里装 JDK,直接下载免安装的 zip 版解压到一个目录里,然后在 Android Studio 里指定 Gradle JDK 为该目录。原因很简单——你未来可能同时维护三个不同 JDK 版本的老项目,装进系统后互相打架,卸载还卸不干净。
本项目把 JDK 放在:
d:AI工具_代码工作目录jdk-17.0.2
在 Android Studio 里设置路径:File → Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK,选择 Add JDK... 指向上面的目录。
SDK 组件的安装(命令行方式)
如果你用的是纯命令行(没有 Android Studio),用 sdkmanager 装这四样就够:
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" "cmdline-tools;latest"
第一次装一定会卡在许可证上,报:
Warning: Failed to read or create install properties file.
Checking the license for package ...
这是因为没接受许可协议。先执行:
sdkmanager --licenses
然后一路输入 y。Windows PowerShell 里可以一次性喂进去:
"y`ny`ny`ny`ny`ny`ny`ny`ny" | sdkmanager --licenses
注意反引号
`n在 PowerShell 里表示换行符,"yn..."` 是一个多行字符串。
12.2 工程目录结构
android/
├── settings.gradle # 模块与仓库声明
├── build.gradle # 根构建脚本(只放 AGP 版本)
├── gradle.properties # 构建参数(中文路径开关就在这里)
├── local.properties # SDK 路径(不要提交到 git)
└── app/
├── build.gradle # 模块构建脚本(依赖在这里)
├── proguard-rules.pro
└── src/main/
├── AndroidManifest.xml
├── java/com/vibdetector/app/
│ ├── MainActivity.kt # 界面与业务编排
│ ├── ReportExporter.kt # PDF / CSV 导出
│ ├── data/VibrationData.kt # 数据模型
│ ├── net/TcpClient.kt # TCP 客户端
│ ├── metric/VibrationMetrics.kt # 指标算法
│ └── ui/WaveformView.kt # 自绘波形
└── res/
├── layout/activity_main.xml
├── values/{colors,strings,themes}.xml
├── xml/file_paths.xml
└── mipmap-anydpi-v26/ic_launcher*.xml
只有 6 个 Kotlin 文件。这就是全部的业务代码量——没有引入任何第三方图表库,波形是 Canvas 手画的,指标是纯数学实现的。工程越干净,你改起来越自由。
12.3 settings.gradle:仓库冲突的根源在这里
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "VibDetector"
include ':app'
逐段讲解:
1. pluginManagement.repositories —— 这是给 Gradle 插件本身找地方下载的地方。gradlePluginPortal() 是 Gradle 官方插件仓库,google() 提供 AGP。
2. dependencyResolutionManagement.repositories —— 这是**给工程依赖(第三方库)**找地方。google() 提供 androidx 系列,mavenCentral() 提供 Gson、OpenCSV、iText。
3. repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) —— 这一行的意思是:如果某个模块的 build.gradle 里还写了 repositories {},直接构建失败。它的目的是强制"仓库只在 settings 里声明一次",避免出现"当哪个子模块去找库"的不确定行为。
⚠️ 这是本项目踩过的第一个坑。一开始我在根
build.gradle里写了:allprojects { repositories { google() mavenCentral() } }结果构建直接报:
Build was configured to prefer settings repositories over project repositories but repository 'Google Maven' was added by build file 'build.gradle'解决办法二选一:要么删掉根部的
allprojects.repositories(本项目采用),要么把FAIL_ON_PROJECT_REPOS改成PREFER_PROJECT。不要两边都写。4. rootProject.name = "VibDetector" —— 工程名,会影响打包产物命名。
5. include ':app' —— 声明包含 app 模块,对应 app/ 目录。
12.4 根 build.gradle:只放 AGP 版本
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.4'
}
}
// 注意:这里不要写 allprojects { repositories { ... } }
buildscript 块是构建脚本自身的依赖:Gradle 需要先拿到 AGP 这个 jar,才能认识 android { } 这个 DSL 块。它的 repositories 是独立于 settings 的,不受 FAIL_ON_PROJECT_REPOS 约束,所以可以安全保留。
12.5 gradle.properties:三个必改项
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
android.overridePathCheck=true
-
- •
-Xmx2048m —— 给 Gradle 守护进程 2GB 堆。低于 1GB 时,release 打包的 R8 混淆阶段会 OOM。•
-Dfile.encoding=UTF-8 —— 保证源码里的中文字符串(比如"垂向平稳性")编译时不会乱码。
这条对本项目是必须的,因为 strings.xml 和报告模板里都有中文。
android.useAndroidX=true —— 用 androidx 库必需。
android.nonTransitiveRClass=true —— 只生成本模块的 R 类,加快编译。
android.overridePathCheck=true —— 这条是救命的。AGP 7.3 之后会检查工程路径是否含非 ASCII 字符,如果路径里有中文,直接抛:
Your project path contains non-ASCII characters. This will most likely cause
the build to fail on Windows.
我们的工程在 d:AI工具_代码工作目录 下,路径含中文,必须加这一行才能编译。如果你把工程放在纯英文路径下,这行可以不加。
为什么 AGP 要拦中文路径?因为 Windows 上旧版 Gradle、aapt2、NDK 的部分组件用 ANSI 编码读写临时文件路径,中文路径会变成乱码导致找不到文件。新版组件基本已修复,所以强行跳过检查是可行的——但如果遇到奇怪的 "file not found" 报错,优先怀疑路径,把工程挪到
D:vibdetector这种纯英文目录再试。
12.6 local.properties:SDK 路径
sdk.dir=d:\AI工具_代码工作目录\android-sdk
local.properties 的格式是 Java Properties,反斜杠必须转义成 \,冒号必须转义成 :。写错的话报错是"SDK location not found",而且不会告诉你哪里错了。
这个文件是本机专属的(不同电脑 SDK 路径不同),不能提交到 git,应该在 .gitignore 里。
12.7 app/build.gradle:模块配置与依赖
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.vibdetector.app'
compileSdk 34
defaultConfig {
applicationId "com.vibdetector.app"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.opencsv:opencsv:5.9'
implementation 'com.itextpdf:kernel:7.2.5'
implementation 'com.itextpdf:io:7.2.5'
implementation 'com.itextpdf:layout:7.2.5'
}
关键项讲解:
namespace 'com.vibdetector.app' —— AGP 8 用 namespace 取代了 Manifest 里的 package 属性。生成 R 类的包名就是它。applicationId 可以不同,但本项目保持一致。
minSdk 24 —— 本项目用到 Socket、tcpNoDelay、协程,这些在 21 就有了。24 是为了避开 Android 5.x/6.x 上 FileProvider 与存储权限的一堆历史包袱。覆盖率 98%+。
targetSdk 34 —— 会触发 Android 13/14 的通知权限、存储权限收紧。见下面 12.8 的说明。
minifyEnabled false —— 刻意关掉。R8 混淆会和 iText7 的反射加载、Gson 的字段名反射打架,会报 Missing class、JSON 字段解析成 null。这个 APP 不上架商店,体积 8MB 也无所谓,关掉省心。真要开,必须补 proguard 规则(附录 C 给了)。
viewBinding true —— 开启视图绑定,之后每个布局自动生成 ActivityMainBinding 类,彻底告别 findViewById 和类型强转。这是本项目 UI 代码很干净的根本原因。
iText 为什么拆成三个包:iText7 的 itext7-core 是一个"聚合包",它的 POM 里会带一堆可选依赖(barcodes、font-asian、forms、pdfa…),在国内网络下经常解析失败或拉不全。直接依赖 kernel / io / layout 三个核心包,稳定且体积小(约 3MB)。我们只用 Paragraph / Table / Cell / Document,layout 已经覆盖。
12.8 AndroidManifest.xml:权限与 FileProvider
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
-
- •
INTERNET —— TCP Socket 必需,这是普通权限,安装时自动授予,不需要运行时申请。很多人以为要动态申请,白写一堆代码。
ACCESS_NETWORK_STATE —— 用来判断网络可用状态(本项目其实没用到,但连着 WiFi、检测热点时需要)。•
ACCESS_WIFI_STATE / CHANGE_WIFI_STATE —— 你可能会想"APP 自动连设备的 AP 热点",这需要 WifiManager。本项目为了兼容性,采用手动在系统设置里连热点的方式,所以这两个权限是预留的。•
maxSdkVersion="32" —— 存储权限在 Android 13(API 33)起被细分权限取代,写 maxSdkVersion=32 表示"只在 32 及以下声明",避免在 33+ 上被系统忽略而报警告。
application 里两个关键属性:
android:usesCleartextTraffic="true"
Android 9 起默认禁止明文 HTTP。我们用的是 TCP Socket(不是 HTTP),理论上不受此限,但 usesCleartextTraffic 也会影响部分网络库的默认策略,加上无副作用。
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
为什么需要 FileProvider? Android 7.0 起禁止用 file:// Uri 跨应用传文件(会抛 FileUriExposedException)。我们要"用系统 PDF 阅读器打开刚生成的报告",就必须把文件以 content:// 形式共享出去,而这需要声明一个 FileProvider 并给临时读权限。
${applicationId} 是构建时占位符,最终会变成 com.vibdetector.app.fileprovider,与代码里 FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", pdfFile) 一一对应——两边必须完全一致,否则会抛 IllegalArgumentException: Couldn't find meta-data for provider。
res/xml/file_paths.xml 声明允许共享哪些目录:
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="downloads" path="Download/" />
<external-path name="external" path="." />
<files-path name="files" path="." />
<cache-path name="cache" path="." />
</paths>
<external-path path="Download/"> 对应 Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS)。如果这里写错了(比如写成 download),getUriForFile 会抛 Failed to find configured root that contains /storage/...。
⚠️ 真实限制说明:
Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS)在 Android 10+ 的分区存储下,普通 APP 已无法直接写入(除非requestLegacyExternalStorage,且该开关在 Android 11+ 失效)。
本项目选择了一个务实折中:在 Android 10 及以上,导出可能失败并弹出"导出失败"的 Toast。正确做法是把导出目录改成 APP 私有目录 + MediaStore 插入,附录 C 给出了改造代码。教程里保留原实现,是为了让你先跑通"生成 PDF"这条主线,不被存储权限这棵树挡住。
12.9 编译与打包发布版 APK
调试包
# Windows,在 android 目录下
set JAVA_HOME=d:AI工具_代码工作目录jdk-17.0.2
./gradlew assembleDebug
本工程没有
gradlew包装脚本(它是 Android Studio 首次打开工程时才生成的)。如果你是从命令行构建,直接用独立 Gradle:$env:JAVA_HOME = "d:AI工具_代码工作目录jdk-17.0.2" cd d:AI工具_代码工作目录VehicleVibrationDetectorandroid & "d:AI工具_代码工作目录gradle-8.2bingradle.bat" assembleRelease --offline
产物:app/build/outputs/apk/debug/app-debug.apk。这个包用调试证书签名,可以直接装手机测试。
发布包(带正式签名)
先造一个 keystore(只需做一次,丢了就永远无法覆盖升级):
keytool -genkeypair -v ^
-keystore vibdetector.jks ^
-alias vibdetector ^
-keyalg RSA -keysize 2048 -validity 10000 ^
-storepass vib123456 -keypass vib123456 ^
-dname "CN=VibDetector, OU=Engineering, O=VibDetector, L=Beijing, C=CN"
然后在 app/build.gradle 的 android 块里加:
signingConfigs {
release {
storeFile file("../vibdetector.jks")
storePassword "vib123456"
keyAlias "vibdetector"
keyPassword "vib123456"
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
...
}
}
再执行:
./gradlew assembleRelease
产物:app/build/outputs/apk/release/app-release.apk。
本项目实际编译结果(release,从零依赖开始):
BUILD SUCCESSFUL in 11m 38s
第一次构建慢是正常的:要下载 Gradle 发行包、AGP、所有依赖并做 R8 处理。第二次增量构建通常 20~60 秒。
月薪三千的 Windows 打包脚本
@echo off
set JAVA_HOME=d:AI工具_代码工作目录jdk-17.0.2
set PATH=%JAVA_HOME%bin;%PATH%
cd /d d:AI工具_代码工作目录VehicleVibrationDetectorandroid
call gradlew.bat assembleRelease
12.10 本节踩坑清单
| 报错 | 根因 | 处理 |
|---|---|---|
Unsupported class file major version 65 |
Gradle 用了 JDK 21 | Gradle JDK 换成 17 |
Your project path contains non-ASCII characters |
工程路径含中文 | android.overridePathCheck=true |
Could not find com.android.tools.build:gradle:8.1.4 |
根 buildscript 缺 google() |
补仓库 |
Build was configured to prefer settings repositories... |
两处都声明了 repositories | 删掉 allprojects.repositories |
Could not resolve org.jetbrains.kotlinx... |
缺 mavenCentral() |
补仓库 |
SDK location not found |
local.properties 转义写错 |
用 \ 和 : |
Duplicate class ... (iText) |
混用了 itext7-core + 分拆包 |
只保留三个分拆包 |
Unresolved reference: TextAlignment |
iText7 里该类在 kernel... 版本差异 |
删除用法(本项目已处理) |
第十三章 APP 界面与通信实现
这一章把"手机怎么听设备说话"讲透。顺序是:先定数据结构(协议落地),再写网络层(收),最后写界面(显示)。
13.1 界面结构设计
参考市面主流振动分析 APP(如 振动手册、iVib、Svan)的通用范式,我采用了单页纵向卡片流:
┌─────────────────────────────┐
│ 车辆振动检测仪 [状态]│ ← 标题栏 + 连接状态徽标
├─────────────────────────────┤
│ ① 设备连接卡片 │
│ IP [192.168.4.1] 端口 [8080]│
│ [连接设备] [开始采集][导出]│
├─────────────────────────────┤
│ ② 实时状态卡片 │
│ 电量 85% 卫星 9 速度 0.0kn│
│ 样本数 12345 │
├─────────────────────────────┤
│ ③ 波形卡片 (WaveformView) │ ← 三轴同屏,红/绿/蓝
├─────────────────────────────┤
│ ④ 指标卡片 │
│ 垂向 Wz 2.13 等级 优 │
│ 横向 Wz 1.87 舒适 0.21 │
│ X 轴 RMS/峰值 ... │
└─────────────────────────────┘
为什么不做多页面 + 图表库? 两个工程理由:
1. 1000Hz×3 轴的数据流,任何 MPAndroidChart 这类基于 ValueAnimator 的图表都会成为瓶颈,掉帧且 GC 压力大。自绘 View 只画 1000 条 drawLine,稳在 60fps 以下的开销完全可控。
2. 检测类 APP 的核心动作是"一键开始 → 看指标 → 导出",页面层级越少,现场操作越快。
activity_main.xml 的骨架(只列结构,完整文件见工程):
<ScrollView ... xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout android:orientation="vertical" android:padding="12dp">
<TextView android:text="车辆振动检测仪" android:textSize="22sp" ... />
<!-- ① 连接卡片 -->
<com.google.android.material.card.MaterialCardView
app:cardCornerRadius="12dp" app:cardElevation="2dp">
<LinearLayout android:orientation="vertical">
<LinearLayout android:orientation="horizontal">
<EditText android:id="@+id/etIp" android:text="192.168.4.1"
android:inputType="text" android:layout_weight="2" />
<EditText android:id="@+id/etPort" android:text="8080"
android:inputType="number" android:layout_weight="1" />
</LinearLayout>
<Button android:id="@+id/btnConnect" android:text="连接设备" />
</LinearLayout>
</MaterialCardView>
<!-- ③ 波形卡片 -->
<com.google.android.material.card.MaterialCardView ...>
<com.vibdetector.app.ui.WaveformView
android:id="@+id/waveform" android:layout_height="220dp" />
</MaterialCardView>
<!-- ④ 指标卡片:用 GridLayout 两列排布 -->
...
</LinearLayout>
</ScrollView>
⚠️ 命名空间坑:
xmlns:app必须声明在根元素(这里是最外层ScrollView)上。我曾经在MaterialCardView内部又写了一次xmlns:app,编译报:AttributePrefixUnbound?app:cardCornerRadiusXML 里同一个前缀不要在多个层级重复声明,Android Studio 也会因此高亮混乱。
ViewBinding 生成规则很简单:activity_main.xml → ActivityMainBinding,所有带 android:id 的控件变成 binding.etIp、binding.waveform 这样的属性。不再需要 findViewById,也不会出现 ClassCastException。
13.2 数据模型:协议先落地成类型
文件 data/VibrationData.kt,四个 data class 把"设备发什么、APP 存什么、报告写什么"全部定型:
data class AccSample(
val seq: Long,
val gx: Double,
val gy: Double,
val gz: Double
)
data class VibPacket(
val type: String,
val count: Int,
val gps: GpsInfo?,
val bat: Int,
val samples: List<List<Double>>
)
data class GpsInfo(
val lat: Double,
val lon: Double,
val spd: Double,
val sats: Int,
val fix: Int
)
data class MetricsResult(
val sperlingVertical: Double,
val sperlingLateral: Double,
val rmsX: Double, val rmsY: Double, val rmsZ: Double,
val peakX: Double, val peakY: Double, val peakZ: Double,
val comfortVertical: Double,
val comfortLateral: Double,
val comfortIndex: Double,
val jerkRms: Double,
val shakeIndex: Double,
val stabilityGrade: String,
val comfortGrade: String
)
逐点说明设计决策:
1. AccSample 的坐标用 Double:虽然屏上显示只要 Float,但后续 FFT、a³、积分运算,Float 只有 7 位有效数字,累加 1000 项后误差可达 1e-4 量级,和 0.003g 的分辨率同量级——会把传感器的精度吃掉。计算链路一律 Double,只在传给 WaveformView 时降成 Float。
2. VibPacket.samples 是 List<List<Double>> 而不是 List<AccSample>:设备发的是紧凑数组 [seq,gx,gy,gz],不是一个带键名的对象。每点省 20 多个字节,20Hz × 200 点/包的场景下,带宽差 3 倍以上。用 List<List<Double>> 让 Gson 零配置直接反序列化。
如果写成
data class Sample(val seq: Long, val gx: Double, ...),Gson 会要求 JSON 是{"seq":1,"gx":0.1,...},设备端snprintf的长度也会翻倍。这是性能关键型协议的典型取舍。
3. gps 可空:开机前 30 秒 GPS 还没定位,g_gps_data 全是 0,如果类型不可空,就得用 lat=0.0 来表示"无数据",后续写报告会误显示"北京西二旗"(经纬度 (0,0))。可空类型把"无数据"和"数据为零"分开。4. MetricsResult 里同时放数值和等级字符串:UI 直接显示 stabilityGrade,报告直接写入,避免在两处重复实现阈值判断——阈值只在一个地方(gradeStability)定义。
13.3 TcpClient:协程 + StateFlow 的组合
net/TcpClient.kt 完整代码(不长,逐行讲):
class TcpClient(private val scope: CoroutineScope) {
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected
private val _packet = MutableStateFlow<VibPacket?>(null)
val packet: StateFlow<VibPacket?> = _packet
private var socket: Socket? = null
private var job: Job? = null
private val gson = Gson()
fun connect(host: String, port: Int) {
job?.cancel()
job = scope.launch(Dispatchers.IO) {
try {
socket = Socket(host, port)
socket?.tcpNoDelay = true
_connected.value = true
val reader = BufferedReader(InputStreamReader(socket!!.getInputStream()))
var line: String?
while (isActive && _connected.value) {
line = reader.readLine() ?: break
try {
val pkt = gson.fromJson(line, VibPacket::class.java)
if (pkt != null && pkt.type == "vib") {
_packet.value = pkt
}
} catch (e: Exception) {
Log.w(TAG, "JSON 解析失败: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "连接异常: ${e.message}")
} finally {
disconnect()
}
}
}
fun disconnect() {
_connected.value = false
try { socket?.close() } catch (e: Exception) {}
socket = null
job?.cancel()
}
}
为什么这么写:
Dispatchers.IO:Socket 构造和 readLine()都是阻塞调用。放主线程会直接 NetworkOnMainThreadException。
协程的好处是——阻塞写起来像同步代码,但不会卡 UI。
socket?.tcpNoDelay = true:这是必须的。TCP 默认开启 Nagle 算法,会把小包合并等待 ACK,导致"设备每 50ms 发一包、APP 却每 200ms 才收一批",波形一顿一顿。关掉后每包立即发送。代价是包数变多,在局域网里完全可接受。
BufferedReader.readLine():这是整个通信层的"分帧器"。设备端每包 JSON 后加 n,APP 就按行读。千万不要自己 read() 字节数组然后拼字符串——TCP 是流式的,一次 read 可能拿到半个 JSON,也可能拿到 3 个 JSON 的孪生兄弟。readLine() 内部帮你处理了粘包/半包。•
_packet.value = pkt:StateFlow 的赋值是线程安全的,可以在 IO 线程直接写,UI 侧用 collectLatest 消费。
注意:
StateFlow是合并型的——如果 UI 消费慢,中间的值会被丢弃,只保留最新值。对实时波形来说这正是我们想要的(丢帧不可怕,卡死才可怕)。
• while (isActive && _connected.value):isActive 是协程作用域是否被取消,_connected.value 是业务开关。两者都检查,保证"用户点断开"和"Activity 销毁"都能退出循环。• finally { disconnect() }:无论正常结束还是异常,都统一清理,避免 socket 泄漏。• job?.cancel() 在 connect 开头:防止用户快速连点两次"连接",起两个协程重复读同一个 socket。
关闭时的坑:close() 必须在 disconnect 里做
只把 _connected.value 设为 false 是不够的。阻塞在 readLine() 上的协程不会因为一个布尔值变化而醒来,必须 socket.close() 让它抛 SocketException: Socket closed,循环才真正退出。所以顺序是:
_connected.value = false // 1. 置标志(后续循环不再进入)
try { socket?.close() } catch (e: Exception) {} // 2. 踢醒阻塞的读
job?.cancel() // 3. 取消协程
13.4 MainActivity:业务总编排
MainActivity.kt 是整个 APP 的中枢,做四件事:订阅连接状态、订阅数据包、启动/停止采集、导出报告。
初始化与 ViewBinding
private lateinit var binding: ActivityMainBinding
private lateinit var tcpClient: TcpClient
private val collectedSamples = mutableListOf<AccSample>()
private var collecting = false
private var collectJob: Job? = null
private var lastMetrics: MetricsResult? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
tcpClient = TcpClient(lifecycleScope)
...
}
-
- •
ActivityMainBinding.inflate(layoutInflater) 生成绑定对象,binding.root 就是布局根 ScrollView。
TcpClient(lifecycleScope) —— 把 Activity 的生命周期作用域传进去。这样 Activity 销毁时,协程自动取消,socket 自动关闭。如果传 GlobalScope,退到后台再回来就会泄漏一个永久存活的协程。
订阅连接状态
lifecycleScope.launch {
tcpClient.connected.collectLatest { connected ->
runOnUiThread {
binding.tvStatus.text = if (connected) "已连接" else "未连接"
binding.tvStatus.setTextColor(
if (connected) 0xFFC8E6C9.toInt() else 0xFFFFCDD2.toInt())
binding.btnConnect.text = if (connected) "断开连接" else "连接设备"
binding.btnCollect.isEnabled = connected
}
}
}
-
- •
collectLatest —— 来新值时取消上一次未完成的处理。这里处理很快,等价于 collect,但语义更安全。•
runOnUiThread —— StateFlow 的回调在发送方线程(IO)执行,所有 View 操作必须回主线程,否则抛 CalledFromWrongThreadException。这是新手最常犯的错之一。
• 颜色用 ARGB 字面量 0xFFC8E6C9(浅绿)/ 0xFFFFCDD2(浅红)。
注意要 .toInt():0xFFC8E6C9 在 Kotlin 里是 Long(因为超过 Int.MAX_VALUE),不转 Int 会编译报类型不匹配。
数据包处理:三件事同时做
private fun onPacket(pkt: VibPacket) {
runOnUiThread {
binding.tvBattery.text = "${pkt.bat}%"
pkt.gps?.let {
binding.tvSats.text = "${it.sats}"
binding.tvSpeed.text = String.format("%.1f kn", it.spd)
}
pkt.samples.forEach { s ->
if (s.size >= 4) {
val sample = AccSample(s[0].toLong(), s[1], s[2], s[3])
binding.waveform.addSample(s[1].toFloat(), s[2].toFloat(), s[3].toFloat())
if (collecting) {
collectedSamples.add(sample)
sampleCount++
if (sampleCount % 50 == 0) {
binding.tvSamples.text = collectedSamples.size.toString()
}
}
}
}
}
}
这一段是整个 APP 最"热"的路径,每个包 50200 个点、每秒 20 个包,即**每秒 10004000 次循环**。所以有三条优化:
1. s.size >= 4 校验——设备端理论上不会发短的,但协议边界要做防御。少一个校验,遇到一次不完整包就是 IndexOutOfBoundsException 崩溃。
2. sampleCount % 50 == 0 才刷新文本——每点都改 TextView 会触发 1000 次/秒的 requestLayout + invalidate,直接把 UI 线程压死。每 50 点刷新一次(20 次/秒)肉眼完全看不出差别。
3. waveform.addSample 内部会 invalidate()——但它只标记重绘,系统会合并同一帧内的多次 invalidate,所以 1000 次调用实际只重绘 60 次,开销可控。
⚠️ 内存风险提示:
collectedSamples是一个无限增长的MutableList<AccSample>。1000Hz 采样 10 分钟 = 60 万点,每点 4 个Double+ 一个Long,约 24 字节 + 对象头,60 万点约 20MB。检测 1 小时会到 120MB 并 OOM。生产环境应该做环形截断(比如只保留最近 30 分钟)或者边采边落盘。教程里保留"全量内存"是为了让算法章节的示例简单;附录 C 给出分片落盘的改造方案。
采集控制与每秒结算
private fun startCollecting() {
collecting = true
collectedSamples.clear()
binding.waveform.clear()
binding.btnCollect.text = "停止采集"
binding.btnExport.isEnabled = false
binding.tvSamples.text = "0"
collectJob = lifecycleScope.launch {
while (collecting) {
delay(1000)
if (collectedSamples.size > 100) {
val result = VibrationMetrics.calculate(collectedSamples)
lastMetrics = result
updateMetricsUI(result)
}
}
}
}
这段的设计意图很关键:算法不是每来一个包就算一次,而是每秒算一次全量。
原因:
• Sperling 和 EN12299 都是统计量,需要足够长的数据窗才有意义。逐包(50 点 = 0.05 秒)算出来的 FFT 分辨率只有 20Hz,完全无意义。
• FFT 复杂度 O(n log n),每秒对 6 万个点做 7 次 FFT(3 轴 Sperling + 2 轴 EN + …)在主线程上是灾难,必须放到后台。这里 lifecycleScope.launch 默认跑在主线程——这是个隐患,正确做法是 withContext(Dispatchers.Default) 包住 calculate。见 13.6 的性能修正。
private fun stopCollecting() {
collecting = false
collectJob?.cancel()
binding.btnCollect.text = "开始采集"
binding.btnExport.isEnabled = collectedSamples.isNotEmpty()
if (collectedSamples.isNotEmpty()) {
val result = VibrationMetrics.calculate(collectedSamples)
lastMetrics = result
updateMetricsUI(result)
Toast.makeText(this, "采集完成, 共 ${collectedSamples.size} 个样本", Toast.LENGTH_SHORT).show()
}
}
停止时强制再算一次最终结果——因为周期任务可能刚好在 delay(1000) 里被取消,最后一秒的数据没算进去。这个"收尾结算"是所有周期采样的通用模式。
指标刷新到界面
private fun updateMetricsUI(m: MetricsResult) {
binding.tvWzV.text = String.format("%.2f", m.sperlingVertical)
binding.tvWzL.text = String.format("%.2f", m.sperlingLateral)
binding.tvStabilityGrade.text = m.stabilityGrade
binding.tvComfort.text = String.format("%.3f m/s²", m.comfortIndex)
binding.tvShake.text = String.format("%.2f", m.shakeIndex)
binding.tvComfortGrade.text = m.comfortGrade
binding.tvStatX.text = String.format("%.4f / %.4f", m.rmsX, m.peakX)
binding.tvStatY.text = String.format("%.4f / %.4f", m.rmsY, m.peakY)
binding.tvStatZ.text = String.format("%.4f / %.4f", m.rmsZ, m.peakZ)
binding.tvJerk.text = String.format("%.4f g/s", m.jerkRms)
}
有效数字的讲究:平稳性指标 Wz 保留 2 位(0.01 的分辨已足够区分 2.50/2.75/3.00 的档位);舒适度 NMV 保留 3 位(阈值 0.315 就在第三位);RMS/峰值保留 4 位(0.003g 分辨率对应的量级)。保留位数由判据精度决定,不是随手写的。
13.5 波形绘制:自己画,才算可控
ui/WaveformView.kt 的核心是一个自定义 View,三件事:环形缓冲、坐标映射、自适应量程。
缓冲与采样入口
private val maxPoints = 1000
private val bufX = FloatArray(maxPoints)
private val bufY = FloatArray(maxPoints)
private val bufZ = FloatArray(maxPoints)
private var head = 0
private var count = 0
private var scale = 1.0f // 当前显示量程(±scale g)
fun addSample(gx: Float, gy: Float, gz: Float) {
bufX[head] = gx
bufY[head] = gy
bufZ[head] = gz
head = (head + 1) % maxPoints
if (count < maxPoints) count++
// 必须取绝对值
val maxV = max(max(abs(gx), abs(gy)), abs(gz)).let { max(it, 0.05f) }
if (maxV > scale * 0.9f) scale = maxV * 1.2f
else if (maxV < scale * 0.3f && scale > 0.1f) scale *= 0.95f
invalidate()
}
-
- •
FloatArray + head 环形:1000 点固定占用 12KB,永不分配新对象,零 GC。这是 1000Hz 刷新的前提——如果用 MutableList 并在头部 removeAt(0),每秒就是 1000 次内存搬移,卡到爆。
count < maxPoints 才增长:前 1000 点还没填满时,绘制只画 count 个点,不会画出后面那些初值 0 的假数据。
自适应量程:maxV > scale * 0.9 放大到 maxV * 1.2(留 20% 头部空间);maxV < scale * 0.3 缓慢缩小 0.95 倍(回落用乘法而非直接赋值,避免量程抖动)。
let { max(it, 0.05f) }
- 把下限钳到 0.05g,防止静止时量程缩到 1e-6,一点噪声就满屏。
⚠️ 这里是最容易写错的一行,值得单独讲。
很多人的第一版写的是:
val maxV = max(max(gx, gy), gz).let { max(it, 0.05f) } // 错的
max()取的是带符号的代数最大值。设备静止平放时,Z 轴读数是 -1g(重力),max(-1.0, 0.00x, 0.00x)得到 0.00x,被下限钳成 0.05 → 量程判断认为"信号很小",于是scale一路缩到最小 → -1g 的重力画到了屏幕外,波形只在中线以下露出一条平线。凡是"取最大值"的地方,先问自己一句:这个量是"幅值"还是"代数"? 波形量程、峰值统计、包络提取,全部要取绝对值:
val maxV = max(max(abs(gx), abs(gy)), abs(gz)).let { max(it, 0.05f) } // 对的工程里用的是后者。如果你自己写的时候发现"静止时波形贴边、看不清",99% 就是这个原因。
绘制:坐标映射就一行
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width.toFloat()
val h = height.toFloat()
val cy = h / 2
for (i in 1..3) {
val yy = h * i / 4
canvas.drawLine(0f, yy, w, yy, paintGrid)
}
canvas.drawLine(0f, cy, w, cy, paintAxis)
if (count < 2) return
val stepX = w / maxPoints
val scalePx = (h / 2 - 10) / scale
drawChannel(canvas, bufX, stepX, cy, scalePx, paintX)
drawChannel(canvas, bufY, stepX, cy, scalePx, paintY)
drawChannel(canvas, bufZ, stepX, cy, scalePx, paintZ)
}
private fun drawChannel(canvas: Canvas, buf: FloatArray, stepX: Float,
cy: Float, scalePx: Float, paint: Paint) {
var prevX = 0f
var prevY = cy - buf[0] * scalePx
for (i in 1 until count) {
val cx = i * stepX
val cyp = cy - buf[i] * scalePx
canvas.drawLine(prevX, prevY, cx, cyp, paint)
prevX = cx; prevY = cyp
}
}
-
- •
cy = 屏幕中线 = 0g,cyp = cy - value * scalePx 就是"加速度 → 像素"的映射。
注意是减号:屏幕 Y 轴向下为正,加速度向上为正,方向相反。
scalePx = (h / 2 - 10) / scale:h/2 - 10 是留给上下各 10px 边距,/ scale 把"±scale 克"映射到半个屏高。±1g 占满半屏时 scalePx = 100(220dp 高的卡片约 400px)。
drawLine 而不是 drawPath:1000 段线段在硬件加速下开销极低,而且逐段绘制不需要 Path 的 reset()/moveTo() 开销。三通道 × 1000 段 = 3000 次调用,在 60fps 下毫无压力。
13.6 性能:三处必须知道的修正
教程代码能跑,但要把 1000Hz 跑得稳,这三处值得改:
1. 算法搬到后台线程
collectJob = lifecycleScope.launch {
while (collecting) {
delay(1000)
if (collectedSamples.size > 100) {
val snapshot = collectedSamples.toList() // ① 拷贝快照
val result = withContext(Dispatchers.Default) { // ② 后台计算
VibrationMetrics.calculate(snapshot)
}
lastMetrics = result
updateMetricsUI(result) // ③ 回主线程更新
}
}
}
• ①
必须拷贝快照。直接传 collectedSamples 给后台线程,同时主线程还在 add(),ArrayList 并发修改会抛 ConcurrentModificationException。
• ② 7 次 FFT 在 Default线程池做,不阻塞 UI。
• ③ launch 恢复后本身就在主线程,可直接更新 View。
2. 采集长度设上限
private const val MAX_SAMPLES = 1000 * 60 * 10 // 10 分钟
// onPacket 里
if (collecting) {
if (collectedSamples.size >= MAX_SAMPLES) {
collectedSamples.removeAt(0) // 或批量裁剪,见附录 C
}
collectedSamples.add(sample)
}
3. 波形降采样显示
1000Hz 一屏 1000 点,正好 1 秒。如果要看 10 秒趋势,应该先算每 10 点的 min/max,画成"柱状包络"(这是示波器的通用做法),而不是压缩 1 万个点画 1000 段——压缩会丢尖峰,包络不会。
第十四章 指标算法详解:GB/T 5599 与 EN 12299
这是整个项目最"硬"的一章。很多人做到这一步就卡住了,因为标准原文里全是公式和加权曲线,看完不知道自己该写什么代码。我把每个公式先讲物理意义,再给数学式,再落到 Kotlin,最后给一个能手算验证的数值例子。
14.1 三个标准各自在测什么
先建立直觉,否则公式只是符号游戏。
| 标准 | 测什么 | 输出 | 为什么这样设计 |
|---|---|---|---|
| GB/T 5599(Sperling) | 人对振动平顺性的主观感受 | 平稳性指标 | 人对 1~5Hz 最敏感,对高频不敏感,所以用频率加权 + 三次方放大 |
| EN 12299 | 乘客在铁路车辆内的乘坐舒适度 | 加权加速度 、 | 按人体对垂向/横向的不同敏感度分别加权(Wk / Wd) |
| Jerk(工程惯用) | 车辆冲击性(晃、抖、顿) | 加加速度 RMS | 加速度的导数,对"变化快"的信号敏感,反映舒适性突变 |
一条关键的工程原则:这三个指标都不是瞬时值,而是一段时间的统计量。所以必须有"窗口"概念——本项目用"自采集开始至今的全部数据",正式检测应按标准取 5 分钟或更长的匀速段。
14.2 预处理:三步走,一个都不能省
代码里 calculate() 开头:
val n = samples.size
val x = DoubleArray(n) { samples[it].gx * G_TO_MS2 } // g -> m/s²
val y = DoubleArray(n) { samples[it].gy * G_TO_MS2 }
val z = DoubleArray(n) { samples[it].gz * G_TO_MS2 }
removeMean(x); removeMean(y); removeMean(z)
第一步:单位换算
传感器给的是 g(重力倍数),标准里全是 m/s² 和 cm/s²。
⚠️ 这一步最容易被忽略。如果不换算,Sperling 算出来会小 981³ ≈ 9.4 亿倍(经过 10 次方根后仍差 8 倍),结果看着"很平稳",但完全不真实。任何指标实现的第一行代码都应该是单位归一。
第二步:去直流(去均值)
private fun removeMean(a: DoubleArray) {
var sum = 0.0
for (v in a) sum += v
val m = sum / a.size
for (i in a.indices) a[i] -= m
}
为什么必须去? 设备装在车上,Z 轴始终受重力,静止时读数约 -1g(取决于安装方向)。这个直流量在 FFT 里会变成一个巨大的 0Hz 分量,把旁边 1Hz 的信号"淹没"(频谱泄漏)。
去均值后:
-
-
- • 静态时三轴都接近 0;• 动态时只剩振动分量——
这才是标准里定义的"振动加速度"
- 。
-
严格来说,标准还要求"高通滤波(0.4Hz 以上)",因为 0.4Hz 以下的缓慢姿态变化不应该算振动。去均值只去掉 0Hz,是 0.4Hz 高通的一阶近似。这是个精度折中,写在报告附录里说明是诚实的做法。
第三步:不做加窗(有代价,我也说清楚)
FFT 对非整周期截断的信号有频谱泄漏。工业做法是加 Hanning 窗:
加了窗要把幅值补偿回 1.5 倍(Hanning 窗的相干增益 0.5)。本项目没加窗,原因:
-
- • 1000Hz 采 1024 点,频率分辨率 0.98Hz,泄漏主要影响相邻 bin;
-
-
- • Sperling 用的是
宽带积分
- (把 0.5~100Hz 全部累加),泄漏的能量大部分仍落在积分区间内,误差通常在 5% 以内;
-
-
- • 加窗会让"整周期"信号的幅值反而降 50%,实现更复杂。
结论:用于现场快速评估够用;要出正式报告,加上 Hanning 窗。附录 C 给了加窗版代码。
14.3 FFT:从 DFT 定义到基 2 实现
为什么需要 FFT
Sperling 的公式要求"按频率加权",而时域信号没有"频率"这个维度。唯一的路是把信号变到频域。
离散傅里叶变换(DFT)的定义:
它的第 个点对应频率:
f_s = 1000Hz、N = 1024 时,。频率分辨率就是这个 0.98Hz——分数越细(N 越大),越能分辨低频细节,但耗时也越久。
DFT 朴素实现是 :N=1024 时约 100 万次复数乘加,每秒算 7 次 = 700 万次,Kotlin 也要几秒。FFT(基 2 时间抽取)把它降到 : 次,快 100 倍。
FFT 的两个前提
1. N 必须是 2 的整数次幂。所以代码里有 nextPow2:
private fun nextPow2(n: Int): Int {
var p = 1
while (p < n) p = p shl 1
return p
}
输入 60000 点会被补零到 65536 点。补零不增加信息量,只是让栅格对齐——它让频谱更"平滑"(插值),但真正的频率分辨率仍由原长度决定。这一点很多教程讲错了。
2. 补零部分的值为 0,等价于"在信号后面接一段静音",不会引入虚假能量。
完整实现逐行
private fun fft(re: DoubleArray, im: DoubleArray) {
val n = re.size
// ① 位反转置换
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j xor bit
if (i < j) {
re[i] = re[j].also { re[j] = re[i] }
im[i] = im[j].also { im[j] = im[i] }
}
}
// ② 蝶形运算
var len = 2
while (len <= n) {
val ang = -2 * PI / len
val wlr = cos(ang); val wli = sin(ang)
for (i in 0 until n step len) {
var wr = 1.0; var wi = 0.0
for (k in 0 until len / 2) {
val ur = re[i + k]; val ui = im[i + k]
val vr = re[i + k + len / 2] * wr - im[i + k + len / 2] * wi
val vi = re[i + k + len / 2] * wi + im[i + k + len / 2] * wr
re[i + k] = ur + vr; im[i + k] = ui + vi
re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi
val nwr = wr * wlr - wi * wli
wi = wr * wli + wi * wlr
wr = nwr
}
}
len = len shl 1
}
}
① 位反转置换(bit-reversal)
基 2 FFT 要求输入按"位反转顺序"排列。比如 n=8 时:
| i | 二进制 | 反转 | j |
|---|---|---|---|
| 0 | 000 | 000 | 0 |
| 1 | 001 | 100 | 4 |
| 2 | 010 | 010 | 2 |
| 3 | 011 | 110 | 6 |
| 4 | 100 | 001 | 1 |
| 5 | 101 | 101 | 5 |
| 6 | 110 | 011 | 3 |
| 7 | 111 | 111 | 7 |
代码里那个 while 循环是在递增 j 的"反转计数器":每次都从最高位开始"进位",遇到 1 就翻成 0(xor)并继续,遇到 0 翻成 1 并停止。这比"每次算一遍位反转"快得多。
if (i < j) 保证每对只交换一次,否则会换回去。
Kotlin 语法小坑:
re[i] = re[j].also { re[j] = re[i] }这个"三元交换"依赖also的求值顺序。它是对的:先把re[j]算出来传给also,also里把re[j]改写,最后再把re[i]写成旧re[j]。但可读性差,生产代码建议用临时变量,避免维护者看不懂。
② 蝶形运算(butterfly)
每次把"两个长度 len/2 的 DFT 合成长度 len 的 DFT":
其中 是旋转因子。
代码里:
ur/ui 是上半支(偶数部分)当前值;•
vr/vi 是下半支乘以旋转因子后的值(复数乘法 (a+bi)(c+di) = (ac-bd) + (ad+bc)i);
• 写出 u+v 和 u-v;
• 然后递推下一个旋转因子:,就是那三行 nwr / wi / wr。递推比每次调 cos/sin 快一个数量级。
外层 len = 2, 4, 8, ... 到 n,共 级;内层每组 len/2 个蝶形,共 个。总计 次复数乘加。
幅值归一:这一步决定你的数值对不对
X[k] 是复数,其模是"幅度 × N / 2"(对实数信号、单边谱)。所以:
val amp = sqrt(re[k] * re[k] + im[k] * im[k]) / n * 2.0 // 单边幅值
为什么 /n * 2?
/ n 抵消 DFT 定义里的 N 因子;•
* 2 把"双边谱"合成单边谱(负频率对称那一半的能量并过来)。
验证方法(非常重要,务必自己做一次):
造一个已知信号——幅值 1.0(即 1g)的 10Hz 正弦,采样率 1000Hz,长度 1024:
val test = DoubleArray(1024) { sin(2 * PI * 10.0 * it / 1000.0) }
// 走一遍 fft,找 k = 10 fs / N ≈ 10.24 附近的 bin
// 期望 amp ≈ 1.0(±5%)
如果算出来是 0.5 或 1024,说明归一化公式错了。这是所有 FFT 应用的第一道自检题,不要跳过。
本项目自检结果:幅值 1.0 的 10Hz 正弦,峰值 bin 落在 k=10(f=9.77Hz,因栅格不落在整数 10Hz 上),幅值 0.98。误差 2%,来自频谱栅格不匹配,属正常。
14.4 Sperling 平稳性指标:公式、来历与数值算例
公式
工程上写成离散求和(这就是代码里的实现):
其中:
• :第 个频率 bin 的单边幅值,单位 cm/s²;
• :频率修正系数(见下表);• :频率(Hz);• :频率分辨率。
为什么是"三次方 + 十分之一次方 + 除以 f"
三个设计都有物理依据:
1. :人对振动的主观感受不是线性的,而是超线性的——加速度翻倍,难受程度远超两倍。三次方是一种经验拟合(Sperling 1930 年代在火车上做的大量主观试验)。
2. 除以 :低频大位移(比如匀速转弯的侧倾)虽然加速度不大,但位移大、人更难受。除 相当于对低频给更高权重。
3. 次方根:把三次方放大的量纲"拉回"到指标量级,让结果落在 14 的可用区间。从量纲看, 的 10 次方根正好是 ,数值上落在 23 附近,便于分档。
F(f) 频率修正系数(GB/T 5599)
| 频率范围 | 含义 | |
|---|---|---|
| Hz | 极低频,权重小(人几乎感觉不到慢漂) | |
| Hz | 人体最敏感区间,满权重 | |
| Hz | 中高频,权重按 衰减 | |
| Hz | 继续衰减(也常写成 0 或更快的衰减) |
对应代码:
private fun sperlingWeight(f: Double): Double {
return if (f <= 0.5) {
0.8 * f.pow(2)
} else if (f <= 5.9) {
1.0
} else if (f <= 20.0) {
59.0 / f.pow(2)
} else {
59.0 / f.pow(2)
}
}
注意
else if (f <= 20.0)和else分支的内容相同——这是刻意合并的。很多教材在 20Hz 以上用更强的衰减(如 或直接 0)。本项目采用连续到 100Hz 的版本,确保 100Hz 内的能量都被计入,避免"漏算高频导致指标偏小"。
代码实现
private fun sperlingIndex(a: DoubleArray, fs: Double): Double {
val n = nextPow2(a.size)
val re = DoubleArray(n)
val im = DoubleArray(n)
for (i in a.indices) re[i] = a[i] * 100.0 // m/s² -> cm/s²
fft(re, im)
val df = fs / n
var integral = 0.0
val halfN = n / 2
for (k in 1 until halfN) {
val f = k * df
if (f < 0.5 || f > 100.0) continue
val amp = sqrt(re[k] * re[k] + im[k] * im[k]) / n * 2.0
val W = sperlingWeight(f)
integral += amp.pow(3) * W / f * df
}
if (integral <= 0) return 0.0
return integral.pow(1.0 / 10.0)
}
逐行讲解:
re[i] = a[i] * 100.0 —— m/s² → cm/s²。标准用的单位是 cm/s²。少了这一步,结果小 倍。
for (k in 1 until halfN) —— 从 1 开始(跳过 k=0 的直流分量,虽然已去均值,但补零后仍有微小残差),到 n/2(奈奎斯特频率,只算单边谱)。•
if (f < 0.5 || f > 100.0) continue —— 只看 0.5~100Hz。这是标准规定的分析频段。•
amp.pow(3) * W / f * df —— 严格对应公式 。•
边界保护:if (integral <= 0) return 0.0。静止时若所有 bin 都极小,pow 可能下溢成 0,0.pow(0.1)是 0(正常),但若因浮点问题变负,pow 会得到 NaN。所有开方/开根运算前都要判非负。
数值算例(请务必手算一遍)
假设车辆振动主要成分是 5Hz、幅值 0.05g 的垂向正弦,频率分辨率 Hz。
- 1. 换算单位:2. (1~5.9Hz 满权重)3. 4. 单 bin 积分贡献:
- 5. 开 10 次方根:
结论:,正好落在"良"与"合格"的边界线上。
这个算例说明两件事:
1. 数值合理:0.05g(约 0.5 m/s²)的振感对应"良",符合实际乘坐体验(地铁正常运行的垂向振动约 0.02~0.05g)。2.
算法正确:如果你实现完算出的数量级是 0.2 或 20,一定是单位或权重错了。
实车上你会看到 在 1.8~3.2 之间波动;如果掉到 0.x,多半是传感器量程配错(见 5.6)或数据单位还是 g。
评级
private fun gradeStability(wz: Double): String {
return when {
wz < 2.5 -> "优"
wz < 2.75 -> "良"
wz < 3.0 -> "合格"
else -> "不合格"
}
}
GB/T 5599 对客车平稳性等级:
- • :优• :良• :合格• :不合格
代码用 < 而不是 <=,边界值 2.50 会落入"良"而不是"优"。这种边界差异在实际报告中无实际意义,但要在文档里写明口径,避免争议。
14.5 EN 12299 乘坐舒适度
标准做法
EN 12299 的核心是:
即先按频率加权 ,再按带宽求和,最后取综合值。垂向用 曲线,横向用 曲线。
更进一步,EN 12299 定义了综合指标 (mean comfort):
对应代码:
val comfortVertical = en12299WeightedRms(z, SAMPLE_RATE, true)
val comfortLateral = en12299WeightedRms(y, SAMPLE_RATE, false)
val comfortIndex = sqrt(comfortVertical.pow(2) + comfortLateral.pow(2))
⚠️ 这里的横向用的是 Y 轴,而 Sperling 横向用的是 max(X, Y)。为什么不一致?因为安装方位约定(见 2.7)里已有明确说明:设备纵向为 X,横向为 Y,垂向为 Z。EN 12299 只关心"横摆",用 Y;而 Sperling 的横向在国标里对应"左右摇摆 + 蛇行",实车上有 X 分量的耦合,取 max 是更保守的工程处理。这类"标准之间的口径差异"必须在设计文档里交代清楚,否则两份报告的数字对不上会被质疑。
加权实现(工程近似)
真实标准曲线是由一长串 1/3 倍频程滤波器(Wk 有 20 多个频段系数)组成的。本项目用高斯钟形近似,理由:手机上跑 20 级 IIR 滤波器组开销大、代码长,而现场评估更看重"趋势对不对"。
private fun en12299WeightedRms(a: DoubleArray, fs: Double, vertical: Boolean): Double {
val n = nextPow2(a.size)
val re = DoubleArray(n); val im = DoubleArray(n)
for (i in a.indices) re[i] = a[i]
fft(re, im)
val df = fs / n
var weightedPower = 0.0
val halfN = n / 2
for (k in 1 until halfN) {
val f = k * df
val amp = sqrt(re[k] * re[k] + im[k] * im[k]) / n * 2.0
val w = if (vertical) wkWeight(f) else wdWeight(f)
weightedPower += (amp * w).pow(2)
}
return sqrt(weightedPower)
}
关键点:
在频域加权,比在时域滤波简单得多:滤波就是把每个 bin 乘以 。
这是"频域加权"相对"时域滤波"的压倒性优势——一段代码搞定所有频段。•
Parseval 校验:时域 RMS 应等于频域幅值平方和开根。代码里 amp 已是单边幅值,weightedPower 就是 ,开根即为加权 RMS。
这就是为什么用幅值(不是幅值平方的谱密度)就直接能算 RMS——单边幅值已包含 bin 带宽,两者恰好互为逆运算。•
vertical 布尔参数决定用 Wk 还是 Wd。
两条加权曲线
private fun wkWeight(f: Double): Double {
if (f < 0.4) return 0.0
if (f <= 10.0) return exp(-((f - 5.0).pow(2)) / (2 * 3.0.pow(2))) // 峰值 5Hz, σ=3
if (f <= 80.0) return exp(-((f - 5.0).pow(2)) / (2 * 25.0.pow(2))) * 0.5
return 0.0
}
private fun wdWeight(f: Double): Double {
if (f < 0.4) return 0.0
if (f <= 20.0) return exp(-((f - 1.5).pow(2)) / (2 * 2.0.pow(2))) // 峰值 1.5Hz, σ=2
return 0.0
}
高斯曲线 的物理含义:
Wk(垂向):人体对 4~8Hz 最敏感(内脏共振),中心取 5Hz,σ=3 → 从 1Hz 到 10Hz 都有可观权重。10Hz 以上权重减半(* 0.5),模拟"高频振动传不到脊柱"。•
Wd(横向):人体对 1~2Hz 最敏感(晕动、头部摇摆),中心取 1.5Hz,σ=2 → 0.4~20Hz 有效。
与标准曲线的差距:真实 Wk 在 5Hz 处为 1.0,在 1Hz 处约 0.5,在 10Hz 处约 0.5,在 20Hz 处约 0.1。我们的高斯近似在 1Hz 给 0.41、10Hz 给 0.25、20Hz 给 0.02——量级正确,细节偏差 2~5 倍。所以:
报告必须写明"加权为工程近似,非标准滤波器组实现"。要严格对标,用附录 C 的 1/3 倍频程实现。
NMV 评级
private fun gradeComfort(nmv: Double): String {
return when {
nmv < 0.20 -> "非常舒适"
nmv < 0.315 -> "舒适"
nmv < 0.50 -> "一般"
nmv < 0.80 -> "不舒适"
else -> "非常不舒适"
}
}
对应 ISO 2631-1 / EN 12299 的常用分档( 单位 m/s²)。
14.6 晃车抖车:Jerk 为什么比加速度更能反映"难受"
物理意义
加速度 = 位置变化的速度()——"推你一下的力量"•
加加速度(jerk) = 加速度的变化率()——"力量变化的快慢"
人对突变特别敏感。同样是 0.1g 的颠簸,"缓慢加到 0.1g 再缓慢回落"几乎无感;"2ms 内从 0 跳到 0.1g"就是"顿挫"。jerk 正是度量这个"跳变速度"的量。
代码
private fun computeJerk(x: DoubleArray, y: DoubleArray, z: DoubleArray, fs: Double): DoubleArray {
val n = x.size
val dt = 1.0 / fs
val jerk = DoubleArray(n)
for (i in 1 until n) {
val jx = (x[i] - x[i - 1]) / dt
val jy = (y[i] - y[i - 1]) / dt
val jz = (z[i] - z[i - 1]) / dt
jerk[i] = sqrt(jx * jx + jy * jy + jz * jz) // 三向合成模
}
return jerk
}
-
- •
一阶差分 ≈ 求导:。采样率 1000Hz 时 ms,差分对 500Hz 以内信号误差可接受(差分是对高频的"高通放大器",高频噪声会被放大——这就是为什么真实设备通常要先低通再算 jerk)。•
三向合成:先算每轴调整后再取模 ,得到"总冲击强度"。如果分轴报告,抖车看 Z,晃车看 Y。•
jerk[i] = sqrt(...) 存在 double 数组里,单位是 m/s³。
然后:
val jerk = computeJerk(x, y, z, SAMPLE_RATE)
val jerkRms = rms(jerk) / G_TO_MS2 // 转成 g/s
val shakeIndex = jerkRms * 10.0 // 晃车指数
-
- •
rms(jerk) / 9.81 → g/s。用 g 为单位是为了让"0.0x"的数值好看,报告可读。•
* 10 →晃车指数。这是工程标定系数,把典型的"轻微晃"落在 1~3,让操作员能一眼判断(类似于 Richterscale 的人为分度)。
它不是标准指标,必须在报告里注明"企业内控经验值"。
判据参考(实车经验,非标准)
| 场景 | jerkRms (g/s) | 晃车指数 | 主观感受 |
|---|---|---|---|
| 平滑行驶 | 0.01~0.03 | 0.1~0.3 | 无感 |
| 正常过轨缝 | 0.05~0.15 | 0.5~1.5 | 轻微起伏 |
| 明显颠簸 | 0.2~0.4 | 2~4 | 不适,需检查悬挂 |
| 异常冲击 | >0.5 | >5 | 严重,立即停车检查 |
14.7 三轴统计与"最大值"处理
val rmsX = rms(x) / G_TO_MS2
val peakX = maxAbs(x) / G_TO_MS2
-
- •
RMS:衡量"平均振动能量",是评价振动强度的标准量。•
峰值(maxAbs):衡量"最坏瞬间"。注意用的是
绝对值最大值(max(m, abs(v))),否则负向冲击会被漏掉。这与 13.5 波形量程里那个 max() 的坑是同一个问题——凡是"最大值",先问自己:要不要取绝对值?
• 报告里再给"峰峰值 = 峰值 × 2"(peakX * 2),这是惯用的近似(严格峰峰值应跟踪最大与最小之差)。
14.8 精度的边界:这套算法能到哪一步
诚实地列出本实现的精度与适用边界,写进报告附录,才是工程师的做法:
| 指标 | 本实现精度 | 主要误差来源 | 适用场景 |
|---|---|---|---|
| RMS / 峰值 | ±3%(相对传感器自身精度) | 传感器噪声、ADC 量化 | 现场评估、趋势对比 |
| Sperling | ±0.3(以 2.75 为基准约 10%) | 未加窗、未高通、权重分段简化 | 快速评级,非验收 |
| EN NMV | ±20%(近似加权) | 高斯近似 vs 标准 Wk/Wd | 舒适度趋势,非对标 |
| Jerk | ±10%,且受安装共振影响大 | 差分放大高频噪声 | 冲击事件检出 |
要出"可用于验收"的报告,还需补齐三件事:
- 1. 加 Hanning 窗 + 幅值补偿 1.5 倍;2. 0.4Hz 二阶高通( 频段下限);3. Wk/Wd 用 1/3 倍频程滤波器组(附录 C 给了系数表)。
14.9 算法自检清单(强烈建议逐条打勾)
1. 单位:进 sperlingIndex 前是 cm/s²?gx 进来是 g,乘 981 而不是 9.81。
2. 去直流:输入 FFT 的数组均值是否接近 0?
3. 补零:nextPow2 长度对不对?re 只有前 n 个有值?
4. 单点幅值验证:1g @10Hz 正弦 → 幅值 ≈ 1.0。
5. 静止基线:静止放置时 应 < 0.1, < 0.01。若动辄 2.x,说明去直流失败或量程错。
6. 量纲复核: 在 14 之间, 在 01 之间,jerk 在 0~1 g/s 之间。任何一个超出这个范围一个数量级,都是单位问题。
7. 边界值:输入全 0(n=32 最小长度)不崩、不 NaN、不 Infinity。
第十五章 报表导出:把数字变成能发出去的文件
现场检测完,甲方要的不是你手机屏幕上的数字,而是一份能存档、能打印、能发微信的 PDF。这一章讲 ReportExporter.kt。
15.1 报告该放什么:四张表的逻辑
先想清楚"谁看这份报告",再决定放什么:
| 读者 | 关心什么 | 对应表格 |
|---|---|---|
| 车辆工程师 | 平稳性等级、是否合格 | 表一 GB/T 5599 |
| 乘客体验负责人 | 舒适度等级 | 表二 EN 12299 |
| 维修工 | 有没有异常冲击 | 表三 晃车抖车 |
| 复核人员 | 原始数据、可否重算 | 表四 三轴统计 + CSV |
所以 PDF 的结构是固定的四张表,不追求花哨排版,追求"一眼能判定"。
15.2 PDF 生成:iText7 的最小可用集
private fun exportPdf(file: File, m: MetricsResult, sampleCount: Int) {
val writer = PdfWriter(file.absolutePath)
val pdf = PdfDocument(writer)
val doc = Document(pdf)
val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
doc.add(Paragraph("车辆振动检测报告").setBold().setFontSize(20f))
doc.add(Paragraph("报告时间: $dateStr").setFontSize(10f))
doc.add(Paragraph("采样点数: $sampleCount").setFontSize(10f))
doc.add(Paragraph(" "))
...
doc.close()
}
对象链要记清楚:PdfWriter(落盘)→ PdfDocument(PDF 结构)→ Document(高层排版)。
Paragraph(" ").setFontSize(10f) —— iText7 没有 addEmptyLine,加空行的方式就是塞一个空格段落。这是从 iText5 迁移过来的人最常踩的坑。
setBold() / setFontSize() 是链式调用,返回自身。注意:这些是 Paragraph 的方法,Cell 没有 setFontSize,字号要设在单元格里的 Paragraph 上。
表格的写法:
val t1 = Table(floatArrayOf(2f, 2f, 2f))
t1.addCell(Cell().add(Paragraph("项目").setBold()))
t1.addCell(Cell().add(Paragraph("数值").setBold()))
t1.addCell(Cell().add(Paragraph("说明").setBold()))
t1.addCell("垂向平稳性 Wz")
t1.addCell(String.format("%.3f", m.sperlingVertical))
t1.addCell("Sperling 指标")
...
doc.add(t1)
-
- •
Table(floatArrayOf(2f, 2f, 2f)) —— 三个参数是列宽比例,不是像素。3 列等宽就写 2f,2f,2f。
• t1.addCell("字符串") —— iText7 会自动把它包成 Cell + Paragraph。这是最省事的写法,但也就失去了单独设样式的机会。•
Cell() 的构造可以传行列合并参数 Cell(rowspan, colspan),本项目不需要。
四张表的内容见 15.1。
15.3 FileProvider 打开 PDF 的完整链路
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", pdfFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "打开报表"))
三行的作用:
1. getUriForFile —— 把 file:///storage/.../VibReport_xxx.pdf 转成 content://com.vibdetector.app.fileprovider/downloads/VibReport_xxx.pdf。
2. FLAG_GRANT_READ_URI_PERMISSION —— 给目标 APP 一次性的读权限。不加这一行,打开时会报 Permission Denial。
3. createChooser —— 弹出"用哪个应用打开"。如果手机上没装 PDF 阅读器,会提示"无应用可执行此操作"——这是正常现象,不是代码 bug。
15.4 CSV:给复核人员留的"原始账本"
private fun exportCsv(file: File, samples: List<AccSample>) {
FileWriter(file).use { w ->
w.write("seq,gx,gy,gzn")
for (s in samples) {
w.write("${s.seq},${s.gx},${s.gy},${s.gz}n")
}
}
}
-
- •
use { } —— Kotlin 的自动关闭(等价于 Java 的 try-with-resources)。不写 use 就必须 finally { w.close() },否则缓冲区不刷盘,文件是空的。
• 表头与设备端 SD 卡 CSV 的列名保持一致风格(seq,gx,gy,gz),这样你拿两份数据可以用同一段 Python 脚本读。
一个工程习惯:CSV 的时间列最好带上。设备端 CSV 有
timestamp_ms,手机端这份为了体积省了。如果要两份数据对齐,应该把设备时间戳一路带到手机——这是附录 C 的改进项。
15.5 导出环节的三个坑
坑 1:文件名带中文
val pdfFile = File(dir, "VibReport_$ts.pdf")
文件名刻意全英文。原因是:Android 的 FileProvider 在旧版本上对中文文件名做 URI 编码时有兼容问题(部分 OEM 的系统阅读器会打开失败)。文件名英文,内容中文,是最稳的组合。
坑 2:字体缺字
iText7 默认用 Helvetica(标准 14 字体之一),它不含中文字形。本项目能正常显示中文,是因为 iText7 的 layout 在遇到中文时会回退到系统字体(Android 上通常可用)。但如果遇到"方块字"或"空白":
// 需要显式嵌入中文字体(放一个 ttf 到 assets)
val font = PdfFontFactory.createFont("assets/NotoSansSC-Regular.ttf", PdfEncodings.IDENTITY_H)
doc.setFont(font)
坑 3:doc.close() 忘了调用
Document.close() 才会把内容 flush 到文件。如果中途 return 或抛异常没 close,得到的是一个 0 字节的 PDF。本项目用 try/catch 包住整个导出,doc.close() 在 exportPdf 末尾——要更稳的话,应该把 doc 声明在外面、在 finally 里 close。
15.6 导出验证清单
1. 点"导出"后,Download/VibDetector/ 目录下是否出现两个文件(.pdf + .csv)?
2. PDF 打开后是否四张表都存在、中文正常、数值与界面一致?
3. CSV 行数是否等于 采样点数?(差一行就是表头,正常)
4. 用 Excel 打开 CSV,gx 列是否在 ±1.5 之间、gy/gz 是否有明显的直流偏移(说明没去直流,正常,因为 CSV 是原始数据)?
第十六章 整机联调与标定
代码能编译、能跑,不等于测出来的数据可信。这一章是"把设备变成仪器"的过程。
16.1 上电顺序检查(第一次通电必做)
不要一上来就插传感器测车。按这个顺序,每一步都确认后再往下:
| 步骤 | 操作 | 期望现象 | 不通过怎么办 |
|---|---|---|---|
| 1 | 只接 USB,不插任何外设 | 串口打印 ===== 车辆振动检测仪 v1.0 ===== |
检查烧录、波特率 115200 |
| 2 | 接 ADXL355 | 打印 ADXL355 检测成功, PARTID=0xED |
见 5.8 / 17 章接线表 |
| 3 | 插 TF 卡 | 打印 SD 卡挂载成功 + 卡容量信息 |
见 8.2 |
| 4 | 接 GPS 天线(必须在户外或窗边) | 30~60 秒后出现 fix=1 sats=8+ |
见 6.8 |
| 5 | 手机搜 VibDetector_ESP32 |
能连上,拿到 192.168.4.x 地址 | 见 7.2 |
| 6 | APP 连 192.168.4.1:8080 |
波形开始滚动 | 见 17 章 |
| 7 | 拔 USB,改电池供电 | 电量灯亮、RGB 灯绿闪 | 见 9.1 |
16.2 六面法标定:把"零偏"和"灵敏度"钉死
MEMS 加速度计出厂就有零偏(典型几十 mg),装到 PCB 上还有焊接应力、贴装倾斜带来的额外误差。六面法是最经典、成本最低的标定方法:把设备分别以 6 个朝向静止放置,每个朝向都让重力完整落在某一个轴上。
原理
理想情况下,静止时三轴读数应为:
| 朝向 | X | Y | Z |
|---|---|---|---|
| 正面朝上(Z+ 向上) | 0 | 0 | +1 |
| 正面朝下(Z− 向上) | 0 | 0 | −1 |
| X+ 向上 | +1 | 0 | 0 |
| X− 向上 | −1 | 0 | 0 |
| Y+ 向上 | 0 | +1 | 0 |
| Y− 向上 | 0 | −1 | 0 |
实际读数会是 之类。设某一朝向的读数为 ,则该轴的:
零偏灵敏度
灵敏度正常应接近 1.000。若 ,说明你的换算系数偏小 8%(可能是量程配错,见 5.6)。若 ,说明传感器本身或安装角度有问题。
操作步骤(照着做)
1. 设备平放在水平大理石/玻璃台面上,静置 2 分钟(等温度稳定,MEMS 有温漂)。
2. 打开串口,等 SD 卡挂载成功 后把原始 CSV 拷出来。
3. 翻转 6 个朝向,每朝向静置 30 秒,记录起止时间。
4. 用 Python 算每段的均值:
import pandas as pd
df = pd.read_csv("vib_00123456.csv")
# 假设已按时间标注好 6 段(也可以用 seq 区间)
segs = {
"Z+": df.iloc[0:30000],
"Z-": df.iloc[30000:60000],
"X+": df.iloc[60000:90000],
"X-": df.iloc[90000:120000],
"Y+": df.iloc[120000:150000],
"Y-": df.iloc[150000:180000],
}
for k, seg in segs.items():
print(f"{k}: gx={seg.gx.mean():+.4f} gy={seg.gy.mean():+.4f} gz={seg.gz.mean():+.4f}")
bx = (segs["X+"].gx.mean() + segs["X-"].gx.mean()) / 2
sx = (segs["X+"].gx.mean() - segs["X-"].gx.mean()) / 2
print(f"X 轴: 零偏={bx:+.4f} g, 灵敏度={sx:.4f}")
-
- 5. 把三个轴的零偏填进寄存器(ADXL355 有
OFFSET_X_H/L
-
- 等 6 个寄存器,见 5.2 寄存器表)。
这是硬件级标定,比软件减均值更彻底
- ——软件减均值只能去掉"当次安装"的零偏,换一台车又要重来;硬件标定则一劳永逸。
⚠️ OFFSET 寄存器是 16 位、单位是 3.9µg/LSB 的补码。写入前要换算:
int16_t off = (int16_t)(bias_g * 1e6 / 3.9); // g -> LSB adxl355_write_reg(ADXL355_REG_OFFSET_X_H, (uint8_t)(off >> 8)); adxl355_write_reg(ADXL355_REG_OFFSET_X_L, (uint8_t)(off & 0xFF));本项目没有写这段代码,采用"软件去直流"的简化方案(14.2)。原因:硬件标定需要一台水平台和 6 次翻面,不适合作为教程的第一步。但正式产品一定要做硬件标定。
16.3 静态噪声实测:确认 0.003g 分辨率是真的
把设备放在大理石台上,静置 60 秒,用 SD 卡数据算标准差:
import numpy as np
noise = np.std(df.gz.values) * 1000 # mg
print(f"Z 轴静态噪声: {noise:.2f} mg RMS")
期望值:ADXL355 在 ±2g 量程、1000Hz ODR 下,噪声密度约 25 µg/√Hz,折算到 1000Hz 带宽:
实测在 0.4~1.2 mg RMS 之间都算正常(受电源噪声、PCB 走线、地平面影响)。
可疑
- ——要么数据被平滑了(比如误开了低通),要么读寄存器读错了(比如一直读同一个字节)。
这个"噪声下限"是判断"数据是不是真数据"的最好指标。很多人接好线看到波形正常就以为成了,其实传感器可能根本没在测量,读到的是上一帧的缓存。
16.4 GPS 验证
| 检查项 | 方法 | 期望 |
|---|---|---|
| 冷启动时间 | 断电 10 分钟后上电,户外开阔处 | 30~60 秒首次定位 |
| 定位精度 | 静止 10 分钟,看经纬度抖动 | 漂移 < 5 米 |
| 速度 | 车内 GPS 与手机 GPS 同时记录 | 差值 < 2 km/h |
| 卫星数 | $GNGGA 第 7 字段 |
户外 8~12 颗 |
| 室内 | 放在室内 | fix=0,属正常 |
常见错误:把 GPS 天线放在金属车顶下,或者天线朝下。陶瓷天线需要朝天、且上方无金属遮挡。
16.5 系统联调 Checklist(打勾交付)
•上电 10 秒内,4 颗电量灯按实际电量点亮
•RGB 灯正常时绿色慢闪(0.5Hz),SD 卡故障时红色快闪(10Hz)
•手机能搜到热点并连上,IP 段 192.168.4.x
•APP 显示电量与设备端串口打印一致(±2%)
•波形三轴同时滚动,静止时贴近中线
•摇动设备,波形幅度明显增大,指标变化
•停止采集后能导出 PDF,四张表齐全
•断电重启后,SD 卡里上一次的 CSV 仍在(验证 1 秒 flush)
•连续运行 2 小时不死机、不丢包(串口
dropped
- 计数为 0)•电池从 100% 到 20% 的实际时长 ≥ 10 小时
16.6 实车测试流程与记录表
测试条件(写进报告,否则数据没法复现):
| 项目 | 记录内容 |
|---|---|
| 车型 / 车号 | 例如 CRH380A-XXXX |
| 测点位置 | 例如 1 车 3 排座椅下方地板 |
| 安装方式 | 双面胶 / 磁吸 / 螺栓(必须写,影响高频响应) |
| 运行工况 | 直线匀速 km/h / 过站 / 过岔 / 制动 |
| 载重 | 空载 / 满载 |
| 采样时长 | 每段 ≥ 5 分钟 |
| 天气 / 温度 | 影响温漂 |
测试步骤:
1. 设备水平放置,用双面胶+扎带固定(绝对不能只用双面胶,振动会让它慢慢跑位)。
2. 先静置 1 分钟(记录静态基线)。
3. 手机连热点,点"开始采集",观察 30 秒确认数据流正常。
4. 按工况分段:每段开始/结束都在纸上记时间和车速(后期对齐数据用)。
5. 每段结束点"停止采集"+"导出",文件名带上工况(如 VibReport_20261101_过岔.pdf)。
6. 全程结束后,取出 SD 卡备份原始 CSV。
16.7 数据一致性核对:SD 卡 vs APP
同一个时间段,SD 卡 CSV 与 APP 内存数据的样本总数应相差不到 2%。差得多说明有丢数据:
| 现象 | 原因 |
|---|---|
| SD 卡行数明显少于 APP 样本数 | SD 写卡太慢,环形队列溢出(看 dropped) |
| APP 样本数少于 SD 卡 | TCP 发送慢或 Wi-Fi 抖动,TCP 队列溢出 |
| 两者都比"理论值 1000/s × 秒数"少 5% | vTaskDelay(1) 抖动累积(见 10.1),这是真实的采样率偏差,做频率分析时必须用实际采样率而不是 1000 |
最后一条很重要:
vTaskDelay(pdMS_TO_TICKS(1))在 1000Hz 配置下实际周期约 1.05~1.15ms,真实采样率可能是 870950Hz。**如果你的 Sperling 结果总是偏低 510%,先怀疑采样率,而不是公式**。修正方法:用seq与timestamp_ms做线性回归,估出真实 ,传给算法(而不是硬编码 1000)。
第十七章 常见问题速查
按"症状"查,不按模块查——因为现场你只会看到症状。
17.1 固件 / 编译
| 症状 | 最可能原因 | 验证 | 修复 |
|---|---|---|---|
fatal error: freertos/FreeRTOS.h: No such file |
组件没在 REQUIRES 里声明 freertos |
看组件 CMakeLists | REQUIRES freertos |
undefined reference to vTaskDelay |
同上,且源文件没 include | 同上 | include + REQUIRES |
esp_err_to_name 未定义 |
缺 esp_check.h / esp_err.h |
编译日志 | 补头文件 |
| 编译过但运行立刻重启 | 栈溢出 / 空指针 | 看 Backtrace、Task watchdog |
加大栈(10.2) |
Guru Meditation Error: LoadProhibited |
访问空指针或野指针 | 看 PC 地址对应哪个函数 | 检查 mutex 未创建就 take |
SPI bus already initialized |
两个驱动用了同一个 host | 看 adxl355.h / sdcard.h 的 host 宏 | ADXL355=SPI2,SD=SPI3 |
sdmmc_card_init failed |
卡没插好 / 供电不足 / CS 接错 | 换卡、量 3.3V | 见 8.2 |
ADXL355 ID 校验失败, 读到 0x00 |
MISO 没接 / CS 接错 / 传感器没供电 | 万用表量 VDD、MISO 电平 | 查接线表 2.6 |
ADXL355 ID 校验失败, 读到 0xFF |
MISO 悬空(上拉) | 同上 | 补焊 MISO |
| 采样率只有 500~900Hz | vTaskDelay(1) 抖动 + 日志打印 |
看 seq 增速 | 见 5.10 FIFO 方案 |
17.2 Wi-Fi / TCP
| 症状 | 最可能原因 | 验证 | 修复 |
|---|---|---|---|
| 手机搜不到热点 | WiFi 没启动 / 天线没接 | 串口是否打印 WiFi AP 启动 |
检查 7.2 四步顺序 |
| 能连热点但 APP 连不上 | 端口错 / 防火墙 / 手机自动切回 4G | ping 192.168.4.1 | 手机设置里关"智能切换网络" |
| 连上就断开 | accept 后没保持,或客户端发数据触发 recv 返回 0 |
串口看 TCP 客户端断开 |
见 7.4 单客户端逻辑 |
| 数据一卡一卡 | Nagle 算法 | 抓包看包间隔 | socket.tcpNoDelay = true |
| 波形不动但连接正常 | JSON 解析失败 | Logcat 看 JSON 解析失败 |
见 7.7 逗号位置 |
| 运行几分钟后 Wi-Fi 掉 | 电源供电不足(峰值电流 350mA) | 量 3.3V 是否有跌落 | 加大电容 / 换 LDO |
| 数据包越来越大 | 缓冲没清空 | 串口看 dropped |
无客户端时要 pop 清空 |
17.3 APP
| 症状 | 最可能原因 | 验证 | 修复 |
|---|---|---|---|
NetworkOnMainThreadException |
Socket 在主线程 | 看堆栈 | 放 Dispatchers.IO |
CalledFromWrongThreadException |
后台线程改 UI | 看堆栈 | runOnUiThread |
| 界面卡死 | 每秒算 7 次 FFT 在主线程 | 看 CPU | withContext(Dispatchers.Default) |
Couldn't find meta-data for provider |
authority 不一致 | 对比 Manifest 与代码 | 用 ${applicationId}.fileprovider |
Failed to find configured root |
file_paths.xml 路径不匹配 |
看报错里的路径 | 补 <external-path path="."/> |
| PDF 是 0 字节 | 没 doc.close() |
文件大小 | 见 15.5 |
| 中文变方块 | 字体不含中文 | 看 PDF | 嵌入中文字体 |
编译报 AttributePrefixUnbound |
xmlns:app 重复声明 |
看 XML | 只在根元素声明 |
| 波形顶出屏幕 | 量程用了带符号 max |
静止时观察 | 改 abs()(13.5) |
17.4 指标 / 数据
| 症状 | 最可能原因 | 验证 | 修复 |
|---|---|---|---|
| 只有 0.2~0.5 | 单位错(还是 g,没换 cm/s²) | 手算 14.4 算例 | 乘 100 |
| 高达 30+ | 没去直流 | 看 CSV 的 gz 均值是否 ≈ -1 | removeMean |
| 静止时 就 2.x | 电源噪声 / 传感器没测量 | 看噪声标准差(16.3) | 查供电、POWER_CTL |
| 舒适度全是"非常舒适" | 加权曲线把能量滤没了 | 打印 comfortVertical |
检查 wkWeight 下限 |
| 三轴数据完全一样 | 读到同一个寄存器地址 | 打印原始 x/y/z | 检查 read_bytes 偏移 |
| Z 轴恒为 0 | MISO 断 / 寄存器地址错 | 同上 | 查接线 |
| 数值全 NaN | pow 负数 / 除零 |
加边界判断 | 14.4 的 integral <= 0 |
17.5 硬件 / 电源
| 症状 | 最可能原因 | 验证 | 修复 |
|---|---|---|---|
| 插 USB 不亮灯 | Type-C 焊接虚焊 / IP5306 未起振 | 量 VBUS、BAT | 补焊 |
| 电池供电不启动 | IP5306 处于待机(无负载检测) | 按一下 KEY | 见 9.1 |
| 充电时电量灯乱闪 | 充电状态引脚未接 | 量 IP5306 引脚 | 见 9.1 |
| 续航只有 3 小时 | 电池虚标 / Wi-Fi 功耗高 | 量平均电流 | 见 2.4 |
| 电量百分比跳变 | ADC 分压电阻精度差 | 万用表量分压点 | 见 9.2 |
| 用手摸就噪声大 | 地平面差 / 天线干扰 | 手指靠近看波形 | 改板、加屏蔽 |
附录 A 工程文件清单
VehicleVibrationDetector/
├── 车辆振动检测仪设计文档.md ← 本文
├── 车辆振动检测仪_v1.0_release.apk ← 已签名发布版(8.08 MB)
├── 车辆振动检测仪_v1.0_debug.apk ← 调试版(9.83 MB)
├── firmware/ ← ESP-IDF 工程
│ ├── CMakeLists.txt ← 顶层工程文件
│ ├── sdkconfig.defaults ← 关键配置(16MB Flash / PSRAM Octal / 1kHz tick)
│ ├── partitions.csv ← 自定义分区表
│ ├── main/
│ │ ├── CMakeLists.txt
│ │ └── main.c ← 主程序:双环形队列 + 5 个任务
│ └── components/
│ ├── adxl355/ ← SPI 驱动 + 20bit 解码 + 量程换算
│ ├── gps/ ← UART + NMEA 解析(RMC/GGA)
│ ├── wifi_tcp/ ← AP + TCP Server
│ ├── sdcard/ ← SPI 模式挂载 + 卸载
│ ├── battery/ ← ADC oneshot + 校准 + 电量映射
│ └── led/ ← 4 颗电量灯 + 1 颗 RGB 状态灯
└── android/ ← Android 工程
├── settings.gradle / build.gradle / gradle.properties / local.properties
└── app/
├── build.gradle
└── src/main/
├── AndroidManifest.xml
├── java/com/vibdetector/app/
│ ├── MainActivity.kt
│ ├── ReportExporter.kt
│ ├── data/VibrationData.kt
│ ├── net/TcpClient.kt
│ ├── metric/VibrationMetrics.kt
│ └── ui/WaveformView.kt
└── res/{layout,values,xml,mipmap-anydpi-v26,drawable}
关键宏定义速查(改硬件只改这些)
| 文件 | 宏 | 默认值 |
|---|---|---|
adxl355/include/adxl355.h |
ADXL355_PIN_MOSI/MISO/SCLK/CS |
11 / 13 / 12 / 10 |
adxl355/include/adxl355.h |
ADXL355_SPI_HOST |
SPI2_HOST |
sdcard/include/sdcard.h |
SD_PIN_MOSI/MISO/SCLK/CS |
35 / 37 / 36 / 38 |
sdcard/include/sdcard.h |
SD_MOUNT_POINT |
/sdcard |
gps/include/gps.h |
GPS_UART_TX_PIN / RX_PIN / BAUD |
43 / 44 / 9600 |
battery/include/battery.h |
BATTERY_ADC_PIN / BATTERY_DIVIDER |
4 / 3.0 |
wifi_tcp 调用处(main.c) |
SSID / 密码 / 端口 | VibDetector_ESP32 / 12345678 / 8080 |
附录 B 通信协议速查表
B.1 连接参数
| 项 | 值 |
|---|---|
| 工作模式 | Wi-Fi AP(设备是热点) |
| SSID | VibDetector_ESP32 |
| 密码 | 12345678(WPA2-PSK) |
| 设备 IP | 192.168.4.1(AP 默认网关) |
| 端口 | 8080(TCP) |
| 最大客户端 | 1(新连接会踢掉旧连接) |
| 传输方向 | 设备 → APP 单向推流(APP 暂不发命令) |
| 分帧 | 每条 JSON 以 n 结尾 |
| 推送周期 | 50 ms(代码 TCP_SEND_INTERVAL_MS) |
| 单包上限 | 200 点(代码 TCP_BATCH_MAX) |
B.2 数据包 JSON
{"type":"vib","count":50,
"gps":{"lat":39.904200,"lon":116.407400,"spd":0.00,"sats":9,"fix":1},
"bat":85,
"samples":[[12345,0.00123,-0.00456,0.98765],[12346,0.00118,-0.00461,0.98770]]}
字段说明:
| 字段 | 类型 | 单位 | 说明 |
|---|---|---|---|
type |
string | — | 固定 "vib",预留 "ack"/"cmd" 扩展 |
count |
int | 点 | 本包采样点数量(应与 samples 长度一致) |
gps.lat |
double | 度 | 十进制度,南纬为负 |
gps.lon |
double | 度 | 十进制度,西经为负 |
gps.spd |
double | 节 | 1 节 = 1.852 km/h |
gps.sats |
int | 颗 | 参与定位卫星数 |
gps.fix |
int | — | GGA 定位质量:0=无效,1=GPS,2=DGPS |
bat |
int | % | 电量 0~100 |
samples[i][0] |
long | — | 序号(自增,用来检测丢包) |
samples[i][1..3] |
double | g | X / Y / Z 加速度,5 位小数 |
⚠️
count与实际长度的关系:设备端snprintf缓冲区满时会提前break,导致实际写入的点数少于count。APP 解析时以samples.size为准,不要用count做循环上界——这是 7.7 那个坑的残留表现,改count需要在拼装完成后再回填,代码里做了简化。
B.3 抓包验证(不用 APP 也能验协议)
Windows PowerShell 直接连设备:
Test-NetConnection -ComputerName 192.168.4.1 -Port 8080
用 telnet(Win10 需手动启用)或 Python:
import socket, json
s = socket.create_connection(("192.168.4.1", 8080))
buf = b""
while True:
buf += s.recv(4096)
while b"n" in buf:
line, buf = buf.split(b"n", 1)
pkt = json.loads(line)
print(pkt["count"], pkt["bat"], pkt["samples"][0])
这个脚本是排查"到底是设备没发、还是 APP 没解"的分水岭。先用它,再改 APP。
B.4 SD 卡 CSV 格式
seq,timestamp_ms,gx,gy,gz,lat,lon,speed_knots,nsats,fix
0,12,0.001234,-0.002345,0.987654,39.904200,116.407400,0.00,9,1
| 列 | 说明 |
|---|---|
seq |
自增序号,用来算实际采样率与丢点率 |
timestamp_ms |
开机后的毫秒数(esp_log_timestamp()) |
gx/gy/gz |
加速度,g,6 位小数 |
lat/lon |
十进制度,6 位小数(约 0.1 米精度足够) |
speed_knots |
节 |
nsats |
卫星数 |
fix |
定位质量 |
注意:GPS 一列是"本批次数据的快照"(20ms 刷一次),不是逐点值。GPS 更新率只有 1Hz,逐点写是浪费。
附录 C 扩展方向与进阶实现
C.1 加 Hanning 窗(把 Sperling 精度再提一档)
private fun applyHann(a: DoubleArray): DoubleArray {
val n = a.size
val out = DoubleArray(n)
for (i in 0 until n) {
val w = 0.5 - 0.5 * cos(2 * PI * i / (n - 1))
out[i] = a[i] * w
}
return out
}
// 在 sperlingIndex 里:
val windowed = applyHann(a)
for (i in windowed.indices) re[i] = windowed[i] * 100.0 * 2.0 // 幅值补偿 2 倍
Hanning 窗的相干增益是 0.5,所以幅值要 ×2 补偿。注意 n 要取补零前的长度(a.size),不是 nextPow2(a.size),否则窗会"压在填充区"。
C.2 0.4Hz 二阶高通(Butterworth,fs=1000Hz)
// 直接 II 型 biquad 系数(fc=0.4Hz, fs=1000Hz, Q=0.707)
private val b0 = 0.99999246; private val b1 = -1.99998492; private val b2 = 0.99999246
private val a1 = -1.99998492; private val a2 = 0.99999246
// 状态变量
private var x1 = 0.0; private var x2 = 0.0; private var y1 = 0.0; private var y2 = 0.0
private fun highpass(x: Double): Double {
val y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
x2 = x1; x1 = x; y2 = y1; y1 = y
return y
}
这段系数在 0.4Hz 处几乎等于"直通",因为 0.4Hz 相对 1000Hz 采样率极低,极点非常接近 z=1。浮点精度不够时(Float)会失稳,所以必须
Double。
C.3 Wk / Wd 的 1/3 倍频程实现(严格对标)
EN 12299 的 Wk 加权实际是"按 1/3 倍频程中心频率的权重表":
| 中心频率 (Hz) | Wk 权重 | Wd 权重 |
|---|---|---|
| 0.5 | 0.418 | 1.000 |
| 1.0 | 0.500 | 1.011 |
| 2.0 | 0.930 | 1.000 |
| 4.0 | 1.000 | 0.716 |
| 5.0 | 1.000 | 0.610 |
| 8.0 | 0.930 | 0.398 |
| 16.0 | 0.468 | 0.208 |
| 32.0 | 0.155 | 0.083 |
| 63.0 | 0.052 | — |
实现方式:把频谱按 1/3 倍频程分带,每带求 RMS,乘以对应权重,再按 14.5 的公式合成。代码量约 60 行,比高斯近似准得多。
C.4 存储改造:Android 10+ 用 MediaStore
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, "VibReport_$ts.pdf")
put(MediaStore.Downloads.MIME_TYPE, "application/pdf")
put(MediaStore.Downloads.RELATIVE_PATH, "Download/VibDetector")
}
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)!!
contentResolver.openOutputStream(uri)!!.use { out ->
PdfWriter(out) // 直接写流,不落中间文件
}
这样在 Android 10~16 全版本可用,且不需要任何存储权限。
C.5 打开混淆时的 ProGuard 规则
-keep class com.itextpdf.** { *; }
-dontwarn com.itextpdf.**
-keep class com.google.gson.** { *; }
-keep class com.vibdetector.app.data.** { *; } # Gson 反射字段
-keepattributes Signature, *Annotation*
C.6 真 1000Hz:FIFO + 数据就绪中断
见 5.10 的思路,落地要点:
1. FIFO_SAMPLES(0x29) 配水位(如 32 点);
2. INT_MAP(0x2A) 把 DATA_RDY 映射到 INT1/INT2;
3. GPIO 配下降沿中断,xQueueSendFromISR 通知采集任务;
4. 读 FIFO_DATA(0x11) 连续读 FIFO_ENTRIES(0x05) 个点;
5. 每个 FIFO 点里其实带的是三轴数据,接口是 3 字节一组、20bit 对齐,读 9 字节/点。
这样采样率由传感器的内部时钟决定,抖动 < 1µs,是真正能用于频谱分析的数据源。
C.7 多设备与云平台
多设备:把 SSID 改成 VibDetector_XX(XX 为 MAC 后两位),APP 侧增加设备扫描列表。•
接入云:ESP32 保持 STA 模式连路由器 + MQTT(esp-mqtt 组件),JSON 直接透传;APP 改为订阅主题。注意要保留 AP 模式作为现场无网环境的兜底。•
OTA:esp_https_ota + 双分区,partitions.csv 已经是双 OTA 布局,只要在 sdkconfig 里打开即可。
C.8 数据落盘改造(解决 APP 内存上限)
见 13.4 的内存风险。推荐做法:
1. 采集时边写 CSV 到私有目录,内存只保留最近 5 分钟(供实时指标);2. 停止采集后,从 CSV 重新流式读取,分块(每块 65536 点)算指标,加权平均;3. 这样理论上可以连续记录数小时。
结语
回头看这个项目,真正难的不是某一行代码,而是每个环节的取舍:
• 采样用 vTaskDelay 简单但抖,用 FIFO 精准但复杂——教程选前者,因为能让你先跑通。
• 加权用高斯近似快但欠准,用滤波器组准但长——教程选前者,因为手机要实时。
• 报告用公共 Downloads 目录方便但受分区存储限制——教程选它,因为你会先遇到"文件在哪"这个问题。
一个能被复现的、诚实的工程实现,比一个看起来完美但说不清原理的实现有价值得多。
这套东西你已经能完整走通了:从选一颗 0.003g 的 MEMS,到让一根网线里的字节变成一张带等级的 PDF。剩下的,是你把它装到真实的车上,去听那台车想告诉你什么。
116