简介:面向工业自动化通信开发者的HART主模式协议栈实现代码包,聚焦智能仪表与控制系统之间基于FSK叠加4-20mA的共线数字通信场景,适合已具备嵌入式C基础、希望深入理解HART协议细节的中高级开发者参考。包内共19个文件,以10个c源文件与8个h头文件为主体,分别承载协议栈核心逻辑与帧结构、命令集等接口定义,另附1个md说明文档,压缩包约21KB,体量轻便便于通读。代码采用模块化设计,通过修改头文件即可在主模式与从模式间切换,覆盖帧结构组织、时序控制、FSK调制解调、奇偶校验与CRC错误检测、命令响应管理及多设备寻址等关键环节。已有570人学习,可帮助读者对照源码理清物理层到应用层的分层实现思路,并作为自有项目中集成与定制HART通信功能的参考底稿。
1. 从一份只有标题的协议栈说起:Hart 主模式到底能跑出什么
第一次看到「Hart 主模式-协议栈实现」这个标题时,我手里只有一份源码包,没有 README,没有版本说明,连目录都是按协议层随手命名的。Hart Protocol 在工业现场总线里属于那种「文档比代码难找、代码比文档更难读」的类型,主模式(Master)意味着这套栈要主动发起轮询、管理从站、处理命令响应,而不是被动等数据。换句话说,它更像一个调度器加一个状态机,而不是单纯的收发工具。这份资源适合两类人:一是手上接着 Hart 仪表、需要自己写上位机或网关固件的嵌入式开发者;二是想拿一套完整主站实现来拆解协议分层、学状态机写法的工程师。下面我按「能干什么、怎么跑、怎么改」的顺序,把这份源码拆开讲。
2. 协议栈分层与工程结构:主模式到底由哪几块拼起来
2.1 从物理层到应用层的四层切分
Hart Protocol 本身是主从式、半双工、基于 1200 bps 频移键控(FSK)的现场通信协议,主模式栈的核心职责不是「发一个字节」,而是把一次完整的命令事务拆成可重试、可超时、可校验的多个阶段。这份源码按常见做法切成了四层:物理/链路适配层负责字节流的收发与帧定界,帧层负责前导码、定界符、地址、命令号、字节计数和校验和的拼装解析,事务层负责「发命令—等响应—超时重发—判错」的循环,应用层则是面向具体命令号(如读主变量、读动态变量)的封装。这种切法和多数现场总线栈一致,好处是换物理介质(比如从 UART 转成 SPI 接调制解调芯片)时只动最底层。
我一般会先看目录里有没有hal、frame、transaction、app这类命名,因为命名往往比注释更诚实。这份源码的结构大致如下,实际文件名可能略有出入,但分层意图是清晰的:
hart_master/ ├── hal/ # 物理层适配:串口/定时器/GPIO 抽象 │ ├── hart_uart.c │ └── hart_timer.c ├── link/ # 链路层:帧定界、字节流状态机 │ └── hart_link.c ├── frame/ # 帧层:前导码、定界符、校验和 │ └── hart_frame.c ├── transaction/ # 事务层:命令重试、超时、响应匹配 │ └── hart_transaction.c ├── app/ # 应用层:常用命令封装 │ └── hart_cmd.c └── port/ # 移植层:平台相关实现 └── port_stm32.c提示:如果目录里出现
port和hal两套抽象,别急着合并,前者通常是「平台启动代码 + 中断向量」,后者是「协议栈内部依赖的接口声明」,合并后移植会变难。
2.2 主模式的状态机放在哪一层
主模式的关键不在帧格式,而在「一次事务的生命周期」。源码里事务层通常用一个枚举状态机表达:空闲、发送请求、等待响应、校验响应、重试、失败。这个状态机是整份代码里最值得先读的部分,因为它决定了超时参数、重试次数、从站地址匹配这些行为。常见做法是把状态机写成switch驱动的非阻塞函数,由定时器节拍调用,而不是在发送函数里while等响应——后者在裸机上会卡死其他任务。
typedef enum { HART_TX_IDLE = 0, HART_TX_SEND_REQ, HART_TX_WAIT_RSP, HART_TX_CHECK_RSP, HART_TX_RETRY, HART_TX_DONE, HART_TX_ERROR } hart_tx_state_t; /* 每个 tick 调用一次,非阻塞推进事务 */ void hart_transaction_poll(hart_ctx_t *ctx) { switch (ctx->state) { case HART_TX_IDLE: if (ctx->req_pending) { hart_frame_build(&ctx->tx_buf, &ctx->req); /* 组帧 */ hart_hal_send(ctx->tx_buf, ctx->tx_len); ctx->retry = 0; ctx->state = HART_TX_WAIT_RSP; hart_timer_start(ctx->tmo_ms); /* 启动响应超时 */ } break; case HART_TX_WAIT_RSP: if (hart_hal_rx_ready()) { /* 收到完整帧 */ hart_timer_stop(); ctx->state = HART_TX_CHECK_RSP; } else if (hart_timer_expired()) { /* 超时 */ ctx->state = (++ctx->retry <= HART_MAX_RETRY) ? HART_TX_RETRY : HART_TX_ERROR; } break; case HART_TX_RETRY: hart_hal_send(ctx->tx_buf, ctx->tx_len); hart_timer_start(ctx->tmo_ms); ctx->state = HART_TX_WAIT_RSP; break; case HART_TX_CHECK_RSP: if (hart_frame_parse(&ctx->rx_buf, &ctx->rsp) == HART_OK && ctx->rsp.addr == ctx->req.addr) { /* 地址必须匹配 */ ctx->state = HART_TX_DONE; } else { ctx->state = HART_TX_ERROR; } break; default: break; } }这段代码里几个参数值得盯住:tmo_ms是响应超时,Hart 在 1200 bps 下传一帧几十字节就要几百毫秒,超时设太短会误判重发,设太长会拖慢轮询周期;HART_MAX_RETRY一般取 2 到 3 次,再多现场噪声下也只是重复失败;addr匹配是主模式必须做的,因为总线上可能挂着多个从站,响应地址对不上就说明串了或帧解析错位。状态机写成非阻塞的好处是,主循环里还能同时跑看门狗喂狗、LED 指示、其他传感器采集,不会因为等一帧响应把整个系统卡住。
2.3 帧层:前导码、定界符与校验和
帧层是协议栈里最「死板」也最容易写错的部分。Hart 帧以若干字节 0xFF 前导码开头,随后是定界符(区分短帧/长帧、主站/从站方向),再是地址、命令号、字节计数、数据域和校验和。校验和是逐字节异或,不是 CRC,这点如果按习惯写成 CRC 就会全错。源码里帧构建函数通常长这样:
/* 构建一帧主站请求,返回帧总长度 */ uint16_t hart_frame_build(uint8_t *buf, const hart_req_t *req) { uint16_t i = 0; uint8_t chk = 0; for (uint8_t p = 0; p < HART_PREAMBLE_LEN; p++) buf[i++] = 0xFF; /* 前导码,帮助从站同步 */ buf[i] = req->is_long ? HART_DELIM_LONG_MASTER : HART_DELIM_SHORT_MASTER; chk ^= buf[i++]; /* 定界符参与校验 */ if (req->is_long) { buf[i++] = req->addr >> 8; /* 长帧地址高字节 */ chk ^= buf[i - 1]; } buf[i] = req->addr & 0xFF; /* 地址低字节 */ chk ^= buf[i++]; buf[i] = req->cmd; /* 命令号 */ chk ^= buf[i++]; buf[i] = req->data_len; /* 字节计数 */ chk ^= buf[i++]; for (uint8_t d = 0; d < req->data_len; d++) { buf[i] = req->data[d]; chk ^= buf[i++]; } buf[i++] = chk; /* 校验和:逐字节异或 */ return i; }逻辑说明:前导码长度HART_PREAMBLE_LEN常见取 5 到 20 字节,现场线路差就多给几个,代价是每帧多花时间;定界符决定短帧还是长帧,短帧地址 1 字节、长帧 2 字节,主站请求和从站响应的定界符值不同,解析时要按方向区分;校验和是异或累加,从定界符开始一直算到数据域末尾,不含前导码。参数上最容易翻车的是data_len和实际数据长度不一致,字节计数写错会导致从站直接丢帧,现象是「发出去没响应」,排查时先打印组好的帧逐字节比对。
3. 把栈跑起来:移植、初始化与一次完整轮询
3.1 移植层要实现的几个接口
这份源码把平台相关的东西收敛到port和hal两层,移植到新板子时,真正要动的函数不多,常见是这几个:串口初始化与字节发送、接收中断或轮询取字节、毫秒定时器启动/停止/查询、以及可选的发送使能引脚控制(半双工收发切换)。我一般会先写一个最小port实现,把发送和定时器跑通,再补接收,因为发送链路错了连帧都出不去,调试会没有参照。
/* port 层需要提供的接口,协议栈内部只依赖这些声明 */ void hart_hal_init(uint32_t baud); void hart_hal_send(const uint8_t *data, uint16_t len); uint8_t hart_hal_rx_ready(void); /* 是否收到完整一帧 */ uint16_t hart_hal_read(uint8_t *buf, uint16_t max); void hart_timer_start(uint32_t ms); void hart_timer_stop(void); uint8_t hart_timer_expired(void);参数说明:baud在 Hart 数字信号层面常按 1200 bps 配置,但如果你用的是外接调制解调芯片,串口波特率可能是芯片要求的另一档,别把协议速率和串口速率混为一谈;hart_hal_rx_ready的语义是「一帧收完」,不是「收到一个字节」,实现时通常靠链路层状态机在检测到帧结束(按字节计数或超时静默)后置标志;定时器精度要求不高,毫秒级即可,但必须单调,别用会回绕又不处理的计数器。
3.2 初始化顺序与主循环骨架
初始化顺序错了,现象往往是「偶尔能通、多数时候超时」。合理顺序是:先初始化 HAL(串口、定时器、收发方向引脚),再初始化链路层和帧层上下文,最后初始化事务层状态为IDLE。主循环里只做两件事:推进事务状态机、处理已完成事务的结果。下面是一个能直接套用的骨架:
static hart_ctx_t g_ctx; int main(void) { hart_hal_init(1200); /* 物理层 */ hart_link_init(&g_ctx); /* 链路层缓冲与状态 */ hart_transaction_init(&g_ctx); /* 事务层状态置 IDLE */ while (1) { hart_transaction_poll(&g_ctx); /* 非阻塞推进 */ if (g_ctx.state == HART_TX_DONE) { hart_cmd_on_response(&g_ctx.rsp); /* 应用层处理响应 */ g_ctx.state = HART_TX_IDLE; } else if (g_ctx.state == HART_TX_ERROR) { hart_cmd_on_error(g_ctx.last_cmd); /* 记录失败命令 */ g_ctx.state = HART_TX_IDLE; } hart_wdt_feed(); /* 别忘了喂狗 */ } }这段骨架的价值在于把「协议推进」和「业务处理」解耦:事务层只管把一帧请求送出去并拿回一帧响应,应用层拿到响应后再决定是解析主变量还是发下一条命令。轮询多条命令时,常见做法是维护一个命令队列,HART_TX_DONE后从队列取下一个填进req,而不是在中断里直接发下一条——中断里做组帧和发送容易和主循环抢状态。
3.3 用一条读命令验证链路
栈跑起来后,别急着上完整业务,先用一条最简单的读命令验证「组帧—发送—接收—解析」整条链路。Hart 里读主变量(命令号常见为 1 或 3,具体看设备手册)返回的是浮点加单位码,解析时要注意字节序和状态字节。验证阶段我一般会打开帧级打印,把发出去的每个字节和收回来的每个字节都打出来,对照手册逐字段核对,这一步能省掉后面大量「玄学」排查。
/* 发起一次读主变量请求,addr 为从站轮询地址 */ void hart_read_pv(hart_ctx_t *ctx, uint8_t addr) { ctx->req.addr = addr; ctx->req.cmd = HART_CMD_READ_PV; ctx->req.is_long = 0; /* 短帧地址即可 */ ctx->req.data_len = 0; /* 读命令通常无数据域 */ ctx->req_pending = 1; }调用后观察state是否按SEND_REQ → WAIT_RSP → CHECK_RSP → DONE走完。如果卡在WAIT_RSP,先查物理层有没有真的发出去(示波器或串口助手抓波形),再查从站地址和命令号是否被设备接受;如果走到CHECK_RSP却进ERROR,多半是校验和或地址匹配失败,把收到的原始帧打出来逐字节算异或就能定位。
4. 避坑与排查:主模式最容易翻车的几个点
4.1 收发方向切换太慢导致首字节丢失
现象:从站偶尔能响应,但响应帧开头几个字节像是被截断,解析校验和总对不上。原因:半双工总线上主站发完请求要切回接收,如果方向引脚切换放在发送函数返回之后、且中间有较长延时,从站可能已经开始回帧,首字节就丢了。解决:在最后一个字节的发送完成中断里立刻切接收,或者发送函数内部等到「发送寄存器空」再切,别在主循环里靠软件延时切换。
4.2 超时参数照搬示例值
现象:实验室里通,到现场大量重试甚至判错。原因:示例里的tmo_ms往往按理想线路给,现场线路电容大、从站响应慢,固定值不够。解决:把响应超时做成可配置,按从站手册的响应时间加余量,并且区分「请求发送超时」和「响应等待超时」,重试次数别设太大,否则一次轮询周期被拉长,上层业务会以为卡死。
4.3 校验和按 CRC 写
现象:帧格式看着全对,从站就是不回。原因:Hart 帧校验是逐字节异或,不是 CRC16,按习惯写 CRC 会得到完全不同的校验字节。解决:把校验和计算单独抽成函数并写单元测试,用一组手工算好的字节做对照,改帧层时先跑这组用例。
4.4 多从站轮询时地址匹配缺失
现象:总线上挂两个以上从站时,响应偶尔「张冠李戴」,数据明显不属于当前命令。原因:事务层收到响应后没校验地址,或者长帧/短帧地址解析错位。解决:在CHECK_RSP状态强制比对响应地址与请求地址,长帧还要比对高低字节,不匹配直接判错而不是当成功处理。
4.5 在中断里做重活
现象:系统偶发死机或看门狗复位。原因:把组帧、校验、甚至应用层解析都塞进串口接收中断,中断执行时间过长,和其他中断打架。解决:中断里只做字节入环形缓冲和帧结束标志置位,组帧解析全部放主循环,靠状态机推进。
5. 二次开发:把主模式栈改成你自己的轮询引擎
拿到这套栈之后,真正花时间的不是让它跑通,而是把它改成适合你现场节奏的轮询引擎。我的做法是先定「轮询表」:把要访问的从站地址、命令号、期望响应长度、超时、失败后动作写成一张静态表,事务层每完成一次就查表取下一条,而不是在业务代码里到处调发送函数。这样改的好处是新增设备只改表,不动状态机。
typedef struct { uint8_t addr; uint8_t cmd; uint16_t tmo_ms; uint8_t max_retry; void (*on_ok)(const hart_rsp_t *rsp); void (*on_fail)(uint8_t cmd); } hart_poll_item_t; static const hart_poll_item_t g_poll_table[] = { { 0x01, HART_CMD_READ_PV, 300, 2, app_store_pv, app_log_fail }, { 0x01, HART_CMD_READ_DYN, 400, 2, app_store_dyn, app_log_fail }, { 0x05, HART_CMD_READ_PV, 300, 3, app_store_pv, app_log_fail }, };参数说明:tmo_ms按从站分别给,别一刀切;max_retry对关键从站可以多给一次,对慢设备反而要少给,避免拖垮整轮;回调里只做数据落库和标志置位,别在回调里再发起新事务,否则状态机会重入。验证方法上,我习惯先离线跑:把hart_hal_send替换成打印,把hart_hal_rx_ready用预置响应帧喂进去,确认状态机和解析逻辑在无硬件时也能走通,再上板联调。这样每次改轮询表或解析逻辑,都能先在没有示波器的环境下回归一遍。
还有一个具体技巧:给事务层加一个「最近一次失败快照」,把失败时的请求帧、收到的原始字节、当前状态、重试计数存进一个结构体,通过调试串口打出来。现场出问题时,这比让客户描述「它不动了」有用得多。从那以后我每次移植这类主站栈,都强制先把帧级打印和失败快照做出来再谈业务,血泪经验是省这一步后面要花几倍时间补。希望帮到你。
本文还有配套的精品资源,点击获取