串口通信中实现基于JSON的数据传输

AI助手已提取文章相关产品:

串口通信中实现基于JSON的数据传输

你有没有遇到过这样的场景:调试一个嵌入式设备时,串口助手屏幕上飘过一串串十六进制数,像天书一样?
你想知道当前传感器温度是多少,结果收到的是 0x5A 0x02 0x19 0x3F —— 得翻协议文档、查表、手动转换……一次两次还行,频繁交互简直让人崩溃。

更头疼的是,团队里前端同事用Python写上位机,硬件工程师用C写单片机,协议一旦改动,两边都得改代码,还得同步更新文档。稍不注意,就出现“我发了但你没收到”、“格式对不上解析失败”这类低级又难查的问题。

其实,这些问题早有解法—— 把JSON搬上串口

没错,就是那个Web开发天天打交道的JSON。别笑,这可不是“杀鸡用牛刀”,而是在资源受限的嵌入式世界里,一种越来越主流、高效且人性化的通信范式。


为什么是JSON?不只是“好看”那么简单

我们先抛开技术细节,来想想: 通信的本质是什么?

不是传输字节,而是 传递意图

传统二进制协议追求极致压缩和性能,却牺牲了可读性和灵活性。比如定义一个命令:

struct cmd {
    uint8_t type;     // 0x01: set_led, 0x02: read_temp
    uint8_t value;    // 亮度值 0~100
    uint8_t reserved; // 对齐用
};

看起来挺紧凑,但问题来了:
- 新增一个参数怎么办?只能改结构体,固件全得升级。
- 调试时怎么知道发出去的是什么?还得靠注释或协议文档。
- Python端要构造这个包,得用 struct.pack('BBB', 1, 75, 0) ,写起来反人类。

而换成JSON呢?

{"cmd":"set_led","brightness":75}\n

一眼就能看懂:这是在设置LED亮度为75。不需要额外文档,字段名本身就是说明。

更重要的是—— 它天生支持扩展
今天加个渐变时间:

{"cmd":"set_led","brightness":75,"fade_time":2000}

旧版本设备收到这条消息,不认识 fade_time ?没关系,只要它能识别 cmd brightness ,就可以忽略未知字段继续执行。系统平滑演进,无需强制升级。

这就是JSON的核心价值: 语义清晰 + 向后兼容 + 开发友好


串口真的适合传JSON吗?资源够用吗?

很多人一听“在STM32上传JSON”,第一反应是:“太重了吧?MCU内存才几KB,堆都分分钟炸。”

这话放在十年前可能成立,但现在—— 完全不是问题

现实已经变了

现在的轻量级JSON库,早已不是当年动辄上千行的庞然大物。以广泛使用的 cJSON 为例:

  • 代码量:约 1000 行 C 语言
  • RAM 占用:解析时临时使用几百字节栈空间(可配置)
  • Flash 占用:编译后约 4~8 KB(GCC O2优化下)

对于一片 STM32F103C8T6(64KB Flash, 20KB RAM)来说,这点开销完全可以接受。

而且,大多数情况下我们传输的JSON并不大。典型的一条控制指令或状态上报,通常在 80~200 字节之间 。比如:

{"sensor":"temp","value":24.6,"ts":1712345678}\n

总共才 50 多个字符。

再算笔账:
波特率设为常见的 115200 bps ,理论最大传输速度约 11.5 KB/s。
即使每秒发送10条JSON消息(每条平均100字节),总带宽需求也才 1 KB/s,不到理论值的10%。

所以结论很明确: 在现代MCU上跑JSON over UART,不仅可行,而且非常实用


怎么安全地把JSON塞进串口?关键在“帧定界”

UART本质是个 字节流通道 ,不像TCP有“包”的概念。它不会自动告诉你哪几个字节属于一条完整消息。

如果你直接往串口发:

{"a":1}\n{"b":2}\n{"c":3}\n

接收方可能会分几次收到:
- 第一次: {"a":1}\n{
- 第二次: "b":2}\n{"c
- 第三次: :3}\n

