嵌入式屏幕上画一个圆,听起来不是什么复杂事,但真正做过的人都知道,这里面坑不少。如果用数学库里的sin、cos去算圆周点,在电脑上毫无压力,换到单片机上一跑,尤其是配了ILI9806G这类TFT彩色屏幕,速度立刻拉胯,有时候甚至一个圆都画不利索。这时候,Bresenham画圆算法几乎是标准答案:它只用整数加减法和比较运算,就能把圆完整地画出来,而且每个圆周点最多只需要几次整数运算,对单片机来说非常友好。
这篇文章我打算从两个层面展开:先讲透Bresenham画圆算法到底是怎么从数学公式变成递推代码的,然后把它落到ILI9806G驱动的LCD屏上,给出可以直接抄的C代码、画点函数、画圆函数,以及我在实际移植过程中踩过的坑和处理技巧。不管你是刚开始玩屏幕驱动的新手,还是已经在做仪表盘、菜单界面、小游戏这类图形功能的老手,这份内容应该都能帮你少走不少弯路。
1. 先搞清楚为什么嵌入式画圆要用Bresenham
1.1 浮点数学函数在单片机上的真实开销
很多人第一次在单片机上画圆,脑子里蹦出来的第一反应是:圆上的每个点坐标不是可以用圆心加半径乘角度算出来吗?公式很简单:
x = x0 + r * cos(θ)
y = y0 + r * sin(θ)
理论上没错,但在单片机上执行就完全是另一回事。假设我们要画一个半径100的圆,如果每3度取一个点,一圈需要120个点,这意味着要计算120次cos和120次sin。在带FPU的Cortex-M4F上还能忍受,如果你用的是经典的51单片机或者低端Cortex-M0,浮点库函数的开销非常可观,一次sin调用可能消耗几百甚至上千个指令周期,再加上浮点转整型的操作,画一个圆可能要让屏幕明显卡顿一下。
这还没算另一个问题:用等角度步进画出来的点,在圆的左右两端会比较稀疏,上下两端又很密集,视觉上不美观。如果为了均匀而增加取点密度,计算量还会进一步上升。所以在嵌入式场景里,用浮点三角函数画圆,并不是一个划算的方案。
1.2 Bresenham的优势和适用场景
Bresenham画圆算法的核心思路,是把“找圆上点”的问题,转化成“从当前像素点走到下一个像素点,应该选右边还是右下”的判断题。这个判断用到的所有值都是整数,只有加法、减法和比较运算,不涉及乘法、除法、浮点,连sqrt都不需要。
这套算法最初是由Jack Bresenham在画直线时提出的,后来经过扩展,中点圆算法也被很多教材统一归到Bresenham名下。它在计算机图形学里的地位非常高,尤其适合资源受限的嵌入式平台。具体到应用场景,常见的包括:
- 仪表盘上的圆形表盘、进度环、状态指示圆点
- 菜单系统的圆形按钮、头像裁剪框
- 小游戏里的碰撞体显示、角色范围圈
- 调色板、色环、雷达扫描界面
本质上,只要你面对的是一块带“画点”能力的LCD屏,无论是ILI9806G、ILI9341、ST7735,还是SSD1306这类单色OLED,这套算法都能用,因为它和具体屏幕芯片无关,最终只依赖一个底层函数:往指定坐标画一个点。
2. Bresenham画圆算法原理:从圆的对称性到整数递推
2.1 圆的八分对称性:只需算八分之一的点
圆有一个很漂亮的几何性质:它关于x轴对称、关于y轴对称,还关于y=x和y=-x这两条对角线对称。也就是说,整个圆可以分成八个完全相同的扇区。只要算出第一象限里面从(0, r)到(r/√2, r/√2)这段45度弧上的点,其他七个部分的点全部可以通过坐标映射得到。
举个例子,如果算法当前计算出一个点(x, y),那么它对应的八个对称点分别是:
- (x, y)
- (-x, y)
- (x, -y)
- (-x, -y)
- (y, x)
- (-y, x)
- (y, -x)
- (-y, -x)
这个性质是Bresenham画圆能够做到高效率的关键。一次循环里虽然画了8个点,但决策计算只做一次,相当于用一份计算量画了八段圆弧。
2.2 决策参数的推导:怎么判断下一个像素落在哪
现在我们只看从(0, r)开始向右下方向走的这段弧线。假设当前已经确定了像素点(x, y),下一步只有两个候选位置:正右方的(x+1, y),以及右下方的(x+1, y-1)。问题就变成:哪一个点离真实圆弧更近?
为了判断,取这两个候选点之间的中点,也就是(x+1, y-0.5)。把这个中点代入圆的隐式方程F(x, y) = x² + y² - r²:
- 如果F(mid) < 0,说明中点在圆内部,那么圆弧更靠近右边点(x+1, y),所以下一步选择向右走,y保持不变;
- 如果F(mid) >= 0,说明中点在圆外部或正好在圆上,那么圆弧更靠近右下点(x+1, y-1),所以下一步选择向右下走,y减1。
这里定义的F(mid)就是决策参数p。标准教科书里,p的初始值是5/4 - r,但为了避免浮点数,大家通常直接用 p = 1 - r。这个改动只影响初始判断的微小偏差,对整数坐标下的显示效果几乎无影响,却能让全程都用整数运算。
接下来就是推导递推式。每次x都会加1,但y是否减1取决于p的正负,所以p的更新分两种情况:
当p < 0时,下一个点取(x+1, y),所以新的决策参数为
p_new = p + 2x + 3
这个式子里x是当前点的x,不是更新之后的x。当p >= 0时,下一个点取(x+1, y-1),所以
p_new = p + 2(x - y) + 5。
代码实现时,如果先让x自增,再更新p,这两个递推式可以改写成:
- p < 0时:p += 2 * x + 1(此时x已经加1)
- p >= 0时:y--,p += 2 * (x - y) + 1(x和y都是更新后的值)
两种写法结果完全等价,只是维护方式不同。我后面给的C代码采用第二种写法,因为它可以少写几次加减法。
2.3 初始值和终止条件:不要写错边界
算法从圆的顶部点(0, r)开始,所以:
- x = 0
- y = r
- p = 1 - r
循环条件是 x <= y,直到x超过y,说明已经越过45度分界线,再继续画就会和已经画过的对称点重复了。这个终止条件很容易被写成 x < y,那样会漏掉最后靠近对角线的点。虽然因为对称点的存在,漏掉的点有时候已经被(sy, sx)这类映射补上,但在半径比较小的圆上,可能出现缺口。
从数学上看,这个循环每次迭代最多画8个点,总共执行约r/√2次,整体复杂度O(r)。一个半径100的圆,循环大约71次,就能画出全部像素,效率非常高。
3. 把算法变成C代码:ILI9806G画点与画圆实战
3.1 先搞定LCD的画点函数
Bresenham算法再漂亮,最终落地的依赖只有一个:能在屏幕上指定坐标画出一个指定颜色的点。所以第一步是把ILI9806G驱动的画点函数写稳定。
ILI9806G是一款常见的TFT LCD控制器,通常用在4.3寸800x480的屏幕上,内部自带GRAM(显示内存)。它的操作流程很典型:
- 初始化控制器(发送初始化命令序列,这部分一般由屏厂或驱动库提供)
- 每次绘图前,先通过命令0x2A设置列地址(Column Address),通过命令0x2B设置行地址(Page Address),这两个命令共同划定一个绘图窗口
- 发送命令0x2C,进入写GRAM状态
- 连续送入像素数据,数据会自动按窗口从左到右、从上到下填充
这里有一个非常重要的细节:ILI9806G的窗口左边界和右边界都是闭区间,也就是说,画一个在坐标(x0, y0)到(x1, y1)之间的矩形窗口,实际宽度是x1 - x0 + 1,高度是y1 - y0 + 1。如果这个区间计算错了,画出来的图会出现偏移或拉伸。
画单点的函数可以这样写:
#define LCD_WIDTH 800 #define LCD_HEIGHT 480 static void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_write_cmd(0x2A); lcd_write_data(x0 >> 8); lcd_write_data(x0 & 0xFF); lcd_write_data(x1 >> 8); lcd_write_data(x1 & 0xFF); lcd_write_cmd(0x2B); lcd_write_data(y0 >> 8); lcd_write_data(y0 & 0xFF); lcd_write_data(y1 >> 8); lcd_write_data(y1 & 0xFF); lcd_write_cmd(0x2C); } void lcd_draw_point(int16_t x, int16_t y, uint16_t color) { if (x < 0 || x >= LCD_WIDTH || y < 0 || y >= LCD_HEIGHT) { return; } lcd_set_window(x, y, x, y); lcd_write_data(color >> 8); lcd_write_data(color & 0xFF); }注意,画点函数里的边界判断不能省。Bresenham算法在圆弧靠近屏幕边缘时,会出现负坐标或超出分辨率的坐标,如果不先过滤,轻则画出错误颜色,重则导致窗口坐标溢出,整个屏幕显示异常。
颜色这里我直接按RGB565格式发送,先发高字节再发低字节。部分屏幕或接口需要反序,这个我在后面的排查章节会单独讲。
3.2 标准Bresenham画圆C代码
有了画点函数,画圆函数就水到渠成了。先定义一个内部的对称点绘制函数,一次性把8个对称点全部画出来:
static void draw_circle_points(int16_t xc, int16_t yc, int16_t x, int16_t y, uint16_t color) { lcd_draw_point(xc + x, yc + y, color); lcd_draw_point(xc - x, yc + y, color); lcd_draw_point(xc + x, yc - y, color); lcd_draw_point(xc - x, yc - y, color); lcd_draw_point(xc + y, yc + x, color); lcd_draw_point(xc - y, yc + x, color); lcd_draw_point(xc + y, yc - x, color); lcd_draw_point(xc - y, yc - x, color); }然后是根据递推式实现的画圆主函数:
void lcd_draw_circle(int16_t xc, int16_t yc, int16_t r, uint16_t color) { int16_t x = 0; int16_t y = r; int16_t p = 1 - r; while (x <= y) { draw_circle_points(xc, yc, x, y, color); x++; if (p < 0) { p += 2 * x + 1; } else { y--; p += 2 * (x - y) + 1; } } }这段代码逻辑很紧凑,但有一个容易让人困惑的地方:p的更新是在x++之后进行的。我在2.2节说过,这样写等于把标准递推式里“2x+3”里的那个+3,拆成了“2 * (x_new) + 1”,数学上是完全等价的。如果你看别的资料时发现递推式对不上,多半就是因为x的取值时机不同,代码没有错,只是表述习惯不同。
用的时候很简单:
// 在屏幕中央画一个半径120的白色圆 lcd_draw_circle(LCD_WIDTH / 2, LCD_HEIGHT / 2, 120, 0xFFFF);3.3 为什么代码这样写能直接跑
可能有读者会问:这个算法不是从圆心(0,0)推出来的吗,为什么我传入任意圆心(xc, yc)也能正常显示?
因为算法里真正的几何计算,始终是以圆心为原点进行的。x和y只是相对圆心的偏移量,画点的时候再加上圆心坐标,就完成了从局部坐标系到屏幕坐标系的平移。这个过程只需要两次加法,不改变算法的整数性质,也不增加额外复杂度。
另外一点需要注意的是数据类型。上面代码里我用了int16_t,因为坐标值和半径在绝大多数屏幕上不会超过32767。但如果你的屏幕分辨率特别大,或者半径值接近1000以上,建议直接升到int32_t,避免乘法或累加过程中溢出。我第一次在51上移植时吃过这个亏,后面专门列一节来讲。
4. 性能优化与功能扩展:填充圆、局部刷新与圆弧
4.1 填充圆的实现思路
画完空心圆,很多人下一步就想画实心圆。最直观的办法是在Bresenham循环的每一步,不再画圆周上的8个点,而是画4条水平线,把整个圆内部的像素从圆心所在行开始逐行填充。
实现代码如下:
void lcd_draw_hline(int16_t x0, int16_t x1, int16_t y, uint16_t color) { if (x0 > x1) { int16_t t = x0; x0 = x1; x1 = t; } if (y < 0 || y >= LCD_HEIGHT) { return; } if (x1 < 0 || x0 >= LCD_WIDTH) { return; } if (x0 < 0) { x0 = 0; } if (x1 >= LCD_WIDTH) { x1 = LCD_WIDTH - 1; } lcd_set_window(x0, y, x1, y); for (int16_t x = x0; x <= x1; x++) { lcd_write_data(color >> 8); lcd_write_data(color & 0xFF); } } void lcd_fill_circle(int16_t xc, int16_t yc, int16_t r, uint16_t color) { int16_t x = 0; int16_t y = r; int16_t p = 1 - r; while (x <= y) { lcd_draw_hline(xc - x, xc + x, yc + y, color); lcd_draw_hline(xc - x, xc + x, yc - y, color); lcd_draw_hline(xc - y, xc + y, yc + x, color); lcd_draw_hline(xc - y, xc + y, yc - x, color); x++; if (p < 0) { p += 2 * x + 1; } else { y--; p += 2 * (x - y) + 1; } } }这段代码里,lcd_draw_hline把画点函数升级成了画水平线函数,一次设置窗口后连续写入多个像素,比逐个点填充快很多。因为窗口本身是矩形的,画一条水平线就是设置一个1像素高的窗口,然后连续写多个颜色数据。
稍微解释一下为什么一次要画4条水平线:当前算法维护的(x, y)同时代表了圆在第一象限的两个对称位置,(xc ± x, yc ± y)和(xc ± y, yc ± x)分别对应靠近水平轴和靠近垂直轴的两段弧线。对这两段弧线同时做水平填充,就能把圆内所有行都覆盖到,不会出现漏行或者半圆空白。
这个实现有一个轻微缺点:当x和y很接近时,部分水平线可能重复绘制,但因为只是多写几次同样颜色的像素,视觉结果完全正确,性能影响也有限。如果你特别在意效率,可以记录上一次绘制的y值,重复时跳过,但多数场景下没必要。
4.2 减少闪烁的局部窗口刷新
画小圆还好,一旦半径超过200,或者界面里同时有很多圆需要频繁重绘,“闪烁”和“重影”就会出现。原因很简单:LCD的GRAM写入速度有限,如果你每次重绘前都执行lcd_clear把整个屏幕清掉,一帧画面里有大量时间是黑屏状态,肉眼看起来就是闪烁。
解决思路有几个:
第一,能用局部刷新就不要全屏刷新。比如进度环变化时,只需要在圆的外接矩形范围内重新绘制,外接矩形的范围是:
- 左上角:(xc - r, yc - r)
- 右下角:(xc + r, yc + r)
如果屏幕有硬件窗口功能,可以先设置这个矩形窗口,然后在这个区域内重绘,而不是全屏清空。
第二,如果MCU支持DMA,可以把一行或一个窗口的像素数据放到缓冲区里,用DMA发送到LCD接口。这样CPU不用一个点一个点地等时序,可以去做其他事情,整体刷新速度提升非常明显。
第三,如果界面元素固定,引擎可以只在初始化时画一次,之后不重绘,只在参数变化时重绘对应区域。这是做嵌入式GUI最基础的优化思路。
4.3 圆弧、椭圆的进一步扩展
Bresenham画圆算法直接画的是完整圆,但很多界面需要的是圆弧或扇形。一个比较省事的办法是:在draw_circle_points里对每个对称点判断角度,只绘制落在指定角度范围内的点,其他点跳过。不过角度判断需要用到atan2或查表,比较费资源。
更好的做法是利用点(x, y)与坐标轴的关系来计算角度。比如在从(0, r)向下走的弧线上,当前点相对圆心的角度就是 atan2(y, x)。如果不想引入浮点,可以预置一个角度阈值表,或者通过比较x和y的比例关系来粗粒度筛选。
椭圆可以看成x轴和y轴方向半径不同的圆,Bresenham算法也能扩展到椭圆,但递推式更复杂,需要同时维护两个决策参数,分别对应x方向和y方向的误差累积。碰到这种需求时,如果屏幕只是用来显示静态椭圆,用参数方程加查表反而更简单;如果要做实时旋转的椭圆或环形动画,那就得老老实实研究中点椭圆算法了。
对大多数嵌入式GUI来说,掌握空心圆和实心圆已经能覆盖80%以上的圆形需求,更复杂的图形通常用图片或矢量字库来替代。
5. 上机实测与高频问题排查
5.1 从空工程到出现圆的完整步骤
我每次在新板子上跑这套代码,都会按下面的顺序来,基本不会出乱子:
- 先确保屏幕能初始化成功,用
lcd_clear(0x0000)把屏幕清成黑色,再逐行刷几条彩色横线,确认接口读写正常。 - 单独画几个离散点,比如在屏幕四角和中心各画一个点,验证
lcd_draw_point的坐标方向是否正确。如果点出来的位置和预期镜像了,多半是初始化序列里的扫描方向或RGB顺序没设对。 - 画一条横线和一条竖线,验证单像素直线正常。这个步骤能把窗口命令0x2A、0x2B和写GRAM命令0x2C的问题暴露出来。
- 调用
lcd_draw_circle,先用半径10的小圆,再用半径100的大圆。小圆用来检查对称性,大圆用来检查边界裁剪。 - 如果一切正常,再加上填充圆、进度环等扩展功能。
这套流程看起来很简单,但能帮你把问题快速隔离到“驱动层”还是“算法层”。我见过太多人一上来就直接调画圆函数,结果屏幕花屏,最后发现是初始化序列里扫描方向设置错了,算法本身一点问题没有。
5.2 不同绘制方式的耗时对比
下面这张表是我在STM32F103主频72MHz、使用16位并口驱动ILI9806G屏幕时的经验数据,不同主频和不同接口会差很多,但相对关系是固定的:
| 绘制方式 | 主要开销 | 体验感受 |
|---|---|---|
| 浮点三角函数逐点画 | 每个点做sin/cos和乘法,FPU缺失时极慢 | 明显卡顿,甚至无法流畅刷新 |
| 查表法逐点连线 | 查表快,但点数多时画线指令多 | 较快,但代码占ROM,角度分辨率固定 |
| Bresenham画圆 | 纯整数加减,每步画8点 | 很快,适合实时刷新 |
| Bresenham填充圆 | 画水平线,窗口连续写入 | 比逐点快很多,适合大圆 |
我给一个参考值:半径100的圆,用Bresenham算法计算部分只循环约71次,也就是几百条整数指令,在72MHz主频下耗时几乎可以忽略;真正的时间都花在LCD写点上。如果屏幕接口是并行16位并且有DMA,画一个半径100的圆可以做到几毫秒内完成。
所以当你在单片机上觉得画圆慢,先不要怀疑算法,优先检查LCD接口时序和lcd_draw_point到lcd_draw_hline的优化。把逐点画改成窗口连续写入,往往会有几倍到十几倍的提升。
5.3 常见问题速查表
我在实际调机过程中整理过一些高频问题,放在这里方便对照排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 圆变成断断续续的点 | 窗口设置没生效,或者写GRAM和设置窗口之间插了多余命令 | 先用画点函数测试,再连续画水平线测试 |
| 圆变成椭圆 | LCD_WIDTH和LCD_HEIGHT反了,或者扫描方向设置和驱动IC不一致 | 检查初始化序列中的扫描方向寄存器,和宏定义分辨率 |
| 颜色不对,偏色或反色 | RGB565高低字节顺序反了 | 交换lcd_write_data的高字节和低字节顺序 |
| 圆位置不对,偏移了半个屏 | 列地址或行地址的区间计算错误 | 对照ILI9806G数据手册仔细检查0x2A和0x2B参数 |
| 半径超过一定值后画圆死机 | 坐标溢出或变量类型溢出 | 把int16_t换成int32_t,检查越界坐标裁剪 |
| 屏幕闪烁、有残影 | 全屏反复清空重绘 | 改成局部窗口刷新 |
| 小圆画出来很粗糙 | 半径太小,整数像素本身就有锯齿 | 属正常现象,可考虑开启LCD的抗锯齿或改用更高分辨率屏幕 |
注意:ILI9806G的窗口区间是闭区间,也就是说设置窗口参数时,x1和y1就是实际右下角坐标,不是“宽度减一”之外再减一。这个细节一旦搞错,整个画面都会错位。
6. 移植中容易被忽略的细节
6.1 变量类型宽度会让算法突然“卡死”
我第一次在8051平台上移植这段代码时,为了省RAM把坐标变量全部定义成了char。当时画的圆半径不大,一切正常。后来把半径调大到150,画圆函数直接不输出任何点,程序看起来像死循环了。查了半天才发现:char在51平台是8位,最大只有127,半径150时的y = r直接变成负数,循环条件x <= y永远为假。
这个坑需要特别提醒:在嵌入式平台,int的宽度并不是固定的,8位机上的int是16位,而char可能是8位。跨平台移植时,不要依赖默认类型,直接用int16_t、uint16_t这类明确宽度的类型,或者统一用int,然后保证半径和坐标都落在有效范围内。
6.2 接口时序与颜色字节顺序
ILI9806G本身支持多种接口,常见的包括16位并口、8位并口、SPI和RGB接口。不管用哪种,都要确认数据线的高低位顺序和写时序是否与驱动器匹配。这个问题最典型的症状是:屏幕能亮、能显示内容,但颜色完全不对,或者画出来的是“噪点”而不是规则图形。
调试这类问题时,不要直接去分析画圆对不对,而是先写一个纯色测试函数,依次填充红、绿、蓝,观察屏幕颜色是否正确。如果红色显示成蓝色,问题大概率出在RGB565的数据字节顺序上,调整一下lcd_write_data(color >> 8)和lcd_write_data(color & 0xFF)的顺序就能解决。
6.3 从画圆到图形界面的扩展建议
如果你做的是一个完整的嵌入式GUI,只靠画圆函数肯定不够,但画圆是整个基础图形库里非常重要的一个模块。可以考虑把画圆、填充圆、画水平线、画垂直线、填充矩形这些基础函数封装成统一的图形库,再基于它们实现圆环进度条、雷达扫描、菜单按钮等控件。
我在实际项目里的习惯是,把LCD底层驱动和图形绘制分开两层:底层驱动只负责lcd_set_window、lcd_draw_point、lcd_draw_hline这类函数;上层图形库再基于底层函数实现画圆、填充、圆弧、多边形。这样换屏幕型号时,只需改底层驱动,上层图形代码可以无缝复用。
最后再说一个隐蔽的小经验:如果你在画填充圆时发现圆心附近有一条竖线颜色不对,先别怀疑算法,看看lcd_draw_hline里窗口宽度是不是0。有些屏幕控制器在窗口宽度为0时不会写入任何数据,有些则会只写一个点,不同芯片的差异比较大,遇到时把窗口宽度强制设为1就能规避。