引言
3D打印机在高速打印时,运动部件的惯性激发的机械共振会严重影响打印质量——表面出现“涟漪纹”(VFA,即垂直表面伪影),甚至导致层错位。Marlin固件从 2.0 版本开始引入 M593 命令,允许用户为 X/Y 轴设置共振频率,通过主动调整运动规划来抑制共振。但问题的关键在于:如何准确测得这组频率?
传统的做法是使用“共振补偿校准”插件(如 Klipper 的 shaper_calibrate),但需要额外的硬件(如加速度计)和复杂接线。本文介绍一套基于 ESP32 的便携式共振检测装置,它通过 ADXL345 加速度计采集振动信号,自动执行扫频 G 代码,利用 FFT 分析找到共振峰,并将结果直接写入 Marlin 的 EEPROM 或发送 M593 指令。更重要的是,它自带 Web 界面,支持实时波形显示、参数调整、G 代码生成,甚至可以一键完成“扫描 → 分析 → 写入”的全流程。
系统架构
硬件组成
- 主控:ESP32 (支持 WiFi/蓝牙,双核,大容量 Flash)
- 加速度计:ADXL345 (I2C 接口,可测 ±16g,采样率 400Hz)
- 显示屏:0.96 寸 OLED (SSD1306, 128×64,可选)
- 供电:通过 USB 或 3.3V 供电
- 连接方式:WiFi STA 或 AP 模式,与打印机同网络通信(HTTP 协议)
软件框架
传感器驱动:ADXL345 的初始化与数据读取,通过 I²C 总线(GPIO21/22)与 ESP32 通信。
FFT 分析:使用 arduinoFFT 库,对 256 点 采样(400Hz 采样率)做 1024 点 FFT(零填充),频率分辨率约 0.39Hz。峰值检测结合了 SNR(信噪比)、Q 因子和相对幅值的综合置信度评分,确保结果可靠。
运动检测:计算加速度矢量的 RMS 偏差,判断打印机是否处于运动状态。阈值可通过 Web 界面调整,并支持自动校准。
状态机:管理校准流程的三段式推进:
INIT_WAITING:等待 INIT.GCO 执行完毕,打印机归位并移动到起始位置SWEEPING:SWEEP.GCO 扫频执行中,持续采集 FFT 数据FINISH_WAITING:FINISH.GCO 归位完成后,计算共振频率
G 代码生成与上传:
- INIT.GCO:归位 → 移动到热床中心 → 抬升 Z 轴 → 切换到相对坐标
- SWEEP.GCO:纯扫频运动(不含归位指令,避免与 INIT/FINISH 重复)
- FINISH.GCO:恢复绝对坐标 → 归位 → 关闭电机
三个文件通过 HTTP 分步上传至打印机 SD 卡,然后按顺序启动打印。
Web 服务器:提供响应式控制面板(兼容手机/PC),使用 Chart.js 实时绘制加速度波形,支持一键校准、G 代码生成下载、噪声基底记录等完整功能。
参数存储:
- EEPROM:保存共振频率、WiFi 凭据、打印机 IP、运动阈值、热床尺寸、幅度阈值
- SPIFFS:保存 X/Y 轴的噪声基底数据(用于频谱抑制,提升信噪比)
核心原理
1. 扫频信号生成
为了激励打印机结构,我们让某个轴(X 或 Y)做正弦往复运动,等效频率由 速度 和 移动距离 决定:
f = v / (2 * travel * 60)
其中 v 是 G1 指令中的 F 参数(mm/min),travel 是单次移动距离(mm)。通过从起始频率到结束频率逐步增加速度,生成一段扫频 G 代码。每个频率点执行若干完整周期,并在每个频率点后插入 G4 P800 暂停 800ms,等待机械运动彻底停止。
我们通过改变运动速度来改变往复运动的频率。以下代码生成一段从 startFreq 到 endFreq 的扫频序列,每个频率点执行 CYCLES_PER_FREQ 个完整周期,并插入 800ms 暂停:
String generateSweepOnlyGCode(String axis, float &startFreq, float &endFreq,
float stepFreq, float &travel, float maxSpeed) {
// 根据频率和行程计算速度 (mm/min)
float v_min = 60 * travel * 2 * startFreq;
float v_max = 60 * travel * 2 * endFreq;
float v_step = 60 * travel * 2 * stepFreq;
String gcode;
gcode += "G91\n"; // 相对坐标
for (float v = v_min; v <= v_max; v += v_step) {
float freq = v / (60 * travel * 2);
for (int cycle = 0; cycle < CYCLES_PER_FREQ; cycle++) {
gcode += "G1 " + axis + String(travel) + " F" + String(v) + " ; ~" + String(freq, 2) + " Hz\n";
gcode += "G1 " + axis + String(-travel) + " F" + String(v) + " ; ~" + String(freq, 2) + " Hz\n";
}
gcode += "G4 P800 ; pause for motion stop\n"; // 等待运动停止
}
return gcode;
}
2. 加速度信号采样与 FFT
在扫频过程中,ESP32 以 200Hz 采样率连续读取 ADXL345 的 X/Y 轴加速度。当检测到运动停止(RMS 低于阈值)且稳定 800ms 后,对最近的 128 个样本进行 FFT 分析。
void processFrequencySampling() {
if (!freqSamplingActive) return;
unsigned long now = micros();
if (now - lastFreqSampleTime < FREQ_SAMPLE_INTERVAL_US) return; // 5ms 间隔
lastFreqSampleTime = now;
sensors_event_t e;
accel.getEvent(&e);
if (testAxis == "X") {
freqDataX[freqSampleCount] = e.acceleration.x;
freqDataY[freqSampleCount] = 0;
} else if (testAxis == "Y") {
freqDataX[freqSampleCount] = 0;
freqDataY[freqSampleCount] = e.acceleration.y;
} else { // XY
freqDataX[freqSampleCount] = e.acceleration.x;
freqDataY[freqSampleCount] = e.acceleration.y;
}
freqSampleCount++;
if (freqSampleCount >= FFT_SAMPLES) { // FFT_SAMPLES = 128
freqSamplingActive = false;
// 接下来调用 analyzeFrequencyWithNoiseFloor 进行 FFT 分析
// ...
}
}
3. FFT 与峰值提取
对 128 个时域样本做零填充至 1024 点,加汉明窗,计算幅度谱。然后:
- 抑制噪声基底(如有预记录的噪声基底,则从频谱中减去)
- 寻找前 10 个极大值点(避开直流分量),计算每个峰的置信度(综合考虑信噪比、Q 因子、相对幅值)
- 选取置信度最高的频率作为该频率点的共振频率
float analyzeFrequencyWithNoiseFloor(float *data, int len, float samplingFreq,
float &confidence, float &altFreq,
const String& axis, float &peakAmplitude) {
const int FFT_SIZE = 1024;
const int HALF_FFT = FFT_SIZE / 2;
// 去直流
float mean = 0;
for (int i = 0; i < len; i++) mean += data[i];
mean /= len;
for (int i = 0; i < len; i++) {
fftReal[i] = data[i] - mean;
fftImag[i] = 0.0;
}
for (int i = len; i < FFT_SIZE; i++) {
fftReal[i] = 0.0;
fftImag[i] = 0.0;
}
// 加汉明窗 + FFT
FFT.windowing(fftReal, FFT_SIZE, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
FFT.compute(fftReal, fftImag, FFT_SIZE, FFT_FORWARD);
FFT.complexToMagnitude(fftReal, fftImag, FFT_SIZE);
// 计算功率谱密度 (PSD)
float psd[HALF_FFT];
float maxMag = 0;
for (int i = 1; i < HALF_FFT; i++) {
psd[i] = fftReal[i] * fftReal[i];
if (psd[i] > maxMag) maxMag = psd[i];
}
// 噪声基底抑制(如果有)
if (noiseFloorEnabled) {
float* floor = (axis == "X") ? noiseFloorX : noiseFloorY;
bool valid = (axis == "X") ? noiseFloorValidX : noiseFloorValidY;
if (valid) {
for (int i = 1; i < HALF_FFT && i < FFT_SAMPLES; i++) {
psd[i] = max(0.0f, psd[i] - floor[i]);
}
}
}
// 峰值检测(找极大值)
struct Peak { int idx; float mag; };
Peak peaks[20];
int peakCount = 0;
for (int i = 3; i < HALF_FFT - 3; i++) {
if (psd[i] > psd[i-1] && psd[i] > psd[i+1]) {
// 合并邻近峰值
// ...
}
}
// 对每个峰值计算置信度(SNR、Q因子、相对幅值)
float bestFreq = 0, bestConfidence = 0;
for (int p = 0; p < peakCount && p < 10; p++) {
int idx = peaks[p].idx;
float freq = idx * samplingFreq / FFT_SIZE;
if (freq < 5.0 || freq > 100.0) continue;
float snr = calculateSNR(psd, HALF_FFT, idx, 5);
float qFactor = calculateQFactor(psd, idx, samplingFreq, FFT_SIZE);
float confidence_score = min(snr / 10.0f, 1.0f) * 0.5f +
min(qFactor / 5.0f, 1.0f) * 0.3f +
min(peaks[p].mag / maxMag, 1.0f) * 0.2f;
if (confidence_score > bestConfidence) {
bestFreq = freq;
bestConfidence = confidence_score;
}
}
confidence = bestConfidence;
return (confidence > 0.5f) ? bestFreq : 0;
}
4. 幅频曲线与最终共振频率
扫频过程产生一系列离散频率点及对应的振幅(FFT 峰值幅值)。最后,使用二次插值(抛物线拟合)在最大振幅点附近插值,得到更精确的共振频率。
5. 写入打印机
得到频率后,自动发送 M593 X Fxx.x(或 Y)命令给打印机,同时存入 EEPROM 以便断电记忆。
运动检测与状态机
运动检测(非阻塞)
计算加速度矢量 RMS 偏差,判断是否运动:
void updateMotionState() {
if (!resonanceTesting) { isMoving = false; return; }
if (!motionDetectionEnabled) { isMoving = true; lastMotionTime = millis(); return; }
const int samples = MOTION_SAMPLES_TOTAL; // 20
float diffs[samples];
float sum = 0;
for (int i = 0; i < samples; i++) {
sensors_event_t e;
accel.getEvent(&e);
float total = sqrt(e.acceleration.x*e.acceleration.x +
e.acceleration.y*e.acceleration.y +
e.acceleration.z*e.acceleration.z);
diffs[i] = total - GRAVITY;
sum += diffs[i];
delayMicroseconds(100); // 100us 间隔,总体约 2ms
}
float mean = sum / samples;
float sqSum = 0;
for (int i = 0; i < samples; i++) {
float dev = diffs[i] - mean;
sqSum += dev * dev;
}
float rms = sqrt(sqSum / samples);
bool nowMoving = (rms > motionThreshold);
if (nowMoving) lastMotionTime = millis();
isMoving = nowMoving;
}
校准状态机(三段式)
在 loop() 中,我们根据当前相位推进流程:
// 在 loop() 中
if (resonanceTesting && calibrationMode) {
switch (calibPhase) {
case INIT_WAITING:
if (!isMoving) {
if (phaseStableStart == 0) phaseStableStart = millis();
if (millis() - phaseStableStart > STABLE_DELAY_MS) {
// 启动扫频文件
if (startPrint("SWEEP.GCO")) {
calibPhase = SWEEPING;
phaseStartTime = millis();
phaseStableStart = 0;
}
}
} else {
phaseStableStart = 0;
}
break;
case SWEEPING:
if (!isMoving) {
if (phaseStableStart == 0) phaseStableStart = millis();
else {
unsigned long stableTime = millis() - phaseStableStart;
// 稳定时间达到频率间隔阈值,记录该频率点
if (stableTime >= PER_FREQ_STABLE_DELAY && !freqEndProcessed) {
// 记录 lastFx/lastFy 和 lastAmpX/lastAmpY 到 calib 数组
// 清空历史平均,准备下一频率
freqEndProcessed = true;
}
// 若稳定时间超过扫频结束超时,则启动结束文件
if (stableTime >= SWEEP_END_TIMEOUT) {
if (startPrint("FINISH.GCO")) {
calibPhase = FINISH_WAITING;
phaseStartTime = millis();
}
}
}
} else {
phaseStableStart = 0;
freqEndProcessed = false;
}
break;
case FINISH_WAITING:
// 类似,等待归位完成
break;
case RESULT:
// 计算共振频率并发送 M593
break;
}
}
噪声基底抑制(提升信噪比)
记录静态噪声时,我们多次做 FFT 并平均功率谱,保存到 SPIFFS:
void saveNoiseFloorToSPIFFS(const String& axis) {
File f = SPIFFS.open((axis == "X") ? "/noise_x.bin" : "/noise_y.bin", FILE_WRITE);
if (!f) return;
float* data = (axis == "X") ? noiseFloorX : noiseFloorY;
f.write((uint8_t*)data, FFT_SAMPLES * sizeof(float));
f.close();
}
void processNoiseCalibration() {
// 采集 FFT_SAMPLES 个样本,做 FFT,累加功率谱到 accum
// 当 noiseSampleCount 达到 NOISE_FFT_TOTAL 后,平均并保存
}
Web 接口与数据交互
后端暴露了多个 REST API,以下是数据返回格式(/data 端点):
void handleData() {
StaticJsonDocument<256> doc;
doc["freqX"] = currentFreqX;
doc["freqY"] = currentFreqY;
doc["moving"] = isMoving;
doc["testing"] = resonanceTesting;
doc["calibration"] = calibrationMode;
doc["phaseProgress"] = phaseProgress; // 0~100
// 相位字符串
switch (calibPhase) {
case INIT_WAITING: doc["calibPhase"] = "INIT_WAITING"; break;
case SWEEPING: doc["calibPhase"] = "SWEEPING"; break;
// ...
}
sendJson(doc);
}
校准结果计算(幅频曲线插值)
在扫频结束后,我们从记录的所有 (频率, 幅值) 点中找出最大幅值,并做二次插值得到精确共振频率:
float calculateResonanceFreq(float* freqs, float* amps, int count) {
if (count < 3) return 0;
int maxIdx = 0;
for (int i = 1; i < count; i++) {
if (amps[i] > amps[maxIdx]) maxIdx = i;
}
// 二次插值
if (maxIdx > 0 && maxIdx < count - 1) {
float a = amps[maxIdx-1], b = amps[maxIdx], c = amps[maxIdx+1];
float denom = a - 2*b + c;
if (fabs(denom) > 1e-6) {
float offset = (a - c) / (2 * denom);
return freqs[maxIdx] + offset * (freqs[maxIdx+1] - freqs[maxIdx]);
}
}
return freqs[maxIdx];
}
使用指南
硬件连接
- ADXL345:VCC→3.3V,GND→GND,SDA→GPIO21,SCL→GPIO22。
- OLED(可选):SDA→GPIO21,SCL→GPIO22(与加速度计共享 I²C 总线)。
- 将 ESP32 靠近打印机,最好固定在热床上(或专用支架上)。
初次上电
- ESP32 默认开启 AP 模式,SSID 为
ResonanceMeasurer,密码12345678。 - 用手机/电脑连接该 WiFi,浏览器访问
192.168.4.1。 - 在页面中设置打印机 IP(打印机需在同一局域网且已开启 WiFi 模块,端口 80)。
- 点击“连接”测试通信,自动读取固件信息(需支持
[ESP800]命令)。 - 输入热床尺寸和安全 Z 高度(保存后生效)。
执行校准
- 选择测试轴(X 或 Y),设置扫频参数(建议起始 5~10Hz,结束 80~100Hz,步进 2Hz,移动距离 10~20mm,最大速度根据打印机能力)。
- 点击“一键校准”,系统自动完成全部流程。
- 校准过程中可观察实时频率和进度条,结束后自动发送 M593,频率结果会保存到 EEPROM。
其他功能
- 测试传感器:只打开加速度计监测,不驱动打印机,适合手动检查振动。
- 手动发送 G 代码:在文本框中输入任意 G 代码,可直接发送给打印机(用于调试)。
- 噪声基底记录:在打印机完全静止时点击“记录噪声基底”,可提高后续分析的抗干扰能力。
- 幅度阈值校准:自动设定信号有效的最小峰峰值,避免噪声被误判为共振。
总结
这套 ESP32 共振补偿校准仪,将复杂的振动检测与 Marlin 校准流程封装成简便的一键操作。它不仅具备专业仪器的精度,还拥有友好的 Web 界面和丰富的辅助功能,让普通创客也能轻松完成高速打印的共振调校。代码全部开源,可根据自身打印机型号微调参数,是 3D 打印爱好者提升打印品质的得力工具。
完整的项目代码可在 [TishonL/Marlin-Auto-InputShaper-ESP32: 一键自动校准 3D 打印机共振频率,基于 Marlin + ESP32 + ADXL345 实现 Input Shaping 自动调谐] 获取,欢迎 star 和 issue。

