文章

《操作系统真象还原》复现

引言

《操作系统真象还原》是一本蛮细且示例内容较多、同时内容也足够精简的操作系统实现书籍。我虽然上过操作系统课程,但感觉课程实验对操作系统底层涉及的模块并不算太多(比如操作系统、文件管理、进程管理等模块并未涉及),而我对这些的实现也蛮感兴趣的,所以这个闲得发慌的暑假,决定跟着这本书把操作系统的实现过程走(看)一遍,知道操作系统底层对这些的某种实现方式。本篇文章主要记录跟着书上源码运行系统过程中遇到的坑,同时也是给自己立下个 flag(已回收)。复现源码见: os_test,实验环境为 Ubuntu 20.04 + bochs-2.6.11。暂时而言,感觉难度并不算太大(,主要时间耗在书本和源码阅读理解。

第一章

最终的 bin 文件夹无东西:注意,bochs如果使用-j编译安装,中途虽然报错,但不最终不会冒出报错的一堆东西,只有你往后走到运行测试时你才会发现少东西,因此推荐不加-j编译安装。

fatal error: X11/Xlib.h: 没有那个文件或目录: 这个错误是因为缺少 X11 的头文件,解决方法是安装 X11 的头文件,sudo apt-get install libx11-dev,注意大小写,同时改完后要重新运行./configure

bochsrc.disk:17: keyboard directive malformed.:由于新版本的 bochs 不再支持keyboard_mapping参数,需要将其改成keyboard,修改后的参考示例(与书上对应)是keyboard: keymap=/[path]/bochs/share/bochs/keymaps/x11-pc-us.map

bochs: cannot connect to X server:遇到这个错误的原因通常是因为使用 vscode 等软件进行远程开发,简单的解决方法是切回虚拟机图形界面,在里面新建终端运行bochs

waiting for gdb to connect:如果在./configure的时候启用了 gdb 调试功能,则打开界面后,还需要再开一个终端,先运行gdb,再通过target remote localhost:[端口号,默认为1234]命令连接 bochs,这样才能进行调试。

第四章

按照书上的方式最终没有出现 P 字母:注意显存地址基址是0xB8000,可以先试着 de 一下全局描述符表。

第五章

这一章算是终于有了一点较大的差异了。书上的代码如果直接复制粘贴是跑不出来的,然后呢,我们开始定位问题。不同于之前直接使用 nasm 将汇编代码汇编成机器指令,这章我们开始接触使用 gcc + ld 这两个工具,但由于现在 gcc 版本过高且操作系统通常是64位,而我们模拟的是32位 CPU ,且高版本 gcc 生成的 elf 文件相对于书上的例子存在额外的程序段,因此按照书上的整会出现问题,这两个问题需要逐一进行解决。

1. 64位系统下编译32位代码

这个问题的解决方法是在 gcc 编译的时候加上-m32参数,即gcc -m32 -c -o kernel.o kernel.s,这样就可以将代码编译成 32 位的目标文件了。同时,在使用 ld 进行链接的时候也需要配套加上-m elf_i386参数,即ld -m elf_i386 xxxx

2. 高版本 gcc 生成的 elf 文件与书上的不同

使用readelf -e命令查看生成的 elf 文件的信息,并与书上的例子进行对比。如下图所示,可以发现文件多了若干个段,除此之外 elf 文件格式都与书上描述相同,入口函数的虚拟地址啥的都是一致的,那么问题出在哪呢?首先我们先使用调试工具\字符输出定位出问题的代码,容易发现,出问题的地方是在代码加载时的 memcpy 函数中,而不是之前假想的,代码加载后的运行过程中。不妨回想下我们加载代码时的行为,我们将代码的每个段复制到与其对应的虚拟地址处,因此出问题的地方应该是,在这个过程中存在内存访问异常的情况。回看这些段的虚拟地址,不难注意到其中的0x080xxxxx,回想一下我们建立的虚拟页表,我们只对0x0 ~ 0x000fffff0xc00 ~ 0xc00fffff提供了物理页,因此系统在访问虚拟地址0x080xxxxx时,就会发现缺页,而我们的系统现在还不能处理缺页中断,因此就会崩溃。为了解决这个问题,我们需要修改虚拟页表,为涉及的0x080xxxxx等虚拟地址提供物理页映射(或者先往后做)。为了避免这里加载的代码被后面的代码覆盖,我这里先将0x08048000 ~ 0x08048fff虚拟地址映射到实际内存的 2MB 开始的连续空间处的 1KB(后续为了使内存布局更紧凑,改成了紧接前面所分配页表后,这里所示代码已同步更改),以避开被前面页表映射的内存区域(低端1M内存),同时第八章的页框数也应更正为258。

1
2
3
4
5
6
7
8
9
10
11
12
; 创建虚拟地址0x08048000对应的目录项,避免后续复制出错
; 将其目录项指向所建 PDE之后,同理创建对应空间的页表项
mov [ebx + 0x080], eax
mov ebx, eax
xor ebx, PG_US_U | PG_RW_W | PG_P ; 将标志位 置空,获取 PDE 地址

mov edx, ebx
add edx,4096; edx 现在指向上面所述 PDE 之后
or edx,PG_US_U | PG_RW_W | PG_P ; 属性为 7, US=1, RW=1, P=1

mov [ebx + 0x48 * 4],edx
; 至此,从页目录项开始共占用 1+1+254+2 = 258 个页框。

image-20230714234124168此时的 elf 信息示意

  • 补充:在第八章的时候,意外发现所生成的 elf 文件中已经不存在这些多余的段了。经过测试,发现并不是第八章给 gcc 添加额外参数的原因(如果查看 .o 文件,还是存在 .note.gnu.property 段节,但 ld 链接后并不存在 .note.gnu.property 段节),推测是 ld 在多文件链接和单文件链接时的行为差异引起的,因此在第八章之后,我们可以放弃上面对虚拟页表的增添,直接使用书上的代码即可。

第七章

nterrupt.c:(.text+0x21f): undefined reference to '__stack_chk_fail_local' : 使用 gcc 编译时添加参数-fno-stack-protector 关闭栈保护

第八章

注意如果不放弃第五章 debug 时额外分配的两个页框,此时初始已分配页框数需修改为256+2=258个。我的做法是直接放弃之前添加的代码(256整齐点)

第十章 输入输出系统

这一章问题就比较严重了,主要集中在锁这一块。首先而言,我觉得书上对锁的实现存在效率上的小问题(而且我直接照搬后还是避免不了 GP 异常),根据书的实现,线程在申请信号量时,如果信号量已经被其它线程获取了,则线程将自身阻塞,这一步的逻辑上看貌似没问题,但线程在申请信号量时,会先关中断,然后只有申请结束后才会恢复原本中断状态。因此假设信号量已经被其他线程申请了,当前线程因申请信号量关中断,之后进入阻塞队列,那么在拥有信号量的线程释放信号量之前系统都处于关中断状态(而且 cli 命令根据资料而言,还会关闭系统时钟中断(⊙x⊙;),那后果就更严重了。。。吧,可能貌似?),显然会大幅影响效率。然后我原本想实现一个简单的自旋锁(好写),然后用自旋锁去实现信号量,但又觉得这个阻塞队列的实现挺不错的 QAQ,就在原有的基础上修改了程序逻辑。现在如果线程申请信号量失败,其会先将自己放入阻塞队列,恢复中断状态,之后空转直到其时间片用完(从而自然地移出就绪队列并等待唤醒)或退出阻塞状态为止。按照我的想法修改后,使用四个线程并发输出,运行了数分钟依旧没问题,应该算是修复(魔改)完成了。锁部分主要修改的代码如下,然后环形缓冲区部分代码感觉也有点问题,但由于还只是个 demo,所以就没有进一步修改。

摸鱼半个月后,整到13章,重新回过头来看,发现书上的这部分代码其实不存在问题?(那问题来了,这个 GP 异常我是怎么解决的。。。不管了,时间过于久远了)虽然说线程申请信号量时,会关中断。但注意下 schedule 函数进行调度后,所执行的线程,其有两种,一是时间片用完,从而在时间中断中被移下 cpu,如果其被调度到,则其继续执行,退出时间中断时会自然地开中断;二是如上所述地自我阻塞的线程,这部分线程按照其逻辑,之后要么继续自我阻塞,要么开中断继续运行。综上,这里锁的实现并没有较大问题。这一部分将从13章开始沿用书上代码,用于提前将线程换出以提高运行效率。

第十一章

  • tss 初始化错误:这里的主要原因在于 gdt 的基址可能有所变动,我的话则是因为 loader.S 中没删除开头的 jmp 命令,所以基础偏移了三个字节。修正也很简单,先用调试器输出 gdt 的基址,然后根据该基址的值相应修正代码中的地址。

  • 进程实现后不能正常运行:如果 tss 能初始化完毕但进程不能切换的话,一个可能的 bug 在于之前参照书上的全盘复制中断的初始化代码。由于转换符顺序问题,书上的代码执行后,中断表描述项高 16 位会被置零,此时中断表地址位于用户空间,而之前内核线程运行时,虚拟地址0xc0000000-0xc00fffff0x00000000-0x000fffff指向同一批物理页,因此清高 16 位”碰巧地”不会出现问题;而当切换进程后,用户空间对应的页目录项发生变化,而处理器依旧尝试去通过用户空间的中断表地址去访问中断表,但这个地址此时未分配或指向未知代码,从而处理器崩溃。正确的地址计算应该是uint64_t idt_operand = ((sizeof(idt) - 1) | (((uint64_t)((uint32_t)idt)) << 16));

第十三章

这一章有个小点,就是 init 的时候还没有开中断,为什么能够正常通过硬盘中断初始化分区表。这是因为进程阻塞时,其唤醒了 idle 线程,idle 线程在运行时会开中断,因此在 idle 线程运行时,硬盘中断和时间中断可以正常执行。

第十四章

这一章的 sync_dir_entry 函数中,如果 inode 原本具有一级间接块表,则书上的代码将存在问题,因为其缺少使用 ide_read 函数将一级间接块表内容读取到 all_blocks 的代码,从而每次访问到 128 个间接块时,都会重新生成一级间接块表,并覆盖掉原有记录,从而导致文件内容丢失。解决方法是在 sync_dir_entry 函数中,添加读取一级间接块表的代码,如下所示。

1
2
3
if (dir_inode->i_sectors[12] != 0) { // 若含有一级间接块表
    ide_read(cur_part->my_disk, dir_inode->i_sectors[12], all_blocks + 12, 1);
}

类似的,在 file_write 函数中,其分配一级间接块索引表所在块时未将其位图同时同步到硬盘

第十五章

首先这一章中,要额外实现用户态的 assert 库函数,从而允许程序进行断言并编译运行 prog_no_arg.c。然后我程序的实际大小为 27980字节。然后修改相关参数继续运行,就迎来之后的内存异常了。内存异常,总体而言,就是书上的代码直接将程序中的段读入内存过于简单粗暴了。一方面,这样将直接破坏程序原有的内存管理结构,所以导致了后续的缺页异常+内存释放异常,同时页表被覆盖了,其中的物理页却没有被释放,因此会产生内存泄漏;另一方面,如果加载失败就更尴尬了,程序原有的内存都不知道哪些被覆盖了。我选择的解决方法是,新开一个页表,然后在里面处理加载,之后覆盖并释放原进程的页表和相关分配的物理页,之后关闭原进程打开的所有文件。同时,这里要先把下一小节的物理页释放相关函数实现了,用于辅助,在 memory 封装实现了 get_a_page_with_pcb 函数。之前的程序有个小 bug,ASSERT(bit_idx > 0);应该改成ASSERT(bit_idx >= 0);,因为 bit_idx 可以取0;此外,顺便修了通用异常处理的bug。emmm,其实这么实现后,跟 exit 后重开一个进程可能效率上提升不会像书上那么快,但这样实现至少更加安全(而且能跑)。主要修改函数如下,其他文件的函数如果未定义再去仓库翻代码吧()

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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
#include "exec.h"
#include "fs.h"
#include "global.h"
#include "memory.h"
#include "thread.h"
#include "process.h"
#include "string.h"
#include "wait_exit.h"
#include "file.h"
#include "stdio.h"

extern void intr_exit(void);
typedef uint32_t Elf32_Word, Elf32_Addr, Elf32_Off;
typedef uint16_t Elf32_Half;

/* 32 位 elf 头 */
struct Elf32_Ehdr {
    unsigned char e_ident[16];
    Elf32_Half e_type;
    Elf32_Half e_machine;
    Elf32_Word e_version;
    Elf32_Addr e_entry;
    Elf32_Off e_phoff;
    Elf32_Off e_shoff;
    Elf32_Word e_flags;
    Elf32_Half e_ehsize;
    Elf32_Half e_phentsize;
    Elf32_Half e_phnum;
    Elf32_Half e_shentsize;
    Elf32_Half e_shnum;
    Elf32_Half e_shstrndx;
};

/* 程序头表 Program header 就是段描述头 */
struct Elf32_Phdr {
Elf32_Word p_type; // 见下面的 enum segment_type
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
};

/* 段类型 */
enum segment_type {
PT_NULL, // 忽略
PT_LOAD, // 可加载程序段
PT_DYNAMIC, // 动态加载信息
PT_INTERP, // 动态加载器名称
PT_NOTE, // 一些辅助信息
PT_SHLIB, // 保留
PT_PHDR // 程序头表
};

/* 将文件描述符 fd 指向的文件中,偏移为 offset,
大小为 filesz 的段加载到虚拟地址为 vaddr 的内存 */
static bool segment_load(int32_t fd, uint32_t offset, uint32_t filesz, uint32_t vaddr, struct task_struct* tmp, struct task_struct* cur) {
    uint32_t vaddr_first_page = vaddr & 0xfffff000;
    // vaddr 地址所在的页框
    uint32_t size_in_first_page = PG_SIZE - (vaddr & 0x00000fff);
    // 加载到内存后,文件在第一个页框中占用的字节大小
    uint32_t occupy_pages = 0;
    uint32_t all_pages = 0;
    bool ret = false;
    /* 若一个页框容不下该段 */
    if (filesz > size_in_first_page) {
        uint32_t left_size = filesz - size_in_first_page;
        occupy_pages = DIV_ROUND_UP(left_size, PG_SIZE) + 1;
        // 1 是指 vaddr_first_page
    } else {
        occupy_pages = 1;
    }
    all_pages = DIV_ROUND_UP(filesz, PG_SIZE);

    // 在内核申请缓冲区,用于中转,之所以不在新进程的用户空间申请,是因为存在磁盘读入,其中会打开中断,因此直接激活新页表进行读入将可能出现问题
    void* buf_page = get_kernel_pages(all_pages);

    /* 为进程分配内存 */
    uint32_t page_idx = 0;
    uint32_t vaddr_page = vaddr_first_page;
    page_dir_activate(tmp);
    while (page_idx < occupy_pages) {
        uint32_t* pde = pde_ptr(vaddr_page);
        uint32_t* pte = pte_ptr(vaddr_page);

        /* 如果 pde 不存在,或者 pte 不存在就分配内存.
        * pde 的判断要在 pte 之前,否则 pde 若不存在会导致
        * 判断 pte 时缺页异常 */
        if (!(*pde & 0x00000001) || !(*pte & 0x00000001)) {
            if (get_a_page_with_pcb(PF_USER, vaddr_page,tmp)  == NULL) {
                goto done;
            }
        } // 如果原进程的页表已经分配了,利用现有的物理页
        // 直接覆盖进程体
        vaddr_page += PG_SIZE;
        page_idx++;
    }
    page_dir_activate(cur);
    sys_lseek(fd, offset, SEEK_SET);
    sys_read(fd, (void*)buf_page, filesz);
    page_dir_activate(tmp);
    memcpy((void*)vaddr, buf_page, filesz);
    ret = true;
done:
    page_dir_activate(cur);
    mfree_page(PF_KERNEL, buf_page, all_pages);
    return ret;
}

/* 仅释放部分用户进程资源:
* 1 页表中对应的物理页
* 2 虚拟内存池占物理页框
*/
static void release_prog_resource_part(struct task_struct* release_thread) {
    uint32_t* pgdir_vaddr = release_thread->pgdir;
    uint16_t user_pde_nr = 768, pde_idx = 0;
    uint32_t pde = 0;
    uint32_t* v_pde_ptr = NULL; // v 表示 var,和函数 pde_ptr 区分

    uint16_t user_pte_nr = 1024, pte_idx = 0;
    uint32_t pte = 0;
    uint32_t* v_pte_ptr = NULL; // 加个 v 表示 var,和函数 pte_ptr 区分

    uint32_t* first_pte_vaddr_in_pde = NULL;
    // 用来记录 pde 中第 1 个 pte 指向的物理页起始地址
    uint32_t pg_phy_addr = 0;

    /* 回收页表中用户空间的页框 */
    while (pde_idx < user_pde_nr) {
        v_pde_ptr = pgdir_vaddr + pde_idx;
        pde = *v_pde_ptr;
        if (pde & 0x00000001) {
            // 如果页目录项 p 位为 1,表示该页目录项下可能有页表项
            first_pte_vaddr_in_pde = pte_ptr(pde_idx * 0x400000);
            // 一个页表表示的内存容量是 4MB,即 0x400000
            pte_idx = 0;
            while (pte_idx < user_pte_nr) {
                v_pte_ptr = first_pte_vaddr_in_pde + pte_idx;
                pte = *v_pte_ptr;
                if (pte & 0x00000001) {
                    /* 将 pte 中记录的物理页框直接在相应内存池的位图中清 0 */
                    pg_phy_addr = pte & 0xfffff000;
                    free_a_phy_page(pg_phy_addr);
                }
                pte_idx++;
            }
            /* 将 pde 中记录的物理页框直接在相应内存池的位图中清 0 */
            pg_phy_addr = pde & 0xfffff000;
            free_a_phy_page(pg_phy_addr);
        }
        pde_idx++;
    }

    /* 回收用户虚拟地址池所占的物理内存*/
    uint32_t bitmap_pg_cnt = (release_thread->userprog_vaddr.vaddr_bitmap.btmp_bytes_len)/ PG_SIZE;
    uint8_t* user_vaddr_pool_bitmap = release_thread ->userprog_vaddr.vaddr_bitmap.bits;
    mfree_page(PF_KERNEL, user_vaddr_pool_bitmap, bitmap_pg_cnt);


}



/* 从文件系统上加载用户程序 pathname,
成功则返回程序的起始地址,否则返回-1 */
static int32_t load(const char* pathname) {
    int32_t ret = -1;
    struct Elf32_Ehdr elf_header;
    struct Elf32_Phdr prog_header;
    memset(&elf_header, 0, sizeof(struct Elf32_Ehdr));

    int32_t fd = sys_open(pathname, O_RDONLY);
    if (fd == -1) {
    return -1;
    }

    if (sys_read(fd, &elf_header, sizeof(struct Elf32_Ehdr)) != sizeof(struct Elf32_Ehdr)) {
    ret = -2;
    goto done;
    }

    /* 校验 elf 头 */
    if (memcmp(elf_header.e_ident, "\177ELF\1\1\1", 7)|| elf_header.e_type != 2 || elf_header.e_machine != 3 || elf_header.e_version != 1 \
        || elf_header.e_phnum > 1024 || elf_header.e_phentsize != sizeof(struct Elf32_Phdr)) {
    ret = -2;
    goto done;
    }

    Elf32_Off prog_header_offset = elf_header.e_phoff;
    Elf32_Half prog_header_size = elf_header.e_phentsize;
    // 申请内核内存来中转
    struct task_struct* cur = running_thread(), *tmp = get_kernel_pages(1);

    memcpy(tmp, cur, PG_SIZE);
    // 创建用户态虚拟地址位图和新页表
    create_user_vaddr_bitmap(tmp);
    tmp->pgdir = create_page_dir();

    /* 遍历所有程序头 */
    uint32_t prog_idx = 0;
    while (prog_idx < elf_header.e_phnum) {
        memset(&prog_header, 0, prog_header_size);

        /* 将文件的指针定位到程序头 */
        sys_lseek(fd, prog_header_offset, SEEK_SET);

        /* 只获取程序头 */
        if (sys_read(fd, &prog_header, prog_header_size) !=prog_header_size) {
            ret = -3;
            goto done;
        }

        /* 如果是可加载段就调用 segment_load 加载到内存 */
        if (PT_LOAD == prog_header.p_type) {
            if (!segment_load(fd, prog_header.p_offset, prog_header.p_filesz, prog_header.p_vaddr,tmp,cur)) {
                ret = -3;
                goto done;
            } 
        }

        /* 更新下一个程序头的偏移 */
        prog_header_offset += elf_header.e_phentsize;
        prog_idx++;
    }

    // while(1); 
    // 使用 tmp 替换 cur
    void * tt = cur->pgdir;
    release_prog_resource_part(cur); 
    // 复制页表和虚拟地址位图
    cur->pgdir = tmp->pgdir;
    cur->userprog_vaddr.vaddr_bitmap.bits = tmp->userprog_vaddr.vaddr_bitmap.bits;
    cur->userprog_vaddr.vaddr_bitmap.btmp_bytes_len = tmp->userprog_vaddr.vaddr_bitmap.btmp_bytes_len;
    // 重新加载页表
    page_dir_activate(cur);
    // 初始化内存块描述符
    block_desc_init(cur->u_block_desc);

    // 释放多余的内存
    mfree_page(PF_KERNEL, tt, 1);
    mfree_page(PF_KERNEL, tmp, 1);
    sys_close(fd);

    ret = elf_header.e_entry;

    done:
    switch (ret) {
        case -3:
            // 回收 tmp 的资源
            release_prog_resource_part(tmp);
            mfree_page(PF_KERNEL, tmp->pgdir, 1);
            mfree_page(PF_KERNEL, tmp, 1);
        case -2:
            // 如果一切顺利,fd在上面已经被释放
            sys_close(fd);
            ret = -1;
        default:
            break;
    }
    page_dir_activate(cur);
    return ret;
}

/* 用 path 指向的程序替换当前进程 */
int32_t sys_execv(const char* path, const char* argv[]) {
    uint32_t argc = 0;
    while (argv[argc]) {
        argc++;
    }

    int32_t entry_point = load(path);

    struct task_struct* cur = running_thread();
    /* 修改进程名 */
    memcpy(cur->name, path, TASK_NAME_LEN);
    cur->name[TASK_NAME_LEN-1] = 0;

    struct intr_stack* intr_0_stack = (struct intr_stack*)((uint32_t)cur + PG_SIZE - sizeof(struct intr_stack));
    /* 参数传递给用户进程 */
    intr_0_stack->ebx = (int32_t)argv;
    intr_0_stack->ecx = argc;
    intr_0_stack->eip = (void*)entry_point;
    /* 使新用户进程的栈地址为最高用户空间地址 */
    // 分配栈空间
    intr_0_stack->esp = (void*)((uint32_t)get_a_page(PF_USER,USER_STACK3_VADDR) + PG_SIZE) ;
    
    // while(1);printf("entry_point: %x\n", entry_point);

    /* exec 不同于 fork,为使新进程更快被执行,直接从中断返回 */
    asm volatile ("movl %0, %%esp; jmp intr_exit" : :"g" (intr_0_stack) : "memory");
    return 0;
}

该小节完成示意图

最终成果示意图

  • 最后嘛,管道这里,不知道整了啥幺蛾子,从上图可以看到,管道可以正常用,但换个形式就不行了,可能是命令行解析哪里出了问题了吧。就这样吧,留个小尾巴(反正这种小 bug 也无伤大雅)。感觉我一路看下来学下来,整本书深入学到的东西还真不少,至少很多地方逻辑通顺了,也祝各位小伙伴收获满满,开心快乐每一天。
本文由作者按照 CC BY 4.0 进行授权