CMake入门:从手写Makefile到现代C++构建基础设施
发布时间:2026/10/12 14:23:36 · 信平特种工考证网

这两年折腾C项目有个特别真实的感受写代码不一定难把构建环境搭明白才是真考验。很多同学写逻辑一把好手让他写个Makefile或者配置一套跨平台构建就直接卡住。Makefile语法太隐晦各种tab和变量展开能让人怀疑人生IDE的工程文件换个机器就废。所以现在主流开源项目基本都转向CMake。CMake的定位挺有意思它自己不是编译器也不是最终的构建工具而是负责生成构建系统的那一层。换句话说它用一套相对统一的cmake语法帮你描述“有哪些源文件、要生成什么目标、各目标之间什么关系”然后自动生成对应的Makefile、Ninja、Xcode工程或Visual Studio工程。这篇是CMake学习笔记的第一篇聚焦基础知识适合刚入门的同学也适合那些抄了不少CMakeLists.txt但遇到问题还是发懵的兄弟。1. 为什么值得花精力系统学CMake1.1 从手写Makefile到CMake痛点在哪儿先说一个很多人没意识到的点Makefile的学习曲线不是“陡”而是“奇怪”。它看起来像配置文件其实是一堆依赖关系和命令行的拼接。缩进必须用Tab变量引用用$(VAR)如果变量值是多个单词一不小心还会被拆成多个参数。我自己最早写Makefile经常因为多加了一个空格导致整条命令失效排查半天发现是格式问题特别挫败。跨平台更是大问题。在Linux上靠g编译到了Windows上可能变成cl.exe同一个工程Makefile里写的rm -f在Windows的cmd环境下直接报错。有些工程干脆为Linux、Windows、macOS各写一套构建脚本维护成本翻三倍不说新增一个源文件还得手动同步到每套脚本里。这种感觉就像你写一套业务逻辑却要为每个平台单独写一遍胶水代码纯属浪费精力。CMake解决的就是这个痛点。它把构建逻辑用一套cmake语法描述出来而不是直接绑定某个平台的具体命令。你在CMakeLists.txt里说“我要一个可执行文件源文件有这些它链接这个库”剩下的编译命令、编译器选择、平台差异交给CMake去处理。生成出来的Makefile、Ninja工程或者Visual Studio工程都是CMake根据当前环境自动生成的不需要手写平台相关命令。还有个好处是工程组织。一个大型项目通常有几十个源文件、好几个子模块手写Makefile要自己维护依赖关系改个头文件可能不会自动触发重编译。CMake通过target之间的依赖关系可以很好地表达“这个库依赖那个库”“这个可执行文件链接这两个静态库”修改之后构建系统会自动判断哪些需要重新编译。这就是我推荐大家系统学CMake的第一个理由它不是锦上添花而是现代C构建绕不开的基础设施。1.2 CMake在生产环境里到底扮演什么角色简单理解CMake是一个“元构建系统”。它不直接编译代码而是根据你的CMakeLists.txt生成对应当前环境的构建文件。你在一台Linux机器上运行cmake ..得到的是Makefile在Windows上用-G Visual Studio 17 2022得到的是.sln工程文件用-G Ninja又可以得到Ninja构建文件。真正执行编译的是make或者ninja但具体怎么编译、有哪些目标、先后顺序都是CMake定好的。用生活化类比来说CMake像个项目经理拿到需求文档CMakeLists.txt召集施工队编译器安排工人和材料源文件和依赖库最后帮你在工地搭好脚手架Makefile/Ninja。你作为开发只需要把需求讲清楚不用每次都手把手教工人在哪颗钉子钉哪里。这个设计带来的一个直接好处是跨平台切换的成本变得很低。比如你在一块Linux服务器上写好并测试通过的一套CMake配置拿到macOS上只需要重新运行cmake生成新的构建文件只要没有平台相关的硬编码路径代码几乎不需要改动。这也是很多开源项目现在只保留CMakeLists.txt不维护各平台专用工程文件的原因。所以学习CMake基础知识表面上是在学一套脚本语法实际上是学习如何用一套描述语言管理复杂构建逻辑。搞清楚add_executable、add_library、target_link_libraries这些命令之后你看任何开源项目的CMakeLists.txt都不会再觉得像天书反而能读出作者的工程组织思路。这也是后文所有内容展开的基础。2. 第一份CMakeLists.txt骨架其实很简单2.1 最小可构建工程只需三行核心命令拿一个最简单的例子开头。假设我有一个命令行小工具功能是统计文本文件里的单词数量源文件放在src/main.cpp和src/stat.cpp。第一版CMakeLists.txt长这样cmake_minimum_required(VERSION 3.16) project(word_stat LANGUAGES CXX) add_executable(word_stat src/main.cpp src/stat.cpp )就这三行已经能完成一个可执行程序的构建。命令流程很简单在工程根目录执行cmake -S . -B buildCMake会读取当前目录的CMakeLists.txt在build目录下生成构建文件然后再执行cmake --build build就能得到可执行文件。这里的每一行都值得解释清楚。cmake_minimum_required(VERSION 3.16)是在告诉CMake“我这份脚本需要3.16及以上版本才能正确解析。”这不是一句表面功夫。新版CMake会废弃一些旧语法比如add_compile_options的某些旧写法、旧式的INCLUDE_DIRECTORIES属性设置版本声明太旧本身不会报错但后续如果用到新特性可能会得到奇怪的警告甚至错误。我的建议是直接写3.16以上能覆盖绝大多数C工程的需求。project(word_stat LANGUAGES CXX)的作用是定义工程名并指定启用哪些语言。默认情况下如果只写project(word_stat)CMake会同时检测C和CXX编译器。虽然大多数机器上两个都有但很多时候我们只需要CXX显式声明LANGUAGES CXX的好处是节省一次编译器检测也让脚本意图更清晰。工程名word_stat同样被用来引用一些变量比如PROJECT_NAME、PROJECT_SOURCE_DIR。add_executable是创建可执行文件目标的命令第一个参数是目标名后面是所有源文件。写过一段时间CMake之后你会形成一个条件反射新增源文件时第一时间改这个列表。这也是新手最容易忽略的地方源文件加了CMakeLists.txt没同步编译半天都找不到符号。2.2 目标target才是CMake的核心抽象CMake最核心的抽象不是“目录”也不是“宏”而是“目标(target)”。一个目标可以是一个可执行文件、一个静态库、一个动态库或者一个只包含头文件的接口库。理解了这个词你对CMake的理解就超过很多人了。可执行文件对应的命令是add_executable()库对应的是add_library()。比如我想把单词统计逻辑抽成一个静态库方便以后写测试用例时复用可以这样add_library(stat_lib STATIC src/stat.cpp) add_executable(word_stat src/main.cpp) target_link_libraries(word_stat PRIVATE stat_lib)看到STATIC这个关键字了吗想生成动态库就改成SHARED。如果不写CMake会根据BUILD_SHARED_LIBS的默认值决定默认是静态库。这里涉及一个高频选择问题什么时候用静态库什么时候用动态库我的实践体会是工具类、算法类代码优先静态库核心原因是可以生成单一可执行文件部署时不用带一堆.so或.dll。如果是打算被多个外部程序共享的组件比如插件系统才需要用动态库。target_link_libraries(word_stat PRIVATE stat_lib)这一行的作用是把两个目标连接起来让word_stat链接stat_lib。注意到PRIVATE关键字了吗它表示这种链接关系只对word_stat可见不需要传递给依赖word_stat的其他目标。同理头文件目录也用target_include_directories(stat_lib PUBLIC ...)这种方式传递PUBLIC表示使用stat_lib的人也能看到这些头文件路径。这种“目标-属性-传递关系”的设计是现代CMake的核心也是老式CMake里大量使用include_directories、link_directories这类全局命令的替代品。单位项目如果用老式写法也能跑起来但随着工程变大全局可见的头文件搜索路径会让你无从排查“这个头文件到底从哪来的”这种问题。2.3 给工程加点“现代感”标准与编译选项C11之后编译器标准直接影响写法。CMake里设置标准有两种常见路子。一种是全局设置set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)另一种是只对某个目标设置target_compile_features(word_stat PRIVATE cxx_std_17)两种都能用我更推荐第二种。理由是范围更可控不会因为某个目标用老标准就被全局设置拖累。实际项目里可能同时有C11的老算法模块和C17的新应用层代码用全局设置只能迁就旧的用target_compile_features可以让每个目标各用各的标准。编译选项也有两种层级。全局的可以用add_compile_options(-Wall -Wextra)目标的可以用target_compile_options(word_stat PRIVATE -Wall -Wextra)。我踩过的坑是在Windows上用MSVC编译时-Wall不是不生效而是语义和GCC完全不一样MSVC的/W4才等价于GCC的-Wall -Wextra。所以跨平台工程里编译选项往往要用条件判断包一下或者用compiler_id检测。这些细节到后面“条件判断”部分再展开。到这里第一份CMakeLists.txt该有的东西基本齐了。你可能会觉得内容有点散别急接着往下看变量和控制流才是把CMake从“写死”变成“灵活”的关键。3. 变量系统CMake的记性与脾气3.1 普通变量、缓存变量、环境变量三种别混CMake里的变量有三种混在一起用会出很多莫名其妙的问题普通变量、缓存变量、环境变量。普通变量用set()定义只在当前作用域内有效函数结束后就消失这也符合大多数编程语言的习惯。比如set(MY_FLAG ON)缓存变量则不一样它会被写入CMakeCache.txt永久存在可以通过cmake -D参数在命令行设置。最典型的就是CMAKE_BUILD_TYPE你可以在命令行写cmake -DCMAKE_BUILD_TYPERelease ..如果CMakeLists.txt里没有设置它它会以缓存变量的形式存在之后每次构建都会记住。定义一个缓存变量的标准姿势是set(ENABLE_TEST ON CACHE BOOL Enable tests)这个BOOL是缓存变量的类型CMake根据它来生成GUI配置界面。除了BOOL还有STRING、FILEPATH、PATH等类型。日常用得最多的是BOOL和STRING。环境变量是用$ENV{...}访问的那种对应的设置方式是set(ENV{PATH} ...)。环境变量只在CMake脚本运行期间临时改变不影响系统本身也很少作为跨模块传参的手段。我记得有一次在脚本里用set(ENV{PKG_CONFIG_PATH} ...)给pkg-config指路结果发现同一行后面执行的外部程序能看到新值但换一个外部命令又要重新设置因为环境变量不会自动跨进程保留。所以能用普通变量或缓存变量解决的就别指望环境变量。把这三者区分开能帮你避免一个很头痛的现象明明改了CMakeLists.txt里的set(...)重新构建时却用的还是旧值。原因往往是缓存变量在CMakeCache.txt里已经有值而CMake的规则是普通变量的赋值不会覆盖已有的缓存变量除非在set()时加FORCE关键字。这个行为解释了很多“我改了怎么没生效”的困惑。3.2 常用内置变量清单CMake提供了大量内置变量第一次看的人会被数量吓到但其实常用的就那么几个我梳理了一张表变量名含义典型用途CMAKE_SOURCE_DIR顶层CMakeLists.txt所在目录引用工程根目录路径CMAKE_BINARY_DIR构建目录根路径识别生成物路径CMAKE_CURRENT_SOURCE_DIR当前正在处理的CMakeLists.txt所在目录子目录里定位自己文件CMAKE_CURRENT_BINARY_DIR当前正在处理的源码目录对应的构建目录子目录生成中间产物PROJECT_NAME最近的project()里定义的工程名日志、安装路径CMAKE_CXX_STANDARDC标准版本配合编译选项使用CMAKE_BUILD_TYPE构建类型Debug/Release/RelWithDebInfo区分优化等级CMAKE_CXX_COMPILERC编译器路径检查编译器打印诊断信息这里面最容易混的是CMAKE_SOURCE_DIR和CMAKE_CURRENT_SOURCE_DIR。如果是单层工程二者相同一旦用了add_subdirectory()子目录里的CMAKE_CURRENT_SOURCE_DIR就是子目录路径而CMAKE_SOURCE_DIR仍然是顶层路径。我自己的经验是在子目录的CMakeLists.txt里引用自己身边的文件一律用CMAKE_CURRENT_SOURCE_DIR否则把工程复制到别的位置时大概率会出问题。3.3 调试三板斧message、--trace、缓存页面学CMake基础一定要掌握调试手段否则写多行脚本之后出错根本无从下手。我的调试三板斧是第一message()。这是最有用的输出命令在CMakeLists.txt的任何位置都可以加一行message(STATUS Current source dir: ${CMAKE_CURRENT_SOURCE_DIR})配置阶段就能看到输出。我建议所有刚写好的逻辑都随手加几条message确认变量赋值、路径拼接是否符合预期比你盲猜几十次快得多。第二--trace和--trace-expand。运行cmake --trace ..可以打印CMake执行过的每一行命令能看到复杂的变量展开成实际值后的结果。脚本复杂到message都不够定位问题时这个命令非常救命。第三打开构建目录里的CMakeCache.txt。所有缓存变量的值都白纸黑字存在这里。如果怀疑某个变量被“神秘地”改掉直接翻这个文件。有一次我遇到一个诡异现象外部传入的库路径始终是旧值查了很久才发现是cache里的变量类型写的是PATHCMake会自动把路径做规范化处理导致我传入的相对路径被拼接成了源码目录下的绝对路径。这件事教会我一条准则路径类型缓存变量尽量传绝对路径别用相对路径去赌CMake的规范化行为。4. 条件与循环让脚本学会自我决策4.1 if条件的真假值规则新人最容易翻车CMake的if()语法看起来像普通编程语言实际规则有坑。最典型的是if(variable)判断的并不是“这个变量是否存在”而是“这个变量的值是什么”。如果变量未定义会被当成空字符串最终判断为假如果变量值为OFF、NO、FALSE、0这类假常量也判断为假值为ON、YES、TRUE、1则判断为真。所以问题就来了set(FLAG OFF) if(FLAG) message(This will not print) endif()这里if(FLAG)取的是变量FLAG的值OFF判断为假不打印。这个逻辑其实合理但如果你是从Python或Shell转过来容易直觉地把if(FLAG)理解为“FLAG非空即真”。OFF作为一个字符串并不是空但在CMake的if规则里它就是假。我刚开始学的时候在这里栽过跟头。更隐蔽的坑是“变量名冲突”。比如set(FOO BAR) set(BAR ON) if(FOO)这里if(FOO)会先把FOO展开成BAR然后CMake发现BAR也是一个已定义变量值又是ON于是判断为真。这就是CMake的“变量展开后再判断”行为不熟悉会产生一种“我明明设置的是字符串怎么变成了布尔”的错觉。解决办法是如果只想做字符串比较就写成if(${FOO} STREQUAL BAR)或者用if(DEFINED FOO)来判断变量是否定义。在比较场景里我常用的模式有几种。判断字符串相等if(${MY_OPTION} STREQUAL special)判断文件是否存在if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/config.ini)判断一个目标是否已定义if(TARGET stat_lib)这几个模式比裸的if(变量)要可靠得多。新手最好记住一条规则if()里放变量名时心里要清楚CMake在做的是“先展开变量值再根据值判断”而不是像Shell一样只检查返回值。4.2 foreach的三种实战用法foreach()是CMake里最常用的循环命令它有几种形式我平时用得最多的是三个场景。第一种遍历列表set(SOURCES src/main.cpp src/stat.cpp) foreach(src IN LISTS SOURCES) message(STATUS source: ${src}) endforeach()这里IN LISTS表示把SOURCES这个变量当成列表来遍历。注意不要和foreach(src ${SOURCES})混淆后者在某些旧写法里也能跑但语义上是在循环前先展开如果SOURCES里有分号或特殊字符容易出错。用IN LISTS是更稳的写法。第二种遍历数字范围foreach(i RANGE 1 10) message(STATUS iteration ${i}) endforeach()我常用它批量生成一组可执行文件或者批量创建目录能省去大量重复的message验证。第三种多个列表同步遍历用ZIP_LISTSset(NAMES a b c) set(PATHS /tmp/a /tmp/b /tmp/c) foreach(name path IN ZIP_LISTS NAMES PATHS) message(STATUS ${name} - ${path}) endforeach()在实际的工程配置里foreach最常见的用途是处理“一源多用”的场景。比如我需要用同一套核心代码分别编译debug版和release版工具或者在项目里生成多个带不同前缀的测试程序用循环比逐行写add_executable清晰得多。要注意的一点是循环体内如果要改变列表内容CMake的变量不是引用传递需要在循环体内重新set()才能保留修改这点和函数参数传递规则保持一致。5. 函数与宏封装重复逻辑的正确姿势5.1 function和macro最核心的差别是作用域写CMakeLists.txt多了之后总会出现同样的几行逻辑反复出现的情况比如“创建一个基于公共源文件的库”“判断某个特性并设置编译宏”。这时候就需要封装函数或宏。CMake里定义函数用function()...endfunction()定义宏用macro()...endmacro()。它们最大的区别是作用域和展开时机。函数有独立作用域内部set()出的普通变量默认不会影响调用者的同名变量。想修改调用者作用域里的变量需要显式set(... PARENT_SCOPE)。这和绝大多数编程语言很像。宏则没有独立作用域它的代码会在调用处直接展开像C语言里的#define宏一样。因此在宏里set()的变量会直接出现在调用者的作用域里。我举个具体例子function(foo) set(VAR_OUT changed in function) endfunction() set(VAR_OUT original) foo() message(STATUS ${VAR_OUT}) # 输出 original再看宏的版本macro(bar) set(VAR_OUT changed in macro) endmacro() set(VAR_OUT original) bar() message(STATUS ${VAR_OUT}) # 输出 changed in macro这个差异在实际项目中很影响决策。我倾向于凡是希望返回值给调用方的或者逻辑里需要创建局部临时变量的优先用函数凡是希望调用结束后让变量自动存活在调用者作用域里的或者对性能极度敏感的极端场景才用宏。日常95%的需求用函数就够了因为作用域隔离能避免不少变量污染。宏还有一个容易困扰初学者的点因为宏是原地展开宏内return()的行为和函数里完全不同。宏里的return()会直接跳出整个调用者作用域可能导致CMakeLists.txt后面的命令全部不执行。这个坑我只踩过一次之后凡是逻辑稍微复杂的我都用函数。5.2 参数解析用好ARGN和cmake_parse_arguments函数和宏的形参在定义时指定但CMake允许调用者传入超出形参数量的额外参数这些会存进ARGN变量而参数总数在ARGC里。直接用ARGN可以拿到所有未命名的额外参数function(print_all) message(STATUS argc${ARGC}, argv${ARGV}) foreach(arg IN LISTS ARGN) message(STATUS extra: ${arg}) endforeach() endfunction() print_all(a b c)这条输出会显示argc3同时ARGV里面有全部三个参数。但更标准的做法是用cmake_parse_arguments。这是官方推荐的参数解析工具能像Python的argparse一样区分选项参数、单值参数和多值参数。用法是function(create_tool) set(options ENABLE_DEBUG) set(oneValueArgs NAME) set(multiValueArgs SOURCES) cmake_parse_arguments(PARSE ${options} ${oneValueArgs} ${multiValueArgs} ${ARGN}) message(STATUS name${PARSE_NAME}) message(STATUS sources${PARSE_SOURCES}) message(STATUS debug${PARSE_ENABLE_DEBUG}) endfunction() create_tool( NAME word_stat SOURCES src/main.cpp src/stat.cpp ENABLE_DEBUG )这个命令会生成PARSE_前缀的变量选项传入则为真单值参数有值多值参数是列表。用这套写法函数签名能自己扩展后加参数不用改调用处对维护大型CMake脚本帮助很大。我还注意到一个习惯性问题很多人写函数不爱加注释半个月后回来看就忘了这个函数是干什么的。CMake脚本没有类型系统参数全是字符串命名和注释就格外重要。我的习惯是每个函数开头写三行注释分别说明作用、参数含义、返回值方式。6. 新手绕不开的坑与排查思路6.1 引号、分号与列表一个符号毁掉整个判断CMake里列表用分号分隔。set(SOURCES a.cpp b.cpp c.cpp)实际上等价于set(SOURCES a.cpp;b.cpp;c.cpp)。因为空格不是分隔符分号才是。这个设计容易在两种场景下坑人。第一种是向执行命令传参。假设我有一段脚本要把源文件列表传给外部Python命令set(SOURCES a.cpp b.cpp c.cpp) execute_process(COMMAND python handle.py ${SOURCES})这里${SOURCES}展开后是a.cpp;b.cpp;c.cppCMake的execute_process会把它当三个参数传符合预期。但如果不小心给${SOURCES}加了引号变成${SOURCES}展开后就成了一个参数“a.cpp;b.cpp;c.cpp”Python拿到手的是一个带分号的字符串逻辑直接出错。所以什么时候加引号什么时候不加引号在CMake里必须想清楚不能凭直觉。第二种是if判断。set(STR hello world) if(${STR} STREQUAL hello world)这个能正常工作因为加了引号后两边都是完整的字符串。但如果漏掉引号写成if(${STR} STREQUAL hello world)CMake会把hello world拆成两个独立参数语法解析就会出现偏差甚至报错。我的经验法则是需要把变量当成单一字符串时加引号需要把变量展开成多个参数传给命令时不加引号。这个法则是踩了不少坑才总结出来的分享给你。6.2 source目录和binary目录路径混乱是万恶之源很多CMake初学者容易把“源码目录”和“构建目录”混为一谈。源码目录是工程文件实际存放的位置构建目录是CMake生成中间文件和最终二进制的位置。为了避免污染源码目录我们才会习惯性地执行cmake -S . -B build把所有生成物隔离到build子目录。路径相关变量有很强的规律SOURCE结尾的是源码路径BINARY结尾的是构建路径。CMAKE_CURRENT_SOURCE_DIR和CMAKE_CURRENT_BINARY_DIR最容易理解当前CMakeLists.txt在哪它的源码目录和二进制目录就在哪。如果希望通过相对路径定位某个资源文件比如配置文件、图标、着色器代码应该基于CMAKE_CURRENT_SOURCE_DIR拼接绝对路径而不是假设运行时当前目录就是源码目录。尤其是通过IDE调试时程序的当前工作目录往往在构建目录直接访问相对路径会找不到文件。举个例子我写过一个图像处理Demo主程序需要读取assets/config.json。一开始我在代码里写fopen(assets/config.json, r)手动从源码根目录运行时没问题一换成IDE调试就失败。后来改成在CMake里定义PROJECT_SOURCE_DIR宏传给代码运行时用这个宏拼绝对路径问题才彻底解决。这类“文件找不到”的近半数问题根源都是路径基准选错了。6.3 常见错误速查表我整理了一份CMake配置期和构建期的常见错误速查表基本上照表排查能解决大部分问题错误现象常见原因处理思路CMake Error: The source directory does not appear to contain CMakeLists.txtcmake命令指向了源码目录错误的路径或者源码目录里确实没有CMakeLists.txt检查-S参数指向的位置确认文件存在Could not find a package configuration filefind_package找不到指定的包常见于没安装、路径没配置确认包安装位置通过CMAKE_PREFIX_PATH指定搜索路径No rule to make target xxx目标名写错或者引用了一个还未add的目标检查add_executable/add_library定义的目标名是否拼写一致修改CMakeLists.txt后构建仍用旧配置缓存没有更新直接删掉build目录重新配置或者跑cmake ..重新生成后再构建链接时的undefined reference库目标没链接进来或者链接顺序不对用target_link_libraries显式链接调整链接顺序CMake Warning: Manually-specified variables were not used-D传入的变量名在脚本中没有用到可能是拼写错误检查变量名或者确认该变量在脚本里确实被引用6.4 系统梳理后的经验总结我在系统梳理这些基础概念之后最大的一个感受是CMake并不难难的是它和普通编程语言的心智模型不同。它能自由操作变量、条件、循环看起来像脚本语言但变量作用域、缓存机制、引号展开又极具自己的特色。拿“缓存变量不覆盖”这件事举例第一次遇到会觉得是CMake不讲道理其实它背后有一个很合理的考虑命令行传参的优先级应当高于脚本里的默认值。理解了这个设计意图行为就变得合理了。学习基础知识阶段我最推荐的实践路径是拿一个真实的、只有一个可执行文件的小工程亲手把CMakeLists.txt从头写一遍然后在里面随意添加变量、加if判断、加函数封装再故意制造几个错误观察现象。这个过程比只看文档有效得多。我自己就是这么过来的每次在新环境重新搭建构建流程时回头再看这些基础都还有新收获。到这里CMake基础知识的第一篇笔记就结束了。下一篇我打算继续写第三方库引入、生成器表达式和安装打包这些更偏实战的部分如果你也在学CMake可以先从这里的基础开始动手试试。
拿不准这条消息跟你有没有关系?
工种不同、批次不同,要求可能差很多。打电话把你的情况说清楚,我们按信阳、平顶山本地的口径给你捋一遍。