V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
ZCPgyer
V2EX  ›  程序员

[蒲公英技术征文] 如何在 ESP-12F/ESP8266 上实现 webserver

  •  2
     
  •   ZCPgyer · 2016-12-01 16:39:09 +08:00 · 4655 次点击
    这是一个创建于 2912 天前的主题,其中的信息可能已经有所发展或是发生改变。

    本文将演示如何在一个 ESP-12F 模块上实现 webserver ,并且可以通过 web 请求对与模块连接的继电器进行控制。

    0.写在前面

    首先,假设本文的读者了解 C 语言、逻辑电路和 HTTP 协议。再次,本文适合物联网开发者和有意向涉及物联网项目的 web 开发者、移动开发者阅读 。最后,如果你只需要了解实现过程,你可以继续往下看,如果你想亲自体验这神奇的过程,除了常用的一些装备和动手能力以外你还要需要准备以下材料。

    ESP-12F 是基于 Espressif ESP8266 芯片开发的 WIFI 控制模块,支持 802.11 b/g/n/e/i 标准并集成了 Tensilica L106 32 位控制器、 4 MB Flash 和 64 KB SRAM 。

    ESP-12F 模块

    Espressif 为 ESP8266 已经移植好了操作系统并且在 github 上开放了 sdk ,这个 SDK 已经实现了 TCP/IP ,只需要实现 http 协议就可以完成 webserver 的功能。

    本例涉及的所有资料和代码在本文最后一节都提供了参考链接,由于笔者能力有限,本文内难免会有一些错误,也请各位读者积极纠正。

    1.开发环境

    ESP-12F 在 Linux 或 Mac OS 下开发并在 Windows 下烧录会更容易。 官网提供了安装好开发环境的虚拟机镜像。安装和配置开发环境不在本文讨论范围内,本文最后一章提供的链接会有很大帮助。

    本文使用的开发环境是 CentOS7 / crosstool-NG / ESP8266_RTOS_SDK

    注意: 如果不擅长自己配置开发环境, esp-open-sdk 项目中的 Readme 会指导如何配置开发环境并创建项目。

    2.硬件的连接和烧录

    按照官方提供的描述连接线路即可,使用面包板和杜邦线连接可以有助于重复使用器件。本文尾提供的链接会很大有帮助。

    注意:

    1. 烧录时需要更改连接到下载模式,否则无法写入程序。烧录以后需要更改连接到 flash boot 模式,否则将无法 boot 。
    2. 烧录过程中需要上电同步,可以给模块掉电在加电也可以把模块 RST 端接地超过一秒重启模块。
    3. ESP-12F 是 3.3 V 电源供电,使用 5V 电源或 USB 供电的同学需要装备 5V-3.3V 电源转换模块。

    使用杜邦线连接以便重复利用模块

    3.测试硬件状态并了解开发流程

    在正式开发之前,需要测试硬件是否工作正常。由于 ESP-12F 不具备任何显示部件,因此调试需要借助串口打印信息。我们在 user/user_main.c 内写入如下代码初始化串口并向串口打印一条信息。同时你还需要链接 wifi 网络。

    代码 3-1: 初始化串口并打印调试信息

      UART_WaitTxFifoEmpty(UART0);  
      UART_WaitTxFifoEmpty(UART1);
      UART_ConfigTypeDef uart_config;
      uart_config.baud_rate = BIT_RATE_115200;  //波特率
      uart_config.data_bits = UART_WordLength_8b; //字长度
      uart_config.parity = USART_Parity_None; //校验位
      uart_config.stop_bits = USART_StopBits_1; //停止位
      uart_config.flow_ctrl = USART_HardwareFlowControl_None;
      uart_config.UART_RxFlowThresh = 120;
      uart_config.UART_InverseMask = UART_None_Inverse;
      UART_ParamConfig(UART0, &uart_config);
      UART_SetPrintPort(UART0);
     // 向串口输出一条信息
      printf("Hello World");
    

    代码 3-2:初始化 wifi 连接

      wifi_set_opmode(STATION_MODE);
      struct station_config * wifi_config = (struct station_config *) zalloc(sizeof(struct station_config));
      sprintf(wifi_config->ssid, "your wifi ssid");
      sprintf(wifi_config->password, "your wifi password");
      wifi_station_set_config(wifi_config);
      free(wifi_config);
      wifi_station_connect();
    

    注意:

    1. 需要先打开串口工具再 boot 模块,否则会漏掉一些调试内容。
    2. wifi 链接创建好后在路由器管理界面就可以看到 IP 地址了。

    4.创建 Socket 并等待连接

    ESP8266_RTOS_SDK 提供了基于 lwip 的 Socket API ,我们只需要简单调用即可实现创建 Socket 并绑定端口的过程。

    代码 4-1:创建 socket 并绑定端口

    int32 listenfd;
    int32 ret;
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
      server_addr.sin_family = AF_INET; //IPV4
      server_addr.sin_addr.s_addr = INADDR_ANY; //任意访问 IP
      server_addr.sin_len = sizeof(server_addr);
      server_addr.sin_port = htons(80); //绑定端口
    do{
        listenfd = socket(AF_INET, SOCK_STREAM, 0);//创建 socket
    } while (listenfd == -1);
    do{
        ret = bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); //绑定端口
    } while (ret != 0);
    do{
        ret = listen(listenfd, SOT_SERVER_MAX_CONNECTIONS); //开始监听端口
    } while (ret != 0);
    

    5.处理 request

    当绑定端口成功以后 accept() 方法就会阻塞程序运行,直到有访问请求。当有连接进入的时候(假设是没有 request body 的 GET 请求),就可以获得 request 的 ID ,并且通过 read() 获取 request header 。当判断 request header 完成后,即可通过 write() 方法向 socket 输出 response header 和 response body ,当这一切都完成的时候,就可以使用close() 关闭连接。至此,一个 request 处理完成。

    注意:

    1. 我们无法实现判断 request header 的长度,而 read()方法会阻塞程序运行,因此我们需要判断 request header 是否完成以确定是否开始向 socket 写入 response 。
    2. 对与有 request body 的请求来说,需要解析 request header 中的 content-length 字段以获取 request body 的程度,从而判断 request body 是否结束以防止 read() 方法阻塞程序。
    3. 在获取 request header 的过程中必须要获取第一行报头的内容以确定请求类和需要访问的资源位置
    4. 关于报头标准请参照 http://www.ietf.org/rfc/rfc2616.txt

    处理 request 的过程

    代码 5-1:处理 request

    while((client_sock = accept(listenfd, (struct sockaddr *)&remote_addr, (socklen_t *)&len)) >= 0) {
      // recieveStatus 的含义 0. watting, 1. method get, 2. request URI get 3. finish recive 4. start send 5.send finished
      int recieveStatus = 0;
      bool cgiRequest = true;
      char recieveBuffer;
      char *httpMethod = (char *)zalloc(8);
      int httpMethodLength = 0;
      char *httpRequestUri = (char *)zalloc(64);
      int httpRequestUriLength = 0;
      char *httpStopFlag = (char *)zalloc(4);
      int httpStopFlagLength = 0;
      httpMethod[0] = 0;
      httpRequestUri[0] = 0;
      httpStopFlag[0] = 0;
      // loop for recieve data
      for(;;) {
        read(clientSock, &recieveBuffer, 1);
        if(recieveStatus == 0) {
        // 获取请求方式
          if(recieveBuffer != 32) {
            httpMethod[httpMethodLength] = recieveBuffer;
            httpMethodLength ++;
          } else {
            httpMethod[httpMethodLength] = 0;
            recieveStatus = 1;
          }
          continue;
        }
        if(recieveStatus == 1) {
        // 获取 URI
          if(recieveBuffer != 32) {
            httpRequestUri[httpRequestUriLength] = recieveBuffer;
            httpRequestUriLength ++;
          } else {
            httpRequestUri[httpRequestUriLength] = 0;
            recieveStatus = 2;
          }
          continue;
        }
        if(recieveStatus == 2) {
        //判断 header 是否结束, header 结束标记是一个空行 因此检测 header 最后 4 个字符是否是连续的\r\n\r\n 即可
          if(recieveBuffer == 10 || recieveBuffer == 13) {
            httpStopFlag[httpStopFlagLength] = recieveBuffer;
            httpStopFlagLength ++;
            httpStopFlag[httpStopFlagLength] = 0;
            if( httpStopFlag[0] == 13 && httpStopFlag[1] == 10 &&  httpStopFlag[2] == 13 && httpStopFlag[3] == 10) {
              recieveStatus == 3;
              break;
            }
          } else {
            httpStopFlagLength = 0;
            httpStopFlag[httpStopFlagLength] = 0;
          }
          continue;
        }
      }
      // 向串口打印获取的信息 可以判断访问是否正确
      printf("Method=%s SOCK=%d\n", httpMethod, clientSock);
      printf("URI=%s SOCK=%d\n", httpRequestUri, clientSock);
      printf("CGIRequestFlag=%d SOCK=%d\n", cgiRequest, clientSock);
      //输出 response header
      write(clientSock, "HTTP/1.1 200 OK\r\n", strlen("HTTP/1.1 200 OK\r\n"));
      write(clientSock, "Server: SOTServer\r\n", strlen("Server: SOTServer\r\n"));
      write(clientSock, "Content-Type: text/plain; charset=utf-8\r\n", strlen("Content-Type: text/plain; charset=utf-8\r\n"));
      write(clientSock, "\r\n", 2);
      //输出 respose body
      write(clientSock, "Hello World", strlen("Hello World"));
      //关闭链接
      close(clientSock);
    }
    

    6.规划 ROM 文件系统

    webserver 肯定是要能服务静态文件的,现在需要手动创建文件系统,考虑到存储器特点、片上资源和计算能力,文件系统被设计成只读 ROM 并且文件的 MIME ,大小,路径等信息被提前存到文件系统里。

    ROM 文件系统被分为两个区域,从 ROM 文件系统开始前 64KB 被划分为 FAT 区域,余下的区域都是文件数据存储区; FAT 区域被分为 512 个 128B 大小的文件条目存储区,每个条目保存一条文件信息,其中前 0x40 字节用于保存文件名, 0x40-0x77 用于保存文件的 MIME 数据, 0x78-0x7B 保存文件大小, 0x7C-0x7F 保存文件开头部分相对于 ROM 首字节的相对偏移量也可以称作文件的位置。

    文件系统分配

    注意

    • 由于 SPI Flash 读数据需要 4B 对齐,所以 ROM 系统内所有文件开始位置必须是 4B 对齐的。

    7.制作静态文件 ROM

    按照上节说到的文件系统,需要把一个特定目录下的所有文件转为一个单独的二进制文件才可以烧录到模块上。这个过程需要先扫描目录内所有文件并获取文件名,再根据名文件名获取文件相关属性将所有的文件信息写入 ROM 文件的 FAT 区,最后将文件二进制流附加在后面,并在文件开始位置 4B 对齐。

    ROM 创建过程

    注意:

    1. 创建 ROM 的 shell 脚本可以在最后一章的链接里获得。
    2. 按照官方推荐的 Flash 布局, ROM 建议烧录在 Flash 的 0 x 0010 0000 位置

    8.读取 ROM 文件内容

    我们需要根据文件名来读取文件,并不是直接读取文件,因此先要在 ROM 的 FAT 区里查找对应文件名的存在位置、 MIME 、大小和存放区域,再去读取文件内容,当读到文件尾的时候不在读取。官方的 spi_flash_read 接口只能读取指定位置的指定长度的数据,这对我们读区文件很不方便。

    代码 8-1:文件系统实现

    // 所谓的文件句柄 保存已经打开文件的信息
    struct SOTROM_filePointer {
      uint32 location;
      uint32 offset;
      uint32 fileSize;
      bool fileExsit;
      char *mime;
    };
    typedef struct SOTROM_filePointer SOTROM_file;
    define SOT_ROM_ORG 0x00100000;
    define SOT_ROM_FAT_SIZE 0x00010000;
    //读区文件 FAT ,匹配每一条文件条目是否于请求的文件名一致,一致则读取信息并返回,否则返回空文件句柄。
    SOTROM_file *SOTROM_fopen(char* fileName) {
      SOTROM_file *openedFile;
      openedFile = malloc(70);
      openedFile->location = 0;
      openedFile->offset = 0;
      openedFile->fileSize = 0;
      openedFile->fileExsit = false;
      // 查找 FAT 区域
      char *pointerFilename = (char *)zalloc(64);
      uint32 currentFATPointer = SOT_ROM_ORG;
      uint32 maxFATPointer = SOT_ROM_ORG + SOT_ROM_FAT_SIZE;
      SpiFlashOpResult res;
      while(currentFATPointer < maxFATPointer) {
        // 获得文件名
        res = spi_flash_read(currentFATPointer, (uint32* )pointerFilename, 64);
        if(res == SPI_FLASH_RESULT_OK) {
          if(strlen(pointerFilename) > 0) {
            if(strcmp(fileName, pointerFilename) == 0) {
              char *pointerFilename = (char *)zalloc(56);
              uint32 fileSize;
              uint32 location;
              res |= spi_flash_read(currentFATPointer + 64, (uint32* )pointerFilename, 56);
              res |= spi_flash_read(currentFATPointer + 120, (uint32* )&fileSize, 4);
              res |= spi_flash_read(currentFATPointer + 124, (uint32* )&location, 4);
              if(res == SPI_FLASH_RESULT_OK) {
                openedFile->fileExsit = true;
                openedFile->mime = pointerFilename;
                openedFile->fileSize = fileSize;
                openedFile->location = location;
                openedFile->location += maxFATPointer;
                break;
              }
            }
            currentFATPointer += 128;
          } else {
            break;
          }
        } else {
          break;
        }
      }
      // 有助于调试的调试信息
      // printf("file found: %d\n", openedFile->fileExsit);
      // printf("file mime: %s\n", openedFile->mime);
      // printf("file length: %d\n", openedFile->fileSize);
      // printf("file location: %d\n", openedFile->location);
      // printf("file offset: %d\n", openedFile->offset);
      return openedFile;
    }
    // 从 SOTROM_fopen 打开的文件里 获取在 offset 指针处读取 datalength 长度的数据并输出到 data 里,并设置 offset 到下一字节位置。若文件长度小于 offset + datalength 只读区到文件末尾
    bool SOTROM_fread(SOTROM_file *file, uint32 *data, int32 datalength) {
      // 检查文件是否存在
      if(!file->fileExsit) {
        return false;
      }
      int32 fileLength = file->fileSize;
      int32 currentOffset = file->offset;
      int32 startReadLocation = file->location + currentOffset;
      // 若指针已经到达文件结尾不读数据
      if(currentOffset >= fileLength) {
        return false;
      }
      // 若超过文件结尾则只读取到文件结尾
      if(currentOffset + datalength > fileLength) {
        datalength = fileLength - currentOffset;
      }
      SpiFlashOpResult res;
      res = spi_flash_read(startReadLocation, data, datalength);
      if(res == SPI_FLASH_RESULT_OK) {
        file->offset = currentOffset + datalength;
        char *tmpDataPtr = (char *)data;
        tmpDataPtr[datalength] = 0;
        return true;
      } else {
        return false;
      }
    }
    

    9.处理动态请求

    动态请求的 URI 一般指向的不是一个真实存在的路径,因此需要区分动态请求和静态请求。本例会把 URI 由 /cgi/ 开头的请求视为动态请求。并且讲动态请求传入一个 Router ,有 Router 把请求转发给每个执行动态的请求的文件或函数,我们称之为 Controller 。

    router 的工作过程

    代码 9-1:router 实现的代码

    void SOTCGI_PROG(char *para, int32 sock)
    // CGI 入口文件,传 socket 连接 ID 和 URL 即可
    void SOTCGI_handler(char * cgiURI, int32 sock) {
      char *response = (char *)zalloc(64);
      SOTCGI_route("/cgi/demo0/", cgiURI, sock, SOTCGI_PROG);
      SOTCGI_route("/cgi/demo1/", cgiURI, sock, SOTCGI_PROG);
    }
    // CGI Router 设置, 根据指定地址 route 绑定指定控制器 callback 。
    void SOTCGI_route(char *route, char *cgiURI, int32 sock, void (* callback)()) {
      if(strncmp(route, cgiURI, strlen(route)) == 0) {
        char *para = substr(cgiURI, strlen(route), strlen(cgiURI));
        (* callback)(para, sock);
        free(para);
      }
    }
    

    代码 9-2:controller 实现的代码模版

    void SOTCGI_PROG(char *para, int32 sock) {
      printf("GET CGI input: %s\n", para);
    }
    

    10.GPIO 的控制

    由于 GPIO 与普通 IO 不一样,因此在使用前必须设置 GPIO 的功能, SDK 为每个 GPIO 都设定了五种功能,使用前需要使用 PIN_FUNC_SELECT 宏函数进行设置,具体每个 GPIO 口的功能,在最后一节给出的链接里会有很大帮助。本例只使用了 GPIO 最基本的逻辑输出的功能。具体 GPOI 功能设置可以参照 SDK 的 API 参考文档。

    代码 10-1:逻辑输出的实现

    PIN_FUNC_SELECT(PERIPHS_IO_MUX_MTDI_U, FUNC_GPIO12);//将 PERIPHS_IO_MUX_MTDI_U 接口绑定为 FUNC_GPIO12 输出功能
    gpio_output_set(BIT12, 0, BIT12, 0); // GPIO12 输出高电平
    gpio_output_set(0, BIT12, BIT12, 0); // GPIO12 输出低电平
    

    11.任务控制

    由于使用了 SDK 内集成了 FreeROTS 操作系统,因此我们可以把整个 Server 启动等待链接和处理请求的过程分配成任务,这样在 server 运行过程中,模块的程序流不会被阻塞。关于 FreeROTS 的任务管理方面,在最后一节给出的链接里会有很大帮助。本例使用了创建任务 xTaskCreate ,挂起任务 vTaskDelay 和销毁任务 vTaskDelete 这三个任务 API 。

    系统启动时先检查网络连接,当网络连接建立好后创建初始化 WebServer 的任务,当初始化完成后初始化任务会被删除并创建 WebServer 的主任务,当有请求进来时,主任务会创建 worker 任务去处理请求,当处理任务完成后, worker 任务会自行删除。

    任务控制

    12.实现 webserver

    结合任务控制和其他的功能我们不难规划出一个 webserver ,具体项目代码在最后一章里有下载链接。

    13.驱动 5V 继电器

    由于 GPIO 输出电平为 3.3V ,不足以驱动 5V 的继电器模块,因此需要使用 5V 的逻辑门电路辅助驱动,本例使用的是 CD4001 四或非门电路。

    14.制作静态页面

    现在我们已经有了一个可以控制继电器的 Webserver ,再有一个前端也面就完美了。将制作好的静态页面写入 ROM 后烧录在 Flash 的 0 x 0010 0000 位置上。完美收工。关于前端实现不在本文讨论范畴,前端代码随项目代码在最后一章的连接里一起给出。

    15.接入调试

    连接好线路,接通电源,进行最终调试。

    最终调试

    我的 Webserver 工作正常,你的呢?

    16.相关资源及项目代码

    关于交叉编译器: https://github.com/esp8266/esp8266-wiki/wiki/Toolchain https://github.com/jcmvbkbc/crosstool-NG http://bbs.espressif.com/viewtopic.php?f=57&t=2

    关于烧写工具: https://github.com/esp8266/esp8266-wiki/wiki/Toolchain http://bbs.espressif.com/viewtopic.php?f=57&t=433

    关于 SDK : https://github.com/espressif/ESP8266_RTOS_SDK https://github.com/pfalcon/esp-open-sdk

    关于 ESP8266 的技术支持文档: http://espressif.com/en/support/download/documents?keys=&field_type_tid%5B%5D=14

    关于硬件的连接和烧录 http://espressif.com/sites/default/files/documentation/2a-esp8266-sdk_getting_started_guide_en.pdf

    关于 GPIO 的功能的描述 http://espressif.com/sites/default/files/documentation/0d-esp8266_pin_list_release_15-11-2014.xlsx

    关于 FreeROTS 的使用 http://www.freertos.org/FreeRTOS-quick-start-guide.html

    本示例源代码 https://github.com/cubicwork/SOTServer-demo

    SOTServer + SOTROM github 项目( 代码整理好以后会开放源代码 ) https://github.com/cubicwork/SOTServer


    作者: CarneyWu

    本文来自 [蒲公英技术征文] ,详情链接: https://jinshuju.net/f/dGmewL 本活动中用户内容均采用 署名-非商业性使用-相同方式共享 3.0 中国大陆 进行许可

    7 条回复    2016-12-01 20:21:15 +08:00
    manhere
        1
    manhere  
       2016-12-01 16:55:58 +08:00
    不错,不过硬件用 nodemcu 的要更省事一些
    Arnie97
        2
    Arnie97  
       2016-12-01 17:15:10 +08:00 via Android
    不要求实时性的话,用 NodeMCU 简单多了~
    liqinliqin
        3
    liqinliqin  
       2016-12-01 17:21:14 +08:00
    lozzow
        4
    lozzow  
       2016-12-01 17:31:12 +08:00 via Android
    我床头灯就就是这个做的,不过功能很简单,只有亮度调节
    yanzixuan
        5
    yanzixuan  
       2016-12-01 17:44:11 +08:00
    @lozzow 我用这个实现了三色等 PWM 调节。然而感觉不能玩自动匹配加入 WIFI ,可玩性就差点啊
    Livisme
        6
    Livisme  
       2016-12-01 17:44:25 +08:00
    想起了搞毕设的时候
    roadna
        7
    roadna  
       2016-12-01 20:21:15 +08:00
    作为家庭“智能”电器的控制面板, web 比 app 方便不少啊。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1777 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 24ms · UTC 16:35 · PVG 00:35 · LAX 08:35 · JFK 11:35
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.