作者:andylin02
学习章节:第7章 进程环境
关键词: 进程环境、main函数、进程终止、环境变量、内存布局、setjmp/longjmp、资源限制

一、引言:理解进程环境,掌握进程控制的“前奏”

在上一章,我们学习了系统数据文件和信息,了解了如何访问系统的各种配置数据。从本章开始,我们将逐步深入到进程的世界。

理解 UNIX 系统环境中 C 程序的环境是理解 UNIX 系统进程控制特性的先决条件。 本章正是为此打基础:当执行一个程序时,其 main 函数是如何被调用的?命令行参数是如何传送给执行程序的?典型的存储空间布局是什么样子?如何分配另外的存储空间?进程如何使用环境变量?

本章主要回答以下核心问题:

  1. main 函数是如何被调用的?
  2. 进程终止的方式有哪几种?
  3. 如何获取和操作环境变量?
  4. C 程序的存储空间是如何布局的?
  5. 如何使用 setjmp 和 longjmp 进行非局部跳转?
  6. 如何获取和设置进程的资源限制?

📌 学习目标:理解进程启动、终止的完整流程,掌握进程的内存布局,能够使用环境变量和资源限制函数。为下一章“进程控制”打好理论基础。

二、核心内容精讲

2.1 main 函数与启动例程

C 程序总是从 main 函数开始执行。main 函数的原型为:int main(int argc, char *argv[]);。argc 是命令行参数的数目,argv 是指向参数的各个指针所构成的数组。ISO C 和 POSIX 都要求 argv[argc] 是一个空指针。

但 main 函数并非程序执行的真正入口点。

当内核执行 C 程序时(使用一个 exec 函数),在调用 main 前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址——这是由连接编辑器(链接器)设置的,而连接编辑器则由 C 编译器调用。启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用 main 函数做好安排。

// 启动例程的伪代码表示
exit(main(argc, argv));

内核

启动例程

用户程序

加载程序

取得参数和环境

返回

调用 exit

main 函数

_start(启动函数)

exec 函数

在汇编层次,这个启动函数的名字通常是 _start。它负责从内核接收命令行参数和环境变量,设置好之后再调用 main 函数。

2.2 进程终止:八种方式

进程终止的方式有8种,其中5种为正常终止,3种为异常终止。

正常终止的5种方式:

  1. 从 main 返回:相当于调用 exit。
  2. 调用 exit:ISO C 定义的函数,会执行清理操作。
  3. 调用 _exit 或 _Exit:立即进入内核。
  4. 最后一个线程从其启动例程返回。
  5. 从最后一个线程调用 pthread_exit。

异常终止的3种方式:

  1. 调用 abort:产生 SIGABRT 信号。
  2. 接到一个信号:某些信号会导致进程终止。
  3. 最后一个线程对取消请求做出响应。
2.2.1 退出函数详解

三个用于正常终止程序的函数:

#include <stdlib.h>
void exit(int status);
void _Exit(int status);

#include <unistd.h>
void _exit(int status);

区别:

  • exit 和 _Exit 由 ISO C 说明,_exit 由 POSIX.1 说明。
  • _exit 和 _Exit 立即进入内核。
  • exit 先执行一些清理处理(如冲洗所有标准 I/O 流,执行终止处理程序),然后进入内核。
2.2.2 atexit:注册终止处理程序

atexit 函数可以登记函数,这些函数将由 exit 自动调用。

#include <stdlib.h>
int atexit(void (*func)(void));

特性:

  • ISO C 规定,一个进程可以登记多至32个函数。
  • 先登记的函数,后调用(即按照与登记顺序相反的顺序调用)。
  • 同一函数登记多次,则会被调用多次。

示例:演示 atexit 的调用顺序。

#include "apue.h"
#include <stdlib.h>

static void hello(void) {
    printf("hello\n");
}

static void byebye(void) {
    printf("byebye\n");
}

int main(void) {
    if (atexit(hello) != 0)
        err_sys("atexit error");
    if (atexit(hello) != 0)  // 同一函数注册两次
        err_sys("atexit error");
    if (atexit(byebye) != 0)
        err_sys("atexit error");

    printf("main is done\n");
    exit(0);
}

运行输出:

main is done
byebye
hello
hello
2.2.3 进程终止状态

exit、_exit、_Exit 都带一个整型参数,称为终止状态(或退出状态)。可以通过 shell 检查进程终止的状态。

特殊规则:

  • 如果调用这些函数时不带终止状态,终止状态是未定义的。
  • 如果 main 执行了一个无返回值的 return 语句,终止状态是未定义的。
  • 如果 main 没有声明返回类型为整型,终止状态是未定义的。
  • 若 main 的返回类型是整型,并且 main 执行到最后一条语句时返回(隐式返回),则进程的终止状态是0。

