1、操作系统要干啥?哪些操作/任务不属于操作系统的管理范畴?

2、工具使用

阅读源码工具:understannd

源码文档自动生成工具:doxygen

虚拟运行环境:qemu

Lab0

1、了解汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
int count = 1;
int value = 1;
int buf[10];

void main(){
asm(
"cld \n\t"
"rep \n\t"
"stosl"
:
: "c" (count), "a"(value), "D" (buf[0])
:
);
}

/*
这段 C 代码给出了一个简单的函数,其中包括了一段内联汇编。
该代码试图展示如何使用 `rep stos` 指令来填充一个缓冲区,但存在一些问题和错误。
我会逐步解释代码,然后提出建议的更正。

1. 变量定义:
```c
int count = 1;
int value = 1;
int buf[10];
```
你定义了三个变量:一个 `count` 用于记录要重复的次数,
一个 `value` 用于指定要存储的值,
以及一个 `buf` 数组用于存储数据。

2. 主函数和内联汇编:
```c
void main(){
asm(
"cld \n\t"
"rep \n\t"
"stsol"
:
: "c" (count), "a"(value), "D" (buf[0])
);
}
```

- `cld`: 清除方向标志(Direction Flag)。这确保了字符串操作(如 `stos`)从低地址向高地址操作。
- `rep stos`: 这应该是一个组合的指令,意思是"重复存储"。
你试图使用这个指令将 `value` 的内容存储到 `buf` 指定的内存位置,
并根据 `count` 的值重复该操作。但这里有个小错误: 你写的是 `"stsol"`,它是不正确的。应该是 `"stosl"`。
- 输出约束(Output operands): 这里没有列出输出操作数,所以你只有一个冒号。
- 输入约束(Input operands): `"c" (count), "a"(value), "D" (buf[0])`,这里:
* `"c" (count)`:把 `count` 放入 `ECX` 寄存器(这是 `rep` 指令使用的计数寄存器)。
* `"a"(value)`:把 `value` 放入 `EAX` 寄存器(这是 `stosl` 指令使用的源数据寄存器)。
* `"D" (buf[0])`:把 `buf` 的首地址放入 `EDI` 寄存器(这是 `stosl` 指令使用的目标地址寄存器)。
但这样写是不准确的,应该是 `"D" (buf)`。

修正后的代码如下:

```c
int count = 1;
int value = 1;
int buf[10];

void main(){
asm(
"cld \n\t"
"rep \n\t"
"stosl"
:
: "c" (count), "a"(value), "D" (buf)
);
}
```

注意:使用内联汇编通常是平台和编译器特定的。
上述代码假定你正在使用 GCC 或其它支持 GNU 汇编语法的编译器,并且在一个 x86 架构的机器上运行。
*/

/*
在 GCC 的内联汇编中,第三个冒号后面的部分是用于指定被汇编代码修改的寄存器列表的,也叫做“Clobber List”。

以下是 `asm` 的基本格式:

asm ( assembly code
: output operands // optional
: input operands // optional
: list of clobbered registers // optional
);


1. Assembly Code: 包含汇编指令的字符串。
2. Output Operands: 输出操作数列表。
3. Input Operands: 输入操作数列表。
4. Clobber List: 表示汇编指令可能会更改的寄存器列表,
这告诉编译器这些寄存器的值可能会被修改,所以不要依赖它们的原始值。

在你的例子中,你使用了 `"c" (count), "a"(value), "D" (buf[0])` 为汇编指令提供输入操作数,
但你并没有指定输出操作数,也没有列出任何可能被修改的寄存器。

虽然在此例中,你知道 `ECX`, `EAX`, 和 `EDI` 寄存器会被修改,
但在许多情况下,列出clobbered registers是很有必要的,因为它可以帮助编译器更好地优化生成的代码。

所以,理论上,你的代码应该像这样:

int count = 1;
int value = 1;
int buf[10];

void main(){
asm(
"cld \n\t"
"rep \n\t"
"stosl"
:
: "c" (count), "a"(value), "D" (buf)
: "ecx", "eax", "edi"
);
}


这里,`"ecx", "eax", "edi"` 是clobber list,告诉编译器这些寄存器的值已经被汇编代码修改了。
*/

3、掌握指针和类型转换相关的C编程

实验源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>

#define STS_IG32 0xE
#define STS_TG32 0xF

typedef unsigned uint32_t;

#define SETGATE(gate, istrap, sel, off, dpl){\
(gate).gd_off_15_0 = (uint32_t)(off) & 0xffff;\
(gate).gd_ss = (sel);\
(gate).gd_args = 0;\
(gate).gd_rsv1 = 0;\
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;\
(gate).gd_s = 0;\
(gate).gd_dpl = (dpl);\
(gate).gd_p = 1;\
(gate).gd_off_31_16 = (uint32_t)(off) >> 16;\
}

struct gatedesc{
unsigned gd_off_15_0: 16;
unsigned gd_ss: 16;
unsigned gd_args: 5;
unsigned gd_rsv1: 3;
unsigned gd_type: 4;
unsigned gd_s: 1;
unsigned gd_dpl: 2;
unsigned gd_p: 1;
unsigned gd_off_31_16: 16;
};

int main(void){
unsigned before;
unsigned intr;
unsigned after;
struct gatedesc gintr;

intr = 8;
before = after = 0;
gintr = *((struct gatedesc*)&intr);
SETGATE(gintr, 0, 1, 2, 3);
intr = *((unsigned*)&(gintr));
printf("intr is 0x%x\n", intr);
printf("gintr is 0x%llx\n", gintr);
return 0;
}

自己根据输出即可推出来是怎么做的。注意,gintr等于0xee0000010002中,最高16位全部等于0没有展示出来。而%x只能展示32位,所以intr的输出结果为:0x10002

一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

硬盘主引导扇区(Master Boot Record,MBR)是位于硬盘第一个扇区(通常是逻辑地址0号扇区)的512字节的数据结构,用于引导计算机操作系统。一个符合规范的硬盘主引导扇区应该包含以下几个特征:

  1. 引导代码(Boot Code): 前446个字节用于存储引导代码,这是引导加载程序(Boot Loader)的代码,负责加载操作系统。这段代码必须是有效的汇编代码,能够启动计算机。

  2. 分区表(Partition Table): 接下来的64字节用于存储分区表,每个分区表项占16字节。一个硬盘可以分为最多4个主分区,每个分区表项描述一个分区的起始位置、大小和分区类型等信息。

  3. 签名字节(Signature): 最后的两个字节(0x55AA)是MBR的签名,标志这个扇区是有效的MBR扇区。这个签名是个小端字节序的16位值,它告诉操作系统这个扇区包含了引导信息,是一个有效的MBR。

总结起来,一个被系统认为是符合规范的硬盘主引导扇区应该包含引导代码、分区表和签名字节。这些特征是为了确保引导加载程序可以正确地读取分区信息,从而启动操作系统。如果这些特征中的任何一个缺失或损坏,可能导致系统无法正确引导。

