拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux 用户登录动态监测:utmp 读取与等待指定用户登录实战

Linux 用户登录动态监测:utmp 读取与等待指定用户登录实战

操作系统实验里有一类题目看起来很朴素,却特别能暴露你对系统接口的掌握程度,比如这个用户登录动态监测:程序跑起来先把当前系统中的已登录用户名单列出来,再检查某个指定用户是否已经登录;如果没登录,不退出,而是等着,直到这个人真正出现在登录列表里。这个需求在操作系统原理和实践课程里很常见,但真动手写,会碰到 utmp 结构、登录记录更新、权限、轮询间隔、终端类型、远程会话判断等一连串细节。它适合刚学 Linux 系统编程的同学,也适合需要做登录审计、值守脚本、实验验收的运维和开发者参考。别小看“列出已登录用户”和“等待指定用户登录”这两个动作,前者要求你理解操作系统怎么记录会话,后者要求你设计一个可靠又不折腾 CPU 的监测循环。下面我就按这个实验从思路、机制到代码、排错完整走一遍,尽量把课本上没展开的坑也讲清楚。

1. 实验目标拆解与登录监测的整体思路

1.1 需求不是“查一次”,而是“持续等”

这个实验的第一层需求是静态查询:程序启动后,先列出当前系统中的已登录用户名单。这个动作很像你平时敲who或w,但实验要求你自己写逻辑,而不是完全依赖现成命令。第二层需求才是关键:检查指定用户是否已登录。如果已经登录,就显示该用户的会话信息,比如终端、登录时间、来源主机;如果未登录,程序不能立刻退出,而要进入等待状态,周期性重新检查,直到目标用户登录成功。很多同学第一次做会写成死循环里不停读文件,结果 CPU 占用上去,实验验收时被老师一眼看出来。更稳妥的做法是“扫描一次、睡一会儿、再扫描”,用时间换 CPU,同时设置合理的轮询间隔和可选超时。

1.2 数据源选择:为什么盯住 utmp

Linux 系统记录用户登录信息的传统文件是/var/run/utmp,现在很多发行版实际路径是/run/utmp,但宏_PATH_UTMP会指向正确位置。这个文件不是普通文本,而是由一组struct utmp记录组成的二进制数据库。登录程序在用户认证成功后,会往这里写入一条USER_PROCESS记录;用户退出时,记录类型会变成DEAD_PROCESS或对应条目被更新。who、users、w这些命令底层大多也在读它。相比解析who的文本输出,直接读 utmp 更接近操作系统原理,字段更完整,也不容易受到输出格式、语言环境、列宽变化的影响。当然,直接读 utmp 需要处理结构体字段、字节序、权限和不同发行版的差异,这正好是实验想训练的能力。

1.3 技术路线:C语言实现为主,Shell作对照

我建议主线用 C 语言实现:用setutent()、getutent()、endutent()遍历 utmp,用ut_type == USER_PROCESS筛选真实登录会话,用strncmp比对ut_user。等待逻辑单独封装成函数,内部用sleep()控制频率。这样既符合操作系统实验对系统调用的要求,也方便扩展成多用户监测、写日志、超时退出。Shell 脚本可以作为对照方案,用who | awk快速验证思路,但验收时如果要求“程序”而不是“命令组合”,还是 C 代码更稳。两条路线我都建议你跑一遍,因为 Shell 能帮你快速确认系统里到底有没有登录记录,C 代码则帮你理解底层数据结构。

1.4 环境准备与最小验证

动手前先做三个检查。第一,确认系统有/run/utmp或/var/run/utmp,可以执行ls -l /run/utmp /var/run/utmp,一般会看到一个 root 和 utmp 组可读写的文件。第二,执行who,看当前是否有活动会话;如果who有输出而你的程序读不到,优先查权限和头文件。第三,确认编译环境,gcc --version、make --version至少有一个可用。实验环境如果是 Ubuntu 20.04、CentOS 7、RHEL 7、麒麟、统信等常见系统,传统 utmp 机制基本可用;如果是在 Docker 容器里做,可能没有挂载宿主机的 utmp,现象就是名单永远为空,这时候要先解决环境,而不是怀疑代码。准备一个测试账号也很重要,比如user1,方便你实际登录、退出、再登录,观察程序输出变化。

