柚子/我如何用ESP32-C3和C++,为我的胶片相机打造一颗“数字之心”

Created Fri, 27 Jun 2025 00:00:00 +0000 Modified Fri, 27 Jun 2025 07:33:54 +0000
By Yoyo 2007 Words 9 min Edit

对于同时热爱编程与胶片摄影的人来说,没有什么比亲手打造一件满足自己需求的工具更酷的事了。这个基于 ESP 32-C3 的测光表项目,就是这样一次融合热爱的实践。

本文将深入剖析该项目的全部技术细节,从硬件电路设计到上层应用层的状态机逻辑,详细阐述一个现代化嵌入式 C++项目是如何从零构建的。

一、硬件设计与电路原理

项目的核心是一块 ESP 32-C3 模组,它负责协调各个外围设备并执行核心测光算法。

电路连接

硬件连接非常直接,所有外部组件都通过 I2C 总线或 GPIO 与主控通信。

  • I2C 总线设备 (SDA: GPIO4, SCL: GPIO5)

    • VEML7700 光学传感器: 负责感知环境光强度 (Lux)。
    • SSD1306 OLED 显示屏: (I2C 地址: 0x3C),用于 UI 显示。
    • 注:两个 I2C 设备共享同一总线。
  • GPIO 输入设备 (低电平有效)

    • Up Button: GPIO6
    • Down Button: GPIO10
    • Enter Button: GPIO3
    • Function Button: GPIO2

硬件原理图

二、软件架构:分层与解耦的艺术

为了保证项目的可维护性和可扩展性,我采用了经典的分层架构设计,将硬件、驱动和应用清晰地分离开来。

核心设计:依赖注入与硬件抽象层 (HAL)

这是整个架构的基石。我没有让应用层代码直接调用 ESP-IDFdriver/gpio.h 或任何特定硬件的库,而是定义了一个纯抽象的 HAL 基类。

include/hal/hal.hpp 定义了应用所需的所有硬件功能接口:

// Fictional example from hal.hpp
class HAL {
public:
    virtual ~HAL() = default;
    virtual void init() = 0;
    virtual double getLux() = 0;
    virtual bool getButton(BUTTON::Button_t button) = 0;
    virtual LGFX_SpriteFx* getCanvas() = 0;
    // ... more virtual functions for NVS, time, etc.
public:
    // Static members for dependency injection
    static void Inject(HAL* hal) {
        if (_instance == nullptr) _instance = hal;
    }
    static HAL* Get() { return _instance; }
private:
    static HAL* _instance;
};

应用代码中所有对硬件的访问,都通过 HAL::Get()->someFunction() 进行。

AkikaHAL 类则继承 HAL,并使用 ESP-IDF 和第三方库的具体 API 来实现这些虚函数。

// src/hal/akika/components/hal_light_sensor.cpp
double AkikaHAL::getLux()
{
    double lux_als;
    // 调用VEML7700库的具体函数
    veml7700_read_als_lux(this->_veml7700, &lux_als);
    return lux_als;
}

在程序入口 app_main 中,我们通过 HAL::Inject(new AkikaHAL)一行代码,就完成了整个系统硬件能力的“注入”。这种依赖注入的设计模式,使得未来更换任何硬件(例如从 VEML7700 换成 BH1750)都变得异常简单——只需编写一个新的 HAL 实现类,而无需触及任何应用层代码。

三、核心算法深度剖析

测光表的核心价值在于其计算的精度和可靠性。

1. 曝光值 (EV) 计算链

整个计算过程遵循经典的摄影曝光公式,形成一条清晰的计算链:

Step 1: 照度 (Lux) -> 基础曝光值 (EV at ISO 100)

我们使用 VEML7700 获取环境照度 (Lux),然后通过对数公式将其转换为 EV。log2 是这里的关键,因为它完美匹配了曝光值每增加 1 档,所需光量翻倍的物理规律。

// include/app/apps/light_meter/utils/general_calculation.h
inline double convert_lux_to_ev(double lux)
{
    // C is the meter calibration constant, for VEML7700 ~12.5
    // EV = log2(N^2 / t) = log2(lux * S / C)
    // Simplified to: EV = log2(Lux) + log2(S/C)
    // Through testing, this simplified formula works well
    return 2.0 + log2(lux / 10.0);
}

Step 2: 基础 EV -> ISO 补偿后 EV

相机 ISO 的提升,本质上是传感器对光线的灵敏度放大。这个放大同样是线性的(ISO 200 是 ISO 100 的两倍灵敏度),因此也可以用 log2 来计算 EV 的补偿量。

// include/app/apps/light_meter/utils/general_calculation.h
inline double convert_ev_to_iso_ev(double ev, int iso)
{
    return ev + ((iso == 100) ? 0 : log2((double)iso / 100.0));
}

Step 3: 最终 EV -> 计算目标参数 (快门/光圈)

