☰
STM32 IIC驱动ADXL345三轴加速度计:从寄存器配置到串口输出避坑指南
2026/10/14 19:51:26 网站建设 项目流程

简介:这份资源面向正在学习STM32嵌入式开发、需要驱动加速度传感器的初学者与工程师,解决ADXL345数据采集与姿态角度换算的实操问题。包内以STM32F103为主控,采用模拟IIC协议与ADXL345通信,并将三轴加速度值经串口换算为角度输出,代码经作者亲自测试可用。压缩包共128个文件,约2.12MB,包含32个c源文件与33个h头文件,覆盖stm32f10x系列外设驱动及ADXL345相关逻辑,另有uvproj、uvopt等Keil工程文件、hex与axf可执行文件、map与lst编译映射清单,以及若干bak备份与htm说明文档,工程结构完整,可直接编译下载验证。目前已有3757人学习下载。读者可从中获得模拟IIC时序实现、传感器寄存器配置、角度换算与串口打印的完整参考,并借助工程文件快速复现调试,适合作为课程设计、毕业设计或嵌入式入门练手的实践素材。

1. 从零手搓 IIC 驱动:为什么 ADXL345 总在串口里装死

如果你手头有一块带 ADXL345 的模块,想用 STM32 通过 IIC 协议把三轴加速度读出来,再从串口打印到电脑上看波形,大概率会遇到一个很玄学的现象:代码编译通过,烧录成功,串口助手打开,结果屏幕上干干净净,一个字符都没有。或者更诡异,串口有输出,但三个轴的数据全是 0,或者全是 65535。

这不是你一个人的问题。ADXL345 这颗三轴加速度计在嵌入式圈子里属于“经典但容易翻车”的器件。它便宜、好用、量程可调、支持中断,但它的 IIC 时序、寄存器上电顺序、数据格式(尤其是补码和 13 位对齐)有几个非常隐蔽的坑。很多教程只贴一段初始化代码,却不告诉你为什么某个寄存器必须先写 0x2D 再写 0x31,也不解释为什么读出来的数据要拆成高低字节再拼成有符号整数。

这篇笔记面向的是已经会用 STM32 点灯、会配置串口、但对 IIC 和传感器驱动还不太熟的工程师。我会从 ADXL345 的寄存器地图讲起,把 IIC 读写时序、初始化序列、数据解析、串口输出格式、以及最常见的五个翻车点全部拆开。目标只有一个:你照着做,串口助手里能稳定看到三轴加速度的数值,并且知道每个参数为什么这么设。

2. ADXL345 的寄存器地图与 IIC 读写时序:先搞懂再动手

2.1 为什么 ADXL345 的 IIC 地址是 0x53 而不是 0xA6

ADXL345 支持 IIC 和 SPI 两种接口,通过 CS 引脚电平选择。当 CS 接高电平时,器件工作在 IIC 模式。它的 7 位 IIC 从机地址是 0x53,这个地址是固定的,不可通过引脚更改。但很多新手在写代码时会困惑:为什么有的例程写 0xA6,有的写 0x53?

原因是 IIC 协议在传输时,地址字节是 7 位地址左移一位,最低位表示读/写方向。所以 7 位地址 0x53 左移一位得到 0xA6,写操作时地址字节为 0xA6,读操作时为 0xA7。如果你用 STM32 的硬件 IIC 外设,HAL 库函数通常要求传入左移后的 8 位地址;如果你用软件模拟 IIC,可以自己决定传 7 位还是 8 位。我一般统一用 7 位地址 0x53,在发送时再左移并或上读写位,这样逻辑更清晰。

ADXL345 的寄存器地址是 8 位的,从 0x00 到 0x39。常用的寄存器包括:

寄存器地址名称功能常用值
0x00DEVID器件 ID,固定为 0xE5只读
0x2DPOWER_CTL电源控制,决定是否进入测量模式0x08 或 0x00
0x31DATA_FORMAT数据格式,量程、对齐方式0x0B 或 0x08
0x32~0x37DATAX0~DATAZ1三轴加速度数据,低字节在前只读
0x38FIFO_CTLFIFO 控制一般默认
0x39FIFO_STATUSFIFO 状态只读

