Linux环境编程
参考书目:《UNIX环境高级编程》
gcc/g++编译命令
基本编译流程:预处理 → 编译 → 汇编 → 链接
- 预处理:处理宏、头文件、条件编译等
- 展开所有宏定义
- 处理所有
#include指令 - 处理条件编译指令(
#ifdef,#ifndef,#if,#else,#endif) - 删除所有注释
- 添加行号和文件名标识(可使用
-P去除) - 保留
#pragma指令
- 编译:将预处理后的代码转换为汇编语言
- 词法分析和语法分析
- 语义分析
- 中间代码生成
- 代码优化
- 生成目标平台的汇编代码
- 汇编:将汇编代码转换为机器码(目标文件)
- 将汇编指令翻译为机器指令
- 生成可重定位的目标文件(.o文件)
- 生成符号表
- 处理标签和地址引用
- 链接:将多个目标文件和库文件连接成可执行文件
- 符号解析(解决未定义的引用)
- 重定位(调整地址引用)
- 合并代码和数据段
- 链接启动代码和库文件
- 生成最终的可执行文件格式
- gcc:c语言的编译工具
- g++:cpp的编译工具
命令选项
| 选项 | 描述 | 示例 |
|---|---|---|
-o <file> |
指定输出文件名 | gcc -o program main.c |
-E |
只预处理,不编译 | gcc -E main.c -o main.i |
-S |
生成汇编代码 | gcc -S main.c -o main.s |
-c |
编译生成机器码不链接 | gcc -c main.c -o main.o |
-save-temps |
保存所有中间文件 | gcc -save-temps main.c -o program |
-pipe |
使用管道代替临时文件 | gcc -pipe main.c -o program |
优化级别选项:
| 选项 | 描述 | 效果 |
|---|---|---|
-O0 |
不优化(默认) | 编译快,调试方便 |
-O1 |
基本优化 | 减少代码大小和执行时间 |
-O2 |
更多优化(推荐) | 包括所有不涉及空间换时间的优化 |
-O3 |
激进优化 | 包括向量化等高级优化 |
-Os |
优化代码大小 | 优先减小可执行文件大小 |
-Ofast |
快速优化 | 可能违反严格标准 |
-Og |
优化调试体验 | 在保持可调试性的同时优化 |
如果使用了优化选项:
- 编译的时间将更长;
- 目标程序不可调试;
- 有效果,但是不可能显著提升程序的性能。(关键还是要看源代码的实现)
目标文件 vs 可执行文件
| 特性 | 目标文件 (.o/.obj) | 可执行文件 |
|---|---|---|
| 文件扩展名 | .o (Unix/Linux) .obj (Windows) | 无扩展名 (Linux) .exe (Windows) |
| 文件类型 | 可重定位目标文件 | 可执行目标文件 |
| 链接状态 | 未链接 | 已完全链接 |
| 能否直接运行 | ❌ 不能直接执行 | ✅ 可以直接运行 |
| 地址引用 | 相对地址/未解析符号 | 绝对地址/已解析符号 |
| 包含内容 | 单个模块的代码和数据 | 完整程序的所有代码和数据 |
| 段结构 | 需要重定位的段 | 加载到内存的最终段 |
| 依赖关系 | 依赖其他目标文件/库 | 可能依赖动态库,但独立运行 |
示例:将hello.c编译为可执行文件hello
1 | # 第1步:预处理 |
静态库、动态库
一般来讲,通用的函数和类不提供源代码文件,而是编译成二进制文件。
库的二进制文件:
- 静态库
- 动态库
如果动态库和静态库同时存在,编译器将优先使用动态库。
静态库
- 文件扩展名:
.a(Unix/Linux),.lib(Windows) - 本质: 多个目标文件(
.o)的归档文件 - 链接时机: 编译/链接时
- 特点:
- 程序在编译时会把库文件的二进制代码链接到目标程序中
- 库代码被复制到最终的可执行文件中
- 如果多个程序中用到了同一静态库中的函数或类,就会存在多份拷贝
- 静态库的链接是在编译时期完成的,执行的时候代码加载速度快。
- 生成的目标程序的可执行文件比较大,浪费空间。
- 程序的更新和发布不方便,如果某一个静态库更新了,所有使用它的程序都需要重新编译
创建方式
1 | g++ -c -o lib库名.a 源代码文件清单 |
1 | g++ -c -o libpublic.a public.cpp |
使用方式
1 | g++ 选项 源代码文件名清单 -l库名 -L库文件所在的目录名 |
1 | g++ -o demo01 demo01.cpp -lpublic -L/home/wucz/tools |
动态库
- 文件扩展名:
.so(Unix/Linux),.dll(Windows),.dylib(macOS) - 本质: 独立的可执行代码模块
- 链接时机: 运行时
- 特点:
- 可执行文件只包含引用,运行时动态加载
- 如果多个进程中用到了同一动态库中的函数或类,那么在内存中只有一份,避免了空间浪费问题
- 程序在运行的过程中,需要用到动态库的时候才把动态库的二进制代码载入内存。
- 可以实现进程之间的代码共享,因此动态库也称为共享库。
- 程序升级比较简单,不需要重新编译程序,只需要更新(重新制作)动态库就行了。
创建方式
1 | g++ -fPIC -shared -o lib库名.so 源代码文件清单 |
1 | g++ -fPIC -shared -o libpublic.so public.cpp |
| 选项 | 全称 | 含义 | 作用阶段 |
|---|---|---|---|
-fPIC |
Position Independent Code | 生成位置无关代码 | 编译阶段 |
-shared |
Shared Library | 生成共享库(动态库) | 链接阶段 |
使用方式
运行可执行程序的时候,需要提前设置LD_LIBRARY_PATH环境变量,即指定动态库的目录
1 | g++ 选项 源代码文件名清单 -l库名 -L库文件所在的目录名 |
1 | g++ -o demo01 demo01.cpp -lpublic -L/home/wucz/tools |
Makefile
Makefile 是一个用于自动化构建程序的脚本文件,由 make 工具解析执行。它定义了:
- 项目的编译规则
- 文件的依赖关系
- 如何生成目标文件
编写规则
1 | target: prerequisites |
组成部分:
- target(目标):要生成的文件名或操作名
- prerequisites(依赖):生成目标所需的文件列表
- recipe(命令):生成目标的具体命令(必须用 Tab 缩进)
示例:
1 | #定义变量 |
main函数的参数
1 | int main() |
-
argc(Argument Count)
- 类型:
int - 含义:命令行参数的数量(包括程序名本身)
- 至少为1(程序名本身)
- 类型:
-
argv(Argument Vector)
- 类型:
char*[]或char** - 含义:字符串指针数组,存储所有命令行参数
argv[0]:程序名称(可能包含路径)argv[1]到argv[argc-1]:用户传入的参数argv[argc]:总是一个空指针(NULL)
- 类型:
-
envp(Environment Pointer)
用于接收操作系统的环境变量。虽然这不是C++标准规定的,但在大多数编译器和平台上都支持。
- 类型:
char*[]或char** - 含义:指向环境变量字符串数组的指针
- 每个字符串格式:
变量名=值 - 数组以
NULL指针结尾 - 典型环境变量:
PATH:系统路径HOME:用户主目录(Unix/Linux)USERNAME:用户名(Windows)TEMP/TMP:临时目录OS:操作系统类型
- 类型:
1 |
|
1 | lizy@ubuntu-Z11PA-U12-Series:~/cppProject$ g++ -o demo1 demo1.cpp |
操作环境变量
设置环境变量
1 | int setenv(const char *name, const char *value, int overwrite); |
setenv 是一个 POSIX 标准(如 POSIX.1-2001)中定义的 C 语言库函数,用于修改或添加环境变量。它通常在 <stdlib.h> 头文件中声明。
setenv 函数将环境变量 name 的值设置为 value。如果环境变量 name 已存在,其行为取决于 overwrite 参数:
- 若
overwrite非零,则覆盖已有的值。 - 若
overwrite为零,则不修改已有的值(函数仍返回成功,但环境变量保持不变)。
环境变量是一组字符串,形式为 名称=值,它们影响进程的行为,如查找可执行文件的路径、设置语言区域等。setenv 允许程序在运行时动态地修改自己的环境变量,这些变更通常会传递给该进程创建的子进程(通过 fork/exec 继承)。
参数详解
name:要设置的环境变量名称。不能包含等号=字符,否则行为未定义(某些实现可能直接返回错误)。value:要赋给环境变量的字符串值。可以包含任意字符,通常不包含空字符(C 字符串以\0结尾)。overwrite:整型标志,决定当变量已存在时的行为:- 非零:覆盖现有值。
- 零:保留现有值,函数成功返回但不做任何更改。
返回值
- 成功时返回 0。
- 出错时返回 -1,并设置
errno以指示错误类型。可能的errno值包括:ENOMEM:内存不足,无法分配新的环境字符串。EINVAL:name为空指针,或指向的字符串为空,或包含=字符(某些实现可能检测并返回此错误)。
获取环境变量
1 | char *getenv(const char *name); |
getenv 是 C 标准库(ISO C 和 POSIX)中定义的函数,用于检索当前进程环境变量的值。它在 <stdlib.h> 头文件中声明。
功能描述
getenv 在进程的环境变量列表中查找名为 name 的变量。如果找到,则返回指向对应值字符串的指针;如果未找到,则返回空指针(NULL)。
环境变量通常以 名称=值 的形式存储,例如 PATH=/usr/bin:/bin。getenv 允许程序读取这些值,以便根据运行环境调整行为(例如读取配置文件路径、调试标志等)。
参数
name:以空字符结尾的字符串,表示要查询的环境变量名称。名称中不应包含等号=,因为环境变量的键名本身不含等号(等号用于分隔名称和值)。如果name为空指针或空字符串,行为由实现定义(通常返回NULL)。
返回值
- 成功:返回指向环境变量 值 的字符串的指针。该字符串以空字符结尾,例如,对于
PATH=/usr/bin,返回的指针指向"/usr/bin"部分(不包括名称和等号)。 - 失败:如果指定的环境变量不存在,返回
NULL。
注意事项
- 返回指针的生命周期:返回的指针指向的环境变量字符串实际存储在进程的环境空间(通常是全局的
environ数组所指向的内存)中。该内存由系统管理,不应被调用者修改(尝试修改可能导致未定义行为,甚至破坏环境)。如果需要修改字符串,应先复制一份副本。 - 线程安全:在 POSIX 环境中,
getenv本身是线程安全的,因为它只读取环境变量。但是,如果其他线程同时调用修改环境的函数(如setenv、putenv、unsetenv),则可能产生竞争条件,导致返回的指针指向的数据被改变或释放。因此,在多线程程序中,建议在访问环境变量时进行适当的同步(例如使用互斥锁),或避免在读取期间修改环境。 - 环境变量的持久性:
getenv返回的值反映的是当前进程的环境。如果之后调用setenv或putenv修改了同一变量,之前获取的指针可能仍然有效(如果修改是就地替换且未释放原内存),但内容可能已改变;某些实现(如 glibc)可能会重新分配内存,导致原指针指向已释放的内存,从而造成悬垂指针。因此,强烈建议不要长期持有getenv返回的指针,而是在每次需要时重新调用getenv。 - 名称大小写:环境变量名称在 Unix-like 系统中是大小写敏感的(例如
PATH和path是不同的变量)。但在某些系统(如 Windows)中可能不敏感,但为了可移植性,应假设大小写敏感。 - 空值处理:环境变量的值可以是空字符串(例如
FOO=),此时getenv返回指向空字符串的指针(""),而不是NULL。调用者应能区分变量存在但值为空 和 变量不存在两种情况。
补充:perror()
1 |
|
- 参数
s:自定义提示字符串(可以为NULL),最终输出时会和系统错误描述拼接; - 返回值:无(
void)
示例:
1 |
|
GDB调试程序
GDB常用命令
如果希望程序可调试,编译时需要加-g选项,并且,不能使用-O的优化选项。
1 | gdb 目标程序 |
| 命令 | 简写 | 命令说明 |
|---|---|---|
| set args | 设置程序运行的参数。 例如:./demo 张三 西施 我是一只傻傻鸟 设置参数的方法是: set args 张三 西施 我是一只傻傻鸟 | |
| break | b | 设置断点,b 20 表示在第20行设置断点,可以设置多个断点。 |
| run | r | 开始运行程序, 程序运行到断点的位置会停下来,如果没有遇到断点,程序一直运行下去。 |
| next | n | 执行当前行语句,如果该语句为函数调用,不会进入函数内部。 VS的F10 |
| step | s | 执行当前行语句,如果该语句为函数调用,则进入函数内部。VS的F11 注意了,如果函数是库函数或第三方提供的函数,用s也是进不去的,因为没有源代码,如果是自定义的函数,只要有源码就可以进去。 |
| p | 显示变量或表达式的值,如果p后面是表达式,会执行这个表达式。 | |
| continue | c | 继续运行程序,遇到下一个断点停止,如果没有遇到断点,程序将一直运行。 VS的F5 |
| set var | 设置变量的值。 假设程序中定义了两个变量: int ii; char name[21]; set var ii=10 把ii的值设置为10; set var name=“西施”。 | |
| quit | q | 退出gdb。 |
1 | (base) lizy@ubuntu-Z11PA-U12-Series:~/cppProject$ gdb demo |
改变变量的值:
- p命令执行表达式
- set var命令
知识拾遗——core文件
在 Linux 系统中,core 文件(又称核心转储文件)是当进程异常终止(例如发生段错误、非法指令等)时,操作系统将该进程的内存映像(包括堆栈、寄存器、程序计数器等信息)保存到磁盘上的一个文件。它本质上是进程在崩溃瞬间的“快照”,主要用于事后调试,帮助开发者定位程序崩溃的原因。
生成条件
core 文件并不是每次程序崩溃都会生成,需要满足以下条件:
- 资源限制允许:shell 或系统对 core 文件大小有限制。使用
ulimit -c查看,若结果为 0 则不生成。可以通过ulimit -c unlimited解除限制(仅在当前 shell 有效)。 - 信号未被捕获或忽略:导致进程终止的信号(如 SIGSEGV、SIGABRT 等)没有被程序自己捕获处理,或者处理函数没有阻止 core dump。
- 文件系统可写:进程的工作目录(或指定的 core 存放目录)有写权限,且磁盘空间充足。
- 设置了适当的 core 模式:通过
/proc/sys/kernel/core_pattern可以控制 core 文件的命名和存放路径。
命名与位置
传统上,core 文件生成在进程的工作目录下,文件名为 core。但现代 Linux 系统通过 /proc/sys/kernel/core_pattern 进行灵活配置:
- 默认可能为
core或core.%p(%p 表示 PID)。 - 可以设置为包含更多信息的格式,如
core.%e.%p.%t(可执行文件名、PID、时间戳)。 - 也可以指定绝对路径,例如
/var/crash/core.%e.%p。
相关文件 /proc/sys/kernel/core_uses_pid 若为 1,则总是在 core 文件名后附加 PID。
作用与分析方法
core 文件的主要用途是事后调试。配合可执行程序和调试信息(编译时加 -g 选项),可以使用 GDB 加载 core 文件来重现崩溃现场:
bash
1 | gdb ./a.out core |
通过分析堆栈和变量值,开发者可以快速定位导致崩溃的代码行。
配置与管理
- 查看当前限制:
ulimit -a或ulimit -c - 临时设置 unlimited:
ulimit -c unlimited - 永久设置:编辑
/etc/security/limits.conf,添加* soft core unlimited - 修改 core 模式:
echo "/tmp/core.%e.%p" > /proc/sys/kernel/core_pattern(需要 root) - 禁用 core 生成:
ulimit -c 0或设置core_pattern为/dev/null
注意事项
- core 文件可能包含敏感数据(密码、密钥等),调试完毕后应及时删除。
- 生产环境通常限制或禁用 core 生成,以免磁盘被写满或泄露信息。
- 对于守护进程,可能需要修改其工作目录或 core 模式,确保有权限写入。
- 使用
file命令可以查看 core 文件的基本信息(如所属程序、架构等)。
与其他系统的对比
Windows 上的类似机制是“用户转储文件”(.dmp),可以通过 WinDbg 等工具分析。macOS 也支持 core 文件,行为与 Linux 类似。
总之,core 文件是 Linux 下不可或缺的调试利器,合理配置和使用它可以极大地提高问题排查效率。
GDB调试内核文件
如果程序在运行的过程中发生了内存泄漏,会被内核强行终止,提示“段错误(吐核)”,内存的状态将保存在core文件中,方便程序员进一步分析。
Linux缺省不会生成core文件,需要修改系统参数。
调试core文件的步骤如下:
- 用ulimit -a查看当前用户的资源限制参数;
- 用ulimit -c unlimited把core file size改为unlimited;
- 运行程序,产生core文件;
- 运行gdb 程序名 core文件名;
- 在gdb中,用
bt查看函数调用栈
1 | lizy@ubuntu-Z11PA-U12-Series:~/cppProject$ ulimit -a |
由于乌班图的额外设置,导致无法复现对内核文件的操作
GDB调试正在运行中的程序
1 | gdb 程序名 -p 进程编号 |
使用ps命令查看进程编号
使用gdb进行调试正在运行的程序时,程序会暂时停止运行
Linux时间操作
UNIX操作系统根据计算机产生的年代把1970年1月1日作为UNIX的纪元时间,1970年1月1日是时间的中间点,将从1970年1月1日起经过的秒数用一个整数存放。
time_t
time_t用于表示时间类型,它是一个long类型的别名,在<time.h>文件中定义,表示从1970年1月1日0时0分0秒到现在的秒数。
1 | typedef long time_t; |
time()库函数
time()库函数用于获取操作系统的当前时间。
time() 是 C 标准库中用于获取当前日历时间的函数,定义在 <time.h> 头文件中。
1 | time_t time(time_t *tloc); |
功能
返回从 1970-01-01 00:00:00 UTC(称为 Unix 纪元)到当前时刻所经过的秒数,即 Unix 时间戳。
参数
tloc:如果非NULL,函数会将返回值也存储到tloc指向的time_t变量中。
返回值
- 成功:返回当前时间戳(
time_t类型,通常是整数)。 - 失败:返回
(time_t)-1。
注意事项
time_t通常实现为 32 位或 64 位有符号整数。32 位的time_t在 2038 年 1 月 19 日会溢出,这是著名的“2038 年问题”。现代系统逐渐采用 64 位time_t以避免该问题。- 得到时间戳后,常配合
localtime()、gmtime()将其转换为可读的日期时间结构struct tm,或使用ctime()直接生成字符串。
time() 是最基础的时间函数,许多其他时间操作(如定时、日志、随机数种子等)都依赖它。
1 | //有两种调用方法: |
tm结构体
tm 结构体是 C 语言中用于表示分解时间(broken-down time)的标准结构,定义在 <time.h> 头文件中。它将一个时间戳(time_t)拆解成年、月、日、时、分、秒等便于人类理解和操作的字段。
1 | struct tm { |
成员详细说明
| 成员 | 含义 | 取值范围/说明 |
|---|---|---|
| tm_sec | 秒 | 0~60(60 为闰秒,通常只用 0~59) |
| tm_min | 分钟 | 0~59 |
| tm_hour | 小时 | 0~23 |
| tm_mday | 月份中的日期 | 1~31 |
| tm_mon | 月份(从 0 开始) | 0 表示一月,11 表示十二月 |
| tm_year | 年份(从 1900 开始计数) | 例如 2024 年表示为 124(因为 2024-1900 = 124) |
| tm_wday | 星期几(从周日开始) | 0 周日,1 周一,…,6 周六 |
| tm_yday | 一年中的第几天 | 0 表示 1 月 1 日,365 表示 12 月 31 日(闰年可能为 366) |
| tm_isdst | 夏令时标志 | 正数表示夏令时生效;0 表示非夏令时;负数表示信息不可用(由系统自动判断) |
tm结构体与 time_t 的相互转换
time_t → struct tm
有两个常用函数:
localtime(const time_t *timep)
将time_t时间戳转换为 本地时区 的tm结构。返回指向静态内部缓冲区的指针(不可重入),可重入版本为localtime_r()。gmtime(const time_t *timep)
将time_t转换为 UTC(协调世界时) 的tm结构。
1 |
|
struct tm → time_t
使用 mktime(struct tm *tm) 函数。它将一个本地时间表示的 tm 结构转换为 time_t 时间戳,返回值为tm结构体同时会规范化 tm 结构(自动调整溢出字段,并填充 tm_wday 和 tm_yday)。
示例:构建一个指定日期,获取时间戳
1 |
|
注意:
mktime假设输入的tm是本地时间,而不是 UTC。- 如果要将 UTC 时间转为时间戳,可以先用
timegm()(非标准,但许多系统提供)或自行转换(如设置 TZ 环境变量为 UTC)。
该函数主要用于时间的运算,例如:把2022-03-01 00:00:25加30分钟。
思路:
- 解析字符串格式的时间,转换成tm结构体;
- 用
mktime()把tm结构体转换成time_t时间; - 把
time_t时间加30*60秒; - 用
localtime_r()把time_t时间转换成tm结构体; - 把
tm结构体转换成字符串
gettimeofday()库函数
gettimeofday() 是一个 POSIX 标准函数,用于获取当前时间,精度达到**微秒(µs)**级别。它比 C 标准库的 time() 函数(精度秒)更精细,常用于需要测量短时间间隔或记录高精度时间戳的场景。
1 |
|
参数
tv:指向struct timeval结构的指针,用于接收当前时间(从 1970-01-01 00:00:00 UTC 至今的秒数和微秒数)。不能为NULL。tz:指向struct timezone结构的指针,用于获取时区信息。该参数已废弃,在 Linux 中应始终设为NULL以保证可移植性。如果传入非空指针,行为取决于系统,但通常返回的时区信息不可靠。
返回值
- 成功返回 0。
- 失败返回 -1,并设置
errno以指示错误(常见错误如EFAULT表示指针无效)。
struct timeval
1 | struct timeval { |
tv_sec与time()返回值含义相同。tv_usec表示微秒部分,与tv_sec共同组成一个完整的当前时间戳。
struct timezone(已废弃)
1 | struct timezone { |
在现代 Linux 系统中,gettimeofday 忽略该参数,直接返回而不填充任何有用信息。因此总是传递 NULL。
示例:测量过程时间
1 |
|
精度与特性
- 微秒精度:
tv_usec提供微秒级分辨率,但实际精度受硬件和内核定时器限制(通常可达几十微秒到几毫秒)。 - 系统时间影响:返回的是挂钟时间(wall-clock time),即系统当前时间。如果系统时间被管理员或 NTP 调整,两次调用
gettimeofday得到的差值可能包含跳跃,不适合测量短时段间隔(除非使用单调时钟)。 - 线程安全:
gettimeofday将结果写入用户提供的缓冲区,不维护内部静态数据,因此是可重入且线程安全的。但若多个线程同时读取,可能看到不同的系统时间(取决于内核调度)。
注意事项与局限
- 时区参数必须为 NULL:为保持可移植性,永远传递
tz = NULL。 - 微秒含义:
tv_usec是秒内的小数部分,范围 0~999999,不能单独解释为从纪元开始的微秒数。 - 系统时间可变:时间可能因 NTP 同步或管理员手动设置而发生跳跃。若需要稳定递增的时间间隔,应使用
clock_gettime(CLOCK_MONOTONIC, ...)。 - 2038 年问题:
tv_sec是time_t类型,若为 32 位,将在 2038 年溢出。现代系统已逐渐迁移到 64 位time_t。 - POSIX 要求 vs 实际支持:
gettimeofday在 SUSv2 中标记为“历史遗留”,POSIX.1-2008 已将其废弃,推荐使用clock_gettime。
现代替代方案:clock_gettime
clock_gettime 是 POSIX.1-2001 引入的更强大的时间获取函数,支持多种时钟源:
1 |
|
struct timespec包含tv_sec(秒)和tv_nsec(纳秒),精度更高。- 常用时钟 ID:
CLOCK_REALTIME:系统实时时间(同gettimeofday,但纳秒级)。CLOCK_MONOTONIC:单调时间,不受系统时间跳变影响,适合测量间隔。CLOCK_PROCESS_CPUTIME_ID:进程 CPU 时间。CLOCK_THREAD_CPUTIME_ID:线程 CPU 时间。
1 | struct timespec ts; |
因此,在新代码中推荐使用 clock_gettime,它更灵活且避免了 gettimeofday 的废弃问题。
总结
gettimeofday是获取微秒级挂钟时间的传统 POSIX 函数。- 使用时将
tz参数置NULL,并注意其时间可能跳跃。 - 对于需要稳定递增的时间测量,请改用
clock_gettime(CLOCK_MONOTONIC, ...)。 - 尽管已被标记为废弃,但由于历史兼容性,它在许多代码库中仍然广泛存在。理解其用法有助于阅读和维护现有代码。
补充
“不可重入”(Non-reentrant)是编程中的一个重要概念,通常用来描述函数或代码段在并发执行时是否安全。如果一个函数被设计成“不可重入”,那么当它在执行过程中被再次调用(例如在多线程环境、信号处理程序或递归调用中),就可能导致数据错乱、程序崩溃或其他不可预期的行为。也可以被叫做线程安全
为什么会不可重入?
函数不可重入的根本原因是它使用了共享资源且没有妥善保护。常见的罪魁祸首有:
- 静态或全局变量
函数内部使用了静态(static)变量或全局变量,这些变量在内存中只有一份副本。如果第一次调用正在修改该变量,第二次调用突然闯入,也会去读写同一个变量,就会造成数据竞争和状态混乱。 - 返回指向内部静态缓冲区的指针
典型例子:localtime()、ctime()、gethostbyname()等旧版 C 库函数。它们将结果存储在一个内部的静态缓冲区中,然后返回该缓冲区的地址。如果两次调用之间没有同步,第二次调用就会覆盖第一次的结果,导致第一次获取的数据损坏。 - 使用了不可重入的库函数
如果一个函数内部调用了其他不可重入的函数,那它自己也变成不可重入的。 - 缺乏锁机制
即使使用了全局变量,如果能用互斥锁保护,也可以实现“线程安全”,但这通常不叫“可重入”,而是“线程安全”。可重入要求函数即使在执行过程中被中断并重新进入,也能正确运行,这对锁的要求更高(例如不能使用会导致死锁的锁)。
可重入函数的特点
一个可重入函数通常具备以下特征:
- 只使用局部变量(存储在栈上,每个调用都有独立副本)。
- 不依赖静态或全局数据。
- 如果必须访问全局数据,会用原子操作或禁止中断的方式保证完整性,或者由调用者提供缓冲区(例如
localtime_r要求传入struct tm *指针)。 - 不调用任何不可重入的函数。
具体例子:localtime 与 localtime_r
C 标准库中,localtime(const time_t *timep) 是一个典型的不可重入函数。它内部使用了一个静态的 struct tm 对象,每次调用都会把结果填充到这个静态对象中,并返回它的地址。
1 |
|
如果两个线程同时执行这段代码,info 指向的静态缓冲区可能被另一个线程的 localtime 调用篡改,导致打印出混乱的结果。
为了解决这个问题,POSIX 标准提供了可重入版本 localtime_r:
1 | struct tm *localtime_r(const time_t *timep, struct tm *result); |
它要求调用者自己分配一个 struct tm 变量(通常是在栈上),并将指针传入。函数将结果写入这个外部提供的缓冲区,从而避免了内部静态数据的冲突。这样即使多个线程同时调用,只要每个线程使用自己的 result 缓冲区,就不会互相干扰。
1 | void* thread_func(void* arg) { |
何时需要关注可重入性?
- 多线程编程:多个线程可能同时调用同一个函数。可重入函数可以在不加锁的情况下安全使用,而不可重入函数则需要外部加锁保护。
- 信号处理程序:信号处理程序可能在主程序的任何地方异步执行。在信号处理程序中调用不可重入函数(如
printf、malloc、localtime)非常危险,因为它们可能修改主程序正在使用的数据,导致程序崩溃。所以信号处理程序只能调用异步信号安全的函数(即可重入函数)。 - 递归调用:如果一个函数在递归调用中依赖于静态数据,也可能出问题。
| 特性 | 线程安全 | 可重入 |
|---|---|---|
| 关注点 | 多个线程同时执行 | 单个线程被中断后重新执行 |
| 数据依赖 | 可以使用全局/静态数据,但需用锁保护 | 不能使用任何共享的可变数据(包括全局、静态) |
| 锁的使用 | 允许,但需小心死锁 | 禁止使用锁(因可能导致死锁) |
| 异步信号安全 | 不一定 | 通常是异步信号安全的前提 |
| 举例 | printf(线程安全,因内部有锁) |
strtok_r、localtime_r |
| 可重入必然线程安全? | 可重入函数一定是线程安全的(因为不共享状态) | 线程安全函数不一定是可重入的(如 printf) |
总结
- 不可重入:函数不能安全地并发执行,通常因为使用了共享静态数据。
- 可重入:函数可以被多个执行流同时调用,且不依赖外部同步机制也能保证正确性。
- 实际开发中,优先使用函数的可重入版本(如
localtime_r替代localtime,strtok_r替代strtok等),或在调用不可重入函数时加锁保护。
程序睡眠
sleep() 和 usleep() 是 Unix/Linux 系统中常用的让程序(或线程)暂停执行一段时间的函数。它们都定义在 <unistd.h> 头文件中,但在精度、行为和标准化程度上有所不同。
sleep() 函数
1 |
|
使调用进程(或线程)挂起至少 seconds 秒,直到:
- 指定的时间过去;
- 或者被一个信号中断(且信号处理函数返回)。
返回值
- 如果休眠了完整的
seconds秒,返回 0。 - 如果被信号提前唤醒,返回 剩余未休眠的秒数(请求时间减去实际已休眠时间)。
tips:
- 精度:秒级,实际休眠时间可能因系统调度略长于请求值。
- 信号影响:
sleep()是可中断的,如果进程在休眠期间收到信号,sleep()会提前返回,并返回剩余秒数。这为处理异步事件提供了可能,但也意味着不能保证精确的定时。 - 线程安全:在 POSIX 系统中,
sleep()是线程安全的,但多线程程序中通常不建议使用,因为它会使整个线程挂起(而非进程),不过sleep()只会影响调用它的线程。 - 可移植性:
sleep()是 POSIX.1 标准的一部分,几乎所有 Unix-like 系统都支持。
usleep() 函数
1 |
|
使调用进程(或线程)挂起至少 usec 微秒(1 微秒 = 1/1,000,000 秒)。
参数
usec:要休眠的微秒数,类型useconds_t在 Linux 上通常是unsigned int。
返回值
- 成功返回 0。
- 失败返回 -1,并设置
errno指示错误(例如EINTR表示被信号中断,EINVAL表示参数无效)。
1 |
|
特点与注意事项
- 精度:微秒级,但实际精度受内核定时器限制(通常为几十微秒到几毫秒)。
- 信号影响:
usleep()也可能被信号中断,此时返回 -1 并设置errno = EINTR。 - 已废弃:
usleep()在 POSIX.1-2001 中仍被支持,但在 POSIX.1-2008 中被标记为废弃,并从规范中移除。原因包括:- 历史实现中对
usec的值有限制(如某些系统要求usec < 1,000,000,即不能超过 1 秒)。 - 与
nanosleep()相比,功能重叠但精度更低,且缺乏对纳秒和高精度时钟的支持。
- 历史实现中对
- 线程安全:通常是线程安全的,但依赖于实现。
- 可移植性:尽管被废弃,许多 Linux 系统仍然提供该函数以兼容旧代码。但新代码不应再使用它。
Linux目录、文件操作
获取当前目录
1 |
|
- 功能:将当前工作目录的绝对路径名存入
buf中。 - 参数:
buf:存放路径的缓冲区,若为NULL,函数会使用malloc分配足够空间(需自行free)。size:缓冲区大小。
- 返回值:成功返回
buf;失败返回NULL。
1 |
|
- 特性测试宏:由于该函数是 GNU 扩展,在包含头文件前需要定义
_GNU_SOURCE,否则函数可能不可见。
返回当前进程的当前工作目录的绝对路径名。它内部通过 getcwd() 实现,但无需调用者提供缓冲区,函数会使用 malloc(3) 动态分配足够大的缓冲区来存储路径。
返回值
- 成功:返回一个指向以 null 结尾的字符串的指针,该字符串包含当前目录的绝对路径。该内存是通过
malloc分配的,因此必须由调用者使用free(3)释放。 - 失败:返回
NULL,并设置errno以指示错误。
可能发生的错误(通过 errno)与 getcwd() 类似,例如:
EACCES:搜索权限不足ENOMEM:内存不足ERANGE:路径长度超过内核支持的最大值(但此函数自动分配,通常不会出现,除非底层getcwd报告该错误)
1 |
|
切换工作目录
1 |
|
返回值:
- 0 成功
- 其它 失败(目录不存在或没有权限)。
1 |
|
创建目录
1 | int mkdir(const char *pathname, mode_t mode); |
参数:
-
pathname:要创建的目录路径。 -
mode:新目录的权限(如0755),受umask影响。一定是4位数- 写
0755是因为 C 语言要求八进制常量以0开头,实际有效权限是755。 - 如果确实需要设置特殊权限,可以使用四位完整数字(如
4755)。
- 写
-
在 Linux 权限体系中,权限确实可以用四位八进制数完整描述:
- 第一位:特殊权限位(setuid、setgid、sticky bit)
- 后三位:标准权限位(所有者、组、其他)
返回值:
- 成功返回 0
- 失败返回 -1
注意:父目录必须存在且进程有写权限。
1 |
|
删除目录
1 |
|
功能:删除一个空目录。
返回值:
- 成功返回 0
- 失败返回 -1
注意:目录必须为空(仅包含 . 和 .. 不算空),否则会失败。
获取目录中的内容
文件存放在目录中,在处理文件之前,必须先知道目录中有哪些文件,所以要获取目录中文件的列表。
步骤:
- 打开目录
- 读取目录
- 关闭目录
打开目录
1 |
|
- 功能:打开一个目录,返回指向目录流的指针(
DIR *)。 - 参数:
name是要打开的目录路径。 - 返回值:成功返回目录流指针;失败返回
NULL,并设置errno。
读取目录
1 |
|
-
功能:从目录流
dirp中读取下一个目录项,返回指向struct dirent结构的指针。 -
返回值:成功返回下一个目录项的信息;到达目录末尾或出错返回
NULL(需要通过errno区分)。 -
struct dirent 至少包含以下成员(因系统而异,POSIX 标准定义):
-
ino_t d_ino– 文件的 inode 号 -
char d_name[]– 文件名(以 null 结尾) -
unsigned char d_type– 文件类型(Linux 支持),常见值:DT_REG普通文件 8DT_DIR目录 4DT_LNK符号链接DT_UNKNOWN未知(需要使用stat进一步判断)
-
struct dirent { long d_ino; // inode number 索引节点号。 off_t d_off; // offset to this dirent 在目录文件中的偏移。 unsigned short d_reclen; // length of this d_name 文件名长度。 unsigned char d_type; // the type of d_name 文件类型。 char d_name [NAME_MAX+1]; // file name文件名,最长255字符。 };1
2
3
4
5
6
7
8
9
### 关闭目录
```c
#include <dirent.h>
int closedir(DIR *dirp);
-
-
功能:关闭目录流,释放资源。
-
返回值:成功返回 0;失败返回 -1。
示例:读取目录的内容
1 |
|
补充
DIR 的本质
DIR 是 C 语言中用于目录操作的一个数据类型,定义在头文件 <dirent.h> 中。它代表一个目录流,类似于文件操作中的 FILE 类型。通过 DIR,程序可以打开一个目录,然后逐个读取其中的目录项(包括文件和子目录)。
- 不透明数据类型:
DIR是一个结构体类型,但其内部成员对程序员不可见(即不透明)。这种设计强制程序员只能通过标准库函数来操作目录流,而不能直接访问或修改其内部字段,保证了封装性和可移植性。 - 目录流:打开一个目录后,系统会建立一个目录流,记录当前读取位置等信息。
DIR *指针指向这个流。 - 类比
FILE:类似于FILE用于文件操作,DIR用于目录操作。但目录流只能用于读取目录项列表,不能像文件那样写入数据(目录的创建、删除等操作使用其他函数如mkdir、rmdir)。
umask
umask(用户文件创建掩码,User File Creation Mask)是 Unix 和 Linux 系统中一个非常重要的概念,它决定了新创建的文件和目录的默认权限。umask 是一个进程属性,它指定了在创建新文件或目录时,哪些权限位应该被自动关闭(即从默认权限中移除)。每个进程(包括 shell)都有自己的 umask 值,通常由父进程继承。当你登录系统时,shell 会从一个系统配置文件(如
/etc/profile或~/.bashrc)中获取默认的 umask 设置。
umask 的值通常用八进制数表示,每一位对应一类用户(所有者、组、其他)的权限。umask 中为 1 的位表示要禁止的权限,为 0 的位表示保留的权限。
最终权限计算公式:
1 | 实际权限 = 默认基础权限 & (~umask) |
其中 & 是按位与,~ 是按位取反。
示例
假设 umask = 022(八进制),二进制表示为 000 010 010:
- 所有者:0 → 不移除任何权限(保留所有权限)。
- 组:2 → 移除写权限(
rwx中的w被移除)。 - 其他:2 → 移除写权限。
创建文件
默认基础权限 666(rw-rw-rw-)与 ~022 按位与:
- 所有者:
rw-&rw-=rw-(6) - 组:
rw-&r--=r--(4) - 其他:
rw-&r--=r--(4)
最终文件权限为 644(rw-r--r--)。
创建目录
默认基础权限 777(rwxrwxrwx)与 ~022 按位与:
- 所有者:
rwx&rwx=rwx(7) - 组:
rwx&r-x=r-x(5) - 其他:
rwx&r-x=r-x(5)
最终目录权限为 755(rwxr-xr-x)。
常见的 umask 值及其效果
| umask | 文件权限 | 目录权限 | 说明 |
|---|---|---|---|
| 022 | 644 | 755 | 最常用,所有者可读写,其他人只读/执行 |
| 002 | 664 | 775 | 组可写,适用于共享目录 |
| 077 | 600 | 700 | 仅所有者可访问,最严格 |
| 027 | 640 | 750 | 所有者完全控制,组可读/执行,其他无权限 |
| 000 | 666 | 777 | 无限制(危险,不推荐) |
Tips:
-
umask 只影响新创建的文件,对已存在的文件权限无影响。
-
root 用户的典型 umask 是 022,普通用户也可能是 022 或 002,取决于发行版配置。
-
子进程继承父进程的 umask,因此在脚本中设置 umask 会影响脚本中运行的所有命令。
-
符号链接的权限不受 umask 影响,因为符号链接的权限总是 777(但实际访问权限由目标文件决定)。
-
使用
mkdir或open等函数时,可以显式指定权限,但最终权限仍会与 umask 进行“与”运算。例如:1
2creat("file", 0666); // 实际权限 = 0666 & ~umask
mkdir("dir", 0777); // 实际权限 = 0777 & ~umask如果你想完全忽略 umask,可以在创建后立即用
chmod修改权限,或者在调用前临时设置 umask 为 0(但需谨慎)。 -
安全建议:除非必要,不要将 umask 设为 000。在编写需要创建敏感文件的程序时,可以临时设置严格的 umask(如 077),创建完成后再恢复。
Linux的系统错误
库函数与系统调用
| 特性 | 系统调用 | 库函数 |
|---|---|---|
| 运行级别 | 内核态(通过陷阱指令进入内核) | 用户态 |
| 上下文切换 | 涉及用户态到内核态的切换,开销较大 | 无上下文切换,开销小 |
| 功能 | 提供操作系统核心服务(进程、文件、网络等) | 提供更高级、更易用的功能(格式化、缓冲、算法) |
| 接口 | 通常由操作系统定义,较底层 | 由库的设计者定义,更抽象 |
| 可移植性 | 依赖特定操作系统,不同系统间差异大 | 可移植性更好(如 C 标准库几乎所有平台都有) |
| 错误处理 | 失败时设置全局 errno,返回 -1 或 NULL |
可能设置 errno,也可能返回其他错误指示 |
| 缓冲 | 无缓冲,直接操作内核对象 | 常带缓冲(如 stdio 的流缓冲),提高性能 |
| 示例 | read()、write()、open() |
fread()、fwrite()、fopen()、printf() |
errno
errno是 Linux/Unix 环境编程中用于报告错误的核心机制,它是一个由系统调用和某些库函数在出错时设置的全局(实为线程局部)变量,用来指示具体的错误原因。
- 定义:
errno是一个由 POSIX 和 ISO C 标准定义的整数左值表达式(lvalue),当系统调用或库函数失败时,内核或库会为其赋予一个正整数的错误码,用以标识具体的错误类型。 - 声明位置:
#include <errno.h> - 初始值:程序启动时,
errno的值为 0。但没有任何标准库函数会将其清零,因此必须通过检查函数的返回值来判断是否发生错误,再查看errno。 - 并不是全部的库函数在调用失败时都会设置errno的值,以man手册为准(一般来说,不属于系统调用的函数不会设置errno,属于系统调用的函数才会设置errno)。
使用 errno 的正确规则
必须先检查返回值,再使用 errno
函数成功时不会修改 errno,其值可能是前一次失败留下的,因此绝对不能用 errno 来判断是否出错。正确模式:
1 |
|
1 | No such file or directory |
及时保存 errno
因为任何库函数(包括 fprintf、strerror 等)都可能修改 errno,所以一旦检测到错误,应立即将 errno 的值保存到局部变量中。
不要假设函数成功会清零 errno
标准规定:函数成功时,errno 保持不变。因此不能依赖 errno 为 0 来断言成功。
信号处理函数中需保存和恢复 errno
信号处理程序可能调用某些异步信号安全的函数,这些函数可能修改 errno。因此,信号处理函数入口应保存原 errno,返回前恢复:
1 | void handler(int sig) { |
errno 的值仅在函数明确指出失败时才有效
只有当一个函数的标准文档中说明它在失败时设置 errno,那么当该函数返回错误指示时,errno 才包含有效信息。
常见错误码分类及示例
错误码通常以 E 开头的宏定义,如 ENOENT。以下是一些常见错误码的分类和含义(完整列表可通过 man errno 查看):
| 错误码 | 值 | 含义 | 常见场景 |
|---|---|---|---|
| 文件/目录 | |||
| ENOENT | 2 | No such file or directory | 打开不存在的文件 |
| EACCES | 13 | Permission denied | 对文件没有读/写/执行权限 |
| EEXIST | 17 | File exists | 创建文件时文件已存在且指定了 O_EXCL |
| ENOTDIR | 20 | Not a directory | 路径中的某部分不是目录 |
| EISDIR | 21 | Is a directory | 试图写入一个目录 |
| 进程/资源 | |||
| EAGAIN | 11 | Resource temporarily unavailable | 非阻塞模式下无数据可读/写,或 fork 资源不足 |
| ENOMEM | 12 | Out of memory | 内存分配失败 |
| EBUSY | 16 | Device or resource busy | 尝试删除正在使用的设备或挂载点 |
| 参数/状态 | |||
| EINVAL | 22 | Invalid argument | 给系统调用传入了无效参数 |
| EINTR | 4 | Interrupted system call | 系统调用被信号中断,通常需要重试 |
| EFAULT | 14 | Bad address | 指针指向非法地址 |
| 网络相关 | |||
| ECONNREFUSED | 111 | Connection refused | 连接被服务器拒绝 |
| ETIMEDOUT | 110 | Connection timed out | 连接超时 |
与 errno 相关的函数
perror – 打印错误消息到 stderr
1 |
|
- 输出:
s: 错误描述,自动换行。例如perror("open")可能输出open: No such file or directory。 - 用于在控制台显示最近一次系统错误的详细信息,在实际开发中,服务程序在后台运行,通过控制台显示错误信息意义不大。(对调试程序略有帮助)
示例:
1 |
|
1 | No such file or directory |
strerror – 将错误码转换为描述字符串
1 |
|
- 返回一个指向静态分配的字符串的指针(不可重入)。线程安全版本
strerror_r可将结果写入用户提供的缓冲区。 - 注意:不应修改返回的字符串。
示例:
1 |
|
1 | 0:Success |
1 |
|
1 | -1 |
errno 相关的可重入函数
strerror_r(POSIX 和 GNU 两种版本,用法不同)。perror本身不是线程安全的,因为它使用全局流 stderr,但通常输出是原子的。
线程安全与可重入性
errno本身是线程安全的:因为现代实现采用线程局部存储。- 但某些函数可能间接导致问题:例如
strerror返回的字符串可能指向一个静态缓冲区,多次调用会互相覆盖,因此它是不可重入的。应优先使用strerror_r。 - 信号处理程序中只能调用异步信号安全的函数(async-signal-safe)。这些函数包括
write、read、_exit等,通常不包含perror或strerror(因为它们可能使用锁)。因此信号处理程序中处理错误的方式有限:可以保存errno或使用简单的write输出预定义的字符串。
常见误区总结
- 用
errno判断是否出错:错误!应该用函数返回值判断。 - 未及时保存
errno:在printf等函数后errno可能已被改变。 - 在信号处理程序中调用非异步信号安全的函数:可能导致死锁或数据损坏。
- 假设
errno为 0 表示成功:不可靠。 - 混淆
errno和返回值的意义:例如read返回 0 表示 EOF,不是错误,此时errno无意义。
目录、文件的更多操作
access() 库函数
access() 用于检查调用进程对指定文件或目录是否具有某种权限(或判断文件是否存在)。它是基于进程的实际用户 ID 和实际组 ID 进行权限检查的,而不是有效用户 ID(与 open 等函数不同)。这在某些需要以特权身份运行的程序中很有用,可以临时检查实际用户的权限。
1 |
|
- pathname:文件或目录的路径。
- mode:要检查的权限掩码,可取以下值(定义在
<unistd.h>):F_OK(0):测试文件是否存在。R_OK(4):测试是否有读权限。W_OK(2):测试是否有写权限。X_OK(1):测试是否有执行权限(对于目录则是搜索权限)。
这些值可以通过按位或(|)组合,例如R_OK | W_OK表示同时检查读写权限。
返回值
- 成功(满足所有检查的权限)返回 0。
- 失败(至少一项不满足或发生错误)返回 -1,并设置
errno以指示具体错误。
常见错误
EACCES:权限不足(例如文件存在但无读权限)。ENOENT:文件不存在。ENOTDIR:路径中的某个部分不是目录。EROFS:文件在只读文件系统上,但检查写权限。
1 |
|
Tips:
access()使用的是实际用户 ID,而不是有效用户 ID。对于设置了 set-user-ID 的程序,它可能产生与预期不同的结果。- 在多线程程序中,
access()的结果可能不可靠,因为权限可能在检查和实际使用之间发生变化。通常建议直接尝试打开文件并处理错误,而不是先检查权限再操作(即“先试后问”原则)。 - 对于符号链接,
access()会检查链接指向的目标文件,而非链接本身。
补充:实际用户ID 有效用户ID
实际用户 ID(Real UID)
- 是谁启动了进程:实际用户 ID 是运行该进程的用户的真实身份,由登录会话确定,通常不会改变(除非通过
setuid()等函数显式修改)。 - 作用:主要用于记账和统计,例如记录哪个用户创建了进程,以及发送信号时检查权限(如向另一个进程发送信号时,需要实际用户 ID 匹配或具有特权)。
有效用户 ID(Effective UID)
- 进程以谁的身份执行:有效用户 ID 是内核在进行权限检查时使用的用户 ID。例如,当进程尝试打开一个文件时,内核会检查该文件权限是否允许有效用户 ID 执行此操作。
- 作用:决定进程当前拥有的访问权限。
示例:passwd 命令
- 普通用户需要修改自己的密码,但密码通常存储在
/etc/shadow文件中,该文件只有root用户可读写。 passwd程序被设置了 setuid 位(可通过ls -l /usr/bin/passwd查看,其权限为-rwsr-xr-x,其中s表示 setuid)当一个可执行文件设置了 setuid 位时,任何用户运行该文件,进程的有效用户 ID(effective UID)会被设置为文件所有者的 UID(通常是 root),而不是运行者的实际用户 ID。这使得普通用户能够临时获得文件所有者的权限来执行特定任务。- 当普通用户执行
passwd时:- 实际用户 ID 仍为该普通用户的 ID(比如 1000)。
- 有效用户 ID 被临时提升为文件所有者(这里是
root,ID 0)。
- 因此,进程在运行期间具有
root权限,可以修改/etc/shadow;但内核仍知道实际用户是谁,以便进行适当的记录或限制(例如,不允许该进程越权访问其他用户的文件)。
这样既让普通用户完成了需要特权的操作,又避免了赋予其永久的 root 权限,提高了系统安全性。
总结
- 实际用户 ID:你是谁(启动进程的人)。
- 有效用户 ID:你看起来是谁(权限检查的依据)。
- 这种区分使得普通用户可以临时获得特权执行特定任务,而不会永久提升权限,是现代操作系统安全机制的重要基石。
stat() 库函数
struct stat 用于存储文件或目录的详细信息,定义在 <sys/stat.h> 中。其成员较多,但常用的是以下几个:
1 | struct stat { |
重点成员:
- st_mode:不仅包含文件权限,还包含文件类型。可使用以下宏判断文件类型(这些宏也定义在
<sys/stat.h>):S_ISREG(st_mode):是否为普通文件S_ISDIR(st_mode):是否为目录S_ISCHR(st_mode):是否为字符设备S_ISBLK(st_mode):是否为块设备S_ISFIFO(st_mode):是否为 FIFO(命名管道)S_ISLNK(st_mode):是否为符号链接S_ISSOCK(st_mode):是否为套接字
- st_size:文件大小,对于普通文件是字节数;对于目录,通常是 4096 或其倍数,具体含义因文件系统而异。
- st_mtime:最后修改时间,为
time_t类型,可通过ctime()或localtime()转换为可读格式。
stat() 函数族
有三个相关的函数:
1 |
|
- stat():通过路径名获取文件属性,会跟随符号链接(即获取链接指向的目标文件属性)。
- fstat():通过已打开的文件描述符获取文件属性。
- lstat():与
stat()类似,但当文件是符号链接时,返回的是链接本身的信息,而非目标文件。
返回值
- 成功返回 0。
- 失败返回 -1,并设置
errno。
1 |
|
Tips:
stat()会穿透符号链接,若需获取符号链接本身的信息,应使用lstat()。st_size对于目录、设备文件等可能没有意义,但仍会返回一个值。- 时间字段是
time_t,通常为自 1970-01-01 00:00:00 UTC 以来的秒数。可使用ctime()、localtime()转换为字符串,或使用strftime()格式化。 st_mode的低 9 位是文件权限,可用常规的八进制掩码提取(如st.st_mode & 0777),但直接使用chmod等函数时需要注意与权限宏的配合。
utime() 库函数
utime() 用于修改文件的最后访问时间(st_atime)和最后修改时间(st_mtime)。它可以设置成指定时间,也可以设置为当前时间。
1 |
|
其中 struct utimbuf 定义如下:
1 | struct utimbuf { |
- filename:要修改时间的文件路径。
- times:指向
utimbuf结构的指针。如果为NULL,则将文件的访问时间和修改时间设置为当前时间。如果非空,则分别设置为actime和modtime指定的时间。
返回值
- 成功返回 0。
- 失败返回 -1,并设置
errno。
1 |
|
Tips:
- 修改文件时间需要适当的权限:要么进程的有效用户 ID 等于文件的所有者,要么具有超级用户权限。如果
times为NULL,则要求进程对文件有写权限(或适当权限)。 utime()不会修改st_ctime(状态更改时间),该字段会自动更新为当前时间。- 有些系统提供了更精确的
utimes()函数(支持微秒),以及 POSIX 标准的futimens()、utimensat()等,支持纳秒级精度和更细的控制。
rename() 库函数
rename() 用于重命名文件或目录,也可以将其移动到另一个目录(相当于 mv 命令)。如果新路径已经存在,则根据具体情况进行替换。
1 |
|
- oldpath:原路径名。
- newpath:新路径名。
返回值
- 成功返回 0。
- 失败返回 -1,并设置
errno。
Tips:
- 如果
newpath已存在,则将其覆盖(要求类型一致,且权限允许)。 - 若
oldpath和newpath指向同一文件(即硬链接),则什么也不做,成功返回。 - 重命名操作是原子的,即不会出现中间状态(例如其他进程无法在重命名期间看到一个不存在的文件或一个被部分覆盖的文件)。
- 对于目录,
newpath要么不存在,要么必须为空目录;并且oldpath和newpath必须在同一文件系统内。
1 |
|
注意:
- 跨文件系统的重命名不支持,如果需要在不同文件系统间移动,必须先复制再删除原文件。
- 重命名目录时,
newpath不能是oldpath的子目录(避免循环)。 - 对特殊文件(如设备文件、管道)同样适用。
- 需要适当权限:对包含
oldpath和newpath的目录需要有写权限,以及相应的文件权限。
remove() 库函数
remove() 用于删除文件或空目录,类似于 rm 命令。它是 ISO C 标准定义的函数,兼具 unlink() 和 rmdir() 的功能。
1 |
|
- pathname:要删除的文件或目录路径。
返回值
- 成功返回 0。
- 失败返回 -1,并设置
errno。
tips:
- 如果
pathname是一个文件,remove()调用unlink()。 - 如果
pathname是一个目录,remove()调用rmdir()。 - 对于符号链接,
remove()会删除链接本身,而不是它指向的文件。
1 |
|
注意:
- 只能删除空目录,否则会失败并置
errno为ENOTEMPTY或EEXIST。 - 删除文件时,
unlink()只是删除目录项,文件实际内容在最后一个硬链接被删除且没有进程打开它时才会被释放。 - 权限要求:对包含该文件的目录需要有写和执行权限,且文件本身若无写权限,会询问(但
remove本身不提示,直接尝试删除,如果目录权限允许,即使文件只读也可删除,因为删除操作修改的是目录,而不是文件内容)。 - 跨平台:
remove()是 C 标准库函数,在 Windows 下也可用,但行为可能略有差异(例如 Windows 上删除目录要求目录为空,且不能删除正在使用的文件)。
信号
信号(signal)是软件中断,是进程之间相互传递消息的一种方法,用于通知进程发生了事件,但是,不能给进程传递任何数据。
信号产生的原因有很多,在Shell中,可以用kill和killall命令发送信号:
1 | kill -信号的类型 进程编号 |
信号种类
| 信号名 | 信号值 | 默认处理动作 | 发出信号的原因 |
|---|---|---|---|
| SIGHUP | 1 | A | 终端挂起或者控制进程终止 |
| SIGINT | 2 | A | 键盘中断Ctrl+c |
| SIGQUIT | 3 | C | 键盘的退出键被按下 |
| SIGILL | 4 | C | 非法指令 |
| SIGABRT | 6 | C | 由abort(3)发出的退出指令 |
| SIGFPE | 8 | C | 浮点异常 |
| SIGKILL | 9 | AEF | 采用kill -9 进程编号 强制杀死程序。 |
| SIGSEGV | 11 | CEF | 无效的内存引用(数组越界、操作空指针和野指针等)。 |
| SIGPIPE | 13 | A | 管道破裂,写一个没有读端口的管道。 |
| SIGALRM | 14 | A | 由闹钟alarm()函数发出的信号。 |
| SIGTERM | 15 | A | 采用“kill 进程编号”或“killall 程序名”通知程序。 |
| SIGUSR1 | 10 | A | 用户自定义信号1 |
| SIGUSR2 | 12 | A | 用户自定义信号2 |
| SIGCHLD | 17 | B | 子进程结束信号 |
| SIGCONT | 18 | 进程继续(曾被停止的进程) | |
| SIGSTOP | 19 | DEF | 终止进程 |
| SIGTSTP | 20 | D | 控制终端(tty)上按下停止键 |
| SIGTTIN | 21 | D | 后台进程企图从控制终端读 |
| SIGTTOU | 22 | D | 后台进程企图从控制终端写 |
| 其它 | <=64 | A | 自定义信号 |
注:处理动作一项中的字母含义如下
- A 缺省的动作是终止进程。
- B 缺省的动作是忽略此信号,将该信号丢弃,不做处理。
- C 缺省的动作是终止进程并进行内核映像转储(core dump)。
- D 缺省的动作是停止进程,进入停止状态的程序还能重新继续,一般是在调试的过程中。
- E 信号不能被捕获。
- F 信号不能被忽略。
信号处理
进程对信号的处理方法有三种:
- 对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程。
- 设置信号的处理函数,收到信号后,由该函数来处理。
- 忽略某个信号,对该信号不做任何处理,就像未发生过一样。
signal函数(注册信号处理函数)可以设置程序对信号的处理方式。
1 |
|
参数:
signum:要处理的信号编号(如SIGINT、SIGQUIT)handler信号处理函数指针,可选值:- 自定义函数(如
void func(int)) SIG_IGN:忽略该信号SIG_DFL:恢复系统默认
- 自定义函数(如
信号的作用
服务程序运行在后台,如果想让中止它,杀掉不是个好办法,因为进程被杀的时候,是突然死亡,没有安排善后工作。
- 如果向服务程序发送一个信号,服务程序收到信号后,调用一个函数,在函数中编写善后的代码,程序就可以有计划的退出。
- 如果向服务程序发送0的信号,可以检测程序是否存活。
示例:
1 |
|
发送信号
Linux操作系统提供了kill和killall命令向进程发送信号,在程序中,可以用kill()函数向其它进程发送信号。
1 | int kill(pid_t pid, int sig); |
参数:
- pid
- pid>0 将信号传给进程号为pid 的进程。
- pid=0 将信号传给和当前进程相同进程组的所有进程,常用于父进程给子进程发送信号,注意,发送信号者进程也会收到自己发出的信号。
- pid=-1 将信号广播传送给系统内所有的进程,例如系统关机时,会向所有的登录窗口广播关机信息。
- sig:准备发送的信号代码,假如其值为0则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在运行。
返回值:
成功执行时,返回0;失败返回-1,errno被设置。
进程终止
有8种方式可以中止进程,其中5种为正常终止,它们是:
- 在main()函数用return返回;
- 在任意函数中调用
exit()函数; - 在任意函数中调用
_exit()或_Exit()函数; - 最后一个线程从其启动例程(线程主函数)用return返回
- 在最后一个线程中调用
pthread_exit()返回
异常终止有3种方式,它们是:
- 调用abort()函数中止;类似于接受一个信号
- 接收到一个信号;
- 最后一个线程对取消请求做出响应。
进程终止的状态
在main()函数中,return的返回值即终止状态,如果没有return语句或调用exit(),那么该进程的终止状态是0。
在Shell中,查看进程终止的状态:
1 | echo $? |
1 | //正常终止进程的3个函数(exit()和_Exit()是由ISO C说明的,_exit()是由POSIX说明的)。 |
如果进程被异常终止,终止状态为非0。
资源释放的问题
retun表示函数返回,会调用局部对象的析构函数,main()函数中的return还会调用全局对象的析构函数。exit()表示终止进程,不会调用局部对象的析构函数,只调用全局对象的析构函数。exit()会执行清理工作,然后退出,_exit()和_Exit()直接退出,不会执行任何清理工作。
| 特性维度 | return |
exit(int status) |
_exit(int status)(POSIX) |
_Exit(int status)(C 标准) |
|---|---|---|---|---|
| 本质 | C 语言关键字(函数返回语句) | 标准库函数(<stdlib.h>) |
系统调用封装(<unistd.h>) |
C 标准库函数(<stdlib.h>),是 _exit() 的标准化封装 |
| 作用范围 | 1. 普通函数:仅退出当前函数,返回到调用者2. main() 函数:等价于 exit(status),终止整个进程 |
终止整个进程(无论在哪个函数中调用) | 直接终止整个进程(底层实现,无任何中间逻辑) | 行为与 _exit() 一致(标准化版本),直接终止进程 |
| 退出前清理逻辑 | 1. 普通函数:无任何清理,仅返回2. main() 函数:触发与 exit() 相同的清理逻辑 |
执行完整的进程退出清理:1. 调用 atexit()/on_exit() 注册的回调函数2. 刷新所有打开的标准 I/O 缓冲区(如 printf 缓冲区)3. 关闭所有打开的文件描述符4. 删除进程创建的临时文件 |
无任何用户态清理逻辑:1. 不执行 atexit 回调2. 不刷新标准 I/O 缓冲区3. 仅内核层面释放进程资源(内存、文件描述符等) |
与 _exit() 完全一致(C 标准统一接口),无用户态清理,仅内核层面释放资源 |
| 返回状态传递 | 1. 普通函数:将值返回给调用者2. main():将 status 传给父进程(通过 wait() 获取) |
将 status 传给父进程(低 8 位有效) |
将 status 传给父进程(低 8 位有效) |
将 status 传给父进程(低 8 位有效) |
| 可重入性(信号场景) | 普通函数中安全;信号处理函数中 return 仅退出处理函数,无风险 |
不安全:清理逻辑可能调用 malloc/printf 等非可重入函数,信号处理函数中禁用 |
安全:无用户态清理,仅内核操作,信号处理函数中推荐使用 | 安全:与 _exit() 一致,信号处理函数中可使用 |
| 跨平台性 | 完全跨平台(C 语言基础特性) | 完全跨平台(C 标准库) | 仅 POSIX 系统(Linux/Unix),Windows 无此接口 | 完全跨平台(C99 及以上标准) |
| 底层依赖 | main() 中最终调用 exit() |
最终调用 _exit() 完成内核层面的进程终止 |
直接调用内核的 exit_group 系统调用 |
底层映射到对应系统的退出接口(Linux 下等价于 _exit()) |
| 典型使用场景 | 1. 普通函数返回结果 / 终止执行2. main() 函数终止进程(推荐) |
1. 非信号场景下主动终止进程2. 需要执行退出清理逻辑时(如刷新日志、释放临时资源) | 1. 信号处理函数中终止进程2. 不需要清理缓冲区 / 执行回调的场景(如子进程退出) | 跨平台场景下替代 _exit(),需要无清理的进程终止 |
进程终止函数
进程可以用atexit()函数登记终止函数(最多32个),这些函数将由exit()自动调用。
1 |
|
exit()调用终止函数的顺序与登记时相反。 进行进程退出前的收尾工作
调用可执行函数
Linux提供了system()函数和exec函数族,在C++程序中,可以执行其它的程序(二进制文件、操作系统命令或Shell脚本)。
system()函数
1 |
|
参数:
command为需要执行的命令字符串
返回值:
- 如果执行的程序不存在,system()函数返回非0;
- 如果执行程序成功,并且被执行的程序终止状态是0,system()函数返回0;
- 如果执行程序成功,并且被执行的程序终止状态不是0,system()函数返回非0。
1 |
|
1 | sh: 1: /aaa/bb: not found |
1 |
|
1 | root@LAPTOP-P5RJLSIB:/mnt/d/桌面# ./demo |
1 |
|
1 | /bin/ls: cannot access './aaa': No such file or directory |
exec函数族
1 |
|
常用是:execl()和execv()
tips:
- 如果执行程序失败则直接返回-1,失败原因存于errno中。
- 新进程的进程编号与原进程相同,但是,新进程取代了原进程的代码段、数据段和堆栈
- 如果执行成功则函数不会返回,当在主程序中成功调用exec后,被调用的程序将取代调用者程序,也就是说,exec函数之后的代码都不会被执行。
1 |
|