exec 调用

进入内核

进入内核

运行

正常终止

等价于 exit(返回值)

执行终止处理程序

fclose 清理

直接调用

main返回

exit

exit调用

atexit

标准I/O

_exit

_exit或_Exit

异常终止

abort

信号

pthread_cancel

2.3 命令行参数与环境表

2.3.1 命令行参数

当执行一个程序时,调用 exec 的进程可以将命令行参数传递给新程序。main 函数的 argc 和 argv 参数正是接收这些参数的。

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
    for (int i = 0; i < argc; ++i) {
        printf("%d: %s\n", i, argv[i]);
    }
    exit(0);
}
2.3.2 环境表

每个程序都会从父进程那里接收一张环境表。和参数表一样,环境表也是一个字符指针数组,其中每个指针包含一个以 null 结束的 C 字符串的地址。全局变量 environ 则包含了该指针数组的地址。

// 环境表的结构
extern char **environ;  // 环境指针

数据结构:

environ ──→ ┌──────────┐
           │ 指针 ──→ "HOME=/home/user\0"
           ├──────────┤
           │ 指针 ──→ "PATH=/bin:/usr/bin\0"
           ├──────────┤
           │ ...      │
           ├──────────┤
           │ NULL     │
           └──────────┘
2.3.3 环境变量操作函数

为了可移植性,应使用标准函数操作环境变量,而不是直接操作 environ。

#include <stdlib.h>

char *getenv(const char *name);          // 获取环境变量的值
int putenv(char *string);                // 添加或修改环境变量
int setenv(const char *name, const char *value, int overwrite); // 设置环境变量
int unsetenv(const char *name);          // 删除环境变量
函数说明
getenv获取名为 name 的环境变量的值,返回指向该值的指针
putenv取形式为 "name=value" 的字符串,将其添加到环境表中。如果 name 已存在,则删除原定义
setenv将 name 设置为 value。如果 name 已存在,根据 overwrite 决定是否覆盖(非0覆盖,0不覆盖)
unsetenv删除 name 的定义

💡 实践提示:直接修改 environ 可能导致内存问题,推荐使用 setenv 和 unsetenv 等标准函数。

2.4 C 程序的存储空间布局

C 程序在内存中由多个段组成。从低地址到高地址的顺序如下:

段说明存储内容特性
正文段(text)CPU 执行的机器指令部分代码、const 全局变量、const 静态变量、字符串字面值可共享、只读
初始化数据段(data)已初始化的数据程序中明确赋初值的全局变量和静态变量从程序文件中读取初值
未初始化数据段(bss)未初始化的数据未初始化的全局变量和静态变量内核在程序执行前将其初始化为0或空指针
堆(heap)动态存储分配malloc、calloc、realloc 分配的内存从低地址向高地址增长
栈(stack)自动变量和函数调用信息局部变量、函数参数、返回地址、调用者环境信息从高地址向低地址增长
命令行参数和环境变量传递给程序的信息argv、environ位于栈顶之上

高地址

向下增长

向上增长

命令行参数和环境变量
(环境字符串)

栈(stack)↓

堆(heap)↑

未初始化数据段
(bss)

初始化数据段
(data)

正文段
(text)

📌 重要规则:未初始化数据段(bss)的内容并不存放在磁盘程序文件中,只有正文段和初始化数据段需要存放在磁盘程序文件中。

2.5 共享库

共享库使得可执行文件不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存该库例程的一个副本。这样可显著减小可执行文件的长度,但增加了运行时的时间开销。

# 使用 size 命令查看程序各段长度
$ size /bin/bash
   text    data     bss     dec     hex filename
 916355   35848   23304  975507   ee293 /bin/bash

动态链接 vs 静态链接:

  • 静态链接:可执行程序包含所有需要的函数,不依赖外部库,文件较大。****
  • 动态链接:可执行程序依赖外部共享库,文件较小,运行时需要动态装入器加载共享库。****

2.6 动态内存分配

C 程序使用 malloc、calloc、realloc、free 等函数进行动态内存分配。

#include <stdlib.h>

void *malloc(size_t size);           // 分配 size 字节,内容未初始化
void *calloc(size_t nobj, size_t size); // 分配 nobj * size 字节,内容初始化为0
void *realloc(void *ptr, size_t newsize); // 更改已分配区域的大小
void free(void *ptr);                // 释放已分配的内存

alloca 函数:

#include <alloca.h>
void *alloca(size_t size);   // 在栈上分配内存,函数返回时自动释放

