当前位置: 首页 > news >正文

Nanopb基本概念

1. 引言

本文将介绍 nanopb 设计的基本概念。

2. Proto 文件

所有 Protocol Buffers 实现都使用 .proto 文件来描述消息格式。
这些文件的目的是作为一种可移植的接口描述语言(interface description language)。

2.1 为 nanopb 编译 .proto 文件

Nanopb 附带了一个 Python 脚本,用于从 .proto 定义文件生成 .pb.c.pb.h 文件:

user@host:~$ nanopb/generator/nanopb_generator.py message.proto  
Writing to message.pb.h and message.pb.c

该脚本内部使用 Google 的 protoc 来解析输入文件。
如果当前系统中没有安装 protoc,则会出现错误提示。
可以通过 pip 安装 grpcio-tools Python 包,或者直接安装 protobuf-compiler 发行版中的 protoc 编译器。

通常推荐使用 Python 包的方式,因为 nanopb 需要 protoc 3.6 或更高版本 才能支持全部功能,而某些系统自带的版本可能较旧。

2.1.1 修改生成器选项

通过生成器选项(generator options),可以为字段设置最大大小,从而使它们可以静态分配内存。
推荐的做法是创建一个与 .proto 文件同名的 .options 文件:

# Foo.proto  
message Foo {  required string name = 1;  
}
# Foo.options  
Foo.name max_size:16

更多信息可参考手册中的 Proto file options 一节。

3. 流(Streams)

Nanopb 使用 流(stream) 来访问编码格式的数据。
流抽象非常轻量,仅由一个包含回调函数指针的结构体(pb_ostream_tpb_istream_t)组成。

以下是编写回调函数的一些通用规则:

  • 1)在出现 I/O 错误时返回 false,编码或解码过程会立即中止。
  • 2)使用 state 存储自定义数据(如文件描述符)。
  • 3)bytes_writtenbytes_leftpb_writepb_read 自动更新。
  • 4)callback回调可能被用于子流(substream)。在这种情况下,bytes_leftbytes_writtenmax_size 的值比原始流更小,不要用这些值计算指针偏移。
  • 5)始终完整读取或写入请求的数据长度。如,POSIX 的 recv() 需要使用 MSG_WAITALL 标志来确保这一点。

3.1 输出流(Output streams)

struct _pb_ostream_t  
{  bool (*callback)(pb_ostream_t *stream, const uint8_t *buf, size_t count);  void *state;  size_t max_size;  size_t bytes_written;  
};

其中:

  • 如果输出流的callbackNULL,则该流仅用于计算写入的字节数,此时会忽略 max_size
  • 否则,当 bytes_written + bytes_to_be_written 大于 max_size 时,pb_write 会立即返回 false
    • 如果不想限制流的大小,可以将 max_size 设为 SIZE_MAX

示例 1:计算消息大小而不存储

Person myperson = ...;  
pb_ostream_t sizestream = {0};  
pb_encode(&sizestream, Person_fields, &myperson);  
printf("Encoded size is %d\n", sizestream.bytes_written);

示例 2:写入到标准输出

bool callback(pb_ostream_t *stream, const uint8_t *buf, size_t count)  
{  FILE *file = (FILE*) stream->state;  return fwrite(buf, 1, count, file) == count;  
}pb_ostream_t stdoutstream = {&callback, stdout, SIZE_MAX, 0};

3.2 输入流(Input streams)

对于输入流,还有一个额外规则:

  • 6)不需要提前知道消息的长度。当读取到 EOF 错误后,将 bytes_left 设为 0 并返回 false
    • 如果 EOF 出现在正确位置,pb_decode() 会检测到并返回 true

输入流结构如下:

struct _pb_istream_t  
{  bool (*callback)(pb_istream_t *stream, uint8_t *buf, size_t count);  void *state;  size_t bytes_left;  
};

