『7x24小时有问必答』

一、这玩意儿到底是干嘛的?

说白了,这是一个用纯C++实现的BERT模型家族CPU推理引擎。它能干的事情很明确:你丢给它一段文本,它在CPU上跑完BERT的完整前向传播,输出分类概率或者句子嵌入向量。整个过程不依赖PyTorch、不依赖CUDA、不依赖Python运行时,就一个二进制文件,双击就能跑。
它支持的模型家族包括:
BERT:原始双向编码器,12层768维,做分类或嵌入任务
DistilBERT:BERT的蒸馏版,6层768维,体积砍半、速度翻倍
RoBERTa:优化训练策略后的BERT变体,效果更稳
分类模型:在BERT基础上接个分类头,做情感分析、主题分类等
seq2seq模型:序列到序列(翻译、摘要等,开发中)
最狠的是量化支持。模型权重可以存成F32(32位全精度)、F16(16位半精度)、Q4_0/Q4_1(4位量化)。一个bert-base-uncased全精度要417MB,压到F16只要一半,4-bit量化后体积更小,推理速度还更快。
而且它是跨平台的。x86有AVX2/FMA加速,ARM有NEON加速,Apple Silicon也能跑。ggml底层自动检测你的CPU指令集,该用的SIMD优化一个不落。

二、为什么要搞这个?

现在做BERT推理,主流方案是调HuggingFace的Transformers库,背后用PyTorch。这套方案的问题是:
第一,太重。  PyTorch本身就要几百MB,再加上模型权重,部署到服务器或者边缘设备上,资源占用很大。
第二,依赖链条长。  Python环境、PyTorch、CUDA(如果用GPU)、各种pip包,部署时出一个问题就要排查半天。
第三,Python的GIL限制。  多线程并发时,Python的全局解释器锁(GIL)会让多核CPU的优势发挥不出来。
第四,启动慢。  Python解释器+PyTorch的初始化开销,在serverless或者短连接场景下很致命。
所以这个项目的思路很直接:把PyTorch训练好的模型权重导出来,用C++和ggml重新实现推理逻辑,直接跑在CPU上,零Python依赖。

三、核心设计思路

3.1 基于ggml:不重复造轮子

ggml是一个专门为端侧推理设计的张量计算库,和llama.cpp是同一个底层。它提供:
多种数据类型:F32、F16、Q4_0、Q4_1(4-bit量化)
自动SIMD优化:x86(AVX/AVX2/FMA)、ARM(NEON)、Apple Accelerate
多线程并行:矩阵乘法自动拆分到多个CPU核心
计算图(Computation Graph):描述前向传播的计算流程,自动调度执行
内存池管理:减少动态内存分配的开销
这个项目没有自己写矩阵乘法、Softmax、LayerNorm,而是直接调用ggml的API。这样可以把精力集中在模型结构的正确还原和tokenizer的实现上。

3.2 模型文件格式:自描述的二进制文件

模型从HuggingFace下载后,需要用一个Python脚本转换成ggml专用的二进制格式。这个格式的结构设计得很巧妙:
文件头(4 bytes)
魔数:0x67676d6c,就是"ggml"四个字符的ASCII码。C++加载时先读这个,对不上直接报错,防止加载错文件。
超参数区(40 bytes)
10个int32参数:词表大小、最大序列长度、中间层维度、注意力头数、隐藏层层数、pad_token_id、嵌入维度、标签数、beam数、量化类型
C++端根据这些参数动态分配内存、创建张量,不需要硬编码模型结构
词表区(变长)
对每个token:先写长度(4 bytes),再写token字符串(len bytes)
重复n_vocab次,构建token→id的映射字典
张量权重区(变长,重复N次)
每个张量的元数据:维度数(4B)、名字长度(4B)、数据类型(4B)、shape(4B×维度数)、名字(name_len B)
然后是实际数据:F32直接写float数组,F16写half数组,Q4_0/Q4_1按块压缩存储
这种自描述格式的好处是:C++端完全不需要知道模型结构,根据文件内容动态创建张量、建立名字到指针的映射。换一个新的BERT变体,只要转换脚本把权重名字映射对,C++代码不用改。

3.3 面向对象的模型结构