引导扇区代码(tools/sign.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>
int main(int argc, char *argv[]) {
struct stat st;
if(argc != 3) {
fprintf(stderr, "Usage: <input filename> <output filename>\n");
return -1;
}
if (stat(argv[1], &st) != 0) {
fprintf(stderr, "Error opening file '%s': %s\n", argv[1], strerror(errno));
return-1;
}
printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
if (st.st_size > 510) {
fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
return-1;
}
char buf[512];
memset(buf, 0, sizeof(buf));
FILE*ifp=fopen(argv[1], "rb");
int size = fread(buf, 1,st.st_size, ifp);
if (size != st.st_size) {
fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
return-1;
}
fclose(ifp);
buf[510]= 0x55;
buf[511] =0xAA;
FILE*ofp = fopen(argv[2], "wb+");
size = fwrite(buf, 1, 512, ofp);
if (size != 512) {
fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
return -1;
}
fclose(ofp);
printf("build 512 bytes boot sector: '%s' success!\n", argv[2]);
return 0;
}

/*
注释
这是一个简单的C程序,用于生成一个包含引导代码的512字节的引导扇区(Boot Sector)。这样的引导扇区通常用于创建可引导的存储介质,例如硬盘、U盘等。以下是程序的主要功能和流程:

1. 使用 `stat` 函数获取指定输入文件(`argv[1]`)的信息,主要是文件大小。

2. 打印输入文件的大小。

3. 检查文件大小是否超过了510字节。如果超过,打印错误信息并退出程序。

4. 读取输入文件的内容(最多510字节),将内容存储在一个大小为512字节的缓冲区中。

5. 在缓冲区的倒数第二个字节(第510字节)写入0x55,倒数第一个字节(第511字节)写入0xAA。这是引导扇区的标志,用于告诉计算机系统这是一个有效的引导扇区。

6. 打开输出文件(`argv[2]`),将修改后的缓冲区写入文件。

7. 打印成功信息并返回0。

这个程序的目的是创建一个符合标准的引导扇区,其中包含引导代码和标志字节。请注意,生成的引导扇区在实际使用时需要按照引导扇区的规范进行进一步配置,以确保它能够正确地引导计算机系统。

以下是程序的一些可能的改进和注意事项:

- 添加更多的错误检查,例如检查文件是否成功打开。
- 考虑处理更多的边界情况,例如文件是否为空。
- 考虑使用二进制文件读写模式("rb" 和 "wb+")而不是文本模式。
- 考虑使用 `perror` 函数来打印与 `fopen`、`fread`、`fwrite` 相关的错误信息。
- 考虑处理大端和小端字节序的问题,特别是当涉及到二进制文件时。
*/

这里因为做项目的需要,我先写下8.6节有关有限状态机的内容

这一节我们介绍逻辑单元内部的一种高效编程方法:有限状态机。有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。

这里画一个图直接直接展示一下CMake和Makefile的使用过程

1
g++ *.cpp -o app  //直接编译    

使用#注释

块注释#[[ ]]

camke_minimum_required:指定使用的cmake的最低版本

project:定义工程名称

add_executable:定义工程会生成一个可执行程序 add_executable(可执行程序名称 源文件名称(空格或者分号隔离源文件))

cmake CMakeLists.txt:文件所在路径

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.15)
project(test)
add_executable(app, add.cpp div.cpp mult.cpp main.cpp sub.cpp)

#如果CMakeLists.txt文件就在当前目录下,直接执行以下命令
cmake .
make
#就会出现app可执行文件

生成了很多其他文件,我们可以把这些临时文件放在一个文件夹里,一般为build文件夹。创建build文件后,cd进build文件,执行camke ..,则cmake执行后生成的文件都放在了build文件夹里。

set的使用

set是为了简化add_executable(app, add.cpp div.cpp mult.cpp main.cpp sub.cpp)的编写,要不然这样和直接编译感觉没啥区别。set给一个变量赋值都是字符串类型,这些文件的名字会作为一个字符串存在变量里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# set指令的语法是:
# [] 中的参数为可选项,如不需要可以不写
set(VAR [VALUE] [CACHE TYPE DOCSTRING [FORCE]])

# VAR:变量名
# VALUE:变量值

# 方式一:各个源文件之间使用空格间隔
set(SRC_LIST add.c div.c main.c mult.c sub.c)

# 方式二:各个源文件之间使用;间隔
set(SRC_LIST add.c;div.c;main.c;mult.c;sub.c)

add_executable(app ${SRC_LIST})

取变量值必须以这种方式。

1
2
# "$ + {变量名}"
${SRC_LIST}

还可以通过set设置使用C++的标准(C++11,C++17)。

1
2
# 正常编译选定c++标准
g++ *.cpp -std=c++11 -o app
1
2
3
4
5
6
7
8
9
10
11
# 使用camke指定c++标准,对应有一个宏叫做DCMAKE_Cxx_STANDARD
# 由两种方式指定C++标准
# 第一种:在CMakeLists.txt中通过set命令指定
set(CAMKE_CXX_STANDARD 11) # 等价于增加 -std=c++11
set(CAMKE_CXX_STANDARD 14)
set(CAMKE_CXX_STANDARD 17)

# 第二种:在执行camke命令的时候制定出这个宏的值
camke CMakeLists.txt文件路径 -DCMAKE_CXX_STANDARD=11
cmake CMakeLists.txt文件路径 -DCMAKE_CXX_STANDARD=14
cmake CMakeLists.txt文件路径 -DCMAKE_CXX_STANDARD=17

set还可以指定输出路径,建议使用绝对路径,如果这个路径中的子目录不存在,会自动生成,无需自己手动创建。

1
2
3
4
5
# 在CMake中指定可执行程序输出的路径,也对应一个宏,叫做EXECUTABLE_OUTPUT_PATH,它的值还是通过set命令设置
set(HOME /home/zcl/linux/soft)
set(EXECUTABLE_UOTPUT_PATH ${HOME}/bin)
# 第一行:定义一个变量用于存储一个绝对路径
# 第二行:将拼接好的路径值设置给EXECUTABLE_OUTPUT_PATH宏

搜索路径

我们可以看到我们上面所讲的使用set并没有解决要将所有源文件名字写出来的本质问题。为了解决这个问题,这里我们讲一下通过搜索某个目录下的文件来引入源文件的方法。CMake给我们提供了两种方法来搜索文件,aux_source_directory命令或者file命令。

方式一

在CMake中使用aux_source_directory命令可以查找某个路径下的所有源文件。

1
2
3
4
5
6
7
8
9
10
# 命令格式
aux_source_directory(<dir> <variable>)
# dir:要搜索的目录
# variable:将从dir目录下搜索到的源文件列表存储到该变量中
# 这里介绍一个宏,PROJECT_SOURCE_DIR宏代表的就是我们在camke命令后携带的那个路径。比如camke ..,PROJECT_SORCE_DIR宏代表的就是`..`
# 再介绍另外一个宏,CMAKE_CURRENT_SOURCE_DIR宏代表的就是当前CMakelists.txt文件所在的路径
# 下面进入实操,比如搜索当前CMakelists.txt文件所在目录下的src目录
aux_source_directory(${PROJECT_SOURCE_DIR}/src SRC_LIST)
add_executable(app ${SRC_LIST})

方式二

通过file命令来搜索出所有需要的源文件