其中:

  • callback必须始终是函数指针。
  • bytes_left 表示可读取的最大字节数上限。
    • 如果回调函数能够按照上述方式处理 EOF,可以将其设置为 SIZE_MAX

示例:以下函数将输入流绑定到 stdin:

bool callback(pb_istream_t *stream, uint8_t *buf, size_t count)  
{  FILE *file = (FILE*)stream->state;  bool status;if (buf == NULL)  {  while (count-- && fgetc(file) != EOF);  return count == 0;  }status = (fread(buf, 1, count, file) == count);if (feof(file))  stream->bytes_left = 0;return status;  
}pb_istream_t stdinstream = {&callback, stdin, SIZE_MAX};

4. 数据类型(Data types)

大多数 Protocol Buffers 的数据类型在 C 中都有直接对应的类型,如:

  • int32int32_t
  • floatfloat
  • boolbool

但对于可变长度类型,规则会更复杂一些:

  • 1)字符串、字节数组、以及任意类型的 repeated 字段 默认映射为 回调函数(callback)。
  • 2)如果 .proto 文件中指定了特殊选项 (nanopb).max_length(nanopb).max_size,则 string 映射为 以 null 结尾的 char 数组,而 bytes 映射为 包含 char 数组和 size 字段的结构体。
  • 3)如果同时设置了 (nanopb).fixed_length = true(nanopb).max_size,那么 bytes 会映射为固定长度的字节数组。
  • 4)如果 repeated 字段设置了 (nanopb).max_count,则映射为固定长度数组,并自动生成一个字段来记录实际元素数量。
  • 5)如果同时设置 (nanopb).fixed_count = true(nanopb).max_count,则不会生成计数字段,因为默认元素数量等于最大数量。

4.1 .proto 定义与生成结构体的对应示例

简单整数字段:

.proto: int32 age = 1;
.pb.h: int32_t age;

未知长度字符串:

.proto: string name = 1;
.pb.h: pb_callback_t name;

已知最大长度字符串:

.proto: string name = 1 [(nanopb).max_length = 40];
.pb.h: char name[41];

未知数量的重复字符串:

.proto: repeated string names = 1;
.pb.h: pb_callback_t names;

已知最大数量和长度的重复字符串:

.proto: repeated string names = 1 [(nanopb).max_length = 40, (nanopb).max_count = 5];
.pb.h: size_t names_count; char names[5][41];

已知最大大小的 bytes 字段:

.proto: bytes data = 1 [(nanopb).max_size = 16];
.pb.h: PB_BYTES_ARRAY_T(16) data; // struct { pb_size_t size; pb_byte_t bytes[n]; }

固定长度的 bytes 字段:

.proto: bytes data = 1 [(nanopb).max_size = 16, (nanopb).fixed_length = true];
.pb.h: pb_byte_t data[16];

已知最大数量的重复整数数组:

.proto: repeated int32 numbers = 1 [(nanopb).max_count = 5];
.pb.h: pb_size_t numbers_count; int32_t numbers[5];

固定数量的重复整数数组:

.proto: repeated int32 numbers = 1 [(nanopb).max_count = 5, (nanopb).fixed_count = true];
.pb.h: int32_t numbers[5];

运行时会对最大长度进行检查。
如果字符串、字节数组或数组的内容超出分配的长度,pb_decode() 将返回 false

注意:
对于 bytes 类型,长度检查可能不完全精确。
编译器可能会为 pb_bytes_t 结构体添加填充字节,而 nanopb 运行时无法知道填充大小。
因此,它会将整个结构体长度都用于存储数据。
这虽然不太“聪明”,但一般不会引发问题。
实际上,如果在 bytes 字段中设置 (nanopb).max_size = 5,可能可以存储 6 个字节。
对于 string 类型,长度限制是精确的。

注意:
解码器一次只能跟踪一个 fixed_count 的 repeated 字段。
通常这不是问题,因为 repeated 字段的元素通常是连续排列的。
但如果多个 fixed_count 的 repeated 字段交叉出现,虽然这样的 protobuf 消息在语义上有效,
nanopb 解码器会报错:“wrong size for fixed count field”。