代码用C++的面向对象方式把BERT拆成了清晰的层次:
BertEmbedding(嵌入层)
word_embeddings:把token ID转成向量
position_embeddings:给每个位置加位置编码
token_type_embeddings:区分句子A和句子B(单句任务可省略)
LayerNorm:归一化 + 缩放 + 偏移
BertTransformer(编码器层)× 12每一层里面有两大部分:
自注意力(Self-Attention)
Q/K/V线性变换:输入分别过三个权重矩阵
reshape + permute:把768维拆成12个头×64维,调整维度顺序
scale:除以√d_head,防止点积值过大
KQ = K × Q:算注意力分数矩阵
Softmax:把分数转成概率
KQV = V × KQ:用注意力概率加权Value
reshape回来:合并12个头,恢复768维
输出投影 + 残差连接 + LayerNorm
前馈网络(Feed-Forward)
先扩维:768 → 3072(intermediate_size)
GELU激活
再压回:3072 → 768
残差连接 + LayerNorm
分类头(Classifier Head)
Pooler:取[CLS]token的输出,过一个线性层+Tanh
Classifier:768 → n_labels(比如2分类就是2维)
Softmax:输出概率分布

3.4 Tokenizer:WordPiece分词

文本不能直接喂给模型,要先切成token。这里用的是和HuggingFace完全兼容的WordPiece分词器,实现分三层:
BasicTokenizer(基础预处理)
可选的NFD归一化(去掉重音符号)
转小写
用utf8proc库做Unicode字符分类
按空格、标点、中文字符切分
WordpieceTokenizer(子词切分)
对每个单词,从长到短匹配词表
如果单词不在词表里,拆成子词,前面加##标记
比如"unhappiness" → "un", "#happiness"
FullTokenizer(完整流程)
先调BasicTokenizer做预处理
再调WordpieceTokenizer做子词切分
自动加[CLS](开头)和[SEP](结尾)
注意:tokenizer对CJK(中日韩)字符做了特殊处理,每个中文字符单独成一个token。这对中文任务很重要。

3.5 两种推理模式

模式一:命令行工具(bert-main)
加载模型 → 输入文本 → tokenize → 推理 → 打印分类概率
适合调试、测试、批量处理
模式二:REST服务(bert-rest)
基于oatpp框架提供HTTP API
客户端POST JSON请求 → 服务端推理 → 返回JSON响应
适合集成到Web应用、微服务架构

四、代码实现原理拆解

4.1 模型加载(bertencoder_load_from_file)

这是整个推理流程的第一步,也是最复杂的一步:
打开文件:以二进制模式读取ggml模型文件
验证魔数:读4字节,必须是0x67676d6c
读超参数:10个int32,打印出来供调试
读词表:循环n_vocab次,每次读长度+字符串,构建token_to_id和id_to_token映射
创建tokenizer:用读到的词表初始化FullTokenizer
确定权重类型:根据f16字段,选择GGML_TYPE_F32/F16/Q4_0/Q4_1
计算内存需求:遍历所有张量,按类型和尺寸计算总内存,打印出来(比如"ggml ctx size = 417.73 MB")
创建ggml上下文:申请一块大内存池,后续所有张量都在这个池里分配
创建张量:按模型结构创建每个权重张量,同时建立名字 → 张量指针的映射表(std::map)
读权重数据:循环读取每个张量的元数据和实际数据,校验shape和size,写入对应的ggml_tensor
计算推理内存:用4个token做一次"预热"推理,算出每个token需要多少内存,后续批量推理时据此分配缓冲区
加载完成后会打印:
model size = 417.65 MB / num tensors = 201

mem_per_token 0 KB, mem_per_input 0 MB

4.2 前向推理(eval流程)

推理的核心在forward函数里,用ggml的计算图来描述整个前向过程:
Embedding层
// token ID → 查word_embeddings表

inpL = ggml_get_rows(ctx0, word_embeddings, token_layer);

// 加位置编码

inpL = ggml_add(ctx0, ggml_get_rows(ctx0, position_embeddings, positions), inpL);

// 加token type(如果有)

inpL = ggml_add(ctx0, inpL, token_type_0);

// LayerNorm

inpL = ggml_norm(ctx0, inpL, norm_eps);

