1.1 汇编程序的Hello world
.data
msg: #首地址
.ascii "Hello, World!\n"
len = . - msg
.text
.global _start #汇编程序的格式
_start:
movl $len, %edx
movl $msg, %ecx
movl $1, %ebx
movl $4, %eax
int $0x80
movl $0, %ebx
movl $1, %eax
int $0x80
运行效果与命令如下
这段汇编语言相当于以下C代码:
#include <unistd.h>
char msg[14] = "Hello, world\n";
#define len 14
int main(void)
{
write(1, msg, len);
_exit(0);
}
.data段有一个编号msg,代表字符串“Hello,world!\n”的首地址,相当于C程序的一个全局变量。注意在C语言中字符串的末尾隐含有一个'\0',而汇编指示.ascii定义
的字符串末尾没有隐含的'\0'。汇编程序中的len代表一个常量,它的值由当前的地址减去符号msg所代表的地址得到,换句话说就是字符串“Hello,world!\n”的长度。
现在解释一下这行代码中.,汇编器总是从前到后把汇编代码转换成目标文件,在这个过程中维护一个地址计数器,当处理到每个段的开头时把地址计数器置成0,然后
每处理一条汇编指示或指令就把地址计数器增加相应的字节数,在汇编程序中,可以取出当前地址计数器的值,是一个常量。
在_start中调了两个系统调用,第一个是write系统调用,第二个是_exit系统调用。在调write系统调用时,eax寄存器保存着write的系统调用号4,ebx,ecx,edx寄存
器分别保存着write系统调用需要的三个参数。ebx保存着文件描述符,进程中每个打开的文件都有一个编号称为文件描述符,文件描述符1表示标准输出,对应于C标准
I/O库的stdout,ecx保存着输出缓冲区的首地址,edx保存着输出的字节数,write系统调用把从msg开始的len个字节写到标准输出。
C代码中的write函数是系统调用的包装函数,其内部实现就是把传进来的三个参数分别赋给ebx,ecx,edx寄存器,然后执行movl $4,$eax和int $0x80两条指令。这
个函数不可能完全用C代码来写,因为任何C代码都不会编译生成int指令,所以这个函数有可能是完全用于汇编写的,也可能是用C内联汇编写的,甚至可能是一个宏定
义(省了参数入栈出栈的步骤)。_exit函数也是如此。你可以通过man _exit查询。
1.2 C标准I/O库函数与Unbuffered I/O函数
C标准I/O库函数是如何用系统调用实现的。
fopen(3)
调用open(2)打开指定的文件,返回一个文件描述符,(就是一个int类型的编号),分配一个FILE结构体,其中包含该文件的描述符、I/O缓冲区和当前读写位置等信
息,返回这个FILE结构体的地址。
fgetc(3)
通过传入的FILE*参数找到文件的描述符,I/O缓冲区和当前读写位置,判断是否从I/O缓冲区中读取下一个字符,如果能读到就直接返回该字符,否则调用read(2),
把文件描述符穿进去,让内核读取文件的数据到I/O缓冲区,然后返回下一个字符。注意,对于C标准I/O库来说打开的文件由FILE*指针标识,而对于内核来说,打开的
文件由文件描述标识,文件描述符从open系统调用获得,打开的文件由write、close系统调用时都需要穿文件描述符。
fputc(3)
判断该文件的I/O缓存区是否有空间在存放一个字符,如果有空间则直接保存在I/O缓冲区中并返回,如果I/O缓冲区已满就调用write(2),让内核把I/O缓冲区的内容
写回文件。
fclose(3)
如果I/O缓冲区中还有数据没有写回文件,就调用write(2)写回文件,然后调用close(2)关闭文件,释放FILE结构体和I/O缓冲区。
关闭文件是为了将buffer中的内容写回到(磁盘)
open、read、write、close等系统函数称为无缓冲I/O(Unbuffered I/O)函数,因为它们位于C标准库的I/O缓冲区的底层。用户程序在读写文件时既可以调用C标准I/O库
函数,也可以调用底层的Unbuffered I/O函数,那么用哪一组函数好呢?
用Unbuffered I/O函数每次读写都要进内核,调一个系统调用比调一个用户空间的函数要慢得多,所以在用户空间开辟I/O缓冲区还是必要的,用C标准I/O库函数就比
较方便,省去了自己管理I/O缓冲区的麻烦。
用C标准I/O库函数要时刻注意I/O缓冲区和实际文件有可能不一致,在必要时需要调用fflush(3)。
我们知道UNIX的传统是Everything is a File,I/O函数不仅仅用于读写常规文件,也用于读写设备文件,比如终端或网络设备。在读写设备时通常是不希望有缓冲区,
例如向代表网络设备的文件写数据就是希望数据通过网络设备发送出去,而不是希望只写到缓冲区里就算完事了,当网络设备收到数据时应用程序也希望第一时间被通
知到,所以网络编程通常直接调用Unbuffered I/O函数。
Unbuffered I/O接口是UNIX标准的一部分,所有UNIX系统的内核都要提供这组服务,但不是C标准库的一部分,也就是说,在支持C语言的非UNIX操作系统上,标准I/O库
的底层可以是另外一组函数(例如Win32 API的ReadFile、WriteFile)。
关于UNIX标准
现在改说说文件描述符了。每个进程在内核中都有一个task_struct结构体来维护进程相关的信息,在Linux内核中称为进程描述符(Process Descriptor),而在操作系统
理论中称为进程控制块(PCB,Process Control Block)。tast_struct中包含该进程当前打开的所有文件的信息,称为文件描述符表,在内核中files_struct结构体表示,其
中的表项称为文件描述符(File Descriptor),每个表项都包含一个指向已打开的指针。
在用户程序中文件描述符指的是文件描述符表的索引(即0、1、2、3这些数字),用int型变量来保存。当调用open打开一个现有文件或创建一个新文件时,内核分
配一个新的文件描述符并返回给进程,当读写该文件时,文件描述符被作为参数传递给read或write。以前我们用C标准I/O函数,调用fopen打开文件,返回一个FILE *
指针,当读写该文件时就传递这个FILE *指针。而fopen、fputc、fgetc的底层实现就要调用open、read、write。可见FILE结构体中必然包含文件描述符,此外还包含缓存
区的相关信息,但FILE指针是不透明,我们不必关心这些信息在FILE结构体中如何保存和表示。
执行程序时会打开三个文件:标准输入、标准输出和标准错误输出。在C标准库中分别用FILE *指针stdin、stdout和stderr表示。这三个文件的描述符分别是0,1,2,保
存在FILE结构体中。头文件unistd.h中有如下的宏定义来表示这三个文件描述符:
#define STDIN_FILENO 0
#define STDOUT_FILENO 1
#define STDERR_FILENO 2
1.3 open函数
open函数可以打开或创建一个文件。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
pathname参数是要打开或创建的文件名,和fopen一样,pathname既可以是相对路径也可以是绝对路径,如果只有文件名没有路径就表示相对于当前工作目录。flags
参数有一系列常数值可供选择,可以同时选择多个常数用按位或运算符连接起来,所以这些常数的宏定义都是以O_开头,表示or。
必选项:一下三个常数中必须指定一个,且仅允许指定一个。
O_RDONLY 只读打开
O_WRONLY 只写打开
O_RDWR 可读可写打开
其它可选项介绍:
O_APPEND表示追加。如果文件已有内容,这次打开文件所写的数据附加到文件的末尾而不是覆盖原来的内容。
O_CREAT若此文件不存在则创建它。使用此选项时需要提供第三个参数mode,表示该文件的访问权限。
O_EXCL如果同时指定了O_CREAT,而文件已经存在,则出错。
O_TRUNC如果此文件存在,而且为只写或可读可写成功打开,则将长度截断(Truncate)为0.
O_NONBLOCK对于设备文件,以O_NONBLOCK方式打开可以做非阻塞I/O(Nonblock I/O)。
1、以写的方式fopen一个文件时,如果文件不存在会自动创建,以写方式open一个文件时,必须明确指定O_CREAT才会创建文件,否则文件不存在就出错返回。
2、以w或w+方式open一个文件时,如果文件已存在就截断,以写方式open一个文件时,必须声明指定O_TRUNC才会截断文件,否则直接在原来的数据上改写。
第三个参数mode是文件的rwx权限,可以用八进制数表示,比如0644表示-rw-r--r--,也可以用S_IRUSR、S_IWUSR等宏定义按位或起来表示详见open(2)。要注意的是
文件的权限是由open的mode参数和当前进程的umask掩码共同决定的。比如在Shell下设置umask掩码为022,然后执行程序a.out
open函数调用成功则返回值为文件描述符,调用失败则返回-1,同时设置error未相应的错误码。由open返回的文件描述符0、1、2,因此调用open打开文件就会返回描
述符3,再调用open就会返回4。可以利用这一点在标准输入、标准输出上打开一个新的文件,实现重定向功能。例如,首先关闭标准输出(文件描述符1),然后打开
另一个文件,那么该文件一定会被分配文件描述符1。后面要讲的dup2函数提供了一种更好的办法在指定的文件描述符上打开文件。
1.4 close函数
#include <unistd.h>
int close(fd);
参数fd是要关闭的文件描述符。close函数成功返回0,失败返回-1。
需要说明的是,当一个进程终止时,即使不调用close,它打开的所有文件也被内核自动关闭。但是对于一个长年累月运行的程序(比如网络服务器),打开的文件描
述符一定要记得关闭,否则随着打开的文件越来越多,会占用大量文件描述符和系统资源。
1.5 read/write
read函数从打开的设备或文件中读取数据。
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
参数count是请求读取的字节数,读上来的数据存在缓冲区buf中,同时文件中的读写位置向后移,返回值是读到的字节数,若读操作之前已到达文件末尾,则读操作返
回0,若出错则返回-1。返回值类型是ssize_t,表示有符号的size_t,这样既可以返回正的字节数、0(到达文件末尾)也可以返回负值-1(出错)。read函数返回时,
返回值决定了buf中的有效字节数。有些情况将使实际读到的字节数(也就是返回值)小于count,例如:
读常规文件时,在读到count字节之前已到达文件末尾。例如,距离文件末尾还有30个字节而请求读100个字节,则read返回30,下次read将返回0.
从终端设备读,通常以行为单位,读到换行符就返回了。
从网络设备读,根据不同的传输层协议和内核缓存机制,返回值可能小于请求的字节数,后面socket编程部分详细介绍。
write函数向打开的设备或文件中写入数据。
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
返回值是写入的字节数,若出错则返回-1。写常规文件时,write的返回值通常等于请求写的字节数count,向设备或网络写入则不一定。
对常规文件是不会阻塞的,不管读多少字节,read一定会有限的时间内返回。从终端和网络上没有发来数据包,read一个socket就会阻塞,会阻塞多长时间也是不确定
的,如果一直没有数据到达就一直阻塞在那里。写常规文件也是不会阻塞的,而向设备或网络写则不一定。
阻塞读终端实例
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(void)
{
char buf[10];
int n;
n = read(STDIN_FILENO, buf, 10);
if(n < 0)
{
perror("read STDIN_FILENO");
exit(1);
}
write(STDOUT_FILENO, buf, n);
printf("\n");
return 0;
}
运行界面如下:
while(1) {
非阻塞read(设备1)
if(设备1有数据到达)
处理数据;
if(设备2有数据到达)
处理数据;
......
}
如果read(设备1)是阻塞的,那么只要设备1没有数据到达就会一直阻塞在设备1的read调用上,即使设备2有数据到达也不能处理,使用非阻塞I/O就可以避免设备2
得不到及时处理。
非阻塞I/O有一个缺点,如果所有设备都一直没有数据到达,调用者需要反复查询做无用功,如果阻塞在哪里,操作系统可以调度别的进程执行,就不会做无用功了。
在使用非阻塞I/O时,通常不会在一个while循环中一直不停地查询(这个称为Tight Loop),而是每延迟等待一会就来查询一下,以免做太多无用功,在延迟等待的时
候可以调度其它进程执行。
while(1) {
非阻塞read(设备1)
if(设备1有数据到达)
处理数据;
if(设备2有数据到达)
处理数据;
......
sleep(n);
}
这样做的问题是,设备1有数据到达时可能不能及时处理,最长需要延迟n秒才能处理,而是反复查询还是做无用功。以后要学习的select(2)函数可以阻塞地同时监视多
个设备,还可以设定设备等待的超时时间,从而圆满地解决了这个问题。
一下是一个非阻塞I/O的例子。目前我们学过得可能引起阻塞的设备只有终端,所以我们用终端来做这个实验。程序开始执行时在0、1、2文件描述符上自动打开的文件
就是终端,但是没有O_NONBLOCK标志。我们可以重新打开一遍设备文件/dec/tty(表示当前终端),在打开时指定O_NONBLOCK标志。
非阻塞读终端实例
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#define MSG_TRY "try again\n"
int main(void)
{
int buf[10];
int n, fd;
fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
if(fd < 0)
{
perror("open /dex/tty error!\n");
exit(1);
}
tryagain:
n = read(fd, buf, 5);
if(n < 0)
{
/*相等时表示非阻塞成功直接跳出*/
if(errno != EAGAIN)
{
perror("read /dev/tty error!\n");
exit(1);
}
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
goto tryagain;
}
write(STDOUT_FILENO, buf, n);
close(fd);
return 0;
}
一下是用非阻塞I/O实现等待超时的例子。既保证了超时退出的逻辑又保证了有数据到达时处理延迟较小。
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
#define MSG_TIMEOUT "timeout\n"
int main(void)
{
char buf[10];
int fd, n;
int i = 0;
fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
if(fd < 0)
{
perror("open /dev/tty error!\n");
exit(1);
}
for(i = 0; i < 5; i++)
{
n = read(fd, buf, 10);
if(n >= 0)
{
break;
}
if(errno != EAGAIN)
{
perror("read /dev/tty error!\n");
exit(1);
}
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
}
if(n == 5)
{
write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT));
}
else
{
write(STDOUT_FILENO, buf, n);
}
close(fd);
return 0;
}
1.6 lseek函数
每个打开的文件都记录当前读写位置,打开文件时读写位置是0,表示文件开头,通常写多少字节就会将读写位置往后移多少个字节。但是有一个例外,如果以
O_APPEND方式打开,每次写操作都会在文件末尾追加数据,然后将读写位置移动到新的文件末尾。lseek和标准I/O库的fseek函数类似,可以移动当前读写位置(或
者叫偏移量)。
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
参数offset和whence的含义和fseek函数完全相同。只不过第一个参数换成了文件描述符。和fseek一样,偏移量允许超过文件末尾,这种情况下对该文件的下一次写
操作将延长文件,中间空洞的部分读出都是0.
若lseek成功执行,则返回新的偏移量,因此可用以下方法确定一个打开文件的当前偏移量:
off_t currpos;
currpos = lseek(fd, 0, SEEK_CUR);
这种方法也可用来确定文件或设备是否可以设置偏移量,常规文件都可以设置偏移量,而设备一般是不可以设置偏移量。如果设备不支持lseek,则lseek返回-1,并
将errno设置为ESPIPE。注意fseek和lseek在返回值上有细微的差别,fseek成功时返回0失败时返回-1,要返回当前偏移量需要调用ftell,而lseek成功时返回当前偏移量
失败时返回-1.
1.7 fcntl
先前我们以read终端设备为例介绍非阻塞I/O,为什么我们不直接对STDIN_FILENO做非阻塞read,而要重新open一遍/dev/tty呢?因为STDIN_FILENO在程序启动时已
经被自动打开了,而我们需要在调用open时指定O_NONBLOCK标志。这里介绍另一种办法,可以用fcntl函数改变一个已打开的文件的属性,可以重新设置读、写、追
加、非阻塞等标志(这些标志称为File Status Flag),而不必重新open文件。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd);
int fcntl(int fd, int cmd, long arg);
int fcntl(int fd, int cmd, struct flock *lock);
这个函数和open一样,也是用可变参数实现的,可变参数的类型和个数取决于前面的cmd参数。下面的例子使用F_GETFL和F_SETFL这两种fcntl命令改变STDIN_FILENO
的属性。加上O_NONBLOCK选项。实现非阻塞读终端同样的功能。
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#define MSG_TRY "try again!\n"
int main(void)
{
char buf[10];
int n;
int flags;
flags = fcntl(STDIN_FILENO, F_GETFL);
flags |= O_NONBLOCK;
if(fcntl(STDIN_FILENO, F_SETFL, flags) == -1)
{
perror("fcntl error!\n");
exit(1);
}
tryagain:
n = read(STDIN_FILENO, buf, 10);
if(n < 0)
{
/*非阻塞失败*/
if(errno != EAGAIN)
{
perror("read error!\n");
exit(1);
}
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
goto tryagain;
}
write(STDOUT_FILENO, buf, n);
return 0;
}
一下程序通过命令行的第一个参数指定一个文件描述符,同时利用Shell的重定向功能在该描述符上打开文件,然后用fcntl的F_GETFL命令取出File Status Flag并打印。
#include <sys/types.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int val;
if(argc != 2)
{
fputs("usage: a.out <descriptor#>\n", stderr);
exit(1);
}
if((val = fcntl(atoi(argv[1]), F_GETFL)) < 0)
{
printf("fcntl error for fd %d\n", atoi(argv[1]));
exit(1);
}
switch(val & O_ACCMODE)
{
case O_RDONLY:
printf("read only");
break;
case O_WRONLY:
printf("write only");
break;
case O_RDWR:
printf("read write");
break;
default:
fputs("invalid access mode!", stderr);
exit(1);
}
if(val & O_APPEND)
printf(", append\n");
if(val & O_NONBLOCK)
printf(", nonblocking\n");
return 0;
}
1.8 iotcl
iotcl用于向设备发控制和配置命令,有些命令也需要读写一些数据,但这些数据是不能用read/write读写的,称为Out-of-band数据。也就是说,read/write读写的数据是
in-band数据,是I/O操作的主体,而ioctl命令传送的是控制信息,其中的数据是辅助的数据。例如,在串口上收发数据通过read/write操作,而串口的波特率、校验位、
停止位通过iotcl设置,A/D转换的结果通过read读取,而A/D转换的精度和工作频率通过iotcl设置。
#include <sys/ioctl.h>
int ioctl(int d, int request, ...);
d是某个设备的文件描述符。request是ioctl的命令,可变参数取决于request,通常是一个指向变量或结构体的指针。若出错则返回-1,若成功则返回其他值,返回值也是取决于request。
以下程序使用TIOCGWINSZ命令获得终端设备的窗口大小
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ioctl.h>
int main(void)
{
struct winsize size;
/*判断终端是否打开*/
if(isatty(STDOUT_FILENO) == 0)
exit(1);
if(ioctl(STDOUT_FILENO, TIOCGWINSZ, &size) < 0)
{
perror("iotcl TIOCGWINSZ error!\n");
exit(1);
}
printf("%d rows, %d columns\n", size.ws_row, size.ws_col);
return 0;
}
1.9 mmap
mmap可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址,对文件的读写可以直接用指针来做而不需要read/write函数。
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int port, int flag, int fileds, off_t off);
int munmap(void *addr, size_t len);
如果addr参数为NULL,内核会自己在进程地址空间中选择合适的地址建立映射。如果addr不是NULL,则给内核一个提示,应该从什么地址开始映射,内核会选择addr之
上的某个合适的地址开始映射。建立映射后,真正的映射首地址通过返回值可以得到。len参数是需要映射,必须是页大小的整数倍(在32位体系系统结构上通常是4k)
filedes是代表该文件的描述符。
port参数有四种取值。
PRO T_EXEC表示映射的这一段可执行,例如映射共享库
PROT_READ表示映射的这一段可读
PROT_WRITE表示映射的这一段可写
PROT_NONE表示映射的这一段不可访问
flag参数有很多种取值,这里只讲两种,其它取值可查看mmap(2)
MAP_SHARED多个线程对同一个文件的映射是共享的,一个进程对映射的内存做了修改,另一个进程也会看到这种变化。
MAP_PRIVATE多个进程对同一个文件的映射不是共享的,一个进程对映射的内存做了修改,另一个进程并不会看到这种变化,也不会真的写到文件中。
如果mmap成功则返回映射首地址,如果出错则返回常数MAP_FAILED。当进程终止时,该进程的映射内存会自动解除,也可以调用munmap解除映射。munmap成功返
回0,出错返回-1。
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
int main(void)
{
int *p;
int fd = open("hello", O_RDWR);
if(fd < 0)
{
perror("open hello error!\n");
exit(1);
}
p = mmap(NULL, 6, PROT_WRITE, MAP_SHARED, fd, 0);
if(p == MAP_FAILED)
{
perror("mmap error!\n");
exit(1);
}
close(fd);
p[0] = 0x30313233;
munmap(p, 6);
return 0;
}