读:witr——从源码看 Linux 如何获取进程信息
witr(Why Is This Running)是个挺有意思的工具。它只回答一个问题:*这个进程为什么在运行?* 给它一个进程名、PID、端口号甚至文件路径,它能给出完整的因果链——从 PID 1 一路追到目标进程,顺便告诉你谁启动的、怎么启动的、有没有危险。
能做到这些,全靠 Linux 的 /proc 文件系统。这篇文章就跟着 witr 的 源码,看看它是怎么从 /proc 里把进程信息一点点挖出来的。
进程的身份信息
witr 读取进程信息的入口是 process_linux.go 里的 ReadProcess 函数。它的第一步就是读 /proc/[pid]/stat :
statPath := fmt.Sprintf("/proc/%d/stat", pid)
stat, err := os.ReadFile(statPath)
proc[pid]/stat 里装了什么
这个文件看起来是一行长文本,里面有几十个字段(具体数量取决于内核版本)。先用 shell 看一眼:
cat /proc/1152/stat
1152 (emacs) S 578 1152 1152 0 -1 4194368 170800 306687 3141 2755 20150 1575 191 258 20 0 1 0 4216 713723904 107122 18446744073709551615 98319520526336 98319523324969 140736657481536 0 0 0 0 67112960 1535209215 1 0 0 17 7 0 0 0 0 0 98319523843024 98319531550712 98319932370944 140736657485873 140736657485888 140736657485888 140736657489897 0
这就是 /proc/[pid]/stat 的原始格式。括号里是进程名,后面一堆数字。每个数字对应一个字段,完整定义在 man 5 proc_pid_stat 里(注意不是 man 5 proc ,那个只是索引页,详细字段说明在 man 5 proc_pid_stat )。witr 感兴趣的几个字段是:
| 字段编号 | 名称 | 含义 |
|---|---|---|
| (2) | comm | 进程名,括在 () 里 |
| (3) | state | 进程状态(见下表) |
| (4) | ppid | 父进程 PID |
| (14) | utime | 用户态 CPU 时间(ticks) |
| (15) | stime | 内核态 CPU 时间(ticks) |
| (22) | starttime | 进程启动时间(单位:clock ticks) |
| (24) | rss | 常驻内存(页数) |
witr 的解析代码:
// stat 格式很阴险,命令名包在 () 里,而且命令本身可能包含括号
raw := string(stat)
open := strings.Index(raw, "(")
close := strings.LastIndex(raw, ")")
comm := raw[open+1 : close] // 字段 (2) comm
fields := strings.Fields(raw[close+2:]) // 括号后面的字段
// 注意:fields 数组从 ")" 之后开始编号,fields[N] 对应 procfs 字段 (N+3)
ppid, _ := strconv.Atoi(fields[1]) // fields[1] = 字段 (4) ppid
state := processState(fields) // fields[0] = 字段 (3) state
startTicks, _ := strconv.ParseInt(fields[19], 10, 64) // fields[19] = 字段 (22) starttime
utime, _ := strconv.ParseFloat(fields[11], 64) // fields[11] = 字段 (14) utime
stime, _ := strconv.ParseFloat(fields[12], 64) // fields[12] = 字段 (15) stime
rssPages, _ := strconv.ParseFloat(fields[21], 64) // fields[21] = 字段 (24) rss
注意它用 LastIndex 找右括号——因为进程名本身可能包含括号(比如 a.out (deleted) )。
state 字段 (3) 的完整取值(来自 man 5 proc_pid_stat ):
| 状态码 | 含义 |
|---|---|
| R | 运行(Running) |
| S | 可中断睡眠(Interruptible sleep) |
| D | 不可中断睡眠(Uninterruptible disk sleep) |
| Z | 僵尸(Zombie) |
| T | 停止(Stopped,收到信号) |
| t | 跟踪停止(Tracing stop,被 debugger 挂住) |
| I | 空闲(Idle,Linux 4.14+,常见于内核线程) |
| X | 死亡(Dead,极少见) |
witr 只判断了 Z 和 T 两种异常状态,其余的视为 healthy 。
除了状态码, utime 和 stime 也被用于健康检测。witr 把两者相加除以 CLK_TCK 得到总 CPU 时间(秒),超过 2 小时就标记为 high-cpu 。 rssPages 则乘以页大小算出常驻内存(MB),超过 1GB 标记为 high-mem :
totalCPU := (utime + stime) / clkTck
if totalCPU > 60*60*2 { // >2h CPU time
health = "high-cpu"
}
memBytes := rssPages * pageSize
memMB := memBytes / (1024 * 1024)
if memMB > 1024 { // >1GB RSS
health = "high-mem"
}
注意 witr 的健康检查是串行覆盖的——如果进程同时是僵尸且 CPU 时间超 2 小时, zombie 会被 high-cpu 覆盖(因为后面的 if 不是 else if )。
用 shell 快速检查一个进程的 CPU 时间和内存:
rest=$(grep -oP '(?<=\)).*' /proc/1152/stat) # utime=fields[11] (字段14), stime=fields[12] (字段15), rss=fields[21] (字段24) tps=$(getconf CLK_TCK) ps=$(getconf PAGESIZE) echo "$rest" | awk -v tps=$tps -v ps=$ps '{ total_cpu = ($12 + $13) / tps rss_mb = $22 * ps / 1024 / 1024 printf "总CPU时间: %.0f 秒 (%.1f 分钟)\n", total_cpu, total_cpu/60 printf "RSS: %.1f MB\n", rss_mb }'
总CPU时间: 214 秒 (3.6 分钟) RSS: 418.4 MB
等价的 shell 命令(同样用 lookbehind/lookahead,正确处理进程名中的括号):
# 提取进程名、状态、PPID # 进程名:第一个 ( 和最后一个 ) 之间 comm=$(grep -oP '(?<=\().*(?=\))' /proc/1152/stat) # 状态和 PPID:最后一个 ) 之后的字段 rest=$(grep -oP '(?<=\)).*' /proc/1152/stat) echo "进程名: $comm" echo "状态: $(echo $rest | awk '{print $1}')" echo "PPID: $(echo $rest | awk '{print $2}')"
进程名: emacs 状态: S PPID: 578
启动时间的计算
启动时间不是直接给的,而是一个从系统启动开始计算的 tick 数。witr 的换算方法:
startedAt := bootTime().Add(time.Duration(startTicks) * time.Second / time.Duration(ticksPerSecond()))
三步:先拿到系统启动时间(boot time),再把 tick 数转换成秒,加上去。
bootTime()读的是/proc/stat里的btime行(注意/proc/stat是系统级统计文件,不是/proc/[pid]/stat)ticksPerSecond()调的是sysconf(_SC_CLK_TCK),Linux 上通常是 100
先看看 btime 长什么样:
grep btime /proc/stat
btime 1749432718
1749432718 就是系统启动时的 Unix 时间戳(秒)。
用 shell 复现完整的启动时间计算:
# 系统启动时间(秒级时间戳) btime=$(grep btime /proc/stat | awk '{print $2}') # 当前进程的 starttime:先取括号后的字段,再取 fields[19](对应 procfs 字段 (22)) rest=$(grep -oP '(?<=\)).*' /proc/$$/stat) ticks=$(echo "$rest" | awk '{print $20}') # ticks per second tps=$(getconf CLK_TCK) # 计算启动时间 date -d @$((btime + ticks / tps)) "+%Y-%m-%d %H:%M:%S"
2026-06-10 14:14:15
完整命令行:/proc/[pid]/cmdline
stat 里的进程名会被内核截断到 15 个字符。要看完整命令行,得读 /proc/[pid]/cmdline :
cmdlineBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
cmd := strings.ReplaceAll(string(cmdlineBytes), "\x00", " ")
cmdline = strings.TrimSpace(cmd)
这个文件的特别之处在于,参数之间用 \0 (空字节)分隔,而不是空格。所以 witr 把 \0 替换成空格。
# 原始内容(用 cat -v 显示空字节为 ^@) cat -v /proc/1152/cmdline echo "---" # 替换空字节后 tr '\0' ' ' < /proc/1152/cmdline
emacs^@--daemon^@--- emacs --daemon
进程的可执行文件路径
// 通过符号链接获取可执行文件的完整路径
exePath, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
readlink /proc/$$/exe
/usr/bin/bash
这个符号链接还有一个隐藏用法:如果可执行文件在进程启动后被删除了,链接目标会以 = (deleted)= 结尾。witr 用这个检测可疑进程:
func isBinaryDeleted(pid int) bool {
exePath, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
if err != nil {
return false
}
return strings.HasSuffix(exePath, " (deleted)")
}
进程的运行用户
witr 没有直接从 /proc 读 UID,而是通过文件系统间接获取:
func readUser(pid int) string {
path := "/proc/" + strconv.Itoa(pid)
info, err := os.Stat(path)
// ...
stat, ok := info.Sys().(*syscall.Stat_t)
uid := int(stat.Uid)
// 然后查 /etc/passwd 缓存映射 UID -> 用户名
}
原理是 /proc/[pid] 目录的 owner 就是进程的 UID。 os.Stat 拿到文件的 stat_t 结构,从中取 Uid 字段,再去 /etc/passwd 查对应的用户名。
# 方法一:看 /proc/[pid] 目录的 owner stat -c '%U' /proc/$$ # 方法二:直接看 status 文件里的 Uid 行 grep Uid /proc/$$/status
lujun9972 Uid: 1000 1000 1000 1000
进程的来路
"这个进程从哪来的?"——witr 的核心功能是构建从 init(PID 1)到目标进程的完整祖先链。
祖先链追溯
ancestry.go 的实现很直接:从目标 PID 开始,反复读取 PPID,一直追溯到 PID 1:
func ResolveAncestry(pid int) ([]model.Process, error) {
var chain []model.Process
seen := make(map[int]bool) // 防环:subreaper 场景下 PPID 链理论上可能成环
current := pid
for current > 0 {
if seen[current] { break }
seen[current] = true
p, err := ReadProcess(current)
if err != nil { break }
chain = append(chain, p)
if p.PPID == 0 || p.PID == 1 { break }
current = p.PPID // 往上走一级
}
// 反转,让 PID 1 在最前面
for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 {
chain[i], chain[j] = chain[j], chain[i]
}
return chain, nil
}
用 shell 模拟这个过程。这里用 grep -oP 的 lookbehind/lookahead 来提取括号内的进程名——它匹配第一个 (\ 和最后一个 )\ 之间的内容,跟 Go 代码的 FirstIndex("(") + LastIndex(")") 行为一致:
# 从当前 shell 向上追溯祖先链 pid=$$ while [ "$pid" -gt 0 ]; do # 用 lookbehind/lookahead 提取括号内容(正确处理进程名中的括号) comm=$(grep -oP '(?<=\().*(?=\))' /proc/$pid/stat 2>/dev/null) # 取最后一个 ) 之后的第二个字段(PPID) ppid=$(grep -oP '(?<=\)).*' /proc/$pid/stat 2>/dev/null | awk '{print $2}') echo "$comm (pid $pid)" [ "$pid" = "1" ] && break pid=$ppid done
bash (pid 135892) bash (pid 135889) python3 (pid 135883) emacs (pid 3666) emacs (pid 2203) bash (pid 2193) systemd (pid 1653) systemd (pid 1)
这就是 witr 输出中 "Why It Exists" 那行的数据来源。
工作目录
cwd, cwdErr := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
/proc/[pid]/cwd 是一个符号链接,指向进程的当前工作目录。
readlink /proc/$$/cwd
/home/lujun9972/github/witr
Git 仓库检测
拿到工作目录后,witr 会向上最多查找 10 层,看有没有 .git 目录:
func detectGitInfo(cwd string) (string, string) {
searchDir := cwd
for depth := 0; depth < 10; depth++ {
gitDir := filepath.Join(searchDir, ".git")
if fi, err := os.Stat(gitDir); err == nil && fi.IsDir() {
gitRepo := filepath.Base(searchDir) // 仓库名 = 目录名
head, _ := os.ReadFile(filepath.Join(gitDir, "HEAD"))
headStr := strings.TrimSpace(string(head))
if strings.HasPrefix(headStr, "ref: ") {
ref := strings.TrimPrefix(headStr, "ref: ")
gitBranch = strings.TrimPrefix(ref, "refs/heads/")
}
return gitRepo, gitBranch
}
parent := filepath.Dir(searchDir)
if parent == searchDir { break }
searchDir = parent
}
return "", ""
}
核心就是读 .git/HEAD 文件。这个文件要么直接是一个 commit hash(detached HEAD),要么是一个 ref: 引用指向分支名。
cat .git/HEAD
ref: refs/heads/main
进程的网络足迹
/proc/net 下的 socket 表
witr 的网络信息获取在 net_linux.go 里。它读取四个文件:
parse("/proc/net/tcp", "TCP", false)
parse("/proc/net/tcp6", "TCP6", true)
parse("/proc/net/udp", "UDP", false)
parse("/proc/net/udp6", "UDP6", true)
这里有个工程细节:=ResolveAncestry= 每走一步 PPID 都调用 ReadProcess ,而 ReadProcess 里会调 readSocketsCached() 解析这四个文件。一个祖先链走 8-10 步,就要解析 8-10 次。witr 用了一个 2 秒 TTL 的缓存(=socketCacheTTL = 2 * time.Second= )避免重复解析——ancestry walk 通常在毫秒内完成,缓存不会过期导致数据不一致。
每个文件的格式都差不多。先看看 /proc/net/tcp 长什么样:
cat /proc/net/tcp | head -5
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode 0: 0100007F:1A0B 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 23826 1 0000000000000000 100 0 0 10 0 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 25084 1 0000000000000000 100 0 0 10 0 2: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 35761 1 0000000000000000 100 0 0 10 0
关键字段:
local_address:本地地址和端口,十六进制,格式是IP:Port。比如0100007F:1A0B就是127.0.0.1:6667st:socket 状态,十六进制。0A= LISTENinode:fields[9](0-indexed),socket 的 inode 号。这个是关联到进程的桥梁
witr 用一个映射表把状态码转成可读名称:
var stateMap = map[string]string{
"01": "ESTABLISHED", "02": "SYN_SENT", "03": "SYN_RECV",
"04": "FIN_WAIT1", "05": "FIN_WAIT2", "06": "TIME_WAIT",
"07": "CLOSE", "08": "CLOSE_WAIT", "09": "LAST_ACK",
"0A": "LISTEN", "0B": "CLOSING",
}
地址解析
十六进制地址的解析有个坑。IPv4 地址在 /proc/net/tcp 里是小端序存储的。比如 0100007F 要倒过来读: 7F 00 00 01 ,就是 127.0.0.1 。
witr 的解析代码:
func parseAddr(raw string, ipv6 bool) (string, int) {
parts := strings.Split(raw, ":")
portHex := parts[1]
port, _ := strconv.ParseInt(portHex, 16, 32)
ipHex := parts[0]
b, _ := hex.DecodeString(ipHex)
// IPv4: 小端序,4 字节倒序
ip := strconv.Itoa(int(b[3])) + "." +
strconv.Itoa(int(b[2])) + "." +
strconv.Itoa(int(b[1])) + "." +
strconv.Itoa(int(b[0]))
return ip, int(port)
}
IPv6 更复杂—— /proc/net/tcp6 里的地址是以 4 个 32 位小端序组存储的,每组内部要倒序。
Dual-stack:=::= 隐含 0.0.0.0
读 /proc/net/tcp6 时你会发现一个现象:有些进程只监听 :: (IPv6 any-address),没有 0.0.0.0 的条目。但 IPv4 流量照样能连进来。
这是因为 Linux 默认开启了 dual-stack:一个 :: 监听同时接受 IPv4 和 IPv6 连接。控制开关是 /proc/sys/net/ipv6/bindv6only :
cat /proc/sys/net/ipv6/bindv6only
0
值为 0(默认)表示 dual-stack 开启,=::= 隐含 0.0.0.0 。值为 1 表示 IPv6 和 IPv4 独立,=::= 只接受 IPv6。
witr 在 process_linux.go 里做了对应的合成:如果 dual-stack 开启、进程监听了 :: 且没有显式的 0.0.0.0 监听,就补一个 IPv4 条目:
dualStackAllowed := isDualStackEnabled()
for _, inode := range inodes {
if s, ok := sockets[inode]; ok {
procSockets = append(procSockets, s)
// 如果 :: 监听且没有显式 0.0.0.0,合成隐式的 IPv4 映射
if dualStackAllowed && s.State == "LISTEN" && s.Address == "::" &&
!ipv4Listeners[s.Port] {
v4 := s
v4.Address = "0.0.0.0"
v4.Protocol = strings.TrimSuffix(s.Protocol, "6")
procSockets = append(procSockets, v4)
}
}
}
这不是 /proc/net/tcp 直接给出的数据,而是 witr 根据内核参数做的推断。对于回答"这个端口对外暴露了吗?"这类问题,这个补全很关键。
用 shell 快速验证一下:
# 解析 /proc/net/tcp 中的地址和端口 awk 'NR>1 { split($2, addr, ":") # 解析端口 cmd = "printf %d 0x" addr[2] cmd | getline port; close(cmd) # 解析 IPv4 地址(小端序) hex = addr[1] ip = "" for (i = 6; i >= 0; i -= 2) { byte = substr(hex, i+1, 2) cmd = "printf %d 0x" byte cmd | getline n; close(cmd) ip = ip (ip=="" ? "" : ".") n } printf "%s:%d (state=%s)\n", ip, port, $4 }' /proc/net/tcp | head -5
从 socket inode 到 PID
知道哪个端口在监听还不够,还得知道是哪个进程。桥梁是 /proc/[pid]/fd/ 目录——进程每打开一个 socket,这里就多一个符号链接 socket:[inode] 。
witr 的做法:
// 遍历 /proc/[pid]/fd/ 下的所有 fd
fdPath := fmt.Sprintf("/proc/%d/fd", pid)
fds, _ := os.ReadDir(fdPath)
for _, fd := range fds {
link, _ := os.Readlink(fmt.Sprintf("%s/%s", fdPath, fd.Name()))
if strings.HasPrefix(link, "socket:[") {
inode := strings.TrimSuffix(strings.TrimPrefix(link, "socket:["), "]")
// 用 inode 去 socket 表里查地址和端口
}
}
shell 复现:
# 看看当前进程打开了哪些 socket ls -l /proc/$$/fd 2>/dev/null | grep socket | head -5 echo "---" # 提取 inode 号 readlink /proc/$$/fd/3 2>/dev/null
lrwx------ 1 lujun9972 lujun9972 64 Jun 9 10:32 3 -> socket:[23826] --- socket:[23826]
拿着这个 inode 23826 去 /proc/net/tcp 里找,就能知道这个 socket 监听在哪个地址和端口上。
端口→PID 的完整映射
witr 在 ListOpenPorts() 里做了完整映射:先解析所有 socket 表,再遍历所有 /proc/[pid]/fd/ 做 inode 匹配。用 bash 模拟:
# 列出所有 LISTEN 状态的端口和对应的 PID # 先构建 inode → 端口映射表(用 awk 精确取第 10 列 inode,避免误匹配) # 注意:用 < <(...) 进程替换而非管道,否则 while 在子 shell 中,数组不会被填充 declare -A inode_port for f in /proc/net/tcp /proc/net/tcp6; do while read inode port; do inode_port[$inode]=$port done < <(awk 'NR>1 && $4=="0A" { split($2, addr, ":") printf "%s %d\n", $10, strtonum("0x" addr[2]) }' "$f") done # 遍历所有进程的 fd,匹配 socket inode for pid in $(ls /proc | grep -E '^[0-9]+$'); do for fd in /proc/$pid/fd/*; do link=$(readlink "$fd" 2>/dev/null) || continue if [[ "$link" == socket:\[*\] ]]; then inode=${link#socket:[} inode=${inode%]} if [[ -n "${inode_port[$inode]}" ]]; then echo "PID=$pid PORT=${inode_port[$inode]} INODE=$inode" fi fi done done 2>/dev/null | sort -t= -k3 -n | uniq | head -10
进程的归属
容器检测:/proc/[pid]/cgroup
witr 通过 /proc/[pid]/cgroup 判断进程是否跑在容器里:
cgroupFile := fmt.Sprintf("/proc/%d/cgroup", pid)
cgroupData, _ := os.ReadFile(cgroupFile)
cgroupStr := string(cgroupData)
switch {
case strings.Contains(cgroupStr, "docker"):
container = "docker"
case strings.Contains(cgroupStr, "podman"), strings.Contains(cgroupStr, "libpod"):
container = "podman"
case strings.Contains(cgroupStr, "kubepods"):
container = "kubernetes"
case strings.Contains(cgroupStr, "containerd"):
container = "containerd"
case strings.Contains(cgroupStr, "colima"):
container = "colima"
}
看看这个文件长什么样:
cat /proc/$$/cgroup
0::/user.slice/user-1000.slice/session-2.scope
如果跑在 Docker 容器里,你会看到类似这样的:
12:cpuset:/docker/abc123def456... 1:name=systemd:/docker/abc123def456...
注意上面是 cgroup v1 的格式(每行一个子系统),而你本机的 0::/user.slice/... 是 cgroup v2 格式(统一层级)。在 cgroup v2 下,Docker 容器的路径变成类似 0::/system.slice/docker-abc123.scope ,但路径里仍然包含 docker 关键字,所以 witr 的 strings.Contains 匹配在两种格式下都能工作。
cgroup 路径里的 docker/ 、 libpod/ 、 kubepods/ 就是容器运行时的指纹。
拿到容器 ID 后,witr 会调用对应的 CLI 获取容器名:
// 用 docker inspect 拿容器名和 compose 信息
cmd := exec.CommandContext(ctx, "docker", "inspect", id,
"--format", "{{.Name}}|{{index .Config.Labels \"com.docker.compose.project\"}}|{{index .Config.Labels \"com.docker.compose.service\"}}")
Snap/Flatpak 沙箱检测
如果 cgroup 没匹配到任何容器,witr 还会检查环境变量——Snap 和 Flatpak 沙箱不走 cgroup,而是在环境变量里留指纹:
if container == "" {
for _, e := range env {
if strings.HasPrefix(e, "SNAP_NAME=") {
container = "snap: " + e[len("SNAP_NAME="):]
}
if strings.HasPrefix(e, "FLATPAK_ID=") {
container = "flatpak: " + e[len("FLATPAK_ID="):]
}
}
}
# 检查当前进程是否在 Snap 或 Flatpak 沙箱中 tr '\0' '\n' < /proc/$$/environ | grep -E 'SNAP_NAME=|FLATPAK_ID='
如果输出为空说明不在沙箱里。Snap 安装的应用(比如 code 编辑器)会带 SNAP_NAME 变量。
docker-proxy 的特殊处理
Docker 在发布端口时,会启动 docker-proxy 进程做端口转发。这个进程的 cgroup 路径里没有容器 ID,但它实际上是容器网络的一部分。witr 对 docker-proxy 做了特殊处理:从命令行参数中提取 -container-ip 的值,然后通过 docker network inspect bridge 反查容器名。
// docker-proxy 命令行长这样:
// docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
func resolveDockerProxyContainer(cmdline string) string {
// 从 cmdline 中提取 -container-ip 参数
parts := strings.Fields(cmdline)
for i, part := range parts {
if part == "-container-ip" && i+1 < len(parts) {
containerIP = parts[i+1]
}
}
// 用 docker network inspect bridge 查 IP 对应的容器名
out, _ := exec.Command("docker", "network", "inspect", "bridge",
"--format", "{{range .Containers}}{{.Name}}:{{.IPv4Address}}{{end}}").Output()
// 匹配 containerIP → 容器名
}
# 查看 docker-proxy 的命令行参数 ps aux | grep docker-proxy | head -3 echo "---" # 手动查 bridge 网络中 IP 与容器名的对应关系 docker network inspect bridge --format '{{range .Containers}}{{.Name}}:{{.IPv4Address}}{{"\n"}}{{end}}'
systemd 服务识别
witr 用两种方式找进程所属的 systemd service:
- 从 cgroup 路径提取 unit 名称
- 用
systemctl status <pid>反查
// 方法一:从 /proc/[pid]/cgroup 提取 .service 或 .scope
func getUnitNameFromCgroup(pid int) string {
data, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid))
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, ":", 3)
path := parts[2]
pathParts := strings.Split(path, "/")
for i := len(pathParts) - 1; i >= 0; i-- {
if strings.HasSuffix(pathParts[i], ".service") ||
strings.HasSuffix(pathParts[i], ".scope") {
return pathParts[i]
}
}
}
return ""
}
// 方法二:systemctl status 按PID查
svcOut, _ := exec.Command("systemctl", "status", fmt.Sprintf("%d", pid)).CombinedOutput()
拿到 unit 名称后,witr 会查询服务描述、配置文件路径和重启次数:
// 一次性查询多个属性
props := []string{"Description", "FragmentPath", "SourcePath", "NRestarts"}
cmd := exec.Command("systemctl", "show", "-p", "Description", "-p", "FragmentPath",
"-p", "NRestarts", "--", unitName)
等价 shell:
# 从 cgroup 获取 unit 名称 cat /proc/$$/cgroup echo "---" # 用 systemctl 按 PID 查服务 systemctl status $$ 2>/dev/null | head -5 echo "---" # 查询服务属性 systemctl show -p Description -p FragmentPath -p NRestarts session-2.scope 2>/dev/null
systemd timer 调度信息
如果服务是被 timer 触发的,witr 会额外查 timer 的调度计划:
// 检查是否有对应的 .timer unit
timerUnit := strings.TrimSuffix(unitName, ".service") + ".timer"
props := querySystemdProperties(
[]string{"TimersCalendar", "TimersMonotonic", "LastTriggerUSec", "NextElapseUSecRealtime"},
timerUnit,
)
这就是为什么 witr 能输出类似 "every 1h, last: 2h ago, next: in 5min" 这样的调度信息。
环境变量:/proc/[pid]/environ
envBytes, _ := os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
for _, e := range strings.Split(string(envBytes), "\x00") {
if e != "" { env = append(env, e) }
}
和 cmdline 一样,变量之间用 \0 分隔。读环境变量有两个用途:
- 显示进程的运行环境(
--env参数) - 检测 SSH 来源(
SSH_CLIENT变量)、Snap 沙箱(SNAP_NAME)、LD_PRELOAD 注入等
# 查看进程环境变量(注意权限限制) tr '\0' '\n' < /proc/$$/environ | head -10
SHELL=/bin/bash SESSION_MANAGER=local/unix:@/tmp/.ICE-unix/1752,unix/unix:/tmp/.ICE-unix/1752 QT_ACCESSIBILITY=1 COLORTERM=truecolor XDG_CONFIG_DIRS=/etc/xdg ...
注意:读 /proc/[pid]/environ 需要 ptrace 读权限(=PTRACE_MODE_READ_FSCREDS= )。同 UID 的进程一般可以互读,不同 UID 则需要 root。权限不够时会报 Permission denied ,而非返回空内容。
进程的健康与安全
Linux Capabilities
普通进程如果有危险 capability(比如 CAP_SYS_ADMIN ),可能是个安全风险。witr 从 /proc/[pid]/status 读取:
func ReadCapabilities(pid int) []string {
data, _ := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "CapEff:\t") {
hex := strings.TrimSpace(strings.TrimPrefix(line, "CapEff:"))
return decodeCapabilities(hex)
}
}
return nil
}
CapEff: 后面是一个 64 位的十六进制掩码,每一位对应一个 capability。比如第 21 位是 CAP_SYS_ADMIN 。
grep CapEff /proc/$$/status
CapEff: 0000000000000000
全零说明没有额外 capability(普通用户正常情况)。
详细内存信息:/proc/[pid]/statm
verbose 模式下,witr 读取更详细的内存数据:
// statm 的 7 个字段:total resident shared text lib data dirty
if statmData, err := os.ReadFile(fmt.Sprintf("/proc/%d/statm", pid)); err == nil {
fields := strings.Fields(string(statmData))
pageSize := uint64(os.Getpagesize())
total, _ := strconv.ParseUint(fields[0], 10, 64) // 虚拟内存(页)
resident, _ := strconv.ParseUint(fields[1], 10, 64) // 常驻内存(页)
memInfo.VMS = total * pageSize
memInfo.RSS = resident * pageSize
}
所有值都是页数,要乘以页大小(通常 4096 字节)才是实际字节数。
echo "页大小: $(getconf PAGESIZE) 字节" echo "---" cat /proc/$$/statm echo "---" # 手动计算:第二列(resident)× 页大小 awk -v ps=$(getconf PAGESIZE) '{printf "RSS: %.1f MB\n", $2*ps/1024/1024}' /proc/$$/statm
页大小: 4096 字节 --- 1062 1056 525 19 0 320 0 --- RSS: 4.2 MB
I/O 统计:/proc/[pid]/io
if ioData, err := os.ReadFile(fmt.Sprintf("/proc/%d/io", pid)); err == nil {
// 读取 read_bytes, write_bytes, syscr, syscw 四个指标
}
cat /proc/$$/io
rchar: 1692823 wchar: 259304 syscr: 1610 syscw: 300 read_bytes: 0 write_bytes: 0 cancelled_write_bytes: 0
rchar/wchar:进程读写过的总字节数(包括缓存命中)read_bytes/write_bytes:实际从磁盘读写的字节数syscr/syscw:read/write 系统调用的次数
打开的文件描述符
fdDir, _ := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid))
fdCount = len(fdDir)
for _, fdEntry := range fdDir {
linkTarget, _ := os.Readlink(fmt.Sprintf("/proc/%d/fd/%s", pid, fdEntry.Name()))
fileDescs = append(fileDescs, fmt.Sprintf("%s -> %s", fdEntry.Name(), linkTarget))
}
# fd 数量 ls /proc/$$/fd | wc -l echo "---" # 前 10 个 fd 的指向 ls -l /proc/$$/fd | head -10
5 --- total 0 lrwx------ 1 lujun9972 lujun9972 64 Jun 9 10:32 0 -> /dev/pts/3 lrwx------ 1 lujun9972 lujun9972 64 Jun 9 10:32 1 -> /dev/pts/3 lrwx------ 1 lujun9972 lujun9972 64 Jun 9 10:32 2 -> /dev/pts/3 lrwx------ 1 lujun9972 lujun9972 64 Jun 9 10:32 3 -> socket:[23826] lrwx------ 1 lujun9972 lujun9972 64 Jun 9 10:32 4 -> anon_inode:[eventpoll]
FD 限制:/proc/[pid]/limits
光知道开了多少 fd 还不够,还得知道上限是多少。witr 从 /proc/[pid]/limits 读 Max open files 行:
data, _ := os.ReadFile(fmt.Sprintf("/proc/%d/limits", pid))
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "Max open files") {
fields := strings.Fields(line)
// fields[3] = soft limit
}
}
grep "Max open files" /proc/$$/limits
Max open files 1048576 1048576 files
三个数字分别是 soft limit、hard limit 和单位名称。
文件锁:/proc/locks 或 lslocks
witr 还会检查进程持有哪些文件锁。优先用 lslocks 命令,找不到时回退到解析 /proc/locks :
// /proc/locks 的每行格式:
// ID: TYPE ACCESS PID DEVICE:INODE START END
// 例如: 1: FLOCK ADVISORY WRITE 1234 08:01:5678 0 EOF
for _, line := range strings.Split(string(lockedFileData), "\n") {
fields := strings.Fields(line)
lockPid := fields[4] // 持有锁的 PID
deviceInode := fields[5] // 设备号:inode号
if lockPid == pidStr {
result = append(result, deviceInode)
}
}
# 用 lslocks 查看进程的文件锁 lslocks -o PATH,TYPE,MODE,PID | head -10 echo "---" # 或者直接看 /proc/locks head -5 /proc/locks
线程数
if statusData, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)); err == nil {
for _, line := range strings.Split(string(statusData), "\n") {
if strings.HasPrefix(line, "Threads:") {
threadCount, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Threads:")))
}
}
}
grep Threads /proc/$$/status
Threads: 1
小结
witr 能回答"这个进程为什么在运行",靠的不是什么黑魔法,而是一个个 /proc 文件的拼图:
| 问题 | 数据源 |
|---|---|
| 进程叫什么?命令行是什么? | /proc/[pid]/stat , cmdline |
| 谁启动的? | stat 的 PPID 字段,递归追溯 |
| 什么时候启动的? | stat 的 starttime + btime |
| 在哪个目录运行的? | /proc/[pid]/cwd 符号链接 |
| 在监听什么端口? | /proc/net/tcp + fd/ 的 inode |
| 跑在容器里吗? | /proc/[pid]/cgroup |
| Snap/Flatpak 沙箱? | /proc/[pid]/environ 里的环境变量 |
| docker-proxy 转发给谁? | cmdline 的 -container-ip + docker network inspect |
| 属于哪个 systemd 服务? | cgroup 路径 + systemctl show |
| 环境变量是什么? | /proc/[pid]/environ |
| 有什么特权? | /proc/[pid]/status 的 CapEff |
| 内存用了多少? | /proc/[pid]/statm |
| 磁盘 I/O 多少? | /proc/[pid]/io |
| 打开了哪些文件? | /proc/[pid]/fd/ + readlink |
| FD 上限是多少? | /proc/[pid]/limits 的 Max open files |
| 持有哪些文件锁? | lslocks 或 /proc/locks |
| 线程数? | /proc/[pid]/status 的 Threads |
man 5 proc_pid_stat 有这些字段的完整文档。注意还有个 /proc/[pid]/status 文件,它是 stat 的人可读替代——很多字段都有标签(如 VmRSS: 、 CapEff: 、 Threads: ),不用解析裸数字。witr 用它读 capabilities 和线程数。
witr 的 源码目录 是一份很好的实战索引——当你想知道某个进程信息怎么取,直接去看 witr 是怎么读的就行。