inpL = ggml_mul(ctx0, inpL, ln_e_w);

inpL = ggml_add(ctx0, inpL, ln_e_b);

Transformer层(循环12次)
// 1. Self-Attention

Qcur = ggml_add(ctx0, ggml_mul_mat(ctx0, q_w, cur), q_b);   // Q线性变换

Qcur = ggml_reshape_3d(ctx0, qLin, d_head, n_head, N);         // 拆成12个头

Qcur = ggml_scale(ctx0, Qcur,  1.0f/sqrt(d_head));                  // 缩放

Q = ggml_permute(ctx0, Qcur,  0,  2,  1,  3);                             // 调整维度

// K和V同理...

KQ = ggml_mul_mat(ctx0, K, Q);                                             // 注意力分数

KQ = ggml_soft_max(ctx0, KQ);                                               // Softmax

KQV = ggml_mul_mat(ctx0, V, KQ);                                          // 加权Value

KQV = ggml_permute(ctx0, KQV,  0,  2,  1,  3);                          // 恢复维度

cur = ggml_reshape_2d(ctx0, ggml_cont(ctx0, KQV), n_embd, N);  // 合并头

// 2. 残差 + LayerNorm

cur = ggml_add(ctx0, ggml_mul_mat(ctx0, o_w, cur), o_b);      // 输出投影

cur = ggml_add(ctx0, cur, inpL);                                          // 残差连接

cur = ggml_norm(ctx0, cur, norm_eps);

cur = ggml_add(ctx0, ggml_mul(ctx0, cur, ln_att_w), ln_att_b);

// 3. FeedForward

cur = ggml_add(ctx0, ggml_mul_mat(ctx0, ff_i_w, cur), ff_i_b);  // 扩维

cur = ggml_gelu(ctx0, cur);                                                        // GELU

cur = ggml_add(ctx0, ggml_mul_mat(ctx0, ff_o_w, cur), ff_o_b);  // 压维

cur = ggml_add(ctx0, att_output, cur);                                       // 残差

cur = ggml_norm(ctx0, cur, norm_eps);

cur = ggml_add(ctx0, ggml_mul(ctx0, cur, ln_out_w), ln_out_b);

分类头
// 取[CLS]位置(第一个token)的输出

cur = ggml_view_2d(ctx0, inpL, n_embd,  1, ...);

// Pooler

cur = ggml_add(ctx0, ggml_mul_mat(ctx0, pool_w, cur), pool_b);

cur = ggml_tanh(ctx0, cur);

// Classifier

cur = ggml_add(ctx0, ggml_mul_mat(ctx0, cls_w, cur), cls_b);

// Softmax

cur = ggml_soft_max(ctx0, cur);

执行计算图
ggml_build_forward_expand(&gf, output);

ggml_graph_compute_with_ctx(ctx0, &gf, n_threads);

ggml会自动把计算图拆成多个节点,按依赖关系调度,多线程并行执行。

4.3 批量推理

代码支持两种批量模式:
真批量(n_batch_size == n_inputs)
把所有输入的token矩阵拼在一起
一次前向传播算出所有结果
效率高,但要求所有输入长度相同(或者padding到相同长度)
伪批量(n_batch_size < n_inputs)
把输入分成若干组,每组n_batch_size个
每组分别调用推理
兼容不同长度的输入,但效率略低
目前实现的是伪批量,真批量还在开发中。

4.4 内存管理

模型内存:加载时一次性分配,ggml_context管理,模型生命周期内不释放
推理内存:每次推理时动态分配ggml_context,推理完立即释放(ggml_free)
初始缓冲区大小:512MB(MEM_SIZE = 512 * 1024 * 1024)
根据"预热"时算出的mem_per_token,动态调整实际使用的缓冲区大小
批量推理时,缓冲区大小 = mem_per_input × batch_size,上限1GB
最大batch:默认4(MAX_BATCH_N = 4),超出会分批处理

4.5 C API 设计

项目暴露了一套C风格的API,方便被其他语言调用:
// 加载模型

BertBaseCtx*  bertencoder_load_from_file(const  char* fname);

// 单条文本分类

void  bert_encode_classify(BertBaseCtx* ctx,  int32_t  n_threads,  

                                        const  char* text,  float* labels)
