apue(四)、进程环境

117 阅读6分钟

1、简介

本章主要介绍进程执行时的环境变量如何存储、Linux存储空间布局是什么样的、执行函数跳转时栈空间的变化以及针对递归调用返回时的优化函数 longjmp 和 setjmp。

2、进程终止

进程终止总共有 8 种方法,其中 5 种为正常终止,如下:

(1) 从 main 函数返回;

(2)调用 exit;

(3)调用 _exit 或者 _Exit;

(4)最后一个线程从其启动例程返回;

(5)从最后一个线程调用 pthread_exit 函数;

剩下 3 种异常返回方式如下:

(6)调用 abort 函数,abort 函数通常用于出现了不可预知的错误,为了防止错误扩大程序自杀的方式;

(7)接收到一个信号,比如 ctrl+c 实际上传递了 SIGINT 信号给内核,程序就会结束;

(8)最后一个线程对取消请求做出响应。

2.1 exit/_exit

#include <stdlib.h>

void exit(int status);

void _Exit(int status);

#include <unistd.h>

void _exit(int status);

exit 函数在执行进程结束前会刷新缓冲区数据,进行标准IO库的关闭清理操作;而 _exit/_Exit 函数直接结束。

exit 函数在退出前会逆序地执行完所有已注册地钩子函数(atexit),而 _exit/_Exit 不执行直接结束。

#include <stdlib.h>

int atexit(void (*func)(void));

有这样一些场景适合使用钩子函数,比如程序中多次调用系统IO函数 open 打开文件获取文件描述符,如果这个过程中有某一次资源申请失败,则需要停止执行并释放掉之前所有的文件描述符资源,这样会造成大量的代码冗余。

int fd0, fd1, fd2, fd3;

int main() {
    fd0 = open("", "");
    if (fd0 < 0) {
        perror("open0");
        exit(1);
    } 
    
    fd1 = open("", "");
    if (fd1 < 0) {
        perror("open1");
        close(fd0);
        exit(1);
    }
   
    fd2 = open("", "");
    if (fd2 < 0) {
        perror("open2");
        close(fd1);
        close(fd0);
        exit(1);
    }
    
    fd3 = open("", "");
    if (fd3 < 0) {
        perror("open3");
        close(fd2);
        close(fd1);
        close(fd0);
        exit(1);
    }
}

如果采取 atexit 就很方便了:

int fd0, fd1, fd2, fd3;

void closefd0() {
    close(fd0); 
}
void closefd1() {
    close(fd1);
}
void closefd2() {
    close(fd2);
}
void closefd3() {
    close(fd3);
}

int main() {
    fd0 = open("", "");
    if (fd0 < 0) {
        perror("open0");
        exit(1);
    } 
    atexit(closefd0);
    
    fd1 = open("", "");
    if (fd1 < 0) {
        perror("open1");
        exit(1);
    }
    atexit(closefd1);
    
    fd2 = open("", "");
    if (fd2 < 0) {
        perror("open2");
        exit(1);
    }
    atexit(closefd2);
    
    fd3 = open("", "");
    if (fd3 < 0) {
        perror("open3");
        exit(1);
    }
    atexit(closefd3);
}

3、存储空间布局

C语言的存储空间布局,从低地址到高地址可以依次划分为正文段、初始化数据段、未初始化数据段、堆、栈、命令行参数和环境变量。

  • 正文段:是由CPU执行的机器指令部分,是只读的;
  • 初始化数据段:包含了程序中需要明确赋的变量,例如任何函数外申明的变量:int maxcount = 99;
  • 未初始化数据段:函数外申明的未初始化的变量,内核将此段中的数据初始化为0或者空指针,例如 long sum[1000];
  • 栈:自动变量及每次函数调用时所需保存的信息存放在栈种;
  • 堆:通常在堆中进行动态存储分配。

image.png

Linux 采用延时分配的方式进行存储空间的分配,malloc 函数并没有真正的分配物理内存,而是返回一个非空指针,真正使用的时候再通过缺页异常进行内存分配。

#include <stdlib.h>

void *malloc(size_t size);

void *calloc(size_t nobj, size_t size);

void *realloc(void *ptr, size_t newsize);

void free(void *ptr);
  • malloc:分配指定字节数的存储区。此存储区中的初始值不确定。
  • calloc:为指定数量指定长度的对象分配存储空间。该空间中每一位都初始化为0。
  • realloc:增加或者减少以前分配区的长度。新增存储区内的初始值不确定。

上述这些内存分配函数通过 sbrk 系统调用实现,虽然 sbrk 可以扩充或者缩小进程的存储空间,但是大多数 malloc 和 free 的实现都不减小进程的存储空间。释放的空间可供以后再分配,但将它们保存在 malloc 池中而不返回给内核。

4、Linux 环境表

每个进程都会有一份环境表,环境表是一个指针数组,其中每个指针包含了一个以 null 结尾的C字符串的地址。全局变量 environ 包含了该指针数组的首地址。

image.png

#include <stdlib.h>

