目录
前言
Linux操作系统是一种开源的、免费的操作系统,它基于Unix操作系统的设计原理和思想而设计。
Linux操作系统的核心是Linux内核,它负责管理计算机的硬件资源,并提供访问硬件的接口。Linux内核的开发和维护是一个全球化的合作过程,众多开发者和组织共同贡献代码、修复错误和改进功能。内核的新版本定期发布,每个版本都包含了一系列的改进和新功能。
由于Linux的开源特性,任何人都可以自由地创建自己的发行版本,因此市面上存在众多不同的发行版本。所以本文所说的Linux操作系统只是众多发行版本中的一员,而Linux只是它的内核。
回过来讲,Linux只是一个内核,而内核指的是一个提供文件管理、进程管理、网络通信、设备驱动等功能的系统软件,所以说Linux并不是一套完整的操作系统,它只是操作系统的核心。一些组织和厂商将Linux和各种软件和文档包装起来,并提供系统安装界面和系统配置、设定与管理工具,就构成了Linux的各种发行版本,比如作者使用的Ubuntu操作系统。
总而言之,Linux不是操作系统,它只是一个被称之为内核的系统软件,而Linux操作系统是包装了Linux内核在内的各种软件而组成的操作系统,比如还有shell命令解释器。
想必到这里你已经明白了什么是Linux内核、什么是Linux操作系统。接下来我们就要来基于Linux操作系统来更深入地了解一些底层的程序设计思想吧。
另外,阅读本文需要掌握C语言的基本语法和Linux操作系统的基本使用。
本文的各种命令和函数将参考man手册,如有疑问可以自行查阅man手册。
使用man man命令可以查看man手册的基本使用,man手册是全英文的,这里不建议使用中文的man手册,尽管你的英语不好,也可以使用翻译大概了解使用方法,比如百度翻译的划译功能就非常的方便。
通过man手册我们可以知道:

翻译过来就是:
下表显示了本手册的章节编号及其包含的页面类型。
1可执行程序或shell命令
2系统调用(内核提供的函数)
3库调用(程序库中的函数)
4特殊文件(通常在/dev/中找到)
5文件格式和约定,例如/etc/passwd
6游戏
7其他(包括宏包和约定),例如man(7)、groff(7)和man page(7)
8系统管理命令(通常仅适用于root用户)
9内核例程[非标准]
作者也是通过翻译软件翻译,如有异议可以自行翻译。
通过上面的命令我们可以知道man手册分为不同的章节,每个章节都代表了不同的使用类型,比如第一章节是shell命令,第二章节是系统调用,第三章节是库调用。我们可以使用{man n 命令或函数}的方式查看确定章节的内容,比如输入 man 2 read 可以查看系统调用中的read函数。
本文是作者的学习分享,技术含量不高,作此篇的主要目的是写给自己看。
第一章:文件
本章将分成3部分来讲解文件的一些操作,首先我们讲什么是系统调用,然后再讲文件IO和文件系统。
1.1 系统调用
系统调用是操作系统提供给用户程序访问内核功能的接口。它允许用户程序通过调用特定的函数来请求操作系统执行某些特定的操作,例如文件操作、进程管理、网络通信等。系统调用是用户程序与操作系统之间的桥梁,它提供了一种标准化的方式来访问操作系统的功能。
常见的Linux系统调用包括:
1. 文件操作:如open、read、write、close等。
2. 进程管理:如fork、exec、wait等。
3. 内存管理:如brk、mmap、munmap等。
4. 网络通信:如socket、bind、listen、accept等。
5. 设备管理:如ioctl、read、write等。
通过系统调用这个接口,Linux将上层应用与下层的内核分离,由于具体过程复杂,我们只能这样简单地理解。
系统系统为了让用户能更好地操作硬件设备,并且对硬件设备也起到保护作用,我们只能使用操作系统提供的系统调用对系统资源进行访问。在系统编程中,我们所写的程序本质就是通过操作系统提供的接口对硬件进行操作。
系统调用有三种方式,就是我们前面在讲man手册时提到的前三种方法:
1可执行程序或shell命令
2系统调用(内核提供的函数)
3库调用(程序库中的函数)
准确来讲系统调用只有第2种,因为2是1和3的基础,1和3是封装了2的调用。
为了方便,之后我们称第2种为系统调用,而另外两种分别称为shell命令和库函数。
系统调用按照功能逻辑大致可分为:进程控制、进程间通信、文件系统控制、系统控制、内存管理、网络管理、socket控制、用户管理。
系统调用的返回值:通常,用-1作为返回值来表明错误并设置错误信息,错误信息存放在全局变量errno中,用户可用perror函数打印出错信息;返回一个0值表明成功。
1.2 文件IO
文件IO是指文件的输入input和输出output操作,也就是对文件进行读取和写入的过程。在计算机编程中,文件IO是一种常见的操作,用于读取和写入文件中的数据。
文件描述符
文件描述符是操作系统中用于标识和访问文件整数值。在Linux系统中,每个打开的文件都会被分配一个唯一的文件描述符。