1
2
3
4
5
6
7
8
9
10
# 命令格式
file(GLOB 变量名 要搜索的文件路径和文件类型)
file(GLOB_RECURSE 变量名 要搜索的文件路径和文件类型)
# GLOB:将指定目录下搜索到的满足条件的所有文件名生成一个列表,并将其存储到变量中
# GLOB_RECURSE:递归搜索指定目录,将搜索到的满足条件的文件名生成一个列表,并将其存储到变量中
# 比如:搜索当前目录的src目录下所有的源文件,并将其存储到变量中
file(GLOB MAIN_SRC ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp)
file(GLOB MAIN_HEAD ${CAMKE_CURRENT_SOURCE_DIR}/include/*.h)
# 注:关于要搜索的文件路径和类型可以加双引号,也可不加
file(GLOB MAIN_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")

搜索头文件路径

在编译项目源文件的时候,很多时候都需要将源文件对应的头文件路径指定出来,这样才能保证在编译过程中编译器能找到这些头文件。在CMake里搜索头文件的命令也很简单

1
2
3
4
include_directories(headpath)
# headpath就是头文件路径,这里建议写绝对路径
# 实操
include_directories(${PROJECT_SOURCE_DIR}/include)

通过CMake制作库文件

有些时候我们编写的源代码并不需要将他们编译生成可执行程序,而是生成一些静态库或动态库提供给第三方使用,下面来讲解在cmake中生成这两类库文件的方法。

制作静态库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 在cmake中,如果要制作静态库,需要使用的命令如下:
add_library(库名称 STATIC 源文件1 [源文件2] ...)
# 注:在Linux中,静态库名字分为三部分:lib+库名字+.a,此处只需要指定出库的名字就可以了,另外两部分在生成该文件的时候会自动填充。
# 如果是动态库,最终生成的库的名字就是:libXXX.so(Linux),libXXX.dll(Windows);
# 如果是静态库,最终生成的库的名字就是:libXXX.a(Linux),libXXX.lib(Windows);
# 下面有一个目录,需要将src目录中的源文件编译成静态库,然后再使用:
.
├── build
├── CMakeLists.txt
├── include # 头文件目录
│   └── head.h
├── main.cpp # 用于测试的源文件
└── src # 源文件目录
├── add.cpp
├── div.cpp
├── mult.cpp
└── sub.cpp
# 根据上面的目录结构,可以这样编写CMakeLists.txt文件:
cmake_minimum_required(VERSION 3.0)
project(CALC)
include_directories(${PROJECT_SOURCE_DIR}/include)
file(GLOB SRC_LIST "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
add_library(calc STATIC ${SRC_LIST})
# 注:STATIC对应的是静态库,SHARED对应的是动态库

制作动态库

1
2
3
4
5
cmake_minimum_required(VERSION 3.0)
project(CALC)
include_directories(${PROJECT_SOURCE_DIR}/include)
file(GLOB SRC_LIST "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
add_library(calc SHARED ${SRC_LIST})

指定输出路径

对于生成的库文件来说和可执行程序一样都可以指定输出路径。这里使用LIBRARY_OUTPUT_PATH宏,这个宏对应静态库文件和动态库文件都适用。

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.0)
project(CALC)
include_directories(${PROJECT_SOURCE_DIR}/include)
file(GLOB SRC_LIST "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
# 设置动态库/静态库生成路径
set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/lib)
# 生成动态库
#add_library(calc SHARED ${SRC_LIST})
# 生成静态库
add_library(calc STATIC ${SRC_LIST})

包含库文件

在编写程序的过程中,可能会用到一些系统提供的动态库或者自己制作出的动态库或者静态库文件,cmake中也为我们提供了相关的加载动态库的命令。

链接静态库

1
2
3
4
5
6
src
├── add.cpp
├── div.cpp
├── main.cpp
├── mult.cpp
└── sub.cpp

现在我们把上面src目录中的add.cpp、div.cpp、mult.cpp、sub.cpp编译成一个静态库文件libcalc.a。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 测试目录结构
$ tree
.
├── build
├── CMakeLists.txt
├── include
│   └── head.h
├── lib
│   └── libcalc.a # 制作出的静态库的名字
└── src
└── main.cpp

4 directories, 4 files

在cmake中,链接静态库的命令如下:

1
2
3
link_libraries(<static lib> [<static lib>...])
# 参数1:指定出要链接的静态库的名字,可以是全名 libxxx.a,也可以是掐头(lib)去尾(.a)之后的名字 xxx
# 参数2-N:要链接的其它静态库的名字

如果该静态库不是系统提供的(自己制作或者使用第三方提供的静态库)可能出现静态库找不到的情况,此时可以将静态库的路径也指定出来:

1
link_directories(<lib path>)

这样,修改之后的CMakeLists.txt文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
cmake_minimum_required(VERSION 3.0)
project(CALC)
# 搜索指定目录下源文件
file(GLOB SRC_LIST ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp)
# 包含头文件路径
include_directories(${PROJECT_SOURCE_DIR}/include)
# 包含静态库路径 添加了这行代码,就可以根据参数指定的路径找到这个静态库了。
link_directories(${PROJECT_SOURCE_DIR}/lib)
# 链接静态库
link_libraries(calc)
add_executable(app ${SRC_LIST})

链接动态库

target不知道这个符号是来自它链接的多个库中的哪一个库,它只知道有这么一个库。

FATAL_ERROR:CMake 错误, 终止所有处理过程(CMake在生成”message to display”这条消息之后就不在执行了,直接中断)

CMake在底层管理的时候会将子字符串通过分号隔开,但通过message打印变量值的时候,看不到这个分号。这个分号有助于cmake进行字符串删除操作。只能删除组成变量的子串。比如一开始SRC=“A123”,后来apeend了“456”, “789”,如果没有分号,你可能可以删除”345”,但有了分号后,就删除不了了,你只能删除组成SRC的完整的子串,比如”A123”, “456”等。

注意,存储列表长度的output variable依旧是一个字符串类型。

DAY1

使用指令创建数据库:CREATE DATABASE zcl_db01;

删除数据库指令:DROP DATABASE zcl_db01;

创建一个使用utf8字符集的zcl_db02数据库:CREATE DATABASE zcl_db02 CHARACTER SET utf8

创建一个使用utf8字符集,并带校对规则的zcl_db03数据库:CREATE DATABASE zcl_db03 CHARACTER SET utf8 COLLATE utf8_bin utf8_bin区分大小写,utf8_general_ci不区分大小写

1
2
3
4
5
6
7
8
#查看当前数据库服务器中的所有数据库
SHOW DATABASES

#查看前面创建的zcl_db01数据库的定义信息
SHOW CREATE DATABASE `zcl_db01`

#在创建数据库、表的时候,为了规避关键字,可以使用``解决
CREATE DATABASE `CREATE`
1
2
3
4
5
6
7
8
#备份数据库
mysqldump -u root -p -B zcl_db03 > e:\\tmp.sql (文件名)

#恢复数据库
source e:\\tmp.sql

#备份数据库的表
mysqldump -u root -p zcl_db03 t1 t2 (t1和t2是表名) > e:\\tmp1.sql

DAY2

创建表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 创建表
# create table table_name
# (
# filed1 datatype,
# filed2 datatype,
# filed3 datatype
# )character set 字符集 collate 校对规则 engine 存储引擎
# filed:指定列名 datatype:指定列类型(字段类型)
# character set:如不指定则为所在数据库字符集
# collate:如不指定则为所在数据库校对规则

# 实操
create table `user`(
id int,
`name` varchar(255),
`passward` varchar(255),
`birthday` date
)character set utf8 collate utf8_general_ci engine innodb;

Mysql常用数据类型

1
2
3
4
5
6
7
8
9
10
11
12
13
# Mysql列类型就是Mysql的数据类型
# 数值类型:
# 整型:tinyint(1个字节) smallint(2个字节) mediumint(3个字节) int(4个字节) bigint(8个字节)
# 小数类型:float(单精度 4个字节) double(双精度 8个字节) decimal[M,D](大小不确定,M代表长度,D代表小数位数)

# 文本类型(字符串类型)
# char(0-255) varchar(0~65535) text(0~2^16-1) longtext(0~2^32-1)

# 二进制数据类型
# blob[0~2^16-1] longblob[0~2^32-1]

# 日期类型
# date[日期 年月日] time[时间 时分秒] datetime[年月日 时分秒 YYYY-MM-DD HH:MM:SS] timestamp[时间戳]

在满足需求的情况下,尽量选择占用空间小的类型

1
2
3
4
5
6
7
8
9
CREATE TABLE t3(
id TINYINT
);
# INSERT INTO t3 VALUES(128); 错误:越界了,超过了tinyint范围。
INSERT INTO t3 VALUES(127);
# 定义无符号的整数 指定unsigned
CREATE TABLE t4(
id TINYINT UNSIGNED
);
1
2
3
4
5
6
7
8
# BIT(M), M在1-64。显示按照bit。
CREATE TABLE t5(
num BIT(8)
);
INSERT INTO t5 VALUES(255);
SELECT * FROM t5;
# 输出:11111111
SELECT * FROM t5 WHERE num = 255;
1
2
3
4
5
6
7
8
9
# DECIMAL[M, D] 
# M是小数位数(精度)的总数,D是小数点(标度)后面的位数。
# 如果D是0,则值没有小数点或分数部分,M最大是65,D最大是30。如果D被省略,则默认是0,如果M被省略,默认是10。
CREATE TABLE t6(
num1 FLOAT,
num2 DOUBLE,
num3 DECIMAL(30, 20)
);
INSERT INTO t6 VALUES(88.123456789123456, 88.123456789123456, 88.123456789123456)
1
2
3
4
5
6
7
8
9
# char(4) 这个4表示字符数(最大255),不是字节数,不管是中文还是英文都是放四个,按字符计算,定长,即使你插入'aa',也会占用分配的4个字符的空间
# varchar(4) 这个4表示字符数,是变长,如果你插入'aa',实际占用空间并不是4个字符,而是实际占用的空间。
# utf-8:一个字符占三个字节
CREATE TABLE t11(
'name' CHAR(4)
);
# INSERT INTO t11 VALUES('ABCDE') 报错,太长
INSERT INTO t11 VALUES('ABCD')
INSERT INTO t11 VALUES('你好')
1
2
3
4
5
6
7
# 日期类型 date datetime timestamp
CREATE TABLE birthday11(
birthday DATE,
worktime DATETIME,
login_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
INSERT INTO bithday11(birthday, worktime) VALUES('2022-11-11', '2022-11-11 10:10:10');

创建表的课堂练习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
CREATE TABLE 'emp'(
id INT,
'name' VARCHAR(32),
sex CHAR(1),
birthday DATE,
entry_date DATETIME,
job VARCHAR(32),
saraly DOUBLE,
'resume' TEXT
) CHARSET utf8 COLLATE utf8_bin ENGINE INNODB;
INSERT INTO 'emp' VALUES(
100, '小妖怪', '男', '2000-11-11', '巡山的', 3000, '大王叫我来巡山'
);
SELECT * FROM 'emp';

# 增加列
ALTER TABLE emp ADD image VARCHAR(32) NOT NULL DEFAULT '' AFTER RESUME

# 显示表结构
DESC emp

# 修改job长度
ALTER TABLE emp MODIFY job VARCHAR(60) NOT NULL DEFAULT ''

# 删除列
ALTER TABLE emp DROP sex

# 修改表名
RENAME TABLE emp TO employee

# 修改表的字符集
ALTER TABLE employee CHARACTER SET utf8

# 修改列名
ALTER TABLE employee CHANGE 'name' user_name VARCHAR(64) NOT NULL DEFAULT ''

数据库CRUD语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 使用INSERT语句向表中插入数据
CREATE TABLE 'goods'(
id INT,
goods_name VARCHAR(10),
price DOUBLE
);
INSERT INTO 'goods' (id, goods_name, price) VALUES (10, '华为手机', 2000);
INSERT INTO 'goods' (id, goods_name, price) VALUES (20, '苹果手机', 20000);

# INSERT语句的细节
-- 插入的数据应与字段的数据类型相同,比如把'abc'添加到int类型会错误。
-- 数据的长度应在列的规定范围内,不能将一个长度为80的字符串加入到长度为40的列中。
-- 在VALUES中列出的数据位置必须与被加入的列的排列位置相对应。
-- 字符和日期类型应包含在单引号中
-- 列可以插入空值,前提是该字段允许为空(没有NOT NULL)
-- INSERT INTO TABLE_NAME (列名) VALUES (),(),(),添加多条数据一次性
-- 如果是给表中的所有字段添加数据,(id, goods_name, price)这个可以省略
-- 默认值的使用,当不给某个字段赋值时,如果有默认值就会添加,否则报错。
-- 如果我们希望指定某个列的默认值,可以在创建表时指定。
CREATE TABLE 'goods2'(
id INT,
goods_name VARCHAR(10),
price DOUBLE NOT NULL DEFAULT 100
);
1
2
3
4
5
6
7
8
9
10
-- 演示update语句
-- employee是表名,salary是列名
-- 将所有员工薪水修改为5000,如果没有带where条件,会修改所有的记录,因此要小心
UPDATE employee SET salary = 5000
-- 将姓名为小妖怪的员工薪水修改为3000
UPDATE employee SET salary = 3000 WHERE user_name = '小妖怪'
-- 将老妖怪的薪水在原有基础上增加1000
INSERT INTO employee VALUES(200, '老妖怪', '1990-11-11', '2000-11-11 10:10:10', '捶背的', 5000, '给大王捶背的');
UPDATE employee SET salary = salary + 1000 WHERE user_name = '老妖怪'
UPDATE employee SET salary = salary + 1000, job = '出主意的' WHERE user_name = '老妖怪'
1
2
3
4
5
6
7
8
9
-- 演示delete语句
-- 删除表中名称为‘老妖怪’的记录
DELETE FROM employee WHERE user_name = '老妖怪';
-- 删除表中所有记录
DELETE FROM employee
-- DELETE删除不了一列的值,但可以使用UPDATE语句置为null
UPDATE employee SET job = NULL WHERE user_name = '老妖怪';
-- 要删除这个表
DROP TABLE employee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
-- select指定查询哪些列的数据
-- *代表查询所有列
-- from指定查询哪张表
-- DISTINCT可选,指显示结果时,是否去掉重复数据
CREATE TABLE student(
id INT NOT NULL DEFAULT 1,
NAME VARCHAR(20) NOT NULL DEFAULT '',
chinese FLOAT NOT NULL DEFAULT 0.0,
english FLOAT NOT NULL DEFAULT 0.0,
math FLOAT NOT NULL DEFAULT 0.0
);

INSERT INTO student(id, NAME, chinese, english, math) VALUES(1, '韩顺平', 89, 78, 90);
INSERT INTO student(id, NAME, chinese, english, math) VALUES(2, '张飞', 67, 98, 56);
INSERT INTO student(id, NAME, chinese, english, math) VALUES(3, '宋江', 87, 78, 77);
INSERT INTO student(id, NAME, chinese, english, math) VALUES(4, '关羽', 88, 98, 90);
INSERT INTO student(id, NAME, chinese, english, math) VALUES(5, '赵云', 82, 84, 67);
INSERT INTO student(id, NAME, chinese, english, math) VALUES(6, '欧阳锋', 55, 85, 45);
INSERT INTO student(id, NAME, chinese, english, math) VALUES(7, '黄蓉', 75, 65, 30);

SELECT * FROM student;

-- 查寻表中所有学生的信息
SELECT * FROM student;

-- 查询表中所有学生的姓名和对应的英语成绩
SELECT 'NAME', english FROM student;

-- 过滤表中重复数据distinct
-- 要查询的记录每个字段都相同,才会去重
SELECT DISTINCT english FROM student;

-- 统计每个学生的总分
SELECT 'NAME', (chinese + english + math) FROM student;

-- 在所有学生总分加10分
SELECT 'NAME', (chinese + english + math + 10) FROM student;

-- 使用别名表示学生分数
SELECT 'NAME', (chinese + english + math + 10) AS total_score FROM student;
SELECT 'NAME' AS '名字', (chinese + english + math + 10) AS total_score FROM student;

-- 查询姓名为赵云的学生成绩
SELECT * FROM student WHERE 'NAME' = '赵云';

-- 查询英语成绩大于90分的同学
SELECT * FROM student WHERE english > 90;

-- 查询总分大于200分的所有同学
SELECT * FROM student WHERE (chinese + english + math) > 200;

-- 查询math大于60并且id大于4的学生成绩
SELECT * FROM student WHERE math > 60 AND id > 4;

-- 查询英语成绩大于语文成绩的同学
SELECT * FROM student WHERE english > chinese;

-- 查询总分大于200分并且数学成绩小于语文成绩的姓韩的学生
-- 韩% 表示名字以韩开头的就可以
SELECT * FROM student WHERE (chinese + english + math) > 200 AND
math < chinese AND 'NAME' LIKE '韩%';

-- 查询英语分数在80-90之间的同学
SELECT * FROM student WHERE english >= 80 AND english <= 90;
SELECT * FROM student WHERE english BETWWWN 80 AND 90; -- BETWEEN AND 是闭区间

-- 查询数学分数为89,90,91的同学
SELECT * FROM student WHERE math = 89 OR math = 90 OR math = 91;
SELECT * FROM student WHERE math IN (89, 90, 91);

5.1.1 判断主机是小端字节序还是大端字节序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>

void byteOrder(){
union
{
short value;
char union_bytes[sizeof(short)];
}test;

test.value = 0x0102;
if(test.union_bytes[0] == 1 && test.union_bytes[1] == 2){
printf("大端字节序\n");
}
else if(test.union_bytes[0] == 2 && test.union_bytes[1] == 1){
printf("小端字节序\n");
}
else{
printf("不清楚\n");
}
}

int main(){
byteOrder();
return 0;
}

5.1.3专用socket地址

TCP/IP协议族有sockaddr_insockaddr_in6两个专用socket地址结构体,他们分别用于IPv4IPv6,这里我只介绍sockaddr_in

1
2
3
4
5
6
7
8
9
struct sockaddr_in{
sa_family_t sin_family; // 地址族:AF_INET
u_int16_t sin_port; // 端口号:要用网络字节序表示
struct in_addr sin_addr; // IPv4地址结构体
}

struct in_addr{
u_int32_t s_addr; // 要用网络字节序表示
}

注意:所有专用socket地址类型的变量在实际使用时都需要转换为通用socket地址类型sockaddr(强制转换即可),因为所有socket编程接口使用的地址参数的类型都是sockaddr

5.1.4IP地址转换函数

1
2
3
4
5
6
7
8
9
10
11
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst); // 用于将字符串表示的IP地址转换为用网络字节序整数表示的IP地址
const char *inet_ntop(int af, const void *src, char *dst, socklen_t cnt); // 最后一个参数指定目标存储单元的大小,这两个宏可以帮我们指定
/*
inet_pton成功时返回1,失败时返回0并设置errno
inet_ntop成功时返回目标存储单元的地址,失败返回NULL并设置errno
*/

#include <netinet/in.h>
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 64

5.2创建socket

1
2
3
4
5
6
7
8
9
#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol)

/*
domain一般为PF_INET(IPv4),或者PF_INET6(IPv6)
type参数指定服务类型,主要有SOCK_STREAM和SOCK_UGRAM
*/

socket系统调用成功时返回一个socket文件描述符,失败返回-1并设置errno

5.3命名socket

创建socket时,我们给它指定了地址族,但是并未指定使用该地址族中的哪个具体socket地址。将一个socket与socket地址绑定称为给socket命名。在服务器程序中,我们通常需要命名socket,因为只有命名后客户端才知道该如何连接它。客户端通常不需要命名socket,而是采用匿名方式,也就是使用操作系统自动分配的socket地址。

1
2
3
4
5
6
7
8
9
#include <sys/types.h>
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);