;

// 批量文本分类

void  bert_encode_batch_classify(BertBaseCtx* ctx,  int32_t  n_threads,

                                                 int32_t  n_batch_size,  int32_t  n_inputs,

                                                 const  char** texts,  float** labels)
;

// 释放模型

void  bert_free(BertBaseCtx* ctx);

这套API可以被Go、Python(ctypes)、Rust等语言直接调用,实现跨语言集成。

4.6 REST服务(基于oatpp)

REST服务的架构:
BertReq(请求DTO)
class  BertReq  :  public  oatpp::DTO {

      DTO_FIELD(String, input,  "input");   // 请求体里的"input"字段

};

ResultDto(响应DTO)
class  ResultDto  :  public  oatpp::DTO {

      DTO_FIELD(String, result);   // 分类概率数组的JSON字符串

};

BertClsController(控制器)
ENDPOINT("POST",  "classify", classify, BODY_DTO(Object<bertreq>, req)) {

       // 1. 从JSON请求体里提取input文本

       // 2. 调用bert_encode_classify做推理

       // 3. 把概率数组转成JSON字符串

       // 4. 包装成ResultDto返回

}

Server启动流程
// 1. 加载模型

// 2. 创建HTTP路由

// 3. 注册Controller

// 4. 创建连接处理器和连接提供者

// 5. 启动服务器,监听指定端口

oatpp::network::Server  server(connectionProvider, connectionHandler);

server.run();

五、DistilBERT支持

除了标准BERT,项目还完整支持了DistilBERT。DistilBERT是BERT的蒸馏版,结构上有几个关键区别:
没有token_type_embeddings
DistilBERT不支持句子对任务(比如NSP),所以去掉了token_type_embeddings
嵌入层只有word_embeddings + position_embeddings + LayerNorm
没有pooler
标准BERT在[CLS]token后接一个Pooler(线性层+Tanh)
DistilBERT直接去[CLS]的输出,或者取所有token的平均
有pre_classifier
DistilBERT的分类头结构是:[CLS] → pre_classifier(768→768,ReLU)→ classifier(768→n_labels)
比BERT多了一个中间层
代码里通过DistilBertEmbedding、DistilBertTransformer、DistilBertClassifierModel三个类完整实现了这些差异。加载函数是distilbert_load_from_file,和BERT的加载函数并行存在。

六、相关领域知识点速览

知识点
一句话解释
BERT
双向Transformer编码器,预训练+微调范式,NLP里程碑
DistilBERT
BERT的知识蒸馏版,6层替代12层,保留97%能力
RoBERTa
优化训练策略的BERT,去掉NSP任务,更大batch更长训练
Transformer编码器
自注意力+前馈网络,层层提炼语义表示
Self-Attention
每个token都"看看"其他所有token,根据相关性加权
Multi-Head Attention
12个头并行计算不同子空间的注意力,捕捉多角度关系
GELU激活函数
比ReLU更平滑的激活,Transformer标配
LayerNorm
对每层输出做归一化,稳定训练、加速收敛
残差连接(Residual)
绕过子层直接相加,缓解梯度消失,支持深层网络
WordPiece分词
把单词拆成子词单元,处理未登录词(OOV)
ggml
轻量级张量计算库,支持量化和多线程,llama.cpp同款
F16半精度
16位浮点数,体积减半,精度损失极小
Q4_0/Q4_1量化
4位整数量化,每64个权重共享缩放因子,体积砍到1/6
oatpp
现代C++ Web框架,支持REST API、JSON、路由
SIMD(AVX2/NEON)
单指令多数据,一条指令同时算多个数,大幅加速矩阵运算

七、这玩意儿能用来干啥?

文本分类服务:情感分析、垃圾邮件检测、新闻主题分类,部署成REST API供前端调用
语义搜索后端:把文档编码成向量,用户查询时做相似度匹配
NLP微服务:在Kubernetes集群里跑轻量级NLP推理Pod,资源占用远低于PyTorch
边缘设备部署:ARM设备、树莓派、嵌入式系统上跑BERT,做本地文本处理
多语言支持:基于多语言BERT(mBERT)做跨语言分类
知识蒸馏验证:验证蒸馏后的模型在C++端的推理效果是否和PyTorch一致

