零知派——STM32驱动BNO086九轴IMU的菜单交互运动姿态仪表盘
随着可穿戴设备与人机交互产品对姿态感知需求的不断提升,九轴IMU传感器在头戴设备、运动手环、机器人导航等场景中的应用日益广泛。目前市面上不少DIY级姿态监测项目的显示界面停留在简单数值罗列,既不直观也缺乏面向真实使用场景的容错设计。本项目为嵌入式姿态监测提供了一种界面友好、具备软件总线自愈能力、且数据可信度有明确视觉提示的低成本解决方案
目录
Q2:Step和Stability拆成独立页面后问题消失,混在一起为何不行?
Q3:RAW Readings页面一直没有数据,但I2C能扫到地址?
项目概述
本项目基于零知派标准板(主控STM32F103RBT6),驱动BNO086九轴传感器(加速度计+陀螺仪+磁力计+内置ARM Cortex-M0传感器融合核心),配合ST7789 240×240 SPI显示屏,做成一个类似智能手表的运动姿态仪表盘,与上位机VOFA+联调,实时输出姿态曲线。双按键长按/短按组合实现菜单导航、页面切换、Tare置零、黑屏休眠/唤醒
项目难点
问题描述:重新烧录代码后系统卡死,串口只打印到" 按键中断已挂载 "就没有下文,只有给BNO086单独断电才能恢复
解决方案: 在set_scl()中增加有限次数等待,超时后放弃并置位超时标志;新增i2cBusRecovery()标准总线恢复流程——手动发送9个SCL时钟脉冲,强制让卡住的从机释放总线
一、系统接线部分
1.1 硬件清单
| 组件 | 型号/规格 | 数量 | 说明 |
|---|---|---|---|
| 主控板 | 零知派标准板(STM32F103RBT6) | 1 | 核心控制器 |
| IMU传感器 | BNO086九轴姿态传感器 | 1 | I2C接口,地址0x4B |
| 显示屏 | ST7789 240×240 SPI TFT | 1 | 四线SPI接口 |
| 按键 | 轻触开关 | 2 | NEXT和ACT按键 |
| 电源 | USB 5V / 3.3V LDO | 1 | 系统供电 |
| 连接线 | 杜邦线/排线 | 若干 | 硬件连接 |
1.2 接线方案表
所有引脚定义以
Config.h代码为准,请严格对照代码定义接线,不得随意更改
| 外设 | 引脚 | 零知派STM32F103引脚 | 功能说明 |
|---|---|---|---|
| BNO086 I2C | SCL | A5 | I2C时钟线(软件I2C) |
| SDA | A4 | I2C数据线(软件I2C) | |
| INT | D5 | 中断引脚(硬件连接后启用) | |
| RST | D6 | 硬件复位引脚(解决断电依赖) | |
| ST7789 SPI | CS | D10 | 片选信号 |
| DC | D2 | 数据/命令选择 | |
| RST | D4 | 显示屏硬件复位 | |
| SCK | D13 | SPI时钟 | |
| MOSI | D11 | SPI数据 | |
| 按键 | NEXT | D0 | 短按:菜单下移/页面切换;长按:休眠 |
| ACT | D1 | 短按:确认进入/Tare置零;长按:返回/关于 | |
| 调试 | TX | D7 | 串口发送(Serial) |
| RX | D8 | 串口接收(Serial) |
ST7789接线提示:ST7789直插零知派标准板的TFT扩展引脚,无需单独接线
1.3 连接示意图

BNO086的INT和RST必须接,硬件RST是解决"必须断电才能恢复"问题的根治方案,INT用于中断模式提高通信可靠性
1.4 具体接线图
两个按键各接一个GPIO(D0和D1)到GND,利用内部上拉,按下为低电平

二、安装与使用部分
2.1 开源平台-输入"BNO086"并搜索-代码下载自动打开

2.2 连接-验证-上传

2.3 调试-串口监视器