open
open函数是Linux系统中的一个系统调用,用于打开文件或者创建一个新文件。
通过man手册我们可以查看open的原型:

pathname:要打开或创建的文件的路径名
flags:指定了打开文件的方式和属性
mode:只有在创建新文件时才会使用,用于指定新文件的权限。
flags参数可以是以下常用的标志之一或者多个标志的组合:
O_RDONLY:只读方式打开文件。
O_WRONLY:只写方式打开文件。
O_RDWR:读写方式打开文件。
(前三个里必须选一个,而且只能选一个)
O_CREAT:如果文件不存在,则创建新文件。
O_EXCL:与O_CREAT一起使用,如果文件已经存在,则返回错误,意思是文件必须由我创建。
O_TRUNC:如果文件存在且以写方式打开,则将其长度截断为0。
O_APPEND:以追加方式打开文件,即每次写操作都追加到文件末尾。
mode参数用于指定新文件的权限,它是一个八进制数,常用的权限值有:
S_IRUSR:用户可读权限。
S_IWUSR:用户可写权限。
S_IXUSR:用户可执行权限。
S_IRGRP:组可读权限。
S_IWGRP:组可写权限。
S_IXGRP:组可执行权限。
S_IROTH:其他人可读权限。
S_IWOTH:其他人可写权限。
S_IXOTH:其他人可执行权限。
open函数返回一个文件描述符,用于后续对文件的读写操作。如果打开或创建文件失败,则返回-1,并设置errno变量来指示错误原因。
代码演示:
#include<stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<stdlib.h>
#include<unistd.h>
int main(int argc, char *argv[]) { // C语言基础知识
if (argc < 2) {
exit(1);
}
// argv[1] 自定义,输出结果不固定
int fd = open(argv[1], O_WRONLY | O_CREAT, 0777); // 打开argv[1]文件
if (fd < 0) {
perror("open"); // 如果打开失败则输出错误信息
exit(1);
} else printf("fd = %d\n", fd); // 如果打开成功则输出文件描述符
close(fd); // 关闭文件
return 0;
}
close
close函数用于关闭一个打开的文件描述符。

fd是需要关闭的文件描述符。close函数的作用是释放文件描述符所占用的资源,并将文件描述符从进程的文件描述符表中移除。
close函数的返回值为0表示成功关闭文件描述符,返回值为-1表示关闭失败,此时可以通过errno变量获取具体的错误信息。
当一个进程终止时,内核对该进程所有尚未关闭的文件描述符调用 close关闭,所以即使用户程序不调用close,在终止时内核也会自动关闭它打开的所有文件。
close的代码在上文open中已经演示过。
read
read函数用于从文件描述符中读取数据。

