1.

项目缘起:为什么我要自己动手搞打印机驱动?
大家好,我是老张,一个在嵌入式领域摸爬滚打了十多年的工程师。
最近接了个挺有意思的项目,客户要求用一块工业触摸屏(上位机)来监控产线数据,最后还得把这些数据整理成报表,用打印机打出来。
听起来是不是挺常规的?但坑就坑在,我们用的那款昆仑通态触摸屏,它系统升级了,从原来的Windows
CE换成了Linux。
这一换,直接把一个我们依赖了很久的“全屏打印”功能给整没了。
厂家那边呢,因为我们的需求量不大,也指望不上他们专门为我们开发这个功能。
这下可好,报表还得打,功能不能少。
怎么办?只能自己动手,丰衣足食了。
既然上位机这条路暂时走不通,我就把目光转向了下位机——直接用单片机去驱动打印机,让单片机作为“二传手”,接收触摸屏的数据,然后指挥打印机干活。
市面上打印机品牌很多,我主要看了惠普和爱普生。
它们大多有USB和并口两种接口。
USB接口虽然现代,但协议复杂,需要处理USB通信协议栈,对单片机资源和编程功底要求都高,外围电路设计也麻烦。
而并口,简直就是为这种点对点、实时控制的场景而生的,协议简单直接,就是电平信号的高低变化。
特别是爱普生的很多针式打印机,为了兼容老的工业设备,至今还保留着并口。
所以,我几乎没怎么犹豫,就选择了并口方案,打印机型号定为了经典耐用的爱普生LQ-630II。
确定了硬件,接下来就是语言。
打印机不是给什么它就打什么的,你得跟它说“行话”。
一查资料,爱普生打印机用的是一种叫“ESC/P”的命令语言,我们通常简称为ESC语言。
这套语言其实就是一系列以0x1B(ESC键的ASCII码)开头的命令序列,用来控制字体、排版、走纸等等一切打印动作。
好了,硬件(单片机+并口)、目标(爱普生LQ-630II)、通信语言(ESC)都齐了,一场硬核的驱动开发实战就此拉开序幕。
2.
硬件握手:单片机与打印机的“物理对话”
硬件连接是第一步,也是所有逻辑的基础。
如果线都接错了,后面代码写得再漂亮也是白搭。
打印机的并口线一头是36针的Centronics接口(接打印机),另一头是25针的DB-25接口(接控制端,就是我们单片机这边)。
我们只需要关心DB-25这一头。
很多人一看到25个针脚就头大,其实真正用到的没几个。
我给大家捋一捋,你照着接,准没错:
- 数据线(D0-D7):这是传输数据的“高速公路”,对应引脚2到9。
一共8根,正好一次传送一个字节(8位)的数据。
这是最重要的线。
- 选通信号(STB):引脚1。
这是单片机给打印机的“读取指令”。
你可以把它想象成敲门。
单片机把数据放到数据线上后,并不会立刻被打印机读取。
只有当STB引脚产生一个从高电平到低电平的下降沿脉冲时,打印机才会“开门”把当前数据线上的数据“拿进去”。
- 忙信号(BUSY):引脚11。
这是打印机给单片机的“状态反馈”。
当它为高电平时,表示打印机正忙(可能在处理数据、打印头移动、缺纸等),没空接收新数据。
单片机必须检测这个信号,只有等它变成低电平(空闲),才能发送下一个数据。
这是实现可靠通信的关键。
- 错误信号(ERROR):引脚15。
当打印机出现卡纸、缺纸等错误时,这个引脚会变成低电平。
单片机可以检测它来做错误处理。
- 初始化信号(INIT):引脚16。
这是一个硬件复位信号。
给这个引脚一个大于50微秒的低电平脉冲,打印机就会复位,清空缓冲区,回到开机初始状态。
调试时很有用。
- 地线(GND):引脚18到25,这8个引脚全都是地线。
非常重要!必须和单片机的地可靠连接,为所有信号提供共同的参考零电位。
接线总结与核心提醒:
- 最小系统:实际上,最核心的连线就三组:8根数据线(D0-D7)、1根选通线(STB)、1根忙线(BUSY)。
有了这三组,基本的数据发送功能就有了。
- 上拉电阻是必须的:单片机的I/O口在初始化时,如果设置为开漏或准双向模式,内部上拉可能不够强。
为了确保信号稳定,尤其是像BUSY、ERROR这样的输入信号,我强烈建议在每条信号线上(除了地线)都接一个4.7K到10K的上拉电阻到VCC(3.3V或5V,看单片机电平)。
这能避免信号因干扰而漂移,是保证长期稳定运行的“定海神针”。
- 电平匹配:老式打印机并口通常是5V
TTL电平。
如果你的单片机是3.3V系统(如STM32系列),需要确保IO口能容忍5V输入(很多STM32的IO是5V容忍的),或者使用电平转换芯片(如74LVC4245)来转换BUSY、ERROR等输入信号。
输出信号(STB,
DATA)一般3.3V也能被5V系统识别为高电平,但为了保险,也可以进行转换。
我的实际电路板上,就是用STM32F103的GPIO口,通过74LVC4245做了电平转换,每个信号线都加了10K上拉电阻,这样接好之后,用万用表和示波器量一下,信号清晰干净,硬件基础就打牢了。
3.
通信协议:一个字节的“长征”之旅
线接好了,接下来就是让数据跑起来。
单片机怎么把一个字节的数据安全地送到打印机里呢?这个过程看似简单,却蕴含着硬件通信最基础的“握手”思想。
我把它分解成几个步骤,你跟着做一遍就明白了。
第一步:准备数据。
假设我们要发送字符‘A’,其ASCII码是0x41。
单片机将这个值写入到连接并口数据线(D0-D7)的GPIO端口寄存器中。
此时,数据线上的电平状态就代表了二进制0100
0001。
第二步:询问状态。
数据准备好了,但不能硬塞。
单片机需要先查看打印机的“忙”(BUSY)信号线。
如果BUSY是高电平,说明打印机还在处理上一个任务,单片机就必须耐心等待,不断查询这个状态。
这是一个阻塞式查询的过程。
第三步:发出“送达”指令。
当检测到BUSY变为低电平(空闲)的瞬间,单片机要立刻执行关键操作:让选通信号(STB)产生一个负脉冲。
具体操作是,先将STB引脚置为高电平,保持一小段时间(微秒级),然后拉低,再保持一段时间(通常1微秒以上,根据打印机手册),最后再拉高。
这个“高-低-高”的变化,就是告诉打印机:“数据已在门口,请查收!”
打印机在这个下降沿的时刻,会锁存当前数据线上的8位数据,并将其存入自己的接收缓冲区。
第四步:打印机处理。
打印机收到数据后,BUSY信号会立刻变高,表示“我已收到,正在处理,勿扰”。
它会将数据存入内部缓冲区。
当缓冲区快满,或者收到了“回车换行”(\r\n)这样的行结束符时,打印机才会启动真正的打印机械动作,把这一行内容打出来。
理解了流程,我们来看代码实现。
最开始的版本很简单,但有问题:
//版本1:简单阻塞等待(适用于测试,不适用于实际项目)
void
}
这个函数在测试时没问题,但在实际系统中是“有毒”的。
因为while死循环会彻底占用CPU,如果打印机忙得久一点(比如换行、走纸),整个系统就卡死了,无法处理其他任务(如接收上位机数据、扫描按键等)。
所以,我们必须把它改造成非阻塞、可轮询的方式,这是我踩坑后得到的宝贵经验:
//版本2:非阻塞轮询式发送(推荐)
unsigned
}
这个函数不会等待。
它被主循环或定时器中断定期调用。
如果打印机忙,它就立刻返回0,主程序可以去做别的事情;如果打印机空闲,它就完成发送并返回1。
这样,单片机的CPU利用率就大大提高了,整个系统也变得响应迅速。
这就是嵌入式开发中常见的状态机和轮询思想的应用。
4.
数据缓冲与队列:让打印任务流畅起来
解决了单个字节的发送,新的问题来了:我们通常要打印的不是一个字符,而是一行文字、一张表格。
怎么高效、可靠地发送一串数据呢?你可能会想,循环调用printByte不就行了?但这里有个关键:必须确保每个字节都成功送达,不能因为打印机偶尔忙一下就把某个字节给丢了。
我的解决方案是引入一个发送缓冲区(Buffer)和一套简单的队列管理机制。
思路是:先把所有要打印的数据(包括ESC控制命令和实际文本)按顺序存到一个数组里,然后由一个后台任务不断地尝试把缓冲区里的数据一个个发出去,发成功一个就标记一个,直到全部发完。
首先,我们定义缓冲区和相关的状态变量:
#definePRINT_BUF_SIZE
g_print_buffer[PRINT_BUF_SIZE];
发送缓冲区
本次待发送的数据总长度
然后,提供两个函数来组装要打印的数据包:
//向缓冲区存入一个字节(常用于存入ESC控制命令)
void
g_print_buffer[g_print_buffer_write_idx]
=
g_print_buffer_write_idx++;
向缓冲区存入一个字符串(常用于存入要打印的文本)
void
g_print_buffer[g_print_buffer_write_idx]
=
g_print_buffer_write_idx++;
str++;
}
怎么用呢?假设我们要设置加粗打印“Hello
World”:
//开始组装一次打印任务
0;
数据组装好了,存在g_print_buffer里了,总长度是g_print_data_len。
接下来,就需要一个打印状态机在后台(比如主循环或低优先级定时器中断里)不断地工作,把数据发出去:
voidstatic
g_print_buffer[g_print_buffer_read_idx];
send_success
(printByte_polling(last_byte_to_send))
发送成功!
g_print_buffer_read_idx++;
读索引加1,指向下一个数据
如果发送失败(打印机忙),send_success保持0,下次循环会重试发送同一个字节
else
}
这个printTask_polling函数是整个打印驱动的核心引擎。
它被周期性调用,每次调用都尝试推进一点点打印任务。
它保证了即使打印机在处理机械动作而暂时“忙”,也不会丢失任何数据,只是稍作等待后重试。
这种“存储-转发”的缓冲队列机制,是处理低速外设的经典模式,让单片机和打印机都能有条不紊地工作。
5.
ESC语言实战:不只是打印文字
前面我们解决了硬件通信和数据流的问题,现在来到了“指挥艺术”的层面——如何使用ESC语言让打印机听我们的话。
ESC/P命令非常丰富,但入门只需掌握几个最常用的,就能实现大部分需求。
所有命令都以0x1B(ESC)或0x1C(FS)等控制字符开头,后面跟着一个或多个参数。
1.
文本格式控制:
- 字体选择:
ESC。n
n=0选择字体A(等宽),n=1选择字体B(比例),这是我常用的。
先发
0x1B,0x4D,
0x00就能切到字体A。
- 加粗:
ESC。n
n=1开启,n=0关闭。
想让标题醒目?就在标题前后加上这个命令。
- 下划线:
ESC。n
n=1开启单线下划线,n=2开启双线下划线,n=0关闭。
用于强调重点数据。
- 对齐方式:
ESC。n
n=0左对齐,n=1居中对齐,n=2右对齐。
打印表格标题时,居中对齐会让报表看起来专业很多。
2.
行间距与换行:
- 设置行间距:
ESC。n
设置n/180英寸的行间距。
ESC2则恢复默认行间距。
如果你觉得打印出来的行太密或太疏,就用这个命令调整。
- 换行处理:这里有个细节。
发送
\n(0x0A)是换行(LineFeed),打印头移到下一行。
发送
\r(0x0D)是回车(CarriageReturn),打印头回到行首。
通常我们发送
\r\n组合,来完成“回车换行”的动作,并触发打印机立即打印当前行。如果你的内容没打出来,检查一下是不是忘了加
\r\n。
3.
走纸与切纸(针对带切刀型号):
- 走纸n行:
ESC。n
让纸张向前走n行。
打印完最后一行后,可以用这个命令让签名栏空出位置。
- 全切纸:
ESC。i
对于LQ-630II这类有自动切纸功能的型号,发送这个命令会完成全切。
注意:发送前要确保打印内容已经全部打印完成(即缓冲区已空),否则会切到一半的内容上。
4.
实战组合案例:打印一张简单的数据报表假设我们要打印一个温度记录表,有标题、表头和数据行。
voidtemp1,
printBuf_putString("温度监控报表\r\n");
加粗关
printBuf_putString("传感器1\t传感器2\r\n");
\t是制表符,不一定所有打印机都支持,这里用空格更稳妥
下划线关
打印数据行(假设有浮点数转换函数floatToStr)
char
printBuf_putString("\t");
制表符分隔
printBuf_putString("\r\n");
换行并触发打印
printBuf_putString("\r\n");
记录数据长度,启动打印任务
}
通过这样的命令组合,你就能完全掌控打印输出的样式和布局。
最好的学习方式就是找到爱普生LQ-630II的ESC/P命令手册(通常叫“Programmer‘s
Manual”),把它当成字典,需要什么功能就去查,然后写成自己的命令函数库。
6.
上下位机协同:构建完整的打印系统
在真实的项目中,单片机(下位机)很少孤立工作。
就像我最初的项目,数据来源于昆仑通态触摸屏(上位机)。
这就涉及到一个关键问题:如何让上位机的大量数据,有序、不丢失地通过单片机打印出来?
直接让上位机一股脑地把所有数据都丢给单片机行不行?理论上可以,但风险很大。
单片机缓冲区有限(我定义了512字节),打印机缓冲区更大(LQ-630II有32KB),但打印机的机械动作很慢。
如果上位机发送速度远快于打印速度,数据要么在单片机端丢失,要么在打印机端堆积,可能导致内存溢出或混乱。
我采用的是一种基于行号的问答式(Query-Response)协议,非常可靠。
核心思想是:下位机主动请求,上位机按需发送。
协议设计如下:
约定数据帧格式:我们约定,上下位机之间通过串口通信(也可以是CAN、以太网等)。
每一帧数据都有一个简单的帧头、行号、数据长度、实际数据、校验和。
[帧头0xAA][行号(2字节)][数据长度(2字节)][数据...][校验和(1字节)]例如,第1行数据“Hello”的帧可能是:
AAXX。
下位机工作流程:
- 初始化后,下位机需要打印第一行。
它向上位机发送一个“数据请求”命令,里面包含请求的行号(比如
REQ:001)。 - 然后进入等待状态,同时继续执行它的打印状态机(即
printTask_polling),处理可能还未完成的上一行打印。 - 当收到上位机回复的、行号匹配的数据帧后,解析出数据内容,将其填入打印缓冲区,并启动打印。
- 关键点:只有当
printTask_polling函数检测到当前所有数据已发送完毕(g_print_buffer_read_idx),并且打印机处于空闲状态后,下位机才会向上位机请求下一行的数据。>=
g_print_data_len
- 初始化后,下位机需要打印第一行。
上位机工作流程:
- 上位机准备好所有要打印的数据,按行存储。
- 收到下位机的“REQ:001”请求后,它从自己的数据池里取出第一行数据,打包成上述帧格式,发送给下位机。
- 然后等待下一个请求。
它不主动推送,完全由下位机的打印节奏来驱动。
这种方式的巨大优势:
- 流量控制:打印机的物理速度成为整个系统的节拍器,不会发生数据洪流淹没下位机的情况。
- 同步可靠:每一行数据都经过请求和确认,绝对不会错行或漏行。
- 资源友好:下位机只需要很小的缓冲区来存储当前正在打印的一行数据,内存占用极小。
- 状态清晰:上位机很容易知道打印进度(当前请求的行号),下位机也很容易处理错误(如果某一行数据接收错误,可以重复请求该行)。
在我的项目里,我就是用STM32的串口中断接收上位机的数据包,在主循环中解析并填充打印缓冲区,同时用另一个状态机管理行号请求的逻辑。
实测下来,即使连续打印几十页的报表,也从未出现过错行或数据丢失的情况,非常稳定。
7.
调试技巧与常见“坑点”汇总
开发这种底层驱动,调试过程占了大部分时间。
我总结了一些血泪教训和实用技巧,希望能帮你少走弯路。
调试必备工具:
- 逻辑分析仪:这是你的“眼睛”。
用它同时抓取数据线(D0-D7)、STB、BUSY这几根关键信号。
你可以清晰地看到数据值是什么时候变化的,STB脉冲宽度够不够,BUSY信号是如何响应的。
很多诡异的问题(比如数据乱码、打印机不响应)一看波形就明白了。
没有逻辑分析仪,用示波器的多通道功能也能看个大概。
- 串口调试助手:用来模拟上位机,给单片机发送测试数据或命令,非常灵活。
- 热敏纸/打印纸:最直接的输出反馈。
准备一卷便宜的纸,专门用来测试。
常见问题与排查清单:
问题:打印机完全没反应,不进纸也不打印。
- 检查1:电源和地线。
确保打印机电源打开,并且单片机的地(GND)和打印机的地(引脚18-25)可靠连接。
这是最常见也是最容易忽略的问题。
- 检查2:BUSY信号。
用万用表或逻辑分析仪测量BUSY引脚电平。
如果一直是高电平,说明打印机可能处于错误状态(缺纸、盖板打开等)。
检查打印机面板是否有错误灯亮起。
- 检查3:STB脉冲。
测量STB引脚是否有正常的负脉冲?脉冲低电平时间是否足够(参考手册,通常>1微秒)?我一开始就因为延时太短,打印机没识别到。
- 检查1:电源和地线。
问题:打印出乱码或错位。
- 检查1:数据线顺序。
并口数据线D0-D7(引脚2-9)是否与单片机GPIO的映射顺序一致?D0对应最低位(LSB),这个顺序不能错。
我曾经把D7和D0接反了,打出来的字符全是乱的。
- 检查2:电平匹配与上拉。
3.3V单片机驱动5V打印机,输入输出电平是否可靠?BUSY信号有没有上拉?信号在长导线传输中有没有衰减?加上上拉电阻和电平转换芯片后,问题往往就解决了。
- 检查3:时序。
在
printByte函数中,数据稳定(delay_us(10))和STB脉冲宽度(delay_us(50))的延时是否足够?可以尝试适当增加这些延时。
- 检查1:数据线顺序。
问题:打印内容不换行,或者换行位置不对。
- 检查:换行符。
你发送的是
\n还是\r\n?对于ESC/P打印机,通常需要发送\r\n(0x0D,0x0A)才能正确回车换行并触发打印。
只发
\n可能只换行不回车,导致下一行字叠印在同一行。 - 检查:行间距命令。
是否无意中发送了
ESC0这样的命令,将行间距设为了0?用
ESC2恢复默认试试。
- 检查:换行符。
问题:打印一段时间后死机或不响应。
- 检查:缓冲区溢出。
你的打印缓冲区
PRINT_BUF_SIZE是否足够大?是否可能在上位机数据过快时被冲垮?确保你的流控协议(如第6节的问答式协议)在工作。 - 检查:程序阻塞。
绝对避免使用
while(P_BUSY);这样的死等。确保你的
printTask_polling是非阻塞的,并且主循环能正常运转。 - 检查:看门狗。
如果单片机开启了看门狗,长时间阻塞在打印任务里会导致复位。
确保打印任务不会独占CPU。
- 检查:缓冲区溢出。
最后的经验之谈:开发这类驱动,耐心和细致的观察比编写复杂的代码更重要。
从硬件连线开始,每一步都验证信号。
先写最简短的测试代码(比如就发送一个字符‘A’),用逻辑分析仪看波形对不对,再用打印机看输出对不对。
一个功能调通了,再增加下一个。
ESC命令也是一样,先在手册上找到命令,单独测试这个命令是否生效,再组合到你的打印任务里。
当我第一次看到单片机驱动着老式的针式打印机,唰唰地打出整齐的报表时,那种成就感是巨大的。
它不像在电脑上点“打印”按钮那么便捷,但每一个字节的流动、每一个信号的跳变都在你的掌控之中,这种与硬件直接对话的乐趣,正是嵌入式开发的魅力所在。
希望我的这些实战记录,能为你点亮一盏小灯。
如果你在实现过程中遇到具体问题,欢迎随时交流讨论。