三、代码讲解部分
本项目代码采用模块化设计,主要包含以下核心模块:Config全局引脚定义、IMUManager传感器驱动封装、DisplayManager显示封装、ButtonManager按键中断消抖、BNO086_MotionDash主程序应用状态机
3.1 按键长短按区分
按键模块是整个交互系统的基础。使用CHANGE中断同时捕获按下和松开两个边沿:
void onInterrupt() {
uint32_t now = millis();
bool pressed = (digitalRead(_pin) == LOW);
// 消抖:150ms内的边沿跳变忽略
if (now - _lastEdge < BTN_DEBOUNCE_MS) {
_lastEdge = now;
return;
}
_lastEdge = now;
if (pressed) {
_pressStart = now;
_isPressed = true;
} else {
// ★ 关键:没有对应按下边沿的释放边沿直接丢弃
// 防止噪声导致_pressStart=0被误判为超长按
if (!_isPressed) return;
_isPressed = false;
uint32_t duration = now - _pressStart;
if (duration >= BTN_LONGPRESS_MS) _pendingLong = true;
else _pendingShort = true;
}
}
消抖窗口BTN_DEBOUNCE_MS=150,长按阈值BTN_LONGPRESS_MS=600
3.2 按需订阅驱动
// page传PAGE_COUNT表示"心跳模式":只开一路低速旋转矢量,用于菜单/关于页
// 持续确认连接状态;传具体页面则只订阅该页需要的report。
void IMUManager::enableForPage(DashPage page) {
if (page == _currentEnabledPage) return;
disableAllOptional();
dbg("IMU", "切换report订阅 -> " + String((int)page));
switch (page) {
case PAGE_COUNT: // 心跳模式
_imu.enableRotationVector(200);
break;
case PAGE_EULER:
case PAGE_ROTATION_VECTOR:
_imu.enableRotationVector(20);
break;
case PAGE_ACCEL_GROUP:
_imu.enableAccelerometer(50);
delay(2);
_imu.enableLinearAccelerometer(50);
delay(2);
_imu.enableGravity(50);
break;
case PAGE_GYRO_GROUP:
_imu.enableGyro(20);
delay(2);
_imu.enableUncalibratedGyro(20);
break;
case PAGE_MAGNETOMETER:
_imu.enableMagnetometer(50);
break;
case PAGE_STEP:
_imu.enableStepCounter(200);
break;
case PAGE_STABILITY:
_imu.enableStabilityClassifier(200);
break;
case PAGE_ACTIVITY_CLASSIFIER:
_imu.enableActivityClassifier(200, 0x1F); // 与Example_07一致的掩码,之前用0x1FF未经验证
break;
case PAGE_RAW_READINGS:
// 官方Example_11_RawReadings里raw report始终是和校准版成对使能的,
// 之前只开了raw版,BNO086内部很可能不产出数据,这是导致该页拿不到
// 数据、一直"长时间无更新"的根因,这里照官方示例改成成对使能。
// 另外数据手册2.1.1节明确写了"raw版请求速率不能快于其挂靠的校准版",
// 之前raw设的是20ms(50Hz)比校准版50ms(20Hz)还快,属于不被支持的配置,
// 这里统一成raw和校准版同速率,符合手册要求
_imu.enableAccelerometer(50);
delay(2);
_imu.enableRawAccelerometer(50);
delay(2);
_imu.enableGyro(50);
delay(2);
_imu.enableRawGyro(50);
delay(2);
_imu.enableMagnetometer(50);
delay(2);
_imu.enableRawMagnetometer(50);
break;
default:
break;
}
_currentEnabledPage = page;
}
void IMUManager::forceResubscribe(DashPage page) {
dbg("IMU", "检测到report长时间无更新,尝试重新订阅自愈");
_currentEnabledPage = (DashPage)255; // 打破"page相同就跳过"的判断,强制重新走一遍
enableForPage(page);
}
每次切页面都要先disableAllOptional()全部关闭再按需打开,这是为了让软件I2C总线上永远只有"当前页面需要的那一路"数据在跑,避免多路report交织导致读串
3.3 数据快照结构
IMUManager内部只维护一份最新数据快照,新数据到达直接覆盖旧值,不排队
// 统一的数据快照结构:Sensor侧只写,Display侧只读
// 采用"覆写式"更新而非队列,因为显示只关心"当前最新值"
struct IMUSnapshot {
// 旋转矢量 / 欧拉角(roll/pitch/yaw已换算成角度,不是弧度)
float qI = 0, qJ = 0, qK = 0, qReal = 0, qRadAcc = 0;
uint8_t qAccuracy = 0;
float roll = 0, pitch = 0, yaw = 0;
// 加速度 / 线性加速度 / 重力
float accX = 0, accY = 0, accZ = 0; uint8_t accAcc = 0;
float linX = 0, linY = 0, linZ = 0; uint8_t linAcc = 0;
float gravX = 0, gravY = 0, gravZ = 0; uint8_t gravAcc = 0;
// 陀螺仪(校准 + 未校准偏置)
float gyroX = 0, gyroY = 0, gyroZ = 0; uint8_t gyroAcc = 0;
float ugyroX = 0, ugyroY = 0, ugyroZ = 0;
float biasX = 0, biasY = 0, biasZ = 0;
// 磁力计
float magX = 0, magY = 0, magZ = 0; uint8_t magAcc = 0;
// 计步 / 稳定性 / 活动分类
uint16_t stepCount = 0;
uint8_t stability = 0; // 0 unknown 1 on-table 2 stationary 3 stable 4 motion
uint8_t activity = 0;
uint8_t activityConfidence[9] = {0};
// 原始读数
int16_t rawAX = 0, rawAY = 0, rawAZ = 0;
int16_t rawGX = 0, rawGY = 0, rawGZ = 0;
int16_t rawMX = 0, rawMY = 0, rawMZ = 0;
// 系统状态
bool wasReset = false;
uint8_t resetReason = 0;
uint32_t lastUpdateMs = 0;
};
弧度转角度
_snap.qI = _imu.getQuatI();
_snap.qJ = _imu.getQuatJ();
_snap.qK = _imu.getQuatK();
_snap.qReal = _imu.getQuatReal();
_snap.qRadAcc = _imu.getQuatRadianAccuracy();
_snap.qAccuracy = _imu.getQuatAccuracy();
// getRoll/getPitch/getYaw返回弧度,这里统一转成角度,
_snap.roll = _imu.getRoll() * 180.0f / PI;
_snap.pitch = _imu.getPitch() * 180.0f / PI;
_snap.yaw = _imu.getYaw() * 180.0f / PI;
getRoll()底层是对四元数做atan2分解,数学库函数天然返回弧度制
3.4 显示增量刷新
每个显示行/条形图都有缓存值(_cache[idx]、_barPercentCache[idx]、_barValueCache[idx])
void DisplayManager::printRow(uint8_t idx, const String &text, uint16_t color) {
if (idx >= _rowCount) return;
if (_cache[idx] == text) return; // 值没变,跳过SPI传输,这是性能关键点
_tft.setFont(NULL);
_tft.fillRect(126, _rowY[idx], 108, 18, cBg());
_tft.setTextSize(2);
_tft.setTextColor(color);
_tft.setCursor(126, _rowY[idx]);
_tft.print(text);
_cache[idx] = text;
}
- drawDynamic()每100ms调用一次,但只有数值真正变化时才发起SPI传输
- 避免无效的屏幕刷新,降低总线负载,提升整体流畅度
菜单滚动窗口
列表类UI最基础也最常用的滚动算法,本质上是把"总数据量"和"可见窗口"两个维度解耦,用一个偏移量做映射
void DisplayManager::drawMenu(int selectedIndex, bool connected) {
_tft.fillScreen(cBg());
printTitle("BNO086 MENU", 0, 0, connected);
// 屏幕像素预算:标题栏占0~30,底部提示要留给y=226起的一行文字(约8px高+边距),
// 所以列表可用区间是y=32到y=222,一共190px;rowH=24时最多完整放7行(168px),
// 这里选7行留一点安全边距,不贴着底部提示,PAGE_COUNT超过7项时靠滚动露出其余项
const int listTop = 32, rowH = 24, visibleRows = 7;
const int trackX = 230, trackW = 6;
int shown = (PAGE_COUNT < visibleRows) ? PAGE_COUNT : visibleRows;
// 让选中项始终落在可见窗口内:选到窗口外就把窗口滚过去
int scrollOffset = 0;
if (selectedIndex >= shown) scrollOffset = selectedIndex - shown + 1;
int maxScroll = PAGE_COUNT - shown;
if (scrollOffset > maxScroll) scrollOffset = maxScroll;
if (scrollOffset < 0) scrollOffset = 0;
for (int row = 0; row < shown; row++) {
int i = scrollOffset + row;
int y = listTop + row * rowH;
uint16_t bg = (i == selectedIndex) ? cMenuSel() : cBg();
_tft.fillRect(0, y, 224, rowH, bg);
_tft.setTextSize(2);
_tft.setTextColor((i == selectedIndex) ? cAccent() : cValue());
_tft.setCursor(10, y + 4);
_tft.print(PAGE_NAMES[i]);
}
// 右侧位置进度条:轨道按可见窗口高度画,滑块位置按selectedIndex在全部
// PAGE_COUNT项里的相对位置算,而不是只看可见的这几项,这样滑块能真实
// 反映"当前选中项在整个列表里的位置",起到滚动条的作用
int listH = shown * rowH;
_tft.fillRect(trackX, listTop, trackW, listH, cBarBg());
int indicatorH = listH / PAGE_COUNT;
if (indicatorH < 8) indicatorH = 8; // 保证滑块本身不会小到看不清
int travel = listH - indicatorH;
int indicatorY = listTop;
if (PAGE_COUNT > 1) {
indicatorY = listTop + (int)((long)selectedIndex * travel / (PAGE_COUNT - 1));
}
_tft.fillRect(trackX, indicatorY, trackW, indicatorH, cAccent());
_tft.setTextSize(1);
_tft.setTextColor(cLabel());
_tft.setCursor(8, 226);
_tft.print("SHORT:select LONG:enter/about");
}
选中项在前7项就不滚动,超出就让选中项始终停在窗口最后一行
3.5 状态机设计
系统本质上是一个四状态的有限状态机,所有交互逻辑都围绕这四个状态转移展开:

void loop() {
// 传感器事件轮询设上限,避免总线偶发异常时while循环不退出、
// 拖累下面按键判断代码执行不到(这是之前"卡在某页无法翻页"的根因之一)
for (uint8_t n = 0; n < IMU_POLL_MAX_PER_LOOP; n++) {
if (!imu.poll()) break;
}
uint32_t now = millis();
// 运行时总线自愈:set_scl()一旦发生过时钟拉伸超时,这里检测到就立刻
// 尝试恢复,不用等到"数据长时间不更新"的看门狗才反应
if (imuWire.sclStretchTimeout) {
dbg("I2C", "检测到时钟拉伸超时标志,触发总线自愈");
imuWire.i2cBusRecovery();
}
// ---------------- 休眠状态:只等待任意按键唤醒 ----------------
if (appState == APP_SLEEP) {
bool wakeEvent = btnNext.consumeShort() || btnNext.consumeLong() ||
btnAct.consumeShort() || btnAct.consumeLong();
if (wakeEvent) {
dbg("SYS", "检测到按键,退出休眠");
btnNext.clearPending();
btnAct.clearPending();
imu.wake();
delay(50); // 给芯片一点恢复时间再继续通信
appState = stateBeforeSleep;
if (appState == APP_PAGE) {
imu.enableForPage(currentPage);
display.drawStatic(currentPage);
} else {
imu.enableForPage(PAGE_COUNT);
display.drawMenu(menuIndex, imu.isConnected());
}
}
return; // 休眠中不做其它任何事
}
// ---------------- 全局功能:NEXT长按 = 进入休眠 ----------------
if (btnNext.consumeLong()) {
dbg("SYS", "进入休眠,黑屏等待按键唤醒");
stateBeforeSleep = appState;
appState = APP_SLEEP;
imu.sleep();
display.sleepScreen();
btnAct.clearPending();
return;
}
// ---------------- 按状态处理短按/长按 ----------------
switch (appState) {
case APP_MENU:
if (btnNext.consumeShort()) {
menuIndex = (menuIndex + 1) % PAGE_COUNT;
display.drawMenu(menuIndex, imu.isConnected());
}
if (btnAct.consumeShort()) {
currentPage = (DashPage)menuIndex;
dbg("SYS", "进入页面 -> " + String(PAGE_NAMES[currentPage]));
appState = APP_PAGE;
imu.enableForPage(currentPage);
display.drawStatic(currentPage);
}
if (btnAct.consumeLong()) {
dbg("SYS", "进入说明页");
appState = APP_ABOUT;
display.drawAbout();
}
if (now - lastMenuStatusMs >= MENU_STATUS_REFRESH_MS) {
lastMenuStatusMs = now;
display.updateMenuConnStatus(imu.isConnected());
}
break;
case APP_PAGE: {
if (btnNext.consumeShort()) {
currentPage = (DashPage)((currentPage + 1) % PAGE_COUNT);
dbg("SYS", "切换页面 -> " + String(PAGE_NAMES[currentPage]));
imu.enableForPage(currentPage);
display.drawStatic(currentPage);
}
if (btnAct.consumeShort()) {
dbg("BTN", "动作键触发 -> Tare");
imu.tare();
}
if (btnAct.consumeLong()) {
dbg("SYS", "返回菜单");
appState = APP_MENU;
menuIndex = currentPage;
imu.enableForPage(PAGE_COUNT);
display.drawMenu(menuIndex, imu.isConnected());
break;
}
// 显示刷新按固定节奏进行,与传感器采样频率解耦
if (now - lastDrawMs >= DISPLAY_REFRESH_MS) {
lastDrawMs = now;
display.drawDynamic(currentPage, imu.data(), imu.isConnected());
}
// 卡死自愈:当前页已订阅report,但超过阈值没收到任何新数据,
// 大概率是订阅状态错乱或软件I2C出现异常包,先试总线恢复,
// 再重新走一遍订阅流程。计步/稳定性/活动分类是事件型report,
// 产出节奏天生慢,用更长阈值;且触发一次后要冷却,不能每个
// loop都重新打断,否则事件型report永远没机会攒够数据产出结果
bool isEventPage = (currentPage == PAGE_STEP || currentPage == PAGE_STABILITY ||
currentPage == PAGE_ACTIVITY_CLASSIFIER);
uint32_t staleThreshold = isEventPage ? IMU_STALE_SLOW_MS : IMU_STALE_MS;
if (imu.isConnected() && imu.msSinceLastUpdate() > staleThreshold &&
now - lastWatchdogMs > staleThreshold) {
lastWatchdogMs = now;
imu.attemptBusRecovery();
imu.forceResubscribe(currentPage);
}
// 仅在欧拉角页、且刚好收到一次新report时才发送,跟官方
// Example_19_GeomagneticRotationVector_to_EulerAngles的输出方式一致
if (currentPage == PAGE_EULER && imu.consumeRotationVectorFresh()) {
const IMUSnapshot &d = imu.data();
vofaSend3(d.roll, d.pitch, d.yaw);
}
// Rotation Vector页:同一个report里的四元数分量,四通道发送
if (currentPage == PAGE_ROTATION_VECTOR && imu.consumeRotationVectorFresh()) {
const IMUSnapshot &d = imu.data();
vofaSend4(d.qI, d.qJ, d.qK, d.qReal);
}
break;
}
case APP_ABOUT:
if (btnNext.consumeShort() || btnAct.consumeShort() || btnAct.consumeLong()) {
appState = APP_MENU;
display.drawMenu(menuIndex, imu.isConnected());
}
break;
default:
break;
}
}
休眠是"叠加"在任意状态之上的一个临时中断,不是独立的第五种业务状态,所以唤醒时要能精确回到原来的菜单还是数据页,这个stateBeforeSleep变量就是实现这一点的关键
系统流程图

