刚接触Linux会有这个观点,程序就是把源代码编译成可执行文件,然后跑起来。但在 Linux 里,真正运行的既不是源代码,也不是磁盘上的文件,而是进程。一个进程至少包含:
PID父进程地址空间文件描述符表当前工作目录环境变量权限信息信号处理方式退出状态
这一章先把一个基础的问题讲清楚:
嵌入式 Linux 应用程序,到底是怎么作为一个进程跑起来的。
一、为什么文件 IO 后面要讲进程
第二章反复讲了一个东西:
fd 不是全局唯一的,它不是整个系统里唯一的编号,而是属于某个进程的。
比如进程 A 打开一个文件,拿到:
进程 B 也打开另一个文件,也可能拿到:
这两个 3 不是同一个东西,它们分别是两个进程内部文件描述符表里的编号。
所以你必须先建立这个模型:
进程 A fd 0 -> stdin fd 1 -> stdout fd 2 -> stderr fd 3 -> /etc/app.conf进程 B fd 0 -> stdin fd 1 -> stdout fd 2 -> stderr fd 3 -> /tmp/log.txt
同样是 fd = 3,在不同进程里可以指向完全不同的对象。
二、程序和进程不是一回事
先把两个概念分清楚:
程序:磁盘上的可执行文件进程:程序运行起来之后的实例
比如你编译出一个程序:
这里的 app 是一个可执行文件。
它在磁盘上,还没有运行。
当你执行:
Linux 才会把这个可执行文件加载起来,然后创建一个进程。
同一个程序可以运行多次。
比如:
磁盘上还是同一个 app 文件。
但系统里会有三个不同的进程。
它们有不同的 PID,有各自的地址空间,有各自的 fd 表,有各自的运行状态。
所以不要说:
更准确的说法是:
三、Linux 启动一个程序时到底做了什么
当你在 shell 里输入:
表面上看,只是执行了一个文件。
但背后大致会经历这些事情:
shell 接收到命令 -> shell 创建子进程 -> 子进程加载 ./app 这个可执行文件 -> 内核为进程准备地址空间 -> 建立代码段、数据段、堆、栈 -> 准备 argv 和环境变量 -> 继承或设置文件描述符 -> 跳到程序入口开始执行 -> 最后进入 main()
注意最后一句:
也就是说,main() 并不是进程真正的第一个入口。
在 main() 之前,还有 C 运行时启动代码负责完成初始化。
比如:
准备 argc/argv准备 envp初始化 C 库设置全局构造逻辑最后调用 main()
你平时写:
int main(void){ return 0;}
只是站在应用程序视角看到的入口。
从系统视角看,Linux 运行一个程序要做的事情比这多得多。
四、PID 和 PPID:系统怎么识别进程
每个进程都有一个 PID。
PID 是 process id,也就是进程 ID。
你可以用命令查看:
或者:
在程序里也可以拿到当前进程的 PID:
#include<stdio.h>#include<unistd.h>intmain(void){ printf("pid = %d\n", getpid()); printf("ppid = %d\n", getppid()); return 0;}
这里:
getpid() 获取当前进程 PIDgetppid() 获取父进程 PID
父进程就是创建当前进程的进程。
如果你在 shell 里执行 ./app,通常 shell 就是它的父进程。
可以这样理解:
所以 app 的 PPID 通常就是 shell 的 PID。
在嵌入式 Linux 里,很多业务程序不是手动从 shell 启动的,而是由 init、systemd、启动脚本或监控进程拉起来的。
这时候它的父进程可能是:
initsystemdshsupervisor某个厂商自定义守护进程
所以调试嵌入式程序时,不要只问“程序有没有跑”。
还要问:
它是谁启动的?它的父进程是谁?它是在什么环境下启动的?
五、进程有哪些资源
一个进程不是只有一段代码,它运行时,内核会为它维护一套上下文。
可以先粗略记住这些:
地址空间文件描述符表当前工作目录环境变量用户 ID 和权限信号处理方式进程状态退出码
比如:
相对路径为什么有时找不到文件?因为当前工作目录不同。为什么同一个程序手动运行正常,开机启动失败?可能是环境变量不同,也可能是权限不同。为什么一个服务跑久了打不开文件?可能是 fd 泄漏。为什么 kill 之后程序退出?因为进程收到了信号。为什么父进程要 wait 子进程?因为子进程退出状态需要被回收。
所以说,进程不是抽象概念,它是 Linux 给正在运行的程序准备的一整套运行环境。
六、为什么 fd 属于进程
前面讲过,进程启动时通常默认打开三个文件描述符:
这三个 fd 也属于当前进程。
你可以写一个小程序验证:
#include<stdio.h>#include<unistd.h>intmain(void){ write(1, "hello stdout\n", 13); write(2, "hello stderr\n", 13); return 0;}
这里没有用 printf(),而是直接对 fd = 1 和 fd = 2 写数据。
通常:
fd 1 输出到终端标准输出fd 2 输出到终端标准错误
但如果你执行:
./app > out.txt 2> err.txt
那么:
fd 1 -> out.txtfd 2 -> err.txt
程序代码没有变,变的是进程启动时的 fd 表,这就是 shell 重定向的本质之一。
所以你要记住:
printf() 不是天然输出到屏幕,它通常是写到当前进程的标准输出,而标准输出背后接到哪里,取决于进程的 fd 表。
很多程序手动运行时能看到日志,放到后台或开机启动后就看不到了。
不是日志消失了,而是它的 stdout/stderr 可能已经不是你的终端。
七、fork:复制一个当前进程
Linux 创建新进程,常见方式之一是 fork()。
fork() 可以先粗略理解为:
复制当前进程,得到一个子进程。
示例:
#include<stdio.h>#include<unistd.h>intmain(void){ pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { printf("child: pid=%d, ppid=%d\n", getpid(), getppid()); } else { printf("parent: pid=%d, child=%d\n", getpid(), pid); } return 0;}
fork() 很特别,它会返回两次。
在父进程里:
在子进程里:
所以代码里经常这样判断:
if (pid == 0) { /* child */} else { /* parent */}
子进程会继承父进程的很多东西。
比如:
如果父进程打开了一个文件,再 fork() 出子进程,子进程也可能拿到对应的 fd。
八、exec:把当前进程替换成另一个程序
fork() 只是复制当前进程,但很多时候,我们创建子进程不是为了让它继续跑同一份代码,而是为了运行另一个程序,这时候就会用到 exec 系列函数。
可以先这样理解:
exec 不创建新进程,而是把当前进程的程序内容替换成另一个可执行文件。
常见模式是:
fork() -> child 里 exec() -> parent 里 wait()
示例:
#include<stdio.h>#include<sys/wait.h>#include<unistd.h>intmain(void){ pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { execl("/bin/ls", "ls", "-l", "/", NULL); perror("execl"); return 1; } wait(NULL); return 0;}
如果 execl() 成功,后面的 perror("execl") 不会执行,因为当前子进程已经被 /bin/ls 替换了,只有 execl() 失败时,才会继续往下走。
很多人第一次看 exec 会误以为它像普通函数一样“调用另一个程序然后返回”。
不对。
成功的 exec 不会返回,它是替换当前进程映像。
九、wait:父进程为什么要回收子进程
子进程退出后,父进程需要知道它怎么退出的。
比如:
父进程可以用 wait() 或 waitpid() 回收子进程。
示例:
#include<stdio.h>#include<sys/wait.h>#include<unistd.h>intmain(void){ pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { return 7; } int status = 0; waitpid(pid, &status, 0); if (WIFEXITED(status)) { printf("child exit code = %d\n", WEXITSTATUS(status)); } return 0;}
如果父进程不回收已经退出的子进程,系统里可能出现僵尸进程。
僵尸进程不是还在运行,它已经退出了,但它的退出状态还留在内核里,等父进程来取。
你用 ps 可能会看到类似:
十、进程退出:return 和退出码
程序最后总要结束的,最简单的方式是:
int main(void){ return 0;}
这里的 0 通常表示成功。
非 0 通常表示失败。
比如:
shell 可以拿到上一个命令的退出码:
这件事非常重要,因为脚本、启动系统、监控程序经常依赖退出码判断程序是否成功。
比如:
不要把退出码当摆设,如果程序明明初始化失败,却还 return 0,外部系统就会误以为它启动成功。
十一、信号:进程如何被外部打断
进程运行时,可能会收到信号。
常见信号包括:
SIGINT 通常来自 Ctrl+C,通知进程"用户想中断你"SIGTERM 请求进程终止SIGKILL 强制杀死进程SIGSEGV 段错误SIGCHLD 子进程状态变化
你在命令行执行:
默认发送的一般是 SIGTERM。
如果执行:
kill -9 <pid> ,默认发送的就是 SIGTERM
SIGKILL 不能被程序捕获,也不能被忽略。
所以不要一上来就 kill -9 ,正常服务应该优先响应 SIGTERM,来进行清理:
简单示例:
#include<signal.h>#include<stdio.h>#include<unistd.h>static volatile sig_atomic_t running = 1;staticvoidhandle_signal(int signo){ (void)signo; running = 0;}intmain(void){ signal(SIGTERM, handle_signal); signal(SIGINT, handle_signal); while (running) { printf("running...\n"); sleep(1); } printf("exit cleanly\n"); return 0;}
这个例子只是一个说明。
真正工程里,信号处理函数里不要做复杂逻辑,更稳的做法通常是设一个标志位,让主循环自己退出。
十二、总结
第一,程序是磁盘上的文件,进程是运行起来的程序实例。第二,每个进程都有自己的 PID、地址空间、fd 表、当前目录和环境变量。第三,fd 属于进程,同一个 fd 数字在不同进程里可以指向不同对象。第四,stdin/stdout/stderr 本质上也是进程默认打开的 fd。第五,当前目录和环境变量会影响程序行为,开机启动问题经常出在这里。第六,fork 复制进程,exec 替换进程,wait 回收子进程。第七,退出码和信号是进程与外部系统交互的重要方式。第八,嵌入式程序通常长期运行,资源继承、资源释放、退出路径都必须认真处理。
你的程序运行起来之后,会变成一个进程。
这个进程有自己的:
PID父进程地址空间文件描述符表当前工作目录环境变量权限信号处理方式退出状态
第二章讲的文件 IO,实际上就是进程通过自己的 fd 表访问外部资源。
第三章讲进程,就是为了把这些东西放回正确的位置。
Linux 管理的不是“源代码”,而是一个个正在运行的进程;理解进程,才算真正开始理解用户态程序如何在系统里活着。