eefocus_4087784 发表于 2026-10-20 15:23:10

零知派——STM32F103RBT6驱动ADXL345三轴加速度计软件I2C姿态监测系统

         在嵌入式开发中,姿态检测是无人机、平衡车、机器人等应用的基础能力。ADXL345作为一款经典的三轴数字加速度计,凭借13位分辨率、±16g量程和超低功耗,成为姿态监测场景的热门选择。本项目采用SoftWire软件I2C方案驱动ADXL345,ST7789显示屏保持板载硬件SPI1,两者互不占用,在零知派标准板上实现了一套完整的姿态监测系统

目录
一、系统接线部分
1.1 硬件清单1.2 接线方案表1.3 连接示意图1.4 具体接线图二、安装与使用部分
三、代码讲解部分
3.1 带总线保护的软件I2C驱动3.2 采集调度与传感器健康检查3.3 滤波、静止判定与姿态解算3.4 非阻塞水平校准状态机3.5 显示、按键与主循环调度四、项目结果演示
4.1 操作流程4.2 视频演示五、ADXL345技术讲解部分
5.1 工作原理5.2 I2C通信协议5.3 寄存器映射六、常见问题解答(FAQ)
Q1:为什么没有 Yaw?Q2:校准总是失败怎么办?Q3:数据抖得厉害?
项目概述      
      零知派标准板使用 STM32F103RBT6 作为MCU,(采用32位高性能ARM Cortex-M3核心,工作频率高达72MHz)。本项目用它的 SoftWire 软件 I2C 驱动 ADXL345,基于三轴加速度计解算Roll(横滚角)和Pitch(俯仰角),板载固定的硬件 SPI1 驱动 1.54 寸 ST7789 屏实时更新姿态角。通过按键交互,采用非阻塞状态机,约2秒自动完成零偏校准,带晃动/未放平检测

项目难点
      问题描述:SoftWire采用开漏输出模式,当总线没有上拉电阻时,set_scl(HIGH)会无限等待,导致程序卡死

解决方案:每次通信前先读两根线的电平,不是高就直接返回失败,不去调用 SoftWire。检测到 SDA 被拉低而 SCL 为高时,补最多 9 个时钟脉冲再发 STOP 释放总线

一、系统接线部分1.1 硬件清单
序号名称数量说明
1             零知派标准板(STM32F103RBT6)      1主控
2ADXL345 模块(如 GY-291)1三轴加速度计,I2C 模式
31.54 寸 ST7789 240×240 SPI 屏1显示
4轻触按键2BTN_NEXT、BTN_ACT
54.7kΩ 电阻2仅当模块没有自带上拉时使用
6杜邦线、面包板、USB 线若干   连接与供电

1.2 接线方案表      以下引脚分配严格依据 Config.h 中的定义:
ADXL345(SoftWire 软件 I2C)

模块引脚零知派引脚Config.h 宏说明
VCC3.3V—供电,按模块规格确认
GNDGND—共地
SCL29(PB10)ADXL_SCL_PIN软件 I2C 时钟
SDA30(PB11)    ADXL_SDA_PIN软件 I2C 数据
CS3.3V—接高电平选择 I2C 模式
SDO / ALT ADDRESS       GNDADXL_I2C_ADDR=0x53    接 3.3V 则地址为 0x1D
INT1 / INT2悬空—本项目未使用
按键(内部上拉,另一端接 GND)

按键零知派引脚Config.h 宏
BTN_NEXT         D0                     BTN_NEXT_PIN   
BTN_ACTD1BTN_ACT_PIN
ST7789(板载硬件 SPI1,引脚固定,直插TFT引脚无需单独接线)

模块引脚零知派引脚Config.h 宏说明
GND / VCC         GND / 3.3V            —供电
SCLD13—(硬件 SPI1 SCK)由 SPI 库决定
SDAD11—(硬件 SPI1 MOSI)         由 SPI 库决定         
RESD4TFT_RST复位
DCD2TFT_DC数据/命令选择
CSD10TFT_CS片选
ADXL345 模块 SDO 接GND 或者默认对应地址 0x53,若SDO接VCC则地址变为0x1D,需同步修改ADXL_I2C_ADDR为0x1D

1.3 连接示意图      屏幕的 SCL/SDA 是 SPI 的时钟和数据,不要和 ADXL345 的 SCL/SDA 混淆
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXB06AE6ESABAj_JDAPM0932.png先接GND再接VCC 避免热插拔损坏传感器

