资讯动态

Tasmota Berry 虚拟机 C 扩展开发参考:从 FFI 到编译期构造的完整指南

发布时间:2026/9/12 2:55:14 来源:尧图企业网站定制
Tasmota Berry 虚拟机 C 扩展开发参考从 FFI 到编译期构造的完整指南【免费下载链接】TasmotaAlternative firmware for ESP8266 and ESP32 based devices with easy configuration using webUI, OTA updates, automation using timers or rules, expandability and entirely local control over MQTT, HTTP, Serial or KNX. Full documentation at项目地址: https://gitcode.com/GitHub_Trending/ta/Tasmota导读本文是面向在 Tasmota 固件中为 Berry 虚拟机VM编写 C 扩展native extension的紧凑型 FFI 参考指南。Berry 是 Tasmota 内置的轻量级脚本语言本指南围绕lib/libesp32/berry中的虚拟机实现系统讲解 VM 生命周期、虚拟栈操作、类型转换、原生函数与类注册、容器与迭代器、异常与调试钩子以及通过coc工具实现编译期对象构造const_object_info_begin和运行时模块注册be_define_native_module两条扩展路径。读完本文你将掌握在 Tasmota 中从零注册一个可被 Berry 脚本调用的原生模块/类的完整方法论并能读懂tasmota/tasmota_xdrv_driver/xdrv_52_9_berry.ino等驱动中 C 与 Berry 交互的底层机制。VM 生命周期创建、加载、执行与销毁Berry 虚拟机通过bvm结构体表示创建和销毁由以下 API 管理实现在 be_api.c#include berry.h bvm *vm be_vm_new(); // 创建 VM 并附带标准库 be_loadstring(vm, print(Hi)); // 编译字符串源码宏 be_loadbuffer(vm, name, buf, len); // 编译内存缓冲区源码 be_loadfile(vm, file.be); // 编译磁盘文件源码 be_pcall(vm, 0); // 受保护调用捕获异常 be_call(vm, argc); // 非保护调用 be_vm_delete(vm); // 销毁 VM其中be_loadstring(vm, s)是宏等价于be_loadbuffer(vm, string, s, strlen(s))be_dostring(vm, s)则一步完成加载 受保护调用。在 Tasmota 中xdrv_52_9_berry.ino 执行用户脚本时正是通过be_loadstring(vm, code_source)将脚本源码编译进 VM随后以be_pcall执行——这构成了Berry控制台与启动脚本autoexec.be的运行基础。be_pcall的返回值对应一组错误码定义于 berry.h错误码含义BE_OK执行成功值为 0BE_EXIT脚本调用exit主动退出BE_MALLOC_FAIL内存分配失败BE_EXCEPTION抛出了可捕获的异常BE_SYNTAX_ERROR源码语法错误BE_EXEC_ERROR字节码执行错误BE_IO_ERROR文件/IO 错误从源码结构看BE_OK与BE_EXIT被定义为枚举BE_OK 0, BE_EXIT其余错误码紧随其后可通过 BE_EXIT判断执行是否失败。需要强调的细节be_load*系列仅做编译真正的执行必须显式调用be_pcall/be_callbe_vm_delete会回收 VM 持有的全部对象长生命周期宿主如 Tasmota通常在启动时创建一次 VM 并复用而非频繁新建销毁。虚拟栈参数传递的唯一通道Berry 的 C API 使用索引访问栈上值。索引1表示第一个参数栈底-1表示栈顶。be_top(vm)返回当前参数个数即栈深。当编写原生函数时be_top(vm)就是脚本侧传入的参数数量。int be_top(bvm *vm); // 栈上值个数 void be_pop(bvm *vm, int n); // 弹出 n 个值 void be_remove(bvm *vm, int index); // 移除指定索引的值后续值前移 int be_absindex(bvm *vm, int index); // 相对索引转绝对索引 void be_moveto(bvm *vm, int from, int to); // 拷贝值到目标槽位 void be_stack_require(bvm *vm, int count); // 确保栈上有 count 个空闲槽其底层实现参见 be_api.cbe_top直接计算vm-top - vm-regbe_absindex将负数索引换算为vm-top index - vm-reg 1的绝对位置。be_remove采用从下往上的逐个前移实现while (v top) { *v v[1]; v; }因此频繁在栈中部增删会有 O(n) 成本。实践要点在压入多个中间值前调用be_stack_require(vm, n)预先申请栈空间可避免 GC 触发的栈扩展写完原生函数后必须保证栈状态与返回值约定一致见下节be_return。类型检查一整套be_is*谓词所有谓词签名一致bbool fn(bvm *vm, int index)对给定栈索引处的值做类型判断。完整清单be_isnil be_isbool be_isint be_isreal be_isnumber be_isstring be_isfunction be_isclosure be_isntvclos be_isproto be_isclass be_isinstance be_ismodule be_islist be_ismap be_iscomptr be_iscomobj be_isbytes be_isderived be_ismapinstance be_islistinstance两条复合语义需要注意与 be_api.c 的实现一致be_isfunction为广义函数闭包、原生函数、原生闭包、ctype_func均返回真be_isnumber等价于int 或 realbe_ismapinstance/be_islistinstance通过be_getbuiltin拿到map/list类后再用be_isderived判断继承关系源码中的be_isinstanceofbuiltin辅助函数因此能识别用户自定义类派生的实例而be_ismap/be_islist只认内部原生容器值。建议的健壮模式先be_top检查参数个数再逐参数be_is*校验类型最后be_to*取值——be_to*对错误类型通常是静默转换或返回空值前置检查可以避免把脚本侧的隐式错误变成难以排查的运行时问题。获取值be_to*转换族bint be_toint(bvm *vm, int index); // 原始强转不会调用 toint() breal be_toreal(bvm *vm, int index); // int/real 统一转 double bbool be_tobool(bvm *vm, int index); // 按 Berry 真值规则转换会调用 tobool() const char* be_tostring(bvm *vm, int index); // 就地转换会调用 tostring() void* be_tocomptr(bvm *vm, int index); // 取 comptr 或 comobj 的裸指针 int be_toindex(bvm *vm, int index); // 类似 toint返回 C int const void* be_tobytes(bvm *vm, int index, size_t *len); // bytes 缓冲区 长度 const char* be_typename(bvm *vm, int index); // 返回 int、string、instance 等类型名 const char* be_classname(bvm *vm, int index); // 返回类名非 class/instance 返回 NULL int be_strlen(bvm *vm, int index); // 取 Berry 字符串长度比 strlen 快实现层面的关键差异见 be_api.cbe_toint不做任何检查直接var_toint(v)——文档明确标注raw cast, does NOT call toint()be_toreal对 int 会做隐式提升对其它类型返回 0.0be_tostring内部对非字符串值先be_val2str就地转换再返回意味着它可能修改栈上该槽位的值且可能触发分配be_tocomptr同时支持BE_COMPTR原生指针与BE_COMOBJ复合对象返回obj-databe_classname对 class 返回be_class_name(c)对 instance 返回be_instance_name(i)用于错误消息中输出对象类型非常实用。be_tobytes与be_pushbytes对应 Berry 的bytes内置类型是 C 与脚本间传递二进制数据如 BLE 报文、哈希摘要的标准通道。压栈be_push*家族void be_pushnil(bvm *vm); void be_pushbool(bvm *vm, int b); void be_pushint(bvm *vm, bint i); void be_pushreal(bvm *vm, breal r); void be_pushstring(bvm *vm, const char *str); void be_pushnstring(bvm *vm, const char *str, size_t n); // 按长度压入可含 \0 const char* be_pushfstring(bvm *vm, const char *fmt, ...); // %d %f %g %s %c %p %% void be_pushvalue(bvm *vm, int index); // 从栈上拷贝值 void be_pushcomptr(bvm *vm, void *ptr); void* be_pushbytes(bvm *vm, const void *buf, size_t len); void be_pushntvfunction(bvm *vm, bntvfunc f); void be_pushntvclosure(bvm *vm, bntvfunc f, int nupvals); void be_pushclass(bvm *vm, const char *name, const bnfuncinfo *lib); void be_pushntvclass(bvm *vm, const struct bclass *c); void be_pushclosure(bvm *vm, void *cl); void* be_pushbuffer(bvm *vm, size_t size); // 压入原始缓冲区可写源码注释点明了一个易错陷阱be_api.cbe_pushstring必须先创建字符串对象、再推进栈顶顺序颠倒会因寄存器中残留未初始化值导致 GC 崩溃。be_pushfstring返回格式化后的字符串指针可用于在原生函数内拼装日志或错误消息be_pushbuffer返回可写的void*配合be_pushbytes是构造/解析二进制块的高效方式。原生函数签名、返回值与全局注册原生函数统一签名为int func(bvm *vm)通过两个宏返回结果be_return(vm); // 返回栈顶值保留栈顶作为返回值 be_return_nil(vm); // 返回 nilbe_return本质是把栈顶元素整理为返回值并弹出其余临时值因此返回值必须预先压栈。完整示例static int my_add(bvm *vm) { if (be_top(vm) 2 be_isint(vm, 1) be_isint(vm, 2)) { be_pushint(vm, be_toint(vm, 1) be_toint(vm, 2)); be_return(vm); } be_return_nil(vm); } be_regfunc(vm, myadd, my_add); // 注册为全局函数be_regfunc的实现be_api.c会根据BE_USE_PRECOMPILED_OBJECT宏走be_builtin_*或be_global_*两套查找/新建路径最终把原生函数指针写入全局变量表——这意味着用be_regfunc注册的函数从脚本侧就是普通全局函数可直接调用。参数不足或类型不符时返回nil是最保守的做法如需严格报错应改用be_raise见异常节。异常机制be_raise(bvm *vm, const char *type, const char *msg); // noreturn抛出异常 be_stop_iteration(bvm *vm); // 抛出 stop_iteration be_getexcept(bvm *vm, int code); // 获取异常上下文 be_dumpexcept(bvm *vm); // 打印异常与调用栈be_raise声明为 noreturn抛出后控制流不返回调用点若调用方用了be_pcall异常会被捕获并转换为错误码返回脚本侧可try/except捕获type对应的异常类。自定义迭代器在无元素可迭代时必须调用be_stop_iteration等价于抛出内置的stop_iteration否则for循环不会正确终止。调试阶段用be_dumpexcept输出完整异常回溯是定位原生代码与脚本交互问题的最快路径。成员、全局与模块访问bbool be_getmember(bvm *vm, int index, const char *k); // 压入 inst.k bbool be_setmember(bvm *vm, int index, const char *k); // inst.k 栈顶 bbool be_getmethod(bvm *vm, int index, const char *k); // 压入方法仅限 instance bbool be_getglobal(bvm *vm, const char *name); void be_setglobal(bvm *vm, const char *name); bbool be_getbuiltin(bvm *vm, const char *name); void be_getsuper(bvm *vm, int index); bbool be_setsuper(bvm *vm, int index); bbool be_classof(bvm *vm, int index); // 压入 instance 所属类 bbool be_isderived(bvm *vm, int index); // 检查继承关系栈顶为基类 bbool be_getmodule(bvm *vm, const char *k);从 be_api.c 的实现可以归纳出语义边界be_getmember对 instance、class、module 均有效ins_member中onlyinsbfalse并可能返回BE_NONEbe_getmethod的onlyinsbtrue只搜索 instance 的方法刻意排除 module避免把模块属性误当方法源码注释明确说明该限制be_setmember支持 instance / module / class 三种对象的成员写入注意被写值取自栈顶be_setglobal的实现很有趣它会先压入 key 再取-2索引的值写入全局表然后弹出 key——阅读源码时不要把先压 key误解为注册了额外全局be_isderived要求栈顶是被比较的基类索引处是待查实例/类沿be_class_super链向上遍历判断be_getsuper/be_setsuper用于在原生侧实现/读取继承链be_setsuper对被 GC 管理的常量类会拒绝修改gc_isconst检查。容器操作内部 list / map 值以下 API 操作的是内部原生容器值BE_LIST/BE_MAP而非脚本层的list/map类实例void be_newlist(bvm *vm); // 压入新 BE_LIST void be_newmap(bvm *vm); // 压入新 BE_MAP void be_newmodule(bvm *vm); void be_newobject(bvm *vm, const char *name); // 实例化内置类压入 instance 与 .p bbool be_getindex(bvm *vm, int index); // 压入 container[栈顶] bbool be_setindex(bvm *vm, int index); // container[栈顶-1] 栈顶 int be_data_size(bvm *vm, int index); // 元素个数非容器返回 -1 void be_data_push(bvm *vm, int index); // 把栈顶追加到 list bbool be_data_insert(bvm *vm, int index); // 插入 key(栈顶-1)/value(栈顶) bbool be_data_remove(bvm *vm, int index); // 按 key(栈顶) 删除 void be_data_resize(bvm *vm, int index); // 把 list 调整为栈顶长度 void be_data_reverse(bvm *vm, int index); bbool be_data_merge(bvm *vm, int index);be_getindex对 list 支持整数索引负数从尾部倒数对 map 以栈顶为 key 查找be_setindex同样支持 list 负索引写入。构建脚本层 list 实例的标准流程文档示例be_getbuiltin(vm, list); // 压入 list 类 be_newlist(vm); // 压入内部 list be_pushint(vm, 100); be_data_push(vm, -2); // 把 100 追加进内部 list be_pop(vm, 1); be_call(vm, 1); // 调用 list 构造函数把内部 list 包装为实例 be_pop(vm, 1); be_return(vm);即内置类 → 内部容器 → 填充数据 → 调用构造器包装 → 弹出中间值 → 返回实例。be_newobject是快捷方式等价于getbuiltin call(0) getmember(.p)用于实例化内置类并拿到其隐藏数据槽.p。迭代器内部容器bbool be_pushiter(bvm *vm, int index); int be_iter_next(bvm *vm, int index); // 0失败, 1list(压入值), 2map(压入 keyvalue) bbool be_iter_hasnext(bvm *vm, int index);be_iter_next的返回值同时携带是否成功与产出形式两种信息返回 1 表示压入了 list 的一个元素返回 2 表示压入了 map 的 key 与 value 各一先 key 后 value返回 0 表示迭代结束。标准迭代循环为be_pushiter初始化 →while (be_iter_hasnext(...))→be_iter_next取数据 → 处理 →be_pop清理 → 继续。原生闭包Upvaluebe_pushntvclosure(vm, func, nupvals); // 创建带 N 个 upvalue 槽的原生闭包 void be_getupval(bvm *vm, int index, int pos); bbool be_setupval(bvm *vm, int index, int pos);原生闭包允许 C 函数捕获脚本环境中的值先be_pushntvclosure压入闭包对象再用be_setupval(vm, index, pos)把栈顶值写入第pos个 upvalue 槽写入后栈顶被消费之后闭包每次被调用时可用be_getupval读取。这一机制常被用于在原生对象上挂回调状态或在事件驱动的 Tasmota 驱动里保存会话上下文而无需依赖全局变量。字符串操作void be_strconcat(bvm *vm, int index); // str[index] str[index] str[栈顶] const char* be_toescape(bvm *vm, int index, int mode); // 转义字符串be_strconcat要求两个操作数均为字符串内部be_assert检查把栈顶字符串拼接到index处的字符串上be_toescape按mode将字符串转义为可打印形式用于日志输出和错误信息。注意 Berry 字符串是短字符串驻留interned优化的be_strlen直接读取内部长度字段比 C 的strlen遍历更快——取脚本字符串长度请优先使用它。引用栈循环检测bbool be_refcontains(bvm *vm, int index); void be_refpush(bvm *vm, int index); void be_refpop(bvm *vm);引用栈用于实现对象图遍历中的循环引用检测遍历前be_refpush记录当前对象若be_refcontains返回真则说明该对象已在遍历路径上存在环应跳过以避免无限递归。典型场景是序列化/深度拷贝/GC 标记算法的原生实现如solidify、tostring递归格式化。调试钩子be_sethook(vm, lcr); // lline 行钩子, ccall 调用钩子, rreturn 返回钩子 be_setntvhook(vm, hook_fn, data, mask); // 原生钩子mask 取 BE_HOOK_LINE|BE_HOOK_CALL|BE_HOOK_RETbe_sethook接受字符串描述需要挂接的事件类型be_setntvhook则直接注册 C 回调函数与掩码。钩子会在字节码执行到对应事件时被触发可用于实现单步调试器、覆盖率统计、性能剖析或超时看门狗——例如在 Tasmota 中限制脚本单次执行时间的场景可以在行钩子里做 tick 计数。可观测性be_set_obs_hook(vm, hook); // 观测事件BE_OBS_PCALL_ERROR、BE_OBS_GC_START 等与调试钩子不同观测钩子面向VM 级事件如be_pcall发生错误、GC 启动/结束适合做长期运行环境下的监控、统计和告警例如统计 Tasmota 设备上脚本抛错频率或 GC 频率以评估内存健康度。关键类型定义typedef struct bvm bvm; // 虚拟机句柄不透明 typedef int (*bntvfunc)(bvm*); // 原生函数指针 typedef long long bint; // 整数类型可用 BE_INTGER_TYPE 宏配置 typedef double breal; // 浮点类型BE_USE_SINGLE_FLOAT 时退化为 float typedef struct { const char *name; bntvfunc function; } bnfuncinfo; // 方法表条目bnfuncinfo是名称 → 函数的静态映射表同时是be_regclass方法表、be_pushclass方法表的元素类型。bint/breal的宽度可通过编译宏调整如小内存 MCU 上用 32 位整数或单精度浮点扩展代码中统一使用bint/breal而非裸的long long/double以保证在 Tasmota 的 ESP32 构建配置下行为一致。编译期构造coc工具与const_object_info_begincoccompile object constructor工具扫描源文件注释中的const_object_info_begin代码块为常量对象类、模块、map 等生成 C 头文件从而在编译期固化对象避免运行时逐成员注册的开销与 ROM 浪费。块结构/* const_object_info_begin type name (attributes) { members } const_object_info_end */ #include ../generate/be_fixed_name.h块结束位置必须紧跟#include生成的be_fixed_name.h头文件生成物统一输出到 berry 的generate/目录可参考仓库中的 generate 目录结构。类型typeclass、module、map、vartab。属性attributes属性取值作用scopelocal/global控制对象是否进入全局表name任意标识符脚本侧可见的名称如mapdependBE_USE_*宏条件编译开关未启用对应宏时不生成stringsweak字符串成员以弱引用方式驻留成员值类型member value typesvar # 自动递增索引实例变量槽位 func(c_func) # 原生方法调用时 self 作为第 1 个参数 static_func(c_func) # 静态方法无 self带 BE_STATIC 标志 closure(solidified_fn) # 预编译字节码闭包 static_closure(fn) # 静态预编译字节码闭包 nil() int(N) real(N) bool(b) str(s) comptr(ptr) class(be_class_xxx) module(be_module_xxx) map(m) list(l) simple_instance(i) bytes_instance(b)类示例与内置map类的声明同构/* const_object_info_begin class be_class_map (scope: global, name: map) { .p, var init, func(m_init) tostring, func(m_tostring) size, func(m_size) } const_object_info_end */ #include ../generate/be_fixed_be_class_map.h其中.p声明隐藏实例数据槽var为自动递增索引func(m_*)绑定原生方法——这正是 Tasmota 中驱动类如gpio、energy、webserver等原生模块背后的类的声明范式。模块示例/* const_object_info_begin module math (scope: global, depend: BE_USE_MATH_MODULE) { sin, func(m_sin) pi, real(M_PI) } const_object_info_end */ #include ../generate/be_fixed_math.h注意depend: BE_USE_MATH_MODULE表明该模块只有在编译时启用对应BE_USE_*宏才会被生成——coc块的依赖控制是 Berry 保持固件体积可控的关键机制。内置表builtin table示例/* const_object_info_begin vartab m_builtin (scope: local) { assert, func(l_assert) print, func(l_print) list, class(be_class_list) } const_object_info_end */vartab用于声明全局内置函数的查找表scope: local表示该表本身不暴露为全局符号。构建命令在 berry 源码根目录执行tools/coc/coc -o generate src default -c default/berry_conf.h即输出目录为generate/扫描src与default两个目录下的源文件读取default/berry_conf.h中的BE_USE_*宏决定条件编译。对应到本仓库的实际路径为 tools/coc其中coc_parser.py负责解析const_object_info_begin块block_builder.py负责写出be_fixed_name.h、配置 default/berry_conf.h 与输出目录 generate。运行时模块注册非预编译路径若不希望走coc编译期构造Berry 也提供纯运行时的注册 API// 模块通过属性表声明 be_native_module_attr_table(mymod) { be_native_module_function(func1, m_func1), be_native_module_int(const1, 42), be_native_module_real(pi, 3.14159), be_native_module_str(name, hello), }; be_define_native_module(mymod, NULL); // 类通过 bnfuncinfo 方法表注册 static const bnfuncinfo members[] { { .p, NULL }, // 声明实例变量槽function 为 NULL { init, m_init }, { method, m_method }, { NULL, NULL } // 哨兵结束 }; be_regclass(vm, MyClass, members);模块属性宏完整清单定义于 berry.h宏脚本侧类型说明be_native_module_nil(_name)nil空值属性be_native_module_int(_name, _v)int整型常量be_native_module_real(_name, _v)real浮点常量be_native_module_bool(_name, _b)bool布尔常量be_native_module_function(_name, _f)函数原生函数be_native_module_str(_name, _s)string字符串常量be_native_module_module(_name, _m)module嵌套模块be_regclass的实现be_api.c在内部经由class_auto_make将bnfuncinfo表转换为类对象function非 NULL 的条目绑定为原生方法function为 NULL 的条目如.p绑定为实例成员变量见class_init中be_class_native_method_bind与be_class_member_bind的分支。方法表末尾必须以{NULL, NULL}哨兵结束。模块注册表接入固件构建运行时注册的模块/类必须加入注册表才会被 Berry 的import/ 全局查找发现。模块注册表位于 default/be_modtab.cbe_extern_native_module(mymod); // 然后把 be_native_module(mymod) 加入 be_module_table[] 数组类注册表be_class_table同理be_extern_native_class(my_class); // 然后把 be_native_class(my_class) 加入 be_class_table 数组从 be_modtab.c 可以看到 Berry 基础模块string、json、math、time、os、global、sys、debug、gc、solidify、introspect、strict、undefined与 Tasmota 扩展模块cb、re、mqtt、persist、autoconf、extension_manager、tapp、light、gpio、display、energy、webserver、httpserver、flash、path、unishox、crypto、BLE、MI32、lv、matter、animation等如何被组织在同一个注册表中——Tasmota 的众多USE_BERRY_*特性正是通过#ifdef条件性地be_extern_native_module并加入数组的。新增一个 Tasmota 原生模块的完整链路为编写模块实现含be_native_module_attr_table与be_define_native_module→ 在 be_modtab.c 中声明并加入be_module_table[]→ 在berry_conf.h中配置依赖宏如需→ 重新编译固件。命名规范仓库内原生代码遵循以下前缀约定在lib/libesp32/berry/src与 Tasmota 各xdrv_*/xsns_*驱动中可大量印证前缀用途l_*library/global 函数如l_print、l_assertm_*类方法如m_init、m_size、m_tostringbe_class_*类对象如be_class_map、be_class_listbe_native_module_*模块对象如be_native_module_math.p/.data隐藏实例数据成员约定俗成用于包裹内部原生类型.p或.data是 Tasmota 驱动中非常核心的约定它作为隐藏的实例数据槽存放 C 侧内部对象的指针/引用脚本侧用户不可见但原生方法可以通过be_getmember(vm, 1, .p)取回包裹的对象——从源码结构看be_newobject也是通过读取.p来返回包装数据的这一约定保证了脚本对象 ↔ C 对象的安全映射。结语本文以lib/libesp32/berry的源码实现为基准完整覆盖了 Berry C 扩展开发的全部核心 API从 VM 生命周期与虚拟栈到类型检查、值转换、压栈、原生函数/类/模块的两种注册路径coc编译期构造与运行时注册再到容器、迭代器、闭包、异常、调试钩子与命名规范。Tasmota 的mqtt、gpio、energy、crypto、BLE、lv等能力全部经由这套 FFI 暴露给 Berry 脚本理解本文即掌握了阅读和扩展这些驱动如 xdrv_52_3_berry_*.ino 系列的底层钥匙。实际开发中建议以类型检查先行、栈平衡为纲、be_return收尾为铁律并善用coc在编译期固化对象以节省 ESP32 宝贵的 RAM 与 Flash。【免费下载链接】TasmotaAlternative firmware for ESP8266 and ESP32 based devices with easy configuration using webUI, OTA updates, automation using timers or rules, expandability and entirely local control over MQTT, HTTP, Serial or KNX. Full documentation at项目地址: https://gitcode.com/GitHub_Trending/ta/Tasmota创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价