这就叫“粘包”和“拆包”。处理不好,JSON解析必崩。

所以,我们必须自己定义 消息边界

最简单也最有效的方案:换行符 \n 作为帧结束符

就像HTTP文本协议用 \r\n\r\n 分隔头一样,我们也约定: 每条JSON消息以 \n 结尾

这样,接收方只需做一件事: 持续缓存接收到的字节,直到遇到 \n ,然后尝试解析这一整段字符串为JSON

来看一段真实可用的C代码(基于中断接收):

#define MAX_JSON_LEN 256
static char rx_buffer[MAX_JSON_LEN];
static int buf_len = 0;

void uart_rx_isr(uint8_t byte) {
    if (byte == '\n' || byte == '\r') {
        if (buf_len == 0) return; // 忽略空行

        rx_buffer[buf_len] = '\0'; // 添加字符串结束符

        // 尝试解析JSON
        cJSON *json = cJSON_Parse(rx_buffer);
        if (json != NULL) {
            handle_json_command(json);  // 处理业务逻辑
            cJSON_Delete(json);
        } else {
            // 解析失败,可以回复错误信息
            send_json_error("parse_failed", rx_buffer);
        }

        buf_len = 0; // 清空缓冲区
    } else {
        if (buf_len < MAX_JSON_LEN - 1) {
            rx_buffer[buf_len++] = byte;
        } else {
            // 缓冲区满!可能是恶意攻击或配置错误
            buf_len = 0;
            send_json_error("buffer_overflow", NULL);
        }
    }
}

是不是很简单?但这里面藏着几个至关重要的工程细节。

⚠️ 细节一:永远不要假设数据“一定完整”

网络通信中有个经典原则:“ 永远不要相信输入 ”。串口也一样。

用户可能手误发送乱码,也可能线路干扰导致数据错乱。你的程序必须能优雅地处理这些异常。

上面代码中, cJSON_Parse() 返回 NULL 时,我们没有崩溃,而是记录日志或返回错误响应。这是一种 防御性编程思维

⚠️ 细节二:缓冲区大小必须有限制

想象一下,如果对方一直发不带 \n 的数据, buf_len 会一直增长,最终溢出数组边界,造成内存破坏。

所以我们设置了 MAX_JSON_LEN = 256 ,并在此处做了判断:

if (buf_len < MAX_JSON_LEN - 1) { ... }

一旦超过阈值,立即清空并报警。这个数字不是随便定的——它是权衡后的结果:

限制太小(如64B) 限制太大(如1024B)
容易触发截断 浪费RAM,增加碎片风险
不够表达复杂指令 可能引发OOM
适合简单控制 适合大数据回传

一般建议: 控制类消息 ≤ 200B,状态上报 ≤ 500B

⚠️ 细节三:考虑超时机制防“半截消息”

还有一种情况:消息开始接收了,但中途断了,再也收不到 \n 。比如发送方突然断电。

这时缓冲区里的数据就成了“孤儿”,既不能解析,也不能丢弃(万一后面还会来呢?)。

解决方案是引入 接收超时定时器

static uint32_t last_byte_time;

void uart_rx_isr(uint8_t byte) {
    last_byte_time = get_tick_ms(); // 更新最后接收时间

    // ... 原有逻辑 ...
}

// 在主循环或定时器中检查
void check_rx_timeout() {
    if (buf_len > 0 && (get_tick_ms() - last_byte_time) > 1000) {
        // 超过1秒未收完,视为异常
        buf_len = 0;
        send_json_error("recv_timeout", NULL);
    }
}

这样一来,即使通信中断,系统也能自我恢复,不会卡死。


如何让JSON更省流量?压缩与优化技巧

虽然JSON比XML轻量得多,但在低速串口上,每一个字节都是钱啊!

特别是当你需要高频上报数据时(比如每100ms发一次),原始格式可能长这样:

{"timestamp":1712345678,"sensor_id":"S001","temperature":25.3,"humidity":60.1,"battery":3.7,"status":"ok"}\n