alloca 的特殊之处在于它在调用者的栈帧上分配内存,因此不需要调用 free 来释放——当函数返回时,这块内存会自动释放。但它不能用于跨函数传递分配的内存。

2.7 非局部跳转:setjmp 和 longjmp

setjmp 和 longjmp 提供了在函数之间进行跳转的能力,常用于从深层嵌套的函数调用中直接返回,而无需逐级返回。这种机制常用于错误处理等场景。

#include <setjmp.h>

int setjmp(jmp_buf env);     // 保存当前调用环境
void longjmp(jmp_buf env, int val);  // 恢复保存的环境
工作原理
  1. setjmp:在需要作为跳转目标的位置调用。它保存当前栈环境到 env 中。首次调用时返回0。
  2. longjmp:在另一个函数中调用。它恢复 env 保存的环境,使程序执行流程跳转到 setjmp 调用点。此时 setjmp 返回 val 参数的值,用于区分不同的跳转来源。

返回 0

跳转

返回 val

调用 setjmp
保存环境到 env

判断返回值

执行正常流程

函数调用链...

调用 longjmp
恢复 env 环境

处理错误/恢复状态

⚠️ 重要限制:longjmp 只能跳转到仍在调用栈上的 setjmp。如果 setjmp 所在的函数已经返回,再调用 longjmp 将导致未定义行为(通常是段错误)。****

setjmp/longjmp 对自动变量、寄存器变量和全局变量的影响:

  • 全局变量、静态变量:在 longjmp 后保持调用 longjmp 时的值
  • 自动变量、寄存器变量:如果变量值在调用 longjmp 之前被修改,其值是否保持不变取决于编译器优化。声明为 volatile 可以确保其值不受 longjmp 影响。

示例:使用 setjmp/longjmp 实现错误恢复。

#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>

jmp_buf main_env;

void error_handler(int code) {
    printf("Error occurred, code: %d\n", code);
    longjmp(main_env, code);
}

int main(void) {
    int ret;
    if ((ret = setjmp(main_env)) != 0) {
        printf("Recovered from error, ret = %d\n", ret);
        // 这里可以进行错误恢复处理
        exit(0);
    }

    printf("Normal execution path\n");
    error_handler(1);  // 触发跳转
    printf("This line will not be executed\n");

    return 0;
}

2.8 进程资源限制

每个进程都有一组资源限制,可以使用 getrlimit 和 setrlimit 函数查询和修改。

#include <sys/resource.h>

int getrlimit(int resource, struct rlimit *rlp);
int setrlimit(int resource, const struct rlimit *rlp);

struct rlimit 结构体包含两个成员:

  • rlim_cur:当前软限制
  • rlim_max:硬限制

常用的资源类型:

资源说明
RLIMIT_AS进程可用存储空间最大长度(字节)
RLIMIT_COREcore 文件最大字节数
RLIMIT_CPUCPU 时间量(秒)
RLIMIT_DATA数据段最大长度(字节)
RLIMIT_FSIZE可创建的文件最大长度(字节)
RLIMIT_NOFILE每个进程能打开的最多文件数
RLIMIT_NPROC每个实际用户 ID 可拥有的子进程数
RLIMIT_STACK栈的最大长度(字节)

规则:

  • 只有超级用户可以更改硬限制。
  • 软限制可以设置为小于或等于硬限制的值。
  • 软限制和硬限制都可以用 RLIM_INFINITY 表示“无穷大”。

示例:查询和设置进程资源限制。

#include "apue.h"
#include <sys/resource.h>
#include <errno.h>

int main(void) {
    struct rlimit rl;
    
    // 获取栈大小的限制
    if (getrlimit(RLIMIT_STACK, &rl) < 0)
        err_sys("getrlimit error");
    printf("RLIMIT_STACK: soft=%ld, hard=%ld\n", 
           (long)rl.rlim_cur, (long)rl.rlim_max);
    
    // 获取能打开的文件数限制
    if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
        err_sys("getrlimit error");
    printf("RLIMIT_NOFILE: soft=%ld, hard=%ld\n", 
           (long)rl.rlim_cur, (long)rl.rlim_max);
    
    return 0;
}

三、完整源代码示例

3.1 打印所有环境变量

#include "apue.h"
#include <stdlib.h>

extern char **environ;  // 环境指针

int main(void) {
    char **env = environ;
    while (*env != NULL) {
        printf("%s\n", *env);
        env++;
    }
    return 0;
}

3.2 获取特定环境变量的值

#include "apue.h"
#include <stdlib.h>

int main(int argc, char *argv[]) {
    char *path = getenv("PATH");
    if (path == NULL)
        printf("PATH environment variable not found\n");
    else
        printf("PATH = %s\n", path);
    return 0;
}

