ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Klipper 主机与微控制器通信协议深度解析:消息声明、二进制编码与传输机制

Klipper 主机与微控制器通信协议深度解析:消息声明、二进制编码与传输机制 Klipper 主机与微控制器通信协议深度解析消息声明、二进制编码与传输机制【免费下载链接】klipperKlipper is a 3d-printer firmware项目地址: https://gitcode.com/GitHub_Trending/kl/klipperKlipper 主机软件与微控制器固件之间通过一套自定义的二进制消息协议进行底层通信本文档源自仓库 docs/Protocol.md对该协议的完整技术细节进行深入剖析。本文将以 RPC 视角讲解命令与响应的声明方式、VLQ 整数与消息块的二进制编码、数据字典的动态协商机制以及带确认与窗口重传的消息流控制并结合src/、klippy/下的实际源码实现进行佐证帮助你理解 Klipper 如何做到低延迟、低带宽且对微控制器极低复杂度的可靠通信。协议总体架构压缩、传输、解析的命令/响应串Klipper 消息协议用于 Klipper 主机软件与 Klipper 微控制器软件之间的底层通信。从高层看该协议可以看作一系列命令串与响应串这些字符串被压缩编码为二进制格式、传输然后在接收端被处理。一组未压缩、人类可读格式的命令示例如下set_digital_out pinPA3 value1 set_digital_out pinPA7 value1 schedule_digital_out oid8 clock4000000 value0 queue_step oid7 interval7458 count10 add331 queue_step oid7 interval11717 count4 add1281关于可用的微控制器命令清单参见 MCU_Commands.md关于如何将 G-Code 文件翻译为对应的人类可读微控制器命令参见 Debugging.md。本文描述 Klipper 消息协议本身消息如何被声明、如何以二进制格式编码即压缩方案、以及如何传输。协议的目标是在主机与微控制器之间建立一个无差错、低延迟、低带宽、且对微控制器实现复杂度低的通信信道。微控制器接口基于 RPC 的命令/响应模型Klipper 传输协议可以视为微控制器与主机之间的一个 RPC远程过程调用 机制微控制器软件声明主机可调用的命令以及它自身可以生成的响应消息主机利用这些信息来指挥微控制器执行动作并解释返回的结果。声明命令DECL_COMMAND()微控制器软件通过在 C 代码中使用DECL_COMMAND()宏来声明一条命令。例如DECL_COMMAND(command_update_digital_out, update_digital_out oid%c value%c);上述代码声明了一条名为update_digital_out的命令主机可以调用它从而触发微控制器中的command_update_digital_out()C 函数被执行同时声明该命令带两个整型参数。当command_update_digital_out()的 C 代码被执行时会被传入一个包含这两个整数的数组——第一个对应oid第二个对应value。参数的描述通常使用 printf() 风格语法如%u。该格式与人类可读的命令视图直接对应如update_digital_out oid7 value1。在上例中value是参数名%c表示该参数是一个整数。内部实现中参数名仅用作文档说明同样%c也只是文档性标识表示期望的整数是 1 字节大小声明的整数大小不影响解析或编码。从源码看DECL_COMMAND()定义在 src/command.h#define DECL_COMMAND_FLAGS(FUNC, FLAGS, MSG) \ DECL_CTR(DECL_COMMAND_FLAGS __stringify(FUNC) \ __stringify(FLAGS) MSG) #define DECL_COMMAND(FUNC, MSG) \ DECL_COMMAND_FLAGS(FUNC, 0, MSG)DECL_COMMAND_FLAGS允许通过HF_IN_SHUTDOWN标志0x01标记某个处理函数即使处于紧急停机emergency stop状态仍可运行——例如get_config、get_clock、get_uptime、clear_shutdown、emergency_stop等基础命令都使用了该标志见 src/basecmd.c。微控制器构建过程会收集所有用DECL_COMMAND()声明的命令确定其参数并安排它们可被调用。这一收集机制的底层依赖DECL_CTR宏它把请求字符串放进目标文件的特殊.compile_time_requestsection见 src/ctr.h构建脚本再将其汇总生成out/compile_time_request.c内含command_index[]命令索引表与command_identify_data[]数据字典数据。声明响应sendf()要从微控制器向主机发送信息需要生成一个响应。响应通过sendf()C 宏同时完成声明与传输。例如sendf(status clock%u status%c, sched_read_time(), sched_is_shutdown());上述代码传输一条包含两个整数参数clock和status的status响应消息。微控制器构建过程会自动找出所有sendf()调用并为它们生成编码器。sendf()的第一个参数描述响应其格式与命令声明相同。主机可以为每个响应注册回调函数。因此效果上命令让主机能够调用微控制器中的 C 函数响应让微控制器软件能够调用主机中的代码——这正是 RPC 双向语义的体现。sendf()宏定义于 src/command.h应只在命令处理函数或任务处理函数中被调用不应在中断或定时器中调用。代码无需针对收到的命令发出sendf()响应它不限制sendf()的调用次数也可以随时在任务处理函数中调用。实现上src/command.c 的command_sendf()通过in_sendf标志位防止中断处理函数在主线代码已处于 sendf 时重入发送。输出响应调试用 output()为简化调试还存在一个output()C 函数。例如output(The value of %u is %s with size %u., x, buf, buf_len);output()的用法与 printf() 类似——用于生成并格式化任意人类可读的消息不属于协议中的正式响应。在主机端 klippy/msgproto.py 中对应OutputFormat类name 为#output它会将output()的参数解析后格式化为可读文本。声明枚举DECL_ENUMERATION()枚举允许主机代码对微控制器按整数处理的参数使用字符串标识符。枚举在微控制器代码中声明例如DECL_ENUMERATION(spi_bus, spi, 0); DECL_ENUMERATION_RANGE(pin, PC0, 16, 8);第一个示例中DECL_ENUMERATION()宏为任何参数名为spi_bus、或以_spi_bus结尾的参数名定义了一个枚举对这些参数而言字符串spi是合法值传输时对应整数 0。第二个示例声明了一个枚举范围pin参数或任何以_pin结尾的参数接受PC0, PC1, PC2, ..., PC7作为合法值这些字符串被传输为整数 16, 17, 18, ..., 23。宏定义同样位于 src/command.hDECL_ENUMERATION(ENUM, NAME, VALUE)与DECL_ENUMERATION_RANGE(ENUM, NAME, VALUE, COUNT)。主机端 klippy/msgproto.py 的Enumeration类实现了双向映射字符串→整数编码、整数→字符串解析lookup_params()会通过name enum_name or name.endswith(_ enum_name)规则匹配枚举归属与文档中参数名或参数名后缀的描述完全一致。声明常量DECL_CONSTANT()常量也可以被导出。例如DECL_CONSTANT(SERIAL_BAUD, 250000);这会从微控制器向主机导出一个名为SERIAL_BAUD、值为 250000 的常量。也可以声明字符串常量DECL_CONSTANT_STR(MCU, pru);主机端MessageParser.get_constants()/get_constant()系列方法见 klippy/msgproto.py可以从数据字典中读取这些常量serialhdl.py中甚至会读取RECEIVE_WINDOW常量来动态调整接收窗口大小说明常量机制被用于主机侧的行为自适应配置。底层消息编码为实现上述 RPC 机制每条命令和响应都被编码为二进制格式进行传输。本节描述传输系统。消息块Message Blocks主机与微控制器之间双向发送的所有数据都包含在消息块中。一个消息块有 2 字节头、3 字节尾格式为1 byte length1 byte sequencen-byte content2 byte crc1 byte synclength 字节消息块总字节数含头部与尾部字节因此消息最小长度为 5 字节当前最大消息块长度为64 字节。sequence 字节低 4 位为 4 位序列号高位固定为0x10高位保留给未来使用。content 字节任意数据格式见下文。crc 字节消息块含头部字节、不含尾部字节的 16 位 CCITT CRC 校验值。sync 字节0x7e。消息块的格式受 HDLC 消息帧启发。与 HDLC 类似消息块开头可以可选地包含一个额外的同步字符但与 HDLC 不同的是同步字符并不独占帧定界它可以出现在消息块内容中。这些常量在主机端 klippy/msgproto.py 与微控制器端 src/command.h 中成对定义MESSAGE_MIN 5、MESSAGE_MAX 64、MESSAGE_HEADER_SIZE 2、MESSAGE_TRAILER_SIZE 3、MESSAGE_POS_LEN 0、MESSAGE_POS_SEQ 1、MESSAGE_TRAILER_CRC 3、MESSAGE_TRAILER_SYNC 1、MESSAGE_PAYLOAD_MAX MESSAGE_MAX - MESSAGE_MIN、MESSAGE_SEQ_MASK 0x0f、MESSAGE_DEST 0x10、MESSAGE_SYNC 0x7e。CCITT CRC 的具体位运算实现可参考crc16_ccitt()主机侧与 klippy/chelper/msgblock.c 的msgblock_crc16_ccitt()C 加速实现。消息块内容每个从主机发往微控制器的消息块其内容包含一系列零个或多个消息命令。每条命令以一个 变长整数VLQ编码的命令 id 开头随后是该命令的零个或多个 VLQ 参数。例如下面四条命令可能被放进单个消息块update_digital_out oid6 value1 update_digital_out oid5 value0 get_config get_clock编码为下列 8 个 VLQ 整数id_update_digital_out61id_update_digital_out50id_get_configid_get_clock为了编码和解析消息内容主机与微控制器必须就命令 id 以及每条命令的参数数量达成一致。在上例中双方都知道id_update_digital_out后面总是跟着两个参数而id_get_config与id_get_clock有零个参数。主机与微控制器共享一份数据字典把命令描述如update_digital_out oid%c value%c映射到整数命令 id。处理数据时解析器会知道在给定命令 id 之后应期待多少个 VLQ 编码参数。从微控制器发往主机的消息块内容遵循相同格式。这些消息中的标识符是响应 id但它们服务于相同目的、遵循相同编码规则。实际中从微控制器发往主机的消息块内容从不超过一条响应。在 src/command.c 的command_dispatch()中可以看到解析循环从内容区起点循环调用command_parse_msgid()解析命令 id通过command_lookup_parser()查找处理函数再调用command_parsef()按声明解析参数。而 klippy/chelper/msgblock.c 的msgblock_decode()则是主机端 C 加速层对同一格式的解析实现。变长整数Variable Length Quantities维基百科 上有关于 VLQ 编码整数通用格式的更多信息。Klipper 使用的编码方案同时支持正负整数接近零的整数占用更少字节正整数通常比负整数占用更少字节。下表显示了每个整数编码所需的字节数整数编码大小-32 .. 951-4096 .. 122872-524288 .. 15728633-67108864 .. 2013265914-2147483648 .. 42949672955该表与 src/command.c / klippy/chelper/msgblock.c 中encode_int()的跳转阈值完全对应sv (3L5) sv -(1L5)编码 1 字节(3L12)对应 2 字节(3L19)对应 3 字节(3L26)对应 4 字节其余情况 5 字节。主机端 Python 实现见 klippy/msgproto.py 的PT_uint32.encode()/parse()其中PT_uint32的max_length 5而%cPT_byte的max_length 2——因为命令 id 本身也是 VLQ 编码encode_msgid()为命令 id 做了最多 2 字节的优化编码见 src/command.c。变长字符串作为上述编码规则的例外如果命令或响应的参数是动态字符串则该参数不编码为简单 VLQ 整数而是先传输 VLQ 编码的长度、再传输内容本身VLQ encoded lengthn-byte contents数据字典中的命令描述让主机与微控制器都知道哪些命令参数使用简单 VLQ 编码、哪些使用字符串编码。主机端PT_string类max_length 64即实现了长度 内容的编码且消息块 64 字节上限意味着单条字符串参数受块长约束。数据字典Data Dictionary为了在微控制器与主机之间建立有意义的通信双方必须就一份数据字典达成一致。数据字典包含命令与响应的整数标识符及其描述。生成微控制器构建过程利用DECL_COMMAND()与sendf()宏的内容生成数据字典构建过程自动为每条命令和响应分配唯一标识符。这一机制让主机与微控制器代码都能无缝地使用可读性强的描述性名称同时保持最小带宽。下载主机在首次连接微控制器时查询数据字典。一旦主机从微控制器下载了数据字典就使用它编码所有命令、解析来自微控制器的所有响应。因此主机必须处理动态数据字典而为了让微控制器软件保持简单微控制器始终使用其静态编译进固件的数据字典。获取方式数据字典通过向微控制器发送identify命令来查询。微控制器对每条identify命令回应一条identify_response消息。由于这两条命令在获取数据字典之前就需要用到它们的整数 id 与参数类型在微控制器和主机两侧都被硬编码identify_response响应 id 为0identify命令 id 为1。除硬编码 id 外identify命令及其响应与其他命令、响应一样被声明和传输。没有其他命令或响应被硬编码。主机端硬编码位于 klippy/msgproto.py 的DefaultMessagesDefaultMessages { identify_response offset%u data%.*s: 0, identify offset%u count%c: 1, }微控制器端实现位于 src/basecmd.c 的command_identify()DECL_COMMAND_FLAGS(command_identify, HF_IN_SHUTDOWN, identify offset%u count%c);它从command_identify_data[]中按offset/count取数据块并通过sendf(identify_response offset%u data%.*s, ...)分块回应。格式传输的数据字典本身是一个zlib 压缩的 JSON 字符串。微控制器构建过程生成该字符串、压缩它并存储在微控制器闪存的 text section 中。数据字典可能远大于最大消息块大小——主机通过发送多条identify命令请求数据字典的连续分块来下载它。获得全部分块后主机组装分块、解压数据、解析内容。主机端解析逻辑见MessageParser.process_identify()klippy/msgproto.pyzlib.decompress(data)解压 →json.loads解析 →fill_enumerations()展开枚举 →_init_messages()注册所有命令/响应/输出消息类型。附加内容除了通信协议信息外数据字典还包含软件版本、枚举由DECL_ENUMERATION定义以及常量由DECL_CONSTANT定义。在process_identify()中可以看到version、build_versions、config常量、kconfig等字段的解析主机可通过get_version_info()、get_constants()、get_kconfig()获取。消息流Message Flow主机 → 微控制器有确认、可重传的可靠信道发往微控制器的消息命令被设计为无差错传输。微控制器会检查每个消息块的 CRC 与序列号确保命令准确且有序。微控制器始终按顺序处理消息块——如果收到乱序块它会丢弃该块及后续乱序块直到收到序列号正确的块。底层主机代码实现了针对丢失/损坏消息块的自动重传系统。为配合此机制微控制器在成功接收每个消息块后传输一个ack 消息块主机在发送每个块后调度一个超时若超时到期仍未收到对应 ack则重传此外若微控制器检测到损坏或乱序块可传输nak 消息块以促成快速重传。ack是一个空内容即 5 字节消息块且序列号大于主机最后一个已接收序列号的消息块nak是一个空内容且序列号小于主机最后一个已接收序列号的消息块。微控制器端实现见 src/command.ccommand_find_block()校验 CRC 与序列号序列号不匹配时执行nak跳转并发送空消息块command_sendf(encode_acknak)command_find_and_dispatch()在成功解析块后调用command_send_ack()。encode_acknak的max_size MESSAGE_MIN即 5 字节空内容消息块与文档描述吻合。协议还实现了一套窗口window传输系统使主机可以同时拥有大量在途in-flight消息块这还是在单个消息块可容纳多条命令之外的并行度。这即使在存在传输延迟的情况下也能最大化带宽利用。超时、重传、窗口与 ack 机制的设计灵感来自 TCP 中的raw_send_wait_ack()等发送逻辑以及通过RECEIVE_WINDOW固件常量调整接收窗口的机制。微控制器 → 主机尽力而为、无重传的信道反方向上从微控制器发往主机的消息块被设计为无差错响应不应损坏但不保证可靠送达响应可能丢失。这样做的目的是让微控制器实现保持简单响应没有自动重传机制——高层代码应当能够处理偶尔丢失的响应通常通过重新请求内容或建立周期性的响应发送计划发往主机的消息块的序列号字段始终比从主机收到的最后一个消息块序列号大 1它不用于跟踪响应消息块的顺序。这一主机侧负责可靠、微控制器侧保持简单的不对称设计是 Klipper 协议在实时性与资源受限的 MCU 上能够高效运行的关键权衡。协议要点速查与延伸阅读消息块结构1B length1B seqcontent2B CRC-CCITT0x7e sync最小 5 字节、最大 64 字节VLQ 整数接近零的整数编码更短1~5 字节覆盖 -2147483648 .. 4294967295动态字符串按VLQ 长度内容编码命令/响应声明DECL_COMMAND、sendf、output、DECL_ENUMERATION(_RANGE)、DECL_CONSTANT(_STR)均通过.compile_time_requestsection 在编译期收集生成数据字典数据字典zlib 压缩的 JSON通过硬编码 ididentify1 / identify_response0的identify命令分块下载除这两条外无其他硬编码 id可靠传输主机→MCU 方向带 CRC/序列号校验、ack/nak 与超时重传、窗口化在途传输MCU→主机方向仅保证不损坏、不保证不丢失。若想深入掌握主机侧的协议解析与调试工具可以继续阅读 klippy/msgproto.py消息类型与数据字典解析、klippy/chelper/msgblock.cC 加速层消息块编解码与 klippy/console.py连接与十六进制转储调试微控制器侧可对照 src/command.c、src/command.h、src/basecmd.c 查看命令收发、帧校验与identify的完整实现。【免费下载链接】klipperKlipper is a 3d-printer firmware项目地址: https://gitcode.com/GitHub_Trending/kl/klipper创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进