本文从一个具体问题出发:同一个项目中存在两套名称相同、实现不同的
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