概述
前面我们学习了8051单片机和C语言的相关知识,但只能在PC上开发程序,并且8051单片机一次只能加载一个程序并运行。下面我们将基于8051单片机搭建一个小型的计算机,并且为其开发一个小型的磁盘操作系统,最终这台小型计算机将能够运行SD卡上的各种应用程序,这台小型计算机就起名为51PC。这样我们就可以在PC上开发应用程序,把应用程序以文件的方式写入SD卡,把SD卡插入51PC,只需输入应用程序的名字就能运行程序,有点像古老的DOS系统,如图7-1所示。
图7-1 51DOS运行示意图
7.1 51PC硬件原理图设计
怎样才能运行SD卡上的二进制程序呢?
8051单片机有64KB程序存储空间和64KB数据存储空间,我们外扩64KB数据存储器(SRAM),把SD中的程序搬移到外扩的SRAM中是可以的。此外, 8051单片机运行时,如果访问的程序存储空间超出片内实际的程序存储器大小,那么单片机将通过PSEN引脚访问片外的程序存储器,当外扩的程序存储器和外扩的SRAM是同一块芯片时,不就可以实现运行SRAM中的程序了。
这里选择89c58单片机,片内有32KB程序存储器和256B SRAM,其余与8051单片机一样,后面的描述中不区分8051和89c58,硬件连接图如图7-2所示。
图7-2 51PC原理图
外扩一片32KB的62256 SRAM,这个都是标准的接法,8051单片机先通过P0口输出低8位地址至74LS373锁存器锁存,然后P2口输出高8位地址和74LS373输出的低8位地址共同构成16位地址,此时的P0口作为数据输入输出口使用。
把8051单片机的PSEN引脚(读程序存储器)和RD引脚(读数据存储器)通过一个与门求与,与后的信号作为SRAM的读信号,这样就实现了无论是读程序存储器还是读数据存储器,都会读外扩的SRAM。把8051单片机的WR引脚与62256芯片的WE引脚连接,8051单片机的A15引脚取反后连接这片62256 SRAM芯片的CE片选引脚(片选信号是低电平有效),也就是这片SRAM扩展到了8051的高32KB地址空间。如此一来就能实现把程序从SD卡搬移到外扩的SRAM,并且跳转到这片SRAM中的程序执行了。
再外扩一片32KB 的62256 SRAM,连接到8051单片机的低32KB数据存储空间,62256 SRAM的CE片选引脚连接8051单片机的A15引脚,8051单片机的WR引脚同样与这片62256芯片的WE引脚连接。这样当访问的空间是高32KB时,A15输出高电平,取反后是低电平,会片选高地址的62256 SRAM;访问低32KB时,A15输出低电平,直接片选低地址的62256 SRAM。将来低地址62256 SRAM的高16KB作为磁盘操作系统的数据存储区,低16KB作为应用程序的数据存储器。
SD卡的数据线和控制线接到8051的P3.2~P3.5引脚上,用软件模拟SD卡的读写时序,最终实现SD卡的读写。外扩4个按键接到P1.4~P1.7上,按键抬起时,输入高电平,按键按下时,输入低电平。外扩4个LED灯接到P1.0~P1.3上,高电平点亮LED灯。
Proteus单片机设置
7.2 文件系统
SD卡是按块读写的,一个块一般是512字节,这样写入和读出数据要记住块编号和数据的大小才行,这显然是不切实际的。如果我们按照文件写入和读取数据,则只要记住文件的名字就行了,至于文件存储的位置和文件大小都可以通过文件信息查出来。要想实现按照文件读写,需要设计一个文件系统,有了文件系统就可以按照文件名字存取数据了。
7.2.1 文件系统设计
我们设计一个简单并且好理解的文件系统,以能实现按照文件名字读写SD卡为目标,暂时不过多考虑效率和质量。这里设计的文件系统可以支持50个文件,每个文件最大32KB。
按块(512字节)读写SD卡的程序网络上有很多开源的代码,我们也不再设计这一部分程序,直接给出读写函数,两个函数都是按照块操作,只需要给出块序号(从0开始)和读出或写入的缓冲。
INT8U MMC_Read_Block(INT32U address, INT8U *buffer);
INT8U MMC_Write_Block(INT32U address, INT8U *buffer);
我们把SD卡划分成4个区域,区域占用的块序号如表7-1所示。
表7-1 SD卡各区域情况
块序号 | 0 | 1~50 | 51~54 | 55~2102 |
说明 | 文件信息占用标志区 | 文件信息区 | 存储空间占用标志区 | 文件内容存储区 |
第一个区域是存储文件信息占用标志。总共50个文件,占用50个字节标志位,有文件就存储1,否则存储0。设计文件信息占用标志位是为了加快文件查找的速度,只有标志位占用了,其对应的位置才有文件信息,再读取文件信息才有意义。
第二个区域是50个文件的文件信息存储区,每个文件的信息组成如表7-2所示。
表7-2 文件信息存储区情况
字节序号 | 0 | 1~32 | 33~36 | 37~164 |
说明 | 文件类型,1是可执行程序,0是文本文件 | 文件名 | 文件大小,目前占用16位,33存放16位数低字节,34存放16位数高字节 | 文件内容存储占用的序号,16位数,低字节在前,高字节在后 |
第三个区域占用4个块,是存储空间占用情况的标志区,1是占用,0是空闲,总共可以记录2048个块的占用情况,2048个块的容量是1MB。50个文件,如果每个文件都达到32KB,实际需要占用1.6MB,而这里只管理1MB容量,我们简单假设了不是每个文件都达到32KB。
第四个区域是文件实际内容存储区,总共2048个块,1MB容量。文件内容存储区是按照也是按照块操作的,就是说,假设一个文件只有1字节内容,那么它也至少要占用1个块,即占用512字节空间。
下面我们看一下这个文件系统是怎么工作的?
创建一个空文件
首先准备好文件信息,即准备好文件类型、文件名称和文件大小,读出第0个块,从文件信息占用标志里找出一个空闲的位置,把文件信息写入空闲的文件信息存储区,并把相应的文件信息占用标志位置1。
写入文件内容
首先,根据文件名从文件信息存储区里找到对应的那条文件信息。然后,依次在存储空间占用标志区里查找空闲的块,把文件内容写入找到的空闲块,直到所有文件内容都写完,每写入一个块,把占用的块序号记录到文件信息里,并且把存储空间占用标志置1。
读取文件内容
首先,根据文件名从文件信息存储区里找到对应的那条文件信息。然后,从文件信息里找出文件内容存储占用的块序号,根据块序号到文件内容存储区读取实际的文件内容。
删除文件
根据文件名从文件信息存储区里找到对应的那条文件信息,把文件信息对应的文件信息占用标志位置0。
获取文件大小
根据文件名从文件信息存储区里找到对应的那条文件信息,从文件信息里读出文件大小,第33字节存放16位数的低字节,第34字节存储16位数的高字节。
文件系统设计好了,但涉及较多硬件操作细节,给编程带来诸多不便,所以我们提供一组文件函数接口,用户编程只需调用函数就能完成文件的一系列操作。提供的文件函数包括:读取、写入、删除、创建、获取文件大小、列出文件信息。文件函数的代码在第6章目录下的“文件函数”文件夹中,file.c和file.h是源代码,FILE.LIB是封装好的库,读者编程时只需把库文件和头文件加到自己的项目中就可以使用了。
文件操作函数使用了一组共同的全局变量:
unsigned char file_buffer[512];//文件内容缓存
unsigned char file_info[512]; //单个文件的信息
unsigned char sd_used[2048]; //磁盘占用标志
unsigned char file_info_used[512]; //文件信息占用标志
7.2.2 文件读取函数
首先,读出文件信息占用标志块,循环判断50个占用标志位,如果有文件占用则读取对应的文件信息,判断文件名是否是待读取的文件名,如果是则找到待读取文件的信息,如果不是再找下一个。找到了文件信息也就找到了文件内容存储的块序号了,后面就根据要读取的文件位置和要读取的文件大小到相应的块里读出内容就行了。
//filename是文件名,buffer是读取出来的内容存放缓存,seek是
//从哪里开始读取,size是读取多少字节,返回值是实际读取的字节数
int fread(char *filename, char *buffer, int seek, int size)
{
int i, j, buffer_size;
OpenSPI();
if (!MMC_Initialise()) //SD卡初始化
return -1;
if(size == 0)
return 0;
MMC_Read_Block(0, file_info_used); //读出文件信息占用标志
for(i=0; i<50; i++)
{
if(file_info_used[i] == 1)
{
MMC_Read_Block(i + 1, file_info);
if(strcmp(&file_info[1], filename) == 0)
//如果有文件信息并且文件名正确,就是找到了文件,停止查找
break;
}
}
if(i == 50)//文件名都不对,那么文件不存在
return -1;
if((seek + size) > (file_info[34] * 256 + file_info[33]))
//要读出的字节数超出文件实际大小,那就能读出多少读多少
buffer_size = file_info[34] * 256 + file_info[33] - seek;
else buffer_size = size;
j = seek/512;//找到seek位置的块序号
MMC_Read_Block(55 + file_info[37 + 2*j] + 256 * file_info[37 + 2*j + 1], file_buffer);//读出seek位置所在块的数据
if(buffer_size <= (512 - seek%512))
//如果需要的字节数读出来的数据就够了,则读取完成
{
memcpy(buffer, file_buffer + seek%512, buffer_size);
return buffer_size;
}else
//如果需要的数据seek所在块不够,那就读取新的块,直到达到size
{
memcpy(buffer, file_buffer + seek%512, 512 - seek%512);
//seek块里有(512 - seek%512)数据,剩下的需要再读取
buffer = buffer + 512 - seek%512;
buffer_size = buffer_size - (512 - seek%512);
j = j + 1;
while(buffer_size > 0)
{//不断读取数据,直到达到buffer_size
MMC_Read_Block(55 + file_info[37 + 2*j] + 256 * file_info[37 + 2*j + 1], file_buffer);
memcpy(buffer, file_buffer, buffer_size>=512?512:buffer_size);
buffer = buffer + 512;
buffer_size = buffer_size - 512;
j = j + 1;
}
}
if((seek + size) > (file_info[34] * 256 + file_info[33]))
//超出文件大小,返回实际读取的字节数
return (file_info[34] * 256 + file_info[33] - seek);
else return size;
}
7.2.3 文件追加函数
这里实现把给定的数据追加到文件结尾。先找出文件名对应的文件信息,判断文件占用的最后一个块剩余空间能不能写下待写入的数据,如果能写下,就把待写入的数据写入并更新文件信息,如果写不下,那就能写多少写多少,剩余的数据写入新的块,并把块序号记录在文件信息,并且更新存储区占用标志和文件信息。
//filename是文件名,buffer是待写入的文件内容缓存,size是要写入
//的字节数,返回值是实际写入的字节数
int fwrite(char *filename, char *buffer, int size)
{
int i, j, k, file_len, buffer_size;
OpenSPI();
if (!MMC_Initialise())
return -1;
if(size == 0)
return 0;
MMC_Read_Block(0, file_info_used); //读出文件信息占用标志
for(i=0; i<50; i++)
{
if(file_info_used[i] == 1)
{
MMC_Read_Block(i + 1, file_info);
//如果文件名与要写入的文件名一致,则找到了文件,跳出循环
if(strcmp(&file_info[1], filename) == 0)
break;
}
}
if(i == 50)//文件不存在
return -1;
buffer_size = size;
file_len = file_info[34] * 256 + file_info[33];
//第一种情况:如果是空文件或者文件大小是512的倍数,也就是最后一
//个块不能再写入数据了
if(file_len%512 == 0)
{
j = file_len/512;
}
//第二种情况:如果最后一个块能够放的下待写入的数据,那就全部写入
else if(buffer_size <= (512 - file_len%512))
{
j = file_len/512;
//存放最后一块数据块号的位置,512倍数的情况在前面判断了
MMC_Read_Block(55 + file_info[37 + 2*j] + 256 * file_info[37 + 2*j + 1], file_buffer);//读出最后一块的数据
//把待写入的数据和文件最后数据拼到一起
memcpy(file_buffer + file_len%512, buffer, buffer_size);
file_len = file_len + buffer_size;
MMC_Write_Block(55 + file_info[37 + 2*j] + 256 * file_info[37 + 2*j + 1], file_buffer);//把内容写入SD卡
file_info[33] = file_len & 0x00FF;
file_info[34] = (file_len >> 8) & 0x00FF;//文件大小
MMC_Write_Block(i + 1, file_info);//写入文件信息
return buffer_size;
}
//第三种情况:最后一块写不下待写入的数据,那就能写多少写多少
else
{
j = file_len/512;//存放最后一块数据块号的位置
MMC_Read_Block(55 + file_info[37 + 2*j] + 256 * file_info[37 + 2*j + 1], file_buffer);//读出最后一块的数据
//拼成一个块
memcpy(file_buffer + file_len%512, buffer, 512 - file_len%512);
MMC_Write_Block(55 + file_info[37 + 2*j] + 256 * file_info[37 + 2*j + 1], file_buffer);
//剩下的数据字节数
buffer_size = buffer_size - (512 - file_len%512);
buffer = buffer + (512 - file_len%512);//剩下的数据起始位置
j = j + 1;//文件信息中数据块号的位置增1
}
//读出文件存储占用标志
MMC_Read_Block(51, &sd_used[0]);
MMC_Read_Block(52, &sd_used[512]);
MMC_Read_Block(53, &sd_used[1024]);
MMC_Read_Block(54, &sd_used[1536]);
//第一种和第三种情况的剩余文件内容写入SD卡
while(buffer_size > 0)
{
for(k=0; k<2048; k++)
{
if(sd_used[k] == 0) //搜索哪个块是空的
break;
}
sd_used[k] = 1; //修改存储区占用标志
//记录占用的块序号,块序号是2个字节
file_info[37 + 2*j] = k & 0x00FF;
file_info[37 + 2*j + 1] = (k >> 8) & 0x00FF;
//file_buffer存储待写入的数据,待写入的数据可能小于512字节
memcpy(file_buffer, buffer, buffer_size>=512?512:buffer_size);
MMC_Write_Block(55 + k, file_buffer); //写入数据
buffer = buffer + 512;
buffer_size = buffer_size - 512;
j = j + 1;
}
file_len = file_len + size;
file_info[33] = file_len & 0x00FF;
file_info[34] = (file_len >> 8) & 0x00FF; //文件大小
MMC_Write_Block(i + 1, file_info);//写入文件信息
MMC_Write_Block(51, &sd_used[0]);//写入存储区占用标志
MMC_Write_Block(52, &sd_used[512]);
MMC_Write_Block(53, &sd_used[1024]);
MMC_Write_Block(54, &sd_used[1536]);
return size;
}
7.2.4 获取文件大小函数
获取文件大小函数很简单,找到文件文件,从文件信息里取出大小即可。
int fsize(char *filename)
{
int i;
OpenSPI();
if (!MMC_Initialise())
return -1;
MMC_Read_Block(0, file_info_used);
for(i=0; i<50; i++)
{
if(file_info_used[i] == 1)
{
MMC_Read_Block(i + 1, file_info);
//如果文件名匹配
if(strcmp(&file_info[1], filename) == 0)
break;
}
}
if(i == 50)//文件不存在
return -1;
return (file_info[34] * 256 + file_info[33]);//文件大小
}
7.2.5 文件创建函数
创建空文件(文件大小是0)函数。找到一个空闲的文件信息位置,把文件信息写入并修改文件信息占用标志。
int fcreate(char *filename)
{
int i;
OpenSPI();
if (!MMC_Initialise())
return -1;
MMC_Read_Block(0, file_info_used);
for(i=0; i<50; i++)//先判断是否有重名的文件
{
if(file_info_used[i] == 1)
{
MMC_Read_Block(i + 1, file_info);
if(strcmp(&file_info[1], filename) == 0)
return -1;//文件已经存在,直接返回报错
}
}
//准备文件信息
memset(file_info, 0, 512);
file_info[0] = 1;//普通文件,1
strcpy(&file_info[1], filename);//文件名,32字节
file_info[33] = 0;
file_info[34] = 0; //文件大小
for(i=0; i<50; i++)//搜索哪个文件信息位置是空的
{
if(file_info_used[i] == 0)
break;
}
if(i == 50)
return -1; //超出了50个文件,报错
file_info_used[i] = 1;
MMC_Write_Block(0, file_info_used);//写入文件信息占用情况
MMC_Write_Block(i + 1, file_info);//写入文件信息
return 0;
}
7.2.6 文件删除函数
删除指定名称的文件。找到指定名称的文件信息,如果是空文件,则直接把文件信息占用标志位置0;如果是非空文件,则需要先把存储区占用标志位置0,再把文件信息占用标志位置0。
int fremove(char *filename)
{
int i, j;
OpenSPI();
if (!MMC_Initialise())
return -1;
MMC_Read_Block(0, file_info_used); //读出文件占用信息标志
for(i=0; i<50; i++)
{
if(file_info_used[i] == 1)
{
MMC_Read_Block(i + 1, file_info);
//如果有文件信息并且文件名正确
if(strcmp(&file_info[1], filename) == 0)
break;
}
}
if(i == 50)//要删除的文件根本不存在
return -1;
//非空文件,需要先清空存储区占用标志
if((file_info[34] * 256 + file_info[33]) > 0)
{
MMC_Read_Block(51, &sd_used[0]);
MMC_Read_Block(52, &sd_used[512]);
MMC_Read_Block(53, &sd_used[1024]);
MMC_Read_Block(54, &sd_used[1536]);
for(j = 0; j < (file_info[34] * 256 + file_info[33] - 1) / 512 + 1; j++) //先求出占用了多少个块
{
sd_used[file_info[j * 2 + 37] + 256 * file_info[j * 2 + 37 + 1]] = 0;//把占用标志清0
}
MMC_Write_Block(51, &sd_used[0]);//占用标志写回
MMC_Write_Block(52, &sd_used[512]);
MMC_Write_Block(53, &sd_used[1024]);
MMC_Write_Block(54, &sd_used[1536]);
}
//文件信息占用标志清零,文件信息本身不用清零
file_info_used[i] = 0;
MMC_Write_Block(0, file_info_used);
return 0;
}
7.2.7 列出所有的文件信息
可以用于读取50个位置的文件信息,包括大小和文件名称。
//读出文件信息,返回值为文件大小,-1表示此位置没有文件,
//filename是读取出来的文件名,num是文件位置(0到49)
int read_fileinfo(int num, char *filename)
{
MMC_Read_Block(0, file_info_used);
if(file_info_used[num] == 1)
{
MMC_Read_Block(num + 1, file_info);
strcpy(filename, &file_info[1]);
//33是低字节,34是高字节
return (file_info[34] * 256 + file_info[33]);
}
else return -1;
}
7.3 操作系统设计
我们设计的操作系统要实现接收用户输入的应用程序名字,然后把程序文件从SD卡加载到内存区并执行这个程序,执行完后返回操作系统命令行。是不是有点像古老的DOS命令行?不妨就给这个操作系统起个名字,就叫51DOS。
7.3.1 51DOS原理
前面我们设计了8051计算机的硬件,外扩了64KB的内存,其中高32KB(0x8000~0xFFFF)既作为数据存储器,也作为程序存储器。低32KB(0x0000~0x7FFF)只作为数据存储器使用,其中前16KB(0x0000~0x3FFF)作为应用程序的数据存储区,后16KB(0x4000~0x7FFF)作为操作系统的数据存储区。操作系统和应用程序共享单片机片内的128字节RAM和特殊功能寄存器。操作系统本身不使用中断系统,应用程序可以使用所有的中断源。再有,0x7F80~0x7FFF用来存储8个字符串,每个字符串最多16字节,操作系统使用这部分内存给应用程序传递参数,比如输入的程序可能是“cat abc.txt”,那么操作系统需要把“abc.txt”字符串传递给cat程序。
简单的说,操作系统使用0x0000~0x7FFF之间程序存储空间和0x4000~0x7FFF之间的数据存储空间,应用程序使用0x8000~0xFFFF之间的程序存储空间和0x0000~0x3FFF之间的数据存储空间,单片机片内的资源则共享。注意,0x8000~0xFFFF之间的程序存储空间和0x8000~0xFFFF之间的数据存储空间是重叠的,既是操作系统的数据区,又是应用程序的程序区。另外,虽然操作系统的变量只占用0x4000~0x7FFF之间的区域,但操作系统也可以访问0x8000~0xFFFF之间数据存储空间,不然就无法实现把程序从SD卡搬移到0x8000~0xFFFF区域了。
根据前面的内存分配来设置Keil开发环境,“Memory Model”和“Code Rom Size”都选择Large模式,把“BL51 Locate”选项中的“Code Range”设置为0x0000-0x7FFF,“Xdata Range”设置为0x4000-0x7F7F。
51DOS从功能上可以大体分成两部分,第一部分接收用户输入的程序名字,第二部分执行程序。
第一部分接收用户程序名称。循环读取串口字符,把字符存入命令缓冲区,当接收到回车符后,认为接收到了完整的程序名称,主循环调用执行程序的子函数。读取串口字符的同时,把接收到的字符再发送回去,这样就能在终端上回显输入的字符串了。上述功能可以使用gets()函数实现。
第二部分执行程序。首先,取出程序名和参数,把参数写入0x7F80参数存储区;然后,把程序文件从SD卡读出并写入0x8000地址处;再把片内的128字节RAM和特殊功能寄存器的内容保存起来;再通过子程序调用指令LCALL调用0x8000处程序代码,程序转移到0x8000处执行;最后,从应用程序返回后,恢复片内RAM和特殊功能寄存器的内容,应用程序执行过程完毕,回到命令行等待执行下一条程序。
为什么用LCALL指令调用0x8000处代码?
LCALL指令会自动把下一条指令的地址入栈保存,跳转到应用程序后,在应用程序里我们会保护返回的地址不被破坏,应用程序最后安排一条return指令,也就能返回操作系统了。
7.3.2 51DOS主要代码
#include <reg51.h>
#include <stdio.h>
#include <string.h>
#include <absacc.h>
#include "file.h"
char cmd_data[100];//命令/程序名称缓冲区
char SPOT_DATA[256]; //操作系统和应用程序切换时的现场保护
//向串口输出一个字符,printf会使用这个PutChar,Keil自带的
//PutChar函数输出rn时,会输出2个换行
void PutChar(char c)
{
SBUF = c;
while (TI == 0);
TI = 0;
}
// 串口初始化
void Init_USART()
{
SCON = 0x50;
TMOD = 0x20;
PCON = 0x00;
TH1 = TL1 = 0xFD;
TI = RI = 0;
TR1 = 1;
}
//执行程序
void execute(char *p)
{
int i, j;
int file_len;
char cmdname[16];//程序名最多16字节
if(*p == 0) //没有输入任何字符命令,只有回车
return;
//取出程序名和参数,参数最多8个,每个参数16字节,写入0x7F80
memset(cmdname, 0, 16);
for(i=0; i<16; i++)//取程序名
{
if((*(p + i) == ' ') || (*(p + i) == '