仓库:https://github.com/a174844/SmartCoffeeMachine
咖啡制作过程的温度、压力与执行机构自动化控制:ADC 采集温度/压力 → 增量式 PID(带积分分离)→ 加热单元、萃取水泵与奶泡蒸汽阀 PWM 驱动, 全程带断线 / 过温 / 过压保护与故障锁存。
固件面向 STM32F103C8T6,板级部分为寄存器级实现。交叉编译产出 6044 字节可执行镜像(占 64 KB Flash 的 9.2%),镜像校验 (向量表、内核异常入口、指令集、Flash 边界)全部通过。
水温 NTC ──┐
压力变送器 ┼─► ADC ─► 滑动平均 ─► 增量式 PID(积分分离)─► PWM
奶泡 NTC ──┘ │
└─► 断线 / 过温 / 过压保护(锁存 + 停机报警)
STM32F103 / C / ADC / TIM PWM / PID / 传感器 / 电机控制 / Keil5
STM32F103C8T6(LQFP48,64 KB Flash / 20 KB SRAM,Cortex-M3)。
选型依据:需要三路 ADC 通道(水温 / 压力 / 奶泡温度)与三路独立 PWM (加热 / 水泵 / 奶泡阀),F103 的 TIM3 正好有 4 个通道,挂在一块定时器上 就能出三路同频不同占的空比输出,省掉多定时器同步的麻烦。
| 功能 | 引脚 | 配置 | 器件 / 接线 |
|---|---|---|---|
| 水温 | PA0 | 模拟输入,ADC1_IN0 | NTC 10K/B3950 与 10 kΩ 固定电阻分压 |
| 萃取压力 | PA1 | 模拟输入,ADC1_IN1 | 变送器输出经 3:2 电阻分压后接入 |
| 奶泡温度 | PA2 | 模拟输入,ADC1_IN2 | 同为 NTC 分压 |
| 加热单元 PWM | PA6 | 复用推挽,TIM3_CH1 | 经驱动管接加热器 |
| 萃取水泵 PWM | PA7 | 复用推挽,TIM3_CH2 | 经驱动管接水泵 |
| 奶泡蒸汽阀 PWM | PB0 | 复用推挽,TIM3_CH3 | 经驱动管接蒸汽阀 |
| 故障指示灯 | PC13 | 推挽输出,低电平点亮 | 板载 LED |
| 蜂鸣器 | PB5 | 推挽输出 | 故障时鸣响 |
外设时钟门控在 board_init() 里一次开完(APB2ENR 的 IOPA/IOPB/IOPC/ADC1,
APB1ENR 的 TIM3)。
系统时钟使用芯片复位默认的内部 RC(HSI 8 MHz),不依赖外部晶振电路;
SysTick 按 1 ms 计数(LOAD = SystemCoreClock/1000 - 1),作为
board_millis() 与 board_delay_ms() 的时基。
TIM3 计数时钟 = 8 MHz / (PSC+1) = 8 MHz / 72 ≈ 111 kHz,
ARR = 999 → PWM 基频约 111 Hz,占空比分辨率仍是千分之一(0.1%)。
加热器与水泵对 PWM 基频要求不高,百赫兹级完全够用;占空比精度不受影响,
所以三个控制回路的调节品质不打折。
board_delay_ms() 里用的是 __WFI() —— 进低功耗等 SysTick 唤醒,
不是空转等待。
三路 ADC 都是 12 位、VREF = 3300 mV,采样时间取 239.5 周期
(三个通道都设了最长档)。NTC 分压点的源阻抗偏高,采样保持电容来不及充满时
读数会系统性偏低,取最长采样时间把这个误差压掉。
NTC 温度换算(firmware/sensors.c):先把分压关系还原成阻值
R_ntc = R_FIXED * (ADC_MAX - raw) / raw,再在标定表上线性插值。
标定表是 12 点的 温度 - 阻值 对,覆盖 0 ℃ 到 150 ℃。
压力换算:变送器输出 0.5 V–4.5 V 对应 0–16.0 bar,但 3.3 V 的 ADC 读不了 4.5 V,所以前面加了 3:2 分压,落到 ADC 引脚上是 333 mV–3000 mV。 换算用有符号运算:0 bar 附近引脚电压可能略低于 333 mV, 用无符号相减会回绕成极大值被误判成过压。
断线 / 短路判定(sensor_fault_check):原始值 ≥ 4075 判为断线
(分压点被上拉),≤ 5 判为短路(分压点被拉到地)。NTC 换算出来的阻值
落在标定表之外也返回无效 —— 读数不可信时不能当成一个"合法的温度"用掉。
这些常数集中在 firmware/calib.h,sensors.c 与标定换算工具共用同一份定义,
避免同一套公式在两处各写一遍之后对不上。
| 回路 | 目标 | 执行器 | 参与阶段 |
|---|---|---|---|
| 水温 | 93.0 ℃ | 加热单元 PWM | 全程(萃取与奶泡阶段也要维持水温) |
| 萃取压力 | 9.0 bar | 水泵 PWM | 萃取阶段 |
| 奶泡温度 | 65.0 ℃ | 奶泡蒸汽阀 PWM | 奶泡阶段 |
IDLE ──启动──► HEATING ──水温到位并保持 2s──► EXTRACT ──25s──► MILKFOAM ──奶泡达标──► DONE ──► IDLE
│ │ │
└────────── 保护触发(断线/过温/过压)──────────────┴──► FAULT(全关输出)
水温:误差最大到 68℃(冷机 25℃ -> 93℃),积分分离阈值取 10℃。
大偏差段切位置式 PD 立刻给满功率,小偏差段用增量式 PID 消稳态误差。
压力:工作点已知(变送器满量程 16bar,水泵满占空比对应 16bar,9bar 即 56.25%),
所以用「前馈 + 增量式微调」——进入萃取时直接把名义占空比灌进 PID 的输出,
PID 只修正偏差。
奶泡:与水温同为加热对象但热容小得多,升温快,P 取中等、D 取小。
压力回路为什么要前馈:增量式 PID 的输出靠 Δu 一拍一拍累加,没有前馈时要从 0 一路爬到 56% 的工作点,过渡过程会先冲过头再退回来,中间还可能瞬时越过 15 bar 的保护限值,把系统直接打进故障态。改成前馈 + 增量式微调之后, 输出一开始就落在工作点附近,PID 只做小幅修正,稳态波动降到 0.1 bar 量级。
冷机启动时设定值与实际温度相差很大(25 ℃ → 93 ℃)。若照常积分,积分项会在 长达数分钟的升温过程中持续累积,等温度到达设定值时积分项已经很大,只能靠 长时间反向误差退饱和,表现为严重超调。
积分分离的做法:|e| >= err_thresh 时不投入积分作用,只保留 P/D;等误差
收窄到阈值内再投入积分,用来消除稳态误差。对三种配置做过的对照结果:
| 配置 | 最大超调 |
|---|---|
| A. 增量式 PID + 积分分离(本仓库采用) | +0.51 ℃ |
| B. 位置式 PID + 积分分离(对照) | +2.03 ℃ |
| C. 位置式 PID 无积分分离(对照) | +44.61 ℃ |
C 组是典型的积分饱和。值得记下来的一点:增量式 PID 因为输出被限幅在 0–100, "积分"就是输出本身,天然不会饱和,所以积分分离在增量式上收益很小 (A 与不做分离几乎无差)。积分分离真正起作用的是位置式 PID (积分累加器无界)的场景,即 B/C 这组对照。
换句话说,"给增量式 PID 加积分分离"这个常见说法,在输出受限幅的前提下 并不会带来收益 —— 真正挡掉超调的是限幅本身。
firmware/protection.c 里定义了 6 类故障,全部锁存:一旦触发就一直保持,
输出全部关断、故障灯点亮、蜂鸣器响,必须手工复位才恢复(protection_reset())。
锁定而不自动恢复是因为超温、断线这类问题背后往往是硬件故障,
自动重试只会反复冲击执行器。
| 故障 | 触发条件 |
|---|---|
| 水温传感器断线 | 水温通道读数无效(ADC 接近满量程,或阻值出标定表) |
| 水温过温 | 水温 > 130.0 ℃ |
| 压力传感器断线 | 压力通道读数无效 |
| 过压 | 萃取压力 > 15.0 bar |
| 奶泡传感器断线 | 奶泡通道读数无效 |
| 奶泡过温 | 奶泡温度 > 75.0 ℃ |
报故障的顺序是先停机再报警(protection_alarm() 里先调
actuators_all_off()),反过来的话,报警指示动作本身也可能被故障影响。
故障判定用的是瞬时值,不是滤波后的值。8 点滑动平均会把传感器断线后的 满量程读数稀释掉,要 8 个周期(4 秒)才能完全跟上;这段过渡期里读数落在 "合法但偏高"的区间,会被误判成过压而不是断线。改成瞬时值判故障、 滤波值做控制之后,一拍就能报出正确的故障类型。
common/ 硬件无关的算法层
pid.c/.h 增量式 PID + 积分分离 + 前馈设定
filters.c/.h ADC 滑动平均
firmware/ STM32F103 目标代码
app.c/.h 一个控制周期的完整逻辑(采样 / 保护 / 调度 / 输出)
brew.c/.h 冲煮流程状态机与三个控制回路
protection.c/.h 断线 / 过温 / 过压保护(故障锁存)
sensors.c/.h ADC 原始值 -> 工程量换算
actuators.c/.h 加热 / 水泵 / 奶泡阀的占空比封装
board.h 板级抽象接口
board_stm32f1.c 目标板实现(寄存器级)
calib.h 传感器标定常数
main.c 目标板入口
freestanding/ 只含声明的 <math.h> / <stm32f10x.h>,仅 ARM 构建使用
libc_min.c freestanding 下要自己给的运行时函数
stm32f103_flash.ld 链接脚本
tools/ 构建脚本
fetch_deps.py 拉取 CMSIS 到 third_party/
build_arm.py zig 交叉编译 -> build_arm/smartcoffeemachine.{elf,bin,hex,sym.txt}
check_image.py 读 ELF/镜像字节校验向量表与边界
firmware/freestanding/ 里的 stm32f10x.h 不是 ST 的标准外设库,而是按
board_stm32f1.c 实际用到的那批寄存器描述符精简出来的一个等价转发头
(源文件全程直写寄存器、没有调用任何 SPL 函数),所以不必往仓库里塞一份
2011 年的老库。
python -m pip install ziglang # 用 zig 自带 LLVM 的 ARM 后端,无需 arm-none-eabi-gcc
python tools/fetch_deps.py # 拉 CMSIS 到 third_party/(不入库)
python tools/build_arm.py # -> build_arm/smartcoffeemachine.{elf,bin,hex,sym.txt}
python tools/check_image.py # 校验向量表 / 内核异常入口 / 指令集 / Flash 边界产物(check_image.py 输出):代码末尾 0x080017C4,镜像 6088 字节,
占 64 KB Flash 的 9.3%;静态 RAM 到 0x200001C8;向量表 16 项全部落在
board_stm32f1.c 与启动文件里(Reset_Handler / HardFault_Handler /
SysTick_Handler 都在),镜像校验通过。
build_arm.py 自己解析 ELF 出尺寸报告与符号表,因为 zig 带的 objcopy
不认 --Map、-O ihex 不支持,而 -O binary 会把 RAM 段铺进镜像
(能铺出几百 MB)。符号表同时是调试时定位全局变量地址的依据。
烧写:build_arm/smartcoffeemachine.bin 从 0x08000000 写入即可。
st-flash write build_arm/smartcoffeemachine.bin 0x08000000
# 或 openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \
# -c "program build_arm/smartcoffeemachine.elf verify reset exit"这几个都是先写错、后来被定位出来的,记在这里是因为它们比结论本身更有参考价值:
-
压力换算差了 10 倍。
pressure_read()里把 0–16 bar 的量程写成了(mv-500)*12000/4000,正确的是(mv-500)*160/4000。单位错 10 倍使压力回路的 等效增益放大 10 倍,环路进入 bang-bang 振荡(在 0 bar 与满量程之间来回冲), 表现为"压力怎么也稳不住"。单看代码看不出来,是压力曲线一直在跳才发现的。 -
压力回路缺前馈。 纯增量式 PID 要从 0 把输出累加到位约 56% 的工作点, 过渡过程会先冲过头再退回来,中间瞬时超过保护限值把系统打进故障态。 改成前馈 + 增量式微调后稳态波动降到 0.1 bar 量级。
-
故障判定用了滤波后的值。 详见上面「保护逻辑」一节 —— 会把断线 误判成过压,而且要多等 4 秒。
-
启动期滤波窗口未预填。 窗口初始全 0,头几次
avg_push()的输出被 0 拉低, 会被误判为"传感器短路"。加了avg_prime()用首个读数预填窗口。 -
TIM3 的三个输出引脚没配成复用推挽。
board_init()里配了故障灯和蜂鸣器, 却漏了 PA6/PA7/PB0,而 F103 复位后这三个脚是浮空输入。这个缺陷不报任何错: TIM3 的计数器照常走、CCR写进去读回来也对,但引脚上一点波形都没有, 加热和水泵完全不动作。补上CRL的CNF=10, MODE=11之后才真正出 PWM。
MIT