经常在技术群里看到两类问题:一类人卡在Shell脚本的语法上,变量和符号搞不清楚;另一类人拿着CMakeLists.txt发懵,不知道那几行配置到底在干嘛。说实话,这两样东西放在一起学,效果反而最好。因为Shell是你和系统交互的“对话语言”,而CMake是很多C/C++项目实际使用的“构建大脑”,两者结合正好覆盖了从命令行操作到工程构建的完整链路。这篇文章我就把自己的使用经验整理出来,讲清楚Shell的基础语法、CMake的核心用法,再带大家写一个用Shell驱动CMake构建的完整脚本,适合刚接触Linux开发、或者从Windows转到嵌入式/C++方向的同学参考。
1. Shell语言基础:先把这些核心概念吃透
1.1 Shell到底是什么,值得花时间学吗
Shell直译过来是“壳”,它是用户和操作系统内核之间的一层解释器。你在终端里敲的每一条命令,都是经过Shell解释之后才交给内核去执行的。Linux和macOS上最常见的是Bash,Windows上如果你装了Git Bash或者WSL,同样能跑Shell脚本。
我个人的看法是:Shell是自动化一切重复劳动的起点。编译、打包、部署、批量处理文件,凡是你在终端里手动敲过的命令,都可以写成脚本让它自动执行。写十遍编译命令,不如写一次build.sh然后每次都敲一句./build.sh来得痛快。最早让我觉得Shell“真香”的场景是批量重命名日志文件,有一批文件叫log_20240101.txt、log_20240102.txt这种格式,想全部改成2024-01-01.log,手动改要改几十个,用Shell的for循环几行就搞定了。很多人第一次意识到Shell的威力,基本都是从这种批量操作的场景开始的。
1.2 变量、参数与特殊符号:${}和$()到底差在哪
这是Shell入门时最容易搞混的两个符号,技术群里隔三差五就有人问“linux shell ${}和$()区别是什么”,今天一次说清楚。
$()是命令替换,意思是在这个括号里先执行命令,把命令的输出结果当成一个值来用。比如:
bash复制today=$(date +%Y%m%d)
echo $today
这里先执行date命令拿到日期字符串,然后赋值给today变量。类似的需求还有获取当前目录、获取CPU核心数等等,核心思路都是“先把命令跑完,把结果拿来用”。
${}是变量扩展,告诉Shell“花括号里的名字是一个变量名”。简单场景下写$var和${var}没区别,但在变量名后面紧跟着其他字符时就必须用花括号了:
bash复制name="world"
echo "hello ${name}!"
echo "hello ${name}x"
第二行如果写成$namex,Shell会认为存在一个叫namex的变量,结果就是空字符串,而且不报错。把变量拼接到路径里时最容易踩这个坑,排查起来特别费劲。
再补充几个写脚本必须知道的位置参数:$0是脚本名,$1、$2是第一个、第二个参数,$#是参数个数,$@是所有参数。处理命令行输入时,这些都是基础中的基础。
1.3 shift命令:处理参数的隐藏利器
shift的作用是把位置参数往左移一位,原来的$2变成$1,$3变成$2。最常见的用法是配合while循环逐个解析命令行选项。
比如写一个安装脚本,支持-a表示安装所有组件、-p后面跟安装路径:
bash复制#!/bin/bash
install_all=false
install_path=""
while [ $# -gt 0 ]; do
case "$1" in
-a)
install_all=true
shift
;;
-p)
install_path=$2
shift 2
;;
*)
echo "用法: $0 [-a] [-p path]"
exit 1
;;
esac
done
echo "安装所有组件: $install_all"
echo "安装路径: $install_path"
这里用shift逐个“吃掉”参数,$2在shift之后自然变成$1,循环就可以继续往下走。这种写法比硬编码$1、$2、$3灵活得多,参数顺序随便调都能正确解析。
1.4 for循环:批量操作的核心骨架
for循环是我用得最多的Shell结构,基本语法有三种。第一种是遍历列表:
bash复制for i in 1 2 3 4 5; do
echo $i
done
第二种在脚本里更实用,遍历当前目录下的文件:
bash复制for file in *.txt; do
echo "处理文件: $file"
done
第三种是C语言风格:
bash复制for ((i=0; i<10; i++)); do
echo $i
done
遍历文件时有一个隐藏坑:如果目录里没有匹配的.txt文件,通配符*.txt会原样传进来,$file变成字面的*.txt字符串而不是报错。这种静默失败很折磨人,我习惯在循环里加判断if [ -f "$file" ]; then,只有文件真实存在才继续处理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CMake入门:构建系统的正确打开方式
2.1 为什么非要用CMake
CMake是一个跨平台的构建系统生成器,核心思想是:你写一份CMakeLists.txt描述工程结构、依赖关系和编译选项,CMake根据这份描述,自动生成当前平台对应的构建文件。Linux上默认生成Makefile,也可以生成Ninja文件;Windows上可以生成Visual Studio工程。程序员只需要维护这一份描述文件,到哪个平台就在哪个平台跑一下cmake。
有人可能会问:直接写Makefile不也行吗?行,但Makefile和平台绑定得很死,Linux下写的Makefile到Windows基本没法直接用,各种路径分隔符、编译器差异都够喝一壶。CMake屏蔽了这些差异,一份配置多处构建,这就是它存在的最大价值。
2.2 CMakeLists.txt核心写法
一个基本到不能再基本、可以直接编译的CMakeLists.txt长这样:
cmake复制cmake_minimum_required(VERSION 3.16)
project(hello_demo LANGUAGES C CXX)
add_executable(hello_demo main.c)
cmake_minimum_required指定最低CMake版本,project声明工程名和用到的语言,add_executable告诉CMake“我要编一个可执行文件,源文件是main.c”。三步走,一个最简单的工程就描述完了。
再补充两个高频命令。链接库用target_link_libraries,加头文件搜索路径用target_include_directories:
cmake复制target_link_libraries(hello_demo PRIVATE m)
target_include_directories(hello_demo PRIVATE include)
生成库文件也很简单:
cmake复制add_library(my_lib STATIC my_lib.c)
STATIC表示静态库,SHARED表示动态库。等工程变大、有多个子目录时,用add_subdirectory把子目录引进来,CMake会递归处理。ESP-IDF开发环境里那句常见的include($env{idf_path}/tools/cmake/project.cmake),本质就是通过include引入一个别人写好的CMake模块,思路和add_subdirectory差不多,都是在复用已有构建逻辑。
2.3 安装与版本:从下载到能跑起来
CMake在不同平台上的安装方式差别很大,这也是网上搜“cmake下载安装”特别多的原因。
Ubuntu下最简单的安装方式:
bash复制sudo apt update
sudo apt install cmake
但apt仓库里的CMake版本通常偏旧,遇到新版CMake特有的语法就会报错。想装较新版本,可以去CMake官网下载官方编译好的二进制包,解压后把bin目录加进PATH:
bash复制wget https://github.com/Kitware/CMake/releases/download/v3.31.0/cmake-3.31.0-linux-x86_64.tar.gz
tar -zxvf cmake-3.31.0-linux-x86_64.tar.gz
sudo mv cmake-3.31.0-linux-x86_64 /opt/cmake-3.31.0
export PATH=/opt/cmake-3.31.0/bin:$PATH
这里export只是临时生效,想永久生效要写进~/.bashrc。Windows下则去官网下载安装包,安装时务必勾选“Add CMake to the system PATH for all users”,这步非常关键,不然就会遇到“无法将cmake识别为cmdlet”的报错。装完验证版本:
bash复制cmake --version
3. Shell + CMake 实战:封装一个构建脚本
3.1 为什么要用Shell再包一层
直接用cmake命令也能构建,为什么还要包一层Shell脚本?因为真实项目的构建流程往往不是一条cmake命令能搞定的。你要创建build目录、配置、编译,可能还要打包、拷贝产物、清理旧文件,中间还有Debug和Release切换、交叉编译工具链切换等操作。把这些都写进一个脚本,你和同事只需要记住一个命令入口./build.sh debug,而不是一长串cmake参数。
更重要的是,脚本把构建流程固化了。新人来了不用问“编译命令是什么”,CI/CD系统也可以直接调这个脚本,减少人为失误。
3.2 完整脚本与逐行解析
我写一个通用性较强的build.sh,支持Debug和Release两种配置:
bash复制#!/bin/bash
# 用法: ./build.sh [debug|release]
BUILD_TYPE=${1:-Release}
BUILD_DIR="build"
if [ "$BUILD_TYPE" != "Debug" ] && [ "$BUILD_TYPE" != "Release" ]; then
echo "错误: BUILD_TYPE 只能是 Debug 或 Release"
exit 1
fi
if [ ! -d "$BUILD_DIR" ]; then
mkdir -p "$BUILD_DIR"
fi
cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE="$BUILD_TYPE"
cmake --build "$BUILD_DIR" -j"$(nproc)"
echo "构建完成,产物在 $BUILD_DIR 目录下"
逐行拆开说几个关键点。
BUILD_TYPE=${1:-Release}是参数默认值语法,第一个参数没传就用Release。这行写法的好处是缺省安全,不会因为没传参直接报错。
判断语句里[ "$BUILD_TYPE" != "Debug" ],变量一定要用双引号括起来。这是Shell脚本最经典的坑之一,不加引号,变量为空或者含空格时条件判断会直接裂开,报unary operator expected之类的错。
cmake -S . -B "$BUILD_DIR"是CMake官方推荐的目录写法,明确指定源目录和构建目录,比传统“先cd进build目录再跑cmake ..”清晰,而且不用手动切换目录。cmake --build "$BUILD_DIR"是跨生成器的统一编译命令,不管底层是Makefile还是Ninja,都能用这一条完成编译。
-j"$(nproc)"是并行编译参数,nproc返回CPU核心数,$(...)做命令替换,最终效果是让编译器用所有核心并行干活,编译速度肉眼可见地快。
3.3 扩展:多配置、交叉编译的脚本思路
上面这个脚本只能算“够用版本”。有交叉编译需求的同学,可以在脚本里加一个工具链文件参数:
bash复制cmake -S . -B build-arm -DCMAKE_TOOLCHAIN_FILE=toolchain-arm.cmake
需要Ninja生成器,配置时加-G Ninja,前提是系统里装了ninja-build。用Ninja最大的感受就是增量编译更快,特别是大工程,体验差距非常明显。
还有热词里提到的“如何将keil工程变成cmake”,这类需求我处理过几次。核心套路是:把Keil工程里的源文件列表、宏定义、Include路径从.uvprojx工程文件里逐个抽出来,整理成CMakeLists.txt,再配合编译器选项去模拟原编译环境。这个过程比较繁琐,因为Keil工程里的分组、条件编译配置往往藏得很深,但整理完之后收益很大:构建可以脚本化,可以集成进CI,不再依赖Windows上的Keil图形界面,一次配置到处编译。
4. 常见问题与排查技巧实录
4.1 Windows下“无法将cmake识别为cmdlet”
这个报错信息出现频率极高:在PowerShell里敲cmake,提示“无法将‘cmake’项识别为 cmdlet、函数、脚本文件或可运行程序的名称”。
问题本质是Path环境变量没配好。CMake安装包自带“Add CMake to system PATH”选项,安装时没勾选,或者装的是旧版本没有这个选项时,就需要手动配。具体操作是找到CMake安装目录,通常是C:\Program Files\CMake\bin,然后“此电脑”右键属性、高级系统设置、环境变量,在Path里新增那一行,保存后关掉当前PowerShell窗口,重新开一个新的,再敲cmake --version就正常了。
注意,一定要开新窗口。环境变量的修改不会自动刷新到已经打开的终端会话里,这个细节卡住了很多人:明明已经加了Path,当前窗口还是报错,其实就是没开新终端。
4.2 Shell脚本最常见的几个坑
热词里有一个很典型的报错:[no write since last change] /bin/sh: wq: command not found shell returned 1。这个报错经常发生在vim编辑器里想保存退出,但按键没按到位,导致vim把wq当成了Shell命令传给外部执行,Shell当然找不到这个命令。解决方式很简单:在vim里正确保存退出是Esc,然后输入:wq回车。如果不小心落到了Shell提示符,直接重新打开文件再操作就行。
除了这个,我每次给新人讲Shell都会强调这几个坑:
- 变量赋值等号两边不能有空格。
name = "test"会被解析成执行一个叫name的命令,然后报command not found,只有name="test"才是赋值。 - 条件判断的方括号两边必须有空格。
[$name = "x"]会报语法错误,写成[ "$name" = "x" ]才对。 - 管道命令的退出码。
cmd1 | cmd2整体退出码是cmd2的退出码,如果要把set -e和管道一起用,最好加set -o pipefail,否则管道前面失败了你都不知道。 - 变量加双引号是保命习惯。路径含空格的文件名,不加引号会在传参时被拆成多个参数,然后输出一堆莫名其妙的内容。
4.3 CMake版本相关的坑
CMake报“版本不满足”特别常见。一种情况是CMakeLists.txt里cmake_minimum_required写得很高,但系统只装了一个老版本,配置阶段直接拒绝执行。解决办法是升级CMake,或者改低cmake_minimum_required——前提是你没用到新版独有的命令。我见过有人图省事把版本号改得很低,结果后面用了新语法,浪费一下午排查,所以更推荐直接升级工具本身。
另一种情况是构建时找不到编译器。比如Windows上装了Linux交叉编译工具链,cmake配置时找不到对应的C编译器。解决办法通常是明确指定编译器路径,或者在CMakeLists里按平台做条件判断,用if(WIN32)、if(UNIX)来区分不同平台要链接的库和依赖,这样一份工程在不同平台上都能正确处理。
5. 一些个人的实操体会
这里说一些我做构建脚本和用CMake几年下来最想传达的经验。
第一个建议:Shell脚本从第一行就养成写#!/bin/bash并且set -e的习惯。set -e的意思是,只要脚本里任何一条命令返回非零退出码,整个脚本立即终止。很多时候脚本跑到一半出错,后面的命令还在继续执行,最后产出的东西是坏的,你又不知道该从哪查起。加上set -e之后,失败会立刻暴露出来,定位问题快很多。如果脚本里确实有“允许失败”的命令,可以在命令后面加|| true,让脚本知道“这次失败我可以接受”。
第二个建议:CMake的构建目录和源码目录一定要分离。不要在源码目录里就地构建,那样会把一堆临时文件、生成的Makefile撒得到处都是,想清理都不知道哪些是自己的代码。统一用build/目录,想清理编译产物就删掉整个build/文件夹,干净利落。
第三个建议:脚本里的错误提示信息尽量把“怎么用”写清楚,比如“用法: ./build.sh [debug|release]”,不要只是一个干巴巴的“参数错误”。你写的脚本不只是给自己用,同事和未来的你都会用,一个清晰的帮助信息能省掉很多沟通成本。
第四个建议:遇到问题先看版本。无论是Shell的语法问题还是CMake的报错,先确认版本是否匹配、路径是否配置好,四成问题都能在这两步里解决。cmake --version、bash --version这些命令几秒钟就能跑完,别嫌麻烦。
最后再分享一个小技巧:写完Shell脚本记得给它加执行权限chmod +x build.sh,不然每次都要用bash build.sh来跑。还有,脚本里的echo提示信息可以多用printf,它比echo更稳定,尤其要输出带变量和多行文本的时候,不会因为不同的Shell解释器实现差异踩到奇怪的坑。
