对于同时热爱编程与胶片摄影的人来说,没有什么比亲手打造一件满足自己需求的工具更酷的事了。这个基于 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
- Up Button:
硬件原理图
二、软件架构:分层与解耦的艺术
为了保证项目的可维护性和可扩展性,我采用了经典的分层架构设计,将硬件、驱动和应用清晰地分离开来。
核心设计:依赖注入与硬件抽象层 (HAL)
这是整个架构的基石。我没有让应用层代码直接调用 ESP-IDF
的 driver/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),由 MeteringMode
和 MeteringStatus
两个枚举类定义。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