本文将演示如何在一个 ESP-12F 模块上实现 webserver ,并且可以通过 web 请求对与模块连接的继电器进行控制。
首先,假设本文的读者了解 C 语言、逻辑电路和 HTTP 协议。再次,本文适合物联网开发者和有意向涉及物联网项目的 web 开发者、移动开发者阅读 。最后,如果你只需要了解实现过程,你可以继续往下看,如果你想亲自体验这神奇的过程,除了常用的一些装备和动手能力以外你还要需要准备以下材料。
ESP-12F 是基于 Espressif ESP8266 芯片开发的 WIFI 控制模块,支持 802.11 b/g/n/e/i 标准并集成了 Tensilica L106 32 位控制器、 4 MB Flash 和 64 KB SRAM 。
Espressif 为 ESP8266 已经移植好了操作系统并且在 github 上开放了 sdk ,这个 SDK 已经实现了 TCP/IP ,只需要实现 http 协议就可以完成 webserver 的功能。
本例涉及的所有资料和代码在本文最后一节都提供了参考链接,由于笔者能力有限,本文内难免会有一些错误,也请各位读者积极纠正。
ESP-12F 在 Linux 或 Mac OS 下开发并在 Windows 下烧录会更容易。 官网提供了安装好开发环境的虚拟机镜像。安装和配置开发环境不在本文讨论范围内,本文最后一章提供的链接会有很大帮助。
本文使用的开发环境是 CentOS7 / crosstool-NG / ESP8266_RTOS_SDK
注意: 如果不擅长自己配置开发环境, esp-open-sdk 项目中的 Readme 会指导如何配置开发环境并创建项目。
按照官方提供的描述连接线路即可,使用面包板和杜邦线连接可以有助于重复使用器件。本文尾提供的链接会很大有帮助。
注意:
在正式开发之前,需要测试硬件是否工作正常。由于 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();
注意:
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);
当绑定端口成功以后 accept()
方法就会阻塞程序运行,直到有访问请求。当有连接进入的时候(假设是没有 request body 的 GET 请求),就可以获得 request 的 ID ,并且通过 read()
获取 request header 。当判断 request header 完成后,即可通过 write()
方法向 socket 输出 response header 和 response body ,当这一切都完成的时候,就可以使用close()
关闭连接。至此,一个 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);
}
webserver 肯定是要能服务静态文件的,现在需要手动创建文件系统,考虑到存储器特点、片上资源和计算能力,文件系统被设计成只读 ROM 并且文件的 MIME ,大小,路径等信息被提前存到文件系统里。
ROM 文件系统被分为两个区域,从 ROM 文件系统开始前 64KB 被划分为 FAT 区域,余下的区域都是文件数据存储区; FAT 区域被分为 512 个 128B 大小的文件条目存储区,每个条目保存一条文件信息,其中前 0x40 字节用于保存文件名, 0x40-0x77 用于保存文件的 MIME 数据, 0x78-0x7B 保存文件大小, 0x7C-0x7F 保存文件开头部分相对于 ROM 首字节的相对偏移量也可以称作文件的位置。
注意
按照上节说到的文件系统,需要把一个特定目录下的所有文件转为一个单独的二进制文件才可以烧录到模块上。这个过程需要先扫描目录内所有文件并获取文件名,再根据名文件名获取文件相关属性将所有的文件信息写入 ROM 文件的 FAT 区,最后将文件二进制流附加在后面,并在文件开始位置 4B 对齐。
注意:
我们需要根据文件名来读取文件,并不是直接读取文件,因此先要在 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;
}
}
动态请求的 URI 一般指向的不是一个真实存在的路径,因此需要区分动态请求和静态请求。本例会把 URI 由 /cgi/ 开头的请求视为动态请求。并且讲动态请求传入一个 Router ,有 Router 把请求转发给每个执行动态的请求的文件或函数,我们称之为 Controller 。
代码 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);
}
由于 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 输出低电平
由于使用了 SDK 内集成了 FreeROTS 操作系统,因此我们可以把整个 Server 启动等待链接和处理请求的过程分配成任务,这样在 server 运行过程中,模块的程序流不会被阻塞。关于 FreeROTS 的任务管理方面,在最后一节给出的链接里会有很大帮助。本例使用了创建任务 xTaskCreate ,挂起任务 vTaskDelay 和销毁任务 vTaskDelete 这三个任务 API 。
系统启动时先检查网络连接,当网络连接建立好后创建初始化 WebServer 的任务,当初始化完成后初始化任务会被删除并创建 WebServer 的主任务,当有请求进来时,主任务会创建 worker 任务去处理请求,当处理任务完成后, worker 任务会自行删除。
结合任务控制和其他的功能我们不难规划出一个 webserver ,具体项目代码在最后一章里有下载链接。
由于 GPIO 输出电平为 3.3V ,不足以驱动 5V 的继电器模块,因此需要使用 5V 的逻辑门电路辅助驱动,本例使用的是 CD4001 四或非门电路。
现在我们已经有了一个可以控制继电器的 Webserver ,再有一个前端也面就完美了。将制作好的静态页面写入 ROM 后烧录在 Flash 的 0 x 0010 0000 位置上。完美收工。关于前端实现不在本文讨论范畴,前端代码随项目代码在最后一章的连接里一起给出。
连接好线路,接通电源,进行最终调试。
我的 Webserver 工作正常,你的呢?
关于交叉编译器: 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 中国大陆 进行许可
1
manhere 2016-12-01 16:55:58 +08:00
不错,不过硬件用 nodemcu 的要更省事一些
|
2
Arnie97 2016-12-01 17:15:10 +08:00 via Android
不要求实时性的话,用 NodeMCU 简单多了~
|
3
liqinliqin 2016-12-01 17:21:14 +08:00
|
4
lozzow 2016-12-01 17:31:12 +08:00 via Android
我床头灯就就是这个做的,不过功能很简单,只有亮度调节
|
6
Livisme 2016-12-01 17:44:25 +08:00
想起了搞毕设的时候
|
7
roadna 2016-12-01 20:21:15 +08:00
作为家庭“智能”电器的控制面板, web 比 app 方便不少啊。
|