STM32智能花盆实战:单总线时序、μC/OS-II任务与舵机联调
简介这份嵌入式智能花盆设计与实现开题报告面向电子信息工程、物联网等专业的本科生与指导教师可用于毕业设计选题、开题答辩准备以及嵌入式入门项目的方案参考。文档围绕STM32最小系统、DS18B20温度传感器、DHT11温湿度传感器、2.8寸触摸屏与9g舵机等硬件梳理了光照、温度、湿度采集与自动浇水、人机交互的实现思路并涉及μC/OS-II实时操作系统的应用。资源包仅含1个PDF文件约143KB轻量易读适合直接打印或在线查阅。国内外发展状况、研究目标与内容、调查法/文献研究法/实验法等研究方法、约8个月的进度安排以及资金、技术与设计周期三方面的可行性分析均有覆盖可帮助读者快速搭建开题报告框架、理解课题论证逻辑与器件选型依据。目前已有83人学习对需要撰写同类课题开题材料或规划智能硬件方案的读者具有参考价值。1. 200 元预算、40 天工期智能花盆真正的难点不在操作系统开题报告里最容易被低估的一句话是“软件方面主要包括单总线通信PWM 调制μC/OS-II 操作系统的使用”。不少人看到 μC/OS-II 就先去啃任务调度和内核裁剪结果板子焊完 DHT11 读出来的湿度永远是 0或者舵机一转 STM32 立刻复位。200 元的器件清单——STM32 最小系统板、2.8 寸触摸屏、DS18B20、DHT11、辉盛 9g 舵机加一个微型水泵——里面没有任何一个器件需要复杂算法翻车点全在微秒级时序容差、舵机堵转电流冲击、以及多个任务争抢同一根数据线的资源竞争上。这套方案适合电子信息工程专业做课程设计或毕业设计的人也适合已经写过点灯和串口、第一次想把“感知—决策—执行—人机界面”闭环跑通的嵌入式开发者。后面按传感层时序、μC/OS-II 任务划分、触摸屏与参数落盘、联调排错四段展开参数全部对着具体芯片能给出的值写不写“适当调整”这种没法复现的话。2. DS18B20 与 DHT11 的单总线时序差异与共存接法两个传感器都只占一根数据线看起来能省引脚实际上这是最容易踩的第一个坑。DS18B20 走的是 Dallas 单总线协议一条线上可以挂多个从机靠 64 位 ROM ID 寻址复位脉冲、写时隙、读时隙都在微秒量级完成。DHT11 虽然也只有一根数据线但它不是标准单总线没有寻址机制没有 ROM一次通信就是一帧固定 40 位数据。两者最致命的冲突在启动方式。DHT11 要求主机把数据线拉低至少 18ms 再释放这个时间对 DS18B20 来说足够走完几十个完整时隙而反过来DS18B20 的微秒级时隙会被 DHT11 误判成非法启动脉冲。所以正确的做法是各占一个 GPIO软件层面再用一个互斥信号量把两个驱动串起来防止任务切换或中断打断正在进行的时序。2.1 引脚分配与上拉电阻的取值器件协议建议引脚上拉关键约束DS18B20Dallas 单总线PA04.7kΩ 到 3.3V线长尽量 1m否则改强上拉DHT11自定义单线PA14.7kΩ~10kΩ 到 3.3V采样间隔 ≥2s启动拉低 ≥18ms光照采集ADC1_IN2PA2分压电阻 10kΩ走 DMA 采样别占 CPU舵机信号PWMPA6TIM3_CH1无需50Hz脉宽 0.5~2.5ms2.8 寸触摸屏SPI1 触摸PA5/PA6/PA7 CS/DC/RST无需SPI 先跑 9MHz 稳定后再提频上拉电阻不是随便抓一个就行。4.7kΩ 是 3.3V 供电下的经验值线越长、总线电容越大上升沿越缓读时隙就容易采到错误电平。如果传感器走线超过半米或者接了多个 DS18B20常见做法是换成 2.2kΩ 甚至加一级 MOSFET 强上拉。2.2 DS18B20 的复位、读写字时隙与微秒延时单总线的全部难度都在这几十行代码里。先解决延时μC/OS-II 下的OSTimeDly最小单位是一个 tick通常是 1~10ms完全不够用必须自己实现微秒级忙等。/* DWT 周期计数器做微秒延时72MHz 下 1us 等于 72 个内核周期 */ static void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000U); while ((DWT-CYCCNT - start) ticks) { } /* 忙等不用中断 */ } /* 复位拉低 500us 后释放等待器件回应的存在脉冲 */ static uint8_t ds18b20_reset(void) { uint8_t ack; OW_OUT(); OW_LOW(); delay_us(500); /* 协议窗口 480~960us取中间值 */ OW_IN(); /* 切输入靠 4.7k 上拉把线拉高 */ delay_us(70); /* 存在脉冲出现在 15~60us 内 */ ack OW_READ(); /* 读到 0 说明器件应答读 1 说明没接上 */ delay_us(430); /* 补齐整个时隙避免残留电平干扰下一次 */ return ack; } /* 写一个字节位 1 拉低 15us 后释放位 0 拉低 60us 以上 */ static void ds18b20_write_byte(uint8_t dat) { for (uint8_t i 0; i 8; i) { OW_OUT(); OW_LOW(); if (dat 0x01) { delay_us(2); /* 短拉低表示逻辑 1 */ OW_IN(); delay_us(60); /* 时隙总长不小于 60us */ } else { delay_us(60); /* 长拉低表示逻辑 0 */ OW_IN(); delay_us(2); } dat 1; } } /* 读一个字节主机拉低 1~2us 触发然后在 15us 内采样 */ static uint8_t ds18b20_read_byte(void) { uint8_t dat 0; for (uint8_t i 0; i 8; i) { dat 1; OW_OUT(); OW_LOW(); delay_us(2); /* 读时隙起点必须拉低至少 1us */ OW_IN(); delay_us(10); /* 在 15us 采样窗口内取中点 */ if (OW_READ()) dat | 0x80; /* 高电平是 1低电平是 0 */ delay_us(50); /* 补满 60us留出恢复时间 */ } return dat; }OW_OUT()和OW_IN()是切换 GPIO 方向的宏输出用推挽输入用浮空。复位函数里的 500us 不要图省事写成 480us晶振误差和编译器优化都可能让它掉到协议下限以下。读时隙的 10us 采样点也一样太早读到的还是器件拉低的那一段太晚就跨进了下一个时隙。注意如果温度恒定为 85℃先怀疑复位没成功。DS18B20 上电默认温度寄存器就是 85℃很多代码在复位失败后仍然继续发读命令读回来的自然就是这个默认值。正确做法是复位失败直接返回错误码不要污染上层数据。2.3 DHT11 的 40 位数据帧与校验和DHT11 的帧结构固定没有命令字也没有地址。字节序号内容取值范围buf[0]湿度整数部分20~90 %RHbuf[1]湿度小数部分固定为 0buf[2]温度整数部分0~50 ℃buf[3]温度小数部分固定为 0buf[4]校验和(buf[0]buf[1]buf[2]buf[3]) 0xFF/* 返回 0 表示成功非 0 是分阶段的错误码方便串口打印定位 */ uint8_t dht11_read(dht11_t *dht) { uint8_t buf[5] {0}; /* 阶段1主机启动信号拉低至少 18ms 后释放 */ DHT_OUT(); DHT_LOW(); HAL_Delay(20); /* 用 20ms 留裕量datasheet 下限是 18ms */ DHT_IN(); /* 释放总线由上拉电阻拉高 20~40us */ if (dht_wait_level(1, 60) ! 0) return 1; /* 等 DHT 拉低 */ if (dht_wait_level(0, 90) ! 0) return 2; /* 等 DHT 拉高 */ /* 阶段2逐位读取 40bit位 0 高电平约 26~28us位 1 约 70us */ for (uint8_t i 0; i 40; i) { if (dht_wait_level(1, 60) ! 0) return 3; /* 每位以 50us 低电平开始 */ delay_us(35); /* 35us 后仍为高电平即判断为 1 */ buf[i / 8] 1; if (DHT_READ()) buf[i / 8] | 0x01; if (dht_wait_level(0, 60) ! 0) return 4; } /* 阶段3校验累加和低 8 位必须等于第五字节 */ if ((uint8_t)(buf[0] buf[1] buf[2] buf[3]) ! buf[4]) return 5; dht-humi_int buf[0]; dht-humi_dec buf[1]; dht-temp_int buf[2]; dht-temp_dec buf[3]; return 0; }返回分阶段错误码是个实用技巧串口打出“err1”就知道卡在等器件响应“err5”就是校验失败比只返回一个 bool 好定位得多。dht_wait_level是带超时的电平等待函数超时时间按单个阶段的物理上限给不要用死循环 while否则传感器没插好就永远卡在采集任务里。2.4 野值剔除与滑动平均DHT11 本身精度就一般加上舵机动作时的电源波动偶尔会输出明显离谱的值。直接拿来驱动浇水决策会看到水泵一秒一开一合。#define FILTER_N 8 static uint16_t temp_win[FILTER_N]; static uint8_t temp_idx 0; /* 先做野值剔除再做 8 点滑动平均 */ uint16_t temp_filter(uint16_t raw) { uint32_t sum 0; /* 与上一轮均值偏差超过 2℃DS18B20 分辨率 0.0625℃直接丢弃 */ for (uint8_t i 0; i FILTER_N; i) sum temp_win[i]; uint16_t last (uint16_t)(sum / FILTER_N); if (last ! 0 (raw last 2 || raw last - 2)) return last; temp_win[temp_idx] raw; /* 写入环形窗口 */ temp_idx (temp_idx 1) % FILTER_N; sum 0; for (uint8_t i 0; i FILTER_N; i) sum temp_win[i]; return (uint16_t)(sum / FILTER_N); /* 定点整数平均避免浮点开销 */ }两点说明一是判野值用的是“上一轮均值”而不是“上一个采样值”抗单点干扰更强二是窗口长度 8 对应 2s 采样周期等于 16s 的平滑时长加热或开窗造成的真实温度变化不会被吃掉太多。3. μC/OS-II 任务划分、优先级分配与 PWM 舵机浇水链路操作系统在这里的作用不是炫技是把“采集慢、决策快、界面卡”这三类节奏完全不同的工作拆开让 DHT11 那 20ms 的忙等不阻塞触摸屏响应。任务划分没做好μC/OS-II 只会让问题更难查。3.1 四个任务的优先级、栈深度与周期任务优先级数值栈大小触发方式职责TaskSensor61KB周期 2s读 DS18B20、DHT11、光照 ADCTaskCtrl71KB邮箱事件阈值判断、舵机控制、超时保护TaskAlarm9512B邮箱事件蜂鸣器、LED、报警日志入队TaskUI112KB周期 50ms触摸扫描、界面刷新空闲任务63系统分配常驻μC/OS-II 自带优先级数值越小越高这是 μC/OS-II 的约定。把 UI 放到最低是有意为之刷屏晚 50ms 用户根本感觉不到但采集任务被拖慢会直接破坏 DHT11 的时序窗口。/* μC/OS-II 保留 0~3 给系统用户任务从 4 开始 */ #define TASK_SENSOR_PRIO 6 #define TASK_CTRL_PRIO 7 #define TASK_ALARM_PRIO 9 #define TASK_UI_PRIO 11 static OS_STK TaskSensorStk[256]; /* 256 * 4B 1KB */ static OS_STK TaskCtrlStk[256]; static OS_STK TaskAlarmStk[128]; static OS_STK TaskUIStk[512]; /* 刷屏和字库解码耗栈给足 2KB */ void App_TaskCreate(void) { /* 第三个参数是栈顶指针OSTaskCreate 要求传 stk[size-1] */ OSTaskCreate(TaskSensor, (void *)0, TaskSensorStk[255], TASK_SENSOR_PRIO); OSTaskCreate(TaskCtrl, (void *)0, TaskCtrlStk[255], TASK_CTRL_PRIO); OSTaskCreate(TaskAlarm, (void *)0, TaskAlarmStk[127], TASK_ALARM_PRIO); OSTaskCreate(TaskUI, (void *)0, TaskUIStk[511], TASK_UI_PRIO); }栈大小别拍脑袋。编译后开OSTaskStkChk()跑满 24 小时看每个任务的实际剩余量再留 30% 裕量。触摸屏任务如果用了 sprintf 和浮点格式化1KB 会直接溢出。3.2 用互斥信号量保护单总线用邮箱传递采样结果采集任务和控制任务不直接共享全局变量而是走邮箱。但传感器驱动本身必须互斥因为 DS18B20 和 DHT11 虽然是两根独立的线却可能被报警任务或界面任务间接调用到。static OS_EVENT *OneWireMutex; /* 互斥信号量带优先级继承 */ static OS_EVENT *SensorMbox; /* 消息邮箱投递采样结果 */ void TaskSensor(void *pdata) { INT8U err; sensor_msg_t msg; (void)pdata; while (1) { /* 第二个参数 0 表示无限等待总线被占时任务挂起让出 CPU */ OSMutexPend(OneWireMutex, 0, err); msg.temp ds18b20_read_temp(); msg.humi dht11_read_humi(); msg.light light_read_percent(); OSMutexPost(OneWireMutex); /* 无论成败都要释放别在中间 return */ OSMboxPost(SensorMbox, (void *)msg); /* 通知控制任务有新数据 */ OSTimeDlyHMSM(0, 0, 2, 0); /* 2s 一轮满足 DHT11 间隔要求 */ } }这里必须用OSMutexPend而不是普通的OSSemPend。互斥信号量支持优先级继承如果 TaskUI 占着总线时被高优先级的 TaskSensor 抢占TaskUI 会临时继承高优先级尽快跑完并释放避免优先级反转。用普通信号量的话界面任务可能被其他中优先级任务压住采集任务跟着一起卡死。注意中断服务程序里绝对不能调用OSMutexPend。中断上下文没有任务控制块μC/OS-II 会直接跑飞。需要中断里触发的用OSMboxPost或OSSemPost让任务去做实际的总线操作。3.3 舵机 PWM50Hz 周期与 0.5~2.5ms 脉宽的换算辉盛 9g 舵机是标准模拟舵机控制信号是 50Hz 的 PWM脉宽 0.5ms 对应一个极限角度2.5ms 对应另一个极限。定时器配置的思路是让计数器 1 个计数等于 1us这样算脉宽不用做除法。/* TIM3_CH1 接 PA672MHz / 72 1MHz1 个计数 1us */ void servo_pwm_init(void) { TIM_TimeBaseInitTypeDef tb; TIM_OCInitTypeDef oc; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); tb.TIM_Prescaler 72 - 1; /* 分频到 1MHz */ tb.TIM_Period 20000 - 1; /* 20000us 20ms即 50Hz */ tb.TIM_CounterMode TIM_CounterMode_Up; tb.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, tb); oc.TIM_OCMode TIM_OCMode_PWM1; oc.TIM_OutputState TIM_OutputState_Enable; oc.TIM_Pulse 1500; /* 上电先给中位避免舵机猛冲 */ oc.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, oc); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); } /* 角度换算-90 度对应 500us90 度对应 2500us总行程 2000us */ void servo_set_angle(int16_t deg) { if (deg -90) deg -90; if (deg 90) deg 90; uint16_t pulse (uint16_t)(1500 (int32_t)deg * 1000 / 90); TIM_SetCompare1(TIM3, pulse); }目标角度脉宽CCR 值1us 计数-90°0.5ms500-60°0.833ms8330°1.5ms150060°2.167ms216790°2.5ms2500TIM_Pulse上电设为 1500 是必要的如果初值是 0舵机会朝一个方向顶到底并持续堵转电流飙升。真正的机械阀开合角度不需要 90°实测 -60° 到 0° 就够减小行程也降低了堵转风险。3.4 浇水决策的迟滞区间与最长注水保护阈值判断不能写成“低于 35% 就浇、高于 35% 就停”那样湿度在阈值附近抖动时舵机会疯狂来回打。#define HUMI_TH_START 35 /* 低于 35%RH 开始浇水 */ #define HUMI_TH_STOP 45 /* 高于 45%RH 停止10% 回差抑制抖动 */ #define WATER_MAX_MS 10000 /* 单次注水最长 10s防传感器故障 */ static void water_control(uint8_t humi, uint32_t now_ms) { static uint8_t watering 0; static uint32_t start_ms 0; if (!watering humi HUMI_TH_START) { watering 1; start_ms now_ms; servo_set_angle(-60); /* 打开机械阀 */ } else if (watering) { if (humi HUMI_TH_STOP || (now_ms - start_ms) WATER_MAX_MS) { watering 0; servo_set_angle(0); /* 复位到关闭位 */ } } }三个参数各有分工。10% 的回差是迟滞控制让状态机在湿度缓慢上升时不会在阈值线上来回翻转。WATER_MAX_MS是故障兜底万一土壤湿度探头被拔了或者读数卡死没有这个上限水泵会一直抽到漏水。now_ms由OSTimeGet()乘以 tick 周期换算别用HAL_GetTick()μC/OS-II 接管 SysTick 后那个计数不一定是 MIT 用户想要的语义。4. 2.8 寸触摸屏界面状态机与阈值参数掉电保存触摸屏这部分的目标很朴素让用户能看着当前温湿度顺手把阈值改掉而且断电重启后阈值还在。听起来简单但坐标不校准和 Flash 写错页是课设里最高频的两个返工点。4.1 触摸坐标校准与按键命中判定电阻屏的原始 ADC 值和屏幕像素之间没有固定关系每块屏甚至每次装配都会有偏差必须校准。两点校准就够了让用户依次点左上角和右下角的准星记录两点的原始值。/* 两点校准记录的原始 ADC 值实际项目里应该存进 Flash */ static int16_t cal_x1 320, cal_y1 3000; static int16_t cal_x2 3600, cal_y2 400; #define LCD_W 240 #define LCD_H 320 void touch_map(uint16_t raw_x, uint16_t raw_y, uint16_t *sx, uint16_t *sy) { /* 线性映射把原始 ADC 区间拉伸到像素区间 */ int32_t tx (int32_t)(raw_x - cal_x1) * LCD_W / (cal_x2 - cal_x1); int32_t ty (int32_t)(raw_y - cal_y1) * LCD_H / (cal_y2 - cal_y1); /* 钳位防止边缘点击映射到屏幕外 */ *sx (uint16_t)(tx 0 ? 0 : (tx LCD_W - 1 ? LCD_W - 1 : tx)); *sy (uint16_t)(ty 0 ? 0 : (ty LCD_H - 1 ? LCD_H - 1 : ty)); }cal_x2 - cal_x1一定不能是 0校准时如果用户两次点的是同一个位置除法就会崩。加一个“两点原始值差值小于 200 就判校准失败、要求重来”的检查。另外注意 X 和 Y 方向在某些屏上要对调这个“对调”的判断放在校准阶段而不是运行时。命中判定用矩形包围盒把按钮做成一张表typedef struct { uint16_t x, y, w, h; uint8_t page; /* 这个按钮属于哪个页面 */ uint8_t action; /* 按下后执行的动作 ID */ } btn_t; static const btn_t btns[] { {180, 270, 55, 40, PAGE_MAIN, ACT_GOTO_SET}, /* 主界面右下角设置 */ {170, 280, 60, 35, PAGE_SET, ACT_GOTO_MAIN}, /* 设置页返回 */ { 20, 100, 40, 40, PAGE_SET, ACT_HUMI_UP}, /* 湿度阈值 1 */ {120, 100, 40, 40, PAGE_SET, ACT_HUMI_DOWN}, /* 湿度阈值 -1 */ }; uint8_t hit_test(uint16_t x, uint16_t y, uint8_t cur_page) { for (uint8_t i 0; i sizeof(btns) / sizeof(btns[0]); i) { if (btns[i].page ! cur_page) continue; /* 只判定当前页的按钮 */ if (x btns[i].x x btns[i].x btns[i].w y btns[i].y y btns[i].y btns[i].h) { return i; } } return 0xFF; /* 0xFF 表示没命中 */ }把page放进按钮结构体里比在每个页面函数里写一堆 if-else 清爽得多也避免了“设置页的加号按钮坐标恰好压在主界面的某个元素上”这类隐蔽 bug。4.2 界面状态机与页面跳转关系界面不要写成一层套一层的函数调用用页面 ID 加动作表驱动。页面 ID名称主要元素可跳转页PAGE_MAIN主界面温度/湿度/光照数值、浇水状态图标PAGE_SET、PAGE_LOGPAGE_SET阈值设置4 组加减按钮、保存按钮PAGE_MAINPAGE_LOG历史记录最近 16 条报警记录PAGE_MAINPAGE_CAL触摸校准两个十字准星PAGE_MAIN/* UI 任务主循环先刷新数据再处理触摸最后处理页面跳转 */ void TaskUI(void *pdata) { uint8_t cur_page PAGE_MAIN; (void)pdata; while (1) { ui_draw_page(cur_page); /* 只重绘变化区域别全屏刷 */ if (touch_scan()) { /* 有按下才继续 */ uint16_t rx, ry, px, py; touch_read_raw(rx, ry); touch_map(rx, ry, px, py); uint8_t idx hit_test(px, py, cur_page); if (idx ! 0xFF) { cur_page ui_dispatch(cur_page, btns[idx].action); ui_mark_dirty(); /* 标记需要重绘 */ } } OSTimeDlyHMSM(0, 0, 0, 50); /* 20Hz 扫描够用且省 CPU */ } }50ms 的扫描周期是权衡结果再快会让 SPI 刷屏抢占过多 CPU再慢点击会明显发涩。ui_mark_dirty配合局部重绘可以避免每帧都重画整个 240×320 屏幕——全屏刷一次在 SPI 9MHz 下要几十毫秒直接吃掉整个时间片。4.3 阈值写入内部 Flash 的页对齐与回读校验STM32F103 中容量产品的 Flash 页是 1KB写入前必须整页擦除且擦除单位不能是字节。参数结构体设计成 4 字节对齐长度正好 8 字节一次字编程搞定。/* 注意地址按实际型号改别照抄写错地址会把程序本身擦掉 */ #define CFG_ADDR 0x0801FC00UL #define CFG_MAGIC 0x5A5A typedef struct { uint16_t magic; /* 魔数用于判断这块区域是否已初始化 */ uint8_t humi_start; /* 开始浇水湿度阈值 */ uint8_t humi_stop; /* 停止浇水湿度阈值 */ uint8_t temp_alarm; /* 高温报警阈值摄氏度 */ uint8_t light_min; /* 补光提醒阈值百分比 */ uint16_t crc; /* 前 6 字节累加和做完整性校验 */ } cfg_t; /* 共 8 字节正好 2 个字 */ uint8_t cfg_save(const cfg_t *c) { FLASH_Unlock(); FLASH_ErasePage(CFG_ADDR); /* 必须先擦否则写入无效 */ uint32_t *p (uint32_t *)c; for (uint8_t i 0; i sizeof(cfg_t) / 4; i) { if (FLASH_ProgramWord(CFG_ADDR i * 4, p[i]) ! FLASH_COMPLETE) { FLASH_Lock(); return 1; /* 编程失败可能是地址超范围 */ } } FLASH_Lock(); /* 回读比对防止写失败后掉电导致配置丢失 */ return memcmp((void *)CFG_ADDR, c, sizeof(cfg_t)) 0 ? 0 : 2; }几个容易忽略的点CFG_ADDR必须落在自己程序的 Flash 使用范围之外写之前先看 map 文件的末尾地址擦除之后、编程之前如果掉电配置就是全 0xFF所以上电加载时要检查 magic 和 crc不合法就写入一份默认值别把这段代码放在主循环里频繁调用Flash 擦写寿命按万次量级算每次调阈值都存一次还行每秒存一次很快就会坏。4.4 报警提示与本地日志报警不走屏幕弹窗而是走独立任务。触摸屏刷新是 50ms 周期报警需要立刻响应两者节奏不同。/* 报警日志环形缓冲只留最近 16 条避免占用 RAM 过多 */ static alarm_log_t log_buf[16]; static uint8_t log_head 0; void TaskAlarm(void *pdata) { INT8U err; alarm_msg_t *m; (void)pdata; while (1) { /* 永久等待邮箱没消息时任务完全挂起不占 CPU */ m (alarm_msg_t *)OSMboxPend(AlarmMbox, 0, err); for (uint8_t i 0; i 3; i) { /* 蜂鸣器短鸣三声 */ BEEP_ON(); OSTimeDlyHMSM(0, 0, 0, 100); BEEP_OFF(); OSTimeDlyHMSM(0, 0, 0, 100); } log_buf[log_head] m-log; /* 环形写入满了就覆盖最旧 */ log_head (log_head 1) % 16; } }蜂鸣器用OSTimeDlyHMSM而不是HAL_Delay是关键。HAL_Delay内部是忙等计数器在 μC/OS-II 下会阻塞整个调度器OSTimeDlyHMSM会把任务挂起CPU 让给别的任务。日志用环形缓冲而不是数组追加是因为 Flash 写日志太慢也没必要16 条足够覆盖一整天的异常。5. 联调排错示波器抓波形、看门狗与长时间跑机验证前面四章都跑通之后真正耗时间的阶段才开始。硬件项目里“代码看起来对”和“板上跑得对”之间的距离基本等于你对示波器和万用表的熟练度。5.1 典型故障与定位手段现象常见原因定位手段DHT11 恒返回 0 或 0xFF上拉缺失启动拉低不足 18ms被中断打断时序示波器抓数据线确认 80us 低 80us 高的应答波形DS18B20 恒读 85℃复位无应答却继续执行读到上电默认值复位函数返回值检查是否存在脉冲失败立即返回错误码舵机动作时 MCU 复位堵转电流超 1A与 MCU 共用 LDO未共地舵机独立 5V 供电两地必接在一起就近并 470uF 电解触摸坐标整体偏移未校准或校准点取在屏幕边缘十字准星放在屏幕内缩 20 像素处原始值存 Flash跑几小时后死机任务栈溢出总线互斥没加导致时序被打断开OSTaskStkChk看剩余栈检查是否每个总线访问都加了互斥舵机复位那个问题最反直觉很多人以为加个大电容就行其实根源是两个电源没有共地。MCU 的 3.3V 和舵机的 5V 各自独立没问题但地线必须连在一起否则 MOS 管开关瞬间的电位差会通过信号线倒灌。5.2 独立看门狗与任务级喂狗STM32 的独立看门狗用内部 LSI约 40kHz即使主时钟挂了也能工作。但它只能复位芯片判断不了“芯片在跑但业务卡住了”——比如触摸任务死循环而采集任务早就不干活了。void iwdg_init(uint16_t ms) { /* LSI 约 40kHz64 分频后约 625Hz1 个计数约 1.6ms */ IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetPrescaler(IWDG_Prescaler_64); IWDG_SetReload((uint16_t)(ms / 1.6f)); IWDG_ReloadCounter(); IWDG_Enable(); }喂狗点的位置是有讲究的。常见做法是在 1ms 定时器中断里无条件喂狗这样写出来的看门狗基本等于没开——只要中断还在跑就不会复位业务早就死了也不知道。/* 两个任务各自完成后置位标志凑齐才喂狗 */ static volatile uint8_t flag_sensor_done 0; static volatile uint8_t flag_ctrl_done 0; void TaskSensor(void *pdata) { /* ... 采集逻辑 ... */ flag_sensor_done 1; /* 采集跑完一轮 */ } void TaskCtrl(void *pdata) { /* ... 决策与舵机控制 ... */ flag_ctrl_done 1; /* 控制跑完一轮 */ } /* 由一个低优先级的合并任务完成喂狗 */ void TaskFeedDog(void *pdata) { (void)pdata; while (1) { if (flag_sensor_done flag_ctrl_done) { IWDG_ReloadCounter(); /* 两个关键任务都活着才喂 */ flag_sensor_done 0; flag_ctrl_done 0; } OSTimeDlyHMSM(0, 0, 0, 500); } }看门狗溢出时间设 2s合并任务 500ms 检查一次运算余量足够。采集任务周期是 2s正好卡在溢出边界上所以阈值要设得比采集周期明显更宽松。最后一步是 72 小时连续跑机。把各任务的运行次数、栈剩余量、总线错误码计数打到串口每 10 秒一行第二天早上先看的是错误码统计而不是屏幕显示。舵机的开关次数、水泵的通电累计时长这些累计量比任何单点读数都更能说明系统是不是稳——它们在长时间尺度上暴露的是极值条件下才会出现的资源泄漏和状态机死锁。本文还有配套的精品资源点击获取