八、怎么跑起来?环境配置与运行测试手把手教

8.1 环境要求

编译环境:
CMake ≥ 3.12
C++20 编译器(GCC 9+ 或 Clang 10+)
Python 3(用于模型转换)
外部依赖库:
utf8proc:Unicode字符处理库,tokenizer需要
oatpp:C++ Web框架,编译REST服务需要
Python依赖:
pip3 install numpy transformers huggingface_hub

8.2 安装外部库

安装utf8proc(Ubuntu/Debian):
sudo apt-get install libutf8proc-dev

安装oatpp(需要手动编译):
git  clone  https://github.com/oatpp/oatpp.git

cd  oatpp

mkdir build &&  cd  build

cmake .. -DOATPP_BUILD_TESTS=OFF

make -j$(nproc)

sudo make install

设置oatpp环境变量(根据实际安装路径调整):
export  LIBRARY_PATH=/usr/local/lib/oatpp-1.3.0:$LIBRARY_PATH

export  LD_LIBRARY_PATH=/usr/local/lib/oatpp-1.3.0:$LD_LIBRARY_PATH

export  CPLUS_INCLUDE_PATH=/usr/local/include/oatpp-1.3.0/oatpp:$CPLUS_INCLUDE_PATH

8.3 拉代码和子模块

git  clone  <仓库地址>

cd  <项目目录>

git submodule update --init --recursive

ggml是子模块,必须初始化,否则编译不过。

8.4 下载和转换模型

方式一:直接下载预转换的模型
pip3 install -r requirements.txt

python3 models/download-ggml.py download bert-base-uncased f32

方式二:从HuggingFace转换自己的模型
cd  models

# 克隆一个模型(示例)

git  clone  https://huggingface.co/yilong2001/bert_cls_example

# 转换(生成F32和F16两个版本)

sh run_conversions.sh bert-base-uncased

转换脚本bert-classify-to-ggml.py会:
读取PyTorch的state_dict
把权重转成numpy数组
按ggml格式写入二进制文件
F16版本会把2D的weight矩阵转成float16

8.5 编译

编译动态库(给其他语言调用):
mkdir build

cd  build

cmake .. -DBUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release

make -j$(nproc)

cd  ..

编译静态库 + 示例程序(命令行工具 + REST服务):
mkdir build

cd  build

cmake .. -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release

make -j$(nproc)

cd  ..

CMake会自动检测CPU架构,开启AVX2/FMA等加速指令。支持的编译选项包括:
选项
说明
BERTS_AVX
启用AVX指令(默认ON)
BERTS_AVX2
启用AVX2指令(默认ON)
BERTS_FMA
启用FMA指令(默认ON)
BERTS_NATIVE
启用-march=native(针对本机CPU优化)
BERTS_LTO
启用链接时优化
BERTS_OPENBLAS
使用OpenBLAS加速
BERTS_STATIC
静态链接

8.6 运行命令行推理

./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f32.bin -p  "This movie is absolutely fantastic!"

输出示例:
bertencoder_load_from_file: loading model from  'models/bert-base-uncased/ggml-model-f32.bin'  - please  wait  ...

bertencoder_load_from_file: n_vocab = 30522

bertencoder_load_from_file: max_position_embeddings    = 512

bertencoder_load_from_file: intermediate_size   = 3072

bertencoder_load_from_file: num_attention_heads   = 12

bertencoder_load_from_file: num_hidden_layers   = 12

bertencoder_load_from_file: n_embd   = 768

bertencoder_load_from_file: f16       = 0

bertencoder_load_from_file: ggml ctx size = 417.73 MB

bertencoder_load_from_file: .........................  done

bertencoder_load_from_file: model size =    417.65 MB / num tensors = 201

main: number of tokens  in  prompt = 7

[0.0234, 0.9766]

main:       load time =    156.61 ms

main:        eval  time =      32.76 ms / 4.68 ms per token

main:      total time =    189.38 ms

结果解读:[0.0234, 0.9766]表示负面情感概率2.34%,正面情感概率97.66%。

8.7 启动REST服务

./build/bin/bert-rest -m models/bert-base-uncased/ggml-model-f32.bin --port 8090