四、项目结果演示
4.1 操作流程
①上电启动

屏幕显示“BNO086 MENU”菜单,顶部绿色圆点表示IMU连接正常
②菜单导航
- NEXT键短按向下移动选中行,右侧进度条同步指示位置
- ACT键短按进入选中页面,进入页面后动态数据按100ms刷新
- NEXT短按切换到下一个页面
③Tare置零

在任何数据页短按ACT键,航向(Yaw)归零,串口打印[IMU]调试: 已执行Tare,航向置零
④返回菜单

长按ACT键(600ms)返回菜单,菜单界面长按ACT键进入关于页
⑤休眠唤醒

长按NEXT键进入休眠(黑屏+IMU低功耗),任意按键唤醒
4.2 VOFA+上位机数据
VOFA+配置要点:
- 协议选择:Firewater(逗号分隔+换行)
- 波特率:115200
- 每帧数据以换行符\n结束
本项目支持两种VOFA+数据输出模式:
①欧拉角三通道数据

- 菜单里选择 "欧拉角模式"进入
- VOFA+协议选择Firewater,串口波特率设为115200,点击连接
- 转动设备,VOFA+波形区会出现roll/pitch/yaw三条曲线,数值单位是角度(°)
- 代码侧只在收到新的旋转矢量report时才发送一次
if (currentPage == PAGE_EULER && imu.consumeRotationVectorFresh()) {
const IMUSnapshot &d = imu.data();
vofaSend3(d.roll, d.pitch, d.yaw);
}
②旋转矢量四元数四通道数据