fd:文件描述符,表示要读取的文件或者设备。
buf:用于存储读取数据的缓冲区。
count:要读取的字节数。
返回值:
成功时,返回实际读取的字节数。
失败时,返回-1,并设置errno来指示错误类型。
read函数的工作原理如下:
1. 从文件描述符指定的文件或设备中读取数据。
2. 将读取的数据存储到提供的缓冲区中。
3. 返回实际读取的字节数。
需要注意的是,read函数是一个阻塞函数,如果没有数据可读,它会一直等待直到有数据可读或者出现错误。
阻塞(Block):当进程调用一个阻塞的系统函数时,该进程被置于睡眠(Sleep)状态,这时内核调度其它进程运行,直到该进程等待的事件发生了它才有可能继续运行。
argv[1]的内容如下:
012345
#include<stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<stdlib.h>
#include<unistd.h>
int main(int argc, char *argv[]) { // C语言基础知识
if (argc < 2) {
exit(1);
}
int fd = open(argv[1], O_RDONLY); // 以只读的方式打开文件argv[1]
if (fd < 0) {
perror("open"); // 如果打开失败则输出错误信息
exit(1);
} else perror("open"); // 打开成功也会有错误信息,只不过是Success而已
char buf[20]; // 设置一个buf缓冲区
int n = read(fd, buf, 10); // 从fd中读取10个字节到buf缓冲区中,要么读到'\0',要么读10个字节
printf("read %d bytes\n", n); // 输出读到的字节个数
for (int i = 0; i < n; i++) { // 输出buf中的内容
printf("%c", buf[i]);
}
close(fd); // 关闭文件
return 0;
}
输出结果为:
OPEN: Success
read 7 bytes
012345
从输出结果可知:
文件打开成功,读取到了7个字节,”012345\n“;并且输出了buf中的内容。
既然有read,那肯定就有write。
write
write函数用于向文件描述符写入数据,用法和read类似。

参数说明:
fd:文件描述符,表示要写入的文件。
buf:指向要写入数据的缓冲区。
count:要写入的字节数。
返回值:
- 成功时,返回实际写入的字节数。
- 失败时,返回-1,并设置errno来指示错误类型。
write函数的工作原理是将缓冲区中的数据写入到文件描述符所代表的文件中。它会尽可能地写入指定的字节数,需要注意的是,write函数是一个阻塞函数,即在写入完成之前会一直等待。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main() {
char buf[20];
int n = read(STDIN_FILENO, buf, 10); // 从标准输入流中读取10个字节存入buf中,要么读到'\0',要么读10个字节
if (n < 0) {
perror("read"); // 如果读取失败则输出错误信息
exit(1);
}
printf("read %d bytes\n", n); // 输出读取了多少字节
write(STDOUT_FILENO, buf, n); // 将buf中存储的字符写入到标准输出流
return 0;
}
输入:
0123
输出:
read 5 bytes
0123
从输出结果可知读到了5个字节,”0123\n“;并且又将buf中的内容输出到了终端。
lseek
lseek函数用于在文件中移动文件指针的位置。

