平台:NUCLEO-H533RE(STM32H533RET6)|语言:C++
工具链:STM32CubeMX + CMake + VSCode
前两篇都是一个引脚说了算:LED 只有高、低两种状态,蜂鸣器认的是方波频率。到了加速度计,信息变成了一组藏在寄存器里的数字,MCU 得先跟芯片“对话”才能拿到。这一篇第一次接上总线:用 I²C 把 LIS3DH 三轴加速度计接到 NUCLEO-H533RE,认 ID、配量程、读三轴,最后在调试器里看着数值随板子翻转而跳变。链路还是那一条:建工程 → 读原理图 → 配外设 → 写驱动 → 下载验证。
一、新建 CubeMX 工程
1.1 选择芯片
打开 STM32CubeMX,在 MCU/MPU Selector 中搜索 STM32H533RET6,确认右侧的封装、Flash、RAM 等信息后,点击 Start Project。

图 1:在 MCU/MPU Selector 中选中 STM32H533RET6(对应 NUCLEO-H533RE)
1.2 处理 TrustZone 提示
STM32H5 系列带 Arm TrustZone,新建工程时会询问是否启用安全隔离。本例不涉及安全分区,选择 without TrustZone activated,点击 OK。

图 2:TrustZone 选择“不启用”
1.3 保留 ICACHE / MPU 默认配置
紧接着会提示是否为 ICACHE 和 MPU 访问 OTP / RO 区域应用默认配置,直接点 Yes 即可,省去手动配置,性能也更好。

图 3:建议直接 Yes,启用默认的 ICACHE 与 MPU 配置
二、先读原理图与手册,再动配置
配置之前先弄清三件事:I²C 接在哪两个引脚、传感器是谁、地址是多少、外部晶振是多少。前两件在 NUCLEO-H533RE 原理图和 LIS3DH 数据手册里,第三件还是那块 24 MHz 晶振。
2.1 总线引脚:PB6 / PB7,I²C1

图 4:NUCLEO-H533RE 原理图:Arduino 排针 CN5 的 10 / 9 脚(SCL/D15、SDA/D14)分别通到 PB6 / PB7
NUCLEO 板上没有加速度计,传感器要外接一个模块,走板上现成的 I²C 排针。从原理图能读出:
PB6 = I²C1_SCL,PB7 = I²C1_SDA,就是图中被圈出来的那两根;
它们引到 CN5 的 10 脚(丝印 SCL/D15)和 9 脚(SDA/D14),正好是 Arduino 的 I²C 位置;
图下方还压着一句备注:SB34 / SB37 只有在把 A4 / A5 当 I²C 用时才需要 ON,上拉电阻要由 Arduino shield 自己装。
[!warning] 上拉电阻别指望板子 I²C 是开漏总线,SCL / SDA 必须有上拉才能拉高。大多数 LIS3DH 模块板上自带 4.7 kΩ 上拉,直接用就行;如果手上是裸芯片或光板,要自己补上拉。“读不到 ID”的原因里,一半以上是总线没上拉。
接线四根:VCC → 3.3 V、GND → GND、SCL → PB6、SDA → PB7。用别的引脚也行,只要换成对应的 I2Cx,后面的寄存器操作和换算全都不用改。
2.2 传感器:LIS3DH,地址 0x19
LIS3DH 是 ST 的三轴 MEMS 加速度计:量程 ±2 / ±4 / ±8 / ±16 g 可选,内置 10 位 / 12 位输出和 32 级 FIFO,I²C 最高 400 kHz。用起来只有五步:读 ID → 写 CTRL_REG4 定量程 → 写 CTRL_REG1 开数据率 → 查 STATUS 有没有新数据 → 连读 6 个字节。
地址由 SA0 引脚决定:SA0 接高为 0x19,接低为 0x18(7 位地址,HAL 里不左移)。本文的模块 SA0 拉高,用 0x19。
用到的寄存器不多:
寄存器地址作用
| WHO_AM_I | 0x0F | 固定返回 0x33,用来确认芯片在线 |
| CTRL_REG1 | 0x20 | 数据率 ODR、低功耗模式、X/Y/Z 使能 |
| CTRL_REG4 | 0x23 | 量程、高分辨率、BDU(防数据撕裂) |
| STATUS_REG | 0x27 | 最高位 ZYXDA(0x08)有新数据时置 1 |
| OUT_X_L | 0x28 | 三轴输出起始地址,X / Y / Z 各占两个字节 |
两个细节先记住:输出寄存器从 0x28 起连续排列,读的时候把子地址或上 0x80(自动递增位),一次事务就能把 6 个字节搬完;另外 LIS3DH 的输出是 12 位左对齐塞进 16 位里的,低 4 位是空的,解码时先右移。
2.3 外部晶振:24 MHz

