ESP32-S3 是一款集成了 Wi-Fi 和蓝牙功能的微控制器,而 lwIP(轻量级 IP)是一个为嵌入式系统设计的开源 TCP/IP 协议栈。通过使用 lwIP 库, ESP32-S3 可以实现与外部网络的通信,包括发送和接收数据包、处理网络连接等。因此, ESP32-S3 是基于 lwIP 来实现网络功能的。
TCP/IP 协议栈
TCP/IP 协议栈是一系列网络协议的总和,构成网络通信的核心骨架,定义了电子设备如何连入因特网以及数据如何在它们之间进行传输。该协议采用 4 层结构,分别是应用层、传输层、网络层和网络接口层, 每一层都使用下一层提供的协议来完成自己的需求。大部分时间都在应用层工作,下层的事情不用操心。此外,网络协议体系本身很复杂庞大,入门门槛高,因此很难搞清楚 TCP/IP 的工作原理。如果读者想深入了解 TCP/IP 协议栈的工作原理,建议阅读《计算机网络书籍》。
TCP/IP 协议栈架构
TCP/IP 协议栈是一个分层结构的模型,每一层负责不同的网络功能。整个协议栈可以被分为四层,从上到下分别是:应用层、传输层、网络层和网络接口层。
1.应用层:这是最顶层,负责处理特定的应用程序细节。在这一层,用户的数据被处理和解释。一些常见的应用层协议包括 HTTP、 FTP、 SMTP 和 DNS 等。
2.传输层:这一层负责数据包的分割、打包以及传输控制,确保数据能够可靠、有序地到达目的地。主要的传输层协议有 TCP 和 UDP。
3.网络层:负责确定数据包的路径从源到目的地。这一层的主要协议是 IP(InternetProtocol),它负责在主机之间发送和接收数据包。
4.网络接口层:这是最底层,负责将数据转换为可以在物理媒介上发送的信号。这一层的协议涉及到如何将数据帧封装在数据链路层,以便在网络上进行传输。
每一层都使用下一层提供的服务,同时对上一层提供服务。这种分层结构使得协议栈更加灵活,易于扩展和维护。不同层次上的协议一起工作,协调数据在计算机网络中的传输,使得不同的计算机能够相互通信。需要注意的是, TCP/IP 协议栈和传统的 OSI 模型并不完全对应。 TCP/IP 协议栈是一个简化的模型,强调了实际的协议实现和因特网的实际运作方式。相比之下, OSI 模型更加全面和理想化,它提供了一个框架来描述不同系统之间的交互方式。下图是 IOS 协议栈与 TCP/IP 协议栈分层架构的对比图。
ISO/OSI 分层模型也是一个分层结构,包括七个层次,从上到下分别是:应用层、表示层、会话层、传输层、网络层、数据链路层和物理层。虽然 ISO/OSI 模型为不同的系统之间的通信提供了一个理论框架,但 TCP/IP 协议栈更侧重于实际的协议实现和因特网的实际运作方式。注意:网络技术的发展并不是遵循严格的 ISO/OSI 分层概念。实际上现在的互联网使用的是TCP/IP 体系结构,但某些应用程序可以直接使用 IP层,或甚至直接使用最下面的网络接口层。
无论哪种表示方法, TCP/IP 模型各个层次都分别对应于不同的协议。 TCP/IP 协议栈负责确保网络设备之间能够通信。它是一组规则,规定了信息如何在网络中传输。其中,这些协议都分布在应用层,传输层和网络层,网络接口层是由硬件来实现。如 Windows 操作系统包含了CBISC 协议栈,该协议栈就是实现了 TCP/IP 协议栈的应用层,传输层和网络层的功能,网络接口层由网卡实现,所以 CBISC 协议栈和网卡构建了网络通信的核心骨架。因此,无论哪一款以太网产品,都必须符合 TCP/IP 体系结构,才能实现网络通信。注意:路由器和交换机等相关网络设备只实现网络层和网络接口层的功能。
TCP/IP 协议栈的封包和拆包
TCP/IP 协议栈的封包和拆包是指在网络通信中,将数据按照一定的协议和格式进行封装和解析的过程。在 TCP/IP 协议栈中,数据封装是指在发送端将数据按照协议规定的格式进行打包,以便在网络中进行传输。在应用层的数据被封装后,会经过传输层、网络层和网络接口层的处理,最终转换成可以在物理网络上传输的帧格式。数据封装的过程涉及到对数据的分段、压缩、加密等操作,以确保数据能够可靠、安全地传输到目的地,下图描述的是封包处理流程。
数据拆包是指接收端收到数据后,按照协议规定的格式对数据进行解析和处理,还原出原始的数据。在接收到数据后,接收端会按照协议规定的层次从下往上逐层处理数据,最终将应用层的数据还原出来。数据拆包的过程涉及到对数据的重组、解压缩、解密等操作,以确保数据能够被正确地解析和处理,下图描述的是拆包处理流程。
需要注意的是, TCP/IP 协议栈的封包和拆包过程涉及到多个层次和协议的处理,需要按照协议规定的格式和顺序进行操作。在实际应用中,需要根据具体的情况选择合适的协议和格式来满足不同的需求。同时,为了保证数据的安全和可靠性,还需要采取相应的加密、压缩等措施,以避免数据被篡改或损坏。
lwIP 简介
lwIP,全称为 Lightweight IP协议,是一种专为嵌入式系统设计的轻量级 TCP/IP协议栈。它可以在无操作系统或带操作系统环境下运行,支持多线程或无线程,适用于 8 位和 32 位微处理器,同时兼容大端和小端系统。它的设计核心理念在于保持 TCP/IP 协议的主要功能同时尽量减少对 RAM 的占用。这意味着,尽管它的体积小巧, 但它能够实现完整的 TCP/IP 通信功能。通常, lwIP 只需十几 KB 的 RAM 和大约 40K 的 ROM 即可运行,使其成为资源受限的嵌入式系统的理想选择。 lwIP 的灵活性使其既可以在无操作系统环境下工作,也可以与各种操作系统配合使用。这为开发者提供了更大的自由度,可以根据具体的应用需求和硬件配置进行优化。无论是在云台接入、无线网关、远程模块还是工控控制器等场景中, lwIP 都能提供强大的网络支持。
lwIP 特性参数
lwIP 的各项特性,如下表所示:
lwIP 与 TCP/IP 体系结构的对应关系
从上图可以清晰地看到, lwIP 软件库主要实现了 TCP/IP 体系结构中的三个层次:应用层、传输层和网络层。这些层次共同处理和传输数据包,确保了数据在网络中的可靠传输。然而,网络接口层作为 TCP/IP 协议栈的最底层,其功能并无法通过软件方式完全实现。这一层的主要任务是将数据包转换为光电模拟信号,以便能够在物理媒介上传输。这个过程涉及到与硬件的直接交互,包括数据的调制解调、信号的转换等,这些都是软件难以模拟或实现的。因此,虽然 lwIP 软件库没有实现网络接口层的功能,但通过与底层硬件的紧密配合,它仍然能够提供完整且高效的 TCP/IP 通信功能。这也使得 lwIP 成为一个适用于资源受限的嵌入式系统的理想选择。在开发过程中,开发者需要根据具体的硬件平台和需求进行相应的配置和优化,以确保lwIP 能够与硬件完美配合,发挥出最佳的性能。
WiFi MAC 内核简介
ESP32-S3 完全遵循 802.11b/g/n Wi-Fi MAC 协议栈,支持分布式控制功能(DCF)下的基本服务集(BSS) STA 和 SoftAP 操作。支持通过最小化主机交互来优化有效工作时长,以实现功耗管理。 ESP32-S3 Wi-Fi MAC 支持 4 个虚拟 Wi-Fi 接口,同时支持基础结构型网络、 SoftAP 模式和 Station+SoftAP 混杂模式。它还具备 RTS 保护、 CTS 保护、立即块确认、分片和重组、TX/RX A-MPDU和TX/RX A-MSDU等高级功能。此外, ESP32-S3还支持无线多媒体、 GCMP、CCMP、 TKIP、 WAPI 等安全协议,并提供自动 Beacon 监测和 802.11mc FTM 支持。
关于 ESP32 的 WiFi MAC 内核,官方没有提供更多学习资料。读者只需了解它扮演 TCP/IP协议的网络接口层角色即可。使用一张示意图来介绍 WiFi 通讯示意图,如下图所示。
从上图可以看出, ESP32-S3 芯片内置 WiFi MAC 内核。当我们发送数据到网络时,数据首先被转化为无线信号,然后发送到该设备连接的 WiFi 路由器中。接着,路由器通过网线将数据传输到目标主机,从而完成数据传输操作。以下是作者对于无线网络传输的描述。
1.数据转化为无线信号:当 ESP32-S3 想要发送数据到网络时,它首先会将数据封装到一个无线传输帧中。这一过程涉及到将数据转化为可以在无线介质上传输的格式。
2.发送到 WiFi 路由器:封装后的无线信号然后被发送到 ESP32-S3 连接的 WiFi 路由器。WiFi 路由器充当一个中间设备,负责将无线信号转换为有线网络信号(如果目标主机是通过有线网络连接的)或直接转发无线信号(如果目标主机也是通过 WiFi 连接的)。
3.路由器传输数据: WiFi 路由器接收到无线信号后,会进一步处理它。如果目标主机是通过有线网络连接的,路由器会将无线信号转换为有线网络信号,并通过网线将其传输到目标主机。如果目标主机也是通过 WiFi 连接的,路由器会直接转发无线信号到目标主机。
4.完成数据传输:最终,目标主机接收到路由器发送的有线网络信号或无线信号,并将其解析为原始数据。这样,整个数据传输过程就完成了。
在整个过程中, ESP32-S3 的 WiFi MAC 内核起着核心的作用,它负责管理无线连接、封装和解封装数据以及与 WiFi 路由器进行通信。
lwIP Socket 编程接口
lwIP 作者为了方便开发者将其他平台上的网络应用程序移植到 lwIP 上,并让更多开发者快速上手 lwIP,作者设计了三种应用程序编程接口: RAW 编程接口、 NETCONN 编程接口和Socket 编程接口。然而,由于 RAW 编程接口只能在无操作系统环境下运行,因此对于内嵌FreeRTOS 操作系统的 ESP32 来说,无法使用这个编程接口。尽管 Socket 编程接口是由NETCONN 编程接口封装而成,但是该接口非常简易的实现网络连接(作者推荐使用此接口)。需要注意的是,由于受到嵌入式处理器资源和性能的限制,部分 Socket 接口并未在 lwIP 中完全实现。因此,为了实现网络连接,推荐使用 Socket API。下面作者简单介绍一下 lwIP Socket 编程接口常用的 API 函数。这些 API 函数如下所示。
socket 函数
该函数的原型,如下源码所示:
#define socket(domain,type,protocol) lwip_socket(domain,type,protocol)
向内核申请一个套接字,本质上该函数调用了函数 lwip_socket,该函数的参数如下表所示:
bind 函数
该函数的原型,如下源码所示:
#define bind(s,name,namelen) lwip_bind(s,name,namelen)
int bind(int s, const struct sockaddr *name, socklen_t namelen)
该函数与 netconn_bind 函数一样,用于服务器端绑定套接字与网卡信息,本质上就是对函数 netconn_bind 再一次封装,从上述源码可以知道参数 name 指向一个 sockaddr 结构体,它包含了本地 IP 地址和端口号等信息;参数 namelen 指出结构体的长度。结构体 sockaddr 定义如下源码所示:
struct sockaddr {
u8_t sa_len; /* 长度 */
sa_family_t sa_family; /* 协议簇 */
char sa_data[14]; /* 连续的 14 字节信息 */
};
struct sockaddr_in {
u8_t sin_len; /* 长度 */
u8_t sin_family; /* 协议簇 */
u16_t sin_port; /* 端口号 */
struct in_addr sin_addr; /* IP 地址 */
char sin_zero[8];
};
可以看出, lwIP 作者定义了两个结构体,结构体 sockaddr 中的 sa_family 指向该套接字所使用的协议簇,本地IP地址和端口号等信息在sa_data数组里面定义,这里暂未用到。由于sa_data以连续空间的方式存在,所以用户要填写其中的 IP 字段和端口 port 字段,这样会比较麻烦,因此 lwIP 定义了另一个结构体 sockaddr_in,它与 sockaddr 结构对等,只是从中抽出 IP 地址和端口号 port,方便于用于的编程操作。
connect 函数
该函数与 netconn 接口的 netconn_connect 函数作用是一样的,因此它是被 netconn_connect函数封装了,该函数的作用是将 Socket 与远程 IP 地址和端口号绑定,如果开发板作为客户端,通常使用这个函数来绑定服务器的 IP 地址和端口号,对于 TCP 连接,调用这个函数会使客户端与服务器之间发生连接握手过程,并建立稳定的连接;如果是 UDP 连接,该函数调用不会有任何数据包被发送,只是在连接结构中记录下服务器的地址信息。当调用成功时,函数返回 0;否则返回-1。该函数的原型如下源码所示:
#define connect(s,name,namelen) lwip_connect(s,name,namelen)
int lwip_connect(int s, const struct sockaddr *name, socklen_t namelen);
listen 函数
该函数和 netconn 的函数 netconn_listen 作用一样,它是由函数 netconn_listen 封装得来的,内核同时接收到多个连接请求时,需要对这些请求进行排队处理,参数 backlog 指明了该套接字
上连接请求队列的最大长度。当调用成功时,函数返回 0;否则返回-1。该函数的原型如下源码所示:
#define listen(s,backlog) lwip_listen(s,backlog)
int lwip_listen(int s, int backlog);
注意: 该函数作用于 TCP 服务器程序。
accept 函数
该函数与 netconn_accept作用是一样的,当接收到新连接后,连接另一端(客户端)的地址信息会被填入到地址结构 addr 中,而对应地址信息的长度被记录到 addrlen 中。函数返回新连接的套接字描述符,若调用失败,函数返回-1。该函数的原型如下源码所示:
#define accept(s,addr,addrlen) lwip_accept(s,addr,addrlen)
int lwip_accept(int s, struct sockaddr *addr, socklen_t *addrlen);
注意: 该函数作用于 TCP 服务器程序。
send()/sendto()函数
该函数是被 netconn_send 封装的,其作用是向另一端发送 UDP 报文,这两个函数的原型如下源码所示:
#define send(s,dataptr,size,flags) lwip_send(s,dataptr,size,flags)
#define sendto(s,dataptr,size,flags,to,tolen) lwip_sendto(s,dataptr,size,flags,to,tolen)
ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags);
ssize_t lwip_sendto(int s, const void *dataptr, size_t size, int flags, const struct sockaddr *to, socklen_t tolen);
可以看出,函数 sendto 比函数 send 多了两个参数,该函数如下表所示:
参数 | 描述 |
s | Socket 接口 |
dataptr | 发送数据的起始地址 |
size | 长度 |
flags | 数据发送时的特殊处理,例如带外数据、紧急数据等,通常设置为 0 |
to(sendto()) | 目的地址信息 |
tolen(sendto()) | 信息的长度 |
write 函数
该函数用于在一条已经建立的连接上发送数据,通常使用在 TCP程序中,但在 UDP程序中也能使用。该函数本质上是基于前面介绍的 send 函数来实现的,其参数的意义与 send 也相同。当函数调用成功时,返回成功发送的字节数;否则返回-1。
read()/recv()/recvfrom()函数
函数 recvfrom和 recv用来从一个套接字中接收数据,该函数可以在 UDP 程序使用,也可在TCP 程序中使用。该函数本质上是被函数 netconn_recv 的封装,其参数与函数 sendto 的参数完全相似,如下表所示,数据发送方的地址信息会被填写到 from 中, fromlen 指明了缓存 from 的长度; mem 和 len 分别记录了接收数据的缓存起始地址和缓存长度, flags 指明用户控制接收的方式,通常设置为 0。两个函数的原型如下源码所示:
#define recv(s,mem,len,flags) lwip_recv(s,mem,len,flags)
#define recvfrom(s,mem,len,flags,from,fromlen) lwip_recvfrom(s,mem,len,flags,from,fromlen)
ssize_t lwip_readv(int s, const struct iovec *iov, int iovcnt);
ssize_t lwip_recvfrom(int s, void *mem, size_t len, int flags, struct sockaddr *from, socklen_t *fromlen);
#define read(s,mem,len) lwip_read(s,mem,len)
ssize_t lwip_read(int s, void *mem, size_t len);
参数 | 描述 |
s | Socket 接口 |
mem | 接收数据的缓存起始地址 |
len | 缓存长度 |
flags | 用户控制接收的方式,通常设置为 0 |
from(recvfrom()) | 发送方的地址信息 |
fromlen(recvfrom()) | 缓存 from 的长度 |
close 函数
函数 close 作用是关闭套接字,对应的套接字描述符不再有效,与描述符对应的内核结构lwip_socket也将被全部复位。该函数本质上是被 netconn_delete的封装,对于 TCP连接来说,该函数将导致断开握手过程的发生。若调用成功,该函数返回 0;否则返回-1。该函数的原型如下源码所示:
#define close(s) lwip_close(s)
int lwip_close(int s);