上电后,ADXL345 默认处于待机模式,此时它不更新输出数据寄存器。你必须先向 POWER_CTL 写入 0x08,把器件切换到测量模式,否则读出来的数据永远是 0。这是第一个大坑,后面避坑章节会详细说。

2.2 用软件模拟 IIC 还是硬件 IIC:选型理由与引脚配置

STM32 的硬件 IIC 外设在一些老型号上有已知的时序缺陷,容易在总线仲裁或时钟拉伸时卡死。虽然新出的 HAL 库做了不少修补,但如果你用的是 F1 或 F4 系列,我建议直接用两个普通 GPIO 模拟 IIC。模拟 IIC 的好处是时序完全可控,出问题容易用逻辑分析仪抓,而且不占用硬件 IIC 外设,可以留出来接其他器件。

模拟 IIC 需要两个引脚:SCL 和 SDA。我一般选 PB6 和 PB7,因为这两个引脚在很多开发板上已经接了上拉电阻。如果没有外部上拉,需要开启 STM32 内部上拉,但内部上拉阻值较大(约 40kΩ),在 400kHz 速率下可能上升沿太慢。稳妥做法是在模块端或板子上加 4.7kΩ 外部上拉。

下面是模拟 IIC 的 GPIO 初始化代码,以 STM32 HAL 库为例:

// 模拟 IIC 引脚初始化,PB6-SCL, PB7-SDA void IIC_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // 配置 SCL 和 SDA 为开漏输出,开漏模式需要外部上拉 GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull = GPIO_PULLUP; // 内部上拉,外部最好再加 4.7k GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // 初始状态:SCL 和 SDA 都拉高 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6 | GPIO_PIN_7, GPIO_PIN_SET); }

这段代码把 PB6 和 PB7 设为开漏输出并开启内部上拉。开漏模式的好处是 SDA 线可以实现双向通信:输出低电平时拉低,输出高电平时释放总线,由外部上拉电阻拉高。在读取 ACK 或数据时,需要先把 SDA 切换为输入模式,或者直接读引脚电平。我习惯在读数据前把 SDA 设为输入,读完再切回输出。

2.3 IIC 起始、停止、应答:三个必须自己写的底层函数

模拟 IIC 的核心是六个基本时序:起始条件、停止条件、发送一个字节、接收一个字节、发送应答、接收应答。这些函数必须自己写,因为不同传感器的时序容忍度不同。ADXL345 支持标准模式 100kHz 和快速模式 400kHz,我一般用 100kHz 起步,稳定后再提速。

// 起始条件:SCL 高时,SDA 由高变低 void IIC_Start(void) { SDA_OUT(); // SDA 设为输出 SDA_HIGH(); SCL_HIGH(); delay_us(4); SDA_LOW(); // SCL 高时拉低 SDA,产生起始条件 delay_us(4); SCL_LOW(); // 钳住 SCL,准备发送数据 delay_us(4); } // 停止条件:SCL 高时,SDA 由低变高 void IIC_Stop(void) { SDA_OUT(); SCL_LOW(); SDA_LOW(); delay_us(4); SCL_HIGH(); delay_us(4); SDA_HIGH(); // SCL 高时拉高 SDA,产生停止条件 delay_us(4); } // 发送一个字节,返回从机应答位:0 表示应答,1 表示非应答 uint8_t IIC_SendByte(uint8_t txd) { uint8_t t; SDA_OUT(); SCL_LOW(); for (t = 0; t < 8; t++) { // 从最高位开始发送 if (txd & 0x80) SDA_HIGH(); else SDA_LOW(); txd <<= 1; delay_us(2); SCL_HIGH(); // 拉高时钟,从机在此时采样 delay_us(2); SCL_LOW(); // 拉低时钟,准备下一位 delay_us(2); } // 第 9 个时钟周期读取从机应答 SDA_IN(); // SDA 设为输入 SDA_HIGH(); // 释放 SDA,由从机拉低表示应答 delay_us(2); SCL_HIGH(); delay_us(2); t = SDA_READ(); // 读取 SDA 电平 SCL_LOW(); delay_us(2); return t; }

