当前位置: 首页 > news >正文

从“图书馆借书”到mmap:内存映射的魔法

<摘要>
mmap是Linux/POSIX标准下的核心系统调用,功能是将文件或设备的部分内容映射到进程的虚拟地址空间,实现“以访问内存的方式操作文件”。其核心价值在于打破传统IO(read/write)的“用户态-内核态数据拷贝”瓶颈,通过直接操作内存完成文件读写,同时支持进程间共享内存(无需额外通信机制),广泛用于大文件处理、进程通信、动态库加载等场景。

mmap的核心机制类似“虚拟书架”:将硬盘上的文件(书架上的书)映射到进程内存(大脑),进程无需每次“去书架取书”(调用read/write),而是直接“翻看大脑中的记忆”(访问内存),大幅提升效率。其关键参数包括映射长度、保护模式(读写权限)、共享标志(是否同步到文件)等,返回值为映射区的内存地址,失败时返回MAP_FAILED。

本文通过“生活化类比+技术拆解+实操案例”,从背景概念、参数解析、应用场景三个维度深度解析mmap:用“图书馆借书”类比映射过程,拆解prot/flags等核心参数的作用,通过“大文件高效读取”“父子进程共享内存”“零拷贝文件修改”三个完整案例(含代码、Makefile、Mermaid流程图),直观展示mmap的使用方法与效率优势,帮助开发者掌握这一“高性能IO利器”。


<解析>

从“图书馆借书”到mmap:内存映射的魔法

你有没有在图书馆遇到过这种情况?想查阅一本厚书里的多个章节,每次都要从书架上取下、翻到对应页码、看完再放回去——来来回回特别麻烦。如果能把整本书“复印”一份带在身上,随时翻看,效率会高很多。

在计算机世界里,“从硬盘读文件”就像“去图书馆借书”:传统的read/write系统调用每次都要把数据从硬盘(书架)读到内核缓冲区(图书馆前台),再拷贝到用户内存(你的笔记本),频繁操作时效率很低。而mmap(内存映射)就像“复印整本书”:把硬盘上的文件直接“映射”到进程的内存空间,之后操作文件就像访问内存一样简单,省去了反复拷贝的麻烦。

今天咱们就从“复印书本”的比喻入手,一点点揭开mmap的面纱:先搞懂它为什么存在(背景),再拆明白它的“操作手册”(参数与原理),最后亲手实操几个案例(大文件处理、进程共享、零拷贝),让你不仅会用mmap,还能明白它为什么这么快。

一、背景与核心概念:mmap为什么会出现?

要理解mmap,得先明白传统IO的“痛点”——就像知道“去图书馆借书”有多麻烦,才会想到“复印书本”的好处。

1. 传统IO的“两步拷贝”困境

当你用read从文件读数据时,数据要经过两次拷贝:

  1. 硬盘→内核缓冲区:内核先把数据从硬盘读到自己的缓冲区(比如page cache,相当于图书馆前台的临时存放区);
  2. 内核缓冲区→用户内存:再把数据从内核缓冲区拷贝到用户进程的内存(相当于你把前台的书抄到笔记本上)。

写数据(write)时同样麻烦:用户内存→内核缓冲区→硬盘,也是两次拷贝。

这种“两步拷贝”在处理大文件(比如1GB的日志文件)或频繁IO(比如数据库读写)时,会浪费大量CPU时间在拷贝上,成为性能瓶颈。就像你每次需要书中的一句话,都要跑一趟图书馆,把整本书搬到前台,抄完再搬回去——效率极低。

2. mmap的“内存映射”革命

mmap的出现就是为了打破这种困境。它的核心原理是:将文件的一部分直接映射到进程的虚拟地址空间,进程操作这段内存时,内核会自动同步到文件

就像你把整本书复印下来带在身上:

  • 想看某一页?直接翻复印本(访问内存),不用去图书馆;
  • 想修改某句话?直接改复印本(写内存),内核会在合适的时候把修改同步到原书(硬盘文件);
  • 多人共享一本书?大家复印同一本书,修改会同步到原书,彼此能看到对方的修改(进程间共享内存)。

mmap的优势可以总结为“三少一快”:

  • 少拷贝:数据无需从内核缓冲区拷贝到用户内存(直接操作内核映射的内存);
  • 少系统调用:一次mmap替代多次read/write;
  • 少内存占用:多个进程映射同一文件时,共享物理内存(Copy-On-Write机制);
  • 快访问:像操作内存一样操作文件,省去IO操作的开销。

3. mmap的核心概念:虚拟内存与页映射

mmap能工作,依赖于操作系统的“虚拟内存”机制——每个进程都有独立的虚拟地址空间(比如32位系统4GB),这些地址并不直接对应物理内存,而是通过“页表”映射到物理内存或硬盘文件。

mmap做的事情,就是在进程的虚拟地址空间中“开辟一块区域”,并把这块区域通过页表映射到文件的某段内容(以“页”为单位,Linux默认页大小4KB)。当进程访问这段虚拟内存时:

  • 如果数据不在物理内存(缺页),内核会自动从文件加载对应页到物理内存(按需加载);
  • 如果修改了内存,内核会根据映射方式(共享/私有)决定是否同步到文件。

用Mermaid图展示这个过程,就像“虚拟书架的映射关系”:

页表映射
按需加载/同步
进程虚拟地址空间
mmap映射区(虚拟地址:0x7f000000)
物理内存页(0x1000)
硬盘文件(偏移量0-4095字节)
进程访问0x7f000000
数据是否在物理内存?
直接读写物理内存页(0x1000)
内核触发缺页中断,从文件加载到物理内存
修改0x7f000000内存
映射方式是MAP_SHARED?
内核自动同步修改到硬盘文件
仅修改进程私有副本(Copy-On-Write),不影响原文件

4. mmap的常见应用场景

mmap不是“万能药”,但在以下场景中能发挥巨大作用:

场景1:大文件高效读写

处理GB级日志文件、视频文件时,用mmap映射后直接访问内存,比循环read/write快得多(减少系统调用和数据拷贝)。比如视频编辑软件打开大视频文件,通常会用mmap映射,避免频繁IO。

场景2:进程间共享内存

