基于行空板K10 便携式 pH 计全链路工程实现

2026-10-0619
精华项目

前言

pH 值是表征水体酸碱特性最核心的理化指标,水体氢离子活度的微小波动,会直接改变物质溶解度、化学反应速率与生物酶活性,深刻影响水生生物的生存环境,是水环境监测、农业生产、教学科研、小型生产加工等诸多领域的关键检测参数。在实际应用场景中,pH 检测具备极强的普适性:水产养殖中,水体 pH 失衡极易导致养殖生物应激、爆发病害,是养殖水质管控的核心指标;环境监测领域,地表水、污水排放、土壤浸出液的 pH 值为法定必测管控项目;水族造景、户外自然水体实地调研需要便携、稳定的现场 pH 检测手段;同时各类教学实验、创客研发以及小型食品、日化加工作坊,也迫切需要成本可控、操作简便、数据可靠的 pH 检测设备。

目前市面主流检测设备存在明显的两极化痛点:高端商用 pH 检测仪精度稳定、功能完备,但售价高昂、操作逻辑复杂,使用门槛较高,并不适配水产爱好者、学生创客、野外调研人员及小型作坊的使用需求;而市面上低价简易的 pH 检测套件,虽具备价格优势,却普遍存在信号抖动噪声大、校准逻辑简陋粗糙、无人机交互优化、缺失数据留存功能等问题,极易出现读数跳变、校准失效、检测结果失真等情况,误导使用者做出错误判断。事实证明,想要在低成本前提下实现精准可信的 pH 检测,仅依靠升级芯片、电极等硬件配置远远不够,必须从模拟信号采集、实时任务调度、校准算法优化、嵌入式人机交互、声光反馈提示到异步数据存储,完成全流程、系统性的工程优化设计。

基于以上行业现状与场景痛点,本项目依托 DFRobot K10(ESP32‑S3)硬件板卡,搭配复合 pH 电极,开发一套完整开源的 pH 计固件工程。项目核心旨在以低成本开源硬件为基础,补齐市面廉价 pH 检测设备缺失的全套工程细节,在严格控制硬件成本的前提下,实现稳定、精准、可信的水体 pH 检测,为普通爱好者、教学实验、小型野外实地作业提供一套可复刻、可二次开发、透明可控的标准化检测方案。本项目所有代码完全开源,不仅提供可直接烧录运行的成品固件,还完整公开信号处理、校准算法、人机交互逻辑、软件音频提示、异步数据存储的核心实现思路,成品设备既可直接组装落地投入实际检测使用,也可作为嵌入式开发、传感器信号处理的优质教学实训案例,助力开源检测工具在民用、教学及小型作业场景的普及落地。

基于行空板K10 便携式 pH 计全链路工程实现_image_1.webp

一、测量精度:从电极到屏幕的整条信号链

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

基于行空板K10 便携式 pH 计全链路工程实现_image_2.webp

测得准不是单点优化,是整条信号链的误差控制。从电极到屏幕,本项目用了五道工序。

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 源码见下,渲染版见 `

基于行空板K10 便携式 pH 计全链路工程实现_image_3.webp

测得准靠的是整条链路的误差控制——12‑bit ADC 量化够用、双核隔离保周期、中值滤波抗脉冲、无锁队列省开销、超时降级保可用。五道工序缺一不可。

二、校准方案:单点偏置 + 能斯特理论斜率

校准是pH计的灵魂,校准流程是否合理直接决定设备读数是否可信。很多开源示例直接照搬网络代码,忽略电极极性、温度补偿、校验逻辑,最终测量结果完全失真。本章从电化学能斯特方程出发,讲清楚本项目“单点校准+理论斜率”这套折中方案背后数学原理,同时介绍极性校验、弹窗防误触、NVS参数持久化、温度自动补偿整套交互与容错逻辑,理解精度和操作门槛之间如何做工程取舍。

校准是 pH 计的核心交互。本项目用单点偏置 + 理论斜率方案,背后是能斯特方程和 pH 电极的物理特性。
基于行空板K10 便携式 pH 计全链路工程实现_image_4.webp

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;
}

基于行空板K10 便携式 pH 计全链路工程实现_image_5.webp

为什么敢用理论斜率: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 防误触 + 方向校验分支)

基于行空板K10 便携式 pH 计全链路工程实现_image_6.webp

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 模态抢占)

基于行空板K10 便携式 pH 计全链路工程实现_image_7.webp

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)

基于行空板K10 便携式 pH 计全链路工程实现_image_8.webp
两键交互靠的是焦点状态机(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 选中状态(黄底白边)

基于行空板K10 便携式 pH 计全链路工程实现_image_9.webp

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;
}

基于行空板K10 便携式 pH 计全链路工程实现_image_10.webp

先在 (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);
}

基于行空板K10 便携式 pH 计全链路工程实现_image_11.webp

三个焦点(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)
基于行空板K10 便携式 pH 计全链路工程实现_image_12.webp

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:NO
  • ph_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 分析)

基于行空板K10 便携式 pH 计全链路工程实现_image_13.webp
数据记录靠异步初始化(不阻塞开机)、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

没有编译环境也可以刷个固件感受一下。


硬件清单

创作许可协议

本项目采用 CC BY(署名) 进行许可。

所需材料/产品

加载全部

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    行空板多功能扩展板 - K10专用

    行空板多功能扩展板 - K10专用

    行空板多功能扩展板 - K10专用

    Gravity: 专业级pH计V2

    Gravity: 专业级pH计V2

    Gravity: 专业级pH计V2

相关推荐

评论(0)
- 没有更多了 -

创作许可协议

本项目采用 CC BY(署名) 进行许可。

相关推荐

所需材料/产品

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

行空板多功能扩展板 - K10专用

行空板多功能扩展板 - K10专用

行空板多功能扩展板 - K10专用

Gravity: 专业级pH计V2

Gravity: 专业级pH计V2

Gravity: 专业级pH计V2