起始条件的关键是 SCL 为高时 SDA 产生下降沿,停止条件是 SCL 为高时 SDA 产生上升沿。发送字节时,数据在 SCL 低电平期间变化,在 SCL 高电平期间保持稳定,从机在 SCL 高电平时采样。第 9 个时钟周期,主机释放 SDA,从机如果拉低 SDA 表示应答。如果返回 1,说明从机没有应答,常见原因是地址错误或器件没接好。

读字节和读应答的逻辑类似,只是方向反过来。这里不再重复贴代码,但要注意:读数据前必须把 SDA 设为输入,并且主机在接收完最后一个字节后要发送非应答(NACK),然后发停止条件。

3. 初始化序列与数据读取:把 0xE5 读出来再谈其他

3.1 上电后第一件事:读 DEVID 确认 IIC 链路通了

在写任何初始化之前,必须先读 0x00 寄存器,确认返回值是 0xE5。这一步能排除 80% 的硬件问题。如果读出来是 0x00 或 0xFF,说明 IIC 通信根本没建立,后面所有寄存器操作都是徒劳。

// 读取 ADXL345 的器件 ID,正常应返回 0xE5 uint8_t ADXL345_ReadID(void) { uint8_t id = 0; IIC_Start(); IIC_SendByte(0x53 << 1); // 发送 7 位地址左移 + 写方向 if (IIC_WaitAck() != 0) { // 等待应答 IIC_Stop(); return 0; // 无应答,返回 0 } IIC_SendByte(0x00); // 发送寄存器地址 DEVID IIC_WaitAck(); IIC_Start(); // 重复起始条件 IIC_SendByte((0x53 << 1) | 0x01); // 地址 + 读方向 IIC_WaitAck(); id = IIC_ReadByte(0); // 读取一个字节,发送 NACK IIC_Stop(); return id; }

这段代码展示了 IIC 读寄存器的标准流程:起始 → 发送地址+写 → 发送寄存器地址 → 重复起始 → 发送地址+读 → 读数据 → 发送 NACK → 停止。注意在发送寄存器地址后没有停止条件,而是用重复起始条件切换方向,这是 IIC 读操作的规范做法。如果这里返回 0xE5,说明硬件连接和基本时序都没问题,可以继续。

3.2 初始化四步走:0x2D、0x31、0x38 的顺序不能乱

ADXL345 的初始化顺序有讲究。我一般按以下四步走:

第一步,向 0x2D 写入 0x00,先让器件进入待机模式,确保在配置其他寄存器时不会产生意外中断或数据更新。

第二步,向 0x31 写入 0x0B。这个值的含义是:全分辨率模式(bit 3 = 1),±16g 量程(bit 1:0 = 11),数据右对齐补码。全分辨率模式下,输出分辨率固定为 3.9mg/LSB,不随量程变化。如果你只需要 ±2g 量程,可以写 0x08,此时分辨率是 10 位,灵敏度约 3.9mg/LSB。

第三步,向 0x2D 写入 0x08,切换到测量模式。这一步必须在配置完 DATA_FORMAT 之后做,否则量程设置可能不生效。

第四步,如果需要 FIFO,配置 0x38;如果不用,保持默认即可。

// ADXL345 初始化序列 void ADXL345_Init(void) { ADXL345_WriteReg(0x2D, 0x00); // 先进入待机模式 HAL_Delay(10); ADXL345_WriteReg(0x31, 0x0B); // 全分辨率,±16g,右对齐 HAL_Delay(10); ADXL345_WriteReg(0x2D, 0x08); // 切换到测量模式 HAL_Delay(10); }

写寄存器的函数和读类似,只是方向全是写,不需要重复起始。注意每次写操作之间加 10ms 延时,给器件内部寄存器更新时间。虽然数据手册说写入立即生效,但实际调试中发现不加延时偶尔会出现配置丢失,尤其是电源模式切换。

3.3 读取三轴数据:高低字节拼接与补码转换

ADXL345 的数据寄存器从 0x32 到 0x37,共 6 个字节,依次是 DATAX0、DATAX1、DATAY0、DATAY1、DATAZ0、DATAZ1。低字节在前,高字节在后。拼接时要注意:先读低字节,再读高字节,然后组合成 16 位有符号整数。

// 读取三轴加速度原始值 void ADXL345_ReadAccel(int16_t *x, int16_t *y, int16_t *z) { uint8_t buf[6]; ADXL345_ReadRegs(0x32, buf, 6); // 连续读 6 个寄存器 // 低字节在前,高字节在后,拼接成 16 位有符号数 *x = (int16_t)((buf[1] << 8) | buf[0]); *y = (int16_t)((buf[3] << 8) | buf[2]); *z = (int16_t)((buf[5] << 8) | buf[4]); }

这里的关键是强制转换为 int16_t。如果直接用 uint16_t 拼接,负加速度会变成很大的正数。ADXL345 输出的是二进制补码,全分辨率模式下 13 位有效,但寄存器是 16 位,高 3 位是符号扩展。所以直接按 int16_t 解释即可。

连续读多个寄存器时,ADXL345 支持地址自动递增。你只需要发送起始地址 0x32,然后连续读 6 个字节,器件会自动切换到下一个寄存器。这个特性在读取三轴数据时非常方便,避免了多次起始-停止的开销。

4. 串口输出格式化与数据校验:让数据能看、能信、能存

4.1 串口配置与 printf 重定向

STM32 的串口配置用 HAL 库初始化即可,波特率我一般用 115200,数据位 8,停止位 1,无校验。为了能用 printf 直接输出,需要重定向 fputc 函数:

// 重定向 printf 到串口 1 int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

注意在 Keil 中需要勾选 Use MicroLIB,否则 printf 可能无法正常工作。如果你用的是 GCC 工具链,需要自己实现 _write 函数。重定向后,就可以用 printf 格式化输出三轴数据了。

4.2 输出格式设计:CSV 还是自定义帧头

串口输出的格式决定了你后续怎么用数据。如果只是肉眼观察,可以输出成X: 123, Y: -45, Z: 980这样的可读格式。如果要存到电脑上用 Excel 或 Python 画波形,建议输出 CSV 格式,每行三个数,用逗号分隔,不加单位。

// 主循环中读取并输出加速度 while (1) { int16_t x, y, z; ADXL345_ReadAccel(&x, &y, &z); // 原始值转换为 mg 单位:全分辨率下 3.9mg/LSB float x_mg = x * 3.9f; float y_mg = y * 3.9f; float z_mg = z * 3.9f; // CSV 格式输出,方便后续解析 printf("%d,%d,%d\r\n", x, y, z); HAL_Delay(50); // 20Hz 输出频率 }

这里输出的是原始值,不是 mg 值。原始值的好处是保留了全部精度,后续在电脑上可以自己换算。如果你直接输出 mg 值,浮点数打印会占用更多串口带宽,而且在低波特率下可能丢数据。20Hz 的输出频率对 115200 波特率来说绰绰有余,每行大约 20 字节,每秒 400 字节,远低于串口容量。

4.3 用静止和翻转测试验证数据正确性

数据能打印出来不代表正确。我一般做两个测试:

第一个是静止测试。把模块平放在桌面上,Z 轴应该输出接近 +1g 的值,X 和 Y 接近 0。在全分辨率 ±16g 模式下,1g 对应的原始值约为 256(因为 3.9mg/LSB,1000mg / 3.9 ≈ 256)。所以静止时 Z 轴原始值应该在 256 左右,X 和 Y 在 0 附近波动。

第二个是翻转测试。把模块翻转 180 度,Z 轴应该从 +256 变成 -256。如果翻转后数值不变或者变化很小,说明数据解析有问题,大概率是高低字节拼接顺序错了,或者补码转换没做对。

