Linux系统编程
类Unix系统目录
在Linux的根目录(/
)下,使用ls
命令,可以看到如下目录
- bin: 存放二进制可执行文件,常用命令的可执行都存放在这里
- boot: 启动Linux系统所需的静态文件,包括内核和启动引导程序的配置文件和二进制文件
- home: 存放普通用户的主目录,每个用户有自己的目录,如
/home/username
- etc: 存放系统配置文件,大多数系统和服务的配置文件都存放在此
- lib以及lib64: 存放系统库文件
- dev:存放设备文件,linux中 ”一切皆文件“ ,所有外设都是以文件的形式挂载到系统中
- mnt: 用于临时挂载文件系统,常用于挂载USB设备或其他外部存储设备。
- root: 系统管理员的主目录。
- tmp: 存放临时文件,这些文件在系统重启后会被删除。
- sbin: 存放系统管理命令,这些命令通常只有系统管理员才会使用,如
fdisk
、ifconfig
、init
等。 - usr: 一个较大的目录,包含用户程序和库文件。它是
unix software resource
的缩写,包含二进制文件、库文件、文档等。- /usr/bin:存放非必要的用户命令。
- /usr/lib 或 /usr/lib64:存放用户程序的库文件。
- /usr/local:存放本地安装的软件,不依赖于系统软件包管理器。
- /usr/share:存放共享数据,如文档、图标、桌面环境的配置文件等
- proc:是一个虚拟文件系统,包含关于当前运行的内核的信息,如进程、内存、设备状态等。
- sys:是一个虚拟文件系统,与
/proc
类似,提供了一种访问和控制硬件设备的方法。 - media:用于挂载可移动存储设备,如CD-ROM、DVD、USB闪存驱动器等。
- var:存放频繁变化的文件,如日志文件、临时邮件存储、打印队列等。
- srv:存放服务的数据文件,如Web服务器的数据。
- opt:存放可选的第三方软件包,通常是商业软件或不包含在默认发行版中的软件。
shell
shell是一个程序,它为用户和操作系统之间提供了一个命令行交互界面。如右图所示,shell接收用户的指令,与kernel沟通,kernel控制硬件工作。shell不是唯一可以与操作系统沟通的应用程序,更常见的是通过图形界面与操作系统进行交互。
常见的shell有bash(用于GNU/Linux系统),tcsh,powershell(用于Windows系统)。
Ubuntu下默认使用的是bash。
bash shell的功能
命令历史(history)
通过方向键上和下可以查看之前输入过的命令
history命令可以查看输入命令,通过history -n,可以查看最近输入的n条命令,如果省略-n,则会查看所有。默认的指令记忆最多1000个。
记忆的指令存储在用户主文件夹的.bash_history 文件中。
命令与文件名补全(Tab键)
通过tab键可以自动补全当前正在输入的文件名或命令
连续按两次tab键,可以显示所有相关的命令或文件名
别名(alias)
可以在命令行输入:alias 别名=’指令’
,来为一段指令绑定一个别名。例如 alias lm=’ls -al’
,让lm
成为ls -al
指令的别名。
要删除的话,使用unalias 别名
。
脚本(shell scripts)
bash可以将一系列指令写在一个文件中,形成一个脚本。
通配符(wildcard)
bash的很多指令中都支持通配符,如ls -l *.jpg
查看所有以.jpg结尾的文件。
查询指令是否是bash shell的内置命令:type
$ type [-tpa] name 选项与参数: :不加任何选项与参数时,type 会显示出 name 是外部指令还是 bash 内置指令 -t :当加入 -t 参数时,type 会将 name 以下面这些字眼显示出他的意义: file :表示为外部指令; alias :表示该指令为命令别名所设置的名称; builtin :表示该指令为 bash 内置的指令功能; -p :如果后面接的 name 为外部指令时,才会显示完整文件名; -a :会由 PATH 变量定义的路径中,将所有含 name 的指令都列出来,包含 alias
指令下达与编辑快捷键
使用\
可以将一行命令分成两行
快捷键 | 功能 |
---|---|
ctrl+u/ctrl_k | 相当于backspace和delete |
ctrl+a/ctrl+e | 光标移至最前/最后 |
目录与文件操作
linux系统文件类型
- 普通文件:
-
- 目录文件:
d
- 字符设备文件:
c
- 块设备文件:
b
- 软连接:
l
- 管道文件:
p
- 套接字:
s
- 未知文件
常用命令
cd—切换目录
cd [dir] 切换到dir cd / 切换到根目录 cd ~ 切换到当前用户目录/home/username cd .. 切换到上级目录 cd - 切换到上次切换的目录,即从dir1切换到dir2后,使用cd - 会切换到dir1,再使用一次,又会切换回dir2
ls—显示文件和文件夹
ls 显示当前路径下的文件和文件夹 ls [dir] 显示dir下的文件和文件夹 ls -l 显示当前路径下文件和文件夹的详细信息 ls -R 递归显示当前路径下的所有文件和文件夹以及文件夹中的文件和文件夹
stat—查看指定文件或文件夹的详细信息,显示的是inode中的信息
stat file // 查看file的详细信息
file—查看指定文件或文件夹是什么类型(来源)
识别文件的类型,如是文本文件还是压缩文件或是其他
file filename // 查看filename是什么文件
使用场景:假如一个压缩文件的后缀名是.mp3,那么你通常会认为它是一个音乐文件,但是使用file可以知道它其实是压缩文件
which—查看命令位置
which ls 查看ls命令所在路径
pwd—查看当前路径
pwd 查看当前路径
touch—创建新文件,或者更新已有文件的访问和修改时间(包括atime,ctime和mtime)
touch filename // 创建filename,或者更新filename的访问和修改时间 touch file{1,2,3} // 创建file1,file2,file3 touch file{a,b,c}.txt // 创建filea.txt,fileb.txt,filec.txt
mkdir—创建目录
mkdir [dir] 创建名为dir的文件夹
rmdir—删除空目录
可以一次删除多个,如果option是-p,可以连空的父目录一起删除
rmdir [option] dir rmdir -p test1/test2 如果test1中没有文件且test2中也没有文件,则会将它们都删除
rm—删除文件,删除目录
rm file 删除file rm dir -rf 递归删除dir及其中所有文件
cp—拷贝文件,拷贝目录
cp [file1] [file2] 在当前目录下拷贝一份file1,新的文件为file2 cp [file] dir/ 将file拷贝到dir路径下 cp [file] ../ 将file拷贝到上一层目录中 cp dir1 dir2 -r 将dir1文件夹及其中所有文件拷贝到dir2 cp dir1 dir2 -a 将dir1文件夹及其中所有文件拷贝到dir2
cp dir1 dir2 -r/-a
:如果dir2存在,就创建dir2/dir1,然后将dir1中的所有内容拷贝到dir2/dir1;如果dir2不存在,那么就创建dir2,然后将dir1中的所有内容拷贝到dir2中.
-a
和 -r
选项的区别:-a会连dir1的文件夹属性一并拷贝,而-r则不会。也就是说-a选项拷贝后,新创建的dir2/dir1或者dir2的文件属性和dir1是相同的。
cat—将文件内容输出到终端
如果cat命令后没有跟文件名,则会读取接下来键盘在终端的标准输入,并且在遇到 \n
后,将这些输入全部输出到标准输出,直到按下ctrl-d
,才会结束。
cat file 将file中的内容输出到标准输出 tac file 从file的最后一行向上逐行输出(只颠倒了行的输出顺序,列的输出顺序没变,还是从左到右)
more—查看文本文件的内容
屏幕显示完一屏就等待用户按下任意键再滚动到下一屏,如果中途不想继续看下去,可以按Ctrl+C或q停止。
more [option] FILE 一屏幕一屏幕的查看FILE内容,回车往下滚一行,空格往下翻一页
less—查看文本文件的内容
可以通过方向键向上和向下滚动,如果中途不想继续看下去,可以按q停止。
less [option] FILE
head—查看文本文件内容,显示指定的前几行内容
如果不指定行数,默认显示10行
head -5 FILE 显示FILE前5行
tail—查看文本文件内容
显示指定的后几行内容,如果不指定行数,默认显示10行
tail -5 FILE 显示FILE后5行
tree—将目录以树状结构显示
不是自带命令,需要安装
tree 显示当前目录结构树 tree dir 显示dir的结构树
wc—显示文件行数、单词数、字节数
计算文件的行数、单词数、字节数并显示,若是不指定文件名称,或是给予的文件名为“-”,则wc指令会从标准输入设备读取数据(此时在终端输入完数据后,要先按回车再按Ctrl+D,才能表示终止输入)。可以一次显示多个文件信息。
wc [option] file wc file 以:行数 单词数 字节数的顺序显示file的信息
-l或-lines:只显示行数
-w或-words:只显示单词数
-c或-bytes或-chars:只显示字节数
od—以指定格式(进制)显示文件
od [option] file od -tcx file 将file中的字符按照16进制显示
option中,-t+以下的任意字母决定数据的显示格式
c ASCII字符或反斜杠序列 d[SIZE] 有符号十进制数,每个整数S1ZE字节 f[SIZE] 浮点数,每个整数SIZE字节 O[SIZE] 八进制(系统里认值为02),每个整数SIZE字节 u[SIZE] 无符号十进制数,每个整数SIZE字节 x[SIZE] 十六进制数,每个整数SIZE字节:
du—查看某个目录的大小
du 查看当前路径下每个目录的大小 du dir 查看dir的大小
-h+单位:以该单位显示目录大小
du -hm dir 以M为单位显示 du -hb dir 以B为单位显示
df—查看磁盘使用情况
df --block-size=GB 以GB为单位显示所有磁盘使用情况 df --block-size=MB 以MB为单位显示所有磁盘使用情况
硬链接和软链接(ln)
ln -s
—软链接(symlink)
ln -s file1 file2 创建file1的软连接file2,可以通过file2访问到file1的内容
软链接中实际存储的是文件路径,这个路径根据创建时输入的文件路径可以是相对路径或绝对路径,如果创建的是文件绝对路径的软链接,那么该链接可以移到任何地方仍然适用,如果是相对路径的软链接,那么显然移动到其他路径下链接就会失效。
软连接可以跨越不同的文件系统
软连接通过ls -l
查看详细信息可以看到指向的原文件
删除原文件后,软链接失效
ln
—硬链接(hard link)
ln file1 file2 创建file1的硬链接file2,可以通过file2访问file1的内容
硬链接指向的是文件的inode(索引节点),而不是路径;
硬链接和原文件必须位于同一个文件系统内
硬链接与原文件具有相同的属性,看上去就像是一模一样的另一个文件
删除原文件不影响硬链接,因为原文件本质就是一个硬链接,只会将该文件的硬链接计数-1,只有硬链接计数为0时,文件才被真正删除
不能对目录创建硬链接
文件属性和用户用户组
whoami—查看当前登录用户
whoami
chmod—改变文件权限
符号模式:
chmod [who] [+|-|=] [mode] 文件名
who: (可以同时指定多个)
u:文件所有者
g:文件所有者同组用户
o:其他用户
a:所有用户(默认值)
操作符号:
+
:添加权限
-
:取消权限
=
:取消之前的权限,赋予给定权限
mode: (可以同时指定多个)
r:读
w:写
x:执行
数字模式:
三个二进制位一组,总共九个二进制位,分别表示文件所有者、同组用户以及其他用户的读、写、执行权限。实际使用中通常使用3个8进制数表示9个二进制数。
chmod 777 :111111111b 给所有用户读、写和执行的权限 chmod 644 :110100100b 给文件所有者读写权限,同组和其他用户读权限
chown—改变某个文件或目录的所属主和所属组
sudo chown [option] [owner:group] file
option:
-R
递归的改变指定目录及其下的所有子目录和文件的拥有者-v
显示chown命令所做的工作
owner和group都可以单独设置
sudo chown tourist file 将file的拥有者改为tourist sudo chown :tourist file 将file的拥有者改为tourist sudo chown tourist:tourist file 将file的拥有者和拥有组改为tourist
chgrp—改变改变某个文件或目录的所属组
sudo chgrp g8 file 将file的所属组改为g8
adduser—添加用户
sudo adduser newUser // 添加newUser用户,并且添加newUser用户组,newUser属于newUser组
在Linux系统中,当你创建一个新用户时,系统默认会为这个新用户创建一个同名的用户组(user group)。这个组的名称通常与用户名相同。这个组的创建是为了更好地管理文件权限和用户访问控制。
可以在/etc/passwd
文件查看所有用户
deluser—删除用户
addgroup—添加用户组
sudo addgroup newGroup // 添加新组newGroup
可以在/etc/group
文件中查看所有组
delgroup—删除用户组
su—改变登录用户,需要输入密码
su another // 将当前用户改变为another
su是switch user的简写
查找与检索
find
find—搜索文件,可以根据文件名、文件类型、文件大小、权限、所有权、修改日期等多种条件来查找文件。
find dir [option] 参数 // 在dir路径下查找符合option条件参数的文件 find /path/to/search -name 'filename' //在指定路径下搜索名为 filename 的文件 find /path/to/search -type f // 查找指定目录下所有普通文件,f对应的是-,其他类型不变(d就是d,l就是l) find /path/to/search -size +10M 查找所有大于10M的文件 find /path/to/search -mtime -7 查找最近7天修改的文件
- -name 按文件名查找(这里可以通过正则来查找指定后缀名的文件)
- -type 按文件类型(不是windows下后缀名那种类型,而是linux的7种文件类型)
- -size 按文件大小
- -mtime 按修改时间(m-modify),修改指的是修改文件内容,time表示单位-天,min-表示单位-分钟,如
-mmin -7
,查找的是最近7分钟 - -atime 按访问时间(a-access)
- -ctime 按改动时间(c-change),改动是指改动文件属性(如访问权限,属于哪个用户等)
find默认递归查找,可以通过-maxdepth限制搜索深度,-maxdepth要放在条件选项前
find /path/to/search -maxdepth 1 -name '*.jpg' // 查找指定目录下所有以.jpg结尾的文件,限制搜索层次一层
- 可以组合多个条件查找
- 可以使用正则表达式
- 可以对找到的文件执行操作
- 可以使用-exec执行命令,-exec后的命令会对find找到的所有文件进行操作
find /path/to/search -maxdepth 1 -name '*.jpg' -exec ls -l {} \;
{}
捕获find查找到的文件,\;
是必要的,表示命令结束了。
- -ok,跟-exec相似,但是在对每个文件操作前会询问是否对文件进行相应的操作。
- xargs:配合管道
|
使用,效果和-exec相同。结果集过大时,-xargs可以进行分片,-exec则不会这样,所以-xargs比-exec更好。
find ./ -type f | xargs ls -l // 查找当前路径下所有普通文件,并且显示它们的详细信息
-xargs存在一个问题,-xargs默认的文件拆分符号是空格,也就是[file file2 file]这样根据空格来拆分不同的文件名,但是如果一个文件名称本身就有空格的话,如‘file abc’,就会被认为是两个文件file和abc。解决这个问题的方法是改变文件拆分符号(-exec没有这个问题)
find ./ -maxdepth 1 -type f -print0 | xargs -print0 ls -l // 查找并输出当前目录下的普通文件的信息,且以null作为文件拆分符号
-print0增加到命令中后,会进行确认,输入y并回车,就能正确显示结果了。
grep
grep—查找文件中包含指定模式的行,可以指定一个或多个文件
grep [option] ‘pattern’ file grep 'as' a.c -n // 查找a.c文件中有字符串as的行内容,加-n显示行号 grep -r -n 'as' path // 递归查找path目录下所有含as的文件,显示文件名和含as的行内容,-n显示行号 ps aux | grep 'pattern' // 查找匹配的进程,如果结果只有一条,说明没有任何匹配的进程,那一条指的是grep自己
option
- -r:递归搜索目录下的所有文件,然后查找这些文件中包含指定模式的行
- -i:忽略大小写
- -w:只选择含有能组成完整的词的匹配的行显示
- -c:只显示匹配的行数(也即是不显示匹配的内容)
- -E:可以在pattern中使用正则表达式
- -v:排除匹配行,也就是显示该文件中不包含匹配的字符串的行
- -o:只显示匹配的单词
软件的安装与卸载
apt
apt是比apt-get更现代的工具,它们的命令几乎相同,下列的命令换成apt-get也能一样使用
安装
sudo apt update // 更新软件列表 sudo apt install [package_name] // 安装软件包
卸载
sudo apt remove [package_name] // 卸载软件 sudo apt purge [package_name] // 完全移除软件及其配置文件
升级
sudo apt upgrade
deb包安装
sudo dpkg -i xxx.deb // 安装deb包 sudo dpkg -r xxx.deb // 删除软件包,使用apt remove也可以卸载 sudo dpkg -r --purge xxx.deb // 移除软件及其配置文件 sudo dpkg -info xxx.deb // 查看软件包信息 sudo dpkg -L xxx.deb // 查看文件拷贝详情 sudo dpkg -l // 查看系统中已安装软件包信息 sudo dpkg-reconfigure xxx // 重新配置软件包
源码安装
根据源码中的readme,编译和安装
压缩与解压
tar—打包并压缩文件,解压文件
tar -zcvf file.tar.gz a b c // 将a,b,c打包并压缩为file.tar.gz,文件名不强制 tar -jcvf file.tar.bz a b c
- a b c指的是一系列文件或文件夹
- tar本身并不提供压缩功能,压缩功能由gzip提供,但是gzip只能压缩一个文件,而tar提供了多个文件打包成一个文件的功能
- -z:表示使用gzip来压缩或解压文件
- -j:表示使用bzip2来压缩或解压文件
- -c:表示创建一个新的归档文件(归档文件在这里表示压缩文件)
- -v :表示在压缩或解压缩的过程中显示文件列表
- -f :后面跟着的是要创建或解压的归档文件的名称
解压缩
tar -zxvf file.tar.gz // 将文件解压到当前文件夹 tar -zxvf file.tar.gz -C dir // 将文件解压到dir目录下
- -x:解压缩文件
- -C:指定解压的目标路径
rar—压缩和解压文件,支持多个文件打包压缩
rar a -m5 file.rar filea fileb filec // 将一系列文件打包压缩到file.rar unrar x file.rar // 在当前目录解压file.rar
- a:表示添加文件
- -m5:表示压缩级别,有m0(无压缩)到m5(最大压缩)
- rar和unrar工具需要安装
- unrar不支持直接指定解压路径
Gcc编译器
GCC(GNU编译器集合)是一个开源的编译器,编译C语言使用gcc命令,编译C++时使用g++命令。
gcc -I path // 指定头文件搜索目录,不指定的话默认当前目录 gcc -c file1.c file2.c -o file.o // 将一系列源码文件编译成目标文件 gcc -o file file1.c file2.o file3.a // 将一系列源码、目标文件和库一起编译链接成可执行文件 gcc -o test test.c ./lib/libmymath.a -I ./inc // 将源码和静态库编译链接成可执行文件,且指定头文件搜索路径
-
-I
(大写i):指定头文件所在目录位置 -
-c
:预处理+编译+汇编,得到二进制目标文件 -
-o
:链接生成可执行文件,或者跟-E, -S, -c一起使用时用作输出 -
-g
:编译时添加调试语句。主要用来支持gdb调试 -
-Wall
:显示所有警告信息 -
-D
:向程序中“动态”注册宏定义。 -
-l
(小写L):指定动态库名称(库名是lib和文件名后缀之间的单词) -
-L
:指定动态库路径 -
-fPIC
:生成与位置无关代码
静态库与共享库(动态库)
静态库
静态库:在可执行程序运行前就已经加入到执行码中,成为执行程序的一部分。共享库:在执行程序运行时加载到执行程序中,可以被多个执行程序共享使用。
静态库:对空间要求较低,而时间要求较高的程序中。
动态库:对时间要求较低,对空间要求较高的程序中。
静态库创建使用
-
首先创建目标文件
gcc -c add.c -o add.o gcc -c minu.c -o minu.o gcc -c divi.c -o divi.o -
使用ar工具将目标文件制作静态库
ar rcs [静态库名] [一系列目标文件] ar rcs libmymath.a add.o minu.o divi.o // 库的名称格式为:lib库名.a -
如何使用:将静态库和使用了这个库的文件一起编译链接
gcc -o test test.c libmymath.a // 编译链接后的可执行文件test中包含了静态库 gcc test.c libmymath.a -o test // 同上一条语句 gcc -o test test.c ./lib/libmymath.a -I ./inc // 将源码和静态库一起编译链接成可执行文件,且指定头文件搜索路径 注意:静态库的顺序要在源码后面
共享库(动态库)
目标文件(.o)中的符号的内存地址是相对地址(可以认为是相对main函数的偏移),与含有main函数的文件链接之后,会相对main符号的地址固定自身的地址。而动态库的文件并不会和使用它的源码链接在一起,因此动态库需要生成与位置无关的代码(可以认为是符号地址不再与main函数位置相关,而是相对于plt的偏移)。如下图所示,在反汇编中可以看到调用共享库函数都标注了@plt,plt也是内存中的一个段,和text,data这些段的地位是同等的。
静态链接: 链接器找到目标文件解析其符号地址,然后将符号地址合并到可执行文件的符号表中,且会将动态库以外的文件合并到可执行文件。
动态链接: 使用动态库的可执行文件拥有动态库的符号地址,但不拥有动态库文件,所以动态库的代码不会和可执行文件一起加载到内存中,但是可执行文件运行时需要通过符号地址找到动态库的代码,动态链接器的作用就是将动态库加载到内存中。动态链接器根据环境变量LD_LIBRARY_PATH中记录的路径去搜索动态库,因此在执行程序前,要将动态库的路径添加到LD_LIBRARY_PATH中。
共享库创建使用
-
生成位置无关代码(Position Independent Code, PIC)(
-fPIC
)gcc -c add.c -o add.o -fPIC // 生成add.o,且add.o中的符号地址不再是相对main符号的偏移 -
制作动态库(
-shared
)gcc -shared -o lib库名.so add.o minu.o divi.o // 将一系列位置无关代码的目标文件生成共享库 -
使用动态库(编译时通过
-l
指定库名,-L
指定库路径)(库名是lib和.so之间的部分)gcc -o test test.c -lmymath -L./lib // 使用动态库静态链接生成可执行代码 注意:-l与库名之间可以不加空格,-L与库路径之间也可以不加空格
但是此时./test的执行并不能成功,因为静态链接只是将动态库中的符号地址合并到可执行文件的符号表中,而程序运行时还需要将动态库代码加载到符号对应的内存地址,而这通过动态链接器来完成。动态链接器在在LD_LIBRARY_PATH变量记录的路径中搜索动态库并加载。
-
添加动态库路径
export LD_LIBRARY_PATH=./lib // a. 临时添加动态库路径 b. 通过在~/.bashrc中添加LD_LIBRARY_PATH变量的值,再source ~/.bashrc来重新加载该配置文件,可以永久的添加动态库路径。建议使用绝对路径。LD_LIBRARY_PATH可以包含多个路径,通过
:
符号分隔。c. 在
/etc/ld.so.conf
文件中写入动态库绝对路径,然后使用sudo ldconfig -v
使配置文件生效。
查看动态库是否能被加载
ldd 可执行文件
会列出可执行文件用到的动态库以及它们的位置,若是有无法找到的动态库,如libmymath.so => not found
,则表明存在无法被加载的动态库,那么显然可执行文件无法运行成功。
GDB调试
GNU 的调试器 GDB 提供了许多有用的特性,支持机器级程序的运行时评估和分析。
用以下的命令来启动 GDB:
gdb program-debug // 调试program-debug
gcc 默认将程序编译为 release 版本,要将程序编译为 debug 版本,需要在末尾加上 -g
,如下实例:
gcc -o hello-debug hello.c -g
以下是常用的 GDB 指令:
补充:
list 0
查看从文件开头往下 10 行代码,连续按回车,可以一直往下显示,直到全部显示完,并且会在代码的左侧显示行号,通过b 行号
可以打断点。- 大多数指令可以简写成开头的字母
- 注意查看寄存器使用的是
$
,即p $rbp
打印的是%rbp
的值
Makefile
Makefile 是一个用于自动化构建和管理项目的文件,它定义了如何从源文件生成目标文件(如可执行文件、库文件等)的规则和依赖关系。Makefile文件的名称只能是makefile或Makefile,执行命令是make。
Makefile 的基本概念
- 目标(Targets):Makefile 中的目标通常是最终要生成的文件,如可执行文件或库文件。目标可以是文件名,也可以是伪目标(如 all 、clean 等)
- 依赖(Dependencies):每个目标都有一个或多个依赖项,这些依赖项是生成目标所需的前提条件。依赖项通常是源文件或其他目标文件
- 规则(Rules):规则定义了如何从依赖项生成目标文件。
规则的基本形式:
目标:依赖 [tab]命令
- 重复执行make时,如果依赖的最后修改时间比目标的最后修改时间大,则会再次执行命令来更新目标,否则会忽略该条规则中的命令
- 如果依赖条件不存在,则会寻找别的规则去产生依赖
ALL:目标
默认情况下,make会将makefile中的第一条规则的目标作为最终目标,只要生成了该目标,就不再继续执行其余的规则。可以通过ALL:目标
来手动指定make的最终目标,ALL:目标
需要放在第一个目标之前。
伪目标和独立命令
伪目标后面通常不接依赖项,它们不是为了生成文件,在执行make命令后,也不会自动执行。它们的作用是用于执行一些特定的操作,即使伪目标的名称与项目中文件名相同,该执行也还是会执行。如下所示,定义了伪目标clean,当输入命令make clean
时,就会执行rm -rf *.o a.out
:
.PHONY: clean clean: rm -rf *.o a.out
独立命令和伪目标的区别就是未使用.PHONY声明。且为使用.PHONY声明的独立命令在当前目录下有同名文件的情况下无法被执行。
make的一些选项:
-n
:尝试执行并输出执行情况,但不真的执行(主要用处是make clean -n,防止删错文件无法恢复,先查看执行情况,确认没有问题,再真正执行)-f
:指定文件执行make命令,如make -f m3
,会将m3作为makefile文件来执行。默认的make会去执行makefile文件中的内容。
两个Makefile函数
src = $(wildcard *.c) #使用通配符 匹配当前路径下所有以.c结尾的文件名,并且将文件名列表赋给变量src obj = $(patsubst %.c,%.o,$(src)) #把变量src中所有的.c替换成.o,也就是说把参数3中的所有参数1部分替换成参数2
以下是一个实例:
.PHONY: clean ALL # 将clean和ALL声明为伪目标,防止有同名文件导致无法执行 src = $(wildcard *.c) # 将所有.c文件的文件名汇集成列表赋给变量src obj = $(patsubst %.c,%.o,$(src)) # 将.c替换成.o ALL:a.out a.out:$(obj) gcc -o a.out $(obj) hello.o:hello.c gcc -c hello.c -o hello.o add.o:add.c gcc -c add.c -o add.o minu.o:minu.c gcc -c minu.c -o minu.o divi.o:divi.c gcc -c divi.c -o divi.o clean: rm -rf $(obj) a.out
三个自动变量
自动变量用于将规则中的目标或依赖匹配到命令中
$@:表示规则中的目标
$<:表示规则中的第一个依赖
$^:表示规则中的所有依赖,组成一个列表,以空格隔开,如果这个列表中有重复的项则消除重复项
以下是一个实例:
.PHONY: clean ALL # 将clean和ALL声明为伪目标,防止有同名文件导致无法执行 src = $(wildcard *.c) # 所有.c文件 obj = $(patsubst %.c,%.o,$(src)) # 将.c替换成.o ALL:a.out a.out:$(obj) gcc -o $@ $^ hello.o:hello.c gcc -c $< -o $@ add.o:add.c gcc -c $< -o $@ minu.o:minu.c gcc -c $< -o $@ divi.o:divi.c gcc -c $< -o $@ clean: rm -rf $(obj) a.out
模式规则
模式规则是makefile中的一种强大的功能,允许你定义一类文件的生成规则,而不是为每个文件单独编写规则。模式规则使用通配符(%
)来匹配文件名的模式。
模式规则的基本格式如下:
target-pattern:dependency-pattern
例如
%.o:%.c command
会根据每一个.c结尾的文件来运行命令创建对应的.o文件。
静态模式
静态模式允许你为一组目标文件定义依赖关系和构建规则。也就是说,普通的模式规则适用于所有文件,而静态模式则是显示的指定了模式适用的文件列表。如下:
objects = a.o b.o c.o $(objects): %.o: %.c gcc -c $< -o $@
该实例中,静态模式只会在生成变量objects中的文件时生效,而不会为其他的.c文件执行命令生成.o文件。
一个实例:
.PHONY: clean ALL # 将clean和ALL声明为伪目标,防止有同名文件导致无法执行 src = $(wildcard *.c) # 所有.c文件 obj = $(patsubst %.c,%.o,$(src)) # 将.c替换成.o ALL:a.out a.out:$(obj) gcc -o $@ $^ %.o:%.c # 匹配所有.c文件,执行命令生成.o文件 gcc -c $< -o $@ clean: rm -rf $(obj) a.out
可以通过定义一个变量的方式,方便的加入一些命令参数,如下:
.PHONY:clean ALL src = $(wildcard *.c) # 所有.c文件 obj = \((patsubst %.c,%.o,\)(src)) # 将.c替换成.o myArgs = -Wall -g # 定义变量,用来增加命令参数 ALL:a.out a.out:$(obj) gcc -o $@ $^ $(myArgs) %.o:%.c gcc -c $< -o $@ $(myArgs) clean: rm -rf $(obj) a.out
文件IO
系统调用
系统调用:内核提供的函数。(可在man手册第2部分查看说明)
以下是调用C库函数printf的过程示例:
open/close函数
man 2 open
查看open函数的man手册。事实上,man man
可以看到man手册的介绍,其中写着系统调用的内容都在第2部分,因此查看系统调用的手册均使用man 2 函数名
,并且在vim编辑器中可以通过2+K
的快捷键来快速访问函数对应的man手册。
函数原型
int open(const char *pathname, int flags); int open(const char *pathname, int flags, mode_t mode) int close(int fd); // 关闭一个文件描述符
- pathname:文件路径
- flags:标识文件打开模式,多个标志一起使用时,中间用
|
分隔 - mode:仅在flags中包含O_CREAT时有效,用来设置文件打开时的访问权限
- 返回值:-1,表示文件打开失败。不为-1的情况下,返回的是文件描述符。
参数说明
常用的flags:(可以用|
符号连接来同时使用多个flag,如O_RDONLY|O_CREAT
)
- O_RDONLY:只读
- O_WRONLY :只写
- O_RDWR:读写
- O_CREAT:创建模式,可以创建文件
- O_EXCL :与O_CREAT一起使用,确保文件创建时如果已经存在同名文件,使open调用失败,这通常是防止文件被意外覆盖
- O_TRUNC:如果文件同时是可写模式,就将文件长度设置为0且丢弃其中的现有内容。
- O_APPEND:文件以追加模式打开,向该文件中写入数据会从文件的末尾开始。
- O_NONBLOCK:以非阻塞模式打开,通常用于读取可以产生阻塞的文件且希望对阻塞行为进行控制时。
仅当O_CREAT被作为参数,创建新文件时,mode参数有效,mode参数指定新文件的使用权限。可以使用9位二进制数来设置文件所有者-所有者组-其他用户权限,为了方便通常输入8进制数,8进制数格式是以0开头后面接8进制数,如0644
表示二进制数110100100
。
也可以通过标志位参数来设置权限,如S_IRWXU允许文件所有者读写和执行文件,S_IRUSR允许文件的所有者写文件, S_IWUSR运行文件所有者执行权限,很显然这些mode参数不好记忆,不如直接使用数字表示。
mode参数受umask影响,实际的权限为mode & ~umask
。umask(user file creation mask)是一个用于设置文件和目录创建时默认权限掩码的系统调用。它决定了新创建的文件和目录的默认权限。例如umask=0022,则新创建文件权限为0644。(默认新创建文件权限为0666,新创建目录为0777,而实际权限为这个值& ~umask
之后得到的结果)
open常见错误
- 打开文件不存在
- 以写方式打开只读文件(打开文件没有对应权限)
- 以只写方式打开目录
错误处理函数
errno
引入头文件<errno.h>后可以通过全局变量errno获取错误代码,errno是整数类型,没有错误时errno=0,有错误时返回一个大于0的数指示错误类型。
strerror
char *strerror(int errnum);
引入<string.h>后,通过strerror(errno)可以获取errno对应的错误消息。
perror
void perror(const char *s);
引入<stdio.h>,perror(””)会先打印传入的字符串(如果有的话),然后打印一个冒号和空格,接着打印errno对应的错误消息。
文件描述符
PCB(进程控制块)
PCB(Process Control Block,进程控制块)是一个结构体,用于管理和描述进程信息。每个进程都有一个与之对应的 PCB,用于存储进程的各种状态信息和管理信息。
文件描述符表
结构体PCB的成员变量 file_struct* file指向文件描述符表。从应用程序使用角度可以认为进程描述符表是一个大小为1024的数组,数组中存放的是文件指针(本质上是键值对,0、1、2…分别映射对应地址),其中0,1,2分别是标准输入、标准输出和标准错误,所以用户打开的文件描述符从3开始。
注意:
- 一个进程最多打开1024个文件描述符(每个进程的PCB是独立的,每个进程的文件描述符表也是独立的)
- 每次打开文件时,都使用当前可用的最小文件描述符
read/write函数
read:从一个文件描述符读取数据到buf中
write:将buf的数据写入一个文件描述符
函数原型
ssize_t read(int fd, void *buf, size_t count) ssize_t write(int fd, const void *buf, size_t count);
参数说明
fd:文件描述符
buf:缓冲区
count:read中表示缓冲区buf大小,write中表示要写入的数据大小(小于等于buf大小,buf相当于一个容器,不一定是装满的状态)
ssize_t :
- 返回非零正数,返回的是读取/写入的字节数;
- 0,表示已经全部读取/写入完毕;
- -1,错误,设置errno。如果errno=EAGIN或EWOULDBLOCK,说明不是read失败,而是read正在以非阻塞方式读取一个设备文件(网络文件),且该文件没有数据。
write函数中buf参数用const修饰的原因是,防止在从源文件向目标文件写入的过程中,将源文件修改掉了。
程序示例
以下程序实现了文件的拷贝功能,其中源文件和目标文件都是在命令中输入,如./a.out file1 file2
#include<unistd.h> #include<fcntl.h> #include<stdio.h> #include<errno.h> #include<stdlib.h> int main(int argc, char* argv[]) { int fd1 = open(argv[1], O_RDONLY); int fd2 = open(argv[2], O_WRONLY|O_CREAT|O_TRUNC, 0644); if(fd1-1) { perror("open argv1 error"); exit(1); } if(fd2-1) { perror("open argv2 error"); exit(1); } int size = 0; char buf[1024]; while((size=read(fd1, buf, sizeof(buf)))!=0) { if(size<0) { perror("read error"); break; } write(fd2, buf, size); } close(fd1); close(fd2); return 0; }
其中:
- perror可以接收一个字符串并打印,并且在这之后打印冒号加空格加errno对应的错误消息
- exit(1)表示程序以错误状态退出
预读入缓输出
C库函数中也提供了fopen,fclose,fputc,fgetc
这四个函数,它们的功能与系统调用open,close,read,write
基本相同,区别在于
- C库的这些函数内部默认维护了一个4kB的缓冲区,你虽然调用了fputc,但实际上并不会立即调用内核接口,而是在存满4kB的内部缓冲区后才去调用内核接口,将这4kB的数据写入内核缓冲区。内核同样也维护了一个4kB的缓冲区,缓冲区满时才会将数据写入磁盘。
- 系统调用,每次调用write写入数据都会调用内核接口, 将数据写入内核的4kB缓冲区,也就是说相较于C库函数,少了一个4kB的缓冲区。
需要注意的是从用户态进入到内核态所需要的资源成本较高,因此在写入同样多的数据,且buf小于4kB的情况下,使用系统调用函数花费的时间要大于C库函数,但显然系统调用能让程序员对读写行为进行更细致的控制。下图展示了使用C库函数和系统调用写入的差异:
阻塞、非阻塞
常规文件无阻塞概念。
产生阻塞的场景:
- 读设备文件
- 读网络文件
示例:
int main() { char buf[10]; int size = read(STDIN_FILENO, buf, count); if(size<0) { perror("read STDIN_FILENO error"); exit(1); } write(STDOUT_FILENO, buf, n); return 0; return 0; }
该示例中,read函数从标准输入文件描述符读取数据,然后使用write函数将数据写到标准输出。显然,标准输入是从终端输入,标准输出是向终端输出。这段代码运行后,就会阻塞,直到我们在终端输入字符串并且按下回车,这时会将输入的字符串打印在终端。
值得注意的是,阻塞不是read函数的功能,阻塞是文件的属性。标准输入文件描述符会打开终端文件/dev/tty
,ll /dev/tty
可以看到文件的类型是c,也就是字符设备文件。
/dev/tty
该文件是linux的终端文件,读取这个文件就是在读取终端的输入,向这个文件写入就是向终端输出。
下面是一个以非阻塞方式读取/dev/tty
文件的示例:
#include<stdio.h> #include<unistd.h> #include<fcntl.h> #include<errno.h> #include<stdlib.h> #include<string.h> int main() { char buf[10]; int fd, n; fd = open("/dev/tty", O_RDONLY|O_NONBLOCK); // 以非阻塞只读模式打开文件 if(fd<0) { perror("open /dev/tty"); exit(1); } fd = open("/dev/tty", O_RDONLY|O_NONBLOCK); // 以非阻塞只读模式打开文件 if(fd<0) { perror("open /dev/tty"); exit(1); } tryagain: n = read(fd, buf, 10); if(n<0) { if(errno!=EAGAIN) // 判断是否是空数据的设备文件(网络文件) { perror("read /dev/tty"); exit(1); }else{ write(STDOUT_FILENO, "try again\n", strlen("try again\n")); sleep(2); goto tryagain; } } write(STDOUT_FILENO, buf, n); close(fd); }
直接使用read读取STDIN_FILENO时,会以阻塞方式打开终端文件,然后读取该文件,这样在没有输入时就会陷入阻塞状态。而上述例子以O_NONBLOCK
非阻塞状态手动打开了终端文件/dev/tty
,此时可以根据读取错误时errno的值来判断这是否是一个空的设备文件(网络文件),以此来处理该文件。
fcntl函数
manipulate file descriptor(修改文件描述符),主要用处是根据文件描述符获取文件属性并进行修改。
原型
#include <unistd.h> #include <fcntl.h> int fcntl(int fd, int cmd, ... /* arg */ );
- fd:文件描述符
- cmd:宏命令,根据传入的宏,fcntl函数有不同的行为,且后面还可以跟一些与宏相关的参数。用的比较多的是
F_GETFL
和F_SETFL
,这两个宏用于获取文件属性和修改文件属性
F_GETFL
和F_SETFL
,示例:
int flags; flags = fcntl(STDIN_FILENO,F_GETFL);//获取stdin属性信息 if(flags == -1){ perror("fcntl error); exit(1); } flags |= O_NONBLOCK; int ret = fcntl(STDIN_FILENO, F_SETFL, flags); if(ret == -1){ perror("fcntl error"); exit(1); }
F_DUPFD
**:**fcntl(fd, F_DUPFD, newfd)
会使文件描述符newfd指向文件描述符fd指向的文件,且返回值就是newfd,相当于dup2(fd, newfd)
(fd<—newfd)。当newfd已被占用时,会使用当前文件描述符表中可用的最小文件描述符指向fd指向的文件,并将该描述符返回,此时相当于dup(fd)
。
lseek函数
lseek - reposition read/write file offset(修改文件读写偏移)
每个打开的文件都记录着当前读写位置,打开文件时读写位置是0,表示文件开头。之后读写多少字就会将读写位置后移多少字节。唯一的例外是以O_APPEND模式打开文件,每次写操作都会在文件末尾追加数据,然后将读写移到新的文件末尾。(可以理解为有一个光标,读写都是在光标位置进行,并且每次读写都会移动光标,lseek的作用就是改变光标位置)
原型
#include <sys/types.h> #include <unistd.h> off_t lseek(int fd, off_t offset, int whence);
lseek会将文件描述符fd的光标移动到whence+offset位置。
- fd:文件描述符
- offset:偏移量
- whence:一个偏移位置。
-
SEEK_SET
表示文件开始 -
SEEK_CUR
表示当前位置 -
SEEK_END
表示文件末尾位置
-
- 返回值 :
- 成功,返回相对于文件起始位置向后的偏移量
- 失败,-1,设置errno
应用场景
文件的读和写共用一个光标。
使用lseek获取、拓展文件大小。
int length = lseek(fd, 0, SEEK_END); // 获取文件大小
// 拓展文件大小 int length = lseek(fd, 100, SEEK_END); // 将光标后移100位 write(fd, "\0", strlen("\0")); // 写入一个字符,引起IO操作,文件大小拓展101字节。
移动光标之后,必须写入数据,文件大小拓展才有效。拓展的大小等于光标后移的位数+写入的字节数。
od -tcx filename 查看文件的16进制表示形式
od -tcd filename 查看文件的10进制表示形式
正规的文件拓展大小方式是使用truncate函数,需要文件有写的权限才能拓展大小
#include <unistd.h> #include <sys/types.h> int truncate(const char *path, off_t length); int ftruncate(int fd, off_t length);
- path:文件路径
- length:长度
- fd:文件描述符
- 返回值:0,成功;-1,错误,设置errno
传入传出参数
传入参数
- 以指针作为函数参数
- 通常有const关键字修饰
- 指针指向有效区域,在函数内部作读操作
传出参数
- 以指针作为函数参数
- 在函数调用之前,指针指向的空间可以无意义,但必须有效
- 在函数内部,作写操作
- 函数调用结束后,传出参数会被函数改变
传入传出参数
- 以指针作为函数参数
- 在函数调用之前,指针指向的空间有实际意义
- 在函数内部,先做读操作,后做写操作
- 在函数调用结束后,被修改并返回给调用者
传入传出参数举例
#include <string.h> char *strcpy(char *dest, const char *src); // 将src指向的数据拷贝给dest char *strncpy(char *dest, const char *src, size_t n);
- dest:传出参数
- src:传入参数
- 返回值:指向dest的指针
#include <string.h> char *strtok(char *str, const char *delim); // 将字符串str按照delim进行分割,返回值是每次分割出来的字符串,具体使用示例见strtokTest.c char *strtok_r(char *str, const char *delim, char **saveptr); // strtok的线程安全版本,使用传入的变量saveptr来保存状态(当前处理到的位置),使用方法和strtok一样,只是要传入一个saveptr字符串地址
- saveptr:传入传出参数,保存当前处理到的位置
- str:第一次调用函数时传入要分割的字符串,后续传入NULL
- delim:分割符号
- 返回值:依次返回分割的字符串;返回NULL,说明已经分割完全部的字符串了
strtokTest.c
#include<string.h> #include<stdio.h> int main() { char str[] = "apple, banana, cherry"; const char* delim = ","; char* token = strtok(str, delim); while(token!=NULL) { printf("%s\n", token); token = strtok(NULL, delim); } return 0; char* token = strtok(str, delim); while(token!=NULL) { printf("%s\n", token); token = strtok(NULL, delim); } return 0; }
文件系统
文件存储
inode(索引节点)
inode(索引节点)是 Unix 和 Linux 文件系统中的一个基本数据结构,本质是结构体,用于存储文件的元数据(metadata),如:权限、类型、大小、时间、用户、盘块位置等。每个文件和目录在文件系统中都有一个唯一的inode。
大多数的inode存储在磁盘上,少量经常使用、近期使用的inode会被缓存到内存中。
目录项,inode和文件数据的关系如下图所示:
dentry(目录项)
dentry用于表示文件系统中的文件和目录。本质是一个结构体。
dentry 的定义和结构
- 文件名:
dentry
包含文件或目录的名称,但不包括文件的数据内容或元数据。 - 父子关系指针:每个
dentry
包含指向其父dentry
的指针和子dentry
的列表,形成了一棵dentry
树。 - 指向
inode
** 的指针**:dentry
包含指向该文件或目录对应的inode
的指针,通过该指针可以访问文件的详细信息。 - 引用计数:用于管理
dentry
在缓存中的生命周期
linux中创建文件的硬链接本质就是创建一个目录项。因此dentry与inode的关系是多对一。
文件系统
文件系统是一组规则,规定了对文件的存储及读取的一般方法。文件系统在磁盘格式化过程中指定。
常见文件系统有:fat32 、ntfs 、exfat、 ext2、ext3、ext4
文件操作
stat函数
获取文件属性,从(inode结构体中获得)
#include <sys/types.h> #include <sys/stat.h> #include <unistd.h> int stat(const char *pathname, struct stat *statbuf); // 将文件的属性传到statbuf中
- pathname:文件名
- statbuf:stat结构体指针(传出参数),文件的属性会存储到该参数中
- 返回值:0,成功;-1,错误,并设置errno。
stat结构体的详细信息:
struct stat{ dev_t st_dev; /* ID of device containing file */ ino_t st_ino; /* Inode number */ mode_t st_mode; /* File type and mode */ nlink_t st_nlink; /* Number of hard links */ uid_t st_uid; /* User ID of owner */ gid_t st_gid; /* Group ID of owner */ dev_t st_rdev; /* Device ID (if special file) */ off_t st_size; /* Total size, in bytes */ blksize_t st_blksize; /* Block size for filesystem I/O */ blkcnt_t st_blocks; /* Number of 512B blocks allocated */ /* Since Linux 2.6, the kernel supports nanosecond precision for the following timestamp fields. For the details before Linux 2.6, see NOTES. */ struct timespec st_atim; /* Time of last access */ struct timespec st_mtim; /* Time of last modification */ struct timespec st_ctim; /* Time of last status change */ define st_atime st_atim.tv_sec /* Backward compatibility */ define st_mtime st_mtim.tv_sec define st_ctime st_ctim.tv_sec /* Since Linux 2.6, the kernel supports nanosecond precision for the following timestamp fields. For the details before Linux 2.6, see NOTES. */ struct timespec st_atim; /* Time of last access */ struct timespec st_mtim; /* Time of last modification */ struct timespec st_ctim; /* Time of last status change */ };
st_mode由两个字节总共16位二进制数组成,用来表示文件的类型及访问权限,如下图:
文件类型
文件类型位占前四位,表示文件的8种类型。分别是:普通文件-
,目录文件d
,字符设备文件c
,块设备文件b
,软连接l
,管道文件p
,套接字s
,未知文件。
访问权限
U-G-O每三位二进制来表示读写执行权限。
特殊权限
总共三位,依次是设置组ID位(setGID),设置用户ID为(setID),粘滞位(stiky)
粘滞位(stiky)
可以对目录设置粘滞位。被设置了该位的目录,其内部文件只有:
-
超级管理员
-
该目录所有者
-
该文件的所有者
以上三种用户有权限做删除、修改操作。其他用户可以读、创建但不能随意删除。
设置用户ID位(setUID)
进程有两个ID:
- EID(有效用户ID),表示进程履行哪个用户的权限。
- UID(实际用户ID),表示进程实际属于哪个用户。
多数情况下,EID和UID相同。但是,当文件的setID被设置后,文件被执行时会以该文件所属的用户权限来执行,而不是当前进程所属用户权限。
例如:当进程执行一个root用户的文件,若该文件的setID位被设置为1, 那么执行该文件时,进程的UID不变。EID变为root,表示进程开始履行root用户权限。
stat使用示例:
#include<sys/types.h> #include<sys/stat.h> #include<unistd.h> #include<string.h> #include<errno.h> #include<stdlib.h> #include<stdio.h> int main(int argc, char* argv[]) { struct stat sbuf; int status = stat(argv[1], &sbuf); if(status==-1) { perror("error"); exit(1); } printf("%s size: %ld bytes\n", argv[1], sbuf.st_size); return 0; printf("%s size: %ld bytes\n", argv[1], sbuf.st_size); return 0; }
lstat函数
原型
int lstat(const char *pathname, struct stat *statbuf);
lstat和stat区别:
- stat会穿透符号链接,lstat不会。
- 穿透符号链接的意思是,通过st_mode来判断文件类型时,对于符号链接文件,stat获取的是符号链接的本尊的文件信息,而lstat获取到的是符号链接文件的信息。
如下所示:
#include<sys/types.h> #include<sys/stat.h> #include<string.h> #include<stdlib.h> #include<stdio.h> #include <sys/sysmacros.h> int main(int argc, char* argv[]) { struct stat sbuf; // int status = stat(argv[1], &sbuf); int status = lstat(argv[1], &sbuf); if(status==-1) { perror("stat error"); exit(1); } switch (sbuf.st_mode & S_IFMT) { case S_IFBLK: printf("block device\n"); break; case S_IFCHR: printf("character device\n"); break; case S_IFDIR: printf("directory\n"); break; case S_IFIFO: printf("FIFO/pipe\n"); break; case S_IFLNK: printf("symlink\n"); break; case S_IFREG: printf("regular file\n"); break; case S_IFSOCK: printf("socket\n"); break; default: printf("unknown?\n"); break; } return 0; return 0; }
当使用该程序判断文件类型,且以一个软连接文件名作为参数时,如果用的是stat,输出的是regular file,如果用的是lstat,输出的是symlink。
link函数
link函数用于创建一个文件的硬链接。硬链接是指向inode的目录项。
int link(const char *oldpath, const char *newpath);
- oldpath:已有的文件路径
- newpath:新的文件路径
- 返回值:0—成功;-1—失败,设置errno
unlink函数用来删除一个文件的目录项
int unlink(const char *pathname);
- pathname:文件路径
应用:通过link和unlink函数实现mv命令的效果
先通过link创建文件的另一个目录项,然后删除原来的目录项。
unlink函数的特征:
清除目录项时,如果文件的硬链接数降到了0,也就是没有dentry指向该文件的inode了,那么等到所有打开该文件的进程关闭该文件后,系统会挑时间将该文件释放掉。(也就是说,不会立即将文件释放,而是等到没有进程打开该文件了,才会释放)
隐式回收
当进程结束运行时,所有该进程打开的文件会被关闭,申请的内存空间会被释放。系统的这一特征称之为隐式回收系统资源。
symlink函数
为一个dentry(目录项)创建一个符号链接(软连接)
int symlink(const char *target, const char *linkpath);
- target:源文件路径
- linkpath:符号链接路径
- 返回值:0—成功;-1—失败,设置errno
符号链接文件中实际存储的是创建符号链接时传入的参数target,也就是一个路径。
readlink函数
readlink函数可以读取符号链接文件本身的内容,也就是得到符号链接存储的源文件路径。(cat会穿透符号链接,获取符号链接的源文件内容)
ssize_t readlink(const char *pathname, char *buf, size_t bufsiz);
- pathname:符号链接文件路径
- buf:缓冲区
- bufsiz:缓冲区大小
- 返回值:0,成功;-1,失败,设置errno
rename函数
重命名一个文件,相当于mv
int rename(const char *oldpath, const char *newpath);
- oldpath:源文件路径
- newpath:新的文件路径
- 返回值:0—成功;-1—失败,设置errno
目录操作
工作目录:./
代表当前目录,指的是进程当前的工作目录,默认是进程所执行的程序所在的目录位置。
getcwd函数
获取进程当前工作目录。相当于命令pwd。
char *getcwd(char *buf, size_t size);
- buf:缓冲区
- size:buf大小
- 返回值:成功—buf中保存当前进程工作目录;失败—NULL
chdir函数
改变当前进程的工作目录
int chdir(const char *path);
- path:目录路径
- 返回值:0—成功;-1—失败,设置errno
文件、目录权限
linux中,一切皆文件,也就是说,目录是文件。 其文件内容是该目录下所有子文件的dentry(目录项)。使用vim打开一个目录,可以看到类似于下面的内容
" ============================================================================ " Netrw Directory Listing (netrw v156) " /home/xrain/libtest " Sorted by name " Sort sequence: [\/]$,\<core\%(\.\d\+\)\=\>,\.h$,\.c$,\.cpp$,\~\=\*$,*,\.o$,\.obj$,\.info$,\.swp$,\.bak$,\~$ " Quick Help: <F1>:help -:go up dir D:delete R:rename s:sort-by x:special " ============================================================================== ../ ./ .vscode/ inc/ lib/ resource/ test.c test* test1*
文件和目录的权限
r | w | x | |
---|---|---|---|
文件 | 文件内容可以被查看 | 文件内容可被修改 | 可以运行产生一个进程 |
目录 | 目录可以被浏览(ls) | 可以在目录创建、删除、修改文件 | 可以被打开、进入(cd) |
opendir函数
根据传入的目录路径打开一个目录。(库函数,属于man手册第3部分)
#include <sys/types.h> #include <dirent.h> // dirent和dentry一样都是directory entry的缩写 DIR *opendir(const char *name);
- name:目录路径,支持绝对路径、相对路径
- 返回值:DIR是目录流指针,目录流是一个抽象概念,实际作用就是标识这个目录,后续通过该目录流指针操作。成功返回指向该目录的目录流指针;失败返回NULL,设置errno
closedir函数
关闭打开的目录。
int closedir(DIR *dirp);
- dirp:目录流指针
- 返回值:0—成功;-1—失败,设置errno
readdir函数
读取目录。
struct dirent *readdir(DIR *dirp);
- drip:目录流指针
- 返回值:成功—返回指向该目录中的一个dirent的结构体指针;失败—返回NULL,设置errno
关于dirent结构体的信息如下所示:
struct dirent { ino_t d_ino; /* Inode number */ off_t d_off; /* Not an offset; see below */ unsigned short d_reclen; /* Length of this record */ unsigned char d_type; /* Type of file; not supported by all filesystem types */ char d_name[256]; /* Null-terminated filename(以Null结尾的文件名),就是文件名 */ };
一个使用readdir函数实现ls效果的例子:
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include<dirent.h> int main(int argc, char* argv[]) { DIR* dp; struct dirent* sdp; dp = opendir(argv[1]); if(dp == NULL) { perror("opendir error"); exit(1); } while((sdp = readdir(dp))!=NULL) { printf("%s\t", sdp->d_name); } printf("\n"); closedir(dp); return 0; while((sdp = readdir(dp))!=NULL) { printf("%s\t", sdp->d_name); } printf("\n"); closedir(dp); return 0;
为什么关于目录的操作函数中只有打开、关闭和读,却没有写入函数呢?因为对目录文件的写入实质上就是在目录中创建文件或是修改、删除目录中的文件,所以对目录写操作属于文件操作。
递归遍历目录
第一种实现。递归打印目录中的所有文件名,只能接收目录作为参数,且只能接受一个参数。
/* 思路: 递归函数中,打开该目录,然后使用while循环读取目录中的所有目录项,对每个目录项进行判断 如果该目录项不是目录,就打印文件路径 否则,将该目录路径作为参数调用递归函数 */ int recur_readdir(const char* path) { DIR* dp; struct dirent* sdp; dp = opendir(path); if(dp == NULL) { perror("opendir error"); exit(1); } while ((sdp=readdir(dp))!=NULL) { char full_path[1024]; snprintf(full_path, sizeof(full_path), "%s/%s", path, sdp->d_name); if(sdp->d_name[0] == '.'){ continue; } if(sdp->d_type != DT_DIR ) { printf("%s\t", full_path); }else{ recur_readdir(full_path); } } closedir(dp); return 0; } int main(int argc, char* argv[]) { recur_readdir(argv[1]); printf("\n"); return 0; while ((sdp=readdir(dp))!=NULL) { char full_path[1024]; snprintf(full_path, sizeof(full_path), "%s/%s", path, sdp->d_name); if(sdp->d_name[0] == '.'){ continue; } if(sdp->d_type != DT_DIR ) { printf("%s\t", full_path); }else{ recur_readdir(full_path); } } closedir(dp); return 0; return 0; }
第二种实现。打印目录下所有文件名及其大小,可以接收多个参数,且既能接受目录路径作为参数,也可以接受文件路径作为参数。和上一种原理一样,没有高下之分,只是这种实现增加了点内容。
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include<sys/stat.h> #include<dirent.h> int recur_readdir(const char* path); int read_dir(const char* dir) { DIR* dp; struct dirent* sdp; char fullpath[1024]; if((dp=opendir(dir))==NULL) // 判断目录是否正确打开 { perror("opendir error"); exit(1); } while ((sdp=readdir(dp))!=NULL) // 读出目录内所有文件,并调用recur_readdir处理 { if(sdp->d_name[0] == '.') continue; snprintf(fullpath, sizeof(fullpath), "%s/%s", dir, sdp->d_name); // 拼接目录和文件名 recur_readdir(fullpath); } closedir(dp); return 0; } int recur_readdir(const char* path){ struct stat sbuf; int status = stat(path, &sbuf); if(status == -1) // 判断是否正确获取到文件属性 { perror("stat error"); exit(1); } if((sbuf.st_mode & S_IFMT) == S_IFDIR) // 判断文件是否是目录 { read_dir(path); // 读取目录 }else{ printf("%s\t%ld\n", path, sbuf.st_size); } return 0; } int main(int argc, char* argv[]) { if(argc == 1) { printf("lack args!\n"); }else { for(int i=1; i<argc; ++i) { recur_readdir(argv[i]); } } return 0; return 0; if((sbuf.st_mode & S_IFMT) == S_IFDIR) // 判断文件是否是目录 { read_dir(path); // 读取目录 }else{ printf("%s\t%ld\n", path, sbuf.st_size); } return 0; return 0; }
重定向
dup和dup2
dup函数,输入一个文件描述符,返回一个新的文件描述符指向输入的文件描述符指向的文件。
dup2函数,输入两个文件描述符,让newfd指向oldfd指向的文件。(oldfd<—newfd)
#include <unistd.h> int dup(int oldfd); int dup2(int oldfd, int newfd);
On success, these system calls return the new file descriptor. On error, -1 is returned, and
errno is set appropriately.
dup2函数的作用示意图如下:
应用场景:把对一个文件的输入重定向到另一个文件。例如cat file1 > file2
,本来cat file1会将file1的内容输入到标准输出,而使用了重定向符号后,标准输出被重定向到file2。
示例:如下代码在给予一个已经存在的文件作为参数时,会将本该打印到终端的内容打印到该文件中
int main(int argc, char* argv[]) { int fd = open(argv[1], O_RDWR); // 打开参数所表示的文件 int fdret = dup2(fd, STDOUT_FILENO); // 将标准输出文件描述符重定向到fd指向的文件 printf("123456789\n"); // 123456789会输出到fd指向的文件中 return 0; return 0; }
fcntl(fd, F_DUPFD, newfd)
当newfd未被占用时,使newfd指向fd指向的文件,返回值为newfd,相当于
dup2(fd,newfd)
当newfd被占用时,返回一个可用的最小文件描述符,使该文件描述符指向fd指向的文件,相当于
dup(fd)
,返回值是新的指向fd指向文件的文件描述符。
进程
进程相关的概念
程序与进程
程序,是指编译好的二进制文件,只占用磁盘空间。
进程,是指运行起来的程序,占用内存、CPU等系统资源。
并发与并行
并发指的是多个任务在同一时间段内交替执行,但并不是同时执行。
并行指的是多个任务同时在多个处理器或核心上执行。
单道程序设计
所有程序一个一个排队执行。若A阻塞,B只能等待,即使CPU处于空闲状态。
多道程序设计
在计算机内存中同时存放多个相互独立的程序,它们在管理程序控制下,相互穿插的运行。宏观并行,微观串行。多道程序设计必须有硬件基础作为保证。
中断系统是多道程序设计模型的硬件基础。中断系统允许外部设备或程序在需要时打断CPU的当前执行。
在多道程序设计中,时钟中断用于实现时间片轮转调度算法。操作系统通过时钟中断来分配每个进程的时间片,当一个进程的时间片用完时,时钟中断会触发,操作系统会暂停当前进程的执行,保存其状态,并切换到另一个进程执行。
CPU与MMU
CPU(Central Processing Unit)中:
- CU(控制单元)负责控制指令,包括取指、译码、执行指令、控制程序输入输出、总线管理以及处理异常情况和特殊情况。
- ALU(算术逻辑单元)负责完成算术运算和逻辑运算
- 寄存器用于存储数据
- 中断系统用于处理异常情况及特殊请求
CPU与物理内存之间隔着MMU(内存管理单元),CPU访问的是虚拟内存,实际访问到的物理内存由MMU管理。MMU是一个硬件模块,负责处理CPU发出的内存访问请求。在现代处理器中,MMU通常集成在CPU芯片内部。
MMU(Memory Management Unit)的功能:
- 虚拟内存与物理内存的映射
- 设置修改内存访问权限。
MMU中的页表用于存储虚拟地址到物理地址的映射关系,一般为4kB。
每个进程拥有自己的虚拟内存空间,其中分为3G的用户空间和1G的内核空间(32位系统),内核空间是所有进程共享的,也就是说所有进程的内核空间映射到同一块物理内存区域,而用户空间则是私有的,每个进程的用户空间映射到物理内存中的不同区域,虚拟内存中开辟的连续的内存实际映射在物理内存中并不一定是连续的。
MMU的访问权限控制:
- 页表和权限位
- 每个页表包含权限位,当进程访问内存时,在从虚拟内存地址转换为物理内存地址时,MMU会检查页表中的权限位。
- 内核态和用户态
- 进程运行在用户态时,只能访问用户空间。进程运行在内核态时,可以访问所有内存空间。
- 系统调用和中断
- 用户进程可以通过系统调用进入内核态,以请求内核提供的服务。
- 中断也可以让进程从用户态切换到内核态。
PCB(进程控制块)
每个进程在内核中都有一个PCB来维护进程相关信息。linux内核的PCB是task_struck结构体,在linux内核源码的/include/sched.h文件中可以查看struct task_struct结构体定义。其内部成员很多,比较重要的有如下部分:
- 进程id。非负整数,系统中每个进程有一个唯一的id。
ps aux
可以查看linux当前所有进程 - 进程的状态。就绪、运行、挂起、停止。
- 进程切换时需要保存和恢复的一些寄存器
- 描述虚拟地址空间的信息
- 描述控制终端的信息
- 当前工作目录
- umask掩码
- 文件描述符表
- 和信号相关的信息
- 用户id和组id
- 会话(Session)和进程组
- 进程可以使用的资源上限(Resource Limit)
进程状态
进程基本状态有5种:
- 初始态
- 就绪态
- 运行态
- 挂起态
- 终止态
其中初始态为进程准备阶段,通常将它与就绪态合并。
环境变量
环境变量,在操作系统中用来配置操作系统运行环境的一些变量。通常具备以下特征:
- 本质是字符串
- 统一的格式:
变量名=值
- 值用来描述进程环境信息
存储形式:与命令行参数类似
使用形式:与命令行参数类似
加载位置:与命令行参数类似。位于用户区,高于stack的起始位置。
以下是一个遍历当前进程环境变量表的C语言程序示例:
#include <stdio.h> #include <stdlib.h> int main() { // 获取环境变量表的指针 extern char **environ; // 相当于environ是一个存储着字符串指针的数组 // 遍历环境变量表 for (char** env = environ; *env!=NULL; ++env) { // 打印每个环境变量 printf("%s\n", *env); } return 0; // 遍历环境变量表 for (char** env = environ; *env!=NULL; ++env) { // 打印每个环境变量 printf("%s\n", *env); } return 0; }
常见环境变量
PATH
可执行文件的搜索路径。
举例说明:ls命令是一个程序,执行时不需要提供完整的路径名/bin/ls,然而通常我们执行当前目录下的程序a.out时却需要提供完整的路径名./a.out,这是因为PATH环境变量的值里面包含了ls名所在的目录/bin,执行ls时会自动在/bin目录下寻找它并执行。因此我们如果将自己的程序路径加入到PATH环境变量中,也可以省略程序的路径,只输入文件名就能执行。
PATH环境变量的值可以包含多个目录,用:
隔开。在Shell中可以使用echo命令查看环境变量的值,echo $PATH
。
SHELL
当前Shell,Ubuntu下通常是/bin/bash
TERM
当前终端类型
LANG
语言和地区,决定了字符编码以及时间、货币等信息的显示格式。
HOME
当前用户主目录的路径。
getenv函数
获取环境变量值
char *getenv(const char *name);
- name:环境变量名
- 返回值:成功,返回环境变量值;失败,返回NULL
setenv函数
设置环境变量值
int setenv(const char *name, const char *value, int overwrite);
- name:环境变量名
- value:环境变量值
- overwrite:1,覆盖原环境变量值;0,不覆盖。
- 返回值:0—成功;-1—失败
unsetenv函数
删除环境变量的定义。(注意,不是删除环境变量的值,而是将环境变量本身删除)
int unsetenv(const char *name);
- name:环境变量名
- 返回值:0—成功;-1—失败。(如果环境变量本来就不存在,同样返回0)
进程控制
fork函数
创建一个与父进程相同的子进程
#include <sys/types.h> #include <unistd.h> pid_t fork(void);
- 返回值:
- 失败,-1
- 成功:
- 在父进程中返回子进程ID
- 在子进程中返回 0
一个示例:
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<sys/types.h> #include<unistd.h> int main() { printf("----------1\n"); printf("----------2\n"); printf("----------3\n"); printf("----------4\n"); pid_t pid = fork(); if(pid<0) { printf("create process error!\n"); exit(1); } else if(pid == 0) { printf("subprocess, pid = %d, parent pid = %d\n", getpid(), getppid()); }else{ printf("main process, pid = %d, subprocess pid = %d, parent process pid = %d\n", getpid(), pid, getppid() ); } printf("----------5\n"); printf("----------6\n"); printf("----------7\n"); printf("----------8\n"); return 0; pid_t pid = fork(); if(pid<0) { printf("create process error!\n"); exit(1); } else if(pid == 0) { printf("subprocess, pid = %d, parent pid = %d\n", getpid(), getppid()); }else{ printf("main process, pid = %d, subprocess pid = %d, parent process pid = %d\n", getpid(), pid, getppid() ); } printf("----------5\n"); printf("----------6\n"); printf("----------7\n"); printf("----------8\n"); return 0; }
使用for循环n次创建多少个子进程
使用for循环n次,循环体中每次循环创建一个子进程,循环结束后创建
区分一个函数是“系统函数”还是“库函数”依据:
- 是否访问内核数据结构
- 是否访问外部硬件资源
满足任一条→ 系统函数;二者均无 → 库函数
getpid函数
获取当前进程id
pid_t getpid(void);
getppid函数
获取当前进程的父进程id
pid_t getppid(void);
getuid函数与geteuid函数
获取当前进程实际用户id和有效用户id
uid_t getuid(void); // 获取当前进程实际用户id uid_t geteuid(void); // 获取当前进程有效用户id
getgid函数
gid_t getgid(void); // 获取当前进程实际用户组ID gid_t getegid(void); // 获取当前进程有效用户组ID
进程共享
刚刚fork之后,父子进程的:
相同之处:
- 全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式等等。(也就是用户空间内几乎所有)
不同之处:
- 进程ID
- fork返回值
- 父进程ID
- 进程运行时间
- 闹钟(定时器)
- 未决信号集
似乎子进程复制了父进程0-3G的所有用户空间内容,以及父进程的PCB,但pid不同。实际上,现在的操作系统不是这么干的。
父子进程间遵循读时共享,写时复制原则。这样设计,能够极大的节省内存开销。
全局变量在父子进程中共享吗?
不共享,父子进程间遵循读时共享写时复制原则。刚fork结束时,全局变量在父子进程中相同,一旦在父进程或是子进程中修改了全局变量,那就会先复制一份全局变量到自己的虚拟内存空间,然后修改自己虚拟内存空间中的全局变量,不会影响到其他进程。
父子进程共享:
-
文件描述符
-
mmap建立的映射区
GDB调试子进程
使用gdb调试的时候,gdb只能跟踪一个进程。可以在fork函数调用之前,通过指令设置gdb调试工具跟踪父进程或者是跟踪子进程。默认跟踪父进程。
set follow-fork-mode child
命令设置gdb在fork之后跟踪子进程。
set follow-fork-mode parent
设置跟踪父进程。
注意,一定要在fork函数调用之前设置才有效。
exec函数族
当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以exec前后的进程id没有改变。(与fork函数结合,使得子进程区执行全新的程序)
// 总共有6个exec函数 int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg, ..., char *const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execve(const char *path, char *const argv[], char *const envp[]);
execlp函数
加载一个进程,在PATH环境变量查找程序。该函数通常用来调用系统程序。如:ls、date、cp、cat等命令。
int execlp(const char *file, const char *arg, ...);
- file:程序名(位于PATH中的)
- arg:程序名及其参数,以程序名开头并且以NULL结尾。例如调用
ls -l
:execlp(”ls”, “ls”, “-l”, NULL);
,NULL标识着参数的结束(sentinel,哨兵符号) - 返回值:如果函数调用成功,那就不会返回;调用失败,返回-1并设置errno,如果PATH中没有找到该程序,会返回-1
示例,将进程信息输出到文件中:
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include<fcntl.h> int main() { int fd = open("wrprocess", O_CREAT|O_WRONLY, 0644); if(fd<0) { perror("creat file error"); exit(1); } dup2(fd, STDOUT_FILENO); // 把标注输出文件描述符重定向到文件描述符fd的文件 int pid = fork(); if(pid<0) { perror("create subprocess error"); exit(1); }else if(pid == 0) { execlp("ps", "ps", "aux", NULL); // ps aux 输出进程信息 } close(fd); return 0; close(fd); return 0; }
注意:execlp("ps", "ps", "aux", ">", "wrprocess", NULL);
的效果不同于在终端输入ps aux > wrprocess
。原因是在终端输入ps aux > wrprocess
实际上是将"ps aux > file", NULL
作为参数输入给shell程序,然后shell识别出ps aux
是一个命令参数列表,>
是重定向符号 ,file
是文件路径,最后作出对应的输出。execlp("ps", "ps", "aux", ">", "wrprocess", NULL);
则是将 "aux", ">", "wrprocess"
作为ps
的参数,显然ps无法正确执行。
execl函数
加载一个进程,通过文件路径来加载
int execl(const char *path, const char *arg, ...);
- path:程序文件的路径
- arg:程序名及其参数,同样是以程序名作为开头,NULL作为结尾。同样以调用
ls -l
为例:execl("/bin/ls", "ls", "-l", NULL)
。
execvp函数
加载一个进程,在PATH环境变量查找程序。相当于把execlp的arg放到execvp的argv数组中。
int execvp(const char *file, char *const argv[]);
exec函数族一般规律
exec函数一旦调用成功即执行新的程序,不返回。只有失败才返回,错误值-1。所以通常我们直接在exec函数调用后直接调用perror和exit,无需if判断。
- l (list) 命令行参数列表
- p (path) 搜素file时使用path变量
- v (vector) 使用命令行参数数组
- e (environment) 使用环境变量数组,不使用进程原有的环境变量,设置新加载程序运行的环境变量。
事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手册第2节,其它函数在man手册第3节。这些函数之间的关系如下图所示。
回收子进程
孤儿进程
孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程。此时,子进程的父进程会变成init进程,这个过程称为init进程领养孤儿进程。
以下程序的输出展示了init进程领养孤儿进程的过程:
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> int main() { int pid = fork(); if(pid<0) { perror("fork error"); exit(1); }else if(pid == 0) { int i = 0; while (i<10) // { printf("i am subprocess, parent id = %d\n", getppid()); sleep(1); // 睡1秒 ++i; } }else { printf("I am parent process, my pid = %d, i am going to die!\n", getpid()); sleep(5); } return 0; return 0; }
输出:
ps ajx
可以查看所有进程ID及他们的父进程ID、组进程ID等信息。可以看到pid=2047的进程就是init进程。
僵尸进程
僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸进程(Zombie)。
僵尸进程是不能使用kill命令清除的,因为kill命令是用来终止进程的,而僵尸进程本身就是终止状态。那么怎么清除僵尸进程呢?
- 终止僵尸进程的父进程,此时僵尸进程会被init进程领养,init会将其清理;
- 父进程调用wait函数清理子进程。
wait函数
一个进程终止时,操作系统的隐式回收机制会:
- 关闭所有文件描述符,
- 释放在用户空间分配的内存。
但它的PCB还保留着,内核在其中保存了一些信息:
- 正常退出:保存退出状态
- 异常终止:保存导致该进程终止的信号
wait函数功能:
- 阻塞并等待任意子进程退出
- 清理子进程残留在内核的PCB资源
- 通过传出参数,得到子进程结束状态
注意:wait函数一次只能清理一个子进程
#include <sys/types.h> #include <sys/wait.h> pid_t wait(int *wstatus);
-
wstatus:传出参数,存储了子进程退出时的状态信息。这个状态信息可以通过一系列的宏来解析,以便了解子进程是如何结束的。如果不关心子进程是如何退出的,可以传入NULL。
-
WIFEXITED(status)
:如果子进程正常退出,则返回非零值。否则返回零。 -
WEXITSTATUS(status)
:如果子进程正常退出,返回子进程的退出状态码(子进程返回值)。只有在WIFEXITED(status)
为真时,这个宏才有意义。 -
WIFSIGNALED(status)
:如果子进程异常终止,(凡是异常终止皆接收到信号),则返回非零值。否则返回零。 -
WTERMSIG(status)
:如果子进程因信号终止,返回导致子进程终止的信号编号。只有在WIFSIGNALED(status)
为真时,这个宏才有意义。 -
WIFSTOPPED(status)
:如果子进程因信号而暂停执行(挂起),则返回非零值。否则返回零。 -
WSTOPSIG(status)
:如果子进程因信号而暂停,返回导致子进程暂停的信号编号。只有在WIFSTOPPED(status)
为真时,这个宏才有意义.
-
-
返回值:
- 成功:返回子进程的进程ID;
- 失败:返回-1,设置errno。
示例
本示例中使用了wait函数来回收子进程,且分别对正常退出和异常退出都做了处理。其中,想要简单的达成异常退出效果可以通过kill 子进程ID
来达成。
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t pid, wpid; int status; pid = fork(); if(pid0) { printf("----child, id = %d, going sleep 10s\n", getpid()); sleep(10); printf("---------child die-----------\n"); return 99; // 为了确认是子进程终止的返回值,不用0作为返回值 }else if(pid>0) { wpid = wait(&status); // 回收子进程,如果子进程未终止,阻塞 if(wpid-1) // 回收失败 { perror("wait error"); exit(1); } if(WIFEXITED(status)) // 进程正常终止 { printf("child exit with %d\n", WEXITSTATUS(status)); } if(WIFSIGNALED(status)) // 进程异常终止 { printf("child kill by signal %d\n", WTERMSIG(status)); } printf("parent die\n"); }else { perror("fork error"); exit(1); } return 0; return 0; }
正常退出和异常退出的输出如下:
waitpid函数
作用与wait函数相同,但是:
- 可以通过pid指定进程进行清理;
- 可以不阻塞。
pid_t waitpid(pid_t pid, int *wstatus, int options);
-
pid:
-
>0
:回收进程ID=pid
的子进程 -
=0
:回收与当前调用waitpid的进程有相同组ID任意子进程 -
=-1
:回收任意子进程 -
<-1
:回收组ID= |pid|
的任意子进程
-
-
wstatus:传出参数,存储了子进程退出时的状态信息。
-
options:传入一个或多个宏来指定回收行为。
-
WNOHANG
:不阻塞。
-
-
返回值:
-
>0
:表示成功回收的子进程ID。 -
=0
:options指定了WNOHANG,并且没有子进程终止。 -
=-1
:回收失败,设置errno。
-
wait与waitpid总结
waitpid相当于wait的增强版,wait可以做的,waitpid都能做。
相同点:
-
wait和waitpid的作用都是回收已终止的子进程。
-
wait和waitpid都可以通过传出参数wstatus获得子进程退出状态
-
wait和waitpid一次都只能回收一个子进程,回收多个子进程可以通过while循环。当
waitpid()
返回-1
且errno
是ECHILD
时,说明已没有子进程了。
不同点:
- wait只能以阻塞方式回收子进程;waitpid既能以阻塞方式回收子进程,也能以非阻塞方式回收子进程。
- wait只能回收任意的子进程;waitpid支持指定回收的子进程条件。
以下是一个使用while循环回收多个子进程的示例:
int i, pid; for(i=0; i<5; ++i) { pid = fork(); if(pid==0) // 子进程不fork { break; } } if(5 == i) // 父进程,只有父进程的i=5 { while((wpid = waitpid(-1, NULL, WNOHANG) != -1) // 使用非阻塞方式回收子进程 { if(wpid>0) { printf("wait child %d\n", wpid); }else if(wpid == 0) { sleep(1); continue; } } }else // 子进程 { sleep(i); printf("I'm %dth child, pid = %d\n", i+1, getpid()); }
进程间通信
IPC(InterProcess Communication)
Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间无法相互访问。要交换数据就必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2从内核缓冲区读取数据到用户空间。内核提供的这种机制称为进程间通信(IPC)。
在进程间完成数据传递需要借助操作系统提供特殊的方法,如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的蓬勃发展,一些方法由于自身设计缺陷被淘汰或者弃用。现今常用的进程间通信方式有:
- 管道 (使用最简单)
- 信号 (开销最小)
- 共享映射区 (无血缘关系)
- 本地套接字 (最稳定)
管道(PIPE)
管道的概念
管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。调用pipe函数即可创建管道。
管道的特征:
- 管道是一个伪文件(实质是内核缓冲区)
- 由两个文件描述符引用,一个表示读端,一个表示写端
- 规定数据从管道的写端流入,从读端流出(单向流动)
管道的原理:管道使用环形队列机制,借助内核缓冲区(初始4kB,会自动扩容)实现。
管道的局限性:
- 数据不能由进程自己写自己读(也就是说写数据的进程不能读,读数据的进程不能写)
- 管道中的数据不能反复读取。(读完一次就消失)
- 半双工通信。(也即是同一时间数据只能向一个方向流动)
- 只有有血缘关系的进程之间可用
pipe函数
#include <unistd.h> int pipe(int pipefd[2]); define _GNU_SOURCE /* See feature_test_macros(7) */ include <fcntl.h> /* Obtain O_* constant definitions */ include <unistd.h> int pipe2(int pipefd[2], int flags);
-
pipefd:含有两个文件描述符的数组
- pipefd[0]:读端
- pipefd[1]:写端
-
返回值:
- 0--成功
- -1--失败,设置errno
一个示例:
#include<stdio.h> #include<stdlib.h> #include<string.h> #include <unistd.h> #include <fcntl.h> int main() { pid_t pid; int fd[2]; char buf[1024]; int pstatus = pipe(fd); // 打开管道 pid = fork(); // 创建子进程,父子进程共享文件描述符 if(pid==0) // 子进程关闭输出描述符,把输入描述符的数据写到终端 { close(fd[1]); int size = read(fd[0], buf, sizeof(buf)); write(STDOUT_FILENO, buf, size); }else if (pid>0) // 父进程关闭输入描述符,向输出描述符写入数据 { close(fd[0]); write(fd[1], "hello pipe\n", sizeof("hello pipe\n")); sleep(1); }else{ perror("fork"); exit(1); } return 0; return 0; }
上述程序的示意图如下:
管道的读写行为
读管道:
-
管道有数据:read返回实际读到的字节数
-
管道无数据:
- 无打开的写端,read返回0(类似于读到文件尾)
- 有打开的写端,read阻塞
写管道:
-
无打开的读端,进程异常终止(可以使用SIGPIPE信号使进程不终止)
-
有打开的读端
- 管道已满:write阻塞
- 管道未满:write将数据写入,并返回实际写入的字节数
利用管道和重定向实现ls | wc -l
ls | wc -l
的原理:把ls向标准输出文件描述符输出重定向为向管道输出(写),并且让wc -l从标准输入读取数据重定向为从管道中读取数据作为参数,然后将wc -l的结果输出到标准输出。在终端输入ls | wc -l
时,是shell程序内部实现了一个管道,而我们要做的就是,在自己的程序中实现一个管道,将标准输出文件描述符重定向到管道文件写描述符,将标准输入文件描述符重定向为管道文件读描述符。
#include<stdio.h> #include<stdlib.h> #include<string.h> #include <unistd.h> #include <fcntl.h> void sys_error(int status, const char* str) // 封装错误处理 { if(status < 0) { perror(str); exit(1); } } int main() { pid_t pid; int fd[2], pstatus; pstatus = pipe(fd); sys_error(pstatus, "pipe error"); pid = fork(); sys_error(pid, "fork error"); if(pid==0) { close(fd[1]); dup2(fd[0], STDIN_FILENO); execlp("wc", "wc", "-l", NULL); sys_error(-1, "wc error"); }else if (pid>0) { close(fd[0]); dup2(fd[1], STDOUT_FILENO); execlp("ls", "ls", NULL); sys_error(-1, "ls error"); } return 0; pstatus = pipe(fd); sys_error(pstatus, "pipe error"); pid = fork(); sys_error(pid, "fork error"); if(pid==0) { close(fd[1]); dup2(fd[0], STDIN_FILENO); execlp("wc", "wc", "-l", NULL); sys_error(-1, "wc error"); }else if (pid>0) { close(fd[0]); dup2(fd[1], STDOUT_FILENO); execlp("ls", "ls", NULL); sys_error(-1, "ls error"); } return 0; }
此时该程序一定概率会出现如下图这种情况,原因是父进程在子进程结束之前结束了,而父进程的父进程bash觉得它的子进程运行结束了,过来抢占终端,然后子进程运行输出数据,导致终端不恰当的显示。解决方法: 将父子进程的内容颠倒,也就是在父进程中执行wc -l,子进程中执行ls。这样的话,父进程永远不会先于子进程结束,因为若是父进程先运行到wc -l,就会阻塞等待子进程执行ls。
管道允许有一个读端,多个写端吗?
理论上是允许的,但是如果不控制写入的顺序的话,会导致读出来数据乱序。
管道缓冲区大小
ulimit -a
可以查看shell启动进程的资源限制,其中有创建管道文件对应的内核缓冲区大小。通常为:
pipe size (512 bytes, -p) 8
,也就是4096字节。
fpathconf
函数可以借助参数选项来查看各种资源的限制。
#include <unistd.h> long fpathconf(int fd, int name);
-
fd:要查询的文件描述符
-
name:要查询的配置选项,常见的有:
- _PC_LINK_MAX:文件的最大链接数
- _PC_PIPE_BUF:管道的缓冲区大小
- _PC_NAME_MAX:文件名最大字节数
-
返回值:成功,返回查询到的配置选项的值;失败,返回-1并设置errno。
管道的优劣
优点:简单,相比于其他的进程间通信要简单的多
缺点:
- 只能单向通信,双向通信需要建立两个管道;
- 只能用于父子、兄弟(有共同祖先)进程间通信。(FIFO解决了此问题)
FIFO
FIFO被称为命名管道,以区分pipe。pipe只能用于有血缘关系的进程间通信,但FIFO打破了这一限制。
FIFO是Linux基础文件类型中的一种(管道文件p)。但是FIFO文件并不会在磁盘上存储,而是用来标识内核中一条通道。各进程可以打开这个文件进行read/write,实际上是在读写内核通道,这样就实现了进程间通信。
创建方式:
-
shell命令:
mkfifo 管道名
-
库函数:
int mkfifo(const char* pathname, mode_t mode);
- pathname:管道文件名路径
- mode:文件创建时的权限,9位二进制表示u-g-o对该文件的访问权限,实际传入3位8进制数来表示9位二进制数
- 返回值:成功,0;失败,-1,设置errno。
一旦创建了FIFO,程序就可以像访问文件一样访问FIFO,常见的IO函数都可以用于FIFO。
FIFO可以一个读多个写,也可以一个写多个读。但是无法保证数据顺序,也不推荐。
共享存储映射
文件进程间通信
使用文件也可以完成IPC,理论依据是,fork后,父子进程共享文件描述符,也就共享打开的文件。
无血缘关系的进程可以打开同一个文件进行通信吗?
可以,因为同一个文件对应的是同一块内核缓冲区。
存储映射I/O
存储映射I/O(memory-mapped I/O)将磁盘中的文件映射到一块内存中。从这块内存中读取数据就相当于读取文件中的数据,向这块内存中写数据,相应的数据就会自动写入文件。这样的好处就是不再局限于使用read/write函数读写文件,而是可以使用很多的字符串处理的函数,如strcat, strcpy。
mmap函数
#include <sys/mman.h> void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
-
返回值:成功,返回创建的映射区首地址;失败,返回MAP_FAILED宏,并设置errno,实质是
(void*) -1
。 -
addr:建立映射区的首地址。一般传递NULL,此时由系统自动分配内存。
-
length:要创建的映射区大小
-
prot:映射区权限,PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE
-
flags:标志位
- MAP_SHARED:会将对映射区所做的操作反映到物理设备(磁盘)上
- MAP_PRIVATE:不会将对映射区所做的操作反映到磁盘上
-
fd:要映射的文件的描述符
-
offset:映射文件的偏移(4k的整数倍),也就是从文件的0+offset位置映射length大小的数据到内存中。
如下是一个简单使用mmap函数的示例:
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<unistd.h> #include<fcntl.h> // 所有的宏都在此头文件 #include<sys/mman.h> #include<sys/types.h> void sys_error(int status, const char* str) // 错误处理 { if(status < 0) { perror(str); exit(1); } } int main() { int fd = open("testmmap", O_CREAT|O_RDWR|O_TRUNC, 0644);// 创建文件 sys_error(fd, "open error"); int ftrun_status = ftruncate(fd, 10);// 拓展文件大小 sys_error(ftrun_status, "trunc error"); void* fp = mmap(NULL, 10, PROT_WRITE, MAP_SHARED, fd, 0);// 文件映射到内存 sys_error((int)fp, "mmap error"); close(fd); // 文件映射完毕就可以关闭文件描述符了 strcpy(fp, "hello mmap!");// 向映射内存拷贝字符串 char buf[10]; fd = open("testmmap", O_RDONLY); int read_status = read(fd, buf, sizeof(buf));// 读取文件中的数据 close(fd); sys_error(read_status, "read error"); printf("%s\n", buf); return 0; char buf[10]; fd = open("testmmap", O_RDONLY); int read_status = read(fd, buf, sizeof(buf));// 读取文件中的数据 close(fd); sys_error(read_status, "read error"); printf("%s\n", buf); return 0; }
munmap函数
同malloc函数申请内存空间使用完要通过free释放空间类似,mmap建立的映射区在使用结束后也应该调用munmap来释放。
int munmap(void *addr, size_t length);
- addr:mmap返回的指针
- length:mmap建立映射区时传入的length
- 返回值:成功,0;失败,-1,设置errno。
mmap注意事项
-
用于创建映射区的文件大小为0,无法创建成功
- 创建大小不为0的映射区,报错“总线错误”
- 创建大小为0的映射区,报错“无效参数”
-
创建映射区时,隐含着一次读操作,因此需要文件打开时至少有读权限。
-
创建MAP_SHARED映射区的权限,要在文件描述符的文件权限之内。
-
创建MAP_PRIVATE映射区的权限,可以大于文件描述符的文件权限。
-
文件描述符在映射区创建完成后即可关闭。
-
offset必须是4096的整数倍(因为MMU映射的最小单位是4096B)
-
munmap的传入地址必须是mmap的返回地址
mmap函数的保险调用方法(一般使用形式)
fd = open("文件名", O_RDWR); mmap(NULL, 有效文件大小, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
mmap父子进程通信
-
父进程创建映射区,要求创建映射区的flag参数为MAP_SHARED
-
fork创建子进程
-
一个进程写,一个进程读
一个父子进程通信的示例:
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<unistd.h> #include<fcntl.h> #include<sys/mman.h> #include<sys/types.h> void sys_error(int status, const char* str) { if(status < 0) { perror(str); exit(1); } } struct box { char name[256]; int height; int width; }; int main() { struct box box1 = {"长方形", 40, 60}; struct box* fp; int fd; int length = sizeof(struct box); fd = open("test_mapIPC", O_CREAT|O_RDWR|O_TRUNC, 0644); // 打开文件 sys_error(fd, "open error"); int trun_status = ftruncate(fd, length); // 拓展文件大小 sys_error(trun_status, "trunc error"); fp = mmap(NULL, length, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); // 映射文件 sys_error(*((int*)fp), "map error"); close(fd); int fork_status = fork(); // 创建子线程 sys_error(fork_status, "fork error"); if(fork_status>0){ // 父进程向映射区域写入 memcpy((struct box*)fp, &box1, length); sleep(1); // 延时一秒,确保子进程已读取了映射区域数据,再释放映射区 munmap(fp, length); }else{ // 子线程从映射区域读取 sleep(1); printf("box is %s, height=%d, width=%d\n", fp->name, fp->height, fp->width); } return 0; fd = open("test_mapIPC", O_CREAT|O_RDWR|O_TRUNC, 0644); // 打开文件 sys_error(fd, "open error"); int trun_status = ftruncate(fd, length); // 拓展文件大小 sys_error(trun_status, "trunc error"); fp = mmap(NULL, length, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); // 映射文件 sys_error(*((int*)fp), "map error"); close(fd); int fork_status = fork(); // 创建子线程 sys_error(fork_status, "fork error"); if(fork_status>0){ // 父进程向映射区域写入 memcpy((struct box*)fp, &box1, length); sleep(1); // 延时一秒,确保子进程已读取了映射区域数据,再释放映射区 munmap(fp, length); }else{ // 子线程从映射区域读取 sleep(1); printf("box is %s, height=%d, width=%d\n", fp->name, fp->height, fp->width); } return 0; }
mmap无血缘关系进程间通信
mmap可以无血缘关系进程间通信。要求:
- 两个进程打开同一个文件,创建映射区
- flag参数为MAP_SHARED
- 一个进程写入,一个进程读取
mmap和FIFO都可以无血缘关系进程间通信,区别:mmap的数据可以反复读取,而FIFO的数据只能读取一次。
一个无血缘关系进程间通信示例:
创建两个源文件,分别负责读和写。在shell中先运行mmapIPC_w.c编译后的可执行文件,再运行mmapIPC_r.c编译后的可执行文件,可以看到写入映射区内存的数据被不断的读取。
mmapIPC_w.c
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<unistd.h> #include<fcntl.h> #include<sys/mman.h> #include<sys/types.h> void sys_error(int status, const char* str) { if(status < 0) { perror(str); exit(1); } } struct box { int id; char name[256]; int height; int width; }; int main() { struct box box1 = {1, "长方形", 40, 60}; struct box* fp; int fd; int length = sizeof(struct box) * 100; fd = open("test_mapIPC", O_CREAT|O_RDWR|O_TRUNC, 0644); // 打开文件 sys_error(fd, "open error"); int trun_status = ftruncate(fd, length); // 拓展文件大小 sys_error(trun_status, "trunc error"); fp = mmap(NULL, length, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); // 映射文件 sys_error(*((int*)fp), "map error"); close(fd); while (box1.id<=100) // 每隔一秒,持续向映射区写入 { int* memstatus = (int*)memcpy(fp, &box1, sizeof(box1)); sys_error(*memstatus, "memcpy error"); box1.id ++; sleep(1); } munmap(fp, length); return 0; } fd = open("test_mapIPC", O_CREAT|O_RDWR|O_TRUNC, 0644); // 打开文件 sys_error(fd, "open error"); int trun_status = ftruncate(fd, length); // 拓展文件大小 sys_error(trun_status, "trunc error"); fp = mmap(NULL, length, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); // 映射文件 sys_error(*((int*)fp), "map error"); close(fd); while (box1.id<=100) // 每隔一秒,持续向映射区写入 { int* memstatus = (int*)memcpy(fp, &box1, sizeof(box1)); sys_error(*memstatus, "memcpy error"); box1.id ++; sleep(1); } munmap(fp, length); return 0;
mmapIPC_r.c
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<unistd.h> #include<fcntl.h> #include<sys/mman.h> #include<sys/types.h> void sys_error(int status, const char* str) { if(status < 0) { perror(str); exit(1); } } struct box { int id; char name[256]; int height; int width; }; int main() { struct box* fp; int fd; int length = sizeof(struct box) * 100; fd = open("test_mapIPC", O_RDONLY); sys_error(fd, "open error"); fp = mmap(NULL, length, PROT_READ, MAP_SHARED, fd, 0); sys_error(*((int*)fp), "map error"); while(fp>0){ 每隔一秒从映射区读取 sleep(1); printf("id=%d, box is %s, height=%d, width=%d\n", fp->id, fp->name, fp->height, fp->width); } return 0; } fd = open("test_mapIPC", O_RDONLY); sys_error(fd, "open error"); fp = mmap(NULL, length, PROT_READ, MAP_SHARED, fd, 0); sys_error(*((int*)fp), "map error"); while(fp>0){ 每隔一秒从映射区读取 sleep(1); printf("id=%d, box is %s, height=%d, width=%d\n", fp->id, fp->name, fp->height, fp->width); } return 0;
匿名映射(仅用于有血缘关系的进程间通信)
使用映射区作进程间通信非常方便,但是每次建立映射区必须要依赖一个文件。是否可以不依赖文件来创建匿名映射区呢?
可以,使用MAP_ANON
或者MAP_ANONYMOUS
作为mmap函数的flags参数。如下是创建匿名映射区的示例:
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANON, -1, 0);
注意:
- 由于没有依赖文件,因此文件描述符参数为-1
- 只能用于有血缘关系的进程间通信
信号
信号的概念
现实生活中,体育比赛中的信号枪,养鸽人的鸽哨都属于信号。
信号的共性:
- 简单
- 不能携带大量信息
- 满足条件才发送
信号是信息的载体,LInux/UNIX环境下,古老、经典的通信方式,现在依然是主要的通信手段。
信号的机制
信号是软件层面的 “中断” 。一旦收到了信号,无论程序执行到什么位置,都立即停止,转而处理信号,处理完毕,再回到之前停止的地方继续执行。
所有信号的产生及处理都是由内核完成的。
与信号相关的事件和状态
信号产生方式:
- 按键产生:如Ctrl+C、Ctrl+Z
- 系统调用产生:如kill、raise、abort
- 软件条件产生:如定时器alarm
- 硬件异常产生:如非法访问内存(段错误)、除以0(浮点数除外)、内存对齐出错(总线错误)
- 命令产生:如kill命令
信号的状态:
-
递达:递送并到达进程
-
未决:产生和递达之间的状态,主要由于阻塞(屏蔽)导致该状态
信号的处理方式:
- 执行默认动作
- 忽略(丢弃)
- 捕获(调用用户处理函数)
阻塞信号集(信号屏蔽字)
本质是位图。用来记录信号的屏蔽状态。被屏蔽的信号在解除屏蔽前,会一直处于未决状态。
未决信号集
本质是位图。用来记录信号的处理状态。未决信号集中的信号表示已经产生但未被处理。
信号的编号
使用命令kill -l
可以查看当前linux系统所有可用信号。其中1-31号信号为常规信号(也叫普通信号或标准信号),34-64号信号为实时信号,与驱动编程和硬件相关。(注意,没有32-33号)
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX
信号四要素
信号四要素
- 编号
- 名称
- 事件
- 默认处理动作
可通过命令man 7 signal
查看帮助文档获取。也可查看linux源码文件/include/uapi/asm/signal.h
。
POSIX.1 描述了下列信号:(POSIX是由IEEE和ISO共同制定的标准,定义了操作系统应该为应用程序提供的接口)
Signal Value Action Comment ────────────────────────────────────────────────────────────────────── SIGHUP 1 Term 在控制终端上是挂起信号, 或者控制进程结束 SIGINT 2 Term 从键盘输入的中断 SIGQUIT 3 Core 从键盘输入的退出 SIGILL 4 Core 无效硬件指令 SIGABRT 6 Core 非正常终止, 可能来自 abort(3) SIGFPE 8 Core 浮点运算例外 SIGKILL 9 Term 杀死进程信号 SIGSEGV 11 Core 无效的内存引用 SIGPIPE 13 Term 管道中止: 写入无人读取的管道 SIGALRM 14 Term 来自 alarm(2) 的超时信号 SIGTERM 15 Term 终止信号 SIGUSR1 30,10,16 Term 用户定义的信号 1 SIGUSR2 31,12,17 Term 用户定义的信号 2 SIGCHLD 20,17,18 Ign 子进程结束或停止 SIGCONT 19,18,25 Cont 继续停止的进程 SIGSTOP 17,19,23 Stop 停止进程 SIGTSTP 18,20,24 Stop 终端上发出的停止信号 SIGTTIN 21,21,26 Stop 后台进程试图从控制终端(tty)输入 SIGTTOU 22,22,27 Stop 后台进程试图在控制终端(tty)输出
Action含义:
-
Term:终止进程
-
Ign:忽略信号 (默认即时对该种信号忽略操作)
-
Core:终止进程,生成Core文件。(查验进程死亡原因,用于gdb调试)
-
Stop:停止(暂停)进程
-
Cont:继续运行进程
只有每个信号对应的事件发生了,该信号才会被递送(但不一定被递达)。
Linux常规信号一览
-
SIGHUP: 当用户退出shell时,由该shell启动的所有进程将收到这个信号,默认动作为终止进程
-
SIGINT:当用户按下了<Ctrl+C>组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号。默认动作为终止进程。
-
SIGQUIT:当用户按下<ctrl+>组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号。默认动作为终止进程。
-
SIGILL:CPU检测到某进程执行了非法指令。默认动作为终止进程并产生core文件
-
SIGTRAP:该信号由断点指令或其他 trap指令产生。默认动作为终止里程 并产生core文件。
-
SIGABRT: 调用abort函数时产生该信号。默认动作为终止进程并产生core文件。
-
SIGBUS:非法访问内存地址,包括内存对齐出错,默认动作为终止进程并产生core文件。
-
SIGFPE:在发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等所有的算法错误。默认动作为终止进程并产生core文件。
-
SIGKILL:无条件终止进程。本信号不能被忽略,处理和阻塞。默认动作为终止进程。它向系统管理员提供了可以杀死任何进程的方法。
-
SIGUSE1:用户定义的信号。即程序员可以在程序中定义并使用该信号。默认动作为终止进程。
-
SIGSEGV:指示进程进行了无效内存访问。默认动作为终止进程并产生core文件。
-
SIGUSR2:另外一个用户自定义信号,程序员可以在程序中定义并使用该信号。默认动作为终止进程。
-
SIGPIPE:Broken pipe向一个没有读端的管道写数据。默认动作为终止进程。
-
SIGALRM: 定时器超时,超时的时间由系统调用alarm设置。默认动作为终止进程。
-
SIGTERM:程序结束信号,与SIGKILL不同的是,该信号可以被阻塞和终止。通常用来要示程序正常退出。执行shell命令Kill时,缺省产生这个信号。默认动作为终止进程。
-
SIGSTKFLT:Linux早期版本出现的信号,现仍保留向后兼容。默认动作为终止进程。
-
SIGCHLD:子进程状态发生变化时,父进程会收到这个信号。默认动作为**忽略**这个信号。
-
SIGCONT:如果进程已停止,则使其继续运行。默认动作为继续/忽略。
-
SIGSTOP:停止进程的执行。信号不能被忽略,处理和阻塞。默认动作为暂停进程。
-
SIGTSTP:停止终端交互进程的运行。按下<ctrl+z>组合键时发出这个信号。默认动作为暂停进程。
-
SIGTTIN:后台进程读终端控制台。默认动作为暂停进程。
-
SIGTTOU: 该信号类似于SIGTTIN,在后台进程要向终端输出数据时发生。默认动作为暂停进程。
-
SIGURG:套接字上有紧急数据时,向当前正在运行的进程发出些信号,报告有紧急数据到达。如网络带外数据到达,默认动作为忽略该信号。
-
SIGXCPU:进程执行时间超过了分配给该进程的CPU时间 ,系统产生该信号并发送给该进程。默认动作为终止进程。
-
SIGXFSZ:超过文件的最大长度设置。默认动作为终止进程。
-
SIGVTALRM:虚拟时钟超时时产生该信号。类似于SIGALRM,但是该信号只计算该进程占用CPU的使用时间。默认动作为终止进程。
-
SGIPROF:类似于SIGVTALRM,它不公包括该进程占用CPU时间还包括执行系统调用时间。默认动作为终止进程。
-
SIGWINCH:窗口变化大小时发出。默认动作为忽略该信号。
-
SIGIO:此信号向进程指示发出了一个异步IO事件。默认动作为忽略。
-
SIGPWR:关机。默认动作为终止进程。
-
SIGSYS:无效的系统调用。默认动作为终止进程并产生core文件。
-
SIGRTMIN ~ (64) SIGRTMAX:LINUX的实时信号,它们没有固定的含义(可以由用户自定义)。所有的实时信号的默认动作都为终止进程。
信号的产生
终端按键产生信号
`Ctrl + c` → 2) SIGINT(终止/中断) "INT" ----Interrupt Ctrl + z → 20) SIGTSTP(暂停/停止) "T" ----Terminal 终端。 Ctrl + z Ctrl + \ → 3) SIGQUIT(退出) Ctrl + \
硬件异常产生信号
除0操作 → 8) SIGFPE (浮点数例外) "F" -----float 浮点数。 非法访问内存 → 11) SIGSEGV (段错误) 总线错误 → 7) SIGBUS
kill函数/命令产生信号
kill命令产生信号:kill -SIG pid
kill函数:给指定进程发送指定信号(不一定杀死)
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);
-
返回值:
- 成功,0;
- 失败,-1 (ID非法,信号非法,普通用户杀init进程等权级问题),设置errno
-
sig:信号。不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
-
pid:进程ID
- pid > 0:发送信号给指定的进程。
- pid = 0:发送信号给kill函数调用者进程属于同一进程组的所有进程。
- pid < -1:发给|pid|对应进程组。
- pid = -1:发送给进程有权限发送的系统中所有进程。
进程组: 每个进程都属于一个进程组,进程组是一个或多个进程集合,他们相互关联,共同完成一个实体任务,每个进程组都有一个进程组长,默认进程组ID与进程组长ID相同。
权限保护: super用户(root)可以发送信号给任意用户,普通用户不能向系统用户发送信号。 普通用户不能成功执行kill -9 root用户的进程pid
。同样,普通用户也不能向其他普通用户发送信号,终止其进程。普通用户只能向自己创建的进程发送信号。普通用户基本规则是:发送者实际或有效用户ID == 接收者实际或有效用户ID。
软件条件产生信号
alarm函数
设置一个闹钟(定时器)来发送信号。调用alarm函数后,根据传入的参数,在指定的秒数后,内核会给当前进程发送14)SIGALARM
信号。进程收到该信号后的默认动作是终止。
#include <unistd.h> unsigned int alarm(unsigned int seconds);
-
seconds:单位为秒,表示闹钟定时的时间
-
返回值:返回旧闹钟剩下的秒数。如下示例:
-
alarm(5) --> 3s --> alarm(3) --> 4s --> alarm(1) alarm(5)返回值0 alarm(3)返回值2 alarm(1)返回值0 前面没有旧定时器 旧定时器距离触发还剩2s 旧定时器已经触发
-
每个进程有且只有一个定时器,设定了新定时器就会取消旧的定时器,并且返回旧定时器剩余的秒数。如果旧定时器已经触发,新定时器返回0。通常会使用alarm(0)
来取消之前的闹钟。
定时与进程状态无关,使用自然计时法,无论进程处于何种状态(就绪、运行、挂起、终止、僵尸),alarm都会计时。
示例:使用alarm函数测试计算机一秒钟能数多少个数
/* alarm.c */ #include<unistd.h> #include<stdio.h> int main() { int i; alarm(1); for(i=0;;++i) { printf("%d\n", i); } return 0; return 0; }
在shell中输入命令time ./alarm
可以查看该程序运行时的时间分析,如下所示
real 0m1.001s user 0m0.024s sys 0m0.511s
Q:
- 可以看到程序真实运行时间是1.001s,其中在用户空间运行的时间只占了0.024s,在内核空间运行的时间也只占了0.511s,二者加起来也比1.001s小上很多,这是为什么呢?
A:
-
因为
程序运行时间 = 用户态CPU时间 + 内核态CPU时间 + I/O等待时间
。- real:表示命令从开始到结束所花费的总时间,包括等待时间(如 I/O 操作等待时间)。
- user:表示命令在用户态所花费的 CPU 时间。
- sys:表示命令在内核态所花费的 CPU 时间。
显然,上述程序是向标准输出进行输出,而标准输出的I/O使用者很多,这就导致了I/O操作等待时间较长。这样也导致了程序运行效率较低。
Q:
- 那么要如何解决这个问题呢?
A:
- 显然要提高该程序的运行效率,就要降低I/O等待时间,一个比较好的想法是通过使用管道,将进程的输出重定向到一个使用者较少的文件,这样会显著减少I/O等待时间。如
time ./alarm > out
,如下所示,可以看到CPU使用时间显著提升。 -
real 0m1.001s user 0m0.774s sys 0m0.218s
因此我们引出了一个结论,程序的运行瓶颈在于I/O,优化程序,首选优化I/O。
setitimer函数
设置定时器。可以代替alarm函数,精度是微秒,可以实现周期定时。
#include <sys/time.h> int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
-
which
:定时器类型-
ITIMER_REAL
:自然计时法,按照真实时间计时,与进程状态无关。定时时间到了,会发送SIGALRM
信号。 -
ITIMER_VIRTUAL
:只计算进程在用户空间占用CPU的时间,当定时时间到了,会发送SIGVTALRM
信号。 -
ITIMER_PROF
:计算进程在用户空间占用CPU的时间+系统调用的CPU时间,当定时时间到了,会发送SIGPROF
信号。
-
-
new_val
:传入参数,struct itimerval
类型,告诉定时器定时多久以及间隔多久再次定时-
struct itimerval
-
struct itimerval { struct timeval it_interval; /* it_value后,每隔it_interval发送一次信号 */ struct timeval it_value; /* 第一次定时时间,即it_value后发送信号 */ }; struct timeval { time_t tv_sec; /* seconds / suseconds_t tv_usec; / microseconds */ };
-
-
-
old_val
:传出参数:获取旧的定时器剩余时间 -
返回值:成功,0;失败,-1,设置errno。
信号集操作函数
内核通过读取未决信号集来判断信号是否应被处理。信号屏蔽字mask可以影响未决信号集。用户无法直接影响未决信号集,但是可以影响屏蔽字mask。通过自定义set来改变mask,达到屏蔽指定信号的目的。
上图中,当信号产生,就会将未决信号集相应的位,置为1。如果屏蔽信号集中相应的位没有置为1,那么该信号会在一段时间后递达(递达后,会将相应的位置为0)。若屏蔽信号集中相应的位被置为1,那么该信号就会一直处于未决状态,直到屏蔽被解除,才能递达。
信号集设定
这一系列函数是为了设置一个与屏蔽信号集数据结构相同的信号集。
#include <signal.h> typedef unsigned long sigset_t; sigset_t set; int sigemptyset(sigset_t *set); // 将set清零 int sigfillset(sigset_t *set); // 将set全部置一 int sigaddset(sigset_t *set, int signum); // 将某个信号加入set int sigdelset(sigset_t *set, int signum); // 将某个信号移除set int sigismember(const sigset_t *set, int signum); // 判断某个信号是否在set中
-
sigset_t
:本质是位集,但不应该直接使用位操作,而是应该使用上述函数操作,以保证在任何情况下都有效 -
set
:sigset_t
类型的位集 -
signum
:信号编号,可以使用信号名,信号名是信号编号的宏 - 返回值:成功,0;失败,-1。sigismember的返回值是bool值。
sigprocmask函数
通过使用信号集来屏蔽信号或解除信号屏蔽。本质是读取和修改进程的信号屏蔽字(PCB中)。
屏蔽信号,是将信号处理延后执行(延后至解除屏蔽);忽略则是将信号丢弃。
#include <signal.h> int sigprocmask(int how, const old_kernel_sigset_t *set, old_kernel_sigset_t *oldset);
-
set
:传入参数,一个信号集(设定1为屏蔽该位信号,设定0为解除屏蔽该位信号)。 -
oldset
:传出参数,保存旧的屏蔽信号集。 -
how
:参数选项,选项影响实际的操作-
SIG_BLOCK
:set表示需要屏蔽的信号,相当于mask |= set
; -
SIG_UNBLOCK
:set表示需要解除屏蔽的信号,相当于mask &= ~set
; -
SIG_SETMASK
:set用于替代原来的屏蔽信号集,相当于mask = set
。若调用sigprocmask解除了对当前若干个信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
-
-
返回值:成功,0;失败,-1,设置errno。
sigpending函数
读取当前进程的未决信号集
#include <signal.h> int sigpending(sigset_t *set);
-
set
:传出参数,保存当前进程的未决信号集。 - 返回值:成功,0;失败,-1,设置errno。
一个修改屏蔽信号集的示例
如下的示例主要做了这几件事:
- 创建信号集(
sigset_t
类型数据) - 修改自己创建的信号集,也就是将要屏蔽的信号对应于信号集中的位置为1(先全部清0,再给相应位置为1)
- 利用该信号集设置屏蔽信号集
- while循环打印未决信号集
程序运行之后,按下Ctrl+C发出SIGINT信号,可以看到打印出来的信息出现了变化,而程序却并未停止,说明该信号被屏蔽了。
#include<signal.h> #include<stdio.h> #include<stdlib.h> #include<errno.h> #include<string.h> void sys_error(const char* str) { perror(str); exit(1); } void print_set(sigset_t* set) { for(int i=1; i<32; ++i) // 对于1-31号信号,判断每一个信号是否出现在信号集中 { if(sigismember(set, i)) { printf("1"); } else { printf("0"); } } printf("\n"); } int main() { sigset_t set, oldset, pendset; int ret = 0; sigemptyset(&set); sigaddset(&set, SIGINT); ret = sigprocmask(SIG_BLOCK, &set, &oldset); if(ret<0) { sys_error("set mask error"); } while(1) { ret = sigpending(&pendset); if(ret<0) sys_error("getpendset error"); print_set(&pendset); } return 0; printf("\n"); ret = sigprocmask(SIG_BLOCK, &set, &oldset); if(ret<0) { sys_error("set mask error"); } while(1) { ret = sigpending(&pendset); if(ret<0) sys_error("getpendset error"); print_set(&pendset); } return 0; }
信号捕捉
signal函数
注册一个信号捕获函数(为什么说是注册?因为捕获信号实际上是由内核完成的)
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
- signum:信号编号
- handler:一个
void (*)(int)
类型的函数,该函数由自己定义,接收的参数是信号编号。使用signal函数注册之后,进程捕获到信号,就会触发该函数。 - 返回值:成功,返回传入的参数handler;失败,返回SIG_ERR。
该函数由ANSI定义,由于历史原因在不同版本的Unix和不同版本的Linux中可能有不同的行为。因此应该尽量避免使用它,取而代之使用sigaction函数。
sigaction函数
修改信号处理动作
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- signum:信号编号
- act:新的行为
- oldact:旧的行为
- 返回值:成功,0;失败,-1,设置errno。
struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); };
-
sa_handler:信号处理函数,当进程捕获到绑定的信号时,就会调用该函数
-
sa_sigaction:另一种信号处理函数,可以接收额外信息,只在sa_flags是
SA_SIGINFO
时才使用该函数 -
sa_mask:信号处理函数执行期间使用的屏蔽信号集
-
sa_flags:信号处理的标志
-
SA_RESETHAND
:信号处理完成后,将信号处理恢复为默认行为。 -
SA_RESTART
:如果信号中断了阻塞系统调用,系统调用将自动重启。 -
SA_NOCLDSTOP
:仅对SIGCHLD
有效,防止子进程在停止时发送SIGCHLD
信号。 -
SA_SIGINFO
:使用sa_sigaction
而不是sa_handler
,并且传递额外的信息。
-
-
sa_restorer:已废弃,不应使用
sigaction函数使用流程:
-
创建
struct sigaction act, oldact
-
创建
void(*)(int)
形式的函数,赋给act.sa_handler
-
设置
act.sa_mask
,图省事可以全部置为0 -
设置
act.flags
,无特别要求可以赋值为0 -
调用
sigaction(signum, &act, &oldact)
,signum是要绑定行为的信号标号,act和oldact就是前面创建的结构体
示例:
#include<signal.h> #include<stdio.h> #include<stdlib.h> #include<errno.h> #include<string.h> void sys_error(const char* str) { perror(str); exit(1); } void sigcatch(int signum) { printf("catch sig: %d\n", signum); return; } int main() { struct sigaction act, oldact; act.sa_handler = sigcatch; sigemptyset(&act.sa_mask); act.sa_flags = 0; int ret = sigaction(SIGINT, &act, &oldact); // 按Ctrl+C发出SIGINT if(ret == -1) { sys_error("sigaction error"); } while(1); return 0; return; int ret = sigaction(SIGINT, &act, &oldact); // 按Ctrl+C发出SIGINT if(ret == -1) { sys_error("sigaction error"); } while(1); return 0; }
信号捕获特性
-
捕获函数执行期间,屏蔽信号集(信号屏蔽字)由sa_mask决定
-
xxx信号捕获函数执行期间,该信号自动被屏蔽
-
阻塞的常规信号不支持排队,产生多次只记录一次。(34-64号信号支持排队)
内核信号捕获过程
- 信号被称为软件“中断”的原因就是它不是真的中断,当信号发出之后,不会像硬件中断一样立即去处理,而是要等到进入内核时,才会处理信号。(进入内核的方式不重要,可以是执行了某条系统调用的指令,或是异常,也可以是时钟中断产生让操作系统拿到控制权,总之进入了内核,就会在处理完本该完成的工作之后,处理当前进程中的信号)
- 内核调用信号处理函数,进入用户模式执行该函数后,必须要执行特殊的系统调用sigreturn再次进入内核,因为函数在调用结束后必须返回给调用者,而信号处理函数是内核调用的,且操作系统不允许进程随意的进入内核,因此必须使用一个系统调用。
SIGCHLD信号
SIGCHLD信号产生条件
子进程终止时
子进程接收到SIGSTOP信号停止时
子进程处在停止状态,接收到SIGCONT后唤醒时
借助SIGCHLD信号回收子进程
代码如下:
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include<sys/wait.h> #include<signal.h> #include<errno.h> /* 实现通过接收子进程发出的SIGCHLD信号来回收子进程 */ void sys_error(const char* str) { perror(str); exit(1); } void catch_chld(int signum) { int pid; while ((pid = wait(NULL)) != -1) // 使用while循环回收,将所有终止的子进程全部回收 { printf("----------%d 已被回收\n", pid); } } int main() { sigset_t set, oldmask; sigemptyset(&set); sigaddset(&set, SIGCHLD); sigprocmask(SIG_BLOCK, &set, &oldmask); // 屏蔽SIGCHLD信号 int i=0; for(;i<15;++i) { int pid=fork(); if(pid==-1) { sys_error("fork error"); } if(pid==0) break; } if(i==15) { // 父进程 struct sigaction act, oldact; act.sa_handler = catch_chld; sigemptyset(&act.sa_mask); act.sa_flags = 0; int status = sigaction(SIGCHLD, &act, &oldact); // 信号绑定行为 sigprocmask(SIG_UNBLOCK, &set, &oldmask); // 解除对SIGCHLD的屏蔽 if(status == -1) sys_error("sigaction error"); printf("i am parent, pid=%d\n", getpid()); while(1); // 不让父进程结束 }else { // 子进程 printf("i am child %d\n", getpid()); } return 0; while ((pid = wait(NULL)) != -1) // 使用while循环回收,将所有终止的子进程全部回收 { printf("----------%d 已被回收\n", pid); } int i=0; for(;i<15;++i) { int pid=fork(); if(pid==-1) { sys_error("fork error"); } if(pid==0) break; } if(i==15) { // 父进程 struct sigaction act, oldact; act.sa_handler = catch_chld; sigemptyset(&act.sa_mask); act.sa_flags = 0; int status = sigaction(SIGCHLD, &act, &oldact); // 信号绑定行为 sigprocmask(SIG_UNBLOCK, &set, &oldmask); // 解除对SIGCHLD的屏蔽 if(status == -1) sys_error("sigaction error"); printf("i am parent, pid=%d\n", getpid()); while(1); // 不让父进程结束 }else { // 子进程 printf("i am child %d\n", getpid()); } return 0; }
程序解析
-
首先使用for循环创建15个子进程。然后,创建SIGCHLD处理函数,并且在父进程中注册SIGCHLD信号处理函数。此时会发现子进程并没有全部被catch_chld函数回收,原因是,父进程运行完毕就直接结束了,而父进程结束,也就不会再处理子进程的信号了。
-
在前面的基础上,为父进程部分代码的最后加上while(1),使得父进程永不结束,一直处理子进程发出的SIGCHLD信号。但是这样并不能完全解决问题,捕获函数执行期间,其余的信号会被自动屏蔽,解除屏蔽后对于这期间发送的所有信号,也只处理一次,这就导致多个子进程几乎同时终止时,只调用了两次catch_chld,回收了两个子进程。
-
在前面的基础上,改造catch_chld,使得该函数一次执行,处理所有已终止的子进程,这样即使同时多个子进程终止发送了信号,只调用了一次catch_chld函数,也可以回收全部终止的子进程。但是此时仍然存在问题,那就是子进程已经终止,父进程中却还没完成信号与处理函数的绑定。
-
在前面的基础上,在fork之前屏蔽mask(信号屏蔽字)中的SIGCHLD信号,直到父进程中完成注册SIGCHLD信号处理函数之后,解除对该信号的屏蔽,linux中被屏蔽的信号会被阻塞直到解除屏蔽后再发送。因此解除屏蔽后,就会调用catch_chld处理信号。此时程序已经没有任何问题了。
中断系统调用
系统调用可以分为两类:慢系统调用(slow system call)和其他系统调用
-
慢系统调用: 慢速系统调用(slow system call)是指那些不会立即返回的系统调用,可能会永远阻塞而无法返回, 如read、write、wait。当阻塞于某个慢系统调用的进程捕获一个信号时,该系统调用会被中断,转而执行信号处理函数。信号处理函数返回时,系统调用的行为取决于信号处理函数注册方式。
-
当使用signal注册信号处理函数时,系统调用会自动重启,函数不会返回
-
使用sigaction注册信号处理函数时
- 默认情况下,系统调用不会重启,函数将返回失败,同时errno被置为EINTR
- 当输入的act参数的
sa_flags
设置为SA_RESTART
时,系统调用会自动重启,函数不会返回
-
-
其他系统调用:会立即返回的系统调用,如getpid、fork
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 清华大学推出第四讲使用 DeepSeek + DeepResearch 让科研像聊天一样简单!
· 推荐几款开源且免费的 .NET MAUI 组件库
· 实操Deepseek接入个人知识库
· 易语言 —— 开山篇
· Trae初体验