提到地面站软件,大多数人的第一反应是C++加Qt的组合,或者直接上Mission Planner、QGroundControl这类现成方案。但如果你的团队以JavaScript技术栈为主,或者希望在地面站中快速集成Web能力、地图服务、云端数据同步,那么Node.js其实是一个非常值得考虑的选择。本文将以DeGroundStation的实现过程为例,完整讲清楚如何用Node.js搭建一套可用的地面站,包括架构设计、串口通信、MAVLink协议解析以及实时数据推送到前端的全链路。

一、为什么地面站适合用Node.js来做
地面站本质上是一个典型的I/O密集型应用:它需要同时与多个数据源打交道——串口或网络链路下来的遥测数据、上行链路的指令通道、地图瓦片请求、日志落盘、前端界面的实时刷新。这些任务没有一个是CPU重型的,但对并发I/O的处理能力要求很高。而这恰恰是Node.js事件循环模型的强项,单线程异步非阻塞的方式可以轻松挂起几十个并发连接,代码写起来比多线程方案直观得多。
第二个优势是生态。串口通信有成熟的serialport包,MAVLink协议有node-mavlink可以直接用,WebSocket有ws,地图和界面可以直接用浏览器承载。整个技术栈可以做到前后端同构,团队维护成本大幅降低。在实际开发DeGroundStation的过程中,从零到能收到第一帧遥测数据,只花了一个下午。
当然也要客观看待局限:Node.js不适合做高频波形绘制、视频硬解码这类CPU密集任务。如果地面站需要渲染高清图传画面,建议这部分交给原生模块或者前端WebCodecs处理,Node.js只负责数据搬运和业务逻辑。
二、DeGroundStation整体架构设计
整个系统划分为四层:链路层、协议层、服务层和展示层。链路层负责建立与无人设备(或卫星地面设备)的物理或网络连接,支持串口、UDP、TCP三种接入方式;协议层处理MAVLink v2消息的编解码;服务层维护飞行器状态机、航点管理、日志记录,并通过WebSocket向前端推送;展示层是一个跑在浏览器里的Web界面,负责地图、仪表盘和指令面板。
下面是核心目录结构的示例:
degroundstation/ ├── src/ │ ├── link/ │ │ ├── serial-link.js // 串口链路 │ │ ├── udp-link.js // UDP链路 │ │ └── index.js // 链路工厂 │ ├── protocol/ │ │ └── mavlink-parser.js // 协议解析器 │ ├── core/ │ │ ├── vehicle-state.js // 飞行器状态机 │ │ └── command-sender.js // 指令发送 │ ├── server/ │ │ ├── ws-server.js // WebSocket推送 │ │ └── http-server.js // 静态资源 │ └── index.js // 入口 └── web/ // 前端界面
这种分层的好处是链路与协议解耦。无论数据从串口来还是从UDP来,进入系统的都是统一的Buffer流,协议层不关心来源;同样,换掉MAVLink换成其他私有协议,服务层和前端几乎不用改动。在DeGroundStation的早期版本中,我们就靠这个设计顺利从模拟器切换到了真实硬件。
三、串口通信与MAVLink协议解析
链路层的第一步是打开串口。使用serialport包非常直接,指定端口名和波特率即可。对于数传电台,常见波特率是57600或115200。代码如下:
const { SerialPort } = require('serialport');
function openSerialLink(portPath, baudRate, onData) {
const port = new SerialPort({
path: portPath,
baudRate: baudRate,
autoOpen: false
});
port.open((err) => {
if (err) {
console.error('串口打开失败:', err.message);
return;
}
console.log(`串口 ${portPath} 已连接`);
});
// 收到底层字节流就往外抛
port.on('data', (chunk) => onData(chunk));
port.on('error', (err) => console.error('串口异常:', err.message));
return port;
}
module.exports = { openSerialLink };
注意串口数据是分片到达的,一帧MAVLink消息很可能被拆成好几个chunk,也可能几个chunk里粘了多帧。所以协议层必须自己维护接收缓冲区,按帧头0xFD逐帧切分。使用node-mavlink可以省去手写解析的工作:
const mavManager = require('./mavManager')();
mavManager.on('HEARTBEAT', (msg) => {
console.log(`飞行器类型: ${msg.type}, 飞控状态: ${msg.system_status}`);
});
mavManager.on('GLOBAL_POSITION_INT', (msg) => {
// 高度单位是毫米,经纬度是度乘以1e7
const data = {
lat: msg.lat / 1e7,
lon: msg.lon / 1e7,
altM: msg.alt / 1000
};
vehicleState.updatePosition(data);
});
这里有个容易踩的坑:MAVLink里经纬度是整数存储的,必须除以1e7才能得到真实度数,高度单位是毫米。很多人第一次接上真机,看到地图上的点跑到海里去了,多半就是单位没换算。另外心跳包是判断链路健康的关键,建议在状态机里对HEARTBEAT做超时检测,超过三秒没有心跳就把界面标记为失联,同时停止发送需要应答的指令,避免指令队列堆积。
四、WebSocket实时推送与前端联动
地面站前端需要毫秒级的姿态刷新和秒级的地图轨迹更新,轮询显然不合适,WebSocket是最自然的方案。服务端的做法是维护一个连接池,把协议层吐出来的高频数据做节流后广播出去:
const WebSocket = require('ws');
function createWsServer(httpServer) {
const wss = new WebSocket.Server({ server: httpServer });
let lastPush = 0;
wss.on('connection', (ws) => {
console.log('前端已连接');
ws.on('message', (raw) => {
// 前端下发的指令,转发给指令发送模块
const cmd = JSON.parse(raw);
commandSender.handle(cmd);
});
});
// 节流推送:姿态数据每100ms最多推一次
vehicleState.on('attitude', (att) => {
const now = Date.now();
if (now - lastPush < 100) return;
lastPush = now;
const payload = JSON.stringify({ type: 'attitude', data: att });
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
});
return wss;
}
节流非常必要。姿态和位置数据的原始频率可能达到每秒50次以上,如果不做过滤直接广播,浏览器端的主线程会被消息事件吃满,地图拖动直接卡死。实践中我们按数据类型区分推送频率:姿态100ms一次、位置500ms一次、航点变更则只在事件发生时推送。这个策略让前端帧率稳定在60fps。
前端部分推荐用Leaflet或MapLibre渲染地图,把收到的坐标点不断追加到Polyline上就能画出飞行轨迹。指令下发走同一条WebSocket通道,前端发JSON,服务端翻译成MAVLink指令帧写回串口,形成完整的双向闭环。
五、调试经验与部署建议
调试阶段强烈建议先用SITL软件在环仿真代替真机。以ArduPilot为例,跑起来之后它会在本地UDP 14550端口输出MAVLink流,把链路工厂切到UDP模式就能收到数据,整个开发过程不碰硬件也能完成大部分功能验证。等仿真环境跑通再接数传电台,问题排查会简单很多。
部署时要注意一点:如果地面站要长期运行在无人值守的机房里,建议用pm2做进程守护,并把串口设备的udev规则固定下来,否则重启后设备名从ttyUSB0变成ttyUSB1,链路就断了。日志方面,除了普通文件日志,最好同时把原始MAVLink字节流按.tlog格式落盘,事后复盘飞行事故时这是最宝贵的数据。
总的来说,用Node.js实现DeGroundStation这套地面站,核心思路就是把事件驱动模型用在合适的地方:链路收发、协议解析、实时推送全部靠事件串联,代码量比传统C++方案少了一多半,而Web前端的表达力又让界面迭代速度非常快。如果你的项目对实时性要求在毫秒级以内、又需要灵活的界面和云端集成,这条技术路线值得认真评估。