ESP32-S3 + GC9A01 圆屏指南针翻车记:HMC5883L 实验好玩,出门别靠它(完整教程)

用 ESP32-S3 + GC9A01 圆屏 + HMC5883L 做出了一个好看的电子指南针,但做完发现精度感人。本文完整记录接线、校准、代码,同时说清楚为什么这套方案只适合实验演示,不适合正式导航应用。

ESP32-S3 + GC9A01 + HMC5883L 圆屏指南针翻车全记录——能做、好看,但这精度你懂的(完整教程)

难度:⭐⭐⭐☆☆(有一点基础可上手) 预计时间:45 分钟 测试环境:Arduino IDE 2.3.8 · Arduino_GFX_Library v1.6.5 · Adafruit_HMC5883_U v1.2.4


⚠️ 先说结论: 这套方案做出来的指南针看着很炫,大方向对得上,但精度典型在 ±5°~±15°,受周围磁场影响大。拿来学习驱动流程、做演示、当桌面摆件——完全够用。用于户外导航、无人机定向、任何精度要求严格的场合——不推荐,后面会说为什么。

TL;DR(快速上手):

  1. 先跑 I2C 扫描确认芯片地址——0x0D 是 QMC5883L(仿制),0x1E 才是真 HMC5883L,按型号装对应的库,否则读数全是乱码
  2. 按接线表连好 12 根线(屏 8 根 + 传感器 4 根,3.3V/GND 可共用)
  3. DECLINATION_DEG 改成你所在城市的磁偏角(北京约 -6.5°,东京约 -7.5°,查询链接见文末)
  4. 上电时按住 BOOT 键(GPIO0)进入 15 秒旋转校准,水平慢转一圈
  5. 松手后校准数据自动存入 NVS,掉电不丢,下次直接开用

前言

买这块 GC9A01 圆屏的时候,我盯着它看了一会儿——1.28 寸,240×240,完美的正圆。这不就是天生的罗盘表盘吗?

然后我花了一个周末把它做出来,打开手机一比对……好吧,指针大方向是对的,就是稍微偏了一点点,大概十来度的样子。转多2圈,发觉不转了。掉电再上电,还是不怎么转了。。。

“肯定是没校准好。” 我重新校准,换了个地方测,对着 iPhone 转圈圈——差距依然在那里,不是代码写错了,是这个传感器模块的先天局限。可以观察到手机靠近,也会影响到它。

所以这篇文章有两个目的:第一,把圆屏指南针完整做出来,代码能跑,校准能过,效果确实好看;第二,把它的精度局限讲清楚,让你在动手前就知道”翻车在哪”——而不是做完了才发现指针对不上 Google Maps。

如果你想学 GC9A01 + HMC5883L 的驱动方法,或者做一个酷炫的桌面摆件,这个项目完全值得做。如果你的目标是”导航精度”,建议直接跳到文章后面的”适不适合正式项目”那一节,再决定要不要继续。


实验效果

GC9A01 圆屏上实时显示指南针表盘:红色指针指北,中央绿色数字显示当前方位角(0°~359°),黄色字母标注最近的八方位(N / NE / E / SE / S / SW / W / NW)。上电时按住 BOOT 键进入 15 秒旋转校准模式,屏幕显示进度条和实时磁场范围;校准完成后指针运动平滑、约 25fps,不会像未校准时那样乱抖。


关于精度,先说清楚: 校准过的 HMC5883L 在理想环境(远离金属和其他磁场源)下,方位角误差约 ±5°。靠近电脑主机、充电器、音箱或螺丝刀时,误差轻松涨到 ±15° 以上。日常桌面使用”大方向没错”,但是我买的这个模块不知道是不是正品,有时候是会抽风不动,精确到十位数就不要指望了。这是硬件的先天局限,不是代码的问题,后面的”适不适合正式项目”一节会详细解释。


元件说明

GC9A01 圆形 TFT 屏

想象一块直径 3.2 厘米的圆形手表屏——GC9A01 就是这个,SPI 接口,分辨率 240×240,驱动内置在屏幕控制器里,ESP32 直接推像素就行,不需要外置 RAM。之所以选它,一是圆形天生适合罗盘 UI,二是 Arduino_GFX_Library 有完整支持,驱动代码几行搞定。

参数规格
分辨率240 × 240 px
接口SPI(最高 80 MHz)
供电3.3V
背光控制高电平点亮
典型功耗约 20 mA(全亮)

