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_t 或 pb_istream_t)组成。
以下是编写回调函数的一些通用规则:
- 1)在出现 I/O 错误时返回
false,编码或解码过程会立即中止。 - 2)使用
state存储自定义数据(如文件描述符)。 - 3)
bytes_written与bytes_left由pb_write和pb_read自动更新。 - 4)callback回调可能被用于子流(substream)。在这种情况下,
bytes_left、bytes_written和max_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;
};
其中:
- 如果输出流的
callback为NULL,则该流仅用于计算写入的字节数,此时会忽略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。
- 如果 EOF 出现在正确位置,
输入流结构如下:
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。
- 如果回调函数能够按照上述方式处理 EOF,可以将其设置为
示例:以下函数将输入流绑定到 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 中都有直接对应的类型,如:
int32→int32_tfloat→floatbool→bool
但对于可变长度类型,规则会更复杂一些:
- 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)已被读取。
- 对于
string和bytes字段,长度值也已被解析,可通过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_function 与 callback_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.ctests/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_size或bytes_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代码内部整型字段的范围。
- 在C编译命令选项或
- 3)
DOUBLE_MUST_BE_8_BYTES- 某些平台(如 AVR)不支持 64 位
double,仅支持 32 位float。 - 解决方法:
- 定义编译选项
PB_CONVERT_DOUBLE_FLOAT,自动进行类型转换; - 或修改
.proto使用float类型(推荐)。
- 定义编译选项
- 某些平台(如 AVR)不支持 64 位
- 4)
INT64_T_WRONG_SIZEstdint.h系统头文件 与所用C编译器不兼容。- 可能是包含路径错误或编译器不支持 64 位整数。
- 若确实不支持,可定义
PB_WITHOUT_64BIT以禁用 64 位支持。
- 5)
variably modified array size- 编译器无法在编译期解析数组静态断言。
- 解决方法:
- 启用 C11 标准模式编译;
- 若在所用编译器上无法使用静态断言,可定义
PB_NO_STATIC_ASSERT关闭检查。
参考资料
[1] Nanopb基本概念
