
简介基于STM32平台的3线非库SPI方式连接ADXL345加速度计完整工程包面向嵌入式开发者可在不依赖标准库的条件下实现三轴加速度数据读取适合资源受限或要求底层可控的场景。包内共22个文件包含C源文件、头文件、启动文件、Keil工程配置以及HEX、ELF、MAP等编译产物均带完整工程配置压缩包仅69KB便于直接查看或烧录验证。目前已有173人学习适合具备基础STM32知识的开发者参考。资源不仅有寄存器级SPI通信代码与3线模式GPIO配置还演示了ADXL345寄存器写入、数据读取和16位二进制补码转换的思路可帮助理解SPI时序和器件数据手册亦能快速移植到实际项目中。 嵌入式项目里凡是涉及ADXL345的基本都能找到一堆现成例程但大多是用I2C或者4线SPI配上HAL库或者标准外设库拿来就能跑。最近我在一个资源比较紧张的板子上调“3线非库SPI接ADXL345”不依赖任何库函数纯寄存器操作结果发现这里的门道比想象中多。尤其是3线SPI的方向切换、寄存器里的SPI模式位、测量模式的坑每一个都能让人折腾半天。这篇就把我完整的调试过程、代码和踩过的坑都记录下来给同样准备这么干的人一个参考。1. 先搞懂3线SPI和非库方案背后的逻辑1.1 3线SPI和4线SPI的本质区别很多朋友接触SPI都是从4线开始的SCK、CS、MOSI、MISO全双工一发一收简单直接。但有些场景下引脚不够用尤其是小封装MCUGPIO恨不得一个掰成两个用这时候3线SPI就非常有价值。3线SPI其实就是把MOSI和MISO合并成一根双向数据线SDA配合SCK和CS一共三根线。通信变成半双工主机发命令时SDA作为输出主机读数据时SDA立即切换成输入。ADXL345本身支持这种模式在数据手册里写得很清楚可以工作在3线或4线SPI模式具体由DATA_FORMAT寄存器0x31的bit6控制置1就启用3线模式。这里有个很容易踩的误区不少模块默认是按4线模式工作的如果你只接了三根线并且没有把DATA_FORMAT的bit6置1那么读出来的数据大概率是全0xFF或者乱跳。我一开始就是在验证DEVID时卡了半天后来翻手册才发现这个位没设。1.2 为什么不用HAL库非要寄存器操作说回“非库”这个点。很多人觉得STM32有HAL库SPI_TransmitReceive一调就完事了为什么还要自讨苦吃去操作寄存器我这次的选择主要基于三个现实原因。第一是固件体积和RAM。板子主控资源紧张HAL库一开SPI外设初始化带中断带DMAflash和RAM开销都不小。寄存器操作只占用几十字节干净利落。第二是3线SPI半双工模式下硬件SPI外设要配置BIDIMODE双向数据模式还要在收发之间切换BIDIOE位来改变数据线方向这套操作如果封装在库函数里每读一个字节都要做一堆上下文切换反而别扭。用GPIO模拟方向切换完全可控时序心里门儿清。第三是从学习角度GPIO模拟SPI能逼着你把协议每个细节吃透。当时钟频率、相位、方向切换都由自己控制时你对整个通信过程的理解会完全不一样。这不是说HAL库不好而是在这种特殊模式下寄存器操作往往更直接、更可控。2. 硬件连接和初始化准备2.1 引脚分配与电路细节先看接线。我用的ADXL345是常见的那种小模块引脚有VCC、GND、CS、SCL/SCK、SDA/SDI、SDO。3线SPI模式下核心连接如下ADXL345引脚接MCU引脚说明VCC3.3VADXL345供电范围2.0~3.6V别接5VGNDGND共地CSPA4片选低电平有效SCLKPA5SPI时钟SDA/SDIPA6双向数据线SDO悬空或接上拉3线模式下不用这里特别注意两点。第一ADXL345是3.3V器件如果MCU是5V供电必须做电平转换否则长期运行容易烧芯片。第二SDA这根双向数据线建议在模块端加一个4.7kΩ到10kΩ的上拉电阻到VCC。原因很简单3线模式下数据线是开漏还是推挽取决于模块内部设计实测很多模块的SDA在输入模式下是高阻态没有上拉的话主机读到的电平可能不稳定尤其是通信速率稍高的时候。还有人会问SDO是不是可以接地。这个我建议悬空就好因为ADXL345的SDO引脚在SPI模式下还有一个作用它和I2C的地址选择位共用。3线SPI模式下不参与数据悬空最保险。2.2 GPIO初始化的寄存器配置引脚选定后先把GPIO初始化写好。既然是“非库”我直接操作GPIO的CRL寄存器STM32F103的PA0~PA7由CRL控制每个引脚占用4位对应关系是引脚编号乘以4。#define ADXL_CS_PIN 4 #define ADXL_SCK_PIN 5 #define ADXL_SDA_PIN 6 #define ADXL_CS_PORT GPIOA #define ADXL_SCK_PORT GPIOA #define ADXL_SDA_PORT GPIOA // 方向控制宏SDA引脚在推挽输出(03)和浮空输入(04)之间切换 #define SDA_DIR_OUT() do { \ GPIOA-CRL ~(0xF (ADXL_SDA_PIN * 4)); \ GPIOA-CRL | (0x3 (ADXL_SDA_PIN * 4)); \ } while(0) #define SDA_DIR_IN() do { \ GPIOA-CRL ~(0xF (ADXL_SDA_PIN * 4)); \ GPIOA-CRL | (0x4 (ADXL_SDA_PIN * 4)); \ } while(0) // 电平控制宏 #define CS_LOW() GPIOA-BRR (1 ADXL_CS_PIN) #define CS_HIGH() GPIOA-BSRR (1 ADXL_CS_PIN) #define SCK_LOW() GPIOA-BRR (1 ADXL_SCK_PIN) #define SCK_HIGH() GPIOA-BSRR (1 ADXL_SCK_PIN) #define SDA_HIGH() GPIOA-BSRR (1 ADXL_SDA_PIN) #define SDA_LOW() GPIOA-BRR (1 ADXL_SDA_PIN) #define SDA_READ() ((GPIOA-IDR (1 ADXL_SDA_PIN)) ! 0)初始化函数里先把CS、SCK配成推挽输出SDA先配成输出默认拉高。void adxl_spi_gpio_init(void) { // 开启GPIOA时钟RCC-APB2ENR bit2 对应IOPA RCC-APB2ENR | (1 2); // PA4、PA5、PA6全部先设为推挽输出50MHz GPIOA-CRL ~(0xFFF (4 * 4)); GPIOA-CRL | (0x333 (4 * 4)); CS_HIGH(); SCK_HIGH(); SDA_HIGH(); }CRL寄存器操作时0x3表示通用推挽输出50MHz0x4表示浮空输入。这个写法在F1系列上通用换到F4或者G4系列就得改成MODER寄存器了思路一致。3. 寄存器级3线SPI时序实现3.1 底层读写字节函数3线SPI的核心全在这两个函数里。先看写字节这个和4线SPI的MOSI写一模一样唯一要注意的是MSB先行时钟相位选择模式0也就是SCK空闲为低数据在上升沿被采样。void spi3_write_byte(uint8_t data) { for (int i 7; i 0; i--) { SCK_LOW(); if (data (1 i)) SDA_HIGH(); else SDA_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); } }读字节就有讲究了因为SDA这根线现在是双向的。主机必须先把它从输出模式切到输入模式然后再产生时钟去采样数据。uint8_t spi3_read_byte(void) { uint8_t data 0; SDA_DIR_IN(); // 关键切输入 for (int i 0; i 8; i) { SCK_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); data (data 1) | (SDA_READ() ? 1 : 0); } SDA_DIR_OUT(); // 读完后切回输出方便下次发命令 return data; }3.2 方向切换是3线SPI的灵魂上面代码里方向切换的位置非常关键。ADXL345在SCK低电平期间把数据输出到SDA线上主机在SCK高电平期间采样所以在SCK拉高之前把SDA切成输入并且保证方向切换发生在SCK空闲低电平状态这是整个3线通信稳定的前提。我第一次写的时候偷了个懒在发送完命令字节后没立刻切方向而是等到循环里才切结果第一个字节的高位全读错数据又乱又跳。后来把SDA_DIR_IN()提到循环之前一切就正常了。还要注意方向切换之后SDA线上有可能会有瞬间的不稳定。解决办法就是加一个微小延时或者像上面代码那样在SCK拉低到拉高之间保留1us的间隔。实际测试1us在100kHz的通信速率下完全够用如果MCU主频高可以把这个延时缩短到几百纳秒但别完全去掉。3.3 ADXL345寄存器读写命令格式有了底层收发函数就可以封装ADXL345的寄存器操作了。ADXL345的SPI命令格式很简单第一个字节最高位是读写标志位1表示读0表示写剩余7位是寄存器地址。所以读命令是0x80|reg写命令是reg0x7F。uint8_t adxl345_read_reg(uint8_t reg) { uint8_t val 0; CS_LOW(); spi3_write_byte(0x80 | reg); val spi3_read_byte(); CS_HIGH(); return val; } void adxl345_write_reg(uint8_t reg, uint8_t val) { CS_LOW(); spi3_write_byte(reg 0x7F); spi3_write_byte(val); CS_HIGH(); }多字节读也有技巧。ADXL345支持连续读多个寄存器地址自动递增读DATAX0到DATAZ1六个寄存器时特别方便。操作上就是CS拉低后先发读命令0x80|0x32然后连续读取六个字节最后CS拉高。整个过程CS必须保持低电平中间不能松。void adxl345_read_multi(uint8_t start_reg, uint8_t *buf, uint8_t len) { CS_LOW(); spi3_write_byte(0x80 | start_reg); SDA_DIR_IN(); for (int i 0; i len; i) { SCK_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); buf[i] (uint8_t)((buf[i] 0) | (SDA_READ() ? 1 : 0)); for (int j 1; j 8; j) { SCK_LOW(); delay_us(1); SCK_HIGH(); delay_us(1); buf[i] (buf[i] 1) | (SDA_READ() ? 1 : 0); } } SDA_DIR_OUT(); CS_HIGH(); }这段代码比单字节读稍微繁琐但好处是只用了一次CS拉低效率高很多。实际上就是手动展开了8位读取避免在连续读时频繁切方向造成时序间隙。4. 完整初始化与加速度数据读取4.1 初始化寄存器两个关键配置ADXL345初始化只需要写两个寄存器DATA_FORMAT0x31和POWER_CTL0x2D。但这两个寄存器里藏着两个大坑。先看DATA_FORMAT。这个寄存器控制数据格式bit6就是SPI模式选择位置1启用3线SPI模式。我设置的是0x4B二进制01001011含义是bit613线SPI模式bit31全分辨率模式FULL_RESbit1:011±16g量程再看POWER_CTL。这个寄存器bit5是Measure位置1才进入测量模式。正确的值是0x20。但网上有大量的例程写的是0x08我一直很困惑为什么这么多代码和手册对不上。实际测试中写了0x08之后芯片没有进入测量模式读出来的加速度数据全为0。后来把所有位都翻了一遍才发现Measure位是bit5。void adxl345_init(void) { // 3线SPI模式 全分辨率 ±16g adxl345_write_reg(0x31, 0x4B); // 进入测量模式 adxl345_write_reg(0x2D, 0x20); delay_ms(10); }初始化之后先读一下DEVID寄存器0x00确认通信通道没问题。DEVID固定是0xE5如果读不到这个值后面一切免谈。uint8_t devid adxl345_read_reg(0x00); if (devid ! 0xE5) { // 通信异常重点检查接线和SPI模式配置 }4.2 读取三轴加速度数据加速度数据寄存器从0x32开始依次是DATAX0、DATAX1、DATAY0、DATAY1、DATAZ0、DATAZ1。每个轴是16位低字节在前。用多字节读一次拿6个字节最省事。typedef struct { int16_t x; int16_t y; int16_t z; } accel_data_t; accel_data_t adxl345_read_accel(void) { uint8_t buf[6] {0}; accel_data_t data {0}; adxl345_read_multi(0x32, buf, 6); data.x (int16_t)((buf[1] 8) | buf[0]); data.y (int16_t)((buf[3] 8) | buf[2]); data.z (int16_t)((buf[5] 8) | buf[4]); return data; }这里有个细节容易搞错数据拼接的时候如果直接用(int16_t)((buf[1]8)|buf[0])buf[1]的符号位会被正确带出来因为左移8位后符号位刚好落在bit15。但是如果你的编译器把int默认当成16位处理就得格外小心。STM32上int是32位所以这样写没问题。计算实际g值也很简单。±16g量程全分辨率模式下每个LSB对应3.9mg16g/4096因为全分辨率时数据是13位有效但左对齐后读出来是16位有符号数实际满量程对应-2048~2047。粗略算就是加速度g值 原始值 * 3.9 / 1000。4.3 实测数据与滤波处理传感器静止水平放置时Z轴读数应该在1g附近X轴和Y轴接近0g。我把原始值打印出来看一眼X: 12, Y: -8, Z: 260Z轴260乘以3.9mg约等于1.014g符合预期。如果Z轴读数在0附近说明测量模式没进去或者量程配置有问题。实际应用中传感器数据会有一定噪声尤其是电源纹波大的时候。我习惯做一次简单的均值滤波取10次采样平均。对于非库工程这是最省事的方案不需要额外引入滤波库。accel_data_t adxl345_read_filtered(uint8_t samples) { int32_t sum_x 0, sum_y 0, sum_z 0; accel_data_t data; for (uint8_t i 0; i samples; i) { data adxl345_read_accel(); sum_x data.x; sum_y data.y; sum_z data.z; } data.x sum_x / samples; data.y sum_y / samples; data.z sum_z / samples; return data; }5. 常见问题与排查技巧实录5.1 读了DEVID返回全0xFF或全0x00这个现象在3线SPI下最常见我排查了一晚上总结出三个高频原因。第一DATA_FORMAT的bit6没有置1。3线SPI模式没真正启用芯片还在4线模式下工作数据线方向错乱读回来全是0xFF。这个最好排查初始化时看0x31有没有写成0x4B。第二方向切换时序不对。特别是spi3_read_byte里如果没有在读数据前把SDA_DIR_IN()切到位那整个读取过程SDA一直处于输出状态读到的自然是自己的输出电平或者高阻态乱码。调试时可以在切方向后加个延时人为增大时序裕量如果数据突然变正常基本就是方向切换时机问题。第三接线虚焊或者接触不良。SDA线是双向的如果接触不良主机输出命令时还勉强能用一旦主机切输入、从设备想拉低电平高阻态下波形就会严重畸变。用示波器看SDA波形最直观没有示波器就换一根杜邦线或者直接把SDA焊上去。5.2 POWER_CTL写0x08不出数据这真的是网上代码重灾区。不少例程写adxl345_write_reg(0x2D, 0x08)说这是测量模式实际上0x08只是设置了唤醒频率2Hz并没有把Measure位置1。芯片处于待机模式加速度数据寄存器全是0。这个坑的隐蔽性在于DEVID照样能读出来寄存器读写通道是通的但就是没加速度数据。如果你遇到“能读ID但数据全0”的情况十有八九是POWER_CTL写错了。正确的测量模式应该是0x20bit5置1。5.3 数据偶发跳变读10次错1次这种问题往往出在通信速率和线上干扰上。GPIO模拟SPI虽然没有硬性上限但在长导线和双向数据线这种半双工场景下速率拉太高容易出问题。我的建议是把两个延时函数都调到至少1us使实际SCK频率在100kHz~250kHz之间稳定性很高。另外MCU主频高时循环里的GPIO操作可能只有几十纳秒如果SDA线上电容较大从设备还没把信号稳定下来主机就采样了也会偶发跳变。可以适当在SCK_HIGH后加一个小的空循环延时让信号有充足时间稳定。5.4 常见问题速查表现象可能原因解决方法读DEVID返回0xFF3线SPI模式未启用确认0x31写入0x4B读DEVID返回0x00SDA没接上或方向切换失败检查SDA线连接确认读前切输入能读ID但加速度全是0POWER_CTL没有进入测量模式0x2D应写入0x20而不是0x08数据偶尔跳一个很大的值时序裕量不足或干扰降低SCK频率延时加大Z轴静止读数明显小于1g量程或分辨率配置不对检查0x31的FULL_RES位和range位数据恒定不变CS一直拉低没释放每次通信结束必须CS_HIGH最后再分享两个小技巧第一调试3线SPI时强烈建议先用4线SPI把ADXL345调通再改成3线。4线模式下数据线收发分离遇到问题容易定位。调通后切到3线只需要关注方向切换一个新变量排查范围小很多。第二GPIO模拟SPI虽然是“土办法”但也是理解协议最快的方式。我每次调新传感器都会坚持用寄存器操作模拟一遍时序跑通之后再考虑要不要换硬件SPI优化速度。这次3线非库接ADXL345虽然折腾了不少时间但之后再看SPI协议相关的任何文档基本都是一遍过。做嵌入式该省的地方省不该省的理解时间千万别省。本文还有配套的精品资源点击获取