简介:这份资源面向工业自动化与嵌入式通信方向的开发者,聚焦HART协议主模式协议栈的软件实现,帮助读者理解如何在现有4-20mA模拟信号上叠加FSK数字信号,完成主设备对智能仪表的寻址、命令下发与数据采集。压缩包共19个文件,以10个C源文件和8个头文件为主体,另附1份说明文档,整体约21KB,代码结构紧凑,便于直接阅读与移植;头文件承担帧结构、命令集与模式配置等接口定义,源文件则对应协议栈各功能模块的实现。资源设计上支持通过修改头文件在主模式与从模式之间切换,模块化程度较高。已有568人学习下载。读者可从中获取帧结构组织、时序控制、FSK调制解调、奇偶校验与CRC错误检测、命令响应管理及多设备寻址等关键实现思路,适合作为协议栈学习与项目集成的参考代码。
1. Hart 主模式-协议栈实现:从帧同步到命令响应的最小闭环
很多做现场仪表的工程师第一次接触 Hart 主模式,都会卡在同一个地方:从机模式跑通了,能被动应答,但一旦要主动发起命令、轮询从机、处理突发变量,整个协议栈就像黑匣子一样不透明。Hart 主模式-协议栈实现的核心,不是把物理层调制解调做出来,而是把帧同步、命令调度、超时重试、突发模式管理这四件事串成一个可复现的闭环。它适合两类人:一类是手上有 Hart 从机设备、想用 MCU 或工控板做主站轮询的嵌入式开发者;另一类是想把 Hart 命令集封装成可测试模块、做协议一致性验证的测试工程师。这篇文章不讲 Hart 物理层 Bell 202 调制解调的模拟电路细节,只讲数字侧协议栈怎么分层、状态机怎么设计、参数怎么设、坑在哪。
2. Hart 主模式协议栈的分层设计与状态机选型
Hart 协议本身是主从式半双工,主模式协议栈要处理的核心矛盾是:物理层是异步串行、帧间隔有严格要求,而应用层命令又需要同步等待响应。如果直接把命令发送和响应接收写成阻塞式串口读写,在突发模式下会被从机主动上报的数据打断,导致命令响应错位。所以协议栈必须分层,且主状态机要能区分「命令事务」和「突发监听」两种上下文。
2.1 物理层抽象与帧定界:为什么不能直接用串口空闲中断
Hart 帧格式是 1 个起始位、8 个数据位、1 个奇偶校验位、1 个停止位,字符间允许有间隔,但帧内字符间隔不能超过 1.5 个字符时间,帧间间隔至少 2 个字符时间。很多开发者直接用 UART 空闲中断判断帧结束,这在 Hart 上会翻车:因为 Hart 帧内字符间隔可能达到 1 个字符时间,空闲中断阈值设小了会把一帧拆成多帧,设大了会把两帧粘在一起。
常见做法是用定时器做字符超时检测:每收到一个字节重置定时器,定时器超时时间设为 1.5 个字符时间,超时即认为帧结束。在 1200bps 下,1 个字符时间约 8.33ms,1.5 个字符时间约 12.5ms。这个参数不能拍脑袋,要根据实际波特率算。
// Hart 帧接收状态机:字符超时定界 #define HART_BAUD_RATE 1200 #define CHAR_TIME_MS (10000 / HART_BAUD_RATE) // 8.33ms #define FRAME_END_TIMEOUT ((CHAR_TIME_MS * 3) / 2) // 12.5ms typedef enum { HART_RX_IDLE, HART_RX_RECEIVING, HART_RX_FRAME_READY } hart_rx_state_t; static hart_rx_state_t rx_state = HART_RX_IDLE; static uint8_t rx_buf[256]; static uint16_t rx_len = 0; static uint32_t last_char_tick = 0; void hart_uart_rx_isr(uint8_t byte) { last_char_tick = get_tick_ms(); if (rx_state == HART_RX_IDLE) { rx_state = HART_RX_RECEIVING; rx_len = 0; } if (rx_len < sizeof(rx_buf)) { rx_buf[rx_len++] = byte; } } void hart_timer_tick_isr(void) { if (rx_state == HART_RX_RECEIVING) { if ((get_tick_ms() - last_char_tick) >= FRAME_END_TIMEOUT) { rx_state = HART_RX_FRAME_READY; } } }这段代码的关键参数是FRAME_END_TIMEOUT,它决定了帧边界判定。设小了会把长帧截断,设大了会丢失帧间隔。实际调试时建议用示波器抓 UART 波形,确认帧内最大字符间隔,再留 20% 余量。另外rx_buf大小要覆盖最长 Hart 帧,短帧格式下最大 25 字节左右,长帧格式下可能到 255 字节,建议直接给 256。
2.2 主状态机设计:命令事务与突发监听的上下文切换
Hart 主模式有两种工作状态:一是主动发送命令并等待响应,二是被动监听从机突发帧。如果状态机设计成单一阻塞流程,突发帧到来时会把命令响应缓冲区冲掉。我一般会把主状态机拆成两个子状态机:命令事务状态机和突发接收状态机,用事件队列解耦。
命令事务状态机有这几个状态:空闲、发送命令、等待响应、处理响应、超时重试。突发接收状态机独立运行,收到突发帧后打时间戳入队,不干扰命令事务。两者共享物理层接收缓冲区,但通过帧类型字段区分。
typedef enum { CMD_IDLE, CMD_SENDING, CMD_WAIT_RESP, CMD_PROC_RESP, CMD_RETRY, CMD_ERROR } cmd_state_t; typedef struct { uint8_t addr; uint8_t cmd; uint8_t data[64]; uint8_t data_len; uint8_t retry_max; uint16_t resp_timeout_ms; } hart_cmd_t; static cmd_state_t cmd_state = CMD_IDLE; static hart_cmd_t current_cmd; static uint8_t retry_count = 0; static uint32_t cmd_send_tick = 0; void hart_cmd_task(void) { switch (cmd_state) { case CMD_IDLE: if (cmd_queue_pop(¤t_cmd)) { retry_count = 0; cmd_state = CMD_SENDING; } break; case CMD_SENDING: hart_send_frame(current_cmd.addr, current_cmd.cmd, current_cmd.data, current_cmd.data_len); cmd_send_tick = get_tick_ms(); cmd_state = CMD_WAIT_RESP; break; case CMD_WAIT_RESP: if (hart_frame_ready()) { cmd_state = CMD_PROC_RESP; } else if ((get_tick_ms() - cmd_send_tick) > current_cmd.resp_timeout_ms) { cmd_state = CMD_RETRY; } break; case CMD_PROC_RESP: if (hart_check_response(¤t_cmd)) { cmd_state = CMD_IDLE; } else { cmd_state = CMD_RETRY; } break; case CMD_RETRY: retry_count++; if (retry_count <= current_cmd.retry_max) { cmd_state = CMD_SENDING; } else { cmd_state = CMD_ERROR; } break; case CMD_ERROR: hart_log_error(¤t_cmd); cmd_state = CMD_IDLE; break; } }这里resp_timeout_ms是主模式最关键的参数之一。Hart 协议规定从机响应时间,但实际设备可能因为内部处理慢而延迟。我一般设 2 到 3 倍理论响应时间,1200bps 下短帧命令设 500ms 左右,长帧命令设 1000ms。retry_max建议 2 到 3 次,再多会拖慢轮询周期。注意CMD_PROC_RESP里要做帧校验,包括地址匹配、命令码匹配、校验和验证,任何一项不通过都走重试。
2.3 命令调度与轮询表:怎么组织多从机多命令
实际项目里主站要轮询多个从机,每个从机要读多个变量。如果每读一个变量发一条命令,效率很低。Hart 支持命令 3 读动态变量和突发模式,前者一次最多读 4 个变量,后者从机主动周期上报。我一般会建一张轮询表,按从机地址分组,每组内按命令优先级排序。
轮询表可以用静态数组实现,每个条目包含从机地址、命令码、数据指针、超时、重试次数。调度器按顺序执行,遇到突发帧插入时暂停当前命令事务,处理完突发再继续。这样既保证实时性,又不丢命令。
typedef struct { uint8_t addr; uint8_t cmd; uint8_t data[64]; uint8_t data_len; uint16_t timeout_ms; uint8_t retry_max; uint8_t priority; // 0 最高 } poll_entry_t; static const poll_entry_t poll_table[] = { {0x01, 0x00, {0}, 0, 500, 2, 0}, // 读唯一标识符 {0x01, 0x03, {0}, 0, 500, 2, 1}, // 读动态变量 {0x02, 0x00, {0}, 0, 500, 2, 0}, {0x02, 0x03, {0}, 0, 500, 2, 1}, }; void hart_poll_scheduler(void) { static uint8_t idx = 0; if (cmd_state != CMD_IDLE) return; const poll_entry_t *e = &poll_table[idx]; hart_cmd_t c; c.addr = e->addr; c.cmd = e->cmd; memcpy(c.data, e->data, e->data_len); c.data_len = e->data_len; c.resp_timeout_ms = e->timeout_ms; c.retry_max = e->retry_max; cmd_queue_push(&c); idx = (idx + 1) % (sizeof(poll_table) / sizeof(poll_table[0])); }轮询表的设计要点是优先级和超时分离。高优先级命令(如读设备状态)超时设短,低优先级命令(如读历史数据)超时设长。另外轮询周期要算总时间,包括命令发送时间、响应等待时间、帧间隔。1200bps 下每字节约 8.33ms,一条短命令加响应约 20 字节,光传输就 170ms 左右,加上超时余量,单从机单命令轮询周期至少 300ms。多从机时要么提高波特率,要么减少轮询变量。
3. Hart 主模式协议栈的代码实现与参数配置
协议栈数字侧实现的核心是帧组装、帧解析、校验和计算、命令映射四块。帧组装要处理前导符、定界符、地址、命令、数据长度、数据、校验和。帧解析是逆过程,但要多做一步帧类型判断:短帧还是长帧,响应帧还是突发帧。校验和是纵向奇偶校验,从定界符开始到数据结束所有字节异或。
3.1 帧组装与解析:短帧长帧的边界处理
Hart 帧有两种格式:短帧和长帧。短帧定界符是 0x02,地址 1 字节,命令 1 字节,数据长度 1 字节,数据 0 到 255 字节,校验和 1 字节。长帧定界符是 0x82,地址 5 字节,其余相同。主模式发送命令时,如果从机地址是短地址(0 到 15),用短帧;如果是长地址(38 位唯一标识符),用长帧。
uint8_t hart_build_frame(uint8_t *buf, uint8_t addr, uint8_t cmd, const uint8_t *data, uint8_t data_len, bool long_addr, const uint8_t *long_id) { uint8_t idx = 0; uint8_t i; uint8_t checksum = 0; // 前导符:至少 5 个 0xFF for (i = 0; i < 5; i++) buf[idx++] = 0xFF; if (long_addr) { buf[idx++] = 0x82; // 长帧定界符 for (i = 0; i < 5; i++) buf[idx++] = long_id[i]; } else { buf[idx++] = 0x02; // 短帧定界符 buf[idx++] = addr; } buf[idx++] = cmd; buf[idx++] = data_len; for (i = 0; i < data_len; i++) buf[idx++] = data[i]; // 校验和:从定界符开始到数据结束 uint8_t start = long_addr ? 5 : 5; for (i = start; i < idx; i++) checksum ^= buf[i]; buf[idx++] = checksum; return idx; }这里有个容易翻车的点:校验和计算起始位置。前导符不参与校验,从定界符开始算。短帧定界符在 buf[5],长帧也在 buf[5],所以start都是 5。但长帧地址占 5 字节,短帧地址占 1 字节,后续字段偏移不同。解析时也要按同样规则反向操作。
帧解析函数要返回帧类型、地址、命令、数据指针和数据长度。解析失败要能区分是前导符不足、定界符错误、校验和错误还是长度越界。
typedef struct { bool is_long; uint8_t addr; uint8_t long_id[5]; uint8_t cmd; uint8_t data_len; uint8_t data[256]; bool valid; } hart_frame_t; bool hart_parse_frame(const uint8_t *buf, uint16_t len, hart_frame_t *out) { uint16_t i; uint8_t checksum = 0; uint16_t start; if (len < 8) return false; // 跳过前导符 i = 0; while (i < len && buf[i] == 0xFF) i++; if (i < 5) return false; // 前导符不足 start = i; if (buf[i] == 0x82) { out->is_long = true; if (len < i + 1 + 5 + 1 + 1 + 1) return false; i++; memcpy(out->long_id, &buf[i], 5); i += 5; } else if (buf[i] == 0x02) { out->is_long = false; if (len < i + 1 + 1 + 1 + 1 + 1) return false; i++; out->addr = buf[i++]; } else { return false; } out->cmd = buf[i++]; out->data_len = buf[i++]; if (len < i + out->data_len + 1) return false; memcpy(out->data, &buf[i], out->data_len); i += out->data_len; for (uint16_t j = start; j < i; j++) checksum ^= buf[j]; if (checksum != buf[i]) return false; out->valid = true; return true; }解析函数里len < 8是最小长度检查,短帧最短是 5 前导 + 1 定界 + 1 地址 + 1 命令 + 1 长度 + 0 数据 + 1 校验 = 10 字节,长帧最短是 5 + 1 + 5 + 1 + 1 + 0 + 1 = 14 字节。实际写的时候按最小可能值检查,避免越界。
3.2 命令映射与响应处理:命令 0、1、2、3 的差异
Hart 命令集里主模式最常用的是命令 0 读唯一标识符、命令 1 读主变量、命令 2 读电流和百分比、命令 3 读动态变量。命令 0 返回 5 字节长地址,用于后续长帧通信。命令 1 返回主变量单位、值、状态。命令 2 返回电流和百分比。命令 3 返回 4 个动态变量,每个变量 6 字节左右。
响应处理要按命令码分支,不能统一处理。命令 0 的响应数据是固定 5 字节,命令 3 的响应数据是变长的,前 1 字节是变量数量,后面每个变量有单位码、值、状态。
void hart_handle_response(const hart_frame_t *f) { switch (f->cmd) { case 0: if (f->data_len >= 5) { memcpy(device_long_id, f->data, 5); device_use_long_addr = true; } break; case 1: if (f->data_len >= 5) { uint8_t unit = f->data[0]; float value = hart_decode_float(&f->data[1]); uint8_t status = f->data[4]; update_pv(unit, value, status); } break; case 2: if (f->data_len >= 4) { float current = hart_decode_float(&f->data[0]); float percent = hart_decode_float(&f->data[2]); update_current(current, percent); } break; case 3: if (f->data_len >= 1) { uint8_t num = f->data[0]; for (uint8_t i = 0; i < num && (1 + i * 6 + 5) < f->data_len; i++) { uint8_t unit = f->data[1 + i * 6]; float value = hart_decode_float(&f->data[1 + i * 6 + 1]); update_dynamic_var(i, unit, value); } } break; default: break; } }hart_decode_float是 Hart 浮点格式解码,Hart 用 IEEE 754 单精度但字节序可能不同,常见的是大端。实际调试时如果读出来的值明显不对,先检查字节序。命令 3 的变量数量字段是 1 字节,但有些设备返回 0 表示无动态变量,这时不要继续解析。
3.3 超时重试与错误统计:参数怎么设才不拖慢轮询
超时重试参数直接影响轮询效率和可靠性。超时设太短,从机还没处理完就重试,导致重复命令;设太长,轮询周期拉长。重试次数设太多,单次故障恢复慢;设太少,偶发干扰导致数据丢失。
我一般按这个规则设:短帧命令超时 500ms,长帧命令超时 1000ms,重试 2 次。如果现场干扰大,重试加到 3 次,但轮询周期要相应放宽。错误统计要记录超时次数、校验和错误次数、地址不匹配次数,这些数据能帮定位是物理层问题还是协议层问题。
typedef struct { uint32_t timeout_cnt; uint32_t checksum_err_cnt; uint32_t addr_mismatch_cnt; uint32_t retry_cnt; uint32_t total_cmd_cnt; } hart_err_stats_t; static hart_err_stats_t err_stats; void hart_record_error(hart_err_type_t type) { switch (type) { case ERR_TIMEOUT: err_stats.timeout_cnt++; break; case ERR_CHECKSUM: err_stats.checksum_err_cnt++; break; case ERR_ADDR_MISMATCH: err_stats.addr_mismatch_cnt++; break; case ERR_RETRY: err_stats.retry_cnt++; break; } err_stats.total_cmd_cnt++; } float hart_get_success_rate(void) { if (err_stats.total_cmd_cnt == 0) return 0.0f; uint32_t fail = err_stats.timeout_cnt + err_stats.checksum_err_cnt + err_stats.addr_mismatch_cnt; return 1.0f - (float)fail / err_stats.total_cmd_cnt; }成功率的计算要注意,重试成功不算失败,只有最终失败才计入。实际项目里成功率低于 95% 就要查物理层,低于 80% 基本是接线或调制解调问题。
4. Hart 主模式协议栈避坑:五个血泪踩坑记录
4.1 前导符数量不够导致从机不响应
现象:主站发送命令后从机完全没反应,示波器看波形正常,但逻辑分析仪抓不到从机响应。
原因:Hart 协议要求前导符至少 5 个 0xFF,但有些从机要求更多,尤其是长帧。我遇到过一款从机要求前导符 8 个以上,发 5 个时从机直接丢弃。
解决:前导符统一发 8 个,兼容性最好。代价是每帧多 3 字节,1200bps 下多 25ms,可接受。
4.2 帧间隔不足导致两帧粘连
现象:连续发送两条命令时,第二条命令的响应解析错误,校验和偶尔通过但数据错位。
原因:Hart 要求帧间间隔至少 2 个字符时间,1200bps 下约 16.7ms。如果主站发完命令立刻发下一条,从机可能把两帧当成一帧。
解决:在CMD_SENDING状态前加帧间隔延时,用定时器保证至少 20ms。另外从机响应后也要等帧间隔再发下一条。
4.3 突发帧冲掉命令响应缓冲区
现象:开启从机突发模式后,命令响应偶尔丢失,错误统计里超时次数增加。
原因:突发帧和命令响应共用接收缓冲区,突发帧到来时如果命令事务正在等待响应,缓冲区被覆盖。
解决:接收缓冲区用双缓冲,物理层中断收到帧后按帧类型分发到不同队列。命令响应队列和突发队列独立,命令事务只从命令响应队列取数据。
4.4 长地址切换时机错误
现象:命令 0 读回长地址后,后续命令用长帧发送,但从机不响应。
原因:命令 0 必须用短帧发送,读回长地址后才能切长帧。如果一开始就用长帧发命令 0,从机不认。
解决:上电后先用短地址发命令 0,拿到长地址后标记device_use_long_addr = true,后续命令用长帧。注意长帧地址是 5 字节,不是 1 字节。
4.5 校验和计算范围搞错
现象:主站发的命令从机不响应,但从机发的响应主站能解析。
原因:主站校验和计算时把前导符也算进去了,或者起始位置搞错。Hart 校验和从定界符开始,到数据最后一个字节结束,不包括前导符和校验和本身。
解决:校验和计算函数单独写,入参是定界符起始指针和长度,前导符不传进去。发送前用已知正确的帧做单元测试,确认校验和一致。
5. Hart 主模式协议栈的进阶技巧:用状态快照做协议一致性验证
协议栈写完后怎么验证它是对的?靠现场跑只能发现显性问题,隐性时序问题很难复现。我一般会加一个状态快照机制:在命令事务的每个状态切换点记录时间戳、状态、当前命令、接收缓冲区摘要,存到环形缓冲区里。出问题时把快照导出来,按时间轴回放,能精确定位是超时设短了、重试逻辑错了还是帧解析有边界 bug。
快照结构体设计要紧凑,每个条目 32 字节左右,环形缓冲区 256 条,总共 8KB,大部分 MCU 都能承受。记录点包括:命令入队、命令发送、响应到达、校验通过、校验失败、超时触发、重试触发、命令完成。
typedef struct { uint32_t tick; uint8_t state; uint8_t cmd; uint8_t addr; uint8_t event; // 0 入队 1 发送 2 响应 3 校验通过 4 校验失败 5 超时 6 重试 7 完成 uint16_t rx_len; uint8_t rx_head[8]; // 接收缓冲区前 8 字节摘要 } hart_snapshot_t; static hart_snapshot_t snap_buf[256]; static uint16_t snap_idx = 0; void hart_snapshot_record(uint8_t event) { hart_snapshot_t *s = &snap_buf[snap_idx]; s->tick = get_tick_ms(); s->state = cmd_state; s->cmd = current_cmd.cmd; s->addr = current_cmd.addr; s->event = event; s->rx_len = hart_get_rx_len(); memcpy(s->rx_head, hart_get_rx_buf(), 8); snap_idx = (snap_idx + 1) % 256; }回放时按tick排序,看状态迁移是否合理。比如发送后 500ms 内没收到响应,快照里应该看到超时事件;如果看到校验失败事件,说明收到了帧但校验不过,要查物理层或校验和算法。
另一个技巧是用脚本模拟从机做自动化测试。用 Python 写一个 Hart 从机模拟器,监听串口,按命令码返回预设响应,故意在某些命令上延迟或返回错误校验和,验证主站的重试和错误处理逻辑。这样不用接真实设备就能跑完所有分支。
import serial import time def hart_checksum(data): cs = 0 for b in data: cs ^= b return cs def build_response(addr, cmd, data): frame = bytearray([0xFF]*5) frame.append(0x02) frame.append(addr) frame.append(cmd) frame.append(len(data)) frame.extend(data) frame.append(hart_checksum(frame[5:])) return bytes(frame) ser = serial.Serial('COM3', 1200, timeout=1) while True: raw = ser.read(64) if len(raw) < 10: continue # 简化解析:跳过前导符,取定界符后字段 idx = 0 while idx < len(raw) and raw[idx] == 0xFF: idx += 1 if idx >= len(raw) or raw[idx] != 0x02: continue addr = raw[idx+1] cmd = raw[idx+2] if cmd == 0: resp = build_response(addr, 0, [0x01,0x02,0x03,0x04,0x05]) elif cmd == 3: resp = build_response(addr, 3, [0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) else: resp = build_response(addr, cmd, []) ser.write(resp) time.sleep(0.05)这个模拟器能覆盖大部分命令响应场景,配合主站的状态快照,基本能在实验室把协议栈调稳。现场调试时把快照功能保留,出问题直接导数据,比猜快得多。
我自己的习惯是:协议栈第一版一定带快照和错误统计,哪怕占点 Flash 和 RAM。后期优化可以关掉,但调试阶段没有这些数据,排查时序问题就是玄学。希望帮到你。
本文还有配套的精品资源,点击获取