4.2 字段回调(Field callbacks)

处理 repeated 字段的最简单方法是为其指定最大大小(如上一节所示)。
但在某些情况下,可能需要处理长度无限制的数组,甚至超出可用 RAM 的大小。

为此,nanopb 提供了 回调接口(callback interface)。

  • 当 nanopb 内核解码到某个字段时,会调用所定义的回调函数。
    • 该回调函数代码可以以自定义方式处理字段,如分片解码数据(decode the data piece-by-piece)并写入文件系统。

pb_callback_t 结构体包含一个函数指针和一个 void* 指针(称为 arg),可以使用它们来传递数据给该回调函数。

  • 如果该函数指针为 NULL,该字段会被跳过。
  • 回调函数接收 arg 的指针,因此可以修改或读取其中的值。

该回调函数的实际行为在编码和解码模式下是有所不同的:

  • 在 编码模式 下,该回调函数会被调用一次,应当输出包括字段标签在内的全部内容。
  • 在 解码模式 下,该回调函数会为每个数据项重复调用。

若要编写更复杂的字段回调函数,建议参看 Google Protobuf 编码规范。

4.2.1 编码回调(Encoding callbacks)

编码回调(Encoding callbacks):

bool (*encode)(pb_ostream_t *stream, const pb_field_iter_t *field, void * const *arg);
参数说明
stream输出流,用于写入数据
field当前正在编码或解码的字段迭代器
arg指向 pb_callback_t 结构中 arg 字段的指针

在编码时,回调函数应当写出完整字段,包括wire type(线类型)和字段编号标签(tag)。
它可以根据需要写入任意数量的字段。
如,若想将数组写作一个 repeated 字段,应当在单次调用中完成。

通常可以使用 pb_encode_tag_for_field 来编码字段的 wire type 和标签号。
但如果想把 repeated 字段编码为打包数组(packed array),则必须改用 pb_encode_tag 并手动指定 wire type 为 PB_WT_STRING

  • 若该回调函数用于子消息(submessage),那么在一次调用 pb_encode() 的过程中,它可能会被多次调用,且每次生成的数据量必须一致。
  • 若回调位于主消息中,则只会被调用一次。

以下回调函数用于写出一个动态长度字符串:

bool write_string(pb_ostream_t *stream, const pb_field_iter_t *field, void * const *arg)  
{  char *str = get_string_from_somewhere();  if (!pb_encode_tag_for_field(stream, field))  return false;return pb_encode_string(stream, (uint8_t*)str, strlen(str));  
}

4.2.2 解码回调(Decoding callbacks)

解码回调(Decoding callbacks):

bool (*decode)(pb_istream_t *stream, const pb_field_iter_t *field, void arg);
参数说明
stream输入流,用于读取数据
field当前正在编码或解码的字段迭代器
arg指向 pb_callback_t 结构中 arg 字段的指针

在解码时,回调函数会接收一个长度受限的子流(substring),其内容对应一个单独字段的内容。

  • 该字段标签(tag)已被读取。
  • 对于 stringbytes 字段,长度值也已被解析,可通过 stream->bytes_left 获取。
  • 对于 repeated 字段,该回调函数会被多次调用。
  • 对于 packed 字段,可以选择在单次调用中读取所有值,或让 pb_decode() 多次调用回调函数,直到所有值都被读取完毕。

以下回调函数读取多个整数并打印出来:

bool read_ints(pb_istream_t *stream, const pb_field_iter_t *field, void arg)  
{  while (stream->bytes_left)  {  uint64_t value;  if (!pb_decode_varint(stream, &value))  return false;  printf("%lld\n", value);  }  return true;  
}

4.2.3 函数名绑定回调(Function name bound callbacks)

函数名绑定回调(Function name bound callbacks):

