5

电子钢琴项目--嵌入式开发 - BingeCome

 1 year ago
source link: https://www.cnblogs.com/bingeblog/p/17196701.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

电子钢琴项目

复盘一下之前做的一个小项目,温习一下c语言和Linux的知识,唔,就是这样子。

一、环境搭建

所用软件以及工具如下:

1、VMware-workstation-full搭配Ubuntu18.04的Linux操作系统,VMware是桌面虚拟计算机软件,提供用户可在单一的桌面上同时运行不同的操作系统和进行开发、测试 、部署新的应用程序。
2、Vsode代码编写软件,配置c语言开发环境

3、CH341/340和PL2303 串口驱动软件

4、SecureCRT串口调试软件

5、GEC6818开发板如下图

在这里插入图片描述

上述环境搭建百度一下就有很多很多教程,这里不在赘述。真的要说配置环境,那得拉老长的文章了。

二、开发板的使用与Linux文件IO

开发流程如下:

  1. 通过Vscode中进行代码编辑,进行开发后放入共享文件夹
  2. Ubantu中切换到共享目录,使用交叉编译器出可执行文件
  3. 打开SecureCRT将可执行文件上传开发板
  4. 在开发板上调试运行

联通开发板与电脑:

  1. 接通电源,插上开发板后,安装CH341/340和PL2303串口驱动,

  2. 在计算机右键点击计算机管理中的设备管理器查看对应端口

  3. 打开SecureCRT串口调试软件,点击快速连接

  4. 设置SSH为serial,端口为查看的端口,波特率115200,流控全部关闭,点击连接即可

文件上传开发板

  • 可执行文件(小):rx demo命令后点击传输选择文件上传,百k以下
  • 程序运行资源(大):如音频、图像文件等,通过U盘上传,在 /mnt/udisk目录下cp -r demo /就行;

Linux文件IO

在这里插入图片描述
  1. open函数打开文件
  2. 定义数据缓冲区,write函数写入数据
  3. lseek函数调整文件位置偏移量
  4. 定义数据缓冲区,read函数存放读到的数据
  5. 打印出读到的数据
  6. 关闭对应文件

小练习:德国国旗的显示

终于简单的归纳了一下部分内容到这里,可以开始做个小Demo练习一下熟练知识点啦!显示德国国旗在开发板上。(不要在意条条的颜色这些细节,问就是酱样紫)

在这里插入图片描述

在开发板上显示德国国旗代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
	// 1.打开lcd驱动
	int lcd_fd;
	lcd_fd = open("/dev/fb0", O_RDWR);
	if(-1 == lcd_fd)
	{
		perror("open lcd failed!\n");
		return -1;
	}

	// 2.处理颜色数据
	// 定义颜色数据缓冲区
	int col_buf[800*480];
	int x,y;
	for(y=0; y<160; y++)
	{
		for(x=0; x<800; x++)
			col_buf[800*y+x] = 0x000000;
	}
	for(y=160; y<320; y++)
	{
		for(x=0; x<800; x++)
			col_buf[800*y+x] = 0xff0000;
	}
	for(y=320; y<480; y++)
	{
		for(x=0; x<800; x++)
			col_buf[800*y+x] = 0xffff00;
	}

	//	3.将颜色数据写入lcd
	write(lcd_fd, col_buf, sizeof(col_buf));

	// 4.关闭lcd
	close(lcd_fd);

	return 0;
}

三、项目界面显示

tips:猜猜界面中的背景图是谁鸭

最终电子钢琴界面效果如下图:

在这里插入图片描述

​ 不同的图片格式有着不同的压缩算法,所以需要将对应格式的图片文件压缩算法库引入项目,这里为了简单一点使用通用的24位图格式BMP文件存储图片,当然在GEC6818开发板的LCD显示屏却是32位图,为了在开发板的系统上正常显示需要进行原色移位即24转32位。

​ 开发板系统的图片像素点是A、R、G、B共4个字节32位,BMP格式的图片像素点是B、G、R共3个字节24位,以B为基准,一个字节是八位,所以将R左移16位,G左移8位,B不动,A可以左移24事实上不管也行默认就可以,然后通过C语言的按位或,配合左移,实现数据的拼接大功告成啦!

