基于行空板K10 便携式 pH 计全链路工程实现
前言
pH 值是表征水体酸碱特性最核心的理化指标,水体氢离子活度的微小波动,会直接改变物质溶解度、化学反应速率与生物酶活性,深刻影响水生生物的生存环境,是水环境监测、农业生产、教学科研、小型生产加工等诸多领域的关键检测参数。在实际应用场景中,pH 检测具备极强的普适性:水产养殖中,水体 pH 失衡极易导致养殖生物应激、爆发病害,是养殖水质管控的核心指标;环境监测领域,地表水、污水排放、土壤浸出液的 pH 值为法定必测管控项目;水族造景、户外自然水体实地调研需要便携、稳定的现场 pH 检测手段;同时各类教学实验、创客研发以及小型食品、日化加工作坊,也迫切需要成本可控、操作简便、数据可靠的 pH 检测设备。
目前市面主流检测设备存在明显的两极化痛点:高端商用 pH 检测仪精度稳定、功能完备,但售价高昂、操作逻辑复杂,使用门槛较高,并不适配水产爱好者、学生创客、野外调研人员及小型作坊的使用需求;而市面上低价简易的 pH 检测套件,虽具备价格优势,却普遍存在信号抖动噪声大、校准逻辑简陋粗糙、无人机交互优化、缺失数据留存功能等问题,极易出现读数跳变、校准失效、检测结果失真等情况,误导使用者做出错误判断。事实证明,想要在低成本前提下实现精准可信的 pH 检测,仅依靠升级芯片、电极等硬件配置远远不够,必须从模拟信号采集、实时任务调度、校准算法优化、嵌入式人机交互、声光反馈提示到异步数据存储,完成全流程、系统性的工程优化设计。
基于以上行业现状与场景痛点,本项目依托 DFRobot K10(ESP32‑S3)硬件板卡,搭配复合 pH 电极,开发一套完整开源的 pH 计固件工程。项目核心旨在以低成本开源硬件为基础,补齐市面廉价 pH 检测设备缺失的全套工程细节,在严格控制硬件成本的前提下,实现稳定、精准、可信的水体 pH 检测,为普通爱好者、教学实验、小型野外实地作业提供一套可复刻、可二次开发、透明可控的标准化检测方案。本项目所有代码完全开源,不仅提供可直接烧录运行的成品固件,还完整公开信号处理、校准算法、人机交互逻辑、软件音频提示、异步数据存储的核心实现思路,成品设备既可直接组装落地投入实际检测使用,也可作为嵌入式开发、传感器信号处理的优质教学实训案例,助力开源检测工具在民用、教学及小型作业场景的普及落地。

一、测量精度:从电极到屏幕的整条信号链
很多人会陷入一个误区:想要测准pH,只需要更换更高精度ADC芯片。但真实嵌入式传感器系统中,测量精度是一整条信号链路共同决定,硬件电极、采样调度、噪声抑制、多核心任务调度、跨线程数据传递,每一个环节都会引入误差。本章完整展示从电极模拟信号输入,到屏幕输出pH读数五道关键工序,解释本项目如何在现有硬件基础上做全链路误差抑制,区分硬件本身极限与软件优化边界。

