
简介面向嵌入式开发者和物联网学习者这是一套STM32单片机通过ESP8266WiFi模块与Android APP实现双向数据通信的完整工程源码解决采集数据经无线网络传输至手机端显示与控制的需求。压缩包共2826个文件约33.86MB包含STM32固件C/H源码、HAL库工程配置、Hex/AXF烧录文件Android端Java代码、Gradle构建脚本、APK安装包以及JSON解析、XML布局、资源文件等覆盖从底层驱动到移动应用的全链路实现。已有2269人学习下载。源码详细展示了UART串口配置、ESP8266 AT指令集接入WiFi并作为TCP客户端发包、Android端Socket服务器监听接收、JSON封装与解析等关键环节并提供完整项目目录结构与中间产物。开发者可参照该套代码快速搭建单片机-WiFi-手机通信原型深入理解TCP/IP协议栈、数据序列化及嵌入式与Android联调方法。1. 为什么STM32与ESP8266之间绕不开串口Android APP在数据传输中的角色一块STM32采集到温湿度数据要实时出现在手机屏幕上中间隔着一片WiFi。最常见的做法是让ESP8266通过串口拿走MCU的数据再利用TCP连接送到Android APP。链路本身不复杂但不少项目卡在两端的协议差异上单片机串口发送的是字节流手机Socket读到的是经过网络切分的TCP流中间靠AT指令和数据帧把语义对齐。下面从硬件连接、AT配置、STM32驱动、Android Studio客户端实现到断线自恢复逐层展开最后落到一套能跑通的代码骨架和可复现的参数组合。这类工程解压后通常包含STM32的Keil工程和Android Studio工程两端各自独立靠网络协议衔接适合做设备联网展示、数据采集系统或毕业设计的开发者。2. STM32与ESP8266的硬件连接和AT指令配置把模块调到TCP就绪状态2.1 串口是STM32和ESP8266之间唯一可靠的通信信道先看引脚定义ESP8266对外提供UART、I2C、SPI、GPIO等多种接口但AT固件默认只开放UART作为控制通道。I2C和SPI在AT指令集里没有标准的接入协议用它传输业务数据要自己写固件工作量明显变大。所以最省事、最可靠的方案是让两者走串口STM32把数据写到USARTESP8266收到后直接从WiFi发出。接线时注意交叉连接STM32的发送端对应ESP8266的接收端STM32的接收端对应ESP8266的发送端。共地是必须的不共地时电平参考点不一致串口会出现随机乱码。STM32引脚ESP8266引脚电平说明PA9USART1_TXRXD3.3V TTLPA10USART1_RXTXD3.3V TTLGNDGND必须共地3.3V输出VCC、CH_PDCH_PD拉高才能工作ESP8266的VCC需要3.3V峰值电流接近300mA启动瞬间可能更高。用STM32开发板上的3.3V引脚供电时如果板载LDO余量不足模块会在连接WiFi的瞬间反复重启。常见做法是给ESP8266单独接一个AMS1117-3.3稳压模块并且在VCC和GND之间并联一个100uF电解电容加一个0.1uF陶瓷电容。用Arduino IDE开发NodeMCU时管脚丝印与ESP8266原厂引脚不同但接线参照的仍是TXD、RXD、VCC、GND这四根。2.2 用AT指令在ESP8266上建立TCP链路从AP和Station两种模式里选一种模块上电后先不接STM32用USB-TTL转接线把它单独接到电脑串口助手设置波特率115200发AT回车返回OK说明固件正常。想让ESP8266连到路由器再由手机通过局域网找到模块使用Station模式指令顺序如下AT ATCWMODE1 ATCWJAPMyWiFi,12345678 ATCIPSTARTTCP,192.168.1.100,8080 ATCIPMODE1 ATCIPSEND每条指令都要以回车换行结尾串口助手里勾选发送新行。AT返回OK表示上一条执行完成ATCWJAP执行时模块会去扫描并连接指定SSID通常需要3到8秒不能按普通指令的超时时间处理。另一种方式是让ESP8266自己开热点手机连接模块的AP数据完全走本地WiFi不经过路由器。这种模式在野外或临时组网时好用指令是ATCWMODE2 ATCWSAPESP_DATA,12345678,3,0 ATCIPMUX1 ATCIPSERVER1,8080ATCWSAP里的参数依次是热点名称、密码、加密方式、信道。最后一个参数0表示不广播SSID1表示广播。手机连上这个热点后访问192.168.4.1:8080就能连到模块。注意AP模式下ESP8266的IP固定是192.168.4.1Station模式下则由路由器分配。两种模式的选择原则很直接数据量小、现场只有手机和模块用AP模式现场有路由器、手机还要同时上网用Station模式。这里需要先明确Android APP承担的角色APP要么作为TCP客户端主动连接ESP8266要么作为TCP服务端等待ESP8266来连。源码包里常见的方案是APP做客户端ESP8266做服务端局域网内手机访问模块IP加端口即可。2.3 透传模式与非透传模式影响STM32端发数据方式的参数选择ATCIPMODE1开启的是透传模式开启后所有从串口进入ESP8266的数据会被原样转发到远端TCP连接不再需要每次发送都敲一遍AT指令。这个模式适合传感器定时上报这种连续数据流STM32侧只需要统一往串口写数据。非透传模式每次发数据都要走ATCIPSEND长度等模块返回大于号后再发送实际内容。这种方式适合请求应答型报文比如STM32收到手机指令后回一条固定格式的状态帧。两种模式的开发体验差别很大后者在代码里要维护一个“等待大于号”的状态前者则不用。模式适用场景STM32发数据方式退出方式透传模式连续数据上报直接串口发送发送延迟500ms非透传模式应答式短报文ATCIPSEND后等再发CIPCLOSE断开实际项目中更常见的做法是透传模式加数据帧封装。STM32端把业务数据加帧头、长度、校验再发出去ESP8266只当透传管道Android端根据帧格式解析。这样ESP8266本身不参与业务协议后续要换4G模块或换NB-IoT模块STM32端代码只需改串口参数上层协议完全不变。3. STM32侧的数据发送代码AT指令状态机、数据帧和串口中断3.1 把代码拆成串口驱动、AT指令、数据帧三层别在main里堆逻辑很多初学者喜欢在while循环里顺序写AT、CIPSTART、CIPSEND再跟着一个HAL_Delay。这样做在单条指令时能跑但一旦遇到AT返回慢、WiFi断开重连、手机端半包到达代码就乱成一团。按功能拆文件是最稳的做法。串口驱动层只负责收发字节不关心字节内容AT指令层负责组指令、等待OK或ERROR、处理超时数据帧层负责打包温度湿度等业务字段调用AT指令层把数据发出去。main函数里只做初始化、连接WiFi、进主循环。这样手机端新加一条控制指令时只改数据帧层不会碰中断和串口配置。3.2 用空闲中断收AT应答用超时轮询代替裸延时AT指令的应答是不定长的有OK两个字节也有CONNECT加OK的长返回。用逐字节接收再判断结尾很容易漏字符。HAL库提供的空闲中断能在串口收到一帧数据、总线空闲时触发一次回调天然适合处理AT应答。初始化代码里开启接收uint8_t rx_buffer[512]; void uart_init(void) { HAL_UARTEx_ReceiveToIdle_IT(huart1, rx_buffer, sizeof(rx_buffer)); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart1) { rx_len Size; HAL_UARTEx_ReceiveToIdle_IT(huart1, rx_buffer, sizeof(rx_buffer)); } }HAL_UARTEx_ReceiveToIdle_IT接收完一帧后通过HAL_UARTEx_RxEventCallback回调Size就是这一帧的字节数。回调里必须重新开启一次空闲中断接收否则串口只会触发一次。收到的内容存在rx_buffer中之后AT指令层从里面找关键字。发送AT指令并等待应答的函数要加超时不能用HAL_Delay死等。正确写法是记录当前Tick在循环里反复检查超时时间和应答内容uint8_t at_send_cmd(const char *cmd, uint16_t timeout_ms) { uint32_t start HAL_GetTick(); rx_len 0; HAL_UART_Transmit(huart1, (uint8_t *)cmd, strlen(cmd), 200); while (HAL_GetTick() - start timeout_ms) { if (strstr((char *)rx_buffer, OK) ! NULL) { return 0; } if (strstr((char *)rx_buffer, ERROR) ! NULL) { return 1; } } return 2; }这个函数的意义在于AT指令的应答不一定一次到齐可能分两段到达。用循环轮询能等到完整应答而HAL_Delay会阻塞整个线程中断回调里的数据无法在等待期间被处理最终导致判断失败。返回2表示超时调用方可以决定重发。注意每次发送前清空rx_len和缓冲区避免上一次残留的OK干扰判断。3.3 自定义数据帧长度域、校验和与JSON字段说明透传模式下ESP8266不关心数据内容STM32与Android之间需要一个双方约定好的帧格式。最常见的自定义帧包含帧头、长度、类型、数据和校验结构如下字段长度含义帧头2字节固定0xA5 0x5A长度1字节类型加数据的字节数类型1字节0x01传感器上报0x02控制应答数据N字节JSON格式的业务数据校验1字节前N个字节累加和取低8位帧头用于Android端快速定位一条完整报文的起点即使发生粘包也能逐个字节检索。长度域是拆包的关键依据Android端先读两个字节的帧头再读一个字节的长度就能知道这一帧总共需要多少字节。校验和用于丢弃被WiFi干扰的损坏帧。组帧并发送的代码可以这样写使用的是非透传模式void send_frame(uint8_t type, uint8_t *data, uint8_t len) { uint8_t buf[64]; uint8_t sum 0; int frame_len 4 len 1; buf[0] 0xA5; buf[1] 0x5A; buf[2] len 1; buf[3] type; memcpy(buf[4], data, len); for (int i 0; i 4 len; i) { sum buf[i]; } buf[4 len] sum; char cmd[32]; sprintf(cmd, ATCIPSEND%d\r\n, frame_len); at_send_cmd(cmd, 500); HAL_UART_Transmit(huart1, buf, frame_len, 500); }ATCIPSEND%d里的frame_len是实际帧字节数必须与实际发送长度保持一致。如果长度不匹配ESP8266会把多出来的字节当成下一条AT指令返回ERROR。发送完一帧数据后模块会返回SEND OK这个返回值可以作为下一帧发送前的等待条件。如果使用透传模式send_frame函数里就不需要发CIPSEND直接HAL_UART_Transmit发送buf即可。透传模式下要特别注意发送间隔如果两条帧间隔太短ESP8266内部缓冲区可能合并它们Android端会一次收到两帧数据靠帧头逐一拆分即可解决。3.4 启动流程从模块就绪到CIPSTART每一段都要有日志STM32上电后不能立刻发ATESP8266自身需要几百毫秒启动。常见做法是主循环里先做一次空AT探测失败则延时重试成功后再按顺序配置WiFi和TCP连接void wifi_boot(const char *ssid, const char *pwd) { char cmd[128]; while (at_send_cmd(AT\r\n, 500) ! 0) { HAL_Delay(1000); } at_send_cmd(ATCWMODE1\r\n, 500); sprintf(cmd, ATCWJAP\%s\,\%s\\r\n, ssid, pwd); at_send_cmd(cmd, 8000); at_send_cmd(ATCIPSTART\TCP\,\192.168.1.100\,8080\r\n, 3000); }ATCWJAP的超时设置成8000毫秒因为模块扫描并连接WiFi需要时间。ATCIPSTART的IP和端口要与Android APP保持一致如果APP作为TCP服务端在监听8080端口这里就填手机的局域网IP。连接失败时ATCIPSTART会返回ERROR这时需要先发ATCIPCLOSE关闭残留连接再重新CIPSTART否则后续指令会一直报busy。调试阶段在每一步前后通过串口打印日志能直接看出模块停在哪个环节。日志输出推荐用宏开关包裹方便发布时关闭#define DEBUG_LOG 1 #if DEBUG_LOG #define DBG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define DBG_PRINT(fmt, ...) #endif##__VA_ARGS__是GCC扩展语法Keil MDK默认的AC5编译器支持如果切到AC6要改为__VA_ARGS__。打印内容至少包含指令名和返回码例如DBG_PRINT(CIPSTART ret%d\r\n, ret)。4. Android APP端的Socket通信建立连接、分包读取和UI刷新4.1 Android Studio工程结构网络层与界面层分离Android Studio新建工程时选Empty Views Activity即可网络请求代码用Java实现兼容性好Kotlin协程会让没有经验的开发者多一层理解成本。工程的包结构按职责划分TcpClient类负责网络连接和数据收发MainActivity负责界面刷新DataParser负责JSON解析。这样TCP连接和界面逻辑耦合度低断线重连时可以复用同一个TcpClient实例。Android Studio默认英文界面想改成中文可以在Settings里的Plugins搜索Chinese Language Pack安装后重启即可不影响工程编译。UI线程不允许做网络操作Socket的创建、读写、关闭都必须放在子线程这是Android系统强制约束运行时直接抛NetworkOnMainThreadException。4.2 用Java Socket连接ESP8266注意超时和线程切换ESP8266作为TCP服务端时Android APP作为客户端主动发起连接。AP模式下IP固定为192.168.4.1Station模式下IP由路由器分配需要在APP设置页手动填入或者通过局域网扫描发现。连接代码封装成独立的Runnablepublic void connect(final String ip, final int port) { new Thread(new Runnable() { Override public void run() { try { socket new Socket(ip, port); socket.setSoTimeout(30000); isConnected true; handler.sendEmptyMessage(MSG_CONNECTED); readLoop(); } catch (IOException e) { handler.sendEmptyMessage(MSG_DISCONNECTED); } } }).start(); }setSoTimeout(30000)表示读操作30秒内没有数据到达就抛出SocketTimeoutException避免read永久阻塞导致线程泄漏。连接成功和断开都通过Handler发消息回主线程不能直接在子线程里改TextView那样会崩溃。Handler消息码需要统一管理至少包含连接成功、连接断开、收到数据三类。4.3 解析STM32上传的JSON数据处理半包和粘包问题TCP是流协议Android端读到的数据可能不是完整一帧也可能一次包含多帧。不能直接把这批字节转成字符串就去做JSON解析必须按换行符或者自定义帧格式拆包。使用换行符作为分隔符配合缓冲区的实现最简单private void readLoop() { byte[] buffer new byte[1024]; ByteArrayOutputStream cache new ByteArrayOutputStream(); while (isConnected) { int len input.read(buffer); if (len 0) break; cache.write(buffer, 0, len); byte[] data cache.toByteArray(); int index -1; for (int i 0; i data.length; i) { if (data[i] \n) { index i; break; } } if (index 0) { String line new String(data, 0, index, UTF-8); cache.reset(); cache.write(data, index 1, data.length - index - 1); handleLine(line); } } }这里input.read是阻塞读能读到多少字节不确定可能是半条报文也可能是好几条。ByteArrayOutputStream作为行缓冲每找到一个换行符就把换行符之前的内容取出来当作一条完整数据剩余字节放回缓存等待下次拼装。如果STM32端固定以\r\n结尾解析时先去掉末尾的\r再交给JSON解析器。收到完整行后用JSONObject解析温度湿度字段private void handleLine(String line) { try { JSONObject obj new JSONObject(line); String type obj.optString(type, ); if (report.equals(type)) { double temp obj.optDouble(temp, 0.0); double hum obj.optDouble(hum, 0.0); runOnUiThread(new Runnable() { Override public void run() { tvTemp.setText(String.format(%.1f, temp)); tvHum.setText(String.format(%.1f, hum)); } }); } } catch (JSONException ignored) { } }optDouble和optString带默认值字段缺失时不会抛异常适合调试阶段数据结构不稳定的场景。runOnUiThread把界面刷新切回主线程。注意JSON解析失败时不要立刻打日志刷屏半包数据在缓冲区逻辑不完善时会频繁触发JSONException先确保拆包逻辑正确再考虑日志。APP向STM32发送控制指令同样简单在子线程里调用output.write指令以\r\n结尾。写操作失败意味着连接已断开这里要触发重连逻辑而不是只弹一个Toastprivate void sendCommand(String cmd) { try { output.write(cmd.getBytes(UTF-8)); output.flush(); } catch (IOException e) { reconnect(); } }5. 联调排错从串口工具到Logcat三步定位问题5.1 先用串口助手单独验证ESP8266AT不回OK时查接线不要一上来就把STM32和ESP8266接在一起调试。先用USB-TTL把ESP8266单独接到电脑串口助手里逐条发送AT指令把模块的日志、AT返回、TCP连接全部验证通过后再接STM32。这样做的好处是能把问题分成两类串口链路问题还是WiFi链路问题。如果串口助手发AT都无响应大概率是RX和TX接反、波特率不对或者CH_PD没有拉高。很多自制底板把CH_PD悬空模块上电后不工作看起来像坏了。CH_PD需要接到3.3V。另外串口调试助手的本地回显和模块返回混在一起容易误判建议关闭本地回显选项。如果出现连续乱码检查波特率ESP8266出厂默认是115200但也有二手模块被改过波特率用ATUART_DEF115200,8,1,0,0重置。5.2 STM32加入后看三个日志点判断数据在哪一环断了STM32和ESP8266都接好之后整个链路有三个关键日志位置STM32的串口日志、ESP8266的串口日志、Android端的Logcat。STM32的串口日志能看到AT指令和返回码ESP8266的串口输出能看到模块自身的连接状态变化Android Logcat能看到Socket连接和数据解析结果。典型正常日志像这样[STM32] - ATCWJAPMyWiFi,12345678 [STM32] - OK [STM32] - ATCIPSTARTTCP,192.168.1.100,8080 [STM32] - CONNECT [STM32] - OK如果STM32发完ATCWJAP后长时间无返回先把同样的AT指令放到串口助手复测确定模块本身能连上再查STM32代码。如果CIPSTART返回CONNECT但后面没有OK说明TCP连接已建立只是模块把OK和CONNECT分两段返回STM32端判断逻辑要兼容这种情况。Android端连不上时先确认防火墙是否放行了监听端口局域网内Windows防火墙经常拦截TCP入站把Java或Android Studio加入允许列表即可。5.3 常见问题对照表供电、电平、波特率、IP端口现象可能原因处理ESP8266上电反复重启供电不足独立3.3V电源并100uF电容AT返回乱码波特率不匹配统一设为115200ATCWJAP一直ERRORSSID或密码错误模块不支持5G改用2.4G频段CIPSTART失败APP端未启动监听IP端口不对确认手机IP和监听端口数据发到一半断流TCP连接被关闭检查心跳触发重连烧录STM32时报“No STM32 Target Found”时优先检查ST-Link的SWD四线连接和复位电容这是烧录器的问题和WiFi链路无关。ESP8266与STM32的串口接线段如果电平不匹配可以在TX和RX线上串联1K电阻分压避免损坏引脚。6. 断线重连与心跳保活让ESP8266和Android端连接自愈6.1 心跳机制的设计频率和数据格式TCP连接在没有数据传输时路由器可能在一段时间后回收空闲连接导致ESP8266和Android端各自以为连接还在实际数据已经到达不了。常见做法是STM32定时发送心跳帧Android端收到后更新最后一次活跃时间。uint32_t last_heart_tick 0; void heart_check(void) { if (HAL_GetTick() - last_heart_tick 30000) { last_heart_tick HAL_GetTick(); send_frame(0x01, (uint8_t *){\type\:\heart\}, 17); } }心跳间隔30秒在局域网内足够占用的流量也很小。Android端用一个定时任务检查最后一次活跃时间超过60秒没收到任何数据就主动关闭Socket并重新连接。这个60秒的阈值要比心跳间隔大留出网络延迟和重传的时间余量。6.2 重连退避策略从5秒翻倍到60秒避免模块被打爆断线重连不能写成固定1秒重试一次否则多个设备同时掉线时ESP8266和手机端会不断尝试连接WiFi模块负载很高。指数退避是一个简单有效的方案。private void scheduleReconnect() { int delay 5000; while (!isConnected delay 60000) { try { Thread.sleep(delay); connect(serverIp, serverPort); delay * 2; } catch (InterruptedException e) { break; } } }首次重连等待5秒失败后依次等10秒、20秒、40秒最大60秒。连接恢复后isConnected变为true循环退出。STM32端位置同样处理发送数据前先用ATCIPSTATUS查询连接状态状态不是3时先执行ATCIPCLOSE再发ATCIPSTART否则直接发数据会返回ERROR。心跳间隔30秒、重连退避从5秒翻倍到60秒是局域网串口透传链路上一个比较稳妥的参数组合。本文还有配套的精品资源点击获取