fd:文件描述符
offset:偏移量
whence:是起始位置
whence参数可以取以下三个值:
SEEK_SET:从文件开头开始计算偏移量。
SEEK_CUR:从当前位置开始计算偏移量。
SEEK_END:从文件末尾开始计算偏移量。
lseek函数的返回值为新的文件指针位置,如果出现错误,则返回-1,并设置errno变量来指示具体的错误原因。
每个打开的文件都记录着当前读写位置,打开文件时读写位置是0,表示文件开头,通常读写多少个字节就会将读写位置往后移多少个字节。
但是有一个例外,如果以0_APPEND方式打开,每次写操作都会在文件末尾追加数据,然后将读写位置移到新的文件末尾。Iseek和标准I/O库的fseek函数类似。
argv[1]的内容如下:
0123456789
#include<stdio.h>
#include<sys/types.h>
#include<fcntl.h>
#include<stdlib.h>
#include<unistd.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
exit(1);
}
int fd = open(argv[1], O_RDONLY); // 以只读的方式打开文件argv[1]
if (fd < 0) {
perror("open"); // 如果打开失败则输出错误信息
exit(1); // 退出程序
}
int pos = lseek(fd, 3, SEEK_SET); // pos指针为从开头往后偏移3个位置,也就是第4个位置
char c;
read(fd, &c, 1); // 指针先自动向后移动一位, 然后再从从fd中读取一个字符,注意是读取当前指针所指向的字符。
write(STDOUT_FILENO, &c, 1); // 将读取到的字符写入标准输出流
printf("\n");
printf("pos = %d\n", pos); // 输出pos的值,也就是指针所指向的位置
close(fd); // 关闭文件
return 0;
}
输出结果如下:
3
pos = 3
对于这个结果可能会有疑问,我们可以自圆其说,显然pos从-1位开始,先移动一位再读一位。这样pos最后所指的位置就是3。
fcntl
fcntl函数用于对文件符进行控制操作,它可以实现对文件描述符的属性修改、锁定、以及其他一些操作。

fd:文件描述符
cmd:要执行的操作命令
arg:可选参数,根据不同的命令可能需要提供不同的参数。(一般我们不使用)
cmd有以下常用的可选参数:
F_DUPFD:复制文件描述符
F_GETFD:获取文件描述符标志
F_SETFD:设置文件描述符标志
F_GETFL:获取文件状态标志
F_SETFL:设置文件状态标志
F_GETLK:获取文件锁信息
F_SETLK:设置文件锁
F_SETLKW:设置文件锁,如果无法获取锁则阻塞
通过使用不同的命令和参数,可以实现对文件描述符的各种操作,如复制、设置标志、获取状态等。
代码演示:
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
#include<stdlib.h>
int main() {
int fd = open("test.txt", O_RDWR | O_CREAT); // 以可读可写的方式打开文件test.txt,如果不存在则创建该文件
if (fd < 0) {
perror("open");
exit(1);
}
int flags = fcntl(fd, F_GETFL); // 获取文件的状态
if (flags < 0) {
perror("fcntl");
exit(1);
}
flags |= O_APPEND; // 添加O_APPEND标志位,表示追加写入
if (fcntl(fd, F_SETFL, flags) < 0) { // 设置文件描述符的标志位
perror("fcntl"); // 如果设置失败则输出错误信息并退出程序
exit(1);
}
char *buf = "Hello, World!\n";
if (write(fd, buf, 14) < 0) { // 向文件写入buf中的内容
perror("write"); // 如果写入失败则输出错误信息并退出程序
exit(1);
}
close(fd); // 关闭文件
return 0;
}
输出结果:
test.txt
Hello, World!
ioctl
由于ioctl的用法过于复杂,我们只作简单的演示。
ioctl函数用于向设备发送控制和配置命令。