2. utmp/utmpx 机制与用户登录数据来源

2.1 utmp、wtmp、btmp 的分工

Linux 里和登录相关的记录文件不止一个。utmp记录当前正在登录的用户,是“现在谁在线”的快照;wtmp记录历史登录、注销、关机、重启等事件,last命令读的就是它;btmp记录失败登录尝试,lastb命令读它。实验要求“动态监测当前已登录用户”,所以核心是utmp,不是wtmp。有些教材会写成/var/adm/utmp、/var/run/utmp,不同版本路径有差异,代码里应优先使用_PATH_UTMP宏,而不是把路径写死。utmpx是 utmp 的扩展接口,提供setutxent()、getutxent()等函数,字段更丰富,但很多 Linux 实验环境用传统utmp.h就够了。你如果发现编译时报getutent未定义,先检查是否包含<utmp.h>,再检查是否需要在编译时定义_GNU_SOURCE。

2.2 struct utmp 里真正要用的字段

struct utmp字段不少,但这个实验只需要盯住几个。ut_type表示记录类型,USER_PROCESS才是用户已登录;ut_user是用户名;ut_line是终端名,比如tty1、pts/0;ut_host是远程登录来源主机,本地登录通常为空;ut_tv是登录时间,类型是struct timeval,里面tv_sec是秒级时间戳;ut_pid是相关进程 ID。还有一个容易忽略的点:这些字符数组不一定以\0结尾,尤其是从二进制文件读出来的旧记录。比较用户名时不要直接用strcmp,更稳的是strncmp(u->ut_user, username, UT_NAMESIZE) == 0,或者先复制到临时缓冲区并手动补零。打印ut_host时也要判断首字符是否为空,否则可能输出一串空白。把这些字段搞明白,后面代码基本就是填空。

2.3 登录过程怎样写入记录

用户从getty、login、sshd、显示管理器等入口登录时,认证成功后会调用相关函数更新 utmp。传统流程里,login程序会填写ut_type、ut_pid、ut_line、ut_user、ut_host、ut_tv,然后写入/var/run/utmp。远程 SSH 登录通常由sshd更新记录,所以你会看到终端是pts/0之类,来源主机是客户端地址。图形界面登录则由显示管理器处理,有些系统会写 utmp,有些系统更多依赖systemd-logind。这也是为什么实验里偶尔会遇到“明明登录了,但 utmp 里没有”的情况。理解写入方,你排查时就知道该看sshd配置、PAM 模块还是 logind。实验代码只负责读,不负责写,所以不要试图手动修改 utmp,容易破坏系统记录。

2.4 过滤 USER_PROCESS 与处理多会话

遍历 utmp 时不能把所有记录都当成“已登录用户”。记录类型包括EMPTY、RUN_LVL、BOOT_TIME、LOGIN_PROCESS、USER_PROCESS、DEAD_PROCESS等。LOGIN_PROCESS通常表示某个终端正在等待登录,用户还没认证成功;USER_PROCESS才是真正已登录。如果不过滤,名单里可能出现空用户名、login、reboot之类奇怪条目。另一个细节是同一用户可能开多个会话,比如本地一个、SSH 一个、图形终端一个。实验要求“检查指定用户是否已登录”,只要存在至少一条USER_PROCESS记录就算已登录;但如果要显示“相应信息”,最好把所有匹配会话都打印出来,并标上终端、来源、登录时间。这样验收时信息更完整,也更能体现你对多会话场景的考虑。

3. 核心功能实现:列出已登录用户并检测指定用户

3.1 读取当前登录名单的完整实现

