用esp32实现一个可配置的网关应用记录:通过网页进行OTA升级
前言
ota升级是嵌入式应用的一个重要功能,能够让千里之外的用户不需烧录等开发知识就能轻松升级程序。
之前在第一家公司的时候,产品基于stm32, 所有的应用升级都是直接拿的烧录器去烧录,当时还不知道有这个东西,以至于某次产品出产,程序出了问题,没法直接给客户bin文件去解决,只能把产品批量退回来,调试解决完,一个个去重新烧录。。。。。后面我跟面试官去讨论这段经历的时候面试官都是惊呆的。
esp32已经封装好了bootloader,自带wifi功能,esp-idf本身提供了很多例程,包括webserver,配置wifi, 软件加密,文件系统, ota等,很好上手,十分适合做不需要太多引脚的物联网消费应用。
如果要在stm32上实现类似的功能就要自己配置bootloader, 移植lwip等网络栈,会比较麻烦。
在公司就见到这么一个esp32应用, ota, wifi, 文件系统,网页配置一应俱全,正好花点时间把相关的功能熟悉一遍,扩宽技术。
实战
原理
网页ota的升级流程是,点击按钮上传页面,浏览器会向esp32的服务器发送POST请求,里面包含用于升级的文件,esp32服务器接收到这个文件后,经过版本和完整性验证无误后,写入自己的ota分区,然后设置启动分区到这个ota分区,重启,重启完后芯片就会运行在ota分区中写入的程序。
对于ota, esp32本身提供了两个例程: simple_ota和native_ota, 两个都是作为http客户端向服务器下载程序。
区别在于,前者已经把整个从http下载和升级的过程封装在了esp_https_ota
,代码十分简短。
esp_http_client_config_t config = {
.url = CONFIG_EXAMPLE_FIRMWARE_UPGRADE_URL, // http://192.168.1.4:8080/blink.bin
...
};
...
esp_https_ota_config_t ota_config = {
.http_config = &config,
};
ESP_LOGI(TAG, "Attempting to download update from %s", config.url);
esp_err_t ret = esp_https_ota(&ota_config);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "OTA Succeed, Rebooting...");
esp_restart();
} else {
ESP_LOGE(TAG, "Firmware upgrade failed");
}
while (1) {
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
而后者过程就比较详细了,能够访问到ota分区以及bin的数据结构,将bin通过esp_ota_write
写入ota分区之前, 还包括从http循环读取数据,版本验证等一大堆流程,比较繁琐。
我要做的是向服务器上传程序,让esp32接触到这个程序来进行升级做不到上述需求,simple_ota没法接触到bin,做不到这个需求,只有native_ota可以做。
webserver方面,我直接用的esp-idf的**file_serving**例程去改。
配置分区表
Flash大小配置为4MB,分区采用自定义分区,节省空间起见,把factory删掉,让bootloader在ota_0分区启动程序。
分区表如下:
# Name, Type, SubType, Offset, Size, Flags
# Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap
nvs,data, nvs, 0x9000, 16K,
otadata, data, ota, 0xd000, 8K,
phy_init, data, phy, 0xf000, 4K,
ota_0, app, ota_0, , 1M,
ota_1, app, ota_1, , 1M,
storage, data, spiffs, , 1M,
弄懂分区表花了不少时间,不需要配的过大,主要看设置的flash大小以及编译出的程序大小,我编译出来的程序大概1M左右,出来的factory默认就是1M,因此ota_0和ota_1都是1M,spiffs我就设置成了1M,加上nvs,otadata和phy_init, 不超过4M。
根据文档,分区表的用到的一些特性记录如下:
- 在使用 OTA 功能时,应用程序应至少拥有 2 个 OTA 应用程序分区(
ota_0
和ota_1
)。 ota
(0) 即 OTA 数据分区 ,用于存储当前所选的 OTA 应用程序的信息。这个分区的大小需要设定为 0x2000(即8K,esp32一个扇区0x1000=4k)。- 如果你希望在 OTA 项目中预留更多 flash,可以删除 factory 分区,转而使用 ota_0 分区。
- 强烈建议为 NVS 分区分配至少 0x3000 字节空间。
取消大文件限制
file_serving例程默认有200kb的大小限制,超过这个限制的文件都无法被上传,需要修改file_server.c配置的文件大小,我这里随便设置到了3MB。
/* Max size of an individual file. Make sure this
* value is same as that set in upload_script.html */
#define MAX_FILE_SIZE (3024*1024) // 3 KB
#define MAX_FILE_SIZE_STR "3MB"
请求头设置
sdkconfig设置Component config → HTTP Server
Max HTTP Request Header Length 从默认512设置成4096,避免出现请求头超过报错的问题。
测试页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tron6000 File Server</title>
<style>
.logo {
font-size: 25px;
text-align: left;
margin-bottom: 10px;
}
.version {
font-size: 15px;
color: gray;
text-align: left;
margin-bottom: 10px;
}
#progressContainer {
margin-top: 10px;
display: none;
}
#uploadProgress {
width: 200px;
height: 20px;
}
#percentage {
margin-left: 10px;
}
</style>
</head>
<body>
<div>
<div class="logo">Tron6000</div>
<div class="version">version:0.0.1</div>
<table class="fixed" border="0">
<col width="1000px" /><col width="500px" />
<tr><td>
<h2>ESP32 File Server</h2>
</td><td>
<table border="0">
<tr>
<td>
<button id="upload_btn" onclick="TriggerUpload()">upload</button>
<button id="upgrade_btn" onclick="TriggerUpgrade()">upgrade</button>
<input id="upload_input" type="file" style="display: none;" onchange="HandleFilePost('/upload/')"></input>
<input id="upgrade_input" type="file" style="display: none;" onchange="HandleFilePost('/upgrade/')"></input>
</td>
</tr>
<tr>
<td>
<div id="progressContainer">
<progress id="uploadProgress" value="0" max="100"></progress>
<span id="percentage">0%</span>
</div>
</td>
</tr>
</table>
</td></tr>
</table>
</div>
</body>
</html>
<script>
function SetVersion(version) {
document.getElementsByClassName("version")[0].innerHTML = "version:" + version;
}
function TriggerUpload(){
document.getElementById("upload_input").click();
}
function TriggerUpgrade() {
document.getElementById("upgrade_input").click();
}
function HandleFilePost(url) {
HandleFilePostImpl(event, url)
}
function HandleFilePostImpl(event, url) {
var filePath = event.target.files[0].name;
var upgrade_path = url + filePath;
var fileInput = event.target.files;
console.log(event.target);
var MAX_FILE_SIZE = 3072*1024;
var MAX_FILE_SIZE_STR = "3MB";
if (fileInput.length == 0) {
alert("No file selected!");
} else if (filePath.length == 0) {
alert("File path on server is not set!");
} else if (filePath.indexOf(' ') >= 0) {
alert("File path on server cannot have spaces!");
} else if (filePath[filePath.length-1] == '/') {
alert("File name not specified after path!");
} else if (fileInput[0].size > MAX_FILE_SIZE) {
alert(`File size must be less than ${MAX_FILE_SIZE_STR}!`);
} else {
event.target.disabled = true;
var file = fileInput[0];
var xhttp = new XMLHttpRequest();
var progressContainer = document.getElementById("progressContainer");
var progressBar = document.getElementById("uploadProgress");
var percentageSpan = document.getElementById("percentage");
// 初始化进度条
progressBar.value = 0;
percentageSpan.textContent = '0%';
progressContainer.style.display = 'block';
xhttp.upload.addEventListener("progress", function(e) {
if (e.lengthComputable) {
var percent = (e.loaded / e.total) * 100;
progressBar.value = percent;
percentageSpan.textContent = percent.toFixed(1) + '%';
}
});
xhttp.onreadystatechange = function() {
if (xhttp.readyState == 4) {
progressContainer.style.display = 'none';
if (xhttp.status == 200) {
alert('文件上传完成');
document.open();
document.write(xhttp.responseText);
document.close();
} else if (xhttp.status == 0) {
alert("Server closed the connection abruptly!");
location.reload()
} else {
alert(xhttp.status + " Error!\n" + xhttp.responseText);
location.reload()
}
}
};
xhttp.open("POST", upgrade_path, true);
xhttp.send(file);
}
}
</script>
编写upgrade_post_handler
在start_server下新注册一个upgrade_post_handler,这个函数会分块下载http文件并写入到ota分区中,这样设计的原因一是为了节省内存(对比文件一次读完再写入分区),二是在分块的过程中就可以进行验证,适用于让其他连接的开发板也升级的情况。
之前看公司的做法,就是esp32串口连了一个gd32开发板,程序是esp32和gd32程序通过自制程序合并在一起,gd32分块在前esp32分块在后,升级程序时就先读取gd32的分开通过串口传输到gd32让gd32升级,然后再读取esp32分块去升级esp32。
static esp_err_t upgrade_post_handler(httpd_req_t *req)
{
esp_err_t err;
esp_ota_handle_t ota_handle = 0;
const esp_partition_t *update_partition = NULL;
char *buf = ((struct file_server_data *)req->user_ctx)->scratch;
int content_length = req->content_len;
int received = 0;
int remaining = content_length;
bool image_header_checked = false;
// 获取OTA更新分区
update_partition = esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL) {
ESP_LOGE(TAG, "No OTA update partition found");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA partition not found");
return ESP_FAIL;
}
// 开始OTA升级会话
err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA begin failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed");
return err;
}
// 分块接收数据并写入OTA分区
while (remaining > 0) {
received = httpd_req_recv(req, buf, MIN(remaining, SCRATCH_BUFSIZE));
if (received < 0) { // 接收错误处理
if (received == HTTPD_SOCK_ERR_TIMEOUT) continue;
ESP_LOGE(TAG, "OTA receive error: %d", received);
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "File receive failed");
return ESP_FAIL;
}
// 首次接收时验证固件头
if (!image_header_checked) {
esp_app_desc_t new_app_info;
# refs:https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32/api-reference/system/app_image_format.html
if (received > sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t)) {
// 从数据中提取固件描述信息
memcpy(&new_app_info,
buf + sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t),
sizeof(esp_app_desc_t));
// 获取当前运行固件信息
esp_app_desc_t running_app_info;
esp_ota_get_partition_description(esp_ota_get_running_partition(), &running_app_info);
ESP_LOGI(TAG, "New firmware version: %s", new_app_info.version);
ESP_LOGI(TAG, "Current firmware version: %s", running_app_info.version);
// 简单版本校验(可选)
if (memcmp(new_app_info.version, running_app_info.version, sizeof(new_app_info.version)) == 0) {
ESP_LOGW(TAG, "Same version detected, aborting upgrade");
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Same firmware version");
return ESP_FAIL;
}
}
image_header_checked = true;
}
// 写入OTA分区
err = esp_ota_write(ota_handle, buf, received);
if (err != ESP_OK) {
ESP_LOGE(TAG, "OTA write failed: %s", esp_err_to_name(err));
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write error");
return err;
}
remaining -= received;
}
// 完成OTA升级
if ((err = esp_ota_end(ota_handle)) != ESP_OK) {
if (err == ESP_ERR_OTA_VALIDATE_FAILED) {
ESP_LOGE(TAG, "Image validation failed");
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Image validation failed");
} else {
ESP_LOGE(TAG, "OTA end failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA finalization failed");
}
return err;
}
// 设置启动分区
if ((err = esp_ota_set_boot_partition(update_partition)) != ESP_OK) {
ESP_LOGE(TAG, "Set boot partition failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Boot partition set failed");
return err;
}
// 发送成功响应并安排重启
httpd_resp_sendstr(req, "<html><body>Upgrade successful! Device will restart in 3 seconds...<script>setTimeout(()=>location='/',3000)</script></body></html>");
// 延迟重启以保证响应发送完成
vTaskDelay(3000 / portTICK_PERIOD_MS);
esp_restart();
return ESP_OK;
}
到此,一个基本的升级逻辑就做好了,不过还面临一个问题:怎么确保上传的文件就是相同的app文件?我如果不小心把blink例程给上传过去让esp32升级不就废了?
所以还剩下一个话题:OTA的上传验证。
验证方法有很多:
比如通过程序修改bin结构(比如头部加入自己的验证密钥), 在下载过程中验证结构对不上就报错,公司就用的这种办法。
flash加密/secure boot,签名比对。
要展开来就是另一个话题了,有空补上。
感想:AI越来越强大了
上面的前端页面和upgrade_post_handler的代码我都是让AI生成的,给deepseek-r1输入一个prompt,它在1分钟内就把业务代码输出出来,还有流程图,十分强大,原本设想一整晚手搓代码的工作,就这样完成了。
不过这建立在:
1.自己已经弄懂流程,具备基本的AI输出验收能力,在给deepseek-r1写prompt之前我查了至少两个小时的资料。
2.esp-idf本身开源且被广泛使用,训练集丰富,不像某些名不见经传的框架(比如AWTK),公开的训练集少,输出的代码都有严重幻觉。
3.实现的功能十分通用且基础(比如ota,发送文件,进程通信等)。
如果AI预期发展乐观,以后程序开发大概会是这么一个流程:
-
确定功能需求
-
根据功能/需求查找相关例程,资料,确定prompt(当然搜资料的过程也可以让AI代之)
-
让AI根据prompt输出程序
-
验收和调节程序
-
测试成功
惊讶的同时多少有些焦虑和迷茫,应用层写代码越来越贬值,如果还是只想当码农而不往业务方向思考,以后几年的日子不会怎么好过。
参考
https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32/api-reference/system/app_image_format.html
https://docs.espressif.com/projects/esp-idf/zh_CN/stable/esp32/api-reference/system/ota.html