fd:文件描述符
request:ioctl的命令也叫请求码
以下是一些常见的ioctl请求码及其功能:
FIONREAD:获取输入缓冲区中的字节数。
FIONBIO:设置文件描述符为非阻塞模式。
FIOASYNC:设置文件描述符为异步模式。
TIOCGWINSZ:获取终端窗口大小。
TIOCSWINSZ:设置终端窗口大小。
TCGETS:获取终端属性。
TCSETS:设置终端属性。
SNDCTL_DSP_RESET:重置音频设备。
SNDCTL_DSP_SYNC:等待音频设备输出缓冲区为空。
VIDIOC_QUERYCAP:查询视频设备的能力。
可变参数取决于request,通常是一个指向变量或结构体的指针。若出错则返回-1,若成功则返回其他值,返回值也是取决于request。
在使用ioctl函数时,需要根据具体的设备和需求来确定request参数的值。不同的设备和功能对应着不同的请求码。一般情况下,请求码由设备驱动程序定义,并通过头文件或宏定义提供给应用程序使用。
例如:
winsize是一个结构体,用于表示终端窗口的大小。它定义在<sys/ioctl.h>头文件中,结构体的定义如下:
struct winsize {
unsigned short ws_row; // 窗口行数
unsigned short ws_col; // 窗口列数
unsigned short ws_xpixel; // 窗口宽度(以像素为单位)
unsigned short ws_ypixel; // 窗口高度(以像素为单位)
};
通过使用ioctl()系统调用,并传递TIOCGWINSZ参数,可以获取当前终端窗口的大小信息,并将其存储在winsize结构体中。
使用样例:
#include<stdio.h>
#include<stdlib.h>
#include<sys/ioctl.h>
#include<unistd.h>
int main() {
struct winsize size; // 定义一个winsize结构体
if (!isatty(1)){ //首先判断与stdout相连的是否是终端设备,如果是则返回1,否则返回0
printf("1 is not tty\n");
exit(1);
}
if (ioctl(1, TIOCGWINSZ, &size) < 0) { //如果操作失败返回负数并设置错误
perror("ioctl");
exit(1);
}
printf("%d rows, %d columns\n", size.ws_row, size.ws_col); //输出窗口的行数和列数
return 0;
}
由于ioctl的功能复杂,更详细的使用还需要自己去摸索。
mmap
mmap可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址,对文件的读写可以直接用指针来做而不需要read/write函数。

参数说明:
addr:映射区域的起始地址,我们通常设置为NULL,由系统自动选择合适的地址。
length:映射区域的长度,以字节为单位。
prot:映射区域的保护方式,可以是以下几种组合:
PROT_READ:可读
PROT_WRITE:可写
PROT_EXEC:可执行
PROT_NONE:无权限
flags:映射区域的标志位,可以是以下几种组合:
MAP_SHARED:与其他进程共享映射区域
MAP_PRIVATE:创建一个私有的写时复制映射区域
MAP_ANONYMOUS:创建一个匿名映射区域,不与文件关联
fd:要映射的文件描述符,如果使用匿名映射,则设置为-1。
offset:文件映射的偏移量,表示从文件的什么位置开始映射,必须是页大小的整数倍。
mmap函数的返回值是映射区域的起始地址,如果映射失败,则返回MAP_FAILED。
使用mmap函数可以实现一些高级的操作,比如内存映射文件、共享内存、匿名映射等。它可以提高文件I/O的效率,同时也可以方便地进行进程间通信。
#include<stdio.h>
#include <fcntl.h>
#include<stdlib.h>
#include<sys/mman.h>
int main() {
int fd = open("test.txt", O_RDWR | O_CREAT); // 打开一个test.txt文件,如果没有则创建
if (fd < 0) { // 如果打开失败则输出错误类型
perror("open");
exit(1); // 输出错误类型并退出程序
}
char *p = mmap(NULL, 10, PROT_WRITE, MAP_SHARED, fd, 0); // 以可写的方式将fd映射到p指针
p[0] = 'c';
p[1] = 'o';
p[2] = 'f';
p[3] = 'f';
p[4] = 'e';
p[5] = 'e'; // 修改p则是修改fd所指向的文件
munmap(p, 10); // 解除对内存映射区域的映射关系
return 0;
}
1.3 文件系统
我们把文件系统分为底层文件系统和虚拟文件系统。
底层文件系统是指直接与硬件交互的文件系统,它负责管理磁盘上的物理存储空间。而虚拟文件系统(Virtual File System,VFS)是操作系统中的一个抽象层,它提供了统一的文件系统接口,使得应用程序可以通过相同的方式访问不同类型的文件系统,如FAT、NTFS、EXT等。
底层文件系统
ext2
关于底层文件系统我们将来介绍ext2文件系统。
ext2文件系统是一种古老的Linux文件系统,它是二代扩展文件系统(Extended File System 2)的简称。掌握了ext2文件系统之后,会发现其他底层文件系统都是相似的。