- 菜单里选择 "四元数模式"进入
- VOFA+协议仍是Firewater,不用重新配置
- 波形区变成四条曲线(i/j/k/real),数值范围固定在-1到1之间,跟欧拉角的角度值观感完全不同,这是正常的——四元数分量本身就是这个取值范围
- 复用同一个"新鲜度标志",只是发送函数换成四通道版本
if (currentPage == PAGE_ROTATION_VECTOR && imu.consumeRotationVectorFresh()) {
const IMUSnapshot &d = imu.data();
vofaSend4(d.qI, d.qJ, d.qK, d.qReal);
}
4.3 视频演示
零知派标准板+BNO086九轴姿态运动仪表盘
本视频演示基于零知派STM32F103标准板驱动BNO086九轴传感器打造的运动姿态仪表盘,展示9个功能页面的实时数据、双按键长短按菜单交互、黑屏休眠唤醒、以及通过VOFA+上位机实时绘制姿态波形的完整效果,同时展示了项目在软件I2C总线异常时的自动恢复能力
五、BNO086技术讲解部分
5.1 BNO086软件架构
BNO086并非一颗普通的MEMS传感器,而是一个完整的传感器中枢(Sensor Hub)解决方案。其内部架构分为三个层次:

| 层次 | 组件 | 功能 |
|---|---|---|
| 硬件层 | 三轴加速度计 + 三轴陀螺仪 + 三轴磁力计 + ARM Cortex-M0+ | 原始信号采集与处理 |
| 固件层 | MotionEngine™ 软件(SH-2固件) | 传感器融合算法、姿态解算、活动识别 |
| 应用层 | SH-2协议 | 主机与传感器中枢的命令/数据交换 |
SH-2(Sensor Hub 2)是运行在BNO086内部ARM处理器上的固件
负责执行所有传感器融合算法。MotionEngine™软件提供了复杂的信号处理算法,能够输出精确的实时3D定位、航向、校准加速度和校准角速度
主机MCU不需要做任何姿态解算——只需要通过通信接口向SH-2发送配置命令,然后读取它算好的结果就行
SHTP(Sensor Hub Transport Protocol)是SH-2与主机之间的通信协议
它运行在物理层(I2C/SPI/UART)之上,负责将SH-2的命令、响应和传感器数据封装成数据包进行传输

