12、远程日志:通过网络发送日志(UDP/TCP),简单的日志服务器实现
嵌入式设备跑在现场,你总不能每次都拿串口线去怼吧?
设备部署在几十公里外,出了问题怎么办?
嗯,这时候就需要远程日志了。说白了,就是把日志通过网络发出来,你在办公室就能看到设备在干什么。
为什么需要远程日志?
我在项目中遇到过好几次这种情况:设备已经在客户现场跑了大半年,突然出现偶发性的重启。串口日志?早就被产品经理砍掉了,说量产机不需要调试口。
你想想看,没有日志,你连死机原因都查不到。这时候远程日志就是救命稻草。
远程日志的核心价值:
- 实时监控:设备运行状态一目了然
- 远程诊断:不用出差到现场
- 历史追溯:日志可以存到服务器上,事后分析
- 批量管理:一台服务器管几百台设备
UDP 还是 TCP?这是个问题
我个人习惯这样选:
| 特性 | UDP | TCP |
|---|---|---|
| 可靠性 | 丢包不重传 | 保证送达 |
| 实时性 | 高,无连接 | 稍低,有握手 |
| 资源占用 | 低 | 高(维护连接池) |
| 适用场景 | 调试日志、非关键信息 | 关键告警、需要确认的消息 |
我的建议:调试阶段用 UDP,简单粗暴。产品阶段如果网络环境差,还是上 TCP 吧。我曾经在 2G 模块上跑 UDP 日志,丢包率高达 30%,查问题查到怀疑人生。
UDP 日志发送端实现
先来个最简单的 UDP 发送。代码量很少,适合资源受限的 MCU。
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define LOG_SERVER_IP "192.168.1.100"
#define LOG_SERVER_PORT 8888
int udp_log_socket = -1;
struct sockaddr_in server_addr;
int log_udp_init(void) {
udp_log_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (udp_log_socket < 0) {
return -1;
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(LOG_SERVER_PORT);
inet_pton(AF_INET, LOG_SERVER_IP, &server_addr.sin_addr);
return 0;
}
void log_udp_send(const char *msg) {
if (udp_log_socket < 0) return;
sendto(udp_log_socket, msg, strlen(msg), 0,
(struct sockaddr *)&server_addr, sizeof(server_addr));
}
void log_udp_deinit(void) {
if (udp_log_socket >= 0) {
close(udp_log_socket);
udp_log_socket = -1;
}
}
注意:UDP 发送不会阻塞,但也不保证对方收到。如果日志丢了,你根本不知道。所以关键日志(比如设备异常重启)建议用 TCP 或者加个本地缓存。
TCP 日志发送端实现
TCP 就稍微复杂一点了。需要处理连接、重连、缓冲区等问题。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define LOG_SERVER_IP "192.168.1.100"
#define LOG_SERVER_PORT 8889
#define RECONNECT_DELAY 5 // 重连间隔(秒)
int tcp_log_socket = -1;
int tcp_connected = 0;
int log_tcp_connect(void) {
if (tcp_connected) return 0;
tcp_log_socket = socket(AF_INET, SOCK_STREAM, 0);
if (tcp_log_socket < 0) return -1;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(LOG_SERVER_PORT);
inet_pton(AF_INET, LOG_SERVER_IP, &addr.sin_addr);
if (connect(tcp_log_socket, (struct sockaddr *)&addr, sizeof(addr)) == 0) {
tcp_connected = 1;
return 0;
}
close(tcp_log_socket);
tcp_log_socket = -1;
return -1;
}
void log_tcp_send(const char *msg) {
if (!tcp_connected) {
// 尝试重连
if (log_tcp_connect() != 0) {
return; // 连不上就先丢了吧
}
}
int len = strlen(msg);
int sent = send(tcp_log_socket, msg, len, 0);
if (sent != len) {
// 发送失败,标记断开
tcp_connected = 0;
close(tcp_log_socket);
tcp_log_socket = -1;
}
}
关键点:TCP 发送失败后一定要关闭 socket 并标记断开。我见过有人不关 socket 直接重连,结果文件描述符泄漏,最后系统报 "Too many open files"。
简单的日志服务器实现
服务器端我习惯用 Python 写原型,验证没问题再考虑用 C 重写。这里给个 Python 版本,够简单够实用。
# log_server.py - 简易日志服务器
import socket
import threading
import time
from datetime import datetime
class LogServer:
def __init__(self, udp_port=8888, tcp_port=8889):
self.udp_port = udp_port
self.tcp_port = tcp_port
self.running = True
def handle_udp(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', self.udp_port))
print(f"[UDP] 日志服务器启动,端口: {self.udp_port}")
while self.running:
try:
data, addr = sock.recvfrom(1024)
msg = data.decode('utf-8', errors='ignore')
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] [UDP] [{addr[0]}:{addr[1]}] {msg}")
except:
break
def handle_tcp_client(self, client_sock, addr):
"""处理单个 TCP 客户端"""
print(f"[TCP] 新客户端连接: {addr[0]}:{addr[1]}")
buffer = b''
while self.running:
try:
data = client_sock.recv(1024)
if not data:
break # 客户端断开
buffer += data
# 按换行符分割消息
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
msg = line.decode('utf-8', errors='ignore')
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"[{timestamp}] [TCP] [{addr[0]}:{addr[1]}] {msg}")
except:
break
print(f"[TCP] 客户端断开: {addr[0]}:{addr[1]}")
client_sock.close()
def handle_tcp(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', self.tcp_port))
sock.listen(5)
print(f"[TCP] 日志服务器启动,端口: {self.tcp_port}")
while self.running:
try:
client, addr = sock.accept()
thread = threading.Thread(
target=self.handle_tcp_client,
args=(client, addr)
)
thread.daemon = True
thread.start()
except:
break
def start(self):
udp_thread = threading.Thread(target=self.handle_udp)
tcp_thread = threading.Thread(target=self.handle_tcp)
udp_thread.daemon = True
tcp_thread.daemon = True
udp_thread.start()
tcp_thread.start()
print("日志服务器已启动,按 Ctrl+C 停止")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
self.running = False
print("\n日志服务器已停止")
if __name__ == '__main__':
server = LogServer()
server.start()
日志格式与协议设计
光发字符串可不行,得有个协议。我一般这样设计:
// 日志消息格式
// [设备ID][日志级别][时间戳][模块名] 消息内容
// 示例:
// [DEV-001][ERROR][2024-01-15 10:30:45][SENSOR] 温度传感器读取失败,返回值: -1
// [DEV-001][INFO][2024-01-15 10:30:46][MAIN] 系统正常运行,CPU: 45%
// 二进制格式(适合带宽有限的场景)
typedef struct {
uint32_t device_id; // 设备ID
uint8_t level; // 日志级别
uint32_t timestamp; // Unix时间戳
uint16_t module_id; // 模块ID
uint16_t msg_len; // 消息长度
char message[0]; // 变长消息
} __attribute__((packed)) log_packet_t;
经验之谈:文本格式调试方便,但带宽开销大。二进制格式省流量,但解析麻烦。我一般调试阶段用文本,量产阶段切二进制。如果设备有 SD 卡,我会本地也存一份,网络断了也不怕。
远程日志的架构图
下面这张图展示了整个远程日志系统的数据流向:
避坑指南
做远程日志这些年,踩过的坑不少。挑几个典型的说说:
- 日志风暴:设备死循环疯狂发日志,直接把网络打爆。我建议加个限速,每秒最多发 10 条。
- 缓冲区溢出:TCP 发送缓冲区满了,send() 会阻塞。MCU 上阻塞几秒钟,整个系统都可能卡死。用非阻塞模式或者单独开个线程发。
- 时间同步:设备没有 RTC,日志时间戳全是 1970 年。要么加 NTP 同步,要么用上电以来的毫秒数。
- 日志泄露:日志里可能包含敏感信息(密码、密钥)。量产版本记得过滤掉。
曾经踩过的坑:有一次设备上报的日志里带了完整的 AT 指令交互过程,里面包含 SIM 卡的 PUK 码。还好是内部测试环境,要是被客户看到就尴尬了。从那以后,我所有日志输出都加了个脱敏函数。
总结
远程日志说白了就是给设备装了个"黑匣子",只不过这个黑匣子会主动往外发数据。
UDP 适合调试,TCP 适合产品。服务器端用 Python 快速验证,稳定了再考虑用 C 重写。
嗯,这一章就到这里。代码都在上面了,拿去用就行。有问题欢迎交流。