大多数计算机使用 8 位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。
每台计算机都有一个字长(word size),指明指针数据的标称大小。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为w
位的机器而言,虚拟地址的范围为 0~2^w -1
,程序最多访问 2^w
个字节。
-
对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及在内存中如何排列这些字节。在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。例如,假设一个类型为 int 的变量 x 的地址为
0x100
,也就是说,地址表达式&x
的值为0x100
。那么x
的4个字节将被存储在内存0x100、0x101、0x102、0x103
位置。 -
某些机器选择在内存中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照最高有效字节到最低有效字节的顺序存储。前一种规则,最低有效字节在最前面的方式,称为 小端法。后一种规则,最高有效字节在最前面的方式,称为大端法。
-
强制类型转换告诉编译器,程序应该把指针看成指向一个字节序列,而不是指向一个原始数据类型的对象。然后,这个指针会被看成是对像使用的最低字节地址。
#include <stdio.h>
typedef unsigned char *byte_pointer;
void show_bytes(byte_pointer start, size_t len)
{
size_t i;
for (i = 0; i < len; i++)
{
printf("%.2x", start[i]);
}
printf("\n");
}
void show_int(int x)
{
show_bytes((byte_pointer)&x, sizeof(int));
}
void test_show_bytes(int val){
int ival = val;
float fval = (float) ival;
show_int(ival); //39300000
show_float(fval); //00e44046
}
int main(){
// 12345 十六进制表示 0x00003039
test_show_bytes(12345);
}
-
C 语言中字符串被编码为一个以 null(其值为0)字符结尾的字符数组。每个字符都由某个标准编码来表示,最常见的是 ASCII 字符码。
-
统一字符集使用32位来表示字符。特别地,UTF-8 表示将每个字符编码为一个字节序列。这样标准 ASCII 字符还是使用和它们在 ASCII 中一样的单字节编码。
#include <stdio.h>
typedef unsigned char *byte_pointer;
void show_bytes(byte_pointer start, size_t len)
{
size_t i;
for (i = 0; i < len; i++)
{
printf("%.2x", start[i]);
}
printf("\n");
}
int main(){
char msg[] = "12345";
show_bytes((byte_pointer)msg,6); //313233343500
//十进制数字 x 的 ASCII 码表示正好是 0x3x。例如 1 表示为 0x31
}
/*
| 或 0110 | 1100 = 1110
& 与 0110 | 1100 = 0100
~ 取反 ~ 1100 = 0011
^ 异或 0110 | 1100 = 1010
*/
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。
运行应用程序代码的进程初始化时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如终端、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
- 内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。
- 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度。是由内核中称为 调度器 的代码处理的。
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为僵死进程(zombie)。
Linux 信号,它允许进程和内核中断其他进程。一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从 I/O 设备复制数据到主存,而输出操作是从主存复制数据到 I/O 设备。
一个 Linux 文件就是一个 m 个字节的序列,所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
- 打开文件 。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。
- Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符1)和标准错误(描述符为2)。头文件
<unistd.h>
定一个了常量STDIN_FILENO(0)
、STDOUT_FILENO(1)
、STDERR_FILENO(2)
,它们可用代替显式的描述符值。 - 改变当前的文件位置。 对于每个打开的文件,内核保持着一个文件位置 k,初始为0。这个文件位置是从文件开头起始的字节偏移量。
- 读写文件。 一个读操作就是从文件复制 n > 0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k + n。给定一个大小为 m 字节的文件,当 k >= m 时执行读操作会触发一个称为 end-of-file(EOF)的条件,应用能检测到这个条件。在文件结尾处并没有明确的 "EOF符号"。
- 关闭文件。 当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
内核用三个相关的数据结构来表示打开的文件:
-
描述符表。每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。
-
文件表。 打开文件的集合是有一张文件表来表示的。所有的进程共享这张表。每个文件表的表项组成包括当前的文件位置、引用计数 (即当前指向该表项的描述符表项数),以及一个指向 v-node 表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中引用计数。内核不回删除这个文件表表项,直到它的引用计数为零。
-
V-node
表。所有的进程共享这张 v-node 表。每个表项包含 stat 结构中的大多数信息,包括 st_mode 和 st_size 成员。 -
父子进程共享文件 ,父进程调用了 fork 后,子进程有一个父进程描述符表的副本。父子进程共享相同的打开文件表集合,因此共享相同的文件位置。在内核删除相应文件表表项之前,父子进程必须都关闭了它们的描述符。
一种方式是使用 dup2 函数。
#include <unistd.h>
int dup2(int oldfd, int newfd);
/*
dup2 函数复制描述符表表项 oldfd 到描述符表表项 newfd,覆盖描述符表表项 newfd 以前的内容。如果 newfd 已经打开了,dup2 会在复制 oldfd 之前关闭 newfd。
*/
-
标准 I/O 库将一个打开的文件模型化为一个流。一个流就是一个指向 FILE 类型的结构和指针。每个 c 程序开始时都有三个打开的流 stdin、stdout 和 stderr。
#include <stdio.h> extern FILE *stdin; extern FILE *stdout; extern FILE *stderr;
-
类型为 FILE 的流是对文件描述符 和 流缓冲区 的抽象。流缓冲区的目的:就是使开销较高的 Linux I/O 系统调用的数量尽可能得小。例如,假设我们有一个程序,它反复调用标准 I/O 的 getc 函数,每次调用返回文件的下一个字符。当第一次调用 getc 时,库通过调用一次 read 函数来填充流缓冲区,然后将缓冲区中的第一个字节返回给应用程序。只要缓冲区中还有未读的字节,接下来对 getc 的调用就能直接从流缓冲区得到数据。
- 当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称为临时端口。服务端套接字地址中的端口通常是某个知名端口。
- 一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对。
-
从 Linux 内核的角度来看,一个套接字就是通信的一个端点。从 Linux 程序的角度来看,套接字就是一个有相应描述符的打开文件。
-
socket
函数,客户端和服务端使用 socket 函数来创建一个套接字描述符。#include <sys/types.h> #include <sys/socket.h> int socket(int domain, int type, int protocol);
-
connect
函数,客户端通过调用connect
函数来建立和服务器的连接。#include <sys/socket.h> int connect(int clientfd,const struct sockaddr *addr, socklen_t addrlen);
-
bind
函数告诉内核将 addr 中的服务器套接字地址和套接字描述符 sockfd 联系起来。#include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-
listen
函数,客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实体。默认情况下,内核会认为 socket 函数创建的描述符对应于 主动套接字,它存在于一个连接的客户端。服务器调用 listen 函数告诉内核,描述符是被服务器而不是客户端使用。#include <sys/socket.h> int listen(int sockfd, int backlog);
-
accept
函数,服务器通过调用accept
函数来等待来自客户端的连接请求。#include <sys/socket.h> int accept(int listenfd, struct sockaddr *addr, int *addrlen);
accept
函数等待来自客户端的连接请求到达侦听描述符 listenfd, 然后在 addr 中填写客户端的套接字地址,并返回一个 已连接描述符,这个描述符可被用来利用Unix I/O
函数与客户端通信。