bool MyMessage_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_iter_t *field);
参数说明
istream输入流(若处于编码阶段则为 NULL)
ostream输出流(若处于解码阶段则为 NULL)
field当前正在编码或解码的字段迭代器

在消息结构体中使用 pb_callback_t 存储函数指针会占用额外内存,且使用起来较为繁琐。
作为替代方案,可以使用生成器选项(generator options) callback_functioncallback_datatype 通过函数名绑定回调。

  • 通常,这个功能会将 callback_datatype 设为 void*,或者设为一个用于存储编码/解码数据的结构体类型。
  • 生成器会自动将 callback_function 设为 MessageName_callback,并在生成的 .pb.h 文件中生成对应的函数原型。
  • 只需在代码中实现该函数,即可接收字段回调,无需手动设置函数指针。

如果希望部分字段使用“函数名绑定回调”,其他字段使用常规的 pb_callback_t,可以从消息级回调中调用 pb_default_field_callback,以读取并调用 pb_callback_t 中存储的函数指针。

4.3 消息描述符(Message descriptor)

要使用 pb_encode()pb_decode() 函数,需要一份消息中所有字段的描述信息。
这类描述通常由 .proto 文件自动生成。

如,Person.proto 中的子消息:

message Person {  message PhoneNumber {  required string number = 1 [(nanopb).max_size = 40];  optional PhoneType type = 2 [default = HOME];  }  
}

将生成如下宏列表(位于 .pb.h 文件中):

#define Person_PhoneNumber_FIELDLIST(X, a) \
X(a, STATIC,   REQUIRED, STRING,   number,            1) \
X(a, STATIC,   OPTIONAL, UENUM,    type,              2)

而在 .pb.c 文件中会有如下宏调用:

PB_BIND(Person_PhoneNumber, Person_PhoneNumber, AUTO)

这些宏会共同生成一个 pb_msgdesc_t 结构体及其关联的列表:

const uint32_t Person_PhoneNumber_field_info[] = { ... };  
const pb_msgdesc_t * const Person_PhoneNumber_submsg_info[] = { ... };  
const pb_msgdesc_t Person_PhoneNumber_msg = {  2,  Person_PhoneNumber_field_info,  Person_PhoneNumber_submsg_info,  Person_PhoneNumber_DEFAULT,  NULL,  
};

编码与解码函数会接收该结构体的指针,并据此处理消息中的每个字段。

4.4 Oneof(互斥字段)

Protocol Buffers 支持 oneof 语法块,其中包含的字段中同一时间只能存在一个。
以下是一个使用 oneof 的示例:

message MsgType1 {  required int32 value = 1;  
}message MsgType2 {  required bool value = 1;  
}message MsgType3 {  required int32 value1 = 1;  required int32 value2 = 2;  
} message MyMessage {  required uint32 uid = 1;  required uint32 pid = 2;  required uint32 utime = 3;oneof payload {  MsgType1 msg1 = 4;  MsgType2 msg2 = 5;  MsgType3 msg3 = 6;  }  
}

Nanopb 会将 payload 生成一个 C 联合体(union),并额外添加一个字段 which_payload

typedef struct _MyMessage {  uint32_t uid;  uint32_t pid;  uint32_t utime;  pb_size_t which_payload;  union {  MsgType1 msg1;  MsgType2 msg2;  MsgType3 msg3;  } payload;  
} MyMessage;

which_payload 用来标记当前激活的 oneof 字段是哪一个。
用户需要手动设置该字段和相应的消息内容,如:

MyMessage msg = MyMessage_init_zero;  
msg.payload.msg2.value = true;  
msg.which_payload = MyMessage_msg2_tag;

请注意:

  • which_payload 字段本身不会出现在编码后的消息中;
  • 未使用的 payload 字段也不会占用任何空间。

如果 oneof 内部的字段包含 pb_callback_t 类型的回调字段,则在解码前无法设置回调函数,因为这些字段共享同一个 union 存储空间。
此时,应使用“函数名绑定回调”或单独的“消息级回调”。
相关示例可参考 tests/oneof_callback。