GC9A01 屏幕模块(8 个引脚)

引脚标注功能
VCC3.3V 供电
GND
SCL / CLKSPI 时钟
SDA / MOSISPI 数据(主→从)
CS片选,低有效
DC数据/命令选择
RST硬件复位,低有效
BL背光控制,高电平点亮

HMC5883L / QMC5883L 三轴磁力计

磁力计是指南针的”鼻子”,负责感知地球磁场在 X/Y/Z 三个方向的强度,然后用反三角函数算出你面朝哪个方向。I2C 接口,3.3V 供电,读取一次数据只需几毫秒。

需要特别说明:市面上绝大多数标着”HMC5883L”的模块,实际芯片是 QST 公司的 QMC5883L——两者引脚兼容,但寄存器完全不同,对应的驱动库也不一样。先别急着装库,按下文的 I2C 扫描步骤确认你手上是哪个芯片,再装对应的库,能省去大半排查时间。

参数HMC5883L(原版)QMC5883L(仿制)
I2C 地址0x1E0x0D
量程±8 Gauss±8 Gauss
分辨率2 mGauss2 mGauss
噪声密度~2 mGauss/√Hz~2 mGauss/√Hz

HMC5883L / QMC5883L 磁力计模块(4 个常用引脚)

引脚标注功能
VCC3.3V 供电
GND
SDAI2C 数据
SCLI2C 时钟
DRDY数据就绪中断(本项目不用,不接也行)

两者基础性能相近,用于实验演示都没问题。但需要说清楚的是:无论哪款芯片,这个价位的磁力计模块都没有片上温漂补偿,也没有传感器融合,只做了最基础的二维磁场测量——这决定了它的精度上限,也决定了它只适合做演示和学习,不适合实际导航应用。


BOM 表

元件型号 / 规格数量参考价
主控开发板ESP32-S3(任意开发板)1¥25~40
圆形 TFT 屏GC9A01,1.28 寸,240×2401¥12~20
磁力计模块HMC5883L 或 QMC5883L1¥3~8
杜邦线公对母,20cm若干¥3

接线方式

建议接完之后对着表格逐根核对一遍,这一步能省掉 80% 的”为什么没反应”排查时间。

GC9A01 圆屏 → ESP32-S3

屏幕引脚ESP32-S3
VCC3.3V
GNDGND
SCL / CLKGPIO12
SDA / MOSIGPIO11
CSGPIO9
DCGPIO10
RSTGPIO18
BLGPIO7(或直接接 3.3V 常亮)

HMC5883L / QMC5883L → ESP32-S3

传感器引脚ESP32-S3
VCC3.3V
GNDGND
SDAGPIO14
SCLGPIO13

需要安装的库

安装前先做一件事——确认你的磁力计芯片型号。上传下面这段代码,打开串口监视器(115200),看打印的 I2C 地址:

#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin(13, 14);  // SDA=13, SCL=14,和本项目一致

  Serial.println("Scanning I2C...");
  for (uint8_t addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.printf("Found device at 0x%02X\n", addr);
    }
  }
  Serial.println("Done.");
}

void loop() {}
  • 打印 0x1E → 是真 HMC5883L,装 Adafruit HMC5883 Unified(作者 Adafruit)
  • 打印 0x0D → 是 QMC5883L,需要把代码里的 #include 和传感器对象换成对应的库(见常见问题第 3 条)

确认芯片后,打开 Arduino IDE → 库管理器,搜索安装:

库名适用芯片测试通过版本
Arduino_GFX_Libraryv1.6.5
Adafruit HMC5883 UnifiedHMC5883L(0x1E)v1.2.4
Adafruit Unified Sensor两者都需要v1.1.15

如果你是 QMC5883L(0x0D),后面常见问题里有替换方案。


完整代码

#include <Arduino_GFX_Library.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_HMC5883_U.h>
#include <Preferences.h>
#include <math.h>

// ─── 第一步:引脚定义 ────────────────────────────────
#define TFT_SCK  12
#define TFT_MOSI 11
#define TFT_CS    9
#define TFT_DC   10
#define TFT_RST  18
#define TFT_BL    7
#define I2C_SDA  14
#define I2C_SCL  13

// 上电时按住此键进入校准模式(BOOT 键,GPIO0,不用另外接按钮)
#define CAL_BTN   0