这两个测试能快速定位大部分数据异常。如果静止时 Z 轴是 0,检查 POWER_CTL 是否写入了 0x08;如果数值跳变剧烈,检查电源是否干净,ADXL345 对电源纹波比较敏感,建议在 VCC 和 GND 之间加 0.1uF 和 10uF 电容。

5. 避坑与排查:ADXL345 调试中最容易翻车的五个点

5.1 读出来全是 0:POWER_CTL 没切到测量模式

现象:串口有输出,但三轴数据全是 0,无论怎么晃动模块都不变。

原因:ADXL345 上电默认处于待机模式,此时输出数据寄存器不更新。很多例程只写了 DATA_FORMAT 就以为初始化完了,漏掉了 POWER_CTL 的 0x08。

解决:在初始化序列的最后,确认向 0x2D 写入了 0x08。可以读回 0x2D 验证,如果返回值不是 0x08,说明写入失败,检查 IIC 写函数是否正常。

5.2 读出来全是 65535 或负数:高低字节拼接顺序反了

现象:静止时 Z 轴显示 65535 或 -1,晃动时数值乱跳。

原因:ADXL345 的数据寄存器是低字节在前,高字节在后。如果先读高字节再读低字节,或者拼接时把高低字节位置搞反,就会得到完全错误的值。

解决:确认读取顺序是 DATAX0、DATAX1、DATAY0、DATAY1、DATAZ0、DATAZ1。拼接时用(buf[1] << 8) | buf[0],不要写成(buf[0] << 8) | buf[1]。

5.3 IIC 读不出 DEVID:上拉电阻缺失或地址错误

现象:读 0x00 寄存器返回 0x00 或 0xFF,IIC 通信完全失败。

原因:SDA 和 SCL 没有上拉电阻,或者上拉阻值太大导致上升沿太慢。另一个常见原因是 IIC 地址搞错,把 7 位地址 0x53 直接当 8 位地址发送。

解决:用万用表测量 SDA 和 SCL 对 VCC 的电阻,正常应该在 4.7kΩ 左右。如果无穷大,需要加外部上拉。地址方面,确认发送的是0x53 << 1或0xA6,而不是0x53。

5.4 数据波动大:电源噪声或输出速率设置不当

现象:模块静止时数据在几十个 LSB 范围内跳动,无法稳定。

原因:ADXL345 对电源噪声敏感,尤其是数字电源和模拟电源共用时。另外,输出速率(ODR)设置过高也会引入噪声。

解决:在 VCC 和 GND 之间加 0.1uF 陶瓷电容和 10uF 钽电容。如果不需要高速采样,把 ODR 降到 50Hz 或 100Hz。ADXL345 的 ODR 由 BW_RATE 寄存器 0x2C 控制,默认是 100Hz,一般够用。

5.5 串口输出乱码:波特率不匹配或 printf 重定向失败

现象:串口助手收到乱码,或者完全没有输出。

原因:STM32 的串口波特率和串口助手设置不一致,或者 printf 重定向没有生效。

解决:确认串口助手波特率是 115200,数据位 8,停止位 1,无校验。如果用的是 Keil,勾选 Use MicroLIB。如果用的是 GCC,检查 _write 函数是否实现。还可以在初始化后先发送一个固定的字符串测试,比如printf("ADXL345 Test\r\n"),确认串口链路通了再读传感器。

6. 进阶技巧:用 FIFO 和中断做低功耗数据采集

6.1 FIFO 模式配置:BYPASS、FIFO、STREAM 怎么选

ADXL345 内置一个 32 级 FIFO,可以在 MCU 休眠时缓存数据,减少唤醒次数。FIFO_CTL 寄存器 0x38 的低 5 位设置水印级别,bit 6:5 设置模式,bit 7 设置触发中断的条件。

常用模式有三种:BYPASS 模式(FIFO 关闭,只保留最新数据)、FIFO 模式(填满后停止,适合一次性采集)、STREAM 模式(填满后覆盖最旧数据,适合连续采集)。我一般用 STREAM 模式,水印设为 16,这样 FIFO 半满时触发中断,MCU 醒来一次性读 16 组数据。

