简介:基于STM32的毕业设计完整工程包,面向电子、嵌入式相关专业学生及开发者。项目采用时间片轮询结构,驱动OLED实时显示温度历史曲线,借助ESP8266-01S模块实现物联网数据上传至手机APP,并具备PI计算输出控制温度方案、温度上下限声光报警功能,覆盖传感器采集、显示、无线通信、控制策略与报警等完整闭环。压缩包共242个文件,约50.16MB,以C语言源码(.c/.h)、Keil工程配置(.uvprojx等)、原理图与PCB设计(.schdoc/.pcbdoc)、说明文档(.pdf/.txt/.md)及编译调试过程文件为主,文档包含设计说明、引脚连接、程序框架与调试记录,便于对照学习与二次开发。已有119人学习浏览,适合用于毕业设计参考、课程设计实践或嵌入式技能提升。工程包包含完整源码与硬件设计文档,为作者整理的实战成果,能帮助读者快速理清STM32项目开发流程,并对ESP8266物联网通信、OLED绘图、PI温控策略形成工程化认知。
1. 把 STM32 的 OLED 温度曲线送进手机 APP:这道毕设题真正难在哪
这道题初看是显示题:OLED 上画一条温度历史曲线,听着就是点几个像素的事。真正动手会发现,难点恰好不在屏幕,而在三段数据通路——传感器把温度变成数值,数值在 128x64 的小屏上变成一条能看懂趋势的曲线,再通过物联网协议穿过路由器落到手机 APP 里。STM32 是中间人,OLED 是终端,ESP8266 加 MQTT 是搬运工,三条链路任何一段断了,演示都会卡在「温度出不来」这种问题上。适合两类人:一类是做毕业设计、需要一条完整可演示链路的本科生;另一类是平时写后端或上位机、想搞清楚嵌入式侧数据怎么上行的工程师。驱动部分按 HAL 库展开,联网部分按乐鑫 AT 2.x 指令集展开,两套代码凑齐就是完整可演示链路。
2. 把硬件链路先立住:STM32、OLED、DS18B20、ESP8266 的选型与接线
2.1 器件选型:不是每一块 OLED 都走 I2C,也不是所有 Wi-Fi 方案都叫 ESP8266
标题里四个关键词对应四类器件,选型时最容易踩的坑是「同名不同物」。OLED 模块里 0.96 寸、128x64、SSD1306 控制器是绝对主流,走 I2C 或 SPI 都有;而 1.44 寸的 OLED 常见是 SPI 接口、ST7789 控制器,驱动方式和 SSD1306 完全不同。如果照搬网上的 SSD1306 代码去点 1.44 寸屏,大概率花屏。这篇按最常见方案展开:0.96 寸 SSD1306,I2C 接口,7 位地址 0x3C。
温度传感器用 DS18B20 是毕业设计的经典选择:单总线协议、12 位分辨率、精度 ±0.5°C,一个 GPIO 引脚就能读完数据。比 NTC 做 ADC 校准省事,比 SHT30 省 I2C 总线。通信模块选 ESP8266-01S,原因是它便宜、资料多、出厂自带 AT 固件,STM32 只需要通过串口发字符串指令就能完成 Wi-Fi 连接和 MQTT 收发,不需要在 STM32 上移植协议栈。
2.2 接线表与引脚分配:先定死 I2C、单总线、串口三组外设
用 STM32F103C8T6 最小系统板为例,引脚分配可以参考下表。注意 ESP8266 的供电是重点,它启动瞬态电流能到 300mA 以上,直接用 STM32 板载 3.3V LDO 经常带不动,表现为模块反复重启。
| 功能 | 器件 | 关键参数 | 接线位置 |
|---|---|---|---|
| 主控 | STM32F103C8T6 | 72MHz,64KB Flash | - |
| 显示 | 0.96 寸 SSD1306 OLED | 128x64,I2C 0x3C | SCL=PB6,SDA=PB7 |
| 测温 | DS18B20 | 单总线,12bit | DQ=PB0,加 4.7k 上拉到 3.3V |
| 联网 | ESP8266-01S | 串口 AT 固件 | TX=PA10,RX=PA9 |
| 供电 | AMS1117-3.3 | 5V 转 3.3V,峰值 500mA | 单独给 ESP8266 供电 |
DS18B20 的数据线是开漏结构,上拉电阻一定要加,否则温度值会随机跳成 85°C 或 -127°C。OLED 模块本身一般带 4.7k 上拉,不用额外补。ESP8266 的 RX 是 3.3V 逻辑,STM32 的 PA9 输出也是 3.3V,可以直接连;如果换 5V 单片机就要加分压。
2.3 CubeMX 初始化里最容易被忽略的三个参数
I2C1 开启后,时钟频率建议直接设 400kHz,不要停在默认的 100kHz。这两个速率整屏刷新 1KB 显存的时间差大约 80ms 对 20ms,肉眼可见的卡顿区别。DS18B20 的 PB0 配置成开漏输出:
GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_OUTPUT_OD; // 开漏,靠外部 4.7k 上拉拉高 gpio.Pull = GPIO_NOPULL; // 上拉电阻放在外部,不用内部上拉 gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio);开漏加外部上拉是单总线协议的标准接法:总线空闲时被上拉电阻拉高,DS18B20 要应答时自己拉低。如果用内部上拉也能跑,但线一长就容易误码。串口方面,给 ESP8266 的 USART1 设成 115200、8N1,和模块默认 AT 固件匹配;如果模块被刷过其他波特率,先用串口助手确认。
2.4 等价方案:ESP32-S3 单芯片替代 STM32+ESP8266
如果不想两片芯片来回调,直接用 ESP32-S3 一颗芯片完成 OLED 驱动加 MQTT 上报是近年常见的毕设变体。ESP-IDF 或 Arduino 下驱动 0.91 寸 OLED(128x32)时,SSD1306 寄存器序列和 STM32 版本完全同源,只是 I2C 底层从 HAL 换成 IDF 的 i2c_master 接口。这个方案省掉 AT 指令桥接,代价是代码从 C 裸机思维切到 RTOS 思维,调试方式也不一样。做毕设求稳的话,STM32 加 ESP8266 的资料量更大,遇到问题更容易搜到答案。
3. HAL 库点亮 OLED 全流程:SSD1306 初始化、显存维护与 DS18B20 温度采集
3.1 I2C 地址和控制字节:0x3C 是地址,0x00 与 0x40 是控制字节
SSD1306 在 I2C 总线上是一个从机,7 位地址默认 0x3C,如果模块上 SA0 电阻焊到高电平则变成 0x3D。调用 HAL 的 I2C 传输函数时,地址要左移一位拼上读写位,所以代码里的 8 位地址是 0x78。每次传输的第一个字节是控制字节,0x00 表示后面跟命令,0x40 表示后面跟显示数据。很容易写错的点是直接把命令字节填进 HAL_I2C_Master_Transmit 的 pData,漏掉控制字节,结果屏幕完全没反应。
#define OLED_ADDR 0x3C void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; // 0x00 控制字节 + 命令 HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR << 1, buf, 2, 100); } void OLED_WriteData(const uint8_t *data, uint16_t len) { uint8_t buf[129]; buf[0] = 0x40; // 0x40 控制字节 + 数据 memcpy(buf + 1, data, len); HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR << 1, buf, len + 1, 100); }HAL_I2C_Master_Transmit最后一个参数是超时毫秒数,I2C 总线上如果有设备没应答,HAL 会等到超时返回错误码。在 while 循环里轮询显示时,这个超时不要设太大,100ms 足够,否则总线异常时程序会卡在等待里。
3.2 SSD1306 初始化序列:16 条命令点亮,关键命令逐个拆
网上 SSD1306 的初始化代码版本很多,核心命令其实就 16 条。注意不要把某宝卖家给的 25 条初始化原样照搬,很多是给 51 单片机准备的,里面重复设置或补了非必要命令,在 HAL 库环境下也能跑,但排查问题时多一个变量。
static void OLED_Init(void) { uint8_t cfg[] = { 0xAE, // 关闭显示,初始化期间保持黑屏 0xD5, 0x80, // 显示时钟分频,默认值即可 0xA8, 0x3F, // 复用比:64 行,128x64 屏必须 0xD3, 0x00, // 显示偏移 0 0x40, // 起始行 0 0x8D, 0x14, // 开启内部电荷泵,不开启屏幕不亮 0x20, 0x02, // 页寻址模式,后续刷屏按页操作 0xA1, // 段重映射,常见模块用这个,方向反了左右镜像 0xC8, // COM 扫描方向,反了上下镜像 0xDA, 0x12, // COM 引脚配置:128x64 用 0x12 0x81, 0xCF, // 对比度 207,室内足够 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH 电压 0xA4, // 按 GDDRAM 内容显示 0xA6, // 正常显示,0xA7 是反色 0xAF // 打开显示 }; for (int i = 0; i < sizeof(cfg); i++) OLED_WriteCmd(cfg[i]); OLED_ClearBuffer(); OLED_Flush(); }0x8D 和 0x14 这条命令是「屏幕不亮」类问题的头号原因:如果漏掉电荷泵开启,屏幕即使在初始化后也只会显示模糊的残影。0x20 设置页寻址模式,这决定了后续刷屏时数据是按 8 行一页连续写入的。
3.3 显存 buffer:SSD1306 不能读回,必须在 MCU 侧维护
SSD1306 的显存是 128x64 位,也就是 1024 字节。I2C 接口只支持写入,不支持读回,所以任何想要「先读屏幕再改局部像素」的思路都行不通。常见做法是在 STM32 侧维护一个 8 页 x128 列的数组,所有绘制操作先改数组,最后整屏推送到屏幕。
uint8_t disp_buf[8][128]; void OLED_ClearBuffer(void) { memset(disp_buf, 0, sizeof(disp_buf)); } void OLED_Flush(void) { for (uint8_t page = 0; page < 8; page++) { uint8_t cmd[] = {0xB0 + page, 0x00, 0x10}; // 设置页地址和列地址 for (int i = 0; i < 3; i++) OLED_WriteCmd(cmd[i]); OLED_WriteData(disp_buf[page], 128); } }页地址 0xB0 到 0xB7 对应屏幕的 8 个水平条带,每个条带 8 像素高。列地址拆分两个命令:0x00 到 0x0F 是低 4 位,0x10 到 0x1F 是高 4 位,合起来是 0 到 127。页寻址模式下写完 128 字节后列自动回绕到 0,但页不会自动加一,所以每页都要重新设置页地址。
3.4 DS18B20 单总线读取:用 GPIO 模拟时序,比 I2C 更容易翻车
DS18B20 走的是单总线协议,所有时序都用微秒级延时实现。HAL_Delay 最小单位是 1ms,不能用,需要一个微秒延时函数。72MHz 主频下,常见的空循环延时写法是:for 循环每次约 3 个时钟周期,校准后乘系数。
void Delay_us(uint32_t us) { for (uint32_t i = 0; i < us * 8; i++) { __NOP(); } }这个系数要在调试器里实测校准,不同优化等级下循环周期会变。读取温度的核心流程分四步:复位、跳过 ROM、启动转换、等待后读取暂存器。12 位分辨率下转换时间是 750ms,这个等待直接 HAL_Delay。
int16_t DS18B20_GetTempX10(void) // 返回值单位 0.1°C { uint8_t low, high; int16_t raw; DS18B20_Reset(); DS18B20_WriteByte(0xCC); // 跳过 ROM:总线上只有一颗 DS18B20 DS18B20_WriteByte(0x44); // 启动温度转换 HAL_Delay(750); // 12 位转换典型时间 750ms DS18B20_Reset(); DS18B20_WriteByte(0xCC); DS18B20_WriteByte(0xBE); // 读暂存器,先低字节后高字节 low = DS18B20_ReadByte(); high = DS18B20_ReadByte(); raw = (high << 8) | low; return (int16_t)(raw * 625 / 1000); // 0.0625 * 10 = 0.625 }raw 是 16 位带符号数,低 4 位是小数部分。raw 乘 625 除 1000 是把温度转成 0.1°C 的整数,这样后续给 OLED 画曲线时全程不需要浮点运算,也方便通过串口打印调试。写字节和读字节的时序差异在采样点:写时靠拉低时长区分 0 和 1,读时要在拉低释放后约 15us 采样数据线电平。
4. 温度历史曲线在 OLED 上的绘制:环形缓存、整数映射与分页刷新
4.1 历史数据为什么用环形缓冲:128 个采样点对应 128 列像素
OLED 横向 128 像素,一列像素对应一个采样点,这是最直观的滚屏方案。用环形缓冲而不是普通数组是因为:温度曲线要持续滚动显示,新数据不断进来,最旧的数据要被覆盖,环形缓冲正好用取模运算完成这件事,不需要 memmove 移动整个数组。
#define HISTORY_LEN 128 static int16_t hist[HISTORY_LEN]; static uint8_t hist_head = 0; void Temp_Record(int16_t temp_x10) { hist[hist_head] = temp_x10; // 写入当前点 hist_head = (hist_head + 1) % HISTORY_LEN; // 头指针前移,满则回绕 }buffer 里存放的是 0.1°C 单位的整数温度,范围从 -550 到 1250,对应 DS18B20 的全量程。头指针指向下一个要写入的位置,同时也是整个缓冲区里最旧的数据。绘制时从 hist_head 开始遍历,就能保证画出来的是时间上连续的一段历史。
4.2 坐标映射公式:把 20.0~50.0°C 压进 OLED 的第 8 到第 56 行
OLED 纵向 64 行不能全用,上下留出边框才像「曲线图」。常见做法是让曲线区占据第 8 到第 56 行,顶部和底部各留出标尺区。温度范围固定比自适应好,自适应会让曲线频繁上下跳动,看起来像传感器坏了。
| 温度范围 | 曲线区行号 | 适用场景 |
|---|---|---|
| 20.0~50.0°C | 8~56 | 室内环境、水温变化,毕设最常用 |
| -10.0~40.0°C | 8~56 | 户外环境监测 |
| 0.0~100.0°C | 8~56 | 量程宽但分辨率低,慎用 |
映射公式本质是线性插值。温度越高,行号越小,像素点越靠近屏幕上方。整数运算是关键,浮点格式化输出到 OLED 这种低密度屏上没有意义,还会拖慢刷新。
static uint8_t temp_to_row(int16_t temp_x10) { if (temp_x10 < TEMP_MIN_X10) temp_x10 = TEMP_MIN_X10; if (temp_x10 > TEMP_MAX_X10) temp_x10 = TEMP_MAX_X10; return (uint8_t)(56 - (temp_x10 - TEMP_MIN_X10) * 48 / (TEMP_MAX_X10 - TEMP_MIN_X10)); }48 是曲线区行号的跨度,56 是下边界。温度等于下限时,括号内为 0,返回 56;温度等于上限时,括号内为 48,返回 8。TEMP_MIN_X10 和 TEMP_MAX_X10 用宏定义成 200 和 500,一眼能看出是 20.0°C 和 50.0°C。
4.3 绘制与送显:先画点再连线的取舍
逐列画点的逻辑很简单,清显存、按序取历史数据、计算行号、把对应像素点亮,最后整屏送显。如果温度变化平缓,点与点之间会自然相接;如果刚开机或传感器跳变,会有断点。断点多的时候,可以在相邻两点之间补一条竖线。
void OLED_DrawTempCurve(void) { OLED_ClearBuffer(); for (int i = 0; i < HISTORY_LEN; i++) { int16_t t = hist[(hist_head + i) % HISTORY_LEN]; OLED_DrawPixel(i, temp_to_row(t), 1); } OLED_Flush(); }OLED_DrawPixel 维护的是 MCU 侧显存数组:先将目标行所属页取出,把对应 bit 置 1,最后 Flush 时才整屏推给 SSD1306。连线算法如果有余力可以用 Bresenham 直线,但 128x64 分辨率下,相邻点之间的间距最多就是量程折算的几行,竖线补齐视觉上完全够用。
4.4 0.91 寸 128x32 屏怎么改:两处参数互换
如果手里是 0.91 寸 128x32 的 OLED,不要硬套上面的代码。初始化数组里 0xA8 后跟的 0x3F(64 行复用比)改成 0x1F(32 行),0xDA 的 0x12 改成 0x02,Flush 循环从 8 页改成 4 页。temp_to_row 里的曲线区也要改,比如第 2 到第 30 行。这两处改完,同样的 HAL 库代码在 ESP32 上用 IDF 跑 0.91 OLED 也一样适用,SSD1306 寄存器序列是通用的。
5. 把温度数据发送到手机 APP:ESP8266 MQTT 桥接与 Android 订阅消费
5.1 链路选型:MQTT、TCP socket、HTTP 轮询怎么选
温度数据从 STM32 到手机 APP 有三条常见路径。最简单的是手机开热点,STM32 通过 ESP8266 直连手机创建的 TCP Server,适合教室演示但出了局域网就废。HTTP 轮询是 STM32 定时 POST 到服务器,APP 再定时 GET,实时性差还得多维护一台服务器。毕设和实际小型项目里最常见的是 MQTT:STM32 发布主题,APP 订阅主题,中间 broker 转发,公网可访问。
| 方案 | 实时性 | 公网访问 | 开发量 | 推荐场景 |
|---|---|---|---|---|
| MQTT + 公共 broker | 秒级 | 支持 | 中 | 毕设、远程监控 |
| 手机热点 + TCP socket | 秒级 | 不支持 | 小 | 教室演示 |
| HTTP 轮询 | 秒级到分钟级 | 需要服务器 | 中 | 低频上报 |
MQTT 的 QoS 等级建议用 0。温度数据是周期上报,丢一帧下一帧马上补上,不需要重传机制。毕设不要被「无源物联网」这类低功耗方向带偏思路,STM32 加 ESP8266 的有源链路开发周期最短,演示也稳定。
5.2 STM32 与 ESP8266 的 AT 桥接:控制指令与 MQTT 发布
ESP8266 官方 AT 2.x 固件提供 MQTT 相关指令,只需要按顺序执行四步:设 Station 模式、连 Wi-Fi、配置 MQTT 参数、连接 broker。调试时先用串口助手手动把每步跑通,再搬进 STM32 代码,这样能把「模块问题」和「MCU 问题」分开。
AT+CWMODE=1 # 设置 Wi-Fi 模式为 Station AT+CWJAP="SSID","PASSWORD" # 连接路由器,等 10 秒返回 WIFI GOT IP AT+MQTTUSERCFG=0,1,"stm32_demo","user","pwd",0,0,"" AT+MQTTCONN=0,"broker.emqx.io",1883,1 AT+MQTTPUB=0,"dev/temp","{\"t\":25.6}",0,0broker 地址用公共测试 broker 即可,客户端 ID 要保证唯一,否则会把同一个 broker 上相同 ID 的连接踢掉。AT+MQTTCONN 最后的参数是 keepalive 秒数,设 1 表示开启心跳保活。发布指令里{\"t\":25.6}的转义是 AT 固件的转义格式,STM32 组包时要在字符串里写成\\\"才能把这个反斜杠发出去。
int ESP8266_PublishTemp(int16_t temp_x10) { char cmd[96]; int n = sprintf(cmd, "AT+MQTTPUB=0,\"dev/temp\",\"{\\\"t\\\":%d.%d}\",0,0\r\n", temp_x10 / 10, temp_x10 % 10); HAL_UART_Transmit(&huart1, (uint8_t*)cmd, n, 200); for (int i = 0; i < 50; i++) { if (UART1_WaitOK()) return 0; // 收到 "OK" 即发布成功 HAL_Delay(20); } return -1; // 超时,常见原因是模块没连上 broker }HAL_UART_Transmit 是阻塞发送,200ms 超时对几十字节的 AT 指令完全够。UART1_WaitOK 的实现在串口接收中断里维护接收缓冲,每次进入判断缓冲区最后几个字节是不是OK\r\n。注意不要用 HAL_UART_Receive 阻塞等 OK,因为模块的响应可能分多段到达。
5.3 载荷设计:一个 JSON 字段也要注意逗号陷阱
MQTT payload 用 JSON 格式方便 APP 端解析,但 AT 指令是按逗号分隔参数的,payload 里如果出现逗号,整个 AT+MQTTPUB 的参数解析就会错位。比如多发一个湿度字段{"t":25.6,"h":40},模块可能把"h":40}当成下一个参数处理,轻则发送失败,重则指令直接报 ERROR。
常见做法是载荷只放一个字段,或者把多个字段做 URL 编码。毕设场景下温度单字段完全够用。APP 端解析时也不要引入 JSON 库,字符串截取即可:
String payload = new String(message.getPayload()); String t = payload.substring(payload.indexOf(':') + 1, payload.indexOf('}')); float temp = Float.parseFloat(t);5.4 Android 端订阅:Paho 回调线程里不能直接改 UI
手机 APP 用 Eclipse Paho 的 Android 客户端,订阅 dev/temp 主题后在回调里拿到温度。回调运行在 MQTT 的网络线程,不能直接修改 UI 控件,必须切到主线程。
try { MqttClient client = new MqttClient( "tcp://broker.emqx.io:1883", MqttClient.generateClientId(), new MemoryPersistence()); client.setCallback(new MqttCallback() { @Override public void connectionLost(Throwable cause) { } @Override public void messageArrived(String topic, MqttMessage message) throws Exception { String payload = new String(message.getPayload()); float temp = Float.parseFloat( payload.substring(payload.indexOf(':') + 1, payload.indexOf('}'))); runOnUiThread(() -> tempText.setText(temp + " °C")); } @Override public void deliveryComplete(IMqttDeliveryToken token) { } }); client.connect(); client.subscribe("dev/temp", 0); } catch (MqttException e) { Log.e("MQTT", "connect failed", e); }代码里 subscribe 的第二个参数是 QoS,与发布端保持一致用 0。generateClientId 生成随机客户端 ID,避免多台手机同时订阅时客户端 ID 冲突导致互踢。subscribe 之后可以在测试工具里向同一主题发布消息验证 APP 显示,也可以用 ESP8266 的串口直接发一条 MQTT 消息。
6. 联调顺序与 OLED 刷新优化:三级验证、I2C 提速、脏矩形刷新
6.1 按「传感器→协议→APP」三级验证,不要在链路末端猜问题
拿到源码包或自己写完代码,别直接盯着 APP 上的温度看到不对就改 APP。先串口打印 DS18B20 原始值,确认室温读数合理;再用串口助手手动执行 AT 指令,确认模块能连上 Wi-Fi 和 broker;最后用手机上的任意 MQTT 调试工具订阅 dev/temp,确认能收到发布的数据。三级链路每一级都有独立验证手段,哪一级过不去就只查哪一级,不要拿着手机 APP 从头查到底。
如果 DS18B20 读出来固定是 85.0°C,那是复位时序问题;OLED 花屏先查地址是 0x3C 还是 0x3D;MQTT 发布超时查一下 ESP8266 是否拿到了 IP,再查 broker 地址有没有拼错。这些问题的定位时间,90% 花在怀疑错层上。
6.2 I2C 400kHz 与脏矩形刷新
OLED 整屏刷新 1KB 数据是显示性能的瓶颈。把 I2C 从 100kHz 提到 400kHz,整屏刷新从约 80ms 降到约 20ms,配合 128 列整屏重绘,曲线刷新率可以达到 12fps 左右,肉眼已经比较顺滑。如果还要更快,就改成局部刷新:曲线变化只影响新写入的那一列和上一次写入的那一列,把这两列的显存字节单独推送到对应页地址,数据量从 1KB 降到 2 字节。
void OLED_FlushColumn(uint8_t x) { uint8_t page = 0; uint8_t cmd[] = {0xB0 + page, (x & 0x0F), 0x10 + (x >> 4)}; // 只刷新曲线所在的一列 for (int i = 0; i < 3; i++) OLED_WriteCmd(cmd[i]); OLED_WriteData((uint8_t*)&disp_buf[page][x], 1); }SSD1306 支持按列地址写入,通过设置低 4 位和高 4 位列地址命令,可以只更新一列数据而不是整屏。这里需要注意,页寻址模式下每次列地址设置会重置列指针,因此局部刷新的前提是显存完整地在 STM32 侧维护。用 400kHz 的 I2C 加上单列刷新,曲线刷新率可以稳定超过 30fps,这个帧率下 OLED 的视觉余晖已经能掩盖点与点之间的间隙了。
本文还有配套的精品资源,点击获取