// 磁偏角(偏西为负)—— 查询工具:https://www.ngdc.noaa.gov/geomag/calculators/magcalc.shtml
// 北京 ≈ -6.5°,上海 ≈ -5.5°,广州 ≈ -3°,东京 ≈ -7.5°
// 不改这个值,指南针整体会偏 X 度,所有方向都错
#define DECLINATION_DEG  (-3.0f)

// ─── 第二步:显示对象初始化 ────────────────────────────────
Arduino_DataBus *bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCK, TFT_MOSI, -1);
Arduino_GC9A01  *gfx = new Arduino_GC9A01(bus, TFT_RST, 0, true);

// Canvas 双缓冲:先在内存里画好整帧,再一次性推送到屏幕,解决闪烁问题
// 内存占用:240×240×2 = 115 KB(ESP32-S3 的 PSRAM 或内部 SRAM 均够用)
Arduino_Canvas  *canvas = new Arduino_Canvas(240, 240, gfx, 0, 0);

// ─── 传感器对象 ──────────────────────────────────
Adafruit_HMC5883_Unified mag = Adafruit_HMC5883_Unified(12345);

// ─── 校准参数(硬铁偏移 + 软铁缩放,存在 NVS 里)───────────────────
Preferences prefs;
float calOffX = 0, calOffY = 0;
float calSclX = 1, calSclY = 1;

// ─── EMA 低通滤波参数 ────────────────────────────
float gSmooth    = 0;
bool  gFirstRead = true;

// alpha 越小越平滑(但响应越慢);桌面摆放用 0.15,手持移动可调到 0.25
#define EMA_ALPHA  0.15f

// ─── 颜色定义(RGB565 格式)────────────────────────────────
#define C_BG      0x0000   // 黑色背景
#define C_RING    0x4208   // 深灰外环
#define C_TICK    0x7BEF   // 灰色小刻度
#define C_MAJOR   0xFFFF   // 白色主刻度 / 标签
#define C_NORTH   0xF800   // 红色 N
#define C_NDL_N   0xF800   // 红针(北端)
#define C_NDL_S   0xCE79   // 银色针(南端)
#define C_DEG     0x07E0   // 绿色度数
#define C_DIR     0xFFE0   // 黄色方向字母

const char* kDir[] = {"N","NE","E","SE","S","SW","W","NW"};

#define CX 120   // 圆心 X
#define CY 120   // 圆心 Y
#define R  100   // 表盘半径

// ─────────────────────────────────────────────
//  读取方位角(含硬铁/软铁校准修正)
// ─────────────────────────────────────────────
float readHeading() {
  sensors_event_t ev;
  mag.getEvent(&ev);

  // 减去硬铁偏移,消除周围固定磁场(螺丝、铜柱等)的干扰
  float x = ev.magnetic.x - calOffX;
  float y = ev.magnetic.y - calOffY;
  // 软铁归一化:把椭圆形的磁场响应映射回圆形
  if (calSclX > 0.01f) x /= calSclX;
  if (calSclY > 0.01f) y /= calSclY;

  float h = atan2f(y, x) + DECLINATION_DEG * (float)M_PI / 180.0f;
  if (h <  0)               h += 2.0f * (float)M_PI;
  if (h > 2.0f*(float)M_PI) h -= 2.0f * (float)M_PI;
  return h * 180.0f / (float)M_PI;
}

// ─────────────────────────────────────────────
//  EMA 低通滤波(正确处理 0°/360° 环绕跳变)
// ─────────────────────────────────────────────
float emaFilter(float newAngle) {
  if (gFirstRead) { gFirstRead = false; return newAngle; }
  float d = newAngle - gSmooth;
  if (d >  180.0f) d -= 360.0f;   // 比如从 359° 跳到 1°,差值应该是 +2°,而不是 -358°
  if (d < -180.0f) d += 360.0f;
  float r = gSmooth + d * EMA_ALPHA;
  if (r <   0.0f) r += 360.0f;
  if (r >= 360.0f) r -= 360.0f;
  return r;
}

