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 卡,我会本地也存一份,网络断了也不怕。

远程日志的架构图

下面这张图展示了整个远程日志系统的数据流向:

远程日志系统架构图 嵌入式设备 日志采集模块 UDP/TCP 发送端 本地缓存(SD卡) 设备ID: DEV-001 网络传输 UDP(无连接,快速) TCP(可靠,有序) 日志服务器 UDP 接收线程 TCP 连接管理 日志解析与存储 端口: 8888/8889 日志流 日志流 服务器控制台输出示例 [2024-01-15 10:30:45] [UDP] [192.168.1.50:12345] [DEV-001][ERROR] 传感器故障 [2024-01-15 10:30:46] [TCP] [192.168.1.51:54321] [DEV-002][INFO] 系统正常启动 日志存储 文件 / 数据库 持久化 设备端 网络 服务器

避坑指南

做远程日志这些年,踩过的坑不少。挑几个典型的说说:

  • 日志风暴:设备死循环疯狂发日志,直接把网络打爆。我建议加个限速,每秒最多发 10 条。
  • 缓冲区溢出:TCP 发送缓冲区满了,send() 会阻塞。MCU 上阻塞几秒钟,整个系统都可能卡死。用非阻塞模式或者单独开个线程发。
  • 时间同步:设备没有 RTC,日志时间戳全是 1970 年。要么加 NTP 同步,要么用上电以来的毫秒数。
  • 日志泄露:日志里可能包含敏感信息(密码、密钥)。量产版本记得过滤掉。
曾经踩过的坑:有一次设备上报的日志里带了完整的 AT 指令交互过程,里面包含 SIM 卡的 PUK 码。还好是内部测试环境,要是被客户看到就尴尬了。从那以后,我所有日志输出都加了个脱敏函数。

总结

远程日志说白了就是给设备装了个"黑匣子",只不过这个黑匣子会主动往外发数据。

UDP 适合调试,TCP 适合产品。服务器端用 Python 快速验证,稳定了再考虑用 C 重写。

嗯,这一章就到这里。代码都在上面了,拿去用就行。有问题欢迎交流。


公众号:蓝海资料掘金营,微信deep3321