在这里插入图片描述
例         二进制:  1101 0101
                   1010 1101<<8 
                   1101 1010<<16
                   0000 0000<<24
      结果 0000 0000 1101 1010 1010 1101 1101 0101 
//int  4字节                   buf是char 1字节         透明度是0不管
bmp_buf[i] = buf[3*i+2]<<16 | buf[3*i+1]<<8 | buf[3*i]; //其实写成代码也就一行搞定     
  • 边界显示:bmp图片的宽所占的字节数如果不能被4整除,windows在保存的时候,会在每一行的后面添加垃圾数凑够4整除
  • 图像偏移:BMP图片有54字节的头文件存储图片的bmp格式图片的长度和宽度,色深,大小……,所以需要用lseek进行偏移
  • 图形翻转:因为BMP独特的编码方式:它的像素点的编码方式是上下颠倒的,我们在开发板的屏幕上显示需要从最下面一行开始向上进行显示,如下图方便理解,左图显示的开发板,右图缓冲区的图片

在这里插入图片描述

图片显示代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
	// 1.打开lcd驱动
	int lcd_fd;
	lcd_fd = open("/dev/fb0", O_RDWR);
	if(-1 == lcd_fd)
	{
		perror("open lcd failed!\n");
		return -1;
	}
	// 2.打开图片
	int bmp_fd;
	bmp_fd = open("/LeiMu.bmp", O_RDONLY);
	if(-1 == bmp_fd)
	{
		perror("open bmp failed!\n");
		return -1;
	}
	char head[54] = {0};
	read(bmp_fd, head, 54);
	int w = *((int *)&head[18]);
	int h = *((int *)&head[22]);
	printf("w=%d, h=%d\n", w,h);
	// 补齐4字节
	int n_add; // 需要补的字节数
	int add_a; // 补齐后的字节数
	n_add = (4-w*3%4)%4;
	add_a = w*3+n_add;
	char bmp_add[add_a*h];
	lseek(bmp_fd, 54, SEEK_SET);
	read(bmp_fd, bmp_add, sizeof(bmp_add));

	// 3.读取图片像素数据
	char bmp_buf[w*h*3];
	for(int j=0; j<h; j++)
		memcpy(&bmp_buf[w*3*j], &bmp_add[add_a*j], w*3);
	// 3.1 24--->32
	int bmp_32[w*h];
	for(int i=0; i<w*h; i++)
	{//                b         g        r        a
		bmp_32[i] = bmp_buf[3*i+0]<<0 | bmp_buf[3*i+1]<<8 | bmp_buf[3*i+2]<<16 | 0x00<<24;
	}
	// 3.2 翻转
	int buffz[800*480];
	int x0=0, y0=0;
	for(int y=0; y<h; y++)
	{
		for(int x=0; x<w; x++)
		{
			buffz[800*(y+y0)+x+x0] = bmp_32[w*(h-1-y)+x];
		}
	}
	// 4.把图片像素数据写入lcd
	write(lcd_fd, buffz, sizeof(bmp_32));
	// 5.关闭lcd,关闭图片
	close(lcd_fd);
	close(bmp_fd);

	return 0;
}

图片显示效果如下:

在这里插入图片描述

​ 猜到背景板上的人物了吗?OK,现在背景搞定了,接下来就是把按键以及头部和底部添上去了,和之前操作差不多其实,无非就是改了改参数而已(正常开发中,这些界面参数都会由UI设计师给出),难道我们要copy这些代码一个个费劲儿的调参吗?不,这里可以封装一下显示的函数,将需要显示的图片长宽以及起始点位置x、y和图片文件路径传给它就好了。

​ 除了需要封装显示函数,这里我们采用效率更高的mmap映射的方式来实现用户空间和内核空间的数据直接交互从而省去了空间不同数据不通的繁琐过程。

mmap介绍

#include <sys/mman.h>

FB = mmap(NULL, 800*480*4, PROT_READ | PROT_WRITE, MAP_SHARED, lcd_fd, 0);