3.3 演示 setjmp/longjmp 对变量的影响

#include <setjmp.h>
#include <stdio.h>

static jmp_buf env;
static int global = 0;

void do_jump(void) {
    int local = 2;
    register int reg = 3;
    static int stat = 4;
    volatile int vol = 5;
    
    global = 10;
    local = 20;
    reg = 30;
    stat = 40;
    vol = 50;
    
    longjmp(env, 1);
}

int main(void) {
    int local = 0;
    register int reg = 0;
    static int stat = 0;
    volatile int vol = 0;
    
    if (setjmp(env) == 0) {
        printf("Initial call\n");
        do_jump();
    } else {
        printf("After longjmp\n");
        printf("global=%d, local=%d, reg=%d, stat=%d, vol=%d\n",
               global, local, reg, stat, vol);
    }
    return 0;
}

四、本章知识点速查表

知识点核心内容
main 函数C 程序入口,原型 int main(int argc, char *argv[]),argv[argc] 为 NULL
启动例程_start,在 main 之前调用,负责取得参数并调用 main
进程终止8种方式:5种正常(main返回、exit、_exit/_Exit等),3种异常(abort、信号等)
退出函数exit(有清理)、_exit/_Exit(无清理)
atexit注册终止处理程序,先注册后调用,最多32个
环境表extern char **environ,每个字符串为 name=value 形式
环境变量函数getenv、putenv、setenv、unsetenv
内存布局正文段 → data → bss → 堆 → 栈 → 命令行参数和环境变量
共享库动态链接 vs 静态链接
动态内存分配malloc、calloc、realloc、free、alloca
setjmp/longjmp非局部跳转,用于错误恢复,不能跳转到已返回的函数
资源限制getrlimit、setrlimit,软限制 ≤ 硬限制

五、动手实践建议

  1. 查看进程内存布局:

    cat /proc/self/maps   # Linux 下查看当前进程的内存映射
    
  2. 使用 size 命令查看程序各段大小:

    size /bin/bash
    
  3. 查看共享库依赖:

    ldd /bin/ls
    
  4. 使用 ulimit 查看 Shell 的资源限制:

    ulimit -a
    

六、常见易混淆点

Q1:exit 和 _exit 有什么区别?
A:exit 会执行标准 I/O 库的清理关闭操作(调用 fclose 冲洗所有输出流),并调用 atexit 注册的终止处理程序,然后进入内核。_exit 和 _Exit 则立即进入内核,不执行任何清理。****

Q2:atexit 注册的函数调用顺序是怎样的?
A:按照与注册顺序相反的顺序调用。先注册的函数后调用,后注册的函数先调用。

Q3:setjmp 和 longjmp 有什么限制?
A:longjmp 只能跳转到仍在调用栈上的 setjmp。如果 setjmp 所在的函数已经返回,再调用 longjmp 会导致未定义行为。****

Q4:alloca 和 malloc 有什么区别?
A:alloca 在栈上分配内存,函数返回时自动释放,不需要调用 free。malloc 在堆上分配内存,必须显式调用 free 释放。alloca 不能用于跨函数传递分配的内存。

Q5:volatile 在 setjmp/longjmp 中起什么作用?
A:setjmp/longjmp 会影响局部变量的值。声明为 volatile 的变量会确保其值在 longjmp 后保持不变(不会被优化器还原)。对于全局变量和静态变量,不需要 volatile,它们的值也会在 longjmp 后保持。

七、学习心得

第7章是全书的进程控制前奏,虽然概念较多,但都是理解后续章节的必要基础。重点掌握以下几点:

  • main 函数的真正入口点是启动例程 _start,它负责接收命令行参数和环境变量后调用 main。
  • atexit 注册的终止处理程序调用顺序是“先注册后调用”。
  • setjmp/longjmp 用于实现非局部跳转,是 C 语言中模拟异常处理机制的方式。编写相关代码时需要注意 volatile 的使用。
  • 动态内存分配时,务必检查返回值(是否为 NULL),并养成及时释放内存的习惯。
  • 理解资源限制,编写健壮的程序应能查询并适应系统的资源限制,而不是假设“无限大”。

理解本章后,你已经为下一章“进程控制”做好了充分准备。后续章节将深入进程的创建、执行和终止。

八、下一篇预告

下一篇将进入第8章“进程控制”,内容包括:

  • 进程标识符(PID)
  • fork、vfork 函数
  • exit 函数族
  • wait 和 waitpid 函数
  • exec 函数族
  • 解释器文件(#!)
  • system 函数
  • 进程会计
  • 用户标识
  • 进程时间

敬请期待!


本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析! ❤️ 如果觉得有用,请给个赞支持一下作者!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