测得准不是单点优化,是整条信号链的误差控制。从电极到屏幕,本项目用了五道工序。
1.1 硬件链路:pH 电极 → 扩展板 ADC → 12‑bit 数字
pH 复合电极输出 mV 级模拟电压,接扩展板 IO_C0 通道:
#define PH_PIN IO_C0
扩展板模拟前端把电极信号调理后送入 12‑bit ADC,满量程 3386mV。电压换算:
r.voltage_mV = (r.raw_median / 4096.0f) * 3386.0f;
量化步长 = 3386/4096 ≈ 0.83 mV,对应 pH 分辨率 0.83/59.16 ≈ 0.014 pH。
测得准的关键认知:ADC 量化极限 0.014 pH,但 pH 电极本身的重复性约 ±0.05 pH。电极是瓶颈,不是 ADC。提高 ADC 位数不会提升实际精度,所以 12‑bit 够用。
3386mV 而不是 3300mV,是扩展板模拟前端的实测满量程,与 DFRobot 官方接口对齐,保证校准存点和官方生态数值可比。
1.2 双核隔离:采集不被 UI 抖动干扰
ADC 采集任务绑定 Core 1:
xTaskCreatePinnedToCore(ph_sample_task, "ph_sample", 4096, NULL, 4, NULL, 1);
// ↑ core 1
LVGL 渲染在 Core 0。某些帧脏区域大,渲染占用 Core 0 几十毫秒。如果 ADC 也在 Core 0,采样周期会被渲染抖动拉长。物理隔离后,50ms 采样周期只受 FreeRTOS 调度器抖动影响(微秒级)。
1.3 50ms 采样 + 10 点中值滤波
采集任务每 50ms 读一次 ADC,推入环形缓冲:
void ph_sample_task(void *arg) {
while(1) {
uint16_t raw;
expansion_adc_read(s_exp, PH_PIN, &raw);
ph_data_pool_push(raw);
vTaskDelay(pdMS_TO_TICKS(PH_SAMPLE_MS)); // 50ms
}
}
主循环聚合时取 10 点中值:
// 插入排序 (n<=10)
for (int i = 1; i < n; i++) { ... }
r.raw_median = vals[n / 2]; // 中值
为什么中值不均值:pH 电极的主要噪声是脉冲噪声(手碰、电磁干扰、电源尖峰),不是高斯白噪声。均值滤波会把一个 100mV 尖峰均摊到 10mV,中值滤波只要尖峰不超过窗口一半就完全免疫。这是针对电极噪声特性的精准选择。
10 点窗口 = 50ms × 10 = 500ms。人眼对数值跳变的容忍约 500ms,再长会感觉迟钝。这是实时性与鲁棒性的平衡点。
1.4 生产者‑消费者无锁写
数据池 push 不加锁:
static volatile uint16_t s_tail = 0;
void ph_data_pool_push(uint16_t adc_raw) {
s_pool[s_tail].raw = adc_raw; // 无锁写
s_tail = (s_tail + 1) % PH_POOL_N; // volatile 自增
}
单生产者(Core 1)独占写,单消费者(Core 0)只读 s_tail 推进顺序。最坏情况是消费者少用一个最新样本,对中值滤波无影响。这是 SPSC(Single‑Producer Single‑Consumer)无锁队列原理,比加锁省上下文切换。
1.5 互斥锁 5ms 超时:可用性优先
消费者加锁用 5ms 超时:
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(5)) == pdTRUE) { ... }
UI 主循环 50ms 一帧,锁被卡住会冻屏。5ms 超时让 UI 宁可丢一帧数据也不死锁——可用性优先于数据完整性。
流程图 1:信号链端到端数据流(Mermaid 源码见下,渲染版见 `

测得准靠的是整条链路的误差控制——12‑bit ADC 量化够用、双核隔离保周期、中值滤波抗脉冲、无锁队列省开销、超时降级保可用。五道工序缺一不可。
二、校准方案:单点偏置 + 能斯特理论斜率
校准是pH计的灵魂,校准流程是否合理直接决定设备读数是否可信。很多开源示例直接照搬网络代码,忽略电极极性、温度补偿、校验逻辑,最终测量结果完全失真。本章从电化学能斯特方程出发,讲清楚本项目“单点校准+理论斜率”这套折中方案背后数学原理,同时介绍极性校验、弹窗防误触、NVS参数持久化、温度自动补偿整套交互与容错逻辑,理解精度和操作门槛之间如何做工程取舍。
校准是 pH 计的核心交互。本项目用单点偏置 + 理论斜率方案,背后是能斯特方程和 pH 电极的物理特性。

2.1 能斯特方程与 59.16 的来历
pH 玻璃电极的膜电位服从能斯特方程:
E = E° - (2.303 RT / F) · pH
25°C 下 2.303 × R × T / F = 59.16 mV,这就是理论斜率:
#define PH_SLOPE_25C 59.16f
温度补偿是线性的:
static float slope_at_temp(float temperature_C) {
return PH_SLOPE_25C * (temperature_C + 273.15f) / 298.15f;
}
T 是绝对温度,一次项,所以这是精确线性关系,不是近似。
2.2 pH 负斜率:公式必须带负号
pH 复合电极的接线方式使得 pH 越低、电压越高:
| 缓冲液 | pH | pH 输出电压 |
|---|---|---|
| 邻苯二甲酸氢钾 | 4.00 | ≈ 2032 mV |
| 混合磷酸盐 | 7.00 | ≈ 1500 mV |
| 硼砂 | 9.18 | < 1500 mV |
校准公式因此带负号:
return PH_BUFFER_7 - (voltage_mV - s_v7) / slope;
// ↑ 负号是 pH 极性决定
极性警示:网上很多 pH 示例是正斜率(
pH = 7 + (V‑V7)/slope),跑在正斜率模块上。pH 是负斜率,照搬会让测量结果完全颠倒。
2.3 单点校准 V7 + V9 方向校验
校准参数只有两个 static float:
static float s_v7 = NAN; // pH 7.00 时的电压,定截距
static float s_v9 = NAN; // pH 9.18 时的电压,仅做方向校验
校准计算只用 V7:
float ph_cal_compute(float voltage_mV, float temperature_C) {
if (!ph_cal_is_calibrated()) return NAN;
float slope = slope_at_temp(temperature_C);
return PH_BUFFER_7 - (voltage_mV - s_v7) / slope;
}
V9 用来做方向校验:V7 - V9 > 10mV 才认为电极极性正确:
float diff = s_v7 - s_v9;
if (diff <= 0 || diff < PH_MIN_CALIBRATION_DIFF_MV) {
ESP_LOGW(TAG, "probe polarity?"); // 极性反接或电极失效
return false;
}

为什么敢用理论斜率:pH 电极出厂斜率与理论值偏差通常 < 3%,全量程误差 < 0.4 pH。两点校准要求用户备两种缓冲液、两次操作,门槛高。用理论斜率牺牲约 0.3 pH 精度,换来单缓冲液校准的用户体验——对 ±0.5 pH 容忍场景合理。
2.4 校准交互流程
校准走 CAL 页三键:
s_btn_cal7 = make_btn(page, BTN_X0, CAL_BTN_Y, BTN_W, BTN_H, "CAL 7.00", FONT14);
s_btn_cal9 = make_btn(page, BTN_X1, CAL_BTN_Y, BTN_W, BTN_H, "CAL 9.18", FONT14);
s_btn_calback = make_btn(page, BTN_X2, CAL_BTN_Y, BTN_W, BTN_H, "EXIT", FONT14);
按下 CAL 7.00 或 CAL 9.18 后,弹 msgbox 确认:
if(s_focus_cal == FOCUS_CAL_7 || s_focus_cal == FOCUS_CAL_9) {
if(s_has_reading) {
ph_sound_confirm();
msgbox_open(s_focus_cal == FOCUS_CAL_7);
} else {
ph_sound_cal_fail(); // 无读数, 提示失败
}
}
msgbox 确认后调对应设置函数,存电压 + 3 秒反馈:
static void msgbox_confirm(void) {
bool ok;
if(s_msgbox_point7) {
ok = ph_cal_set_point7(s_pending_voltage);
snprintf(s_cal_fb, sizeof(s_cal_fb), ok?"7.00 SAVED %.0f mV":"7/9 POLARITY?", s_pending_voltage);
}
if(ok) ph_sound_cal_done(); // 成功音
else ph_sound_cal_fail(); // 失败音
}
流程图 2:校准交互状态转换(msgbox 防误触 + 方向校验分支)

2.5 温度补偿:DS18B20 数字温度 自动接入
温度传感器接 IO_C1,30s 采样一次:
#define TEMP_IO IO_C1
// ph_temp.c
vTaskDelay(pdMS_TO_TICKS(30000)); // 30s
校准计算自动读温度:
float ph_cal_compute_auto_temp(float voltage_mV) {
return ph_cal_compute(voltage_mV, ph_temp_get());
}
无探头时回退 25°C,优雅降级——核心功能不依赖辅助外设。
校准参数持久化:
s_v7/s_v9已持久化到 NVS,开机加载,设置即写、重置即擦,断电不丢。仍可改进:加电压域版本号,ADC 换算口径变更后自动作废旧校准。
校准靠能斯特方程定理论斜率、单点 V7 定截距、V9 做方向校验、温度自动补偿、msgbox 防误触。整套方案在 ±0.5 pH 容忍场景下用单缓冲液达到够用精度。
三、两键交互:焦点状态机 + 模态抢占
本硬件板卡没有触摸屏,整机仅提供两个实体按键,却要完成页面切换、校准操作、日志启停、弹窗确认全套复杂操作。很多嵌入式项目遇到按键少的问题就会简化功能,而本项目使用焦点状态机的设计思路,用极少硬件实现完整操作流。本章讲解按键硬件驱动、事件跨线程传递、I2C总线节流优化、模态弹窗抢占逻辑,展示资源受限设备下,如何用软件设计弥补硬件接口不足。
K10 板没有触摸屏,只有 TCA9555 IO 扩展芯片上的 P2/P12 两个实体按键。两个键要完成测量启停、校准、页面切换、弹窗确认全部操作——靠的是焦点状态机 + 按键分工。
3.1 硬件链路:TCA9555 + iot_button
按键不在 ESP32 直连 GPIO 上,而在 TCA9555 IO 扩展芯片(I2C @0x20)。iot_button 库支持自定义驱动,通过回调读 TCA9555:
static uint8_t btn_a_get_level(button_driver_t *driver) {
uint32_t level = 0;
if (btn_io_read(&level) == ESP_OK) {
return (level & IO_EXPANDER_PIN_NUM_2) ? 0 : 1; // P2, 按下=0
}
return 0;
}
iot_button 负责消抖、单击、长按识别:
cfg.long_press_time = 1000; // 长按 1s
cfg.short_press_time = 0;
iot_button_register_cb(s_btn_a, BUTTON_SINGLE_CLICK, NULL, btn_cb_a_single, NULL);
iot_button_register_cb(s_btn_a, BUTTON_LONG_PRESS_START, NULL, btn_cb_a_long, NULL);
3.2 两键分工:P12 切焦点,P2 触发
按键映射:
// P12 (UI_BTN_B) → 循环切换焦点
// P2 (UI_BTN_A) → 触发当前焦点
焦点状态机有两个枚举:
typedef enum { FOCUS_MAIN_START=0, FOCUS_MAIN_CAL, FOCUS_MAIN_COUNT } focus_main_t;
typedef enum { FOCUS_CAL_7=0, FOCUS_CAL_9, FOCUS_CAL_BACK, FOCUS_CAL_COUNT } focus_cal_t;
- MAIN 页 2 焦点:START(启停日志)、CAL(进校准页)
- CAL 页 3 焦点:CAL 7.00、CAL 9.18、EXIT
P12 按下时焦点循环切换:
if(btn == UI_BTN_B) {
if(s_page == PAGE_MAIN) {
s_focus_main = (focus_main_t)((s_focus_main + 1) % FOCUS_MAIN_COUNT);
} else {
s_focus_cal = (focus_cal_t)((s_focus_cal + 1) % FOCUS_CAL_COUNT);
}
ph_sound_move(); // 切焦点音效
}
P2 按下时触发当前焦点:MAIN 页触发 START/CAL,CAL 页触发校准/退出。
流程图 3:两键焦点状态机(P12 切换 / P2 触发 / msgbox 模态抢占)

3.3 焦点视觉反馈:黄底白边
焦点态切换:
static void apply_focus(lv_obj_t *btn, bool on) {
ui_pixel_set_selected(btn, on, true); // 选中: 黄底 + 白边
}
void ui_pixel_set_selected(lv_obj_t *panel, bool selected, bool enabled) {
uint32_t color = !enabled ? 0x78909C : (selected ? UI_YELLOW : UI_PAPER);
lv_obj_set_style_bg_color(panel, lv_color_hex(color), 0);
lv_obj_set_style_border_color(panel,
lv_color_hex(selected ? 0xFFFFFF : UI_INK), 0);
}
选中=黄底白边,未选中=纸底墨边。两键操作时用户始终知道当前焦点在哪。
3.4 按键事件传递:volatile flag
按键回调在 iot_button 的 esp_timer 上下文执行,不能直接调 LVGL(LVGL 非线程安全)。所以回调只设 volatile flag:
static volatile int s_btn_evt = -1;
static void on_button(int btn) {
if(btn != UI_BTN_A && btn != UI_BTN_B) return;
s_btn_evt = btn;
}
主循环在 lvgl_port_lock 内消费:
if (lvgl_port_lock(100)) {
process_button_pending(); // 锁内消费按键事件
render();
lvgl_port_unlock();
}
process_button_pending 做焦点切换、msgbox 操作、音效触发。这是嵌入式 GUI 的标准范式——中断线程设标志,主线程持锁消费。
3.5 I2C 读节流:共享缓存 + 10ms
iot_button 每 5ms 调用读取接口,两个按键在同一个 16‑bit 寄存器,一次 I2C 读就能拿到两个。节流逻辑:
static esp_err_t btn_io_read(uint32_t *level) {
int64_t now = esp_timer_get_time();
if (now - s_btn_io_last_read >= BTN_IO_MIN_READ_US) { // 10ms
esp_io_expander_get_level(exp, 0xFFFF, &val);
s_btn_io_cache = val;
s_btn_io_last_read = now;
}
*level = s_btn_io_cache; // 两按键都从缓存取
return ESP_OK;
}
10ms 内只读一次,两个按键从同一缓存取值。省 75% 总线占用,避免 esp_timer 任务被 I2C 阻塞卡死。
3.6 msgbox 模态:弹窗抢占按键
校准确认弹窗打开时,按键全部交给弹窗处理:
if(s_msgbox) {
if(btn == UI_BTN_B) {
msgbox_set_focus(!s_msgbox_ok_focus); // P12 切 OK/CANCEL
} else {
if(s_msgbox_ok_focus) msgbox_confirm(); // P2 确认
else { msgbox_close(); } // P2 取消
}
return; // 不冒泡到主页面
}
return 是关键——弹窗没消费的按键也不冒泡到主页面,否则状态会乱。这是模态对话框的标准实现。
流程图 4:按键事件全链路时序(esp_timer → volatile flag → lvgl_port_lock)

两键交互靠的是焦点状态机(P12 切、P2 触发)+ volatile flag 跨线程传递 + I2C 读节流 + msgbox 模态抢占。没有触屏不是限制,倒逼出了更清晰的焦点管理逻辑。
四、像素水下风 UI:组件原语与视觉语言
良好人机界面不只是好看,更要降低使用者认知负担,把复杂仪器状态直观展示出来。本项目没有直接套用LVGL现成圆角UI组件,而是自研一套像素水下风格UI工具集
ui_pixel。本章介绍整套视觉体系的设计思路,包含场景渲染、面板组件、吉祥物情感反馈、数据可视化、状态提示徽章,讲解如何在小尺寸屏幕硬件上,兼顾信息展示清晰度、操作反馈、个性化视觉表达。
本项目 UI 是 v3 像素水下风,由新增的 ui_pixel 组件提供。和常见的圆角卡片风不同,这套设计有强烈的复古游戏机视觉语言。
4.1 ui_pixel 组件:像素风工具集
ui_pixel.c 提供六个原语:
| API | 作用 |
|---|---|
ui_pixel_screen_create |
创建水下场景屏幕 |
ui_pixel_panel_create |
创建纸白面板(错位阴影 + 4px 描边) |
ui_pixel_label |
创建标签 |
ui_pixel_mascot_create |
创建小电视吉祥物 |
ui_pixel_mascot_jump |
吉祥物跳跃动画 |
ui_pixel_set_selected |
选中状态(黄底白边) |

4.2 水下场景:水波、气泡、沙底
ui_pixel_screen_create一次性铺好整个场景:
// 顶部暗色带 + 水波条
block(scr, 0, 0, UI_WIDTH, 4, UI_WATER_DEEP);
add_wave(scr, 18, 0, 34); add_wave(scr, 96, 1, 46); add_wave(scr, 186, 0, 30);
// 左右边缘气泡
block(scr, 3, 70, 3, 3, UI_BUBBLE); block(scr, 5, 110, 2, 2, UI_BUBBLE); ...
// 沙底: 沙带 + 亮唇边 + 深色斑 + 墨色小石
block(scr, 0, 296, UI_WIDTH, 24, UI_SAND);
block(scr, 0, 296, UI_WIDTH, 3, UI_SAND_LIGHT);
block(scr, 22, 306, 8, 4, UI_INK); // 墨色小石
色板定义:
#define UI_WATER 0x1E7BD6 /* 主背景 */
#define UI_WAVE 0x5BB8F0 /* 像素水波 */
#define UI_BUBBLE 0xBFE8FF /* 气泡 */
#define UI_SAND 0xE8D28A /* 沙底 */
#define UI_INK 0x17202A /* 墨色 */
#define UI_PAPER 0xF4F4EA /* 纸白 */
所有元素用 block() 函数生成——方角、无圆角、硬边像素块。这是 8‑bit 游戏机的视觉语言。
4.3 纸白面板:错位投影 + 粗描边
ui_pixel_panel_create:
lv_obj_t *ui_pixel_panel_create(lv_obj_t *parent, int x, int y, int w, int h, uint32_t color) {
block(parent, x + 5, y + 6, w, h, UI_INK); // 错位墨色阴影
lv_obj_t *panel = block(parent, x, y, w, h, color); // 纸白面板
lv_obj_set_style_border_color(panel, lv_color_hex(UI_INK), 0);
lv_obj_set_style_border_width(panel, 4, 0); // 4px 墨边
return panel;
}

先在 (x+5, y+6) 画一个墨色块作错位阴影,再在 (x, y) 画纸白面板。阴影是实体块不是模糊投影——像素风的硬阴影。4px 粗描边强化纸牌质感。
4.4 小电视吉祥物:眨眼 + 跳跃
ui_pixel_mascot_create用十几个 block 拼出一个小电视机器人:橙色天线、墨色身体、紫色耳朵、浅蓝屏幕、深蓝眼睛、橙色围巾、紫色脚。
眼睛会眨:
static void start_blink(lv_obj_t *eye) {
lv_anim_set_values(&anim, LV_OPA_COVER, LV_OPA_20); // 不透明→半透明
lv_anim_set_duration(&anim, 70); // 70ms
lv_anim_set_repeat_delay(&anim, 1700); // 1.7s 间隔
lv_anim_set_repeat_count(&anim, LV_ANIM_REPEAT_INFINITE);
}
按键时吉祥物跳跃:
void ui_pixel_mascot_jump(lv_obj_t *mascot) {
lv_anim_set_values(&anim, y, y - 5); // 上跳 5px
lv_anim_set_duration(&anim, 110); // 110ms
lv_anim_set_playback_duration(&anim, 140); // 140ms 落回
}
主循环每次消费按键都触发跳跃:
ui_pixel_mascot_jump(s_mascot); // 每次按键 → 吉祥物跳跃
吉祥物跨页常驻,站沙底上,z 序最高(msgbox 在 top layer 仍会盖住它)。这是整个 UI 的情感锚点——冷冰冰的仪表盘有了个会眨眼会跳的小家伙。
4.5 48px 大数字 + pH 渐变进度条
MAIN 页 hero 卡用 48px 字体显示 pH 值:
s_lbl_ph = ui_pixel_label(hero, "--.-", FONT48, UI_INK);
pH 进度条指示色随 pH 渐变:
static lv_color_t ph_color(float ph) {
const float p[5] = {0, 4, 7, 10, 14};
const lv_color_t stops[5] = {
lv_color_hex(UI_RED), lv_color_hex(UI_ORANGE),
lv_color_hex(UI_GREEN), lv_color_hex(UI_BLUE), lv_color_hex(UI_PURPLE),
};
// 5 段分段线性插值: 0红 → 4橙 → 7绿 → 10蓝 → 14紫
}
下方 24 段彩虹参考条 + 0/7/14 刻度,用整数边界计算避免缝隙:
int x0 = i * BAR_W / PH_SEG_N;
int x1 = (i + 1) * BAR_W / PH_SEG_N; // 相邻段共享边界
4.6 状态徽章:REC 闪烁 / IDLE / SD:NO
右上角徽章三态:
if(!sd_ready) {
ui_label_set_fmt(bl, "SD:NO"); // 橙底
} else if(s_log_active) {
ui_label_set_fmt(bl, "REC"); // 红底, 500ms 闪烁
lv_obj_set_style_opa(s_badge, (now_ms/500)&1 ? LV_OPA_40 : LV_OPA_COVER, DEF);
} else {
ui_label_set_fmt(bl, "IDLE"); // 纸底
}
REC 闪烁用 (now_ms/500)&1 位运算切透明度,每 500ms 翻转一次,无额外定时器开销。
4.7 双页容器 + 单点视图 CAL 页
两个全屏容器,切换是一行 hidden flag:
lv_obj_add_flag(cal ? s_page_main : s_page_cal, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(cal ? s_page_cal : s_page_main, LV_OBJ_FLAG_HIDDEN);
CAL 页是单点视图——卡片只显示当前焦点的校准点,P12 切焦点时内容跟随切换:
if(f == FOCUS_CAL_7 || f == FOCUS_CAL_9) {
ui_label_set_fmt(s_lbl_cal_tt, "BUFFER %s", p7?"7.00":"9.18");
// 显示当前待校准点的电压
} else { // FOCUS_CAL_BACK → 总结卡
ui_label_set_fmt(s_lbl_cal_tt, "SUMMARY");
ui_label_set_fmt(s_lbl_cal_val, "%.1f mV/pH", slope);
}

三个焦点(7.00 / 9.18 / EXIT)共用一个卡片,内容随焦点动态切换。这比三张静态卡省屏幕空间,240×320 竖屏每一寸都珍贵。
UI 特点是像素水下风——水波气泡沙底场景、纸白墨边错位投影面板、会眨眼会跳的小电视吉祥物、48px 大数字、pH 渐变进度条、REC 闪烁徽章、单点视图 CAL 页。整套由 ui_pixel 组件六个原语拼出,视觉语言统一。
五、音效合成:软件生成钢琴音色
大部分嵌入式设备的提示音效直接播放预存WAV音频文件,会占用大量Flash存储空间。本项目不使用音频文件,完全依靠代码实时合成钢琴音色。本章讲解波形查表、谐波叠加、包络衰减整套音频算法实现,以及独立任务播放、PSRAM内存分配、I2S硬件输出,展示资源受限设备下软件音频合成工程方案,同时介绍人机反馈层面音效设计的细节迭代。
音效不是调库播放 WAV 文件,是软件合成钢琴音色——从正弦波表到谐波叠加到指数包络,全部代码生成。
5.1 硬件链路:I2S → NS4168
音频走 I2S0,外接 NS4168 喇叭:
#define BSP_I2S_DOUT (GPIO_NUM_45) // → NS4168 SDI
#define BSP_I2S_SAMPLE_RATE 24000 // 24kHz
初始化时清空 I2S 缓冲防上电爆音:
static int16_t silence[1024];
memset(silence, 0, sizeof(silence));
bsp_audio_speaker_write(silence, sizeof(silence) / sizeof(int16_t));
5.2 钢琴音色:基频 + 谐波 + 指数衰减
音符生成:
static int note_generate(int16_t *buf, size_t buf_cap, float freq, double dur_s, float vol) {
const float inc1 = freq / SAMPLE_RATE; // 基频相位增量
const float inc2 = 2.0f * freq / SAMPLE_RATE; // 2 次谐波
const float inc3 = 3.0f * freq / SAMPLE_RATE; // 3 次谐波
for (int i = 0; i < samples; i++) {
float t = (float)i / samples;
float env = pow(2.0, -5.0 * t); // 指数衰减包络
float v = (s_sin_table[i1] // 基频 (系数 1.0)
+ 0.5f * s_sin_table[i2] // 2 次谐波 (系数 0.5)
+ 0.2f * s_sin_table[i3]) // 3 次谐波 (系数 0.2)
* env * 0.30f * vol;
buf[i] = (int16_t)v;
}
}
三个设计点:
- 谐波叠加:钢琴不是纯正弦,叠加 2/3 次谐波给音头亮度和金属感。系数 1.0/0.5/0.2 是钢琴傅里叶级数前三项的工程近似
- 指数衰减:
pow(2, -5t)模拟钢琴击弦后能量耗散,τ ≈ 0.29s,符合中音区实测 - 正弦波表:1024 点预生成查表,替代运行时
sinf调用,省 95% CPU
5.3 独立 task 播放:不阻塞 UI
每个音效起独立 task 播放,播完自删:
static void tone_task(void *arg) {
int16_t *buf = heap_caps_malloc(MAX_BUF_SAMPLES * sizeof(int16_t),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); // PSRAM
int n = note_generate(buf, MAX_BUF_SAMPLES, p‑>freq, p‑>dur, p‑>vol);
bsp_audio_speaker_write(buf, (size_t)n); // 阻塞写完
heap_caps_free(buf);
vTaskDelete(NULL); // 自删
}
缓冲分配用 MALLOC_CAP_SPIRAM 放 PSRAM,不挤内部 SRAM。24KB 缓冲在 8MB PSRAM 里无压力。
流程图 5:音效合成与播放数据流(软件钢琴音色 → 独立 task → I2S)
5.4 操作 → 音符映射
八种操作音效:
| 操作 | 音符 | 频率 | 特点 |
|---|---|---|---|
ph_sound_move |
C5 | 523Hz | 极短清脆,切焦点 |
ph_sound_confirm |
G4 | 392Hz | 柔和低频,音量 0.55 |
ph_sound_cancel |
A4→E4 | 440→330Hz | 短下行琶音 |
ph_sound_cal_enter |
C4‑E4‑G4 | 262‑330‑392Hz | 上行琶音,进入校准 |
ph_sound_cal_exit |
G4‑E4‑C4 | 392‑330‑262Hz | 下行琶音,退出 |
ph_sound_cal_done |
C4 | 262Hz | 柔和长音 0.30s,校准成功 |
ph_sound_cal_fail |
E4‑C4 | 330‑262Hz | 下行低音,失败 |
ph_sound_log_toggle |
G4 | 392Hz | 日志启停 |
音效设计演进:校准成功音效早期版本为高音和弦,后改为"C4=262Hz 低音区柔和长音"。这是人机工程迭代——校准成功是低频长音让人安心,不是高频刺耳让人焦虑。
5.5 24kHz 采样率的考量
采样率 24kHz,奈奎斯特频率 12kHz。人耳上限 20kHz 按理需要 40kHz,但本项目只播钢琴低音区(C4=262Hz 到 C5=523Hz),3 次谐波最高 1569Hz,远低于 12kHz。对目标音源够用,还省一半 I2S 带宽和缓冲内存。
音效靠软件合成——正弦波表查表 + 3 次谐波叠加 + 指数衰减包络生成钢琴音色,独立 task 播放不阻塞 UI,PSRAM 分配缓冲不挤内部资源,八种操作音效覆盖全部交互反馈。
六、数据记录:不影响主交互的 SD 卡旁路
很多传感器设备做SD卡存储功能时,简单调用文件读写接口,极易出现写卡阻塞主线程、无卡整机卡死、写入无效测量数据等问题。本项目将SD卡日志设计为一套旁路附属功能,不会干扰pH测量与界面交互。本章讲解SD卡独占SPI总线、异步初始化、多层写卡条件过滤、锁超时降级、CSV日志格式设计,同时配套PC端数据分析脚本,实现现场采集、事后数据分析完整链路。
数据记录不是"调 fopen 写文件"那么简单——SD 卡慢、可能未插、写卡期间 UI 不能冻。本项目用异步初始化 + 30 分钟间隔 + 互斥锁超时 + 优雅降级四件套,把 SD 卡日志做成不影响主交互的旁路。
6.1 硬件链路:SD 卡走专用 SPI2 总线
SD 卡不和 LCD 共用 SPI3,独占 SPI2 @20MHz:
#define BSP_SD_SPI_HOST (SPI2_HOST)
#define BSP_SD_CS (GPIO_NUM_40)
#define BSP_SD_MOSI (GPIO_NUM_42)
#define BSP_SD_MISO (GPIO_NUM_41)
#define BSP_SD_SCLK (GPIO_NUM_44)
#define BSP_SD_SPI_FREQ_HZ (20 * 1000 * 1000) /* 20MHz */
独占总线避免和 LCD 帧刷新争用 SPI,写卡时屏幕不闪。FATFS 挂载点 /sdcard,簇大小 16KB——对 30 分钟一次的小写入,大簇减少碎片但浪费尾空间,是合理权衡。
6.2 异步初始化:独立低优先级任务
SD 卡初始化可能阻塞数秒(无卡超时、坏卡重试)。如果放在 app_main 主线,开机黑屏数秒。所以用独立任务:
xTaskCreate(log_init_task, "log_init", 4096, NULL, 2, NULL); // 优先级 2, 最低
// ...
static void log_init_task(void *arg) {
if(ph_log_init()!=ESP_OK) ESP_LOGW(TAG, "SD not ready, logging disabled");
vTaskDelete(NULL);
}
优先级 2 是全系统最低(采集 4、tone 5、LVGL 默认更高),不抢任何任务。ph_log_init 失败只置 s_ready=false,不阻塞主循环。核心功能不依赖 SD 卡。
6.3 30 分钟间隔 + 四重写卡门
写卡间隔 30 分钟:
#define PH_LOG_INTERVAL_MS (30UL * 60UL * 1000UL)
主循环每帧检查写卡条件:
if(sd_ready && r.has_data && calibrated && s_log_active) {
if(now_ms - s_last_log_ms >= PH_LOG_INTERVAL_MS)
s_should_log = true;
}
四个条件全满足才标记写卡:
sd_ready:SD 卡初始化成功r.has_data:数据池有有效读数calibrated:已校准(未校准的 pH 是错的,不写)s_log_active:用户按 START 开了日志
未校准不写卡是关键——避免用户拿到一堆无效数据后才发现没校准。
render 只置标志,写卡在锁外执行:
if(s_should_log) {
s_should_log=false;
s_last_log_ms=(uint64_t)esp_timer_get_time()/1000;
ph_log_write(ph_data_pool_aggregate(), ph_temp_get(), ph_temp_is_valid());
}
ph_log_write 不持 lvgl_port_lock——SD 写卡几十毫秒,持锁会冻屏。写卡和 UI 渲染解耦。
6.4 CSV 行格式:时间戳 + 读数 + 温度 + 健康度
每行格式:
fprintf(f, "%s,read,%d,%.2f,%.2f,%.1f,%s,%u,%u\n",
ts, raw, vm, ph, temperature, temp_str,
(unsigned int)heap, (unsigned int)up);
九个字段:
| 字段 | 来源 | 作用 |
|---|---|---|
boot+HH:MM:SS |
esp_timer_get_time |
上电计时,无 RTC |
read |
常量 | 行类型标记,预留扩展 |
raw |
r.raw_median |
12‑bit ADC 中值 |
voltage_mV |
r.voltage_mV |
换算后电压 |
ph |
r.ph |
校准后 pH |
temperature |
ph_temp_get |
温度补偿值 |
OK/N/A |
temp_valid |
温度是否有效 |
heap |
esp_get_free_heap_size |
空闲堆,监测内存泄漏 |
uptime_ms |
esp_timer_get_time |
累计运行时间 |
heap 字段是亮点——长期运行日志能看出内存趋势,泄漏早发现。boot+ 前缀明确这是上电计时,不是绝对时间(无 RTC),分析时按 uptime_ms 排序最稳。
时间戳格式化:
static void fmt_uptime(char *buf, size_t n) {
uint64_t us = esp_timer_get_time();
uint32_t sec = (uint32_t)(us / 1000000);
snprintf(buf, n, "boot+%02u:%02u:%02u", h, m, s);
}
6.5 互斥锁 50ms 超时:UI 优先于日志
ph_log_write 持互斥锁 50ms 超时:
if (xSemaphoreTake(s_log_mutex, pdMS_TO_TICKS(50)) != pdTRUE) return ESP_ERR_TIMEOUT;
50ms 是一帧 UI 时间。锁超时直接返回 ESP_ERR_TIMEOUT 丢这一条日志,不冻屏。可用性优先于数据完整性——和数据池的 5ms 超时是同一哲学。
fopen/fprintf/fclose 每次写都开闭文件。代价是 30 分钟一次的 open 开销(~10ms),收益是断电不丢整文件——FATFS 元数据已落盘。
6.6 启停交互:P2 + REC 闪烁徽章
日志启停是 MAIN 页 START 焦点的 P2 触发:
if(s_focus_main == FOCUS_MAIN_START) {
s_log_active = !s_log_active;
ph_sound_log_toggle(); // G4=392Hz 提示音
}
按钮文字随状态切换:
lv_label_set_text(lv_obj_get_child(s_btn_start, 0), s_log_active?"STOP":"START");
状态徽章三态:SD:NO(橙)/ REC(红 500ms 闪烁)/ IDLE(纸)。REC 闪烁用位运算,无额外定时器。
6.7 优雅降级:无卡不阻塞
ph_log_init 失败路径:
esp_err_t ret = bsp_sdcard_init(&card);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "SD init fail (%s), logging disabled", esp_err_to_name(ret));
return ret; // s_ready 保持 false
}
s_ready=false 后:
ph_log_is_ready返回 false → 徽章显示 SD:NOph_log_write直接返回ESP_ERR_INVALID_STATE- 主循环条件短路,不进写卡分支
整条链路无卡也能跑——pH 测量、校准、UI、音效全部正常,只是没日志。这是嵌入式系统的优雅降级范式。
6.8 PC 端分析:ph_analyzer.py
仓库脚本读取 CSV 做统计:
def calculate_statistics(ph_values):
return {
"average": sum(ph_values) / len(ph_values),
"min": min(ph_values),
"max": max(ph_values),
"count": len(ph_values),
}
三个工程细节:
- 自动检测分隔符:用
csv.Sniffer识别逗号/分号/制表符,Excel 导出兼容 - 按 header 找 pH 列:不依赖列顺序,CSV 加列不破脚本
- 过滤 0‑14 范围外:跳过未校准的 NaN 和异常值
流程图 6:数据记录全链路(初始化 → 写卡条件 → CSV → PC 分析)

数据记录靠异步初始化(不阻塞开机)、30min 间隔 + 四重写卡门(不写无效数据)、锁外写卡 + 50ms 超时(不冻屏)、无卡优雅降级(核心功能不受影响)、PC 端自动分析脚本。整套设计把 SD 卡日志做成不影响主交互的旁路。
七、项目全景图
本项目是基于 ESP32‑S3 的低成本开源硬件实现接近商用设备的使用体验,核心特色在于整套系统化工程优化,而非单纯硬件堆料。测量上采用双核隔离采集、中值滤波抑制脉冲干扰,厘清 ADC 与电极的性能边界,解决廉价 pH 计读数跳变痛点。兼顾使用门槛与测量可靠性,增加极性校验、自动温度补偿与参数断电保存,规避常见开源代码极性倒置缺陷。仅两个实体按键,依靠焦点状态机、模态弹窗实现全部操作,搭配自研像素水下风 UI,提供视觉高亮反馈;软件实时合成钢琴风格音效,实现视听双重交互提示。项目采用esp 最新的 IDF6.0+编程环境,充分发挥了行空板k10的综合性能,兼顾了pH计检测数据准确性,操控便利性,生动的交互感受,具有很高实用价值。
| 维度 | 核心手段 |
|---|---|
| 测得准 | 双核隔离 + 中值滤波 + 无锁队列 |
| 校得对 | 能斯特理论斜率 + 单点 V7 + V9 方向校验 |
| 两键交互 | 焦点状态机 + volatile flag + msgbox 模态 |
| UI 特点 | ui_pixel 像素水下风 + 吉祥物 + 渐变进度条 |
| 音效合成 | 谐波叠加 + 指数衰减 + 独立 task + PSRAM |
| 数据记录 | 异步初始化 + 30min 间隔 + 锁外写卡 + 优雅降级 |
鉴于 基于 ESP32 的嵌入式 pH 检测记录仪系统设计与渔业应用研究 文章中已经对本项目所使用的pH计的性能进行了深入的测试,所以在这就不做赘述了。相比之前项目使用的触屏操控和esp32 引脚adc读数的方式,本项目使用按键操控,独立芯片管理的adc读数,可减少主机的干扰,可以获取更加稳定的数据,更有千秋,手上有K10产品的同学,自然采用本解决方案是最佳选择。
本项目基于多年的水质监测实践,深知产业的对pH计性能的真实诉求,开展专项治理方式,还原了专业pH计该有的性能。每一处都做了工程权衡—原理该有的尊重,一分没少。
项目地址:gitee.com/genvex/k10‑ph‑meter
没有编译环境也可以刷个固件感受一下。