返回值为void型万能指针,可以和浮点型、整型等兼容。关键看定义的存储数据数组是什么类型就用什么类型;

共有6个参数:

  • 第一个 映射内存的起始地址,我们一般用NULL,系统会自动寻找一个合适的起始地址。
  • 第二个 映射内存的大小,就是我们要把一个多大的文件映射到内存中,mmap映射后,会返回 给我们一个内存映射的起始地址,这个len就是我们文件的大小,8004804。
  • 第三个 映射内存的保护权限,一般给可读可写就行。
  • 第四个 我们要选共享也就是map-shared。
  • 第五个 文件描述符,把lcd文件描述符给他就可以。
  • 第六个 文件映射的开始区域偏移量,那么在屏幕上来说,要从左上角,也就是0开始。
munmap(FB, 800*480*4);  //释放虚例内存函数
  • 第一个参数 释放内存地址
  • 第二个参数 释放内存长度

封装的显示图片函数代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/mman.h>	
#include <signal.h>

#define LCD_PATH "/dev/fb0"

int lcd_fd;
int *FB;

//1,LCD初始化函数
void Lcd_Init(void)
{
	//1,打开LCD文件
	lcd_fd = open(LCD_PATH, O_RDWR);
	if(-1 == lcd_fd)
	{
		perror("open lcd failed");
		return;
	}
	
	//2,lcd映射到用户空间
	FB = mmap(NULL, 800*480*4, PROT_READ | PROT_WRITE, MAP_SHARED, lcd_fd, 0);
	if(MAP_FAILED == FB)
	{
		perror("mmap lcd failed");
		return;
	}
}

//2,LCD释放函数
void Lcd_Uninit(void)
{
	//4,解除映射,关闭文件
	munmap(FB, 800*480*4);
	close(lcd_fd);
}

//3,显示宽度为win,高度为high的bmp图片  在起点坐标为(x_s, y_s)这个点开始显示图片
void Show_Bmp(int win, int high, int x_s, int y_s, char *picname)
{
	int i, j;
	int tmp;
	char buf[win*high*3];		//存放图片的原始数据
	int bmp_buf[win*high];		//存放转码之后的ARGB数据
	
	//1,lcd初始化
	Lcd_Init();
	
	//2,读取图片数据,并且进行转码 RGB -> ARGB
		//打开图片
		FILE *fp = fopen(picname, "r");
		if(NULL == fp)
		{
			perror("fopen failed");
			return;
		}
		
		//读取图片像素点原始数据
		fseek(fp, 54, SEEK_SET);
		fread(buf, 3, win*high, fp);
		
		//将读取的数据进行转码 24-->32
		for(i=0; i<win*high; i++)
		{	
			//ARGB			R					G			B
			bmp_buf[i] = buf[3*i+2]<<16 | buf[3*i+1]<<8 | buf[3*i];
		}
		//将转码的数据进行倒转 把第i行,第j列的点跟第479-i行,第j列的点进行交换
		for(i=0; i<high/2; i++)	//0~239行
		{
			for(j=0; j<win; j++) 	//0~799列
			{
				//第i行,第j列的点跟第479-i行,第j列的点进行交换
				tmp = bmp_buf[win*i+j];
				bmp_buf[win*i+j] = bmp_buf[win*(high-1-i)+j];
				bmp_buf[win*(high-1-i)+j] = tmp;
			}
		}
	//3,将转码之后的数据写入LCD (写入到LCD的区域由 (0,0) --> (100, 20))
	for(i=y_s; i<high+y_s && i<480; i++)		// 0 ~ high-1行   20 ~ high+20-1
	{
		for(j=x_s; j<win+x_s && j<800; j++)	// 0~win-1列	  100 ~ win+100-1	
		{
			//FB[800*i+j] = bmp_buf[win*i+j];(图片的数组中第i行,第j列的点)
			FB[800*i+j] = bmp_buf[win*(i-y_s)+j-x_s];			
		}
	}		
	//4,lcd资源销毁,关闭图片
	fclose(fp);
	Lcd_Uninit();
}