启动块 (Boot Block)
大小就是1KB,由PC标准规定,用来存储磁盘分区信息和启动信息,任件文件系统都不能使用该块。
超级块(Super Block)
描述整个分区的文件系统信息,例如块大小、文件系统版本号、上次mount的时间等等。超级块在每个块组的开头都有一份拷贝。
块位图(Block Bitmap)
块位图就是用来描述整个块组中哪些块已用哪些块空闲的,本身占一个块,其中的每个 bit代表本块组中的一个块,这个bit为1表示该块已用,这个bit为0表示该块空闲可用。
块组描述符表(GDT,Group Descriptor Table)
由很多块组描述符组成,整个分区分成多少个块组就对应有多少个块组描述符。每个块组描述符存储一个块组的描述信息,包括inode表哪里开始,数据块从哪里开始,空闲的inode和数据块还有多少个等。块组描述符表在每个块组的开头也都有一份拷贝,这些信息是非常重要的,因此它们都有多份拷贝。
inode位图 (inode Bitmap)
和块位图类似,本身占一个块,其中每个bit表示一个inode是否空闲可用。
inode表(inode Table)
文件类型(常规、目录、符号链接等),权限,文件大小,创建/修改/访问时间等信息存在inode中,每个文件都有一个inode。
数据块(Data Block)
常规文件:文件的数据存储在数据块中。
目录:该目录下的所有文件名和目录名存储在数据块中。(注意:文件名保存在它所在目录的数据块中,其它信息都保存在该文件的inode中)
符号链接:如果目标路径名较短则直接保存在inode中以便更快地查找,否则分配一个数据块来保存。
设备文件、FIFO和socket等特殊文件:没有数据块,设备文件的主设备号和次设备号保存在inode中。
stat
stat函数是Linux系统中的一个系统调用,用于获取文件或目录的inode信息。

参数说明:
pathname:要获取信息的文件或目录的路径名
statbuf:一个指向struct stat结构体的指针,用于存储获取到的文件或目录的inode信息。
struct stat结构体定义了文件或目录的innode信息。
以下是struct stat结构体的定义:
struct stat {
dev_t st_dev; // 文件所在设备的ID
ino_t st_ino; // 文件的inode节点号
mode_t st_mode; // 文件的类型和权限
nlink_t st_nlink; // 文件的硬链接数
uid_t st_uid; // 文件所有者的用户ID
gid_t st_gid; // 文件所有者的组ID
dev_t st_rdev; // 若文件为设备文件,则为其设备号
off_t st_size; // 文件大小(字节数)
blksize_t st_blksize; // 文件系统I/O缓冲区大小
blkcnt_t st_blocks; // 分配给文件的512字节块数
time_t st_atime; // 文件最后访问时间
time_t st_mtime; // 文件最后修改时间
time_t st_ctime; // 文件最后状态改变时间
};
调用stat函数成功时,返回值为0;失败时,返回值为-1,并设置errno来指示具体的错误原因。
虚拟文件系统
虚拟文件系统(Virtual File System,VFS)是操作系统中的一个抽象层,向上层用户进程提供标准的系统调用,向下层文件系统要求必须实现这些系统调用,使得不同类型的文件系统可以以相同的方式被访问和操作。VF

本文详细介绍了Linux操作系统编程,涵盖了文件操作、进程、信号、线程等方面。从系统调用如open、read、write、close、fcntl、ioctl、mmap,到文件系统、进程控制、进程间通信、信号处理等概念,以及信号产生、阻塞、捕捉的方法。文章通过实例演示了各种系统调用的使用,并强调了Linux内核如何通过系统调用接口提供对硬件资源的访问。最后,文章预告了关于线程的内容将在后续更新。
最低0.47元/天 解锁文章
696

被折叠的 条评论
为什么被折叠?