// ─────────────────────────────────────────────
//  全帧渲染(画完整帧再推屏,杜绝闪烁)
// ─────────────────────────────────────────────
void drawFrame(float angle) {
  canvas->fillScreen(C_BG);

  // 外环(4 像素宽,给表盘加一个边框感)
  for (int r = R; r > R - 4; r--)
    canvas->drawCircle(CX, CY, r, C_RING);

  // 刻度线:每 10° 一根,每 30° 加长,每 90° 用白色
  for (int deg = 0; deg < 360; deg += 10) {
    float rad = deg * (float)M_PI / 180.0f;
    int   len = (deg % 30 == 0) ? 12 : 6;
    canvas->drawLine(
      CX + (int)(cosf(rad) * (R - 5)),    CY + (int)(sinf(rad) * (R - 5)),
      CX + (int)(cosf(rad) * (R-5-len)),  CY + (int)(sinf(rad) * (R-5-len)),
      (deg % 90 == 0) ? C_MAJOR : C_TICK
    );
  }

  // N/E/S/W 标签,N 用红色醒目
  canvas->setTextSize(2);
  canvas->setTextColor(C_NORTH); canvas->setCursor(CX-6,    CY-R+20);  canvas->print("N");
  canvas->setTextColor(C_MAJOR); canvas->setCursor(CX+R-32, CY-7);     canvas->print("E");
                                 canvas->setCursor(CX-6,    CY+R-32);  canvas->print("S");
                                 canvas->setCursor(CX-R+20, CY-7);     canvas->print("W");

  // 指针(3 像素宽,视觉更清晰)
  float rad  = angle * (float)M_PI / 180.0f;
  float perp = rad + (float)M_PI / 2.0f;
  int   pdx  = (int)roundf(cosf(perp));
  int   pdy  = (int)roundf(sinf(perp));
  int   nx   = CX + (int)(sinf(rad) * 68);   // 红针(指北端)
  int   ny   = CY - (int)(cosf(rad) * 68);
  int   sx   = CX - (int)(sinf(rad) * 42);   // 银针(指南端,短一点)
  int   sy   = CY + (int)(cosf(rad) * 42);
  for (int d = -1; d <= 1; d++) {
    canvas->drawLine(CX+pdx*d, CY+pdy*d, nx+pdx*d, ny+pdy*d, C_NDL_N);
    canvas->drawLine(CX+pdx*d, CY+pdy*d, sx+pdx*d, sy+pdy*d, C_NDL_S);
  }

  // 中心轴小圆(装饰用)
  canvas->fillCircle(CX, CY, 9, C_RING);
  canvas->drawCircle(CX, CY, 9, 0xA534);
  canvas->fillCircle(CX, CY, 3, C_MAJOR);

  // 中央显示度数(绿色)和八方位字母(黄色)
  canvas->setTextSize(2);
  canvas->setTextColor(C_DEG);
  char buf[8]; sprintf(buf, "%3d", (int)angle);
  canvas->setCursor(CX - 18, CY - 14); canvas->print(buf);

  int   idx = ((int)(angle + 22.5f) % 360) / 45;
  int   w   = strlen(kDir[idx]) * 6;
  canvas->setTextSize(1);
  canvas->setTextColor(C_DIR);
  canvas->setCursor(CX - w/2, CY + 6); canvas->print(kDir[idx]);

  canvas->flush();   // ← 整帧一次性推送到屏幕,这一行是解决闪烁的关键
}