1.4 具体接线图      模块板面上通常印有 X/Y 轴箭头,水平放置时 Z 轴朝上应读到约 +1g。装反会导致 Roll 差 180°
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXB0WADOO0ACEZESpaKSw661.png
二、安装与使用部分2.1 开源平台-输入"ADXL345"并搜索-代码下载自动打开
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBzuAOsMWAAniFDRjhUk881.png2.2 连接-验证-上传
https://file1.elecfans.com/web3/M00/80/71/wKgZPGrXBzOAZD_zAA2pNQSflJ0016.png2.3 调试-串口监视器
https://file1.elecfans.com/web3/M00/80/71/wKgZPGrXByyAIMPdAA3UrCv_pb4082.png
三、代码讲解部分工程结构与参数总览

文件作用
ADXL345_Attitude_Monitor_I2C.ino   主程序:初始化、按键调度、显示刷新、FireWater 输出      
Config.h引脚、滤波、校准、显示参数集中配置
ADXL345_SoftI2C.h/.cppADXL345 软件 I2C 驱动(封装 SoftWire)
AttitudeMonitor.h/.cpp采集、滤波、校准状态机、姿态解算、趋势历史
DisplayManager.h/.cppST7789 显示,数据页、趋势页、校准进度、掉线提示
ButtonManager.h按键中断、消抖、长短按识别
系统流程图
https://file1.elecfans.com/web3/M00/80/71/wKgZPGrXByKAdj-CABSrci-wW5E359.png
3.1 带总线保护的软件I2C驱动bool ADXL345_SoftI2C::linesHigh() {
return digitalRead(_scl) && digitalRead(_sda);
}

bool ADXL345_SoftI2C::ready() {
return _started && linesHigh();   // 没起来或总线被拉低/无上拉,一律不碰SoftWire
}

bool ADXL345_SoftI2C::begin() {
if (!_started) {                  // 先用普通输入看总线状态,不碰SoftWire
    pinMode(_scl, INPUT);
    pinMode(_sda, INPUT);
    delay(2);
}
if (!digitalRead(_sda) && digitalRead(_scl)) busRecover();
if (!linesHigh()) return false;   // 无上拉电阻或被短路到地

if (!_started) { _wire.begin(); _started = true; }
return modifyBits(ADXL345_REG_POWER_CTL, 0x08, 0x08);// 进入测量模式,同时验证器件应答
}SoftWire 的 begin() 会把引脚设为开漏输出,并在 set_scl(HIGH) 后等待 SCL 变高。总线没有上拉时会卡死,所以我们先把两脚设成普通输入读电平,确认总线正常再调用 _wire.begin()
总线恢复
      SDA 被从机拉低时,主机补最多 9 个 SCL 脉冲让从机把剩下的位移完,再发 STOP
void ADXL345_SoftI2C::busRecover() {
// SDA被从机拉低时:9个SCL脉冲 + STOP
for (uint8_t i = 0; i < 9; i++) {
    digitalWrite(_scl, LOW);
    delayMicroseconds(5);
    digitalWrite(_scl, HIGH);
    delayMicroseconds(5);
    if (digitalRead(_sda)) break;// 从机已释放SDA
}
// ... STOP信号
}寄存器读写:读寄存器就是“写寄存器地址,再读 n 字节”
bool ADXL345_SoftI2C::readRegs(uint8_t reg, uint8_t *buf, uint8_t n) {
if (!ready()) return false;
_wire.beginTransmission(_addr);
_wire.write(reg);
if (_wire.endTransmission() != 0) return false;      // 0 = SUCCESS
if (_wire.requestFrom(_addr, (int)n) != n) return false;
for (uint8_t i = 0; i < n; i++) buf = _wire.read();
return true;
}
[*]写操作先发设备地址+W,再发寄存器地址,最后发数据
[*]读操作先写寄存器地址,再Re-Start,发地址+R读取数据
通信失败时 readAccel() 返回 (-1,-1,-1),上层统一按通信异常处理:
<pre><code class="language-cpp">*x = (int16_t)(((uint16_t)b << 8) | b);   // 先读进数组再拼,避免求值顺序未定义</code></pre><figure class="image"></figure>https://file1.elecfans.com/web2/M00/03/FB/poYBAGDYdXCAWkKMAAAAK8RNs4s030.png
3.2 采集调度与传感器健康检查bool AttitudeMonitor::configure() {
if (!adxl.begin()) return false;
if (adxl.getDeviceID() != ADXL_DEVID) return false;   // 0xE5

adxl.setRange(ADXL_RANGE_G);      // ±4g
adxl.setFullRes(true);            // 全分辨率:恒定3.9mg/LSB
adxl.clearOffsets();                // 硬件偏移清零,统一使用软件偏移
adxl.setRateCode(ADXL_BW_CODE);   // 0x0A = 100Hz,LOW_POWER位=0噪声更低
return adxl.getRateCode() == ADXL_BW_CODE;   // 读回确认
}配置完后读回 BW_RATE 确认,既验证写入,也验证通信。
bool AttitudeMonitor::update() {
...
if (now - lastHealth >= ADXL_HEALTH_MS) { lastHealth = now; healthCheck(); }
if (!sensorOk) return false;
if (now - lastPoll < ADXL_POLL_MS) return false;   // 轮询间隔
lastPoll = now;
if (!adxl.dataReady()) return false;               // 没有新数据就不读

int xr, yr, zr;
adxl.readAccel(&xr, &yr, &zr);
if (xr == -1 && yr == -1 && zr == -1) {            // 通信失败
    if (++badCount >= 20) sensorOk = false;
    return false;
}
badCount = 0;
processSample(xr * ADXL_FULLRES_G_LSB, yr * ADXL_FULLRES_G_LSB, zr * ADXL_FULLRES_G_LSB);
return true;
}
三个机制:

机制做法作用
新样本检测轮询 DATA_READY,每 2ms 一次每次读到的都是新数据
连续错误判定读到 (-1,-1,-1) 累计 20 次判掉线防止偶发错误误报
健康检查每秒读 DEVID,掉线后调用 adxl.begin() 恢复总线,再重新 configure()接回自动恢复,最长约 2 秒

3.3 滤波、静止判定与姿态解算maX = gx; maY = gy; maZ = gz;
maIdx = (maIdx + 1) % MOVING_AVG_SIZE;

float sx = 0, sy = 0, sz = 0;
float mnx = maX, mxx = maX, mny = maY, mxy = maY, mnz = maZ, mxz = maZ;
for (int i = 0; i < MOVING_AVG_SIZE; i++) {
    sx += maX; sy += maY; sz += maZ;
    if (maX < mnx) mnx = maX;
    if (maX > mxx) mxx = maX;
    if (maY < mny) mny = maY;
    if (maY > mxy) mxy = maY;
    if (maZ < mnz) mnz = maZ;
    if (maZ > mxz) mxz = maZ;
}
fx = updateKalman(kx, sx / MOVING_AVG_SIZE);
fy = updateKalman(ky, sy / MOVING_AVG_SIZE);
fz = updateKalman(kz, sz / MOVING_AVG_SIZE);

// 静止判定:窗口极差小于阈值,且连续满足 STABLE_HOLD_SAMPLES 个样本;一旦超限立即判MOVING
if ((mxx - mnx) < STABLE_RANGE_G && (mxy - mny) < STABLE_RANGE_G && (mxz - mnz) < STABLE_RANGE_G) {
    if (stableCount < 255) stableCount++;
} else {
    stableCount = 0;
}
stable = (stableCount >= STABLE_HOLD_SAMPLES);

// 姿态角:Roll绕X轴(±180°),Pitch绕Y轴(±90°)
float roll= atan2f(fy, fz) * RAD2DEG_F;
float pitch = atan2f(-fx, sqrtf(fy * fy + fz * fz)) * RAD2DEG_F;

bool first = !angleValid;
if (first || fabsf(roll- rollOut)>= ANGLE_DEADBAND_DEG) rollOut= roll;1. 首个样本预填充。 缓冲区和卡尔曼初值如果是 0,开机后前几百毫秒会有一段“爬升”。代码用第一个样本把窗口填满:
if (!filterPrimed) {                              // 首个样本填满缓冲区,避免启动爬升
    for (int i = 0; i < MOVING_AVG_SIZE; i++) { maX = gx; maY = gy; maZ = gz; }
    initKalman(kx, KALMAN_Q, KALMAN_R, gx);
    initKalman(ky, KALMAN_Q, KALMAN_R, gy);
    initKalman(kz, KALMAN_Q, KALMAN_R, gz);
    maIdx = 0;
    stableCount = 0;
    filterPrimed = true;
}2. 一维卡尔曼。
float AttitudeMonitor::updateKalman(KalmanFilter &kf, float m) {
kf.p = kf.p + kf.q;                      // 预测
kf.k = kf.p / (kf.p + kf.r);             // 增益
kf.x = kf.x + kf.k * (m - kf.x);         // 更新
kf.p = (1.0f - kf.k) * kf.p;
return kf.x;
}按 Q=0.01、R=0.1 算,稳态增益约 0.27(理论估算)。Q 越大越信测量(响应快、噪声大),R 越大越信预测(更平滑、更迟钝)。
3. 静止判定。 判断窗口里最近 8 个样本三轴极差是否小于 0.03g,并要求连续满足 10 个样本才判 STABLE,一旦超限立即判 MOVING。这样状态不会在阈值附近来回闪。
4. 角度死区。 变化小于 0.05° 就保持原值,抑制显示末位跳字。
5. 姿态解算。 静止时加速度计测到的是重力向量,用三个分量算倾角:Roll 绕 X 轴,用 atan2(ay, az),范围 ±180°;Pitch 绕 Y 轴,用 atan2(-ax, sqrt(ay²+az²)),范围 ±90°。用 atan2 而不是 atan,翻转超过 90° 后 Roll 仍然正确。

