拓冰建站拓冰建站
首页 / 资讯中心 / 正文

ARM架构与交叉编译实战:从原理到部署的完整指南

今天是这个嵌入式系列笔记的第17天。前半个月还在x86的舒适圈里写C语言、折腾Linux命令今天开始要正式跟ARM架构和交叉编译短兵相接。如果你也做过嵌入式或者物联网大概率会遇到同一个场景你的开发机是x86的笔记本编译出来的程序跑到ARM板子上直接“Segmentation fault”你用apt装了个编译器交叉编译出来的东西放到板子上却提示“No such file or directory”甚至你把一个好好的.so文件从x86服务器拷到ARM环境里结果程序连加载都加载不了。这些坑我基本上都踩过一遍今天这篇就把ARM架构和交叉编译这条线彻底捋清楚。这篇文章适合谁刚入坑嵌入式开发的同学、需要在ARM服务器上部署应用的后端工程师、以及想把老项目从x86迁移到ARM平台的团队。我会从ARM架构的核心特点讲起再拆解交叉编译的原理最后用实际命令和工程案例把环境搭建、Qt交叉编译、动态库迁移这些环节全部过一遍。说句实话搞懂这几件事你就拿到了嵌入式开发和ARM平台迁移的入场券。1. ARM架构到底哪里不一样为什么它无处不在1.1 ARM的“精简指令集”到底精简了什么ARM全称是Advanced RISC Machine核心是RISCReduced Instruction Set Computer精简指令集架构。这个概念听起来抽象但理解起来其实很简单x86那种CISC复杂指令集架构指令又多又复杂一条指令能干很多活但代价是硬件设计复杂、功耗高ARM走的是另一个方向指令集短小精悍每条指令完成操作相对简单但是流水线效率高、硬件上省电、发热小。打个比方CISC像是一个全能型老师傅一个人能同时干水电、木工、砌墙ARM这类RISC更像一条标准化流水线每个工人只干一件特别熟练的事但整条产线非常高效。对于手机、路由器、智能家居这种电池供电、散热有限的场景ARM的“功耗换性能”思路几乎是唯一解。你可能觉得ARM性能不如桌面CPU但今天的ARM芯片在服务器领域已经能和x86正面竞争了AWS的ARM实例比如比较热门的Ampere C1-Ultra CPU就是例证——ARM早已不是“低端单片机”的代名词。1.2 ARM和x86在内核设计上的几个关键差异除了指令集风格不同ARM和x86在底层细节上差异也很大我挑几个会影响写代码和编译的差异点寄存器数量与命名x86由于历史兼容性包袱通用寄存器数量相对少而且很多指令有隐含寄存器ARM的通用寄存器更多ARM32有16个通用寄存器AArch64有31个编译器有更多的腾挪空间所以ARM的代码生成往往可以更优化。指令长度与对齐ARM32指令固定32位AArch64也是定长指令取指和解码简单x86指令是变长的最长的指令可以十几个字节解码器复杂度很高。这也是ARM能省电的重要原因。内存对齐要求ARM对非对齐访问的支持比较“挑剔”虽然现代ARM核心支持非对齐访问但性能会明显下降甚至在部分外设场景会直接报错。你从x86迁移代码到ARM时那些用#pragma pack(1)压出来的结构体二进制造型一定要重新检查。大小端x86和ARM默认都是小端little-endian但ARM体系结构本身支持运行在大端模式。如果你在嵌入式开发中用到了自定义协议或者直接解析网络字节序这一点要特别小心。另外现在大家常说的“ARM”其实是一个家族包括Cortex-A应用处理器、Cortex-R实时处理器、Cortex-M微控制器。不同系列的ARM指令集也有差异比如Cortex-M只支持Thumb/Thumb-2指令集不能直接跑为Cortex-A编译的程序。这直接决定了你用哪一套交叉编译工具链。1.3 ARM不是一颗CPU而是一套SoC骨架刚接触ARM时容易把它类比成一个“Intel CPU”那样的存在这是理解ARM生态最大的误区。ARM公司不直接生产芯片而是把微架构授权给芯片厂商像苹果、高通、三星、瑞萨这些厂商拿到授权后会围绕ARM核心打造一套完整的SoCSystem on Chip。也就是说一颗ARM芯片里不光有CPU核心还有GPU、内存控制器、中断控制器、各种外设控制器UART、SPI、I2C、USB、以太网MAC等。芯片内部通过AMBA总线AXI/AHB/APB把这些部件挂接到一起有些大SoC内部还会用到类似ARM Socrates这类工具来自动生成NIC-400互联总线。这一点和x86主板非常不同x86的系统架构是开放标准你可以自由搭配不同厂商的主板、芯片组和外设。ARM SoC则是一个相对封闭的系统外设地址、中断号、时钟树基本是芯片原厂定死的所以针对一块板子写的底层驱动换一块板子可能就要重写。这就解释了为什么嵌入式开发中BSPBoard Support Package和设备树的概念这么重要。你在PC上装Linux内核可以自动识别几乎所有硬件在ARM板子上跑Linux必须提前准备好匹配这块板子的设备树.dts和内核配置告诉内核“我有哪些外设、它们映射在哪些地址”。这也是ARM交叉编译比纯x86开发复杂的重要原因。1.4 ARM生态在服务器和中间件领域的存在感过去说“ARM版本”一般只指嵌入式平台但现在不一样了。随着云厂商推出ARM实例Redis、MySQL、Nginx等中间件都开始发布官方ARM版本很多服务器端应用也需要从x86迁移到ARM环境。我最近就看到不少团队在处理“.so从x86迁移ARM”的兼容性排查说明开发者已经不只是把ARM当单片机用了。同时像llama.cpp这类AI推理项目也专门针对ARM架构做了NEON指令优化在手机上甚至能跑得不错。ARM从嵌入式到云计算、AI边缘计算全面铺开之后作为开发者掌握架构差异和交叉编译能力就成了一种通用技能不再局限于嵌入式岗。2. 交叉编译的原理为什么非要“在电脑上编译到板子上运行”2.1 为什么不在目标板上直接编译也许你会问为什么不能直接在ARM板子上写代码、编译、调试理论上可以但现实中基本行不通原因有三个性能差距太大。树莓派算性能不错的开发板了编译一个Linux内核也要半小时以上如果是一块Cortex-M4或者低端Cortex-A板子内存可能只有几十到几百MB编译大一点的项目直接OOM。开发体验差。在板子上编辑代码、看日志、查文档都不方便开发和调试被严重割裂。工具链不好装。目标板上的OS可能很精简甚至没有完整的gcc和make工具链就算有包管理也可能不完善装依赖库能折腾一整天。所以业界的标准做法是在性能强劲的x86开发主机上安装针对ARM平台的交叉编译器编译出ARM架构的二进制文件再拷贝到目标板上运行。这个过程就叫交叉编译。2.2 交叉编译工具链的组成交叉编译工具链不是单独一个gcc命令那么简单它是一整套工具。以最常见的GNU工具链为例核心包括binutils包含汇编器as、链接器ld、归档工具ar、反汇编工具objdump等。编译的“编译-汇编-链接”三步里汇编和链接主要靠这一组工具。gcc负责把C/C源码编译成汇编代码。glibc或者其他C库C运行库再往上还有C标准库libstdc。gdb交叉调试器可选的但强烈建议装。内核头文件编译某些涉及系统调用的程序时需要目标平台Linux内核的头文件。平时你看到一个交叉工具链的名字比如arm-linux-gnueabihf-gcc其实包含了非常丰富的信息从左到右分别是目标架构arm、目标系统linux、C库gnu表示glibc、浮点ABIhf表示硬浮点hard-float。再有aarch64-linux-gnu-gcc就表示64位ARM的Linux工具链浮点采用的是通用GNU那一套。看懂这个命名你就知道该选哪个工具链了。2.3 工具链选型发行版自带还是厂商定制交叉编译工具链的来源很多不同来源有不同特点发行版自带工具链最省事Ubuntu上执行sudo apt install gcc-arm-linux-gnueabihf装出来的就是arm-linux-gnueabihf-gcc。优点是安装简单、方便入门缺点是工具链和C库版本可能和你的目标板不匹配编译出来的程序拷到板子上容易遇到glibc版本太新或太旧的问题。厂商官方工具链最靠谱如果板子用的是NXP、TI、瑞芯微、全志这些厂商的SoC他们一般会提供完整的交叉编译工具链和BSP包。这种工具链针对特定芯片优化过C库、内核头文件完全匹配强烈推荐量产项目使用。Buildroot/Yocto工具链最可控Buildroot这类工具可以一键生成完整的交叉工具链、根文件系统、内核镜像。虽然配置起来稍微复杂但生成的东西自洽性最好团队协作时能统一环境。我个人的建议是如果你只是学习或做demo用发行版工具链就够了如果做正式产品最好用厂商提供的工具链或者Buildroot整套方案否则后面调试库版本地狱时会想哭。3. 动手实践Ubuntu上交叉编译并运行一个Hello World3.1 安装arm-linux-gnueabihf工具链以Ubuntu 20.04或者24.04为例安装32位ARM的Linux交叉编译工具链sudo apt update sudo apt install gcc-arm-linux-gnueabihf binutils-arm-linux-gnueabihf libc6-dev-armhf-cross如果目标是64位ARM则安装sudo apt install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu libc6-dev-arm64-cross装好以后验证一下arm-linux-gnueabihf-gcc --version如果你看到正常的gcc版本信息说明工具链已经就绪。除了gcc还需要配套的g、ld、objdump等通常在binutils包已经带上了。3.2 编译Hello World并验证输出文件先写一个最简单的C程序#include stdio.h int main(void) { printf(Hello ARM, I am running on the target board!\n); return 0; }然后交叉编译arm-linux-gnueabihf-gcc -o hello_arm hello.c这一步生成了hello_arm文件。为了确认这个文件确实不是x86程序用file命令看一下file hello_arm输出类似hello_arm: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, BuildID[sha1]..., not stripped看到ARM、interpreter /lib/ld-linux-armhf.so.3这些关键字就说明它确实是ARM架构的动态链接可执行文件。如果你在x86主机上直接执行./hello_arm大概率会报“cannot execute binary file: Exec format error”这是正常的因为你还没放到目标板上。3.3 把程序部署到目标板运行的三种方式拿到hello_arm之后怎么把它弄到板子上根据板子的情况常用的有这三种scp拷贝适用于板子已经连上网络的情况。假设板子IP是192.168.1.100用户名是root执行scp hello_arm root192.168.1.100:/root/然后ssh到板子上运行。这是最常见的方式。adb push如果板子是Android系统或者支持ADB调试比如某些开发板带USB调试口可以执行adb push hello_arm /data/local/tmp/再adb shell执行。Android系统的/system分区通常是只读的所以优先推到/data或/sdcard。NFS挂载开发阶段最方便的方式。在Ubuntu主机上配置NFS服务把编译输出目录导出去板子上mount -t nfs主机IP:/共享目录 /mnt这样就可以直接在板子上运行主机上的文件省去反复拷贝的麻烦。我开发时最常用的是NFS配合一个简单的部署脚本每次编译完直接在板子上刷新运行效率高很多。3.4 动态链接器最隐蔽的坑之一如果你把hello_arm成功拷贝到板子上运行时报错hello_arm: not found但ls一下文件明明就在当前目录权限也是可执行的这就很迷惑。其实问题出在动态链接器上。file命令输出的那一行interpreter /lib/ld-linux-armhf.so.3表示程序启动时会先找这个动态链接器来加载依赖库。如果目标板上的动态链接器不在这个路径或者根本不存在系统就会把程序当作“找不到”从而报“not found”。怎么解决两个思路把工具链中的动态链接器拷贝到板子的对应路径。通常是/usr/arm-linux-gnueabihf/lib/ld-linux-armhf.so.3拷贝到板子的/lib下即可。改用静态链接编译。执行arm-linux-gnueabihf-gcc -static -o hello_arm hello.c这样编译出来的可执行文件不依赖动态链接器拷到板上直接跑。代价是体积会变大很多一个hello world可能到700KB甚至更大但对快速验证来说是省心的选择。在实际开发中我一般调试阶段用静态链接稳定后再考虑动态链接减小体积。4. 进阶实战Qt、Redis与依赖库的交叉编译4.1 Qt交叉编译环境搭建的核心思路热搜词里有不少人在问“Ubuntu 20.04安装Qt交叉编译环境”或“Qt5.12.10交叉编译”说明Qt在嵌入式GUI开发中确实常青。Qt交叉编译比普通C程序复杂很多因为Qt本身依赖不少第三方库如OpenSSL、fontconfig、libpng等而且qmake/qmake.conf里的配置项特别多容易出错。整体思路可以分四步第一步准备目标板的sysroot。sysroot可以理解为“目标板上的一整套头文件和库的根目录”。最简单的方式是把开发板上完整跑起来的根文件系统拷贝到主机上比如放在~/rootfs目录下。后续Qt编译时会从这个sysroot里找依赖的头文件和库。第二步准备好交叉工具链。确保arm-linux-gnueabihf-gcc等在PATH里并且能正常编译。Qt交叉编译跟普通程序最大的不同是它对工具链的版本、glibc版本非常敏感所以尽量用板子厂商提供的匹配工具链。第三步配置Qt的交叉编译平台文件。Qt源码包中提供了一批mkspecs平台配置模板路径在qt-everywhere-src-5.12.10/qtbase/mkspecs/。你要先复制一份linux-arm-gnueabi-g重命名成自己板子的名字比如linux-arm-linux-gnueabihf-g然后修改qmake.conf把交叉编译器改成arm-linux-gnueabihf-gcc和g并在QMAKE_CFLAGS和QMAKE_CXXFLAGS里加上所需目标板的架构选项比如-marcharmv7-a -mfloat-abihard -mfpuneon。第四步执行configure。比较常用的配置参数如下./configure -prefix /usr/local/qt5.12.10-arm \ -xplatform linux-arm-linux-gnueabihf-g \ -opensource -confirm-license \ -nomake examples -nomake tests \ -no-opengl \ -sql-sqlite \ -openssl-linked这里的-xplatform指定了我们修改过的平台文件-prefix表示最终要安装到的目录是否带OpenSSL取决于你的业务-openssl-linked表示Qt库本身去链接OpenSSL的ARM版本。configure过程可能会报“missing dependencies”那就需要回到第一步在sysroot里补充对应的开发包。configure通过后执行make -j4 make install然后把安装目录整个拷贝到板子的/usr/local/qt5.12.10-arm下再设置板子上的环境变量QT_ROOT和LD_LIBRARY_PATH。这样Qt应用就可以交叉编译和部署了。Qt这块我踩过最大的坑是sysroot里缺少了某些开发库头文件Qt configure明明提示“found”但真正编译某个模块时报找不到头文件排查了很久才发现是对应依赖库没有安装-dev包。给新手一个建议在sysroot里尽量把目标板上能装的库dev版本都装上交叉编译Qt会顺畅很多。4.2 Redis、MySQL等中间件的ARM版本编译部署很多后端应用需要在ARM服务器上跑Redis和MySQL。社区搜索里也有关键词“redis arm版本”和“mysql arm”这里单独讲一下。Redis的交叉编译其实非常简单因为它依赖非常少。假设你已经安装了arm交叉工具链执行wget https://download.redis.io/releases/redis-7.2.4.tar.gz tar xzf redis-7.2.4.tar.gz cd redis-7.2.4 make CCarm-linux-gnueabihf-gcc MALLOClibc这里指定使用libc的内存分配器是为了避免默认的jemalloc在交叉编译环境下缺少工具链配置导致编译失败。编译完成后redis-server和redis-cli等二进制就在src目录下直接拷到目标板的/usr/local/bin目录即可。MySQL则复杂得多。官方提供ARM版本的二进制包时优先用官方包或者发行版仓库安装比如Ubuntu的apt install mysql-server。如果你一定要交叉编译仅依赖项就够你折腾半天涉及cmake、boost、openssl、ncurses等。我的原则是对于大型项目不自己交叉编译能用官方ARM二进制就用官方的因为交叉编译大型软件的坑实在太多性价比太低。4.3 Buildroot一键生成交叉编译工具链和文件系统如果你要做的项目比较正式建议直接研究一下Buildroot。它的原理是先下载一份构建描述文件.config然后自动下载源码、编译工具链、库、应用最后生成一个可以直接烧录到开发板的镜象文件系统。Buildroot配置通过make menuconfig操作关键的选项有Target options - Target Architecture 选择ARM再选择具体的ARM核心Cortex-A7、Cortex-A53等。Toolchain选项里选择外部工具链或者使用Buildroot内置的构建工具链并且可以明确指定glibc/uclibc/musl版本。Target packages里勾选需要的运行时库和应用比如openssl、libpcap、redis甚至可以勾选Qt。配置完以后执行make等它跑完输出目录output/images下就有根文件系统镜像output/host/bin下就是这套环境自带的交叉编译工具链。用Buildroot生成工具链最大的好处是工具链、内核头文件、C库版本完全自洽不会出现板子上库版本和工具链不匹配的“灵异事件”。4.4 依赖第三方库的项目交叉编译通用套路回到实际项目很多程序都会依赖openssl这种第三方库。步骤通常是这样先用交叉工具链把openssl单独编译安装到sysroot再编译自己的程序。openssl交叉编译示例./Configure linux-armv4 \ --prefix/home/user/rootfs/usr/local/openssl \ --cross-compile-prefixarm-linux-gnueabihf- \ shared make -j4 make install编译自己的程序时通过CFLAGS和LDFLAGS告诉编译器去哪里找openssl的头文件和库arm-linux-gnueabihf-gcc \ -I/home/user/rootfs/usr/local/openssl/include \ main.c \ -L/home/user/rootfs/usr/local/openssl/lib \ -lssl -lcrypto \ -o myapp源码依赖不复杂的项目这套“先依赖后自身”的流程基本都能走通。5. 排查实录从“编译出来跑不起来”到一套顺畅流程5.1 file和readelf判断二进制架构的第一利器我每次拿到一个新编译出来的库或可执行文件第一件事就是file一下。file会告诉你这个文件是ARM还是x86、32位还是64位、被谁动态链接。遇到“.so从x86迁移ARM”的情况这个方法尤其有效因为.c文件可以在x86上编译成.so但那只能在x86环境运行不是编译出一个名字带.so的文件就能用在ARM上。想深入检查依赖库用readelfarm-linux-gnueabihf-readelf -d myapp.so可以看到NEEDED区域列出这个库依赖了哪些其他库比如libssl.so.1.1这种。如果目标板上缺少这些依赖库程序运行时就会报“cannot open shared object file”。5.2 “No such file or directory”不一定真的找不到文件前面提过动态链接器的问题再展开一下。有时候你file检查文件没问题拷到板子上执行却报“No such file or directory”这类情况下95%是动态链接器路径不对5%是库版本不匹配。解决办法是readelf -l hello_arm | grep interpreter看看解释器路径是什么再去板子上确认这个文件是否存在。不存在就拷贝过去或者用静态编译绕开。5.3 .so文件从x86迁移到ARM的三大坑我见过不少团队在x86服务器上编译了一堆.so直接以为放到ARM服务器上就能跑最后遇到各种奇葩报错。这里总结三大坑第一个坑是架构不匹配。x86的.so文件在ARM上根本不能识别必须用ARM交叉编译器重新编译源码。这看起来是常识但现实中总有人拿“改个后缀名”或者“直接解压替换”来踩坑。第二个坑是ABI不匹配。即使都是ARM架构硬浮点和软浮点ABI也不兼容。编译时必须和目标系统的float ABI一致否则链接时或运行时会报“wrong ELF class”或“unexpected PLT reloc type”。这类问题用file命令看“hard-float”或“soft-float”标识即可排查。第三个坑是依赖链。很多.so依赖其他第三方库交叉编译的时候不能只编译目标.so还要把它依赖的库一起按相同工具链编译并部署到目标板。我习惯用ldd或者readelf列全依赖树再逐一核对目标板上是否满足。5.4 Keil MDK中ARM Compiler版本缺失问题这个虽然和Linux交叉编译场景不太一样但热搜词里很多人搜“ARM Compiler 5.06 update 7”我也把它放在排查清单里。Keil MDK升级到5.37以后默认不再捆绑ARM Compiler 5AC5而是使用AC6基于Clang。老工程在AC6下经常编译不过比如内联汇编语法不兼容、优化行为变化。解决办法是在Keil的Pack Installer里找到“ARM Compiler 5.06 update 7 (build 960)”并安装然后在MDK的Options for Target - Target标签页里把ARM Compiler选为“Use default compiler version 5”而不是AC6。要注意AC5和AC6生成的工程文件在项目配置上有细微差别切换后最好Clean一下再重新Build。顺便说一句如果你拿到一个64位ARM处理器项目那么你可能还会看到ARM Compiler 6的版本这两个编译器的优化质量和C标准支持差异很大。新项目可以直接用AC6老项目为了稳定可以继续AC5。5.5 ARM仿真器引脚与FPGA的IO模式在做ARM底层调试时经常会看到仿真器引脚定义的问题比如JTAG就包括TCK、TMS、TDI、TDO、TRST、GND等SWD接口则包括SWDIO、SWCLK、GND和VCC。连线之前一定要核对板子上的丝印和仿真器文档反接轻则无法识别目标芯片重则可能烧掉调试口电路。此外常见的ARM仿真器如J-Link、ST-Link在不同模式下工作电压不一样1.8V和3.3V目标板不能混用要用跳线或转接板适配。这个问题引出一个相通的知识点就是GPIO推挽、开漏和上拉/下拉。在ARM开发中推挽输出Push-Pull是指引脚既能输出高电平也能输出低电平驱动能力强适合LED、蜂鸣器这类负载开漏输出Open-Drain只能主动拉低高电平要靠外部上拉电阻实现适合I2C这种需要“线与”的总线。FPGA的IO也有类似模式配置比如LVCMOS、开漏、上拉、下拉等理解了ARM的GPIO配置方式再看FPGA的IO约束文件就能很快上手。我用过一个项目案例用FPGA监听一块ARM SoC的启动信号调试的时候FPGA的IO配置成开漏输出忘了加上拉电阻结果电平一直被拉低导致ARM识别不到外部中断。后来加入上拉电阻问题立刻消失。这说明底层硬件配置和软件编译一样细节决定成败。5.6 常见错误速查表现象可能原因解决动作在x86主机上执行ARM程序报Exec format error架构不匹配程序是ARM格式把程序拷贝到目标板运行或者用qemu模拟执行板子上运行报not found但文件存在动态链接器路径不对或缺失readelf -l检查interpreter并将ld-linux拷贝到目标板或者静态编译链接时提示cannot find -lXXX缺少对应的交叉编译库或库路径未指定交叉编译该依赖库到sysroot用-L指定库路径运行时报cannot open shared object file缺少动态依赖库用readelf -d查看NEEDED并补齐依赖库运行时Prelink/library版本不匹配工具链glibc版本和目标板不一致使用厂商工具链或Buildroot统一版本硬浮点程序/软浮点程序混用浮点ABI不一致统一使用hard-float并在编译参数加-mfloat-abihardKeil编译报Missing: compiler version 5MDK没有安装AC5编译器在Pack Installer安装ARM Compiler 5.06 update7并切换编译器版本板子上程序启动慢怀疑“卡死”可能是statically linked导致体积过大或者耗时的初始化用time命令定位必要时改用动态链接版5.7 让交叉编译的流程形成肌肉记忆到了第17天这个节点我发现最重要的不是记住某个具体命令而是形成一套稳定、可复用的操作流程。我现在的标准流程是先在主机上交叉编译出ARM版可执行文件用file验证架构再通过NFS挂载或者scp部署到目标板运行时严格区分“编译错误”和“运行环境错误”编译错误看主机编译器输出运行错误先看file和readelf。只要遵循这套流程绝大多数问题都能在半小时内定位。一些额外的经验如果说前面这些都是能写进文档的操作步骤那最后这些算是我个人反复踩坑之后沉淀下来的习惯吧。我在实际开发中见过太多人把时间耗在“编译不过”上其实大部分原因出在环境不统一——主机上装了一套交叉编译器板子上是另一套C库sysroot也不齐全。我现在每接一个新项目第一件事永远是锁定工具链版本和目标板C库版本把它们写进README。还有一个小技巧在主机上建一个专门的交叉编译脚本目录把configure、make、sysroot路径、部署脚本全部固化下来新同事加入时直接跑脚本就能复现整个流程省去了无数次口口相传的“经验教学”。交叉编译这事入门门槛其实不高但真正的分水岭在于你遇到“not found”时是急着查翻译软件还是能直接想到动态链接器路径你迁移.so时是先看环境还是直接复制过去再说。把这些思维方法变成习惯之后后面再接触新的架构、新的工具链就会从容很多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门