去年冬天,我在工作室里捣鼓出了一个有意思的小玩意儿——基于STM32F407的电子画板。这可不是普通的课程设计作业,而是一个真正具备实用价值的创作工具。它不仅能实现基础的画圆、画线、画矩形功能,还支持图形缩放、颜色选择、SD卡存储和触摸屏校准等高级特性。
这个项目的灵感来源于我想为嵌入式开发者打造一个低成本的原型开发工具。市面上的开发板要么功能太简单,要么价格昂贵。于是,我决定自己动手,从PCB设计到固件开发全部亲力亲为。整个过程就像在单片机的世界里玩现实版《我的世界》,每一行代码、每一个电路连接都充满挑战和乐趣。
在选择硬件组件时,我遵循了"够用就好"的原则,既保证性能又控制成本:
提示:电阻屏虽然体验不如电容屏,但在精度要求高的绘图场景反而更有优势,因为可以使用尖头触笔实现更精细的操作。
原理图设计有几个值得分享的巧妙之处:
触摸屏接口设计:
显存管理电路:
电源设计:

图形引擎的核心是graphic_engine.c文件,实现了基本的绘图功能:
c复制// 像素绘制函数
void DrawPixel(uint16_t x, uint16_t y, uint16_t color) {
if(x >= LCD_WIDTH || y >= LCD_HEIGHT) return;
*(__IO uint16_t*)(LCD_FRAME_BUFFER + 2*(y*LCD_WIDTH + x)) = color;
}
// Bresenham画线算法
void DrawLine(int x0, int y0, int x1, int y1, uint16_t c) {
int dx = abs(x1-x0), sx = x0<x1 ? 1 : -1;
int dy = -abs(y1-y0), sy = y0<y1 ? 1 : -1;
int err = dx+dy, e2;
while(1){
DrawPixel(x0,y0,c);
if(x0==x1 && y0==y1) break;
e2 = 2*err;
if(e2 >= dy) { err += dy; x0 += sx; }
if(e2 <= dx) { err += dx; y0 += sy; }
}
}
画圆算法采用了优化的Bresenham实现,避免浮点运算:
c复制void DrawCircle(int x0, int y0, int r, uint16_t c) {
int x = 0, y = r;
int d = 3 - 2 * r;
while (x <= y) {
// 绘制八个对称点
DrawPixel(x0 + x, y0 + y, c);
DrawPixel(x0 - x, y0 + y, c);
DrawPixel(x0 + x, y0 - y, c);
DrawPixel(x0 - x, y0 - y, c);
DrawPixel(x0 + y, y0 + x, c);
DrawPixel(x0 - y, y0 + x, c);
DrawPixel(x0 + y, y0 - x, c);
DrawPixel(x0 - y, y0 - x, c);
if(d < 0)
d += 4 * x + 6;
else {
d += 4 * (x - y) + 10;
y--;
}
x++;
}
}
颜色选择功能在color_palette.c中实现,采用预定义色板方案:
c复制#define COLOR(r,g,b) (((r>>3)<<11) | ((g>>2)<<5) | (b>>3))
const uint16_t palette[16] = {
COLOR(255,0,0), // 红
COLOR(0,255,0), // 绿
COLOR(0,0,255), // 蓝
COLOR(255,255,0), // 黄
// 其他12种颜色...
};
uint16_t GetSelectedColor(uint16_t x, uint16_t y) {
// 将触摸坐标映射到16宫格
uint8_t col = x / (LCD_WIDTH/4);
uint8_t row = y / (LCD_HEIGHT/4);
return palette[row*4 + col];
}
为了解决画面闪烁问题,实现了双缓冲机制:
c复制// 在SRAM中分配两个显存缓冲区
#define BUF_SIZE (LCD_WIDTH * LCD_HEIGHT * 2)
uint8_t frameBuffer[2][BUF_SIZE];
uint8_t currentBuffer = 0;
void SwapBuffer() {
currentBuffer ^= 1; // 切换缓冲区
LCD_SetFrameBuffer(frameBuffer[currentBuffer]);
// 等待DMA传输完成
while(DMA2_Stream1->CR & DMA_SxCR_EN);
// 启动DMA传输
DMA_Cmd(DMA2_Stream1, ENABLE);
}
使用FATFS文件系统实现SD卡存储功能:
c复制FATFS fs; // 文件系统对象
FIL file; // 文件对象
void SaveToBMP(const char* filename) {
// 创建BMP文件头
#pragma pack(1)
typedef struct {
uint16_t bfType;
uint32_t bfSize;
uint16_t bfReserved1;
uint16_t bfReserved2;
uint32_t bfOffBits;
uint32_t biSize;
int32_t biWidth;
int32_t biHeight;
// 其他BMP头字段...
} BMP_Header;
#pragma pack()
BMP_Header header = {0};
// 填充header各字段...
// 写入文件
f_mount(&fs, "", 1);
f_open(&file, filename, FA_WRITE | FA_CREATE_ALWAYS);
f_write(&file, &header, sizeof(header), &bytesWritten);
// 写入像素数据(注意BMP是从下往上存储)
for(int y=LCD_HEIGHT-1; y>=0; y--) {
f_write(&file, &frameBuffer[currentBuffer][y*LCD_WIDTH*2], LCD_WIDTH*2, &bytesWritten);
}
f_close(&file);
}
图片浏览功能的关键是BMP解码:
c复制void ShowBMP(const char* filename) {
f_open(&file, filename, FA_READ);
// 读取文件头获取图片尺寸
BMP_Header header;
f_read(&file, &header, sizeof(header), &bytesRead);
// 计算缩放比例
float scaleX = (float)LCD_WIDTH / header.biWidth;
float scaleY = (float)LCD_HEIGHT / header.biHeight;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
// 解码并显示图片
uint16_t pixel;
for(int y=0; y<header.biHeight; y++) {
for(int x=0; x<header.biWidth; x++) {
f_read(&file, &pixel, 2, &bytesRead);
DrawPixel(x*scale, y*scale, pixel);
}
// 跳过行对齐字节
if(header.biWidth % 2) f_seek(&file, f_tell(&file)+1);
}
f_close(&file);
}
触摸校准是项目中最具挑战性的部分之一,我采用了五点校准法:
c复制typedef struct {
uint16_t magic; // 校验魔数0xAA55
float a, b, c; // 校准参数
float d, e, f;
} CalibData;
void TouchCalibrate() {
uint16_t tx[5] = {50, LCD_WIDTH-50, LCD_WIDTH/2, 50, LCD_WIDTH-50};
uint16_t ty[5] = {50, 50, LCD_HEIGHT/2, LCD_HEIGHT-50, LCD_HEIGHT-50};
uint16_t adcX[5], adcY[5];
// 采集五个点的原始ADC值
for(int i=0; i<5; i++) {
DrawCross(tx[i], ty[i], RED); // 显示校准点
while(!TouchGetPoint(&adcX[i], &adcY[i])); // 等待触摸
Delay(500);
}
// 计算校准参数
// ... 矩阵运算过程省略 ...
// 保存到Flash
FLASH_Unlock();
FLASH_EraseSector(FLASH_Sector_11, VoltageRange_3);
FLASH_ProgramHalfWord(0x080E0000, calibData.magic);
// ... 保存其他参数 ...
FLASH_Lock();
}
获取触摸点后需要进行坐标转换:
c复制void TouchGetPoint(uint16_t *x, uint16_t *y) {
uint16_t adcX, adcY;
// 测量X坐标
GPIO_ResetBits(GPIOB, GPIO_Pin_0); // X-接地
GPIO_SetBits(GPIOB, GPIO_Pin_1); // X+接VCC
GPIO_ResetBits(GPIOA, GPIO_Pin_4); // Y-高阻
GPIO_ResetBits(GPIOA, GPIO_Pin_5); // Y+高阻
adcX = ADC_Read(ADC_Channel_8);
// 测量Y坐标
GPIO_ResetBits(GPIOA, GPIO_Pin_5); // Y-接地
GPIO_SetBits(GPIOA, GPIO_Pin_4); // Y+接VCC
GPIO_ResetBits(GPIOB, GPIO_Pin_0); // X-高阻
GPIO_ResetBits(GPIOB, GPIO_Pin_1); // X+高阻
adcY = ADC_Read(ADC_Channel_9);
// 应用校准参数
*x = (uint16_t)(calibData.a * adcX + calibData.b * adcY + calibData.c);
*y = (uint16_t)(calibData.d * adcX + calibData.e * adcY + calibData.f);
}
c复制void FillRect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t c) {
uint16_t *p = (uint16_t*)(LCD_FRAME_BUFFER + 2*(y0*LCD_WIDTH + x0));
for(int y=0; y<h; y++) {
for(int x=0; x<w; x++) {
p[x] = c;
}
p += LCD_WIDTH; // 跳到下一行
}
}
利用DMA加速显存传输:
c复制void LCD_Init() {
// 配置DMA
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_Channel = DMA_Channel_0;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&LCD->RAM;
DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)frameBuffer[currentBuffer];
// ... 其他DMA配置 ...
DMA_Init(DMA2_Stream1, &DMA_InitStructure);
}
现象:触摸点与实际显示位置偏差大
解决方法:
现象:SD卡无法写入或文件损坏
排查步骤:
现象:绘图时屏幕有明显闪烁
优化方案:
目前这个电子画板已经实现了基本功能,但还有很大的扩展空间:
这个项目最让我自豪的不是最终成品,而是整个开发过程中积累的经验。从寄存器级别的硬件操作到复杂的图形算法,每一个问题的解决都让我对嵌入式系统有了更深的理解。特别是显存管理策略和触摸校准算法,这些经验在任何嵌入式GUI项目中都弥足珍贵。