前言
**欢迎观看Linux系列文章!!**第9篇主要讲述了如何设计一个简单shell、基础IO的系统文件IO、文件描述符、重定向和缓冲区相关知识。
自设计shell
该篇的目的是为了总结之前学到的Linux知识,完成的shell只是一个很简单的程序。
输出命令提示符
代码如下
cpp
#include<cstdio>
#include<iostream>
#include<cstring>
#include<cstdlib>
#include<sys/types.h>
#include<unistd.h>
#include<sys/wait.h>
#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "
//获取USERNAME
const char* GetUserName()
{
const char* name = getenv("USER");
return name == NULL ? "NONE" : name;
}
//获取HOSTNAME
const char* GetHostName()
{
const char* hostname = getenv("HOSTNAME");
return hostname == NULL ? "NONE" : hostname;
}
//获取PWD
const char* GetPwd()
{
const char* pwd = getenv("PWD");
return pwd == NULL ? "NONE" : pwd;
}
//处理Pwd,只保留当前目录,去除上级目录.
#define SLASH "/"
std::string DirName(const char* pwd)
{
std::string dir = pwd;
if(dir == SLASH) return SLASH;
auto pos = dir.rfind(SLASH);
if(pos == std::string::npos) return "BUG?";
return dir.substr(pos + 1);
}
//构造一个命令行提示符
void MakeCmdLine(char cmd_prompt[], int size)
{
snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}
//打印输出命令行提示符
void PrintCmdPrompt()
{
char prompt[COMMAND_SIZE];
MakeCmdLine(prompt, sizeof(prompt));
printf("%s", prompt);
fflush(stdout);
}
int main()
{
while(true)
{
//1.输出命令行提示符
PrintCmdPrompt();
return 0;
}
效果如图:

获取用户命令与命令分析
演示代码:
cpp
······
//shell全局数据
#define MAXARGC 128
char* g_argv[MAXARGC];//命令行参数表
int g_argc = 0;
······
//获取用户输入的命令行
bool GetCmdLine(char* out, int size)
{
char *c = fgets(out, size, stdin);
if(c == NULL) return false;
out[strlen(out)-1] = 0;
if(strlen(out) == 0) return false;
return true;
}
//命令行分析
#define SEP " "
bool CmdParse(char* const cmdline)
{
g_argc = 0;
g_argv[g_argc++] = strtok(cmdline, SEP);
while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));//这样可以把nullptr也放在参数表中
g_argc--;
return g_argc > 0 ? true : false;
}
//输出命令行参数表
void PrintArgv()
{
for(int i = 0; g_argv[i]; i++)
{
printf("argv[%d]->%s\n", i, g_argv[i]);
}
printf("argc == %d\n", g_argc);
}
int main()
{
while(true)
{
//1.输出命令行提示符
PrintCmdPrompt();
//2.获取用户输入的命令
char cmdline[COMMAND_SIZE];
if(!GetCmdLine(cmdline, sizeof(cmdline)))
{
continue;
}
//3.命令行分析
if(CmdParse(cmdline))
{
continue;
}
PrintArgv();
}
return 0;
}
使用strtok分割用户输入的命令,然后把结果放在参数表g_argv中。
效果如图:

执行命令
演示代码:
cpp
//4.执行命令
pid_t id = fork();
if(id == 0)
{
execvp(g_argv[0], g_argv);
exit(0);
}
pid_t rid = waitpid(id, NULL, 0);
(void)rid;//没用的代码,只是为了避免警告
该部分紧接着放在命令行解释代码的下面。
效果如图:
执行内建命令
但是有些命令是内建命令,内建命令需要父进程shell完成,如cd,pwd,export等。因为子进程执行内建命令无法影响到父进程,而改变工作路径、改变环境变量等命令需要对shell本身也产生影响。shell完成内建命令,通过继承环境变量可以影响到子进程。
cpp
······
//模拟一个环境变量表
char cwd[1024];
char cwdenv[1024];
·······
bool Cd()
{
if(g_argc == 1)
{
std::string home = GetHome();
if(home.empty()) return true;
chdir(home.c_str());
}
else
{
std::string where = g_argv[1];
if(where == "-")
{}
else if(where == "~")
{}
else
{
chdir(where.c_str());
}
}
return true;
}
bool Echo()
{
if(g_argc == 2)
{
std::string opt = g_argv[1];
if(opt == "$?")
{
std::cout << lastcode << std::endl;
lastcode = 0;
return true;
}
else if(opt[0] == '$')
{
std::string env_name = opt.substr(1);
const char* env_value = getenv(env_name.c_str());
if(env_value)
std::cout << env_value << std::endl;
}
else
{
std::cout << opt << std::endl;
}
}
return true;
}
//检查并执行内建命令(这里只演示cd和echo $命令)
bool CheckAndExecBuiltin()
{
std::string cmd = g_argv[0];
if(cmd == "cd")
{
Cd();
return true;
}
else if(cmd == "echo")
{
Echo();
return true;
}
return false;
}
//执行命令
int Execute()
{
pid_t id = fork();
if(id == 0)
{
//子
execvp(g_argv[0], g_argv);
exit(0);
}
//父
pid_t rid = waitpid(id, NULL, 0);
(void)rid;//没用的代码,只是为了避免警告
return 0;
}
int main()
{
while(true)
{
//1.输出命令行提示符
PrintCmdPrompt();
//2.获取用户输入的命令
char cmdline[COMMAND_SIZE];
if(!GetCmdLine(cmdline, sizeof(cmdline)))
{
continue;
}
//3.命令行分析
if(!CmdParse(cmdline))
{
continue;
}
//PrintArgv();
//4.检测并处理内建命令
if(CheckAndExecBuiltin())
{
continue;
}
//5.执行命令
Execute();
}
return 0;
}
这里先检测是否为内建命令(代码中只以cd作为演示)。若是,则不创建子进程完成命令,而是shell来完成。这里的cd命令使用了chdir系统调用函数来改变工作路径,同时使用putenv导入新的PWD环境变量,这样一来,子进程就可以通过继承新的PWD来改变旧的PWD了。
获取环境变量表
shell的环境变量是通过shell脚本从系统中获取的,我们自设计的shell,就从这个父进程shell中获取即可。
cpp
·······
//环境变量表
#define Max_ENVS 100
char *g_env[Max_ENVS];
int g_envs = 0;
······
void InitEnv()
{
extern char** environ;
memset(g_env, 0, sizeof(g_env));
g_envs = 0;
//获取环境变量
for(int i = 0; environ[i]; i++)
{
//申请空间
g_env[i] = (char*)malloc(strlen(environ[i])+1);
strcpy(g_env[i], environ[i]);
g_envs++;
}
g_env[g_envs++] = (char*)"TESTENV=testing";
g_env[g_envs] = NULL;
//导入环境变量
for(int i = 0; g_env[i]; i++)
{
putenv(g_env[i]);
}
}

这样获取到的环境变量是全局的。
基础IO
理解文件
狭义上
文件在磁盘中;
磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的;
磁盘是外设;
磁盘上的文件本质是对文件的所有操作,都是对外设的输入和输出,即IO。
广义上
Linux下,一切皆文件。
文件操作的归类认知
0KB大小的文件也要占用磁盘空间;
文件是文件属性和文件内容的集合(文件 = 属性(元数据) + 内容);
所有文件操作本质上是文件内容操作和文件属性操作。
系统角度
对文件操作本质是进程对文件操作;
磁盘的管理者是操作系统;
文件的读写本质不是通过C/C++的库函数来操作的,而是通过文件相关的系统调用函数完成,这些库函数只是对这些系统调用的封装。
C语言文件接口
cpp
#include<stdio.h>
#include<string.h>
int main()
{
FILE* fp = fopen("log.txt", "w");
if(fp == NULL)
{
perror("fopen");
}
int cnt = 1;
while(cnt <= 10)
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "msg%d\n", cnt++);
fwrite(buffer, strlen(buffer), 1, fp);
}
fclose(fp);
return 0;
}
这里只简单回顾C语言的文件接口,具体知识可以在别处学。
fopen的选项:

r
只读打开文本文件。文件必须已存在。流指针位于文件开头。
r+
读写打开文本文件。文件必须已存在。流指针位于文件开头。可读可写。
w
只写打开文本文件。如果文件不存在则创建;如果存在则将其长度截断为 0(清空内容)。流指针位于文件开头。
w+
读写打开文本文件。文件不存在则创建,存在则清空。流指针位于文件开头。
a
追加打开文本文件(只写末尾)。文件不存在则创建。流指针位于文件末尾,所有写入都追加到结尾。
a+
读 + 追加打开文本文件。文件不存在则创建。读取可以从文件开头开始,但写入永远追加到文件末尾。
系统文件I/O
传递标记位的一种方法
通过位图来传递,将一个整数对不同宏进行按位&,结果大于0的,就相当于传递了一个对应的宏。
open函数
打开文件
头文件和语法:
cpp
#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,要打开/创建的文件的路径(绝对路径或者相对路径)
flags,标志位。有如下这些标志,这些标志实际上都是宏,数值大小都是2的幂,也就是都只有一个位上是1,不同的标志,这个1的位置也不一样。
| 标志 | 含义 |
|---|---|
O_RDONLY |
只读打开 |
O_WRONLY |
只写打开 |
O_RDWR |
读写打开 |
O_CREAT |
若文件不存在则创建,此时必须提供 mode 参数 |
O_EXCL |
与 O_CREAT 共用,若文件已存在则打开失败(原子检测) |
O_TRUNC |
若文件存在且以写方式打开,清空内容写入 |
O_APPEND |
每次写入前将文件偏移移到末尾(追加写入) |
O_NONBLOCK / O_NDELAY |
以非阻塞方式打开(用于设备、管道等) |
O_SYNC |
写入操作等待数据和元数据都落盘后才返回 |
O_DIRECTORY |
若路径不是目录则打开失败 |
传参时,将需要功能对应的标志进行组合按位或 | ,就能得到一个整数,将这个整数作为flag传入函数内部。
使用时,在内部将flag与每个标志分别进行按位与 &,如果结果不为0,说明flags包含了这个标志。
这种做法叫做位图。
mode,权限码,这在之前权限的部分讲过,通过权限码来给创建的文件设置权限。只有在创建文件的时候才可以带上这个权限码。设置权限的时候还要经过系统的权限掩码umask计算。
返回值 ,打开文件的文件描述符。**其中0,1,2是默认的文件描述符,0表示标准输入stdin、1表示标准输出stdout、2表示标准错误stderr。**而FILE类型是C语言提供的一个结构体,它封装了文件描述符。
示例:
cpp
int fd = open("log.txt", O_CREAT | O_WRONLY, 0666);
这个权限掩码可以程序里面设置
cpp
umask(0);
这段代码把umask掩码设置为了0,这里设置的掩码不会影响到系统内部的掩码,程序根据就近原则使用这里新设置的掩码。
当open函数成功之后,返回一个文件描述符fd;失败就返回-1,并且设置一个错误码。
write函数
写操作
头文件和语法:
cpp
#include <unistd.h>
ssize_t write (int fd, const void *buf, size_t count);
fd,open文件的时候返回的文件描述符。
buf, 写入的内容。因为类型是void,所以什么类型的数据都可以往里面写,都已二进制写入。所以写入字符时,因为字符的二进制数据可以被Vim解析,所以可以看到有意义的字符串,可是如果是其他类型的数据,写入之后Vim无法解析,看到的就是一堆乱码。
count,将要写入内容的大小。
返回值,实际写入内容的大小。错误时返回-1,并设置错误码。
示例:
cpp
const char* msg = "hello world\n";
write (fd, msg, strlen(msg));
这里计算长度不需要把 \0 算进去,因为 \0 是C语言的语法规定,不是文件系统的,所以不用操心 \0 ,直接写入内容即可。
写操作的效果会受open获得的标志影响,若带上O_TRUNC,每次写都会从文件开头写,会覆盖原来的内容;若带上O_APPEND,就从文件末尾写,不会覆盖原来的内容。
read函数
读操作
头文件与语法:
cpp
#include<unistd.h>
ssize_t read(int fd, void *buf, size_t count);
fd,open文件的时候返回的文件描述符。
buf, 接收读取内容的字符串。因为类型是void,所以什么类型的数据都可以往里面写,都已二进制写入。所以写入字符时,因为字符的二进制数据可以被Vim解析,所以可以看到有意义的字符串,可是如果是其他类型的数据,写入之后Vim无法解析,看到的就是一堆乱码。
count,将要读取内容的大小。
返回值,实际读取内容的大小。错误时返回-1,并设置错误码。
示例:
cpp
char buffer[64];
int n = read(fd, buffer, sizeof(buffer) -1);
close函数
关闭文件
cpp
close(fd);
关闭对应文件描述符fd的文件。
系统角度下的文件
文件描述符的本质
同PCB,当进程打开文件,就会创建一个结构体struct file,里面存放了文件的属性(权限、读写位置、读写选项、操作方法、缓冲区指针······)。
内部有一个指针指向文件缓冲区。文件的内容放到缓冲区中供程序访问(缓冲区后面讲)。
所有打开的文件就以链表的方式组织起来,对文件的操作就转变为对这个链表的操作了。
PCB内部有一个指针指向一个指针数组,叫作文件操作符表,存放的指针类型是struct file*,存放的指针指向该进程打开的文件,从而标识文件都由哪个进程打开。

