• 【Linux从入门到精通】通信 | 共享内存(System V)


     

      本篇文章接着上篇文章通信 | 管道通信(匿名管道 & 命名管道)进行讲解。本篇文章的中点内容是共享内存

    文章目录

     一、初识与创建共享内存

    1、1 什么是共享内存

    1、2 共享内存函数

    1、2、1 创建共享内存 shmget

    1、2、2 ftok 生成 key

    1、2、3 获取共享内存 shmget

    1、3 demo 代码

    二、对共享内存进行相关操作 

    2、1 查看/删除 共享内存资源

    2、2 共享内存挂接和访问

    2、2、1 共享内存的挂接 shmat()

    2、2、2 共享内存的访问

    2、3 删除共享内存 shmctl 

    三、完整共享内存通信 demo 代码

    3、1 Log.hpp 日志

    3、2 comm.hpp

    3、3 shmClient.cpp

    3、4 shmServer.cpp


    🙋‍♂️ 作者:@Ggggggtm 🙋‍♂️

    👀 专栏:Linux从入门到精通  👀

    💥 标题:共享内存 💥

     ❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️

     一、初识与创建共享内存

    1、1 什么是共享内存

      我们在之前学管道通信时,是怎么实现通信的呢?匿名管道通信的方式是子进程继承父进程的内核数据结构,使得父子进程能够看到同一块空间命名管道信是让不同进程打开同一份文件。我们发现通信的前提就是让不同的进程看到同一份“资源”。当然,共享内存也不例外。

       每个进程都有自己独立的地址空间,所以它们彼此之间不能直接访问对方的内存。而共享内存则提供了一种特殊的内存区域,允许多个进程可以同时访问和操作同一块内存。具体如下图:

      这里再次解释一下上图。我们创建共享内存的过程:一个进程在内核空间申请一个共享内存对象,让后通过页表建立与物理内存的映射。让后另一个进程通过特殊的方法和算法来找到该共享内存并且与其建立映射。下面我们会对上述过程进行详细解释。 

    1、2 共享内存函数

    1、2、1 创建共享内存 shmget

      shmget函数用于创建一个新的共享内存段或者获取现有的共享内存段的标识符

    函数原型为:

    int shmget(key_t key, size_t size, int shmflg);

    参数说明:

    • key:用于标识共享内存段的键值。可以使用ftok函数生成
    • size:指定共享内存段的大小,以字节为单位。
    • shmflg:用于指定共享内存段的访问权限和标志位,可以使用IPC_CREAT、IPC_EXCL等宏进行设置。

    返回值:

    • 如果成功,返回共享内存段的标识符(即共享内存ID)。
    • 如果失败,返回-1,并设置errno。

      更加详细的如下图:

      我们在这里具体解释一下第三个参数 shmflg。这个参数是可以有多个选择的。底层是利用了位图的思想。主要是IPC_CREAT和IPC_EXCL两个选项。

      IPC_CREAT和IPC_EXCL是在shmget函数中使用的标志位,用于指定共享内存段的访问权限和标志。它们在shmget函数的第三个参数shmflg中使用。

    • IPC_CREAT:该标志用于创建一个新的共享内存段。如果指定的key对应的共享内存段不存在,则创建一个新的共享内存段。如果共享内存段已经存在,则返回该共享内存段的标识符(即共享内存ID)。

    • IPC_EXCL:该标志与IPC_CREAT一起使用,在创建共享内存段时起作用。如果指定的key对应的共享内存段已经存在,则shmget函数会失败,并返回-1,并且置errno为EEXIST(资源已存在)。

       我们接下来再看一下 shmget 的具体使用例子。

    int shmid = shmget(key, size, IPC_CREAT | permission_flags);

      上述代码中,IPC_CREAT标志位用于创建共享内存段。如果指定的key对应的共享内存段已经存在,那么shmget函数会返回该共享内存段的标识符;如果共享内存段不存在,则会创建一个新的共享内存段,并返回新创建的共享内存段的标识符。

    1、2、2 ftok 生成 key

      在函数shmget中,key值是用于标识或检索共享内存段的关键值。它在创建或访问共享内存时起到重要作用。具体来说,key值用于以下两个目的:

    • 当多个进程需要访问同一个共享内存段时,它们可以使用相同的key值来标识这个共享内存段。
    • 如果一个共享内存段已经存在,并且其他进程想要访问它,那么只需要提供相同的key值即可找到该共享内存段。

      那在使用 shmget 函数之前,我们应该使用 ftok 函数生成key值,来表示这个共享内存段。由于是标示共享空间,所以应该确定唯一性。至于key的值是多少并不关键。那我们看一下ftok 函数的用法。具体如下:

    参数说明:

    • pathname:一个包含一个现有文件的路径名,用于生成k值。最好是有权访问这个文件。
    • proj_id:不同的proj_id可以被用作区分不同类型的通信方式或不同的ipc资源,来生成不同的k值。其实就是一个任意整型值。

      我们再看一下其具体的例子:

    1. #include
    2. #include
    3. #include
    4. int main()
    5. {
    6. key_t key;
    7. char *path = "./example.txt";
    8. int proj_id = 0x66;
    9. // 使用ftok函数生成k值
    10. key = ftok(path, proj_id);
    11. printf("Generated key: %d\n", key);
    12. return 0;
    13. }

      另一个进程也可以用相同的方法生成相同的key值。ftok函数根据给定的路径名和proj_id生成k值。当路径名和proj_id相同时,生成的k值也相同。这是因为ftok函数内部使用了哈希运算,将路径名和proj_id转化为一个唯一的整数。尽管可能存在哈希冲突(即不同的路径名和proj_id生成相同的k值),但概率非常低。通常情况下,不同的进程可以使用相同的路径名和proj_id生成相同的k值是非常罕见的。即使出现相同的k值,由于进程间通信中还有其他参数的限制(如消息队列标识、共享内存标识等),不同进程之间的IPC通信仍然可以正常进行。  

      生成 key 值后,我们就可以用key值创建共享内存,或者来获取共享内存。下面我们看一下获取使用key值来获取共享内存的方法。

    1、2、3 获取共享内存 shmget

      shmget 函数还可用来获取共享内存。当生成的key值已经有对应的共享内存时,shmget 函数就会返回这段共享内存的标识码。我么不只需要将第三个参数修改为0,就是来获取对应key值的共享内存。

    1、3 demo 代码

      我们接下来写一段代码测试和总结一下我们上面所学到的函数。下面为实例:

    1. // Log.hpp
    2. #include
    3. #include
    4. #define Debug 0
    5. #define Notice 1
    6. #define Warning 2
    7. #define Error 3
    8. const std::string msg[] = {
    9. "Debug",
    10. "Notice",
    11. "Warning",
    12. "Error"
    13. };
    14. std::ostream &Log(std::string message, int level)
    15. {
    16. std::cout << " | " << (unsigned)time(nullptr) << " | " << msg[level] << " | " << message;
    17. return std::cout;
    18. }
    19. // comm.hpp
    20. #pragma once
    21. #include
    22. #include
    23. #include
    24. #include
    25. #include
    26. #include
    27. #include
    28. #include
    29. #include
    30. #include
    31. #include
    32. #include "Log.hpp"
    33. using namespace std;
    34. #define PATH_NAME "/home/gtm"
    35. #define PROJ_ID 0x66
    36. #define SHM_SIZE 4096 //共享内存的大小,最好是页(PAGE: 4096)的整数倍
    37. #define FIFO_NAME "./fifo"
    38. // shmClient.cc
    39. #include"comm.hpp"
    40. int main()
    41. {
    42. Log("child pid is : ", Debug) << getpid() << endl;
    43. key_t k = ftok(PATH_NAME, PROJ_ID);
    44. if (k < 0)
    45. {
    46. Log("create key failed", Error) << " client key : " << k << endl;
    47. exit(1);
    48. }
    49. Log("create key done", Debug) << " client key : " << k << endl;
    50. // 获取共享内存
    51. int shmid = shmget(k, SHM_SIZE, 0);
    52. if(shmid < 0)
    53. {
    54. Log("create shm failed", Error) << " client key : " << k << endl;
    55. exit(2);
    56. }
    57. Log("create shm success", Error) << " client shmid : " << shmid << endl;
    58. return 0;
    59. }
    60. // shmServer.cc
    61. #include "comm.hpp"
    62. string TransToHex(key_t k)
    63. {
    64. char buffer[32];
    65. snprintf(buffer, sizeof buffer, "0x%x", k);
    66. return buffer;
    67. }
    68. int main()
    69. {
    70. // 我们之前为了通信,所做的所有的工作,属于什么工作呢:让不同的进程看到了同一份资源(内存)
    71. // 1. 创建公共的Key值
    72. key_t k = ftok(PATH_NAME, PROJ_ID);
    73. assert(k != -1);
    74. Log("create key done", Debug) << " server key : " << TransToHex(k) << endl;
    75. // 2. 创建共享内存 -- 建议要创建一个全新的共享内存 -- 通信的发起者
    76. int shmid = shmget(k, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666); //
    77. if (shmid == -1)
    78. {
    79. perror("shmget");
    80. exit(1);
    81. }
    82. Log("create shm done", Debug) << " shmid : " << shmid << endl;
    83. return 0;
    84. }

      shmServer.cc文件中的代码是服务端代码。首先调用ftok函数生成一个唯一的键值,并将其转换为十六进制字符串表示。然后使用shmget函数创建一个共享内存段,创建时指定了IPC_CREAT标志,用于新建共享内存段。如果创建成功,返回一个共享内存标识符shmid。

      shmClient.cc文件中的代码是客户端代码。首先也是调用ftok函数生成一个唯一的键值。然后通过shmget函数打开已存在的共享内存段,打开时不需要指定IPC_CREAT标志,而是提供即将打开的共享内存段的键值和大小。如果打开成功,返回一个共享内存标识符shmid。

      Log.hpp文件定义了一个宏和一个Log函数。宏定义了四个日志级别,分别对应Debug、Notice、Warning和Error四个字符串。Log函数负责输出日志信息,接受一个字符串信息和一个日志级别参数。Log函数将时间戳、日志级别和消息内容输出到标准输出流中。

      上述代码就是完成了创建共享内存的功能,并且在其中打印了一些日志信息。我们不妨来看一下运行结果。具体如下图:

      我们发现对应的shmid是相同的,说明Server和Client确实获得了相同的共享内存块。他们所生成的key值相同吗?其实是相同的,如下图:

    二、对共享内存进行相关操作 

    2、1 查看/删除 共享内存资源

      当我们再次运行时,就会发生错误。具体如下图:

      为什么呢?原因是我们刚刚创建的共享内存依然存在。当进程结束时,共享内存并不会自动释放。为什么呢?我们可以认为共享内存是属于操作系统。所以共享内存的生命周期随操作系统!这时我们可以手动关闭共享内存。在关闭前首先要查看共享内存,指令:ipcs -m。具体如下图:

      当然,查到共享内存后,可以用指令进行删除。那么问题来了,使用key值删除呢?还是用shmid 进行删除呢?我们这里需要注意:共享内存中的shmid和key值是两个不同的概念

    1. shmid(Shared Memory ID)是共享内存的标识符,由操作系统分配,并作为一个非负整数对共享内存进行引用。在使用共享内存时,我们需要通过shmid来进行操作,如创建、附加、访问和删除等。shmid可以看作是内核用于标识某个特定共享内存段的一个唯一值

    2. key值是用户定义的一个标识符,通常是一个整数值。在创建共享内存时,我们可以使用ftok函数生成一个key值,以便其他进程可以通过这个key值来获取相同的共享内存区域。key值是用于标识共享内存的用户级别的标识符,不同于shmid,其值不受内核控制

      所以删除共享内存,我们使用的shmid。具体指令:ipcrm -m shmid。如下图:

      当我们删除共享内存后,再次进行查找发现就没有了,且程序能够正常运行。

    2、2 共享内存挂接和访问

    2、2、1 共享内存的挂接 shmat()

      在调用shmget()函数时,内核会在内部维护一个共享内存表格,其中包含了共享内存的相关信息,包括共享内存的大小、权限等。当调用成功后,将返回一个唯一的共享内存标识符,该标识符可以用于后续的共享内存操作。

      那么正常来说,我们访问共享内存是需要通过系统调用的。但是我们这里可以将内核级别的共享内存挂接到进程的地址空间。然后用户就可以直接进行访问

      进程可以使用系统提供的函数(如shmat())将自己的地址空间映射到共享内存。也可以理解为shmat()函数将共享内存附加到进程的虚拟地址空间中,使得进程可以访问该共享内存所指向的物理内存区域。具体用法如下:

      参数说明:

    • shm_id:共享内存标识符,通过调用 shmget 获取。
    • shm_addr:内存段的地址,通常传入 NULL,表示由系统自动选择一个适合的地址。
    • shmflg:控制共享内存段的附加方式和权限,可以使用 IPC_CREAT 标志创建新的共享内存段。通常传入0。

      返回值:

    • 如果成功,返回指向共享内存段第一个字节的指针;
    • 如果失败,返回 void * 类型的错误值 -1

      其实我们看完其使用方法后,有没有发现与 malloc 很相似。malloc 申请空间成功后,会返回所申请空间的起始地址。否则就会返回NULL。shmat 与其确实有些相似。我们可结合如下例子一起理解:

    1. #include
    2. #include
    3. #include
    4. #include
    5. int main() {
    6. int shm_id;
    7. key_t key;
    8. int *shared_memory;
    9. // 获取共享内存标识符
    10. key = ftok("。/file", 0x66);
    11. shm_id = shmget(key, sizeof(int), IPC_CREAT | 0666);
    12. // 将共享内存段附加到进程的地址空间中
    13. shared_memory = shmat(shm_id, NULL, 0);
    14. // 访问共享内存
    15. printf("共享内存中的值为:%d\n", *shared_memory);
    16. // 分离共享内存段
    17. shmdt(shared_memory);
    18. return 0;
    19. }

      我们也看到了最后是有一个去关联的 shmdt 函数。参数就是我们所获取的共享内存的起始地址,这里就不再过多解释此函数

      这里有会有一个问题:将内核级别的共享内存挂接到进程地址空间的哪里了呢?我们看如下图:

       我们之前学进程地址空间时,知道堆和栈的中间有大量的镂空,而这段位置就是内核级别的共享内存所挂接到的位置!

    2、2、2 共享内存的访问

      到这里,我们已经学习了共享内存的大部分内容。只差对共享内存的访问了。当我们对共享内存进行挂接后, 就可以得到共享内存挂接后的起始地址。我们用户可以对其进行直接访问(写入/读取)。我们给出如下伪代码:

    1. // shmServer.cpp
    2. char *shmaddr = (char *)shmat(shmid, nullptr, 0);
    3. Log("attach shm done", Debug) << " shmid : " << shmid << endl;
    4. for(;;)
    5. {
    6. printf("%s\n", shmaddr);
    7. if(strcmp(shmaddr, "quit") == 0) break;
    8. sleep(1);
    9. }
    10. // shmClient.cpp
    11. // 挂接并获得共享内存起始地址
    12. char *shmaddr = (char *)shmat(shmid, nullptr, 0);
    13. if(shmaddr == nullptr)
    14. {
    15. Log("attach shm failed", Error) << " client key : " << k << endl;
    16. exit(3);
    17. }
    18. char a = 'a';
    19. for(; a <= 'c'; a++)
    20. {
    21. // 我们是每一次都向shmaddr[共享内存的起始地址]写入
    22. snprintf(shmaddr, SHM_SIZE - 1,\
    23. "hello server, 我是其他进程,我的pid: %d, inc: %c\n",\
    24. getpid(), a);
    25. sleep(3);
    26. }

      对上述代码是一个使用共享内存进行进程间通信的伪代码。下面对代码进行详解:

    1. 首先,在服务端(shmServer.cpp)中,通过shmat函数将共享内存连接到当前进程的地址空间。shmat函数的第一个参数是共享内存的标识符shmid,第二个参数为NULL表示让系统自动选择合适的地址分配给共享内存,第三个参数为0表示以默认权限进行操作。连接完成后,返回共享内存的起始地址,并赋值给shmaddr指针。

    2. 服务器端的for循环中,通过printf函数将shmaddr指向的共享内存内容输出到标准输出(读取)。然后通过strcmp函数判断共享内存中的内容是否为"quit",如果是,则跳出循环,结束程序。否则,通过sleep函数暂停1秒钟。

    3. 在客户端(shmClient.cpp)中,同样通过shmat函数连接到共享内存,并将共享内存的起始地址赋给shmaddr指针。若连接失败(shmaddr为nullptr),则输出错误信息并退出程序。

    4. 客户端的for循环中,使用snprintf函数将格式化的字符串写入shmaddr指向的共享内存中(写入)。该字符串包含了客户端进程的PID(进程标识符)和一个递增的字符,以展示多次写入的内容。然后通过sleep函数暂停3秒钟。

      运行结果如下:

      我们通过运行结果发现:在客户端没有写入的情况下,服务端进行读取时也会读到内容。读到的是空字符串(共享内存默认会初始化为0)。我们发现共享内存的读写并没有访问控制。我们知道命名管道通信是由访问控制的。但是当一个进程写入时,另一个就能够马上看到写入的内容。所以共享内存是所有进程间通信(IPC),速度最快的!不需要过多的拷贝!!(不需要将数据给操作系统)。如果我想一定程度的访问控制呢?可以在共享内存读写的过程中加入命名管道来控制。

    2、3 删除共享内存 shmctl 

      上面我们了解了可以使用Linux指令对共享内存进行删除,我们也可以使用系统调用 shmctl()函数 对其进行删除。具体使用如下:

     参数说明:

    • shmid:共享内存标识符,通过shmget函数获取得到。
    • cmd:表示对共享内存进行的操作类型,可以选择的参数有:
      • IPC_STAT:获取共享内存的状态信息,将共享内存的属性保存在buf所指向的结构体中。
      • IPC_SET:设置共享内存的属性,使用buf所指向的结构体中的值进行设置。
      • IPC_RMID:删除共享内存。
    • buf:指向一个struct shmid_ds结构体的指针,用于存储共享内存的属性信息。通常使用nullptr。

      shmctl函数可以用于对共享内存段进行控制操作。它能够实现共享内存的创建、删除、以及获取和修改共享内存的属性。但是我们该函数最常用删除共享内存。使用IPC_RMID操作可以删除指定的共享内存段,并释放系统资源。这个操作会立即删除共享内存段,以及与它关联的任何进程中的键和id

    三、完整共享内存通信 demo 代码

    3、1 Log.hpp 日志

      

    1. #include
    2. #include
    3. #define Debug 0
    4. #define Notice 1
    5. #define Warning 2
    6. #define Error 3
    7. const std::string msg[] = {
    8. "Debug",
    9. "Notice",
    10. "Warning",
    11. "Error"
    12. };
    13. std::ostream &Log(std::string message, int level)
    14. {
    15. std::cout << " | " << (unsigned)time(nullptr) << " | " << msg[level] << " | " << message;
    16. return std::cout;
    17. }

    3、2 comm.hpp

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. #include
    10. #include
    11. #include
    12. #include
    13. #include "Log.hpp"
    14. using namespace std; //不推荐
    15. #define PATH_NAME "/home/whb"
    16. #define PROJ_ID 0x66
    17. #define SHM_SIZE 4096 //共享内存的大小,最好是页(PAGE: 4096)的整数倍
    18. #define FIFO_NAME "./fifo"
    19. class Init
    20. {
    21. public:
    22. Init()
    23. {
    24. umask(0);
    25. int n = mkfifo(FIFO_NAME, 0666);
    26. assert(n == 0);
    27. (void)n;
    28. Log("create fifo success",Notice) << "\n";
    29. }
    30. ~Init()
    31. {
    32. unlink(FIFO_NAME);
    33. Log("remove fifo success",Notice) << "\n";
    34. }
    35. };
    36. #define READ O_RDONLY
    37. #define WRITE O_WRONLY
    38. int OpenFIFO(std::string pathname, int flags)
    39. {
    40. int fd = open(pathname.c_str(), flags);
    41. assert(fd >= 0);
    42. return fd;
    43. }
    44. void Wait(int fd)
    45. {
    46. Log("等待中....", Notice) << "\n";
    47. uint32_t temp = 0;
    48. ssize_t s = read(fd, &temp, sizeof(uint32_t));
    49. assert(s == sizeof(uint32_t));
    50. (void)s;
    51. }
    52. void Signal(int fd)
    53. {
    54. uint32_t temp = 1;
    55. ssize_t s = write(fd, &temp, sizeof(uint32_t));
    56. assert(s == sizeof(uint32_t));
    57. (void)s;
    58. Log("唤醒中....", Notice) << "\n";
    59. }
    60. void CloseFifo(int fd)
    61. {
    62. close(fd);
    63. }

    3、3 shmClient.cpp

    1. #include "comm.hpp"
    2. int main()
    3. {
    4. Log("child pid is : ", Debug) << getpid() << endl;
    5. key_t k = ftok(PATH_NAME, PROJ_ID);
    6. if (k < 0)
    7. {
    8. Log("create key failed", Error) << " client key : " << k << endl;
    9. exit(1);
    10. }
    11. Log("create key done", Debug) << " client key : " << k << endl;
    12. // 获取共享内存
    13. int shmid = shmget(k, SHM_SIZE, 0);
    14. if(shmid < 0)
    15. {
    16. Log("create shm failed", Error) << " client key : " << k << endl;
    17. exit(2);
    18. }
    19. Log("create shm success", Error) << " client key : " << k << endl;
    20. // sleep(10);
    21. char *shmaddr = (char *)shmat(shmid, nullptr, 0);
    22. if(shmaddr == nullptr)
    23. {
    24. Log("attach shm failed", Error) << " client key : " << k << endl;
    25. exit(3);
    26. }
    27. Log("attach shm success", Error) << " client key : " << k << endl;
    28. int fd = OpenFIFO(FIFO_NAME, WRITE);
    29. // client将共享内存看做一个char 类型的buffer
    30. while(true)
    31. {
    32. ssize_t s = read(0, shmaddr, SHM_SIZE-1);
    33. if(s > 0)
    34. {
    35. shmaddr[s-1] = 0;
    36. Signal(fd);
    37. if(strcmp(shmaddr,"quit") == 0) break;
    38. }
    39. }
    40. CloseFifo(fd);
    41. // 去关联
    42. int n = shmdt(shmaddr);
    43. assert(n != -1);
    44. Log("detach shm success", Error) << " client key : " << k << endl;
    45. return 0;
    46. }

    3、4 shmServer.cpp

    1. #include "comm.hpp"
    2. Init init;
    3. string TransToHex(key_t k)
    4. {
    5. char buffer[32];
    6. snprintf(buffer, sizeof buffer, "0x%x", k);
    7. return buffer;
    8. }
    9. int main()
    10. {
    11. // 1. 创建公共的Key值
    12. key_t k = ftok(PATH_NAME, PROJ_ID);
    13. assert(k != -1);
    14. Log("create key done", Debug) << " server key : " << TransToHex(k) << endl;
    15. // 2. 创建共享内存 -- 建议要创建一个全新的共享内存 -- 通信的发起者
    16. int shmid = shmget(k, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666); //
    17. if (shmid == -1)
    18. {
    19. perror("shmget");
    20. exit(1);
    21. }
    22. Log("create shm done", Debug) << " shmid : " << shmid << endl;
    23. // 3. 将指定的共享内存,挂接到自己的地址空间
    24. char *shmaddr = (char *)shmat(shmid, nullptr, 0);
    25. Log("attach shm done", Debug) << " shmid : " << shmid << endl;
    26. int fd = OpenFIFO(FIFO_NAME, READ);
    27. for(;;)
    28. {
    29. Wait(fd);
    30. // 临界区
    31. printf("%s\n", shmaddr);
    32. if(strcmp(shmaddr, "quit") == 0) break;
    33. // sleep(1);
    34. }
    35. // 4. 将指定的共享内存,从自己的地址空间中去关联
    36. int n = shmdt(shmaddr);
    37. assert(n != -1);
    38. (void)n;
    39. Log("detach shm done", Debug) << " shmid : " << shmid << endl;
    40. // 5. 删除共享内存,IPC_RMID即便是有进程和当下的shm挂接,依旧删除共享内存
    41. n = shmctl(shmid, IPC_RMID, nullptr);
    42. assert(n != -1);
    43. (void)n;
    44. Log("delete shm done", Debug) << " shmid : " << shmid << endl;
    45. CloseFifo(fd);
    46. return 0;
    47. }

      上述共享内存代码是结合了命名管道通信进行了访问控制

  • 相关阅读:
    Jupyter Notebook + Pyecharts——学习笔记(04)
    【VUE】点击哪个按钮哪个高亮
    分享篇:初识Canvas
    (4) OpenCV图像处理kNN近邻算法-识别数字0和1
    JavaScript简介
    监控 5 分钟抓拍一次人脸,不够 89 次算旷工,居家办公员工:不敢去厕所
    同样是IT行业,测试和开发薪资真有这么大差别?
    通过postgres_fdw实现跨库访问
    HCNP Routing&Switching之组播技术PIM-SM 稀疏模式
    Worthington酶促细胞收获&细胞粘附和收获
  • 原文地址:https://blog.csdn.net/weixin_67596609/article/details/132754559