足足90多个字符。能不能再压一压?

当然可以!以下是几种实战中常用的优化手段。

✅ 技巧一:去掉空格,使用紧凑输出

标准JSON允许格式化排版,但那是给人看的。机器通信要用最小化版本。

cJSON提供两个打印函数:

char *pretty = cJSON_Print(root);           // 带缩进,易读
char *compact = cJSON_PrintUnformatted(root); // 无空格,节省空间

对比一下:
- 格式化版: {\n "a": 1\n}
- 紧凑版: {"a":1}

光这一项就能省下20%~30%的传输量。

✅ 技巧二:字段名缩写(Schema映射)

既然字段名是为了表达含义,那我们可以用短名字代替长名字,只要双方约定好就行。

比如:

原字段名 缩写
command cmd
brightness brt
temperature tmp
humidity hum
timestamp ts

于是原来的长消息变成:

{"ts":1712345678,"id":"S001,"tmp":25.3,"hum":60.1,"bat":3.7,"st":"ok"}\n

轻松砍掉十几个字节。

💡 提示:可以在文档中维护一张“字段映射表”,方便后期查阅。

✅ 技巧三:数值精度裁剪

浮点数往往带有过多小数位。比如ADC读取电压 3.712846 V,真的需要这么多位吗?

根据实际需求,保留1~2位足矣。修改后:

{"v":3.7}  // 而不是 {"voltage":3.712846}

不仅节省空间,还能减少浮点运算带来的精度误差。

✅ 技巧四:数组替代对象(当字段固定时)

如果每次发送的字段是固定的,可以用数组代替对象,进一步压缩。

比如原本:

{"tmp":25.3,"hum":60.1,"prs":1013}

改成:

[25.3,60.1,1013]

长度从约40字节降到约20字节!

当然代价是失去了自描述性,必须严格依赖顺序。适合高频、低延迟的传感器数据流。

你可以设计成两种模式共存:
- 控制命令用 命名式JSON对象 (强调可读)
- 数据采集用 紧凑数组格式 (强调效率)


安全性不能忽视:别让JSON成为漏洞入口

有人可能会说:“JSON这么简单,还能有什么安全问题?”

错。任何外部输入都是潜在攻击面。

设想这样一个场景:你的设备通过串口接收JSON命令来重启系统:

{"cmd":"reboot"}

但如果有人恶意发送:

{"cmd":"format_disk"} 

或者更狠一点:

{"cmd":"inject_malware"}

你的设备会不会执行?

当然不会——因为你没实现这些命令。但问题是, 你是否验证了输入的合法性?

✅ 防御策略一:命令白名单机制

最简单的做法是在解析后做一层校验:

void handle_json_command(cJSON *root) {
    cJSON *cmd = cJSON_GetObjectItem(root, "cmd");
    if (!cmd || !cJSON_IsString(cmd)) {
        send_error("missing_cmd");
        return;
    }

    const char *cmd_str = cmd->valuestring;

    if (strcmp(cmd_str, "set_led") == 0) {
        handle_set_led(root);
    } else if (strcmp(cmd_str, "read_temp") == 0) {
        handle_read_temp(root);
    } else if (strcmp(cmd_str, "reboot") == 0) {
        handle_reboot(root);
    } else {
        send_error("unknown_cmd", cmd_str); // 明确拒绝非法指令
    }
}

这样即使收到奇怪命令,也不会误执行。

✅ 防御策略二:参数范围检查

即使命令合法,参数也可能越界。

比如设置LED亮度:

{"cmd":"set_led","brightness":999999}

如果不做检查,直接传给PWM函数,可能导致溢出或死机。

务必加上校验:

int brightness = cJSON_GetObjectInt(root, "brightness");
if (brightness < 0 || brightness > 100) {
    send_error("invalid_brightness", brightness);
    return;
}
pwm_set_duty(brightness);

✅ 防御策略三:敏感操作加认证

对于重启、擦除Flash、恢复出厂设置等高危操作,建议增加认证机制。

例如:

{"cmd":"factory_reset","token":"erase_now_2024"}

只有匹配预设 token 才执行。虽然不能防物理接触攻击,但至少能防止意外触发。

甚至可以动态生成一次性token,提升安全性。


实战案例:构建一个可交互的传感器节点

让我们动手搭一个小系统,看看整个流程怎么跑起来。

硬件架构

[PC 上位机] <--UART(115200)--> [ESP32主控] <--I2C--> [BME280传感器]

ESP32负责读取温湿度,并响应PC发来的查询命令。

功能需求

  1. PC发送 {"action":"get_env"}
  2. ESP32返回 {"temp":24.6,"hum":58.2,"pres":1013.4}

ESP32端代码骨架(Arduino环境)

#include <cJSON.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

Adafruit_BME280 bme;

#define RX_BUF_SIZE 256
char rx_buffer[RX_BUF_SIZE];
int buf_index = 0;

void setup() {
    Serial.begin(115200);
    Wire.begin();

    if (!bme.begin(0x76)) {
        Serial.println("BME280 not found!");
        while (1);
    }
}

void loop() {
    while (Serial.available()) {
        char c = Serial.read();
        if (c == '\n' || c == '\r') {
            if (buf_index > 0) {
                rx_buffer[buf_index] = '\0';
                parseIncomingMessage(rx_buffer);
                buf_index = 0;
            }
        } else {
            if (buf_index < RX_BUF_SIZE - 1) {
                rx_buffer[buf_index++] = c;
            } else {
                buf_index = 0; // 溢出保护
            }
        }
    }

    delay(10); // 主循环不要太忙
}

void parseIncomingMessage(char* input) {
    cJSON *root = cJSON_Parse(input);
    if (!root) {
        sendError("invalid_json", input);
        return;
    }

    cJSON *action = cJSON_GetObjectItem(root, "action");
    if (action && cJSON_IsString(action)) {
        if (strcmp(action->valuestring, "get_env") == 0) {
            sendEnvironmentData();
        } else {
            sendError("unknown_action", action->valuestring);
        }
    } else {
        sendError("missing_action", nullptr);
    }

    cJSON_Delete(root);
}

void sendEnvironmentData() {
    float temp = bme.readTemperature();
    float hum = bme.readHumidity();
    float pres = bme.readPressure() / 100.0f;

    cJSON *root = cJSON_CreateObject();
    cJSON_AddNumberToObject(root, "temp", temp);
    cJSON_AddNumberToObject(root, "hum", hum);
    cJSON_AddNumberToObject(root, "pres", pres);

    char *out = cJSON_PrintUnformatted(root);
    if (out) {
        Serial.println(out); // 自动加\n
        free(out);
    }
    cJSON_Delete(root);
}

void sendError(const char* code, const char* detail) {
    cJSON *root = cJSON_CreateObject();
    cJSON_AddStringToObject(root, "error", code);
    if (detail) cJSON_AddStringToObject(root, "detail", detail);

    char *out = cJSON_PrintUnformatted(root);
    if (out) {
        Serial.println(out);
        free(out);
    }
    cJSON_Delete(root);
}

PC端测试(Python)

import serial
import json

ser = serial.Serial('COM5', 115200)

def send_cmd(cmd):
    msg = json.dumps(cmd) + '\n'
    ser.write(msg.encode())

    # 等待响应
    while True:
        line = ser.readline().decode().strip()
        if line:
            try:
                resp = json.loads(line)
                print("Received:", resp)
                break
            except:
                print("Malformed response:", line)
                break

# 发送请求
send_cmd({"action": "get_env"})

运行结果:

Received: {'temp': 24.6, 'hum': 58.2, 'pres': 1013.4}

整个过程干净利落,没有任何编码解码负担。前后端开发者都能快速理解协议内容。


进阶技巧:如何应对复杂场景?

上面的例子还算简单。实际项目中,你会遇到更多挑战。

场景一:双向通信冲突

如果主机和设备都能主动发消息,怎么避免“撞车”?

比如:
- 设备正要上报心跳
- 主机同时下发配置指令

两者同时往串口写,数据就会混在一起,谁都解析不了。

解决方案:串口层加锁 + 发送队列

给发送操作加互斥锁:

SemaphoreHandle_t tx_mutex;

void send_json_safely(cJSON *root) {
    if (xSemaphoreTake(tx_mutex, 100 / portTICK_PERIOD_MS)) {
        char *out = cJSON_PrintUnformatted(root);
        if (out) {
            uart_write_bytes(UART_NUM_1, out, strlen(out));
            uart_write_bytes(UART_NUM_1, "\n", 1);
            free(out);
        }
        xSemaphoreGive(tx_mutex);
    }
}

所有发送行为都走这个函数,确保同一时间只有一个任务在发数据。

场景二:大数据块传输(如固件升级)

JSON适合小数据,但你要传几KB的固件镜像怎么办?

别硬塞进JSON。正确的做法是:

  1. 先用JSON协商传输参数:
    json {"cmd":"start_ota","url":"http://x.y.z/firmware.bin","size":123456,"crc":0xABCD1234}
  2. 收到确认后,切换到 二进制模式 传输数据块
  3. 传完再用JSON通知结果:
    json {"event":"ota_complete","result":"success"}

即: 控制信令用JSON,数据载荷用专用协议 ,各司其职。

场景三:多设备级联

如果有多个从机挂在同一条总线上(类似Modbus),怎么区分?

可以在JSON中加入 addr 字段:

{"addr":2,"cmd":"read_battery"}

主机广播,每个从机判断 addr 是否匹配自己,再决定是否响应。

不过要注意:UART本身不支持多点通信,需配合RS485等差分信号实现。


工程建议:让系统更健壮的10个小贴士

  1. 统一编码格式 :全部使用UTF-8,避免中文乱码
  2. 禁用动态内存频繁分配 :在RTOS中使用内存池管理cJSON对象
  3. 启用DMA接收 :减少CPU中断负担,尤其适用于高速率场景
  4. 添加启动握手 :设备上电后发送 {"boot":"ready","ver":"1.2.0"} 告知状态
  5. 支持Ping/Pong心跳检测
    json {"ping":12345} → {"pong":12345}
  6. 日志分级输出 :DEBUG级别可发送详细JSON日志,RELEASE版关闭
  7. 支持配置导出导入
    json {"cmd":"save_config"} → {"config":{"led_mode":2,"report_intv":60}}
  8. 使用静态分析工具 :如Cppcheck扫描cJSON使用是否存在内存泄漏
  9. 波特率自适应探测 :首次连接尝试常见波特率(9600, 115200等)
  10. 提供schema文档 :用JSON Schema规范接口格式,方便协作

写到最后:技术的选择,其实是人的选择

回到最初的问题:为什么要在串口上传JSON?

因为它让机器之间的对话,变得更像人与人之间的交流。

以前,我们需要一本厚厚的《通信协议手册》,里面写着:

“命令类型0x01表示设置LED,参数范围0~100,第3字节保留…”

现在,协议就在消息里:

{"cmd":"set_led","brightness":75}

不需要解释,自然懂。

它降低了新人入门门槛,减少了沟通成本,提升了调试效率。在一个快速迭代的时代,这些“软性收益”往往比那几个百分点的性能损耗重要得多。

而且,今天的嵌入式平台早已今非昔比。ESP32、STM32H7、GD32系列……越来越多的MCU具备足够的资源运行现代软件栈。

我们不必再为“省100字节”而牺牲可维护性。

相反,我们应该思考如何利用好这些资源,构建更容易理解、更少出错、更能适应变化的系统。

JSON over UART 正是这样一个微小但有力的实践。

下次当你准备设计一个新的串口协议时,不妨问问自己:

“我能用JSON吗?如果能,为什么不呢?” 🤔💡

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值