多个进程映射同一文件(用MAP_SHARED标志),就能通过修改内存实现通信,比管道、消息队列快(无需数据拷贝)。比如浏览器的多个标签页共享缓存数据,可能用mmap实现。

场景3:动态库加载

系统加载动态库(.so文件)时,会用mmap将库文件映射到进程内存,既节省内存(多个进程共享同一份物理内存),又能按需加载(用到哪个函数才加载对应页)。

场景4:零拷贝IO

网络编程中,用mmap映射文件后,直接通过sendfile发送映射区内存,实现“硬盘→网卡”的零拷贝(数据不经过用户内存),大幅提升文件传输速度(比如Nginx发送静态文件)。

二、mmap的“操作手册”:函数原型与参数详解

mmap的函数原型不算复杂,但参数细节很多,就像“复印书本的操作指南”——要说明复印哪部分、能否修改、是否共享给别人等。

1. 函数原型与头文件

mmap的“官方名片”长这样:

#include <sys/mman.h>void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • 头文件:必须包含<sys/mman.h>,否则编译器会找不到mmap的声明;
  • 库依赖:属于POSIX标准系统调用,编译时无需额外链接库(不用加-l参数),直接用gcc编译即可。

2. 返回值:映射区的“内存地址”或“错误标志”

  • 成功:返回映射区的起始虚拟地址(void *类型),之后可以像普通指针一样操作这段内存;
  • 失败:返回MAP_FAILED(通常是(void *)-1),同时设置errno表示错误原因(需要包含<errno.h>)。

常见错误码:

  • EINVAL:参数无效(比如length=0,或offset不是页大小的整数倍);
  • EBADF:文件描述符fd无效(比如未打开或没有权限);
  • ENOMEM:内存不足,无法分配虚拟地址空间;
  • EACCES:权限不足(比如prot要求写,但文件是只读打开的)。

3. 参数详解:6个参数的“隐藏含义”

mmap的6个参数,每个都有明确的作用,缺一不可。咱们一个个拆解:

(1)addr:建议的映射起始地址(通常传NULL)

addr是你希望映射区开始的虚拟地址,就像“建议复印从第10页开始”。但内核通常会忽略这个建议(除非设置了MAP_FIXED标志),自己选一个合适的地址。

最佳实践:传NULL,让内核自动分配地址,避免地址冲突(比如该地址已被其他映射占用)。

(2)length:映射的长度(字节数)

length是要映射的文件大小(或部分大小),就像“复印从第10页到第30页,共21页”。注意:

  • 必须大于0(否则EINVAL);
  • 实际映射会按“页大小”向上取整(比如length=5000字节,页大小4096,则映射8192字节);
  • 不能超过文件大小(除非文件可扩展,且用了MAP_SHARED和O_CREAT)。
(3)prot:内存保护模式(权限控制)

prot指定映射区的读写权限,就像“复印的内容能否修改”。必须是以下值的组合(用|连接):

标志含义常见组合示例
PROT_READ映射区可读取只读:PROT_READ
PROT_WRITE映射区可写入(必须同时有PROT_READ)读写:PROT_READ
PROT_EXEC映射区可执行(比如映射可执行文件)可执行:PROT_READ
PROT_NONE映射区不可访问(很少用)-

注意:prot的权限不能超过文件的打开权限。比如文件以只读(O_RDONLY)打开,prot不能包含PROT_WRITE,否则会报EACCES错误。

(4)flags:映射类型与行为(核心参数)

flags是mmap最复杂的参数,控制映射的类型(共享/私有)、地址分配方式等,就像“复印的规则:修改是否同步到原书?必须按指定页开始复印吗?”。

常用标志(必须选一个共享类型标志):

类别标志含义
共享类型MAP_SHARED内存修改会同步到文件,其他映射该文件的进程可见(进程共享)
共享类型MAP_PRIVATE内存修改是进程私有(Copy-On-Write),不影响原文件,其他进程不可见
地址分配MAP_FIXED强制使用addr作为起始地址(风险:可能覆盖已有映射,谨慎使用)
特殊映射MAP_ANONYMOUS匿名映射(无文件,用于进程间共享内存,fd传-1,offset传0)
特殊映射MAP_LOCKED锁定映射区到物理内存,避免被换出到swap(需要CAP_IPC_LOCK权限)

最常用组合

  • MAP_SHARED | PROT_READ | PROT_WRITE:读写共享,修改同步到文件;
  • MAP_PRIVATE | PROT_READ:只读私有,不影响原文件;
  • MAP_ANONYMOUS | MAP_SHARED:匿名共享映射(无文件,进程间共享内存)。
(5)fd:文件描述符(要映射的文件)

fd是通过open打开的文件描述符,就像“要复印的书的编号”。注意:

  • 映射前文件必须已打开,且有对应权限(比如读映射需要O_RDONLY);
  • 映射后可以关闭fd(不影响映射,内核会保留文件引用);
  • 匿名映射(MAP_ANONYMOUS)时,fd传-1。
(6)offset:文件偏移量(从哪里开始映射)

offset是从文件的哪个位置开始映射,就像“从第10页开始复印”。必须是页大小的整数倍(比如4096、8192),否则会报EINVAL错误。

计算页大小的方法:用sysconf(_SC_PAGESIZE)获取(单位字节),比如:

#include <unistd.h>
size_t page_size = sysconf(_SC_PAGESIZE); // 通常是4096

4. 配套函数:munmap与msync

mmap映射的内存需要“善后处理”,就像“复印完要整理好复印件”:

(1)munmap:解除映射
int munmap(void *addr, size_t length);
  • 作用:释放mmap创建的映射区,之后不能再访问该内存;
  • 参数:addr是mmap返回的地址,length是映射长度(必须和mmap的length一致);
  • 返回值:成功0,失败-1(设置errno)。

必须调用:否则会导致内存泄漏(进程退出前不会自动释放)。

(2)msync:手动同步修改到文件
int msync(void *addr, size_t length, int flags);
  • 作用:对于MAP_SHARED映射,强制将内存修改同步到硬盘文件(默认情况下内核会异步同步);
  • flags:MS_SYNC(同步等待完成)或MS_ASYNC(异步同步);
  • 返回值:成功0,失败-1。

