ESP-32 TCP裸金属HTTP服务器
这篇文章将讨论如何在ESP-32上借助ESP-IDF使用低层TCP/IP套接字来搭建支持静态或固定长度内容的HTTP服务器,而不使用HTTP库。本文还会讨论lwIP如何send、shutdown和close一个TCP/IP网络套接字。
ESP-32, ESP-IDF, TCP/IP, HTTP服务器, lwIP, network socket, 网络套接字, send, shutdown, close
--by Captdam @ Sep 7, 2026Index
这个项目中,我将会使用ESP-32来搭建一个简单的HTTP服务器用于提供静态HTML网页和固定长度二进制文件(例如传感器读数)。这个项目将使用C语言开发,并使用官方ESP-IDF框架坂本6.0.2。
阅读这篇博客需要最基础的HTTP知识:
-
HTTP基于TCP/IP。
-
HTTP是基于文本的。
-
HTTP由数行文字的HTTP头、一列空行、内容构成。
阅读这篇博客需要最基础的TCP知识:
-
TCP是连接性的。
-
TCP包含的主要状态有:
LISTEN、ESTABLISHED、FIN-WAIT、CLOSED。 -
TCP头包含大量数字用于追踪状态、重传、流量控制,例如
ACK、SEQ、WIN和状态位,例如ACK、RST、FIN。
我们将从最弱智的写法开始,一步一步完善我们的代码,并通过分析底层lwIP库的源代码探讨其原由。
上层ESP-IDF HTTP/HTTPS库对比下层TCP/IP库
上层ESP-IDF HTTP/HTTPS库
ESP-IDF框架带有用于HTTP与HTTPS服务器的库。但是,我并不喜欢这个库的工作方式。
在ESP-IDF中,我将需要向库注册一些处理程序(handler):
httpd_register_uri_handler(server, &handler);
该处的处理程序包含URL与如何回复该请求:
static const httpd_uri_t ctrl = {
.uri = "/index.html",
.method = HTTP_GET,
.handler = respond_function,
.user_ctx = NULL
};
static esp_err_t respond_function(httpd_req_t *req) {
httpd_resp_set_type(req, "text/html");
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
httpd_resp_send_chunk(req, payload, len);
httpd_resp_send(req, payload, strlen(payload));
return ESP_OK;
}
也就是说,这个库将(以一定花销)维护一份可以响应的URL的清单。我认为该库的作者想要模仿在一台真正的HTTP服务器上(我指像是Linux上Apache那样的),系统将会查找磁盘上的文件,而这里说的清单就是文件列表。
下层TCP/IP库
此外:
-
我只需要最简单的HTTP功能:
-
GET
-
-
和最短的头:
-
HTTP/1.0 200 OK或是HTTP/1.0 404 Not Found -
Content-Length: sizeof(payload) -
Content-Type: text/html; charset=utf-8或是Content-Type: application/octet-stream
-
-
也不需要TLS:
-
该设备只在内网工作。
-
加密与解密带来额外系统负担。
-
我们都知道,HTTP是一种基于TCP/IP的文本通讯协议。创建HTTP请求或回复也很简单。我们只需要一个基于文本的头,一行空行,再加上HTML或二进制内容就可以了。因此,如果能使用轻量级的TCP/IP(socket)库和几行代码就解决的问题的话,我们没有理由使用使用繁重的HTTP或HTTPS库。杀鸡焉用斩牛刀。
下面展示了HTML网页和二进制文件的HTTP回复示例:
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 82
<!DOCTYPE html><html><head><title>123</title></head><body><p>456</p></body></html>
HTTP/1.0 200 OK
Content-Type: application/octet-stream
Content-Length: 8
ABCDEFGH
我们可以这样来来创建它们:
const char html_index[] = "<!DOCTYPE html><html><head><title>123</title></head><body><p>456</p></body></html>";
char tcp_buffer[4096];
int sock = accept(tcp_sock, NULL, NULL);
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n%s", sizeof(html_index), html_index);
send(sock, tcp_buffer, wp, 0);
close(sock);
const char file_bin[8] = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'};
char tcp_buffer[4096];
int sock = accept(tcp_sock, NULL, NULL);
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type:Content-Type: application/octet-stream\r\nContent-Length: %d\r\n\r\n%s", sizeof(file_bin), file_bin);
memcpy(tcp_buffer + wp, file_bin, sizeof(file_bin));
send(sock, tcp_buffer, wp + sizeof(file_bin), 0);
close(sock);
上面的代码只是为了展示如何通过socket来发送数据,并不适用于生产环境。其中,sprintf(dest, "format string %s", str)函数家族在复制文本时效率低下,不仅需要解析format格式字符串,还要在将字符串从源复制到目标时检测零结束符。
这里我使用sizeof()而不是strlen()因为sizeof()可以被用于string[](包括二进制字符串)且在编译时就能得到长度。智能的编译器甚至可以将sprintf(dest, "format string %d", sizeof(...))直接替换为memcpy(dest, "format string 1234", 19),因为所有需要的条件都在编译时可知了。
但是,sizeof()用于得到该变量的长度,而不是实际数据的长度。因此,不能给指针使用。当实际数据比缓冲小时,得到的长度也比实际数据的长度要大。对于常量字符串const char index_html[] = "Something...",sizeof(index_html)会得到实际数据的长度+1(零结束符)。
使用ESP-IDF在ESP-32上创建TCP/IP服务器
启动TCP/IP服务器
在app_main()函数中执行下列代码以启动TCP/IP服务器:
struct sockaddr_storage dest_addr;
struct sockaddr_in *dest_addr_ip4 = (struct sockaddr_in *)&dest_addr;
dest_addr_ip4->sin_addr.s_addr = htonl(INADDR_ANY);
dest_addr_ip4->sin_family = AF_INET;
dest_addr_ip4->sin_port = htons(80);
tcp_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
if (tcp_sock < 0) {
ESP_LOGE("[TCP]", "Unable to create socket: errno %d", errno);
return;
}
int opt = 1;
setsockopt(tcp_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
ESP_LOGI("[TCP]", "Socket created fd=%d", tcp_sock);
int err = bind(tcp_sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (err != 0) {
ESP_LOGE("[TCP]", "Socket unable to bind: errno %d", errno);
ESP_LOGE("[TCP]", "IPPROTO: %d", AF_INET);
close(tcp_sock);
return;
}
ESP_LOGI("[TCP]", "Socket bound");
err = listen(tcp_sock, 1);
if (err != 0) {
ESP_LOGE("[TCP]", "Error occurred during listen: errno %d", errno);
close(tcp_sock);
return;
}
xTaskCreate(tcp_main, "TCP", 4096, NULL, 5, NULL);
ESP_LOGI("[TCP]", "Socket ready");
在app_main()函数中的上述的代码将在芯片初始化后执行,并只会执行一遍。当完成后,app_main()函数亦会返回,并释放该函数占用的空间(RAM与闪存缓存):
-
RAM包含了函数的栈。
-
该函数在闪存中执行,也就是CPU的外部。当执行时,闪存中包含代码的部分必须先被缓存到一块小的内部RAM中才能被CPU读取指令。当这个函数不再被执行时,该RAM就可以用于缓存其它闪存内容。
在上面的代码中,我们做了:
-
启动并绑定流套接字(TCP/IP)到端口80。
-
开始监听该端口收到的请求。
-
创建一个RTOS任务
tcp_main。这类似于在POSIX上创建一个线程专用于处理TCP/IP通讯。
响应TCP/IP连接
接下来,创建一个函数tcp_main()用于处理TCP/IP通讯,和一个缓冲char tcp_buffer[4096]:
DRAM_ATTR int tcp_sock;
DRAM_ATTR char tcp_buffer[4096];
IRAM_ATTR void tcp_main(void* param) { while (1) {
int sock = accept(tcp_sock, NULL, NULL);
// int recvlen = recv(sock, tcp_buffer, sizeof(tcp_buffer), 0); // Optionally read the request
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n%s", sizeof(html_index), html_index);
send(sock, tcp_buffer, wp, 0);
close(sock);
} }
在该函数中,我们:
-
等待一个新的请求。
-
读取该请求,即将数据从内核空间(socket缓冲)复制到用户空间(我们的
char tcp_buffer[])。 -
将响应打印到用户空间的缓冲。
-
发送响应,即将数据从用户空间提交给内核空间。
-
关闭这个连接。
-
回到步骤一,等待下一个请求。
注意这里函数tcp_main()与缓冲char tcp_buffer[]分别添加了IRAM_ATTR与DRAM_ATTR属性。这保证他们将被加载到RAM中而不是闪存或外部SPI RAM以避免缓存缺失带来的停顿。因为该函数和缓存被频繁使用,因此需要避免停顿。
这里只展示tcp_main()函数的示例。不要在生产环境中这么使用。
发送HTTP响应
HTTP格式
上面的例子已经展示了,要发送一个HTTP响应,我们先发送头,然后是内容。
头
头的第一行总是HTTP/1.0 200 OK,表示服务器可以提供请求的资源。
对于文本类的响应(HTML),第二行为Content-Type: text/html; charset=utf-8,即表示响应主体为使用utf-8编码的HTML网页文本。对于二进制响应,我们可以使用Content-Type: application/octet-stream,即表示响应主体为自定义的二进制字符串。
第三行就是重点了,它用于表示响应主体(HTML或二进制文件)的长度。因为我们的响应是静态网页和固定长度的二进制文件,我们可以使用sizeof(index_html)来得到长度(单位:字节)。
以上三行构成最简单的HTTP头。
HTML主体
头之后是一行空行,再往后就是我们的HTML内容了。
因为是静态HTML网页,我们可以使用常量字符串来保存它。
创建一个新的文件index.html,并填入下面的示例HTML代码。接下来,使用#include "index.html"来包含这个文件:
#define MLSTR(...) #__VA_ARGS__
const char html_index[] = MLSTR(<!DOCTYPE html>
<html><head>
<title>123</title>
</head><body>
<p>456</p>
</body></html>);
#undef MLSTR
使用这个方法可以让字符串被大多数IDE以HTML的语法高亮,但是兼容C编译器。对于开发来说很是方便。
一次发送HTTP头与内容
我们将需要先在缓冲中创建完整的HTTP响应,然后再发送该缓冲。这里假设缓冲足够大以容纳头与内容。
等待客户端请求:
int sock = accept(tcp_sock, NULL, NULL);
首先,在缓冲内填充HTTP头与HTML内容。
使用sprintf()函数来将头写入缓冲:
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n", sizeof(html_index));
HTTP使用\r\n作为下一行。
要在头之后的地方写入HTML内容,将HTML内容复制到缓冲偏移我们刚才写入的数据量的地方:
memcpy(tcp_buffer + wp, html_index, sizeof(html_index));
对于长度已知的字符串,内存复制memcpy(dest, src, len)比字符串打印sprintf(dest, "%s", src)要快。通常来说,它可以使用多字节复制,或是(在内容切换时)使用DMA在后台复制,并可以用于非文本数据。
对于二进制文件:
memcpy(tcp_buffer + wp, file_bin, sizeof(file_bin));
现在,发送:
send(sock, tcp_buffer, wp + sizeof(html_index), 0);
最后,关闭连接:
close(sock);
使用电脑上的浏览器来请求ESP-32上该资源,得到如下截图:
使用Wireshark来查看该TCP/IP交互:
- PC开始TCP/IP连接。
- 服务器执行
int sock = accept(tcp_sock, NULL, NULL)接受TCP/IP连接。 - PC确认连接。
- PC发送HTTP请求。
- 服务器发送HTTP响应。
- 服务器执行
close(sock)重置TCP/IP连接。目前看起来稳如老狗。
先发送HTTP头然后发送内容
哎,我觉得把HTML内容要复制进缓冲非常不优雅。能不能直接用send()函数发送HTML内容呀?
当然可以!
但是我们还得先发送HTTP头。我能不能分别发送HTTP头和内容呀?
当然可以!TCP/IP是一种流式协议,把数据分一次或多次发送本质上并没有区别。只要在close()关闭套接字前把所有数据按顺序全部发送就OK。
等待客户端请求:
int sock = accept(tcp_sock, NULL, NULL);
发送HTTP头:
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n", sizeof(html_index));
send(sock, tcp_buffer, wp, 0);
然后,发送HTML内容:
send(sock, html_index, sizeof(html_index), 0);
最后,关闭连接:
close(sock);
使用电脑上的浏览器来请求ESP-32上该资源,得到如下截图:
使用Wireshark来查看该TCP/IP交互:
- PC开始TCP/IP连接。
- 服务器执行
accept()接受TCP/IP连接。 - PC确认连接。
- PC发送HTTP请求。
- 服务器发送HTTP头。此时HTML内容还没被
send()发送。 - 在HTML内容尚未发送时,服务器执行
close()重置TCP/IP连接。因为在连接关闭前只有部分HTTP响应抵达客户端浏览器,所以浏览器显示“连接被重置”。
连接关闭
该错误是由有连接在完整的HTTP响应发送前就被服务器关闭(重置)了。
浏览器需要接收完整的HTTP响应。也就是说,包含头与头中Content-Length所指定长度的内容。但是,只有部分响应被收到。因此,浏览器警告我们“连接被重置”。
如果我们回到上一个例子中,我们一次性发送了HTTP头和内容,我们可以看到连接虽然被重置了,但是,所幸完整的响应都在连接关闭前发送了。
连接重置代表了异常情况。在该例子中,服务器强制关闭了(RST)TCP/IP连接。因此,Wireshark将第69帧涂红高亮,告诉我们该帧包含错误。相对的,正常情况下连接应该被使用FIN逐步关闭。
虽然TCP/IP连接出错了,但是在第一个例子中,浏览器确实收到了足够的数据以尽可能显示该网页。因此,就没有“连接被重置”的警告。相反,在第二个例子中,浏览器只收到部分所需的数据,神仙也救不了,只能报错。
目前尚不清楚为什么第一个send()能被满足,而第二个send()则不能,即使close()是在第二个send()后才被服务端执行的。
继续阅读思考答案,或者点这里偷看答案抄作业
因为不先shutdown(sock, SHUT_WR)而是直接close()将导致TCP/IP线程立刻发送一个RST包。此时,第一个send()发送的数据已经在线上了,而第二个send()的数据还在TCP/IP队列中。close()发出的RST就会夺舍仍在队列中的第二个send()的数据。
目前,我们只要知道,我们必须通过在调用close(sock)前调用shutdown(sock, SHUT_WR)的方式关闭套接字的写入段,以等待所有队列中的send()完成。我们在之后再仔细讨论其缘由。
现在,把所有东西放一起,我们的代码就变成了:
int sock = accept(tcp_sock, NULL, NULL);
// Send header then body
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n", sizeof(html_index));
send(sock, tcp_buffer, wp, 0);
send(sock, html_index, sizeof(html_index), 0);
// Combine and send
/*
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n", sizeof(html_index));
memcpy(tcp_buffer + wp, html_index, sizeof(html_index));
send(sock, tcp_buffer, wp + sizeof(html_index), 0);
*/
shutdown(sock, SHUT_WR);
close(sock);
使用Wireshark来查看添加shutdown(sock, SHUT_WR)后的TCP/IP交互:
- PC开始TCP/IP连接。
- 服务器执行
accept()接受TCP/IP连接。 - PC确认连接。
- PC发送HTTP请求。
- 服务器发送HTTP头。
- 服务器发送HTTP内容。现在,HTTP响应已完成。同时,服务器告诉PC连接
close()关闭。 - PC确认HTTP响应。
- PC关闭连接。
- 服务器确认连接关闭。
此处为第71帧的详细内容,即响应的第一部分。
可以看到,经发送HTTP头。
此处为第75帧的详细内容,即响应的第二部分。
可以看到,HTTP内容也被发送。注意TCP层中的Sequence Number为80,表示这段数据应该被添加在上一段数据的末尾。
同时注意FIN标志,表示close()关闭连接。
速度差异
晃眼一看,似乎多次调用send()更快更方便,因为不用额外把HTML内容复制到缓冲中。但是,send()函数实际上是系统调用,也就是说,程序执行需要从用户空间(我们的代码)转换到内核空间(以控制底层硬件发送数据)。每当系统调用时,就会发生上下文切换、权限控制、内存管理器设置、数据检查与清理等,这都带来极大的性能消耗。
要测量速度差异,我们可以使用一个底层分析工具,也就是使用hal/cpu_hal.h里的cpu_hal_get_cycle_count()来读取CPU周期计数器。通过将交互开始时与结束时的周期相减即可得到程序的速度。
添加分析工具:
int sock = accept(tcp_sock, NULL, NULL);
uint32_t start = cpu_hal_get_cycle_count();
// Send header then body
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n", sizeof(html_index));
send(sock, tcp_buffer, wp, 0);
send(sock, html_index, sizeof(html_index), 0);
// Combine and send
/*
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n", sizeof(html_index));
memcpy(tcp_buffer + wp, html_index, sizeof(html_index));
send(sock, tcp_buffer, wp + sizeof(html_index), 0);
*/
uint32_t end1 = cpu_hal_get_cycle_count(); // After send()
shutdown(sock, SHUT_WR);
uint32_t end2 = cpu_hal_get_cycle_count(); // After shutdown()
close(sock);
uint32_t end3 = cpu_hal_get_cycle_count(); // After close()
ESP_LOGI("[TCP]", "Finished in %"PRIu32" / %"PRIu32" / %"PRIu32" cycles", end1 - start, end2 - start, end3 - start);
接下来,对于每种情况请求该网页10+次:
| 情况 | 平均 | 最低 | 最高 |
|---|---|---|---|
先发头后内容 - send()后 |
269389 | 240183 | 288083 |
和并发送 - send()后 |
206019 | 199953 | 221597 |
| 提高 | 30.8% | 20.1% | 30.0% |
先发头后内容 - shutdown()后 |
408135 | 369389 | 461831 |
和并发送 - shutdown()后 |
375104 | 325218 | 408098 |
| 提高 | 8.8% | 13.6% | 13.2% |
先发头后内容 - close()后 |
525985 | 477074 | 563292 |
和并发送 - close()后 |
479778 | 440645 | 540287 |
| 提高 | 9.6% | 8.3% | 4.3% |
可以看到,即使是需要额外复制数据,将HTTP头与内容和并写入缓冲后发送比分多次调用send()发送头与内容要快5-10%。因为每次调用都会带来上下文切换的严重负担。
继续阅读思考答案,或者点这里偷看答案抄作业
实际上,因为lwIP的多线程异步特性导致其需要:
- 需要使用锁。
- 线程阻塞恢复并不是及时的,当前线程阻塞时系统会切换到其它线程,只有其他线程挂起时才会恢复到当前线程。
另外,查看lwIP的源码可见,在调用时会大量进行参数检测、状态检测、调用子函数。这些开销一点不比复制内存小。
如果指令缓存不足,若是调用lwIP时或是返回时,程序指令因为被其他程序覆盖未被加载到RAM中,系统将需要从闪存中重新缓存程序指令。这将带来非常久的停滞。
与此相比,memcpy()的操作量其实并不大,不需要上下文切换,且缓存特性非常好。
但是,当内容变大,所需的缓冲空间也会变大。
究竟选用哪种方案需要更多的考量。
长HTTP响应
在前面的例子中,我们成功地发送了一个简短的HTML网页到客户端。这展示了如何使用TCP/IP与几行代码就可以在ESP-32上搭建一个HTTP服务器。但是,实际情况中,我们所需要发送的数据显然会更大。例如,一个HTML文件通常会有几千到几万字节,而一张图片则会是几百千字节到几兆字节大小。
我们将需要分两种情况讨论这个问题,首先是数据超过网络MTU大小,然后是数据超过内核网络缓冲大小。
超过MTU/MSS大小
虽然不是100%准确,我们认为MTU(网络层最大传输单元maximum transmission unit)为1500字节。MSS(TCP中最大分段大小maximum segment size)比MTU小一点,因为要容纳TCP头。
在这个例子中,我们将发送一个约2k字节的HTML网页:
ESP-32现在作为服务器连接在我家的WiFi路由器的2.4GHz频道上,同时我将使用一台连接在同一路由器的5GHz频道的PC来请求这个网页。
让我们看看PC上的Wireshark的记录:
可以看到Wireshark的记录中:
- 客户端开始连接。客户端MSS大小为1440字节。
- 服务器接受连接。服务器MSS大小为1440字节。
- 客户端发送请求。
- HTTP响应的第一部分(1440字节内容)。
- HTTP响应的第二部分(762字节内容),和上一部分一起组成一个完整的HTTP响应。
完整的HTTP响应共2202字节长,大于MSS。因此,需要分两帧传输。
超过内核缓冲大小
我们在之前已经讨论过了send()调用会将数据从用户空间复制到内核空间。既然是复制到内核空间,那么在底层硬件一个比特接着一个比特地发送数据时,内核就一定需要消耗一些内存(缓冲)来暂时存放这些数据。必然,这个缓冲不可能是无限的。
如果我们查看Linux手册,send(int sockfd, const void buf[size], size_t size, int flags)返回实际发送的数据长度 return the number of bytes sent。也就是说,实际上,出于内核缓冲大小限制,可能只有部分我们提供的buf[]被发送。在这种情况下,我们需要在接下来重新发送剩下的部分。
在芯片初始化时,可以看到串口上打印了一些log,其中一条为:
也就是说,系统分配了5760字节作为发送端的缓冲。这个数字可以在menuconfig中被修改:
调用send()最多发送这么多数据。因此我们的软件需要通过再次调用这个函数的方式重传剩余的部分。为了检验send()的工作状况,我们将打印发送的数据量和剩余的数据量:
IRAM_ATTR void tcp_main(void* param) {
while (1) {
int sock = accept(tcp_sock, NULL, NULL);
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n", sizeof(html_index));
memcpy(tcp_buffer + wp, html_index, sizeof(html_index));
size_t sent = 0, total = wp + sizeof(html_index);
while (sent < total) {
ESP_LOGI("[TCP]", "Sent combined %d of %d", (int)sent, (int)total);
size_t just = send(sock, tcp_buffer + sent, total - sent, 0);
ESP_LOGI("[TCP]", "Just %d of %d", (int)just, (int)total);
sent += just;
}
shutdown(sock, SHUT_WR);
close(sock);
}
}
获取该网页:
让我们看看PC上的Wireshark的记录:
查看ESP-32的log:
所有的数据一次就发送完成了,即使我们提供的数据超过了10k字节。怪了怪了?俺寻思应该分两次发送每次最多5760字节(或者更小一点,因为还要包含一些TCP头)呀?
为了进一步验证这个特性,我们决定搞一发大的,我们要发一个二进制字符串DRAM_ATTR uint32_t sensor_buffer[0x8000]。这几乎耗尽了ESP-32的所有剩余RAM:
DRAM_ATTR uint32_t sensor_buffer[0x8000];
IRAM_ATTR void tcp_main(void* param) {
while (1) {
int sock = accept(tcp_sock, NULL, NULL);
size_t wp = sprintf(tcp_buffer, "HTTP/1.0 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %d\r\n\r\n", sizeof(sensor_buffer));
send(sock, tcp_buffer, wp, 0);
size_t sent = 0, total = sizeof(sensor_buffer);
while (sent < total) {
ESP_LOGI("[TCP]", "Sent body %d of %d", (int)sent, (int)total);
size_t just = send(sock, sensor_buffer + sent, total - sent, 0);
ESP_LOGI("[TCP]", "Just %d of %d", (int)just, (int)total);
sent += just;
}
shutdown(sock, SHUT_WR);
close(sock);
}
}
获取该资源。如终端显示,这么大个雷霆数据花了93个TCP分段:
但是一次就发送了:
继续阅读思考答案,或者点这里偷看答案抄作业
lwIP中,send()只有在数据被完全写入TCP/IP的缓冲后才会返回。
lwIP TCP实现
ESP-IDF使用lwIP作为底层网络套接字来实现。
lwIP有两种模式:
-
Mainloop模式 - 应用直接使用lwIP的底层接口。
-
OS模式 - 应用不能直接操作lwIP的底层接口。TCP/IP任务在一个专用线程中。应用通过发送消息到TCP/IP线程的方式来使用网络。
ESP-IDF使用OS模式。
在这个章节中,我们将讨论lwIP如何工作在:
-
LWIP_NETCONN_FULLDUPLEX = 0- 只用一个线程可以读写与关闭一个套接字。 -
LWIP_TCPIP_CORE_LOCKING = 0- 使用TCP/IP线程,而不是在应用线程内执行操作。 -
LWIP_SO_LINGER = 0- 禁用SO_LINGER。
下面展示简化的lwIP源代码。
通过调用src/api/api_lib.c中的netconn_apimsg(lwip_netconn_*, &API_MSG_VAR_REF(msg))来发送消息到TCP/IP线程,这里lwip_netconn_*为TCP/IP线程中需要执行的函数:
static err_t netconn_apimsg(tcpip_callback_fn fn, struct api_msg *apimsg) {
err_t err = tcpip_send_msg_wait_sem(fn, apimsg, LWIP_API_MSG_SEM(apimsg));
return err == ERR_OK ? apimsg->err : err;
}
在src/api/tcpip.c中:
err_t tcpip_send_msg_wait_sem(tcpip_callback_fn fn, void *apimsg, sys_sem_t *sem) {
TCPIP_MSG_VAR_DECLARE(msg);
TCPIP_MSG_VAR_ALLOC(msg);
TCPIP_MSG_VAR_REF(msg).type = TCPIP_MSG_API;
TCPIP_MSG_VAR_REF(msg).msg.api_msg.function = fn;
TCPIP_MSG_VAR_REF(msg).msg.api_msg.msg = apimsg;
sys_mbox_post(&tcpip_mbox, &TCPIP_MSG_VAR_REF(msg));
sys_arch_sem_wait(sem, 0);
TCPIP_MSG_VAR_FREE(msg);
return ERR_OK;
}
将消息发送到TCP/IP线程。在ESP-IDF中,使用FreeRTOS的xQueueSendToBack(mbox->mbx, &msg, portMAX_DELAY)实现。
在发送消息到TCP/IP线程时,也会发送一个锁,并将应用阻塞。在ESP-IDF中,使用FreeRTOS的xSemaphoreTake(sem->sem, timeout_ticks)实现。
这把锁可以被TCP/IP线程使用void sys_sem_signal(sys_sem_t *sem)释放。在ESP-IDF中,使用FreeRTOS的xSemaphoreGive(sem->sem)实现。注意,锁可能在底层TCP/IP任务彻底完成前释放。
也就谁说,lwIP是异步的。应用(用户空间)只能将命令加入TCP/IP线程(内核空间)的队列。
应用端
lwip_*()用于实现套接字接口。在src/include/lwip/socket.h中:
#define lwip_send send
#define lwip_close close
#define lwip_shutdown shutdown
lwip_*()提供了参数检测、格式转化,并调用netconn_*()。之后,清理数据。在src/api/scoket.c:
要send()数据到网络的另一侧:
ssize_t lwip_send(int s, const void *data, size_t size, int flags) {
struct lwip_sock sock = get_socket(s);
u8_t write_flags = NETCONN_COPY | flags;
size_t written = 0;
err = netconn_write_partly(sock->conn, data, size, write_flags, &written);
return err == ERR_OK ? (ssize_t)written : -1;
}
send()时数据将被从用户空间复制到系统空间。lwip_send()不支持无复制发送。
要shutdown()这个连接:
int lwip_shutdown(int s, int how) {
struct lwip_sock sock = get_socket(s);
u8_t shut_rx = x(how), shut_tx = x(how);
err_t err = netconn_shutdown(sock->conn, shut_rx, shut_tx);
return err == ERR_OK ? 0 : -1;
}
要close()这个连接:
int lwip_close(int s) {
struct lwip_sock sock = get_socket(s);
err_t err = netconn_prepare_delete(sock->conn);
if (err != ERR_OK)
return -1;
free_socket(sock, NETCONNTYPE_GROUP(netconn_type(sock->conn)) == NETCONN_TCP);
return 0;
}
static void free_socket(struct lwip_sock *sock, int is_tcp) {
free_socket_locked(sock, is_tcp, &conn, &lastdata);
free_socket_free_elements(is_tcp, conn, &lastdata);
}
static int free_socket_locked(struct lwip_sock *sock, int is_tcp, struct netconn **conn, union lwip_sock_lastdata *lastdata) {
sock->fd_used--;
*lastdata = sock->lastdata;
sock->lastdata.pbuf = NULL;
sock->select_waiting = 0;
*conn = sock->conn;
sock->conn = NULL;
return 1;
}
static void free_socket_free_elements(int is_tcp, struct netconn *conn, union lwip_sock_lastdata *lastdata) {
pbuf_free(lastdata->pbuf);
netbuf_delete(lastdata->netbuf);
netconn_delete(conn);
}
对于TCP/IP连接,close()将在TCP/IP线程成功后释放套接字(释放缓冲)。
netconn_*()通过调用netconn_apimsg(lwip_netconn_*, &API_MSG_VAR_REF(msg))来发送消息(干什么,在哪个套接字)到TCP/IP线程。注意这个函数是阻塞的。在src/api/api_lib.c中:
对于send():
err_t netconn_write_partly(struct netconn *conn, const void *dataptr, size_t size, u8_t apiflags, size_t *bytes_written) {
return netconn_write_vectors_partly(conn, &(struct netvector vector){.ptr = dataptr, .len = size}, 1, apiflags, bytes_written);
}
err_t netconn_write_vectors_partly(struct netconn *conn, struct netvector *vectors, u16_t vectorcnt, u8_t apiflags, size_t *bytes_written) {
msg = {
.conn = conn,
.msg.w.vector = vectors,
.msg.w.vector_cnt = vectorcnt,
.msg.w.vector_off = 0,
.msg.w.apiflags = apiflags,
.msg.w.len = size,
.msg.w.offset = 0
};
err_t err = netconn_apimsg(lwip_netconn_do_write, &API_MSG_VAR_REF(msg));
*bytes_written = API_MSG_VAR_REF(msg).msg.w.offset;
return err;
}
应用端的API发送一条包含了多个向量(但是只使用一个)的消息到TCP/IP线程。然后,应用将会阻塞,直到TCP/IP线程将它释放。最后,实际发送的数据字节量通过msg.w.offset传回。
对于shutdown():
err_t netconn_shutdown(struct netconn *conn, u8_t shut_rx, u8_t shut_tx) {
return netconn_close_shutdown(conn, (u8_t)((shut_rx ? NETCONN_SHUT_RD : 0) | (shut_tx ? NETCONN_SHUT_WR : 0)));
}
static err_t netconn_close_shutdown(struct netconn *conn, u8_t how) {
msg = {
.conn = conn,
.msg.sd.shut = how,
.msg.sd.polls_left = ((LWIP_TCP_CLOSE_TIMEOUT_MS_DEFAULT + TCP_SLOW_INTERVAL - 1) / TCP_SLOW_INTERVAL) + 1
};
return netconn_apimsg(lwip_netconn_do_close, &API_MSG_VAR_REF(msg));
}
对于close():
err_t netconn_prepare_delete(struct netconn *conn) {
msg = {
.conn = conn,
.msg.sd.polls_left = ((LWIP_TCP_CLOSE_TIMEOUT_MS_DEFAULT + TCP_SLOW_INTERVAL - 1) / TCP_SLOW_INTERVAL) + 1,
};
return netconn_apimsg(lwip_netconn_do_delconn, &API_MSG_VAR_REF(msg));
}
TCP/IP线程 - 发送
应用向TCP/IP线程发送消息以开始发送任务。在src/api/api_msg.c中:
void lwip_netconn_do_write(void *m) {
lwip_netconn_do_writemore((struct api_msg *)m->conn);
}
lwip_netconn_do_write()初始化写操作。
static err_t lwip_netconn_do_writemore(struct netconn *conn, u8_t delayed) {
err_t err;
u8_t apiflags = conn->current_msg->msg.w.apiflags;
u16_t len, available;
const void *dataptr = (const u8_t *)conn->current_msg->msg.w.vector->ptr + conn->current_msg->msg.w.vector_off;
size_t diff = conn->current_msg->msg.w.vector->len - conn->current_msg->msg.w.vector_off;
err = tcp_write(conn->pcb.tcp, dataptr, min(tcp_sndbuf(conn->pcb.tcp), diff), apiflags);
if (err == ERR_OK) {
conn->current_msg->msg.w.offset += len;
conn->current_msg->msg.w.vector_off += len;
if (conn->current_msg->msg.w.vector_off == conn->current_msg->msg.w.vector->len) { // check if current vector is finished
conn->current_msg->msg.w.vector_cnt--;
if (conn->current_msg->msg.w.vector_cnt > 0) { // if we have additional vectors, move on to them
conn->current_msg->msg.w.vector++;
conn->current_msg->msg.w.vector_off = 0;
}
}
}
err_t out_err = tcp_output(conn->pcb.tcp);
if (conn->current_msg->msg.w.offset == conn->current_msg->msg.w.len) {
sys_sem_signal(LWIP_API_MSG_SEM(conn->current_msg));
}
}
lwip_netconn_do_writemore()将消息中的向量(即需要发送的数据)通过tcp_write()填充进TCP/IP缓冲,最大tcp_sndbuf()字节(对于我们来说就是5670字节的缓冲大小,或者该缓冲剩余量)。接下来,调用tcp_output()来发送它们。
当消息的偏移量等于其长度conn->current_msg->msg.w.offset == conn->current_msg->msg.w.len时,就代表数据完全缓冲了。换言之,读指针到达队尾。
如果需要发送的数据可以被填入TCP/IP缓冲,send()调用lwip_netconn_do_writemore()后就会在数据填入TCP/IP缓冲后不再阻塞。应用继续执行其他操作,同时,数据将在后他发送。
如果数据太长,超出了TCP/IP缓冲剩余空间,send()就会阻塞。同时,数据在后台发送,逐渐释放TCP/IP缓冲。另外,lwip_netconn_do_writemore()可以被sent_tcp()或poll_tcp()调用(类似于周期性重试),逐渐继续填充TCP/IP缓冲并发送。当数据被完全写入TCP/IP缓冲后,send()不再阻塞。
因此,在应用侧,调用send()总是能一次就把整个数据发送完。
但是,我们还是可以保留在调用send()时比较并重发剩余数据的循环的代码,因为这是手册推荐的写法,如果我们更换了其它的底层库也能防止出bug。
以上不适用于非阻塞的send(..., MSG_DONTWAIT)。
src/core/tcp_out.c中的err_t tcp_write(struct tcp_pcb *pcb, const void *arg, u16_t len, u8_t apiflags)用于填充TCP/IP缓冲:
-
将提供的数据切分为更小的能装入MSS或剩余TCP/IP缓冲(取其低)的大小。
-
为数据创建TCP分段,添加TCP头。
-
将TCP分段加入队列。
src/core/tcp_out.c中的err_t tcp_output(struct tcp_pcb *pcb)初始化TCP分段发送:
-
检测发送窗口大小。如果太小,就发一个空的
ACK包,并之后再试。 -
检查
ACK、SEQ等数字。 -
调用
static err_t tcp_output_segment(struct tcp_seg *seg, struct tcp_pcb *pcb, struct netif *netif)来通过IP发送数据包。
我们可以修改menuconfig的参数来打印更多的lwIP的log:
可以看到发送数据时lwIP内部在不断分配与释放内存以创建TCP/IP包。
总结一下:
-
在用户空间,我们可以传一个比内核空间TCP缓冲大或是MSS还要大的数据给
send(),并且整个数据都能够一次性发送完成。 -
在lwIP库的上层(
api_msg.c),用户提供的数据将被逐渐填充进5760字节的内核侧TCP/IP缓冲,阻塞直到完全填充(但并未完全发送)。 -
在lwIP库的下层(
tcp_out.c),TCP/IP缓冲内的数据将被分割为更小的TCP分段以满足MSS。 -
send()将数据加入TCP/IP缓冲队列。当send()返回时,数据仍在发送中。
TCP/IP 线程 - 关闭读写
在填充TCP/IP缓冲后,彻底关闭套接字前,先关闭写入侧。在src/api/api_msg.c中:
void lwip_netconn_do_close(void *m) {
struct api_msg *msg = (struct api_msg *)m;
msg->conn->current_msg = msg;
lwip_netconn_do_close_internal(msg->conn);
}
在关闭写入侧时,只执行以下操作:
static err_t lwip_netconn_do_close_internal(struct netconn *conn, u8_t delayed) {
struct tcp_pcb *tpcb = conn->pcb.tcp;
tcp_sent(tpcb, NULL);
err = tcp_shutdown(tpcb, shut & NETCONN_SHUT_RD = 0, shut & NETCONN_SHUT_WR = 1);
conn->current_msg->err = err;
conn->current_msg = NULL;
sys_sem_signal(LWIP_API_MSG_SEM(conn->current_msg));
}
lwip_netconn_do_close_internal()将:
-
移除
send()程序,因此,之后就不再可写了。 -
调用
err_t tcp_shutdown(struct tcp_pcb *pcb, int shut_rx, int shut_tx)来关闭TCP连接的写入侧,其中shut_rx为0,shut_tx为1。 -
释放应用线程。
因为当前pcb状态为ESTABLISHED(通过accept()获得套接字),在关闭写入侧时,src/core/tcp.c中只执行以下操作:
err_t tcp_shutdown(struct tcp_pcb *pcb, int shut_rx, int shut_tx) {
return tcp_close_shutdown(pcb, (u8_t)shut_rx = 0);
}
static err_t tcp_close_shutdown(struct tcp_pcb *pcb, u8_t rst_on_unacked_data) {
return tcp_close_shutdown_fin(pcb);
}
static err_t tcp_close_shutdown_fin(struct tcp_pcb *pcb) {
tcp_send_fin(pcb);
pcb->state = FIN_WAIT_1;
tcp_output(pcb);
return ERR_OK;
}
src/core/tcp_out.c中的tcp_send_fin(pcb)可能将一个新的空白FIN包加入队列,也可能将队列中最后一个包的FIN位设置。
当FIN包入队后pcb状态改变为FIN_WAIT_1。应用线程恢复。
总结一下:
-
在关闭套接字写入侧时状态为
ESTABLISHED。 -
移除写程序,禁用后续写操作。
-
shutdown(sock, SHUT_WR)将一个新FIN包加入队列,或将队列中最后一个包的FIN位设置。 -
和
send()一样,当shutdown()返回时,FIN包仍在队列中。
TCP/IP 线程 - 关闭
关闭TCP/IP连接。在src/api/api_msg.c中:
void lwip_netconn_do_delconn(void *m) {
struct api_msg *msg = (struct api_msg *)m;
netconn_drain(msg->conn);
msg->msg.sd.shut = NETCONN_SHUT_RDWR;
msg->conn->current_msg = msg;
lwip_netconn_do_close_internal(msg->conn);
}
lwip_netconn_do_close_internal()将:
-
调用
static void netconn_drain(struct netconn *conn)删除rcvmbox和acceptmbox。未读的数据将被丢弃,未接受的连接将被放弃。 -
关闭连接的读写侧。
然后,在关闭读写两侧时,只会执行以下操作:
static err_t lwip_netconn_do_close_internal(struct netconn *conn, u8_t delayed) {
struct tcp_pcb *tpcb = conn->pcb.tcp;
tcp_*(tpcb, NULL);
err = tcp_close_ext(tpcb, 1);
conn->current_msg->err = err;
conn->current_msg = NULL;
sys_sem_signal(LWIP_API_MSG_SEM(conn->current_msg));
}
lwip_netconn_do_close_internal()将:
-
移除所有程序,禁用后续所有操作。
-
调用
err_t tcp_close_ext(struct tcp_pcb *pcb, u8_t rst_on_unacked_data)来关闭TCP/IP连接,其中rst_on_unacked_data为1。 -
释放应用线程。
shutdown()和close()都会调用lwip_netconn_do_close_internal(),然后区别在:
-
shutdown(tcp, SHUT_RD ^ SHUT_WR)在只关闭一侧时(读或写)会调用tcp_shutdown()。 -
shutdown(tcp, SHUT_RDWR)在同时关闭两侧时(读和写)会调用tcp_close_ext()。 -
close(tcp)等同于shutdown(tcp, SHUT_RDWR)同时关闭两侧,调用tcp_close_ext()。但是,在应用侧,close()会释放套接字。
因为当前pcb状态为FIN_WAIT_1(在shutdown(sock, SHUT_WR)时,tcp_close_shutdown_fin()中修改),src/core/tcp.c中只执行以下操作:
err_t tcp_close_ext(struct tcp_pcb *pcb, u8_t rst_on_unacked_data) {
return tcp_close_shutdown(pcb, rst_on_unacked_data = 1);
}
static err_t tcp_close_shutdown(struct tcp_pcb *pcb, u8_t rst_on_unacked_data) {
return tcp_close_shutdown_fin(pcb);
}
static err_t tcp_close_shutdown_fin(struct tcp_pcb *pcb) {
return ERR_OK;
}
TCP/IP侧没有值得讨论的内容。
但是,如果我们没有先关闭写入侧,当前pcb状态就会保持ESTABLISHED,src/core/tcp.c中只执行以下操作:
err_t tcp_close_ext(struct tcp_pcb *pcb, u8_t rst_on_unacked_data) {
return tcp_close_shutdown(pcb, rst_on_unacked_data = 1);
}
static err_t tcp_close_shutdown(struct tcp_pcb *pcb, u8_t rst_on_unacked_data) {
if (rst_on_unacked_data && ((pcb->state == ESTABLISHED) || (pcb->state == CLOSE_WAIT))) {
tcp_rst(pcb, pcb->snd_nxt, pcb->rcv_nxt, &pcb->local_ip, &pcb->remote_ip, pcb->local_port, pcb->remote_port);
tcp_pcb_purge(pcb);
return ERR_OK;
}
}
src/core/tcp_out.c中的tcp_send_fin(pcb)立刻发送一个RST包(而不是加入队列)。
src/core/tcp.c中的tcp_pcb_purge(pcb)清空所有缓冲数据(包括未发送的和未确认的)。
总结一下:
-
close()在应用端会释放套接字。 -
调用
shutdown(sock, SHUT_WR)后调用close()在这里没什么需要重点提出的操作。 -
不调用
shutdown(sock, SHUT_WR)而是直接调用close()将发送一个RST包并清空TCP/IP缓冲。
其它优化
完全移除printf()类函数
在之前的例子中,我们通过sprint()函数来打印HTTP头:
#define HTTP_HEADER_TEXTHTML "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: %d\r\n\r\n"
DRAM_ATTR char tcp_buffer[12000];
size_t wp = sprintf(tcp_buffer, HTTP_HEADER_TEXTHTML, sizeof(html_index));
memcpy(tcp_buffer + wp, html_index, sizeof(html_index));
send(sock, tcp_buffer, wp + sizeof(html_index), 0);
前面说过,printf()类花销不小:
-
需要处理格式字符串。
-
我们不需要这么复杂且冗余的函数,也不需要整太多花活。
因为复杂且冗余,很多嵌入式应用使用的
printf()类函数都是精简化的,特别是对小数的支持。
我们只需要复制内存和打印数字。ESP-32的ROM中提供了二进制转文字的函数:
int esp_rom_cvt(unsigned long long val, long radix, int pad, const char *digits, char *buf);
现在,我们就可以把之前的代码修改为:
#define HTTP_HEADER_TEXTHTML "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: "
DRAM_ATTR char tcp_buffer[12000] = HTTP_HEADER_TEXTHTML;
//memcpy(tcp_buffer, HTTP_HEADER_TEXTHTML, sizeof(HTTP_HEADER_TEXTHTML) - 1); // Not required to refill header
char* dest = tcp_buffer + sizeof(HTTP_HEADER_TEXTHTML) - 1; // Do not include the zero-terminator
dest += esp_rom_cvt(sizeof(html_index), 10, 0, "0123456789", dest);
*(dest++) = '\r';
*(dest++) = '\n';
*(dest++) = '\r';
*(dest++) = '\n';
memcpy(dest, html_index, sizeof(html_index) - 1);
send(sock, tcp_buffer, dest - tcp_buffer + sizeof(html_index) - 1, 0);
我们可以把HTTP头看成两部分:静态部分包含HTTP 200状态码、内容类型、内容长度的名字,动态部分未内容长度。
-
我们使用
memcpy()来写静态部分。如果我们只提供HTML内容,我们就只需要在程序初始化时做一次这个操作。 -
使用
esp_rom_cvt()将长度从二进制转为文字,写在静态部分之后。因为头的静态部分HTTP_HEADER_TEXTHTML是C语言字符串,其末尾包含0结束符,我们使用sizeof(HTTP_HEADER_TEXTHTML) - 1将其跳过。 -
添加
\r\n来结束头的最后一行(也就是内容长度数字之后)。
添加\r\n作为空行。
将内容复制到空行后。因为内容html_index是C语言字符串,其末尾包含0结束符,我们使用sizeof(html_index) - 1将其跳过。
完全静态缓冲
既然HTML文件是静态的,其长度也必定是在编译时就已知的。也就是说,HTTP头中的Content-Length在编译时就可以确定了。那么,整个HTTP头都可以时静态的。我们可以HTTP头和内容都写入一个常量字符串:
const char index_http[] = "HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 82\r\n\r\n<!DOCTYPE html><html><head><title>123</title></head><body><p>456</p></body></html>";
send(sock, index_http, sizeof(index_http), 0);
这样,既不需要复制,也不用创建缓冲,还只要一次send()调用。