// ─────────────────────────────────────────────
//  15 秒旋转校准
//  原理:记录传感器在各方向的最大/最小值,
//       算出硬铁偏移(offset)和软铁缩放(scale)
// ─────────────────────────────────────────────
void runCalibration() {
  float minX =  1e6f, maxX = -1e6f;
  float minY =  1e6f, maxY = -1e6f;
  const uint32_t DUR = 15000;
  uint32_t t0 = millis();

  while (millis() - t0 < DUR) {
    sensors_event_t ev; mag.getEvent(&ev);
    if (ev.magnetic.x < minX) minX = ev.magnetic.x;
    if (ev.magnetic.x > maxX) maxX = ev.magnetic.x;
    if (ev.magnetic.y < minY) minY = ev.magnetic.y;
    if (ev.magnetic.y > maxY) maxY = ev.magnetic.y;

    // 实时显示校准进度画面
    canvas->fillScreen(C_BG);
    canvas->setTextColor(C_DIR);  canvas->setTextSize(2);
    canvas->setCursor(15, 60);  canvas->print("CALIBRATING");
    canvas->setTextColor(C_MAJOR); canvas->setTextSize(1);
    canvas->setCursor(8, 95);   canvas->print("Slowly rotate 360 deg");
    canvas->setCursor(18, 109); canvas->print("Keep device level");
    // 进度条
    int p = (millis() - t0) * (R*2-2) / DUR;
    canvas->drawRect(20, 130, R*2, 14, C_MAJOR);
    canvas->fillRect(21, 131, p, 12, 0x07E0);
    // 实时显示磁场范围(帮助确认是否转满了一圈)
    char b[44];
    canvas->setTextColor(0x7BEF);
    sprintf(b, "X[%.1f ~ %.1f]", minX, maxX);
    canvas->setCursor(8, 157); canvas->print(b);
    sprintf(b, "Y[%.1f ~ %.1f]", minY, maxY);
    canvas->setCursor(8, 170); canvas->print(b);
    canvas->flush();
    delay(50);
  }

  // 计算偏移和缩放
  calOffX = (maxX + minX) / 2.0f;
  calOffY = (maxY + minY) / 2.0f;
  calSclX = (maxX - minX) / 2.0f;  if (calSclX < 0.01f) calSclX = 1.0f;
  calSclY = (maxY - minY) / 2.0f;  if (calSclY < 0.01f) calSclY = 1.0f;

  // 保存到 NVS(掉电不丢)
  prefs.begin("compass", false);
  prefs.putFloat("offX", calOffX);  prefs.putFloat("offY", calOffY);
  prefs.putFloat("sclX", calSclX);  prefs.putFloat("sclY", calSclY);
  prefs.end();

  // 校准结果画面
  canvas->fillScreen(C_BG);
  canvas->setTextColor(0x07E0); canvas->setTextSize(2);
  canvas->setCursor(30, 88); canvas->print("CAL DONE!");
  canvas->setTextColor(C_MAJOR); canvas->setTextSize(1);
  char b[44];
  sprintf(b, "offX = %.1f", calOffX); canvas->setCursor(10, 120); canvas->print(b);
  sprintf(b, "offY = %.1f", calOffY); canvas->setCursor(10, 133); canvas->print(b);
  sprintf(b, "sclX = %.1f", calSclX); canvas->setCursor(10, 148); canvas->print(b);
  sprintf(b, "sclY = %.1f", calSclY); canvas->setCursor(10, 161); canvas->print(b);
  canvas->flush();
  delay(3000);
}

// ─────────────────────────────────────────────
//  从 NVS 加载上次保存的校准数据
// ─────────────────────────────────────────────
void loadCalibration() {
  prefs.begin("compass", true);
  calOffX = prefs.getFloat("offX", 0.0f);
  calOffY = prefs.getFloat("offY", 0.0f);
  calSclX = prefs.getFloat("sclX", 1.0f);
  calSclY = prefs.getFloat("sclY", 1.0f);
  prefs.end();
  if (calSclX < 0.01f) calSclX = 1.0f;
  if (calSclY < 0.01f) calSclY = 1.0f;
  Serial.printf("[CAL] off=(%.2f, %.2f)  scl=(%.2f, %.2f)\n",
                calOffX, calOffY, calSclX, calSclY);
}

// ─────────────────────────────────────────────
//  Setup
// ─────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, HIGH);  // 背光点亮
  pinMode(CAL_BTN, INPUT_PULLUP);

  gfx->begin();
  canvas->begin();       // 分配帧缓冲,此时消耗约 115 KB 内存

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(400000); // 400 kHz 快速模式,降低 I2C 读取延迟

  if (!mag.begin()) {
    // 传感器找不到时,屏幕显示红色错误提示
    canvas->fillScreen(0xF800);
    canvas->setTextColor(0xFFFF); canvas->setTextSize(2);
    canvas->setCursor(10, 100); canvas->print("SENSOR ERROR");
    canvas->setCursor(10, 125); canvas->print("Check wiring!");
    canvas->flush();
    while (1) delay(500);
  }

  loadCalibration();

  // 上电时按住 BOOT(GPIO0) → 进入旋转校准
  if (digitalRead(CAL_BTN) == LOW) {
    canvas->fillScreen(C_BG);
    canvas->setTextColor(C_DIR); canvas->setTextSize(1);
    canvas->setCursor(10, 112); canvas->print("Release to start cal...");
    canvas->flush();
    while (digitalRead(CAL_BTN) == LOW) delay(10);
    delay(500);
    runCalibration();
  }

  // 丢弃前几个不稳定的热机读数
  for (int i = 0; i < 8; i++) {
    sensors_event_t ev; mag.getEvent(&ev); delay(15);
  }
  gSmooth    = readHeading();
  gFirstRead = false;
}