输出:
bertencoder_load_from_file: loading model from  'models/bert-base-uncased/ggml-model-f32.bin'  - please  wait  ...

...

I |2023-11-05 00:05:29 1699113929846361| MyApp:Server running on port 8090

测试API:
curl -X POST http://localhost:8090/classify \

   -H  "Content-Type: application/json"  \

   -d  '{"input": "This movie is absolutely fantastic!"}'

响应:
{"result":  "[0.0234,0.9766]"}

8.8 批量推理测试

./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f32.bin \

   -t 6   # 使用6个线程

批量推理的C API:
const  char* texts[] = {

       "This is great!",

       "This is terrible.",

       "I don't know what to think."

};

float* labels[3];

float  label_data[3][2];   // 3条文本,每条2个分类概率

for  (int  i =  0; i <  3; i++) {

      labels = label_data;

}

bert_encode_batch_classify(ctx,  6,  2,  3, texts, labels);

// labels[0] = [0.02, 0.98]   // 正面

// labels[1] = [0.95, 0.05]   // 负面

// labels[2] = [0.45, 0.55]   // 中性

8.9 性能调优

选择量化格式:
# F32:最准,最慢,最大(~417MB)

./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f32.bin

# F16:几乎一样准,快一点,小一半(~209MB)

./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f16.bin

# Q4_0:体积砍到1/6,速度最快,精度略有损失(~70MB)

./build/bin/bert-main -m models/bert-base-uncased/ggml-model-q4_0.bin

调整线程数:
./build/bin/bert-main -m model.bin -t 8   # 用8个线程

启用OpenBLAS(如果安装了):
cmake .. -DBERTS_OPENBLAS=ON

8.10 自己写代码调用

C++调用:
#include  "bertbase.h"

#include  "bertencoder.h"

int  main()  {

       // 加载模型

       auto* ctx = bert::bertencoder_load_from_file("model.bin");

      

       // 单条推理

       float  labels[2];

      bert::bert_encode_classify(ctx,  4,  "Hello world", labels);

      

       printf("Prob: %.4f, %.4f\n", labels[0], labels[1]);

      

      bert::bert_free(ctx);

       return  0;

}

Go调用(通过CGO):
//  cgo  LDFLAGS: -L. -lbertencoder -lggml

//  include  "bertbase.h"

import  "C"

func  classify(text  string)  []float32  {

      ctx := C.bertencoder_load_from_file(C.CString("model.bin"))

      labels :=  make([]C.float,  2)

      C.bert_encode_classify(ctx,  4, C.CString(text), &labels[0])

       // ...

}

If you need the complete source code, please add the WeChat number (c17865354792)

九、总结

这个项目虽然代码量不大,但把"在CPU上跑BERT家族模型"这件事做得非常扎实:
基于ggml:复用成熟的张量计算和量化基础设施,自动SIMD优化,跨平台
自描述模型格式:从HuggingFace无缝转换,C++端零硬编码,换模型不用改代码
完整BERT实现:嵌入层、12层Transformer、分类头,每一层都用ggml计算图精确还原
DistilBERT支持:完整实现蒸馏版结构差异,体积减半速度翻倍
两种使用模式:命令行工具适合调试测试,REST服务适合生产部署
C API + 跨语言:可以被Go、Python、Rust等语言调用,集成到任意技术栈
量化支持:F32/F16/Q4_0/Q4_1四种格式,在精度和速度之间灵活取舍
如果你正在做一个需要BERT推理的项目,又不想拖着一个几百MB的PyTorch环境,这就是一个很好的参考方案。纯C++、零Python依赖、CPU就能跑、还能部署成REST服务——在很多场景下,这就足够了。
Welcome to follow WeChat official account【程序猿编码】

---

</bertreq>

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

上一主题上一主题         下一主题下一主题
QQ手机版小黑屋粤ICP备17165530号

关于我们·投诉举报· 用户帮助· 联系我们 · 本站服务 · 版权声明· 隐私政策 · 投搞指南

法律保护:PLC技术网,plcjs.com,plcjs.net等字样
Copyright 2010-2030. All rights reserved. 


微信公众号二维码 抖音二维码 百家号二维码 今日头条二维码哔哩哔哩二维码