嵌入式操作系统 | 网关维护状态表 + 每 2 秒上报(含设备在线判定)
本课程开源地址(Gitee):https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git
课件、示例代码与验收脚本都在该仓库,可直接git clone或下载 ZIP 使用。
模块七 · 2 学时 · 环境:Ubuntu(gcc + libubox)+ socat 虚拟串口
一句话概述:网关不再"收到就转发",而是把设备状态记进一张表,然后每 2 秒自己组一帧0x81(帧尾多一个"在线"字节)UDP 上报给固定目标;设备超过 3 秒没上报就标成离线。下课时你能看到:灯 亮 | 风扇 2 档 | 25.0 ℃ | 在线→ 关掉设备 →离线→ 设备回来 →在线。
本节目标
- 会定义"设备状态表"结构体(灯/风扇/温度/最后收到时间/在线标志),并在收到帧时更新它
- 会用
uloop_timeout做周期上报(回调里再排一次),会自己组帧(build_frame+ CRC16) - 会做在线判定(用单调时间比较),并说清"为什么要把上报目标固定下来"(对比课时 27 的坑)
- 能复现全过程:上线 → 掉线 → 恢复
一、讲解内容
1.1 知识讲解
本节在课时 28 的基础上加什么。上一节的网关只是"把设备帧广播出去";这一节它开始记住设备的当前状态,并主动、定期地对外汇报:
设备面板 ──0x80 状态帧(每秒)──▶ 网关 ├── ① 更新"设备状态表"(灯/风扇/温度/最后收到时间) ├── ② 广播给已连接的 TCP 客户端(课时 28 已有) └── ③ 每 2 秒自己组一帧 0x81 ──UDP──▶ 监听端/界面/上位机 (表里的值 + online 标志)为什么要"状态表"?三个现实原因,学生要能说出来:
| 原因 | 说明 |
|---|---|
| 查询要快 | 上位机问"现在什么状态",网关直接读表回答,不用现去问设备(设备可能正忙/掉线) |
| 掉线要能判断 | 表里记了"最后收到的时间",一比较就知道设备是不是失联了;掉线后最后已知值还能显示 |
| 上报要解耦 | 上报节奏(2 秒)和设备的节奏(1 秒)不必绑定;设备换了、上报目标变了都不用动设备 |
帧格式加了一版(v1.1):网关对外发的是0x81,比设备的0x80多一个"在线"字节:
| 方向 | TYPE | LEN | PAYLOAD |
|---|---|---|---|
| 设备 → 网关 | 0x80 | 4 | 灯(1) 风扇(1) 温度×10(2 大端) |
| 网关 → 上位机 | 0x81 | 5 | 灯(1) 风扇(1) 温度×10(2 大端)在线(1) |
在线判定怎么做?关键是用单调时间(CLOCK_MONOTONIC),不是墙上时钟(date那种会被手动/校时改动):
staticlongnow_ms(void)/* 单调时间的毫秒数 */{structtimespects;clock_gettime(CLOCK_MONOTONIC,&ts);return(long)ts.tv_sec*1000+(long)(ts.tv_nsec/1000000);}/* 收到设备状态帧时: */state.last_seen_ms=now_ms();/* 记下"最后一次见到" */state.online=1;/* 每 2 秒上报时先判一次: */if(state.online&&now_ms()-state.last_seen_ms>OFFLINE_MS){state.online=0;/* 3 秒没消息 → 掉线 */}阈值怎么定?要比设备的上报周期宽 2~3 倍:设备 1 秒一帧 → 3 秒判掉线(本课)。太紧会"抖动"(设备只是慢了半拍就报掉线),太松则掉线发现慢。
"上报目标固定"是本节特意改的设计(对比课时 27):
| 课时 27(UDP 收命令) | 本节 | |
|---|---|---|
| 上报发给谁 | “最近给我发过包的那个人”(peer) | 固定的127.0.0.1:9100(用./gateway_full /tmp/ttyV0 9000 127.0.0.1 9100指定) |
| 风险 | 任何程序发个包就把地址顶掉,真正的上位机收不到 | 不受"谁说话"影响;目标可以写死、也可以(课时 31)从 uci 读 |
| 适合 | 快速验证 | 真实部署 |
1.2 操作步骤(一条命令一步,看现象)
第 0 步:环境检查(gcc、libubox、socat、pyserial;完整清单见课时 24 附录 F)
gcc--version|head-1ls/usr/local/include/libubox/uloop.hwhichsocat python3-c"import serial; print('pyserial OK')"第 1 步:编译(本节的网关是从课时 28 复制过来改的)
cd~/lab/课时29-网关状态表与上报ls# 应看到 gateway_full.c、state_listener.pygcc-Wall-Wextra-ogateway_full gateway_full.c-lubox现象(实测):[OK] gateway_full 编译无警告。
第 2 步:四个终端准备好(A 串口 / B 设备面板 / C 网关 / D 监听端)
# 终端 A:虚拟串口socat-d-dpty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1# 终端 B:设备面板(课时 26 的程序)cd~/lab/课时26-设备面板接串口&&python3 device_panel_serial.py# 终端 C:网关(参数:串口 / TCP端口 / 上报目标IP / 上报端口)cd~/lab/课时29-网关状态表与上报 ./gateway_full /tmp/ttyV09000127.0.0.19100# 终端 D:监听上报(只看,不发命令)python3-ustate_listener.py9100网关启动的现象(实测):
网关已启动:串口 /tmp/ttyV0 @ 9600 ⇄ TCP 0.0.0.0:9000(Ctrl+C 退出) 上位机发命令:先发 2 字节长度(大端),再发那一帧 设备状态:① 广播给已连接的 TCP 客户端;② 每 2000 毫秒 UDP 上报到 127.0.0.1:9100终端 D 的现象(网关每 2 秒上报一次,注意最后那个"在线"):
监听 UDP 9100,等网关每 2 秒上报一次(Ctrl+C 退出) 收到 55 AA 81 05 00 00 00 F1 01 0E 0D → 灯 灭 | 风扇 0 档 | 24.1 ℃ | 在线 收到 55 AA 81 05 00 00 00 F3 01 0F 6D → 灯 灭 | 风扇 0 档 | 24.3 ℃ | 在线帧里的
81 05= TYPE 0x81、LEN 5,最后那个字节01就是"在线"。
第 3 步:用 TCP 命令控制设备,看上报跟着变(课时 28 的客户端就能用)
cd~/lab/课时29-网关状态表与上报 python3-u../课时28-网关TCP命令通道/tcp_client.py led on python3-u../课时28-网关TCP命令通道/tcp_client.py fan2现象(实测,终端 D):
收到 55 AA 81 05 01 02 00 F2 01 32 85 → 灯 亮 | 风扇 2 档 | 24.2 ℃ | 在线 收到 55 AA 81 05 01 02 00 F4 01 31 25 → 灯 亮 | 风扇 2 档 | 24.4 ℃ | 在线命令走 TCP(时长 28),状态走 UDP 上报(本节)——控制与数据分开,这也是真实工控系统的常见做法。
第 4 步:★掉线实验——把设备"拔掉"
pkill-f'device_panel_seria[l]'# 杀掉设备面板(相当于把设备断电/拔线)等 5~6 秒,终端 D 的现象(实测,先保持"最后已知值",再把在线位变成 00):
收到 55 AA 81 05 01 02 00 F3 01 33 15 → 灯 亮 | 风扇 2 档 | 24.3 ℃ | 在线 收到 55 AA 81 05 01 02 00 F1 01 32 75 → 灯 亮 | 风扇 2 档 | 24.1 ℃ | 在线 收到 55 AA 81 05 01 02 00 F1 00 F3 B5 → 灯 亮 | 风扇 2 档 | 24.1 ℃ | 离线 ← online 变 0网关那边同时打印(实测):
★ 设备掉线了(超过 3000 毫秒没收到状态帧) 网关上报状态(0x81)(11 字节):55 AA 81 05 01 02 00 F1 00 F3 B5 → 灯 亮 | 风扇 2 档 | 24.1 ℃ | 离线(第 7 次上报)第 5 步:★设备回来了
cd~/lab/课时26-设备面板接串口 python3 device_panel_serial.py现象(实测,几秒内自动恢复"在线"):
★ 设备上线了(灯 灭,风扇 0 档,24.9 ℃) ← 网关日志 收到 55 AA 81 05 00 00 00 F7 01 0D AD → 灯 灭 | 风扇 0 档 | 24.7 ℃ | 在线第 6 步:停网关看统计
Ctrl+C停网关(终端 C),现象(实测):
网关退出。统计:串口收 18 帧、TCP 广播 4 次;TCP 收 2 帧、转给串口 2 帧;状态上报 11 次三个数字对照着看:18 帧是从设备收到的、11 次是每 2 秒上报的次数、2 帧是客户端下发的命令。
第 7 步:收工。依次 Ctrl+C:监听端 → 网关 → 设备面板 → socat。
1.3 常见错误与排查
| 现象 | 原因 | 解决 |
|---|---|---|
编译报undefined reference to 'clock_gettime' | 老环境需要链接 rt | 加-lrt(Ubuntu 20.04 一般不需要) |
| 监听端一直没输出 | ① 网关没起来 ② 上报端口不对 ③ 被防火墙挡 | 看网关日志有没有"网关上报状态(0x81)";确认监听端端口与网关第 4 个参数一致 |
监听端报Address already in use | 9100 被别的程序占着 | ss -lunp | grep 9100找出来杀掉,或两边都换端口(网关第 4 参数 + 监听端口) |
| 状态一直是"离线" | 设备面板没跑 / 串口不对 | 确认设备面板用/tmp/ttyV1、网关用/tmp/ttyV0;看设备面板日志有没有"发送 0x80" |
| 掉线实验里"杀不掉设备" | kill的是xvfb-run,里面 python 还活着 | 用pkill -f 'device_panel_seria[l]'(按进程名杀);用pgrep -af device_panel确认 |
| 在线/离线来回跳 | 判定阈值太小(比设备上报周期还短) | 阈值至少是设备上报周期的 2~3 倍(本课:设备 1 秒一帧 → 3 秒判掉线) |
| 温度不变化 | 设备面板停了(离线时表里是最后已知值) | 这是设计如此:离线后保留最后值,界面上要显示"离线+最后值" |
| 上报帧 CRC 不对 | 组帧时 CRC 覆盖范围写错 | 范围是TYPE + LEN + PAYLOAD(不含 55 AA,也不含 CRC 本身) |
二、学生练习
本节命令速查表:
| 命令 | 作用 | 示例 |
|---|---|---|
gcc -Wall -Wextra -o gateway_full gateway_full.c -lubox | 编译本节网关 | — |
./gateway_full /tmp/ttyV0 9000 127.0.0.1 9100 | 启动(串口/TCP端口/上报IP/上报端口) | 4 个参数都能省,用默认值 |
python3 -u state_listener.py 9100 | 监听上报(看 在线/离线) | Ctrl+C 退出 |
python3 -u ../课时28-网关TCP命令通道/tcp_client.py led on | 用 TCP 命令控制设备 | 复用课时 28 的工具 |
pkill -f 'device_panel_seria[l]' | 模拟"设备掉线" | 看上报变 离线 |
ss -lunp | grep 9100 | 看谁占着上报端口 | 排查端口冲突 |
| 编号 | 任务 | 提示 | 预期结果 |
|---|---|---|---|
| L29.1 | 编译 + 四个终端起来 | 第 1、2 步 | 监听端每 2 秒一行 `… |
| L29.2 | 用 TCP 命令改状态 | 第 3 步 | 上报里的灯/风扇跟着变 |
| L29.3 | ★掉线实验 | 第 4 步 | 3 秒后 online 变 0,网关打印"设备掉线了" |
| L29.4 | 设备恢复 | 第 5 步 | 网关打印"设备上线了",上报恢复"在线" |
| L29.5 | 停网关看统计 | 第 6 步 | 三个数字能对上(收帧/上报/命令) |
| L29.6 | 把OFFLINE_MS改成 1000 再试 | 改#define OFFLINE_MS 1000 | 掉线判得很快,但正常时也可能"误判"(体会阈值取舍) |
| L29.7 | 把上报周期改成 500 毫秒 | 改#define REPORT_MS 500 | 监听端每 0.5 秒一行(网络和 CPU 占用也会上升) |
| L29.8 | 让上报目标改成宿主机 IP | 第 4 个参数填宿主机 IP(192.168.31.x) | 宿主机上用 Python/工具收到上报(体会"上报目标可配置") |
三、作业
- 必做:交 3 张截图(监听端"在线"的一行 / 掉线后"离线"的一行 + 网关"设备掉线了" / 恢复后"在线"),并回答:① 网关为什么要维护"设备状态表"?不维护行不行?② 在线判定的阈值应该怎么定?定太小/太大分别会怎样?③ 为什么要用
CLOCK_MONOTONIC(单调时间)而不是date那种时间?④ 本节把上报目标改成了"固定地址",比课时 27 的"谁说话听谁的"好在哪? - 选做:① 在
0x81帧里再加一个"最后更新时间"(比如"距离上次收到设备帧多少秒",1 字节),监听端也翻译它;② 给网关加一条"设备连续离线超过 10 秒就打印一条告警"的日志(提示:在send_state_report里加个计数器) - 提交:截图 + 文字回答,命名
课时29-作业截图.png
四、本节小结与下节预告
本节网关"长脑子"了:一张设备状态表(灯/风扇/温度/最后收到时间/在线)、每 2 秒自己组帧0x81上报(含在线字节)、超过 3 秒就判掉线,并且把上报目标固定下来(不再受"谁说话"影响)。你还亲手做了"在线 → 掉线 → 恢复"的全过程实验。
下节课(课时 30)做上位机界面:用 Flask 起一个网页,页面上显示灯/风扇/温度/在线(收网关的 UDP 上报),并放控制按钮(走 TCP 发命令)——做完之后,用鼠标就能控制那个"串口设备",而且在 Windows 宿主机浏览器里也能操作。
附录:怎么运行 + 完整代码
两个文件在
代码/课时29-网关状态表与上报/:gateway_full.c(网关,从课时 28 复制后加功能)和state_listener.py(上报监听端)。完整代码以仓库文件为准,下面摘本节新增的代码。
附录 A|环境准备 + 怎么运行
# ===== ⓿ 环境准备(缺哪个装哪个;完整清单见课时 24 附录 F)=====sudoaptupdatesudoaptinstall-ybuild-essential cmake pkg-config socatsudoaptinstall-ypython3-pyqt5 python3-serial xvfb# ===== 终端 A:虚拟串口 =====socat-d-dpty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1# ===== 终端 B:设备面板 =====cd~/lab/课时26-设备面板接串口&&python3 device_panel_serial.py# ===== 终端 C:本节网关(每 2 秒上报)=====cd~/lab/课时29-网关状态表与上报 gcc-Wall-Wextra-ogateway_full gateway_full.c-lubox./gateway_full /tmp/ttyV09000127.0.0.19100# ===== 终端 D:监听上报 =====python3-ustate_listener.py9100# ===== 掉线 / 恢复实验 =====pkill-f'device_panel_seria[l]'# 掉线:上报变"离线"cd~/lab/课时26-设备面板接串口&&python3 device_panel_serial.py# 恢复:"在线"附录 B|本节新增的代码(从课时 28 复制后加的部分)
B-1 状态表 + 单调时间(文件开头)
/* ===== 课时 29 新增的常量 ===== */#defineT_STATUS0x80/* 设备 → 网关:状态上报(灯 / 风扇 / 温度) */#defineT_STATE_REPORT0x81/* 网关 → 上位机:网关状态(多一个"在线"字节) */#defineREPORT_MS2000/* 每 2 秒上报一次 */#defineOFFLINE_MS3000/* 超过 3 秒没收到设备状态帧 → 判定离线 */#defineSTATE_PORT_DEF9100/* 上报目标端口(课时 31 会改成从 uci 读) */#defineSTATE_HOST_DEF"127.0.0.1"/* ===== 课时 29 新增:设备状态表 + 上报定时器 ===== */structdevice_state{intled;/* 灯:0 灭 / 1 亮 */intfan;/* 风扇:0~3 档 */inttemp_x10;/* 温度 ×10(250 = 25.0 ℃) */longlast_seen_ms;/* 最近一次收到设备状态帧的时间(毫秒) */intonline;/* 1 在线 / 0 离线 */};staticstructdevice_statestate={0,0,250,0,0};/* 初值:灯灭、风扇停、25.0℃、离线 */staticstructuloop_timeoutt_report;/* 周期上报定时器 */staticintudp_fd=-1;/* 只用来发上报(sendto) */staticstructsockaddr_instate_peer;/* 上报目标(固定) */staticintreport_cnt;/* 上报了多少次 *//* 单调时间的毫秒数(用 clock_gettime,不受系统时间被改的影响) */staticlongnow_ms(void){structtimespects;clock_gettime(CLOCK_MONOTONIC,&ts);return(long)ts.tv_sec*1000+(long)(ts.tv_nsec/1000000);}B-2 收到设备状态帧 → 记进状态表(顺便判"上线")
/* 收到设备的 0x80 帧:把它的值记到状态表里(这才是"网关"该干的事) */staticvoidupdate_state_from(constuint8_t*frame,inttotal){uint8_ttype=frame[2];intplen=frame[3];constuint8_t*payload=frame+4;(void)total;if(type!=T_STATUS||plen!=4){printf("(这一帧不是设备状态帧,不进状态表:TYPE=0x%02X LEN=%d)\n",type,plen);return;}state.led=payload[0];state.fan=payload[1];state.temp_x10=(payload[2]<<8)|payload[3];state.last_seen_ms=now_ms();if(!state.online){state.online=1;printf("★ 设备上线了(灯 %s,风扇 %d 档,%.1f ℃)\n",state.led?"亮":"灭",state.fan,state.temp_x10/10.0);fflush(stdout);}}B-3 周期上报:先判在线 → 组 0x81 → sendto → 再排下一次
/* 每 2 秒:判在线 → 组一帧 0x81 → 发给固定上报目标 → 再排下一次 */staticvoidsend_state_report(structuloop_timeout*t){uint8_tframe[32];uint8_tpayload[5];intlen;/* ① 在线判定:太久没收到设备状态帧,就算离线(值保留,界面还能显示"最后已知值") */if(state.online&&state.last_seen_ms!=0&&now_ms()-state.last_seen_ms>OFFLINE_MS){printf("★ 设备掉线了(超过 %d 毫秒没收到状态帧)\n",OFFLINE_MS);fflush(stdout);state.online=0;}/* ② 组 0x81:灯 风扇 温度高 温度低 在线 */payload[0]=(uint8_t)state.led;payload[1]=(uint8_t)state.fan;payload[2]=(uint8_t)((state.temp_x10>>8)&0xFF);payload[3]=(uint8_t)(state.temp_x10&0xFF);payload[4]=(uint8_t)state.online;len=build_frame(frame,T_STATE_REPORT,payload,5);/* ③ 发出去 */if(sendto(udp_fd,frame,(size_t)len,0,(structsockaddr*)&state_peer,sizeof(state_peer))!=len){perror("sendto(state)");}else{report_cnt++;dump("网关上报状态(0x81)",frame,len);printf(" → 灯 %s | 风扇 %d 档 | %.1f ℃ | %s(第 %d 次上报)\n",state.led?"亮":"灭",state.fan,state.temp_x10/10.0,state.online?"在线":"离线",report_cnt);fflush(stdout);}uloop_timeout_set(t,REPORT_MS);/* 周期任务的写法:回调里再排一次 */}B-4 main() 里注册定时器和"上报目标"
constchar*dev=(argc>=2)?argv[1]:DEV_DEFAULT;intport=(argc>=3)?atoi(argv[2]):TCP_PORT;constchar*state_host=(argc>=4)?argv[3]:STATE_HOST_DEF;/* 课时 29 新增 */intstate_port=(argc>=5)?atoi(argv[4]):STATE_PORT_DEF;.../* ===== 课时 29 新增:UDP socket(只用来上报)+ 上报定时器 ===== */udp_fd=socket(AF_INET,SOCK_DGRAM,0);if(udp_fd<0){perror("socket(udp)");close(ser_fd);close(listen_fd);uloop_done();return1;}memset(&state_peer,0,sizeof(state_peer));state_peer.sin_family=AF_INET;state_peer.sin_port=htons((uint16_t)state_port);inet_aton(state_host,&state_peer.sin_addr);t_report.cb=send_state_report;uloop_timeout_set(&t_report,REPORT_MS);/* 2 秒后开始第一次上报 */另外,
serial_handle_frames()里在广播那一行前面加了一句update_state_from(ser_rxbuf, total);——
设备帧既广播给 TCP 客户端,也记进状态表,两条路互不影响。