在嵌入式系统和资源受限的环境中,传统的Protocol Buffers 可能显得过于庞大。因此,nanopb 应运而生,它是一个轻量级的 Protocol Buffers 生成器,专为嵌入式系统设计c语言设计。本文将介绍如何安装和使用 nanopb,以及通过一个简单的例子来展示它的基本用法。
网上的大多数文章都是只讲如何使用。其实新手刚拿到后,很重要的一点是如何用起来?如何安装环境?protoc工具在哪里搞到?这里从环境介绍到详细使用做个总结,留作备忘。
什么是Protocol Buffers
Protocol Buffer是谷歌推出的,和开发语言无关、平台无关、可扩展的机制,用于序列化结构化数据------像XML,但它更小、更快、更简单。
关键特点:
1、跨平台。
2、可扩展。
3、序列化结构化数据。
4、使用简单。
官方网址:https://developers.google.com/protocol-buffers
支持最常使用的语言
1、C++
2、C#
3、Java
4、Python
什么是 nanopb
nanopb 是一个非常轻量级的 C 库,用于 Protocol Buffers 的序列化和反序列化。它专为嵌入式系统设计,可以运行在内存和存储空间有限的环境中。nanopb 支持 Protocol Buffers 2.3 和 3.0 版本的标准,因此可以用于大多数现有的 Protocol Buffers 定义文件。
仓库地址:https://gitcode.com/gh_mirrors/na/nanopb
github仓:https://github.com/nanopb/nanopb
安装 nanopb
这个很重要。很多人拿到仓库后,苦于找不到protoc工具,不知道如何生成pb.c文件。其实原因就出在这里,没有安装环境。而官方在这里的介绍简单了些。nanopb 的安装可以通过 pip3 和从源码编译两种方式进行。本文推荐使用 pip3 安装,因为它更加简便快捷。
首先,确保你的开发环境中已经安装了 Python3 和 pip3。如果还没有安装,可以通过以下命令安装:
bash
sudo apt-get update
sudo apt-get install python3 python3-pip
接下来,使用 pip3 安装 nanopb 的相关工具。这里我们使用阿里云的镜像源来加速安装:
bash
pip3 install protobuf grpcio-tools -i https://mirrors.aliyun.com/pypi/simple/
安装完成后,你需要确保 nanopb 的生成器工具 protoc
和 nanopb_generator
可用。上面安装完依赖后,这两个工具自然可用。可以将/root/test/c/nanopb/generator/加入搭排环境变量里,linux下方便使用protoc。 你可以通过以下命令来生成 .pb.c
和 .pb.h
文件:
bash
../../generator/protoc --nanopb_out=. simple.proto
# 或者
nanopb_generator simple.proto
这里 simple.proto
是你的 Protocol Buffers 定义文件。生成器会根据这个文件生成对应的 C 代码文件。
使用 nanopb
要使用 Nanopb 库,您需要执行以下两个步骤:
- 使用 protoc 编译您的 .proto 文件以生成适用于 Nanopb 的文件。
- 在项目中包含 pb_encode.c、pb_decode.c 和 pb_common.c。
开始学习的最佳方式是研究 "examples/simple" 目录中的示例项目。它包含了一个 Makefile,在大多数 Linux 系统上可以直接工作。然而,对于其他类型的构建系统,可以参考该目录下 README.txt 中的手动步骤。
下面,我们将通过一个简单的例子来展示如何使用 nanopb。
假设我们有一个简单的 Protocol Buffers 定义文件 simple.proto
,
内容如下:
proto
syntax = "proto2";
message SimpleMessage {
required int32 lucky_number = 1;
}
这个定义文件中只包含一个消息定义 SimpleMessage
,它有一个 int32
类型的字段 lucky_number
。
接下来,我们将编写 C 代码来处理这个消息。示例代码如下:
c
#include <stdio.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include "simple.pb.h"
int main()
{
/* This is the buffer where we will store our message. */
uint8_t buffer[128];
size_t message_length;
bool status;
/* Encode our message */
{
/* Allocate space on the stack to store the message data.
*
* Nanopb generates simple struct definitions for all the messages.
* - check out the contents of simple.pb.h!
* It is a good idea to always initialize your structures
* so that you do not have garbage data from RAM in there.
*/
SimpleMessage message = SimpleMessage_init_zero;
/* Create a stream that will write to our buffer. */
pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
/* Fill in the lucky number */
message.lucky_number = 13;
/* Now we are ready to encode the message! */
status = pb_encode(&stream, SimpleMessage_fields, &message);
message_length = stream.bytes_written;
/* Then just check for any errors.. */
if (!status)
{
printf("Encoding failed: %s\n", PB_GET_ERROR(&stream));
return 1;
}
}
/* Now we could transmit the message over network, store it in a file or
* wrap it to a pigeon's leg.
*/
/* But because we are lazy, we will just decode it immediately. */
{
/* Allocate space for the decoded message. */
SimpleMessage message = SimpleMessage_init_zero;
/* Create a stream that reads from the buffer. */
pb_istream_t stream = pb_istream_from_buffer(buffer, message_length);
/* Now we are ready to decode the message. */
status = pb_decode(&stream, SimpleMessage_fields, &message);
/* Check for errors... */
if (!status)
{
printf("Decoding failed: %s\n", PB_GET_ERROR(&stream));
return 1;
}
/* Print the data contained in the message. */
printf("Your lucky number was %d!\n", (int)message.lucky_number);
}
return 0;
}
这段代码首先初始化了一个 SimpleMessage
结构体,然后使用 pb_encode
函数将这个结构体编码到一个字节缓冲区中。接着,它又将这个缓冲区中的数据解码回一个 SimpleMessage
结构体,并输出其中的 lucky_number
字段。
Makefile 文件
为了简化编译过程,我们可以使用 Makefile 文件来管理编译规则。以下是示例 Makefile 文件的内容:
makefile
# Include the nanopb provided Makefile rules
include ../../extra/nanopb.mk
# Compiler flags to enable all warnings & debug info
CFLAGS = -Wall -Werror -g -O0
CFLAGS += -I$(NANOPB_DIR)
# C source code files that are required
CSRC = simple.c # The main program
CSRC += simple.pb.c # The compiled protocol definition
CSRC += $(NANOPB_DIR)/pb_encode.c # The nanopb encoder
CSRC += $(NANOPB_DIR)/pb_decode.c # The nanopb decoder
CSRC += $(NANOPB_DIR)/pb_common.c # The nanopb common parts
# Build rule for the main program
simple: $(CSRC)
$(CC) $(CFLAGS) -osimple $(CSRC)
# Build rule for the protocol
simple.pb.c: simple.proto
$(PROTOC) $(PROTOC_OPTS) --nanopb_out=. $<
这个 Makefile 文件首先包含了 nanopb 提供的 Makefile 规则 nanopb.mk
。然后,它定义了一些编译器标志 CFLAGS
,用于在编译过程中启用所有警告并包含调试信息。CSRC
变量列出了所有需要编译的 C 源代码文件,包括主程序文件 simple.c
、编译后的 Protocol Buffers 定义文件 simple.pb.c
以及 nanopb 库的核心文件。
最后,Makefile 文件定义了生成最终可执行文件 simple
的规则,以及从 Protocol Buffers 定义文件 simple.proto
生成 C 源代码文件 simple.pb.c
的规则。
nanopb.mk 文件
nanopb.mk
文件包含了 nanopb 提供的 Makefile 规则,用于生成 .pb.c
和 .pb.h
文件以及定义 nanopb 库的核心文件路径。以下是 nanopb.mk
文件的内容:
makefile
# This is an include file for Makefiles. It provides rules for building
# .pb.c and .pb.h files out of .proto, as well the path to nanopb core.
# Path to the nanopb root directory
NANOPB_DIR := $(patsubst %/,%,$(dir $(patsubst %/,%,$(dir $(lastword $(MAKEFILE_LIST)))))))
# Files for the nanopb core
NANOPB_CORE = $(NANOPB_DIR)/pb_encode.c $(NANOPB_DIR)/pb_decode.c $(NANOPB_DIR)/pb_common.c
# Check if we are running on Windows
ifdef windir
WINDOWS = 1
endif
ifdef WINDIR
WINDOWS = 1
endif
# Check whether to use binary version of nanopb_generator or the
# system-supplied python interpreter.
ifneq "$(wildcard $(NANOPB_DIR)/generator-bin)" ""
# Binary package
PROTOC = $(NANOPB_DIR)/generator-bin/protoc
PROTOC_OPTS =
else
# Source only or git checkout
PROTOC_OPTS =
ifdef WINDOWS
PROTOC = python $(NANOPB_DIR)/generator/protoc
else
PROTOC = $(NANOPB_DIR)/generator/protoc
endif
endif
# Rule for building .pb.c and .pb.h
%.pb.c %.pb.h: %.proto %.options
$(PROTOC) $(PROTOC_OPTS) --nanopb_out=. $<
%.pb.c %.pb.h: %.proto
$(PROTOC) $(PROTOC_OPTS) --nanopb_out=. $<
这个文件首先定义了 nanopb 的根目录路径 NANOPB_DIR
,然后列出了 nanopb 库的核心文件路径 NANOPB_CORE
。接着,它检查当前操作系统是否为 Windows,并根据操作系统的不同来设置 PROTOC
变量,以指向正确的 protoc
生成器工具。最后,它定义了生成 .pb.c
和 .pb.h
文件的规则。
稍复杂的使用示例
接下来做一个稍复杂的使用,proto文件定义了两个message:KeyValue和DeviceConfig。其中,DeviceConfig包含一些基本类型的字段和一个重复的KeyValue字段。生成的nanopb头文件中对于字符串和重复字段,使用了pb_callback_t类型,这意味着这些字段需要通过回调函数来处理,或者在生成时可能没有设置相应的选项来优化为静态数组。
假如有以下proto文件定义:
bash
syntax = "proto2";
package example;
message KeyValue {
required string key = 1;
required int32 value = 2;
}
message DeviceConfig {
required int32 BrdNum = 1;
required int32 address = 2;
required string type = 3;
required int32 priority = 4;
required int32 accessTime = 5;
required string brdLocation = 6;
repeated KeyValue bitMean = 7;
}
编码(序列化)的过程大致是:初始化消息结构体,填充数据,然后调用pb_encode函数。对于回调处理的字段(如字符串和重复字段),需要设置回调函数或者在结构体中正确填充数据。例如,pb_callback_t类型的字段需要用户提供函数来处理数据的编码,或者在结构体中直接设置对应的数据指针和大小。
在提供的DeviceConfig结构体中,type、brdLocation和bitMean都是回调类型。对于字符串字段,通常可以使用pb_callback_t的简单方式,例如直接指定字符串的指针和长度,或者设置一个encode函数。而bitMean是repeated的KeyValue,需要处理为重复字段,可能使用多次回调或者预分配数组。但根据生成的代码,这里可能还是需要使用回调来处理每个条目。
因此在以下代码中,处理这些回调字段是关键。对于编码,需要为每个pb_callback_t字段设置对应的函数,或者直接填充数据。例如,对于字符串字段,可能可以使用pb_ostream_from_buffer来创建一个输出流,然后使用pb_encode函数来编码数据。
以下为编码和解码的使用:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pb_encode.h>
#include <pb_decode.h>
#include "simple.pb.h"
// 编码回调 ---------------------------------------------------
bool encode_string(pb_ostream_t *stream, const pb_field_t *field, void *const *arg) {
const char *str = *(const char **)*arg;
if (!pb_encode_tag_for_field(stream, field))
return false;
return pb_encode_string(stream, (uint8_t*)str, strlen(str));
}
bool encode_bitMean(pb_ostream_t *stream, const pb_field_t *field, void *const *arg) {
const example_KeyValue *items = (const example_KeyValue *)*arg;
for (size_t i = 0; i < 2; i++) {
if (!pb_encode_tag_for_field(stream, field))
return false;
if (!pb_encode_submessage(stream, example_KeyValue_fields, &items[i]))
return false;
}
return true;
}
// 解码回调 ---------------------------------------------------
typedef struct {
char *key;
int32_t value;
} KeyValueData;
bool decode_string(pb_istream_t *stream, const pb_field_t *field, void **arg) {
char **strptr = (char **)*arg;
size_t len = stream->bytes_left;
*strptr = malloc(len + 1);
if (!pb_read(stream, (uint8_t*)*strptr, len)) {
free(*strptr);
return false;
}
(*strptr)[len] = '\0';
return true;
}
bool decode_bitMean(pb_istream_t *stream, const pb_field_t *field, void **arg) {
KeyValueData **items = (KeyValueData **)*arg;
size_t count = (*items == NULL) ? 0 : (*items)[0].value;
// 扩展数组空间(使用[0]存储计数)
KeyValueData *new_items = realloc(*items, (count + 1 + 1) * sizeof(KeyValueData));
if (!new_items) return false;
*items = new_items;
(*items)[0].value = count + 1; // 更新计数
KeyValueData *item = &(*items)[count + 1]; // 数据从[1]开始
item->key = NULL;
example_KeyValue kv = example_KeyValue_init_zero;
kv.key.arg = &item->key;
kv.key.funcs.decode = decode_string;
if (!pb_decode(stream, example_KeyValue_fields, &kv)) {
return false;
}
item->value = kv.value;
return true;
}
int main() {
/**************** 编码阶段 ****************/
example_DeviceConfig encode_config = example_DeviceConfig_init_default;
uint8_t buffer[256];
// 配置编码参数
encode_config.BrdNum = 123;
encode_config.address = 456;
encode_config.priority = 1;
encode_config.accessTime = 999;
const char *type_str = "temperature";
encode_config.type.arg = &type_str;
encode_config.type.funcs.encode = encode_string;
const char *loc_str = "Room 101";
encode_config.brdLocation.arg = &loc_str;
encode_config.brdLocation.funcs.encode = encode_string;
example_KeyValue bit_means[2] = {
{.key.arg = (void*)&(const char*[]){"status"}, .key.funcs.encode = encode_string, .value = 100},
{.key.arg = (void*)&(const char*[]){"error"}, .key.funcs.encode = encode_string, .value = 0}
};
encode_config.bitMean.arg = bit_means;
encode_config.bitMean.funcs.encode = encode_bitMean;
pb_ostream_t ostream = pb_ostream_from_buffer(buffer, sizeof(buffer));
if (!pb_encode(&ostream, example_DeviceConfig_fields, &encode_config)) {
fprintf(stderr, "Encode error: %s\n", PB_GET_ERROR(&ostream));
return 1;
}
/**************** 解码阶段 ****************/
example_DeviceConfig decode_config = example_DeviceConfig_init_zero;
KeyValueData *bit_means_decoded = NULL;
char *decoded_type = NULL;
char *decoded_loc = NULL;
// 配置解码回调
decode_config.type.arg = &decoded_type;
decode_config.type.funcs.decode = decode_string;
decode_config.brdLocation.arg = &decoded_loc;
decode_config.brdLocation.funcs.decode = decode_string;
decode_config.bitMean.arg = &bit_means_decoded;
decode_config.bitMean.funcs.decode = decode_bitMean;
pb_istream_t istream = pb_istream_from_buffer(buffer, ostream.bytes_written); // 关键修正点
if (!pb_decode(&istream, example_DeviceConfig_fields, &decode_config)) {
fprintf(stderr, "Decode error: %s\n", PB_GET_ERROR(&istream));
free(decoded_type);
free(decoded_loc);
if (bit_means_decoded) {
for (size_t i = 1; i <= bit_means_decoded[0].value; i++)
free(bit_means_decoded[i].key);
free(bit_means_decoded);
}
return 1;
}
/**************** 输出结果 ****************/
printf("Decoded config:\n"
"Type: %s\n"
"Location: %s\n"
"BitMeans count: %d\n",
decoded_type, decoded_loc,
bit_means_decoded ? bit_means_decoded[0].value : 0);
if (bit_means_decoded) {
for (int i = 1; i <= bit_means_decoded[0].value; i++) {
printf(" [%d] %s = %d\n",
i, bit_means_decoded[i].key, bit_means_decoded[i].value);
}
}
/**************** 清理资源 ****************/
free(decoded_type);
free(decoded_loc);
if (bit_means_decoded) {
for (int i = 1; i <= bit_means_decoded[0].value; i++) {
free(bit_means_decoded[i].key);
}
free(bit_means_decoded);
}
return 0;
}
优化使用示例
上述示例,使用了回调和动态内存分配,稍显复杂,改为以下方式则代码更简洁、内存管理更安全。
以下是通过添加nanopb选项优化proto定义后的使用示例:
1. 修改后的proto文件
protobuf
syntax = "proto2";
import "nanopb.proto"; // 需要nanopb库中的选项定义
package example;
message KeyValue {
required string key = 1 [(nanopb).max_size = 64]; // 限制key最大64字节
required int32 value = 2;
}
message DeviceConfig {
required int32 BrdNum = 1;
required int32 address = 2;
required string type = 3 [(nanopb).max_size = 64]; // 限制type长度
required int32 priority = 4;
required int32 accessTime = 5;
required string brdLocation = 6 [(nanopb).max_size = 128]; // 限制位置字符串
repeated KeyValue bitMean = 7 [(nanopb).max_count = 10]; // 最多10个元素
}
2. 生成新的头文件
使用nanopb生成器命令:
bash
protoc --nanopb_out=. simple.proto
3. 优化后的使用示例
c
#include "simple.pb.h"
#include <pb_encode.h>
#include <pb_decode.h>
#include <stdio.h>
#include <string.h>
int main() {
/**************** 编码示例 ****************/
example_DeviceConfig config = example_DeviceConfig_init_default;
// 直接赋值字符串(不再需要回调)
strncpy(config.type, "temperature", sizeof(config.type));
strncpy(config.brdLocation, "Room 101", sizeof(config.brdLocation));
// 设置基本数值字段
config.BrdNum = 123;
config.address = 456;
config.priority = 1;
config.accessTime = 999;
// 填充重复字段
config.bitMean_count = 2; // 自动生成的数组长度字段
strncpy(config.bitMean[0].key, "status", sizeof(config.bitMean[0].key));
config.bitMean[0].value = 100;
strncpy(config.bitMean[1].key, "error", sizeof(config.bitMean[1].key));
config.bitMean[1].value = 0;
// 编码缓冲区
uint8_t buffer[256];
pb_ostream_t ostream = pb_ostream_from_buffer(buffer, sizeof(buffer));
if (!pb_encode(&ostream, example_DeviceConfig_fields, &config)) {
fprintf(stderr, "Encode failed: %s\n", PB_GET_ERROR(&ostream));
return 1;
}
/**************** 解码示例 ****************/
example_DeviceConfig decoded = example_DeviceConfig_init_default;
pb_istream_t istream = pb_istream_from_buffer(buffer, ostream.bytes_written);
if (!pb_decode(&istream, example_DeviceConfig_fields, &decoded)) {
fprintf(stderr, "Decode failed: %s\n", PB_GET_ERROR(&istream));
return 1;
}
/**************** 输出结果 ****************/
printf("Decoded config:\n"
"Type: %s\n"
"Location: %s\n"
"BitMeans count: %d\n",
decoded.type,
decoded.brdLocation,
decoded.bitMean_count);
for (int i = 0; i < decoded.bitMean_count; i++) {
printf(" [%d] %s = %d\n",
i, decoded.bitMean[i].key, decoded.bitMean[i].value);
}
return 0;
}
优化后代码的主要变化
-
数据结构变化:
c// 原始回调方式 pb_callback_t type; // 优化后静态分配 char type[64]; size_t type_size;
-
重复字段处理:
c// 原始方式 pb_callback_t bitMean; // 优化后静态数组 example_KeyValue bitMean[10]; size_t bitMean_count;
-
字符串处理:
c// 之前需要回调 config.type.funcs.encode = encode_string; // 现在直接操作 strncpy(config.type, "text", sizeof(config.type));
关键优势说明
-
内存管理简化:
- 所有字符串字段变为固定长度char数组
- 重复字段变为固定大小的数组+count计数器
- 不再需要手动malloc/free
-
性能提升:
- 消除回调函数开销
- 数据内存连续,提高缓存命中率
-
代码可读性增强:
- 字段访问方式与常规结构体一致
- 减少约60%的样板代码
注意事项
-
字段长度限制:
- 超出max_size的字符串会被截断
- 超过max_count的数组元素会被丢弃
-
默认值初始化:
cexample_DeviceConfig_init_default // 会清零所有字段 example_DeviceConfig_init_zero // 同上,两者等效
-
字符串处理建议:
c// 使用strncpy防止溢出 strncpy(config.type, src, sizeof(config.type)); config.type[sizeof(config.type)-1] = '\0'; // 确保终止符
-
协议兼容性:
- 修改后的proto仍然与标准protobuf兼容
- nanopb选项仅影响生成代码的实现方式
推荐使用场景
- 嵌入式系统(内存受限环境)
- 需要确定性内存分配的场景
- 对性能要求较高的实时系统
- 希望简化代码逻辑的项目
通过这种优化方式,代码复杂度显著降低,同时保持了协议的高效性。建议根据实际字段的预期最大长度来设置合理的max_size
和max_count
值,在内存使用和灵活性之间取得平衡。
总结
通过本文,我们了解了什么是 nanopb,及其在嵌入式系统中的应用场景。我们还学习了如何安装和使用 nanopb,包括编写 Protocol Buffers 定义文件、C 代码文件以及 Makefile 文件。通过这些步骤,你可以轻松地在你的项目中集成 nanopb,以便更高效地进行消息的序列化和反序列化。