linux 环境变量

楔子

今天方方同学突然跑来跟我说了一个“奇怪”的现象。

新打开一个终端,然后执行

str=123 | echo $str

str怎么都无法正确输出。

我当机立断的指出,你这管道完全是瞎用,用管道来传递环境变量怎么可能有效呢!

然后给他示范了正确的方式

str=123 echo $str

结果,好像没有啥用。

恩、等等,再试试这个吧

env str=123 echo $str

咦,还是没用?这个echo一定是个假echo,再来

env str=123 /bin/echo $str

结果,尴尬。然后方方同学也只好尴尬的走了。

环境变量的传递

一通乱试没用后,冷静下来想了想原因,根据环境变量的本质和bash的这种语法结构,整理出如下原因。

  1. key=value cmd_xx 这个在sh/bash里叫做simple command,可以让 key=value 这对环境
    变量仅应用到 cmd_xx 的进程周期。
  2. 如果忽略后面的 cmd_xx ,则 key=value 这对环境变量会作用到本次shell之后的所有命令上(若不进行unset)。
  3. echo $str这句的 $str 是在echo的参数位置,也就是在对/bin/echo这个程序建立进程前,就已经被解析了,而解析的结果
    根据simple command的规则,自然就是空的。

因此虽然echo在执行时有一个str=123的环境变量,但echo的逻辑是打印它接受到参数,而非打印出某个环境变量的值。

环境变量与内核

接触过linux一段时间的同学,应该懂得可以从/proc/pid/environ下查看任意进程的环境变量值。特别是在调试桌面进程时,
我们可以方便的通过这个文件,快速查看有问题的某个GUI进程是否链接到了正确的dbus,locale设置是否正确的。

但这些同学一定会在之后的某一天会突然发现/proc/pid/environ的值竟然不正确!比如写一个Go程序,在里面设置
环境变量后,/proc/pid/environ 文件并没有实际的改变。

package main

import "fmt"
import "os"
import "time"

func main() {
    os.Setenv("NAME", "snyh")
    env := fmt.Sprintf("strings /proc/%d/environ | grep NAME", os.Getpid())
    fmt.Println(env)
    time.Sleep(time.Second*10000)
}

遇到这种情况,首先找相关官方定义,proc下的内容一般通过proc(5)即可查看比较正确的说明。

This file contains the initial environment that was set when the currently
executing program was started via execve(2).

查过手册就能很清楚的明白,这个文件的内容只是在进程被创建时的环境变量,并不包含后面通过os.Setenv等
方式设置的值。(但即使只是刚创建时的环境变量,对了解进程的状态还是有很大帮助的)

环境变量到底是什么?

再深入一些,我们会去思考环境变量到底是什么?
环境变量是一个内核维护的进程数据吗?

如果不是,为什么会有 /proc/%d/environ 这个文件的存在呢?
如果是,为什么 setenv 在man section 3 里而不是 section 2 里?

我个人的理解是,环境变量是用户态的一个约定俗成,是一个功能简单的IPC,但被玩的炉火纯青了,虽然它只能在进程
创建时进行传递,但支撑了大量的配置信息传递的需求。

  1. 通过 extern char**environ 这个全局变量来维护当前进程的environment, setenv, putenv,
    unsetenv 等等,只是对这个 environ 变量进行普通的操作。
  2. 通过 execve(filename, argv, envp) 系列的syscall运行程序的时候,通过envp参数来设置子进程
    的初始化环境变量。
  3. 内核将 envp 对应的值通过 /proc/pid/environ “导出”到用户态。
  4. 此后,当前进程和子进程之间的环境变量已经没有任何关系了,当前进程也没有正常手段可以去改变子进程
    的环境变量了。

可我们知道 extern char** environ 只是一个C运行环境下的概念,那其他语言环境下又是怎么处理的
呢? nodejs、php、Go、java等都有类似 setenv 的接口。有些语言是基于C的,但有些语言比如Go是可以
完全脱离C语言环境。
实际,其他语言如果不链接libc的话,压根就没有 char**environ 这个变量,因为没有C环境,所以也不需要
考虑 setenv 的兼容性,因为只有在调用 execve(2) 时才涉及到环境变量。
但大部分非C语言,都会考虑去兼容 extern char** environ 的,一般都是通过ELF等标准方式在链接
阶段创建一个重定向以便运行时找都libc的environ的地址。

修改 /proc/pid/environ 文件

/proc/pid/environ 只是进程创建时的环境变量的snapshot,那它的值到底是从哪里获取到的呢?
是简单的在 exceve(2) 时把 envp 拷贝一份,之后直接返回吗?

细想一下,不太可能,因为
1. 内核一般不做这么浪费资源的事,拷贝过去后,仅仅提供给 /proc/pid/environ 输出,实在态浪费。
2. /proc/pid/environ 的格式实际上和 char**environ 是不一样的。
前者是一个字符串接一个’\0′, 而后者是一个array of char* pointer

/proc 目录下的内容基本全部在内核代码的 /fs/proc/base.c 中,搜索相关关键字一般都能很快找到
相关实现,我们要找的就是 environ_read 函数。

struct mm_struct *mm = file->private_data;
unsigned long env_start, env_end;

/* Ensure the process spawned far enough to have an environment. */
if (!mm || !mm->env_end)
    return 0;

........

env_start = mm->env_start;
env_end = mm->env_end;

实际就是读取 mm_structenv_start, env_end 字段,但这里只是读取他们,并不会做任何修改。
所以继续去查询这两个字段被引用的地方。

会发现大致就3个地方和 env_start 相关
1. /proc/pid/environ 的读取
2. execve 实现中,为程序创建进程时,fs/binfmt_elf.c:crate_elf_tables
3. prctl(2) 中的 prctl_set_mm_map

1和2前面已经介绍了,3这个之前没了解过。但实际直接在 prctl(2) 中就有相关介绍。
通过这个系统调用,实际上是可以随意修改 /proc/pid/environ 的内容的。
比如,以下函数就可以修改此文件的内容:

#include 
void change_environment(const char* env)
{
  prctl(PR_SET_MM, PR_SET_MM_ENV_START, env, 0, 0);
  prctl(PR_SET_MM, PR_SET_MM_ENV_END, env+strlen(env), 0, 0);
}

prctl 属于比较底层的系统调用了,类似 ioctl ,是把非常多的功能塞到一起了。
其中比较知名的有,
1. PR_SET_CHILD_SUBREAPER 用来模拟1号进程回收僵尸进程。
(也就是通过这个syscall,非1号进程也可以作为默认的回收进程)
2. CAPXX capability相关读取和设置。
3. PR_SET_MM 设置 mm_struct 相关的字段,这个主要是给 ld.so 用的,
比如 /proc/pid/comm 就是 ld.so 通过这个来给用户程序设置名字的(但只能设置一次)
4. PR_SET_NAME 线程的名字,比如Qt等库中可以为线程设置不同的名字,方便调试。
5. PR_SET_UNALIGN 这个是在国产平台上可能需要用到的,用来设置kernel对unalign地址
操作时的行为。

思考

  1. str=123 | echo $str 为何没有报错, “|” 不是管道吗,管道不是pipe两个process吗?
    这里前面并没有进程呀?
  2. 有哪些手段可以修改其他进程的环境变量?

3 条思考于 “linux 环境变量

    1. 头像snyh

      对,还有 str=123; echo $str

      这两个都相当于使用了, “环境变量的传递”中的第二条。 会让str=123延续到脚本的后续执行过程。

发表评论

电子邮件地址不会被公开。 必填项已用*标注