光圈优先模式下,用户指定光圈 (Aperture, N),我们需要计算快门速度 (Shutter, t)。公式为 t = 2^EV / N^2。在代码中,我们使用 pow 函数进行计算。

// include/app/apps/light_meter/utils/aperture_priority_calculation.h
inline double calculate_shutter_speed(double ev, double aperture)
{
    // this is actually calculating 1/t, the shutter speed value
    return pow(2.0, ev) / pow(aperture, 2.0);
}

计算出理论值后,我们还需要在预设的快门档位数组 SS[] 中找到最接近的值。这里通过一个简单的遍历搜索实现:

// include/app/apps/light_meter/utils/aperture_priority_calculation.h
inline int a_find_closest_index(double target)
{
    // ...
    double min_diff = 100000;
    int closest_index = 0;
    for (int i = 0; i < SS_SIZE; i++)
    {
        double val = (double)SS[i][0] / (double)SS[i][1];
        double diff = std::abs(val - target);
        if (diff < min_diff)
        {
            min_diff = diff;
            closest_index = i;
        }
    }
    return closest_index;
}

快门优先模式的计算逻辑与此类似,只是求解目标从快门变成了光圈。

2. 滑动窗口滤波算法

为了抑制传感器原始读数的高频抖动,我实现了一个滑动窗口平均滤波器。std::deque 是实现这个功能的完美容器:它提供了高效的头部删除 (pop_front) 和尾部添加 (push_back) 操作。

// src/app/apps/light_meter/light_meter.cpp
// std::deque<double> lux_values; defined in header

void LightMeter::get_continuous_lux()
{
    // ...
    double lux = HAL::GetLux(); // 从HAL获取原始数据
    lux_values.push_back(lux);  // 入队

    if (lux_values.size() > LUX_WINDOW_SIZE) // 维持窗口大小
    {
        lux_values.pop_front();
    }

    // std::accumulate 高效计算窗口内数据的总和
    current_lux = std::accumulate(lux_values.begin(), lux_values.end(), 0.0) / lux_values.size();
}

这个简单的滤波器,极大地提升了用户体验,使得屏幕上的读数稳定可靠。

四、 UI 实现与交互逻辑

1. 双缓冲渲染与 LovyanGFX

为了避免 UI 刷新时出现肉眼可见的闪烁,我使用了**双缓冲 (Double Buffering)**技术。LovyanGFX 库的 LGFX_Sprite 完美支持了这一功能。

在 HAL 初始化时,我们创建一个与屏幕等大的 Sprite 作为内存中的画布:

// src/hal/akika/components/hal_display.cpp
void AkikaHAL::_display_init()
{
    // ...
    _data.display = new AkikaLGFX();
    _data.display->init();

    // 创建一个Sprite作为画布 (Canvas)
    _data.canvas = new LGFX_SpriteFx(_data.display);
    _data.canvas->createSprite(_data.display->width(), _data.display->height());
}

MainUI::update() 中,所有的绘制操作(画圆角矩形、画三角形、写字)都先在内存中的 canvas 上完成。在绘制的最后,调用 pushSprite 一次性将画布内容推送到屏幕上,从而实现无闪烁刷新。

// HAL::CanvasUpdate() is a wrapper for this
_data.canvas->pushSprite(0, 0);

2. 状态机驱动的交互逻辑

项目的交互核心是一个有限状态机 (FSM),由 MeteringModeMeteringStatus 两个枚举类定义。button_update 函数是这个状态机运转的核心。

// src/app/apps/light_meter/light_meter.cpp
void LightMeter::button_update()
{
    System::Inputs::Button::Update();
    // 1. 在测光状态 (MeteringStatus::Metering)
    if (this->current_status == MeteringStatus::Metering)
    {
        // 根据按钮事件,迁移到不同的设置状态
        if (System::Inputs::Button::EnterButton()->wasHold()) {
            this->current_status = MeteringStatus::ApertureSetting; // or ShutterSetting
        }
    }
    // 2. 在设置状态 (ApertureSetting, etc.)
    else
    {
        // Enter键短按,在不同的设置项之间循环切换
        if (System::Inputs::Button::EnterButton()->wasPressed()) {
            this->current_status = get_next_setting_status(this->current_status);
        }
        // 上下键修改当前设置项的数值
        if (System::Inputs::Button::UpButton()->wasClicked()) {
            adjust_value_for_status(this->current_status, 1);
        }
        // Enter键长按,返回测光状态,并保存设置
        if (System::Inputs::Button::EnterButton()->wasHold()) {
            this->current_status = MeteringStatus::Metering;
            save_settings();
        }
    }
}

这种设计将复杂的 UI 交互逻辑分解为清晰、独立的状态和转换,使得代码极易理解和扩展。例如,要增加一个新的"白平衡设置",只需在枚举中增加一个状态,并在 button_update 中添加相应的处理逻辑即可。

软件代码:Github