最近写完了一个简单的ESP8266项目。包含配网,自动重连与连接状态检测,MQTT连接与数据交互功能。

在此罗列一些基于PlatformIO、使用ESP8266开发一个简易网关遇到的问题和解法,针对一些内容的设计思路,一些常用的设计方法。

在文章中,“例子”都是开发此项目遇到的问题。示例代码部分来源于仓库。

仓库链接:SilverDragonYo/ESP8266-IoT-Gateway: An ESP8266-based IoT gateway

  1. 事件驱动与轮询驱动

    这是在研究配网和LED灯状态显示的时候接触的。我也不能很好地定义,我结合Gemini的说明解释一下吧。

    简单理解,轮询驱动就是在主循环中不断地、主动地去询问外部设备或状态;再简单一点,都是主循环。单片机一般都是这类,不论是while(1)还是loop()

    对于事件驱动,就是当外部发生事情时,系统或硬件会自动调用这个函数。你如果开发过微信小程序,或者接触过前端,应该容易理解。例如微信小程序的JS里的onShow()函数,就是预先注册了,当页面加载好,会执行这个函数。

  2. 有限状态机实现非阻塞轮询

    这是针对轮询驱动的优化。在这个项目的开发中,就非常重要。

    因为ESP8266是单核单线程MCU,资源由用户代码和Wi-Fi协议栈公用。意味着用户代码如果长时间阻塞,或者注册一些回调函数,这些函数引用一些指针,就可能出现包括野指针在内的问题导致异常崩溃。

    有限状态机,它在维基百科的定义是:有限状态机 - 维基百科,自由的百科全书

    但是在这里,不太需要完全理解理论。我们简单理解,就是把一件事情拆分为几个状态,同一时间同一状态只能处理一件事情。例如Wi-Fi连接,进行一次尝试之后就要转入结果等待的状态,而不是重复尝试。

  3. 排除崩溃故障

    • 现象:在执行某些功能的时候单片机重启。

    • 原因:一般不是语法错误,所以编译能过。可能是库选择不当,或者代码逻辑问题。

    • 解法:如果你也使用VScode的PIO进行开发,可以使用自带的monitor_filters处理,方法如下:

      1. 打开工程对应的platformio.ini文件。

      2. 添加monitor_filters如下:

        1
        monitor_filters = esp8266_exception_decoder

        如果是esp32,就是esp32_exception_decoder

      3. 在上传代码时候,选择upload and Monitor选项。

        通过添加这个过滤器,可以实现在出现异常时自动解码。

      Arduino IDE也可以用 Exception Decoder,可以参考这篇文章,相比PIO要麻烦一些。

      Debugging Arduino Errors with the ESP32 Exception Decoder

      如果需要查找对应的过滤器,可以参考下面的文章。

      pio device monitor — PlatformIO latest documentation

      需要查找异常崩溃序号对应的错误,可以参考下面的文章(虽然可能没什么用)。

      ESP8266 重启原因以及常见 Fatal Exception 原因 | 乐鑫科技文档

    • 例子:在本次开发中,我遇到了下面的异常:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      Exception (4): 
      epc1=0x4000dd22 epc2=0x00000000 epc3=0x00000000 excvaddr=0x00000000 depc=0x00000000
      >>>stack>>>
      ctx: cont sp: 3ffffdf0 end: 3fffffd0 offset: 0160
      3fffff50: 00000009 00002710 4020a586 4020b860
      3fffff60: 00000000 00e7c4a8 3fffdad0 00449a77
      3fffff70: 40207c9c 3ffe8fe8 3ffeef44 3ffef02c
      3fffff80: 3fffdad0 3ffeee00 3ffeed74 402017a0
      3fffff90: 3fffdad0 3ffeee00 3ffeef44 40201108
      3fffffa0: 3fffdad0 00000000 3ffeee00 402015aa
      3fffffb0: 3fffdad0 00000000 3ffef000 4020a71c
      3fffffc0: feefeffe feefeffe 3fffdab0 40100ea5
      <<<stack<<<
      --------------- CUT HERE FOR EXCEPTION DECODER ---------------

      解码之后的是这样的:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      --------------- CUT HERE FOR EXCEPTION DECODER --------------- 
      Soft WDT reset
      Exception (4): epc1=0x4010032d epc2=0x00000000 epc3=0x00000000 excvaddr=0x00000000 depc=0x00000000
      Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT register epc1=0x4010032d in millis at ??:?
      >>>stack>>>
      ctx: cont
      sp: 3ffffe10 end: 3fffffd0 offset: 0160
      3fffff70: 40207c9c 3ffe8fe8 3ffeef44 3ffef02c
      3fffff80: 3fffdad0 3ffeee00 3ffeed74 402017a0
      3fffff90: 3fffdad0 3ffeee00 3ffeef44 40201108
      3fffffa0: 3fffdad0 00000000 3ffeee00 402015aa
      3fffffb0: 3fffdad0 00000000 3ffef000 4020a71c
      3fffffc0: feefeffe feefeffe 3fffdab0 40100ea5
      <<<stack<<<
      0x40207c9c in HardwareSerial::write(unsigned char const*, unsigned int) at ??:?
      0x402017a0 in SoftTimer::expired() at ??:?
      0x40201108 in connectToServer() at ??:?
      0x402015aa in loop at ??:?
      0x4020a71c in loop_wrapper() at core_esp8266_main.cpp:?
      0x40100ea5 in cont_wrapper at ??:?

      就可以判断出是软件看门狗触发了,

      调用链是loop → connectToServer() → SoftTimer::expired() → HardwareSerial::write

      我们看一下错误的源码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      void connectToServer() { 
      while (!Client.connected()) {
      if(ConnectTimer.expired()) {
      Serial.print("Attempting MQTT connection...");
      if (Client.connect(UID)) {
      Serial.println("connected");
      Serial.print("subscribe:");
      Serial.println(subTopic);
      Client.subscribe(subTopic);
      } else {
      Serial.print("failed, rc=");
      Serial.print(Client.state());
      Serial.println(" try again in 5 seconds");
      }
      }
      }
      }

      在这里,一旦没法连上MQTT服务器,就会通过while (!Client.connected())循环反复重试,从而阻塞主循环,触发看门狗。

  4. 用LED灯显示不同状态

    • 灵感:8266开发板上面有个灯,平时也没用。用户在上电之后又不知道单片机在干什么,交互做的不好,干脆让这个灯的不同状态来表示一些东西,物尽其用。

    • 简单的思路:

      采用轮询驱动思路,使用switch-case语句,结合非常简单的状态机搭建合理的函数。

      一个简单的示例如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      void ledTask() {
      unsigned long now = millis();
      switch(currentMode) {
      case LED_OFF:
      digitalWrite(LED_BUILTIN, HIGH);
      break;

      case LED_ON:
      digitalWrite(LED_BUILTIN, LOW);
      break;

      case LED_BLINK_SLOW:
      if(now - lastTick >= blinkInterval_slow) {
      lastTick = now;
      digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
      }
      break;

      case LED_BLINK_FAST:
      if(now - lastTick >= blinkInterval_fast) {
      lastTick = now;
      digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
      }
      break;
      }
      }

      ledTask()放进主循环里即可。

      当然还需要一个函数,用以修改状态,以及一个枚举类型,用来存放这些状态。

      很容易引发一些思考。例如:为什么要用这样的思路,为什么要用millis计时而不是delay?

      因为如果长时间阻塞主循环,可能触发看门狗重启,导致异常。delay是直接阻塞主循环得到的延时。

  5. Wi-Fi状态机

    • 用途:

      写一个Wi-Fi任务状态机,让8266能够按照指定流程完成比较复杂的wifi任务。

      不过值得注意的是,8266只支持2.4G的Wi-Fi。

    • 流程设计:

      采用轮询驱动思路,同样依赖switch-case语句。整体流程就是:

      启动→尝试连接→失败→配置→重连→成功→定时检测连接状态。

    • 示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      void wifiTask() {
      switch (currentState) {
      case WIFI_CONNECT:
      WiFi.mode(WIFI_STA);
      setLedMode(LED_BLINK_SLOW);
      wifiConnect();
      currentState = WIFI_CONNECTING;
      break;

      case WIFI_CONNECTING:
      if(WiFi.status() == WL_CONNECTED) {
      currentState = WIFI_CONNECTED;
      } else if (RetryTimer.expired() && !wifiConfigMode) {
      currentState = WIFI_CONFIG;
      }
      break;

      case WIFI_CONFIG:
      setLedMode(LED_BLINK_FAST);
      if(!wifiConfigMode) enterConfigMode();
      currentState = DNS_SETTING;
      DNSSetupTimer.reset();
      break;

      case DNS_SETTING:
      if(DNSSetupTimer.expired()) {
      if(!dnsActive && WiFi.softAPIP() != IPAddress(0,0,0,0)) {
      dnsStart();
      currentState = WIFI_CONFIG_SAVING;
      }
      }
      break;

      case WIFI_CONFIG_SAVING:
      if(!wifiConfigMode) {
      Serial.println("get wifi config");
      currentState = WIFI_CONFIG_SAVED;
      ConfigSaveTimer.reset();
      }
      break;

      case WIFI_CONFIG_SAVED:
      if(ConfigSaveTimer.expired()) {
      Serial.println("restart connect");
      server.stop();
      dnsEnd();
      currentState = WIFI_CONNECT;
      }
      break;

      case WIFI_CONNECTED:
      setLedMode(LED_ON);
      Serial.println("WiFi connected");
      Serial.print("IP address: ");
      Serial.println(WiFi.localIP());
      currentState = WIFI_MONITOR;
      break;

      case WIFI_MONITOR:
      if(MonitorTimer.expired() && WiFi.status() != WL_CONNECTED) {
      currentState = WIFI_CONNECT;
      }
      break;
      }
      }
    • 简单解析:

      1. 注意合理拆分状态,一个状态只完成一件事情。
      2. 切记及时切换状态,避免重入。
      3. AP模式和STA模式:简单理解,AP模式就是单片机自身作为热点,供外部设备连接;STA模式就是单片机作为终端设备,连接网络。
      4. 避免使用delay延时,长时间阻塞主循环。如果要用到较多定时器,可以写一个对象。
  6. AP配网

    顾名思义,就是在AP模式下实现配网。不要问AP模式是什么。

    配网,就是给物联网设备配置网络参数,让它能联网。如果你用过校园网,或者需要联网的智能设备,在配置模式里扫码连Wi-Fi或者需要先输入学号和密码,也是类似的操作。

    在这个项目里面,就是在8266上电后,自动联网失败时,手机连接8266的热点,进入配网页面,配置正确的SSID和密码,重连就可连上配置好的Wi-Fi。

    如果没有配网功能,SSID和密码都写死,那换个热点,单片机就上不了网了。

    • 选用库:ESP8266WebServerDNSServer

    • 推荐一个好用的仓库,包含很多常用库:esp8266/Arduino: ESP8266 core for Arduino

    • 示例代码;

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      void serverSetup() {
      server.on("/", HTTP_GET, []() {
      server.send_P(200, "text/html", index_html);
      });

      // 处理安卓、iOS、Windows系统的网络连接测试请求
      server.on("/generate_204", HTTP_GET, []() {
      server.send_P(200, "text/html", index_html);
      });

      server.on("/library/test/success.html", HTTP_GET, []() {
      server.send_P(200, "text/html", index_html);
      });

      server.on("/connecttest.txt", HTTP_GET, []() {
      server.send_P(200, "text/html", index_html);
      });

      server.on("/wifi", HTTP_POST, []() {
      char ssid[21];
      char password[21];
      if(server.hasArg("ssid")) {
      server.arg("ssid").toCharArray(ssid, sizeof(ssid));
      };

      if(server.hasArg("password")) {
      server.arg("password").toCharArray(password, sizeof(password));
      }

      server.send_P(200, "text/html", success_html);
      storageWiFiConfig(ssid, password);
      wifiConfigMode = false;
      });

      server.onNotFound([]() {
      server.send_P(404, "text/plain", "not found");
      });
      server.begin();
      }
    • DNS库使用示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      void dnsStart() {
      dnsServer.start(DNS_PORT, "*", IPAddress(192, 168, 4, 1));
      Serial.println("dns started");
      dnsActive = true;
      }

      void dnsEnd() {
      if(dnsActive) {
      dnsServer.stop();
      Serial.println("dns ended");
      dnsActive = false;
      }
      }
    • 部分解析:

      1. 简单思路:DNS将所有域名解析到目标IP,然后返回非期望的状态码,让设备判断需要登陆/配置,并跳转配网页面。

      2. 关于server.on()的参数:

        1
        void on(const String &uri, HTTPMethod method, THandlerFunction fn, THandlerFunction ufn);
      3. 关于/generate_204HTTP generate_204 检测网络连接是否正常 | Yann’s Blog

      4. DNS_PORT:一般为53。

      5. 项目使用的均为同步库,针对server,需要在主循环里放一句server.handleClient();,针对DNSServer,需要放dnsServer.processNextRequest();

  7. 写一个简单的网页

    • 用途:在配网时,用户需要进入一个简单的页面,输入并提交对应的Wi-Fi SSID以及密码。

    • 示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      const char index_html[] PROGMEM = R"keqing(
      <head>
      <meta charset="UTF-8">
      <title>设备配网页</title>
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <style>
      html {font-family: Arial; display: inline-block; text-align: center;}
      h2 {font-weight: bolder; margin-top: 15px;}
      input {margin-bottom: 5px; height: 35px; border-radius: 8px;}
      button {margin-top: 5px; border-radius: 8px; width: 100px; height: 35px;}
      body {margin:x auto;}
      </style>
      </head>

      <body>
      <h2>ElectroDragon</h2>
      <h4>输入WiFi名称和密码, 然后提交</h4>
      <h5 style="color: red;">请输入不超过 20 位的数字或英文字母</h5>
      <form action="wifi" method="POST">
      <input name="ssid" placeholder="请输入WiFi SSID"><br>
      <input name="password" placeholder="请输入WiFi密码"><br>
      <button type="submit">提交</button>
      </form>
      </body>
      )keqing";
    • 部分解释:

      1. <meta charset="UTF-8">meta是网页的元数据标签,charset="UTF-8"表示指定UTF-8编码,与IDE一致,可以防止编码错误,导致在用户设备上显示乱码。

      2. 语句const char index_html[] PROGMEM = R"keqing()keqing"

        关键字 解释
        const 常量
        char 字符类型。字符串的基础单位。
        index_html[] 字符数组
        PROGMEM Program Memory,程序存储器
        R"keqing()keqing" 原始字符串字面量 (Raw String Literal),解决字符串需要转义的问题。R开始,括号内的字符会被原样保留。可以写作R"()",也可以加上喜欢的标签(例如keqing),防止字符串内出现括号导致解析错误。
      3. forminputform表单可以通过type为submit的按钮提交,以键值对保存参数,键为input中的name

      4. 关于样式定义和其他内容,请自行查找。

  8. THE END

​ 没了。