使用I2C作为物理层,通过SHTP协议与BNO086内部的SH-2固件通信

数据手册明确说明"BNO08X应用不支持传统的I2C寄存器式接口"。这意味着你不能像操作普通I2C传感器那样直接读写寄存器——所有通信都必须通过SHTP协议进行
5.2 SHTP通信协议栈
SHTP是一种基于数据包的传输协议,所有命令、响应和传感器报告都以特定格式的数据包在总线上传输
SHTP数据包头部格式
| 字节偏移 | 字段 | 说明 |
|---|---|---|
| Byte 0 | Length LSB | 总长度(含4字节Header)的低8位 |
| Byte 1 | Length MSB(含Continuation标志) | Bit 15 = 延续标志(1=分片包,0=完整包);Bit 14-0 = 总长度 |
| Byte 2 | Channel | 通道号(0-5) |
| Byte 3 | Sequence Number | 每个通道独立的序列号,用于检测丢包 |
Length字段的特殊含义
-
如果Length = 0,表示BNO086当前没有数据可发送——这是BNO086与BNO085的关键区别之一
-
数据手册7.1.3节明确说明:当BNO086被轮询但没有数据时,它会返回全0的SHTP Header,主机应检查Length字段,为0则直接丢弃该包
Continuation标志的用途
当传感器报告大于单次I2C传输的最大长度时,SHTP支持将一个大payload拆成多个分片包传输

