简介:这是一份基于STM32的BNO085姿态传感器数据读取与CAN总线发送的嵌入式实战项目,适合单片机初学者、毕业设计/课程设计/工程实训等场景。资源包含完整可编译工程与烧录固件,主控以STM32F1系列HAL库为基础,实现I2C读取BNO085姿态数据,并通过CAN外设将状态信息发送到总线,覆盖传感器驱动、协议解析、CAN通信等关键环节。压缩包共225个文件,含63个h头文件、35个c源文件、工程配置文件(uvprojx/ioc)、编译中间文件与hex/axf烧录文件,整体约9.06MB,目录结构清晰,便于直接导入Keil查看或二次开发。已有679人学习下载。资料附带使用说明,开发者可快速复现,也可基于现有驱动扩展更多传感器或通信功能,适合作为项目基础框架参考。
1. 用CAN总线把BNO085的姿态数据送出去,比想象中多两个坑
BNO085 内部自带一颗协处理器,加速度计、陀螺仪、磁力计的数据会先在传感器内部融合成四元数再输出,所以在 STM32 上读它并不需要自己写姿态解算。真正出问题的往往在两个地方:一个是对 SH-2 协议包的字节序和通道号理解不到位,不同固件版本会对不上;另一个是一旦接上 CAN,波特率、位时间段、终端电阻、ID 冲突会同时冒出来。这个标题对应的实现,简单说就是 STM32 用 UART 读 BNO085 的姿态状态数据,解析成欧拉角和状态字后打包成 CAN 数据帧,按固定周期向总线上广播。对课设、毕设和竞赛来说,这条链路覆盖了传感器驱动、串口协议、总线通信三个必查的验收点,顺着本文的接线和代码可以直接跑通,再按自己的报告格式改字段就行。
2. 硬件怎么接、CubeMX参数怎么填:先把链路基础钉死
把传感器和 CAN 收发器接好,是后面所有调试的前提。BNO085 的 UART 接口是 3.3V 电平,STM32F103 的引脚也是 3.3V,两者可以直接连,不建议中间再串 5V 电平转换或分压电阻。CAN 收发器这里按最常见的 TJA1050 或板载收发器模块处理,如果你的板子已经集成了收发器,TXD/RXD 引脚直接接到 STM32 即可。
2.1 接线表:BNO085 与 STM32F103C8 的 UART 连接
下表默认使用 USART1,这是 F103 上最容易配、也最不容易与其他调试口冲突的一组引脚。
| BNO085 引脚 | STM32F103C8 引脚 | 方向 | 说明 |
|---|---|---|---|
| VIN | 3.3V | 电源输入 | BNO085 的 VIN 不是 5V tolerant,接错容易烧模块 |
| GND | GND | 电源地 | 必须与 STM32 共地 |
| TX | PA10 (USART1_RX) | BNO085 -> STM32 | 传感器数据输出脚 |
| RX | PA9 (USART1_TX) | STM32 -> BNO085 | 传感器数据输入脚,也用来发使能命令 |
CAN 收发器接线如下:
| CAN 收发器引脚 | STM32F103C8 引脚 | 说明 |
|---|---|---|
| TXD | PA12 (CAN1_TX) | CAN1 默认映射 |
| RXD | PA11 (CAN1_RX) | CAN1 默认映射 |
| CANH / CANL | 总线差分线 | 总线两端各接 120Ω 终端电阻 |
注意:F103C8 的 CAN 引脚可以通过重映射换到 PB8/PB9,但默认映射 PA11/PA12 最省事。如果 PA11/PA12 被其他外设占用,再考虑重映射,同一时间只能启用一组映射。
2.2 CubeMX 关键配置:时钟树决定 CAN 波特率是否准确
在 STM32CubeMX 里新建 F103C8 工程后,先照下面这张表把系统时钟和外设状态配置好。
| 配置项 | 值 | 说明 |
|---|---|---|
| SYS Debug | Serial Wire | 不要选 JTAG,否则会占用 PB3/PB4 |
| RCC HSE | Crystal/Ceramic Resonator | 按板载 8MHz 晶振设置 |
| 主频 | 72MHz | APB1 总线时钟 36MHz,挂 CAN1 |
| USART1 Mode | Asynchronous | 波特率 115200,8N1 |
| CAN1 Mode | Activated | 波特率填 500000 Bits/s |
这里最容易算错的就是 APB1。CAN1 在 F103 上挂在 APB1 总线上,APB1 时钟是 36MHz,CAN 外设的位时间参数也基于这个时钟计算。CubeMX 里直接填 500kbps,还需要检查生成的 CAN 初始化代码中时序参数是否合理。常见的一组参数是:预分频 Prescaler=4,BS1=13tq,BS2=4tq,SJW=1tq。计算如下:
- tq 时间 = 1 / (36MHz / 4) = 0.111μs
- 一个位时间 = 1 + 13 + 4 = 18tq,即 2μs
- 波特率 = 1 / 2μs = 500kbps
如果你用的是 Keil MDK 而不是 CubeIDE,先去 Pack Installer 安装 STM32F1xx_DFP 芯片包,否则器件列表里选不到 STM32F103CB。很多新手在第一步就卡在这里,编译时提示找不到目标芯片头文件。
2.3 生成工程后先做一次烧录验证
代码生成后,先不急着接 BNO085,写一个点灯程序确认 ST-Link 烧录链路是通的。这个是整个方案的兜底:如果烧录都不通,后面所有调试都没有意义。
提示:烧录时如果报
No STM32 target found!,优先检查 ST-Link 的 SWD 四根线有没有接反、目标板是否有 3.3V 供电、BOOT0 是否拉低。还有一部分是板载 ST-Link 固件版本过旧,用 STM32CubeProgrammer 升级一下固件再试。Windows 下如果 USB 转串口设备出现黄色感叹号,驱动也要一并处理,否则后面 printf 输出根本看不到。
烧录通过后,建议先做两项快速检查:用万用表确认 PA9/PA10 对地电压约为 0V 到 3.3V 之间的逻辑电平,量一下 CANH 和 CANL 之间是否能看到约 60Ω 的终端电阻值。两项都正常再往下走。
3. 读 BNO085 前,先把 SH-2 协议包拆明白
BNO085 的 UART 输出不是简单的“一帧数据”,而是带通道、序列号、校验和的 SHTP 包。很多人在这一步直接把收到的字节流按固定长度截断,结果姿态数据总是乱跳,问题就出在没按协议边界解析。
3.1 SHTP 包的最小结构
一个标准的 SHTP 包由以下几部分组成。
| 字段 | 长度 | 说明 |
|---|---|---|
| 包起始 | 1 字节 | 固定 0x50 |
| payload 长度 | 2 字节 | 小端,从 channel 字节开始计数 |
| channel | 1 字节 | 命令通道和传感器报告通道是分开的 |
| sequence | 1 字节 | 每发一包加 1,用于对包序 |
| payload | 变长 | 命令参数或传感器报告数据 |
| checksum | 1 字节 | 对包起始到 payload 末尾所有字节的补码和 |
校验和的具体算法是:从 0x50 开始,把包括 payload 在内的所有字节求和,取低 8 位,再取反加一。也就是说,把校验和字节也加进去之后,整个包所有字节的和对 256 取模必须等于 0。这个校验在解析端非常有用,能过滤掉大部分串口电平抖动产生的错包。
一个完整包的十六进制示意: 50 08 00 02 0A 00 00 F9 0B 14 00 00 00 00 84其中08 00表示 payload 长度 8,02是命令通道,0A是序列号,F9 0B 14 00 00 00 00是 SET_REPORT 命令数据,84是校验和。你不需要背这个结构,但解析代码必须按照这个边界来做,不能用固定字节偏移。
3.2 让 BNO085 持续上报旋转向量
上电后 BNO085 不会默认输出姿态数据,需要先通过命令通道发一条 SET_REPORT 请求。下面这段代码把旋转向量报告使能命令封装成一个函数,间隔参数单位是毫秒。
void bno085_enable_rotation_vector(uint8_t seq, uint16_t interval_ms) { uint8_t pkt[15] = { 0x50, 0x08, 0x00, // SHTP 起始 + payload 长度 8 0x02, // 命令通道 seq, // 序列号,每次自增 0x00, 0x00, // 最大响应时间,填 0 表示不限制 0xF9, // SET_REPORT 0x0B, // 旋转向量 feature ID interval_ms & 0xFF, // 报告间隔低字节 interval_ms >> 8, // 报告间隔高字节 0x00, 0x00, 0x00, // 后续参数,按 feature 定义补齐 }; uint8_t sum = 0; for (uint8_t i = 0; i < 14; i++) { sum += pkt[i]; } pkt[14] = (uint8_t)(~sum + 1u); // 补码和校验 HAL_UART_Transmit(&huart1, pkt, sizeof(pkt), 20); }调用时 seq 建议用一个全局变量递增,避免连续发多条命令时序列号相同导致传感器忽略请求。interval_ms填 20 表示每 20ms 上报一次姿态,对应 50Hz,够一般运动控制用。如果后续 CAN 发送周期也是 20ms,那传感器每次上报的数据都能对应上一帧 CAN 报文。
3.3 一个字节一个字节喂出来的解析状态机
UART 中断每收到一个字节就交给下面的状态机函数处理。状态机的作用是先在字节流里找到 0x50,再根据长度字段确定这一包什么时候结束,最后用校验和决定这包数据是否有效。
static uint8_t rx_buf[128]; static uint16_t rx_len, target_len; static uint8_t rx_sum, rx_state; void bno085_feed_byte(uint8_t c) { switch (rx_state) { case 0: // 等待包起始 if (c == 0x50) { rx_buf[0] = c; rx_sum = c; rx_len = 1; rx_state = 1; } break; case 1: // 收到长度低字节 rx_buf[1] = c; rx_sum += c; rx_len = 2; rx_state = 2; break; case 2: // 收到长度高字节 rx_buf[2] = c; rx_sum += c; rx_len = 3; target_len = rx_buf[1] | (rx_buf[2] << 8); if (target_len + 4 > sizeof(rx_buf)) { rx_state = 0; // 长度异常,重新同步 } else { rx_state = 3; } break; default: // 连续接收剩余字节和校验和 rx_buf[rx_len++] = c; rx_sum += c; if (rx_len >= target_len + 4) { if ((uint8_t)rx_sum == 0) { // 补码和校验通过 bno085_parse_packet(rx_buf, rx_len); } rx_state = 0; } break; } }这里有几个参数要解释清楚。target_len是 SHTP 头里的 payload 长度,它从 channel 字节开始计数。包的总字节数等于target_len + 4,其中 4 是指包起始 1 字节、长度 2 字节、校验和 1 字节。状态机收到全部字节后,用rx_sum == 0做校验,因为补码和的特性就是整个包所有字节相加低 8 位为 0。
接下来在解析函数里把四元数转成欧拉角,并记录精度状态字:
static int16_t rd_i16(uint8_t *p) { return (int16_t)(p[0] | (p[1] << 8)); // SHTP 里多字节为小端 } static void bno085_parse_packet(uint8_t *buf, uint16_t len) { if (buf[3] != SENSOR_CHANNEL) { // 只处理传感器报告通道 return; } uint8_t *d = &buf[5]; // 跳过 channel 和 seq if (d[0] == 0x0B) { // 旋转向量报告 ID float qr = rd_i16(&d[1]) / 16384.0f; // 实数部分,按 14 位定点 float qi = rd_i16(&d[3]) / 16384.0f; float qj = rd_i16(&d[5]) / 16384.0f; float qk = rd_i16(&d[7]) / 16384.0f; att.roll = atan2f(2.0f * (qr * qi + qj * qk), 1.0f - 2.0f * (qi * qi + qj * qj)); att.pitch = asinf(2.0f * (qr * qj - qk * qi)); att.yaw = atan2f(2.0f * (qr * qk + qi * qj), 1.0f - 2.0f * (qj * qj + qk * qk)); att.status = d[9] & 0x03; // 精度状态,0 到 3 att_frame_valid = 1; } }注意:旋转向量报告里四元数的排列顺序,不同固件版本的字段定义可能不一样。调试时先让模块静止,看解析出的 roll 和 pitch 是否接近 0,yaw 是否随转动单调变化。如果发现 yaw 不动、roll 乱跳,大概率是 qr 和 qi 的顺序反了,调换字段位置即可。
3.4 串口调试输出:先确认解析结果再连 CAN
不要直接跳到 CAN 发送。先用串口把 yaw、pitch、roll 打出来,观察数据是否符合物理转动。这一步可以省掉后面两倍的排错时间。
printf("yaw=%.2f pitch=%.2f roll=%.2f st=%d\r\n", att.yaw * 57.3f, att.pitch * 57.3f, att.roll * 57.3f, att.status);把模块平放,yaw 会在当前朝向附近小幅波动,roll 和 pitch 应该在 0 附近小幅跳动,status 字段从 0 慢慢变到 3 说明传感器内部已经完成初始化校准。如果 yaw 一直在跳且没有规律,检查模块周围有没有强磁干扰,以及 BNO085 的安装方向是否和预期一致。
4. 报文设计与 bxCAN 发送:状态数据按帧广播
CAN 侧的任务比 UART 简单,难点在设计数据帧的字段分配和发送周期。BNO085 已经给出了四元数、欧拉角、精度状态,CAN 报文只需要把这些数据压缩成 8 字节,再周期性调用发送函数。
4.1 数据帧字段设计
我一般把 CAN 的标准帧 ID 定为 0x181,8 字节数据段按下面的格式填充。
| 字节 | 字段 | 格式 | 说明 |
|---|---|---|---|
| Byte 0-1 | Yaw | int16,0.01°/位 | 小端序 |
| Byte 2-3 | Pitch | int16,0.01°/位 | 小端序 |
| Byte 4-5 | Roll | int16,0.01°/位 | 小端序 |
| Byte 6 | 状态字 | bit0 数据有效,bit1-2 精度等级 | 0 到 3 |
| Byte 7 | 帧计数 | uint8,0-255 循环 | 接收端检测丢帧 |
这里的角度精度按 0.01° 量化,能满足大多数运动控制和车辆姿态监控场景。如果你需要更高精度,可以把 Yaw 改成 float32 的弧度值,但那样 8 字节只能装下 2 个浮点数,需要拆成两帧。毕设和课设场景里,0.01°的 int16 量化已经足够,还省了总线占用。
滤波器配置也要在启动时做好。下面的配置把接收过滤器放空,也就是不过滤任何接收帧,方便调试:
CAN_FilterTypeDef filter = {0}; filter.FilterActivation = ENABLE; filter.FilterBank = 0; filter.FilterFIFOAssignment = CAN_RX_FIFO0; filter.FilterIdHigh = 0x0000; filter.FilterIdLow = 0x0000; filter.FilterMaskIdHigh = 0x0000; filter.FilterMaskIdLow = 0x0000; filter.FilterMode = CAN_FILTERMODE_IDMASK; filter.FilterScale = CAN_FILTERSCALE_32BIT; HAL_CAN_ConfigFilter(&hcan1, &filter); HAL_CAN_Start(&hcan1);如果只允许接收 ID 0x181 的报文,把FilterIdLow填成(0x181 << 5),FilterMaskIdLow填成0x7FF << 5,掩码位为 1 表示必须匹配。
4.2 发送函数与邮箱状态检查
下面这个函数把欧拉角转成 int16 后塞进 8 字节帧,并通过 bxCAN 的发送邮箱发出。发送前先查邮箱空闲数量,避免在邮箱满时调用 HAL_CAN_AddTxMessage 造成阻塞。
static uint8_t can_seq; void can_send_attitude(void) { uint8_t data[8]; int16_t yaw = (int16_t)(att.yaw * 5729.58f); // 弧度转 0.01° int16_t pitch = (int16_t)(att.pitch * 5729.58f); int16_t roll = (int16_t)(att.roll * 5729.58f); data[0] = (uint8_t)(yaw >> 8); data[1] = (uint8_t)(yaw & 0xFF); data[2] = (uint8_t)(pitch >> 8); data[3] = (uint8_t)(pitch & 0xFF); data[4] = (uint8_t)(roll >> 8); data[5] = (uint8_t)(roll & 0xFF); data[6] = (uint8_t)(0x01 | ((att.status & 0x03) << 1)); data[7] = can_seq++; uint32_t mailbox; if (HAL_CAN_GetTxMailboxesFreeLevel(&hcan1) > 0) { HAL_CAN_AddTxMessage(&hcan1, &tx_header, data, &mailbox); } }tx_header的配置在前面第 2 章的 CubeMX 生成代码中已经定义,显式指定一下更稳妥:
CAN_TxHeaderTypeDef tx_header = { .StdId = 0x181, .ExtId = 0, .IDE = CAN_ID_STD, .RTR = CAN_RTR_DATA, .DLC = 8, .TransmitGlobalTime = DISABLE, };发送时机放在主循环的 tick 判断里,不放在 BNO085 的 UART 中断回调里直接发。原因是 SH-2 上报频率和 CAN 发送频率未必一致,串口中断里做 CAN 发送会让优先级关系变得复杂。
4.3 发送周期与总线负载怎么权衡
假设 BNO085 每 20ms 出一包姿态数据,CAN 发送周期最好也设为 20ms 或 10ms。如果设 1ms 发送,总线上一半以上的报文都在重复同一份姿态数据,负载率会被白白抬高。按 500kbps 计算,一个 8 字节标准数据帧包含帧头和填充后大约占用 220μs 到 250μs。
| 发送周期 | 单节点负载率 | 5 节点同频率发送 |
|---|---|---|
| 1ms | 约 25% | 超过 100%,总线不可用 |
| 5ms | 约 5% | 25% |
| 10ms | 约 2.5% | 12.5% |
| 20ms | 约 1.25% | 6.25% |
CAN 总线的仲裁机制是 ID 越小优先级越高。0x181 属于自定义地址,在它上面可以再挂更高优先级的节点用 0x100 之类的 ID,确保紧急报文能先发出去。如果你的项目中还有一个控制器接收这条姿态报文,务必让接收端只按 ID 过滤,不要依赖字节里的帧计数来判断报文先后顺序。
4.4 关于 CAN FD 的一个提醒
如果用的是带 FDCAN 的 STM32G4 或 H7,且总线上的其他节点也支持 CAN FD,可以把四元数、欧拉角、时间戳一次塞进 64 字节数据段里,甚至做速率切换加快传输。但真正的工程选型里,CAN FD 的兼容性问题远多于标准 CAN。毕设和课设的外设节点往往是 USB-CAN 分析仪,很多低价分析仪只支持标准 CAN,不支持 CAN FD,如果一开始就上 FD,到最后验证环节会发现自己抓不了包。建议默认用标准 CAN 把链路跑通,再考虑要不要升级 FD。
5. 用回环和波形把整条 CAN 链路验收掉
到了最后阶段,数据已经从 BNO085 读到 STM32,也调用了 CAN 发送函数。这时最大的风险不是发送代码,而是总线上根本没有人确认报文。下面这套验证路径我每次都会走一遍,能把问题范围快速缩小。
5.1 回环模式:不接外部设备先自测
先把 CAN1 的工作模式改成回环模式,这一步不需要外部 CAN 收发器和终端电阻,数据会从内部直接回到接收 FIFO。
hcan1.Init.Mode = CAN_MODE_LOOPBACK; HAL_CAN_Start(&hcan1);在发送函数被调用后,打开 CAN1 接收中断,在HAL_CAN_RxFifo0MsgPendingCallback回调里打印收到的 ID 和数据。如果收到的 ID 是 0x181,且数据里的 yaw 和本地发送前的 yaw 一致,说明 CAN 外设的配置和发送流程是正确的,问题只剩外部物理链路。
5.2 用示波器看 CAN 波形判断链路好坏
接上 TJA1050 和外部总线后,用示波器探头分别点 CANH 和 CANL,重点看差分电压。隐性电平时 CANH 和 CANL 都在 2.5V 附近,差分接近 0V;显性电平时 CANH 升到约 3.5V,CANL 降到约 1.5V,差分约 2V。如果波形幅值明显偏低,或者显性电平持续时间不稳定,先检查终端电阻。
| 现象 | 可能原因 |
|---|---|
| 波形幅值只有 1V 左右 | 缺少终端电阻,或某端 120Ω 脱落 |
| 帧和帧之间毛刺多 | 节点供电不稳,或收发器电源滤波不够 |
| 一直发错误帧 | 波特率不匹配,或总线上某个节点未上电 |
| 发送后无 ACK 时隙 | 总线只有一个节点,收发器无 ACK 回应 |
这里有一个很容易被忽略的坑:如果总线上只有 STM32 一个节点且没有接 USB-CAN 分析仪,发送的帧会因为没人回复 ACK 而导致发送邮箱一直被占住,现象是 Can 发送失败或初始化后无法继续发送。这不是代码问题,接一个能回 ACK 的节点就能解决。
5.3 用 USB-CAN 抓包做端到端一致性校验
最后一步是把 USB-CAN 分析仪挂到总线上,波特率设为 500kbps,抓取 0x181 的报文。让 BNO085 在水平桌面上缓慢旋转,观察分析软件里的 yaw 值是否从 0 到 360 平滑变化,帧计数器是否连续递增。如果帧计数器出现跳号,说明某一次发送被总线错误打断,需要回到第 5.2 节查波形。
还建议做一次“数据有效性”的确认:把模块断电再上电,观察状态字从 0 到 3 的过程。精度状态 0 表示传感器还在初始化,此时 CAN 帧里的欧拉角不可信;状态变成 3 之后,姿态数据才能被接收端当作有效值。把这一条逻辑直接写进接收端,比在发送端过滤更可靠。
本文还有配套的精品资源,点击获取