下面这版代码可以直接作为实验主体。它先定义打印函数,再定义检测函数,最后实现等待循环。注意我用了UT_NAMESIZE做安全比较,并对时间做了本地格式化。代码里没有花哨技巧,重点是逻辑清楚,方便你对照实验报告。实际编译时如果提示implicit declaration of function 'getutent',可以在文件最上方加#define _GNU_SOURCE,或者检查编译参数。

#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <utmp.h> #include <time.h> #include <unistd.h> #include <errno.h> static void format_time(time_t t, char *buf, size_t len) { struct tm *tm_info = localtime(&t); if (tm_info == NULL) { snprintf(buf, len, "未知时间"); return; } strftime(buf, len, "%Y-%m-%d %H:%M:%S", tm_info); } void list_logged_users(void) { struct utmp *u; int count = 0; printf("%-16s %-12s %-20s %s\n", "用户名", "终端", "登录时间", "来源"); printf("------------------------------------------------------------\n"); setutent(); while ((u = getutent()) != NULL) { if (u->ut_type != USER_PROCESS) { continue; } if (u->ut_user[0] == '\0') { continue; } char timebuf[64]; format_time(u->ut_tv.tv_sec, timebuf, sizeof(timebuf)); printf("%-16s %-12s %-20s %s\n", u->ut_user, u->ut_line, timebuf, u->ut_host[0] ? u->ut_host : "本地"); count++; } endutent(); if (count == 0) { printf("当前没有检测到已登录用户。\n"); } }

这段代码的关键是setutent()和endutent()成对出现。getutent()每次返回一条记录,下一次调用会覆盖上一次的静态缓冲区,所以如果你需要保留某条记录,必须马上复制或用完即弃。打印时我判断了ut_user[0]和ut_host[0],避免输出空用户名和空来源。终端字段用ut_line,远程登录通常显示pts/0、pts/1,本地虚拟控制台显示tty1等。实际跑起来如果名单为空,先看who有没有输出,再检查权限和路径。

3.2 检测指定用户是否已登录

检测函数比列出名单更简单,只要遍历 utmp,找到USER_PROCESS且用户名匹配即可。这里也要注意立即返回,不要等到遍历完再判断,否则虽然结果一样,但多会话场景下你会丢掉“有几个会话”的信息。下面这个版本同时支持后续打印会话详情。

int is_user_logged_in(const char *username) { struct utmp *u; int found = 0; setutent(); while ((u = getutent()) != NULL) { if (u->ut_type != USER_PROCESS) { continue; } if (strncmp(u->ut_user, username, UT_NAMESIZE) == 0) { found = 1; break; } } endutent(); return found; } void show_user_sessions(const char *username) { struct utmp *u; int count = 0; setutent(); while ((u = getutent()) != NULL) { if (u->ut_type != USER_PROCESS) { continue; } if (strncmp(u->ut_user, username, UT_NAMESIZE) != 0) { continue; } char timebuf[64]; format_time(u->ut_tv.tv_sec, timebuf, sizeof(timebuf)); printf(" 会话%d: 终端=%s 来源=%s 登录时间=%s\n", ++count, u->ut_line, u->ut_host[0] ? u->ut_host : "本地", timebuf); } endutent(); if (count == 0) { printf(" 未找到该用户的活动会话。\n"); } }

strncmp的第三个参数用UT_NAMESIZE是为了避免ut_user没有终止符时越界读取。实际用户名一般不会正好占满整个数组,但实验代码最好养成安全习惯。如果你在 AD 域环境里测试,用户名可能带域名后缀,比如DOMAIN\\user或user@domain,这时候你输入的检测名必须和 utmp 里的记录一致,否则会误判为未登录。

3.3 等待指定用户登录的轮询循环

等待逻辑是整个实验的“动态”部分。核心思路是:检查一次,如果已登录就返回成功;如果未登录,打印提示,睡若干秒,再检查。为了避免永久挂起,可以加一个可选超时参数。轮询间隔建议 2 秒左右,太短浪费 CPU,太长验收时反应迟钝。下面这个函数还处理了超时和间隔参数。