第一个包的Continuation=0,后续包的Continuation=1,主机端需要将分片重新组装成完整的payload
六个通信通道
BNO086的SHTP协议定义了6个通信通道,每个通道有独立的用途和序列号
| 通道号 | 名称 | 用途 |
|---|---|---|
| Channel 0 | Command | SHTP协议自身的控制与配置(广告请求等) |
| Channel 1 | Executable/Device | 设备级命令:Reset、On、Sleep |
| Channel 2 | Control | SensorHub控制命令(使能/配置传感器Report) |
| Channel 3 | Input Normal | 普通传感器数据报告 |
| Channel 4 | Input Wake | 唤醒传感器数据报告 |
| Channel 5 | Input Gyro RV | 陀螺仪积分旋转矢量(高速率专用通道) |

Channel 1:发送EXECUTABLE_DEVICE_CMD_RESET(软复位)
Channel 2:发送SENSORHUB_SET_FEATURE_CMD(使能Report)和SENSORHUB_COMMAND_REQ(Tare等命令)
Channel 3/4:接收传感器数据报告
广告(Advertisement)机制:BNO086启动后会在Channel 0上广播“广告”数据包,包含固件版本、支持的Report列表、各通道长度等信息。主机需要监听这些广告才能正确解析后续数据
I2C物理层时钟拉伸
当BNO086被轮询但没有数据时,它会拉低SCL时钟线(时钟拉伸),直到数据准备好——主机必须在set_scl()中实现超时保护,不能无限等待
I2C 写周期:

I2C 读周期:

BNO086改进了轮询行为,没有数据时返回全0 Header而非无限时钟拉伸
5.3 HINT中断与超时机制
H_INTN引脚是应用中断线,指示BNO08X需要主机关注。该引脚应接到具备唤醒能力的GPIO。中断为低电平有效

BNO086支持两种数据读取模式:
-
中断模式:BNO086有数据时拉低HINT → 主机检测到中断后发起I2C读取 → 可靠、低功耗
-
轮询模式:主机定期主动询问“有数据吗?” → BNO086返回0长度Header表示没有 → 存在总线冲突风险
轮询模式的风险
当两路Report同时订阅时(如Step + Stability),两路数据包以不同相位交替到达,轮询可能在数据包边界处读到错误对齐的数据
超时机制

hal_wait_for_int()函数实现
static bool hal_wait_for_int(void) {
for (int i = 0; i < 500; i++) {
if (!digitalRead(_int_pin)) return true;
delay(1);
}
hal_hardwareReset();
return false;
}
函数逻辑
- 循环500次,每次检查INT引脚是否为低(有数据就绪)
- 每次循环delay(1)——总超时时间 = 500ms
- 如果500ms内INT未被拉低,执行硬件复位并返回false

Activity Classifier虽然需要4秒才能产出最终判定结果,但它在积累数据的过程中可能会提前产出中间状态报告——这些报告在使能后约800-1000ms内到达

对事件型Report采取多层保护
hal_wait_for_int()超时从500ms改为1000ms,给事件型Report足够的第一响应时间
六、常见问题解答(FAQ)
Q1:为什么BNO086必须接硬件RST?
A:RST=-1时走的是软件复位指令,若BNO086内部SH-2固件已卡死则无法处理该指令,只有硬件拉低NRST才能强制恢复。接D5/D6后彻底解决了“必须整体断电才能恢复”的问题
Q2:Step和Stability拆成独立页面后问题消失,混在一起为何不行?
A:轮询模式下无INT中断同步,两路report相位交错易导致读包错位,数据在0和真实值间跳变。拆分成独立页面后每页只订阅一路report,从架构上避免并发串扰
Q3:RAW Readings页面一直没有数据,但I2C能扫到地址?
A:Raw report必须和对应的校准版report成对使能,单独订阅Raw版不会产生数据。同时Raw版速率不能快于校准版,需保持同频
项目资源整合
SH2 接口库: ceva-dsp/sh2
BNO086库文件: SparkFun_BNO08x_Arduino_Library
BNO08X数据手册: BNO08X Datasheet
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)