图 5:HSE 使用 24 MHz 晶振(X1),LSE 为 32.768 kHz(本例不使用)
HSE 接的是 24 MHz 晶振(X1,OSC_IN / OSC_OUT),后面的时钟树就以此为输入。
三、CubeMX 外设配置
3.1 调试接口:Serial Wire
在左侧 Trace and Debug → DEBUG(旧版本在 System Core → SYS)中,把 Debug 设为 Serial Wire。

图 6:Debug 选择 Serial Wire,保留 PA13 / PA14 的 SWD 功能
[!tip] 为什么第一步就配调试口 把 PA13 / PA14 保留给 SWD,避免它们被当作普通 GPIO 占用,导致后面下载、调试都连不上。
3.2 时钟树:24 MHz → 250 MHz
在 System Core → RCC 中,把 High Speed Clock (HSE) 设为 Crystal/Ceramic Resonator,Low Speed Clock (LSE) 保持 Disable;再切到 Clock Configuration,将 HSE 选为 PLL1 的输入源,按图设置分频与倍频。

图 7:HSE 24 MHz 作为 PLL 源,SYSCLK 250 MHz,AHB 不分频
STM32H533 的最高主频为 250 MHz,这里直接拉满:SYSCLK = HCLK = 250 MHz,AHB Prescaler = /1。若右上角出现 Resolve Clock Issues 红标,按提示逐项检查未使能的时钟源即可。
I2C1 的时钟源默认取 PCLK1,这一档 APB1 不分频,所以 PCLK1 = 250 MHz——CubeMX 就是按这个频率算出的 I²C 时序(下一节的 Timing = 0x10C043E5)。改时钟树之后如果没重新生成代码,I²C 的速率就会偏。
3.3 I2C1:PB6 / PB7,Fast Mode 400 kHz
回到 Pinout & Configuration,在芯片图上点击 PB6,选择 I2C1_SCL;再点击 PB7,选择 I2C1_SDA。也可以在左侧 Connectivity → I2C1 中把 I2C 设为 I2C,CubeMX 会按复用功能自动分配引脚。

图 8:PB6 / PB7 复用为 I2C1_SCL / I2C1_SDA,PA5 仍是 GPIO 输出,PA7 仍是 TIM3_CH2
按下表配置 I2C1:
参数取值
| I2C Speed Mode | Fast Mode |
| I2C Clock Speed | 400 kHz(Timing 0x10C043E5) |
| Clock Source | PCLK1(250 MHz) |
| Addressing Mode | 7-bit |
| Dual Address Mode / General Call | Disable |
| Clock No Stretch Mode | Disable |
| Analog Filter | Enable |
| Digital Filter | 0 |
LIS3DH 支持 Fast Mode,400 kHz 下读一次 6 字节大约只要 200 µs,对 100 Hz 的采样率绰绰有余。模拟滤波默认打开,能滤掉一部分总线毛刺;数字滤波留 0 即可。
3.4 DMA:给 I2C1 挂一对 GPDMA 通道
在 Connectivity → I2C1 → DMA Settings 里点 Add,分别添加 I2C1_TX 和 I2C1_RX 两个请求。STM32H5 用的是新一代 GPDMA,配置项比老的 DMA 多,关键是两个“递增”开关:

图 9:I2C1_TX 通道(GPDMA1_Channel0):源地址递增开、目标地址递增关

