简介:STM32F103与HMC5883L数字磁力计的嵌入式开发工程,面向电子罗盘、地磁导航、姿态测量及惯性导航项目开发者,也适合正在学习STM32外设接口与传感器驱动的进阶学生。工程完整实现了HMC5883L的底层驱动,包含I²C/SPI总线初始化与配置、传感器寄存器读写、量程与采样率设置、三轴磁场数据采集与滤波处理,并给出基于atan2的三轴角度解算逻辑,读者可直接在Keil MDK中打开工程,编译下载至STM32F103开发板验证。压缩包共201个文件,以C源码、H头文件、Keil工程配置(uvproj/uvopt)和编译输出(o/axf/hex)为主,另有少量备份、说明文本与调试辅助文件,目录结构清晰,便于依据功能模块检索代码。整体资源包仅3.32MB,下载与部署都非常轻量。该资源已有3123人学习使用,对掌握数字磁力计编程、理解STM32通信配置及完成罗盘类项目都有实际参考价值。 做嵌入式这几年,凡是涉及导航、定位、姿态解算的项目,基本绕不开电子罗盘。HMC5883L这款三轴磁力计芯片,虽然型号老,但胜在便宜、够用、资料多,一直是 DIY 飞控、小车循迹、手持朝向检测的热门选择。搭配 STM32F103,走 I2C 总线,几十行代码就能读出磁场数据,再配合校准算法算出方位角,一个可用的电子罗盘就出来了。
这篇文章我把整个流程串一遍:从硬件接线、驱动写法,到校准原理、航向解算,再到低功耗、掉电保存这些工程上逃不掉的坑,一次性说透。适合刚接触 STM32 的初学者,也对已经能跑流水灯但没碰过传感器通信的朋友有帮助。
1. 项目整体设计与方案选型
1.1 为什么是 STM32F103 + HMC5883L
STM32F103 几乎是国内嵌入式入门的标配 MCU,72MHz 主频、丰富的外设资源,而且生态成熟——标准外设库、HAL 库、LL 库都有大量现成代码。做传感器读取这种任务,它甚至有点大材小用,但也正是因为资源充裕,你可以在跑好驱动的前提下,继续往上叠加显示、无线传输、姿态解算等功能,不用一上来就扣性能。
HMC5883L 则是霍尼韦尔早年推出的三轴磁阻传感器,量程 ±8 高斯,灵敏度典型值 1090 LSB/Gauss(±1.3 高斯量程下),分辨率能做到毫高斯级别。它通过 I2C 接口输出 16 位磁场数据,配合 STM32 的硬件 I2C 或者模拟 GPIO I2C 都能轻松读取。要 GPS 弱信号下补方向、要让小车走直线不跑偏,这种磁力计是最划算的选择。
1.2 I2C 通信的本质与接线要点
I2C 只有两根线:SCL(时钟)和 SDA(数据),属于半双工同步通信。主机控制时钟,从机在地址匹配时响应。HMC5883L 的 7 位设备地址是0x1E,读写时左移一位拼上 R/W 位——这也是新手最容易搞混的地方。
硬件接线相当简单:
STM32F103 HMC5883L PA0 (SCL) --- SCL PA1 (SDA) --- SDA 3.3V --- VCC GND --- GND注意几个细节:
- HMC5883L 是 3.3V 供电芯片,绝对不能直接接 5V。和 STM32F103 连接时,如果板子是 5V 供电,需要确认 I/O 兼容性。标准库配置 GPIO 为开漏输出,加上外部上拉到 3.3V,就能稳妥通信。
- 很多 STM32 最小系统板的 I/O 是 5V 容忍的,但 HMC5883L 模块上的 SCL/SDA 不一定做过电平转换。稳妥做法是:MCU 的 I2C 引脚配置为开漏模式,通过 4.7kΩ 电阻上拉到 3.3V。这样即使 MCU I/O 被拉高到 5V 也不会倒灌进传感器。
- SDA/SCL 上拉电阻不是随便选的。I2C 规范里,标准模式 100kHz 通常用 4.7kΩ,快速模式 400kHz 可以用 2.2kΩ。上拉太小,上升沿会变缓,波形畸变;上拉太大,信号拉高太慢,高速通信容易出错。大多数 HMC5883L 模块板载了上拉电阻,用杜邦线短接时问题不大,但如果自己画板子,这个电阻必须算进去。
SPI 也是这个芯片支持的通信方式,但我基本不推荐。原因很简单:HMC5883L 的寄存器结构按照 I2C 设计,SPI 模式的数据手册描述比较绕,而且相比 I2C 少两根线几乎没有优势。I2C 还能一条总线上挂多个设备,后续扩展性更好。
1.3 新旧固件库的选择,其实没那么多纠结
网上搜 STM32F103 的资料,一半是标准外设库(StdPeriph),一半是 HAL 库。标准库写起来直接操作寄存器和外设结构体,代码精简、执行效率高,很多老工程师手里都有一套成熟模板。HAL 库封装更厚,但移植性更强,CubeMX 能直接生成初始化代码。
我的建议是:如果你只跑这一个小项目,标准库足够,因为代码量小,出问题好排查;如果后续要接 RTOS、要频繁换芯片型号,HAL 库更省心。这篇文的驱动代码我按标准库风格写,但改成 HAL 也就改几个函数名,逻辑完全一致。
2. HMC5883L 数据读取的核心实现
2.1 寄存器机制与初始化配置
HMC5883L 内部寄存器不多,常用的就这几个:
| 寄存器 | 地址 | 作用 |
|---|---|---|
| 配置寄存器 A | 0x00 | 采样平均、数据输出速率 |
| 配置寄存器 B | 0x01 | 增益(量程)设置 |
| 模式寄存器 | 0x02 | 连续测量 / 单次测量 / 空闲 |
| 数据输出 X | 0x03~0x04 | X 轴磁场值,16 位 |
| 数据输出 Y | 0x05~0x06 | Y 轴磁场值 |
| 数据输出 Z | 0x07~0x08 | Z 轴磁场值 |
| 状态寄存器 | 0x09 | DRDY 数据就绪位 |
初始化时先写配置寄存器 A,我习惯设成0x70:8 次平均采样,输出速率 75Hz,正常测量模式。这样读出来的数据相对平稳,后期软件滤波压力小。
配置寄存器 B 控制增益,默认0x20对应 ±1.3 高斯量程。如果你确定环境磁场较强,或者传感器的安装位置靠近电机、喇叭这类带磁部件,建议调成0x40(±1.9 高斯)甚至0x60(±4.0 高斯)。量程越大,分辨率越低,所以够用就好,别盲目调大。
模式寄存器设0x00进入连续测量模式,之后只要等数据就绪标志,就能直接读 6 个字节的 XYZ 数据了。
2.2 模拟 I2C 和硬件 I2C:我为什么推荐先上模拟
STM32F103 的硬件 I2C 外设在早期标准库里口碑不好,动不动就卡死在 BUSY 状态。虽然多数情况是代码配置问题,但新手遇到这种玄学问题会很煎熬。模拟 I2C 用两个 GPIO 翻转电平,逻辑完全透明,出问题一眼就能看出是拉不低还是上拉不够。
模拟 I2C 的核心就是几个函数:起始信号、停止信号、发送字节、接收字节、等待应答。下面是完整驱动代码,放在标准库环境下可以直接编译:
// 宏定义引脚 #define I2C_SCL_GPIO GPIOA #define I2C_SCL_PIN GPIO_Pin_0 #define I2C_SDA_GPIO GPIOA #define I2C_SDA_PIN GPIO_Pin_1 #define SCL_H() GPIO_SetBits(I2C_SCL_GPIO, I2C_SCL_PIN) #define SCL_L() GPIO_ResetBits(I2C_SCL_GPIO, I2C_SCL_PIN) #define SDA_H() GPIO_SetBits(I2C_SDA_GPIO, I2C_SDA_PIN) #define SDA_L() GPIO_ResetBits(I2C_SDA_GPIO, I2C_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_GPIO, I2C_SDA_PIN) // 微秒级延时函数,72MHz主频下可以使用SysTick实现 void I2C_Delay(void) { delay_us(5); } void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); I2C_Delay(); SCL_L(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); I2C_Delay(); } uint8_t I2C_WaitAck(void) { uint8_t timeout = 0; SDA_H(); I2C_Delay(); SCL_H(); I2C_Delay(); while (SDA_READ()) { if (++timeout > 250) { return 1; // 无应答 } I2C_Delay(); } SCL_L(); I2C_Delay(); return 0; } void I2C_SendByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) SDA_H(); else SDA_L(); dat <<= 1; I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); } } uint8_t I2C_RecvByte(void) { uint8_t dat = 0; SDA_H(); for (uint8_t i = 0; i < 8; i++) { dat <<= 1; SCL_H(); I2C_Delay(); if (SDA_READ()) dat |= 0x01; SCL_L(); I2C_Delay(); } return dat; }这套代码看起来简单,但有个容易踩的坑:每步操作之间一定要留够延时,尤其是在 72MHz 的 STM32 上,GPIO 翻转速度太快,传感器跟不上。延时 5 微秒已经比较保守了,如果你把速率提高,比如延时减到 1 微秒,就要用示波器看波形,确保 SCL 高电平宽度满足 HMC5883L 的最小要求。
2.3 读磁力计数据的完整函数
有了底层的 I2C 字节操作,读写 HMC5883L 寄存器就顺理成章了。写寄存器先发设备地址0x3C(0x1E 左移一位写方向),再发寄存器地址和数据。读数据则先写寄存器地址,再发读地址0x3D,连续读取 6 个字节。
#define HMC5883L_ADDR_W 0x3C #define HMC5883L_ADDR_R 0x3D void HMC5883L_WriteReg(uint8_t reg, uint8_t val) { I2C_Start(); I2C_SendByte(HMC5883L_ADDR_W); I2C_WaitAck(); I2C_SendByte(reg); I2C_WaitAck(); I2C_SendByte(val); I2C_WaitAck(); I2C_Stop(); } void HMC5883L_ReadData(int16_t *x, int16_t *y, int16_t *z) { uint8_t buf[6]; I2C_Start(); I2C_SendByte(HMC5883L_ADDR_W); I2C_WaitAck(); I2C_SendByte(0x03); I2C_WaitAck(); I2C_Start(); I2C_SendByte(HMC5883L_ADDR_R); I2C_WaitAck(); for (uint8_t i = 0; i < 6; i++) { buf[i] = I2C_RecvByte(); if (i < 5) { // 发送应答,表示继续读 SDA_L(); } else { // 最后一个字节不应答 SDA_H(); } SCL_H(); I2C_Delay(); SCL_L(); I2C_Delay(); } I2C_Stop(); *x = (int16_t)((buf[0] << 8) | buf[1]); *y = (int16_t)((buf[2] << 8) | buf[3]); *z = (int16_t)((buf[4] << 8) | buf[5]); }在读数据之前,建议先读状态寄存器 0x09,确认 bit0(DRDY)为 1,表示新数据已准备好。这样能避免读到同一组旧数据导致方位角抖动。实测下来,连续测量模式 75Hz 输出速率下,DRDY 置位频率和数据输出是一致的。
还有一个细节:HMC5883L 的 16 位数据是高位在前(big-endian),(buf[0] << 8) | buf[1]这个组合顺序不能反。我见过有人在这里栽跟头,读出来的值完全不对,还怀疑是芯片坏了。
3. 校准与航向解算
3.1 为什么必须校准:硬磁干扰与偏移
刚把传感器数据读出来时,你可能会发现三个轴的原始值都在几百到一千多之间浮动,而不是以 0 为中心。这不是芯片坏,而是硬磁干扰——电路板上的走线、芯片引脚、附近的铁磁材料都会给传感器叠加一个固定偏置磁场。这个偏置让 X/Y 轴数据画在坐标图上时,圆心偏离原点。
如果不做校准,直接算方位角,结果会非常诡异:把传感器转一圈,角度变化不均匀,有些方向甚至完全不对。这也是很多新手做电子罗盘失败的核心原因,不是通信打通就算完事,校准才是精度分水岭。
校准方法很简单,叫圆规法:把传感器水平放置,在原地缓慢旋转 360 度,记录 X/Y 轴的最大最小值,然后算出偏移量:
offset_x = (x_max + x_min) / 2; offset_y = (y_max + y_min) / 2;采集过程中要注意两点:
- 旋转要匀速,尽量让传感器扫过圆周的各个方向,不要让某个象限数据量特别少。
- 整个校准过程中,传感器周围不要有移动的金属物体、手机、螺丝刀。铁磁性物体靠近会影响读数,让校准结果失真。
数据采集完成后,把 X/Y 值减去偏移量,就得到了以 0 为中心的干净数据。
3.2 利用已有的校准代码思路
我习惯在工程里加一个mag_calib.c模块,负责采集样本、计算偏移、保存参数到 Flash。这样产品每次开机可以自动校准,或者通过串口命令进入校准模式。下面是一个简单的校准采集函数:
#define CALIB_SAMPLES 200 typedef struct { int16_t x_offset; int16_t y_offset; int16_t z_offset; uint8_t calibrated; } MagCalib_t; MagCalib_t mag_calib; void MagCalib_UpdateBounds(int16_t x, int16_t y, int16_t z) { static int16_t x_min = 32767, x_max = -32768; static int16_t y_min = 32767, y_max = -32768; static int16_t z_min = 32767, z_max = -32768; static uint16_t count = 0; if (x < x_min) x_min = x; if (x > x_max) x_max = x; if (y < y_min) y_min = y; if (y > y_max) y_max = y; if (z < z_min) z_min = z; if (z > z_max) z_max = z; count++; if (count >= CALIB_SAMPLES) { mag_calib.x_offset = (x_min + x_max) / 2; mag_calib.y_offset = (y_min + y_max) / 2; mag_calib.z_offset = (z_min + z_max) / 2; mag_calib.calibrated = 1; } }圆规法采集 200 个样本就够用了。如果你追求更精细的校准,比如处理软磁干扰,就需要做椭圆拟合,把 X/Y 轴数据拟合成椭圆,再做归一化。不过大多数室内项目、小车导航场景,圆规法已经能到 ±2 度以内,完全够用。
3.3 方位角计算
校准完之后,水平状态下航向角用反正切函数就能算:
#include "math.h" int16_t x = raw_x - mag_calib.x_offset; int16_t y = raw_y - mag_calib.y_offset; float heading = atan2f((float)y, (float)x) * 180.0f / 3.14159265f; if (heading < 0) { heading += 360.0f; }这里有个坐标系对应关系要注意:X 轴指向传感器模块的某个方向,Y 轴与之垂直。atan2(y, x)得到的是 X 轴正方向与地磁北方向的夹角。如果你的安装方向不同,可能需要交换 X/Y 或者改变正负号,这个靠实际测试对齐。
单片机跑浮点运算在 F103 上没什么压力,但如果你希望角度计算更流畅,可以用查找表或者 CORDIC 算法,不过那是性能抠到极端时才需要的优化,普通场景没必要。
3.4 磁偏角修正
磁力计测的是地磁北,不是地理北(真北)。两者之间存在一个磁偏角,不同地区差异很大,甚至同一城市不同年份也在缓慢变化。带 GPS 或需要精确导航的系统,必须查当地磁偏角并修正:
heading = heading + declination_angle; // declination_angle 单位为度,东偏为正 if (heading >= 360.0f) heading -= 360.0f; if (heading < 0) heading += 360.0f;这个修正值在工程里通常做成配置文件,不同区域下发不同参数,而不是写死到代码里。如果是做手持设备,也可以提供一个简单的“磁偏角设置”菜单,让用户在首次使用时手动输入。
4. 常见问题与调试技巧
4.1 读不到数据,或者全是 0xFF
遇到这种问题,优先级最高的排查方向:
- 设备地址对不对。HMC5883L 的 7 位地址是
0x1E,有的模块用的是 QMC5883L,驱动和地址都不一样。QMC5883L 也是常见芯片,有些商家声称兼容 HMC5883L,但寄存器定义有差异,代码不能直接通用。 - I2C 总线是不是卡死了。模拟 I2C 很少卡死,但硬件 I2C 标准库容易 BUSY。用模拟 I2C 调试最直观:用 GPIO 翻转把 SCL/SDA 的状态打印出来,或者直接量引脚波形。
- 上拉电阻缺失。如果你用的是自己画的板子而不是模块,SDA/SCL 必须要有上拉电阻。没有上拉,开漏输出根本拉不高电平,通信必然失败。
- 焊接问题。贴片 HMC5883L 引脚间距小,手工焊容易连锡或虚焊,检查一下有没有桥接。
4.2 GPIO 能不能容忍 5V
这也是热搜里频繁出现的问题。STM32F103 的数据手册里清楚写了,大部分 I/O 是 5V 容忍的,但模拟输入引脚(如 PA0~PA3 在 ADC 模式下)不行。用于 I2C 的两个引脚如果配置成开漏输出,上拉到 3.3V,那么即使外部有 5V 上拉,电流也被限制,不会损伤芯片。但如果引脚配置成推挽输出且直接接 5V,就有风险。
稳妥的做法是加电平转换芯片,或者直接用 3.3V 供电的模块。
4.3 数据漂移、角度抖动
HMC5883L 原始数据其实相当稳定,如果你看到角度不停跳动,多半是这两个原因:
- 传感器附近有变化的磁场干扰,比如电机转动、大电流导线、手机靠近。解决办法是远离干扰源,或者做电磁屏蔽。
- 数据采样噪声偏大。可以连续采多次取平均,或者加一阶低通滤波:
filtered = alpha * raw + (1 - alpha) * filtered;alpha取 0.2 到 0.5 之间,具体看你的输出频率。滤波系数越大,响应越快但噪声也大;系数越小,越平滑但滞后明显。我一般配合 50Hz 输出速率,alpha 取 0.3,拖动传感器时方位角能迅速跟上,静止时稳定在 ±0.5 度以内。
4.4 内部晶振能不能用
这个项目对时钟精度要求不高,I2C 通信是主机产生时钟,只要速率别偏太多就能工作。STM32F103 用内部 8MHz RC 振荡器时,误差在常温下约 ±1%,而 I2C 的时序要求只要在范围内就行。我做过极端测试,内部晶振下 I2C 模拟时序完全正常,航向角计算用的浮点运算也对时钟不敏感。所以,只要不是做需要高精度时间基准的应用——比如精确延时秒表、音频采样——内部晶振在这个项目里完全可用,还能省两个引脚和一块外部晶振。
4.5 低功耗停机模式下怎么用
这又是热搜里经常被问到的方向。产品如果用电池供电,单片机大部分时间应该待在停机模式,定时醒来采一次磁力计数据。注意一点:HMC5883L 在连续测量模式下功耗在 100μA 级别,停机前要把传感器切到空闲模式,也就是模式寄存器写0x01,否则系统虽然睡了,传感器还在工作。
使用 STM32 的停机模式,代码大致思路:
// 进入停机模式前 HMC5883L_WriteReg(0x02, 0x01); // 空闲模式 PWR_EnterSTOPMode(PWR_Regulator_LowPower, PWR_STOPEntry_WFI); SystemInit(); // 唤醒后重新配置时钟 HMC5883L_WriteReg(0x02, 0x00); // 重新进入连续测量因为内部晶振在停机唤醒后需要重新稳定,所以SystemInit()是必须的。另外使用 HSI 内部晶振的话,这段流程更简单,不需要等待外部晶振起振,能缩短唤醒时间。
4.6 掉电保存校准参数
校准参数算出来之后,如果每次上电都重新校准,不仅费时间,用户还容易忘记操作。更合理的方式是把 offset 保存到 STM32F103 的片上 Flash 或者外部 EEPROM。STM32F103 的 Flash 有一个特点:按页擦除,一页 1KB 或 2KB(不同型号不一样)。保存参数时,先备份原有数据,再擦除整页,然后写入。
直接用内部 Flash 有个好处是省成本,但要小心不要在程序运行时频繁擦写,Flash 寿命有限。校准参数是一次性写入,影响不大。如果以后有历史记录、多次修改的需求,再考虑外挂 AT24C02 或 W25Q64。
4.7 显示方位角:ILI9341 与 WS2812B
这个项目做出来之后,总要有个输出方式。最简单的是串口打印,但演示效果最直观的是接一块 3.2 寸 ILI9341 电阻触摸屏,画一个罗盘表盘,把方位角指针画上去。ILI9341 用 SPI 接口驱动,速率可以拉到 18MHz 以上,刷一个简单的圆和指针完全没有压力。
想让效果更吸引人,还可以加一条 WS2812B 灯带,根据方位角改变颜色和亮灯段数。WS2812B 是单总线协议,用 STM32 的 SPI + DMA 或者用 PWM + DMA 都能驱动,不占 CPU。颜色从红色(北)渐变到蓝色(南),直观展示朝向。
这两个模块结合起来,就是一个完整的罗盘演示项目:传感器读取 -> 校准 -> 角度计算 -> 屏幕绘制 -> 灯光反馈。这也是我发现很多开发者愿意去复现的完整链路。
4.8 测量相位差和 4 路 ADC 等扩展玩法
如果你已经掌握了 HMC5883L 的读取,完全可以把它当作一个“磁场感知”模块去拓展思路。比如,让传感器固定在转台上,电机带动它旋转,输出的正弦波与电机编码器的脉冲之间存在相位差,通过这个相位差可以推算磁场的绝对方向。这种思路在角度传感器、电机转子位置检测中很常见。
STM32F103 刚好有高级定时器和 ADC,4 路 ADC 同时采集磁场信号和电流信号,再配合 DMA 做波形分析,就能在同一个工程里实现“磁力计 + 电流采样”的双重功能。这也是我后来把项目升级到 FreeRTOS 的原因——任务多起来之后,裸机 while 循环的调度逻辑很快就变得混乱。
5. 实战经验总结与后续扩展
说实话,HMC5883L 这个芯片本身并不复杂,真正拉开代码水平差距的,是能不能把“读传感器”变成“做一个可靠的功能”。我在这几个项目里踩出来的体会是:
- 驱动层一定要做超时处理,不要一直死等应答。I2C 从机异常时,死等会让系统卡死,加一个 timeout 变量,问题立刻好排查。
- 校准参数和磁偏角这类配置,不要写死在代码里。放到 Flash 的独立区域,用结构体管理,后续维护方便很多。
- 如果你决定用内部晶振,记得留意 ADC 和定时器的精度。像呼吸灯这种 demo 无所谓,但涉及测量相位差、4 路 ADC 同步采样时,外部晶振的精度优势就体现出来了。
后续想继续扩展这个项目,可以考虑的方向很多:把 HMC5883L 和 MPU6050 做数据融合,输出无漂移的姿态角;配合 GPS 模块做一个完整的电子导航仪;或者把它做成一个 USB 传感器节点,插到电脑上用 Python 读取方位数据,做可视化调试界面。
硬件永远有更贵的方案,但把现有方案做透,才是嵌入式开发里真正重要的事。
本文还有配套的精品资源,点击获取