参数影响
MOVING_AVG_SIZE越大越平滑,延迟越大(窗口 8 样本,约 35ms 群延迟)   
KALMAN_Q / KALMAN_R比值决定平滑与响应的平衡
ANGLE_DEADBAND_DEG    越大越稳,但小动作会被吞掉
STABLE_RANGE_G越小越难判静止

3.4 非阻塞水平校准状态机<blockquote><p>      校准过程分为采样和判定两个阶段。采样期间累计200个样本(约2秒@100Hz),同时记录每个轴的最大最小值用于晃动检测</p>状态机: CAL_IDLE → CAL_RUNNING → CAL_OK / CAL_FAIL_MOVED / CAL_FAIL_TILT,失败提示 3 秒后恢复 IDLE,旧校准值保留

状态含义进入条件离开条件
CAL_IDLE没有在校准上电 / 结果提示到期 / 清除校准 / 传感器掉线 / 进入睡眠调用 startCalibration()
CAL_RUNNING采样中startCalibration()采满 CAL_SAMPLES 个样本
CAL_OK刚校准成功calFinish() 通过两项检查3 秒后回 IDLE
CAL_FAIL_MOVED失败:采样期间在晃动任一轴极差超限3 秒后回 IDLE
CAL_FAIL_TILT失败:没放平平均值偏离水平超限3 秒后回 IDLE
启动校准
// ─── 校准 ────────────────────────────────────────────────────────────────────
void AttitudeMonitor::startCalibration() {
if (!sensorOk || calState == CAL_RUNNING) return;
calCount = 0;
calSumX = calSumY = calSumZ = 0;
calMinX = calMinY = calMinZ =100.0f;
calMaxX = calMaxY = calMaxZ = -100.0f;
calState = CAL_RUNNING;
DEBUG_PRINTLN(" start - keep the board level and still");
}最小值/最大值用 ±100g 做“哨兵初值”,量程只有 ±4g,第一个样本一进来就会把它们覆盖。
采样累加
void AttitudeMonitor::processSample(float gx, float gy, float gz) {
if (calState == CAL_RUNNING) calAccumulate(gx, gy, gz);

gx -= xOff; gy -= yOff; gz -= zOff;               // 软件偏移(未校准时为0)

if (!filterPrimed) {                              // 首个样本填满缓冲区,避免启动爬升
    for (int i = 0; i < MOVING_AVG_SIZE; i++) { maX = gx; maY = gy; maZ = gz; }
    initKalman(kx, KALMAN_Q, KALMAN_R, gx);
    initKalman(ky, KALMAN_Q, KALMAN_R, gy);
    initKalman(kz, KALMAN_Q, KALMAN_R, gz);
    maIdx = 0;
    stableCount = 0;
    filterPrimed = true;
}

maX = gx; maY = gy; maZ = gz;
maIdx = (maIdx + 1) % MOVING_AVG_SIZE;

float sx = 0, sy = 0, sz = 0;
float mnx = maX, mxx = maX, mny = maY, mxy = maY, mnz = maZ, mxz = maZ;
for (int i = 0; i < MOVING_AVG_SIZE; i++) {
    sx += maX; sy += maY; sz += maZ;
    if (maX < mnx) mnx = maX;
    if (maX > mxx) mxx = maX;
    if (maY < mny) mny = maY;
    if (maY > mxy) mxy = maY;
    if (maZ < mnz) mnz = maZ;
    if (maZ > mxz) mxz = maZ;
}
fx = updateKalman(kx, sx / MOVING_AVG_SIZE);
fy = updateKalman(ky, sy / MOVING_AVG_SIZE);
fz = updateKalman(kz, sz / MOVING_AVG_SIZE);

// 静止判定:窗口极差小于阈值,且连续满足 STABLE_HOLD_SAMPLES 个样本;一旦超限立即判MOVING
if ((mxx - mnx) < STABLE_RANGE_G && (mxy - mny) < STABLE_RANGE_G && (mxz - mnz) < STABLE_RANGE_G) {
    if (stableCount < 255) stableCount++;
} else {
    stableCount = 0;
}
stable = (stableCount >= STABLE_HOLD_SAMPLES);

// 姿态角:Roll绕X轴(±180°),Pitch绕Y轴(±90°)
float roll= atan2f(fy, fz) * RAD2DEG_F;
float pitch = atan2f(-fx, sqrtf(fy * fy + fz * fz)) * RAD2DEG_F;

bool first = !angleValid;
if (first || fabsf(roll- rollOut)>= ANGLE_DEADBAND_DEG) rollOut= roll;
if (first || fabsf(pitch - pitchOut) >= ANGLE_DEADBAND_DEG) pitchOut = pitch;
if (first) {
    angleValid = true;
    for (int i = 0; i < TREND_HISTORY_LEN; i++) { rollHist = rollOut; pitchHist = pitchOut; }
    histHead = 0;
    pushDiv = 0;
}

if (++pushDiv >= TREND_PUSH_DIV) {
    pushDiv = 0;
    pushHistory();
}
}