图 10:I2C1_RX 通道(GPDMA1_Channel1):源地址递增关、目标地址递增开
参数I2C1_TX(Channel0)I2C1_RX(Channel1)
| Direction | Memory To Peripheral | Peripheral To Memory |
| Request | I2C1_TX | I2C1_RX |
| Source Address Increment | Enabled | Disabled |
| Destination Address Increment | Disabled | Enabled |
| Data Width | Byte | Byte |
| Burst Length | 1 | 1 |
| Circular Mode | Disable | Disable |
| Priority | Low | Low |
TX 的“源”是内存,RX 的“目标”是内存——递增永远落在内存那一侧,I²C 数据寄存器那一侧保持固定。加完 DMA 后,CubeMX 会自动打开 I2C1_EV_IRQn / I2C1_ER_IRQn,别手动关掉,HAL 靠中断判断一帧传完没有。
[!warning] 递增位配反了,数据会“安静地错” 启动传输时 HAL 不会再检查这两个递增位,它默认你在 CubeMX 里已经配好。RX 的目标地址不递增,6 字节突发会全部写进同一个 buf[0];TX 的源地址不递增,发出去的就是同一个字节重复。总线不报错、HAL_OK 照旧返回,只是数字不对——这种 bug 最难查。
本例其实用不满 DMA:传感器事务只有 1 字节和 6 字节。所以驱动里把 DMA 门槛设在 6 字节,把这几种短事务全部留在阻塞路径上,只把 DMA 留给将来更长的传输(比如一次读 FIFO 的 32 字节)。
四、生成 CMake 工程
4.1 Project Manager 设置
切到 Project Manager,按下表填写:
配置项取值
| Project Name | accel |
| Project Location | 全英文路径,例如 E:\stm32cubemx\todo |
| Application Structure | Advanced |
| Toolchain / IDE | CMake |
| Code Generator | Copy only the necessary library files |
| Generated files | 勾选 Generate peripheral initialization as a pair of '.c/.h' files per peripheral 勾选 Keep User Code when re-generating |