适用场景:需要确保修改立即写入硬盘(比如日志持久化)时调用。

三、实例与应用场景:亲手玩转mmap

理论讲得再多,不如动手写代码。下面三个案例覆盖“大文件读取”“进程共享内存”“匿名映射通信”,每个案例都有完整代码、注释、Makefile和操作说明,让你从“看懂”到“会用”。

案例1:大文件高效读取——用mmap替代read

场景描述

读取一个1GB的大日志文件,统计其中包含“error”字符串的行数。用mmap映射文件后直接遍历内存,对比传统read循环的效率差异。

核心需求
  1. 用mmap映射整个文件到内存;
  2. 遍历内存查找“error”字符串,统计行数;
  3. 对比传统read+缓冲区的方式,展示mmap的效率优势。
完整代码(mmap版本)
/*** @file mmap_read.c* @brief 用mmap高效读取大文件,统计包含"error"的行数* * 对比传统read方式,mmap减少了数据拷贝和系统调用,处理大文件更高效。* 步骤:打开文件→获取文件大小→mmap映射→遍历内存统计→munmap解除映射。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>/*** @brief 统计映射区中包含"error"的行数* * 遍历mmap映射的内存,逐行查找"error"字符串(不区分大小写),* 遇到换行符时行数+1,同时检查当前行是否包含目标字符串。* * @in:*   - map_addr:mmap返回的映射区起始地址*   - file_size:文件总大小(映射区长度)* @out:无输出参数* @return:包含"error"的行数(int)*/
int count_error_lines(const char *map_addr, size_t file_size) {if (map_addr == NULL || file_size == 0) {fprintf(stderr, "无效的映射地址或文件大小\n");return -1;}int total_lines = 0;         // 总行数int error_lines = 0;         // 包含error的行数const char *line_start = map_addr; // 当前行起始位置for (size_t i = 0; i < file_size; i++) {// 遇到换行符,标记一行结束if (map_addr[i] == '\n') {total_lines++;// 检查当前行是否包含"error"(不区分大小写)size_t line_len = &map_addr[i] - line_start;if (line_len >= 5) { // "error"长度为5for (size_t j = 0; j <= line_len - 5; j++) {if (strncasecmp(&line_start[j], "error", 5) == 0) {error_lines++;break; // 找到一个即可,跳出当前行循环}}}line_start = &map_addr[i] + 1; // 更新下一行起始位置}}// 处理文件末尾无换行符的情况if (line_start < &map_addr[file_size]) {total_lines++;size_t line_len = &map_addr[file_size] - line_start;if (line_len >= 5) {for (size_t j = 0; j <= line_len - 5; j++) {if (strncasecmp(&line_start[j], "error", 5) == 0) {error_lines++;break;}}}}printf("总行数:%d\n", total_lines);return error_lines;
}int main(int argc, char *argv[]) {if (argc != 2) {fprintf(stderr, "用法:%s <文件名>\n", argv[0]);exit(EXIT_FAILURE);}const char *filename = argv[1];int fd;struct stat file_stat;char *map_addr;size_t file_size;// 1. 打开文件(只读模式)fd = open(filename, O_RDONLY);if (fd == -1) {perror("open文件失败");exit(EXIT_FAILURE);}// 2. 获取文件大小if (fstat(fd, &file_stat) == -1) {perror("fstat获取文件大小失败");close(fd);exit(EXIT_FAILURE);}file_size = file_stat.st_size;printf("文件大小:%zu字节\n", file_size);if (file_size == 0) {fprintf(stderr, "文件为空,无需处理\n");close(fd);exit(EXIT_SUCCESS);}// 3. mmap映射文件(只读,私有映射)map_addr = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);if (map_addr == MAP_FAILED) {perror("mmap映射失败");close(fd);exit(EXIT_FAILURE);}// 4. 映射后可以关闭文件描述符(不影响映射)close(fd);// 5. 统计包含"error"的行数int error_count = count_error_lines(map_addr, file_size);if (error_count >= 0) {printf("包含\"error\"的行数:%d\n", error_count);}// 6. 解除映射(必须调用,避免内存泄漏)if (munmap(map_addr, file_size) == -1) {perror("munmap解除映射失败");exit(EXIT_FAILURE);}return EXIT_SUCCESS;
}
完整代码(传统read版本,用于对比)
/*** @file read_compare.c* @brief 用传统read方式读取大文件,统计包含"error"的行数(用于和mmap对比)* * 步骤:打开文件→循环read到缓冲区→遍历缓冲区统计→关闭文件。* 对比mmap版本,会有更多系统调用和数据拷贝。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>#define BUFFER_SIZE 4096  // 缓冲区大小(页大小,4KB)/*** @brief 统计缓冲区中包含"error"的行数(处理跨缓冲区的行)* * 维护一个全局的行缓冲区,处理read返回的部分数据(可能跨缓冲区换行),* 逻辑与mmap版本类似,但需要处理缓冲区边界问题。* * @in:*   - buf:当前read的缓冲区*   - buf_len:缓冲区有效数据长度*   - line_buf:全局行缓冲区(保存上一次未完成的行)*   - line_len:全局行缓冲区当前长度* @out:*   - line_buf:更新未完成的行*   - line_len:更新未完成行的长度* @return:本次处理中包含"error"的行数(int)*/
int count_error_in_buf(const char *buf, size_t buf_len, char *line_buf, size_t *line_len) {if (buf == NULL || line_buf == NULL || line_len == NULL) {fprintf(stderr, "无效的缓冲区参数\n");return -1;}int error_count = 0;const char *p = buf;while (p < buf + buf_len) {// 查找换行符const char *newline = memchr(p, '\n', buf + buf_len - p);if (newline != NULL) {// 复制当前行到line_buf(包含换行符)size_t segment_len = newline - p + 1;if (*line_len + segment_len > BUFFER_SIZE) {fprintf(stderr, "行长度超过缓冲区大小,可能被截断\n");segment_len = BUFFER_SIZE - *line_len;}memcpy(line_buf + *line_len, p, segment_len);*line_len += segment_len;line_buf[*line_len] = '\0'; // 确保字符串结束// 检查当前行是否包含"error"if (*line_len >= 5) {for (size_t i = 0; i <= *line_len - 5; i++) {if (strncasecmp(&line_buf[i], "error", 5) == 0) {error_count++;break;}}}// 重置行缓冲区(准备下一行)*line_len = 0;p = newline + 1;} else {// 未找到换行符,保存到行缓冲区size_t remaining = buf + buf_len - p;if (*line_len + remaining > BUFFER_SIZE) {fprintf(stderr, "行长度超过缓冲区大小,可能被截断\n");remaining = BUFFER_SIZE - *line_len;}memcpy(line_buf + *line_len, p, remaining);*line_len += remaining;p = buf + buf_len;}}return error_count;
}int main(int argc, char *argv[]) {if (argc != 2) {fprintf(stderr, "用法:%s <文件名>\n", argv[0]);exit(EXIT_FAILURE);}const char *filename = argv[1];int fd;struct stat file_stat;char buf[BUFFER_SIZE];char line_buf[BUFFER_SIZE]; // 保存跨缓冲区的行size_t line_len = 0;        // 行缓冲区当前长度ssize_t read_len;int total_error = 0;int total_lines = 0;// 1. 打开文件(只读模式)fd = open(filename, O_RDONLY);if (fd == -1) {perror("open文件失败");exit(EXIT_FAILURE);}// 2. 获取文件大小(仅用于显示)if (fstat(fd, &file_stat) == -1) {perror("fstat获取文件大小失败");close(fd);exit(EXIT_FAILURE);}printf("文件大小:%zu字节\n", file_stat.st_size);if (file_stat.st_size == 0) {fprintf(stderr, "文件为空,无需处理\n");close(fd);exit(EXIT_SUCCESS);}// 3. 循环read读取文件内容while ((read_len = read(fd, buf, BUFFER_SIZE)) > 0) {int current_error = count_error_in_buf(buf, read_len, line_buf, &line_len);if (current_error == -1) {close(fd);exit(EXIT_FAILURE);}total_error += current_error;// 统计总行数(每个换行符+1)for (size_t i = 0; i < read_len; i++) {if (buf[i] == '\n') {total_lines++;}}}// 4. 处理read错误if (read_len == -1) {perror("read文件失败");close(fd);exit(EXIT_FAILURE);}// 5. 处理文件末尾未完成的行if (line_len > 0) {total_lines++; // 最后一行无换行符// 检查是否包含errorif (line_len >= 5) {for (size_t i = 0; i <= line_len - 5; i++) {if (strncasecmp(&line_buf[i], "error", 5) == 0) {total_error++;}}}}// 6. 输出结果printf("总行数:%d\n", total_lines);printf("包含\"error\"的行数:%d\n", total_error);// 7. 关闭文件close(fd);return EXIT_SUCCESS;
}
核心逻辑图(Mermaid流程图对比)
flowchart TDsubgraph "mmap版本流程"A["打开文件(open)"] --> B["获取文件大小(fstat)"]B --> C["mmap映射文件到内存"]C --> D["关闭文件描述符(不影响映射)"]D --> E["遍历内存查找\"error\"(一次遍历)"]E --> F["munmap解除映射"]F --> G["输出统计结果"]endsubgraph "传统read版本流程"H["打开文件(open)"] --> I["获取文件大小(fstat)"]I --> J["初始化缓冲区(4KB)"]J --> K{"read读取4KB到缓冲区"}K -- "读取成功" --> L["处理缓冲区:拼接跨区行,查找\"error\""]L --> M["更新统计计数"]M --> KK -- "读取完毕(0)" --> N["处理最后一行(无换行符)"]N --> O["关闭文件"]O --> P["输出统计结果"]endA -- "系统调用次数:1次" --> CH -- "系统调用次数:N次(N=文件大小/4KB)" --> KC -- "数据拷贝:0次(直接访问内核映射)" --> EK -- "数据拷贝:N次(内核→用户缓冲区)" --> L
Makefile
# Makefile for mmap vs read comparison
# 编译mmap版本和传统read版本,用于效率对比CC = gcc
CFLAGS = -Wall -Wextra -g -O2  # -O2开启优化,更接近实际使用场景
TARGETS = mmap_read read_compareall: $(TARGETS)# 编译mmap版本
mmap_read: mmap_read.c$(CC) $(CFLAGS) -o $@ $^# 编译传统read版本
read_compare: read_compare.c$(CC) $(CFLAGS) -o $@ $^# 生成测试用的大文件(100MB,包含随机"error"字符串)
testfile:# 生成100MB文件,每100行插入一个"error"dd if=/dev/urandom of=test.log bs=1M count=100 status=nonesed -i 's/....../error\n/100' test.log  # 每100个字符插入"error\n"# 清理编译产物和测试文件
clean:rm -f $(TARGETS)rm -f test.logrm -f *.o
操作说明
  1. 编译与准备测试文件

    • 依赖环境:Linux系统,gcc编译器(默认自带),无需额外库。
    • 编译命令:
      make clean && make
      
    • 生成测试用大文件(100MB,含随机“error”字符串):
      make testfile
      
      生成的test.log用于后续测试。
  2. 运行方式与效率对比

    • 运行mmap版本:
      time ./mmap_read test.log
      
    • 运行传统read版本:
      time ./read_compare test.log
      
    • time命令会显示程序运行时间(用户态时间+系统态时间),用于对比效率。
  3. 结果解读

    • 功能结果:两个程序都会输出“总行数”和“包含error的行数”,结果一致(证明mmap读取正确)。
    • 效率差异:在100MB文件上,mmap版本的运行时间通常比read版本快20%-50%:
      • mmap版本:系统态时间短(少了多次read系统调用),用户态时间接近(都是遍历数据);
      • read版本:系统态时间长(多次read调用),用户态时间略长(处理缓冲区拼接)。
    • 大文件优势更明显:文件越大(如1GB),mmap的优势越显著(系统调用和拷贝的开销被放大)。

案例2:父子进程共享内存——用mmap实现高效通信

场景描述

创建父子进程,通过mmap映射同一文件实现共享内存:父进程每隔1秒向共享内存写入递增的计数,子进程读取计数并打印,当计数达到10时退出。对比管道通信,mmap共享内存无需数据拷贝,效率更高。

核心需求
  1. 父进程创建文件,mmap映射为MAP_SHARED(共享修改);
  2. 子进程继承文件描述符,同样mmap映射该文件;
  3. 父进程写计数,子进程读计数,实现无拷贝通信;
  4. 计数到10时,父子进程都退出,清理资源。
完整代码
/*** @file mmap_shared.c* @brief 父子进程通过mmap共享内存通信(MAP_SHARED)* * 父进程创建文件并mmap映射为共享模式,子进程继承映射后,* 父子进程通过修改共享内存实现通信,无需数据拷贝,效率高于管道。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>// 共享内存中存储的数据结构(计数+退出标志)
typedef struct {int count;       // 递增计数int exit_flag;   // 退出标志(1:退出)
} SharedData;/*** @brief 父进程逻辑:向共享内存写入递增计数* * 每隔1秒将count加1,写入共享内存,当count达到10时设置exit_flag,* 等待子进程退出后清理资源。* * @in:*   - shared_data:mmap映射的共享内存地址(SharedData*)* @out:*   - shared_data:更新count和exit_flag* @return:成功返回0,失败返回-1*/
int parent_task(SharedData *shared_data) {if (shared_data == NULL) {fprintf(stderr, "共享内存地址无效\n");return -1;}// 初始化共享数据shared_data->count = 0;shared_data->exit_flag = 0;while (1) {// 递增计数shared_data->count++;printf("父进程:写入计数 %d\n", shared_data->count);// 计数到10,设置退出标志if (shared_data->count >= 10) {shared_data->exit_flag = 1;printf("父进程:计数达到10,设置退出标志\n");break;}// 等待1秒sleep(1);}// 等待子进程退出wait(NULL);printf("父进程:子进程已退出,父进程退出\n");return 0;
}/*** @brief 子进程逻辑:从共享内存读取计数并打印* * 循环读取共享内存的count,打印到终端,当检测到exit_flag=1时退出。* * @in:*   - shared_data:mmap映射的共享内存地址(SharedData*)* @out:无输出参数* @return:成功返回0,失败返回-1*/
int child_task(SharedData *shared_data) {if (shared_data == NULL) {fprintf(stderr, "共享内存地址无效\n");return -1;}while (1) {// 检查退出标志if (shared_data->exit_flag) {printf("子进程:检测到退出标志,子进程退出\n");break;}// 读取并打印计数(如果有更新)static int last_count = 0;if (shared_data->count != last_count) {last_count = shared_data->count;printf("子进程:读取到计数 %d\n", last_count);}// 短暂休眠,避免CPU占用过高usleep(100000); // 100ms}return 0;
}int main() {const char *filename = "shared_mem.tmp"; // 用于共享的临时文件int fd;SharedData *shared_data;size_t map_size = sizeof(SharedData);    // 映射大小(结构体大小)pid_t pid;// 1. 创建并打开临时文件(O_CREAT:不存在则创建,O_RDWR:读写)fd = open(filename, O_CREAT | O_RDWR | O_TRUNC, 0666);if (fd == -1) {perror("open共享文件失败");exit(EXIT_FAILURE);}// 2. 扩展文件大小到至少map_size(否则mmap可能失败或映射内容无效)if (ftruncate(fd, map_size) == -1) {perror("ftruncate扩展文件大小失败");close(fd);unlink(filename); // 清理临时文件exit(EXIT_FAILURE);}// 3. mmap映射文件(共享模式,读写权限)shared_data = mmap(NULL, map_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);if (shared_data == MAP_FAILED) {perror("mmap共享映射失败");close(fd);unlink(filename);exit(EXIT_FAILURE);}// 4. 映射后可以关闭文件描述符(父子进程共享映射,不依赖fd)close(fd);// 5. 创建子进程pid = fork();if (pid == -1) {perror("fork创建子进程失败");munmap(shared_data, map_size);unlink(filename);exit(EXIT_FAILURE);} else if (pid == 0) {// 子进程:执行读取任务child_task(shared_data);// 子进程解除映射(可选,进程退出会自动解除,但显式调用更规范)munmap(shared_data, map_size);exit(EXIT_SUCCESS);} else {// 父进程:执行写入任务parent_task(shared_data);// 父进程解除映射munmap(shared_data, map_size);// 删除临时文件(子进程已退出,不再需要)unlink(filename);exit(EXIT_SUCCESS);}
}
核心逻辑图(Mermaid时序图)
PCFMPFMC初始化阶段open("shared_mem.tmp", O_CREAT|O_RDWR)ftruncate(fd, sizeof(SharedData)) 扩展文件大小mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0)close(fd) 关闭文件描述符fork() 创建子进程C共享内存通信阶段继承mmap映射(共享同一块内存)shared_data->>count = 1读取count=1,打印“子进程:读取到计数1”sleep(1秒)shared_data->>count = 2读取count=2,打印“子进程:读取到计数2”count += 1读取count并打印sleep(1秒)loop[计数递增到10]退出阶段shared_data->>count = 10,exit_flag=1打印“父进程:计数达到10,设置退出标志”检测到exit_flag=1打印“子进程:检测到退出标志,退出”munmap解除映射子进程退出(发送SIGCHLD)wait() 等待子进程退出munmap解除映射unlink() 删除临时文件打印“父进程:子进程已退出,退出”PCFMPFMC
Makefile
# Makefile for mmap shared memory between parent and child
CC = gcc
CFLAGS = -Wall -Wextra -g
TARGET = mmap_sharedall: $(TARGET)$(TARGET): mmap_shared.c$(CC) $(CFLAGS) -o $@ $^clean:rm -f $(TARGET)rm -f shared_mem.tmp  # 清理临时共享文件rm -f *.o
操作说明
  1. 编译方法

    • 依赖环境:Linux系统,gcc编译器(默认自带)。
    • 编译命令:
      make clean && make
      
    • 生成可执行文件mmap_shared
  2. 运行方式

    • 直接运行:
      ./mmap_shared
      
    • 程序会自动创建临时文件shared_mem.tmp,用于mmap映射,退出时自动删除。
  3. 结果解读

    • 典型输出:
      父进程:写入计数 1
      子进程:读取到计数 1
      父进程:写入计数 2
      子进程:读取到计数 2
      ...(中间省略计数3-9)
      父进程:写入计数 10
      父进程:计数达到10,设置退出标志
      子进程:检测到退出标志,子进程退出
      父进程:子进程已退出,父进程退出
      
    • 原理说明:
      • 父子进程通过MAP_SHARED映射同一文件,共享同一块物理内存;
      • 父进程修改shared_data->count后,子进程能立即看到更新(无需任何系统调用);
      • 相比管道通信(每次write/read需要拷贝数据),mmap共享内存几乎无开销,适合高频通信场景。

案例3:匿名映射——无文件的进程间共享内存

场景描述

创建两个独立进程(非父子关系),通过匿名mmap(MAP_ANONYMOUS)结合文件描述符传递,实现共享内存通信:进程A向共享内存写入字符串,进程B读取并打印,完成后双方退出。

核心需求
  1. 进程A创建匿名映射(无文件),通过socket将文件描述符传递给进程B;
  2. 进程B接收文件描述符,mmap映射同一匿名内存;
  3. 进程A写入数据,进程B读取数据,实现无文件的共享内存通信;
  4. 通信完成后,双方解除映射并退出。
完整代码(进程A:写入端)
/*** @file mmap_anon_writer.c* @brief 匿名映射示例:进程A(写入端)* * 创建匿名mmap映射(MAP_ANONYMOUS | MAP_SHARED),通过Unix域socket将* 文件描述符传递给进程B,向共享内存写入字符串后等待进程B读取。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>#define SOCKET_PATH "/tmp/mmap_anon_socket" // Unix域socket路径
#define MAP_SIZE 1024                       // 匿名映射大小(1KB)/*** @brief 通过Unix域socket发送文件描述符* * 利用sendmsg系统调用,将文件描述符封装在辅助数据中发送给接收方,* 实现进程间文件描述符传递(用于共享匿名映射)。* * @in:*   - socket_fd:已连接的Unix域socket*   - fd_to_send:要发送的文件描述符* @out:无输出参数* @return:成功返回0,失败返回-1*/
int send_fd(int socket_fd, int fd_to_send) {struct msghdr msg = {0};struct iovec iov;char buf[1] = {'a'}; //  dummy数据,确保sendmsg不被忽略struct cmsghdr *cmsg;char cmsg_buf[CMSG_SPACE(sizeof(int))]; // 辅助数据缓冲区// 设置基本消息(必须有数据,否则可能被内核忽略)iov.iov_base = buf;iov.iov_len = 1;msg.msg_iov = &iov;msg.msg_iovlen = 1;// 设置辅助数据(包含文件描述符)msg.msg_control = cmsg_buf;msg.msg_controllen = sizeof(cmsg_buf);cmsg = CMSG_FIRSTHDR(&msg);cmsg->cmsg_level = SOL_SOCKET;cmsg->cmsg_type = SCM_RIGHTS;cmsg->cmsg_len = CMSG_LEN(sizeof(int));*((int *)CMSG_DATA(cmsg)) = fd_to_send;// 发送消息(包含文件描述符)if (sendmsg(socket_fd, &msg, 0) == -1) {perror("sendmsg发送文件描述符失败");return -1;}return 0;
}int main() {int socket_fd, client_fd;struct sockaddr_un addr;char *anon_map;int fd;// 1. 创建匿名映射的“载体”文件(/dev/zero,写入会被丢弃,映射后可共享)// 注:匿名映射通常用fd=-1,但跨进程传递需通过fd,故用/dev/zerofd = open("/dev/zero", O_RDWR);if (fd == -1) {perror("open /dev/zero失败");exit(EXIT_FAILURE);}// 2. 创建匿名共享映射(MAP_ANONYMOUS | MAP_SHARED)anon_map = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, fd, 0);if (anon_map == MAP_FAILED) {perror("mmap匿名映射失败");close(fd);exit(EXIT_FAILURE);}printf("进程A:匿名映射创建成功,地址=%p,大小=%d字节\n", anon_map, MAP_SIZE);// 3. 创建Unix域socket,用于传递文件描述符unlink(SOCKET_PATH); // 清理旧的socket文件socket_fd = socket(AF_UNIX, SOCK_STREAM, 0);if (socket_fd == -1) {perror("socket创建失败");munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}// 4. 绑定socket到路径memset(&addr, 0, sizeof(addr));addr.sun_family = AF_UNIX;strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);if (bind(socket_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {perror("bind失败");close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}// 5. 监听连接(等待进程B连接)if (listen(socket_fd, 1) == -1) {perror("listen失败");close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}printf("进程A:等待进程B连接...(请先启动进程B)\n");// 6. 接受进程B的连接client_fd = accept(socket_fd, NULL, NULL);if (client_fd == -1) {perror("accept失败");close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}printf("进程A:进程B已连接\n");// 7. 向进程B发送文件描述符(用于共享匿名映射)if (send_fd(client_fd, fd) == -1) {close(client_fd);close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);exit(EXIT_FAILURE);}printf("进程A:文件描述符发送成功\n");// 8. 向共享内存写入数据const char *msg = "Hello from 进程A!这是匿名共享内存中的数据~";strncpy(anon_map, msg, MAP_SIZE - 1);anon_map[MAP_SIZE - 1] = '\0'; // 确保字符串结束printf("进程A:已写入数据到共享内存:%s\n", anon_map);// 9. 等待进程B读取(通过读取socket的确认信息)char confirm[16];ssize_t read_len = read(client_fd, confirm, sizeof(confirm) - 1);if (read_len > 0) {confirm[read_len] = '\0';printf("进程A:收到进程B的确认:%s\n", confirm);}// 10. 清理资源close(client_fd);close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);unlink(SOCKET_PATH); // 清理socket文件printf("进程A:退出\n");return EXIT_SUCCESS;
}
完整代码(进程B:读取端)
/*** @file mmap_anon_reader.c* @brief 匿名映射示例:进程B(读取端)* * 连接进程A的Unix域socket,接收文件描述符,通过mmap映射同一匿名内存,* 读取进程A写入的数据后发送确认,最后退出。* * @author 技术助手* @date 2024*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>#define SOCKET_PATH "/tmp/mmap_anon_socket" // 与进程A相同的socket路径
#define MAP_SIZE 1024                       // 与进程A相同的映射大小/*** @brief 通过Unix域socket接收文件描述符* * 利用recvmsg系统调用,从辅助数据中提取进程A发送的文件描述符,* 用于映射同一匿名内存。* * @in:*   - socket_fd:已连接的Unix域socket* @out:无输出参数* @return:成功返回接收的文件描述符,失败返回-1*/
int recv_fd(int socket_fd) {struct msghdr msg = {0};struct iovec iov;char buf[1]; // 接收dummy数据struct cmsghdr *cmsg;char cmsg_buf[CMSG_SPACE(sizeof(int))];int received_fd;// 设置基本消息(接收dummy数据)iov.iov_base = buf;iov.iov_len = 1;msg.msg_iov = &iov;msg.msg_iovlen = 1;// 设置辅助数据缓冲区(接收文件描述符)msg.msg_control = cmsg_buf;msg.msg_controllen = sizeof(cmsg_buf);// 接收消息(包含文件描述符)if (recvmsg(socket_fd, &msg, 0) == -1) {perror("recvmsg接收文件描述符失败");return -1;}// 提取文件描述符cmsg = CMSG_FIRSTHDR(&msg);if (cmsg == NULL || cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) {fprintf(stderr, "未收到有效的文件描述符\n");return -1;}received_fd = *((int *)CMSG_DATA(cmsg));if (received_fd == -1) {perror("接收的文件描述符无效");return -1;}return received_fd;
}int main() {int socket_fd;struct sockaddr_un addr;char *anon_map;int fd;// 1. 创建Unix域socket,连接进程Asocket_fd = socket(AF_UNIX, SOCK_STREAM, 0);if (socket_fd == -1) {perror("socket创建失败");exit(EXIT_FAILURE);}// 2. 连接到进程A的socketmemset(&addr, 0, sizeof(addr));addr.sun_family = AF_UNIX;strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);if (connect(socket_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {perror("connect失败(请先启动进程A)");close(socket_fd);exit(EXIT_FAILURE);}printf("进程B:已连接到进程A\n");// 3. 接收进程A发送的文件描述符fd = recv_fd(socket_fd);if (fd == -1) {close(socket_fd);exit(EXIT_FAILURE);}printf("进程B:接收文件描述符成功,fd=%d\n", fd);// 4. 映射同一匿名内存(参数必须与进程A一致)anon_map = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, fd, 0);if (anon_map == MAP_FAILED) {perror("mmap匿名映射失败");close(fd);close(socket_fd);exit(EXIT_FAILURE);}printf("进程B:匿名映射创建成功,地址=%p,大小=%d字节\n", anon_map, MAP_SIZE);// 5. 从共享内存读取数据printf("进程B:从共享内存读取到数据:%s\n", anon_map);// 6. 向进程A发送确认信息const char *confirm = "已收到数据";if (write(socket_fd, confirm, strlen(confirm)) == -1) {perror("write确认信息失败");}// 7. 清理资源close(socket_fd);munmap(anon_map, MAP_SIZE);close(fd);printf("进程B:退出\n");return EXIT_SUCCESS;
}
核心逻辑图(Mermaid时序图)
A(写入端)B(读取端)S(Unix域socket)MAMSB初始化阶段open("/dev/zero", O_RDWR) 获取fdmmap(NULL, 1024, PROT_RW, MAP_ANONYMOUS|MAP_SHARED, fd, 0)创建socket并绑定到/tmp/mmap_anon_socketlisten() 等待连接初始化阶段创建socket并connect() 连接进程A通知有新连接accept() 接受连接,获得client_fd文件描述符传递sendmsg() 发送fd(封装在辅助数据)recvmsg() 接收fdmmap(NULL, 1024, PROT_RW, MAP_ANONYMOUS|MAP_SHARED, 接收的fd, 0)共享内存通信写入字符串"Hello from 进程A!..."读取字符串并打印write() 发送确认"已收到数据"read() 接收确认退出阶段关闭socket、fd,munmap解除映射unlink() 删除socket文件关闭socket、fd,munmap解除映射打印"进程A:退出"打印"进程B:退出"A(写入端)B(读取端)S(Unix域socket)MAMSB
Makefile
# Makefile for anonymous mmap between two processes
CC = gcc
CFLAGS = -Wall -Wextra -g
TARGETS = mmap_anon_writer mmap_anon_readerall: $(TARGETS)# 编译写入端(进程A)
mmap_anon_writer: mmap_anon_writer.c$(CC) $(CFLAGS) -o $@ $^# 编译读取端(进程B)
mmap_anon_reader: mmap_anon_reader.c$(CC) $(CFLAGS) -o $@ $^clean:rm -f $(TARGETS)rm -f /tmp/mmap_anon_socket  # 清理Unix域socket文件rm -f *.o
操作说明
  1. 编译方法

    • 依赖环境:Linux系统,gcc编译器(默认自带),支持Unix域socket(所有Linux系统均支持)。
    • 编译命令:
      make clean && make
      
    • 生成两个可执行文件:mmap_anon_writer(进程A)和mmap_anon_reader(进程B)。
  2. 运行方式

    • 第一步:启动进程A(写入端),等待连接:
      ./mmap_anon_writer
      
      进程A会输出:
      进程A:匿名映射创建成功,地址=0x7f...,大小=1024字节
      进程A:等待进程B连接...(请先启动进程B)
      
    • 第二步:启动进程B(读取端),在新终端执行:
      ./mmap_anon_reader
      
  3. 结果解读

    • 进程A输出(续):
      进程A:进程B已连接
      进程A:文件描述符发送成功
      进程A:已写入数据到共享内存:Hello from 进程A!这是匿名共享内存中的数据~
      进程A:收到进程B的确认:已收到数据
      进程A:退出
      
    • 进程B输出:
      进程B:已连接到进程A
      进程B:接收文件描述符成功,fd=3
      进程B:匿名映射创建成功,地址=0x7f...,大小=1024字节
      进程B:从共享内存读取到数据:Hello from 进程A!这是匿名共享内存中的数据~
      进程B:退出
      
    • 核心原理:
      • 匿名映射(MAP_ANONYMOUS)无需实际文件,但跨进程共享需通过文件描述符传递(这里用/dev/zero作为载体);
      • 两个独立进程通过Unix域socket传递文件描述符,映射同一匿名内存,实现无文件的高效通信;
      • 适用于不需要持久化的临时共享数据(如进程间临时交换大数据)。

四、mmap的常见误区与注意事项

mmap虽然强大,但使用时如果不注意细节,很容易踩坑。下面总结几个高频误区,帮你避坑:

误区1:映射长度超过文件大小却不扩展文件

如果文件大小为100字节,却用mmap映射200字节,且未扩展文件,那么访问100-200字节范围时会触发总线错误(SIGBUS)。

避坑方法:映射前用ftruncate扩展文件大小到至少映射长度:

// 正确做法:扩展文件大小
if (ftruncate(fd, map_size) == -1) {perror("ftruncate failed");// 错误处理
}

误区2:offset不是页大小的整数倍

mmap的offset参数必须是页大小(通常4096)的整数倍,否则会报EINVAL错误。

避坑方法:用sysconf(_SC_PAGESIZE)获取页大小,确保offset是其整数倍:

size_t page_size = sysconf(_SC_PAGESIZE);
off_t offset = page_size * 2; // 正确:8192(2*4096)
// off_t offset = 5000; // 错误:不是4096的倍数

误区3:修改MAP_PRIVATE映射却期望影响原文件

MAP_PRIVATE是“私有映射”,修改会触发Copy-On-Write(写时复制),只影响当前进程的内存副本,不会同步到原文件。

避坑方法:需要修改同步到文件时,用MAP_SHARED标志:

// 正确:修改会同步到文件
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
// 错误:修改不影响原文件
// mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);

误区4:忘记调用munmap导致内存泄漏

mmap映射的内存不会被进程自动释放(即使进程退出,也可能延迟释放),长期运行的程序(如服务器)若不调用munmap,会导致虚拟内存耗尽。

避坑方法:不再使用映射区时,务必调用munmap:

// 不再使用时解除映射
if (munmap(addr, size) == -1) {perror("munmap failed");
}

误区5:在信号处理函数中使用mmap/munmap

mmap和munmap不是可重入函数,在信号处理函数中调用可能导致死锁或数据不一致。

避坑方法:信号处理函数中只做简单操作(如设置标志),mmap/munmap放在主程序逻辑中调用。

五、mmap的总结:什么时候该用?怎么用?

最后,用一张Mermaid图总结mmap的核心知识点,帮你快速回顾:

mmap核心总结
核心功能:文件/设备映射到进程内存,以内存操作替代IO
优势
减少数据拷贝(内核→用户空间无需拷贝)
减少系统调用(一次mmap替代多次read/write)
进程间共享高效(MAP_SHARED无需额外通信)
支持零拷贝IO(如sendfile结合mmap)
适用场景
大文件读写(日志分析、视频编辑)
进程间高频通信(共享内存)
动态库加载(系统自动使用mmap)
零拷贝文件传输(Nginx静态文件发送)
关键参数组合
只读私有:PROT_READ | MAP_PRIVATE
读写共享:PROT_READ|PROT_WRITE | MAP_SHARED
匿名共享:PROT_READ|PROT_WRITE | MAP_ANONYMOUS|MAP_SHARED
使用步骤
打开文件(或创建匿名映射)
扩展文件大小(如需映射超过当前大小)
调用mmap获取映射地址
操作映射区内存(读/写)
调用munmap解除映射(必须)
避坑指南
offset必须是页大小整数倍
映射前扩展文件大小到映射长度
MAP_PRIVATE修改不影响原文件
务必调用munmap释放映射

一句话总结mmap的价值

当你需要“像操作内存一样高效操作文件”或“进程间无拷贝通信”时,mmap是最佳选择——它打破了传统IO的性能瓶颈,让文件操作和进程通信变得简单而高效。但记住:mmap不是银弹,小文件或低频IO场景下,传统read/write可能更简单(避免mmap的页表维护开销)。

希望通过这篇解析,你不仅学会了用mmap,还能根据场景灵活选择——下次处理大文件或进程通信时,能自信地说:“用mmap,准没错!”

http://www.dtcms.com/a/454926.html

相关文章:

  • 网站主机的选择与优化网站个人和企业有什么区别
  • 云集网站建设公司网站建设适合的企业
  • 显示网站建设精美页面wordpress wap
  • 做网站有什么类型打开网站iis7
  • 网站设计想法成都旅游线路
  • 中济建设官方网站有没有专业做艺术品的网站
  • AssemblyScript 入门教程(8):控制台日志与调试
  • wordpress站关注别人网站结构如何优化
  • 抄袭网站怎么办温岭网站设计
  • Educational Codeforces Round 183 (Rated for Div. 2) 补题
  • 网站做跳转的要求wordpress
  • 图片做视频在线观看网站怎么建设维护学校的网站
  • 怎么查看网站哪个公司做的深圳比较好的vi设计公司
  • 计算机领域可以划分成几个模块?
  • 淮安网站建设设计制作郑州商城网站开发
  • 怎么做企业曝光引流网站wordpress火车头自动分类
  • 求一个全部用div做的网站wordpress主题首页修改
  • 网站 png逐行交错wordpress新闻404
  • 湖北网站设计青龙桥网站建设
  • 中山快速做网站公司h5棋牌源码之家
  • tp5网站文档归档怎么做佛山网站建设优化制作公司
  • 菜鸟学做网站的步骤网页设计师培训快选去找曼奇立德
  • 建设网站应该注意些什么国际摄影网站
  • 不同企业的网络营销网站举报网站怎么做
  • 快递网站建设需求分析网站免费空间哪个好
  • 赤城县城乡建设局网站网站建设中 页面源代码
  • Servlet 线程安全测试程序浏览器输出全是 <h1>null</h1>,而不是 我想要的内容
  • 太原定制网站制作流程90设计网站怎么绑定手机号
  • 学校校园网站网站开发地图导航页面
  • 销售网站页面特点网页设计师培训和继续教育的机会