void AttitudeMonitor::calAccumulate(float gx, float gy, float gz) {
calSumX += gx; calSumY += gy; calSumZ += gz;
if (gx < calMinX) calMinX = gx;
if (gx > calMaxX) calMaxX = gx;
if (gy < calMinY) calMinY = gy;
if (gy > calMaxY) calMaxY = gy;
if (gz < calMinZ) calMinZ = gz;
if (gz > calMaxZ) calMaxZ = gz;
if (++calCount >= CAL_SAMPLES) calFinish();
}

[*]采样节拍由 ADXL345 的 DATA_READY 决定(100Hz),200 个样本约 2 秒
[*]累计的是原始读数,所以重复校准时不会把上一次的偏移混进来
结算与保留旧值
      xOff/yOff/zOff 和 calibrated 这四个变量只在两道检查都通过之后才赋值,失败分支都在赋值之前 return
void AttitudeMonitor::calFinish() {
float n= (float)calCount;
float mx = calSumX / n, my = calSumY / n, mz = calSumZ / n;

calResultTime = millis();                                  // 先记时,用于提示到期

// 第一关:采样期间晃没晃?
if ((calMaxX - calMinX) > CAL_MAX_RANGE_G ||
      (calMaxY - calMinY) > CAL_MAX_RANGE_G ||
      (calMaxZ - calMinZ) > CAL_MAX_RANGE_G) {
    calState = CAL_FAIL_MOVED;
    return;                                                // ← 提前返回,不改任何偏移
}
// 第二关:板子放平了吗?
if (fabsf(mx) > CAL_MAX_TILT_G || fabsf(my) > CAL_MAX_TILT_G ||
      fabsf(mz - 1.0f) > CAL_MAX_TILT_G) {
    calState = CAL_FAIL_TILT;
    return;                                                // ← 提前返回,不改任何偏移
}

// 两关都过,才提交新偏移
xOff = mx;yOff = my;zOff = mz - 1.0f;
calibrated = true;
calState = CAL_OK;
resetFilter();
}

[*]先判晃动,再判没放平。因为晃动时平均值没有意义,如果先判倾斜,会给出误导性的提示
手册给出的零 g 偏移最坏情况,X/Y 约 ±150mg,Z 约 ±250mg。0.20g 比 Z 轴最坏情况还严,典型器件(Z 约 ±80mg)没问题,但极端个体平放也可能被判 NOT LEVEL。遇到这种情况,把 CAL_MAX_TILT_G 放宽到 0.30
验证方法

操作预期现象
放平不动,短按 BTN_ACT 或发 c进度条走满约 2 秒,底部显示 CAL OK 和三轴偏移,水平时角度接近 0
校准过程中晃动板子底部红字 BOARD MOVED,旧偏移保留
板子倾斜 30° 后校准底部红字 NOT LEVEL,旧偏移保留
校准中按其他键或发 c/r无反应,串口提示 ignored
校准中拔掉 SDA屏幕 SENSOR ERROR,接回后自动恢复,校准不会卡死

3.5 显示、按键与主循环调度1. 按键中断消抖与长短按(ButtonManager)。
void onInterrupt() {
uint32_t now = millis();
bool pressed = (digitalRead(_pin) == LOW);
if (now - _lastEdge < BTN_DEBOUNCE_MS) { _lastEdge = now; return; }   // 消抖
_lastEdge = now;
if (pressed) { _pressStart = now; _isPressed = true; }
else {
    if (!_isPressed) return;                     // 没有按下就先收到释放:噪声,丢弃
    _isPressed = false;
    if (now - _pressStart >= BTN_LONGPRESS_MS) _pendingLong = true;
    else _pendingShort = true;
}
}
CHANGE 中断同时捕获按下和松开,松开时按持续时间判定长短按。ISR 里只记时间戳,不做耗时操作,真正的处理放在 loop() 里消费(consumeShort/consumeLong)。变量用 volatile,因为 ISR 和主循环都会读写。
2. 按键功能映射。