// ─────────────────────────────────────────────
//  Loop:读数 → 滤波 → 渲染,循环约 25fps
// ─────────────────────────────────────────────
void loop() {
  float raw = readHeading();
  gSmooth   = emaFilter(raw);
  drawFrame(gSmooth);
  delay(30);  // 30ms ≈ 33fps,实际加上渲染时间约 25fps
}

代码说明

为什么要用 Canvas? Arduino_Canvas 相当于在内存里开了一块 115KB 的”草稿纸”,先把整帧画完,再用 canvas->flush() 一次性推到屏幕。如果直接往屏幕上画,每一笔都会立刻显示,指针转动时会明显闪烁。Canvas 解决了这个问题,代价是多占一块内存。

readHeading() 做了什么? 从传感器拿到的 X/Y 磁场强度,减去硬铁偏移(消除固定磁场干扰),再除以软铁缩放系数(修正各轴灵敏度不一致),最后加上磁偏角修正,得到真北方向的角度。

emaFilter() 为什么要处理环绕? 如果指针从 359° 转到 1°,两个读数之差是 -358°,如果直接做加权平均,指针会反方向转一大圈。代码里先把差值限制在 [-180°, +180°] 范围内,再做平滑,就能正确处理跨越 0° 的情况。

校准原理是什么? 在水平面内转一圈,传感器的 X/Y 读数会描绘出一个椭圆(理想情况是圆)。记录最大最小值,中点就是硬铁偏移,半径就是软铁缩放系数。校准完成后,数据存入 NVS(类似手机里的 EEPROM),下次上电自动加载,不需要每次重新校准。


常见问题排查

别慌,90% 的问题出在这几个地方。

屏幕全黑或全白,什么都不显示。 先检查 BL(背光)引脚是否高电平——如果接的是 GPIO7,确认代码里有 digitalWrite(TFT_BL, HIGH);如果直接接 3.3V,背光应该一直亮,黑屏说明别的引脚有问题。再对照接线表逐根确认 CS、DC、RST 是否接到了正确的 GPIO,其中 CS 和 DC 接反是高频失误。

串口打印 SENSOR ERROR,屏幕显示红色报错。 磁力计没响应,大概率是 I2C 接线问题——SDA/SCL 接反了,或者接到了不同的 GPIO。确认 Wire.begin(13, 14) 对应的是你实际接的引脚。另一个可能是模块没有 3.3V 供电,用万用表量一下 VCC 脚。

指针乱跳,完全不准,或者一直停在某个方向不动。 最可能的原因是你的模块是 QMC5883L(0x0D),但代码用的是 HMC5883L 的库——两个库寄存器定义完全不同,读出来的数就是乱的。先跑 I2C 扫描确认地址,如果是 0x0D,需要把代码里的 #include <Adafruit_HMC5883_U.h> 和传感器对象换成 QMC5883LCompass 库的写法,网上有现成的适配示例。

校准完了,但指向还是偏了 10°~20°。 检查 DECLINATION_DEG 有没有改成你所在城市的值,这个参数差了 5° 就会让所有方向都系统性偏移。东京约 -7.5°,北京约 -6.5°,准确值用文末的 NOAA 工具查询。另一个原因是校准时周围有强磁场(手机、螺丝刀、音箱磁铁),换个空旷的地方重新校准一次。

编译报错 Adafruit_HMC5883_U.h: No such file or directory 库没装或者装错了。打开 Arduino IDE → 工具 → 管理库,搜索 HMC5883,安装 Adafruit HMC5883 Unified 以及它依赖的 Adafruit Unified Sensor。


FAQ 问答

Q:HMC5883L 和 QMC5883L 有什么区别?能用同一个库驱动吗? A:不能混用。两者引脚完全兼容(焊上去外形一样),但内部寄存器地址不同,驱动协议不同,用错库读出来全是无意义的数值。HMC5883L 的 I2C 地址是 0x1E,QMC5883L 是 0x0D,用 I2C 扫描一秒钟就能确认。

Q:BL 背光引脚能直接接 3.3V 吗,还是必须接 GPIO? A:直接接 3.3V 完全可以,屏幕会全程常亮。用 GPIO 控制的好处是可以在代码里控制亮度或者休眠时关掉背光省电。如果不需要这些功能,接 3.3V 省一个 GPIO。