所以文件描述符本质是一个数组下标。
文件描述符的分配
从未被分配的最小文件描述符开始分配。如果你把默认描述符0,1,2一开始就关闭了,那你再打开新的文件,被分配的文件描述符就从0,1,2开始。
案例:
我们把fd==1给关闭,就是关闭了标准输出stdout。然后打开一个文件,这个文件的描述符就是1。当我们再使用printf的时候,打印的内容是打印到文件描述符1的文件中。所以此时显示器上不会打印内容,原本要打印的内容就打印到了文件里。
重定向原理
上文的"案例"就是一种重定向操作。重定向就是改变文件描述符表的指针指向。
shell中重定向
输出重定向:将fd(1)重定向到指定文件
> ------ 覆盖输出到文件
cpp
ls - l -a > log.txt
ls命令输出内容重定向到log.txt中。文件不存在就创建,存在就清空。
>>------ 追加输出到文件
cpp
ls - l -a >> log.txt
与 > 类似,但是不会清空覆盖文件,而是追加方式输出到文件。
输入重定向:将fd(0)重定向到指定文件
< ------ 从文件中读取
bash
wc -l < data.txt
代码中重定向
重定向核心函数:
cpp
#include<unistd.h>
int dup2(int oldfd, int newfd);
成功,返回新的文件描述符;失败,返回 -1,并设置错误码。
作用是把oldfd覆盖到newfd上,newfd就作为oldfd的一份拷贝。此时newfd和oldfd相等,指向相同的文件。
理解一切皆文件
这有明显的好处:开发者仅需要一套API和开发工作,即可调取Linux系统汇总绝大部分的资源。
· 除了常见的文件,如C语言文件,图片,txt文档,可执行文件的等,设备也会被当成文件处理。
· 在底层,不同的设备有不同的属性,底层会为不同的设备创建一个结构体device存放基本信息(设备种类、设备状态、其他属性)。不同的设备还有不同的读方法和写方法,但他们的函数指针类型命名,参数,都是一样的。
· 通过虚拟文件系统VFS,创建一个struct file(底层的文件结构体),该结构体内存放了读方法函数指针和写方法函数指针。他们指向设备device的读写方法,通过同一个函数指针实现不同设备的不同调用方法。
· 进程访问设备时,就访问对应的file,file中有标识会标识设备文件。
· 读写时通过函数指针调用不同设备不同的读写方法。
· 在C++中是通过父子类继承和多态来实现的。
总而言之,步骤如下:
进程调用读写函数
>>>> VFS中找到设备文件
>>>> 识别为设备文件并提取设备号
>>>> 更具设备号找到驱动
>>>> 通过驱动找到读写操作函数
>>>> 使用硬件设备完成操作
缓冲区
什么是缓冲区
是内存的一部分,用来缓冲输入或输出的数据,这部分预留的空间叫作缓冲区。
根据对应的输入或输出设备,分为输入缓冲区和输出缓冲区。
该图展示的是缓冲区的简单框架。

我们常说的缓冲区,其实是用户级语言层缓冲区。
这个缓冲区在FILE结构体中,就是fopen函数的返回值。当我们打开一个文件,C语言库会malloc一块空间,作为用户级缓冲区。
在把用户级缓冲区的数据拷贝给OS之后,会有更复杂的操作,这里不多赘述。我们可以认为,当我们把数据拷贝给OS相当于拷贝给了硬件,拷过去之后就让OS自行完成剩下操作即可。
缓冲区刷新策略
即上图所说的"刷新条件"
①立即刷新 ------ 无缓冲 ------ 写透模式 WT(无条件刷新)
②满了刷新 ------ 全缓冲
③满行刷新 ------ 行刷新
内核缓冲区的刷新策略更复杂,这里就不多说。
缓冲区的作用
核心目的:提高效率
若用写透模式WT,就要多次调用系统调用函数,而调用系统调用是要消耗系统资源的。
用户级语言层缓冲区和文件内核缓冲区的关系,就像是家里的垃圾桶和小区楼下的垃圾桶一样。每次产生垃圾都跑下楼扔,比先扔家里的垃圾桶,满了再下楼要费劲更多。
全缓冲比行缓冲要更有效率,行缓冲比全缓冲更有实时性。所以一般情况下,普通文件使用的是全缓冲,显示器使用的是行刷新。
❤~~本文完结!!感谢观看!!接下来更精彩!!欢迎来我博客做客~~❤