按键动作功能
BTN_NEXT短按数据页 ↔ 趋势页
BTN_NEXT         长按         清除校准(原串口命令 r)
BTN_ACT短按水平校准(原命令 c)
BTN_ACT长按屏幕睡眠 + ADXL345 待机,任意键唤醒      
校准进行中,其他按键会被屏蔽(clearPending()),避免打乱进度覆盖层。原命令 s(显示状态)则常驻数据页底部。
3. 屏幕局部刷新。 每个显示值都有一个字符串缓存,内容没变就不重画:
<pre><code class="language-cpp">if (_cRoll != sr) { drawValue(4, ANG_VAL_Y, 112, ANG_VAL_H, 3, sr, cAccent()); _cRoll = sr; }</code></pre><p></p>这样减少 SPI 流量,也减少闪烁。校准进度条只在百分比变化时重画。小数用 fmtAngle 把 -0.0 归零,避免静止时符号来回跳。4. 主循环调度,不阻塞。
      全程用 millis() 计时,没有 delay(),所以采样、显示、串口输出互不卡住。FireWater 一行是 att:roll,pitch\n
void loop() {
uint32_t now = millis();

// ── 休眠:任意键唤醒 ──────────────────────────────────────────────────────
if (appState == APP_SLEEP) {
    bool wake = btnNext.consumeShort() || btnNext.consumeLong() ||
                btnAct.consumeShort()|| btnAct.consumeLong();
    if (wake) {
      btnNext.clearPending();
      btnAct.clearPending();
      DEBUG_PRINTLN(" wake");
      appState = stateBeforeSleep;
      display.wakeScreen();
      Att.wake();
      gSensorOk = Att.isSensorOk();
      display.redrawCurrentPage(gSensorOk);
      if (!gSensorOk) display.drawSensorError();
      tDisplay = tFw = now;
    }
    return;
}

// ── BTN_ACT 长按:屏幕点点taptap安卓睡眠 + ADXL345待机(校准中也允许进入)──────────
if (btnAct.consumeLong()) {
    DEBUG_PRINTLN(" sleep (ST7789 sleep-in + ADXL345 standby)");
    stateBeforeSleep = appState;
    appState = APP_SLEEP;
    Att.sleep();
    display.sleepScreen();
    btnNext.clearPending();
    btnAct.clearPending();
    return;
}

// ── 校准进行中:屏蔽翻页/重复校准/清除,避免打乱进度覆盖层 ────────────────
if (Att.isCalibrating()) {
    btnNext.clearPending();
    btnAct.clearPending();
} else {
    // BTN_NEXT 短按:数据页 ↔ 趋势图页(掉线提示画面下不翻页)
    if (btnNext.consumeShort() && gSensorOk) {
      display.togglePage(true);
      DEBUG_PRINTLN(" page toggle");
    }
    // BTN_NEXT 长按:清除校准数据(原命令 r)
    if (btnNext.consumeLong()) {
      Att.resetCalibration();
    }
    // BTN_ACT 短按:水平校准(原命令 c),非阻塞,进度显示在屏幕上
    if (btnAct.consumeShort() && gSensorOk) {
      Att.startCalibration();
    }
}

// ── 采样:高频轮询,只有ADXL345有新数据时才处理 ────────────────────────────
Att.update();

bool sensorOk = Att.isSensorOk();
if (sensorOk != gSensorOk) {
    gSensorOk = sensorOk;
    if (!sensorOk) {
      DEBUG_PRINTLN(" sensor lost");
      display.drawSensorError();
    } else {
      DEBUG_PRINTLN(" sensor recovered");
      display.redrawCurrentPage(true);
      tDisplay = now;
    }
}

// ── 显示刷新 ──────────────────────────────────────────────────────────────
if (gSensorOk && (now - tDisplay >= DISPLAY_REFRESH_MS)) {
    tDisplay = now;
    AttSnapshot snap;
    buildSnapshot(snap);
    display.drawDynamic(snap);
}

// ── VOFA+ FireWater:"标签:通道1,通道2\n" ─────────────────────────────────
if (gSensorOk && (now - tFw >= FW_OUTPUT_MS)) {
    tFw = now;
    AttitudeData d = Att.getData();
    Serial.print("att:");
    Serial.print(d.roll, 2);
    Serial.print(",");
    Serial.print(d.pitch, 2);
#if FW_OUTPUT_ACCEL
    Serial.print(",");
    Serial.print(d.ax, 3);
    Serial.print(",");
    Serial.print(d.ay, 3);
    Serial.print(",");
    Serial.print(d.az, 3);
#endif
    Serial.print("\n");
}
}
调试文本不要带冒号和逗号,否则会被 VOFA+ 当成数据解析。下面是整个系统的运行流程,上电初始化一次,之后 loop() 里五个动作循环执行:
https://file1.elecfans.com/web3/M00/80/71/wKgZPGrXBumARv7qAAU1O_Nk3cU382.png
四、项目结果演示4.1 操作流程①上电启动。 屏幕先显示顶栏,传感器初始化成功后进入数据页,顶栏右侧圆点为绿色。
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBt-AZbzZADGSl--j-O4795.png         板子放平不动,屏幕显示 Roll、Pitch、ax/ay/az,状态行为 STABLE。底部显示 UNCALIBRATED。