int wait_for_user(const char *username, int interval, int timeout) { time_t start = time(NULL); while (1) { if (is_user_logged_in(username)) { printf("\n[+] 用户 %s 已登录。\n", username); show_user_sessions(username); return 0; } if (timeout > 0) { time_t now = time(NULL); if (difftime(now, start) >= timeout) { printf("\n[-] 等待超时,用户 %s 仍未登录。\n", username); return 1; } } printf("[*] 用户 %s 尚未登录,%d 秒后重新检查...\n", username, interval); fflush(stdout); sleep(interval); } }

主函数先列出名单,再检查目标用户。如果已经登录,直接显示详情;如果未登录,进入等待。这样结构清晰,输出也符合实验要求。

int main(int argc, char *argv[]) { if (argc < 2) { fprintf(stderr, "用法: %s <用户名> [轮询间隔秒] [超时秒]\n", argv[0]); return 1; } const char *target = argv[1]; int interval = (argc >= 3) ? atoi(argv[2]) : 2; int timeout = (argc >= 4) ? atoi(argv[3]) : 0; if (interval <= 0) { interval = 2; } printf("当前已登录用户名单:\n"); list_logged_users(); if (is_user_logged_in(target)) { printf("\n[+] 指定用户 %s 已经登录。\n", target); show_user_sessions(target); return 0; } printf("\n[*] 指定用户 %s 当前未登录,开始等待。\n", target); return wait_for_user(target, interval, timeout); }

3.4 编译、运行与输出解读

把代码保存为login_monitor.c,编译命令用gcc -Wall -Wextra -O2 -o login_monitor login_monitor.c。-Wall -Wextra能把很多隐藏问题揪出来,比如未使用变量、格式字符串不匹配。运行示例:./login_monitor zhangsan 2 120,表示监测zhangsan,每 2 秒查一次,最多等 120 秒。如果你当前是普通用户且读不到 utmp,可能需要在实验环境里用sudo ./login_monitor zhangsan 2 120,或者把当前用户加入utmp组后重新登录。某些系统上/run/utmp权限是rw-rw-r-- root utmp,普通用户不在 utmp 组就会打开失败。不要为了图省事把 utmp 改成全局可写,那会破坏系统登录记录安全。

实测输出通常会先打印表头,然后列出所有USER_PROCESS记录。如果目标用户此时未登录,会看到“尚未登录,2 秒后重新检查”的提示。你另开一个终端,用目标账号登录后,等待循环下一轮就会检测到,并打印该用户的终端、来源和登录时间。如果目标用户已经在线,程序会直接显示详情并退出。这个流程完全对应实验要求:先列名单,再查指定用户,未登录则等待,直到登录成功。

3.5 Shell脚本对照版

为了快速验证,你还可以写一个 Shell 版本。它不是用来替代 C 实验的,而是用来排查“系统里到底有没有记录”。who命令底层也读 utmp,输出第一列就是用户名。下面这个脚本能完成同样的动态监测。

#!/bin/bash list_logged_users() { echo "当前已登录用户名单:" who } is_logged_in() { local target="$1" who | awk -v u="$target" '$1 == u { found = 1 } END { exit found ? 0 : 1 }' } wait_for_user() { local target="$1" local interval="${2:-2}" local timeout="${3:-0}" local start start=$(date +%s) while true; do if is_logged_in "$target"; then echo "[+] 用户 $target 已登录。" who | awk -v u="$target" '$1 == u' return 0 fi if [ "$timeout" -gt 0 ]; then local now now=$(date +%s) if [ $((now - start)) -ge "$timeout" ]; then echo "[-] 等待超时,用户 $target 仍未登录。" return 1 fi fi echo "[*] 用户 $target 尚未登录,$interval 秒后重新检查..." sleep "$interval" done } if [ $# -lt 1 ]; then echo "用法: $0 <用户名> [轮询间隔秒] [超时秒]" exit 1 fi list_logged_users wait_for_user "$1" "${2:-2}" "${3:-0}"