4.5 扩展字段(Extension fields)

Protocol Buffers 支持一种称为扩展字段的机制,它允许在消息外部定义额外的字段,甚至定义在完全不同的 .proto 文件中。

要声明一个可扩展的消息,在 .proto 文件中使用 extensions 关键字:

message MyMessage {  .. fields ..  extensions 100 to 199;  
}

对于每个可扩展的消息,nanopb_generator.py 会额外声明一个名为 extensions 的回调字段。
该字段及其关联类型 pb_extension_t 构成一个处理器链表。

  • 当解码器遇到未知字段时,会依次调用这些处理器;
  • 若某个处理器成功处理该字段,则停止;否则继续直到链表末尾。

实际的扩展字段使用 extend 关键字定义,并处于全局命名空间中:

extend MyMessage {  optional int32 myextension = 100;  
}

对于每个扩展字段,nanopb_generator.py 会生成一个 pb_extension_type_t 类型的常量。
要将基础消息与扩展字段连接起来,需要:

  • 1)为扩展字段分配存储空间:
    如,对于 int32 类型的扩展字段,需要定义一个 int32_t 变量来存放值。
  • 2)创建一个 pb_extension_t 常量,其中包含指向该变量的指针,以及指向生成的 pb_extension_type_t 的指针。
  • 3)将消息的 extensions 指针设置为该 pb_extension_t 实例。

可在以下示例中查看具体用法:

  • tests/extensions/encode_extensions.c
  • tests/extensions/decode_extensions.c

4.6 默认值(Default values)

Protocol Buffers 有两种语法版本:proto2 和 proto3。
其中,proto2 允许用户在 .proto 文件中定义默认值,如:

message MyMessage {  optional bytes foo = 1 [default = "ABC\x01\x02\x03"];  optional string bar = 2 [default = "åäö"];  
}

Nanopb 会为这些默认值生成静态初始化和运行时初始化代码。
在生成的 myproto.pb.h 文件中,会包含一个宏:

#define MyMessage_init_default {...}

可用于将整个消息初始化为默认值:

MyMessage msg = MyMessage_init_default;

除此之外,pb_decode() 也会在运行时自动将字段初始化为默认值。
如果不希望这样,可使用 pb_decode_ex() 来跳过默认值初始化。

5. 消息分帧(Message framing)

Protocol Buffers 本身不定义消息分帧机制,即不规定如何在传输层封装消息。
因此,用户需自行实现分帧逻辑,以适配具体场景。

常见的分帧需求包括:

  • 1)编码消息长度;
  • 2)编码消息类型;
  • 3)实现同步、错误检测等特定应用需求。

如:

  • UDP 包天然满足这些需求;
  • TCP 流常常只需在消息前添加长度和类型标识;
  • 串口(Serial port)等底层接口可能需要更健壮的帧格式(如 HDLC,high-level data link control)。

Nanopb 提供了若干辅助机制以简化分帧实现:

  • 1)pb_encode_ex()pb_decode_ex() —— 自动在消息数据前加上 varint 编码的长度字段;
  • 2)支持 union 消息 与 oneof,可用于实现顶层容器结构;
  • 3)通过 (nanopb_msgopt).msgid 选项可指定消息 ID,并在消息头中读取。

6. 返回值与错误处理(Return values and error handling)

Nanopb 中的大多数函数返回 bool 值:

  • true 表示成功;
  • false 表示失败。

此外,Nanopb 还提供了调试错误信息功能:

  • 错误描述字符串存放在 stream->errmsg 中。

这些错误信息可帮助定位问题来源。
常见错误原因包括:

  • 1)无效的 Protocol Buffers 二进制消息;
  • 2)二进制消息与 .proto 定义不匹配;
  • 3)消息未正确结束(长度错误);
  • 4)超出流(stream)的 max_sizebytes_left
  • 5)超出字符串或数组字段的 max_size / max_count
  • 6)用户自定义流回调函数中的 IO 错误;
  • 7)用户回调函数中发生的错误;
  • 8)内存不足或栈溢出;
  • 9)无效的字段描述符(通常表示代码生成器的 bug)。

