代码生成

MSpec 格式

qianmoQqianmoQ· 更新于 2026-10-01· 阅读 27 分钟· 0 次阅读

登录后可跨设备保存划线和私人笔记登录

MSpec 格式(Message Specification,消息规范)是我们在评估了大量其他方案之后,通过一次头脑风暴会议得出的结果。

我们只是坐下来,开始编写一个想象中的格式(想象中的格式甚至是我们最初使用的名字:Machine-Readable SPEC,即机器可读规范 = mspec)。当我们得到了一个看起来可行的初始格式后,便开始为其编写解析器,并在实现新协议和语言模板的过程中,不断迭代地对规范和解析器进行微调。

这是一种基于文本的格式。

在这些规范的根层级上,是一组 type、discriminatedType、dataIo、enum 和 'constants' 块。

type 元素是这样的对象:其内容和结构与输入无关。

一个例子是 S7 格式的 TPKTPacket:

[type TPKTPacket
    [const    uint 8                 protocolId 0x03]
    [reserved uint 8                 '0x00']
    [implicit uint 16                len       'payload.lengthInBytes + 4']
    [simple   COTPPacket('len - 4') payload]
]

一个 discriminatedType 类型则与之相反,它是一个内容与结构受输入影响的对象。

每个判别类型可以包含任意数量的普通字段,但必须恰好包含一个 typeSwitch 元素。

例如,S7 格式规范的一部分如下所示:

[discriminatedType S7Message
    [const         uint 8  protocolId      0x32]
    [discriminator uint 8  messageType]
    [reserved      uint 16 '0x0000']
    [simple        uint 16 tpduReference]
    [implicit      uint 16 parameterLength 'parameter != null ? parameter.lengthInBytes : 0']
    [implicit      uint 16 payloadLength   'payload != null ? payload.lengthInBytes : 0']
    [typeSwitch messageType
        ['0x01' S7MessageRequest
        ]
        ['0x02' S7MessageResponse
            [simple uint 8 errorClass]
            [simple uint 8 errorCode]
        ]
        ['0x03' S7MessageResponseData
            [simple uint 8 errorClass]
            [simple uint 8 errorCode]
        ]
        ['0x07' S7MessageUserData
        ]
    ]
    [optional S7Parameter ('messageType')              parameter 'parameterLength > 0']
    [optional S7Payload   ('messageType', 'parameter') payload   'payloadLength > 0'  ]
]

常量类型(constants type)允许你为协议定义常量。

[constants
    [const          uint 16     adsDiscoveryUdpDefaultPort 48899]
]

