当前位置: 首页 > article >正文

用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_0ota_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


http://www.kler.cn/a/552884.html

相关文章:

  • 【金融量化】解读量化投资回测指标
  • C#中的加密和解密类设计
  • 网络工程师 (43)IP数据报
  • SCANet代码解读
  • 爬取网站内容转为markdown 和 html(通常模式)
  • kotlin Java 使用ArrayList.add() ,set()前面所有值被 覆盖 的问题
  • 上证50股指期货持仓量查询的方式在哪里?
  • STL之string类的模拟实现
  • Pilz安全继电器介绍(PNOZ X2.8P,Pilz MB0)
  • DeepSeek:情智机器人的“情感引擎”与未来变革者
  • Zookeeper 和 Redis 哪种更好?
  • 一键部署开源DeepSeek并集成到钉钉
  • Ubuntu 下 nginx-1.24.0 源码分析 - ngx_get_full_name 函数
  • C++核心指导原则: 函数部分
  • C++字符串处理指南:从基础操作到性能优化——基于std::string的全面解析
  • 【QT常用技术讲解】国产Linux桌面系统+window系统通过窗口句柄对窗口进行操作
  • Jtti.cc:CentOS下PyTorch运行出错怎么办
  • Java集合之ArrayList(含源码解析 超详细)
  • 测试。。。
  • 在高流量下保持WordPress网站的稳定和高效运行