Q:DECLINATION_DEG 怎么查我城市的准确值? A:用 NOAA 提供的磁偏角计算工具(见文末参考资料),输入你的城市坐标,Model 选 WMM,会给出当前日期的精确磁偏角。偏东为正值,偏西为负值。日本东部城市普遍在 -7° 到 -8° 之间,中国东部沿海约 -5° 到 -6°。

Q:EMA_ALPHA 调大或调小有什么区别? A:alpha 越大,指针响应越快,但越容易抖动;越小,指针越平滑,但转动时有明显的拖尾感。0.15 适合平放在桌面的场景;如果是手持走动,可以调到 0.25 ~ 0.3。取值范围是 0.0(完全不动)到 1.0(不滤波,原始值)。

Q:校准数据存在哪?换了电脑重新烧录代码后还在吗? A:校准数据存在 ESP32 的 NVS(非易失性存储,类似 EEPROM),烧录新代码不会清除 NVS,下次上电直接加载。只有执行”擦除所有 Flash”操作时才会丢失,届时需要重新校准一次。

Q:115 KB 的帧缓冲会不会太大?ESP32-C3 能用吗? A:ESP32-S3 有 512KB SRAM,115KB 没问题。ESP32-C3 只有 400KB SRAM,加上代码和堆栈,实测会比较紧张,建议用 PSRAM 版本或者改用更小尺寸的屏幕。原版 ESP32(WROOM / WROVER)的 SRAM 更少,WROVER 版带 PSRAM 的可以用,WROOM 无 PSRAM 版大概率 OOM 崩溃。

Q:为什么我的指南针和手机差了十几度,是正常的吗? A:在这套方案里,差十几度是完全正常的现象,不是 bug。HMC5883L/QMC5883L 在有干扰的真实环境里,±10°~±15° 是常见误差范围。如果误差稳定在 ±5° 以内,已经算校准得不错了。想让误差更小,需要换精度更高的传感器并引入九轴融合,单靠调参数不够。

Q:能不能用这套方案做正式的导航或定向产品? A:不推荐。精度只有 ±5°~±15°,受周围磁场环境影响大,也没有倾斜补偿——只要不是严格水平放置,误差就会明显增大。做演示、学习原理、当桌面摆件完全够用;需要实际导航精度的场合,建议换 ICM-20948 这类带硬件传感器融合的方案。


HMC5883L 适不适合正式项目?

直接说结论:不适合。

实验演示没问题,学习驱动流程、展示 maker 项目、桌面摆件——都可以。但如果你在做一个真正需要方向感知的产品,这套方案有三个绕不过去的问题:

第一,没有倾斜补偿。模块一旦不是水平放置,方位角误差就快速增加——歪 20° 能带来超过 10° 的方向偏差。iPhone 用加速度计实时补偿这个误差,这块模块本身做不到,需要额外接 MPU6050 并修改算法。

第二,受环境磁场影响严重。旁边的电脑电源、USB 线、金属支架都会污染读数,而且这种干扰是动态的,校准一次存入 NVS 并不能补偿运动中实时变化的磁场。

第三,市售模块质量参差不齐。大多数是 QMC5883L 仿制版,没有原版 HMC5883L 的片上温漂补偿,温度变化时读数会飘。

如果你的项目需要可靠的方向感知,更合适的选择是 ICM-20948(集成九轴传感器 + 硬件 DMP 融合),或者直接用 GPS 模块结合两点坐标计算朝向——精度和稳定性不是一个量级。

这个项目的正确定位是:麻雀虽小五脏俱全的学习样本。它让你完整走一遍”磁力计驱动 → 硬铁校准 → 滤波 → 显示”的完整链路,这套知识用到更好的传感器上完全通用。


延伸玩法

做完基础款,有几个方向可以接着探索:

加一块 MPU6050 六轴传感器,读取加速度计数据做倾斜补偿。这是上面提到的最大局限之一——现在这个版本只有 2D 磁场,设备稍微歪一点就会产生明显误差;加上倾斜补偿后竖着拿也能保持准确,这也是 iPhone 指南针稳定的核心原因之一。这是让这个项目”从玩具升级到可用”最值得做的一步。

接一块 SD 卡模块,用 LVGL 或者自己画的地图叠加指南针方向,做一个离线导航仪。圆屏的显示面积有限,但显示当前朝向和目标方向的箭头完全够用。

把方位角数据通过 Wi-Fi 推送到 MQTT broker,接入 Home Assistant 或者自己的 dashboard,做成一个桌面方向感知传感器,用于判断门窗朝向或天线对准。


参考资料