资讯动态

NVR嵌入设备Web程序:CGI、取流解码与二次开发实践

发布时间:2026/10/26 5:21:34 来源:尧图企业网站定制
简介这是一份从海康威视DS-8632N-ST网络硬盘录像机中导出的嵌入式WEB程序源码面向需要研究NVR设备网页端实现、进行二次开发参考或Web安全学习的开发者与运维人员。官方固件通常只提供登录页面而不开放ASP源代码因此该包可用于了解设备Web界面的页面组织、前端资源引用、登录验证流程与基础接口调用方式对复现设备管理界面和理解嵌入式Web结构有较高参考价值。压缩包共266个文件约762KB包含108个png与31个gif界面图片、30个js脚本、10个css样式、6个asp页面以及xml、html和若干带版本标记的资源文件覆盖登录、预览、日志等模块的前端与脚本资源。内容预览可见preview、common、log等文件统一标注3.0.4.140310版本便于对照设备固件梳理页面结构与资源依赖。已有2329人学习适合作为海康NVR嵌入式Web界面与ASP实现的参考资料。1. 从设备本地页面说起NVR 嵌入设备 Web 程序到底在跑什么事情通常从现场开始机房弱电柜里那台 NVR 上架完布线工把网口插上接下来你给客户演示的第一件事往往不是专业客户端而是浏览器输入设备 IP打开登录页点一下预览就开始出画面。NVR 嵌入设备 Web 程序干的就是这件事——它不是一个独立部署的网站而是随固件一起烧进设备、由设备自带的轻量 HTTP 服务托管的静态页面外加一层 CGI 接口。前端负责界面、取流、时间轴这些看得见的东西CGI 负责把设备内部能力翻译成 HTTP。它适合三类人做集成要对接设备私有接口的、需要在原生页面上加自己模块的、以及想弄明白嵌入式前端和后端怎么分工的开发者。下一页开始我把这套结构按“页面怎么组织、请求怎么走、画面怎么出、配置怎么改”的顺序拆开。2. 目录与进程结构一个 CGI 请求在设备里怎么走嵌入设备上做 Web第一件要接受的事是资源约束。设备 Flash 通常只留出几十兆给 Web 根目录内存按百兆算没有 Node没有打包产物运行时用的就是浏览器原生的那套只有 ES5 或浅一层 ES6 是安全区。所以这套程序的形态非常固定一堆静态文件 若干个 CGI 可执行程序 一个守着 80 端口的轻量 HTTP 服务。搞清这三者的位置关系后面所有调试都会顺很多。2.1 固件里的 Web 目录长什么样我一般先把设备上的目录扒一遍再动手改代码因为厂商的目录命名习惯往往能直接告诉你哪些文件是原生的、哪些可以替换。常见的布局是这样# 设备根文件系统里 Web 相关的典型布局示意实际路径以固件为准 / ├── bin/ │ ├── webserverd # 轻量 HTTP 服务监听 80/443 │ └── cgid # CGI 守护或由 webserver 直接 fork CGI ├── etc/ │ └── web.conf # 端口、根目录、CGI 白名单、是否强制鉴权 ├── home/ │ └── web/ # Web 根目录HTTP 服务指向这里 │ ├── index.html # 主框架页登录后跳转 │ ├── login.html │ ├── static/ │ │ ├── js/app.js │ │ ├── css/app.css │ │ └── wasm/decoder.wasm # 软解 H.264/H.265 的模块 │ └── cgi-bin/ │ ├── query.cgi # 读类接口状态、通道、日志 │ ├── config.cgi # 写类接口参数下发 │ └── play.cgi # 取流票据、回放检索 └── var/ └── log/web.log # 设备页面自己的运行日志这里有两个关键点。home/web是纯静态目录HTTP 服务只做文件读取不做任何业务所有带逻辑的东西都在cgi-bin下面是编译好的可执行文件不是脚本。也就是说你改页面只需要替换static和 html改接口就必须重新交叉编译 CGI 并塞进固件。etc/web.conf决定 CGI 白名单和鉴权开关很多“页面能打开但接口 403”的问题根源都在这个文件里。webserverd这类服务在嵌入设备上通常只支持单进程或少量并发长连接要慎用。我见过有人在页面上开四条 WebSocket 拉四路主码流设备 CPU 直接跑满页面看着像卡死其实是服务端被拖垮了。提示动手前先把设备/home/web整个目录打包备份到本地改坏了还能原样塞回去这是嵌入式设备上唯一靠谱的后悔药。2.2 请求链路从浏览器到 CGI 再到设备业务层一次页面操作在这套结构里的完整路径是浏览器发起请求 → HTTP 服务按 URL 匹配到cgi-bin/xxx.cgi→ fork 出进程并注入环境变量 → CGI 解析参数、校验会话、调设备内部能力 → 拼装响应体输出。CGI 是“一次请求一个进程”的模型所以 CGI 里绝不能做耗时几十秒的事否则并发一两下就把设备压住。下面是一个 CGI 入口的典型写法重点看它怎么取参数、怎么校验会话、怎么控制返回头#include stdio.h #include stdlib.h #include string.h /* 设备侧 CGI 入口query.cgi 只处理读类请求不做写操作 */ int main(void) { /* HTTP 服务把 query string 和请求头注入成环境变量这是唯一入口 */ char *qs getenv(QUERY_STRING); /* methodgetChlStatuschl1 */ char *cookie getenv(HTTP_COOKIE); /* 登录后下发的会话票据 */ char method[32] {0}; int chl 1; /* 1. 极简解析嵌入式环境不引第三方库按 keyvalue 手撕足够 */ if (qs ! NULL) { sscanf(qs, method%31[^]chl%d, method, chl); } /* 2. 会话校验不通过直接 401前端据此统一跳登录页 */ if (check_session(cookie) ! 0) { printf(Status: 401 Unauthorized\r\n); printf(Content-Type: application/json\r\n\r\n); printf({\code\:401,\msg\:\session expired\}); return 0; } /* 3. 先输出响应头再输出 body顺序反了浏览器就拿不到内容 */ printf(Content-Type: application/json\r\n); printf(Cache-Control: no-store\r\n\r\n); if (strcmp(method, getChlStatus) 0) { dev_channel_t ch; if (dev_get_channel(chl, ch) ! 0) { /* 调设备内部接口 */ printf({\code\:500,\msg\:\device busy\}); return 0; } printf({\code\:0,\chl\:%d,\online\:%d,\stream\:\%s\}, chl, ch.online, ch.main_stream); } else { printf({\code\:404,\msg\:\unknown method\}); } return 0; }几个参数和写法必须解释清楚。QUERY_STRING是没有?的那一段HTTP_前缀是请求头转大写加下划线头名里的-会变成_所以Cookie读的是HTTP_COOKIEUser-Agent读的是HTTP_USER_AGENT。返回非 200 状态时不能直接写HTTP/1.1 401CGI 规范里要用Status:头由 HTTP 服务翻译成状态行。Cache-Control: no-store是设备页面的保命设置设备固件升级后静态资源指纹没变浏览器极容易拿旧缓存去配新接口表现就是页面某块区域永远白着。sscanf那行用%31[^]而不是%s是因为%s遇到空格才停参数里同时带时会一并吞进去串到下一个参数上这类 bug 在嵌入式 CGI 里非常常见翻车了还很难看出来。2.3 接口分三档读、写、取流别混在一起工程上我会把 CGI 拆成三类职责边界清晰调试和权限控制都好做。读类接口走 GET参数全在 query string 上返回 JSON无副作用浏览器刷新多少次都安全写类接口走 POST 或 PUT报文用 JSON 或 XML必须带 CSRF 或会话二次校验写完立刻返回不要在 CGI 里同步等待设备重启取流类接口是第三种它只发一张短期票据真正的流走另一条 WebSocket 通道这样 CGI 进程能很快退出不占着不放。下表是我在项目里一直沿用的划分方式可以直接抄类别代表 CGI方法报文超时建议是否可缓存读类query.cgiGETJSON8sno-store写类config.cgiPOST/PUTJSON/XML15s禁止缓存取流play.cgiGETJSON 票据5sno-store文件静态资源GET原文件10s带版本号可长缓存分档之后还有一个隐含好处读类接口可以放心做轮询。设备页面上“通道在线状态”每 5 秒刷一次走的就是读类写类绝不轮询。我见过有人把配置查询也做成每秒一次设备 CPU 直接飙到 90%页面反而更卡。3. 预览模块拆解一路 RTSP 流怎么变成浏览器里的画面预览是这套程序里最值钱也最容易出问题的模块。设备侧存的是标准 RTSP 流浏览器不认识 RTSP也不认裸 H.264中间必须有一层转换。老设备页面上常见的是插件方案——装一个本地解码控件浏览器把流地址交给它由它负责解码和渲染。现在更常见的是纯前端方案服务端把流从 RTSP 拉出来转成裸流或封装后的分片通过 WebSocket 或 HTTP 推给页面页面用 WebAssembly 软解再用 Media Source Extensions 喂给 video 标签。这条链路每一段都可能出问题所以要在代码里把边界切清楚。3.1 取流与鉴权票据为什么一定要放在 URL 上设备页面的登录流程和普通网站差不多用户名密码 POST 到login.cgi服务端校验后在响应头里下发一个会话票据浏览器存成 Cookie后续请求自动带上。麻烦出在取流这一步——WebSocket 的握手是浏览器发起的前端 API 不允许自定义请求头也就是说你没法像 fetch 那样手动塞一个X-Token。所以工程上的通行做法是两步走先调play.cgi换一张短时效票据再把票据拼在 WebSocket 的 query string 上。// 先换一次性票据再建连接票据 30 秒内有效用完即弃 async function openStream(chl, streamType 1) { const ticketResp await fetch( /cgi-bin/play.cgi?methodgetTicketchl${chl}type${streamType}, { credentials: include, cache: no-store } ); const { code, ticket, wsPort } await ticketResp.json(); if (code ! 0) throw new Error(取票失败 code${code}); // WebSocket 无法自定义头凭证只能走 query string const url ws://${location.hostname}:${wsPort}/ws/live ?chl${chl}type${streamType}ticket${encodeURIComponent(ticket)}; return new NvrPlayer(url); }ticket必须短时效且一次性因为 query string 会进设备日志、会留在浏览器历史里用长期会话票据等于把账号密码写在地址栏。wsPort从接口返回而不是写死是因为不同固件版本推流端口不固定写死 8080 换台设备就黑屏。streamType这个参数区分主码流和子码流1 通常是主码流2 是子码流四路以上分屏必须切子码流否则四路 1080p 主码流同时软解浏览器标签页直接卡死。注意取票接口一定要放在 CGI 里做权限校验不能只靠 WebSocket 服务端自己判断。推流服务和 HTTP 服务经常是两个进程权限模型不统一很容易出现“页面已登出但流还在推”的情况。3.2 播放器封装收流、解码、渲染三段要分开写前端播放器我一般封成一个类内部严格分成三段WebSocket 收数据、WASM 解码、MSE 喂帧。三段之间用回调串起来任何一段出错都能单独定位而不是整块黑屏让你猜。class NvrPlayer { constructor(wsUrl, videoEl) { this.ws null; this.decoder null; // WASM 解码器实例 this.queue []; // 待解码帧队列防止解码跟不上收流 this.video videoEl; this.maxQueue 30; // 队列上限超了丢最老的帧而不是无限涨 } start() { this.ws new WebSocket(this.wsUrl); this.ws.binaryType arraybuffer; // 必须显式设默认是 blob解码器读不了 this.ws.onmessage (ev) { if (this.queue.length this.maxQueue) { this.queue.shift(); // 丢旧帧保实时预览场景延迟优先于完整 } this.queue.push(new Uint8Array(ev.data)); }; this.decoder createDecoder({ codec: h264, onFrame: (yuv) this.feed(yuv) }); this.timer setInterval(() { const frame this.queue.shift(); if (frame) this.decoder.decode(frame); }, 10); } feed(yuv) { // 解码结果经 MSE 喂给 video需提前 appendInitSegment this.sourceBuffer.appendBuffer(transcodeToFmp4(yuv)); } stop() { clearInterval(this.timer); this.ws this.ws.close(); this.decoder this.decoder.release(); } }binaryType arraybuffer这个设置是最容易漏的一行漏了之后 WebSocket 收到的是 BlobWASM 那侧拿不到裸字节表现就是连接成功、有数据、就是不出画面。队列上限maxQueue是为了抗抖动设备网络差的时候帧会突然堆一批过来没有上限就是内存一路涨到标签页崩溃。渲染侧如果走 MSE初始分片要先 append 一次initSegment再喂媒体分片顺序错了浏览器会抛MediaSource is in a closed state。分屏场景下还有一条血泪经验不要给每个格子单独建一个 WASM 解码实例。解码器实例很吃内存九宫格就是九份设备端一开就爆。工程上的做法是共享一个解码器池按需分配切到单画面时把其它实例回收掉。4. 配置与回放接口契约和状态怎么对齐预览跑通之后页面上的工作量基本都压在配置和回放上。这两块看着是表单和时间轴实际难点在接口契约——设备侧的私有接口大多沿用 XML 报文字段嵌套深、全量覆盖语义、生效时机各不相同。不把契约摸清写出来的页面就是“保存成功但没生效”“改了一个字段其它字段被冲掉”这类玄学问题。4.1 接口契约PUT 全量覆盖是最常踩的坑设备侧的配置接口常见形态是GET 拿一份完整 XML改完整体 PUT 回去。注意是整体不是增量。这意味着你只想改通道名也必须把拿到的整份配置带上否则没带的字段会被当成空值写进去。!-- GET /cgi-bin/config.cgi?methodgetChannelchl1 的返回体 -- Config version1.0 Channel id1 Name通道一/Name Enabletrue/Enable MainStream Width1920/Width Height1080/Height Fps25/Fps Bitrate4096/Bitrate /MainStream SubStream Width704/Width Height576/Height Fps15/Fps /SubStream /Channel /Config前端改配置时我一般这么处理先把完整 XML 解析成对象改目标字段再序列化回去。中间不做任何字段裁剪序列化时保留原有节点顺序。// 读-改-写拿到完整配置对象只动目标字段其余原样带回 async function renameChannel(chl, newName) { const xmlText await fetch( /cgi-bin/config.cgi?methodgetChannelchl${chl}, { credentials: include, cache: no-store } ).then(r r.text()); const doc new DOMParser().parseFromString(xmlText, text/xml); const nameNode doc.querySelector(Channel[id${chl}] Name); if (!nameNode) throw new Error(节点不存在固件版本可能不匹配); nameNode.textContent newName; const body new XMLSerializer().serializeToString(doc); const resp await fetch(/cgi-bin/config.cgi?methodsetChannelchl${chl}, { method: PUT, credentials: include, headers: { Content-Type: text/xml }, body }); return resp.json(); // { code: 0 } 只代表写入成功不代表已生效 }code: 0只表示设备收下了这份配置落盘和生效是两件事有些字段要重启对应服务才生效。所以要养成习惯写完立刻再 GET 一次做回读校验比对目标字段是否真的变了。只信写接口的返回码迟早会遇到“保存成功但值没变”的现场投诉。4.2 回放时间轴按分片拉别一次要全量回放模块的主体是一个时间轴加一个播放区。设备侧一般提供“按时间段检索录像分片”的接口返回一串带起止时间的片段前端把片段画到时间轴上用户点哪段就拉哪段。这里最容易犯的错是拿到检索结果后一次性请求整天的录像数据。设备存储是按文件或按小时分片的一次要全天CGI 要么超时要么把设备 IO 打满导致整个页面卡住。正确做法是把检索和取流分开检索只拿片段元数据取流按需拉当前播放位置前后的几片// 检索只取元数据画时间轴用真正取流按播放位置懒加载 async function searchRecording(chl, startTs, endTs) { const url /cgi-bin/play.cgi?methodsearchchl${chl} start${startTs}end${endTs}; const { code, segments } await fetch(url, { credentials: include, cache: no-store }).then(r r.json()); if (code ! 0) return []; // segments: [{ start, end, type }]type 区分定时录像/移动侦测/报警 return segments.map(s ({ ...s, left: toPercent(s.start, startTs, endTs) })); } function toPercent(ts, startTs, endTs) { return ((ts - startTs) / (endTs - startTs)) * 100; // 时间轴百分比定位 }type字段是回放体验的关键前端拿它给时间轴分色用户一眼能看出哪段是移动侦测触发的。toPercent这类换算别在渲染循环里重复算一次性算好挂到片段对象上时间轴拖动时才不会掉帧。时间戳还有个大坑设备时钟和浏览器时区不一定一致。设备上常见的是本地时间存录像接口返回的却是 UTC直接画到时间轴上整段偏移八小时用户以为录像丢了。工程上的做法是登录后先调一次设备时间接口算出设备与浏览器的时差所有时间展示前统一加上这个偏移。4.3 生效时机哪些改完要重启哪些热生效配置项按生效方式可以分三档页面上的交互设计要跟着区分。网络参数、端口这类改完必须要设备重启接口返回后页面应该给明确的“需重启生效”提示并禁用重复提交编码参数、码流分辨率这类通常重启对应通道即可设备会短暂断流预览区要显示“重连中”通道名、OSD 位置这类基本热生效改完刷新即可看到。把这三档写进前端的一张表里比在每个表单里散着判断靠谱得多。用户在页面上点了保存最怕的是不知道接下来要等多久、要不要断电把等待预期显式告诉用户能省掉一大半现场沟通。5. 常见问题排查预览黑屏、页面白屏、配置不生效设备页面出问题现象往往只有一句“打不开”或“黑的”但原因可能在前端、在 CGI、在推流服务、在设备资源。这一章按现象归三类每条都给出定位路径照着走基本能收敛。5.1 预览类连上了但没画面现象一WebSocket 状态显示 101 已连接有数据帧进来video 标签就是黑的。原因通常是binaryType没设成arraybuffer或者 MSE 的initSegment没先 append 就喂了媒体分片浏览器直接丢弃。 解决先看控制台有没有MediaSource is in a closed state有就是顺序问题没有就打印收到的byteLength字节数正常但无画面八成是解码器 codec 标错H.265 流按 H.264 解出来的必然是黑屏。现象二单画面正常切四画面后整体卡死甚至页面无响应。原因是每格都建了独立解码实例且都在拉主码流设备 CPU 和浏览器内存同时被打满。 解决分屏时统一切子码流解码器改成共享池切回单画面再释放多余实例。判断方法很直接——打开任务管理器看标签页内存四画面比单画面涨三倍以上就是这个问题。现象三画面能出但延迟越跑越大几分钟后差出十几秒。原因是收流速度大于解码速度帧队列只进不出。 解决给队列设上限并丢弃旧帧预览场景延迟比完整更重要。同时检查设备侧推流是不是没做丢帧策略编码帧率高于实际能推的带宽时积压会在服务端就开始。5.2 页面与配置类能打开但不对劲现象一固件升级后页面白屏或者某块区域一直转圈。原因是浏览器缓存了旧的 JS 和 CSS而新固件的接口路径或返回结构变了。 解决静态资源引用加版本号查询串服务端对 html 返回no-store对带版本号的静态资源才允许长缓存。升级后强制刷新一次把旧缓存清干净。现象二配置保存提示成功刷新后值没变。原因是写接口走的是全量覆盖前端只提交了改动字段其它字段被清空设备校验不过就整体回滚但返回码仍是成功。 解决改成读-改-写提交后立刻回读校验目标字段。定位时把 PUT 的请求体完整打印出来和 GET 回来的原文逐节点比对缺哪个节点一目了然。现象三回放时间轴上明明有录像段点进去提示无录像。原因是设备时间与浏览器时区不一致检索时传的时间戳偏移了。 解决登录后先取设备时间算偏移所有时间参数统一换算后再发请求。判断方法拿检索返回的片段起止时间和设备本地时间对一下差整小时就是时区差几分钟就是设备没同步时间。现象四登录后操作几分钟就跳回登录页。原因是会话票据过期时间短而页面没有做续期或者轮询请求把票据用旧了。 解决在统一请求封装里拦截 401触发一次静默续期再重放原请求续期失败才跳登录页。注意续期接口本身不能再触发拦截否则会死循环。提示设备端页面排查先把/var/log/web.log和 CGI 的 stderr 打开看一遍比在浏览器里猜快得多。嵌入式环境没有花哨的工具日志就是唯一的黑匣子。6. 二次开发在原生页面上加自己的模块最常见的二次开发需求是在设备原生页面里加一个自己的功能页比如把通道状态汇总成一张看板或者接一个内网的工单入口。做法不复杂但顺序错了会反复返工。第一步是挂菜单。原生页面的导航一般由app.js里的一份配置驱动找到菜单数组追加一项指向你自己的 html。不要直接改index.html的 DOM固件一升级就全丢改配置数组至少还能靠 diff 找回来。第二步是复用请求封装。别自己再写一套 fetch直接调第 2 章那个API.request会话失效、超时、错误码都在里面收口了。新页面用到的接口如果没有对应 CGI就在cgi-bin下新增一个别往query.cgi里塞职责混了下个接手的人会骂人。第三步是静态资源打包。设备 Flash 空间紧张新页面尽量别引第三方库一个图表库动辄几百 KB塞进去可能直接把分区撑爆。我用过的做法是能画 SVG 就手写 SVG能算的就在前端算实在要引库就先看压缩后体积。第四步是验证。改完不是刷新页面看一眼就完事要按这条链路走替换文件 → 重新打包固件 → 设备断电重启 → 浏览器清缓存 → 单画面和四画面各开一次 → 断电再来一遍。中间任何一步跳过都可能在上线后炸出来。改造点改动位置是否需要重编 CGI是否需重启设备新增菜单项static/js/app.js否否刷新即可新增页面home/web/ 下新增 html否否新增接口cgi-bin/ 新增 CGI是是改接口字段对应 CGI 源码是是换播放器static/js/player.js否否还有一条我吃过亏的经验给设备加自定义页面一定不要用设备上没有的特性。localStorage在部分固件的浏览器里被裁掉了Intl时区格式化也可能没有用之前先做能力检测别等现场才发现整页报错。从那以后我每次给设备加自定义页面都强制走一遍改文件、重打包、断电重启、清缓存、四路同开看一眼一步不省。嵌入式设备没有热更新这回事能省的只有事前检查。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