ESP-32 TCP裸金属HTTP服务器

这篇文章将讨论如何在ESP-32上借助ESP-IDF使用低层TCP/IP套接字来搭建支持静态或固定长度内容的HTTP服务器,而不使用HTTP库。本文还会讨论lwIP如何send、shutdown和close一个TCP/IP网络套接字。

--by Captdam @ Sep 7, 2026 Aug 26, 2026

Index

这个项目中,我将会使用ESP-32来搭建一个简单的HTTP服务器用于提供静态HTML网页固定长度二进制文件(例如传感器读数)。这个项目将使用C语言开发,并使用官方ESP-IDF框架坂本6.0.2

阅读这篇博客需要最基础的HTTP知识:

阅读这篇博客需要最基础的TCP知识:

我们将从最弱智的写法开始,一步一步完善我们的代码,并通过分析底层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是一种基于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与闪存缓存):

在上面的代码中,我们做了:

  1. 启动并绑定流套接字(TCP/IP)到端口80。

  2. 开始监听该端口收到的请求。

  3. 创建一个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);
} }
	

在该函数中,我们:

  1. 等待一个新的请求。

  2. 读取该请求,即将数据从内核空间(socket缓冲)复制到用户空间(我们的char tcp_buffer[])。

  3. 将响应打印到用户空间的缓冲。

  4. 发送响应,即将数据从用户空间提交给内核空间。

  5. 关闭这个连接。

  6. 回到步骤一,等待下一个请求。

注意这里函数tcp_main()与缓冲char tcp_buffer[]分别添加了IRAM_ATTRDRAM_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
	
C语言风格的多行字符串编码在VS code中以HTML语法高亮
C语言风格的多行字符串编码在VS code中以HTML语法高亮

使用这个方法可以让字符串被大多数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交互:

TCP/IP交互 - 一次发送后重置
TCP/IP交互 - 一次发送后重置
  1. PC开始TCP/IP连接。
  2. 服务器执行int sock = accept(tcp_sock, NULL, NULL)接受TCP/IP连接。
  3. PC确认连接。
  4. PC发送HTTP请求。
  5. 服务器发送HTTP响应。
  6. 服务器执行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交互:

TCP/IP交互 - 分别发送后重置
TCP/IP交互 - 分别发送后重置
  1. PC开始TCP/IP连接。
  2. 服务器执行accept()接受TCP/IP连接。
  3. PC确认连接。
  4. PC发送HTTP请求。
  5. 服务器发送HTTP头。此时HTML内容还没被send()发送。
  6. 在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交互:

TCP/IP交互 - 分别发送后关闭
TCP/IP交互 - 分别发送后关闭
  1. PC开始TCP/IP连接。
  2. 服务器执行accept()接受TCP/IP连接。
  3. PC确认连接。
  4. PC发送HTTP请求。
  5. 服务器发送HTTP头。
  6. 服务器发送HTTP内容。现在,HTTP响应已完成。同时,服务器告诉PC连接close()关闭。
  7. PC确认HTTP响应。
  8. PC关闭连接。
  9. 服务器确认连接关闭。
TCP/IP交互 - 分别发送后关闭 - 第一次发送
TCP/IP交互 - 分别发送后关闭 - 第一次发送

此处为第71帧的详细内容,即响应的第一部分。

可以看到,经发送HTTP头。

TCP/IP交互 - 分别发送后关闭 - 第二次发送
TCP/IP交互 - 分别发送后关闭 - 第二次发送

此处为第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+次:

CPU周期数
情况 平均 最低 最高
先发头后内容 - 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的记录:

完整的TCP/IP交互
完整的TCP/IP交互
包含完整HTTP响应的帧
包含完整HTTP响应的帧

可以看到Wireshark的记录中:

  1. 客户端开始连接。客户端MSS大小为1440字节。
  2. 服务器接受连接。服务器MSS大小为1440字节。
  3. 客户端发送请求。
  4. HTTP响应的第一部分(1440字节内容)。
  5. 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,其中一条为:

ESP-32 boot info - Window size
ESP-32 boot info - Window size

也就是说,系统分配了5760字节作为发送端的缓冲。这个数字可以在menuconfig中被修改:

ESP-32配置 - lwIP TCP
ESP-32配置 - lwIP TCP

调用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的记录:

完整的TCP/IP交互和包含完整HTTP响应的帧
完整的TCP/IP交互和包含完整HTTP响应的帧

查看ESP-32的log:

ESP-32终端log
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分段:

包含完整HTTP响应的帧
包含完整HTTP响应的帧

但是一次就发送了:

ESP-32终端log
ESP-32终端log
继续阅读思考答案,或者点这里偷看答案抄作业

lwIP中,send()只有在数据被完全写入TCP/IP的缓冲后才会返回。

lwIP TCP实现

ESP-IDF使用lwIP作为底层网络套接字来实现。

lwIP有两种模式

ESP-IDF使用OS模式。

在这个章节中,我们将讨论lwIP如何工作在:

下面展示简化的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缓冲:

  1. 将提供的数据切分为更小的能装入MSS或剩余TCP/IP缓冲(取其低)的大小。

  2. 为数据创建TCP分段,添加TCP头。

  3. 将TCP分段加入队列。

src/core/tcp_out.c中的err_t tcp_output(struct tcp_pcb *pcb)初始化TCP分段发送:

  1. 检测发送窗口大小。如果太小,就发一个空的ACK包,并之后再试。

  2. 检查ACKSEQ等数字。

  3. 调用static err_t tcp_output_segment(struct tcp_seg *seg, struct tcp_pcb *pcb, struct netif *netif)来通过IP发送数据包。

我们可以修改menuconfig的参数来打印更多的lwIP的log:

lwIP TCP的log
lwIP TCP的log

可以看到发送数据时lwIP内部在不断分配与释放内存以创建TCP/IP包。

总结一下:

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()将:

  1. 移除send()程序,因此,之后就不再可写了。

  2. 调用err_t tcp_shutdown(struct tcp_pcb *pcb, int shut_rx, int shut_tx)来关闭TCP连接的写入侧,其中shut_rx为0,shut_tx为1。

  3. 释放应用线程。

因为当前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。应用线程恢复。

总结一下:

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()将:

  1. 调用static void netconn_drain(struct netconn *conn)删除rcvmboxacceptmbox。未读的数据将被丢弃,未接受的连接将被放弃。

  2. 关闭连接的读写侧。

然后,在关闭读写两侧时,只会执行以下操作:


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()将:

  1. 移除所有程序,禁用后续所有操作。

  2. 调用err_t tcp_close_ext(struct tcp_pcb *pcb, u8_t rst_on_unacked_data)来关闭TCP/IP连接,其中rst_on_unacked_data为1。

  3. 释放应用线程。

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状态就会保持ESTABLISHEDsrc/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)清空所有缓冲数据(包括未发送的和未确认的)。

总结一下:

其它优化

完全移除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()类花销不小:

我们只需要复制内存和打印数字。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状态码、内容类型、内容长度的名字,动态部分未内容长度。

  1. 我们使用memcpy()来写静态部分。如果我们只提供HTML内容,我们就只需要在程序初始化时做一次这个操作。

  2. 使用esp_rom_cvt()将长度从二进制转为文字,写在静态部分之后。因为头的静态部分HTTP_HEADER_TEXTHTML是C语言字符串,其末尾包含0结束符,我们使用sizeof(HTTP_HEADER_TEXTHTML) - 1将其跳过。

  3. 添加\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()调用。