串口通信中实现基于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发来的查询命令。
功能需求
- PC发送
{"action":"get_env"} - 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。正确的做法是:
- 先用JSON协商传输参数:
json {"cmd":"start_ota","url":"http://x.y.z/firmware.bin","size":123456,"crc":0xABCD1234} - 收到确认后,切换到 二进制模式 传输数据块
- 传完再用JSON通知结果:
json {"event":"ota_complete","result":"success"}
即: 控制信令用JSON,数据载荷用专用协议 ,各司其职。
场景三:多设备级联
如果有多个从机挂在同一条总线上(类似Modbus),怎么区分?
可以在JSON中加入 addr 字段:
{"addr":2,"cmd":"read_battery"}
主机广播,每个从机判断 addr 是否匹配自己,再决定是否响应。
不过要注意:UART本身不支持多点通信,需配合RS485等差分信号实现。
工程建议:让系统更健壮的10个小贴士
- 统一编码格式 :全部使用UTF-8,避免中文乱码
- 禁用动态内存频繁分配 :在RTOS中使用内存池管理cJSON对象
- 启用DMA接收 :减少CPU中断负担,尤其适用于高速率场景
- 添加启动握手 :设备上电后发送
{"boot":"ready","ver":"1.2.0"}告知状态 - 支持Ping/Pong心跳检测 :
json {"ping":12345} → {"pong":12345} - 日志分级输出 :DEBUG级别可发送详细JSON日志,RELEASE版关闭
- 支持配置导出导入 :
json {"cmd":"save_config"} → {"config":{"led_mode":2,"report_intv":60}} - 使用静态分析工具 :如Cppcheck扫描cJSON使用是否存在内存泄漏
- 波特率自适应探测 :首次连接尝试常见波特率(9600, 115200等)
- 提供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),仅供参考
3566

被折叠的 条评论
为什么被折叠?



