Linux共享内存原理与实战:从内核到C++实现|附源码

上篇文章:Linux命名管道:跨进程通信实战指南|附源码

目录

前言

1.核心原理:共享内存究竟是什么?

1.1内存映射的魔法

1.2操作系统如何管理共享内存?

2.两个进程如何找到同一块共享内存?

2.1约定的暗号:key

[2.2如何生成唯一的 key? ------ ftok 函数](#2.2如何生成唯一的 key? —— ftok 函数)

[2.3key 与 shmid 的区别](#2.3key 与 shmid 的区别)

3.共享内存的生命周期:四大核心系统调用

[3.1创建 / 获取共享内存:shmget](#3.1创建 / 获取共享内存:shmget)

3.2挂接共享内存:shmat (Attach)

3.3去关联共享内存:shmdt (Detach)

[3.4控制 / 删除共享内存:shmctl](#3.4控制 / 删除共享内存:shmctl)

[4.C++ 面向对象实战:从基础到完善](#4.C++ 面向对象实战:从基础到完善)

4.1初代版本:仅实现创建与获取

4.2踩坑记录生命周期引发的报错 (承上启下)

[4.3最终源码:挂接、通信与 RAII 自动清理](#4.3最终源码:挂接、通信与 RAII 自动清理)

5.终极总结:共享内存的三大核心特性


前言

在探索 Linux 进程间通信(IPC)的旅程中,我们必然会遇到一座绕不过去的大山------System V 共享内存(Shared Memory)

很多资料都会告诉你:"共享内存区是最快的 IPC 形式 "。但它是如何做到"最快"的?一旦这样的内存映射到共享它的进程的地址空间,这些进程间的数据传递为什么不再涉及到内核?换句话说,进程为什么不再通过执行进入内核的系统调用来传递彼此的数据?

本文将结合底层原理图,带你由浅入深地剖析 System V 共享内存的运作机制。

1.核心原理:共享内存究竟是什么?

要理解共享内存,我们首先要打破一个常规认知:进程之间是相互隔离的。那么,如何让两个隔离的进程(进程A和进程B)进行通信?

答案:让他们看到同一块物理内存

1.1内存映射的魔法

根据操作系统原理,每个进程都有自己独立的虚拟地址空间(mm_struct)。在Linux的进程地址空间发布中,有一块特定的区域被称为共享区(内存映射段) ,它刚好位于栈区(向下增长)和堆区(向上增长)之间

共享内存的创建和使用过程如下(结合地址空间分布思考):

  1. 申请物理内存:操作系统在真实的物理内存中开辟一块空间。

  2. 挂接(Attach) :将这块物理内存分别映射到进程 A 和进程 B 的共享区(即栈与堆之间的区域)。此时,进程 A 和进程 B 各自的页表会将这部分虚拟地址指向同一块物理地址。

  3. 去关联(Detach):通信完毕后,取消虚拟地址与物理内存的映射关系(即修改各自的页表)。

  4. 释放内存:最后由操作系统回收这块物理内存。

突破点理解:为什么它是最快的? 传统的管道或消息队列通信,需要调用 read / write系统调用 ,数据需要在用户态和内核态之间来回拷贝。 而共享内存一旦挂接(Attach)成功,这块内存就直接属于进程的用户空间 !用户程序可以通过指针直接访问 这块内存,就像使用malloc()申请的内存一样,完全不需要任何系统调用来辅助读写。这就是它速度最快的根本原因!

1.2操作系统如何管理共享内存?

既然是物理内存,系统中可能会同时存在多块共享内存供不同的进程组使用。操作系统必然要对这些共享内存进行管理。 管理的核心理念是:先描述,再组织 。 在 Linux 内核中,每块共享内存都有一个对应的数据结构 struct shmid_ds 来描述它的属性:

cpp 复制代码
struct shmid_ds
{
    struct ipc_perm shm_perm;    /* operation perms */
    int shm_segsz;               /* size of segment
                 (bytes) */
    __kernel_time_t shm_atime;   /* last attach time
                                  */
    __kernel_time_t shm_dtime;   /* last detach time
                                  */
    __kernel_time_t shm_ctime;   /* last change time
                                  */
    __kernel_ipc_pid_t shm_cpid; /* pid of creator */
    __kernel_ipc_pid_t shm_lpid; /* pid of last operator */
    unsigned short shm_nattch;   /* no. of current
     attaches */
    unsigned short shm_unused;   /* compatibility */
    void shm_unused2;
    / ditto - used by DIPC * /
                  void shm_unused3;
    / unused * /
};

2.两个进程如何找到同一块共享内存?

这是初学者最容易卡壳的地方。既然系统里有很多共享内存,进程 A 创建了一块共享内存,进程 B 怎么知道哪一块是进程 A 创建的呢?共享内存一定需要一个全局唯一的标识符!

2.1约定的暗号:key

为了让 A 和 B 找到同一块内存,程序员需要为它们设定一个"约定"的值,这个值就是 key

  • key 是一个在操作系统内核中使用的唯一标识符。

  • 进程 A 用这个 key 去内核里申请创建一个共享内存。

  • 进程 B 用同一个 key 去内核里查找,就能准确获取到进程 A 创建的那块共享内存。

2.2如何生成唯一的 key? ------ ftok 函数

我们不能随便写一个数字作为 key(容易冲突),Linux 提供了 ftok 函数,利用文件系统的信息来通过算法生成一个相对唯一的 key

复制代码
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
  • pathname:工程路径名(随便写一个存在的路径,A和B必须写一样的)。

  • proj_id:项目 ID(随便写一个数字,A和B必须写一样的)。

  • 底层算法ftok 实际上是提取了 pathname 对应文件的 inode number(索引节点号)proj_id 组合计算出一个唯一的 key 值。

2.3keyshmid 的区别

既然有了key,为什么各种共享内存函数返回和使用的都是shmid?

  • key :属于内核级的概念,用来在系统全局唯一标识一块共享内存。

  • shmid :属于用户级 的概念,是 shmget 函数成功后返回的标识码。

类比理解 :这就像文件系统一样。key 类似于文件的底层 inode 号,而 shmid 类似于你在代码中使用的文件描述符 fd。我们在用户态写代码时,只认 shmid (fd),不直接操作 key (inode)。

3.共享内存的生命周期:四大核心系统调用

理解了原理,我们再来看操作共享内存的四个关键 API,就会觉得非常自然了。

3.1创建 / 获取共享内存:shmget

功能:用来创建一段新的共享内存,或者获取一段已经存在的共享内存。

原型:

cpp 复制代码
int shmget(key_t key, size_t size, int shmflg);

参数解析:

  • key :使用 ftok 生成的内核唯一标识。

  • size :共享内存的大小。建议设置为 4KB 的整数倍(因为操作系统分配内存的基本单位是页,1页=4KB)。

  • shmflg:标志位(包含权限标志,用法类似创建文件时的 mode)。

    • 单独传入 IPC_CREAT:如果共享内存不存在,就创建并返回;如果已存在,就直接获取并返回

    • 传入 IPC_CREAT | IPC_EXCL:如果共享内存不存在,就创建并返回;如果已存在,出错返回 。这保证了如果函数调用成功,返回的一定是一块全新的共享内存!

返回值 :成功返回一个非负整数 shmid;失败返回 -1。

3.2挂接共享内存:shmat (Attach)

功能:将刚刚创建的物理共享内存,映射连接到当前进程的虚拟地址空间。

cpp 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);

参数解析:

  • shmidshmget 返回的标识码。

  • shmaddr :指定连接的虚拟地址。通常设为 NULL,表示让操作系统核心自动为我们选择一个合适的地址(强烈建议)。

  • shmflg :通常设为 0。如果是 SHM_RDONLY,表示只读。

返回值 :成功返回一个指针(类似 malloc 的返回值),指向共享内存第一个字节;失败返回 (void *)-1

说明:

shmaddr为NULL,核心自动选择⼀个地址

shmaddr不为NULL且shmflg⽆SHM_RND标记,则以shmaddr为连接地址。

shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会⾃动向下调整为SHMLBA的整数倍。公式:shmaddr - (shmaddr % SHMLBA)

shmflg=SHM_RDONLY,表示连接操作用来只读共享内存

3.3去关联共享内存:shmdt (Detach)

功能:将共享内存段与当前进程的虚拟地址空间脱离。

原型:

cpp 复制代码
 int shmdt(const void *shmaddr);

参数shmaddr 就是 shmat 成功时返回的那个首地址指针。 返回值:成功返回 0;失败返回 -1。

⚠️ 注意shmdt 只是切断了进程和共享内存的联系(修改页表),并不等于删除共享内存段! 物理内存依然存在于操作系统中。

3.4控制 / 删除共享内存:shmctl

功能 :用于控制共享内存(获取状态、修改属性、最常用的是删除它)。

原型:

cpp 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

参数解析:

  • shmid:共享内存标识码。

  • cmd:将要采取的动作(即控制命令)。具体有以下三个常用命令:

命令 说明
IPC_STAT shmid_ds 结构中的数据设置为共享内存的当前关联值(即获取属性)。
IPC_SET 在进程有足够权限的前提下,把共享内存的当前关联值设置为 shmid_ds 数据结构中给出的值(即修改属性)。
IPC_RMID 删除共享内存段
  • buf :指向状态数据结构 shmid_ds 的指针。如果只是为了删除(传入 IPC_RMID),此处传 NULL 即可。

返回值:成功返回 0;失败返回 -1。

4.C++ 面向对象实战:从基础到完善

为了更好地理解系统调用与内存管理的关系,我们将实战分为两步:先写一个仅包含创建和获取的初代版本,发现并解决其中的生命周期问题后,再完成包含完整数据传输的终极版本。

4.1初代版本:仅实现创建与获取

首先,我们写好统一的编译文件 Makefile

cpp 复制代码
all:Reader Writer
Reader:Reader.cc
	g++ -o $@ $^ -std=c++11
Writer:Writer.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f Reader Writer

我们在 Reader.cc 中创建,在 Writer.cc 中获取:

Reader.cc

cpp 复制代码
#include "Shm.hpp"
#include <iostream>
#include <string>

// Writer -> shm -> Reader
int main()
{
    // 在内核中创建共享内存
    Shm shm;
    shm.Create();
    
    return 0;
}

Writer.cc

cpp 复制代码
#include "Shm.hpp"
#include <iostream>
#include <string>


int main()
{
    Shm shm;
    shm.Get();

    return 0;
}

我们将 shmget 封装进 Shm.hpp,提供简单的 CreateGet 接口:

cpp 复制代码
#ifndef __SHM_HPP
#define __SHM_HPP

#include <iostream>
#include <sys/shm.h>
#include <string>

const std::string proj_name = ".";
const int proj_id = 0x6666;
const int g_size = 4096;

class Shm
{
public:
    Shm(int size = g_size):_shmid(-1), _size(size)
    {}
   ~Shm(){}
private:
    key_t GetKey()
    {
        key_t k = ftok(proj_name.c_str(), proj_id);
        if(k < 0)
        {
            perror("ftok");
        }
        return k;
    }
public:
    // 创建
    bool Create()
    {
        // 获取key值
        key_t k = GetKey();
        // 创建共享内存
        _shmid = shmget(k, _size, IPC_CREAT | IPC_EXCL);
        if(_shmid < 0)
        {
            perror("shmget");
            return false;
        }
        std::cout << "shmid: " << _shmid << std::endl;
        return true;
    }
    // 获取共享内存
    bool Get()
    {
        key_t k = GetKey();
        std::cout << "key: " << k << std::endl;
        return true;
    }
private:
    int _shmid;
    int _size;
};

#endif

4.2踩坑记录生命周期引发的报错 (承上启下)

运行上述代码,当你运行一次 Reader 后,程序退出。可是当你尝试再次运行 Reader 时,程序报错了!

我们先通过查看共享内存,命令为:ipcs -m:

cpp 复制代码
$ ipcs -m

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      
0x660210c7 7          xxx404     0          4096       0
  • perms : 权限(我们在代码中设置的 0666)。

  • nattch: 当前挂接的进程数。如果为 0,说明没有任何进程在使用它,但它依然占据着内存。

你会发现,刚刚创建的共享内存依然存在(status 在那里,且 nattch 为 0 表示没有进程连接)。由于我们在 Create 时使用了 IPC_CREAT | IPC_EXCL,强求创建全新的内存,所以第二次运行时检测到内存已存在,就报错了。

结论:

共享内存的生命周期 System V IPC 资源(包括共享内存)的生命周期是随内核的 ,而不是随进程的。这意味着如果进程退出前没有主动调用 shmctl(..., IPC_RMID, ...) 删除它,这块共享内存会一直驻留在操作系统中,直到系统重启或使用命令行工具(如 ipcrm)手动删除。

我们此时先通过命令删除:ipcrm -m

注意:要通过shmid删除,因为它始终属于用户级别的操作。

其中,perms是所属组权限,我们通过在创建时基于权限:

通过上述的错误引出了我们下一步的完善目标:真正的通信需要包含 Attach(挂接)、写/读数据、Detach(去关联),并在程序结束时通过代码主动 Delete(删除) 共享内存。

4.3最终源码:挂接、通信与 RAII 自动清理

为了实现真正的数据通信,我们定义一个数据结构 buffer_t,并在 Shm 类中补充完整的方法。

终极版 Shm.hpp

cpp 复制代码
#ifndef __SHM_HPP
#define __SHM_HPP

#include <iostream>
#include <cstdio>
#include <sys/shm.h>
#include <string>
#include <unistd.h>

const std::string proj_name = ".";
const int proj_id = 0x6666;
const int g_size = 4096;

static std::string ToHex(long long data)
{
    char hex[64];
    snprintf(hex, sizeof(hex), "0x%llx", data);
    return hex;
}

class Shm
{
public:
    Shm(int size = g_size):_shmid(-1), _size(size), _key(0), _start(nullptr)
    {}
   ~Shm(){}
private:
    key_t GetKey()
    {
        _key = ftok(proj_name.c_str(), proj_id);
        if(_key < 0)
        {
            perror("ftok");
        }
        return _key;
    }

    bool CreateCoreHelper(int flags)
    {
        // 获取key值
        key_t k = GetKey();
        // 创建共享内存
        _shmid = shmget(k, _size, flags);
        if(_shmid < 0)
        {
            perror("shmget");
            return false;
        }
        return true;
    }
public:
    // 创建
    bool Create()
    {
        return CreateCoreHelper(IPC_CREAT | IPC_EXCL | 0666);
    }
    // 获取共享内存
    bool Get()
    {
        // key_t k = GetKey();
        // std::cout << "key: " << k << std::endl;
        // return true;
        return CreateCoreHelper(IPC_CREAT);
    }
    // 删除共享内存
    bool Delete()
    {
        int n = shmctl(_shmid, IPC_RMID, NULL);
        return n < 0 ? false : true;
    }
    // 获取共享内存属性
    void GetShmAttr()
    {
        struct shmid_ds ds;
        int n = shmctl(_shmid, IPC_STAT, &ds);
        if(n < 0)
        {
            perror("shmctl");
            return;
        }
        std::cout << "pid: " << getpid() << std::endl;

        std::cout << ds.shm_cpid << std::endl;
        std::cout << ds.shm_segsz << std::endl;
        std::cout << ToHex(ds.shm_perm.__key) << std::endl;
    }
    // 链接
    void *Attach()
    {
        _start = shmat(_shmid, nullptr, 0);
        return _start;
    }
    // 去关联
    void Detach()
    {
        int n = shmdt(_start);
        (void)n;
    }

    void Debug()
    {
        std::cout << "key: " << ToHex(_key) << std::endl;
        std::cout << "shmid: " << _shmid << std::endl;
    }
private:
    key_t _key;
    int _shmid;
    int _size;
    void* _start;
};

typedef struct Data
{
    int count;
    char buffer[26*2];
}buffer_t;

#endif

终极版读取端 Reader.cc 负责创建内存、挂接、循环读取并在结束时清理资源。

cpp 复制代码
#include "Shm.hpp"
#include <iostream>
#include <string>
#include <unistd.h>

// Writer -> shm -> Reader
int main()
{
    // 在内核中创建共享内存
    Shm shm;
    shm.Create();
    // sleep(3);

    char * addr = (char *)shm.Attach(); // 获得共享内存起始地址
    // std::cout << "addr: " << ToHex((long long)addr) << std::endl;
    // sleep(10);

    buffer_t *shm_addr = (buffer_t*)addr;

    while(true)
    {
        std::cout << "count: " << shm_addr->count << std::endl;
        std::cout << "data: " << shm_addr->buffer << std::endl;
        sleep(1);

        if(shm_addr->count>=26)
            break;
    }

    shm.Detach();
    // sleep(3);

    
    // shm.Debug();
    // shm.GetShmAttr();
    
    // sleep(5);
    
    shm.Delete();
    // sleep(3);

    return 0;
}

终极版写入端 Writer.cc 这里巧妙地利用了一个全局对象 Init (C++ RAII 机制),使得程序启动和销毁时自动执行获取、挂接与去关联操作。

cpp 复制代码
#include "Shm.hpp"
#include <iostream>
#include <string>
#include <string.h>

Shm shm;

class Init
{
public:
    Init()
    {
        shm.Get();
        char *addr = (char *)shm.Attach();
        std::cout << "addr: " << ToHex((long long)addr) << std::endl;
    }
    ~Init()
    {
        shm.Detach();
    }
    char *Addr() // 起始地址
    {
        return addr;
    }

public:
    char *addr;
};

Init init;

int main()
{
    std::cout << "test begin..." << std::endl;

    buffer_t *shm = (buffer_t *)init.Addr();
    shm->count = 0;
    memset(shm->buffer, 0, 4096);

    char ch = 'A';
    for (int i = 0; i < 26 * 2; i += 2, ch++)
    {
        shm->buffer[i] = ch;
        shm->buffer[i+1] = ch;
        shm->count++;

        sleep(1);
    }
    return 0;
}

5.终极总结:共享内存的三大核心特性

通过以上的理论与代码演进过程,我们可以提炼出关于 System V 共享内存的三个最重要结论:

  1. 无需系统调用(零拷贝机制) : 我们在终极版代码中向共享内存读写数据时,仅仅是做了一次强转 (buffer_t*)addr,然后就像访问本地变量一样通过指针直接赋值(如 shm_buf->count++)。全程没有使用任何类似 read/write 的系统调用。因为共享内存被映射到了进程自己的用户空间中!

  2. 天下武功,唯快不破(速度最快) : 由第一点可知,进程间数据传递跳过了"用户态与内核态之间的数据拷贝"环节。一方在向内存中写入数据时,另一方几乎立刻就能通过地址空间看到数据的变化。这使得它成为了所有 IPC 机制中速度最快的存在。

  3. 缺乏保护机制(裸奔的内存) : 仔细观察我们的代码你会发现,共享内存没有自带任何同步和互斥的保护机制 !如果在多进程高并发的情况下,A 在写的同时 B 强行去读,必然会导致数据不一致(脏读)。因此,在实际的商业开发中,共享内存几乎总是要与信号量(Semaphore)或互斥锁配合使用,来保证对内存读写的安全性。

希望这篇文章能帮你彻底打通 Linux 共享内存的任督二脉!如果你觉得有收获,欢迎点赞收藏,也欢迎在评论区交流讨论。

相关推荐
HIT_Weston1 小时前
77、【Agent】【OpenCode】bash 工具提示词(持久化)(一)
人工智能·agent·opencode
无敌的六边形狗勾战士1 小时前
重温DIRE:走向通用人工智能生成的图像检测
人工智能
苏宸啊1 小时前
linux文件描述符和重定向的理解
linux
计算机安禾1 小时前
【c++面向对象编程】第1篇:从C到C++:面向对象编程思想入门
c语言·c++·算法
Anjgst1 小时前
宝塔面板命令行
linux·运维·服务器·笔记
liuhuizuikeai1 小时前
菜品抽奖活动MFC+服务端
c++·windows·mfc
云天AI实战派1 小时前
ChatGPT / Realtime API / 智能体故障排查指南:语音模型、浏览器会话与权限问题全流程解决方案
人工智能·chatgpt
agicall.com1 小时前
信电助 - 信创坐席盒 UB-B-XC 型号功能列表
人工智能·语音识别·信创电话助手·座机语音转文字·固话录音转文字
ouliten1 小时前
C++笔记:Lambda表达式
c++·笔记