int main()
{
    Show_Bmp(800,480,0,0, "LeiMu.bmp");
    Show_Bmp(800,80,0,0,"logo.bmp");//top部图片
    for (int i = 0; i < 12; i++)
        Show_Bmp(60,280,40+60*i,100,"key_off.bmp");
    Show_Bmp(800,80,0,400,"bar.bmp");//bootom部图片
    return 0;
}

到这里,以上代码就可以实现,钢琴界面的显示啦!效果见最开始的图哦。目前只是静态的显示,接下来要做的是获取点击的位置,判断区域,播放对应音乐,按键换图。

四、触摸屏应用

使用Linux中的输入子系统,可以轻松获取触摸屏的被触摸的坐标,引入#include <linux/input.h>即可开始使用,其实它是定义了一个结构体,如下所示

struct input_event
{
        struct timeval time;时间戳,精确到微秒
        _u16 type;输入事件类型
        _u16 code;具体事件描述,比如触摸屏的EV_ABS中的ABS_X和ABS_Y;
        _u16 value;具体动作描述
}
    EV_SYN:事件分割标志
    EV_ABS:发生了触摸屏事件,触摸屏坐标值ABS_X,ABS_Y;
    EV_REL:发生了鼠标事件
    EV_KEY:发生了键盘事件,设备的状态发生变化
    BTN_TOUCH:点击事件   
struct timeval{
        _time_t tv_sec;秒
        long int tv_usec;微秒
}

在这里插入图片描述

当用户点击时,只需要根据输入事件类型为触摸屏,就可以去具体事件描述里获取EV_ABS,这也就是触摸屏坐标值ABS_X,ABS_Y,废话不多说,直接上代码!

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/mman.h>	
#include <signal.h>
#include <linux/input.h>	//输入子系统的头文件

#define LCD_PATH "/dev/fb0"
#define TS_PATH  "/dev/input/event0"

int lcd_fd;
int *FB;
int ts_x, ts_y;		//存放点击屏幕的横纵坐标


//获取一次点击触摸屏的坐标信息,存入ts_x,ts_y
int get_ts(void)
{
	//1,打开触摸屏文件
	int fd = open(TS_PATH, O_RDWR);
	if(-1 == fd)
	{
		perror("open ts failed");
		return -1;
	}
	
	//2,读取触摸屏文件数据
	struct input_event xy;		
	int flag = 0;		//记录当前获取坐标的信息
	
	while(1)
	{
		read(fd, &xy, sizeof(xy));
		if(xy.type == EV_ABS && xy.code == ABS_X && flag == 0)
		{
			ts_x = xy.value * 800 / 1024;		//获取点击的时候X轴坐标的值 (0~1024)--> (0~800)
			flag = 1;
		}
		if(xy.type == EV_ABS && xy.code == ABS_Y && flag == 1)
		{
			ts_y = xy.value * 480 / 600;		//获取点击的时候Y轴坐标的值 (0~600)-->(0~480)
			flag = 2;
		}
		//设置条件:每读取一次完整的坐标,就打印一次坐标
		if(flag == 2)
		{
			flag = 0;
			printf("(%d,%d)\n", ts_x, ts_y);
			break;	//获取一次坐标就跳出循环
		}
	}
	//3,关闭触摸屏文件
	close(fd);
}

这里qmy_lhl博主的一个比较好的地方,定义一个flag来控制获取函数的结束,同时需要注意获取点击时候坐标的值,注意一个细节,ts_x,ts_y是定义的全局变量便于拿到主函数里去用。

最后,再坚持一下下,这个项目接近实现啦!

现在我们有了用户按压了琴键,这时候需要播放对应的声音,我们借助Alsa(Advanced Linux Sound Architecture)库(官方去下载即可,搜一下就有)来实现播放,这里记得复制libasound.so.2加上混响并配置一下全局变量就好了

现在,我们在代码中执行下面的代码即可播放对应路径的声音!

execlp("aplay","aplay",”./mp3/d1.wav”,NULL);

琴键那么多声音,难道我们要一个个手戳重复的代码来播放吗?不,这里封装一下下播放函数,只需要传一个编号就能播放一个MP3目录下对应的声音;