图 11:工程名、路径、CMake 工具链与代码生成选项
[!warning] 路径不要带中文 CubeMX 生成的工程路径请保持全英文,中文路径在 CMake / GCC 环节很容易出现难以排查的报错。
4.2 生成代码
点击右上角的 GENERATE CODE,等待生成完成后选择 Open Project,即可用 VSCode 打开工程。
想直接在前两篇的工程上继续也行:PA5 心跳灯、TIM3_CH2 都在,跳过新建工程,从 3.3 节开始补 I2C1 与 DMA 即可。
五、用 C++ 封装 I²C 与 LIS3DH
5.1 I²C 先包一层
HAL_I2C_Mem_Read() / HAL_I2C_Mem_Write() 已经够用,但它们把“7 位地址、子地址、缓冲区、超时”全塞在一行参数里,参数一多就容易写反。沿用前两篇的做法,包一个 STM32I2C:
class STM32I2C
{
public:
STM32I2C(I2C_HandleTypeDef* hi2c, uint8_t* buffer, size_t buffer_size,
uint32_t dma_enable_min_size = 8)
: hi2c_(hi2c), buffer_(buffer), buffer_size_(buffer_size),
dma_enable_min_size_(dma_enable_min_size) {}
ErrorCode MemRead(uint16_t addr, uint8_t reg, RawData data, ReadOperation& op);
ErrorCode MemWrite(uint16_t addr, uint8_t reg, ConstRawData data, WriteOperation& op);
// 长度“大于”这个阈值才走 DMA,短事务继续用阻塞轮询
uint32_t dma_enable_min_size_;
private:
I2C_HandleTypeDef* hi2c_;
uint8_t* buffer_;
size_t buffer_size_;
};几点说明:
addr 是 7 位地址,不左移:0x19 就写 0x19,HAL 内部自己会 << 1;
MemRead() 对应“先写寄存器地址、再读数据”的复合时序,MemWrite() 对应“寄存器地址 + 数据”一次写完;
RawData{&value, n} 只是“指针 + 长度”的轻量视图,ReadOperation / WriteOperation 把信号量 + 超时绑在一次事务上,阻塞和 DMA 两种后端共用同一个签名;
dma_enable_min_size_ 的语义是“严格大于才启用 DMA”。本例传 6:1 字节的寄存器读写和 6 字节的三轴连读都落在阻塞路径上,行为最好预测。
STM32I2C 继承自 I2C 这个抽象基类,LIS3DH 只认基类、不认 HAL——换平台或者改成 SPI 读同一个芯片,驱动类基本不用动。
5.2 寄存器读写:一行搞定
先把寄存器地址和数据结构写清楚:
namespace
{
constexpr uint16_t LIS3DH_I2C_ADDR = 0x19; // SA0 接高;接低时写 0x18
constexpr uint8_t LIS3DH_WHO_AM_I = 0x0F;
constexpr uint8_t LIS3DH_CTRL_REG1 = 0x20;
constexpr uint8_t LIS3DH_CTRL_REG4 = 0x23;
constexpr uint8_t LIS3DH_STATUS_REG = 0x27;
constexpr uint8_t LIS3DH_OUT_X_L = 0x28;
constexpr uint8_t LIS3DH_AUTO_INCREMENT = 0x80;
constexpr uint8_t LIS3DH_XYZ_DATA_READY = 0x08;
constexpr uint32_t I2C_TIMEOUT_MS = 200;
struct LIS3DH_Accel_t
{
int16_t x;
int16_t y;
int16_t z;
};
} // namespace驱动里不需要知道 I²C 是怎么实现的,只依赖 I2C 这个抽象——MemRead() / MemWrite() 就两个动作。于是读、写一个寄存器都只剩一次调用:
class LIS3DH
{
public:
explicit LIS3DH(I2C& i2c) : i2c_(i2c) {}
ErrorCode Init();
ErrorCode ReadAccel(LIS3DH_Accel_t& acc);
private:
ErrorCode ReadReg(uint8_t reg, uint8_t& value);
ErrorCode WriteReg(uint8_t reg, uint8_t value);
ErrorCode ReadID(uint8_t& id);
static int16_t DecodeAxis(uint8_t low, uint8_t high);
I2C& i2c_;
Semaphore semaphore_; // 一个器件一个信号量,读、写不会互相插队
};ErrorCode LIS3DH::ReadReg(uint8_t reg, uint8_t& value)
{
ReadOperation op(semaphore_, I2C_TIMEOUT_MS);
return i2c_.MemRead(LIS3DH_I2C_ADDR, reg, RawData{&value, 1}, op);
}
ErrorCode LIS3DH::WriteReg(uint8_t reg, uint8_t value)
{
WriteOperation op(semaphore_, I2C_TIMEOUT_MS);
return i2c_.MemWrite(LIS3DH_I2C_ADDR, reg, ConstRawData{&value, 1}, op);
}
ErrorCode LIS3DH::ReadID(uint8_t& id) { return ReadReg(LIS3DH_WHO_AM_I, id); }I2C_TIMEOUT_MS 取 200,指的是“等这次事务的最长等待”;阻塞后端内部还有 HAL 自己的超时(20 ms 量级),所以模块被拔掉也不会把主循环卡死几秒。
5.3 初始化:先认 ID,再配寄存器
上电后先读 WHO_AM_I,返回 0x33 才说明对面真是 LIS3DH(地址对了但器件不对、SA0 接错、总线被别的器件占了,都会在这里露馅)。确认之后再写两个配置寄存器:
ErrorCode LIS3DH::Init()
{
Thread::Sleep(20); // 上电后给芯片一点启动时间
ErrorCode status = ErrorCode::INIT_ERR;
for (uint8_t retry = 0; retry < 5; ++retry)
{
uint8_t id = 0;
status = ReadID(id);
if (status == ErrorCode::OK)
{
if (id != 0x33)
{
status = ErrorCode::NOT_FOUND;
}
else
{
// BDU=1, BLE=0, FS=00 (±2 g), HR=1:12 位,1 mg/LSB
status = WriteReg(LIS3DH_CTRL_REG4, 0x88);
if (status == ErrorCode::OK)
{
// ODR=0101 (100 Hz), LPen=0, X/Y/Z 全开
status = WriteReg(LIS3DH_CTRL_REG1, 0x57);
}
if (status == ErrorCode::OK)
{
Thread::Sleep(70); // 高分辨率模式的启动时间:7 / ODR
return ErrorCode::OK;
}
}
}
Thread::Sleep(5);
}
return status;
}两个魔法数字拆开看就清楚了。
CTRL_REG4 = 0x88:
位名称取值含义
| b7 | BDU | 1 | 输出寄存器成对更新,读的过程中不会被新样本覆盖 |
| b6 | BLE | 0 | 小端,低字节在前 |
| b5:4 | FS | 00 | 量程 ±2 g |
| b3 | HR | 1 | 高分辨率模式,12 位输出 |
| b2:1 | ST | 00 | 不做自检 |
| b0 | SIM | 0 | 走 I²C(不是 SPI 三线模式) |
CTRL_REG1 = 0x57:
位名称取值含义
| b7:4 | ODR | 0101 | 输出数据率 100 Hz |
| b3 | LPen | 0 | 正常模式,低功耗关 |
| b2:0 | Zen/Yen/Xen | 111 | 三轴全部使能 |
先写 CTRL_REG4 再写 CTRL_REG1:ODR = 0000 时芯片处于掉电模式,此时配置量程不产生数据;顺序反过来也能用,只是会先冒出一批按默认量程采的样本。
[!note] BDU 为什么要开 三轴的 6 个输出寄存器分属两组高低字节。如果读的过程中刚好来了新样本,可能拼出“上半拍 + 下半拍”的撕裂数据。开 BDU 后,寄存器在读完之前锁住,配合 6 字节连读,拿到的三轴一定属于同一时刻。
另外,0x88 只在 ±2 g 下成立:换成 ±4 g 就是 2 mg/LSB,换算系数也要跟着翻倍。
Init() 的重试逻辑是给“热插拔”和“上电慢”准备的:模块没插好、上电比 MCU 晚、上电瞬间总线还没稳,都会让第一次读 ID 失败;等几毫秒再试,往往就好了。5 次都失败才返回错误,交给上层决定要不要再等。
5.4 读三轴:先问“有没有新数据”
I²C 上没有“数据就绪”中断,只能主动问。所以每次读之前先看 STATUS_REG 的 ZYXDA 位,有新数据再去连读 6 个字节:
ErrorCode LIS3DH::ReadAccel(LIS3DH_Accel_t& acc)
{
uint8_t status_reg = 0;
ErrorCode status = ReadReg(LIS3DH_STATUS_REG, status_reg);
if (status != ErrorCode::OK)
{
return status;
}
if ((status_reg & LIS3DH_XYZ_DATA_READY) == 0)
{
return ErrorCode::EMPTY; // 还没有新样本,这不是错误
}
uint8_t buf[6] = {};
ReadOperation op(semaphore_, I2C_TIMEOUT_MS);
status = i2c_.MemRead(LIS3DH_I2C_ADDR, LIS3DH_OUT_X_L | LIS3DH_AUTO_INCREMENT,
RawData{buf, sizeof(buf)}, op);
if (status == ErrorCode::OK)
{
acc.x = DecodeAxis(buf[0], buf[1]);
acc.y = DecodeAxis(buf[2], buf[3]);
acc.z = DecodeAxis(buf[4], buf[5]);
}
return status;
}几个关键点:
读 6 个字节,不是分三次读。子地址或上 0x80(AUTO_INCREMENT)后,芯片会按 OUT_X_L、OUT_X_H、OUT_Y_L…… 的顺序自动往下走,一次事务就拿到同一时刻的三轴;
EMPTY 不是故障。100 Hz 的 ODR 意味着每 10 ms 才有一个新样本,主循环偶尔问早了很正常,上层不该把它算进错误计数;
失败时不碰 acc。只有 status == OK 才解码,保证上层永远拿不到“半新半旧”的数。
5.5 12 位补码怎么解
LIS3DH 的三轴是 12 位有符号数、左对齐放进的 16 位寄存器:低字节的高 4 位是数据的低 4 位,低字节的低 4 位没用。所以解码分两步——先右移 4 位去掉空位,再把 12 位补码符号扩展到 16 位:
static int16_t LIS3DH::DecodeAxis(uint8_t low, uint8_t high)
{
// 去掉低位寄存器里没用的低 4 位,再把 12 位补码符号扩展成 16 位
const int32_t value = ((static_cast<uint16_t>(high) << 8) | low) >> 4;
return static_cast<int16_t>(value >= 0x800 ? value - 0x1000 : value);
}举几个例子:
low / high12 位原始值输出(mg)
| 0x00 / 0x00 | 0x000 | 0 |
| 0x00 / 0x10 | 0x100 | 256 |
| 0xF0 / 0xFF | 0xFFF → -1 | -1 |
| 0x00 / 0x80 | 0x800 → -2048 | -2048 |
±2 g 量程下 12 位正好覆盖 -2048 ~ +2047,分辨率 1 mg/LSB,所以读出来的数直接就是毫克:静止时 z ≈ 1000,也就是 1 g。
[!tip] 单位换算省不了 如果把量程改成 ±4 g,同样 12 位就只能分辨 2 mg,代码里得乘 2;±8 g 乘 4,±16 g 则乘 12。换算系数跟着量程走,别写死。
5.6 采样循环:心跳灯 + 失败重试
把上面几块拼起来。先准备对象和调试变量——volatile 是为了让调试器随时能看到最新值,也防止编译器把它们优化没:
// DMA 缓冲区(STM32H533RE 没有 D-Cache,不需要做 cache 维护)
alignas(4) static uint8_t i2c1_buf[32];
// GPIO / PWM,沿用前两篇
static STM32GPIO PA5(GPIOA, GPIO_PIN_5);
static STM32PWM pwm_tim3_ch2(&htim3, TIM_CHANNEL_2, false);
// I²C:阈值 6 表示只有长度大于 6 的事务才走 DMA
static STM32I2C i2c1(&hi2c1, i2c1_buf, sizeof(i2c1_buf), 6);
static LIS3DH lis3dh(i2c1);
// 调试器里盯这几个变量:x/y/z 的单位是 mg(1000 mg = 1 g)
volatile LIS3DH_Accel_t lis3dh_accel = {};
volatile ErrorCode lis3dh_status = ErrorCode::INIT_ERR;
volatile bool lis3dh_ready = false;
volatile bool lis3dh_sample_valid = false;
volatile uint32_t lis3dh_sample_count = 0;
volatile uint32_t lis3dh_error_count = 0;上电先初始化一次,失败不阻塞,交给主循环按秒重试:
lis3dh_status = lis3dh.Init();
lis3dh_ready = (lis3dh_status == ErrorCode::OK);
if (!lis3dh_ready)
{
lis3dh_error_count = lis3dh_error_count + 1;
}主循环里三件事交替:心跳灯、掉线重试、读加速度。
bool led_on = false;
PA5.Write(led_on);
uint32_t last_led_ms = Timebase::GetMilliseconds();
uint32_t last_retry_ms = last_led_ms;
while (true)
{
const uint32_t now = Timebase::GetMilliseconds();
// 1 Hz 心跳:灯还在闪,说明主循环没卡在 I²C 上
if (now - last_led_ms >= 500)
{
last_led_ms = now;
led_on = !led_on;
PA5.Write(led_on);
}
// 掉线后每秒重试一次初始化,插拔模块能自愈
if (!lis3dh_ready && now - last_retry_ms >= 1000)
{
lis3dh_status = lis3dh.Init();
lis3dh_ready = (lis3dh_status == ErrorCode::OK);
last_retry_ms = Timebase::GetMilliseconds();
if (!lis3dh_ready)
{
lis3dh_error_count = lis3dh_error_count + 1;
}
}
if (lis3dh_ready)
{
LIS3DH_Accel_t acc = {};
lis3dh_status = lis3dh.ReadAccel(acc);
if (lis3dh_status == ErrorCode::OK)
{
lis3dh_accel.x = acc.x;
lis3dh_accel.y = acc.y;
lis3dh_accel.z = acc.z;
lis3dh_sample_valid = true;
lis3dh_sample_count = lis3dh_sample_count + 1;
}
else if (lis3dh_status != ErrorCode::EMPTY)
{
// 真错误:作废旧样本,等一秒重新初始化
lis3dh_sample_valid = false;
lis3dh_ready = false;
lis3dh_error_count = lis3dh_error_count + 1;
last_retry_ms = Timebase::GetMilliseconds();
}
}
Thread::Sleep(10);
}几个设计上的小考虑:
Thread::Sleep(10) 跟 100 Hz 的 ODR 对齐,一轮大致落到一个新样本上,不会空转也不会把总线占满;
EMPTY 只跳过这一轮,不动 ready;真错误才把 ready 清零,走“每秒重试”的分支;
sample_count 每秒大致涨 100,error_count 应该一直是 0(或者只在插拔时涨一两个),这两个计数器是最直观的健康指标;
心跳灯和传感器互不影响:灯不闪了就说明卡在阻塞 I²C 里,灯照闪而数据不动,问题就在总线或配置上。
[!note] 这里的延时和计时Thread::Sleep() / Timebase::GetMilliseconds() 是工程里对延时和毫秒计时的薄封装,作用分别相当于 HAL_Delay() 和 HAL_GetTick(),但换成 RTOS 或别的 tick 源时应用代码不用改。
六、编译、下载与验证
6.1 编译与下载
在 VSCode 中装好 CMake Tools 与 STM32 扩展,打开工程后先 Configure、再 Build(终端里等价于 cmake --preset Debug 加 cmake --build --preset Debug)。随后打开 运行和调试,选择 STM32Cube: STM32 Launch STLink GDB Server(NUCLEO 板载 ST-Link),按 F5 下载并启动调试。

