
简介基于STM32F10x微控制器的咖啡机控制系统完整源码包面向嵌入式开发、物联网应用及自动化控制学习者。项目以STM32芯片作为下位机通过SPI串行外设接口与树莓派上位机通信实现对锅炉、水泵等硬件的开关与速度控制覆盖烧水、闷蒸、冲泡等全自动流程。资源共130个文件其中包含STM32固件工程C与H源文件、Python上位机脚本、HTML/JS监控界面、Makefile及配置文件整体压缩包约3.2MB目录结构清晰便于按模块检索。特别值得一提的是源码内置SocketIO实时反馈机制可实时查看温度、重量与设备状态同时附带单元测试代码用于验证SPI通信模块的功能完整性。目前已有76人学习使用适合需要完整参考嵌入式与上位机联动方案的开发者既能理解底层硬件控制逻辑也能借鉴Python端通信与前端展示的工程实现并可直接作为咖啡机或类似温控/称重项目的二次开发基础。1. 咖啡机不是点灯板STM32F10x 控制系统的真正难点在哪基于 STM32F10x 微控制器的咖啡机控制系统听起来像课程设计开题真做起来却是一个典型的“温控 流程调度 人机交互”三者耦合的嵌入式小项目。控制对象包括加热丝、水泵、电磁阀、水位与温度传感器最终要稳定复现出“加热到目标温度、按状态机走完冲煮流程、不干烧、不停机”的完整行为。难点不在点亮一颗 LCD而在温度采样稳不稳、PID 参数怎么定、继电器动作会不会把单片机拉死。适合谁做毕设、小家电预研或者想从裸机点灯跨到状态机编程的开发者这套控制逻辑都能直接落到自己的板子上。2. 引脚怎么分才算够用F10x 外设分配与最小初始化代码2.1 STM32F103C8T6 的资源账一颗芯片怎么扛下整台咖啡机先算账。LQFP48 封装的 STM32F103C8T664KB Flash、20KB RAM内部外设含 3 个 USART、2 个 ADC12 位、4 个通用定时器对一台家用级咖啡机来说资源绰绰有余。控制需求拆解下来采样类有 NTC 温度 1 路可扩展 2 路、水位开关 2 路锅炉低水位、水箱无水输出类有加热丝 1 路、水泵 1 路、电磁阀 1 到 2 路、蒸汽阀 1 路人机类有功能按键 3 到 4 个、LCD1602 或 OLED 显示屏、蜂鸣器。粗算下来消耗大约 4 路 ADC、4 路 GPIO 输入、6 到 8 路 GPIO 输出引脚总量 16 个左右还剩一半引脚给调试和扩展。引脚分配是这个项目里最不愿意妥协的部分因为一旦开始布线就不好改。常见分配做法如下表。功能引脚模式备注NTC 锅炉温度PA0模拟输入 ADC_Channel_0采样时间需要拉长NTC/DS18B20 扩展PA1模拟输入/开漏留作第二路测温锅炉水位PB0上拉输入低电平表示缺水水箱水位PB1上拉输入低电平表示无水加热继电器/可控硅PB12推挽输出高电平有效水泵继电器PB13推挽输出同上电磁阀PB14推挽输出同上蒸汽阀/蜂鸣器PB15推挽输出可复用按键建议放在 PA4 到 PA7 或 PC13 到 PC15它们不占用 ADC1 的常用通道也避开 JTAG/SWD 的 PA13/PA14。然后是初始化代码GPIO 部分/* 咖啡机控制系统 GPIO 初始化 */ void DRV_GPIO_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); /* 温度采样PA0 模拟输入 */ gpio.GPIO_Pin GPIO_Pin_0; gpio.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, gpio); /* 水位输入PB0、PB1 上拉输入 */ gpio.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; gpio.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOB, gpio); /* 输出控制PB12-PB15 推挽输出初始全部置低 */ gpio.GPIO_Pin GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_2MHz; GPIO_Init(GPIOB, gpio); GPIO_ResetBits(GPIOB, GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15); }逻辑说明PB12 到 PB15 统一走推挽输出是因为后面接的是 NPN 三极管或 ULN2003 的输入级推挽能提供更干净的开关电平。如果接的是光耦MOC3021配可控硅方案就要根据光耦输入极性决定输出电平极性不能照抄。参数说明GPIO_Speed_2MHz 就够继电器这种低频开关用没必要设 50MHz速率越高反而越容易出现振铃。ADC 采样部分我一般不用 DMA通道少、查询足够代码也更直白uint16_t ADC_ReadChannel(uint8_t adc_channel) { ADC_RegularChannelConfig(ADC1, adc_channel, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); return ADC_GetConversionValue(ADC1); /* 0-4095 */ }逻辑说明每次读前先把通道号配置好再启动软件触发。典型用法是ADC_ReadChannel(ADC_Channel_0)读 PA0。参数说明ADC_SampleTime_239Cycles5 是 12 位 ADC 里最长的采样时间。NTC 是高阻源分压点阻抗在 10k 甚至几十千欧量级如果采样电容充电时间不够转换结果会系统性偏低把采样时间拉满能明显改善真实度。代价是速度慢但每秒读 20 次温度完全够用。2.2 电源与驱动边界继电器不能直接怼在 GPIO 上这块属于布线阶段最容易踩坑的地方。STM32F103 的 GPIO 灌电流/拉电流标称 20 到 25mA一个 5V 小型继电器线圈电流就要 70 到 100mA直接驱动必烧引脚。常用方案是 NPN 三极管S8050或 ULN2003 达林顿管线圈两端必须并联续流二极管。用三极管时基极加 1k 限流电阻集电极接继电器线圈线圈另一端接 5V。3.3V/5V 边界要注意单片机在 3.3V继电器驱动在 5V两者共地即可但千万别把 5V 直接灌进 GPIO。加光耦PC817 起步可以让控制侧与负载侧完全隔离虽然对直流继电器不是必须但对有 220V 加热丝的系统我建议加后面的避坑章还会展开。顺带把最小系统的三个细节一起说掉BOOT0 必须接下拉电阻到地否则上电可能进入 ISP 模式导致程序不跑SWD 的 PA13/PA14 不要在板上做其他功能复用不然烧录器连不上这是很多人拿到源码板子后的第一道坎复位引脚 RST 上拉并加 100nF 电容防止上电抖动引起异常重启。3. 热水不是烧开就完事温度采样与 PID 控温实现3.1 用 NTC 还是 DS18B20温度“测准”的工程前提咖啡机工作温度区间通常在 50 到 95℃这区间用 NTC 和 DS18B20 都行但取舍不一样。NTC 响应快热时间常数小适合放在锅炉壁或水路里做闭环缺点是阻值非线性、需要分压电路和公式换算。DS18B20 是数字接口免校准但 12 位转换一次最长要 750ms对快速变化的出水温度来说滞后明显。这个项目我一般用 NTC 做锅炉温控DS18B20 只作为备选第二路水温参考。NTC 分压电路常见做法是“VCC 到 R_ref 到分压点再到 NTC 到地”分压点接 PA0。这样温度升高、NTC 阻值下降、分压点电压下降。R_ref 选 10k与 100k NTC 在室温附近分压后仍有较好的灵敏度。温度换算用 B 参数方程#define NTC_R25 10000.0f /* 25℃ 阻值 */ #define NTC_B 3950.0f /* B 值 */ #define R_REF 10000.0f /* 分压电阻 */ #define V_ADC_REF 3.30f /* ADC 参考电压 */ float TEMP_GetNtcCelsius(uint16_t adc_value) { float vx (float)adc_value / 4095.0f * V_ADC_REF; float rt R_REF * vx / (V_ADC_REF - vx); /* NTC 当前阻值 */ float kelvin 1.0f / (1.0f / 298.15f (1.0f / NTC_B) * logf(rt / NTC_R25)); return kelvin - 273.15f; }逻辑说明前三行把 ADC 原始值换成电压 vx再用分压公式反推 NTC 阻值 rt最后通过 B 参数方程得到开尔文温度减 273.15 转摄氏。这套公式在 50 到 95℃ 范围内误差可以压到 1% 以内。参数说明V_ADC_REF 别默认写 3.30要拿万用表实测板上 VDD 或参考电压很多 F103 开发板实测 3.28V 甚至更低差 0.02V 在 95℃ 附近就能带来 1℃ 以上的偏置误差。批量做产品可以出厂前做两点校准比如沸水 100℃ 和冰水 0℃用一个线性偏移量修正。滤掉 ADC 锯齿最有效的是中值加平均。每轮采集 8 次去掉最大最小值剩下 6 个求平均uint16_t TEMP_GetFilteredAdc(void) { uint16_t buf[8], tmp; for (int i 0; i 8; i) buf[i] ADC_ReadChannel(ADC_Channel_0); for (int i 0; i 8; i) /* 冒泡排小半够用即可 */ for (int j 0; j 7 - i; j) if (buf[j] buf[j 1]) { tmp buf[j]; buf[j] buf[j1]; buf[j1] tmp; } return (buf[2] buf[3] buf[4] buf[5]) 2; }逻辑说明排序后去掉 2 个最大、2 个最小留下中间 4 个取平均。NTC 信号不会突变这种滤波对 50Hz 工频干扰和布线上拾到的毛刺很有效代价是每轮要 8 次 ADC 转换配合 239.5 周期采样时间总体耗时约 1 到 2ms控制节拍完全跟得上。参数说明如果发现温度曲线还是抖可以把窗口加到 12 次但不要在保温阶段把滤波窗口开太大那会把真实的缓慢温度变化也抹平导致 PID 误判。3.2 位置式 PID 怎么写参数初值与调参路径加热丝是纯滞后大惯量对象想稳定在目标温度附近光靠“到温就停”的通断控制一定会冲温。常见做法是 PID 算出 0 到 100% 的加热占空比再用时间比例方式驱动可控硅或继电器。因为系统只有加热、没有制冷输出下限必须是 0。位置式 PID 的实现typedef struct { float kp, ki, kd; float target, last_err, integral; float integral_limit; /* 积分限幅防止加热段积分饱和 */ float out_limit; /* 输出上限加热百分比 0-100 */ } PID_T; float PID_Calc(PID_T *p, float current, float dt) { float err p-target - current; float derr (err - p-last_err) / dt; p-integral err * dt; if (p-integral p-integral_limit) p-integral p-integral_limit; if (p-integral -p-integral_limit) p-integral -p-integral_limit; float out p-kp * err p-ki * p-integral p-kd * derr; if (out p-out_limit) out p-out_limit; if (out 0.0f) out 0.0f; p-last_err err; return out; }逻辑说明err 为目标减当前积分项用err * dt累加微分用误差差分除以 dt输出直接是占空比。积分限幅先做输出限幅后做顺序不能反否则积分项会带着一屁股旧账在保温阶段反复震荡。参数整定路径我习惯分三步。第一步全零只给 Kp 0.8 到 2.0具体看加热功率和水量跑阶跃升温看曲线第二步慢慢加 Ki0.02 到 0.1/s 量级把稳态静差吃掉第三步加 Kd1 到 5 之间压一阶滞后带来的过冲。采样周期 dt 统一用 0.2 到 0.5s不需要 1ms 跑一次因为加热系统的热惯性在秒级更快的 PID 循环只会让输出在小噪声上颤抖。时间比例执行层是另一个坑。PID 算出的 60% 不代表继电器每秒通断 600ms那种动作频率会把触点打成电火花。常见做法是固定一个 2 秒的 TTL 周期占空比 60%就前 1.2 秒导通、后 0.8 秒断开用软件定时器管理void HEAT_PeriodicUpdate(void) /* 每 10ms 调用 */ { static uint16_t tick 0; uint16_t on_time (uint16_t)(heat_percent * 20.0f); /* 2s 周期内导通 ms 数 */ if (tick on_time) HEAT_ON(); else HEAT_OFF(); if (tick 2000) tick 0; }逻辑说明这个函数由 10ms 定时器节拍驱动每次比较当前 tick 是否落在导通窗口内。2 秒周期对继电器是安全频率升温过程中不会频繁抖动触点。如果换成双向可控硅过零触发可以把周期缩到 1 秒以内控温更平滑。在温度逼近目标时还有一个必须处理的边界目标是 92℃别等误差为 0 才切小占空比。我的经验是在目标值以下 3 到 4℃ 就开始收出力否则靠余热就能冲过去 5℃。这个“提前量”可以简单地在状态机里判断温度进入target - 3.0f区间后直接把 PID 输出上限从 100 降到 20。4. 状态机与按键扫描让控制系统按“冲泡流程”走起来4.1 主状态机待机、加热、萃取、保温谁先谁后咖啡机不是一直加热而是依序执行“待机到加热、加热到萃取、萃取到保温”的流程水位异常还要随时中断。用状态机比用 if 嵌套清晰得多状态切换不再散落在一堆标志位里。typedef enum { SYS_IDLE, SYS_HEATING, SYS_BREWING, SYS_KEEP_WARM, SYS_ERROR } SYS_STATE_E; volatile SYS_STATE_E sys_state SYS_IDLE; void SYS_Task(void) /* 主循环反复调用 */ { switch (sys_state) { case SYS_IDLE: if (key_long_press KEY_BREW water_ok) { temp_target 92.0f; sys_state SYS_HEATING; } break; case SYS_HEATING: if (!water_ok) { sys_state SYS_ERROR; break; } heat_percent PID_Calc(brew_pid, temp_now, 0.3f); if (temp_now temp_target - 0.5f) sys_state SYS_BREWING; break; case SYS_BREWING: heat_percent 0.0f; /* 萃取阶段通常靠锅炉余温和水泵流量 */ pump_on(); brew_runtime 10; /* 主循环节拍 10ms */ if (brew_runtime 30000) { pump_off(); sys_state SYS_KEEP_WARM; } break; default: break; } }逻辑说明每个 case 只做一件事检查触发条件、执行当前阶段动作、必要时切换状态。萃取阶段不要继续满功率加热否则蒸汽混进萃取水路压力曲线全乱。状态机里的brew_runtime用主循环计数累加而不是 Delay保证按键和显示不受阻塞。参数说明PID 调用节拍写死 0.3 不太优雅正式工程里应该用PID_T.period_ms判断距上次调用是否满 300ms避免主循环快慢影响控制周期。状态机实现的另一个好处是错误处理有唯一出口检测到锅炉缺水直接进 SYS_ERROR关加热、关水泵、蜂鸣器鸣 3 声然后回到 SYS_IDLE。这样用户按任何键都不会重启加热流程。4.2 按键扫描10ms 定时器里的移位消抖按键消抖最稳的做法不是 Delay 20ms 再读一次而是在 10ms 定时中断里做移位采样。连续读到两个相同的稳定电平才认为按下可以有效避免“刚好卡在抖动沿”的误判。uint8_t key_scan_value[4]; /* 每个按键的移位寄存器 */ uint8_t key_press_trig; /* 按下触发bit 表示哪颗键 */ uint8_t key_release_trig; /* 释放触发 */ void KEY_TimerScan(void) /* 每 10ms 从定时中断调用 */ { for (int i 0; i 4; i) { key_scan_value[i] (key_scan_value[i] 1) | (key_pin_level(i) ? 1 : 0); if ((key_scan_value[i] 0x03) 0x00) key_press_trig | (1 i); if ((key_scan_value[i] 0x03) 0x03) key_release_trig | (1 i); } }逻辑说明每次中断把当前电平左移进 8 位寄存器然后看最低两位。连续两个周期都是低电平才确认按下都是高电平才确认释放。0x03 判定相当于 20ms 消抖跟硬件 RC 消抖互补实测手感没有连击。短按和长按的区分放在主循环里做按下触发后开始计数超过 800ms 且未释放判定为长按按下时间短于 800ms 在释放触发时判为短按。这里有个细节状态机切换不要在“按下”瞬间立即执行最好留到“释放”确认否则长按启动时会把短按的逻辑也带出。4.3 源码工程怎么组织拿到 STM32F10x 源码先看这四个文件聊到控制系统源码本身还得说清楚工程结构。标准库StdPeriph的 F10x 工程目录通常长这样目录职责初次移植必改项User/main.c、中断入口系统时钟初始化和任务调度HARDWARE/pid.c、temp.c、key.c、heat.c、lcd.c引脚定义、NTC 参数SYSTEM/delay.c、usart.c、sys.c晶振频率、串口调试开关Core/core_cm3.c、启动文件一般不动移植到自己的板子时我一般建议优先核对四个地方delay.c里的系统时钟是 8MHz 外部晶振还是内部 RC这决定所有延时是否偏了几倍lcd.c或显示屏驱动的引脚和时序对不对temp.c里 R_REF 和 NTC_B 是否匹配手上的传感器pid.c的默认参数只能作为起调初值不可能开箱即用。主循环调度用最简单的前后台结构就够了int main(void) { DRV_GPIO_Init(); ADC_Init(); TIM_Init(); /* 10ms 节拍驱动按键扫描和 PID 运算 */ USART_Init(115200); LCD_Init(); while (1) { KEY_Process(); /* 扫描结果转成数组键值 */ SYS_Task(); /* 状态机主体 */ PID_Run(); /* 按周期计算占空比 */ LCD_Refresh(); /* 显示温度、状态、PID 输出 */ USART_PrintDebug(); /* 可选调参时打印曲线 */ } }逻辑说明前后台结构把所有周期任务挂在主循环里定时中断只负责打节拍和采集。咖啡机最多也就 6 到 8 个任务裸机完全够等以后加配网、加 app 联动再说上 RTOS 的事现在提前引入调度器只是增加排错难度。参数说明LCD_Refresh 如果刷屏占用较多 CPU可以改成每 100ms 刷一次状态数据用全局变量传递。5. 咖啡机控制系统避坑5 个必须提前预防的坑5.1 继电器一吸合MCU 立刻复位现象加热继电器吸合瞬间整个系统黑屏重启每次都稳定复现。原因两个叠加。继电器线圈断电瞬间产生反电动势没有续流二极管时这个尖峰直接灌进 3.3V 电源网络更隐蔽的是加热丝冷态电阻小上电瞬间浪涌电流把开关电源输出电压拉穿MCU 掉电复位。解决继电器线圈两端必须并接 1N4007 续流二极管或者用 ULN2003 这种内置续流保护的驱动器。加热丝回路如果用的是继电器而非可控硅至少在电源模块输出端加 470uF 电解电容加 22uF 陶瓷电容做缓冲。完成这两步后实测继电器通断不再引起复位。如果你的板子驱动级没有和 MCU 供电隔离这个坑躲不掉。5.2 水温显示跳变到 96℃ADC 采样像黑匣子现象温度读数在 20 秒内从 78℃ 跳到 96℃有时又回落到 0℃加热逻辑跟着乱套。原因ADC 多通道切换后采样电容里还留着上一通道的电荷第一次转换结果会偏NTC 分压点的高阻网络对布线耦合的 50Hz 干扰很敏感有些开发板 VREF 引脚只悬空或只接了个 100nF动态阻抗大。解决每轮采样先丢弃第一次 ADC 结果后面连续采 8 次再做中值平均。把 ADC 采样时间设到 239.5 周期同时给 VREF 加 1uF 加 100nF 双电容到地。NTC 引线缩短分压点和引脚之间不要走长线。做完这几步温度曲线基本就顺了。别指望一个滤波公式解决所有电磁干扰模拟布局才是治本。5.3 按键一按屏幕就花主循环被长延时阻塞现象按下开始键后LCD 在加热过程中出现乱码温度显示好几秒不动。原因状态机里用了Delay_ms(500)或类似死等逻辑等待水泵运行主循环界面刷新被卡住。LCD 这类时序敏感的外设一旦刷新间隙超过 200ms驱动端的指针和显存状态就可能错乱。解决把所有“等一会儿”改成定时器自由计数。水泵要运行 30 秒就在进入萃取时记录start_tick每轮主循环检查tick - start_tick 30000期间该刷屏刷屏、该扫键扫键。主循环里禁止任何超过 10ms 的阻塞调用。5.4 温度冲到 97℃ 才停PID 没限幅现象目标 92℃实际 97℃ 才开始回落然后一路掉到 90℃ 又冲上去。原因PID 输出没有做 0 到 100 限幅或者积分项在整个加热段无限制累加导致接近目标时输出还停留在 100%。加热丝余热惯性本来就大输出降到 0 之后温度还会继续爬升。解决PID_Calc里积分限幅先于输出限幅执行。初调时把 integral_limit 设在 10 到 20out_limit 在加热段设 100、接近目标时降到 20。这是调参里最有效的一招能让冲温从 5℃ 收到 1.5℃ 以内。5.5 220V 采样引线连到 ADC样机直接冒烟现象想在样机上检测加热丝电流把电流互感器输出直接连到 PA1 的 ADC 引脚上电瞬间板子冒烟。原因互感器次级地没有与 MCU 隔离或者靠共模走线幻想能隔离220V 侧的瞬态直接窜进了 3.3V 系统。解决如果是测量加热电流用 ACS712 这类隔离型电流传感器信号地与 MCU 严格单点共地更加稳妥的是用光耦加比较器做“加热丝有没有断、是否在加热”的定性检测不进 ADC。带 220V 负载的样机调试建议先经隔离变压器供电人身安全余量比什么都重要。6. 调参的技巧与参数固化串口看曲线Flash 存配方6.1 串口看曲线先开环测滞后再闭环调占空比调试 PID 最快的方法是看曲线而不是看温度数字。USART 每秒打印一帧temp,target,out接收端用串口助手录成 CSV拖进 Excel 作图。开环测试先做一次不给 PID固定输出 30% 加热记录温度从室温到 95℃ 的完整曲线。这条曲线能告诉你系统滞后是 10 秒还是 30 秒稳态增益大概多少Kp/Ki/Kd 的初值范围就有依据了不用拍脑袋。6.2 用 Flash 最后一页存参数参数固化与校验回退曲线看熟了下一步是把参数固化进 Flash。STM32F103C8T6 的 Flash 有 64KB最后 1KB 页地址0x0800FC00可以放一组出厂参数。写入前必须先整页擦除再把 PID 三个系数、目标温度、模式参数结构体写进去写完读回校验#define FLASH_PARA_ADDR 0x0800FC00 typedef struct { float kp; float ki; float kd; float target_temp; uint16_t crc; /* 简单累加校验 */ } SYS_PARA; void PARA_Save(SYS_PARA *p) { FLASH_Unlock(); FLASH_ErasePage(FLASH_PARA_ADDR); uint32_t *src (uint32_t *)p; for (int i 0; i sizeof(SYS_PARA) / 4; i) FLASH_ProgramWord(FLASH_PARA_ADDR i * 4, src[i]); FLASH_Lock(); }逻辑说明结构体按 4 字节对齐这里用 32 位指针逐字写入。擦除整页会把同页其他参数一起抹掉所以所有需固化的参数必须放在同一个结构体里统一管理。校验方式我用累加和的 crc 字段上电读出来校验失败就回退默认值。我自己的习惯是底版参数永远存一组保守值Kp 取 1.0、Ki 取 0.03、Kd 留 0目标温度 90℃这类参数在大多数板上即使不是最优也至少能跑出稳定曲线。调好的最优参数单独记录在源码注释里不覆盖默认值。因为在不同加热功率、不同锅炉容积之间PID 参数根本没有通解能保证第一次上电不失控就是给现场留了最大的后悔药。这套“先开环测滞后、再串口看曲线、最后 Flash 固化”的流程我用过十几块不同板子从来没在调参上浪费超过一个晚上希望帮到你。本文还有配套的精品资源点击获取