void play(int num){
    char str[32] = {0};
    printf("%d\n",num);
    sprintf(str,"./mp3/d%d.wav",num);//打印字符到str中
    execlp("aplay","aplay",str,NULL);
    return;
}

​ 秋豆麻袋,目前用户点击一下就会播放一次对应编号的声音,这是一个线程,多点多放对应着多线程的问题,下面是一个很形象的比喻:

  • 单进程单线程:一个人在一个桌子上吃菜。
  • 单进程多线程:多个人在同一个桌子上一起吃菜。
  • 多进程单线程:多个人每个人在自己的桌子上吃菜。

​ 多线程的问题是多个人同时吃一道菜的时候容易发生争抢,例如两个人同时夹一个菜,一个人刚伸出筷子,结果伸到的时候已经被夹走菜了。。。此时就必须等一个人夹一口之后,在还给另外一个人夹菜,也就是说资源共享就会发生冲突争抢。

1、对于 Windows 系统来说,【开桌子】的开销很大,因此 Windows 鼓励大家在一个桌子上吃菜。因此 Windows 多线程学习重点是要大量面对资源争抢与同步方面的问题。

2、对于 Linux 系统来说,【开桌子】的开销很小,因此 Linux 鼓励大家尽量每个人都开自己的桌子吃菜。这带来新的问题是:坐在两张不同的桌子上,说话不方便。因此,Linux 下的学习重点大家要学习进程间通讯的方法。

这里,我们用fork()函数执行子进程实现播放多个声音,解决这个问题。

下面放上完整代码:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/mman.h>	
#include <signal.h>
#include <linux/input.h>	//输入子系统的头文件

#define LCD_PATH "/dev/fb0"
#define TS_PATH  "/dev/input/event0"

int lcd_fd;
int *FB;
int ts_x, ts_y;		//存放点击屏幕的横纵坐标

//1,LCD初始化函数
void Lcd_Init(void)
{
	//1,打开LCD文件
	lcd_fd = open(LCD_PATH, O_RDWR);
	if(-1 == lcd_fd)
	{
		perror("open lcd failed");
		return;
	}
	
	//2,lcd映射到用户空间
	FB = mmap(NULL, 800*480*4, PROT_READ | PROT_WRITE, MAP_SHARED, lcd_fd, 0);
	if(MAP_FAILED == FB)
	{
		perror("mmap lcd failed");
		return;
	}
}

//2,LCD释放函数
void Lcd_Uninit(void)
{
	//4,解除映射,关闭文件
	munmap(FB, 800*480*4);
	close(lcd_fd);
}

//3,显示宽度为win,高度为high的bmp图片  在起点坐标为(x_s, y_s)这个点开始显示图片
void Show_Bmp(int win, int high, int x_s, int y_s, char *picname)
{
	int i, j;
	int tmp;
	char buf[win*high*3];		//存放图片的原始数据
	int bmp_buf[win*high];		//存放转码之后的ARGB数据
	
	//1,lcd初始化
	Lcd_Init();
	
	//2,读取图片数据,并且进行转码 RGB -> ARGB
		//打开图片
		FILE *fp = fopen(picname, "r");
		if(NULL == fp)
		{
			perror("fopen failed");
			return;
		}
		
		//读取图片像素点原始数据
		fseek(fp, 54, SEEK_SET);
		fread(buf, 3, win*high, fp);
		
		//将读取的数据进行转码 24-->32
		for(i=0; i<win*high; i++)
		{	
			//ARGB			R					G			B
			bmp_buf[i] = buf[3*i+2]<<16 | buf[3*i+1]<<8 | buf[3*i];
		}
		//将转码的数据进行倒转 把第i行,第j列的点跟第479-i行,第j列的点进行交换
		for(i=0; i<high/2; i++)	//0~239行
		{
			for(j=0; j<win; j++) 	//0~799列
			{
				//第i行,第j列的点跟第479-i行,第j列的点进行交换
				tmp = bmp_buf[win*i+j];
				bmp_buf[win*i+j] = bmp_buf[win*(high-1-i)+j];
				bmp_buf[win*(high-1-i)+j] = tmp;
			}
		}
	//3,将转码之后的数据写入LCD (写入到LCD的区域由 (0,0) --> (100, 20))
	for(i=y_s; i<high+y_s && i<480; i++)		// 0 ~ high-1行   20 ~ high+20-1
	{
		for(j=x_s; j<win+x_s && j<800; j++)	// 0~win-1列	  100 ~ win+100-1	
		{
			//FB[800*i+j] = bmp_buf[win*i+j];(图片的数组中第i行,第j列的点)
			FB[800*i+j] = bmp_buf[win*(i-y_s)+j-x_s];			
		}
	}		
	//4,lcd资源销毁,关闭图片
	fclose(fp);
	Lcd_Uninit();
}