Shell 版的优点是直观,缺点是解析文本不如读结构体精确。比如who输出格式会随系统变化,某些远程会话第一列可能不是纯用户名,AD 域账户也可能带反斜杠。用它做初步验证很合适,正式实验报告还是建议以 C 版本为主。

4. 常见问题与排查技巧实录

4.1 utmp为空或读取失败

最常见的现象是程序运行后显示“当前没有检测到已登录用户”,但你明明已经登录。先别改代码,按顺序排查。第一步,执行who,如果who也没输出,说明系统当前确实没有传统 utmp 会话,或者你处在容器、精简系统、Wayland 桌面等环境。第二步,检查文件:ls -l /run/utmp /var/run/utmp,看是否存在、权限是否可读。第三步,用loginctl list-sessions看 systemd 是否记录了会话。如果who有输出而程序读不到,优先怀疑权限,尝试在实验环境用sudo运行。还要注意,有些系统把 utmp 放在/var/run/utmp,而/run是/var/run的符号链接,代码用_PATH_UTMP最稳。若在容器里,宿主机和容器 utmp 是隔离的,除非特别挂载,否则容器内看不到宿主机登录用户。

4.2 用户明明登录却检测不到

这种情况通常有四个原因。第一,记录类型不是USER_PROCESS,比如某些登录阶段只是LOGIN_PROCESS,那代表等待认证,不算已登录。第二,用户名比较不一致,比如你输入zhangsan,utmp 里是zhangsan@example.com或DOMAIN\zhangsan,strncmp就不会匹配。第三,目标用户通过图形界面登录,系统没有写传统 utmp,而是由 logind 管理。第四,目标用户登录在另一台机器或另一个容器里,你监测的机器根本收不到记录。排查时先打印所有USER_PROCESS记录,观察真实用户名和终端名。如果用户名带后缀,可以在检测函数里增加模糊匹配规则,但实验环境下建议保持精确匹配,避免误判。你也可以用loginctl list-users和who -a做交叉验证。

4.3 等待循环的CPU、超时与信号

等待循环最容易踩的坑是忙等。有人写成while (!is_user_logged_in(user)) { },中间没有sleep,结果一个核跑满。正确做法是每次检查后sleep(interval),间隔 2 秒通常够用。如果你希望反应更快,可以设成 1 秒,但没必要用 0.1 秒,因为人登录本身需要时间。另一个问题是无限等待,验收时如果目标用户一直不登录,程序会卡在那里。加一个可选超时参数更友好,超时后返回非零并打印原因即可。还可以注册SIGINT信号处理,让用户按Ctrl+C时优雅退出,不过实验基础版不加也能接受。注意sleep可能被信号中断,如果需要精确计时,可以用循环重新计算剩余时间,但一般实验不需要这么复杂。

4.4 容器、远程桌面、AD域账户等场景

实验环境如果跑在容器里,传统 utmp 经常不可用。容器有自己的进程和文件系统,宿主机的登录会话不会自动出现在容器内。这时候要么在宿主机做实验,要么把 utmp 文件只读挂载进去,但挂载后还要考虑记录实时性和权限。远程桌面连接 Windows Server 后再连 Linux 实验机,情况取决于你连的是 SSH 还是图形协议,SSH 通常写pts记录,图形远程桌面可能由显示管理器处理,未必写传统 utmp。AD 域用户登录 Linux 时,用户名可能带域信息,utmp 中的ut_user可能是短名,也可能是全名,最好先用who观察。麒麟、统信、RHEL、CentOS 等系统在传统 utmp 上差异不大,但新版本可能逐步转向systemd-logind,所以代码检测不到时,交叉验证比死磕代码更有效。

4.5 常见问题速查表