/*
bind将my_addr所指的socket地址分配给未命名的socket文件描述符,addrlen参数指出该socket地址的长度
bind成功时返回0,失败时返回-1并设置errno,其中两种常见的errno是:EACCES和EADDRINUSE。
*/

5.4监听socket

socket被命名之后,还不能立即接收客户端连接,我们需要使用如下系统调用来创建一个监听队列以存放待处理的客户连接

1
2
3
4
5
6
7
#include <sys/socket.h>
int listen(int sockfd, int backlog);

/*
sockfd参数指定被监听的socket,backlog参数提示内核监听队列的最大长度。监听队列的长度如果超过backlog,服务器将不受理新的客户端连接,客户端也将收到ECONNREFUSED错误信息。(实际上最多可以接收backlog + 1个客户端连接)
listen成功时返回0,失败时返回-1并设置errno。
*/

下面我们编写一个程序测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
#include <stdio.h>
#include <string.h>

/*
basename使用示例:
`basename()` 是一个常用于处理文件路径和程序参数的库函数。
它的主要目的是从一个给定的路径中提取基础名(base name),也就是最后一部分的名称,不包含任何前导的目录。

来自 `<libgen.h>`(或在某些系统中是 `<string.h>` 或 `<strings.h>`),`basename()` 函数的原型如下:

char *basename(char *path);

让我们看一些使用 `basename()` 的例子:

1. 输入 `/home/user/documents/file.txt` 返回 `file.txt`
2. 输入 `/home/user/documents/folder/` 返回 `folder`
3. 输入 `/home/user/documents/` 返回 `documents`
4. 输入 `file.txt` 返回 `file.txt`

在给出的代码示例中:

printf("usage: %s ip_address port_num backlog\n", basename(argv[0]));

`argv[0]` 通常是程序的名称,包括它被执行时的完整路径。
使用 `basename()` 函数,你可以仅提取程序的实际名称,而不包括其路径,这在显示帮助或错误消息时特别有用。

举个例子,假设程序的完整路径是 `/home/user/my_program`,那么 `basename(argv[0])` 就会返回 `my_program`。这样,上述的 `printf` 语句将输出:

usage: my_program ip_address port_num backlog

需要注意的是,`basename()` 函数可能会修改其参数,也可能返回一个指向静态存储区的指针。
因此,如果原始路径字符串不应被修改,那么在调用 `basename()` 之前,最好先复制这个字符串。
*/

static bool stop = false;

static void term_handler(int sig){
stop = true;
}

int main(int argc, char *argv[]){
signal(SIGTERM, term_handler);

if(argc <= 3){
printf("usage: %s ip_address port_num backlog\n", basename(argv[0]));
return 1;
}

char *ip = argv[1];
int port = atoi(argv[2]);

int sockfd = socket(PF_INET, SOCK_STREAM, 0);
assert(sockfd != -1);

int sign = 0;

struct sockaddr_in server_addr;
server_addr.sin_family = PF_INET;
server_addr.sin_port = htons(port);
inet_pton(PF_INET, ip, &server_addr.sin_addr);

sign = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
assert(sign != -1);

sign = listen(sockfd, 5);
assert(sign != -1);

while(!stop){
sleep(1);
}

close(sockfd);
return 0;
}

/*
结果忘记截图了,大家可以自己试一下,具体步骤
./test 172.30.78.145 8000 5
另开终端多次 telnet 172.30.78.145 8000
netstat -nt | grep 8000
*/

这个命令组合使用了两个命令:netstatgrep,并通过管道(|)将第一个命令的输出作为第二个命令的输入。我会为你逐步解释它:

  1. netstat -nt:

    • netstat: 这是一个命令行工具,用于显示网络状态,包括网络连接、路由表、接口统计等。
    • -n: 表示以数字形式显示地址和端口号,而不是尝试解析它们的名称。
  • -t: 仅显示TCP连接。

因此,netstat -nt 的输出会列出系统上所有活动的TCP连接,同时显示它们的源和目标IP地址以及端口号,并直接显示数字而不进行名称解析。

  1. |:
  • 这是一个管道操作符,用于将前一个命令的输出作为后一个命令的输入。
  1. grep 8000:
    • grep: 是一个强大的文本搜索工具,用于搜索匹配的字符串。
    • 8000: 是你想在 netstat 的输出中搜索的字符串。
    这个命令会从 netstat 的输出中筛选出所有包含 “8000” 的行,这通常意味着你正在查找与端口 8000 相关的所有活动连接。

综上所述,netstat -nt | grep 8000 会显示所有在端口 8000 上的活动TCP连接。

5.5接受连接

代码:接受一个异常的连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <stdio.h>
#include <libgen.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>

int main(int argc, char* argv[]){
if(argc <= 2){
printf("运行程序,需输入这三个参数:%s, ip_address, port_num\n", basename(argv[0]));
return 1;
}

const char* ip = argv[1];
int port = atoi(argv[2]);

struct sockaddr_in address;
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = ntohs(port);

int sock = socket(AF_INET, SOCK_STREAM, 0);
assert(sock >= 0);

int ret = bind(sock, (struct sockaddr*)&address, sizeof(address));
assert(ret != -1);

ret = listen(sock, 5);
assert(ret != -1);

sleep(20);

struct sockaddr_in client;
socklen_t client_len = sizeof(client);
int connfd = accept(sock, (struct sockaddr*)&client, &client_len);
if(connfd < 0){
printf("errno is: %d\n", errno);
}else{
char remote[INET_ADDRSTRLEN];
printf("connected with ip: %s and port: %d\n", inet_ntop(AF_INET, &client.sin_addr, remote, client_len),
ntohs(client.sin_port));
close(connfd);
}
close(sock);
return 0;
}

1、第一次运行报错,undefined reference to main,这种情况一般有三种可能:

  • 没有定义main函数
  • main函数的main拼写错误
  • 刚写的代码忘记保存了

2、accept函数是阻塞的,上述代码即服务器端运行的时候,会阻塞在accept处,一旦客户端请求建立连接,服务器立马终止程序。注意accept只是从listen监听队列中取出连接,它不会理会客户端处于什么状态。

3、一直在思考select/poll/epoll这些有什么用。首先因为listen是有监听队列的,劣势就在于只能一个个处理,并且同时接入的连接数有限。比如队列长度为5,处理完一个,再建立下一个连接,这样如果某一个连接处理很长时间一直阻塞在那里,就导致后面的新请求连接建立超时。很直观的想法是fork新进程或者创建新线程来处理新连接,每来一个连接我就创建一个来跟他对接。这样资源消耗太大。因此就有了select/poll/epoll,先把连接建立起来并放进文件描述符,最后从这里面寻找哪些发生了可读可写事件,也避免了因为读写事件造成的阻塞(没有数据到来就阻塞了)。

有关C++内存管理问题总结如下

基础问题

C++中堆和栈的区别是什么

  1. 栈由系统分配释放,栈上变量的生命周期是确定的,一般与作用域有关。栈的地址由高到低,栈上分配的空间大小在编译时通常已知。
  2. 堆由程序员手动开辟释放,堆上的变量除非被显示释放,否则会持续存在。堆的地址由低到高,堆上几乎可以分配任意大小的内存块,但可能会造成内存碎片。
  3. 这里说一下C++的内存分区:堆区、栈区、data区、bss段、代码段。数据data区存放的是静态变量和初始化的全局变量,bss段存放的是未初始化的全局变量。

什么是RAII?为什么它在C++中很重要

RAII是一种编程思想和设计模式,核心思想是:将资源的获取与对象的初始化捆绑在一起,将资源的释放与对象的销毁捆绑在一起。这样,资源管理就与对象的生命周期紧密关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <fstream>

class File {
private:
std::fstream fs;

public:
File(const std::string& filename) {
fs.open(filename, std::ios::in | std::ios::out);
if (!fs.is_open()) {
throw std::runtime_error("Failed to open the file");
}
}

// 其他与文件相关的操作...

~File() {
if (fs.is_open()) {
fs.close();
}
}
};

int main() {
try {
File myFile("sample.txt");
// 进行文件操作...

} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
}

// 当myFile对象离开其作用域时,它的析构函数会自动被调用,从而关闭文件
}

解释newdelete,与mallocfree的区别

  • 属性的区别

    new/delete:这两个是C++中的关键字;

    malloc/free:这两个是库函数;

  • 使用上的区别

    malloc:申请空间需要显式填入申请内存的大小;

    new:无需显式填入申请内存的大小,new会根据new的类型分配内存;

  • 返回类型的区别

    new操作符内存分配成功,返回的是对象类型的指针,类型严格与对象匹配,无需进行类型转换,故new是符合类型安全性的操作符。

    malloc内存分配成功返回的是void*指针,需要通过强制类型转换,转换成我们需要的类型。

    所以C++newmalloc安全可靠。

  • 分配失败的区别

    malloc分配失败会返回NULL,我们可以通过判断返回值是否是NULL得知是否分配成功。

    new分配失败会抛出bad_alloc异常。

  • 扩张内存的区别

    malloc有内存扩张机制(通过realloc实现)。

    new没有扩张内存机制。

中级问题

为什么C++推荐使用智能指针,如shared_ptrunique_ptr

  • 自动管理内存:对于unique_ptr,当它超出作用域或者被重新分配时,它指向的对象会被删除。对于shared_ptr,当它的引用计数为0时,它指向的对象会被删除。
  • 异常安全:当函数抛出异常,智能指针确保资源被正确清理,避免资源泄露。
  • 防止悬挂指针:悬挂指针是指指向已经释放内存的指针。unique_ptrshared_ptr可以减少悬挂指针的风险,因为他们确保在没有引用的时候释放资源。

你能解释shared_ptr中的引用计数机制是如何工作的吗

  • 通过一个指针实现引用计数功能,加锁,保证线程安全

什么情况下会导致内存泄漏,你如何检测和预防

  • 指针重新赋值

    1
    2
    3
    4
    int *p = new int();
    int *np = new int();
    p = np;
    //p原来的指向的内存无法释放,因为现在没有指针指向这块内存
  • 错误的内存释放

    假设有一个指针p指向10字节的内存,该内存的第三个字节np又指向某个动态分配的内存, 如果此时你直接delete(p),则会导致np指向的内存无法释放。

  • 返回值的不正确处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int *f(){
    return new int(42);
    }

    void f1(){
    f();
    }

    //由于没有对函数f()的返回值做正确接收,将会导致f函数分配的内存无法释放。

关于内存泄露可以使用工具:Valgrind

高级问题

描述C++的内存模型是什么?如何保证线程间的数据同步?

什么是内存屏障(memory barrier)或内存栅栏?在哪些场景中需要使用它?

你有没有用过定制的内存分配器,比如为某些高性能的应用场景?如果有,请描述其工作原理和使用场景。

实践问题

描述一个你曾遇到的复杂的内存相关bug,你是如何诊断和解决的?

你如何评估一个C++程序的内存使用效率?你使用过哪些工具或技术

请编写一个小程序,其中创建一个有资源泄漏的类,然后展示如何使用工具或方法找到并修复该泄漏。

话不多说,直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <mutex>

class Singleton{
public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;


//双重检查锁定模式
//这样做的目的是,如果实例已经被创建,可以避免每次加锁解锁的操作,提高性能
//但看文字可能不好理解,这里给出不是双重检查锁定模式就好理解了

/*
static Singleton& getInstance(){
std::lock_guard<std::mutex> lock(mutex_);
if(instance_ == nullptr){
instance_ = new Singleton();
}
return *instance_;
}
这种写法性能非常低下,因为每次调用instance()都会加锁释放锁,而这个步骤只有在第一次new Singleton()才是有必要的。
只要p被创建出来了,不管多少线程同时访问,使用if (p == nullptr) 进行判断都是足够的(只是读操作,不需要加锁),没有线程安全问题,
加了锁之后反而存在性能问题。
*/

static Singleton& getInstance(){
if(instance_ == nullptr){
std::lock_guard<std::mutex> lock(mutex_);
if(instance_ == nullptr){
instance_ = new Singleton();
}
}
return *instance_;
}
private:
Singleton() {}
~Singleton() {}

static std::mutex mutex_;
static Singleton *instance_;
};

std::mutex Singleton::mutex_;
Singleton* Singleton::instance_ = nullptr;

问题

构造析构为什么要私有

解答:

在单例模式中,构造函数和析构函数被设置为私有的原因是为了确保满足单例模式的核心要求:系统中某个类只能存在一个实例。

通过将构造函数和析构函数设为私有,我们可以确保以下几点:

  1. 外部无法实例化:由于构造函数是私有的,这意味着不能在类的外部直接创建该类的实例。这确保了实例的创建只能通过单例类提供的某些特定方法(如getInstance)来完成,从而控制实例的数量。

  2. 禁止复制:单例模式要确保只有一个实例存在,所以我们不希望该类的对象被复制。将构造函数设为私有可以防止复制构造,但为了进一步确保不被复制,我们通常还需要禁止拷贝构造函数和拷贝赋值操作符(通过= delete)。

  3. 外部无法销毁:将析构函数设为私有可以确保外部代码无法直接删除单例对象。通常,单例对象在程序结束时自动销毁,或者单例类提供了一个专门的方法来手动销毁它。

  4. 继承控制:由于构造函数和析构函数是私有的,这也意味着这个类不能被继承(因为派生类的构造函数需要调用基类的构造函数)。

综上所述,将构造函数和析构函数设为私有是为了确保满足单例模式的设计原则,即系统中该类只有一个实例,并提供对该实例的全局访问点。

本篇文章主要参考如下文章,主要是对代码做一个较为详尽的解释

参考链接:https://blog.csdn.net/qq_46495964/article/details/122952567

前言: 日志系统在程序运行中有着非常大的作用,用于记录程序的运行情况,在程序出错后查看日志,方便地定位出错的大概范围。在设计日志系统之前,先考虑一下日志需要输出什么信息呢?什么信息才是有用的信息,都知道写日志是一种对文件的io操作,所以尽可能避免输出没用的信息。 有用的信息:关键变量的值、运行的位置(哪个文件、哪个函数、哪一行)、时间、线程号、进程号等等。

日志系统的设计

  1. 日志的级别

    在测试、调试、交付等场景需要输出不同的级别日志。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //常见的日志级别
    enum LOGLEVEL
    {
    LOG_LEVEL_NONE,
    LOG_LEVEL_ERROR, // error
    LOG_LEVEL_WARNING, // warning
    LOG_LEVEL_DEBUG, // debug
    LOG_LEVEL_INFO, // info
    };
  2. 日志的输出地

    日志输出的地方可能不同,终端、控制台、UI界面、文件等等都有。

    1
    2
    3
    4
    5
    6
    enum LOGTARGET
    {
    LOG_TERM = 0x00,
    LOG_FILE = 0x01,
    LOG_UI = 0x10
    };
  3. 日志的作用域

    日志做到什么时候都可以输出,可作用于全程序文件,考虑到多线程情况下,必须保证日志的输出需要得到线程安全的保障,所以需要一个全局且唯一的日志器。使用设计模式中的单例模式—–日志器

C++版本的日志系统的实现

Logger.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
这是一个基本的线程安全日志系统的头文件,其目的是为应用程序提供日志功能。以下是对这个头文件的详细解释:

1. **预处理指令和宏**:
- `#ifndef`, `#define`, 和 `#endif` 用于保证头文件只被包含一次,避免重复定义。
- `#define` 宏定义了三个日志级别:`LogInfo`, `LogWarning`, 和 `LogError`。这些宏都使用了变参(`...`),允许用户为日志提供自定义的格式和参数。这些宏捕获了日志消息的来源(文件、行号和函数)并将其添加到日志队列中。

2. **类定义:Logger**
- 是一个单例类,这意味着整个程序中只能有一个实例。这通过私有的默认构造函数、删除的复制构造函数和赋值运算符来实现。
- `GetInstance()` 方法提供了对单例实例的访问。
- `SetFileName()` 允许设置日志文件的名称。
- `Start()` 和 `Stop()` 用于启动和停止日志线程。
- `AddToQueue()` 是用于将新的日志消息添加到日志队列的方法。
- `threadfunc()` 是日志线程的工作函数,它持续从日志队列中读取消息并写入文件。

3. **类的私有成员**:
- `filename_`: 存储日志文件的名称。
- `fp_`: 用于文件操作的文件指针。请注意,在所给代码中,`FILE` 的声明被注释掉了,你需要包含 `<cstdio>` 或者直接使用 C++ 的文件流类(如 `std::ofstream`)。
- `spthread_`: 一个智能指针,指向日志线程。
- `mutex_`: 用于保护日志队列和其他共享资源,确保多线程访问时的线程安全。
- `cv_`: 条件变量,用于通知日志线程有新的消息到来。
- `exit_`: 一个标志,指示日志线程何时退出。
- `queue_`: 存储待处理日志消息的队列。

4. **注释**:
- 提供了关于该文件的基本信息,如文件名称和日期。

总之,这个日志系统设计的思路是:
1. 用户调用预定义的宏添加日志消息。
2. 日志消息被添加到一个线程安全的队列。
3. 一个独立的日志线程从队列中取出消息并写入文件。

这种设计允许应用程序非阻塞地添加日志消息,而将I/O操作留给专门的线程,从而提高应用程序的性能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/** 
* 日志类头文件, Logger.h
* 2022.02.1
**/

#ifndef __LOGGER_H__
#define __LOGGER_H__

#include <string>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <list>

//struct FILE;

#define LogInfo(...) Logger::GetInstance().AddToQueue("INFO", __FILE__, __LINE__, __PRETTY_FUNCTION__, __VA_ARGS__)
#define LogWarning(...) Logger::GetInstance().AddToQueue("WARNING", __FILE__, __LINE__, __PRETTY_FUNCTION__, __VA_ARGS__)
#define LogError(...) Logger::GetInstance().AddToQueue("ERROR", __FILE__, __LINE__, __PRETTY_FUNCTION__, __VA_ARGS__)

class Logger
{
public:
static Logger& GetInstance();

void SetFileName(const char* filename);
bool Start();
void Stop();

void AddToQueue(const char* pszLevel, const char* pszFile, int lineNo, const char* pszFuncSig, char* pszFmt, ...);

private:
Logger() = default;
Logger(const Logger& rhs) = delete;
Logger& operator =(Logger& rhs) = delete;

void threadfunc();

private:
std::string filename_;
FILE* fp_{};
std::shared_ptr<std::thread> spthread_;
std::mutex mutex_;
std::condition_variable cv_; //有新的日志到来的标识
bool exit_{false};
std::list<std::string> queue_;
};

#endif //!__LOGGER_H__

Logger.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
这是日志类的实现文件`Logger.cpp`。它对`Logger.h`中定义的函数进行了具体的实现。我将分步解释这个文件的内容:

1. **#include语句**: 引入了所需的头文件,其中`Logger.h`是之前你展示的日志类的头文件,其他的是C和C++标准库中的头文件。

2. **GetInstance方法**: 实现了单例模式的核心功能。通过使用局部静态对象`logger`,它确保只创建一次`Logger`类的实例。

3. **SetFileName方法**: 允许用户设置日志文件的名称。

4. **Start方法**:
- 如果用户没有提供日志文件名,它会生成一个默认的日志文件名,该文件名包含当前的日期和时间。
- 尝试打开日志文件以写入和读取。
- 创建一个新线程来处理日志消息。

5. **Stop方法**:
- 设置`exit_`标志为`true`。
- 通过条件变量通知日志线程。
- 等待日志线程结束。

6. **AddToQueue方法**:
- 使用变长参数列表格式化日志消息。
- 生成一个包含日期、时间、日志级别、线程ID、源文件名、行号、函数签名和日志消息的完整日志字符串。
- 使用互斥锁将完整的日志字符串添加到队列。
- 通过条件变量通知日志线程有新的消息到来。

7. **threadfunc方法**:
- 日志线程的核心功能。
- 检查文件指针是否为空。
- 在`exit_`为`false`的情况下,循环处理日志消息。
- 使用互斥锁和条件变量等待新的消息到来。
- 当有新的消息时,从队列中取出并写入日志文件。
- 如果设置了退出标志并且队列为空,线程将退出。

总体上,这是一个简单但功能完整的日志系统实现。它使用了多线程,互斥锁和条件变量来确保线程安全,并使日志操作与应用程序的其他部分异步进行,从而提高了性能。

然而,也有一些可以进一步优化或改进的地方:
1. 在`Start()`中,文件是以"wt+"模式打开的,这意味着如果文件已经存在,它会被覆盖。如果希望在现有文件中追加日志,则应使用"at+"
2. 代码中没有明确的错误处理或异常处理策略。例如,如果`fopen`失败或`std::thread`创建失败时。
3. 在`threadfunc`中,当队列不为空时,每处理一个消息就会刷新文件,这可能影响性能。可以考虑在队列中有多个消息时,一次性写入多个消息,然后再刷新。
4. 可能会考虑添加一个`Rotate`功能,当日志文件大小超过某个限制时,自动将当前日志文件重命名,并创建一个新的日志文件。
5. 为了提高性能,可以考虑使用双缓冲技术,这样可以减少互斥锁的争用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
/**
* 日志类实现文件, Logger.cpp
* 2022.02.1
**/

#include "Logger.h"
#include <time.h>
#include <stdio.h>
#include <memory>
#include <stdarg.h>

Logger& Logger::GetInstance()
{
static Logger logger;
return logger;
}

void Logger::SetFileName(const char* filename)
{
filename_ = filename;
}

bool Logger::Start()
{
if (filename_.empty())
{
time_t now = time(NULL);
struct tm* t = localtime(&now);
char timestr[64] = { 0 };
sprintf(timestr, "%04d%02d%02d%02d%02d%02d.imserver.log", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
filename_ = timestr;
}

fp_ = fopen(filename_.c_str(), "wt+");
if (fp_ == NULL)
return false;

spthread_.reset(new std::thread(std::bind(&Logger::threadfunc, this)));

return true;
}

void Logger::Stop()
{
exit_ = true;
cv_.notify_one();

//等待时间线程结束
spthread_->join();
}

void Logger::AddToQueue(const char* pszLevel, const char* pszFile, int lineNo, const char* pszFuncSig, char* pszFmt, ...)
{
char msg[256] = { 0 };

va_list vArgList;
va_start(vArgList, pszFmt);
vsnprintf(msg, 256, pszFmt, vArgList);
va_end(vArgList);

time_t now = time(NULL);
struct tm* tmstr = localtime(&now);
char content[512] = { 0 };
sprintf(content, "[%04d-%02d-%02d %02d:%02d:%02d][%s][0x%04x][%s:%d %s]%s\n",
tmstr->tm_year + 1900,
tmstr->tm_mon + 1,
tmstr->tm_mday,
tmstr->tm_hour,
tmstr->tm_min,
tmstr->tm_sec,
pszLevel,
std::this_thread::get_id(),
pszFile,
lineNo,
pszFuncSig,
msg);

{
std::lock_guard<std::mutex> guard(mutex_);
queue_.emplace_back(content);
}

cv_.notify_one();
}

void Logger::threadfunc()
{
if (fp_ == NULL)
return;

while (!exit_)
{
//写日志
std::unique_lock<std::mutex> guard(mutex_);
while (queue_.empty())
{
if (exit_)
return;

cv_.wait(guard);
}

//写日志
const std::string& str = queue_.front();

fwrite((void*)str.c_str(), str.length(), 1, fp_);
fflush(fp_);
queue_.pop_front();
}
}

问题描述

  1. Stop方法: 设置exit_标志为true。 通过条件变量通知日志线程。 等待日志线程结束

    请问这里为什么需要通知日志线程

    解答:在Logger类的实现中,日志线程的工作是从队列中获取日志消息并将其写入文件。为了实现这个功能,它在队列为空时通过cv_.wait(guard)进入休眠状态,等待新的日志消息的到来。

    当你调用Stop方法时,你希望日志线程能够尽快结束。但有一种情况需要考虑:当Stop被调用时,队列可能仍然是空的,这意味着日志线程可能处于cv_.wait(guard)的休眠状态。

    在这种情况下,即使你设置了exit_标志为true,日志线程仍然不会醒来检查这个标志,因为它正在等待新的日志消息到来。

    为了确保日志线程能够醒来、检查exit_标志并正确地退出,你需要在Stop方法中通过cv_.notify_one()通知日志线程。这样,日志线程将被唤醒,然后它可以检查exit_标志,并根据该标志的值决定是否退出。

    总之,通知日志线程是为了确保它可以从cv_.wait(guard)中醒来,然后检查退出条件并安全地退出。

  2. 这个程序没有设计线程来将新的日志信息加入队列吗

    从提供的代码来看,日志系统中只设计了一个专门的日志线程(在Logger::Start()方法中启动),其任务是从队列中提取日志消息并将其写入文件。此外,并没有为添加新日志消息到队列专门设计线程。

    当你希望写入日志时,只需在你的主程序或任何其他线程中调用LogInfo(), LogWarning(), 或 LogError()这样的宏。这些宏内部会调用Logger::AddToQueue(),该方法将日志消息格式化并加入到队列中。这种设计的优点是简单且高效,因为它允许多个线程在运行时无缝地向日志系统添加消息,而无需额外的线程。

    但值得注意的是,Logger::AddToQueue()使用了互斥锁mutex_来保证多个线程同时向队列添加消息时的线程安全。这意味着,即使多个线程同时调用日志宏,Logger::AddToQueue()中的代码仍然是线程安全的。

    总的来说,这个日志系统的设计考虑到了多线程环境,允许多个线程同时添加日志消息,但它并没有专门设置一个线程来完成这项任务,而是允许主程序或其他任何线程来直接调用添加日志的宏。

    自己的话:你自己程序在运行的时候是不是有多个线程,这些线程就是负责啥时候要调用LogInfo(), LogWarning(), 或 LogError()这样的宏。所以当然不需要额外的线程来操作加入队列操作,只需要一个带锁函数即可。

0%