一种类型`s start is declared by an opening square bracket [ followed by the type or discriminatedType keyword, which is directly followed by a name. A Type definition is ended with a closing square bracket ]。

每个类型定义都包含一个所谓的字段列表。

可用的字段类型如下:

  • abstract:在父类型声明中使用,用于声明一个必须在所有子类型中以相同类型定义的字段(保留给 discriminatedType 使用)。
  • array:简单类型或复杂类型对象的数组。
  • assert:通常与 constant 字段类似,不过它抛出的是 AssertionExceptions 而不是硬 ParseExceptions。它们与可选字段配合使用。
  • batchSet:这是一个伪字段,允许你为一组字段附加额外的属性。
  • checksum:用于计算和校验校验和的值。
  • const:要求取某个给定值,若值不匹配则引发硬异常。
  • discriminator:简单类型字段的一种特殊类型,用于确定对象的具体类型(保留给 discriminatedType 使用)。
  • enum:字段的一种特殊形式,当希望使用枚举类型的属性而非其主值时使用。
  • implicit:解析时所需的一个字段,但通常由其他数据确定,因此它不会存储在对象中,而是在序列化时计算得出。
  • manualArray:与数组字段类似,不过序列化、解析、元素数量以及大小的逻辑都必须手动提供。
  • manual:简单字段,其解析、序列化以及大小的逻辑都必须手动提供。
  • optional:简单类型或复杂类型对象,只有当可选条件表达式求值为 true,且解析被引用类型时没有抛出 AssertionException 时才存在。
  • padding:用于添加填充数据以使数据结构对齐的字段。
  • peek:尝试解析给定结构但并不实际消耗字节的字段。
  • reserved:要求取某个给定值,但条件不满足时仅给出警告。
  • simple:简单类型或复杂类型对象。
  • state:允许你在类中将某个参数赋值为有状态的变量。
  • typeSwitch:并非真正的字段,但表示子类型的存在,这些子类型以内联方式声明(保留给 discriminatedType 使用)。
  • unknown:用于声明消息中尚待定义的部分的字段。通常在逆向工程协议时使用。带有 unknown 字段的消息只能解析而不能序列化。
  • validation:此字段实际上并不是真正的字段,它更像是在解析期间检查的一个条件,如果检查失败,它会抛出一个验证异常,该异常由
  • virtual:在消息中生成一个字段,通常仅用于简化。它不用于解析或序列化。

这些类型的完整语法及说明将在后续章节中给出。

另一个我们需要说明的问题是类型是如何指定的。

通常,我们在字段定义中使用的类型分为两类:

  • 简单类型
  • 复杂类型

简单类型

简单类型通常是原始数据,其格式为:

{base-type} {size}

目前可用的基础类型如下:

  • bit:简单的布尔值或比特。
  • byte:固定为 8 位的特殊值,其默认取值为有符号还是无符号取决于编程语言(Java 中默认为有符号整数值,而 C 和 Go 中默认为无符号整数)。
  • int:输入被视为有符号整数值。
  • uint:输入被视为无符号整数值。
  • float:输入被视为浮点数。
  • string:输入被视为字符串。

对于 dataIo 类型,我们还有一些额外的类型:- time:输入被视为时间表示 - date:输入被视为日期表示 - dateTime:输入被视为带时间的日期

除 bit 和 byte 类型外,其余类型都接受一个 size 值,用于指定应当读取多少个 bits。对于 bit 字段,该值显然默认为 1;对于 byte,比特数默认为 8。

因此,读取一个无符号 8 位整数的写法为:uint 8。

目前有一种特殊的类型,专门用于字符串值,其长度由表达式而非固定的比特数决定,因此被视为变长字符串:

  • vstring:输入被视为变长字符串,并且需要一个表达式来提供要读取的比特数。

复杂类型

与简单类型不同,复杂类型会引用其他复杂类型(规范文档的根元素)。

解析器应如何解释它们,由所引用类型的定义决定。

例如,在上面的示例中,S7Parameter 是在规范的另一部分中定义的。

字段类型及其语法

array 字段

array 字段正是你所预期的那样。它会生成一个字段,该字段不是单值元素,而是元素的数组或列表。

[array {bit|byte}           {name} {count|length|terminated} '{expression}']
[array {simple-type} {size} {name} {count|length|terminated} '{expression}']
[array {complex-type}       {name} {count|length|terminated} '{expression}']

数组类型既可以是简单类型,也可以是复杂类型,并且拥有一个名称。数组字段必须指定其长度的确定方式,以及定义其长度的表达式。可能的取值有:

  • count:这意味着将解析出与 expression 所指定的数量完全一致的元素个数。
  • length:在这种情况下,会读取给定数量的字节。因此,如果已经解析了一个元素,但仍有剩余字节,则继续解析下一个元素。
  • terminated:在这种情况下,解析器会持续读取元素,直到遇到终止序列为止。

assert 字段

assert 字段与 const 字段基本相同。然而,主要区别在于:当解析出的值与预期值不匹配时,处理的方式不同。

[assert         {bit|byte}            {name}          '{assert-value}']
[assert         {simple-type} {size}  {name}          '{assert-value}']

const 字段会直接中止解析并报错,而 assert 字段也会中止解析,但错误只会沿调用栈向上传递,直到遇到第一个 optional 字段为止。

在这种情况下,解析器会回退到开始解析 optional 字段之前的位置,然后从下一个字段继续解析,跳过 optional 字段。

如果上游没有 optional 字段,则解析该消息将以错误结束。

另请参阅:- 校验字段:与 assert 字段类似,但不执行解析,而是仅检查某个条件。- 可选字段:optional 字段能够感知由 assert 和 validation 字段产生的解析错误类型。

batchSet 字段

批量设置字段允许你为一组字段定义或修改属性。

[batchSet byteOrder='integerEncoding == IntegerEncoding.BIG_ENDIAN ? BIG_ENDIAN : LITTLE_ENDIAN'
[simple DceRpc_ObjectUuid        objectUuid                                            ]
[simple DceRpc_InterfaceUuid     interfaceUuid                                         ]
        [simple DceRpc_ActivityUuid      activityUuid                                          ]
]

checksum 字段

校验和字段只能作用于简单类型。

[checksum {bit|byte}           {name} '{checksum-expression}']
[checksum {simple-type} {size} {name} '{checksum-expression}']

解析时,会解析给定的简单类型,然后将结果与 checksum-expression 提供的值进行比较。如果不匹配,则抛出异常。

序列化时,会对 checksum-expression 进行求值,然后输出其结果。

注意:由于校验和通常基于从消息开头读取到校验和位置为止的字节数据计算得出,因此表达式中提供了一个名为 checksumRawData 的人工变量,其类型为 byte[],包含当前消息元素及其(判别类型情况下的)子类型中已读取的所有字节数据。

该字段不会在内存中保存任何数据。

另请参见:- 隐式字段:校验和字段与隐式字段类似,不同之处在于 checksum-expression 在解析时进行求值,如果值不匹配则抛出异常。

const 字段

const 字段仅读取给定的简单类型,并与给定的参考值进行比较。

[const {bit|byte}           {name} {reference}]
[const {simple-type} {size} {name} {reference}]

解析时,如果解析出的值与预期值不匹配,解析器将抛出异常。

序列化时,则只是输出预期的常量。

该字段不会在内存中保存任何数据。

另请参见:- 隐式字段:const 字段与隐式字段类似,但它会将解析的输入与参考值进行比较,如果两者不匹配则抛出异常。

discriminator 字段

discriminator 字段仅在 `discriminatedType` 中使用。

[discriminator {simple-type} {size} {name}]

它们用于字段的值决定判别式(discriminated)具体类型的情况。在这种情况下,我们不必浪费内存来存储判别式值,该值可以静态地分配给该类型。

在解析时,判别式字段的结果仅作为一个局部可用的变量。

在序列化时,它访问判别式类型的常量,并将其用作输出。

另请参阅:

  • 隐式字段:判别式字段类似于隐式字段,但它不提供序列化表达式,因为它使用了其所属类型的判别常量。
  • 判别式类型

隐式字段

隐式类型是从其包含的数据中隐式获得其值的字段。

[implicit {bit|byte}           {name} '{serialization-expression}']
[implicit {simple-type} {size} {name} '{serialization-expression}']

在解析时,会提供一个隐式类型作为局部变量,可供其他表达式使用。

在序列化时,会执行序列化表达式,并输出其结果值。

这类字段通常用于处理元素数量或长度值的字段,因为这些值可以在序列化时隐式计算得出。

此字段不会在内存中保留任何数据。

manualArray 字段

[manualArray {bit|byte}           {name} {count|length|terminated} '{loop-expression}' '{serialization-expression}' '{deserialization-expression}' '{length-expression}']
[manualArray {simple-type} {size} {name} {count|length|terminated} '{loop-expression}' '{serialization-expression}' '{deserialization-expression}' '{length-expression}']
[manualArray {complex-type}       {name} {count|length|terminated} '{loop-expression}' '{serialization-expression}' '{deserialization-expression}' '{length-expression}']

manual 字段

[manual {bit|byte}           {name} '{serialization-expression}' '{deserialization-expression}' '{length-expression}']
[manual {simple-type} {size} {name} '{serialization-expression}' '{deserialization-expression}' '{length-expression}']
[manual {complex-type}       {name} '{serialization-expression}' '{deserialization-expression}' '{length-expression}']

可选字段

可选字段是一种也可以为 null 的字段类型。

[optional {bit|byte}           {name} ('{optional-expression}')?]
[optional {simple-type} {size} {name} ('{optional-expression}')?]
[optional {complex-type}       {name} ('{optional-expression}')?]

optional-expression 属性是可选的。如果提供了该属性,则会计算 optional-expression。若结果为 false\,则不解析任何内容;若结果为 true,则进行解析。

无论哪种情况,如果在解析 optional 字段的内容时某个 assert 或 validation 字段解析失败,解析器将回退到开始解析 optional 字段之前的位置,然后跳过该可选字段,解析器继续处理下一个字段。

序列化时,如果该字段为 null,则不输出任何内容;如果不为 null,则按正常方式序列化。

另请参见:- 简单字段:一般来说,optional 字段与 simple 字段相同,只是前者可以为 null 或被跳过。- assert:断言字段与 const 字段类似,但可以中止对某个 optional 字段的解析。- validation:如果任一子类型中的校验字段失败,则会中止对 optional 字段的解析。

padding 字段

padding 字段用于对齐数据块。它会根据 padding 表达式指定的次数输出额外的填充数据。只有当表达式的结果大于零时才会添加填充。

[padding {bit|byte}            {name} '{pading-value}' '{times-padding}']
[padding {simple-type} {size}  {name} '{pading-value}' '{times-padding}']

在解析 padding 字段时,times-padding 表达式决定了应该读取多少次 padding-value。因此它实际上并不会检查读取的值是否与 padding-value 匹配,而只是确保读取相同数量的位。读取到的值会被直接丢弃。

在序列化时,times-padding 定义了应该写入多少次 padding-value。

该字段不会在内存中保存任何数据。

peek 字段

reserved 字段

保留字段与 const 字段非常相似,只不过当值不匹配时它们不会抛出异常,而是记录日志信息。

这样做的原因在于,通常保留字段在开始被使用之前都会保持给定的值。

如果该字段开始被使用,这不应该破坏现有的应用程序,但应该发出一个提示信号,因为可能有必要更新相应的驱动程序。

[reserved {bit|byte}           {name} '{reference}']
[reserved {simple-type} {size} {name} '{reference}']

在解析值时,会解析一个 reserved 字段,并将其结果与参考值进行比较,然后丢弃。

如果两者不匹配,将写入一条日志消息。

此字段不会在内存中保留任何数据。

另请参阅:- const 字段

简单字段(simple Field)

简单字段是最常见的字段类型。

一个 simple 字段直接映射到消息类型的常规类型字段。

[simple {bit|byte}           {name}]
[simple {simple-type} {size} {name}]
[simple {complex-type}       {name}]

解析时,给定类型会被解析(不能是 null)并保存到对应模型实例的属性字段中。

序列化时,则使用简单类型序列化器进行正常序列化,或将序列化委托给复杂类型。

typeSwitch 字段

这类字段只能出现在判别类型(discriminated types)中。

discriminatedType 必须包含恰好一个 typeSwitch 字段,因为它定义了各个子类型。

[typeSwitch {field-or-attribute-1}(,{field-or-attribute-2}, ...)
    ['{field-1-value-1}' {subtype-1-name}
        ... Fields ...
    ]
    ['{field-1-value-2}', '{field-2-value-1}' {subtype-2-name}
        ... Fields ...
    ]
    ['{field-1-value-3}', '{field-2-value-2}' {subtype-2-name} [uint 8 'existing-attribute-1', uint 16 'existing-attribute-2']
        ... Fields ...
    ]

类型转换元素必须包含至少一个参数表达式构成的列表。只有最后一个选项可以为空,这将产生一个默认类型。

每个子类型声明一个以逗号分隔的具体值列表。

它所包含的元素数量最多不能超过为该类型转换声明的参数数量。

匹配类型的查找在解析时从第一个参数开始。

如果该参数匹配且没有更多值,则类型已找到;如果提供了更多值,则将它们与其他参数值进行比较。

如果未找到任何类型,则抛出异常。

在每个子类型内部可以使用类型的子集来声明字段(discriminator 和 typeSwitch 在此处不能使用)

上面代码片段中的第三个案例还将一个命名属性传递给子类型。该名称必须与在 switchType 之前解析的任何参数或命名字段相同。这些参数随后可在子类型中的表达式中使用或继续向下传递。

另请参阅:- discriminatedType

unknown 字段

这种字段类型主要用于逆向工程一种新协议的场景。它允许解析任意类型的信息,将其存储和使用,并序列化回去。

总体而言,它有点类似于 simple 字段,只是明确表明我们尚不完全知道如何处理该内容。

validation 字段

如前所述,validation 字段实际上并不是一个字段,而是添加到类型解析器中的一项检查。

如果 validation 字段中提供的表达式失败,解析器将中止解析并沿调用栈向上回溯,直到找到第一个 optional 字段。如果找到一个,解析器会回退到开始解析 optional 字段之前的位置,然后跳过 optional 字段,继续处理下一个字段。

如果调用栈上方没有 optional 字段,则解析失败。

virtual 字段

虚拟字段对输入或输出没有影响。它们只是在生成的模型类中创建人为的 get 方法。

[virtual {bit|byte}           {name} '{value-expression}']
[virtual {simple-type} {size} {name} '{value-expression}']
[virtual {complex-type}       {name} '{value-expression}']

virtual 属性的返回值并非绑定到某个属性,而是通过对 value-expression 求值得到的。

参数

有时需要传递额外的参数。

如果复杂类型需要参数,则在该类型的头部声明这些参数。

[discriminatedType S7Payload(uint 8 'messageType', S7Parameter 'parameter')
    [typeSwitch 'parameter.discriminatorValues[0]', 'messageType'
        ['0xF0' S7PayloadSetupCommunication]
        ['0x04','0x01' S7PayloadReadVarRequest]
        ['0x04','0x03' S7PayloadReadVarResponse
            [arrayField S7VarPayloadDataItem 'items' count 'CAST(parameter, S7ParameterReadVarResponse).numItems']
        ]
        ['0x05','0x01' S7PayloadWriteVarRequest
            [arrayField S7VarPayloadDataItem 'items' count 'COUNT(CAST(parameter, S7ParameterWriteVarRequest).items)']
        ]
        ['0x05','0x03' S7PayloadWriteVarResponse
            [arrayField S7VarPayloadStatusItem 'items' count 'CAST(parameter, S7ParameterWriteVarResponse).numItems']
        ]
        ['0x00','0x07' S7PayloadUserData
        ]
    ]
]

因此,每当引用一个复杂类型时,都可以向下一个类型传递一份额外的参数列表。

上面的片段中就有这样一个例子:

[field S7Payload   'payload'   ['messageType', 'parameter']]

序列化器与解析器参数

参数会影响解析器或序列化器的运行方式。

凡是使用了解析器参数的地方,该参数在其处理的所有子类型中同样有效。

byteOrder

一个 byteOrder 参数可以设置或更改解析器所使用的字节序。

目前支持两种变体:

  • BIG_ENDIAN
  • LITTLE_ENDIAN

encoding

每种简单类型都有默认编码,这在绝大多数情况下都适用。

例如,无符号整数使用二进制补码表示,浮点值采用 IEEE 754 单精度或双精度编码,字符串默认以 UTF-8 编码。

但在某些情况下需要使用其他编码。尤其是在处理字符串时,可以使用 ASCII、UTF-16 等多种不同的编码。对于数值,同样可能使用不同的编码。例如,KNX 使用一种非标准的 16 位浮点编码;而在 S7 驱动中,采用了特殊编码来表示数值,使其以十六进制格式呈现。

可以通过 encoding 属性来选择非默认编码。

引用判别类型的父类名称

对于判别类型,有时希望不必在每个分支中重复父类名称。此时可以在每个分支名称的开头使用 * 引用。

[discriminatedType SuperImportantClass
    [simple uint 8 testNumber]
    [typeSwitch testNumber
        ['0x08' *Status
            [simple uint 8 testNewNumber]
        ]
    ]
]

在这里,子类将继承类名 SuperImportantClassStatus。

评论

登录后参与评论

正在加载评论…