现象可能原因快速排查处理建议
名单为空无活动会话、容器隔离、权限不足who、loginctl list-sessions、ls -l /run/utmp换宿主机实验,或用sudo,检查 utmp 路径
已登录检测不到类型不是USER_PROCESS、用户名带域后缀打印全部记录,观察ut_type和ut_user过滤USER_PROCESS,核对真实用户名
读取失败普通用户无 utmp 读权限看程序是否报错,检查文件权限加入 utmp 组或用实验环境允许的方式运行
CPU 占用高等待循环没有休眠top查看进程 CPU加入sleep(interval),间隔 1 到 2 秒
等待不退出没有超时机制手动Ctrl+C测试增加timeout参数,超时返回
时间显示异常时区或时间戳为 0看ut_tv.tv_sec是否合理用localtime转换,检查系统时区
用户登出后还显示记录未及时更新或没有过滤类型用户登出后再跑一次重新扫描,忽略DEAD_PROCESS
多会话只显示一个检测到第一个就返回用show_user_sessions查看遍历所有匹配记录,统计会话数

5. 可扩展方向与实验心得

5.1 监测多个用户与登录登出事件

基础实验只要求监测一个指定用户,但你可以把它扩展成多用户监测。做法很简单,把目标用户放到数组里,每次轮询对所有目标调用is_user_logged_in,给每个用户维护一个状态,从未登录变已登录时打印“上线”,从已登录变未登录时打印“下线”。这样就能做一个小型登录动态面板。再进一步,可以把事件追加到日志文件,格式类似时间 用户 事件 终端 来源,方便后续审计。注意不要记录密码、命令内容等敏感信息,只记录登录元数据即可。如果你想做得更“操作系统”,可以研究inotify是否能监控 utmp 变化,但 utmp 更新方式特殊,inotify不一定可靠,轮询仍然是最简单稳妥的方案。

5.2 和 last、journalctl、systemd-logind 交叉验证

排查时不要只盯着自己的代码。last看历史登录,who看当前会话,w看当前会话和活动,loginctl list-sessions看 systemd 管理的会话,journalctl -u sshd看 SSH 登录日志。它们的数据源不完全相同,交叉验证能快速定位问题。比如who有记录但程序读不到,说明是代码或权限问题;who没有但loginctl有,说明系统走的是 logind 路线,传统 utmp 不完整;last有历史但who没有,说明用户已经退出,当前没有活动会话。实验报告里写清楚这些工具的差异,比只贴代码更能体现你对操作系统原理的理解。

5.3 我在这个实验里踩过的坑

我最早写这个实验时,犯过一个典型错误:用strcmp比较ut_user,结果偶尔崩溃或误判。后来才意识到 utmp 是二进制结构,字段不一定以\0结尾,必须用长度受限的比较。第二个坑是忘记endutent(),短时间内反复打开可能影响资源,虽然实验里不明显,但不是好习惯。第三个坑是轮询间隔设成 0.1 秒,程序跑起来风扇狂转,改成 2 秒后世界安静了。第四个坑是只过滤了用户名,没有过滤ut_type,结果把LOGIN_PROCESS也算成已登录,验收时被指出逻辑不严。第五个坑是在容器里做实验,折腾半天发现容器根本没有宿主机的 utmp。把这些坑提前避开,实验会顺很多。

5.4 数据边界与合规提醒

登录监测涉及用户会话信息,虽然 utmp 里没有密码,但用户名、终端、来源地址、登录时间也属于需要谨慎处理的数据。实验环境下自己测试没问题,如果要把代码用到真实生产环境,必须遵守单位的安全规范,控制读取权限,日志文件设置合适权限,避免把来源地址、账号信息随意暴露。程序只读 utmp,不要尝试写入或篡改,否则可能影响系统登录记录。对于多用户服务器,建议只做必要的状态判断,不采集无关信息。把边界想清楚,代码才既好用又稳妥。

最后再分享一个实际习惯:我通常会把轮询间隔做成命令行参数,默认 2 秒;把超时设成 0 表示无限等待,验收时再用 30 秒或 60 秒快速演示。测试时先开一个终端跑监测程序,再开另一个终端用目标账号登录,观察输出从“尚未登录”变成“已登录”,顺便检查终端名和来源是否符合预期。如果你在图形界面或容器里测不出来,就换到纯 SSH 或本机 tty 环境,传统 utmp 在这种场景下最老实,也最能验证你对操作系统登录机制的理解。

返回列表