简介:这份嵌入式智能花盆设计与实现开题报告,面向电子信息工程、物联网等专业的本科生与指导教师,可用于毕业设计选题、开题答辩准备以及嵌入式入门项目的方案参考。文档围绕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 引脚分配与上拉电阻的取值
| 器件 | 协议 | 建议引脚 | 上拉 | 关键约束 |
|---|---|---|---|---|
| DS18B20 | Dallas 单总线 | PA0 | 4.7kΩ 到 3.3V | 线长尽量 <1m,否则改强上拉 |
| DHT11 | 自定义单线 | PA1 | 4.7kΩ~10kΩ 到 3.3V | 采样间隔 ≥2s,启动拉低 ≥18ms |
| 光照采集 | ADC1_IN2 | PA2 | 分压电阻 10kΩ | 走 DMA 采样,别占 CPU |
| 舵机信号 | PWM | PA6(TIM3_CH1) | 无需 | 50Hz,脉宽 0.5~2.5ms |
| 2.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 %RH |
| buf[1] | 湿度小数部分 | 固定为 0 |
| buf[2] | 温度整数部分 | 0~50 ℃ |
| buf[3] | 温度小数部分 | 固定为 0 |
| buf[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; }返回分阶段错误码是个实用技巧:串口打出“err=1”就知道卡在等器件响应,“err=5”就是校验失败,比只返回一个 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 四个任务的优先级、栈深度与周期
| 任务 | 优先级数值 | 栈大小 | 触发方式 | 职责 |
|---|---|---|---|---|
| TaskSensor | 6 | 1KB | 周期 2s | 读 DS18B20、DHT11、光照 ADC |
| TaskCtrl | 7 | 1KB | 邮箱事件 | 阈值判断、舵机控制、超时保护 |
| TaskAlarm | 9 | 512B | 邮箱事件 | 蜂鸣器、LED、报警日志入队 |
| TaskUI | 11 | 2KB | 周期 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 舵机 PWM:50Hz 周期与 0.5~2.5ms 脉宽的换算
辉盛 9g 舵机是标准模拟舵机,控制信号是 50Hz 的 PWM,脉宽 0.5ms 对应一个极限角度,2.5ms 对应另一个极限。定时器配置的思路是让计数器 1 个计数等于 1us,这样算脉宽不用做除法。
/* TIM3_CH1 接 PA6:72MHz / 72 = 1MHz,1 个计数 = 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 度对应 500us,+90 度对应 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.5ms | 500 |
| -60° | 0.833ms | 833 |
| 0° | 1.5ms | 1500 |
| +60° | 2.167ms | 2167 |
| +90° | 2.5ms | 2500 |
TIM_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_LOG |
| PAGE_SET | 阈值设置 | 4 组加减按钮、保存按钮 | PAGE_MAIN |
| PAGE_LOG | 历史记录 | 最近 16 条报警记录 | PAGE_MAIN |
| PAGE_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 约 40kHz,64 分频后约 625Hz,1 个计数约 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 秒一行,第二天早上先看的是错误码统计而不是屏幕显示。舵机的开关次数、水泵的通电累计时长这些累计量,比任何单点读数都更能说明系统是不是稳——它们在长时间尺度上暴露的是极值条件下才会出现的资源泄漏和状态机死锁。
本文还有配套的精品资源,点击获取