②水平校准。 把板子放在水平桌面上,短按 BTN_ACT。屏幕出现 CALIBRATING 和进度条,约 2 秒完成。 长按 BTN_NEXT,清除校准底部变回 UNCALIBRATED。
https://file1.elecfans.com/web3/M00/80/70/wKgZPGrXBtaAFTkXABOmPng97Vs749.png      成功后底部显示 CAL OK 和三轴偏移(单位 mg),此时水平时 Roll、Pitch 应接近 0。校准期间故意晃动,会提示 BOARD MOVED,没放平则提示 NOT LEVEL。
https://file1.elecfans.com/web3/M00/80/70/wKgZPGrXBsuACURzAAs3iyfmYvw440.png③倾斜测试。 把板子向不同方向倾斜,Roll、Pitch 随之变化,晃动时状态变成 MOVING,停下约 100ms 后变回 STABLE。
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBsKAID19AAa4Fm6zHWQ328.png      上位机波形:打开 VOFA+,数据引擎选 FireWater,波特率 115200,即可看到 Roll、Pitch 两路波形。

④趋势页。 短按 BTN_NEXT 切换趋势页,黄线是 Roll,青线是 Pitch,虚线是 0°,曲线覆盖最近约 12 秒。
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBrmAeyr3ABVN8tc6SJQ366.png⑤睡眠与唤醒。 长按 BTN_ACT,屏幕进入点点taptap安卓级睡眠,ADXL345 进入待机;按任意键唤醒。
⑥掉线恢复。 拔掉 SDA 线,屏幕提示 SENSOR ERROR,插回后自动恢复。
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBqGAOG-VAB0XDtkoIJ8783.png
4.2 视频演示https://live.csdn.net/v/549078?spm=1001.2014.3001.5501
用零知派标准板(STM32F103RBT6)驱动 ADXL345 三轴加速度计,演示 Roll/Pitch 实时显示、水平校准(含晃动和没放平两种失败提示)、趋势曲线、VOFA+ 波形,以及拔线后的掉线报错和自动恢复

五、ADXL345技术讲解部分      ADXL345是一款基于MEMS技术的三轴加速度计,采用电容式传感结构。当传感器沿检测轴加速时,内部质量块位移改变差分电容,经ADC转换为数字信号输出
5.1 工作原理
MEMS 差分电容:点点taptap安卓内部是硅片上的多晶硅微结构,弹簧悬着一块质量块,质量块上的活动极板和固定极板组成差分电容。
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBoOABd9vAACFtLKq6S0711.png加速度让质量块偏移,两侧电容一增一减,经解调得到与加速度成正比的输出,并能区分方向。它既能测运动产生的动态加速度,也能测重力这样的静态加速度,所以能用来测倾角。
①量程、全分辨率与灵敏度
      ADXL345 量程可选 ±2/4/8/16g。全分辨率模式下分辨率随量程增加(±16g 时 13 位),每 LSB 保持约 3.9mg,典型灵敏度 256 LSB/g。固定 10 位模式下,每 LSB 对应的 g 数随量程变化

配置对应 DATA_FORMAT 位本项目
量程Range(D1:D0):00=±2g,01=±4g,10=±8g,11=±16g             01         
全分辨率            FULL_RES(D3)=11
右对齐Justify(D2)=00
②输出速率、带宽与噪声
BW_RATE(0x2C)低 4 位选择输出速率,带宽 = ODR/2;bit4 是 LOW_POWER,置 1 功耗低但噪声更大。速率码与 ODR 的对应:

速率码ODR(Hz)带宽(Hz)
0x095025
0x0A            100               50            
0x0B200100
0x0C400200
默认值就是 0x0A(100Hz)。很多库里的 BW_100 指的是带宽 100Hz,也就是 ODR 200Hz
噪声: 100Hz 时 X、Y 约 0.75 LSB rms,Z 约 1.1 LSB rms。100Hz 以上,ODR 每翻一倍噪声约增加 √2 倍;低功耗模式噪声更大。按 2.9mg rms 估算,单次测量的倾角噪声约 0.17°
https://file1.elecfans.com/web3/M00/80/70/wKgZPGrXBmmAPyQWAADucS4-Ejw141.png通信速度限制: 用 100kHz I2C 时,最高输出速率约 200Hz,400kHz 约 800Hz。选 ODR 要和通信速度匹配,否则会丢样本或多噪声

