本篇将介绍内存映射过程中的一些系统分页边界陷阱,可助你打通任督二脉,掌握内存映射基本的使用和出错处理。
1. 第一种情况
之前有说过在调用mmap函数创建内存映射时,建议指定参数length最好是系统内存分页的整数倍,虽然这并不是强制的,但这么做当然是有原因的,如果参数length恰好是系统内存分页的整数倍,那么内存映射区和文件映射区范围恰好是一样的。
我们来看一个mmap函数在映射时,length不是系统内存分页的整数倍的情况:调用mmap(NULL , 6000 , prot , MAP_SHARED , fd , 0); ,创建一个6000字节大小的内存映射区,我们来看看系统内核会怎么做
图1-length不是系统分页的整数倍
由于length不是系统内存分页的整数倍,那么在真实的映射过程中,系统内核会在内存创建8192大小的映射区(也就是说,系统内核会偷偷调整为内存分页的整数倍)。同理,被打开的文件实际上也会被映射成8192字节大小的真实映射区域。
也就是说,应用程序调用mmap希望创建一个6000字节的内存映射区,但实际上系统内核并没有这样做。应用程序依然可以访问6000 - 8191字节区域,且这个区域的修改操作也会反映到打开的文件中。
当程序试图访问内存映射区(0 - 8191)之外的字节区域时将会发生段错误并收到SIGSEGV信号,该信号的默认动作是终止进程并打印core dump,SIGSEGV信号表示访问的地址无效,即没有物理内存对应该地址,只要超过了映射区域就是非法访问。
2. 模拟收到SIGSEGV信号实验
模拟程序访问内存映射区之外的区域引发SIGSEGV信号。
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <errno.h>
#include <signal.h>
//信号处理函数
void sig_handler(int sig){
if(sig == SIGSEGV){
puts("catch SIGSEGV");
//模拟收到信号,然后进程退出
exit(-1);
}
}
int main(void){
char *addr;
int len = 0;
int ret;
int fd = open("test.txt", O_RDWR|O_CREAT, 0644);
if (fd < 0){
perror("open error");
}
//捕捉SIGSEGV信号
signal(SIGSEGV , sig_handler);
//拓展文件大小9500
lseek(fd , 9500 , SEEK_SET);
write(fd , "0" , 1);
//指向文件开头
lseek(fd , 0 , SEEK_SET);
//创建6000字节大小的共享映射区,可读写
addr =(char *)mmap(NULL , 6000 , PROT_WRITE|PROT_READ , MAP_SHARED , fd , 0);
if (addr == MAP_FAILED){
perror("mmap err: ");
}
close(fd);
//正常访问
strcpy(addr , "hello world");
printf("%s\n", addr);
//指针越界
int i;
for(i = 0; i < 8192; i++){
addr++;
}
//ddr指针指向超过了内存映射区,此时指针访问未被映射区域将会收到SIGSEGV信号
strcpy(addr , "ABCDEF");
//解除映射
/*
ret = munmap(addr , 1024);
if(ret < 0){
perror("munmap error: ");
}
*/
return 0;
}
程序执行结果:
收到SIGSEGV信号后,进程终止时打印了core dump。
3. 第二种情况
再来看一个更加复杂的情况,当内存映射区域超过了打开文件的大小:调用mmap(NULL , 8192, prot , MAP_SHARED , fd , 0); ,创建一个8192字节大小的内存映射区。
图2-内存映射超过了打开的文件大小
如上图所示,当调用mmap创建一个8192字节大小的内存映射区时(是系统分页的整数倍),系统会在内核中分配一个8192大小的缓冲区。由于打开的文件只有2200字节大小,不是系统分页的整数倍,这种情况下,在进行映射过程中系统内核会偷偷调整为系统分页的整数倍。
虽然内存映射区域被系统调整为4096大小,但实际上内存映射区只有0 - 2199区域被映射到文件,剩下的2200-4095区域依然可以访问,但是在该区域的修改操作将不会反映到打开的文件中,并且这段区域将会初始化为0,同时该区域不会对其他进程共享。
如果试图访问超过映射区域(4096-8191区域)将会收到SIGBUS信号并产生Bus error错误,SIGBUS信号表示指针对应的地址是有效的,但总线不能正常访问该指针指向的内存地址(对于总线,在这篇文章中有详细的介绍和说明:1-计算机和汇编语言的4.5小节:计算机中的总线)。因此创建一个超过打开文件大小的内存映射区可能会产生错误,但是可以通过扩展文件大小(例如lseek函数)使得映射之前不可访问的部分变得可用。
4. 第二种情况模拟实验
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <errno.h>
#include <signal.h>
//SIGSEGV信号
void sig_handler1(int sig){
if(sig == SIGSEGV){
puts("catch SIGSEGV");
//模拟收到信号,然后进程退出
exit(-1);
}
}
//SIGBUS信号
void sig_handler2(int sig){
if(sig == SIGBUS){
puts("catch SIGBUS");
exit(-1);
}
}
int main(void){
char *addr;
int len = 0;
int ret;
int fd = open("test.txt", O_RDWR|O_CREAT, 0644);
if (fd < 0){
perror("open error");
}
//捕捉SIGSEGV信号
signal(SIGSEGV , sig_handler1);
//捕捉SIGBUS信号
signal(SIGBUS , sig_handler2);
//拓展文件大小2200
lseek(fd , 2200 , SEEK_SET);
write(fd , "0" , 1);
//指向文件开头
lseek(fd , 0 , SEEK_SET);
//创建8192字节大小的共享映射区,可读写
addr =(char *)mmap(NULL , 8192 , PROT_WRITE|PROT_READ , MAP_SHARED , fd , 0);
if (addr == MAP_FAILED){
perror("mmap err: ");
}
//正常访问
strcpy(addr , "hello world");
printf("%s\n", addr);
//指针位移到2200区域
int i;
for(i = 0; i < 2220; i++){
addr++;
}
//可以访问,但修改操作不会反映到文件中
strcpy(addr , "PHP is best");
//指针位移到4096区域
for(i = 0; i < 1896; i++){
addr++;
}
//访问未被映射区域,会收到SIGBUS信号
strcpy(addr , "ABCDEF");
//解除映射
/*
ret = munmap(addr , 1024);
if(ret < 0){
perror("munmap error: ");
}
*/
return 0;
}
程序执行结果:
5. 总结
1. 掌握内存映射过程中的系统分页边界
2. 理解引发SIGSEGV和SIGBUS信号的错误原因和处理