//获取环境变量
char *getenv(const char *name);
//新增环境变量到环境变量表
int putenv(char *str);
//将name值设置为value,若rewrite为非0,则首先删除其现有定义;若rewrite为0,则覆盖现有定义
int setenv(const char *name, const char *value, int rewrite);
//删除name的定义
int unsetenv(const char *name);

上述函数在修改环境表时是如何操作的?首先环境表和环境字符串通常存放在进程存储空间的顶部(栈之上),删除一个字符串很简单,只需要先在环境表中找到该指针然后将后续指针都向环境表首部依次移动一个位置。但是增加一个字符串或者修改一个现有字符串会困难的多。因为环境表位于进程地址空间的顶部,所以它不能再向高地址方向扩展,同时也不能移动在它下面的各栈帧,所以不能向低地址扩展,这两者使得该空间的长度不能再增加了。

那么如果要新增字符串,只能在其它地方存储新的环境表,具体情况可以分为修改环境表、新增环境表:

(1)如果修改一个现有的 name:

a. 如果新 value 的长度小于或等于现有的 value长度,将新字符串复制到原字符串所用的空间中;

b. 如果新 value 的长度大于原长度,必须调用 malloc 为新字符串分配空间,然后将新字符串复制到该空间中,接着使环境表中的指针指向新分配区域;

(2)如果新增一个 name:

首先必须通过 malloc 为 name=value 字符串分配空间,然后将该字符串复制到此空间中。

a. 如果这是第一次新增 name,则必须调用 malloc 为新的指针表分配空间,接着将原环境表复制到新环境表中,将新增加的 name=value 存放在该指针表的表尾,然后又将一个空指针存放在其后。最后使 environ 指向新指针表。如果原环境表位于栈顶之上,那么必须将此表移至堆中,但是此表中大多数指针仍然指向栈顶上的各 name=value 字符串。

b. 如果不是第一次新增 name,直接调用 realloc 重新分配空间,然后将新 name=value 字符串的指针存放在该表表尾,后面跟上一个空指针。

5、setjmp/longjmp

c 语言中 goto 是不能跨越函数的,执行这种函数跳转功能的是 setjmp/longjmp函数,它们对于处理深层次嵌套函数调用出错情况非常有用。

#include <setjmp.h>

int setjmp(jmp_buf env);

void longjmp(jmp_buf env, int val);

参数介绍:

  • env:jmp_buf 类型。这一数据类型是某种形式的数组,其中存放了 longjmp 能用来恢复栈状态的所有信息。因为需要在另一个函数中引用 env变量,所以通常将 env变量设置为全局变量。
  • val:返回一个int值,可以根据这个值判断发生异常的位置。
#include <unistd.h>
#include <stdio.h>
#include <setjmp.h>

#define TOK_ADD 5
#define MAXLINE 1024

void do_line(char *);
void cmd_add(void);
int  get_token(void);

int main(void) {
    char line[MAXLINE];
    while (fgets(line, MAXLINE, stdin) != NULL) 
        do_line(line);
    exit(0);
}

char *tok_ptr;

void do_line(char *ptr) {
    int cmd;
    tok_ptr = ptr;
    while ((cmd = get_token()) > 0) {
        switch (cmd) {
            case TOK_ADD:
                cmd_add();
                break;
        }
    }
}

void cmd_add(void) {
    int token;
    token = get_token();
}

int get_token(void) {   //如果这里出现异常就需要层层递归返回
    return 0;
}

如果在函数 cmd_add 中出现了异常,按照递归的思想我们不得不以检查返回值的方法逐层返回,这样会很麻烦。通过 setjmp/longjmp 就能够实现在栈上跳过若干调用帧。首先通过 setjmp 函数设置跳转点,然后在需要跳转的位置调用 longjmp,根据不同的返回值确定异常发生的位置。

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

#define TOK_ADD 5
#define MAXLINE 1024

jmp_buf jmpbuffer;

void do_line(char *);
void cmd_add(void);
int  get_token(void);

int main(void) {
    char line[MAXLINE];

    if (setjmp(jmpbuffer) != 0)    //设置跳跃点
        printf("error");
    while (fgets(line, MAXLINE, stdin) != NULL) 
        do_line(line);
    exit(0);
}

char *tok_ptr;

void do_line(char *ptr) {
    int cmd;
    tok_ptr = ptr;
    while ((cmd = get_token()) > 0) {
        switch (cmd) {
            case TOK_ADD:
                cmd_add();
                break;
        }
    }
}

void cmd_add(void) {
    int token;
    token = get_token();
    if (token < 0) {
        longjmp(jmpbuffer, 1);
    }
}

int get_token(void) {
    return 0;
}

解决了递归栈跳转的问题,还有一个问题就是关于变量值的保存,比如自动变量、寄存器变量、易失变量等等在调用 longjmp 后值会如何变化?

参考APUE P174 可以发现,在 longjmp 之后,存放在存储器中的变量将具有 longjmp 时的值,而在CPU和浮点寄存器中的变量则恢复为调用 setjmp 时的值。