5.2 I2C通信协议      I2C总线使用开漏输出结构:设备只能将总线拉低,不能主动驱动高电平,总线空闲时由上拉电阻拉至高电平
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBkuAcyFsAAA9xIsS7V8542.png引脚设为开漏输出,高电平靠外部上拉,所以必须有上拉电阻。SDO/ALT ADDRESS 引脚决定地址:接高电平地址是 0x1D,接地是 0x53
软件 I2C 原理
      发送设备地址加写位、发送寄存器地址,然后再发设备地址加读位,读取数据
https://file1.elecfans.com/web3/M00/80/70/wKgZPGrXBhuASs7zAAChWMgbutA841.png起始条件(SCL 高时 SDA 下降)→ 8 位数据加 ACK → 停止条件(SCL 高时 SDA 上升)
https://file1.elecfans.com/web3/M00/80/78/wKgZO2rXBhSAEw8oAABNeE65weU143.png数据手册建议三轴数据用多字节读取,避免读到更新到一半的数据

5.3 寄存器映射      本项目实际用到 7 组寄存器:DEVID、OFSX/Y/Z、BW_RATE、POWER_CTL、INT_SOURCE、DATA_FORMAT、DATA 六字节
https://file1.elecfans.com/web3/M00/80/70/wKgZPGrXBguAesOFABKoOmr13IM793.png①事件来源
      ADXL345 按输出速率不断刷新数据寄存器。主控如果在寄存器正在更新时去读,可能读到新旧混杂的数据
https://file1.elecfans.com/web3/M00/80/70/wKgZPGrXBgGAC8jlAACghtASAlY274.pngDATA_READY 与数据一致性:INT_SOURCE(0x30)的 bit7 是 DATA_READY:有新数据时置位,不管 INT_ENABLE 是否使能都会置位,读数据寄存器时清零
DATA_READY 的规则

规则说明
置位有新数据就置位
与 INT_ENABLE 无关INT_ENABLE/INT_MAP 只管 INT 引脚输出,DATA_READY 位本身一直有效。所以不接 INT 引脚也能靠读寄存器判断
清零读数据寄存器 0x32~0x37 时清零
复位值INT_SOURCE 复位值是 0x02,只取 D7 判断
同步到 DATA_READY,或者用 FIFO 流模式,避免读到正在更新的数据。本项目采用前者;后续可以升级为 FIFO 流模式(32 级),降低主控负担
②硬件偏移
      8 位补码,每 LSB 是 15.6mg,自动加到输出数据上。本项目启动时清零,偏移改在软件里扣除

偏移与校准(对应 OFSX/Y/Z 0x1E~0x20)
https://file1.elecfans.com/web3/M00/80/70/wKgZPGrXBfiAUcpPAADqEcAKA-0736.png单点校准: 让一个轴(通常是 Z)处在 1g 重力场,另外两个轴在 0g,取一批样本平均:

[*]X_actual = X_meas − X0g,Y_actual = Y_meas − Y0g
[*]Z0g = Z+1g − S_Z,Z_actual = Z_meas − Z0g(S_Z 是理想灵敏度)
软件偏移:在 processSample() 里直接减,精度不受限,可随时清除

六、常见问题解答(FAQ)Q1:为什么没有 Yaw?      A:加速度计靠重力测姿态,绕重力轴旋转时重力投影不变,所以测不出 Yaw,需要陀螺仪或磁力计
Q2:校准总是失败怎么办?
       A:提示 BOARD MOVED 说明采样期间板子在动、NOT LEVEL说明板子没放平,平均值偏离水平超过 0.20g。确保校准期间板子平稳放置,不要触碰。如果桌面本身倾斜较大,可适当增大CAL_MAX_TILT_G
Q3:数据抖得厉害?
      A:先检查速率码是不是 0x0A,再用 MOVING_AVG_SIZE、KALMAN_Q/R、ANGLE_DEADBAND_DEG 调。调大平均窗口或 R 会更稳,但响应更慢。

项目资源整合
ADXL345 数据手册:      ADXL345 (Rev. G)
VOFA+ 上位机:                VOFA+官网

页: [1]
查看完整版本: 零知派——STM32F103RBT6驱动ADXL345三轴加速度计软件I2C姿态监测系统

Baidu
map