• 用C语言编写你自己内核


    介绍

    好吧,您已经知道内核是什么 内核_百度百科

    编写操作系统的第一部分是以 16 位汇编(实模式)编写引导加载程序。
    引导加载程序是在任何操作系统运行之前运行的程序。
    它用于引导其他操作系统,通常每个操作系统都有一组特定的引导加载程序。
    转到以下链接以在 16 位汇编中创建您自己的引导加载程序

                   https://createyourownos.blogspot.in/

    引导加载程序通常选择一个特定的操作系统并启动它的进程,然后操作系统将自身加载到内存中。
    如果您正在编写自己的引导加载程序来加载内核,您需要了解内存的整体寻址/中断以及 BIOS。
    大多数情况下,每个操作系统都有特定的引导加载程序。
    在线市场上有很多可用的引导加载程序。
    但是有一些专有的引导加载程序,例如用于 Windows 操作系统的 Windows Boot Manager 或用于 Apple 操作系统的 BootX。
    但是有很多免费和开源的引导加载程序。看比较,

                     https://en.wikipedia.org/wiki/Comparison_of_boot_loaders

    其中最著名的是 GNU GRUB - 来自 GNU 项目的 GNU Grand Unified Bootloader 包,用于类 Unix 系统。

                     https://en.wikipedia.org/wiki/GNU_GRUB

    我们将使用 GNU GRUB 来加载我们的内核,因为它支持许多操作系统的多重引导。

     

    要求

    GNU/Linux :-  任何发行版(Ubuntu/Debian/RedHat 等)。
    Assembler :-  GNU Assembler( gas ) 用于组装汇编语言文件。
    GCC :-   GNU 编译器集合,C 编译器。任何版本 4、5、6、7、8 等
    Xorriso :-  创建、加载、操作 ISO 9660 文件系统映像的包。(man xorriso)
    grub-mkrescue :-  制作 GRUB 救援映像,此包在内部调用 xorriso构建iso映像的功能。
    QEMU :-  快速 EMUlator 在虚拟机中启动我们的内核,而无需重新启动主系统。

    使用代码

    好吧,从头开始编写内核就是在屏幕上打印一些东西。
    所以我们有一个VGA(Visual Graphics Array),一个控制显示器的硬件系统。

                https://en.wikipedia.org/wiki/Video_Graphics_Array

    VGA 具有固定数量的内存,地址为0xA00000xBFFFF

    0xA0000用于 EGA/VGA 图形模式 (64 KB)
    0xB0000用于单色文本模式 (32 KB)
    0xB8000用于彩色文本模式和 CGA 兼容图形模式 (32 KB)


    首先,您需要一个指示 GRUB 加载它的多重引导引导加载程序文件。
    必须定义以下字段。

     

    Magic :-一个固定的十六进制数,由引导加载程序标识为要加载的内核的标头(起点)。
    flags :-如果设置了 flags 字中的位 0,则与操作系统一起加载的所有引导模块必须在页面 (4KB) 边界上对齐。
    校验和:-引导加载程序用于特殊用途,其值必须是魔术编号和标志的总和。

    我们不需要其他信息, 
    但更多详细信息  https://www.gnu.org/software/grub/manual/multiboot/multiboot.pdf

    好的,让我们为上述信息编写一个 GAS 汇编代码。
    如上图所示,我们不需要某些字段。

    boot.S

    1. # set magic number to 0x1BADB002 to identified by bootloader
    2. .set MAGIC, 0x1BADB002
    3. # set flags to 0
    4. .set FLAGS, 0
    5. # set the checksum
    6. .set CHECKSUM, -(MAGIC + FLAGS)
    7. # set multiboot enabled
    8. .section .multiboot
    9. # define type to long for each data defined as above
    10. .long MAGIC
    11. .long FLAGS
    12. .long CHECKSUM
    13. # set the stack bottom
    14. stackBottom:
    15. # define the maximum size of stack to 512 bytes
    16. .skip 1024
    17. # set the stack top which grows from higher to lower
    18. stackTop:
    19. .section .text
    20. .global _start
    21. .type _start, @function
    22. _start:
    23. # assign current stack pointer location to stackTop
    24. mov $stackTop, %esp
    25. # call the kernel main source
    26. call kernel_entry
    27. cli
    28. # put system in infinite loop
    29. hltLoop:
    30. hlt
    31. jmp hltLoop
    32. .size _start, . - _start

     

    我们定义了一个大小为 1024 字节的堆栈,并由 stackBottom 和 stackTop 标识符管理。
    然后在 _start 中,我们存储一个当前堆栈指针,并调用内核的主函数(kernel_entry)。

    如您所知,每个流程都由不同的部分组成,例如数据、bss、rodata 和文本。
    您可以通过编译源代码而不组装它来查看每个部分。

    例如:运行以下命令
            gcc -S kernel.c
          并查看 kernel.S 文件。

    而这部分需要一个内存来存储它们,这个内存大小由链接器映像文件提供。
    每个内存都与每个块的大小对齐。
    它主要需要将所有目标文件链接在一起以形成最终的内核映像。
    链接器图像文件提供了应该为每个部分分配多少大小。
    信息存储在最终的内核映像中。
    如果您在 hexeditor 中打开最终的内核映像(.bin 文件),您会看到很多 00 字节。
    链接器映像文件包含一个入口点(在我们的例子中,它是在文件 boot.S 中定义的 _start)和在 BLOCK 关键字中定义的大小与间距大小对齐的部分。

    linker.ld

    1. /* entry point of our kernel */
    2. ENTRY(_start)
    3. SECTIONS
    4. {
    5. /* we need 1MB of space atleast */
    6. . = 1M;
    7. /* text section */
    8. .text BLOCK(4K) : ALIGN(4K)
    9. {
    10. *(.multiboot)
    11. *(.text)
    12. }
    13. /* read only data section */
    14. .rodata BLOCK(4K) : ALIGN(4K)
    15. {
    16. *(.rodata)
    17. }
    18. /* data section */
    19. .data BLOCK(4K) : ALIGN(4K)
    20. {
    21. *(.data)
    22. }
    23. /* bss section */
    24. .bss BLOCK(4K) : ALIGN(4K)
    25. {
    26. *(COMMON)
    27. *(.bss)
    28. }
    29. }

     

    现在您需要一个配置文件来指示 grub 加载带有关联图像文件
    grub.cfg的菜单

    1. menuentry "MyOS" {
    2. multiboot /boot/MyOS.bin
    3. }

    现在让我们编写一个简单的 HelloWorld 内核代码。

    kernel.h

    1. #ifndef KERNEL_H
    2. #define KERNEL_H
    3. typedef unsigned char uint8;
    4. typedef unsigned short uint16;
    5. typedef unsigned int uint32;
    6. #define VGA_ADDRESS 0xB8000
    7. #define BUFSIZE 2200
    8. uint16* vga_buffer;
    9. #define NULL 0
    10. enum vga_color {
    11. BLACK,
    12. BLUE,
    13. GREEN,
    14. CYAN,
    15. RED,
    16. MAGENTA,
    17. BROWN,
    18. GREY,
    19. DARK_GREY,
    20. BRIGHT_BLUE,
    21. BRIGHT_GREEN,
    22. BRIGHT_CYAN,
    23. BRIGHT_RED,
    24. BRIGHT_MAGENTA,
    25. YELLOW,
    26. WHITE,
    27. };
    28. #endif

    这里我们使用 16 位 vga 缓冲区,在我的机器上,VGA 地址从0xB8000开始,32 位从0xA0000开始。
    指向 VGA 地址的无符号 16 位类型终端缓冲区指针。
    它有 8*16 像素的字体大小。
    见上图。

    kernel.c

    1. #include "kernel.h"
    2. /*
    3. 16 bit video buffer elements(register ax)
    4. 8 bits(ah) higher :
    5. lower 4 bits - forec olor
    6. higher 4 bits - back color
    7. 8 bits(al) lower :
    8. 8 bits : ASCII character to print
    9. */
    10. uint16 vga_entry(unsigned char ch, uint8 fore_color, uint8 back_color)
    11. {
    12. uint16 ax = 0;
    13. uint8 ah = 0, al = 0;
    14. ah = back_color;
    15. ah <<= 4;
    16. ah |= fore_color;
    17. ax = ah;
    18. ax <<= 8;
    19. al = ch;
    20. ax |= al;
    21. return ax;
    22. }
    23. //clear video buffer array
    24. void clear_vga_buffer(uint16 **buffer, uint8 fore_color, uint8 back_color)
    25. {
    26. uint32 i;
    27. for(i = 0; i < BUFSIZE; i++){
    28. (*buffer)[i] = vga_entry(NULL, fore_color, back_color);
    29. }
    30. }
    31. //initialize vga buffer
    32. void init_vga(uint8 fore_color, uint8 back_color)
    33. {
    34. vga_buffer = (uint16*)VGA_ADDRESS; //point vga_buffer pointer to VGA_ADDRESS
    35. clear_vga_buffer(&vga_buffer, fore_color, back_color); //clear buffer
    36. }
    37. void kernel_entry()
    38. {
    39. //first init vga with fore & back colors
    40. init_vga(WHITE, BLACK);
    41. //assign each ASCII character to video buffer
    42. //you can change colors here
    43. vga_buffer[0] = vga_entry('H', WHITE, BLACK);
    44. vga_buffer[1] = vga_entry('e', WHITE, BLACK);
    45. vga_buffer[2] = vga_entry('l', WHITE, BLACK);
    46. vga_buffer[3] = vga_entry('l', WHITE, BLACK);
    47. vga_buffer[4] = vga_entry('o', WHITE, BLACK);
    48. vga_buffer[5] = vga_entry(' ', WHITE, BLACK);
    49. vga_buffer[6] = vga_entry('W', WHITE, BLACK);
    50. vga_buffer[7] = vga_entry('o', WHITE, BLACK);
    51. vga_buffer[8] = vga_entry('r', WHITE, BLACK);
    52. vga_buffer[9] = vga_entry('l', WHITE, BLACK);
    53. vga_buffer[10] = vga_entry('d', WHITE, BLACK);
    54. }

    vga_entry()函数返回的值是uint16类型,突出显示字符以用颜色打印它。
    该值存储在缓冲区中以在屏幕上显示字符。
    首先让我们将指针vga_buffer指向 VGA 地址0xB8000

    Segment : 0xB800 & Offset : 0(our index variable(vga_index))
    现在你有一个VGA数组,你只需要根据屏幕上打印的内容为数组的每个索引分配特定的值,就像我们通常在分配值到数组。
    请参阅上面在屏幕上打印 HelloWorld 的每个字符的代码。

    好的,让我们编译源代码。
    在终端上键入 sh run.sh 命令。

    run.sh

    1. #assemble boot.s file
    2. as --32 boot.s -o boot.o
    3. #compile kernel.c file
    4. gcc -m32 -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra
    5. #linking the kernel with kernel.o and boot.o files
    6. ld -m elf_i386 -T linker.ld kernel.o boot.o -o MyOS.bin -nostdlib
    7. #check MyOS.bin file is x86 multiboot file or not
    8. grub-file --is-x86-multiboot MyOS.bin
    9. #building the iso file
    10. mkdir -p isodir/boot/grub
    11. cp MyOS.bin isodir/boot/MyOS.bin
    12. cp grub.cfg isodir/boot/grub/grub.cfg
    13. grub-mkrescue -o MyOS.iso isodir
    14. #run it in qemu
    15. qemu-system-x86_64 -cdrom MyOS.iso

    确保您已安装构建内核所需的所有软件包。

    输出是 

     

    如您所见,将每个值分配给 VGA 缓冲区是一种开销,因此我们可以为此编写一个函数,该函数可以在屏幕上打印我们的字符串(意味着将字符串中的每个字符值分配给 VGA 缓冲区)。

     

    kernel_2 :-

    kernel.h

    1. #ifndef KERNEL_H
    2. #define KERNEL_H
    3. typedef unsigned char uint8;
    4. typedef unsigned short uint16;
    5. typedef unsigned int uint32;
    6. #define VGA_ADDRESS 0xB8000
    7. #define BUFSIZE 2200
    8. uint16* vga_buffer;
    9. #define NULL 0
    10. enum vga_color {
    11. BLACK,
    12. BLUE,
    13. GREEN,
    14. CYAN,
    15. RED,
    16. MAGENTA,
    17. BROWN,
    18. GREY,
    19. DARK_GREY,
    20. BRIGHT_BLUE,
    21. BRIGHT_GREEN,
    22. BRIGHT_CYAN,
    23. BRIGHT_RED,
    24. BRIGHT_MAGENTA,
    25. YELLOW,
    26. WHITE,
    27. };
    28. #endif

    digit_ascii_codes 是字符 0 到 9 的十六进制值。当我们想在屏幕上打印它们时需要它们。vga_index 是我们的 VGA 数组索引。为该索引分配值时 vga_index 会增加。要打印 32 位整数,首先需要将其转换为字符串,然后再打印字符串。
    BUFSIZE 是我们 VGA 的极限。要打印新行,您必须根据像素字体大小跳过 VGA 指针(vga_buffer)中的一些字节。
    为此,我们需要另一个变量来存储当前行索引(next_line_index)。

    1. #include "kernel.h"
    2. //index for video buffer array
    3. uint32 vga_index;
    4. //counter to store new lines
    5. static uint32 next_line_index = 1;
    6. //fore & back color values
    7. uint8 g_fore_color = WHITE, g_back_color = BLUE;
    8. //digit ascii code for printing integers
    9. int digit_ascii_codes[10] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39};
    10. /*
    11. 16 bit video buffer elements(register ax)
    12. 8 bits(ah) higher :
    13. lower 4 bits - forec olor
    14. higher 4 bits - back color
    15. 8 bits(al) lower :
    16. 8 bits : ASCII character to print
    17. */
    18. uint16 vga_entry(unsigned char ch, uint8 fore_color, uint8 back_color)
    19. {
    20. uint16 ax = 0;
    21. uint8 ah = 0, al = 0;
    22. ah = back_color;
    23. ah <<= 4;
    24. ah |= fore_color;
    25. ax = ah;
    26. ax <<= 8;
    27. al = ch;
    28. ax |= al;
    29. return ax;
    30. }
    31. //clear video buffer array
    32. void clear_vga_buffer(uint16 **buffer, uint8 fore_color, uint8 back_color)
    33. {
    34. uint32 i;
    35. for(i = 0; i < BUFSIZE; i++){
    36. (*buffer)[i] = vga_entry(NULL, fore_color, back_color);
    37. }
    38. next_line_index = 1;
    39. vga_index = 0;
    40. }
    41. //initialize vga buffer
    42. void init_vga(uint8 fore_color, uint8 back_color)
    43. {
    44. vga_buffer = (uint16*)VGA_ADDRESS;
    45. clear_vga_buffer(&vga_buffer, fore_color, back_color);
    46. g_fore_color = fore_color;
    47. g_back_color = back_color;
    48. }
    49. /*
    50. increase vga_index by width of row(80)
    51. */
    52. void print_new_line()
    53. {
    54. if(next_line_index >= 55){
    55. next_line_index = 0;
    56. clear_vga_buffer(&vga_buffer, g_fore_color, g_back_color);
    57. }
    58. vga_index = 80*next_line_index;
    59. next_line_index++;
    60. }
    61. //assign ascii character to video buffer
    62. void print_char(char ch)
    63. {
    64. vga_buffer[vga_index] = vga_entry(ch, g_fore_color, g_back_color);
    65. vga_index++;
    66. }
    67. uint32 strlen(const char* str)
    68. {
    69. uint32 length = 0;
    70. while(str[length])
    71. length++;
    72. return length;
    73. }
    74. uint32 digit_count(int num)
    75. {
    76. uint32 count = 0;
    77. if(num == 0)
    78. return 1;
    79. while(num > 0){
    80. count++;
    81. num = num/10;
    82. }
    83. return count;
    84. }
    85. void itoa(int num, char *number)
    86. {
    87. int dgcount = digit_count(num);
    88. int index = dgcount - 1;
    89. char x;
    90. if(num == 0 && dgcount == 1){
    91. number[0] = '0';
    92. number[1] = '\0';
    93. }else{
    94. while(num != 0){
    95. x = num % 10;
    96. number[index] = x + '0';
    97. index--;
    98. num = num / 10;
    99. }
    100. number[dgcount] = '\0';
    101. }
    102. }
    103. //print string by calling print_char
    104. void print_string(char *str)
    105. {
    106. uint32 index = 0;
    107. while(str[index]){
    108. print_char(str[index]);
    109. index++;
    110. }
    111. }
    112. //print int by converting it into string
    113. //& then printing string
    114. void print_int(int num)
    115. {
    116. char str_num[digit_count(num)+1];
    117. itoa(num, str_num);
    118. print_string(str_num);
    119. }
    120. void kernel_entry()
    121. {
    122. //first init vga with fore & back colors
    123. init_vga(WHITE, BLACK);
    124. /*call above function to print something
    125. here to change the fore & back color
    126. assign g_fore_color & g_back_color to color values
    127. g_fore_color = BRIGHT_RED;
    128. */
    129. print_string("Hello World!");
    130. print_new_line();
    131. print_int(123456789);
    132. print_new_line();
    133. print_string("Goodbye World!");
    134. }

     

     

    正如您所看到的,调用每个函数来显示值是开销,这就是为什么 C 编程提供了一个带有格式说明符的printf()函数,该函数使用每个说明符使用诸如 \ 之类的文字向标准输出设备打印/设置特定值n、\t、\r 等。

    键盘 :-

    对于键盘 I/O,使用端口号 0x60 和输入/输出指令。从键盘下载 kernel_source 代码。它从用户那里读取击键并将它们显示在屏幕上。

     

    1. #ifndef KEYBOARD_H
    2. #define KEYBOARD_H
    3. #define KEYBOARD_PORT 0x60
    4. #define KEY_A 0x1E
    5. #define KEY_B 0x30
    6. #define KEY_C 0x2E
    7. #define KEY_D 0x20
    8. #define KEY_E 0x12
    9. #define KEY_F 0x21
    10. #define KEY_G 0x22
    11. #define KEY_H 0x23
    12. #define KEY_I 0x17
    13. #define KEY_J 0x24
    14. #define KEY_K 0x25
    15. #define KEY_L 0x26
    16. #define KEY_M 0x32
    17. #define KEY_N 0x31
    18. #define KEY_O 0x18
    19. #define KEY_P 0x19
    20. #define KEY_Q 0x10
    21. #define KEY_R 0x13
    22. #define KEY_S 0x1F
    23. #define KEY_T 0x14
    24. #define KEY_U 0x16
    25. #define KEY_V 0x2F
    26. #define KEY_W 0x11
    27. #define KEY_X 0x2D
    28. #define KEY_Y 0x15
    29. #define KEY_Z 0x2C
    30. #define KEY_1 0x02
    31. #define KEY_2 0x03
    32. #define KEY_3 0x04
    33. #define KEY_4 0x05
    34. #define KEY_5 0x06
    35. #define KEY_6 0x07
    36. #define KEY_7 0x08
    37. #define KEY_8 0x09
    38. #define KEY_9 0x0A
    39. #define KEY_0 0x0B
    40. #define KEY_MINUS 0x0C
    41. #define KEY_EQUAL 0x0D
    42. #define KEY_SQUARE_OPEN_BRACKET 0x1A
    43. #define KEY_SQUARE_CLOSE_BRACKET 0x1B
    44. #define KEY_SEMICOLON 0x27
    45. #define KEY_BACKSLASH 0x2B
    46. #define KEY_COMMA 0x33
    47. #define KEY_DOT 0x34
    48. #define KEY_FORESLHASH 0x35
    49. #define KEY_F1 0x3B
    50. #define KEY_F2 0x3C
    51. #define KEY_F3 0x3D
    52. #define KEY_F4 0x3E
    53. #define KEY_F5 0x3F
    54. #define KEY_F6 0x40
    55. #define KEY_F7 0x41
    56. #define KEY_F8 0x42
    57. #define KEY_F9 0x43
    58. #define KEY_F10 0x44
    59. #define KEY_F11 0x85
    60. #define KEY_F12 0x86
    61. #define KEY_BACKSPACE 0x0E
    62. #define KEY_DELETE 0x53
    63. #define KEY_DOWN 0x50
    64. #define KEY_END 0x4F
    65. #define KEY_ENTER 0x1C
    66. #define KEY_ESC 0x01
    67. #define KEY_HOME 0x47
    68. #define KEY_INSERT 0x52
    69. #define KEY_KEYPAD_5 0x4C
    70. #define KEY_KEYPAD_MUL 0x37
    71. #define KEY_KEYPAD_Minus 0x4A
    72. #define KEY_KEYPAD_PLUS 0x4E
    73. #define KEY_KEYPAD_DIV 0x35
    74. #define KEY_LEFT 0x4B
    75. #define KEY_PAGE_DOWN 0x51
    76. #define KEY_PAGE_UP 0x49
    77. #define KEY_PRINT_SCREEN 0x37
    78. #define KEY_RIGHT 0x4D
    79. #define KEY_SPACE 0x39
    80. #define KEY_TAB 0x0F
    81. #define KEY_UP 0x48
    82. #endif

    inb() 从指定端口接收字节并返回。

    outb() 将字节发送到指定端口。

    1. uint8 inb(uint16 port)
    2. {
    3. uint8 ret;
    4. asm volatile("inb %1, %0" : "=a"(ret) : "d"(port));
    5. return ret;
    6. }
    7. void outb(uint16 port, uint8 data)
    8. {
    9. asm volatile("outb %0, %1" : "=a"(data) : "d"(port));
    10. }
    11. char get_input_keycode()
    12. {
    13. char ch = 0;
    14. while((ch = inb(KEYBOARD_PORT)) != 0){
    15. if(ch > 0)
    16. return ch;
    17. }
    18. return ch;
    19. }
    20. /*
    21. keep the cpu busy for doing nothing(nop)
    22. so that io port will not be processed by cpu
    23. here timer can also be used, but lets do this in looping counter
    24. */
    25. void wait_for_io(uint32 timer_count)
    26. {
    27. while(1){
    28. asm volatile("nop");
    29. timer_count--;
    30. if(timer_count <= 0)
    31. break;
    32. }
    33. }
    34. void sleep(uint32 timer_count)
    35. {
    36. wait_for_io(timer_count);
    37. }
    38. void test_input()
    39. {
    40. char ch = 0;
    41. char keycode = 0;
    42. do{
    43. keycode = get_input_keycode();
    44. if(keycode == KEY_ENTER){
    45. print_new_line();
    46. }else{
    47. ch = get_ascii_char(keycode);
    48. print_char(ch);
    49. }
    50. sleep(0x02FFFFFF);
    51. }while(ch > 0);
    52. }
    53. void kernel_entry()
    54. {
    55. init_vga(WHITE, BLUE);
    56. print_string("Type here, one key per second, ENTER to go to next line");
    57. print_new_line();
    58. test_input();
    59. }

    每个键码都通过函数get_ascii_char()转换为其 ASCII 字符。

     

    制图图形用户界面:-

    下载 DOSBox 等旧系统中使用的绘图框的 kernel_source (kernel_source/GUI/)

     

    井字游戏:-

    我们有打印代码、键盘 I/O 处理和使用绘图字符的 GUI。所以让我们在内核中编写一个简单的井字游戏,可以在任何 PC 上运行。

    载 kernel_source 代码,kernel_source/Tic-Tac-Toe。

    怎么玩 :

    使用箭头键(上、下、左、右)在单元格之间移动白框,然后按空格键选择该单元格。

    玩家 1 的盒子为红色,玩家 2 的盒子为蓝色。

    请参阅轮到哪个玩家轮到选择单元格。(轮到:玩家 1)

    如果您在实际硬件上运行此程序,则增加 tic_tac_toe.c 中的 launch_game() 和 kernel.c 中的 kernel_entry() 中的 sleep() 函数的值,以便正常工作不会太快。我使用了 0x2FFFFFFF。

     

    有关从头开始的操作系统、操作系统计算器和操作系统中的低级图形的更多信息。

    源代码链接https://download.csdn.net/download/qq_20173195/86500517

    参考

  • 相关阅读:
    springboot曦乐苹果园林管理系统的设计与实现毕业设计源码100854
    客户听不进去,很强势,太难沟通了,怎么办?
    springboot毕设项目城市人口户籍在线管理系统j37rt(java+VUE+Mybatis+Maven+Mysql)
    潍坊科技学院图书馆藏《乡村振兴战略下传统村落文化旅游设计》许少辉八一新书
    【DL with Pytorch】第 5 章 :风格迁移
    ): error C2039: “swish_param“: 不是 “caffe::LayerParameter“ 的成员
    Java 泛型
    Linux 学习之路 -- 进程篇 -- 进程控制
    448. 找到所有数组中消失的数字
    Docker 部署 Firefly III 服务
  • 原文地址:https://blog.csdn.net/qq_20173195/article/details/126543419