//4,获取一次点击触摸屏的坐标信息,存入ts_x,ts_y
int get_ts(void)
{
	//1,打开触摸屏文件
	int fd = open(TS_PATH, O_RDWR);
	if(-1 == fd)
	{
		perror("open ts failed");
		return -1;
	}
	
	//2,读取触摸屏文件数据
	struct input_event xy;		
	int flag = 0;		//记录当前获取坐标的信息
	
	while(1)
	{
		read(fd, &xy, sizeof(xy));
		if(xy.type == EV_ABS && xy.code == ABS_X && flag == 0)
		{
			ts_x = xy.value * 800 / 1024;		//获取点击的时候X轴坐标的值 (0~1024)--> (0~800)
			flag = 1;
		}
		if(xy.type == EV_ABS && xy.code == ABS_Y && flag == 1)
		{
			ts_y = xy.value * 480 / 600;		//获取点击的时候Y轴坐标的值 (0~600)-->(0~480)
			flag = 2;
		}
		//设置条件:每读取一次完整的坐标,就打印一次坐标
		if(flag == 2)
		{
			flag = 0;
			printf("(%d,%d)\n", ts_x, ts_y);
			break;	//获取一次坐标就跳出循环
		}
	}
	//3,关闭触摸屏文件
	close(fd);
}

void play(int num){
    char str[32] = {0};
    printf("%d\n",num);
    sprintf(str,"./mp3/d%d.wav",num);//打印字符到str中
    execlp("aplay","aplay",str,NULL);
    return;
}

int main()
{
    Show_Bmp(800,480,0,0, "LeiMu.bmp");
    Show_Bmp(800,80,0,0,"logo.bmp");//top
    for (int i = 0; i < 12; i++)
        Show_Bmp(60,280,40+60*i,100,"key_off.bmp");
    Show_Bmp(800,80,0,400,"bar.bmp");//bootom

    while (1)
    {
        get_ts();
        int num;long stime;
        num = (ts_x-40)/60; 
        if(ts_y > 100 && ts_y < 380 && ts_x > 40+num*60 && ts_x < 100+num*60)//如果点击对应区域就创建一条子进程播放音乐 
		{
            Show_Bmp(60,280,40+num*60,100,"key_on.bmp");//改变按下的键
            if(0 == fork()){             
                play(num+1);            
            } 
            stime = 200000;
            usleep(stime);
            Show_Bmp(60,280,40+num*60,100,"key_off.bmp");//恢复按下的键
        }          
    }
    return 0;
}

五、收工躺平

弄了个《小星星》代码播放的,毫无感情,全是技巧,播放即社死,千万别轻易尝试!

void Playstar(){
    int star[4][7]={{1,1,5,5,6,6,5} //一闪一闪亮晶晶
                ,{4,4,3,3,2,2,1}    //满天都是小星星
                ,{5,5,4,4,3,3,2}    //挂在天空放光明,
                ,{5,5,4,4,3,3,2}};  //好像许多小眼睛
    int stime;
    for(int i=0;i<4;i++)
    {
        for(int j=0;j<7;j++)
        {
            if(0 == fork())
            {
                play(star[i][j]);                
            }
            printf("%d\t",star[i][j]);
            stime = 200000;
            usleep(stime);
    	}
        stime = 500000;
        usleep(stime);
    }
}

文章中省略了一些细节,但基本上完整复盘了该项目过程,如有错漏,欢迎指出哦!

参考文章:

一些GEC6818的小项目链接:

__EOF__


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK