赞
踩
本文主要介绍一下物联网协议如NTP协议、HTTP协议和MQTT协议的接口使用
在了解WIFI之前需要了解一下TCP/IP协议和lwIP协议,参考以下链接:https://blog.csdn.net/weixin_44567668/article/details/139619797
首先lwIP协议是一种专为嵌入式系统设计的轻量级TCP/IP协议栈。lwIP与 TCP/IP体系结构的对应关系:
回到WIFI,市面上很多都是以太网或者WIFI透传模块,他们有的使用SPI接口,有的使用UART串口来与MCU进行通讯,如ESP8266。但实际上有部分芯片是内嵌WIFI模组的MCU,如ESP32、W601等等。这里以ESP32-S3为例,其内嵌WiFi MAC内核,只需了解它扮演 TCP/IP协议的网络接口层角色即可。如下图所示:
到这里对WIFI已经有基本的了解,我这里再扩充一下WLAN设备。WLAN 框架是RT-Thread开发的一套用于管理WIFI的中间件。对下连接具体的WIFI驱动,控制 WIFI 的连接断开,扫描等操作。对上承载不同的应用,为应用提供 WIFI 控制,事件,数据导流等操作,为上层应用提供统一的 WIFI 控制接口。WIFI 框架层次图:
NTP(Network Time Protocol)网络时间协议基于UDP,是用来使计算机时间同步化的一种协议,它可以使计算机对其服务器或时钟源(如石英钟,GPS 等等)做同步化,它可以提供高精准度的时间校正(LAN 上与标准间差小于 1 毫秒,WAN 上几十毫秒),且可介由加密确认的方式来防止恶毒的协议攻击。时间按 NTP 服务器的等级传播。按照离外部 UTC 源的远近把所有服务器归入不同的 Stratum(层)中。
NTP数据报文格式,如下图所示:
NTP数据报文格式的各个字段的作用,如下表所示:
从上表可知,NTP 报文的字段非常多,这些字段并不是每一个都必须设置的,可以根据项目的需要来构建 NTP 请求报文。
由上可以知道获取 NTP 实时时间步骤了:
① 以 UDP 协议连接阿里云 NTP 服务器
② 发送 NTP 报文到阿里云 NTP 服务器
③ 获取阿里云 NTP服务器返回的数据,取第 40 位到 43 位的十六进制数值。
④ 把 40 位到 43 位的十六进制数值转成十进制
⑤ 把十进制数值减去1900-1970 的时间差(2208988800 秒)
⑥ 数值转成年月日时分秒
lwip_demo.h
头文件#define NTP_DEMO_RX_BUFSIZE 2000 /* 定义udp最大接收数据长度 */ #define NTP_DEMO_PORT 123 /* 定义udp连接的本地端口号 */ typedef struct _NPTformat { char version; /* 版本号 */ char leap; /* 时钟同步 */ char mode; /* 模式 */ char stratum; /* 系统时钟的层数 */ char poll; /* 更新间隔 */ signed char precision; /* 精密度 */ unsigned int rootdelay; /* 本地到主参考时钟源的往返时间 */ unsigned int rootdisp; /* 统时钟相对于主参考时钟的最大误差 */ char refid; /* 参考识别码 */ unsigned long long reftime;/* 参考时间 */ unsigned long long org; /* 开始的时间戳 */ unsigned long long rec; /* 收到的时间戳 */ unsigned long long xmt; /* 传输时间戳 */ } NPTformat; typedef struct _DateTime /*此结构体定义了NTP时间同步的相关变量*/ { int year; /* 年 */ int month; /* 月 */ int day; /* 天 */ int hour; /* 时 */ int minute; /* 分 */ int second; /* 秒 */ } DateTime; #define SECS_PERDAY 86400UL /* 一天中的几秒钟 = 60*60*24 */ #define UTC_ADJ_HRS 8 /* SEOUL : GMT+8(东八区北京) */ #define EPOCH 1900 /* NTP 起始年 */ #define HOST_NAME "ntp1.aliyun.com" /*阿里云NTP服务器域名 */
lwip_demo.c
源文件#define NTP_TIMESTAMP_DELTA 2208988800UL const char g_days[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; NPTformat g_ntpformat; /* NT数据包结构体 */ DateTime g_nowdate; /* 时间结构体 */ uint8_t g_ntp_message[48]; /* 发送数据包的缓存区 */ uint8_t g_ntp_demo_recvbuf[NTP_DEMO_RX_BUFSIZE]; /* NTP接收数据缓冲区 */ uint8_t g_lwip_time_buf[100]; /** *@brief 计算日期时间 *@param secondsUTC 世界标准时间 *@retval 无 */ void lwip_calc_date_time(unsigned long long time) { unsigned int Pass4year; int hours_per_year; if (time <= 0) { time = 0; } g_nowdate.second = (int)(time % 60); /* 取秒时间 */ time /= 60; g_nowdate.minute = (int)(time % 60); /* 取分钟时间 */ time /= 60; g_nowdate.hour = (int)(time % 24); /* 小时数 */ Pass4year = ((unsigned int)time / (1461L * 24L));/* 取过去多少个四年,每四年有 1461*24 小时 */ g_nowdate.year = (Pass4year << 2) + 1970; /* 计算年份 */ time %= 1461 * 24; /* 四年中剩下的小时数 */ for (;;) /* 校正闰年影响的年份,计算一年中剩下的小时数 */ { hours_per_year = 365 * 24; /* 一年的小时数 */ if ((g_nowdate.year & 3) == 0) /* 判断闰年 */ { hours_per_year += 24; /* 是闰年,一年则多24小时,即一天 */ } if (time < hours_per_year) { break; } g_nowdate.year++; time -= hours_per_year; } time /= 24; /* 一年中剩下的天数 */ time++; /* 假定为闰年 */ if ((g_nowdate.year & 3) == 0) /* 校正闰年的误差,计算月份,日期 */ { if (time > 60) { time--; } else { if (time == 60) { g_nowdate.month = 1; g_nowdate.day = 29; return ; } } } for (g_nowdate.month = 0; g_days[g_nowdate.month] < time; g_nowdate.month++) /* 计算月日 */ { time -= g_days[g_nowdate.month]; } g_nowdate.day = (int)(time); return; } /** *@brief 从NTP服务器获取时间 *@param buf:存放缓存 *@param idx:定义存放数据起始位置 *@retval 无 */ void lwip_get_seconds_from_ntp_server(uint8_t *buf, uint16_t idx) { unsigned long long atk_seconds = 0; uint8_t i = 0; for (i = 0; i < 4; i++) /* 获取40~43位的数据 */ { atk_seconds = (atk_seconds << 8) | buf[idx + i]; /* 把40~43位转成16进制再转成十进制 */ } atk_seconds -= NTP_TIMESTAMP_DELTA;/* 减去减去1900-1970的时间差(2208988800秒) */ lwip_calc_date_time(atk_seconds); /* 由UTC时间计算日期 */ } /** *@brief 初始化NTP Client信息 *@param 无 *@retval 无 */ void lwip_ntp_client_init(void) { uint8_t flag; g_ntpformat.leap = 0; /* leap indicator */ g_ntpformat.version = 3; /* version number */ g_ntpformat.mode = 3; /* mode */ g_ntpformat.stratum = 0; /* stratum */ g_ntpformat.poll = 0; /* poll interval */ g_ntpformat.precision = 0; /* precision */ g_ntpformat.rootdelay = 0; /* root delay */ g_ntpformat.rootdisp = 0; /* root dispersion */ g_ntpformat.refid = 0; /* reference ID */ g_ntpformat.reftime = 0; /* reference time */ g_ntpformat.org = 0; /* origin timestamp */ g_ntpformat.rec = 0; /* receive timestamp */ g_ntpformat.xmt = 0; /* transmit timestamp */ flag = (g_ntpformat.version << 3) + g_ntpformat.mode; /* one byte Flag */ memcpy(g_ntp_message, (void const *)(&flag), 1); } /** * @brief lwip_demo程序入口 * @param 无 * @retval 无 */ void lwip_demo(void) { err_t err; static struct netconn *udpconn; static struct netbuf *recvbuf; static struct netbuf *sentbuf; ip_addr_t destipaddr; uint32_t data_len = 0; struct pbuf *q; lwip_ntp_client_init(); /* 第一步:创建udp控制块 */ udpconn = netconn_new(NETCONN_UDP); /* 定义接收超时时间 */ udpconn->recv_timeout = 10; if (udpconn != NULL) /* 判断创建控制块释放成功 */ { /* 第二步:绑定控制块、本地IP和端口 */ err = netconn_bind(udpconn, IP_ADDR_ANY, NTP_DEMO_PORT); /* 域名解析 */ netconn_gethostbyname((char *)(HOST_NAME), &(destipaddr)); /* 第三步:连接或者建立对话框 */ netconn_connect(udpconn, &destipaddr, NTP_DEMO_PORT); /* 连接到远端主机 */ if (err == ERR_OK) /* 绑定完成 */ { while (1) { sentbuf = netbuf_new(); netbuf_alloc(sentbuf, 48); memcpy(sentbuf->p->payload, (void *)g_ntp_message, sizeof(g_ntp_message)); err = netconn_send(udpconn, sentbuf); /* 将sentbuf中的数据发送出去 */ if (err != ERR_OK) { printf("发送失败\r\n"); netbuf_delete(sentbuf); /* 删除buf */ } netbuf_delete(sentbuf); /* 删除buf */ /* 第五步:接收数据 */ netconn_recv(udpconn, &recvbuf); vTaskDelay(1000); /* 延时1s */ if (recvbuf != NULL) /* 接收到数据 */ { memset(g_ntp_demo_recvbuf, 0, NTP_DEMO_RX_BUFSIZE); /*数据接收缓冲区清零 */ for (q = recvbuf->p; q != NULL; q = q->next) /*遍历完整个pbuf链表 */ { /* 判断要拷贝到UDP_DEMO_RX_BUFSIZE中的数据是否大于UDP_DEMO_RX_BUFSIZE的剩余空间,如果大于 */ /* 的话就只拷贝UDP_DEMO_RX_BUFSIZE中剩余长度的数据,否则的话就拷贝所有的数据 */ if (q->len > (NTP_DEMO_RX_BUFSIZE - data_len)) memcpy(g_ntp_demo_recvbuf + data_len, q->payload, (NTP_DEMO_RX_BUFSIZE - data_len)); /* 拷贝数据 */ else memcpy(g_ntp_demo_recvbuf + data_len, q->payload, q->len); data_len += q->len; if (data_len > NTP_DEMO_RX_BUFSIZE) break; /* 超出TCP客户端接收数组,跳出 */ } data_len = 0; /* 复制完成后data_len要清零 */ lwip_get_seconds_from_ntp_server(g_ntp_demo_recvbuf,40); /* 从NTP服务器获取时间 */ printf("北京时间:%02d-%02d-%02d %02d:%02d:%02d\r\n", g_nowdate.year, g_nowdate.month + 1, g_nowdate.day, g_nowdate.hour + 8, g_nowdate.minute, g_nowdate.second); sprintf((char*)g_lwip_time_buf,"BJ time:%02d-%02d-%02d %02d:%02d:%02d", g_nowdate.year, g_nowdate.month + 1, g_nowdate.day, g_nowdate.hour + 8, g_nowdate.minute, g_nowdate.second); lcd_show_string(5, 170, lcddev.width, 16, 16, (char*)g_lwip_time_buf, RED); netbuf_delete(recvbuf); /* 删除buf */ } else vTaskDelay(5); /* 延时5ms */ } } else printf("NTP绑定失败\r\n"); } else printf("NTP连接创建失败\r\n"); }
在此文件下定义了四个函数,这些函数的作用如下表所示:
函数 | 描述 |
---|---|
lwip_demo() | 实现UDP连接,使用NETCONN接口 |
lwip_ntp_client_init() | 构建NTP请求报文 |
lwip_get_seconds_from_ntp_server() | 获取NTP服务器的数据 |
lwip_calc_date_time() | 计算日期时间 |
HTTP(Hypertext Transfer Protocol)协议,即超文本传输协议,是用于从万维网(WWW:World Wide Web )服务器传输超文本到本地浏览器的传送协议。HTTP 协议是基于TCP/IP 协议的网络应用层协议。默认端口为80端口。HTTP 协议是一种请求/响应式的协议。一个客户端与服务器建立连接之后,发送一个请求给服务器。服务器接收到请求之后,通过接收到的信息判断响应方式,并且给予客户端相应的响应,完成整个 HTTP数据交互流程。
HTTP定义了与服务器交互的不同方法,其最基本的方法是 GET、PORT 和 HEAD。如下图所示。
互联网通过URL来定位,URL全称是 Uniform Resource Locator,是互联网上用来标识某一处资源的绝对地址,大部分 URL 都会遵循 URL 的语法,一个 URL 的组成有多个不同的组件,一个 URL的通用格式如下:
<scheme>://<user>:<password>@<host>:<port>/<path>;<params>?<query>#<frag>
HTTP 报文是由 3 个部分组成,分别是:对报文进行描述的“起始行”,包含属性的“首部”,以及可选的“数据主体”,对于请求报文与应答报文,只有“起始行”的格式是不一样的。起始行和首部就是由行分隔的 ASCII 文本组成,每行都以由两个字符组成的行终止序列作为结束,其中包括一个回车符(ASCII 码 13)和一个换行符(ASCII 码 10), 这个行终止序列可以写做 CRLF。
# HTTP请求报文
<method> <request-URL> <version> //起始行
<headers> //首部
<entity-body> //数据主体
# HTTP应答报文
<version> <status> <reason-phrase> //起始行
<headers> //首部
<entity-body> //数据主体
下面就对这两种 HTTP 报文的各个部分简单描述一下:
HTTP/<major>.<minor>
HTTP协议可以应用在客户端,也可以在服务器端,在客户端可以用来获取服务器数据,比如从服务器下载固件进行升级。也可以用在服务器端,那样我们可以做一个简单网页来访问控制单片机。同上一个例程一样,新建一个任务调用函数lwip_demo()
/* HTTP报头总是以响应码开头(例如HTTP/1.1 200 OK)和一个内容类型,以便客户端知道接下来是什么,然后是一个空行: */ /* 浏览器响应数据类型为文本数据 */ static const char http_html_hdr[] = "HTTP/1.1 200 OK\r\nContent-type: text/html\r\n\r\n"; static const char http_index_html[] = "<!DOCTYPE html>"\ "<html>"\ "<head>"\ "<title> Webserver实验 </title>"\ "<meta http-equiv='Content-Type' content='text/html; charset=GB2312'/>"\ "</head>"\ "<body>"\ "<h1>http server</h1>"\ "<div class='label' >"\ "<label>LED State:</label>"\ "</div>"\ "<div class='checkboxes'>"\ "<input type='checkbox' name='led1' value='1' />打开 <input type='checkbox' name='led1' value='2' />关闭"\ "</div>"\ "<br>"\ "<br>"\ "<div class='label'>"\ "<label>BEEP State:</label>"\ "</div>"\ "<div class='checkboxes'>"\ "<input type='checkbox' name='led1' value='1' />打开 <input type='checkbox' name='led1' value='2' />关闭"\ "</div>"\ "<br>"\ "<br>"\ "<input type='submit' class='sendbtn' value='发送'>"\ "<br>"\ "</body>"\ "</html>"; /** * @brief 寻找指定字符位置 * @param buf 缓冲区指针 * @param name 寻找字符 * @retval 返回字符的地址 */ char *lwip_data_locate(char *buf, char *name) { char *p; p = strstr((char *)buf, name); if (p == NULL) { return NULL; } p += strlen(name); return p; } /** * @brief 服务HTTP线程中接受的一个HTTP连接 * @param conn netconn控制块 * @retval 无 */ static void lwip_server_netconn_serve(struct netconn *conn) { struct netbuf *inbuf; char *buf; u16_t buflen; err_t err; char *ptemp; /* 从端口读取数据,如果那里还没有数据,则阻塞。 我们假设请求(我们关心的部分)在一个netbuf中 */ err = netconn_recv(conn, &inbuf); if (err == ERR_OK) { netbuf_data(inbuf, (void **)&buf, &buflen); /* 这是一个HTTP GET命令吗?只检查前5个字符,因为 GET还有其他格式,我们保持简单)*/ if (buflen >= 5 && buf[0] == 'G' && buf[1] == 'E' && buf[2] == 'T' && buf[3] == ' ' && buf[4] == '/' ) { start_html: /* 发送HTML标题 从大小中减去1,因为我们没有在字符串中发送\0 NETCONN_NOCOPY:我们的数据是常量静态的,所以不需要复制它 */ netconn_write(conn, http_html_hdr, sizeof(http_html_hdr) - 1, NETCONN_NOCOPY); /* 发送我们的HTML页面 */ netconn_write(conn, http_index_html, sizeof(http_index_html) - 1, NETCONN_NOCOPY); } else if(buflen>=8&&buf[0]=='P'&&buf[1]=='O'&&buf[2]=='S'&&buf[3]=='T') { ptemp = lwip_data_locate((char *)buf, "led1="); if (ptemp != NULL) { if (*ptemp == '1') /* 查看led1的值。为1则灯亮,为2则灭,此值与HTML网页中设置有关 */ { LED0(0); /* 点亮LED1 */ } else { LED0(1); /* 熄灭LED1 */ } } ptemp = lwip_data_locate((char *)buf, "beep="); /* 查看beep的值。为3则灯亮,为4则灭,此值与HTML网页中设置有关 */ if (ptemp != NULL ) { if (*ptemp == '3') { /* 打开蜂鸣器 */ } else { /* 关闭蜂鸣器 */ } } goto start_html; } } /* 关闭连接(服务器在HTTP中关闭) */ netconn_close(conn); /* 删除缓冲区(netconn_recv给我们所有权, 所以我们必须确保释放缓冲区) */ netbuf_delete(inbuf); } /** * @brief lwip_demo程序入口 * @param 无 * @retval 无 */ void lwip_demo(void) { struct netconn *conn, *newconn; err_t err; /* 创建一个新的TCP连接句柄 */ /* 使用默认IP地址绑定到端口80 (HTTP) */ conn = netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 80); /* 将连接置于侦听状态 */ netconn_listen(conn); do { err = netconn_accept(conn, &newconn); if (err == ERR_OK) { lwip_server_netconn_serve(newconn);//调用HTTP服务器子程序 netconn_delete(newconn); } } while (err == ERR_OK); netconn_close(conn); netconn_delete(conn); }
lwip_demo()
:建立 TCP 连接lwip_data_locate()
:寻找指定字符位置lwip_server_netconn_serve()
:服务 HTTP 线程中接受的一个HTTP连接,主要分为三步:
netconn_write
把网页数据发送到浏览器当中lwip_data_locate
判断触发源,判断完成之后根据触发源来执行相应的动作其中网页格式为HTML,具体语法可参考:
MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一个基于客户端-服务器的消息发布/订阅(publish/subscribe)传输协议,该协议构建于 TCP/IP 协议上,由 IBM 在 1999 年发布。
实现 MQTT 协议需要:客户端和服务器端 MQTT 协议中有三种身份:发布者(Publish)、代理(Broker)(服务器)、订阅者(Subscribe)。其中,消息的发布者和订阅者都是客户端,消息代理是服务器,消息发布者可以同时是订阅者,如下图所示
MQTT 传输的消息分为:主题(Topic)和消息的内容(payload)两部分
要在客户端与代理服务端建立一个 TCP 连接,建立连接的过程是由客户端主动发起的,代理服务一直是处于指定端口的监听状态,当监听到有客户端要接入的时候,就会立刻去处理。客户端在发起连接请求时,携带客户端 ID、账号、密码、心跳间隔时间等数据。代理服务收到后检查自己的连接权限配置中是否允许该账号密码连接,如果允许则建立会话标识并保存,绑定客户端 ID 与会话,并记录心跳间隔时间(判断是否掉线和启动遗嘱时用)和遗嘱消息等,然后回发连接成功确认消息给客户端,客户端收到连接成功的确认消息后,进入下一步(通常是开始订阅主题,如果不需要订阅则跳过)。如下图所示:
客户端将需要订阅的主题经过 SUBSCRIBE 报文发送给代理服务,代理服务则将这个主题记录到该客户端 ID 下(以后有这个主题发布就会发送给该客户端),然后回复确认消息SUBACK 报文,客户端接到 SUBACK 报文后知道已经订阅成功,则处于等待监听代理服务推送的消息,也可以继续订阅其他主题或发布主题,如下图所示:
当某一客户端发布一个主题到代理服务后,代理服务先回复该客户端收到主题的确认消息,该客户端收到确认后就可以继续自己的逻辑了。但这时主题消息还没有发给订阅了这个主题的客户端,代理要根据质量级别(QoS)来决定怎样处理这个主题。所以这里充分体现了是MQTT 协议是异步通信模式,不是立即端到端反应的,如下图所示:
固定报头
MQTT 协议工作在 TCP 协议之上,因为客户端和服务器都是应用层,那么必然需要一种协议在两者之间进行通信,那么随之而来的就是 MQTT 控制报文, MQTT 控制报文有3个部分组成,分别是固定报头(fixed header)、可变报头(variable header)、有效荷载(数据区域 payload)。固定报头,所有的 MQTT 控制报文都包含,可变报头与有效载荷是部分 MQTT 控制报文包含。固定报头占据两字节的空间,具体见图
固定报头的第一个字节分为控制报文的类型(4bit),以及控制报文类型的标志位,控制类型共有 14 种,其中0与15被系统保留出来,其他的类型具体见表格
固定报头的 bit0-bit3 为标志位,依照报文类型有不同的含义,事实上,除了 PUBLISH类型报文以外,其他报文的标志位均为系统保留,PUBLISH 报文的第一字节 bit3 是控制报文的重复分发标志(DUP),bit1-bit2 是服务质量等级,bit0 是 PUBLISH 报文的保留标志,用于标识 PUBLISH 是否保留,当客户端发送一个 PUBLISH 消息到服务器,如果保留标识位置 1,那么服务器应该保留这条消息,当一个新的订阅者订阅这个主题的时候,最后保留的主题消息应被发送到新订阅的用户。
固定报头的第二个字节开始是剩余长度字段,是用于记录剩余报文长度的,表示当前的消息剩余的字节数,包括可变报头和有效载荷区域(如果存在),但剩余长度不包括用于编码剩余长度字段本身的字节数。
剩余长度字段使用一个变长度编码方案,对小于 128 的值它使用单字节编码,而对于更大的数值则按下面的方式处理:每个字节的低 7 位用于编码数据长度,最高位(bit7)用于标识剩余长度字段是否有更多的字节,且按照大端模式进行编码,因此每个字节可以编码 128 个数值和一个延续位,剩余长度字段最大可拥有 4 个字节。
可变报头
可变报头并不是所有的 MQTT 报文都带有的(比如 PINGREQ 心跳请求与 PINGRESP心跳响应报文就没有可变报头),只有某些报文才拥有可变报头,它在固定报头和有效负载之间,可变报头的内容会根据报文类型的不同而有所不同,但可变报头的报文标识符(Packet Identifier)字段存在于在多个类型的报文里,而有一些报文又没有报文标识符字段,具体见表格
报文标识符结构具体见图
因为对于不同的报文,可变报头是不一样的,下面就简单讲解几个报文的可变报头
总的来说,整个 CONNECT 报文可变报头的内容如下:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。