简介:面向嵌入式初学者与物联网开发者,资源包基于意法半导体的STM32F103C8T6微控制器和ESP8266 WiFi模块,构成了一个以串口通讯控制无线模块的完整TCP服务器工程。它演示了如何通过AT指令集配置Wi-Fi模块、建立和关闭TCP连接,并配合STM32的两组串口,分别实现用户界面交互和与ESP8266的数据交换,让开发者直观理解串口通讯在微控制器与无线模块协作中的关键作用,对没有接触过无线模块的开发者而言,能明显降低上手门槛。压缩包共244个文件、7.16MB,包含C/H源码、Keil工程配置文件、编译中间文件以及PDF说明文档,其中标准外设库中USART、GPIO、定时器等驱动配置均可直接查阅。工程还给出了AT+CIPSTART建立连接、AT+CIPSEND发送数据、AT+CIPCLOSE关闭连接等常用指令的收发处理思路,并针对连接断开、重试等异常场景提供了状态处理逻辑;系统初始化、串口参数匹配、连接状态显示、数据收发处理等代码模块清晰可查,便于在此基础上扩展远程控制与数据采集功能。目前已有2955人学习浏览该资源,适合希望将STM32接入Wi-Fi网络并搭建TCP服务的小型项目实践者。 搞嵌入式这些年,一直在跟串口、WiFi、TCP这几个词打交道。最近给一套采集设备做远程参数配置,需求很直接:硬件端得能被手机和PC主动连上,下发配置、读取实时数据。选型的时候没多想,就是STM32F103C8T6加ESP8266,用AT指令把ESP8266配成TCP服务器。这套组合便宜、资料多、怎么折腾都不心疼。但真正把连接做稳、做到长时间不掉线,远不是敲几条AT指令那么简单。这篇文章把我完整的接线、指令时序、状态机代码和实际调试踩过的坑都整理出来,给正准备做同类项目的朋友一个参考。
1. 为什么选这套组合:STM32F103C8T6 + ESP8266的服务端角色
1.1 TCP服务器在这里解决的真实问题
很多初学者一听到"TCP服务器"就觉得高深,其实在这个项目里,它解决的就是一个非常朴素的痛点:设备端需要让别的设备主动来找它。手机App要连设备修改参数,上位机要连设备读取数据,如果设备只做客户端去连服务器,那就得依赖一台额外部署的服务器,局域网内反而绕远路。
让ESP8266工作在AP模式并开启TCP Server,手机直接连接设备发出的WiFi热点,然后通过TCP协议连接到指定端口。这样STM32就相当于拥有一个可以被外部主动访问的网络入口。整个链路是:
STM32F103C8T6 <----串口----> ESP8266 <----WiFi----> 手机/PCSTM32和ESP8266之间只走AT指令,ESP8266负责所有网络协议栈的处理,对STM32来说,TCP连接的建立、断开、数据收发,全部被抽象成一串串可读的文本指令。这也是这套方案的生命力所在——不依赖特定操作系统,不需要跑lwIP协议栈,裸机工程就能搞定。
1.2 方案边界:能用和不能用的场景
这套组合适合什么场景?我先说结论:适合并发量小、单包数据小、局域网内通信的场合。一个设备带两三个客户端,每个客户端几秒钟交互一次,传输几十到几百字节的数据,ESP8266完全扛得住,而且稳定性可以做得很好。
不适合的场景也很明显:如果要求高并发、大数据量持续传输、跨公网访问,那ESP8266就不太合适了。WiFi模块的缓冲区有限,串口波特率再高也受AT固件处理能力制约。实测下来,单包超过1KB就比较容易出现分包或者丢帧,需要自己处理组包逻辑。跨公网访问则需要云服务器中转或者内网穿透方案,这不是本文讨论的重点。
2. 硬件接线与固件准备:翻车率高发区
2.1 引脚分配与供电细节
先看硬件连接。我用的STM32F103C8T6最小系统板,板载串口一般是USART1(PA9/PA10),这个串口通常被USB转串口芯片占着,用来打印调试信息。所以和ESP8266通信我建议单独用一个串口,比如USART2(PA2/PA3)或USART3(PB10/PB11),避免调试信息和AT指令应答混在一起。
具体接线如下:
| STM32F103C8T6 | ESP8266模块 | 说明 |
|---|---|---|
| PA2 (USART2_TX) | RXD | STM32发送给ESP8266 |
| PA3 (USART2_RX) | TXD | ESP8266发送给STM32 |
| 3.3V | VCC | 给ESP8266供电 |
| GND | GND | 必须共地 |
| 3.3V | CH_PD (EN) | 使能引脚,拉高 |
| — | GPIO0 | 悬空即可,进入运行模式 |
注意:ESP8266工作时峰值电流能到300mA以上,甚至更高。STM32最小系统板上的板载LDO一般只有几十到一百多毫安的能力,直接给ESP8266供电,模块会不断重启。最稳妥的做法是外接一个AMS1117-3.3稳压芯片,或者用独立3.3V电源,并且把电源地和STM32的地连在一起。这个坑我一开始踩得很惨,后面在调试章节详细说。
还有一个容易忽略的点:ESP8266的TXD/RXD是3.3V电平,STM32F103C8T6的IO口也是3.3V,两者可以直接连接,不需要电平转换。但如果你用的是5V的单片机,那就必须加电平转换电路,否则很容易烧模块。
2.2 AT固件版本与初始化自检
拿到ESP8266模块第一步不是接单片机,而是先用USB转TTL模块单独测试一下,确认固件版本和指令集。我用的是安信可ESP-12F模块,出厂一般带AT固件。测试方式很简单:USB转TTL的TX接模块RX,RX接TX,共地,模块供电3.3V,打开串口助手,波特率设置115200。
输入AT回车,如果返回OK,说明固件正常。然后输入:
AT+GMR这个指令会返回固件版本号,建议用1.7.4以后的版本或者官方较新的AT固件。老版本固件对TCP服务器模式的支持有差异,有些指令参数格式都不一样。
单独测试还有一个好处,可以把常用的初始化指令事先跑通,确认模块在AP模式下的行为。比如设置AP参数、开启CIPSERVER,然后把测试结果记录下来,方便后面单片机端联调时对照。
3. TCP服务器AT指令时序:连接建立的关键过程
3.1 AP模式与STA模式的指令流差异
要让ESP8266变成TCP服务器,有两条路:一条是让模块发WiFi热点(AP模式),另一条是让模块连接家里路由器(STA模式),客户端通过路由器访问设备。两条路的指令差别主要在WiFi连接部分,TCP服务器部分是一样的。
AP模式完整指令流:
AT // 测试,返回OK ATE0 // 关闭回显,减少串口干扰 AT+CWMODE=2 // 设置AP模式,2表示仅AP AT+CWSAP="ESP_AP","12345678",1,3 // 配置热点名、密码、信道1、WPA2加密 AT+CIPMUX=1 // 开启多连接,这是CIPSERVER的前置条件 AT+CIPSERVER=1,8080 // 启动TCP服务器,端口8080 AT+CIFSR // 查询模块IP地址,AP模式下通常是192.168.4.1这里最关键的是AT+CIPMUX=1。很多人在这一步栽跟头:如果CIPMUX保持0(单连接模式),执行CIPSERVER会直接返回ERROR。因为TCP服务器天然要处理多个客户端连接,AT固件规定服务器模式必须工作在多连接模式下。
STA模式则是在CWMODE设置上不同:
AT+CWMODE=3 // AP+STA双模式,或者直接用AT+CWMODE=1 AT+CWJAP="你的路由器","密码"STA模式下,模块会从路由器获取IP,AT+CIFSR会返回一个局域网IP。客户端连接这个IP加端口号即可访问设备。这里容易遇到一个问题:如果路由器开启了AP隔离,那么连接WiFi的设备之间不能互相访问,即使在同一局域网也连不上。遇到这种情况,去路由器后台关掉AP隔离即可。
3.2 客户端连接后固件的行为
当客户端成功连接上TCP服务器时,ESP8266会主动向串口推送一条消息:
0,CONNECT含义是链路0(link id 0)与客户端建立了连接。如果再有第二个客户端连进来,就会看到1,CONNECT。每个客户端会被分配一个独立的链路号,范围是0到4,也就是说ESP8266在TCP服务器模式下最大支持5个客户端。
客户端发送数据过来时,ESP8266推送的格式是:
+IPD,0,5:hello拆开看:+IPD是固定头,0是链路号,5是数据长度,冒号后面跟着的hello就是实际数据。这个格式是AT固件的标准推送格式,STM32端解析数据帧就是围绕这个格式做文章。
任何时刻想确认当前有几个客户端在线,可以主动查询:
AT+CIPSTATUS返回的包里会列出每个链路的状态,比如:
+CIPSTATUS:0,1,0,"192.168.4.2",8080这里的字段含义是链路ID、连接状态(1表示已连接)、是否为远端关闭、远端IP、远端端口。这个查询指令在断线检测里非常有用,后面会详细说明用法。
3.3 发送数据的完整帧流程
ESP8266向指定客户端发送数据,不是直接把数据内容跟在指令后面就行,而是有固定流程:
AT+CIPSEND=0,5第一个参数是链路号,第二个参数是待发送数据的字节长度。此时ESP8266会返回一个>提示符,表示可以输入数据了。接下来输入5个字节的数据,比如hello,模块才会真正把数据发送出去,然后返回:
SEND OK如果发送失败,会返回SEND FAIL。这个细节在STM32端编程时非常关键,因为>符号出现之前,你发送的任何字节都不会被模块当成数据处理,而是被当成AT指令的一部分。所以完整的发送流程是一个"先请求、等提示、再喂数据"的三步交互过程。
4. STM32串口驱动与协议状态机:代码层面的核心设计
4.1 串口接收缓冲与数据帧识别
STM32通过串口接收ESP8266的数据,问题在于:ESP8266串口发过来的内容不是纯数据,而是AT指令应答、系统消息、网络数据混在一起的流。可能上一帧是OK,下一帧就是0,CONNECT,紧接着又出现+IPD,0,5:hello。所以单片机的串口接收不能简单地把字节存进缓冲区就完事,必须设计一个能识别帧边界、能提取有效负载的解析逻辑。
最基础的做法是用一个环形缓冲区加状态机。串口中断里只负责把字节丢进环形缓冲区,主循环里逐个取出字节,用状态机判断当前是在数据帧的头、长度、还是数据内容中。状态机的好处是不会因为一帧数据到达的时序发生错乱,只要状态转移设计正确,连续来多少帧都能稳定解析。
4.2 AT命令发送与应答的封装思路
发送AT指令时,我建议做一个简单的封装,避免在主循环里直接阻塞等待应答。思路是:定义一个"等待应答字符串"的全局标记,发送指令时记录当前指令ID,然后主循环中每收到一行字符串,就比较是否匹配期望的应答,比如OK、ERROR、SEND OK、发送提示符>等。
这种方式比阻塞延时稳定得多,因为AT指令的应答时间是动态的,网络状态差的时候,几十毫秒到几百毫秒都有可能。如果用HAL_Delay固定等200毫秒再判断,慢的时候读取不到应答,快的时候又浪费CPU时间。
4.3 关键代码骨架
我用HAL库写了一个简化的解析框架,核心思路是逐字节状态机解析+IPD帧,同时保留AT应答的检测:
typedef struct { uint8_t state; // 当前状态 uint8_t conn_id; // 解析出的链路号 uint16_t data_len; // 解析出的数据长度 uint16_t cnt; // 当前已接收数据计数 } ipd_parser_t; typedef enum { IDLE, // 空闲 MATCH_HEAD, // 匹配 "+IPD," GET_ID, // 读取链路号 GET_LEN, // 读取长度 WAIT_COLON, // 等待冒号 READ_DATA // 读取数据 } parser_state_t; void parse_byte(uint8_t c, ipd_parser_t *p) { switch (p->state) { case IDLE: { static uint8_t match_idx = 0; const char *head = "+IPD"; if (c == head[match_idx]) { match_idx++; if (match_idx == 4) { match_idx = 0; p->state = GET_ID; } } else { match_idx = 0; } break; } case GET_ID: if (c == ',') { p->state = GET_LEN; } else if (c >= '0' && c <= '9') { p->conn_id = c - '0'; } break; case GET_LEN: if (c == ':') { p->cnt = 0; p->state = READ_DATA; } else if (c >= '0' && c <= '9') { p->data_len = p->data_len * 10 + (c - '0'); } break; case READ_DATA: // 这里把收到的字节存到对应的应用缓冲区 app_recv_data_buf[p->cnt++] = c; if (p->cnt >= p->data_len) { // 一帧数据接收完成,交给业务逻辑处理 app_on_tcp_data(p->conn_id, app_recv_data_buf, p->cnt); p->data_len = 0; p->state = IDLE; } break; } }这个状态机的关键点在于完全靠字符驱动,不依赖固定延时,不受串口数据分包影响。即使一帧+IPD,0,10:helloworld被拆成两次串口中断到达,也能正确组帧。
对于AT指令行,我额外使用一个简单的行缓冲器,遇到\n时认为一行结束,然后把这一行与预设关键字比较:
void process_at_line(const char *line) { if (strstr(line, "OK")) { at_cmd_ack = ACK_OK; } else if (strstr(line, "ERROR")) { at_cmd_ack = ACK_ERROR; } else if (strstr(line, "SEND OK")) { at_cmd_ack = ACK_SEND_OK; } else if (strstr(line, "CONNECT")) { // 新客户端接入 app_on_client_connect(); } else if (strstr(line, "CLOSED")) { // 客户端断开 app_on_client_close(); } }在主循环中,发送AT指令的流程做成非阻塞状态机。先发指令字符串,然后等待at_cmd_ack被置位,配套一个超时计数器,超时未应答就报错重试。这样整个TCP通信链路跑起来后,主循环还能腾出时间处理其他业务逻辑。
5. 实际调试中踩过的坑:从现象到根因
5.1 WiFi模块反复重启,不是代码问题
项目刚开始调试时,WiFi模块每隔十几秒就重启一次,日志里偶发ready字样。一开始以为是代码问题,查了AT指令配置、查了状态机,都没有头绪。后来用万用表量了模块VCC引脚,发现电压在3.1V到3.4V之间剧烈波动,这才反应过来:供电跟不上。
改用外置AMS1117-3.3稳压芯片,输入5V,输出接ESP8266 VCC,并加了一个470uF电解电容和一个100nF陶瓷电容。模块瞬间安静了,连接也稳定了。这个坑真的非常经典,尤其是用最小系统板直接引3.3V给ESP8266供电的时候。
经验:遇到WiFi模块反复重启、配网失败、长时间运行后掉线,先测供电,再看程序。电源不稳会导致一切玄学问题。
5.2 +IPD数据被截断
数据量稍大时发现一个现象:ESP8266推送+IPD,0,20:...,但STM32这边收到的数据经常只有十几个字节,后面内容丢了。排查后定位到两个原因。
第一,串口接收缓冲区太小。我一开始用了一个64字节的数组,ESP8266在115200波特率下,每毫秒大约能传11.5字节,如果主循环处理不及时,还没来的及取走数据,新数据就把缓冲区冲掉了。后面把环形缓冲加大到512字节,问题缓解不少。
第二,没有考虑数据分片。TCP连接本质上是一个流式协议,客户端发送的20字节可能在底层被拆成两个TCP包到达ESP8266,模块会分别推送两条IPD帧,或者一条帧长度和实际长度不一致。所以解析+IPD时,不能假设冒号后面的数据一次性到齐,必须用状态机累积,收到len个字节才认为帧结束。
5.3 应答与数据混杂导致解析错乱
在TCP服务器模式下,AT指令的应答和网络数据是同一个串口返回的。比如客户端刚好在你发送AT+CIPSTATUS的时候推送数据过来,串口里就会同时出现+CIPSTATUS:...和+IPD,0,N:...。如果解析逻辑写得死,就会把IPD帧的前半段当成AT应答处理,导致数据错位。
解决办法是为AT指令发送和IPD数据解析各设独立状态机。AT行解析器不管IPD帧,IPD解析器也不管普通命令行。识别入口就是看一行开头的头几个字节是不是+IPD,如果是则把这个状态机待命,进入IPD解析模式,在数据读取完成前,所有字节都交给IPD解析器,不参与AT行匹配。这样两条解析链路互不干扰,哪怕同时到来也能正确分流。
5.4 客户端掉线后服务器继续保持连接
手机或者上位机程序突然退出时,TCP连接并不会立刻在ESP8266侧消失。如果客户端断开时没有正常发送FIN包,或者网络链路异常,ESP8266侧会一直维持着这个连接,直到底层TCP超时。这会导致什么问题?一个客户端反复重连几次后,链路号被占满,新的客户端再也连不进来。
应对策略是在应用层主动检测。我的做法是:服务端每隔一定时间(比如3秒)发送一个应用层心跳包,同时维护一个最后收到客户端数据的时间戳。如果连续几个心跳周期都没有收到任何来自该链路的数据,就主动通过AT+CIPCLOSE=0关闭这条链路,释放连接资源。
AT+CIPCLOSE=0 // 关闭链路0实测下来,配合心跳机制,断线后的链路回收从原来的十几分钟缩短到了几秒钟。这个机制在嵌入式TCP通信中几乎属于标配,属于没有硬件TCP keepalive控制权时的最朴素方案。
6. 让服务器连接更可靠的几条经验
6.1 应用层心跳是保命手段
ESP8266的AT固件本身对TCP keepalive的控制很弱,你很难通过AT指令设置底层保活探测周期。所以在设计整个通信协议时,一定要在应用层加入自定义心跳。心跳包尽量做小,比如固定4字节:帧头、命令字、序号、校验。客户端收到心跳后原样回复,服务端来判断链路活性。
如果两端都是自己做,协议约定好就行。如果客户端是第三方工具或者现成App,没法定制心跳,那就退而求其次,定期用AT+CIPSTATUS轮询连接状态,发现断开的链路就用AT+CIPCLOSE主动回收。
6.2 上位机或App端的注意事项
TCP通信的问题往往出在两端。客户端如果不是自己写的,先确认连接时使用的目的IP。AP模式下,ESP8266发出来的热点通常子网是192.168.4.1,手机连上热点后,设备地址是固定的192.168.4.1,端口就是你设置的8080。用Socket工具测试时,很多朋友会把IP填成手机自己获取的IP,那就永远连不上服务器。
另外上位机收数据时,要注意粘包和半包问题。TCP是字节流,不是消息边界,一帧+IPD,0,5:hello解析出的5字节,在客户端那边可能和其他数据一起到达。我自己用C#写上位机测试时,就专门写了一个基于循环缓冲区的组帧函数,每次收到网络流先入缓冲,再按帧头帧尾提取完整帧。
6.3 从单链路到多链路的扩展思路
前面提到ESP8266服务器模式最多支持5个连接。在实际业务中,一般会有一两个固定上位机长期保持连接,另外几个是手机临时连接。设计时建议给每条链路一个状态表:
| 链路ID | 客户端IP | 是否在传输 | 最后活跃时间 | 应用状态 |
|---|---|---|---|---|
| 0 | 192.168.4.2 | 否 | 12:01:33 | 已认证 |
| 1 | — | 否 | 12:03:10 | 空闲 |
这样在多客户端场景下,STM32就能知道数据应该回给谁,哪个链路超时了需要清理。如果业务逻辑简单,也可以约定固定链路号对应的角色,比如链路0只给上位机用,链路1到4给临时手机连接。这样省去了动态分配和身份识别的复杂度,对于单片机裸机程序来说尤其合适。
这套方案我已经在几个正式项目里跑过了,包括小型环境监测设备和简易门禁控制系统。整体体验是:STM32F103C8T6的资源足够跑AT指令解析和TCP业务逻辑,ESP8266在局域网环境下做TCP服务器完全够用,关键是要把串口处理、状态机设计、掉线检测这三个环节做扎实。如果你也在做类似项目,建议先在电脑上把AT指令流程完整走一遍,再开始写单片机代码,所有的坑在串口助手阶段就能提前暴露一半。后面想继续深入,可以考虑用定时器加DMA方式接收串口数据,进一步降低CPU占用,也可以在应用层基础上扩展简单的帧校验和分包重传,把嵌入式TCP通信做得更稳定。
本文还有配套的精品资源,点击获取