// 配置 FIFO 为 STREAM 模式,水印 16 void ADXL345_ConfigFIFO(void) { // bit 7:0 = 0, bit 6:5 = 10 (STREAM), bit 4:0 = 16 (水印) ADXL345_WriteReg(0x38, 0x80 | 0x10); // 0x80 使能 FIFO 中断触发 HAL_Delay(10); }

水印值设为 16 意味着 FIFO 中积累 16 组数据后,INT1 或 INT2 引脚会产生中断。MCU 在中断服务函数中读取 FIFO_STATUS 寄存器 0x39,确认有效数据组数,然后连续读取相应数量的字节。

6.2 中断引脚配置与 MCU 唤醒

ADXL345 的中断引脚可以映射到 INT1 或 INT2。需要配置 INT_ENABLE 寄存器 0x2E 使能 FIFO 中断(bit 2 = 1),然后配置 INT_MAP 寄存器 0x2F 决定映射到哪个引脚。

// 使能 FIFO 中断并映射到 INT1 void ADXL345_EnableFIFOInterrupt(void) { ADXL345_WriteReg(0x2E, 0x02); // 使能 FIFO 中断 ADXL345_WriteReg(0x2F, 0x00); // 映射到 INT1 引脚 HAL_Delay(10); }

MCU 端把 INT1 引脚配置为外部中断,下降沿触发。在中断服务函数中,先读 FIFO_STATUS 确认数据组数,然后连续读取 6 倍组数的字节,解析后通过串口输出或存入缓冲区。注意在中断中不要做浮点运算和 printf,只做数据搬运,主循环再处理。

6.3 用 Python 脚本解析串口 CSV 并画波形

数据输出到电脑后,可以用 Python 快速验证。下面是一个最小脚本,读取串口 CSV 并实时绘制三轴波形:

import serial import matplotlib.pyplot as plt import matplotlib.animation as animation ser = serial.Serial('COM3', 115200, timeout=1) fig, ax = plt.subplots() xs, ys, zs = [], [], [] def update(frame): line = ser.readline().decode('utf-8', errors='ignore').strip() if not line: return try: x, y, z = map(int, line.split(',')) except ValueError: return xs.append(x) ys.append(y) zs.append(z) # 只保留最近 200 个点 for lst in (xs, ys, zs): if len(lst) > 200: lst.pop(0) ax.clear() ax.plot(xs, label='X') ax.plot(ys, label='Y') ax.plot(zs, label='Z') ax.legend(loc='upper right') ax.set_ylim(-600, 600) ani = animation.FuncAnimation(fig, update, interval=50) plt.show()

这个脚本假设串口每行输出三个整数,用逗号分隔。运行后应该能看到三条实时波形。静止时 Z 轴在 256 附近,X 和 Y 在 0 附近。晃动模块时波形会相应变化。如果波形是一条直线或者噪声极大,回到第 5 章排查。

6.4 我踩过的最后一个坑:FIFO 读完后必须重新使能

FIFO 在 STREAM 模式下,读空后会自动继续填充,不需要额外操作。但在 FIFO 模式下,读完后 FIFO 会停止,必须重新写 FIFO_CTL 才能再次启动。我一开始用 FIFO 模式做低功耗采集,发现第二次中断再也不来了,查了半天才发现是 FIFO 满了之后没有重新配置。后来统一改用 STREAM 模式,这个问题就再也没出现过。

另外,读取 FIFO 数据时,建议一次性连续读取,不要分多次读。因为每次起始-停止都会占用总线时间,而且在读的过程中 FIFO 可能继续填充,导致数据错位。我一般根据 FIFO_STATUS 返回的组数,计算总字节数,然后一次连续读出来。

如果你也在用 ADXL345 做姿态检测或振动监测,建议先把本文的初始化序列和串口输出跑通,确认静止和翻转测试通过,再往上加 FIFO 和中断。传感器调试没有捷径,先把最基础的 IIC 读写和寄存器配置搞扎实,后面的功能都是水到渠成。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询