图 12:在 VSCode 的运行和调试中选择 ST-Link GDB Server
Debug 构建大约占 Flash 43.7 kB / RAM 2.8 kB,accel.elf、.hex、.bin 都在 build/Debug/ 下。
6.2 在调试器里读数值
这个实验没有串口打印,验证全靠调试器。在 WATCH 面板里加入 lis3dh_accel、lis3dh_status、lis3dh_ready、lis3dh_sample_valid、lis3dh_sample_count(sample_valid 标记当前这组数还算不算新鲜),让程序跑起来,然后翻板子:
板子姿态大致输出(mg)
| 正面平放 | x ≈ 0,y ≈ 0,z ≈ +1000 |
| 反面平放 | z ≈ -1000 |
| 长边立起 | x ≈ ±1000 |
| 短边立起 | y ≈ ±1000 |
| 斜着 45° | 两轴各约 ±707 |
判断“读对了”的三个依据:z 平放时接近 +1000;三轴平方和 √(x² + y² + z²) 稳定在 1000 附近(重力模长不变,说明标度和量程没错);sample_count 每秒涨 100 左右、error_count 不涨。
想进一步验证,可以在 WATCH 里加表达式 lis3dh_accel.z / 1000.0 看 g 值,或者加上倾角公式 atan2(x, z) 直接读角度。 示例:

图 13:在 VSCode中查看数据
6.3 读不出来怎么查
LIS3DH 的坑基本都集中在“认不到”和“读不对”两类:
现象优先检查
| WHO_AM_I 超时 / 返回 0x00 | SCL / SDA 有没有上拉;接线顺序;模块是否 3.3 V 供电 |
| 读回固定值 0xFF 或 0x00 | SA0 电平与地址是否对上(0x19 / 0x18) |
| ID 对、但一直 EMPTY | CTRL_REG1 是否写进去了(ODR 为 0 就是掉电);STATUS 地址对不对 |
| 数值大 16 倍 | 解码时漏了 >> 4:12 位数据是左对齐的,低 4 位是空的 |
| 正负号反了 | 12 位补码符号扩展写错;或量程与换算系数不匹配 |
| x/y/z 不同步、偶发撕裂 | 没做 6 字节连读;BDU 没开 |
| 上电偶尔 NOT_FOUND,重启就好 | 模块上电比 MCU 慢,Init() 里的 Sleep(20) 和重试够不够 |
| 读几次后总线卡死 | 上拉太弱、线太长、速率太高;先把 400 kHz 降到 100 kHz 试试 |
七、小结
这次从“点一个引脚”走到“跟一颗芯片对话”,I²C 把这条链路又拉长了一截:
CubeMX 选型建工程,处理 TrustZone 与 ICACHE 默认配置;
从原理图确认 I²C 在 PB6 / PB7,并检查总线上拉;
配置 SWD、HSE 24 MHz → 250 MHz 时钟树、I2C1 Fast Mode 400 kHz,以及一对 GPDMA 通道;
生成 CMake 工程,用 STM32I2C 封装寄存器读写,用 LIS3DH 类收拢初始化、状态判断和数据解码;
VSCode + ST-Link 下载,在调试器里看 x / y / z 随姿态变化。
我要赚赏金
