本文将演示如何在一个 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.硬件的连接和烧录
按照官方提供的描述连接线路即可,使用面包板和杜邦线连接可以有助于重复使用器件。本文尾提供的链接会很大有帮助。
注意:
烧录时需要更改连接到下载模式,否则无法写入程序。烧录以后需要更改连接到flash boot模式,否则将无法boot。
烧录过程中需要上电同步,可以给模块掉电在加电也可以把模块RST端接地超过一秒重启模块。
ESP-12F是3.3 V 电源供电,使用5V电源或USB供电的同学需要装备5V-3.3V 电源转换模块。
使用杜邦线连接以便重复利用模块
3.测试硬件状态并了解开发流程
在正式开发之前,需要测试硬件是否工作正常。由于ESP-12F不具备任何显示部件,因此调试需要借助串口打印信息。我们在 user/user_main.c 内写入如下代码初始化串口并向串口打印一条信息。同时你还需要链接wifi网络。
代码3-1: 初始化串口并打印调试信息
// 初始化UART 用户需要按照相同的设置设置串口调试工具
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连接
// init wifi connection
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();
注意:
需要先打开串口工具再boot模块,否则会漏掉一些调试内容。
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处理完成。
注意:
我们无法实现判断request header的长度,而read()方法会阻塞程序运行,因此我们需要判断request header 是否完成以确定是否开始向socket写入response。
对与有 request body 的请求来说,需要解析request header 中的 content-length 字段以获取request body的程度,从而判断request body 是否结束以防止 read() 方法阻塞程序。
在获取request header 的过程中必须要获取第一行报头的内容以确定请求类和需要访问的资源位置
关于报头标准请参照 http://www.ietf.org/rfc/rfc26...
处理 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创建过程
注意:
创建ROM的shell脚本可以在最后一章的链接里获得。
按照官方推荐的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/es...
https://github.com/jcmvbkbc/c...
http://bbs.espressif.com/view...
关于烧写工具:
https://github.com/esp8266/es...
http://bbs.espressif.com/view...
关于SDK:
https://github.com/espressif/...
https://github.com/pfalcon/es...
关于ESP8266的技术支持文档:
http://espressif.com/en/suppo...
关于硬件的连接和烧录
http://espressif.com/sites/de...
关于GPIO的功能的描述
http://espressif.com/sites/de...
关于FreeROTS的使用
http://www.freertos.org/FreeR...
本示例源代码
https://github.com/cubicwork/...
SOTServer + SOTROM github项目( 代码整理好以后会开放源代码 )
https://github.com/cubicwork/...
作者:CarneyWu
本文来自【蒲公英技术征文】,详情链接:https://jinshuju.net/f/dGmewL
本活动用户内容均采用 署名-非商业性使用-相同方式共享 3.0 中国大陆 进行许可