从函数调用到进程替换:使用 execl、CMake 与 PowerShell 构建一个多可执行程序
本文从一个具体问题出发:同一个项目中存在两套名称相同、实现不同的
main()、use()和and(),如何根据输入条件选择其中一套执行?文章将依次说明普通函数调用、编译链接、execl()进程替换、CMake 多目标构建,以及 PowerShell 调用运算符&的原理。
一、问题背景
假设项目中有两套 C 程序。
第一套位于根目录:
main.c -> use.c -> and.c
第二套位于to目录:
to/main.c -> to/use.c -> to/and.c
两套程序中的函数名完全相同,但函数内部可以执行不同逻辑。项目还需要一个入口程序in.c:读取整数c,当c > 3时执行根目录程序,否则执行to目录中的程序。
最终目录如下:
move/ ├── CMakeLists.txt ├── build.sh ├── in.c ├── main.c ├── use.c ├── and.c └── to/ ├── main.c ├── use.c └── and.c
这个项目可以称为:
多可执行程序 C 项目;
CMake 多目标项目;
进程启动器或进程调度器示例。
理解它的关键,是先区分函数、可执行程序和进程。
二、函数、可执行程序与进程并不是一回事
1. 函数
函数是程序内部的一段代码。调用use()时,CPU 跳转到use()对应的指令,执行结束后再返回调用位置。
根目录程序的调用链为:
main() -> use() -> and()
这三个函数始终运行在同一个进程内。函数调用不会启动一个新程序,也不会创建新的独立地址空间。
2. 可执行程序
C 源文件不能直接由操作系统执行。它们需要经过预处理、编译、汇编和链接,最后形成可执行文件:
.c 源文件 -> 预处理 -> 编译 -> 汇编 -> .o 目标文件 -> 链接 -> .exe 可执行文件
根目录的三个源文件可以生成一个程序:
gcc main.c use.c and.c -o main.exe
to目录中的三个源文件生成另一个程序:
gcc to/main.c to/use.c to/and.c -o to/main.exe
3. 进程
进程是可执行程序的一次运行实例。磁盘上的main.exe是文件;运行后的main.exe才是进程。
普通函数调用发生在一个进程内部,而execl()操作的是整个进程:它使用另一个可执行程序替换当前进程正在运行的程序。
三、为什么两套同名函数不会冲突
根目录和to目录中都定义了main()、use()和and()。这些名称能够共存,是因为两套源码分别链接成两个可执行程序。
main.c + use.c + and.c | +----> main.exe to/main.c + to/use.c + to/and.c | +----> to/main.exe
每个可执行程序都有自己的符号表。根目录main.exe中的use()与to/main.exe中的use()不属于同一个链接单元,因此不会冲突。
如果把六个源文件一次性链接:
gcc main.c use.c and.c to/main.c to/use.c to/and.c -o wrong.exe
链接器会发现多个main、use和and定义,并报告重复符号错误。这里需要的不是重命名函数,而是建立两个独立的构建目标。
四、使用execl()选择目标程序
1.execl()的参数结构
execl()的基本形式为:
execl(program_path, argv0, arg1, arg2, ..., NULL);
参数含义如下:
| 参数 | 作用 |
|---|---|
program_path | 要执行的可执行文件路径 |
argv0 | 目标程序收到的argv[0],通常填写程序名 |
arg1、arg2 | 传给目标程序的命令行参数 |
NULL | 表示可变参数列表结束,不能省略 |
execl()执行的是编译后的程序,而不是.c源文件。因此不能写成:
execl("./main.c", "main.c", (char *)NULL); /* 错误思路 */必须先把源码构建为main.exe,再执行它。
2. 启动程序的实现
本项目的in.c如下:
#include <stdio.h> #include <process.h> int main(void) { int c; printf("input: "); fflush(stdout); if (scanf("%d", &c) != 1) { fputs("invalid input\n", stderr); return 1; } if (c > 3) execl("./main.exe", "main.exe", "root-argument", (char *)NULL); else execl("./to/main.exe", "main.exe", "to-argument-1", "to-argument-2", (char *)NULL); perror("execl"); return 1; }程序首先读取c。输入无法转换为整数时,scanf()返回值不是1,程序输出错误并结束。
当c > 3时执行:
execl("./main.exe", "main.exe", "root-argument", (char *)NULL);根目录程序收到:
argc = 2 argv[0] = "main.exe" argv[1] = "root-argument"
当c <= 3时执行:
execl("./to/main.exe", "main.exe", "to-argument-1", "to-argument-2", (char *)NULL);to/main.exe收到:
argc = 3 argv[0] = "main.exe" argv[1] = "to-argument-1" argv[2] = "to-argument-2"
3.execl()成功后为什么不返回
execl()不是普通函数调用。调用成功后,当前进程的程序代码、数据和调用栈会被目标程序替换。
in.exe 读取 c | +-- c > 3 ----> main.exe | +-- c <= 3 ----> to/main.exe
因此,调用成功时不会继续执行in.c中execl()后面的代码。只有调用失败时,程序才会执行:
perror("execl"); return 1;perror()会根据系统保存的错误码输出原因,例如目标文件不存在或路径错误。
五、两个main()如何接收不同参数
两个程序都使用标准入口形式:
int main(int argc, char *argv[])
这里不需要发明两种不同的main()函数签名。真正不同的是execl()传入的参数数量和内容。
根目录main.c接收一个业务参数:
#include <stdio.h> void use(void); int main(int argc, char *argv[]) { if (argc != 2) return 1; puts("move/main.c"); printf("argument: %s\n", argv[1]); use(); return 0; }to/main.c接收两个业务参数:
#include <stdio.h> void use(void); int main(int argc, char *argv[]) { if (argc != 3) return 1; puts("move/to/main.c"); printf("arguments: %s, %s\n", argv[1], argv[2]); use(); return 0; }argc包含argv[0],所以一个业务参数对应argc == 2,两个业务参数对应argc == 3。
六、用 CMake 管理三个可执行程序
手动执行三条 GCC 命令可以完成构建,但随着文件数量增加,命令会变得难以维护。CMake 的作用是描述“哪些源文件属于哪个目标”,再生成底层构建工具需要的文件。
本项目的CMakeLists.txt如下:
cmake_minimum_required(VERSION 3.15) project(move_example C) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) add_executable(move_main main.c use.c and.c) set_target_properties(move_main PROPERTIES OUTPUT_NAME main RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" ) add_executable(to_main to/main.c to/use.c to/and.c) set_target_properties(to_main PROPERTIES OUTPUT_NAME main RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/to" ) add_executable(in in.c) set_target_properties(in PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" )1. CMake 目标名与输出文件名
下面两条语句定义了两个不同的 CMake 目标:
add_executable(move_main main.c use.c and.c) add_executable(to_main to/main.c to/use.c to/and.c)
move_main和to_main是 CMake 内部使用的逻辑目标名,必须不同。通过OUTPUT_NAME main,它们最终都可以输出为main.exe。
两个文件不会相互覆盖,因为输出目录不同:
move_main -> build/main.exe to_main -> build/to/main.exe in -> build/in.exe
这个输出结构与in.c中的路径严格对应:
"./main.exe" "./to/main.exe"
2. CMake 的两个阶段
CMake 通常分为“配置与生成”和“构建”两个阶段。
配置与生成:
cmake -S . -B build -G "MinGW Makefiles"
-S .:源码目录是当前目录;-B build:生成文件和构建产物放在build;-G "MinGW Makefiles":使用 MinGW Makefiles 生成器。
执行构建:
cmake --build build
CMake 本身通常不直接编译 C 源码。它生成 Makefile,再由mingw32-make根据 Makefile 调用 GCC 和链接器。
七、用build.sh自动完成构建
当前build.sh将配置、构建和运行组合起来:
#!/usr/bin/env sh set -eu root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) cmake -S "$root" -B "$root/build" -G "MinGW Makefiles" cmake --build "$root/build" cd "$root/build" ./in.exe
set -e表示某条命令失败后立即停止,避免构建失败后仍然尝试运行旧程序。set -u表示读取未定义变量时立即报错。
下面这行取得脚本所在目录的绝对路径:
root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
脚本最后切换到build目录再运行in.exe。这一步非常重要,因为execl()使用的是相对路径。程序查找./main.exe时,依据的是当前工作目录,而不是in.exe文件自身所在目录。
八、为什么不能使用gcc build.sh
以下命令是错误的:
gcc build.sh
GCC 用于处理 C 源文件、汇编文件、目标文件和库。build.sh是 Shell 脚本,需要由 Shell 解释器逐行执行。
把脚本交给 GCC 后,链接器会尝试把文本内容当作目标文件或链接脚本,因此出现:
file format not recognized syntax error
正确思路是:
bash 解释 build.sh -> build.sh 调用 CMake -> CMake 调用构建工具 -> 构建工具调用 GCC
九、PowerShell 为什么需要&
在 PowerShell 中,带引号的路径只是一个字符串:
"C:\Program Files\Git\bin\bash.exe"
引号解决了路径中包含空格的问题,但字符串不会自动变成命令。PowerShell 的调用运算符&用于把后面的字符串或变量内容作为命令执行:
& "C:\Program Files\Git\bin\bash.exe" ./build.sh
PowerShell 会把它解析为:
可执行程序:C:\Program Files\Git\bin\bash.exe 参数: ./build.sh
如果不使用引号:
C:\Program Files\Git\bin\bash.exe ./build.sh
PowerShell 会在空格处截断路径,并尝试执行C:\Program,所以出现“无法将C:\Program识别为命令”的错误。
如果只使用引号而不使用&,PowerShell 得到的仍然只是一个字符串。两者的职责不同:
引号保证含空格的路径被视为一个整体;
&明确要求 PowerShell 执行这个路径指向的程序。
调用运算符也可以执行变量保存的程序路径:
$bash = "C:\Program Files\Git\bin\bash.exe" & $bash ./build.sh
需要注意,PowerShell 开头的&表示“调用”。Linux Shell 中放在命令末尾的&表示“后台运行”。二者只是符号相同,语义并不相同。
十、Git Bash、WSL 和 PowerShell 的环境差异
Windows 上可能同时存在多个名字相近的 Bash,但它们不是同一个环境。
1. Git Bash
Git Bash 是 Git for Windows 附带的类 Unix 命令环境。应使用公开入口:
& "C:\Program Files\Git\bin\bash.exe" ./build.sh
不建议直接调用:
& "C:\Program Files\Git\usr\bin\bash.exe" ./build.sh
usr/bin/bash.exe是内部组件。绕过正常入口可能导致环境没有正确初始化,随后出现:
dirname: command not found
2. WSL
下面的路径通常会启动 Windows Subsystem for Linux(WSL):
C:\Users\30126\AppData\Local\Microsoft\WindowsApps\bash.exe
WSL 中运行的是 Linux 版 CMake。Linux 版 CMake 不提供 Windows 专用的MinGW Makefiles生成器,因此会报告:
CMake Error: Could not create named generator MinGW Makefiles
当前in.c还使用了 MinGW/Windows 的<process.h>和.exe文件名,所以当前版本应在 Windows PowerShell 或 Git Bash 环境构建,不应直接放到 WSL 原生构建。
3. PowerShell
PowerShell 可以直接执行 Windows 版 CMake,无须经过 Bash:
cmake -S . -B build -G "MinGW Makefiles" cmake --build build Set-Location build .\in.exe
build.sh的价值是把这些步骤自动化;它不是完成 CMake 构建的唯一入口。
十一、准备构建环境
在 PowerShell 中检查工具:
Get-Command cmake Get-Command gcc Get-Command mingw32-make Test-Path "C:\Program Files\Git\bin\bash.exe"
当前构建方式需要:
Windows 版 CMake;
MinGW GCC;
mingw32-make;Git Bash,仅在执行
build.sh时需要。
如果系统没有 CMake,可以使用 Windows Package Manager 安装:
winget install --id Kitware.CMake -e
安装完成后重新打开 PowerShell,使新的PATH生效,再检查:
cmake --version
使用 Git Bash 自动构建:
& "C:\Program Files\Git\bin\bash.exe" ./build.sh
或者直接在 PowerShell 中构建:
cmake -S . -B build -G "MinGW Makefiles" cmake --build build Set-Location build .\in.exe
十二、运行结果
输入大于3的值,例如5:
input: 5 move/main.c argument: root-argument move/use.c: use() move/and.c: and()
输入不大于3的值,例如2:
input: 2 move/to/main.c arguments: to-argument-1, to-argument-2 move/to/use.c: use() move/to/and.c: and()
这证明in.exe根据条件选择了不同程序,并向两个main()传入了不同数量的参数。
十三、常见错误及定位方法
| 错误现象 | 根本原因 | 解决方法 |
|---|---|---|
file format not recognized | 使用 GCC 处理了 Shell 脚本 | 使用 Bash 执行build.sh |
无法识别C:\Program | 路径包含空格但未正确调用 | 使用& "完整路径" 参数 |
dirname: command not found | 直接启动 Git 内部 Bash,环境未初始化 | 使用Git\bin\bash.exe |
cmake: command not found | CMake 未安装或不在PATH中 | 安装 CMake 并重启终端 |
不支持MinGW Makefiles | 在 WSL/Linux 中使用了 Windows 生成器 | 改用 Windows CMake |
execl: No such file or directory | 工作目录不正确或目标程序未构建 | 从build目录运行并检查输出文件 |
重复定义main、use或and | 两套源码被链接到同一个目标 | 为两套源码定义不同 CMake 目标 |
十四、进一步理解几个容易忽略的细节
1. 为什么调用fflush(stdout)
下面的提示没有换行符:
printf("input: ");输出内容可能暂存在缓冲区中。调用:
fflush(stdout);
可以确保提示在程序等待输入之前显示。程序即将执行exec类操作时,主动刷新需要保留的输出也是稳妥做法。
2. 为什么声明和定义必须一致
如果use.c中声明:
void and(void);
那么and.c中也应定义为:
void and(void)
一处声明为void、另一处定义为int会造成类型不一致。小型示例可以直接声明;正式项目通常把共享声明放入.h头文件,避免多个源文件各自维护声明。
3.execl()与execlp()的区别
execl()使用给定路径:
execl("./main.exe", "main.exe", (char *)NULL);execlp()可以根据PATH环境变量搜索程序。当前项目需要明确区分根目录和to目录中的两个main.exe,因此指定路径更合适。
4. Windows 与 POSIX 的可移植性
在 POSIX 系统中,execl()通常由<unistd.h>声明;当前 MinGW/Windows 示例使用<process.h>。文件扩展名、头文件和构建生成器都与平台有关。如果要让同一份源码同时支持 Windows 和 Linux,需要通过条件编译处理这些差异,而不能只修改 Shell 命令。
十五、什么时候应该采用这种结构
这种结构适合:
根据配置或输入启动不同工具;
启动器完成参数验证后切换到实际程序;
一个仓库需要构建客户端、服务端和管理程序;
不同功能必须拥有独立的进程和地址空间;
需要分别部署、测试或替换不同程序。
如果两个分支只是执行少量不同计算,直接调用函数通常更合理:
if (c > 3) root_use(); else to_use();
进程替换增加了构建目标、路径、参数传递和错误处理成本。只有确实需要独立程序边界时,才应使用execl()。
十六、结语
这个示例连接了 C 项目中几个经常被分开学习的概念。
main() -> use() -> and()展示的是同一进程内的函数调用;两套同名函数通过独立链接目标形成两个可执行程序;execl()根据输入条件使用其中一个程序替换启动器;CMake 负责描述三个目标及其输出位置;build.sh将构建步骤自动化;PowerShell 的&则负责调用路径中包含空格的 Bash 程序。
完整调用链可以概括为:
PowerShell 的 & -> 启动 Git Bash -> Git Bash 解释 build.sh -> build.sh 调用 CMake -> CMake 调用 MinGW 构建工具 -> 构建工具调用 GCC -> 生成 in.exe 和两个 main.exe -> in.exe 使用 execl() 选择目标程序 -> 目标 main() 调用 use() -> use() 调用 and()
理解这条链路后,命令解释、构建生成、编译链接、进程替换和函数调用之间的边界就会清晰:每一层只负责自己的任务,上一层通过明确的接口驱动下一层。
附录:完整项目源码
以下代码与本文对应,可以按照开头的目录结构分别保存。
1.in.c
#include <stdio.h> #include <process.h> int main(void) { int c; printf("input: "); fflush(stdout); if (scanf("%d", &c) != 1) { fputs("invalid input\n", stderr); return 1; } if (c > 3) execl("./main.exe", "main.exe", "root-argument", (char *)NULL); else execl("./to/main.exe", "main.exe", "to-argument-1", "to-argument-2", (char *)NULL); perror("execl"); return 1; }2. 根目录main.c
#include <stdio.h> void use(void); int main(int argc, char *argv[]) { if (argc != 2) return 1; puts("move/main.c"); printf("argument: %s\n", argv[1]); use(); return 0; }3. 根目录use.c
#include <stdio.h> void and(void); void use(void) { puts("move/use.c: use()"); and(); }4. 根目录and.c
#include <stdio.h> void and(void) { puts("move/and.c: and()"); }5.to/main.c
#include <stdio.h> void use(void); int main(int argc, char *argv[]) { if (argc != 3) return 1; puts("move/to/main.c"); printf("arguments: %s, %s\n", argv[1], argv[2]); use(); return 0; }6.to/use.c
#include <stdio.h> void and(void); void use(void) { puts("move/to/use.c: use()"); and(); }7.to/and.c
#include <stdio.h> void and(void) { puts("move/to/and.c: and()"); }8.CMakeLists.txt
cmake_minimum_required(VERSION 3.15) project(move_example C) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) add_executable(move_main main.c use.c and.c) set_target_properties(move_main PROPERTIES OUTPUT_NAME main RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" ) add_executable(to_main to/main.c to/use.c to/and.c) set_target_properties(to_main PROPERTIES OUTPUT_NAME main RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/to" ) add_executable(in in.c) set_target_properties(in PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" )9.build.sh
#!/usr/bin/env sh set -eu root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) cmake -S "$root" -B "$root/build" -G "MinGW Makefiles" cmake --build "$root/build" cd "$root/build" ./in.exe
