资讯详情

资讯详情

Testbed单元测试配置、指针映射与MC/DC覆盖率避坑指南

简介《Testbed工具手册—单元测试方法V1.0》面向使用C/C LDRA Testbed开展白盒测试的开发与测试人员系统梳理单元测试的落地流程与操作要点。压缩包内为1个doc文档大小约2.01MB内容围绕Testbed V8.2.0展开从选择待测文件、配置头文件路径和添加宏到选择分析项、创建测试序列、全局变量赋值、输入输出变量创建、入参指针或数组映射及桩函数返回值设置再到运行测试用例、查看覆盖率、保存报告形成较完整的操作链路。文档还给出测试用例执行失败等常见问题的排查思路并说明覆盖率报告与回归报告的查看方法便于读者按目录快速定位配置细节和验证环节。已有6123人学习适合需要掌握Testbed单元测试流程、补齐白盒测试实操步骤的初中级测试工程师参考。1. 一份 15 页手册能省掉多少折腾Testbed 单元测试真正的门槛在配置覆盖率跑到 100%报告导出评审时被人一句「MC/DC 里 C2 这个条件根本没翻转过」问住——白盒测试里这种场面不算少见。Testbed 单元测试这套流程卡人的地方从来不是点按钮而是前置配置和变量映射头文件路径没配全分析阶段就还你一堆未声明标识符指针入参没做全局映射驱动一编译就崩全局变量在两处各赋了一遍值昨天全 PASS 的用例今天就集体变红。这份《Testbed 工具手册—单元测试方法 V1.0》基于 C/C LDRA Testbed V8.2.0把从选文件、配头文件与宏到建序列、映射指针、设桩、跑用例、读覆盖率与回归报告的整条链路按操作顺序固定下来适合已经被覆盖率指标追着跑、但工具还没用顺的嵌入式 C 开发和测试同学。2. 开测前的静态配置头文件路径与宏的两套写法2.1 为什么 Testbed 找不到头文件静态分析的检索模型Testbed 做单元测试第一步不是生成驱动而是对被测算件做静态解析。解析器读你的xxx.c遇到#include bsp_reg.h这类引用会去一份由你自己维护的搜索路径清单里逐条找。找不到的后果不是「报个警告」那么轻——它会连锁反应结构体定义缺失函数原型认不出来返回值被默认成int指针参数被当成未声明类型等到生成驱动时入参列表和实际函数签名对不上后面所有映射、赋值、桩函数设置全部白做。所以头文件路径这件事属于「一次配错整轮重来」的位置。我一般会在接手一个模块的第一天就把这件事做完并且把配置文件和代码放在同一个仓库里而不是塞在工具默认目录下——不然换台机器或者换个人接手就得靠记忆重新摸一遍。另一个容易忽略的点是宏。C 代码里#ifdef用得越多Testbed 静态解析时走的分支和你实际编译时走的分支越可能不是同一条。解析走了 A 分支你测的是 B 分支的代码覆盖率数字看着漂亮实际根本没测到交付的二进制。这类问题在项目里不算玄学纯粹是配置没对齐。2.2 用 Search Options 建 dat可视化配置路径先走一遍界面路径把流程走通后面再谈怎么把它工程化。第一步是选文件File / Select File选中待测的.c文件。注意这里选的是被测源文件不是头文件头文件是靠路径搜索进来的。第二步是配路径Configure / Static Options。如果是第一次给这个工程加头文件界面上的路径清单是空的需要先创建一个 dat 文件来存这份清单——点对应按钮新建存放目录按你自己的工程习惯放建议直接放在工程根目录的testbed_conf/下面名字取成被测模块名比如frame_assemble.dat。创建完之后点弹框中间的Search Options按钮进入路径配置页点路径输入框右边的…按钮选到头文件所在目录如果该目录下还有多层子目录勾选下方的Add Subdirectories as search paths让工具递归展开点Add search path路径进入列表点确定保存。这里的顺序不能反。先选目录再加进列表和先点 Add 再去填路径在某些版本上表现不一样前者更稳。另外Add Subdirectories as search paths这个勾子目录层级超过两层时几乎是必勾的——不勾的话形如#include driver/spi/spi_reg.h这种带相对路径的引用就找不到报错信息还只说「找不到 spi_reg.h」不告诉你它去哪找过排查起来很费时间。2.3 手写 dat 文件把配置纳入版本管理界面点多了就会发现dat 文件本质就是一份纯文本的路径清单工具按行读行首有一个有效位标记。手册里写得很清楚1表示该条语句有效0表示注释掉。既然是可读文本那就没必要每次都靠鼠标点。新建一个 dat 文件用任意文本编辑器打开内容大概长这样1 D:\proj\app\inc 1 D:\proj\app\inc\driver 1 D:\proj\bsp\include 1 D:\proj\third_party\cmsis\include 0 D:\proj\legacy\old_inc这几行的含义分别是前四条是参与搜索的有效路径第五条是历史遗留目录暂时用0注释掉等确认没有模块引用它之后再删。这样做的价值在于可追溯——改了什么、什么时候注释掉的、谁注释的都留在提交记录里比在界面里点来点去然后忘了自己加过什么要靠谱得多。参数上有几个细节值得记一下位置写法说明行首1或01有效0注释不要留空行首路径绝对路径相对路径在换工作目录后会失效分隔符反斜杠或正斜杠跟界面Edit里看到的样子保持一致最稳子目录逐条列出手写时不认递归写了父目录不等于包含子目录手写的好处是能进版本库、能 diff、能复制给同事。坏处是第一次写容易犯格式错误——把行首的1写成true或者路径两端加了引号工具都不会给你友好的报错只会安静地当成一条无效路径跳过。所以我的习惯是先在界面上用Search Options建一个能跑通的 dat再用Edit打开它照着那个格式往里加行。格式永远以工具自己生成的那份为准不要凭想象写。2.4 宏定义与条件编译那些「编译不过」的真相宏的配置入口和头文件路径挨着Macros→Find/Creat创建或打开一个 dat 文件 → 点Edit Defult进入编辑把需要开放的宏粘贴进去。这里最容易踩坑的地方是粘贴的格式。很多人直接把源码里的#define行整段复制进去结果分析阶段报一堆语法错误。正确的写法是只写宏名本身带值的写成NAMEVALUE形式和编译器命令行里的-D参数一个路子1 UNIT_TEST 1 BOARD_REV3 1 LOG_LEVEL0 0 USE_DMA_DOUBLE_BUFFER 0 ENABLE_LEGACY_PROTOCOLUNIT_TEST这条一般是必须加的工程里通常用它来切换掉底层硬件相关的实现或者把某些static函数暴露出来。BOARD_REV3这种带值的宏决定了条件编译走哪个硬件分支——这条配错你测的就是另一块板子的逻辑。USE_DMA_DOUBLE_BUFFER用0注释掉表示这一轮先不测双缓冲路径等主流程覆盖完了再打开补测。判断宏配得对不对有个快办法配好之后重新跑一次分析然后看分析结果里列出来的函数数量。如果某个你确定存在的分支函数没出现在函数列表里八成就是控制它的宏没生效。这时别急着怀疑工具先回去看宏文件里那一行是不是被0注释掉了或者宏名里多打了一个下划线。3. 从分析项到测试序列一次完整的用例搭建流程3.1 前四项分析项在算什么为什么不能跳过分析界面上有一排复选框做单元测试选前四项然后点Start Analysis。这四项对应的是静态信息提取、控制流分析、数据流分析和接口信息整理只有这些结果都产出了后面的测试序列界面才知道你这个函数有哪些入参、哪些全局变量、调用了哪些外部函数。如果跳过分析直接进单元测试界面手册给了个兜底点标记的那个UnitTest only按钮工具会默认把前四项补跑一遍。效果等价但少了分步失败时的定位信息——这一步挂了你不知道是路径没配好还是宏不对。所以第一次配置时我建议老老实实点Start Analysis等它跑完逐项看结果确认函数列表、调用关系都正常再点按钮进单元测试界面。一个实际的检查动作分析完成后翻一下函数清单确认你准备测的那个函数在里面并且它的形参列表跟源码一致。形参列表如果出现int、unsigned这种被「降级推断」的类型说明某个头文件没搜到结构体或 typedef 没解析出来。这种状态下生成的驱动参数类型就是错的后面映射做得再仔细也救不回来。3.2 建序列命名规范和首次配置的那几个勾进入单元测试界面后Sequence / New新建一个测试序列。序列名我建议直接用待测函数名比如frame_assemble。原因不是强迫症——回归报告和用例导出文件里都会带上序列名用函数名命名过两个月别人翻报告时能一眼定位不用先去查「seq_01 是哪个函数」。安装工具后第一次做单元测试弹框里会有若干选项需要勾选。手册这里只给了截图没给文字清单我的经验是按你实际编译工具链对齐编译器族、C 语言标准、是否启用内联展开、是否按 C 规则处理这几项必须跟工程一致其余的第一次可以先保持默认等跑通了再回头调整。点continue之后在界面右侧的函数列表里右键待测函数Create New Test Case弹窗里继续点continue直到测试用例生成。生成过程会创建驱动骨架把入参、全局变量、返回值、被调用函数都列出来这也是你后面所有操作的战场。3.3 全局变量赋值Input global 与 Code Inserts 只能二选一用例生成后界面右下角会自动列出需要输入和输出的变量。这里要分清两类输入Input parameter applied through local函数自己的入参只能在这里赋值或者做映射Input global函数里用到的全局变量它有两个赋值位置。两个位置是指右下角直接赋或者左下角右键用例 →Modify Existing Test Case / Code Inserts / Initialisation Code在弹框里写初始化代码。两者的区别很实在——右下角是键值形式一行一个变量适合简单标量Code Inserts 是代码片段适合需要调用函数、做循环初始化、或者按数组批量赋值的场景。关键约束在手册里写得明确如果右下角已经自动生成了某个Input global的赋值项而你又准备用 Code Inserts 来赋这个值必须先把右下角那一项删掉。不删的后果是两处赋值都生效谁覆盖谁取决于驱动生成顺序表现出来就是「代码里明明改了值跑出来还是旧值」。这类问题没有报错、没有提示纯靠经验属于典型的黑匣子式问题。提示同一个变量的赋值只保留一处要么右下角要么 Code Inserts别两边都留。3.4 IO 变量创建Use 和 Regression Analysis 的对应关系有些输入输出不在自动生成的列表里比如函数内部通过指针回写的中间结果需要手动创建。操作是在右下角空白处右键 →Create IO Variable In Test Case然后填四项字段填什么说明Name变量名必须和源码里出现的名字一致Type数据类型基础类型直接写结构体按声明写UseInput Global 或 Output Global决定它是喂进去还是读出来Regression AnalysisAssign 或 Compare Write输入选 Assign输出选 Compare WriteValue输入值或期望输出值输出变量这里写的是预期值Regression Analysis这一列的语义经常被搞混。Assign是「运行前把这个值赋给变量」Compare Write是「运行后把变量值和 Value 比较不一致就判失败」。输出变量如果选成了Assign那就变成运行前给输出变量赋初值跑完之后工具根本不校验结果用例永远 PASS覆盖率也是真的但断言是假的——这种假绿灯比报错危险得多。3.5 指针与数组映射Declaration Type 填基本类型的道理入参是指针或者数组时不能直接给值必须映射成一个同类型的全局实体。这一节用一段示例代码来说明比空讲清楚。typedef struct { unsigned char buf[16]; unsigned short len; } Frame; extern int bsp_read_reg(unsigned int addr, unsigned int *val); extern int bsp_write_reg(unsigned int addr, unsigned int val); int frame_assemble(const unsigned char *raw, unsigned short raw_len, Frame *out) { unsigned short i; if (raw 0 || out 0) { return -1; } if (raw_len sizeof(out-buf)) { return -2; } for (i 0; i raw_len; i) { out-buf[i] raw[i]; } out-len raw_len; return 0; }raw是const unsigned char *out是Frame *两个都得映射。方法一在指针变量的Value处右键选Point to global Entity数组用Assign to global弹框里Name随便起类型会自动带出来。如果映射后编译报「变量未声明」基本可以确定是 Testbed 不认识工程里自定义的 typedef——Frame这种类型在某些版本上解析不出来。这时在Declaration Type里填它的基本类型比如unsigned charArray Bounds填数组脚标大小这里是16Initial Value填初值。方法二菜单栏Global variables / Create User Global弹框和方法一一样但没有默认类型全部手动填填完之后回到指针的Value处写变量名数组写变量名[n]。两种方法出来的效果一样区别在于方法一省事、方法二可控。我一般在结构体指针上用法一先试报错就切法二手填。这里有个参数值得单独说Array Bounds填的是元素个数不是字节数。unsigned char buf[16]就是 16填成字节数在unsigned short数组上会直接越界工具不报错跑出来的数据是脏的。3.6 桩函数返回值只影响 Exit 不影响入参双击界面上标记的函数调用区域能看到被测函数调用的所有外部函数比如上面例子里的bsp_read_reg和bsp_write_reg。要对某个函数设桩右键选中它 →Set Return Value on Exit在弹框Value里填返回值。需要说清楚边界这里设的只是返回值的桩不改变被测代码传给它的入参也不阻止真实调用。也就是说如果你的函数依赖bsp_read_reg通过指针回写*val光设返回值没用*val还是未初始化的。这种带出参的桩要么在Initialisation Code里预先给*val指向的全局实体赋值前提是这个指针已经被映射成全局实体要么改用更完整的桩函数机制具体要看工具版本支持到什么程度。另外一条函数里调用了带返回值的库函数比如memcpy之外的strlen如果不想让它真跑也可以在同一个入口设返回值。但要注意设了桩的函数在覆盖率报告里会标成未覆盖——因为它的函数体没进去。这不是缺陷是桩的固有代价评审时提前说明就行。3.7 跑用例的两条路径与各自的判断依据输入、输出、桩都设好之后可以跑。手册给了两条路径路径一对第一个用例在空白处右键Run Test Case Driver或者点界面上的运行按钮或者按顺序点Generate Driver→Build Driver→Execute Driver→ProcessRegression Driver Output。最后这种分步点法麻烦但定位问题最有效——卡在哪一步问题就在哪一层Generate失败是配置和映射问题Build失败是驱动代码和头文件问题Execute失败是运行时崩溃或者死循环Process失败是结果文件没生成或者格式不对。路径二用例已经跑通一次、后面又设计了好几个可以整体重跑也可以右键单个用例点Resequence This Test Case单独跑或者Resequence This and Subsequent Test Cases从选中这条开始逐条单跑。单跑的价值在对比——预期输出和实际输出一条一条对着看哪条断言挂了立刻能定位到具体入参组合。有个使用上的建议调参数阶段用单跑收敛阶段用全量跑。单跑快反馈及时但单跑出来的覆盖率不是最终结果这点在下一章会讲清楚。4. 覆盖率报告怎么读语句、分支、MC/DC 三层4.1 跑完先别急着看数字Delete workfiles 的必要性用例通过之后右上角会显示函数当前的覆盖率。要展开看明细走Results / Dynamic Coverage Analysis Report里面有语句、分支、MC/DC 三层。问题在于工具默认是增量累计的。你在调试过程中反反复复跑用例中间有几轮可能是参数没设对、桩没生效、甚至驱动根本没编译成功这些失败运行留下的中间数据会混进覆盖率统计里。表现出来就是明明每个用例都 PASS覆盖率就是上不去或者上一次是 100%这一次重跑变成 92%。手册给的清除方式是Source / Delete LDRA Testbed workfiles弹框里选中对应选项后点 OK把工作文件清掉然后用路径一Run Test Case Driver那套完整流程全量重跑一遍。清完之后重新统计出来的数字才是可信的。注意清 workfiles 会丢掉之前所有运行的历史数据清之前先把覆盖率报告和回归报告导出一份。这一步我一般会在两个时机做一是提交正式报告前二是发现覆盖率数字和预期对不上时。养成习惯之后就不用再纠结「这个 92% 到底是真的没覆盖还是脏数据」。4.2 语句覆盖与分支覆盖的着色规则报告里点函数名可以跳到该函数在报告中的具体位置往下看三层内容。语句层每个可执行语句一行颜色表示是否被执行过。初次覆盖或者单跑模式下中间一列显示绿色右侧一列反映的是当前所有已执行用例的累计情况。这两列的区别来自运行方式——用完整流程跑中间列看的是所有执行过的用例的语句和分支情况用Resequence单跑中间列只反映当前或最后一个单独执行的用例。分支层在函数体下方单独列出来覆盖到的分支显示黑色没覆盖到的显示蓝色。蓝色那一行就是你下一轮要补的用例方向——看到某个if的 else 分支是蓝的就设计一个让它走 else 的入参。这里有个常见误读以为分支覆盖 100% 就等于逻辑全覆盖。并不。if (a b)这种复合条件分支覆盖只要求整体结果为真和为假各出现一次afalse和bfalse两种情况合并成一条路径其中一个条件可能从头到尾没被单独验证过。要补这一层得看 MC/DC。4.3 MC/DC 的 C1/C2/C3 表格怎么对上代码MC/DC 部分在分支执行情况下方。报告里会先列出实际代码的行号和判断条件原文然后把条件里的每个子表达式用代号区分C1、C2、C3再在下面的表格里引用这些代号。表格结构是三块左边是代号和它们之间的逻辑关系中间一列和右边一列是各种真值组合下是否被覆盖显示yes表示已经覆盖no表示还没覆盖。读法很直接找no那一行看它对应的条件是哪个代号取真、哪个取假然后倒推什么入参能让这个条件成立。比如if (a 0 b 0)表格里可能出现「C1 真 C2 假」这一行是no那就得设计一个a 0且b ! 0的用例。MC/DC 要求每个条件都能独立影响判断结果所以在写用例之前先看这张表比先写完一堆用例再回头补要省很多时间。我的习惯是函数分析完就把 MC/DC 表格截图存下来当成用例设计清单用——每写一条用例勾掉一行no。语句、分支、MC/DC 三层的对应关系整理成一张表层级报告位置判据补用例的方向语句函数体逐行着色每行是否被执行找出从未进入的代码行分支函数体下方独立区域黑色已覆盖、蓝色未覆盖构造走 else / 默认分支的入参MC/DC分支区域下方表格yes 已覆盖、no 未覆盖让每个子条件单独翻转一次4.4 回归报告Status、Fails、Suspends 三列的分工Results / Regression Report是另一份要看的报告结构和覆盖率报告完全不同。开头是测试人员在序列里写的描述内容。往下是逐条用例的执行结果重点看三列Status用例执行结果正常情况下应该全是PASS。如果出现无法执行的状态说明驱动层面有问题先回去查映射和桩别急着改断言。Fails失败率。没问题的单元这一列必须是 0。不为 0 就说明有断言不成立要提问题单并追溯到具体用例不是改预期值让它变绿。Suspends输出全局变量里Value值为Suspend的占比。这个值不为 0 通常意味着某些输出变量没设预期值或者自动生成时带出来的默认Suspend没处理掉。再往下是每条用例的详细信息用例描述、输入输出值、桩函数设置。这一节在评审时最有说服力——别人不用打开工具看报告就知道每条用例喂了什么、期望什么、桩成什么样。一个实操细节自动生成时如果某个输出变量的Value显示Suspend而它其实不是你需要校验的量直接右键Remove移除别留着。留着就会计入Suspends统计把一份本来干净的报告弄出一个不好解释的数字。5. 避坑清单单元测试里最容易翻车的五个点5.1 头文件路径加了分析还是报未声明现象Static Options里明明加了头文件目录分析结果里还是提示某个结构体未声明函数形参类型被推断成int。原因有两个常见来源。一是目录下有多层子目录Add Subdirectories as search paths没勾工具只搜了顶层二是手写 dat 时行首标记写错或者路径拼错一个字符工具安静跳过不报错。解决先用Edit打开 dat 文件逐行核对行首是不是1路径是否真实存在。确认无误后在界面上重新勾一次Add Subdirectories as search paths把子目录逐条加进列表。加完重跑分析看函数列表里的形参类型是否恢复正常。恢复不了就把 dat 里这一条注释成0手动加一条完整子目录路径试一次用二分法定位是哪一级目录没被搜到。5.2 用例全 PASS覆盖率却卡在 60% 多现象回归报告里每条用例都是PASS、Fails为 0但覆盖率报告里语句覆盖只有六十几个百分点反复重跑数字不动。原因workfiles 里残留了之前失败运行的数据或者之前用Resequence单跑过统计口径变成了「最后一个用例」而不是「全部用例」。解决Source / Delete LDRA Testbed workfiles清掉工作文件然后不要用单跑走完整流程Run Test Case Driver全量重跑。重跑后再看覆盖率数字一般会明显变化。如果还是上不去那就不是脏数据是真的有用例没覆盖到按分支层的蓝色行去补。5.3 指针映射后编译报变量未声明现象按方法一把指针Point to global Entity映射完生成驱动时报某个变量未声明或者类型不匹配。原因被测代码里用了工程自定义的 typedef结构体、联合体、函数指针类型Testbed 静态解析时没识别出来映射时自动生成的类型是空的或者错的。解决回到映射弹框在Declaration Type里填这个指针指向的基本类型比如unsigned char或者unsigned shortArray Bounds填元素个数Initial Value填初值。填完重新生成驱动。这个做法牺牲了类型检查的严格性但能保证驱动编译通过是这类场景下的常规取舍。5.4 全局变量改了值跑出来还是旧值现象在Code Inserts / Initialisation Code里给某个全局变量赋了新值跑用例后断言还是按旧值走。原因右下角自动生成的Input global赋值项没删两处赋值同时生效实际生效的是哪一个取决于驱动生成顺序。解决先看右下角有没有同名变量有就右键Remove删掉只保留Code Inserts里的那一处。反过来也一样——如果决定用右下角赋值就把Code Inserts里对应的那行注释掉。原则是同一个变量只留一个赋值入口别图省事两处都写。5.5 回归报告 Suspends 一列不为 0现象Status全PASS但Suspends列显示非零百分比评审时被问到答不上来。原因输出类变量在自动生成时Value被设成了Suspend后续没有赋预期值也没有移除工具把它统计成「未判定」。解决逐个检查输出变量需要校验的用Compare Write赋上预期值不需要校验的直接右键Remove。改完重跑Suspends应该回到 0。如果移除后某个断言反而不成立了说明那个变量其实是被测逻辑的一部分得补回预期值而不是删掉。6. 把用例变成可复用资产导出、复核和一个自检习惯6.1 三类产物分开存别只存报告一轮单元测试跑完要落盘的东西有三类位置都在菜单里但很多人只导了覆盖率报告就走了下次回归时用例全丢得从头再点一遍。产物导出路径用途测试用例文件Sequence / Export Test Cases (TCF)→save as下次回归直接导入不用重建用例覆盖率报告Results / Dynamic Coverage Analysis Report→save as提交评审、对比不同版本回归报告Results / Regression Report→save as证明每条用例的输入输出与桩设置TCF 是最容易被忽略的一份。它是用例的序列化文件包含序列结构、输入输出赋值、桩函数设置。代码改了一版之后导入 TCF 重跑一遍就能看出哪些用例因为接口变更失效了——比重新点一遍用例快得多也比凭记忆重建可靠。命名上我习惯用「模块名_函数名_版本号_日期」三类产物同名不同后缀放在同一个目录下。这样回归时一眼能看出这轮测的是哪个版本不至于翻出三份报告对不上号。6.2 覆盖率 100% 之后再做一次反向验证覆盖率全绿不等于测对了。我见过不止一次这样的情况断言写反了、预期值填成了实际值、输出变量选成了Assign结果所有用例PASS覆盖率还特别好看。所以数字达标之后我会做一次反向验证随便挑一条用例把它的预期输出值故意改错一个重跑。如果这条用例变成FAIL说明断言链路是通的如果它依然PASS那这条用例的校验根本没生效得回去查Regression Analysis那一列是不是选错了。这个动作花不了两分钟但能挡住一整类假绿灯。尤其是接手别人做过的用例集时这一步几乎是必做的——你没法从报告里看出上一个人填的预期值是真算出来的还是复制粘贴的。6.3 从那次报告被退回说起早些年交过一份覆盖率 100% 的单元测试报告被退回来理由是 MC/DC 表格里有两个no没消掉而语句和分支都是满的。当时我以为覆盖率看总数就行没意识到三层是分开统计的。从那以后我每次跑完用例都强制走一遍固定顺序先清 workfiles再全量跑然后按语句、分支、MC/DC 从上往下逐层看最后做一次故意改错预期值的反向验证。这套动作加起来不到十分钟但省掉的返工时间远不止十分钟。这份 15 页的手册本身不长价值在于它把每个操作的入口位置固定下来了配合上面那套 dat 配置模板和自检顺序基本可以做到换个人接手也能复现同一套结果。如果你手上正好有一个 C 模块要交白盒覆盖率把手册和你的testbed_conf目录放在一起边配边对照着走一遍比只看截图要快。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →