dustland

dustball in dustland

windows XP notepad.exe逆向分析

基本结构和我们写的差不多,都是主窗口加一个编辑控件

不同的是,人家的有更nb的地方:

有状态栏,可以显示当前输入位置(行号,列号),状态栏是钩子驱动的

可以分析命令行

寻找WinMain

ida一开始会停在程序入口点,start函数这里,显然不是WinMain函数,也不是main函数,是运行环境初始化函数,目前尚未学习WinMain之前的执行过程,现在的任务就是找到WinMain在哪里

start函数看起来很乱不好分析,在functions列表中也没有找到WinMain字样,但是函数列表里面有一个DialogFunc,估计是某个窗口过程函数调用的对话框

如果是主窗口过程,那么就离着找到WinMain不远了

从DialogFunc上查看交叉引用

image-20220831223117517

到这个sub_1002919函数看看去

1
int __stdcall sub_1002919(HWND hWnd, __int16 a2, int a3);

这个函数只有三个参数,而窗口过程函数要有四个参数

1
LRESULT CALLBACK WndProc (HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);

显然sub_1002919还不是窗口过程函数,那么再看sub_1002919的交叉引用

image-20220831223421292

这两个交叉引用是在同一个函数中的,现在去sub_103134看看

1
LRESULT __thiscall sub_1003134(void *this, HWND hWnd, UINT Msg, WPARAM wParam, LPARAM a5);

光从函数定义上就能看出,这十有八九是一个窗口过程了

再看看sub_1003134的交叉引用

image-20220831223624249

去sub_1004143看看

这个函数在注册窗口类,sub_1003134是注册给这个窗口类的过程回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
BOOL __usercall sub_1004143@<eax>(HINSTANCE a1@<esi>)
{
int v1; // eax
WNDCLASSEXW v3; // [esp+4h] [ebp-30h] BYREF

v3.cbSize = 48;
v1 = GetSystemMetrics(41);
v3.hCursor = LoadCursorW(0, (LPCWSTR)(32513 - (v1 != 0)));
v3.hIcon = LoadIconW(a1, (LPCWSTR)2);
v3.hIconSm = (HICON)LoadImageW(a1, (LPCWSTR)2, 1u, 16, 16, 0);
v3.lpszMenuName = (LPCWSTR)1;
v3.hInstance = a1;
v3.lpszClassName = ClassName;
v3.lpfnWndProc = (WNDPROC)sub_1003134;
v3.hbrBackground = (HBRUSH)6;
v3.style = 0;
v3.cbClsExtra = 0;
v3.cbWndExtra = 0;
return RegisterClassExW(&v3) != 0;
}

清晰了

再看看谁调用了sub_1004143估计就是WinMain了

image-20220831223754846

去sub_10041CA看看

1
int __stdcall sub_10041CA(HINSTANCE hInstance, HGDIOBJ h, int a3, int nCmdShow)

和WinMain函数的接口一模一样,四个参数,__stdcall调用约定,int返回值

1
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,PSTR szCmdLine, int iCmdShow);

看看sub_10041CA函数正文,它加载了键盘加速键,加载了光标,最重要的是,它有消息循环

1
2
3
4
5
6
7
8
9
10
while ( GetMessageW(&Msg, 0, 0, 0) )
{
if ( Msg.message == 80 )
PostMessageW(hWndParent, 0x8001u, 0, 0);
if ( (!hDlg || !IsDialogMessageW(hDlg, &Msg)) && !TranslateAcceleratorW(hWndParent, hAccTable, &Msg) )
{
TranslateMessage(&Msg);
DispatchMessageW(&Msg);
}
}

现在就可以肯定地把sub_100041CA更名为WinMain

image-20220831225104278

主函数WinMain

各种哑名起一个相对合理的名字,各种常数改成枚举值,这样反汇编的WinMain就看起来很顺眼了

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
WPARAM __stdcall WinMain(HINSTANCE hInstance, HGDIOBJ hPrevInstance, int a3, int nCmdShow)
{
const WCHAR *rawCMDLine; // edi
HMODULE sysMetrics; // eax
FARPROC RegisterPenApp; // eax
const WCHAR *pureCMDLine; // eax
DWORD pid; // eax
HWINEVENTHOOK hHook; // ebx
struct tagMSG Msg; // [esp+8h] [ebp-20h] BYREF
void (__stdcall *regPenApp)(_DWORD, _DWORD); // [esp+24h] [ebp-4h]

rawCMDLine = GetCommandLineW();
sysMetrics = GetSystemMetrics(41);
RegisterPenApp = GetProcAddress(sysMetrics, "RegisterPenApp");
regPenApp = RegisterPenApp;
if ( RegisterPenApp )
(RegisterPenApp)(1, 1);
pureCMDLine = CmdLineNoProgName(rawCMDLine); // 去掉命令行中的程序名
if ( myRegister(hInstance, hPrevInstance, pureCMDLine, nCmdShow) )// 注册窗口类,创建窗口实例
{
pid = GetCurrentProcessId(); // 当前进程id
hHook = SetWinEventHook(EVENT_OBJECT_LOCATIONCHANGE, EVENT_OBJECT_LOCATIONCHANGE, 0, pfnWinEventProc, pid, 0, 0);// 钩子只对EVENT_OBJECT_LOCATIONCHANGE关心,只要是记事本的形状位置发生变化,就会触发这个钩子
while ( GetMessageW(&Msg, 0, 0, 0) ) // 消息循环
{
if ( Msg.message == WM_INPUTLANGCHANGEREQUEST )
PostMessageW(hWndParent, 0x8001u, 0, 0);
if ( (!hDlg || !IsDialogMessageW(hDlg, &Msg)) && !TranslateAcceleratorW(hWndParent, hAccTable, &Msg) )// 判断是不是查找框的消息
{
TranslateMessage(&Msg);
DispatchMessageW(&Msg);
}
}
sub_10018B0(); // 释放"页面设置"结构体
LocalFree(hMem);
if ( hHook )
UnhookWinEvent(hHook);
}
else
{
Msg.wParam = 0;
}
if ( regPenApp )
regPenApp(1, 0);
return Msg.wParam;
}

和我们自己写的记事本有一些不同的地方,

首先是RegisterPenApp这个东西,上网查也没有查到详细的资料,RegisterPenApp这里说是win95之前的API,太老了不让用了,这个问题不大

然后是对WinMain的命令行剥了壳,去掉了第一个参数,程序目录,这个问题不大

然后给本进程注册了一个钩子,针对EVENT_OBJECT_LOCATIONCHANGE事件,这个需要研究一下

事件钩子hookfunc

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
HWND __stdcall hookfunc(int flag)
{
LRESULT lineNumber; // eax
int logicLineNumber; // edi
HWND colIndex; // eax
WPARAM logicColNumber; // ebx
wchar_t Buffer[128]; // [esp+Ch] [ebp-108h] BYREF
LPARAM lParam; // [esp+10Ch] [ebp-8h] BYREF
WPARAM wParam; // [esp+110h] [ebp-4h] BYREF

SendMessageW(hWnd, EM_GETSEL, &wParam, &lParam);// 获取正在高亮的位置信息
lineNumber = SendMessageW(hWnd, EM_LINEFROMCHAR, wParam, 0);// 获取高亮区域的字符索引
logicLineNumber = lineNumber + 1; // 人从1开始计数,计算机从0开始计数
colIndex = SendMessageW(hWnd, EM_LINEINDEX, lineNumber, 0);// 行号
logicColNumber = wParam - colIndex + 1;
if ( flag || logicColNumber != historyColNumber || logicLineNumber != historyLineNumber )
{
snwprintf(Buffer, 0x7Fu, Format, logicLineNumber, wParam - colIndex + 1);// 第几行第几列打印到Buffer
colIndex = hwndStatus; // hwndStatus状态框句柄
Buffer[127] = 0; // 最后一个字符置零
if ( hwndStatus )
colIndex = SendMessageW(hwndStatus, SB_SETTEXTW, 1u, Buffer);// 如果当前存在状态框,则向状态狂发送Buffer
}
historyLineNumber = logicLineNumber; // 更新当前行号
historyColNumber = logicColNumber;
return colIndex;
}

钩子对于EVENT_OBJECT_LOCATIONCHANGE感兴趣,只要是输入位置发生变化就会收到该钩子消息

该钩子的作用是,更新记录的鼠标位置,并向状态框发送消息,让状态框实时显示当前输入位置

啥是状态栏

这个钩子在消息循环之前注册生效,在消息循环之后析构,可以认为在notepad主窗口的可见时期内,钩子都是生效的,只要是开启了状态栏,钩子就会通知状态栏更新输入位置状态

到此钩子函数分析完毕,下面需要看看myRegister都是注册了什么,然后看看主窗口,搜索框,状态栏各自的窗口过程是啥样的

啥都干myRegister

WinMain中是这样调用myRegister函数的

1
myRegister(hInstance, hPrevInstance, pureCMDLine, nCmdShow)

此处的pureCMDLine已经剥去了第一个参数,即程序位置

myRegister干了很多事,包括

注册主窗口类

创建主窗口实例

创建状态栏窗口

初始化菜单

命令行分析(反编译实在太乱,一时间看不清都干了啥)

粗略上看命令行分析支持从命令行打开已有文件,新建文件,改变编码方式等等

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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
int __stdcall myRegister(HINSTANCE hInstance, HGDIOBJ oldhFont, LPSTR pureCmdLine, int nCmdShow)
{
HINSTANCE hInst; // esi
int v6; // eax
HACCEL hAccelerator; // eax
void *v8; // ecx
HWND hwnd; // edx
HMENU hMenu; // eax
HMENU hSubMenu; // eax
int hDeviceCaps; // eax
unsigned __int16 *iterCMD; // eax
unsigned __int16 *CopyIterCMD; // edi
int v15; // eax
int v16; // eax
WCHAR Name[32]; // [esp+8h] [ebp-98h] BYREF
WINDOWPLACEMENT wndpl; // [esp+48h] [ebp-58h] BYREF
struct tagRECT Rect; // [esp+74h] [ebp-2Ch] BYREF
struct tagRECT rectStatus; // [esp+84h] [ebp-1Ch] BYREF
LPARAM lParam[2]; // [esp+94h] [ebp-Ch] BYREF
HDC hdc; // [esp+9Ch] [ebp-4h]

WM_FINDREPLACE = RegisterWindowMessageW(L"commdlg_FindReplace");// 注册人工消息,只要是调用查找框就会发出该消息
if ( !WM_FINDREPLACE )
return 0;
WM_HELP = RegisterWindowMessageW(L"commdlg_help");// 注册帮助框消息
if ( !WM_HELP )
return 0;
hdc = GetDC(0); // 设备环境句柄
if ( !hdc )
return 0;
hInst = hInstance; // 应用程序句柄
if ( !sub_1003D57(hInstance) )
return 0;
v6 = GetSystemMetrics(41); // SM_PENWINDOWS,还是和Pen相关的,老古董了
hCursor_0 = LoadCursorW(0, (32513 - (v6 != 0)));// 加载了一个寂寞
hCursor = LoadCursorW(0, 0x7F02); // 32706号光标类型?然而不是系统预定义的?
hAccelerator = LoadAcceleratorsW(hInst, L"MainAcc");// 加载键盘加速键
hAccTable = hAccelerator; // 这拷贝了一个寂寞吧
if ( !hCursor || !hAccelerator || !oldhFont && !myRegistClass(hInst) )// 看看这一伙子都就位了吗
return 0;
::hInstance = hInst;
psdw.lStructSize = 84; // 页面设置
psdw.hDevMode = 0;
psdw.hDevNames = 0;
psdw.hInstance = hInst;
sub_10018DF(v8); // 页面设置相关
setFont(); // 字体设置相关
hwnd = CreateWindowExW(0, ClassName, &szText, WS_OVERLAPPEDWINDOW, X, Y, nWidth, nHeight, 0, 0, hInst, 0);// 创建窗口实例,窗口类ClassName="notepad"已经在myRegisterClass注册过
hwndParent = hwnd;
psdw.hwndOwner = hwnd;
if ( !hwnd )
return 0;
if ( nWidth != 0x80000000 && nHeight != 0x80000000 )
{
memset(&wndpl, 0, sizeof(wndpl));
wndpl.rcNormalPosition.left = X; // 设置窗口在屏幕上的位置信息
wndpl.rcNormalPosition.right = nWidth + X;
wndpl.rcNormalPosition.top = Y;
wndpl.rcNormalPosition.bottom = nHeight + Y;
wndpl.length = 44;
SetWindowPlacement(hwnd, &wndpl);
hwnd = hwndParent;
}
DragAcceptFiles(hwnd, 1); // 允许往hwnd窗口拖拽打开文件
GetClientRect(hwndParent, &Rect); // 获取主窗口客户区
hWnd = CreateWindowExW( // 创建编辑控件窗口,以主窗口作为父窗口,占据主窗口的整个客户区
0x200u,
L"Edit",
&szText,
wParam1 != 0 ? 0x50200104 : 0x50300104,// 是否具有水平滚动轴
0,
0,
Rect.right,
Rect.bottom - 100,
hwndParent,
0xF,
hInstance,
0);
if ( !hWnd )
return 0;
hwndStatus = CreateStatusWindowW( // 创建状态栏窗口实例
(*&isStatusWndVisible != 0 ? WS_VISIBLE : 0) | 0x44800000,// WS_CHILD|WS_CLIPSIBLINGS|WS_BORDER
// 子窗口|被其他子窗口重叠时不重绘|具有边线

&szText,
hwndParent,
0x401u); // 状态栏窗口控制标识符
if ( !hwndStatus ) // 检测状态栏窗口是否创建成功
return 0;
hookfunc(1); // 创建完状态栏窗口之后立刻更新当前行号列号状态,如果状态栏可见则通知状态栏重绘
GetClientRect(hwndStatus, &rectStatus); // 获取状态窗口的客户区
cyStatus = rectStatus.bottom - rectStatus.top;// 状态栏高度
lParam[1] = -1;
lParam[0] = 3 * (rectStatus.right - rectStatus.left) / 4;
SendMessageW(hwndStatus, SB_SETPARTS, 2u, lParam);// 状态栏分成两部分,0号部分占据了状态栏的前四分之三,1号部分占据剩下所有
SendMessageW(hWnd, EM_FMTLINES, wParam1, 0); // wParam1标志着是否自动换行,如果wParam1=1则不自动换行,wParam=0则自动换行
if ( wParam1 ) // 如果不自动换行
{
hMenu = GetMenu(hwndParent);
hSubMenu = GetSubMenu(hMenu, 3); // 父窗口的第三个菜单,查看,状态栏
EnableMenuItem(hSubMenu, 0x1Bu, 1u); // 0x1B号菜单使能,推测是状态栏使能
}
hDeviceCaps = GetDeviceCaps(hdc, 90); // 获取设备信息
lf.lfHeight = -MulDiv(*&nNumber, hDeviceCaps, 720);
hFont = CreateFontIndirectW(&lf); // 加载字体
oldhFont = SelectObject(hdc, hFont); // 将新字体选入设备环境
GetTextFaceW(hdc, 32, Name); // 获得当前使用的字体名称
SelectObject(hdc, oldhFont);
if ( lstrcmpiW(Name, lf.lfFaceName) ) // 如果目前的字体和原来记录的字体不同则更新
{
EnumFontsW(hdc, lf.lfFaceName, Proc, &lf);
DeleteObject(hFont);
hFont = CreateFontIndirectW(&lf);
}
SendMessageW(hWnd, WM_SETFONT, hFont, 0); // 通知hWnd窗口改变字体,hWnd是编辑控件句柄
ReleaseDC(0, hdc);
word_1009800 = 0;
hMem = LocalAlloc(0x42u, 2u);
PostMessageW(hWnd, EM_LIMITTEXT, 0, 0); // 设置hWnd窗口的字数限制
sub_1001C5B(lpString2);
ShowWindow(hwndParent, nCmdShow); // 显示窗口
SetCursor(hCursor_0); // 修改光标
iterCMD = getNextCMD(pureCmdLine); //从这里开始就是对于命令行的处理了
encodingMode = -1;
CopyIterCMD = iterCMD;
if ( !Lstrcmp(iterCMD, L"/A") ) // 命令行上是否有/A
{
encodingMode = 0;
LABEL_25:
CopyIterCMD = getNextCMD(CopyIterCMD + 2);
goto LABEL_26;
}
if ( !Lstrcmp(CopyIterCMD, L"/W") ) // 命令行上是否有/W
encodingMode = 1;
if ( encodingMode != -1 )
goto LABEL_25;
LABEL_26:
v15 = sub_1003E29(CopyIterCMD);
if ( !v15 )
{
if ( sub_1003F7F(CopyIterCMD, nCmdShow) )
{
PostMessageW(hwndParent, WM_CLOSE, 0, 0);
return 1;
}
if ( *CopyIterCMD )
{
sub_1003C5B(FileName, CopyIterCMD);
hFile = CreateFileW(FileName, 0x80000000, 3u, 0, 3u, 0x80u, 0);// 命令行上指定的文件不存在,需要新建
if ( hFile != -1 )
goto LABEL_41;
if ( GetLastError() == 2 )
{
v16 = sub_1001F55(hwndParent, lpCaption, dword_1008020, FileName, 0x33u);
if ( v16 == 2 )
return 0;
if ( v16 == 6 )
hFile = CreateFileW(FileName, 0xC0000000, 3u, 0, 4u, 0x80u, 0);
}
else
{
sub_1004A24(FileName);
sub_1001C5B(lpString2);
lstrcpyW(FileName, lpString2);
}
if ( hFile != -1 )
LABEL_41:
sub_1004D75(FileName, encodingMode);
}
LABEL_42:
sub_10040D7(&word_10095E0);
sub_10040D7(&word_1009540);
memset(&ofnw, 0, sizeof(ofnw));
ofnw.hInstance = hInstance; // 保存/另存为框 结构体
memset(&findReplace, 0, sizeof(findReplace));
ofnw.lStructSize = 88;
ofnw.hwndOwner = hwndParent;
ofnw.nMaxFile = 260;
findReplace.lStructSize = 40;
findReplace.hwndOwner = hwndParent;
SendMessageW(hWnd, EM_GETSEL, &nCmdShow, &hInstance);
SendMessageW(hWnd, EM_SETSEL, nCmdShow, hInstance);
SendMessageW(hWnd, EM_SCROLLCARET, 0, 0);
if ( (GetKeyboardLayout(0) & 0x3FF) == 0x11 )// 获取键盘布局模式
SendMessageW(hWnd, EM_SETIMESTATUS, 1u, 1);
return 1;
}
if ( v15 != 2 )
goto LABEL_42;
return 0;
}

注册窗口类myRegistClass

创建主窗口类,没什么特别之处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
BOOL __usercall myRegistClass@<eax>(HINSTANCE a1@<esi>)
{
int v1; // eax
WNDCLASSEXW WndClass; // [esp+4h] [ebp-30h] BYREF

WndClass.cbSize = 48;
v1 = GetSystemMetrics(41);
WndClass.hCursor = LoadCursorW(0, (32513 - (v1 != 0)));
WndClass.hIcon = LoadIconW(a1, 2);
WndClass.hIconSm = LoadImageW(a1, 2, 1u, 16, 16, 0);
WndClass.lpszMenuName = 1;
WndClass.hInstance = a1;
WndClass.lpszClassName = ClassName;//关键,设置主窗口类名
WndClass.lpfnWndProc = WndProc;//关键,注册主窗口过程
WndClass.hbrBackground = 6;
WndClass.style = 0;
WndClass.cbClsExtra = 0;
WndClass.cbWndExtra = 0;
return RegisterClassExW(&WndClass) != 0;
}

WndProc主窗口过程,基本上和我们自己写的差不多的逻辑,处理的消息基本上就是那一些

010editor 12.0.1注册破解

过期again

已经受够了30天就得重装一次010editor,这次给他一劳永逸一下破解喽

010editor登录注册不需要联网,这表明它自己就带着账户密码了,或者说给定一个账户它通过某些哈希算法等等得到一串狗屁不同的数字作为密码,或者说A用户名对应a密码,B用户名对应b密码等等

这些都有可能

反正只要是不用联网麻烦服务器验证登录,一切就好说

ida64打开之

1
PS C:\Program Files\010Editor> ida64 010editor

等ida64分析完之后,直接看函数列表是令人失望的,全是哑名,

连个start或者main或者WinMain函数都找不到

还有一点儿发现就是010editor使用Qt写的

image-20220911203227392

静态分析是看不出来了,用动态的

选择使用本地windows调试器,啥断点也不用下,直接开始调试

必然在注册页面停下

image-20220911203350032

这里有一个猜想是,处理注册成功和失败的函数或者说过程离得不远,可能是在一个if-else条件控制下,就跟windows过程函数一样,对于A消息用这个分支处理,对于B消息用那个分支处理

还有一个事实是,这里胡乱尝试注册用户名和密码,成功的机率很小,但是几乎百分百失败(这不废话吗),这样说是因为,可以通过失败的情况看看周围有没有成功的情况

image-20220911204005784

乱输入一些东西然后Check License,弹出对话框了

好了现在用IDA的search Text(Alt+T)功能去查"Invalid name"等字样,果然查到了,令人意想不到的简单

1
2
3
4
.text:00007FF61CE45819 loc_7FF61CE45819:                       ; CODE XREF: sub_7FF61CE45130+61F↑j
.text:00007FF61CE45819 mov edx, 90h
.text:00007FF61CE4581E lea rcx, aInvalidNameOrP ; "Invalid name or password. Please enter "...
.text:00007FF61CE45825 call cs:?fromAscii_helper@QString@@CAPEAU?$QTypedArrayData@G@@PEBDH@Z ; QString::fromAscii_helper(char const *,int)

这里edx=90h是字符串的长度,rcx中存放的是字符串基地址

然后接着就调用Qt函数了

我估计和windows32程序设计中的对话框或者说messageBox是差不多的东西

在这里F5反编译一下看的更清楚

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if ( v16 != 147 )
{
v48 = QString::fromAscii_helper(
"Invalid name or password. Please enter your name and password exactly as given when you purchased 010 Edit"
"or (make sure no quotes are included).",
144i64);
sub_7FF61CC55065(&v48);
v25 = (char *)&v48;
goto LABEL_68;
}
if ( v17 != 113 )
{
v48 = QString::fromAscii_helper("Password accepted but the trial period is already over.", 55i64);
sub_7FF61CC55065(&v48);
v25 = (char *)&v48;
goto LABEL_68;
}
v48 = QString::fromAscii_helper("Password accepted. Your trial period has been extended.", 55i64);
sub_7FF61CC55065(&v48);
v32 = &v48;

除了"Invalid name..."这句,还有其他各种情况

比如密码正确,但是试用期过了,

比如密码正确,试用期已经延长

...

可以猜测,密码正确,注册成功 这种情况也在附近,划拉划拉看看,还真就有

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if ( v17 == 219 )
{
sub_7FF61CC53508(v18, v46);
v43 = QString::fromAscii_helper("MMMM d, yyyy", 12i64);
v26 = (const struct QString *)QDate::toString(v46, v47, &v43);
v27 = (const struct QString *)QString::fromUtf8(
&v44,
"Password accepted. This license entitles you to:\n"
"\n"
" - Free Upgrades\n"
" - Free Support\n"
" - Free Repository Updates\n"
"\n"
"until ",
0xFFFFFFFFi64);

当v17=219的时候,对应密码通过,有驾驶证了

那么现在的焦点就是v17,怎样才能让v17=219呢?

在v17身上按X看交叉引用

image-20220911204910187

Line 157和Line 197都是写操作的交叉引用,并且这两个都在sub_7FF61CC584F54函数

后面五个都是读,读当然不会修改v17

去sub_7FF61CC584F54看看发生了什么

笑死,sub_7FF61CC584F54这个函数就是我们当前所在函数

其中会改变v17的是

1
v17 = sub_7FF61CC584F4(qword_7FF61D94EE50, 13i64, 18887i64);

再去sub_7FF61CC584F4看看

1
2
3
4
__int64 __fastcall sub_7FF61CC584F4(__int64 a1, __int64 a2, __int64 a3)
{
return sub_7FF61CF9AD70(a1, a2, a3);
}

再去sub_7FF61CF9AD70看看

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
__int64 __fastcall sub_7FF61CF9AD70(__int64 a1, __int64 a2, __int64 a3)
{
unsigned int v3; // edi
__int64 result; // rax
int v6; // eax
int v7; // eax
int v8; // eax
unsigned int v9; // ecx
int v10; // eax
unsigned int v11; // ecx
int v12; // eax
unsigned int v13; // ecx

v3 = a2;
if ( *(_DWORD *)(a1 + 60) )
return 275i64;
v6 = sub_7FF61CC56118(a1, a2, a3);
switch ( v6 )
{
case 45:
return 219i64;
case 78:
v12 = sub_7FF61CC58FCB(a1, v3);
v13 = 524;
if ( v12 != 23 )
v13 = 237;
result = v13;
break;
case 231:
return 375i64;
default:
v7 = sub_7FF61CC58FCB(a1, v3);
if ( v7 == 23 )
return 113i64;
if ( v7 != 42 )
{
if ( v7 == 312 )
{
v8 = sub_7FF61CC55B37(a1);
v9 = 47;
if ( v8 == 419 )
v9 = 249;
return v9;
}
return 375i64;
}
v10 = sub_7FF61CC55B37(a1);
v11 = 375;
if ( v10 == 419 )
v11 = 249;
result = v11;
break;
}
return result;
}

如果要返回219

需要

1
2
3
4
5
6
7
8
if ( *(_DWORD *)(a1 + 60) )//这里得是0
return 275i64;
v6 = sub_7FF61CC56118(a1, a2, a3);//返回45
switch ( v6 )
{
case 45:
return 219i64;
...

在前面这个if上下断点然后重新开始调试,结果它不会找茬,直接到

v6 = sub_7FF61CC56118(a1, a2, a3);这里

单步步过之后v6=0x93显然不是45,v6它故意找茬

这时候看看反汇编

1
2
.text:00007FF61CF9AD95 call    sub_7FF61CC56118
.text:00007FF61CF9AD9A cmp eax, 2Dh ; '-'

失败的尝试

2Dh=45d

既然v6从sub_7FF61CC56118(a1, a2, a3);出来等于0x93,那么我就让v6=0x93的时候返回219,直接修改指令,将2Dh改成93h

image-20220911211116648

这两步都在Edit->Patch program菜单中

image-20220911211234713

然而改了之后汇编指令成了这样:

1
.text:00007FF72357AD9A 83 F8 93                      cmp     eax, 0FFFFFF93h

83F8是操作码还有eax,剩下一个字节93是立即数,这里只给立即数留了1个字节,而93h=0x10010011正好相当于一个负数,改了白改

又得改回去

成功的尝试

改操作数指令不够长,那么可以改jz为jnz

1
2
.text:00007FF72357AD9A 83 F8 2D                      cmp     eax, 2Dh ; '-'
.text:00007FF72357AD9D 0F 84 C0 00 00 00 jz loc_7FF72357AE63

原来得是eax==2D,才能满足jz要求,现在直接让他jnz

image-20220911212949281
1
.text:00007FF72357AD9D 0F 85 C0 00 00 00             jnz     loc_7FF72357AE63

可以看见操作码从0F84变成了0F85

然后保存修改,Apply patches to input file...

或者x64dbg也可以干这个事情

image-20220911213946860

然后打开补丁后的程序,已经没人找茬了

image-20220911214059651

"证书颁发给:"

"0个用户证书"

就很喜感

windows SDK PopPad3

一个比较完整的记事本,借以学习

windows API程序设计

模块化程序设计

windows消息机制

模块之间通信

面向对象思想

项目结构

其项目结构为:

image-20220824171205363

其中源文件的名称是自解释的,

PopPad.c实现主窗口过程,它调用其他模块中的函数实现功能

PopFile.c模块,实现的是与文件系统交互的逻辑

PopFind.c模块,实现的是查找功能

PopFont.c模块,实现的是设置字体的功能

PopPrnt0.c模块,在未来实现打印机功能

PopPad.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
#define EDITID   1//编辑器控件的id
#define UNTITLED TEXT ("(untitled)") //未保存文件的名称
//声明本模块中的函数
LRESULT CALLBACK WndProc (HWND, UINT, WPARAM, LPARAM) ;
BOOL CALLBACK AboutDlgProc (HWND, UINT, WPARAM, LPARAM) ;

//声明其他模块中的函数
// Functions in POPFILE.C

void PopFileInitialize (HWND) ;
BOOL PopFileOpenDlg (HWND, PTSTR, PTSTR) ;
BOOL PopFileSaveDlg (HWND, PTSTR, PTSTR) ;
BOOL PopFileRead (HWND, PTSTR) ;
BOOL PopFileWrite (HWND, PTSTR) ;

// Functions in POPFIND.C

HWND PopFindFindDlg (HWND) ;
HWND PopFindReplaceDlg (HWND) ;
BOOL PopFindFindText (HWND, int *, LPFINDREPLACE) ;
BOOL PopFindReplaceText (HWND, int *, LPFINDREPLACE) ;
BOOL PopFindNextText (HWND, int *) ;
BOOL PopFindValidFind (void) ;

// Functions in POPFONT.C

void PopFontInitialize (HWND) ;
BOOL PopFontChooseFont (HWND) ;
void PopFontSetFont (HWND) ;
void PopFontDeinitialize (void) ;

// Functions in POPPRNT.C

BOOL PopPrntPrintFile (HINSTANCE, HWND, HWND, PTSTR) ;

// Global variables

static HWND hDlgModeless ;//全局非模态对话框句柄,用于查找框
static TCHAR szAppName[] = TEXT ("PopPad") ;//本引用程序名,用于注册类名,资源名

WinMain

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
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
PSTR szCmdLine, int iCmdShow)
{
MSG msg;
HWND hwnd;
HACCEL hAccel;
WNDCLASS wndclass;//主窗口样式

wndclass.style = CS_HREDRAW | CS_VREDRAW;//窗口宽高发生变化时都会重绘
wndclass.lpfnWndProc = WndProc; //注册主窗口过程回调函数
wndclass.cbClsExtra = 0;
wndclass.cbWndExtra = 0;
wndclass.hInstance = hInstance;
wndclass.hIcon = LoadIcon(hInstance, szAppName);
wndclass.hCursor = LoadCursor(NULL, IDC_ARROW);
wndclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
wndclass.lpszMenuName = szAppName;//和菜单挂钩
wndclass.lpszClassName = szAppName;//定义本窗口类类名

if (!RegisterClass(&wndclass)) //注册窗口类
{
MessageBox(NULL, TEXT("This program requires Windows NT!"),
szAppName, MB_ICONERROR);
return 0;
}

hwnd = CreateWindow(
szAppName, NULL,//使用szAppName窗口类进行实例化,无窗口名
WS_OVERLAPPEDWINDOW,//层叠窗口
CW_USEDEFAULT, CW_USEDEFAULT,//x,y,宽,高 都是默认值
CW_USEDEFAULT, CW_USEDEFAULT,
NULL, NULL, hInstance, szCmdLine//无父窗口,不覆盖菜单,应用程序实例,命令行
);

ShowWindow(hwnd, iCmdShow);
UpdateWindow(hwnd);

hAccel = LoadAccelerators(hInstance, szAppName);//键盘加速键,也是用szAppName作为资源名

while (GetMessage(&msg, NULL, 0, 0))//从消息循环获得一条消息
{
if (hDlgModeless == NULL || !IsDialogMessage(hDlgModeless, &msg))//需要先分清本条消息是给主窗口的还是给对话框窗口的
{//hDlgModeless是非模态对话框,即查找框的句柄.该句柄为NULL时表明当前没有活动的查找框,那么本消息只能是主窗口的,
//如果hDlgModeless不为NULL,还需判断到底是谁的消息,只需要调用IsDialogMessage(hDlgModeless, &msg)
//如果是查找框的消息,则IsDialogMessage返回TRUE,并且已经把该消息发往查找框自己的过程函数了
//如果不是查找框的消息,则函数返回FALSE,继续向下执行
if (!TranslateAccelerator(hwnd, hAccel, &msg))//翻译加速键,如果是加速键消息则翻译了然后重新进入消息循环,
{//如果不是键盘加速键或者说已经被翻译成非键盘加速键的消息,则通过if判断
TranslateMessage(&msg);
DispatchMessage(&msg);
}
}
}
return msg.wParam;
}

从主函数上看,本程序使用了键盘加速键,并且可以有查找框这种非模态窗口

WndProc主窗口过程

变量定义

1
2
3
4
5
6
7
8
static BOOL      bNeedSave = FALSE ;//记录当前文件自从最近的修之后有没有改动,如果有则需要修改,相当于脏位
static HINSTANCE hInst ; //本应用程序实例句柄
static HWND hwndEdit ; //编辑控件的句柄
static int iOffset ; //高亮选中字体数
static TCHAR szFileName[MAX_PATH], szTitleName[MAX_PATH] ;//文件名,标题栏名称
static UINT messageFindReplace ;//记录查找替换结果
int iSelBeg, iSelEnd, iEnable ;//高亮选择区域开始,结束,iEnable是剪切复制等功能的使能开关,只有有高亮区域时iEnable有效
LPFINDREPLACE pfr ;//查找替换框指针,用这个指针操作查替框行为

下面就进入switch-case分拣消息了

switch(message)

WM_CREATE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
case WM_CREATE:
hInst = ((LPCREATESTRUCT) lParam) -> hInstance ;//hInst初始化为当前应用程序实例句柄,在创建编辑控件的时候需要用到该值

// Create the edit control child window

hwndEdit = CreateWindow (TEXT ("edit"), NULL,//创建子窗口,使用"edit"类(预定义控件类),子窗口无名
WS_CHILD | WS_VISIBLE | WS_HSCROLL | WS_VSCROLL |//子窗口属性:子窗口|可见|水平滚动|竖直滚动|
WS_BORDER | ES_LEFT | ES_MULTILINE | //有边框 | 文本左对齐 | 多行文本
ES_NOHIDESEL | ES_AUTOHSCROLL | ES_AUTOVSCROLL,//失去焦点时变灰|自动水平滚动|自动竖直滚动(显式区域跟随输入提示符)
0, 0, 0, 0,//一开始窗口没有大小
hwnd, (HMENU) EDITID, hInst, NULL) ;//主窗口作为父窗口,EDITID宏作为编辑控件索引,应用程序实例句柄

SendMessage (hwndEdit, EM_LIMITTEXT, 32000, 0L) ;//立刻向编辑控件发送一条消息,限制总字数不超过32000字

// Initialize common dialog box stuff

PopFileInitialize (hwnd) ;//PopFile.c模块中的函数,用于初始化用户在Open和Save As对话框中的选项
PopFontInitialize (hwndEdit) ;//PopFont.c中的函数,初始化用户在Font对话框中的选项

messageFindReplace = RegisterWindowMessage (FINDMSGSTRING) ;//注册自己的窗口消息,messageFindReplace使其消息编号
//RegisterWindowMessage专门用于注册在两个相互写作的窗口之间通信的消息,另一个注册过FINDMSGSTRING的窗口是查找框,
//也就是说,只要唤醒查找框主窗口就会收到messageFindReplace消息
DoCaption (hwnd, szTitleName) ;//将主窗口标题改为szTitleName
return 0 ;
WM_SETFOCUS
1
2
3
case WM_SETFOCUS:
SetFocus (hwndEdit) ;//主窗口获得焦点立刻把焦点让给编辑控件
return 0 ;

WM_SIZE

1
2
3
case WM_SIZE: //编辑控件应该占据主窗口的整个客户区
MoveWindow (hwndEdit, 0, 0, LOWORD (lParam), HIWORD (lParam), TRUE) ;
return 0 ;

WM_INITMENUPOPUP

switch(lParam)
case 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
case 1:             // Edit menu//1号弹出菜单,即Edit菜单

// Enable Undo if edit control can do it

EnableMenuItem ((HMENU) wParam, IDM_EDIT_UNDO,
SendMessage (hwndEdit, EM_CANUNDO, 0, 0L) ?//向编辑控件询问是否可以执行撤销操作
MF_ENABLED : MF_GRAYED) ;//如果SendMessage返回TRUE则MF_ENABLED,那么EnableMenuItem就将IDM_EDIT_UNDO菜单项使能
//否则将IDM_EDIT_UNDO菜单项变灰禁用

// Enable Paste if text is in the clipboard

EnableMenuItem ((HMENU) wParam, IDM_EDIT_PASTE,
IsClipboardFormatAvailable (CF_TEXT) ?//询问剪贴板是否可用,
MF_ENABLED : MF_GRAYED) ;//使能或者吃灰

// Enable Cut, Copy, and Del if text is selected

SendMessage (hwndEdit, EM_GETSEL, (WPARAM) &iSelBeg,//向编辑控件询问当前高亮情况,写到iSelBeg和iSelEnd中
(LPARAM) &iSelEnd) ;

iEnable = iSelBeg != iSelEnd ? MF_ENABLED : MF_GRAYED ;//iEnable是剪切拷贝删除的开关,
//如果iSelBeg==iSelEnd说明没有高亮,则iEnable=MF_GRAYED吃灰

EnableMenuItem ((HMENU) wParam, IDM_EDIT_CUT, iEnable) ;//根据iEnable情况决定三个菜单是吃灰还是使能
EnableMenuItem ((HMENU) wParam, IDM_EDIT_COPY, iEnable) ;
EnableMenuItem ((HMENU) wParam, IDM_EDIT_CLEAR, iEnable) ;
break ;
case 2
1
2
3
4
5
6
7
8
9
10
11
12
case 2:             // Search menu//第二个弹出菜单,查找菜单

// Enable Find, Next, and Replace if modeless
// dialogs are not already active

iEnable = hDlgModeless == NULL ?//如果当前已经有非模态对话框实例,即已经有打开的查找或者查换窗口,则不允许再打开第二个,吃灰
MF_ENABLED : MF_GRAYED ;

EnableMenuItem ((HMENU) wParam, IDM_SEARCH_FIND, iEnable) ;//根据iEnable决定这三个菜单是吃灰还是使能
EnableMenuItem ((HMENU) wParam, IDM_SEARCH_NEXT, iEnable) ;
EnableMenuItem ((HMENU) wParam, IDM_SEARCH_REPLACE, iEnable) ;
break ;

WM_COMMAND

控件消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (lParam && LOWORD (wParam) == EDITID)//如果是来自编辑控件的消息,LOWORD(wParam)是找茬的控件的索引,即菜单值
{
switch (HIWORD (wParam))//HIWORD(wParam)存放的是消息类型
{
case EN_UPDATE ://有更新,比如增删字符
bNeedSave = TRUE ;//bNeedSave标志着文件脏了,需要保存
return 0 ;

case EN_ERRSPACE ://内存不够了
case EN_MAXTEXT ://超过最大字数32000字了
MessageBox (hwnd, TEXT ("Edit control out of space."),//弹出消息框
szAppName, MB_OK | MB_ICONSTOP) ;
return 0 ;
}
break ;
}
菜单消息
switch(LOWORD(wParam))
IDM_FILE_NEW
1
2
3
4
5
6
7
8
9
10
11
case IDM_FILE_NEW://新建文件 
if (bNeedSave && IDCANCEL == AskAboutSave (hwnd, szTitleName))
//如果当前文件脏了,并且弹出的询问保存对话框中选择了是,直接返回,不新建窗口
return 0 ;
//如果刚才的文件不需要保存,或者弹窗询问是否保存时选择了否,则放弃刚才的文件,新建一个
SetWindowText (hwndEdit, TEXT ("\0")) ;//设置编辑控件初始内容为空,这里如果写上其他字符串则作为编辑控件的第一行
szFileName[0] = '\0' ;//无文件名
szTitleName[0] = '\0' ;//无标题名
DoCaption (hwnd, szTitleName) ;//更新主窗口标题栏
bNeedSave = FALSE ;//设置不需要保存
return 0 ;
IDM_FILE_OPEN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case IDM_FILE_OPEN://打开文件 
if (bNeedSave && IDCANCEL == AskAboutSave (hwnd, szTitleName))//和IDM_FILE_NEW时一个逻辑
return 0 ;

if (PopFileOpenDlg (hwnd, szFileName, szTitleName))//PopFileOpenDlg封装了GetOpenFileName函数
//szFileName保存了用户希望打开的文件名,设置标题栏应该表现的值
{
if (!PopFileRead (hwndEdit, szFileName))//PopFileRead尝试往hwndEdit控件读取szFileName文件
{//如果读取失败则弹窗报错
OkMessage (hwnd, TEXT ("Could not read file %s!"),//封装MessageBox
szTitleName) ;
szFileName[0] = '\0' ;
szTitleName[0] = '\0' ;
}
}

DoCaption (hwnd, szTitleName) ;//修改标题栏
bNeedSave = FALSE ;//刚打开文件不需要保存
return 0 ;
IDM_FILE_SAVE&&IDM_FILE_SAVE_AS
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
case IDM_FILE_SAVE://保存文件
if (szFileName[0])//如果已经有文件名,说明要么是保存过了,要么是打开的其他文件
{
if (PopFileWrite (hwndEdit, szFileName))//通知hwndEdit控件输出到szFileName文件
{
bNeedSave = FALSE ;//刚保存过,不需要再保存
return 1 ;//返回1表明保存成功
}
else//保存失败
{
OkMessage (hwnd, TEXT ("Could not write file %s"),
szTitleName) ;
return 0 ;
}
}
//保存文件时还有一种情况,就是新建的,之前从来没有保存的文件,这种情况下
// 保存和另存为有相同的逻辑
// fall through
case IDM_FILE_SAVE_AS://另存为
if (PopFileSaveDlg (hwnd, szFileName, szTitleName))//弹出保存对话框
{
//进入if则表明保存成功

DoCaption (hwnd, szTitleName) ;//修改文件名为新的szTitleName

if (PopFileWrite (hwndEdit, szFileName))//通知hwndEdit写入szFileName文件
{
bNeedSave = FALSE ;//刚保存过,不需要再保存
return 1 ;
}
else//保存失败
{
OkMessage (hwnd, TEXT ("Could not write file %s"),
szTitleName) ;
return 0 ;
}
}
return 0 ;
IDM_FILE_PRINT
1
2
3
4
5
6
case IDM_FILE_PRINT://打印
if (!PopPrntPrintFile (hInst, hwnd, hwndEdit, szTitleName))//桩函数,尚未实现
OkMessage (hwnd, TEXT ("Could not print file %s"),
szTitleName) ;
return 0 ;

IDM_APP_EXIT
1
2
3
case IDM_APP_EXIT://关闭窗口
SendMessage (hwnd, WM_CLOSE, 0, 0) ;
return 0 ;
撤剪拷粘清全选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
case IDM_EDIT_UNDO://撤销操作,应该发往编辑控件
SendMessage (hwndEdit, WM_UNDO, 0, 0) ;
return 0 ;

case IDM_EDIT_CUT://剪切
SendMessage (hwndEdit, WM_CUT, 0, 0) ;
return 0 ;

case IDM_EDIT_COPY:
SendMessage (hwndEdit, WM_COPY, 0, 0) ;
return 0 ;

case IDM_EDIT_PASTE:
SendMessage (hwndEdit, WM_PASTE, 0, 0) ;
return 0 ;

case IDM_EDIT_CLEAR:
SendMessage (hwndEdit, WM_CLEAR, 0, 0) ;
return 0 ;

case IDM_EDIT_SELECT_ALL:
SendMessage (hwndEdit, EM_SETSEL, 0, -1) ;
return 0 ;
查找&&查换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case IDM_SEARCH_FIND://查找
SendMessage (hwndEdit, EM_GETSEL, 0, (LPARAM) &iOffset) ;//
hDlgModeless = PopFindFindDlg (hwnd) ;//创建查找框,PopFindFindDlg封装了FindText API
return 0 ;

case IDM_SEARCH_NEXT://查找下一个
SendMessage (hwndEdit, EM_GETSEL, 0, (LPARAM) &iOffset) ;

if (PopFindValidFind ())
PopFindNextText (hwndEdit, &iOffset) ;
else
hDlgModeless = PopFindFindDlg (hwnd) ;

return 0 ;

case IDM_SEARCH_REPLACE://查找替换
SendMessage (hwndEdit, EM_GETSEL, 0, (LPARAM) &iOffset) ;
hDlgModeless = PopFindReplaceDlg (hwnd) ;
return 0 ;
IDM_FORMAT_FONT
1
2
3
4
5
case IDM_FORMAT_FONT://字体格式
if (PopFontChooseFont (hwnd))
PopFontSetFont (hwndEdit) ;//PopFontChooseFont和PopFontSetFont属于同一模块,模块内部会有通信

return 0 ;
帮助&&关于
1
2
3
4
5
6
7
8
case IDM_HELP://帮助
OkMessage (hwnd, TEXT ("Help not yet implemented!"),
TEXT ("\0")) ;
return 0 ;

case IDM_APP_ABOUT: //关于
DialogBox (hInst, TEXT ("AboutBox"), hwnd, AboutDlgProc) ;//使用AboutDlgProc作为模态对话框的窗口过程
return 0 ;

关闭

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
case WM_CLOSE://关闭窗口
if (!bNeedSave || IDCANCEL != AskAboutSave (hwnd, szTitleName))//不需要保存或者询问保存点选的否
DestroyWindow (hwnd) ;//销毁父窗口

return 0 ;

case WM_QUERYENDSESSION ://结束对话或者系统关闭时询问
if (!bNeedSave || IDCANCEL != AskAboutSave (hwnd, szTitleName))
return 1 ;

return 0 ;

case WM_DESTROY:
PopFontDeinitialize () ;//清理逻辑字体
PostQuitMessage (0) ;//发送退出消息,结束消息循环
return 0 ;

messageFindReplace

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
default:
// Process "Find-Replace" messages

if (message == messageFindReplace)//如果是召唤查找框的消息,此时lParam就是查找框结构体的地址
{
pfr = (LPFINDREPLACE) lParam ;//pfr指针获取FINDREPLACE结构体地址

if (pfr->Flags & FR_DIALOGTERM)
hDlgModeless = NULL ;

if (pfr->Flags & FR_FINDNEXT)
if (!PopFindFindText (hwndEdit, &iOffset, pfr))
OkMessage (hwnd, TEXT ("Text not found!"),
TEXT ("\0")) ;

if (pfr->Flags & FR_REPLACE || pfr->Flags & FR_REPLACEALL)
if (!PopFindReplaceText (hwndEdit, &iOffset, pfr))
OkMessage (hwnd, TEXT ("Text not found!"),
TEXT ("\0")) ;

if (pfr->Flags & FR_REPLACEALL)
while (PopFindReplaceText (hwndEdit, &iOffset, pfr)) ;

return 0 ;
}
break ;
}

return DefWindowProc (hwnd, message, wParam, lParam) ;

PopFile.c

整个模块包装了一个对象ofn,模块内的函数都是作用与该ofn对象的,本质上是面向对象风格的

模块变量ofn

1
static OPENFILENAME ofn ;

OPENFILENAME结构体用于保存用户关于保存文件的选择,比如文件名,文件位置等

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
#include <commdlg.h>
typedef struct tagOFNA {
DWORD lStructSize;//本结构体的大小
HWND hwndOwner;//调用对话框的窗口句柄
HINSTANCE hInstance;//对话框模板内存对象
LPCSTR lpstrFilter;//过滤器格式化字符串
LPSTR lpstrCustomFilter;//保留用户选择的过滤器字符串
DWORD nMaxCustFilter;//lpstrCustomFilter的长度
DWORD nFilterIndex;//当前选中的过滤器的下标
LPSTR lpstrFile;//文件路径
DWORD nMaxFile;//文件路径长度
LPSTR lpstrFileTitle;//文件名和拓展名
DWORD nMaxFileTitle;//文件名和拓展名的长度
LPCSTR lpstrInitialDir;//初始目录
LPCSTR lpstrTitle;//对话框标题
DWORD Flags;//标志
WORD nFileOffset;//文件名在lpstrFile中的下标
WORD nFileExtension;//拓展名在lpstrFile中的下标
LPCSTR lpstrDefExt;//默认拓展名
LPARAM lCustData;
LPOFNHOOKPROC lpfnHook;//指向钩子程序
LPCSTR lpTemplateName;
LPEDITMENU lpEditInfo;
LPCSTR lpstrPrompt;
void *pvReserved;
DWORD dwReserved;
DWORD FlagsEx;
} OPENFILENAMEA, *LPOPENFILENAMEA;

lStructSize

本结构体大小,冗余量

hwndOwner

拥有本对话框的窗口句柄

hInstance

如果Flags=OFN_ENABLETEMPLATEHANDLE,那么hInstance时内存中的一个对话框模板

如果Flags=OFN_ENABLETEMPLATE ,那么hInstance是一个模块句柄,该模块中有lpTemplateName指明的对话框模板,

如果Flags=OFN_EXPLORER ,那么系统使用文件资源管理器Explorer风格的对话框

如果Flags=NULL,那么系统使用老式文件资源管理器Explorer风格对话框

lpstrFilter

文件名过滤器,打开文件的时候经常可以看见这个功能

image-20220831173604402

比如上传图片的时候限制后缀为.jpg或者.png格式

实际上是一个字符串

其格式是这样的:

1
2
3
4
static TCHAR szFilter[] = TEXT("废话 \0*.<拓展名>\0") \
TEXT("废话 \0*.<拓展名>\0") \
...\
TEXT("废话 \0*.<拓展名>\0\0") ;//最后两个\0挨着意思是szFilter到头了

废话是写给人看的

本函数在实现的时候就根据\0去分割,

两个\0之间的*.<拓展名>不允许有空格

*是通配符,表示所有字符串

比如

1
2
3
static TCHAR szFilter[] = TEXT ("Text Files (*.TXT)\0*.txt\0")  \
TEXT ("ASCII Files (*.ASC)\0*.asc\0") \
TEXT ("All Files (*.*)\0*.*\0\0") ;

这就允许*.txt,*.asc以及任意格式的文件了

既然允许任意格式,那还费力写txt和asc干啥

如果lpstrFilter=NULL,则不显示任何过滤器

模块函数

PopFileOpenDlg

实质上是封装了GetOpenFileName函数

1
2
3
4
5
6
7
8
9
10
BOOL PopFileOpenDlg (HWND hwnd, PTSTR pstrFileName, PTSTR pstrTitleName)
{
ofn.hwndOwner = hwnd ;//设置窗口主人句柄为主窗口
ofn.lpstrFile = pstrFileName ;//外部指针
ofn.lpstrFileTitle = pstrTitleName ;
ofn.Flags = OFN_HIDEREADONLY | OFN_CREATEPROMPT ;
//隐藏只读选项(不是很明白啥意思),如果不存在文件则给用户新建文件的权限
return GetOpenFileName (&ofn) ;
}

这里Flags中有一个OFN_CREATEPROMPT,设置这个标记,当试图打开一个不存的文件的时候,会弹窗询问是否新建该文件

image-20220910214637043

否则,即如果不将这个Flag置起来,如此打开不存在的文件会被拒绝

image-20220910214723862

GetOpenFileName干了啥?

创建"打开"对话框,允许用户指定要打开的文件或文件夹.

image-20220910215708583

参数&ofn指定了获取文件名,目录放到哪里,以及Flags决定的标志

本函数在主模块PopPad.c的主窗口过程IDM_FILE_OPEN消息中唯一一次被调用,即发生点击Open菜单的时候,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case IDM_FILE_OPEN:
if (bNeedSave && IDCANCEL == AskAboutSave (hwnd, szTitleName))
return 0 ;//判断是否需要保存旧的文件

if (PopFileOpenDlg (hwnd, szFileName, szTitleName))//PopFileOpenDlg会返回是否成功打开(可能因为不存在或者权限 失败,只有成功打开才能继续)
//
{
if (!PopFileRead (hwndEdit, szFileName))
{
OkMessage (hwnd, TEXT ("Could not read file %s!"),
szTitleName) ;
szFileName[0] = '\0' ;
szTitleName[0] = '\0' ;
}
}

DoCaption (hwnd, szTitleName) ;
bNeedSave = FALSE ;
return 0 ;

大概PopFileOpenDlg返回时,ofn结构体已经保存了用户的选择,而PopPad.c中调用该函数时传递的指针已经被PopFileOpenDlg设置为和ofn中的指针同指向了,因此PopPad.c中的szFileName和szTitleName旧获取到了用户希望打开的文件信息

PopFileSaveDlg

1
2
3
4
5
6
7
8
9
BOOL PopFileSaveDlg (HWND hwnd, PTSTR pstrFileName, PTSTR pstrTitleName)
{
ofn.hwndOwner = hwnd ;
ofn.lpstrFile = pstrFileName ;//直接使用外部指针
ofn.lpstrFileTitle = pstrTitleName ;
ofn.Flags = OFN_OVERWRITEPROMPT ;

return GetSaveFileName (&ofn) ;
}

OFN_OVERWRITEPROMPT标志的作用是,保存文件时如果有重名文件则弹窗让用户确认是否覆盖,否则会直接覆盖(可能会因为只读权限导致无法覆盖).

GetSaveFileName函数会创建一个保存对话框,让用户指定保存的位置

image-20220910220340540

显然该函数只会被主模块的Save和Save As菜单消息处理使用

PopFileRead

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
BOOL PopFileRead (HWND hwndEdit, PTSTR pstrFileName)
//将pstrFileName中的所有文字读入poppad,成功则TRUE,否则FALSE
{
BYTE bySwap ;//调换字节时的临时变量
DWORD dwBytesRead ;
HANDLE hFile ;//打开文件句柄,相当于linux上的文件描述符file descriptor
int i, iFileLength, iUniTest ;
PBYTE pBuffer, pText, pConv ;

// Open the file.

if (INVALID_HANDLE_VALUE ==
(hFile = CreateFile (pstrFileName, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, 0, NULL)))
return FALSE ;//判断是否能成功打开文件,如果能则hFile获取文件句柄,否则返回FALSE

// Get file size in bytes and allocate memory for read.
// Add an extra two bytes for zero termination.

iFileLength = GetFileSize (hFile, NULL) ; //iFileLength获取文件总长度
pBuffer = malloc (iFileLength + 2) ;//pBuffer动态在内存上申请iFileLength+2的空间,意思是最后要放\0

// Read file and put terminating zeros at end.

ReadFile (hFile, pBuffer, iFileLength, &dwBytesRead, NULL) ;
//从hFile读取至多iFileLength个字节放到pBuffer中,实际读取了多少个放到dwBytesRead
CloseHandle (hFile) ;//关闭文件句柄
pBuffer[iFileLength] = '\0' ;//pBuffer缓冲区以两个\0结尾
pBuffer[iFileLength + 1] = '\0' ;

// Test to see if the text is unicode

iUniTest = IS_TEXT_UNICODE_SIGNATURE | IS_TEXT_UNICODE_REVERSE_SIGNATURE ;
//刚才是以Ascii码的方式读入的字节,每个字节都是独立的,
//现在需要判断读入的是否是Unicode码,如果是则需要两个字节并一个字
if (IsTextUnicode (pBuffer, iFileLength, &iUniTest))
{//如果真的是unicode编码的,
pText = pBuffer + 2 ;
iFileLength -= 2 ;

if (iUniTest & IS_TEXT_UNICODE_REVERSE_SIGNATURE)
//虽然是unicode,但是每个unicode的两个字节都反过来了,应该调换一下
{
for (i = 0 ; i < iFileLength / 2 ; i++)//每两个字节一组进行调换
{
bySwap = ((BYTE *) pText) [2 * i] ;//暂时存放低字节
((BYTE *) pText) [2 * i] = ((BYTE *) pText) [2 * i + 1] ;//原来的高字节放到新的低字节
((BYTE *) pText) [2 * i + 1] = bySwap ;//原来的低字节放到新的高字节
}
}

// Allocate memory for possibly converted string

pConv = malloc(iFileLength + 2);

// If the edit control is not Unicode, convert Unicode text to
// non-Unicode (ie, in general, wide character).

#ifndef UNICODE
WideCharToMultiByte (CP_ACP, 0, (PWSTR) pText, -1, pConv,
iFileLength + 2, NULL, NULL) ;

// If the edit control is Unicode, just copy the string
#else
lstrcpy ((PTSTR) pConv, (PTSTR) pText) ;//pText拷贝到pConv
#endif

}
else // the file is not Unicode
{
pText = pBuffer ;

// Allocate memory for possibly converted string.

pConv = malloc (2 * iFileLength + 2) ;

// If the edit control is Unicode, convert ASCII text.

#ifdef UNICODE //如果定义了UNICODE码
MultiByteToWideChar (CP_ACP, 0, pText, -1, (PTSTR) pConv,
iFileLength + 1) ;//pConv转到Unicode码,放到pText

// If not, just copy buffer
#else
lstrcpy ((PTSTR) pConv, (PTSTR) pText) ;
#endif
}

SetWindowText (hwndEdit, (PTSTR) pConv) ;//将pConv指向的文字区交给hwndEdit窗口,打印到屏幕
free (pBuffer) ;//事了拂衣去,深藏功与名
free (pConv) ;

return TRUE ;
}

这里调用了很多API

CreateFile
1
2
3
4
5
6
7
8
9
HANDLE CreateFileA(
[in] LPCSTR lpFileName,
[in] DWORD dwDesiredAccess,
[in] DWORD dwShareMode,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
[in] DWORD dwCreationDisposition,
[in] DWORD dwFlagsAndAttributes,
[in, optional] HANDLE hTemplateFile
);

打开或者创建一个文件,返回其文件句柄,lpFileName是希望打开或者创建的文件,dwDesiredAccess是希望的访问权限,包括读写执行.后面使用该文件的时候不能超过CreateFile时限定的权限

dwShareMode一旦置起来,则该文件只允许被当前程序打开,直到本程序释放文件句柄后,才允许文件被下一个程序访问

lpSecurityAttributes安全属性,包括文件句柄的继承性以及访问权限,在<<windows核心编程>>中有详细解释

dwCreationDisposition,指定打开文件的模式,包括文件存在或者不存在时的打开规则

dwFlagsAndAttributes文件属性标志,即创建只读文件或者隐藏文件等

hTemplateFile模板文件句柄,用另一个文件的权限等信息覆盖本文件的设置

在popfile.c中该函数是这样用的:

1
2
3
4
5
6
7
8
9
hFile = CreateFile (
pstrFileName, //从poppad.c主模块中传递过来的文件名
GENERIC_READ, //读权限
FILE_SHARE_READ,//允许共享读
NULL, //使用默认安全属性
OPEN_EXISTING, //打开已经存在的,如果不存在则报错
0, //忽略
NULL//忽略,不使用模板文件
);
GetFileSize
1
2
3
4
DWORD GetFileSize(
[in] HANDLE hFile,
[out, optional] LPDWORD lpFileSizeHigh
);

lpFileSizeHigh返回文件长度的高二字节(文件总大小要占用四个字节,lpFileSizeHigh只返回高2字节,虽然不知道这样返回有啥意义)

调用成功则返回准确的文件大小,一个双字

1
iFileLength = GetFileSize (hFile, NULL) ;

iFileLength获得了文件大小

ReadFile
1
2
3
4
5
6
7
BOOL ReadFile(
[in] HANDLE hFile,
[out] LPVOID lpBuffer,
[in] DWORD nNumberOfBytesToRead,
[out, optional] LPDWORD lpNumberOfBytesRead,
[in, out, optional] LPOVERLAPPED lpOverlapped
);

读取文件内容,从hFile句柄指定的文件读取至多nNumberOfBytesToRead个字节,放到lpBuffer指定的缓冲区,实际读取的字节数还要取决于hFile文件的 大小,lpNumberOfBytesRead存放实际读取到的字节数

lpOverlapped和异步IO有关,现在不管它,默认NULL

1
2
ReadFile (hFile, pBuffer, iFileLength, &dwBytesRead, NULL);

从hFile读取至多iFileLength个字节放到pBuffer中,实际读取了多少个放到dwBytesRead

PopFileWrite

逻辑和PopFileRead基本相同,读改成写就完了

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
BOOL PopFileWrite (HWND hwndEdit, PTSTR pstrFileName)
{
DWORD dwBytesWritten ;
HANDLE hFile ;
int iLength ;
PTSTR pstrBuffer ;
WORD wByteOrderMark = 0xFEFF ;

// Open the file, creating it if necessary

if (INVALID_HANDLE_VALUE ==
(hFile = CreateFile (pstrFileName, GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, 0, NULL)))
return FALSE ;

// Get the number of characters in the edit control and allocate
// memory for them.

iLength = GetWindowTextLength (hwndEdit) ;
pstrBuffer = (PTSTR) malloc ((iLength + 1) * sizeof (TCHAR)) ;

if (!pstrBuffer)
{
CloseHandle (hFile) ;
return FALSE ;
}

// If the edit control will return Unicode text, write the
// byte order mark to the file.

#ifdef UNICODE
WriteFile (hFile, &wByteOrderMark, 2, &dwBytesWritten, NULL) ;
#endif

// Get the edit buffer and write that out to the file.

GetWindowText (hwndEdit, pstrBuffer, iLength + 1) ;
WriteFile (hFile, pstrBuffer, iLength * sizeof (TCHAR),
&dwBytesWritten, NULL) ;

if ((iLength * sizeof (TCHAR)) != (int) dwBytesWritten)
{
CloseHandle (hFile) ;
free (pstrBuffer) ;
return FALSE ;
}

CloseHandle (hFile) ;
free (pstrBuffer) ;

return TRUE ;
}

PopFind.c

本模块实现了对poppad的查找替换的支持

模块全局/静态变量

1
2
3
4
5
6
7
#define MAX_STRING_LEN   256

static TCHAR szFindText [MAX_STRING_LEN] ;//需要查找的文本缓冲区
static TCHAR szReplText [MAX_STRING_LEN] ;//需要替换的文本缓冲区

...
static FINDREPLACE fr ;

关于FINDREPLACE

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct tagFINDREPLACEW {
DWORD lStructSize;//本结构体大小
HWND hwndOwner;//窗口句柄
HINSTANCE hInstance;//对话框内存对象
DWORD Flags;//标志
LPWSTR lpstrFindWhat;//需要查找的文字
LPWSTR lpstrReplaceWith;//需要替换成的文字
WORD wFindWhatLen;//需要查找的文字长度
WORD wReplaceWithLen;//需要替换成的文字长度
LPARAM lCustData;//钩子过程参数
LPFRHOOKPROC lpfnHook;//钩子过程
LPCWSTR lpTemplateName;//查找替换框模板
} FINDREPLACEW, *LPFINDREPLACEW;

模块函数

PopFindFindDlg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HWND PopFindFindDlg (HWND hwnd)
{
static FINDREPLACE fr ; // must be static for modeless dialog!!!

fr.lStructSize = sizeof (FINDREPLACE) ;
fr.hwndOwner = hwnd ;
fr.hInstance = NULL ;
fr.Flags = FR_HIDEUPDOWN | FR_HIDEMATCHCASE | FR_HIDEWHOLEWORD ;//隐藏查找方向,隐藏大小写匹配,隐藏全词匹配
fr.lpstrFindWhat = szFindText ;//从静态变量获取要查找的字符串
fr.lpstrReplaceWith = NULL ;//之查找不替换,不需要本缓冲区
fr.wFindWhatLen = MAX_STRING_LEN ;//最大查找长度
fr.wReplaceWithLen = 0 ;
fr.lCustData = 0 ;
fr.lpfnHook = NULL ;
fr.lpTemplateName = NULL ;

return FindText (&fr) ;//调用API
}

这里fr.Flags= FR_HIDEUPDOWN | FR_HIDEMATCHCASE | FR_HIDEWHOLEWORD ;

意思是:

隐藏查找方向,隐藏大小写匹配,隐藏全词匹配

如果这些都不隐藏,则查找对话框是这样的

image-20220910231639276

都隐藏之后的查找框

image-20220910231722908

FindText只需要fr结构体作为参数,如果查找成功则返回查找框的句柄

PopFindReplaceDlg

逻辑和PopFindFindDlg相似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HWND PopFindReplaceDlg (HWND hwnd)
{
static FINDREPLACE fr ; // must be static for modeless dialog!!!

fr.lStructSize = sizeof (FINDREPLACE) ;
fr.hwndOwner = hwnd ;
fr.hInstance = NULL ;
fr.Flags = FR_HIDEUPDOWN | FR_HIDEMATCHCASE | FR_HIDEWHOLEWORD ;
fr.lpstrFindWhat = szFindText ;
fr.lpstrReplaceWith = szReplText ;
fr.wFindWhatLen = MAX_STRING_LEN ;
fr.wReplaceWithLen = MAX_STRING_LEN ;
fr.lCustData = 0 ;
fr.lpfnHook = NULL ;
fr.lpTemplateName = NULL ;

return ReplaceText (&fr) ;
}

PopFindFindText

查找逻辑函数

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
BOOL PopFindFindText (HWND hwndEdit, int * piSearchOffset, LPFINDREPLACE pfr)
{
int iLength, iPos ;
PTSTR pstrDoc, pstrPos ;

// Read in the edit document

iLength = GetWindowTextLength (hwndEdit) ;
//hwndEdit标题栏字数,如果是编辑控件则是该控件中已经输入的字数

if (NULL == (pstrDoc = (PTSTR) malloc ((iLength + 1) * sizeof (TCHAR))))//申请相应大小的堆空间
return FALSE ;

GetWindowText (hwndEdit, pstrDoc, iLength + 1) ;//将编辑空间中的所有字节拷贝到pstrDoc缓冲区

// Search the document for the find string

pstrPos = _tcsstr (pstrDoc + * piSearchOffset, pfr->lpstrFindWhat) ;
//调用字符串查找函数,如果找到则返回* piSearchOffset之后首次匹配的指针位置
free (pstrDoc) ;

// Return an error code if the string cannot be found

if (pstrPos == NULL)//如果是NULL则表明没有查找到
return FALSE ;

// Find the position in the document and the new start offset

iPos = pstrPos - pstrDoc ;//否则查找到了,返回下标(指针做差得到距离,又pstrDoc是缓冲区起始指针,因此该距离就是下标)
* piSearchOffset = iPos + lstrlen (pfr->lpstrFindWhat) ;//移动查找起始指针,避免下一次重复查找

// Select the found text

SendMessage (hwndEdit, EM_SETSEL, iPos, * piSearchOffset) ;//通知编辑空间,对偏移量*piSearchOffset,长度为iPos的文字进行高亮选中
SendMessage (hwndEdit, EM_SCROLLCARET, 0, 0) ;//将刚才高亮的区域滚动进入客户区

return TRUE ;
}

到此需要再看一下主模块中对于调用查找框和使用查找逻辑函数,是怎么配合的

在主窗口过程的最后,default块中,处理查找替换消息

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
             case IDM_SEARCH_FIND:
SendMessage (hwndEdit, EM_GETSEL, 0, (LPARAM) &iOffset) ;//获取高亮区域,将其位置放到iOffset
hDlgModeless = PopFindFindDlg (hwnd) ;//调用PopFindFindDlg,获取到的输入都放在popfind模块,主模块不关心
return 0 ;

....

//messageFindReplace在主窗口过程一开始被注册messageFindReplace = RegisterWindowMessage (FINDMSGSTRING) ;
//在commdlg.h中宏定义为#define FINDMSGSTRINGW L"commdlg_FindReplace"
//也就是说只要是调用了查找框,就会发送一条该注册消息
if (message == messageFindReplace)//调用查找框之后会收到该消息,顺理成章
{
pfr = (LPFINDREPLACE) lParam ;

if (pfr->Flags & FR_DIALOGTERM)
hDlgModeless = NULL ;

if (pfr->Flags & FR_FINDNEXT)
if (!PopFindFindText (hwndEdit, &iOffset, pfr))//IDM_SEARCH_FIND会修改iOffset为当前高亮位置
OkMessage (hwnd, TEXT ("Text not found!"),//如果没有查到则弹窗报告
TEXT ("\0")) ;

if (pfr->Flags & FR_REPLACE || pfr->Flags & FR_REPLACEALL)
if (!PopFindReplaceText (hwndEdit, &iOffset, pfr))
OkMessage (hwnd, TEXT ("Text not found!"),
TEXT ("\0")) ;

if (pfr->Flags & FR_REPLACEALL)
while (PopFindReplaceText (hwndEdit, &iOffset, pfr)) ;

return 0 ;
}

也就是说,用户通过菜单或者ctrl+f快捷键,让主窗口过程处理IDM_SEARCH_FIND,创建了模态查找框,由于已经注册过messageFindReplace和查找框关联,主窗口会接着收到该消息,查找框用来获取用户希望查找的字符串,数据存放到popfind模块中,不归主模块poppad.c管理

然后messageFindReplace中调用PopFindFindText,再次将控制交给popfind模块,该模块的函数可以访问该模块的静态变量

PopFindFindText就在该模块中发挥作用了

PopFindNextText

查找下一个

1
2
3
4
5
6
7
8
BOOL PopFindNextText (HWND hwndEdit, int * piSearchOffset)
{
FINDREPLACE fr ;

fr.lpstrFindWhat = szFindText ;

return PopFindFindText (hwndEdit, piSearchOffset, &fr) ;
}

只需要更新一下起始查找位置然后套用PopFindFindText就可以了

PopFindReplaceText

查找并替换

1
2
3
4
5
6
7
8
9
10
11
12
13
BOOL PopFindReplaceText (HWND hwndEdit, int * piSearchOffset, LPFINDREPLACE pfr)
{
// Find the text

if (!PopFindFindText (hwndEdit, piSearchOffset, pfr))
return FALSE ;

// Replace it

SendMessage (hwndEdit, EM_REPLACESEL, 0, (LPARAM) pfr->lpstrReplaceWith) ;

return TRUE ;
}

首先调用PopFindFindText找到目标字符串,然后给编辑控件发EM_REPLACESEL消息,提醒编辑控件用pfr->lpstrReplaceWith替换原来的字符串

而pfr->lpstrReplaceWith这个值是之前调用模态查替对话框获得的输入

替换编辑控件中哪个位置的字符串呢?

这个不需要操心,因为查替首先要查找,查找到了就会高亮,需要替换的就是高亮的部分

PopFindValidFind

是否是有效查找,待查缓冲区非空才有效

1
2
3
4
BOOL PopFindValidFind (void)
{
return * szFindText != '\0' ;
}

Manim 文本

MarkupText

Text

image-20220907163441733
参数 说明
text str对象,需要写入的字符串
color str对象,文字的颜色,默认为WHITE
font 文字的字体
font_size 文字的大小,默认为48
size 文字的缩放比例,默认为1,表示原来的尺寸
tab_width 制表符tab的宽度,默认为4,表示4个空格的宽度
slant 设置斜体,默认为NORMAL。设为ITATIC表示文字设为斜体
weight 设置加粗,默认为NORMAL。设为BOLD表示文字加粗
gradient 可迭代对象,且元素为颜色,表示设置的渐变色的各个节点
t2c 字典Dict,子串到颜色的映射表
t2g 字典Dict,子串到渐变色节点迭代器的映射表

Text类只接受一个字符串,不支持数学公式,不支持',换行可以用''

属性

在构造函数上通过键值对的形式传递参数,修改属性

颜色相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
text = Text('Hello', color=BLUE)

text = Text('Hello, world!', t2c={'world':BLUE})

text = Text('Hello', gradient=(BLUE, GREEN,RED))#颜色渐变,从左到右依次


text = Text( #精确模式
'Google',
t2c={
'[:1]':'#3174f0', '[1:2]':'#e53125', #下标0到1之间使用蓝色,1到2之间使用红色...
'[2:3]':'#fbb003', '[3:4]':'#3174f0',
'[4:5]':'#269a43', '[5:]':'#e53125',
}
)

text = Text( #继承自Mobject的属性#就和调css差不多
'empire march!',
fill_color=RED,#填充颜色
fill_opacity=0.5,#填充色的透明度
stroke_color=BLACK,#边线颜色
stroke_width=2,#边线宽度
)

字体相关

1
2
text = Text('Hello', font='Source Han Sans')#修改整体字体
text = Text('Hello, world!', t2f={'world':'Forte'})#修改局部字体

倾斜相关

1
2
text = Text('Hello', slant=ITALIC)#整体倾斜,slant要么是ITALIC要么是NORMAL
text = Text('Hello, world!', t2s={'world':ITALIC})#局部倾斜

粗细相关

1
2
text = Text('Hello', weight=BOLD)	#整体加粗,weight=NORMAL/BOLD
text = Text('Hello, world!', t2w={'world':BOLD}) #局部加粗

大小相关

1
text = Text('Hello', size=5)#整体5号

行间距

line_spacing_height,lsh

1
text = Text('Hello\nWorld', lsh=1.5)#1.5倍行间距,使用\n换行时有效

函数

Text类对象实例化之后,作用于对象的成员函数

1
2
3
4
5
6
7
8
9
10
self.play(FadeIn(text))
self.wait()
text.set_color(RED)#更改颜色
self.wait()
text[1:6].set_color(BLUE)#更改局部颜色
self.wait()
text.set_color_by_gradient(RED,BLACK)#更改渐变色
self.wait()
text[4:8].set_color_by_gradient(WHITE,BLUE)#更改局部渐变色
self.wait()

SingleStringTex

manim中Tex的基类,主要用于渲染单行公式或者数学符号.

也可以使用换行符//渲染多行公式

属性 说明
tex_string str对象,表示输入的tex代码
fill_opacity float,填充的透明度
font_size 字体大小,默认为48
alignment str对象,对齐方式,默认为
1
2
3
4
5
class Main(Scene):
def construct(self):
tex=SingleStringTex(r"f(x)=a_0+a_1x+a_2x^2+...+a_nx^n=\sum_{i=0}^n a_i x^i")
tex.set_color(BLUE)
self.play(Write(tex))

效果

image-20220909100945214

文字转换为公式的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from turtle import left
from manimlib import *

class Main(Scene):
def construct(self):
text=Text("Definition of the ellipse")
tex2 = SingleStringTex(
tex_string=r"\frac{x^2}{a^2}+\frac{y^2}{b^2}=1",
font_size=80
)
self.add(text)
self.wait()
self.play(ReplacementTransform(text, tex2), run_time=1)
self.wait()
Main4

公式上色

使用字典映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from turtle import left
from manimlib import *

class Main(Scene):
def construct(self):
color_dict={#颜色字典
"x":RED,#x字母都上色红色
"f":PINK,
"a":GREEN
}
tex=Tex(
r"f(x)=a_0+a_1x+a_2x^2+...+a_nx^n=\sum_{i=0}^n a_i x^i",
font_size=60,
tex_to_color_map=color_dict#使用颜色字典
)
self.add(tex)
self.play(Write(tex))
# self.wait()

效果: Main5

Tex

Tex是SingleStringTex的派生类,可以接收任意多个tex代码作为参数.

如果想要使用多行公式,不如使用换行符//,在typora中编辑好了之后放到SingleStringTex中

TexText

Tex的派生类

1
2
3
4
5
class TexText(Tex):
CONFIG = {
"math_mode": False,
"arg_separator": "",
}

math_mode默认是false,此时的用法和typora中很像

BulletedList

项目列表,TexText的子类

用于产生条目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from turtle import left, position
from manimlib import *

class demo(Scene):
def construct(self):
text=Text("To-Do List")
self.add(text)
self.play(Write(text))
self.wait()
text2=text.copy()
text2.move_to(np.array([0,3,0]))
self.play(ReplacementTransform(text,text2))#产生移动效果
tex=BulletedList(#项目列表
"win32 WinMine",
"win32 notepad",
"x86 assembly",
"linux kernel 0.12"
)
self.add(tex)
self.play(Write(tex))

效果

Main6

Title

参数 说明
scale_factor float或者int,缩放比例,用来控制标题大小。默认值为1,表示不做缩放
include_underline bool,表示标题是否需要下划线,默认为True
underline_width float或者int,表示标题下下划线的宽度,默认为屏幕宽度减去2
match_underline_width_to_text bool,表示是否需要让下划线宽度适应于标题的宽度,默认为False
underline_buff float,下划线的厚度,默认为MED_SMALL_BUFF(也就是0.25)

数学公式渲染的标题,好处就是自动定在比较靠上的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from turtle import left, position
from manimlib import *

class Main(Scene):
def construct(self):
title=Title(
r"TODO List"
)
self.play(Write(title))
tex=BulletedList(#项目列表
"win32 WinMine",
"win32 notepad",
"x86 assembly",
"linux kernel 0.12"
)
self.add(tex)
self.play(Write(tex))

image-20220909105811995

如果设置tex支持中文,则渲染效果是很好的,但是所有数学公式的渲染都需要花费时间,这是tex相对于text的最大缺点

BraceLabel

SingleStringTex的派生类,增强的Brace,可以在花括号上写字了

用于标注形体长度等等

1
2
3
4
5
6
7
8
9
10
11
12
from manimlib import *

class Main(Scene):
def construct(self):
circle=Circle()
self.play(Write(circle))
bl=BraceLabel(
circle,
text="d=10",
brace_direction=UP
)
self.play(Write(bl))
image-20220909110914772

BraceText

类似BraceLabel,但是允许$行内公式$

1
2
3
4
5
6
7
8
9
10
11
12
from manimlib import *

class Main(Scene):
def construct(self):
circle=Circle()
self.play(Write(circle))
bt=BraceText(
obj=circle,
text="直径$=10$",
brace_direction=UP
)
self.play(Write(bt))
image-20220909111120584

Manim 入门

参考快速入门 - ManimGL 文档

在manim根目录下面建立start.py文件

1
2
3
4
5
6
7
8
manim/
├── manimlib/
│ ├── animation/
│ ├── ...
│ ├── default_config.yml
│ └── window.py
├── (custom_config.yml)
└── start.py

从例程开始

1
2
3
4
5
6
7
8
9
from manimlib import *

class SquareToCircle(Scene):
def construct(self):
circle = Circle()
circle.set_fill(RED, opacity=1)
circle.set_stroke(PINK, width=4)

self.add(circle)

执行命令

1
manimgl start.py SquareToCircle

产生一个红色粉圈的圆点

image-20220906154543534

可以使用键盘鼠标移动画面

  • 滚动鼠标中键来上下移动画面
  • 按住键盘上 z 键的同时滚动鼠标中键来缩放画面
  • 按住键盘上 f 键的同时移动鼠标来平移画面
  • 按住键盘上 d 键的同时移动鼠标来改变三维视角
  • 按下键盘上 r 键恢复到最初的视角

最后,你可以通过按 q 来关闭窗口并退出程序.

整个代码算是定义了一个类SquareToCircle,它是Scene的子类,然后重写了父类的函数construct

1
2
3
4
def construct(self) -> None:
# Where all the animation happens
# To be implemented in subclasses
pass

从这个函数中的注释看,应该在该函数中定义所有动画.

然而貌似这只是创建了一个类,并没有实例化啊

在执行命令时已经暗藏玄机了

为了运行这个程序绘制动画,我们并没有使用

1
python start.py

这个命令,如果真的这样写了什么都不会发生,因为我们就创建了一个子类但是没有实例化,相当于没有任何动作

我们事实上用到的命令是

1
manimgl start.py SquareToCircle

这后面跟着一个参数SquareToCircle,可以大胆的猜测,

manimgl使用start.py这个文件中的SquareToCircle类创造实例去了,然后就执行了construct函数中的动作

之前从来没有学过python,从manim的main模块入手,看看控制流是怎么样的

从命令行开始的控制流

main模块

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
#!/usr/bin/env python
from manimlib import __version__
import manimlib.config
import manimlib.extract_scene
import manimlib.logger
import manimlib.utils.init_config


def main():
print(f"ManimGL \033[32mv{__version__}\033[0m")

args = manimlib.config.parse_cli()#命令行语法分析
if args.version and args.file is None:#
return
if args.log_level:
manimlib.logger.log.setLevel(args.log_level)

if args.config:
manimlib.utils.init_config.init_customization()
else:
config = manimlib.config.get_configuration(args)
scenes = manimlib.extract_scene.main(config)

for scene in scenes:
scene.run()


if __name__ == "__main__":
main()

main函数里首先打印了manim版本信息,运行时也是这样的

1
2
3
PS D:\manim> manimgl start.py SquareToCircle 
ManimGL v1.6.1
...

然后调用了manim命令行的语法分析器,将结果保存到args数组中

这个语法分析器干了啥呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def parse_cli():
try:
parser = argparse.ArgumentParser()
module_location = parser.add_mutually_exclusive_group()
module_location.add_argument(
"file",
nargs="?",
help="Path to file holding the python code for the scene",
)
parser.add_argument(
"scene_names",
nargs="*",
help="Name of the Scene class you want to see",
)
parser.add_argument...
parser.add_argument(
"--log-level",
help="Level of messages to Display, can be DEBUG / INFO / WARNING / ERROR / CRITICAL"
)
args = parser.parse_args()
return args

调用了python自带的参数语法分析器,然后给他添加了很多规则

目标模块位置不包括在参数内,目标模块位置的键叫做"file"

对于参数,添加了一些键比如"scene_names","--log-level"等等

最后用args数组保存语法分析器的运行结果

以后再写shell的时候可以借鉴python的语法分析器了

因此main函数中调用args.version,args.log_level等等都是args数组中的键了

如果命令行参数有表明本次执行命令是为了修改设置的(if args.config),

则调用manimlib.utils.init_config.init_customization将命令行给出的各种参数保存到库中

否则,即本次命令不是为了设置,那么就是为了运行动画了

调用manimlib.extract_scene.main用scenes数组保存所有场景类的实例,

这个函数干了啥呢?

1
2
3
4
5
6
7
8
9
10
def main(config):
module = config["module"]
scene_config = get_scene_config(config)
if module is None:
# If no module was passed in, just play the blank scene
return [BlankScene(**scene_config)]

all_scene_classes = get_scene_classes_from_module(module)
scenes = get_scenes_to_render(all_scene_classes, scene_config, config)
return scenes

config来自库中的设置,也可以命令行上修改

module获取命令行指定的模块名

scene_config获取场景设置

如果命令行上没有传递目标模块,则执行默认的黑色场景

all_scene_classes从目标模块module中获取所有的scene子类

1
2
3
4
5
6
7
8
9
10
11
def get_scene_classes_from_module(module):
if hasattr(module, "SCENES_IN_ORDER"):
return module.SCENES_IN_ORDER
else:
return [
member[1]
for member in inspect.getmembers(
module,
lambda x: is_child_scene(x, module)#判断是否是scene的子类
)
]

然后get_scenes_to_render将每个scene子类实例化,并返回对象数组

然后遍历scenes数组,逐一运行每一个场景实例

1
2
for scene in scenes:
scene.run()

调用的是每个对象的run函数

run函数干了啥呢?它进行了一些设置,比如设置动画开始运行的时间,

1
2
3
run()->
construct()#执行construct中定义的动画
interact()#允许交互

终于就调用到我们自己写的construct函数了

总结:

主模块干了这么几件事:

1.语法分析,分割命令行

2.获取目标模块中的所有scene的子类,并给每个该种类创建实例

3.遍历执行每个该种类的run函数,run会首先调用construct绘制动画,然后调用interact,允许用户和动画调用

这大概就是控制流的方向

自定义construct

粗略地看完控制流,回到start.py中,看看construct是怎么写的

1
2
3
4
5
6
def construct(self):
circle = Circle()
circle.set_fill(RED, opacity=1)
circle.set_stroke(PINK, width=4)

self.add(circle)

创建了一个Circle类的实例,然后调用两个setter函数设置了图形的填充颜色,边线颜色,

然后把该实例添加到SquareToCircle实例的mobjects数组中,该数组用来存放需要被动画演示的对象

可以大胆推测,当SquareToCircle实例创建之后会遍历mobjects数组演示这些对象

那么Circle类是怎么定义的呢?

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
class Circle(Arc):
CONFIG = {
"color": RED,
"close_new_points": True,
"anchors_span_full_range": False
}

def __init__(self, start_angle: float = 0, **kwargs):
Arc.__init__(self, start_angle, TAU, **kwargs)

def surround(
self,
mobject: Mobject,
dim_to_match: int = 0,
stretch: bool = False,
buff: float = MED_SMALL_BUFF
):
# Ignores dim_to_match and stretch; result will always be a circle
# TODO: Perhaps create an ellipse class to handle singele-dimension stretching

self.replace(mobject, dim_to_match, stretch)
self.stretch((self.get_width() + 2 * buff) / self.get_width(), 0)
self.stretch((self.get_height() + 2 * buff) / self.get_height(), 1)

def point_at_angle(self, angle: float) -> np.ndarray:
start_angle = self.get_start_angle()
return self.point_from_proportion(
(angle - start_angle) / TAU
)

def get_radius(self) -> float:
return get_norm(self.get_start() - self.get_center())

这个Circle类已经经过了层层继承,算是曾曾曾...孙子类了

object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
classDiagram 
class object {
python中的万类之源
}
class Mobject{
manim中形状的父类
}
class VMobject{
矢量图形的父类
}
class TipableVMobject{
可以带箭头的矢量图的父类
}
class Arc{
圆弧图形的父类
}
class Circle{
圆圈类
}
object <|-- Mobject
Mobject <|-- VMobject
VMobject <|-- TipableVMobject
TipableVMobject <|-- Arc
Arc <|-- Circle

添加动画

1
2
3
4
5
6
7
8
9
10
11
12
13
from manimlib import *

class SquareToCircle(Scene):
def construct(self):
circle = Circle()
circle.set_fill(BLUE, opacity=0.5)
circle.set_stroke(BLUE_E, width=4)
square = Square()

self.play(ShowCreation(square))
self.wait()
self.play(ReplacementTransform(square, circle))
self.wait()

相比于上一次,增加了一个展示创建矩形和矩形变圆圈的动画

1
2
3
4
5
6
square = Square()

self.play(ShowCreation(square))#展示创建一个矩形的过程
self.wait()#默认停顿DEFAULT_WAIT_TIME 秒
self.play(ReplacementTransform(square, circle))#展示矩形转换为原型的过程
self.wait()

启用交互

在最后加上一句

1
self.embed()

当该函数执行之后,iPython终端会变成

1
2
3
4
5
In [1]: play(circle.animate.stretch(4, dim=0))

In [2]: play(circle.animate.stretch(4, dim=0))

In [3]:

In [n]:之后输入命令就可以了

此时所有的函数都是对作用于selft的,play就相当于self.play,当然写着self.也不会错

在iPython终端上调用play(ReplacementTransform(circle, square)),就实现了从矩形再变灰圆圈

manim命令行

环境变量Path里有一个python<版本号>\Scripts\目录

image-20220906175408295

这下面不知道什么时候就有了

image-20220906175442199

也就是说不从manim 根目录下面也可以使用该命令

1
2
manimgl <code>.py <Scene> <flags>
manim-render <code>.py <Scene> <flags>

其中

<code>.py是源文件

<scene>是要渲染的场景类,如果不写并且<code>.py中只有一个类,则渲染这个类,否则需要列出所有类

<flags>选项

flags选项

常用的有

  • -w 把场景写入文件
  • -o 把场景写入文件并打开
  • -s 跳到最后只展示最后一帧
  • -so 保存最后一帧并打开
  • -n <number> 跳到场景中第 n 个动画
  • -f 打开窗口全屏

-w,-o

比如manimgl start.py -w之后默认会在当前工作目录下面生成一个video文件夹,

里面有一个SquareToCircle.mp4文件,显然是按照场景类名命令的文件名

可以使用--video_dir VIDEO_DIR修改该导出目录

-o选项的作用类似于-w,只不过导出文件后又打开了

-n number

-n <number>跳转到场景中的第n个动画,啥意思呢?

每一个self.play都是一个动画

1
2
self.play(ShowCreation(square)) #第一个动画
self.play(ReplacementTransform(square, circle)) #第二个动画

-n number1,bumber2...只渲染指定的动画

-c <color>

设置背景颜色,比如WHITE就是白色

更多参数

详见命令行参数和配置 - ManimGL 文档

个性化设置

在manim的各级子目录下面都可以建立custom_config.yml文件

子目录终端custom_config.yml会覆盖父目录中custom_config.yml的设置

只要有custom_config.yml,就会覆盖manim/manimlib/default_config.yml中的设置

命令行上的设置又会覆盖所有yml中的设置

1
2
3
4
5
6
7
8
9
10
manim/
├── manimlib/
│ ├── animation/
│ ├── ...
│ ├── default_config.yml
│ └── window.py
├── project/
│ ├── code.py
│ └── custom_config.yml
└── custom_config.yml

也可以在命令行上手工指定配置文件的位置

1
manimgl project/code.py --config_file /path/to/custom_config.yml

这个配置文档应该怎么写呢?

manim/manimlib/default_config.yml给出了样本

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
directories:
mirror_module_path: False #是否在output指定的目录下建立项目并输出文件
output: "" #输出目录需要mirror_module_path: True有效
raster_images: "" #像素图像源
vector_images: "" #矢量图源
sounds: "" #声音文件源
temporary_storage: "" #临时存储占用的目录
tex:
executable: "latex" #编译latex使用的可执行程序
template_file: "tex_template.tex" #使用的latex模板,模板,模板
intermediate_filetype: "dvi" #编译后产生的中间矢量文档类型,latex->dvi;xelatex->xdv
text_to_replace: "[tex_expression]"
universal_import_line: "from manimlib import *" #直接进入交互模式时需要导入的包
style:
font: "Consolas" #默认字体类型
text_alignment: "LEFT" #字体对齐模式
background_color: "#333333" #默认背景颜色,灰色
window_position: UR #窗口在显示器中的位置,默认是Uppder,Right,右上角,U/O/D,L/O/R
window_monitor: 0 #在哪个显示器出现,默认是0号
full_screen: False #是否全屏展示,默认不全屏
break_into_partial_movies: False #是否将每个self.play和self.wait单独分成一个视频
camera_resolutions: #导出分辨率
low: "854x480" #低级分辨率低
med: "1280x720"
high: "1920x1080"
4k: "3840x2160"
default_resolution: "high" #默认是high等级的,已经够用了
fps: 30 #帧率

Manim项目结构

来自Manim 结构 - ManimGL 文档

Manim structure

Manim完整控制流

https://fastly.jsdelivr.net/gh/manim-kindergarten/CDN@master/manimgl_assets/manim_shaders_process_cn.png

玩具内核V0.2

LDT和TSS

0.1内核版本时内核只用到了GDT,没有用到LDT.

这就导致内核和所有用户任务占用的内存空间是放到一起存储的

内核当然不愿意和肤浅的用户程序在同一层上

于是使用LDT让用户任务成为人下人

image-20220902150426682

TSS:Task Segment State,任务状态表,当任务被切换时,本次执行到最后的堆栈指针,程序计数器,各种通用寄存器等都要保存,方便下一次继续执行本任务的时候恢复状态

TSS结构

image-20220902160852184

这里除了ESP指向的堆栈,还额外定义了三个堆栈,即ESP0,1,2分别指向的堆栈

这是因为堆栈段的权限必须始终保持和当前代码段权限相同

用户程序权限最低,它可能调用2,1,0环上的代码,那么就得跟着切换堆栈

多个附加堆栈段都会在程序自己的LDT中注册,在TSS中只会保留这些附加堆栈的选择子和段内偏移量

LDT:局部段描述符表,只维护一个任务的分段,比如堆栈段,数据段,代码段

LDTR:LDT寄存器,负责记录当前正在执行的任务的LDT基地址,LDTR只有一个,哪个任务正在执行就用LDTR记录哪一个的LDT基址

TR:任务状态表寄存器,也是只有一个,指向当前正在执行的任务

在段选择子中第三位(位2)

image-20220902160111155

就是区分该索引是指向GDT的还是LDT的,由于这个段选择子是当前任务给出的,因此此时的LDT必然是当前任务的LDT,此时LDTR必然爆粗的是当前任务的LDT基地址,只需要拿着索引去查LDT就可以获得段地址了

LDT和GDT还有一点不同是,GDT[0]是空的不使用,但是LDT[0]是可用的

私有空间和公有空间

image-20220902161503214

每个程序都有自己的代码段数据段堆栈段等私有空间,多个程序共享内核的系统调用还有运行库,这属于公有空间

一般情况下程序运行在私有空间,只有需要系统调用的时候或者需要调用库函数的时候,才会转到公有空间去执行

特权级

特权级定义

CPL:当前正在执行的代码的权限

RPL:段选择子权限

实际由于CS总是指向当前正在执行的代码段你,大多数时候RPL和CPL是一个东西

只有低级函数调用高级依存代码段函数时有别,后面会提到

DPL:段描述符权限

IOPL:IO当前代码段你得IO特权级

权限占用两位,能够标识的特权级就是0,1,2,3,数字越大则权限越低,0环为最高权限.

image-20220902162251067

操作系统内核一般运行在0环,权限最高.

用户程序的权限最低,在3环

一个位于三环的用户程序,不管访问什么段,其设置的段选择子中RPL只能是3

比如

如果其使用系统调用则cs中Index设置为操作系统代码段,RPL=3

如果其引用了运行库的一个全局数据,则ds中的Index设置为运行库数据段,RPL=3

而一个位于0环的段,其段描述符中的DPL就是0

特权指令:只能由特权级0的代码执行的指令,比如停机指令HLT,还有对控制寄存器CR0的写操作的指令,lgdt,lldt,ltr等等

IOPL:IOPL是处理器中程序状态字的两位,用来表征当前正在执行的代码段的IO权限

image-20220902164920415

严禁低级代码段访问高级数据段

数据段的特权级规则

数据段的DPL决定了其最低访问权限,只允许权限至少相同或者更高的代码段访问

0环的数据段只能被0环的代码段访问(门当户对)

3环的数据段可以被0,1,2,3环的代码段访问(DPL=3,RPL=0,1,2,3)

内核数据段是在0环上的,用户程序的代码段是在3环上的,显然用户程序是没法访问内核数据段的

严禁控制向低特权级转移

代码段的特权级规则

代码段有依从的和非依从的之分

非依从的代码段讲究一个门当户对,2环的代码段只允许被2环的代码段啊调用,0,1,3都不行.也就是权限高的不行,权限低的也不行

依从的代码段还允许被特权低的代码段调用,即2环的依从的代码段可以被2,3环的代码段调用,不允许被0,1环的代码段调用.这是因为特权级越高的代码段,其安全性越高,0环的代码段基本都是写死的不会出事.从3环调用0环是系统调用的方向,安全性只能是越来越高

但是0环不敢随意调用3环,如果用户自己写一个3环的病毒,被操作系统调用了就寄了

这貌似根数据段的特权规则正好相反,

数据段只允许权限同级或者更高的代码段调用.

而代码段顶多允许同级或者权限更低的代码段调用.

这就好比将校可以任意获取军士的情报,但是军士不能获取将校的情报

军士可以执行将校的命令,但是将校不能执行军士的命令

显然一个将军相比于一个士兵是更不容易叛变的,并且保密等级更高的

如何区分一个代码段是不是依从的呢?

代码段描述符的TYPE字段有一个C位,

如果C=0则是非依从的,只能被同级调用.

如果C=1则是依从的,允许同级和下级调用

image-20220902171216715

严禁控制向低特权级转移

严禁控制向低特权级转移

严禁控制向低特权级转移

针对这句话貌似可以找很多茬:

一是,当系统调用返回到用户函数时,不就是从0环的内核代码段返回到3环的用户程序代码段了吗?实际上依存的代码段执行时,当前特权级CPL不会改变,即保持用户程序的权限3环.那么这样系统调用整个过程都是在3环上的,没有涉及控制匆匆高级到低级转移.

二是,win32程序设计时,操作系统是可以主动调用窗口过程回调函数的,这种情况是否属于0环的操作系统调用了3环的用户函数?有了茬一的解释,茬二也可以解释

消息循环

1
2
3
4
5
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}

DispatchMessage是被3环程序调用的3环API,往后即使调用0环的依存内核函数,CPL也一直保持3环不变.回调函数wndproc是在啥时候调用的呢?可以理解为:

用户程序,with CPL=3->DispatchMessage->内核高DPL函数->回调函数wndproc->返回

从一开始用户程序,CPL就是3了,这条链上一直是3,因此没有违背"严禁控制向低特权级转移"这条规则

门描述符

在特权级之间转移控制的另一种方法,即使用门

每一个符号都需要一个调用门,之所以叫做"门"是因为它确实就像一个门,在调用一个符号之前需要先经过它的门,让门来检测第一关,能否调用该符号

调用门:

jmp far 将控制转移到高级代码段,不改变CPL

call far 将控制转移到高级代码段,改变CPL

也是不允许从将控制从高级转移到低级,但是允许从高级返回到低级,即call far之后的返回是从高级转移到低级的

IO操作比如访问硬盘,必须使用调用门

门实际上是一个段描述符,在LDT或者GDT中,占用的大小和普通的段描述符一样,都是32字节,但是每一位的意义和普通的段描述符不同

最低和最高的两个字是段内偏移量的低字和高字

第二个字是例程所在代码段的选择子

第三个字是属性

属性 意义
P 1有效,如果是0则调用该门导致处理器异常中断
DPL 访问目标代码段需要的权限下限(目标代码段的DPL决定的是上限)
0 未使用固定为0
TYPE
000 未使用,固定为0
栈传递的参数个数 存放该门对应的代码调用需要几个函数,
方便切换堆栈的时候把参数从老栈搬到新栈上来
由于参数个数一共有5位,因此最多可以传31个参数

当栈切换时,SS:ESP会被自动更新成新的堆栈,然后根据调用门的栈传递参数个数从老栈顶上取下相同字节数来拷贝到新栈上作为参数.这个事是处理器自动干的,程序员不用管

如果是用寄存器传递参数则根本不需要拷贝

关于

image-20220902213223828

如果一个调用门的DPL=2,这是一个下限,那么只有同级或者更高权限的特权级为0,1,2的代码可以通过该门.

钻法律空子

现在已经有了一些游戏规定

1.在不使用调用门时

数据段只能被同级或者高级代码段访问

代码段只能被同级或者低级代码段调用,但是CPL不变

2.在使用调用门时

call far将控制转移到高级代码段时,会将CPL从低级改成高级,并且允许从高级返回到低级并再修改CPL为低级

3.有一个0环的系统调用read_hard_disk(logicalnumber,selector,offset),为用户程序提供读取磁盘到内存的服务

它接收三个参数,逻辑扇区号logicalnumber,写入内存的段选择子selector,写入内存的段偏移量offset

意思是读取指定logicalnumber扇区,拷贝到内存的selector:offset这个位置

上述游戏规则看似完备,实际上还是存在漏洞,这样想:

操作系统开发者希望的用户这样使用该系统调用:

1
read_hard_disk(逻辑扇区号,用户程序数据段选择子,用户程序数据段偏移量);

翻译到图上就是

正确用法

但是还有外门邪道的用法,如果用户通过一些手段知道了内核数据段的选择子,比如通过wingdb内核调试等方式,反正只要想,就能获取,它这样使用该系统调用

1
read_hard_disk(逻辑扇区号,内核数据段选择子,内核数据段偏移量);

画到图上

image-20220902180334115

你不是不允许3环上的用户程序访问0环上的内核数据段吗?

我想办法让0环上的代码访问0环的内核数据段不就可以了吗?

我得先想办法让我的CPL从3环的用户应用提升到0环,怎么提升呢?显然只有一个call far可以做到

而该系统调用恰好就是call far调用的,自然而然就把CPL提升到0了,此时0环上的内核例程代替3环上的用户程序,向0环的内核数据段写入了磁盘扇区数据

如果该用户提前将恶意数据写到该扇区,或者说用一个没用的扇区覆盖了该内核数据段的所有,如果该数据段是比较重要的比如内核堆栈,内核就寄了

那么能否禁用call far呢?使用jmp far调用不行吗?还真不行,

如果可以使用jmp far,就意味着CPL一直在3环保持不变,内核例程去访问磁盘时还是在3环上,

显然这是不可以的,因为访问磁盘的函数必须是非依存0环权限的,

否则如果是0环依存权限,则用户程序可以不通过系统调用,而是直接调用该访问磁盘的函数了,这样0环就没有存在的意义了

完善法律

可以先用jmp far跳转到依存的内核检查例程,

该检查例程作为入口在call far进入0环的访问磁盘例程呢,

访问磁盘例程把数据先读到内核入口例程的缓冲区,

等磁盘例程返回后,内核例程回复到3环权限再把缓冲区尝试写到目标内存中.

这样在3环上是不可能往0环的内核数据段写东西的

这个猜想正是现行的解决方案,即"再增加一道门卫",这个检查例程就是门卫

1
2
3
4
5
6
7
8
9
10
11
12
graph LR
B(ring0检查例程,依存)
C(ring0磁盘读写例程,非依存)
D(ring0内存读写例程,依存)
A(调用者,可能是用户函数,也可能是系统函数)
A--"jmp far,CPL=0"-->B
subgraph 系统调用

B--"1.call far"-->C
C--"2.return"-->B
B--"3.jmp far"--->D
end

如果是应用程序调用该系统调用,则读写内存的时候一定CPL=3,如果参数selector是一个内核段的选择子,则CPL=3显然是无法访问的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
graph LR
A(ring3应用程序)
B(ring0检查例程,依存)
C(ring0磁盘读写例程,非依存)
D(ring0内存读写例程,依存)

subgraph ring 3
A--"jmp far,CPL=3"-->B
D
end
subgraph ring 0
B--"1.call far,CPL=0"-->C
C--"2.return,CPL=3"-->B
B--"3.jmp far,CPL=3"--->D
end

如果是系统函数调用该系统调用则全程在ring0,不需担心任何问题

1
2
3
4
5
6
7
8
9
10
11
12
13
graph LR
A(ring0系统函数)
B(ring0检查例程,依存)
C(ring0磁盘读写例程,非依存)
D(ring0内存读写例程,依存)

subgraph ring 0
A--"jmp far,CPL=0"-->B
B--"1.call far,CPL=0"-->C
C--"2.return,CPL=0"-->B
B--"3.jmp far,CPL=0"--->D
end

也就是说,调用者调用该系统调用的时候,操作系统会根据调用者的段选择子中的RPL设置CPL,每当发生mov ds,ax这种指令的时候,处理器就会检查

image-20220902202919564

编程实现

主引导扇区和上一个内核时相同,内核的头部也是相同的,这属于内核和主引导扇区之间的协议,理论上是永远不变的

两个内核都是从start标号处开始执行,内核开始执行时,主引导扇区中已经给内核准备好了代码段,数据段,堆栈段,并且处理器也运行在保护模式了

然后两个内核都打印了CPU信息,之后的逻辑就不同了

在进入内核start之前,GDT是这样的

image-20220830192340336

start

内核开始的标号

上来先打印字符串还有CPU信息,不再分析了

安装调用门

遍历符号表,给每个符号(不管是函数还是变量符号)都在GDT中建立一个门描述符

为啥要建立门描述符?

内核中公用例程段的符号权限都是0,也就相当于系统调用,如果想让用户程序也使用他们,那就必须安装调用门

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
       mov ecx,core_data_seg_sel          ;使ds指向核心数据段 
mov ds,ecx

;以下开始安装为整个系统服务的调用门。特权级之间的控制转移必须使用门
mov edi,salt ;C-SALT表的起始位置
mov ecx,salt_items ;C-SALT表的条目数量
.b3:
push ecx ;保存循环变量
mov eax,[edi+256] ;该条目入口点的32位偏移地址 ;每个条目的前256个字符是符号名,然后紧跟着符号地址
mov bx,[edi+260] ;该条目入口点的段选择子 ;符号地址后面紧跟着段选择子
mov cx,1_11_0_1100_000_00000B ;特权级3的调用门(3以上的特权级才 ;gdt属性
;允许访问),0个参数(因为用寄存器
;传递参数,而没有用栈)
call sys_routine_seg_sel:make_gate_descriptor ;构造门描述符
call sys_routine_seg_sel:set_up_gdt_descriptor ;写入全局段描述符表
mov [edi+260],cx ;将返回的门描述符选择子回填
add edi,salt_item_len ;指向下一个C-SALT条目
pop ecx ;还原循环变量
loop .b3

这里调用了sys_routine_seg_sel:make_gate_descriptor函数,该函数的调用约定是:

eax存放门代码的段偏移量

bx存放门代码的段选择子

cx存放段属性,按照段描述符高16位的格式存放

返回值edx:eax表示完整的调用门描述符

然后调用set_up_gdt_descriptor在GDT中注册调用门,用cx返回该GDT描述符的段选择子

该选择子被回填到原来的符号选择子位置(原来存放的是符号所在段选择子)

这里回填只改变了选择子,没有改变偏移量

调用门描述符中会保存该符号的段选择子和段内偏移,原来该符号也会保存一个段内偏移

因此段内偏移会被保存两次

内核符号表表项的变化:

符号成员 段内偏移 大小 原意义 新意义
符号名 0(edi指向该处) 256Bytes 符号名 --
符号偏移量 256 4Bytes 符号段内偏移量 --
符号段选择子 260 2Bytes 符号所在段选择子 符号调用门的选择子

现在要调用一个符号,查符号表获得的是其调用门选择子,

用该调用门选择子查GDT获得的是调用门描述符

用调用门描述符中的段选择子查GDT获得符号所在段地址

加上调用门描述符中的符号段内偏移才得到符号的线性地址

循环为每一个内核符号建立调用门后,GDT表长这样

image-20220903215205939

测试调用门

1
2
3
4
5
6
7
;对门进行测试 
mov ebx,message_2
call far [salt_1+256] ;通过门显示信息(偏移量将被忽略)

mov ebx,message_3
call sys_routine_seg_sel:put_string ;在内核中调用例程不需要通过门

如果想要使用调用门,要么是jmp far,要么是call far

打印message_2时,使用call far调用[salt_1+256] 这个位置,正好是put_string符号的段内偏移量起始地址

call far会将其目的操作数开始的低32个字节作为段内偏移量,将高16字节作为段选择子,如果这是个调用门则忽略段内偏移量,因为调用门描述符中还会记录这个符号的段内偏移量

打印message_3时,没有使用call far或者jmp far指令,也就是说不会使用调用门.实际上确实如此,这里直接用段选择子:段内偏移量进行调用,根本没有访问符号表.因为内核代码本来就是最高权限0环,内核代码调用内核公用例程段是门当户对的

这里提示我们即使一个符号有调用门,也不一定非要使用调用门调用该符号,老方法对于内核代码调用内核代码还是可用的

创建任务控制块

TCB,Task control Block,任务控制块(可以理解为进程控制块),这个玩意纯粹是我们为了维护程序执行状态,比如各种通用寄存器,堆栈指针,程序计数器等设置的,不是处理器的硬性要求.

对于一个允许多任务轮转的操作系统,必须要有数据结构维护任务执行上下文,方便下一次调度到该任务的时候可以继续执行下去

1
2
3
4
5
;创建任务控制块。这不是处理器的要求,而是我们自己为了方便而设立的
mov ecx,0x46
call sys_routine_seg_sel:allocate_memory
call append_to_tcb_link ;将任务控制块追加到TCB链表

allocate_memory函数使用ecx作为希望分配的字节数,使用ecx返回申请地址空间的线性首地址

这里申请了0x46字节的内存空间,接着给append_to_tcb_link注册为TCB

append_to_tcb_link是一个近函数,它使用ecx作为TCB(Task control Block)的线性地址,将该TCB加到已有任务链上

这个玩具内核使用链表维护所有的TCB数据,每个TCB的最开始的4个字节是指向下一个TCB的指针,剩下0x42个字节是存放本任务控制信息的

image-20220903215749487

多个TCB以链表形式连接,这就是append_to_tcb_link函数的工作了

image-20220903215916565

下面详细研究一下append_to_tcb_link干了啥

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
 tcb_chain        dd  0	;内核数据段定义的TCB链条首地址指针
...
append_to_tcb_link: ;在TCB链上追加任务控制块
;输入:ECX=TCB线性基地址
push eax
push edx
push ds
push es

mov eax,core_data_seg_sel ;令DS指向内核数据段 ;这里有两种寻址方式,tcb_chain是定义在内核数据段的,只能使用内核数据段:偏移量的方式进行寻址
mov ds,eax
mov eax,mem_0_4_gb_seg_sel ;令ES指向0..4GB段;每一个TCB都是allocate_memory申请的地址,使用线性地址寻址,这里4G地址空间段的段地址为0,就相当于线性地址了
mov es,eax

mov dword [es: ecx+0x00],0 ;当前TCB指针域清零,以指示这是最后一个TCB
;显然要进行尾插法了

mov eax,[tcb_chain] ;TCB表头指针,在没有插入任何任务之前,[tcb_chain]=0
or eax,eax ;链表为空?
jz .notcb ;如果链表为空则跳转notcb(没有tcb)

.searc: ;否则,即链表不空,需要首先遍历到链表尾,searc就是干这个事的
mov edx,eax ;如果链表非空则tcb_chain这个头节点指针是会指向第一个TCB的
mov eax,[es: edx+0x00] ;每个TCB的第一个双字都是下一个TCB的地址,如果该值为0说明找到了最后一个TCB,否则还得继续往后遍历
or eax,eax ;判断下一个TCB的指针是否是0
jnz .searc ;如果不是则继续向后遍历

mov [es: edx+0x00],ecx ;刚找到的"最后一个TCB"的指针域指向要新加入的TCB,完成尾插法
jmp .retpc ;跳转函数尾声

.notcb:
mov [tcb_chain],ecx ;若为空表,直接令表头指针指向TCB

.retpc:
pop es
pop ds
pop edx
pop eax

ret

教材上把这个过程总结成了流程图

image-20220903221859132

加载器加载用户程序

1
2
3
4
5
6
7
push dword 50                      ;用户程序位于逻辑50扇区 ;栈次顶函数
push ecx ;压入任务控制块起始线性地址 ;栈顶参数

call load_relocate_program

mov ebx,do_status
call sys_routine_seg_sel:put_string

加载器使用栈传递参数,栈顶作为加载目标地址,栈次顶作为用户程序所在的逻辑扇区号

由于加载器要给用户程序建立LDT,而原来的加载器只是将用户程序的各段一并注册到GDT,

因此两章的加载器会有较大不同,下面研究一下本章的加载器干了啥

栈传参数

加载器使用栈传递参数,执行call命令之后,栈上是这样的

image-20220903223350815
开端

函数开端时又压栈保护调用者寄存器

1
2
3
4
5
6
pushad

push ds ;即使压栈的是16位的寄存器,栈顶依然下降4字节
push es

mov ebp,esp ;为访问通过堆栈传递的参数做准备

这三步执行完毕之后,栈的状态

image-20220903225454843

在加载器返回直线,ebp就一直指向调用者寄存器的最顶上,局部变量的最底下的位置,也就是加载器正儿八经的栈帧底部

设置es段选择子

设置es段选择子指向4G数据段

1
2
mov ecx,mem_0_4_gb_seg_sel
mov es,ecx
申请LDT空间,写入TCB
1
2
3
4
5
6
7
mov esi,[ebp+11*4]                 ;从堆栈中取得TCB的基地址

;以下申请创建LDT所需要的内存
mov ecx,160 ;允许安装20个LDT描述符
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x0c],ecx ;登记LDT基地址到TCB中
mov word [es:esi+0x0a],0xffff ;登记LDT初始的界限到TCB中

ecx=160向allocate_memory申请160字节的内存空间,用于存放本任务的LDT表,由于一个LDT表项占用8字节,并且0号表项可用,因此该LDT表最多放20个描述符

返回该内存空间的首地址放到[es:esi+0x0c],实际上是一个线性寻址,es段基址为0

es:esi保存的是TCB的内存基地址

[es:esi+0x0c]即TCB的0x0c偏移处,LDT基地址

[es:esi+0x0a]即TCB的0x0a偏移处,LDT当前界限值

image-20220903215749487

也就是说把allocate_memory申请的空间作为LDT,然后将其基地址写到TCB中.并且将LDT当前界限值设为最大0xffff

读取用户程序头
1
2
3
4
5
6
7
;以下开始加载用户程序 
mov eax,core_data_seg_sel
mov ds,eax ;切换DS到内核数据段

mov eax,[ebp+12*4] ;从堆栈中取出用户程序起始扇区号
mov ebx,core_buf ;读取程序头部数据
call sys_routine_seg_sel:read_hard_disk_0

ds指向内核数据段

ebp+12*4指向之前作为加载器的参数压栈的逻辑扇区号

image-20220903230748207

将逻辑扇区号放到eax,将core_buf内核缓冲区地址放到ebx,作为参数调用read_hard_disk_0函数

该函数读取eax指定的逻辑扇区,将数据拷贝到ds:ebx指定的内内存地址上

判断程序大小
1
2
3
4
5
6
7
;以下判断整个程序有多大
mov eax,[core_buf] ;程序尺寸
mov ebx,eax
and ebx,0xfffffe00 ;使之512字节对齐(能被512整除的数低
add ebx,512 ;9位都为0
test eax,0x000001ff ;程序的大小正好是512的倍数吗?
cmovnz eax,ebx ;不是。使用凑整的结果

效果是获取程序占用的扇区对应的总内存数,最后一个扇区如果占不满则向上取整为一个扇区

由于刚才已经将程序头512字节放到了core_buf指向的内核缓冲区上,现在core_buf指向的就是用户程序头的基地址,用户程序头上是啥又涉及到协议了

项目 大小(bytes) 文件偏移(bytes) 原意义 新意义
program_length program_end 4 0 程序总大小 --
head_len header_end 4 4 程序头大小 程序头段选择子
stack_seg -- 4 8 接收栈段选择子 程序堆栈段选择子
stack_len -- 4 12 程序建议的栈长度 --
prgentry start 4 16 入口点文件文件偏移 --
code_seg section.code.start 4 20 代码段文件偏移 程序代码段选择子
code_len code_end 4 24 代码段长度 --
data_seg section.data.start 4 28 数据段文件偏移 程序数据段选择子
data_len data_end 4 32 数据单长度 --
salt_items (header_end-salt)/256 4 36 符号表项数 --
符号1... 符号值(字面量) 256Bytes 40 第一个符号 --
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号 --
...

[core_buf+0x00]即程序总大小

为整个程序申请空间并注册
1
2
3
mov ecx,eax                        ;实际需要申请的内存数量
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x06],ecx ;登记程序加载基地址到TCB中

eax中放的是向上取整之后的程序总大小字节数,通过ecx传递给allocate_memory申请相应大小的内存,

然将该内存基地址后写到[es:esi+0x06],即TCB的程序加载基地址处

读取整个程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
       mov ebx,ecx                        ;ebx -> 申请到的内存首地址
xor edx,edx
mov ecx,512
div ecx
mov ecx,eax ;总扇区数

mov eax,mem_0_4_gb_seg_sel ;切换DS到0-4GB的段
mov ds,eax

mov eax,[ebp+12*4] ;起始扇区号
.b1:
call sys_routine_seg_sel:read_hard_disk_0
inc eax
loop .b1 ;循环读,直到读完整个用户程序

edx:eax除以512得到总扇区数放到ecx作为循环变量,下面要循环读入这些扇区

起始扇区号还是使用压栈传递给加载器的参数

循环读入这些扇区到ebx指定的内存中

获取程序基地址
1
mov edi,[es:esi+0x06]              ;获得程序加载基地址

[es:esi+0x06] 是之前给整个程序申请空间之后写到TCB中的

建立程序头部段描述符

edi指向程序基地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
;建立程序头部段描述符
mov eax,edi ;程序头部起始线性地址
mov ebx,[edi+0x04] ;段长度
dec ebx ;段界限
mov ecx,0x0040f200 ;字节粒度的数据段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor

;安装头部段描述符到LDT中
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt

or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [es:esi+0x44],cx ;登记程序头部段选择子到TCB
mov [edi+0x04],cx ;和头部内

程序头部段基地址放到eax

[edi+0x04]在协议中一开始是程序头段长度(后来被改成程序头段选择子) 段长度-1得到段界限放到ebx

属性0x40f200放到ecx

调用make_seg_descriptor,eax,ebx,ecx作为参数传递

edx:eax作为返回值,返回完整的段描述符

esi从加载器函数开始时就被设置指向TCB基址,ebx获得其拷贝

调用fill_descriptor_in_ldt,它使用edx:eax作为描述符,ebx作为TCB基地址,该函数将描述符安装在LDT中(LDT基地址通过查TCB表得知),更新LDT界限.返回CX作为描述符选择子.

注意fill_descriptor_in_ldt并没有lldt,即没有更新ldtr,其更新需要等整个LDT建立完毕之后

将cx返回的段选择子加上特权级3,分别写到TCB和程序头部的程序头部段选择子中

建立程序代码段描述符
1
2
3
4
5
6
7
8
9
10
11
;建立程序代码段描述符
mov eax,edi
add eax,[edi+0x14] ;代码起始线性地址
mov ebx,[edi+0x18] ;段长度
dec ebx ;段界限
mov ecx,0x0040f800 ;字节粒度的代码段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x14],cx ;登记代码段选择子到头部

过程类似建立程序头部段描述符,但是最后没有将代码段写到TCB中,因为TCB不需要记录,这是TSS需要干的,而对于TSS的设置还在后面

建立程序数据段描述符
1
2
3
4
5
6
7
8
9
10
11
;建立程序数据段描述符
mov eax,edi
add eax,[edi+0x1c] ;数据段起始线性地址
mov ebx,[edi+0x20] ;段长度
dec ebx ;段界限
mov ecx,0x0040f200 ;字节粒度的数据段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x1c],cx ;登记数据段选择子到头部
建立程序堆栈段描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;建立程序堆栈段描述符
mov ecx,[edi+0x0c] ;4KB的倍率
mov ebx,0x000fffff
sub ebx,ecx ;得到段界限
mov eax,4096
mul ecx
mov ecx,eax ;准备为堆栈分配内存
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;得到堆栈的高端物理地址
mov ecx,0x00c0f600 ;字节粒度的堆栈段描述符,特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B ;设置选择子的特权级为3
mov [edi+0x08],cx ;登记堆栈段选择子到头部

与前面的段不同的是,程序堆栈段是需要另申请空间的

重定位用户程序符号

用户程序的符号表,在链接之前保存的是符号名,现在遍历用户程序符号表,将用户符号引用解析到内核符号

算法和上一章相同,对于每个用户程序符号,遍历整个内核符号表

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
       ;重定位SALT 
mov eax,mem_0_4_gb_seg_sel ;这里和前一章不同,头部段描述符
mov es,eax ;已安装,但还没有生效,故只能通
;过4GB段访问用户程序头部 ;es指向整个4G内存地址空间
mov eax,core_data_seg_sel ;ds指向内核数据区
mov ds,eax

cld

mov ecx,[es:edi+0x24] ;U-SALT条目数(通过访问4GB段取得) ,edi是程序基址的线性地址,配合es使用
add edi,0x28 ;U-SALT在4GB段内的偏移
.b2: ;外圈循环
push ecx
push edi

mov ecx,salt_items ;内核符号每次都从最头上开始
mov esi,salt
.b3:
push edi ;中圈循环需要使用,因此将外圈循环变量压栈保存
push esi
push ecx

mov ecx,64 ;检索表中,每条目的比较次数
repe cmpsd ;每次比较4字节 ,esi随着移动
jnz .b4 ;符号不匹配,跳转b4
mov eax,[esi] ;若匹配,则esi恰好指向其后的地址,即符号的偏移地址
mov [es:edi-256],eax ;将字符串改写成偏移地址 ,原符号名处改成符号的偏移地址
mov ax,[esi+4] ;esi再加4指向段选择子
or ax,0000000000000011B ;以用户程序自己的特权级使用调用门
;故RPL=3
mov [es:edi-252],ax ;回填调用门选择子
.b4:

pop ecx
pop esi
add esi,salt_item_len ;esi循环变量后移一个符号
pop edi ;从头比较
loop .b3 ;固定用户程序中的符号,遍历内核符号

pop edi
add edi,256
pop ecx
loop .b2
建立并注册各特权级堆栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mov esi,[ebp+11*4]                 ;从堆栈中取得TCB的基地址

;创建0特权级堆栈
mov ecx,4096
mov eax,ecx ;为生成堆栈高端地址做准备
mov [es:esi+0x1a],ecx
shr dword [es:esi+0x1a],12 ;登记0特权级堆栈尺寸到TCB
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;堆栈必须使用高端地址为基地址
mov [es:esi+0x1e],eax ;登记0特权级堆栈基地址到TCB
mov ebx,0xffffe ;段长度(界限)
mov ecx,0x00c09600 ;4KB粒度,读写,特权级0
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0000 ;设置选择子的特权级为0
mov [es:esi+0x22],cx ;登记0特权级堆栈选择子到TCB
mov dword [es:esi+0x24],0 ;登记0特权级堆栈初始ESP到TCB

esi指向TCB基地址

esi+0x1a指向的0环栈以4KB为单位的长度

这里将4096放到该位置,然后右移12位,意思是4096右移12位得到1,即0环栈的大小是1×4K

ecx=4096B交给allocate_memory,为该堆栈申请空间

然后ecx保存返回地址,加上eax先前保存的空间大小,达到该空间的最大地址,由于堆栈往小地址生长,因此该地址就作为基地址了

写到esi+0x1e位置,即0特权级堆栈基地址

段大小是4KB那么段界限就是0xffffe

0x00c9600作为属性放到ecx准备制作段描述符

make_seg_descriptoredx:eax返回完整的段描述符

然后调用fill_descriptor_in_ldt,该函数使用edx:eax作为段描述符,使用ebx作为TCB基地址,将该段描述符写入TCB指向的LDT中

cx返回该段的选择子,该选择子将权限置为0后,放到TCB+0x22,即0环栈选择子上

然后将0环栈初始esp置为0

这就建立好了0环栈,下面1,2环栈类似

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
	
;创建1特权级堆栈
mov ecx,4096
mov eax,ecx ;为生成堆栈高端地址做准备
mov [es:esi+0x28],ecx
shr [es:esi+0x28],12 ;登记1特权级堆栈尺寸到TCB
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;堆栈必须使用高端地址为基地址
mov [es:esi+0x2c],eax ;登记1特权级堆栈基地址到TCB
mov ebx,0xffffe ;段长度(界限)
mov ecx,0x00c0b600 ;4KB粒度,读写,特权级1
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0001 ;设置选择子的特权级为1
mov [es:esi+0x30],cx ;登记1特权级堆栈选择子到TCB
mov dword [es:esi+0x32],0 ;登记1特权级堆栈初始ESP到TCB

;创建2特权级堆栈
mov ecx,4096
mov eax,ecx ;为生成堆栈高端地址做准备
mov [es:esi+0x36],ecx
shr [es:esi+0x36],12 ;登记2特权级堆栈尺寸到TCB
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;堆栈必须使用高端地址为基地址
mov [es:esi+0x3a],ecx ;登记2特权级堆栈基地址到TCB
mov ebx,0xffffe ;段长度(界限)
mov ecx,0x00c0d600 ;4KB粒度,读写,特权级2
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0010 ;设置选择子的特权级为2
mov [es:esi+0x3e],cx ;登记2特权级堆栈选择子到TCB

GDT中注册LDT
1
2
3
4
5
6
7
;在GDT中登记LDT描述符
mov eax,[es:esi+0x0c] ;LDT的起始线性地址
movzx ebx,word [es:esi+0x0a] ;LDT段界限
mov ecx,0x00408200 ;LDT描述符,特权级0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [es:esi+0x10],cx ;登记LDT选择子到TCB中

TCB+0x0c指向LDT线性基地址,放到eax

TCB+0x0a指向LDT当前界限值,放到ebx

0x00408200作为属性,放到ecx

调用make_seg_descriptor,set_up_gdt_descriptor将LDT描述符注册到GDT,并将该LDT的选择子写到TCB+0x10位置

创建TSS
image-20220904211634293

TSS是一个大块头,详细保存了程序上下文,共需要占用104字节

貌似TCB和TSS有很多重复保存的地方

确实两者都保存了附加堆栈的段选择子,LDT段在GDT中的选择子

但是TSS是一个动态变化的结构,TCB在进程创建之后就不再发生变化了

TSS中的各种寄存器都是会随着进程的执行发生变化

1
2
3
4
5
6
;创建用户程序的TSS
mov ecx,104 ;tss的基本尺寸
mov [es:esi+0x12],cx
dec word [es:esi+0x12] ;登记TSS界限值到TCB
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x14],ecx ;登记TSS基地址到TCB

esi还是指向TCB基地址

esi+0x12是TSS界限,设为104-1=103

然后调用allocate_memory给TSS申请104字节空间,将该空间的首地址放到TCB+0x14即TSS基地址上

登记基本TSS内容

由于进程开始执行之前,TSS和TCB中的很多内容都是重复的,因此TSS建立时,可以从TCB抄作业

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
;登记基本的TSS表格内容
mov word [es:ecx+0],0 ;反向链=0

mov edx,[es:esi+0x24] ;登记0特权级堆栈初始ESP
mov [es:ecx+4],edx ;到TSS中

mov dx,[es:esi+0x22] ;登记0特权级堆栈段选择子
mov [es:ecx+8],dx ;到TSS中

mov edx,[es:esi+0x32] ;登记1特权级堆栈初始ESP
mov [es:ecx+12],edx ;到TSS中

mov dx,[es:esi+0x30] ;登记1特权级堆栈段选择子
mov [es:ecx+16],dx ;到TSS中

mov edx,[es:esi+0x40] ;登记2特权级堆栈初始ESP
mov [es:ecx+20],edx ;到TSS中

mov dx,[es:esi+0x3e] ;登记2特权级堆栈段选择子
mov [es:ecx+24],dx ;到TSS中

mov dx,[es:esi+0x10] ;登记任务的LDT选择子
mov [es:ecx+96],dx ;到TSS中

mov dx,[es:esi+0x12] ;登记任务的I/O位图偏移
mov [es:ecx+102],dx ;到TSS中

mov word [es:ecx+100],0 ;T=0

GDT中注册TSS

为啥要在GDT中注册TSS?

TSS也是一块内存区域,在保护模式下,每块内存区域都需要现在GDT或者LDT中注册之后才能访问.在内核中可以使用4G的数据段访问全部内存,但是控制切换到用户程序之后就不能使用4G数据段了,要想还能访问TSS,内核就必须给用户程序准备好TSS

TSS描述符长这样

image-20220904213805641

其中TYPE.B位,表征任务当前状态,是"忙"还是"不忙",忙就是正在执行或者挂起,处理器会自动修改该位

1
2
3
4
5
6
7
;在GDT中登记TSS描述符
mov eax,[es:esi+0x14] ;TSS的起始线性地址
movzx ebx,word [es:esi+0x12] ;段长度(界限)
mov ecx,0x00408900 ;TSS描述符,特权级0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [es:esi+0x18],cx ;登记TSS选择子到TCB

注册TSS时用到的基址和界限信息还是从TCB中抄来的

加载器函数尾声
1
2
3
4
5
6
pop es                             ;恢复到调用此过程前的es段 
pop ds ;恢复到调用此过程前的ds段

popad

ret 8 ;丢弃调用本过程前压入的参数

ret 8退栈8个字节,正好给加载器传递参数时是两个4字节参数

这样加载器返回后堆栈平衡

加载ldtr,tr寄存器,ds段寄存器

1
2
3
4
5
6
7
8
9
10
11
mov ebx,do_status
call sys_routine_seg_sel:put_string

mov eax,mem_0_4_gb_seg_sel ;ds指向4G全内存段,后面要用线性地址
mov ds,eax

ltr [ecx+0x18] ;加载任务状态段 ;ecx指向TCB,TCB+0x18是TSS选择子
lldt [ecx+0x10] ;加载LDT,TCB+0x10是LDT选择子

mov eax,[ecx+0x44] ;ecx+0x44是用户程序头选择子
mov ds,eax ;切换到用户程序头部段

假装从调用门返回

我们之前一直是在0环的内核上工作的 ,而现在竟然让我们将控制放权给一个三环上的应用程序,显然正常情况下这是不可容忍的.

不使用调用门时,即使是依存的代码段也只允许从3环代码段调用0环代码

使用调用门时,也只允许控制向高级代码段转移,顶多有一个改不改CPI的区别

貌似各种情况都不允许控制从0环向3环转移

但是但是,call far调用之后,允许使用retf从0环返回到3环

即虽然不允许0环主动调用3环,但是允许从0环返回3环

这就是下面retf的作用

1
2
3
4
5
6
7
8
;以下假装是从调用门返回。摹仿处理器压入返回参数 
push dword [0x08] ;调用前的堆栈段选择子
push dword 0 ;调用前的esp

push dword [0x14] ;调用前的代码段选择子
push dword [0x10] ;调用前的eip

retf

压栈时操作数的段寄存器ds指向用户程序头段

ds:0x08指向用户程序段选择子

ds:0x14指向用户程序代码段选择子

ds:0x10指向用户程序入口点

image-20220904215017068

retf指令干了啥呢?

retf用于段间返回,将此时栈顶低32位作为段内偏移量,高32位作为段选择子(实际只使用了高16位,再高16位置零忽略)

retf执行之后会将栈顶的入口点段内偏移退栈交给eip,将程序段选择子退栈交给cs

image-20220904215323965

然后接着处理器就根据eip取指令继续执行了,就相当于从内核"返回"到用户程序了,虽然这是用户程序的第一次执行

此时栈顶是程序堆栈段,自然而然地被作为程序的

图解TSS,TCB,LDT

简略
详细?

有些地方还是没有彻底明白

玩具内核

书上这一章是按照文件讲解的,先概述了内核源代码的组成,然后是主引导扇区,然后是用户程序

感觉不如跟随指令流的顺序更清晰,从主引导扇区开始分析

主引导扇区结构

主引导扇区的任务就是设置全局段描述符表并且让处理器转变成保护模式,并且加载内核并转让控制权

前面的已经学习过了,如何加载内核呢?

我们需要先把内核写到虚拟磁盘上的一个固定的地方,并且主引导程序可以找到这个地方,主引导程序还得知道从磁盘中搬出内核来,应该放到内存中的什么地方

实模式

内核常数定义

1
2
core_base_address equ 0x00040000   ;常数,内核加载的起始内存地址 
core_start_sector equ 0x00000001 ;常数,内核的起始逻辑扇区号

core_base_address定义了内核应该被加载内存的到什么地方,

core_start_sector定义了内核在磁盘中被放到了什么地方.

设置堆栈

1
2
3
mov ax,cs      
mov ss,ax
mov sp,0x7c00

堆栈还是从0x0000到0x7c00,栈顶一开始在0x7c00

给GDT找位置

在第11章中用字gdt_size和双字gdt_pose规定了gdt的位置,并且用lgdt [cs:gdt_size+0x7c00]将两个数据放到了gdtr中

在本章中两个变量只留了一个标号pgdt,高双字GDT物理地址直接写死不用修改,只需要修改低字的gdt界限

此时cs:pgdt+0x7c00+0x02指向的就是高双字,即GDT地址

1
2
3
4
5
6
7
8
9
10
11
12
13
;计算GDT所在的逻辑段地址
mov eax,[cs:pgdt+0x7c00+0x02] ;GDT的32位物理地址
xor edx,edx
mov ebx,16
div ebx ;分解成16位逻辑地址

mov ds,eax ;令DS指向该段以进行操作
mov ebx,edx ;段内起始偏移地址
...
lgdt [cs: pgdt+0x7c00]
...
pgdt dw 0
dd 0x00007e00 ;GDT的物理地址

取出地址值放到eax,edx:eax除以16即这个地址值整体右移四个单位,商放到eax作为段地址,余数放到edx作为段内偏移量

edx:eax/16=16*eax+edx,此时eax值直接放到段寄存器中,寻址的时候自动左移4位加上偏移量获得物理地址

eax给ds,edx该ebx,这样ds:ebx就指向GDT基地址了

创建GDT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
;跳过0#号描述符的槽位 
;创建1#描述符,这是一个数据段,对应0~4GB的线性地址空间
mov dword [ebx+0x08],0x0000ffff ;基地址为0,段界限为0xFFFFF
mov dword [ebx+0x0c],0x00cf9200 ;粒度为4KB,存储器段描述符

;创建保护模式下初始代码段描述符
mov dword [ebx+0x10],0x7c0001ff ;基地址为0x00007c00,界限0x1FF
mov dword [ebx+0x14],0x00409800 ;粒度为1个字节,代码段描述符

;建立保护模式下的堆栈段描述符 ;基地址为0x00007C00,界限0xFFFFE
mov dword [ebx+0x18],0x7c00fffe ;粒度为4KB
mov dword [ebx+0x1c],0x00cf9600

;建立保护模式下的显示缓冲区描述符
mov dword [ebx+0x20],0x80007fff ;基地址为0x000B8000,界限0x07FFF
mov dword [ebx+0x24],0x0040920b ;粒度为字节

跳过0号槽,建立了四个段描述符

一号槽

一号槽是数据段的描述符,包含了整个4G内存空间

项目 数值 意义
段基地址 0x00000000 基址从0开始
段界限 0xfffff 段最大4G
G粒度 1 4K为粒度
D默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XEWA 0010 不可执行,
往大地址生长
可读写
二号槽

二号槽是代码段描述符

项目 数值 意义
段基地址 0x00007c00 基址从0x7c00开始
段界限 0x001fff 段最大8KB
G粒度 0 1B为粒度
B默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XCRA 1000 可执行
不可读写
往大地址生长
三号槽

三号槽是堆栈段描述符

项目 数值 意义
段基地址 0x00007c00 基址从0开始
段界限 0xffffe 段最大1MB
G粒度 0 1B为粒度
D默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XEWA 0110 不可执行,
往小地址生长
可读写
四号槽

四号槽是显存映射段

项目 数值 意义
段基地址 0x000b8000 基址从0开始
段界限 0x07fff 段最大32KB
G粒度 0 1B为粒度
D默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XEWA 0010 不可执行,
往大地址生长
可读写

使能A20

1
2
3
in al,0x92                         ;南桥芯片内的端口 
or al,0000_0010B
out 0x92,al ;打开A20

关中断

1
2
cli                                ;中断机制尚未工作

设置控制寄存器CR0的PE位

1
2
3
mov eax,cr0
or eax,1
mov cr0,eax ;设置PE位

此后处理器就工作在保护模式了

保护模式

清空流水线,串行化处理器

1
2
3
4
;以下进入保护模式... ...
jmp dword 0x0010:flush ;16位的描述符选择子:32位偏移
;清流水线并串行化处理器

这里远跳转的目标是一个实模式下的表达方法,段地址:偏移量

然而现在CPU已经处在保护模式了,会用保护模式理解这条指令

段地址0x0010将被理解为段选择子,意思是一个

Index=0x10的全局0环段选择子,对应到GDT表的二号槽,代码段描述符

偏移量flush交给ip

作用相当于

1
2
mov ax,0x0010
mov cs,ax

但是保护模式下不允许任何给代码段寄存器cs显式赋值的操作,只能使用jmp far或者call far等指令改变cs的值

[bits 32]

此后所有指令都使用32位模式编译

flush标号

加载各段

加载数据段
1
2
mov eax,0x0008                     ;加载数据段(0..4GB)选择子
mov ds,eax

selector=0x0008表示一个Index=0x1的全局0环段选择子,对应到GDT一号槽,4G数据段

加载堆栈段
1
2
3
mov eax,0x0018                     ;加载堆栈段选择子 
mov ss,eax
xor esp,esp ;堆栈指针 <- 0

selector=0x0018表示一个Index=0x11的全局0环段选择子,对应GDT三号槽,堆栈段

加载内核

1
2
3
4
5
6
7
8
9
10
core_base_address equ 0x00040000   ;常数,内核加载的起始内存地址 
core_start_sector equ 0x00000001 ;常数,内核的起始逻辑扇区号
...
;以下加载系统核心程序
mov edi,core_base_address

mov eax,core_start_sector
mov ebx,edi ;起始地址
call read_hard_disk_0 ;以下读取程序的起始部分(一个扇区)

read_hard_disk_0是一个自定义过程,其调用约定是

两个参数

将内核所在的硬盘第一个逻辑扇区号放到eax寄存器,

将内核希望加载到的内存地址放到ds:ebx寄存器,

返回的时候ebx将+512字节

其他寄存器不发生变化

此前ds已经被作为数据段的段选择子加载好了,指向一个从0开始,界限4G的数据段

这里

core_base_address=0x40000->edi->ebx

core_start_sector=1->eax

两个参数都设置好了,然后调用call read_hard_disk_0,其效果是,

内核的头512个字节已经放到内存中0x40000开始的512个字节,此时ds:ebx指向该512个字节的结尾处即0x40512

计算内核占用扇区数

之前core_base_address=0x40000->edi,edi寄存器存放的是内核的基址,此后edi也一直指向内核的基址

经过加载内核之后,内核的头512个字节已经被放到edi指向的0x40000上了

而内核代码最开始的一个双字就是内核的大小,因此此时edi指向的又是内核的大小

1
2
3
;内核代码的第一个双字:
;以下是系统核心的头部,用于加载核心程序
core_length dd core_end ;核心程序总长度#00
1
2
3
4
5
6
7
8
;以下判断整个程序有多大
mov eax,[edi] ;核心程序尺寸
xor edx,edx
mov ecx,512 ;512字节每扇区
div ecx
or edx,edx
jnz @1 ;未除尽,因此结果比实际扇区数少1
dec eax ;已经读了一个扇区,扇区总数减1

[edi]=core_length-->eax

edx:eax÷512即计算内核的总长度用多少个扇区可以放开

结果商放到eax,余数放到edx

显然eax是向下取整的商,如果有余数即edx!=0,

由于后面希望eax中存放的是剩余需要加载的扇区数,即除了头一个扇区,剩下需要加载的扇区数

如果没有余数,则eax本来表示的是包括第一个扇区的内核总扇区数,现在需要给他-1,表示除了头一个扇区外的剩余扇区数

如果有余数,则eax本来需要加上1表示包括第一个扇区和最后一个不完整扇区的内核总扇区数,现在不给他加就表示除了头一个扇区但是包含最后一个不完整扇区的剩余扇区数

因此余数不为零时jnz跳转实现,不执行dec eax.余数为0时jnz跳转不实现,执行dec eax

读取全部内核

进入@1时,eax中存放的是内核剩余没有加载的扇区数,说剩余因为头一个扇区已经加载了

需要首先考虑一个问题,如果内核足够小,只占用了头512个字节,那么eax就是0,此时就不需要再加载其他扇区了

当eax>0时就意味着内核除了头512个字节还有剩余部分没有加载

1
2
3
4
5
6
7
8
9
10
11
12
@1:
or eax,eax ;考虑实际长度≤512个字节的情况
jz setup ;EAX=0 ?;如果内核果真小于512字节,则直接setup
;否则先读取剩余扇区再setup
;读取剩余的扇区
mov ecx,eax ;32位模式下的LOOP使用ECX
mov eax,core_start_sector
inc eax ;从下一个逻辑扇区接着读
@2:
call read_hard_disk_0
inc eax
loop @2 ;循环读,直到读完整个内核

加载内核使用的时loop循环,进入循环之前的初始化是这样的:

eax(剩余需要加载的扇区数)-->ecx作为循环变量

头512字节所在逻辑扇区core_start_sector-->eax-->eax+1表示第二个逻辑扇区

ebx自从加载完头一个内核扇区之后再也没有改动过,因此ebx自然指向内存中0x40512位置

循环体中每次先call read_hard_disk_0然后再增加eax逻辑扇区号

这就把整个内核加载进了内存中

read_hard_disk_0函数实现设计访问IO端口,目前尚未了解,留作后话

安装内核setup

加载GDT基址

1
2
3
4
5
       mov esi,[0x7c00+pgdt+0x02]         ;不可以在代码段内寻址pgdt,但可以
;通过4GB的段来访问
...
pgdt dw 0
dd 0x00007e00 ;GDT的物理地址

这里[0x7c00+pgdt+0x02]寻址时默认使用ds段,而ds段选择子对应的是一个从0开始,遍布整个4G内存空间的数据段.

所以0x7c00+pgdt+0x02得到的正好是gdt的物理地址

此后esi就指向GDT表的物理地址了

建立内核段

此后要在GDT中再建立三个内核的段,即

公用例程段

核心数据段

核心代码段

建立这些内核段的信息,已经在内核的最开始给出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;以下是系统核心的头部,用于加载核心程序 
core_length dd core_end ;核心程序总长度#00

sys_routine_seg dd section.sys_routine.start
;系统公用例程段位置#04

core_data_seg dd section.core_data.start
;核心数据段位置#08

core_code_seg dd section.core_code.start
;核心代码段位置#0c


core_entry dd start ;核心代码段入口点#10
dw core_code_seg_sel
0x00 内核总长度指针汇编地址,指向的也是内核总长度的汇编地址,不是物理地址
[edi+0x00] 内核总长度的汇编地址
[edi+0x00]+edi 内核总长度的基地址
[[edi+0x00]+edi] 内核总长度

一定要注意怎么获取内核总长度的

make_gdt_descriptor调用约定

在主引导记录中专门定义了一个过程make_gdt_descriptor,

用来在保护模式下创建全局段描述符,这个函数的调用约定为:

参数:

eax传递该段描述符的线性基地址

ebx传递该段界限

ecx传递属性,原来在啥位置在ecx中就放在啥位置

关于ecx属性的描述

由于描述符的属性只在高32位出现,而ecx也是32位的,因此直接把高32位放到ecx即可

image-20220830173245724

返回值:

edx:eax存放完整的描述符,高四个字节放在edx,低四个字节放到eax

该函数只是用寄存器返回描述符应该什么样,它不会自动写到内存中,何况参数中也没有给定往内存哪里写,将描述符写入内存是我们需要另外写的

函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
make_gdt_descriptor:                     ;构造描述符
;输入:EAX=线性基地址
; EBX=段界限
; ECX=属性(各属性位都在原始
; 位置,其它没用到的位置0)
;返回:EDX:EAX=完整的描述符
mov edx,eax
shl eax,16
or ax,bx ;描述符前32位(EAX)构造完毕

and edx,0xffff0000 ;清除基地址中无关的位
rol edx,8
bswap edx ;装配基址的31~24和23~16 (80486+)

xor bx,bx
or edx,ebx ;装配段界限的高4位

or edx,ecx ;装配属性

ret

线性基地址->eax->edx

eax左移16位,相当于保留了线性基地址的低16位,然后eax的低16位左移补0,然后和bx按位或,这就设置好了段描述符的低双字

edx和0xffff0000按位与,只保留了高16位,然后rol循环位移8位,意思是将高16位中的高8位放到低8位,高16位的低8位放到高八位(有点绕)

bswap将字节序反过来,于是刚才edx中的低8位和高8位又换过来了,这就设置好了edx中的基址高16位

xor bx将ebx的低16位置零,然后edx和ebx按位或,把段界限高4位放到edx中

最后edx和ecx按位或,把属性附加到edx中

建立公用例程段描述符

edi指向内核基地址,

dword ptr [edi+0x04] 内核公用例程段物理地址

dword ptr [edi+0x08] 内核核心数据段物理地址

1
2
3
4
5
6
7
8
9
10
;建立公用例程段描述符
mov eax,[edi+0x04] ;公用例程代码段起始汇编地址
mov ebx,[edi+0x08] ;核心数据段汇编地址
sub ebx,eax
dec ebx ;公用例程段界限
add eax,edi ;公用例程段基地址
mov ecx,0x00409800 ;字节粒度的代码段描述符
call make_gdt_descriptor
mov [esi+0x28],eax
mov [esi+0x2c],edx

由于公用例程段后面紧跟着就是核心数据段地址,因此有

ebx核心数据地址-eax公用例程地址=公用例程段大小

即eax-ebx-->ebx存放公用例程段大小

由于段界限总是比段大小少一个字节,因此dec ebx之后ebx存放的就是公用例程段的界限了

[edi+0x04]->eax,此时eax存放的是公用例程段的汇编地址

add eax,edi相当于[edi+[edi+0x04]]->eax,此时eax存放的才是公用例程段的线性地址

0x00409800-->ecx,表示的段属性为:

G D L AVL P DPL S TYPE(XCRA)
0 1 0 0 1 00 1 1000
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 只能执行
不可读写
地址增大

此时

eax存放公用例程段的线性地址

ebx存放公用例程段的界限

ecx存放公用例程段的属性

三个参数均已准备完毕

调用函数call make_gdt_descriptor,注册这个段描述符

返回时edx存放描述符高四个字节,eax存放描述符低四个字节

image-20220830180249500

下面只需要把两个寄存器分别写到内存中GDT的第5个槽位中即可

这就是两个mov干的事情

1
2
mov [esi+0x28],eax
mov [esi+0x2c],edx
建立核心数据段描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 core_data_seg    dd section.core_data.start
;核心数据段位置#08

core_code_seg dd section.core_code.start
;核心代码段位置#0c
...
;建立核心数据段描述符
mov eax,[edi+0x08] ;核心数据段起始汇编地址
mov ebx,[edi+0x0c] ;核心代码段汇编地址
sub ebx,eax
dec ebx ;核心数据段界限
add eax,edi ;核心数据段基地址
mov ecx,0x00409200 ;字节粒度的数据段描述符
call make_gdt_descriptor
mov [esi+0x30],eax
mov [esi+0x34],edx

由于数据段后面紧跟着代码段,因此代码段汇编地址-数据段汇编地址=数据段大小

这个值再减一得到数据段界限,放到ebx中作为参数

[edi+0x08]是数据段的汇编地址,

[edi+[edi+0x08]]是数据段的线性地址,放到eax作为参数

该数据段包括了pgdt,allocate_memory使用的ram_alloc(下一次分配内存的起始地址),符号表,字符串表,内核缓冲区,内核栈指针暂存区,cpu版本号信息

这里的符号表有固定的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
;符号地址检索表
salt:
salt_1 db '@PrintString'
times 256-($-salt_1) db 0
dd put_string
dw sys_routine_seg_sel

salt_2 db '@ReadDiskData'
times 256-($-salt_2) db 0
dd read_hard_disk_0
dw sys_routine_seg_sel

salt_3 db '@PrintDwordAsHexString'
times 256-($-salt_3) db 0
dd put_hex_dword
dw sys_routine_seg_sel

salt_4 db '@TerminateProgram'
times 256-($-salt_4) db 0
dd return_point
dw core_code_seg_sel

每个符号都是这种结构:

1
2
3
标号	256byte 符号名字符串(多余填0)
dd 符号地址
dw 符号段地址

这个符号表在用户程序链接时将会发挥重大作用,装载器将会根据内核符号表设置用户符号表,落实用户程序对内核函数的引用

每个符号256byte,这意味着最大可以有\(2^{16}=64K=65536\)个符号,显然这对于玩具内核来说,是用不完的

建立核心代码段描述符
1
2
3
4
5
6
7
8
9
10
;建立核心代码段描述符
mov eax,[edi+0x0c] ;核心代码段起始汇编地址
mov ebx,[edi+0x00] ;程序总长度
sub ebx,eax
dec ebx ;核心代码段界限
add eax,edi ;核心代码段基地址
mov ecx,0x00409800 ;字节粒度的代码段描述符
call make_gdt_descriptor
mov [esi+0x38],eax
mov [esi+0x3c],edx
修改描述符表界限

在建立三个内核段之前,有四个在实模式下建立的段还有下标为0的空槽.因此之前GDT的界限是40-1=39

现在多了三个新的段描述符,GDT的界限就成了64-1=63

1
mov word [0x7c00+pgdt],63          ;描述符表的界限
重新加载GDTR

GDT的基址没有变化,但是界限增大了,而GDTR中保存的界限还是之前的39,需要重新加载GDTR更新界限

1
lgdt [0x7c00+pgdt]      

到此新的GDT建立完毕了

image-20220830192340336

转让控制权

到此主引导记录的任务就完成了,也给内核铺好了路,下面就到了内核发挥作用了

1
jmp far [edi+0x10]  

edi指向内核基址,edi+0x10就是内核代码入口点的指针地址,[edi+0x10]相当于解引用,就是内核代码入口点,

本远jmp指令就将控制转移到该入口点位置,即设置eip=[edi+0x10] ,顺便cs也改了

玩具内核

主引导记录最后一个远跳转将控制转移到了内核的[edi+0x10]即start标号处,start标号在内核源代码的第531行,从这里开始分析,看看内核干了什么

设置数据段

1
2
3
4
core_data_seg_sel     equ  0x30    ;内核数据段选择子 
...
mov ecx,core_data_seg_sel ;使ds指向核心数据段
mov ds,ecx

core_data_seg_sel=0x30->ecx->ds

即把selector=0x30这么一个Index=0x6的全局0环选择子放到ds里,

对应的是GDT偏移0x30处的核心数据段描述符

打印字符串

1
2
3
4
5
6
7
     message_1        db  '  If you seen this message,that means we '
db 'are now in protect mode,and the system '
db 'core is loaded,and the video display '
db 'routine works perfectly.',0x0d,0x0a,0
...
mov ebx,message_1
call sys_routine_seg_sel:put_string

调用了公用例程段的put_string函数

这样使用call指令会把冒号前面的sys_routine_seg_sel(0x28)作为段选择子放到cs寄存器并查GDT,写入高速缓存器

然后将eip设置为段内偏移量put_string标号

call会自动把call前下一条指令的地址的段:偏移 地址压栈,方便retf时从栈里退出来还给cs:eip

该函数的调用约定是用ds:ebx指向串地址作为唯一的参数

函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
put_string:                                 ;显示0终止的字符串并移动光标 
;输入:DS:EBX=串地址
push ecx ;函数开端,被调用者保存寄存器
.getc:
mov cl,[ebx] ;ebx相当于字符串指针,[ebx]解引用取一个字符放到cl字节寄存器上
or cl,cl ;判断这个字节是否是0
jz .exit ;如果是0则函数返回
call put_char ;不是0则调用子函数put_char
inc ebx ;ebx指针向后移动一位,轮到打印下一个字符
jmp .getc ;重复getc过程

.exit:
pop ecx ;返还被调用者保存寄存器
retf ;段间返回

put_string相当于循环调用了put_char,

put_char的调用约定是CL作为参数传递需要打印字符的ASCII码

put_char设计IO操作,需要访问端口,目前尚未学习,留作后话

显式处理器信息

cpuid指令

cpuid指令需要eax作为参数,根据eax的不同值决定不同的返回结果,返回值使用eax,ebx,ecx,edx四个寄存器承担,

然后拷贝到内存里[cpu_brand]上

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
cpu_brnd0        db 0x0d,0x0a,'  ',0
cpu_brand times 52 db 0
cpu_brnd1 db 0x0d,0x0a,0x0d,0x0a,0
...
;显示处理器品牌信息
mov eax,0x80000002
cpuid
mov [cpu_brand + 0x00],eax
mov [cpu_brand + 0x04],ebx
mov [cpu_brand + 0x08],ecx
mov [cpu_brand + 0x0c],edx

mov eax,0x80000003
cpuid
mov [cpu_brand + 0x10],eax
mov [cpu_brand + 0x14],ebx
mov [cpu_brand + 0x18],ecx
mov [cpu_brand + 0x1c],edx

mov eax,0x80000004
cpuid
mov [cpu_brand + 0x20],eax
mov [cpu_brand + 0x24],ebx
mov [cpu_brand + 0x28],ecx
mov [cpu_brand + 0x2c],edx

mov ebx,cpu_brnd0
call sys_routine_seg_sel:put_string
mov ebx,cpu_brand
call sys_routine_seg_sel:put_string
mov ebx,cpu_brnd1
call sys_routine_seg_sel:put_string

使用cpuid获取信息并拷贝到内存后,用put_string函数以此打印出来

加载并执行用户程序

首先还是打印一句废话表示现在在干什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 core_entry       dd start          ;核心代码段入口点#10
dw core_code_seg_sel

....
mov ebx,message_5
call sys_routine_seg_sel:put_string
mov esi,50 ;用户程序位于逻辑50扇区,这是写死的,实际的操作系统不这样
call load_relocate_program
mov ebx,do_status
call sys_routine_seg_sel:put_string

mov [esp_pointer],esp ;临时保存堆栈指针

mov ds,ax

jmp far [0x10] ;控制权交给用户程序(入口点)
;堆栈可能切换

然后调用了load_relocate_program,这个函数就在本段之内,所以不是段间调用,不需要修改段寄存器

该函数作用是加载用户程序,类似于加载elf程序

其调用约定是,esi保存需要加载的程序在磁盘中的逻辑扇区号

执行完毕后将该用户程序头段选择子放在ax寄存器返回

然后打印一个"Done"字符串意思是加载完了,可以执行了

下面临时保存当前堆栈指针,并且把数据段换成ax返回值,即用户程序头段选择子

然后远跳转到[0x10],默认使用段寄存器ds,由于先前已经将ds修改为用户程序头段选择子,用户程序头中[0x10]位置存放的是该程序的入口点地址prgentry,这就相当于间接跳转将控制转移到程序的入口点

但是吧,这里ds到底指向谁,也就是说从load_relocate_program返回的时候,ax到底是啥,到现在不是很确定,

后面详细分析一下这个加载器函数干了啥吧

从用户程序返回

用户程序返回时也是远jmp指令跳转到内核的return_point,至于怎么跳转的现在不做讨论,只需要知道它跳转到了return_point

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
return_point:                                ;用户程序返回点
mov eax,core_data_seg_sel ;使ds指向核心数据段
mov ds,eax

mov eax,core_stack_seg_sel ;切换回内核自己的堆栈
mov ss,eax
mov esp,[esp_pointer]

mov ebx,message_6
call sys_routine_seg_sel:put_string

;这里可以放置清除用户程序各种描述符的指令
;也可以加载并启动其它程序

hlt

回来之后把ds从用户数据段改成内核数据段,把ss从用户堆栈改成内核堆栈,并且设置好执行用户程序之前的内核栈顶指针

然后打印废话

1
2
message_6        db  0x0d,0x0a,0x0d,0x0a,0x0d,0x0a
db ' User program terminated,control returned.',0

意思是从用户程序回来了

最后hlt停机,使处理器处于停止状态,不再执行任何指令

下面着重研究一下加载器是怎么工作的

用户程序的加载过程

协议

加载器和用户程序头必须有相同的协议,加载器认为用户程序头的第10个字节是入口点,用户程序头也得这样认为

这个协议集中体现在用户程序头上,比如elf头,coff头,pe头等等

这里适用于玩具内核的用户程序也有自己的头,它长这样

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
SECTION header vstart=0

program_length dd program_end ;程序总长度#0x00

head_len dd header_end ;程序头部的长度#0x04

stack_seg dd 0 ;用于接收堆栈段选择子#0x08
stack_len dd 1 ;程序建议的堆栈大小#0x0c
;以4KB为单位

prgentry dd start ;程序入口#0x10
code_seg dd section.code.start ;代码段位置#0x14
code_len dd code_end ;代码段长度#0x18

data_seg dd section.data.start ;数据段位置#0x1c
data_len dd data_end ;数据段长度#0x20

;-------------------------------------------------------------------------------
;符号地址检索表
salt_items dd (header_end-salt)/256 ;#0x24

salt: ;#0x28
PrintString db '@PrintString'
times 256-($-PrintString) db 0

TerminateProgram db '@TerminateProgram'
times 256-($-TerminateProgram) db 0

ReadDiskData db '@ReadDiskData'
times 256-($-ReadDiskData) db 0

header_end:

加载器工作前的协议头

项目 大小(bytes) 文件偏移(bytes) 意义
program_length program_end 4 0 程序总大小
head_len header_end 4 4 程序头大小
stack_seg -- 4 8 接收栈段选择子
stack_len -- 4 12 程序建议的栈长度
prgentry start 4 16 入口点文件文件偏移
code_seg section.code.start 4 20 代码段文件偏移
code_len code_end 4 24 代码段长度
data_seg section.data.start 4 28 数据段文件偏移
data_len data_end 4 32 数据单长度
salt_items (header_end-salt)/256 4 36 符号表项数
符号1... 符号值(字面量) 256Bytes 40 第一个符号
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号
...

加载器工作后的协议头

项目 大小(bytes) 文件偏移(bytes) 原意义 新意义
program_length program_end 4 0 程序总大小 --
head_len header_end 4 4 程序头大小 程序头段选择子
stack_seg -- 4 8 接收栈段选择子 程序堆栈段选择子
stack_len -- 4 12 程序建议的栈长度 --
prgentry start 4 16 入口点文件文件偏移 --
code_seg section.code.start 4 20 代码段文件偏移 程序代码段选择子
code_len code_end 4 24 代码段长度 --
data_seg section.data.start 4 28 数据段文件偏移 程序数据段选择子
data_len data_end 4 32 数据单长度 --
salt_items (header_end-salt)/256 4 36 符号表项数 --
符号1... 符号值(字面量) 256Bytes 40 第一个符号 --
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号 --
...

为啥加载器工作后有些变量会有新的意义?需要详细研究加载器工作过程之后再说

salt不是密码学上的盐,是符号表的意思

符号表是链接使用的,这些符号都是操作系统内核函数的桩,装载程序时需要把函数的实际地址写到符号表中.

可以把这些由用户程序调用的位于内核中的函数视为libc一样的存在,也可以视为系统调用函数.反正现在内核还没有分的这么清.

既然链接发生在程序装载时,那么是否可以认为这是动态链接呢?

内核和加载器不需要知道用户程序的逻辑是什么,只需要清楚用户程序是否按照协议规定了自己的文件头,并从该文件头获取入口点,各区段地址等信息

加载器工作流程

内核代码的387到528行是加载器函数的源代码

它调用了很多其他函数

1
2
3
4
5
sys_routine_seg_sel:read_hard_disk_0
sys_routine_seg_sel:allocate_memory
sys_routine_seg_sel:read_hard_disk_0
sys_routine_seg_sel:make_seg_descriptor
sys_routine_seg_sel:set_up_gdt_descriptor

这些函数的功能从名字上就能看出来,用到时再分析函数逻辑和调用约定

start中调用加载器时使用esi传递了唯一的参数,该参数是用户程序所在的逻辑扇区号,在这个玩具内核中被写死为50

函数开端

函数开端保存了一众寄存器,将ds段切换到内核数据段

(实际上调用这个函数之前ds一直也是内核数据段,这里切换一下就不用管先前是啥了,以防万一)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
load_relocate_program:                      ;加载并重定位用户程序
;输入:ESI=起始逻辑扇区号
;返回:AX=指向用户程序头部的选择子
push ebx
push ecx
push edx
push esi
push edi

push ds
push es

mov eax,core_data_seg_sel
mov ds,eax ;切换DS到内核数据段

读取程序头

1
2
3
mov eax,esi                        ;读取程序头部数据 
mov ebx,core_buf
call sys_routine_seg_sel:read_hard_disk_0

esi中是start传过来的目标程序头所在的逻辑扇区号,放到eax上作为参数

core_buf是一个2048字节的缓冲区的标号,内核用它来暂时存放程序头一个扇区512字节

1
core_buf   times 2048 db 0         ;内核用的缓冲区

core_buf->ebx即把该缓冲区地址放到ebx上作为另一个参数

read_hard_disk_0这个函数的调用约定是

eax=逻辑扇区号

ds:ebx=目标缓冲区地址

返回值ebx=ebx+512

由于函数设计IO操作,暂时不分析其逻辑,留作后话

这里跨段调用read_hard_disk_0,cs寄存器修改为sys_routine_seg_sel段选择子

但是ds不变,因此调用函数和加载器共享数据段

函数调用完毕后从core_buf指向的内存地址开始的512个字节就是用户程序的头512个字节的拷贝

计算程序总大小

1
2
3
4
5
6
7
;以下判断整个程序有多大
mov eax,[core_buf] ;程序尺寸
mov ebx,eax
and ebx,0xfffffe00 ;使之512字节对齐(能被512整除的数,
add ebx,512 ;低9位都为0
test eax,0x000001ff ;程序的大小正好是512的倍数吗?
cmovnz eax,ebx ;不是。使用凑整的结果

[core_buf]->eax->ebx即将程序开始的四个字节放到ebx中,从协议上可知这四个字节就是程序总大小

ebx与0xfffffe00按位与,即与

1111'1111'1111'1111'1111'1110'0000'0000按位与

即保留高23位,低9位置0,即舍弃不满\(2^9B=512B\)的部分,向下取整

然后ebx+512->ebx相当于刚才的操作向上取整,即程序如果不是512B的倍数,则向上取整到最近的512B倍

如果程序本来的大小就是512B的倍数,则其低9位都是0,那么test eax,0x000001ff会置ZF=0,如果真是这样则此时eax就作为加载总字节数,否则将经过取整的ebx作为加载总字节数

上述过程说了这么多,实际上就干了一个事,计算程序占用的所有扇区的总字节数,最后一个扇区要是不满则按一个扇区算

为程序申请内存空间

由于程序执行需要有自己的代码段,数据段,堆栈段,并且还要和内核的独立,那么就需要另外申请

1
2
3
4
mov ecx,eax                        ;实际需要申请的内存数量
call sys_routine_seg_sel:allocate_memory
mov ebx,ecx ;ebx -> 申请到的内存首地址
push ebx ;保存该首地址

实际需要申请的内存数量->eax->ecx

allocate_memory函数使用ecx作为参数,需要申请的内存大小,用ecx寄存器返回申请到的内存的首地址

返回值->ecx->ebx压栈保存

allocate_memory函数实现

allocate_memory用一个ram_alloc记录当前内存用到哪里了,

allocate_memory函数十分滴简单,他只会从ram_alloc开始分配希望的大小,然后更新ram_alloc为下一次调用做准备

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
allocate_memory:                            ;分配内存
;输入:ECX=希望分配的字节数
;输出:ECX=起始线性地址
push ds
push eax
push ebx

mov eax,core_data_seg_sel
mov ds,eax

mov eax,[ram_alloc] ;ram_alloc是下一次分配的地址
add eax,ecx ;下一次分配时的起始地址

;这里应当有检测可用内存数量的指令

mov ecx,[ram_alloc] ;返回分配的起始地址

mov ebx,eax
and ebx,0xfffffffc
add ebx,4 ;强制对齐
test eax,0x00000003 ;下次分配的起始地址最好是4字节对齐
cmovnz eax,ebx ;如果没有对齐,则强制对齐
mov [ram_alloc],eax ;下次从该地址分配内存,更新下一次分配地址
;cmovcc指令可以避免控制转移
pop ebx
pop eax
pop ds

retf

计算总扇区数

1
2
3
4
5
6
xor edx,edx
mov ecx,512
div ecx
mov ecx,eax ;总扇区数


edx置零,eax中存放的是申请的字节数,

edx:eax÷512得到扇区数,由于eax已经对扇区向上取整过了,这里一定得到整数,没有余数,商在eax中

扇区数(商)->eax->ecx

这里把扇区数放到ecx是有目的的,后面循环拷贝扇区时就需要用ecx作为循环变量计数

加载数据段

由于要给程序分配空间,需要访问内核数据段之外的内存,显然使用内核数据段会触发访问越界异常,因此需要将数据段改成可以访问全部4G空间的那个数据段

1
2
mov eax,mem_0_4_gb_seg_sel         ;切换DS到0-4GB的段
mov ds,eax

拷贝整个程序到内存

1
2
3
4
5
       mov eax,esi                        ;起始扇区号 
.b1:
call sys_routine_seg_sel:read_hard_disk_0
inc eax
loop .b1 ;循环读,直到读完整个用户程序

esi是程序所在的第一个逻辑扇区号,拷贝到eax作为参数,

每次循环将eax指定的扇区从硬盘拷贝到ds:ebx指定的内存位置,

然后ebx+512指向下一块内存空间,eax自增1表示拷贝下一个逻辑扇区,ecx每次loog自动减1,表示循环计数-1,直到ecx降为0完成整个程序的拷贝

建立四个程序段描述符

建立每个段描述符时用到了两个函数

1
2
sys_routine_seg_sel:make_seg_descriptor
sys_routine_seg_sel:set_up_gdt_descriptor
make_seg_descriptor

该函数的调用约定是,EAX传递线性基地址,EBX传递段界限,ECX传递属性,各属性位的位置和段描述符中相同

返回EDX:EAX完整的段描述符

set_up_gdt_descriptor

该函数的调用约定是,EDX:EAX传递完整的段描述符,输出CX作为段选择子

该函数会修改内存中的GDT,并且增大GDT界限,修改GDTR寄存器

至于函数实现,一看就明白,不用分析了

建立程序头部段描述符
1
2
3
4
5
6
7
8
9
;建立程序头部段描述符
pop edi ;恢复程序装载的首地址
mov eax,edi ;程序头部起始线性地址
mov ebx,[edi+0x04] ;段长度
dec ebx ;段界限
mov ecx,0x00409200 ;字节粒度的数据段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x04],cx ;返回段选择子放到edi+0x04即程序头预留好的

上来就pop,将栈顶弹给edi,这栈顶是啥呢?是allocate_memory返回的申请到的内存首地址->ecx->ebx->压栈

堆栈顶的历史沿革

1
2
3
4
mov ecx,eax                        ;实际需要申请的内存数量
call sys_routine_seg_sel:allocate_memory
mov ebx,ecx ;ebx -> 申请到的内存首地址
push ebx ;保存该首地址

此处edi->eax作为段基地址

[edi+0x04]即程序的第4个字节,由协议可知是head_len,即程序头长度->ebx然后ebx-1->ebx作为段界限

0x00409200->ecx作为段属性

G D/B L AVL P DPL S TYPE(XEWA)
0 1 0 0 1 00 1 0010
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 可读写
不可执行
正向增长

最后段选择子放到[edi+0x04],这里原来是程序头大小,程序装载后就不需要了,把程序头段选择子放到这里

建立程序代码段描述符
1
2
3
4
5
6
7
8
9
;建立程序代码段描述符
mov eax,edi
add eax,[edi+0x14] ;代码起始线性地址
mov ebx,[edi+0x18] ;段长度
dec ebx ;段界限
mov ecx,0x00409800 ;字节粒度的代码段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x14],cx

edi还是指向程序起始地址

[edi+0x14]是代码段的文件偏移(或者说汇编地址)

[edi+[edi+0x14]]是代码段的线性地址,放到eax中作为参数

[edi+0x18]是代码段长度,减一后放到ebx作为参数

0x00409800作为段属性放到ecx上作为参数

G D/B L AVL P DPL S TYPE(XCRA)
0 1 0 0 1 00 1 1000
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 可执行
不可读写
正方向生长

eax,ebx,ecx作为参数传递给make_seg_descriptor后,edx:eax返回完整的段描述符,立刻用set_up_gdt_descriptor写入到内存GDT并更新GDTR

最后把段选择子放到[edi+0x14],原来是代码段偏移,现在放代码段选择子

建立程序数据段描述符
1
2
3
4
5
6
7
8
9
10
;建立程序数据段描述符
mov eax,edi
add eax,[edi+0x1c] ;数据段起始线性地址
mov ebx,[edi+0x20] ;段长度
dec ebx ;段界限
mov ecx,0x00409200 ;字节粒度的数据段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x1c],cx

edi还是程序基址

[edi+0x1c]是数据段的汇编地址

[edi+[edi+0x1c]]是数据段的线性地址,放到eax作为参数

[edi+0x20]是数据段长度,减一后放到ebx作为参数

0x00409200描述段属性,放到ecx作为参数

G D/B L AVL P DPL S TYPE(XEWA)
0 1 0 0 1 00 1 0010
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 可读写
不可执行
正向生长

最后把段选择子放到[edi+0x1c],这里本来是数据段的汇编地址,程序装载后就没用了,现在放上数据段选择子

建立程序堆栈段描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
;建立程序堆栈段描述符
mov ecx,[edi+0x0c] ;4KB的倍率
mov ebx,0x000fffff
sub ebx,ecx ;得到段界限
mov eax,4096
mul dword [edi+0x0c]
mov ecx,eax ;准备为堆栈分配内存
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;得到堆栈的高端物理地址
mov ecx,0x00c09600 ;4KB粒度的堆栈段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x08],cx

[edi+0x0c]是程序建议的段长度(粒度是4K),放到ecx中

0xfffff->ebx

0xfffff-[edi+0x0c]->ebx得到段界限

即堆栈段栈顶在0x100000位置,栈底在0xfffff-[edi+0x0c]

4K×[edi+0x0c]->eax->ecx作为参数,希望申请的空间大小

调用allocate_memory函数,ecx返回该申请地址的首地址

返回值->ecx->eax

0x00c09600->ecx作为段属性

G D/B L AVL P DPL S TYPE(XEWA)
1 1 0 0 1 00 1 0110
粒度4KB 32位段 非64位段 内存中存在 0环 非系统段 可读写
不可执行
反向生长

此时eax存放段基地址,ebx存放段界限,ecx存放段属性,均已准备完毕,调用make_seg_descriptor,edx:eax返回完整的描述符

然后set_up_gdt_descriptor写入内存,更新GDTR

最后把堆栈段选择子放到[edi+0x08],这里是给堆栈段选择子预留的空间

四个程序段建立后的程序头

项目 大小(bytes) 文件偏移(bytes) 原意义 新意义
program_length program_end 4 0 程序总大小 --
head_len header_end 4 4 程序头大小 程序头段选择子
stack_seg -- 4 8 接收栈段选择子 程序堆栈段选择子
stack_len -- 4 12 程序建议的栈长度 --
prgentry start 4 16 入口点文件文件偏移 --
code_seg section.code.start 4 20 代码段文件偏移 程序代码段选择子
code_len code_end 4 24 代码段长度 --
data_seg section.data.start 4 28 数据段文件偏移 程序数据段选择子
data_len data_end 4 32 数据单长度 --
salt_items (header_end-salt)/256 4 36 符号表项数 --
符号1... 符号值(字面量) 256Bytes 40 第一个符号 --
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号 --
...

重定位符号表

1
2
3
4
5
6
7
8
9
;重定位SALT
mov eax,[edi+0x04]
mov es,eax ;es -> 用户程序头部
mov eax,core_data_seg_sel
mov ds,eax
cld ;设置串拷贝正方向

mov ecx,[es:0x24] ;用户程序的SALT条目数
mov edi,0x28 ;用户程序内的SALT位于头部内0x2c处

[edi+0x04]现在是程序头段选择子,放到es中es就指向了程序头

为啥要指向程序头?不指向程序数据段或者代码段?

因为这里要进行符号拷贝,而符号在程序头中

core_data_seg_sel是内核数据段选择子,放到ds上

看样子设置ds和es,是要进行跨段串拷贝了,方向是ds:esi->es:edi

此时es指向程序基址

[es:0x24]是salt_items,程序符号表符号数,放到ecx中作为循环变量

[es:0x28]是第一个符号的起始地址,因此edi置为0x28,这样es:edi就指向第一个符号了,

此时目的地就设置好了,还差一个源操作数的esi没有设置好

为啥没有立刻设置呢?

因为内核中的符号数量可能大于等于用户程序的符号数量,并且符号的排列顺序也有可能不相同,如果要重定位程序的符号表,可以想到的两种方法,一是根据程序符号表遍历内核符号表,即对每一个程序符号,都遍历内核符号表,找到其在内核中的地址然后写到该程序符号上

另一种是根据内核符号表遍历程序符号表

这里装载器采用的是根据用户符号表查内核符号表的方式,具体实现如下

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
.b2: ;外圈循环,遍历用户符号表
push ecx ;保存用户程序符号数
push edi ;保存用户符号在程序头段的偏移量

mov ecx,salt_items ;内核中的符号总数量,大于等于程序使用到的符号数量
mov esi,salt ; ;esi指向内核符号表
.b3:;中圈循环,对每一个用户符号表,都要遍历一遍内核符号表
push edi ;
push esi ;中圈循环中的源地址
push ecx ;中圈循环的循环变量,在进入内圈循环之前压栈保存

mov ecx,64 ;检索表中,每条目的比较次数 ,内圈循环变量
;内圈循环,字符串匹配,判断两个符号名是否相同
repe cmpsd ;每次比较4字节 ,64*4=256,恰好是每个符号的长度
;内圈循环结束,如果两个字符串256个字节完全相同则ZF=1,否则ZF=0
jnz .b4 ;不相同,即不是同一个符号,不匹配,跳转
mov eax,[esi] ;若匹配,esi恰好指向其后的地址数据,
;内核符号表中,每个符号的256个字节的符号名之后紧跟着符号的段偏移量,因此此时[esi]就是符号的段偏移量
mov [es:edi-256],eax ;将字符串改写成偏移地址 ;由于比较的时候edi和esi会同时增大,这里要把edi倒回来,然后把eax中的符号段偏移量放到程序符号名这里,原来程序符号表存放的是符号名,符号解析后就成了符号地址
mov ax,[esi+4] ;esi再加4越过内核符号段偏移量,指向内核符号段选择子
mov [es:edi-252],ax ;将段选择子拷贝到程序符号中,紧跟着段偏移量后面
.b4:

pop ecx ;恢复中圈循环变量
pop esi ;恢复中圈源操作数
add esi,salt_item_len ;源操作数加上一个内核符号表项的长度,指向下一个内核符号
pop edi ;从头比较
loop .b3 ;用新的内核符号和刚才的用户程序符号比较,遍历内核符号直到找到匹配该用户符号的内核符号
;中圈循环结束,如果执行到此,说明刚才的用户程序符号解析完毕,应该解析下一个用户程序符号了
pop edi ;恢复外圈目的操作数
add edi,256 ;外圈目的操作数加上程序符号表项的长度,指向下一个程序符号
pop ecx ;恢复外圈循环变量
loop .b2 ;重复外圈循环,遍历程序符号表项,每次解析一个程序符号
;外圈循环结束,所有程序符号都已解析

翻译成伪代码就是

1
2
3
4
5
6
7
8
for(s1:用户符号表){
for(s2:内核符号表){
if(strcmp(s1,s2)==0){
s2段选择子:s2段内偏移 解析到s1;
break;
}
}
}

函数尾声

设置返回值

加载器函数约定的是ax作为返回值,此时es指向的是用户程序头段,即es就是用户程序头段选择子

1
mov ax,[es:0x04]

[es:0x04]指向用户程序头段选择子,现在可以肯定地说,加载器函数的返回值是用户程序头段选择子

恢复被调用者保存寄存器
1
2
3
4
5
6
7
8
pop es                             ;恢复到调用此过程前的es段 
pop ds ;恢复到调用此过程前的ds段

pop edi
pop esi
pop edx
pop ecx
pop ebx
返回
1
ret

到此加载器工作完成,用户程序已经加载进入内存

用户程序的执行过程

内核让权

加载器工作完成后,内核通过一个远跳转,将控制交给用户程序的入口点start

1
2
3
4
5
6
7
8
9
      call load_relocate_program	;返回时ax中存放程序头段选择子
...

mov [esp_pointer],esp ;临时保存堆栈指针

mov ds,ax ;ds获取程序头段选择子

jmp far [0x10] ;控制权交给用户程序(入口点)
;堆栈可能切换

[0x10]默认使用ds段,此时的ds是程序头段选择子,因此[0x10]就是程序入口点

程序还权

1
2
3
4
5
6
start:
mov eax,ds
mov fs,eax
...;程序具体干了啥不重要了,无非读取一些信息然后打印

jmp far [fs:TerminateProgram] ;将控制权返回到系统

程序头段选择子ds->eax->fs

TerminateProgram在装载时被解析为'@TerminateProgram'这个符号,在内核符号表中,这个符号长这样

1
2
3
4
salt_4           db  '@TerminateProgram'
times 256-($-salt_4) db 0
dd return_point
dw core_code_seg_sel

也就是说,符号解析把core_code_seg_sel:return_point放到了[fs:TerminateProgram]

这里间接远跳转就跳到了内核中的return_point标号处

1
2
3
4
5
6
       ;内核中:
jmp far [0x10] ;控制权交给用户程序(入口点);内核让权
;堆栈可能切换
;程序让权,跳转到return_point
return_point:
...

总结

到此,实模式到保护模式的转换,玩具内核的加载,用户程序的加载就都实现了

还有几个遗留的小问题,就是如何访问IO端口,read_hard_disk_0put_char两个函数如何实现的,留作后话

chapter 11 保护模式

全局描述符表

32位保护模式下任何段使用之前都需要注册登记,否则不让用,

注册时还需要说明该段的访问权限,如果一个只能读写的段非要在上面执行代码会被制止

如果访问范围超过了段的界限也会除法处理器产生内部异常中断

注册登记段信息的地方就是描述符表,描述符表有全局的GDT也有局部的LDT

全局描述符表是给整个系统服务的,处理器从实模式进入保护模式之前必须设置好全局描述符表,即GDT是在实模式下建立的,那么其内存地址应该不超过8086的寻址范围1M,(在进入保护模式之后搬到别的地方另说)

处理器怎么直到GDT放到内存上哪里了呢?全局描述符表寄存器GDTR就是干这个事的--它专门记录全局描述符表在内存中的位置

GDTR有48位,高32位记录的是全局描述符表的基地址,低16位记录的是该表的界限,因此该表可以在在32位可寻址的4G内存的任何地方,长度最长是64KB.

又全局描述符表的表项一条是8字节,因此该表最大可以有64K/8=8K条记录

image-20220829164636897
image-20220829164840344

段选择子

段描述符

用段寄存器中存放的选择子中的索引查段描述符表得到段描述符,段描述符相当于一个保存段信息的结构体

段描述符就是描述符表GDT或者LDT等的表项,每个段描述符长8字节

image-20220829165349719

这个段描述符长的很不顺溜,段基地址被分成了三块,段界限被分成两块

这样设计是为了和废物16位保护模式兼容

基址和界限

段基地址共32位,段界限共20位,即一个段的基地址可以是4G地址空间中的任何地方,段大小最大是1M(或4G,取决于粒度G的规定)

粒度G

Granularity,粒度,用于解释段界限的含义

段界限占用了16位,如果以1B为单位,则一个段最大是\(2^{20}\times 1B=1MB\)大小

然而4G的地址空间应该允许以G为量级的段

当段界限的单位是4KB时则一个段最大是\(2^{20}\times 4KB=4G\)

为啥要以4KB为单位?因为分页时一页的大小就是4KB,这样规定粒度方便给一个段分配页数

G=0表示段界限的单位是1B

G=1表示段界限的单位是4KB

段描述符类型S

S=0表示系统段

S=1表示代码段或者数据段

描述符特权级DPL

Descpirtor Privilege Level

指定要访问该段需要最低的权限

即0环还是3环,规定段级别,

0环为最高级,只能由系统访问

3环为最低级,可以由系统或者用户程序访问.

段存在位P

Segment Present

P=0表示段不存在于物理内存中,即建立了描述符但是尚未建立对应物理页,或者刚才该段在内存中存在但是现在被交换到了磁盘中,也需要把P置0

P=1表示该段已经在物理内存中了

该位用于触发缺页中断,属于虚存调度策略

默认操作数大小D/B

Default Operation Size

用于兼容16位保护模式

对于代码段,该位是D位

D=0表示指令中的偏移地址和操作数都是16位的,比如使用ax,ip等16位的寄存器,不使用eax,eip等32位寄存器,即使eax的高16位有东西也忽略

D=1则是32位的

对于数据段,该位是B位

B=0表示16位的,B=1表示32位的

B=0使用16位栈顶指针sp,不使用esp,栈边界也是16位的

描述符子类型TYPE

TYPE占用了4位,分别是X(执行),E/C(拓展方向/特权依从),W(写)A位

image-20220829172645403

A位不管是代码段还是数据段,都表示是否已访问(Access),属于虚存调度的范畴

对数据段

E指定的拓展方向,该段是往地址增大的方向生长,比如堆;还是往地址减小的方向生长,比如栈

W=0表示只读,W=1表示读写,不管怎么找,必须有读的权限

X=0表示不可执行,X=1表示可执行,可以猜测NX保护就是修改的该位

对代码段

C表示是否特权级依从,这里的特权级就是DPL指定的段描述符特权级

C=0表示可以被同级段调用

C=1表示可以被低级段调用

R表示是否可读,

R=0不可读

R=1可读

不管怎么着,代码段一定是不可写,可执行的

这里的可读不可读是对程序的限制,不是对处理器的限制,处理器从代码段取代码是不受限制的,但是程序如果尝试使用[cs:offset]从代码段取东西看看,是不被允许的

软件可用位AVL

操作系统使用,处理器不管这一位.算是预留的一位

64位代码段标记L

L=1表示64位

L=0表示32位

32位下该位置0

走向保护模式

例子中将栈安排在0x7C00开始往低地址方向生长

主引导程序512个字节占据从0x7C00开始到0x7E00

从0x7E00开始的64K到0x17DFF是GDT

image-20220829191817146

计算GDT所在的逻辑段地址

1
2
3
4
5
6
7
8
9
10
;计算GDT所在的逻辑段地址 
mov ax,[cs:gdt_base+0x7c00] ;低16位
mov dx,[cs:gdt_base+0x7c00+0x02] ;高16位
mov bx,16
div bx
mov ds,ax ;令DS指向该段以进行操作
mov bx,dx ;段内起始偏移地址
....
gdt_size dw 0
gdt_base dd 0x00007e00 ;GDT的物理地址

0x7c00是本程序加载到内存中的位置

cs:gdt_base是该标号的汇编地址

两者加起来才得到该标号的物理地址也就是gdt_base的地址

把这个地址开始的四个字节0x00007e00放到dx:ax里,然后除以16,商放到ds里作为段地址,余数放到bx里作为段内起始偏移地址

此时还处在实模式,因此段地址除以16再交给段寄存器

从ds:bx开始就是全局段描述符表了

创建段描述符

Intel处理器要求0号段描述符为空,有意义的段描述符从1号开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;创建0#描述符,它是空描述符,这是处理器的要求
mov dword [bx+0x00],0x00
mov dword [bx+0x04],0x00

;创建#1描述符,保护模式下的代码段描述符
mov dword [bx+0x08],0x7c0001ff
mov dword [bx+0x0c],0x00409800

;创建#2描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区)
mov dword [bx+0x10],0x8000ffff
mov dword [bx+0x14],0x0040920b

;创建#3描述符,保护模式下的堆栈段描述符
mov dword [bx+0x18],0x00007a00
mov dword [bx+0x1c],0x00409600

这里1号段描述符的意义是:

段基址0x00007c00,恰好是主引导记录加载到内存中的地址

段界限0x001ff,段长度为512字节

G=0,粒度为字节,

D=1,32位段

L=0,非64位段

AVL=0

P=1,目前位于内存中

DPL=00,0环

S=1,代码段

TYPE(XCRA)=1000,只能执行,向上拓展

初始化段描述符表寄存器

1
2
3
4
;初始化描述符表寄存器GDTR
mov word [cs: gdt_size+0x7c00],31 ;描述符表的界限(总字节数减一)

lgdt [cs: gdt_size+0x7c00]

算上没有意义的0号描述符,一共有四个描述符,共32字节,因此GDT表的界限应该是31,放到gdt_size中

lgdt指令用于加载GDT表地址到GDTR寄存器,其操作数是一个48位数,也就是内存中6个字节,高32位是GDT地址,低16位是GDT界限

这里使用lgdt [cs: gdt_size+0x7c00]意思是从gdt_size标号开始的48位,低16位作为GDT界限,高32位作为GDT地址,放到GDTR中

而gdt_size开始的内存是这样定义的:

1
2
gdt_size         dw 0		;低地址,一个字,16
gdt_base dd 0x00007e00 ;高地址,一共双字,32

由于先前用mov word [cs: gdt_size+0x7c00],31已经设置好了gdt_size

这里lgdt准确地将GDT的地址和界限放到了GDTR中

A20与地址回绕

8086只有20根地址总线A0-A19,不存在A20这根线.

在8086时,地址最大值是0xFFFFF,再加1就高位截断了成了0x00000,这就是地址回绕.书上说当时很多程序员利用这个"特性"编程,并且好像还那个以此为自豪

80286时地址线就有24根儿了,0xFFFFF+1=0x100000,因为位数足够多,不会高位截断,也就不再回绕了,这样原来利用地址回绕写的程序全都寄了.

为了保持兼容性,保持一下这些程序员的自尊心,IBM在A20上设置了一个开关,兼容8086时就不用A20,让他一直置0,这就有了0x0FFFFF+1=0x000000,又绕起来了.IBM把A20和键盘控制器上一个开关按位与了再接到内存条子上,这个开关的端口号0x60.

向0x60端口写入数据,第一位置1则该键向与门输出1,此时A20生效

向0x60端口写入数据,第一位置0则该键向与门输出0,此时A20失效

image-20220829203454342

80486以后处理器有了A20M#引脚,低电平时A20失效

image-20220829203538236

向0x92端口的第二位(位1)置1就打开了A20,A20有效.置0则A20失效.

开机时自动置有效

0x60和0x92关于A20的控制是或,即只要有一个开关打开,A20就有效

而要从实模式转换为32位保护模式,显然需要打开A20

1
2
3
in al,0x92                         ;南桥芯片内的端口 
or al,0000_0010B
out 0x92,al ;打开A20

in就是从0x92读取一个字节的数据放到al寄存器

然后通过按位或将al的第二位(位1)置高,其他位不变

然后out将al输出到0x92一个字节

这就设置好了0x92端口处的快速A20和初始化寄存器.A20就打开了

关闭中断

进入保护模式后,BIOS提供的实模式下的中断功能不能再使用,而保护模式的中断环境尚未设置,因此进入保护模式前需要先关闭中断

1
cli

CR0与保护模式

控制CPU运行模式的开关在CR0寄存器

CR0的最低位(位0)如果是1则CPU进入保护模式,置0则为实模式

至于CR0其他位干啥的现在不关心

1
2
3
mov eax,cr0                    ;cr0放到eax
or eax,1 ;低位置1
mov cr0,eax ;设置PE位

此后CPU就工作在保护模式了

32位机器上的段寄存器

image-20220829210802621

高16位是段选择子,对外可见,并且兼容8086的段寄存器用法

描述符高速缓存器不可见,存放段基地址,段界限,段属性

为啥叫缓存器呢?

32位实模式段寄存器用法

8086实模式下,段寄存器中直接放段基址,段寄存器就是16位,没有描述符高速缓存器这种东西,寻址的时候就段寄存器×16+偏移量

而32位机器的实模式,前16位和8086的段寄存器作用相同,但是有高速缓存器这种东西

它缓存了个啥呢?寻址的时候不是要段寄存器×16吗,高速缓存器就缓存了这个值(聊胜于无吧)

给段寄存器赋值的时候就把该值✖16然后放到高速缓存器中了

对外表现仍然像8086的20位实模式,只不过由于高速缓存器的存在,速度更快了

32位保护模式段寄存器的用法

32位保护模式下,段寄存器CS,DS等等仍然是16位的,显然让他们继续保存段基址已经放不下了,他们确实也不再直接保存段地址,而是保存的段选择子,

段选择子是段描述符表的下标,

即用段选择子去查相应的段描述符表,得到的表项是段描述符,

段描述符中包含了段基址,界限,段类型等等各种信息

1
2
3
实模式:查段寄存器立刻获得段基址
32位保护模式:查段寄存器中的段选择子获得段描述符表下标,查段描述符表获得段描述符
段描述符包含段基址信息
image-20220829170144452

高13位就是段描述符表中的下标,13位可以寻址8K条记录,这和段描述符表最大记录数量是一致的

再低一位是全局/局部 段描述表标志,如果是0则该选择子中的索引是全局描述符表的下标

如果是1则该选择子中的索引是局部描述符表的下标

最低的两位是请求特权级RPL,表示给出该选择子的程序的特权级

这里要区分段描述符中的DPL和段选择子的RPL

DPL表示的是该段的特权级

RPL表示需要访问该段的程序的特权级

高速缓存器的作用是啥呢?在段描述符表中也有段的基址,界限,属性,为啥又要在描述符高速缓存器中再写一遍?这就是"缓存"的作用.如果没有高速缓存寄存器,那么每次使用这个段,都需要用段选择字查段描述符表获得段描述符指定的基址和界限,这就涉及到内存访问了.如果第一次放问该段时查表获得了段基址,把他存到高速缓存器中,那么下一次使用这个段的时候,就不需要访问内存了.显然访问寄存器速度比访问内存快

保护模式下的内存访问

数据段当初是这样创建的

1
2
3
;创建#2描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区) 
mov dword [bx+0x10],0x8000ffff
mov dword [bx+0x14],0x0040920b

段基址指向0xb8000,即显存区域

mbr程序中,将ds寄存器置为数据段的选择子,数据段描述符在全局段描述符表的第3项,下标为2,权限为00,因此将0x10放到ds中作为段选择子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
flush:
mov cx,00000000000_10_000B ;加载数据段选择子(0x10)
mov ds,cx

;以下在屏幕上显示"Protect mode OK."
mov byte [0x00],'P' ;这里[0x00]默认使用ds指向段
mov byte [0x02],'r'
mov byte [0x04],'o'
mov byte [0x06],'t'
mov byte [0x08],'e'
mov byte [0x0a],'c'
mov byte [0x0c],'t'
mov byte [0x0e],' '
mov byte [0x10],'m'
mov byte [0x12],'o'
mov byte [0x14],'d'
mov byte [0x16],'e'
mov byte [0x18],' '
mov byte [0x1a],'O'
mov byte [0x1c],'K'

加载描述符高速缓存器

mov ds,cx这条改变段寄存器ds的指令执行之后,处理器会自动查GDT表获取段基址,界限,属性,填到高速缓存器中

这个过程用图表示为

image-20220829213426419

用段寄存器中的索引值乘以8是因为,GDT表的表项8字节,加上GDTR中存放的GDT表基地址,就得到了相应表项的起始地址,从该段描述符中获取相关信息填到高速缓存器中

地址翻译

由于访问内存前首先要设置段寄存器,只要是使段寄存器发生变化的指令,比如mov,jmp far,call far等,都会导致处理器自动加载高速缓存器

那么当实际需要访问内存的时候,高速缓存器已经加载好了,基地址是0xb8000,

这时高速缓存器中的基址等信息和用选择子查GDT表获取到的基址等信息是相同的,因此不需要再查段地址了

image-20220829213823071

mov byte [0x00],'P'这条指令,默认使用ds指向的数据段,偏移量0x00,

寻址的时候只需要从ds段寄存器的描述符高速缓存器中,

把缓存好的数据段界限拿出来,和偏移量比一下,看看该偏移量是否越界了,如果没有则

把缓存好的数据段基址拿出来,加上该偏移量,得到32位线性地址0xb8000

地址总线上0x000b8000信号置高

然后把'P'的ASCII码放到数据总线上,置高

然后CPU发出内存写指令,'P'就写到内存的0x000b8000位置了,这个位置恰好又是显存映射区,因此直接输出到屏幕了

这里地址翻译的结果是"线性地址",不是物理地址,这是因为,如果使用了分页机制,那么该线性地址有可能不等于物理地址,其所在虚拟页号不一定等于物理页号

如果没有使用分页机制,那么可以说线性地址就是物理地址

取指过程也类似

image-20220829214456572

清空流水线

在进入保护模式前,段寄存器以及高速缓存器已经有东西了,进入保护模式需要更新这些值.并且很多实模式的指令已经在流水线上了,进入保护模式后不再适用,需要清空流水线

使用远jmp或者远call,既可以更新段寄存器,又可以把流水线扬了

因此在设置PE位之后有一个jmp dword跳转

jmp dword 32位远跳转指令

1
2
3
4
5
6
7
8
9
10
11
12
     mov eax,cr0                    ;cr0放到eax
or eax,1 ;低位置1
mov cr0,eax ;设置PE位

;以下进入保护模式... ...
jmp dword 0x0008:flush ;16位的描述符选择子:32位偏移
;清流水线并串行化处理器
[bits 32] ;伪指令,此后用32位编译

flush:
mov cx,00000000000_10_000B ;加载数据段选择子(0x10)
mov ds,cx

这里jmp dword 0x0008:flush

意思是跳转到一个32位地址,段选择子是0x0008,(即GDT索引为0x1,G=0,RPL=00)偏移量为flush

dword修饰意思是使用32位的偏移量,编译成的机器码带有前缀0x66,表示处理器会按32位的方式执行该指令

由于当前已经处于16位保护模式,又dword表明使用32位方式执行,因此cs的段选择子会被置为0x8,高速缓存器也会查GDT后填入0x7c00基址,0x1ff界限

flush就交给EIP寄存器

因为代码段可能有转移,刚才顺序执行的流水线无效了,全都扬了

然后[bits 32]是nasm伪指令,意思是后面的代码编译成32位模式

但是保护模式下不允许使用mov指令修改CS寄存器内容,就算用ax寄存器中转也白搭.

只是对于CS寄存器有这个限制,其他段寄存器没有限制

保护模式的栈

堆栈段描述符

GDT中堆栈段描述符长这样

1
2
3
;创建#3描述符,保护模式下的堆栈段描述符
mov dword [bx+0x18],0x00007a00
mov dword [bx+0x1c],0x00409600

线性基地址0

段界限0x7A00,最大7A00字节

粒度G=0字节

D=1,32位段,默认push压栈4个字节,使用esp(如果是D=0,16位,则默认push压栈字,使用sp)

S=1,数据段

P=1,在内存中

DPL=0,0环

TYPE=0010 可读写,向下生长

初始化堆栈

1
2
3
mov cx,00000000000_11_000B         ;加载堆栈段选择子
mov ss,cx
mov esp,0x7c00

选择子意思是下标0x11=3,查全局段描述符表,0环权限

将该段选择子放到ss堆栈段寄存器,将引起处理器自动查GDT表获取段基址放到段描述符高速缓存器中

然后将esp置为0x7c00表示栈顶指针位置,

对于esp,有一个要求,esp>粒度×界限,也就是说esp最低要保证栈空间满足粒度呈×界限这么多,但是高不封顶

啥意思呢?你不是esp要比粒度×界限大吗,我大一个字节也是大,大10个字节也是大,esp顶到天上也是大

image-20220829223100310

esp的变化方向将会是0x7c00->0

使用堆栈

1
2
3
4
5
6
7
8
9
10
     mov ebp,esp                        ;保存堆栈指针 
push byte '.' ;压入立即数(字节)

sub ebp,4
cmp ebp,esp ;判断压入立即数时,ESP是否减4
jnz ghalt
pop eax
mov [0x1e],al ;显示句点
ghalt:
hlt ;已经禁止中断,将不会被唤醒

ebp获得esp拷贝

push byte指令导致'.'压栈,但是实际压入栈中的是一个双字,esp会减4,在esp+1放上'.',在esp+2,esp+3,esp+4都放0

为了证实这一点,ebp直接-4,如果刚才的理论正确,则ebp此时应该等于esp,那么cmp指令将会把ZF=1置起来.那么jnz跳转不实现,那么将栈上刚压入的一个四字推给eax寄存器,esp+4恢复原样,然后al字节放到显存[0x1e]上打印句点到屏幕

也就是说只要是运行起来最后有句点,说明理论正确

image-20220829224646277

运行结果确实有句点,证明push byte '.'导致栈顶下降了4字节

调试

可能会预见的问题

每次虚拟机运行关闭之后,都会在虚拟硬盘目录下面产生一共.lock文件

image-20220829232306055

只要是有这个东西下一次开机虚拟机准起不来

扬了就行了

观察处理器上电后的段寄存器状态

使用bochs调试运行nobody.vhd,bochs会自动在第一条指令指向前停下

此时用r观察所有寄存器状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<bochs:1> r
rax: 00000000_00000000
rbx: 00000000_00000000
rcx: 00000000_00000000
rdx: 00000000_00000000
rsp: 00000000_00000000
rbp: 00000000_00000000
rsi: 00000000_00000000
rdi: 00000000_00000000
r8 : 00000000_00000000
r9 : 00000000_00000000
r10: 00000000_00000000
r11: 00000000_00000000
r12: 00000000_00000000
r13: 00000000_00000000
r14: 00000000_00000000
r15: 00000000_00000000
rip: 00000000_0000fff0
eflags 0x00000002: id vip vif ac vm rf nt IOPL=0 of df if tf sf zf af pf cf

除了程序计数器rip,其他寄存器全是0

使用sreg观察所有段寄存器状态

bochs可以观察高速缓存器的内容

dh,dl是段描述符的内容,显然此时还没有建立GDT,dh和dl的值是bochs根据高速缓存器的值造的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<bochs:2> sreg
es:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
cs:0xf000, dh=0xff0093ff, dl=0x0000ffff, valid=7
Data segment, base=0xffff0000, limit=0x0000ffff, Read/Write, Accessed
ss:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ds:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
fs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
gs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ldtr:0x0000, dh=0x00008200, dl=0x0000ffff, valid=1
tr:0x0000, dh=0x00008b00, dl=0x0000ffff, valid=1
gdtr:base=0x0000000000000000, limit=0xffff
idtr:base=0x0000000000000000, limit=0xffff

只有cs段寄存器的基地址是0xf0000,其他都是0

lgdt之后全局gdtr寄存器的变化

lgdt以内存操作数的低16位为界限,高32位为基址,加载gdt表信息到gdtr寄存器

怎么观察这个事呢?

首先需要找到lgdt的地址,可以在0x7c00处下断点,按c执行到此,然后u/20反汇编20条指令,观察这些指令的地址

image-20220829230722202

这就找到了lgdt的地址,然后再0x7c5f上下断点,按c执行到此

执行之前sreg打印一下gdtr的状态

1
gdtr:base=0x00000000000f9ad7, limit=0x30

s单步执行之后再打印一下gdtr的状态

1
gdtr:base=0x0000000000007e00, limit=0x1f

使用xp/8 0x7e00观察GDT表

1
2
3
4
<bochs:13> 0x/8 0x7e00
[bochs]:
0x0000000000007e00 <bogus+ 0>: 0x00000000 0x00000000 0x7c0001ff 0x00409800
0x0000000000007e10 <bogus+ 16>: 0x8000ffff 0x0040920b 0x00007a00 0x00409600

0x7e00处是全空的0下标段描述符,后面的段描述符是有实际意义的

置PE位后段寄存器的变化

通过设置CR0的PE位,处理器进入保护模式,段寄存器仍然保存了实模式下的内容,除非有修改段寄存器的指令

怎么观察这个事呢?

首先需要找到即将进入保护模式的指令

可以使用u/balabala 反汇编一坨指令找他

1
0000000000007c73: (                    ): mov cr0, eax              ; 0f22c0

在0x7c73下断点然后c执行到此

image-20220829231354276

执行前后用sreg观察段寄存器是没有任何变化的

后面jmp dword远跳转就会改变段寄存器了,首先反汇编找到该远跳转的地址

1
0000000000007c76: (                    ): jmpf 0x0008:0000007e      ; 66ea7e0000000800

在0x7c76下断点然后c执行到此

执行前先sreg打印一下CS的状态

1
2
cs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed

s单步执行后再sreg观察cs的状态

1
2
cs:0x0008, dh=0x00409900, dl=0x7c0001ff, valid=1
Code segment, base=0x00007c00, limit=0x000001ff, Execute-Only, Non-Conforming, Accessed, 32-bit

此时dh,dl都指向了GDT中的信息,cs存放的是选择子

高速缓存器中的base和limit业已设置好了

观察控制寄存器CR0的变化

重新调试运行,找到设置PE位的指令

1
0000000000007c73: (                    ): mov cr0, eax              ; 0f22c0

在0x7c73下断点然后c运行到此

执行前creg打印一下CR0的状态

1
2
<bochs:6> creg
CR0=0x60000010: pg CD NW ac wp ne ET ts em mp pe

此时的pe=0表明处理器工作在实模式

然后s单步执行之后creg观察CR0

1
2
3
4
5
<bochs:7> s
Next at t=17179024
(0) [0x000000007c76] 0000:0000000000007c76 (unk. ctxt): jmpf 0x0008:0000007e ; 66ea7e0000000800
<bochs:8> creg
CR0=0x60000011: pg CD NW ac wp ne ET ts em mp PE

果然PE=1了,表明处理器工作在保护状态

mermaid制图

mermaid in typora

image-20220621095136061

在代码块的代码类型处声明为mermaid就可以使用mermaid制图了

代码块首行classDiagram表明本代码块要绘制一个类图

image-20220621105215969

journey

旅程图(或者说游记图,随便爱咋咋叫)

源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
journey		#首行表明本图类型为游记
title 摆烂周记 #标题,整个图表只能有一个title,最终显示的时候以最后出现的title为准
section 6.15-6.17 #section声明了一个章节,在下一个section之前的部分为该章节的组成
上午摆烂 :5 #章节组成用 事情:心情指数 表示,中间的冒号是解析器的判断标志. 心情从06(实际上范围更大)
下午摆烂 :6 #缩进不会影响显示,但是规范起见还是缩进对齐
section 6.18
上午摆烂 :4
下午摆烂 :4
事情貌似不太对劲 :3 #一个章节下面可以有任意个部分
晚上继续摆烂 :5
section 6.19
上午摆烂 :4
下午摆烂 :5
section 6.20
复习毛概 :1
考毛概 :0
section 6.21
复习软概 :1
背软概 :0

效果图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
journey		#首行表明本图类型为游记
title 摆烂周记 #标题,整个图表只能有一个title,最终显示的时候以最后出现的title为准
section 6.15-6.17 #section声明了一个章节,在下一个section之前的部分为该章节的组成
上午摆烂 :5 #章节组成用 事情:心情指数 表示,中间的冒号是解析器的判断标志. 心情从0到6(实际上范围更大)
下午摆烂 :6 #缩进不会影响显示,但是规范起见还是缩进对齐
section 6.18
上午摆烂 :4
下午摆烂 :4
事情貌似不太对劲 :3 #一个章节下面可以有任意个部分
晚上继续摆烂 :5
section 6.19
上午摆烂 :4
下午摆烂 :5
section 6.20
复习毛概 :1
考毛概 :0
section 6.21
复习软概 :1
背软概 :0

flowchart

流程图

定义节点

定义节点的格式:

1
节点句柄<节点内容> %%尖括号表示节点类型

节点句柄纯粹是为了下一次使用该节点的时候方便索引同一个节点,

节点类型有多种,比如

方形(缺省值,即只写节点句柄时默认使用的形状),圆角,菱形,平行四边形等等

只写句柄的节点又叫做一次性节点,因为这时句柄还起节点内容的作用,不方便被再次索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flowchart LR;
A(圆角节点)
B[方形节点]
C([跑道节点])
D[[子程序节点]]
E[(数据库节点)]
F((圆圆圈圈))
G>旗帜节点]
H{菱形判断框}
I{{六边形节点}}
J[/平行四边形节点/]
K[\反平行四边形节点\]
L[/体形节点\]
M[\倒梯形节点/]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart TB
A(圆角节点)
B[方形节点]
C([跑道节点])
D[[子程序节点]]
E[(数据库节点)]
F((圆圆圈圈))
G>旗帜节点]
H{菱形判断框}
I{{六边形节点}}
J[/平行四边形节点/]
K[\反平行四边形节点\]
L[/体形节点\]
M[\倒梯形节点/]

节点关系

类比CS Academy

学图论时,用过CS Academy

image-20220621195316039

规范的写法是在Node Count这一行给出节点数量n

然后接着在Graph Data的前n行分别给出每个节点的句柄

然后接下来的任意多行给出这几个节点的关系,

比如1 2就是在1和2节点之间连一条边,如果是directed有向图则方向从左向右,即1->2

如果边有边权,则按照A B dist这种格式输入给定

在mermaid中,使用方法和CS Academy类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart 
A((0))%%首先声明一下节点句柄,类型,内容
B((1))
C((2))
D((3))
E((4))
F((5))
A--10-->C%%带有权值的有向边类型
A--5-->E
B--11-->C
C--18-->E
F--20-->A
D--15-->F
A--5--->F

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart
A((0))
B((1))
C((2))
D((3))
E((4))
F((5))
A--10-->C
A--5-->E
B--11-->C
C--18-->E
F--20-->A
D--15-->F
A--5--->F

边的长度和类型也是可以改变的

1
2
3
4
5
6
7
8
9
flowchart LR		
I---J %%中间的横杠越多,线越长,如果两个点之间有多条连线,此时多加横杠有可能不起作用
I-->J
I--文本-->J
I-.->J
I-.文本.->J
I ==粗线===J
I ==粗箭头线==>J
1 & 2 & 3--> 4 & 5
1
2
3
4
5
6
7
8
9
flowchart LR		
I---J
I-->J
I--文本-->J
I-.->J
I-.文本.->J
I ==粗线===J
I ==粗箭头线==>J
1 & 2 & 3--> 4 & 5

箭头也有多种类型

1
2
3
4
5
6
7
8
flowchart LR
A((1))
B((2))
A ----o B %%圈结尾
A ----x B %%x结尾
A <---> B %%双向箭头
A ----> B %%单项箭头

1
2
3
4
5
6
7
8
flowchart LR
A((1))
B((2))
A ----o B %%圈结尾
A ----x B %%x结尾
A <---> B %%双向箭头
A ----> B %%单项箭头

子图

声明子图用

1
2
3
subgraph	<子图句柄> <[子图名]>
...#中间的内容为子图
end
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
flowchart
A((0))
B((1))

subgraph sub1 [点子图]
C((2))
D((3))
end

E((4))
F((5))


A--10-->C

subgraph sub2 [边子图]

A--5-->E
end

B--11-->C
C--18-->E
F--20-->A
D--15-->F
A--5--->F

G--->sub1 %%点指向子图
sub1-->sub2 %%子图之间互相指
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
flowchart

A((0))
B((1))

subgraph sub1 [点子图]
C((2))
D((3))
end

E((4))
F((5))


A--10-->C

subgraph sub2 [边子图]

A--5-->E
end

B--11-->C
C--18-->E
F--20-->A
D--15-->F
A--5--->F

G--->sub1 %%点指向子图
sub1-->sub2 %%子图之间互相指

子图也可以嵌套子图

链接

1
2
3
4
5
6
7
flowchart
A %%声明三个节点
B
G
click B "https://www.baidu.com/" "linkage to baidu" %%给B节点上一个到百度的链接,点击跳转
click G "http://www.github.com" "linkage to github" %%给G上一个到github的链接

1
2
3
4
5
6
flowchart 
A
B
G
click B "https://www.baidu.com/" "linkage to baidu"
click G "http://www.github.com" "linkage to github"

节点样式

css样式

1
style <节点句柄> 键1:值1,键2:值2...
1
2
3
4
flowchart LR
id1(Start)-->id2(End)
style id1 fill:#0A0,stroke:#ff4,stroke-width:16px
style id2 fill:#bbf,stroke:#f66,stroke-width:2px,color:#fff,stroke-dasharray: 5 5

实际上已经是层叠样式表css的范畴了

属性名称 属性值
fill 节点内容的底色
stroke 外边框颜色
stroke-width 外边框宽度,单位像素
color 节点内容字体颜色
stroke-dasharray 外边框的边框虚实线
1
2
3
4
flowchart LR
id1(Start)-->id2(End)
style id1 fill:#0A0,stroke:#ff4,stroke-width:16px
style id2 fill:#bbf,stroke:#f66,stroke-width:2px,color:#fff,stroke-dasharray: 5 5

样式类:

相当于设定一个固定的样式模板

1
classDef <样式类名> 键1:值1,键2:值2...;

使用时将该模板应用于某个节点(子图也可以)

1
2
3
class <节点索引1><节点索引2>... <样式类名>;
or
<节点索引>:::<样式类名> %%注意中间有四个冒号
1
2
3
4
5
6
7
flowchart
classDef 黄圈绿底黑字 fill:#0A0,stroke:#ff4,stroke-width:16px;%%声明一个样式类型
A((节点A));%%声明一个圆圈节点
class A 黄圈绿底黑字;%%将刚才的样式类型应用于A
B[(数据库)];
classDef 数据库专用 fill:#bbf,stroke:#f66,stroke-width:2px,color:#fff,stroke-dasharray: 5 5;
B:::数据库专用
1
2
3
4
5
6
7
flowchart
classDef 黄圈绿底黑字 fill:#0A0,stroke:#ff4,stroke-width:16px;%%声明一个样式类型
A((节点A));%%声明一个圆圈节点
class A 黄圈绿底黑字;%%将刚才的样式类型应用于A
B[(数据库)];
classDef 数据库专用 fill:#bbf,stroke:#f66,stroke-width:2px,color:#fff,stroke-dasharray: 5 5;
B:::数据库专用

甚至多套上几圈可以造一个靶

默认类

如果声明一个叫做default的样式类,则所有没有手动分配样式类的节点都会自动使用default样式,子图也是

1
2
3
4
5
6
7
flowchart
classDef default fill:#0A0,stroke:#ff4,stroke-width:16px;%%声明一个样式类型
A(( ))
subgraph sub [子图]
B((dot))
end
C[/绿的/]
1
2
3
4
5
6
7
flowchart
classDef default fill:#0A0,stroke:#ff4,stroke-width:16px;%%声明一个样式类型
A(( ))
subgraph sub [子图]
B((dot))
end
C[/绿的/]

fontawesome

1
fa:#<图表类名>#
1
2
3
flowchart TD
E["fa:fa-ge" Imperial March!]
R["fa:fa-ra may the force be with you!"]

星战的帝国和叛军竟然也被收录进了字体库,pornhub都没有这荣幸(可能是它图标太长了)

1
2
3
4
flowchart TD
E["fa:fa-ge" Imperial March!]
R["fa:fa-ra may the force be with you!"]

1
2
3
flowchart TD
L["fa:fa-linux" linux]
W["fa:fa-windows" windows]

erDiagram

信息世界基本概念

实体关系模型:(ER模型)由实体类型及其之间的关系组成

实体:客观存在并且可相互区分的事物

比如具体的人,或者抽象的一次订货

属性:实体的某一特性

比如人的年龄,一次订货中的订货数量

比如学生,学生的姓名学号年龄等都是学生实体的属性

1
2
3
4
5
6
7
8
9
10
11
erDiagram
STUDENT{
string name
int ID
char gender
int grade
int class
string TEL
int age
}

码:(key,键):唯一标识实体的属性集

比如人的学号,一次订货的订单号

实体型:用实体名及其属性名集合来抽象和刻画同类实体

比如 用户(用户名,密码,VIP等级,账号等级)

实体集:同一类型实体的集合

比如全体用户

联系:

实体内部联系:一个实体的不同属性之间的关系

实体间联系:不同实体集之间的联系

ER图

Entity-Relationship digram

用来描述现实世界的概念模型

1
2
3
4
5
erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ LINE-ITEM : contains
CUSTOMER }|..|{ DELIVERY-ADDRESS : uses

实体名约定俗成要大写

语法

1
<first-entity> [<relationship> <second-entity> : <relationship-label>]

比如

1
PROPERTY ||--|{ ROOM : contains
1
2
erDiagram
PROPERTY ||--|{ ROOM : contains

mermaid将first-entity和second-entity都画成矩形框,表示实体,框里是实体名

实体关系用<relationship>指定,在图上表现为关系连线上的字

关系

这里的关系指的是ER图中实体间的外关系,不是一个实体的不同属性的内关系

传统的ER图用菱形框表示关系

比如这样表示学生和选课的多对多关系

1
2
3
4
5
flowchart LR;
S[学生]
C[课程]
R{选课}
S --n--- R --m--- C

在mermaid ER图中,不再使用菱形表示关系,而是用鱼尾纹连线

1
<first-entity> [<relationship> <second-entity> : <relationship-label>]

这里relationship就是关系,相当于传统模型的菱形框,

relationship-label是关系描述,相当于原传统模型菱形框中的文字,如果多于一个单词则整个字符串用双引号包括,如果空则双引号空

relationship可以细分成三部分,第一个实体相对于第二个实体的数量,第二个实体相对于第一个实体的数量,是否是子关系

关于数量关系

Value (left) Value (right) Meaning
|o o| Zero or one
|| || Exactly one
}o o{ Zero or more (no upper limit)
}| |{ One or more (no upper limit)

比如

1
A||--|{B

这就表示A对B是一对多关系

1
2
erDiagram
CAR }|--|{ DRIVER : DRIVE
1
2
erDiagram
CAR }|--|{ DRIVER : DRIVE

这个图就可以翻译为,一辆车可以被不同司机驾驶,一个司机可以驾驶不同车辆

1
2
erDiagram
DRIVER ||--|{ CAR : possess

这个图就可以翻译为,一个司机可以拥有多辆车,但是一辆车只能有一个主人

如果在一个关系中,一个实体必须依附于其他实体才能存在,则这两个实体之间需要用实线连接

如果在一个关系中,两个实体可以相互独立存在,则用虚线连接

1
2
erDiagram
CAR }|--|{ DRIVER : DRIVE
1
2
erDiagram
CAR }|--|{ DRIVER : DRIVE

没有驾驶员车也是存在的,车报废了驾驶员也是活着的

但是对于驾驶证,没有驾驶员就没有他的证,没有证人就不是驾驶员

一个驾驶员可以有多个驾驶证,A证B证C证

一个普通人(比驾驶员限制宽松)就不一定需要有驾驶证

1
2
3
erDiagram
DRIVER ||--|{ LICENSE : have
PERSON |o..o{ LICENSE : have

属性

传统的实体图,实体的属性使用椭圆表示的,用一根线连接到实体上

img

而mermaid画的实体,规定属性的方式类似于类图

1
2
3
4
5
6
7
8
9
10
11
12
erDiagram
STUDENT {
string name
int ID
char gender
int grade
int class
string TEL
int age
}


1
2
3
4
5
6
7
8
9
10
erDiagram
LICENSE{
string LicenseID
string LicenseIssueDate
string DriverName
string DriverID
image Driveravatar
}
DRIVER ||--|{ LICENSE : have
PERSON |o..o{ LICENSE : have

主引导记录

书上借助主引导扇区的例子,讲解了实模式下分段机制,显存的使用等等基本原理

为啥要借助主引导扇区呢?

因为虚拟机开机之后BIOS自动将磁盘的前512字节作为主引导记录装载进入内存的0x7c00位置并从0x7c00处开始执行.

我们的代码直接写到主引导记录中就可以顺理成章地执行了

自举过程

计算机上电之后,如果设置硬盘位首选的启动设备,则ROM-BIOS将视图读取硬盘的逻辑0号扇区,即物理上的0面0道1扇区.这里就是主引导扇区的位置

ROM-BIOS会把这个扇区的512字节搬到内存的0x7c00处,后面就是对内存中的拷贝进行的一系列操作

ROM-BIOS检查这512字节的最后两个字节是否是0x55,0xAA,这是主引导扇区魔数

如果主引导扇区有效则跳转打0x7c00继续执行

主引导扇区的内容一般是检测操作系统在硬盘上的位置,把grub代码加载道内存,跳转grub举起操作系统来

显示

显卡与显示器

显卡负责给显示器提供内容

显示器负责显式

显示器接到显卡上,显卡接到主板上

显卡控制显示器的最小单位是像素,每个像素点需要24比特存储颜色(RGB各8比特).显卡使用字节的显式存储器(显存,video RAM,VRAM)存放像素点的颜色.要显式啥就把啥信息放到显存里.

显示器是一个二维平面,儿显存就是一个内存条子的存储器,它是线性的,这就涉及到一维数组到二维数组的映射关系了

显存相当于一个缓冲区,CPU把想要输出到屏幕的东西放到显存里.显卡周期性地从显存拿出来按顺序显式到屏幕上.

显卡工作模式

显卡的工作模式分为文本模式和图形模式

图形模式下操作的确实是像素点

文本模式下只需要在显存中写入指定的字符的ASCII码,显卡内部经过字符发生器,就可以翻译成一些像素点的组合输出到屏幕上,也就是代替人干了计算每个像素点位置的事,人只需要指定第几行第几列写哪个字符就可以了

一般个人电脑显卡加电自检之后都会把自己初始化为80×25的文本模式

每行可以显示80个字符,一共可以显示25行.满屏是2000个字符

端口映射内存

如果正儿八经地论起来,除了内存条子,其他的设备都是IO外设,CPU和内存条子打交道的速度得是外设的成千上万倍,如果想要实现细致流畅的游戏画面体验,经过IO访问显存是办不到的.于是把显存也和内存统一编址,这就绕开了IO直接让CPU像访问内存一样访问显存.

对于8086来说,其CPU宽20位,可以寻址1M的内存空间0x0到0x9FFFF,

那从0xA0000到0xFFFFF就不是内存条子的事了,比如

0xB8000到0xBFFFF这32KB的地址空间映射给显存

0xF0000到0xFFFFF这64K的地址空间映射给ROM-BIOS

image-20220825153008702

段寄存器

显存入乡随俗,怎么访问内存就得怎么访问显存

访问内存使用段地址:偏移量,

物理地址=段地址×16+偏移量

那么如果要访问显存,就得根据显存的起始地址设置段地址,由于显存起始地址是0xB8000,因此段地址就是0xB800,寻址的时候CPU会自动把0xB800乘16的

怎样让段寄存器等于0xB800呢?

mov es,0xB800这样写吗?不可以,Intel的处理器规定不允许将立即数传递给段寄存器,必须使用通用寄存器或者内存单元中转一下,也就是说

1
2
mov ax,0xb800                 ;指向文本模式的显示缓冲区
mov es,ax

字符模式下一个表示字符

字符模式下每个字符需要占据连续的两个字节,低字节是该字符的ASCII代码,高字节是该字符的颜色特征

image-20220825162813158

比如这里0xB8000开始的连续两个字节,

低字节0x48是'H'的ASCII码,

高字节0x07=00000111B表示黑背景色不闪烁,白前景色

显示字符

例子中想要显示一串字符串"Label offset:"

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
      mov ax,0xb800                 //es段设置为显存地址起始位置
mov es,ax
mov byte [es:0x00],'L'
mov byte [es:0x01],0x07
mov byte [es:0x02],'a'
mov byte [es:0x03],0x07
mov byte [es:0x04],'b'
mov byte [es:0x05],0x07
mov byte [es:0x06],'e'
mov byte [es:0x07],0x07
mov byte [es:0x08],'l'
mov byte [es:0x09],0x07
mov byte [es:0x0a],' '
mov byte [es:0x0b],0x07
mov byte [es:0x0c],"o"
mov byte [es:0x0d],0x07
mov byte [es:0x0e],'f'
mov byte [es:0x0f],0x07
mov byte [es:0x10],'f'
mov byte [es:0x11],0x07
mov byte [es:0x12],'s'
mov byte [es:0x13],0x07
mov byte [es:0x14],'e'
mov byte [es:0x15],0x07
mov byte [es:0x16],'t'
mov byte [es:0x17],0x07
mov byte [es:0x18],':'
mov byte [es:0x19],0x07

每两个相邻字节的低字节放字符ASCII代码,高字节调制颜色,都是不闪烁的黑背景,白前景

寻址的时候使用的是[es:0x00],转换成物理地址就是

es*16+0x00,为啥要写es呢?因为段寄存器默认是ds,这里需要段超越前缀指定使用es段

而es=0x8b00,乘以16之后恰好就是显存映射地址的起始位置

用byte关键字来修饰操作数的范围

1
mov byte [es:0x00],'L'

意思是'L'顶多占用一个字节,不能再多了,这条指令只会把0x4C放到es:00上

如果这样写

1
mov word [es:0x00],'L'

则把0x4C放到es:0x00,把0x00放到es:0x01

如果是把寄存器的值放到内存上则不用byte或者word修饰操作数的范围,因为寄存器已经自带宽度了

指令地址

汇编地址

前面字句过程中也已经提到过,BIOS会把MBR这个512个字节放到内存条的0x7c00上.也就是说代码是从0x7c00开始的

刚才显存扯了一大堆,但是都是相当于往数据段放东西,这个数据段位于0xB8000

汇编地址:

编译器会把0x7c00开始的MBR代码作为一个独立的段处理

这样每条指令相对于段地址都有一个汇编地址(段内),在lst文件中可以看到这个汇编地址

1
2
3
4
5
6
7
8
 行号 段内偏移 指令								汇编                                    
6 00000000 B800B8 mov ax,0xb800 ;指向文本模式的显示缓冲区
7 00000003 8EC0 mov es,ax
8
9 ;以下显示字符串"Label offset:"
10 00000005 26C60600004C mov byte [es:0x00],'L'
11 0000000B 26C606010007 mov byte [es:0x01],0x07
12 00000011 26C606020061 mov byte [es:0x02],'a'

第一条有意义的指令是在第6行,段内偏移量为0,指令内容是0xB800B8,翻译成汇编语言就是mov ax,0xb800,由于这条指令长3字节,因此可以计算得到下一条指令的段内偏移量就是3

第七行指令的段内偏移量就是3

标号

如果要在汇编语言中写出跳转功能,就得从一个地址转到另一个地址.

怎么指定目标地址呢?让人手工计算目标地址然后写到jmp后面吗?显然人工计算指令地址不显示,比如一段千八行的汇编语言,有好多指令都是跳转或者调用指令,现在第一行的指令发现错了要改,假设原来两个字节的指令改成了一个字节,那么后面的所有指令地址就都减1,这就需要所有的跳转和调用指令重定位.让人手工一行一行改太慢了

于是就发明了标号这种东西,类似于一个变量符号,作用是记录一个地址,编译器会自动计算该标号的地址,在生成二进制码的时候自动把标号翻译成地址,根宏定义展开一样

比如主引导记录中有这么一句:

1
infi: jmp near infi                 ;无限循环

infi标号的这条指令要跳转到infi标号处的指令,也就是自己跳转到自己的开始,这就形成了无线循环

标号还可以直接写到汇编指令里:

1
2
3
4
dividnd dw 0x3f0
divisor db 0x3f
mov ax,[dividnd]
div byte [divisor]

这里dividnd标号是0x3f0的地址,divisor是0x3f的地址

在编译时就像宏定义展开一样,mov ax,[dividnd]这里的标号就会自动转化成地址

声明并初始化数据

1
number db 0,0,0,0,0

这里number是标号,db是定义字节的伪指令,后面定义了五个字节,都初始化为0,相邻两个字节之间用逗号隔开.6

这里定义的五个字节就在当前代码段中

但是标注的做法应该是代码数据分离,数据专门放在数据段中,使用段超越方法寻址

byte [ds:00]就表示数据段的最开始的字节

意思是从number开始的五个字节都是0

dw声明字,dd声明双字,dq声明四字

db,dw,dd,dq都是伪指令,当编译完成时就找不到影子了

编译完成后的样子,就是5个0

1
100 0000012E 0000000000                number db 0,0,0,0,0

用db声明的变量就必须在字节大小范围内,最小是0x00,最大是0xFF,如果比这还大就会截断高位

比如db 0xFF00,编译之后保留低字节0x00

段超越前缀

如果这样写mov al,[0x00]

意思是把内存中0x00位置的数据搬到al寄存器中,问题是,这个0x00是相对于哪个段的偏移量呢?

DS?CS?

默认的数据段是DS,因此如果不指明段超越前缀,就是访问的数据段

但是如果要访问ES段寄存器指向的段,应该怎么写呢?

mov al,[es:0x00]

这里es:就是段超越前缀

除法指令

除法指令div只有一个操作数作为除数

被除数默认是AX寄存器

8086允许两种类型的整数除法,

其一是16位被除数除以8位除数由于8086的寄存器宽度为8,因此一个AX就可以放开被除数.除完了商放到AL,余数放到AH

其二是32位被除数除以16位除数

此时除数刚好占用一个寄存器,但是被除数就需要放到两个寄存器中了,即DX放高16位,AX放低16位,除完了商放在AX,余数放在DX

处理器怎么区分两种除法应该用哪种呢?看除数的格式,如果除数是一个字寄存器,比如div cx,就得是32位除被数除以16位除数.如果是div cl,就是16位被除数除以8位除数

在主引导扇区代码的第37到47行是这样写的

1
2
3
4
5
6
7
8
9
10
mov ax,number ;这里number是一个地址标号
mov bx,10 ;被除数置为10

mov cx,cs
mov ds,cx ;ds寄存器指向当前cx寄存器的段地址

mov dx,0 ;被除数高位置0
div bx ;DX:AX/BX

mov [0x7c00+number+0x00],dl ;dl放的是余数的低字节,也就是number这个地址的个位数字,放到0x7c00+number+0x00这个位置上

主引导记录中一直重复该过程,意思是十进制分解被除数number标号地址的各位,放到0x7c00+number开始的五个字节上

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
mov ax,number                 ;?????number???????
mov bx,10


mov cx,cs
mov ds,cx


mov dx,0
div bx
mov [0x7c00+number+0x00],dl


xor dx,dx
div bx
mov [0x7c00+number+0x01],dl


xor dx,dx
div bx
mov [0x7c00+number+0x02],dl


xor dx,dx
div bx
mov [0x7c00+number+0x03],dl

;????位???????
xor dx,dx
div bx
mov [0x7c00+number+0x04],dl

mov [0x7c00+number+0x00],dl这就很诡异了,0x7c00是怎么来的?

偏移量换算

1
mov [0x7c00+number+0x00],dl

这里多了一个0x7c00,而不是直接mov [number+0x00],dl

这是因为,主引导扇区会被bios装载到内存的0x7c00处

此时CS=0x0000,IP=0x7c00

number是相对于mbr.asm这个文件的偏移量12E.

如果写mov [number+0x00],dl.实际上就是mov [0x12E],dl

放到了内存条上的0x12E位置,直接出了主引导区了

image-20220827151725554

mbr.asm程序自始至终没有修改过cs的值,那么这个值就是BIOS设置的,一直是0x0000不变

显示分解出来的各个数位

分解的各个数位按照个十百千万的顺序放到0x7c00+number+0x00到0x7c00+number+0x04的五个字节中

对于万位上的数字,mbr程序是这样写的:

1
2
3
4
mov al,[0x7c00+number+0x04]
add al,0x30
mov [es:0x1a],al
mov byte [es:0x1b],0x04

把万位数字拿出来放到al里,加上0x30转化成这个数字的ASCII码,然后放到es:0x1a位置,由于es早已设置为0xb800,即显存的起始位置,因此es:0x1a就是第一行的第1a个字符

在es:0x1b位置设置的是该字符的显示样式,设置为0x04意思是无背景不闪烁,红色前景

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
mov al,[0x7c00+number+0x04]
add al,0x30
mov [es:0x1a],al
mov byte [es:0x1b],0x04

mov al,[0x7c00+number+0x03]
add al,0x30
mov [es:0x1c],al
mov byte [es:0x1d],0x04

mov al,[0x7c00+number+0x02]
add al,0x30
mov [es:0x1e],al
mov byte [es:0x1f],0x04

mov al,[0x7c00+number+0x01]
add al,0x30
mov [es:0x20],al
mov byte [es:0x21],0x04

mov al,[0x7c00+number+0x00]
add al,0x30
mov [es:0x22],al
mov byte [es:0x23],0x04

mov byte [es:0x24],'D'
mov byte [es:0x25],0x07

最后另外放了一个黑底不闪烁白字'D',意思是十进制的缩写(Decimal)

因此打印效果为

image-20220827154928612

无限循环

在主引导记录中打印了number的数位分解之后,立刻进入无线循环

1
infi: jmp near infi  

为啥要这样写呢?

因为如果不写无线循环,那么虚拟机就直接寄了,主引导记录并没有抛砖引玉地加载操作系统,这512个字节跑完之后,后面就全是0了,不存在指令了

段内跳转near

jmp near意思是跳转到当前段内的一个地址,也就是不改变CS段寄存器地址,只改变ip的地址,因此操作数只是infi这个标号,不需要cs:infi指定跳转到哪个段的infi

填充与魔数

前面的代码和数据都写完了也远远达不到512个字节,主引导记录需要最后两个字符是0xAA55才有效.因此中间的字节需要说一些废话填充

1
2
times 203 db 0
db 0x55,0xaa

连着定义了203个字节都置0,最后两个字节是0xAA55魔数

到此主引导记录才有效

Bochs调试

之前我们一直使用的都是virtualBox虚拟机,只能运行不能调试,唯一能够调试运行的虚拟机就是Bochs

bochs配置

将主引导记录写入虚拟磁盘vhd文件之后启动bochs,需要在开始菜单上进行一些设置

image-20220827161343625

Disk&Boot

image-20220827161537279

都2202年了,不会还有人用软盘吧

ATA,硬盘接口标准,PATA是以前的IDE接口,SATA是当前使用的接口标准

每个计算机有多个ATA通道,允许加多块磁盘

ATA channel 0 是必须的,将nobody.vhd挂到ATA channel0上即可

ATA channel 0

image-20220827162047232
image-20220827162545217

这里Cylinders,Heads等参数不是乱写的,需要填虚拟磁盘的实际情况,可以手工分析最后512个字节的几何参数,也可以使用教材配套软件fixvhdwr.exe

image-20220827162429565

Boot Options

image-20220827162851643

保存

image-20220827162908917

前面两个设置完毕之后Save保存设置,下一次开Bochs的时候就不用重新设置了

保存一个bochsrc.bxrc文件,找一个牢稳的地方保存,比如bochs的根目录,或者虚拟机根目录,反正就是下一次启动bochs需要手动load该文件,能找到就行

bochs运行主引导记录

设置好之后就可以从bochs启动了

可以看到第一行打印出了"Label offset:00302D"

image-20220827163212152

bochs调试

使用bochsdbg启动虚拟机,此时除了模拟虚拟机屏幕窗口之外,还有一个终端窗口用于调试

image-20220827163659611

bochs在执行第一条指令前停下来等待调试命令

此时第一条指令位于f000:fff0,指令内容是

1
jmp far 0xf000:e05b

这个cs:ip指向的地址翻译成物理地址就是0xfe05b

而BIOS映射到内存的地址是[0xF0000,0xFFFFF],显然第一条指令跳转到了BIOS中

此后在这个调试终端中就可以输入命令进行调试了

断点

如果想在主引导记录开始下断点,由于主引导记录将被bios装载进入内存的0x7c00位置,

因此可以在调试终端中这样写

1
b 0x7c00

这样就在0x7c00出下拉一个断点,处理器将在执行0x7c00的指令之前停下

执行到断点

在0x7c00下断点后,命令c,一直执行直到遇到断点

1
(0) [0x000000007c00] 0000:7c00 (unk. ctxt): mov ax, 0xb800            ; b800b8

确实是主引导记录的第一条指令,将要通过ax中转设置es为显存基地址

分号后面的十六进制数是该条指令的机器码

查看寄存器

使用r命令查看所有通用寄存器内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<bochs:5> r
rax: 00000000_0000aa55
rbx: 00000000_00000000
rcx: 00000000_00090000
rdx: 00000000_00000080
rsp: 00000000_0000ffd6
rbp: 00000000_00000000
rsi: 00000000_000e0000
rdi: 00000000_0000ffac
r8 : 00000000_00000000
r9 : 00000000_00000000
r10: 00000000_00000000
r11: 00000000_00000000
r12: 00000000_00000000
r13: 00000000_00000000
r14: 00000000_00000000
r15: 00000000_00000000
rip: 00000000_00007c00
eflags 0x00000082: id vip vif ac vm rf nt IOPL=0 of df if tf SF zf af pf cf

此时rax存放的是0xAA55,估计是BIOS检查主引导记录有效性时留下的

rip程序计数器此时指向0x7c00

单步执行

单步执行之后,程序停在0x7c03位置,此处的指令是mov es,ax.

1
2
3
<bochs:6> s
Next at t=17178999
(0) [0x000000007c03] 0000:7c03 (unk. ctxt): mov es, ax ; 8ec0

那么此时第一条指令已经执行完毕了,ax的值应该是0xb800,r打印一下

1
2
<bochs:7> r
rax: 00000000_0000b800

确实如此

查看段寄存器

单步执行第二条指令mov es,ax.

之后es应该被赋值为0xb800

使用sreg打印所有段寄存器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<bochs:17> sreg
es:0xb800, dh=0x0000930b, dl=0x8000ffff, valid=1
Data segment, base=0x000b8000, limit=0x0000ffff, Read/Write, Accessed
cs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ss:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ds:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
fs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
gs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ldtr:0x0000, dh=0x00008200, dl=0x0000ffff, valid=1
tr:0x0000, dh=0x00008b00, dl=0x0000ffff, valid=1
gdtr:base=0x00000000000f9ad7, limit=0x30
idtr:base=0x0000000000000000, limit=0x3ff

sreg不但打印了段寄存器内容,还打印了段寄存器隐藏的高速缓存器的内容,这是ollydbg做不到的

高速缓存器还有ldtr,gdtr等寄存器都是在保护模式才会用到,现在不管

查看内存

再连续执行两步,写入显存映射区域

1
2
3
4
5
6
7
(0) [0x000000007c05] 0000:7c05 (unk. ctxt): mov byte ptr es:0x0000, 0x4c ; 26c60600004c
<bochs:18> s
Next at t=17179001
(0) [0x000000007c0b] 0000:7c0b (unk. ctxt): mov byte ptr es:0x0001, 0x07 ; 26c606010007
<bochs:19> s
Next at t=17179002
(0) [0x000000007c11] 0000:7c11 (unk. ctxt): mov byte ptr es:0x0002, 0x61 ; 26c606020061

使用xp(examine memory at physical address),显示指定物理内存处内容

1
xp (/数量) <物理地址>

xp默认情况下一次显示一个双字,xp /n就是显示n个双字

使用xp命令观察0xb8000开始的第一个双字

1
2
3
<bochs:24> xp 0xb8000
[bochs]:
0x00000000000b8000 <bogus+ 0>: 0x0b6f074c

低两个字节是0x074c,正好对应

1
2
mov byte ptr es:0x0000, 0x4c
mov byte ptr es:0x0001, 0x07

退出调试

命令q退出

初始化段地址

汇编地址和物理地址

汇编地址就是文件偏移量

实际加载到内存中时的地址不一定是汇编地址,有可能需要重定位基地址

mbr.asm被BIOS加载到内存的0x7c00处时,所有的汇编地址都需要再加上0x7c00才是其准确的物理地址,这是因为,此时的段寄存器是0,

如果想要在寻址的时候不写这个看上去很奇怪的0x7c00,可以将段寄存器改成0x7c0

1
2
mov ax,0x7c0                  ;设置数据段基地址 
mov ds,ax

此后任何汇编地址A就代表了物理地址A+0x7c0*16也就是准确的物理地址

实际上就是从0000:A+0x7c00转变成0x7c00:A

image-20220829151300719

段间数据传送

在机组课本上这叫做"串操作指令"

movs/movsb/movsw

数据流DS:SI->ES:DI

由cld和std两条指令置正方向还是反方向

cld之后DF=0,正向拷贝,movsb每次si和di加一,movsw每次si和di加二

std之后DF=1,反向拷贝,movsw每次si和di减一,movsw每次si和di减二

rep 重复执行,每次cx-1直到cx减为0,cx相当于循环变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
       jmp near start;跳过数据区

mytext db 'L',0x07,'a',0x07,'b',0x07,'e',0x07,'l',0x07,' ',0x07,'o',0x07,\
'f',0x07,'f',0x07,'s',0x07,'e',0x07,'t',0x07,':',0x07
number db 0,0,0,0,0

start:
mov ax,0x7c0 ;设置数据段基地址
mov ds,ax


mov ax,0xb800 ;设置附加段基地址
mov es,ax

cld ;拷贝方向标志清零,正向传送
mov si,mytext
mov di,0
mov cx,(number-mytext)/2 ;实际上等于 13
rep movsw ;重复cx次,每次重复cx-1,直到为0

将当前数据段的mytext标号开始处的(number-mytext)/2个字,正方向拷贝到0xb800:0000开始的内存区域中,

这恰好把mytext完全拷贝到显存区域

使用循环

loop循环

loop 标号,重复执行标号开始的代码,每次cx-1,如果cx降为0则跳出循环

由于loop指令位于循环的末尾,标号开始的代码无论如何和都要执行至少一次,因此实际上相当于do-while循环

1
2
3
4
5
6
7
8
9
10
11
12
13
       ;得到标号所代表的偏移地址
mov ax,number

;计算各个数位
mov bx,ax
mov cx,5 ;循环次数5次
mov si,10 ;除数
digit:
xor dx,dx
div si ;dx:ax/si
mov [bx],dl ;保存数位,就写入number标号的内存区域
inc bx ;下一次要写入的位置
loop digit

条件跳转循环

1
2
3
4
5
6
7
8
9
10
11
12
13
      ;显示各个数位
mov bx,number
mov si,4
show:
mov al,[bx+si]
add al,0x30
mov ah,0x04
mov [es:di],ax
add di,2
dec si
jns show

mov word [es:di],0x0744

把number标号地址放到bx上,也就是分解number地址的十位得到的数字的地址

si作为偏移量,实际上bx[si]就构成了一个数组,si每次减1,直到0.

每次将[bx+si]放到al再加上0x30成为该数字的ASCII码,ah放上0x04即显示样式

然后ax整个两个字节被放到es:di上,就放到了显存中

然后di-2给下一个字符腾地方

每次si-1,直到减为-1是jns不满足跳转条件,跳出循环,执行下一条,即mov指令

跳出循环后的第一条指令是把0x0744这个字放到es:di上,即把'D'及其显示样式放到显存上

美元标记

NASM提供两个想美元符号的标记

$:单个美元符号,表示当前行的行首汇编地址

$$:双美元符号,表示当前段的起始汇编地址

1
2
3
4
5
6
       jmp near $;当前行首隐藏标号

times 510-($-$$) db 0;当前行首隐藏标号减去从头到次的字节数,
;510是去掉0xAA55之后的字节数
;再去掉前面已经占用的字节数,就是还需要填充的字节数
db 0x55,0xaa

jmp near $就是跳转到本行行首,陷入死循环

times 510-($-$$) db 0重复db 0这条指令若干次,

具体次数是510-程序到本指令之前已经有的字节数

也就是空闲的地方填满0

最后两个字节放0x55,0xaa,有效魔数

调试运行

还是在0x7c00下断点,然后c一直运行到该断点处停下

然后一直s单步执行到rep movsw指令

1
2
3
<bochs:11> s
Next at t=17179007
(0) [0x000000007c36] 0000:7c36 (unk. ctxt): rep movsw word ptr es:[di], word ptr ds:[si] ; f3a5

此时用r命令观察一下各个寄存器的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<bochs:12> r
rax: 00000000_0000b800
rbx: 00000000_00000000
rcx: 00000000_0009000d
rdx: 00000000_00000080
rsp: 00000000_0000ffd6
rbp: 00000000_00000000
rsi: 00000000_000e0003
rdi: 00000000_00000000
r8 : 00000000_00000000
r9 : 00000000_00000000
r10: 00000000_00000000
r11: 00000000_00000000
r12: 00000000_00000000
r13: 00000000_00000000
r14: 00000000_00000000
r15: 00000000_00000000
rip: 00000000_00007c36
eflags 0x00000082: id vip vif ac vm rf nt IOPL=0 of df if tf SF zf af pf cf

rcx=0x9000d,cx=0x000d,实模式下只用到cx寄存器,高位这个9没有作用

用sreg观察一下段寄存器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<bochs:13> sreg
es:0xb800, dh=0x0000930b, dl=0x8000ffff, valid=1
Data segment, base=0x000b8000, limit=0x0000ffff, Read/Write, Accessed
cs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ss:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ds:0x07c0, dh=0x00009300, dl=0x7c00ffff, valid=1
Data segment, base=0x00007c00, limit=0x0000ffff, Read/Write, Accessed
fs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
gs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ldtr:0x0000, dh=0x00008200, dl=0x0000ffff, valid=1
tr:0x0000, dh=0x00008b00, dl=0x0000ffff, valid=1
gdtr:base=0x00000000000f9ad7, limit=0x30
idtr:base=0x0000000000000000, limit=0x3ff

es和ds已经设置好了

跳出rep,loop循环n

现在马上要执行rep movsw指令了

如果使用s则会进入循环,如果想步过循环到循环外面的第一条指令,使用n指令

1
2
3
<bochs:14> n
Next at t=17179020
(0) [0x000000007c38] 0000:7c38 (unk. ctxt): mov ax, 0x001d ; b81d00

此时使用xp指令观察显存区域是否已经获得拷贝

1
2
3
<bochs:15> xp /4 0xb8000
[bochs]:
0x00000000000b8000 <bogus+ 0>: 0x0761074c 0x07650762 0x0720076c 0x0766076f

能够使用n跳出的循环有个特点,就是cx作为循环变量,啥时候跳出循环是很清晰的

如果是跳出循环的条件判断在循环体中,比如一个if条件判断,则n指令就行不通了,此时可以使用u+b+c三种指令实现

反汇编u

1
u/<行数>

从当前行开始反汇编若干行指令

在需要跳出条件循环时,可以反汇编若干行,然后在循环外面设置一个断点,然后使用c一直执行到断点停下