7. 静态断言(Static assertions)

Nanopb 使用静态断言在编译期检查结构体大小正确性。
PB_STATIC_ASSERT 定义在 pb.h 中:

  • 若编译器支持 ISO C11,则使用 _Static_assert 关键字;
  • 否则使用“负数组大小”技巧模拟静态断言。

常见的静态断言错误及其原因如下:

  • 1)FIELDINFO_DOES_NOT_FIT_width2 / width1
    • 消息超过 256 字节,但nanopb生成器未正确检测。
    • 解决方法:
      • 同时将所有 .proto 文件作为参数传给 nanopb_generator.py,确保子消息定义被找到;
      • 或在 .proto 文件中手动设置 (nanopb).descriptorsize = DS_4
  • 2)FIELDINFO_DOES_NOT_FIT_width4
    • 消息超过 64 KB。
    • 解决方法:
      • 在C编译命令选项或 pb.h 中启用 PB_FIELD_32BIT,以扩大nanopb代码内部整型字段的范围。
  • 3)DOUBLE_MUST_BE_8_BYTES
    • 某些平台(如 AVR)不支持 64 位 double,仅支持 32 位 float
    • 解决方法:
      • 定义编译选项 PB_CONVERT_DOUBLE_FLOAT,自动进行类型转换;
      • 或修改 .proto 使用 float 类型(推荐)。
  • 4)INT64_T_WRONG_SIZE
    • stdint.h系统头文件 与所用C编译器不兼容。
    • 可能是包含路径错误或编译器不支持 64 位整数。
    • 若确实不支持,可定义 PB_WITHOUT_64BIT 以禁用 64 位支持。
  • 5)variably modified array size
    • 编译器无法在编译期解析数组静态断言。
    • 解决方法:
      • 启用 C11 标准模式编译;
      • 若在所用编译器上无法使用静态断言,可定义 PB_NO_STATIC_ASSERT 关闭检查。

参考资料

[1] Nanopb基本概念

http://www.dtcms.com/a/593760.html

相关文章:

  • 微网站开发平台 知乎东家乐装修公司简介
  • 基于交替方向乘子法(ADMM)的RPCA MATLAB实现
  • redis删除一个键用del还是unlink
  • 用vue.js做网站百度区域代理
  • 好人一生平安网站哪个好抖音代运营培训
  • 前端基础面试题(Css,Html,Js,Ts)
  • 使用c#强大的SourceGenerator现对象的深克隆
  • 企业移动网站建设网站文件夹命名规则
  • 【动态链接库】一、VS下基本制作与使用
  • 百度网站排名规则长春百度快速优化
  • xpert AI工作流工具本地部署
  • SP30N06NK 30V N沟道MOSFET技术解析与应用指南
  • 深圳建站公司推荐国内平台有哪些
  • 使用DFSDM模拟看门狗做过流保护以及封波应用 LAT1612
  • 远程传输大文件的软件有哪些?
  • 北京建设官方网站渠道网络大厦
  • 鸿蒙 Next 如何使用 AVRecorder 从0到1实现视频录制功能(ArkTS)
  • 动态背景网站北京网站设计制作费用
  • LSTM模型做分类任务2(PyTorch实现)
  • 企业网站模板 简洁wordpress 水印
  • PostgreSQL模式:数据库中的命名空间艺术
  • 数据库的4个基本概念
  • 做a网站wordpress 导入xml
  • zzcms网站开发wordpress 文章密码保护
  • 51-55 函数
  • 社交网站图片展示上门做网站公司哪家好
  • 请求头中传递错误信息
  • 安装使用IDEA完整过程(含maven,tomcat配置)
  • Vue3中的常用指令
  • C语言算法:排序算法进阶