☰
STC8G1K08A串口通信实战:从Hello World到中断处理
2026/9/27 3:35:02 网站建设 项目流程

手里拿到一颗STC8G1K08A,想用串口给PC发个Hello World,这事看着简单,做起来却有一堆细节:引脚选哪组、波特率怎么配、发送是轮询还是中断、接收又该在哪里处理。这篇文章打算按我自己实际调通的顺序,把串口通信从零到中断处理整个过一遍,顺便把Boot和App共存时中断向量怎么安排也讲清楚。适合刚接触STC8G1K08A的初学者,也适合用8051内核做产品、想整理一套稳定串口代码的工程师。

STC8G1K08A这颗芯片最大的特点就是便宜、够用,8KB Flash、1KB RAM,在大多数需要串口做调试或通信的小项目里完全够打。很多朋友上来就把串口调通,却不知道为什么那样配,导致换一个波特率、换一个引脚就抓瞎。所以我打算从“为什么选这颗料”开始讲,讲清楚寄存器背后的原理,再给可以直接抄的代码,最后把中断处理那些坑填平。

1. 先搞清楚STC8G1K08A这颗料,再谈串口

1.1 一颗8脚单片机为什么值得玩

STC8G1K08A是STC宏晶推出的8051内核增强型单片机,常见封装SOP8、SOP16、DFN等。我平时用得最多的是SOP8,8个引脚去掉电源和地,剩下6个IO,在这么小的体积下居然还集成了8KB Flash、1KB SRAM、ADC、比较器、PWM、SPI、I2C和多路UART,扩展性比很多人想象中强很多。

选它做串口相关的小项目,核心原因是开发和下载足够简单:一个USB转TTL模块就能下载程序,不需要仿真器,也不需要外部晶振和复杂复位电路,非常契合“最小系统+串口”的定位。内部IRC振荡器出厂校准过,精度够用,这意味着硬件上省掉了两颗负载电容加一颗晶振,BOM成本可以压得很低。

实际使用中,1KB SRAM是最大的限制。如果串口缓冲区开太大,其他全局变量就没地方放。但经过合理规划,16字节接收环形缓冲加16字节发送缓冲完全够用,8KB Flash也能在Bootloader和App同时存在的情况下装下不少代码逻辑。正因为这些资源特点,这颗芯片非常适合做小家电控制、传感器数据上报、简单协议转换等串口类应用。

1.2 UART的资源分配与引脚选择

STC8G1K08A的UART1默认映射在P3.0(RXD)和P3.1(TXD)。SOP8封装下这两个引脚通常都能正常引出,但很多项目里P3.0和P3.1还要复用下载功能,所以搞清楚引脚切换非常关键。

UART1的引脚映射由P_SW1寄存器控制。P_SW1的高两位S1_S1、S1_S0决定UART1接在哪一组引脚上,默认00对应P3.0/P3.1。代码里不要直接对P_SW1整个赋值,因为高两位之外还有别的外设引脚配置,正确做法是读-改-写:

P_SW1 &= 0x3F; // 清零S1_S1, S1_S0,选择UART1默认引脚P3.0/P3.1

如果你需要把UART1切换到其他引脚,去看数据手册里P_SW1的完整表格即可。这里建议优先用默认引脚,原因只有一个:STC-ISP下载默认也从P3.0/P3.1走,程序里如果不小心把引脚切走,下载器可能连不上芯片,还得靠按住复位键重新上电恢复,很折腾。

1.3 开发环境与工程建立的关键一步

我用的开发环境是Keil C51,但Keil自带的器件列表里通常没有STC8G1K08A。解决方法是在安装好Keil后,打开STC-ISP下载软件,进入“Keil仿真设置”,把STC型号添加到Keil的器件数据库里。这一步漏掉的话,新建工程时会发现找不到芯片型号,头文件也没法自动匹配。

添加完成后新建Keil工程,选择“STC8G1K08A”,编译器会自动引入STC8G系列头文件,例如STC8G.H。如果老工程没有这个头文件,可以去STC-ISP安装目录里找,复制到工程目录,再把头文件包含路径加上。这个文件里包含了所有SFR地址定义,没有它,后面用到的P_SW1、AUXR、SCON、SBUF全都得自己用sfr关键字声明,写起来又烦又容易出错。

工程建好后还有两件小事建议提前做:Output标签页勾选“Create HEX File”,否则STC-ISP没有hex文件可以下载;实际项目建议先用一个点灯程序验证下载链路正常,再开始写串口。这样如果串口调不通,至少能排除下载问题,省得来回怀疑硬件。

2. 从零配置UART1:寄存器、波特率与第一个字符

2.1 波特率是怎么算出来的,为什么我用11.0592MHz晶振

串口模式1是异步8位UART,波特率由定时器1的溢出率决定,核心关系是:

波特率 = 定时器溢出率 / 16

如果定时器1工作在1T模式,定时器时钟就是系统时钟,那么溢出率 = 系统时钟 / (256 - 定时器初值),所以:

波特率 = 系统时钟 / [16 × (256 - TH1)]

以11.0592MHz和9600波特率为例:

256 - TH1 = 11059200 / (16 × 9600) = 72

TH1 = 256 - 72 = 184 = 0xB8

这就是为什么网上所有STC串口例程里TH1、TL1都写0xB8。11.0592MHz是经过特意选择的频率,能整除常见波特率,误差为零。如果系统时钟跑12MHz,算9600还勉强,算115200就完全不能用。所以我的习惯是:涉及串口的项目,系统时钟优先选11.0592MHz,直接用内部IRC振荡器,不需要外部晶振。

初始化代码:

SCON = 0x50; // 串口1模式1,8位UART,REN=1使能接收 AUXR |= 0x40; // T1x12=1,定时器1工作在1T模式 TMOD &= 0x0F; // 只修改定时器1,不干扰定时器0 TMOD |= 0x20; // 定时器1模式2,8位自动重装 TH1 = 0xB8; // 9600波特率重装值 TL1 = 0xB8; TR1 = 1; // 启动定时器1 TI = 0; // 清发送完成标志 RI = 0; // 清接收标志

这里用定时器1的模式2(8位自动重装)是因为8051传统架构下,这种模式溢出后硬件自动把TH1重新装入TL1,不需要在中断里手动赋初值,作为波特率发生器非常稳定。T1x12位置1让定时器走1T模式,如果把这个位清零,代码里的初值还要重新按12分频计算,很多抄来的例程时序不对,多半就出在这个地方。

2.2 最简发送代码:往SBUF写一个字节

串口发送的核心动作就两个:把数据写进SBUF寄存器,等发送完成标志TI置1。代码如下:

void UART1_SendByte(unsigned char dat) { SBUF = dat; while (!TI); // 等待发送完成 TI = 0; // 清标志,准备下一次发送 }

TI是串口硬件在帧发送完毕后自动置1的。只要TI没置1,说明数据还在移位寄存器里没发完,程序就在while处等着。这个函数简单,但有个顺序细节:一定要等到TI=1之后才清标志,不要一开始就清。有些代码为了保险在发送前加一句TI = 0,如果上一次的TI本来就为0,那没问题;但如果上一次TI还没被处理,发送前清标志会导致函数直接从while跳出,数据根本没发出去,排查起来还挺隐蔽。

有了单字节发送,字符串发送就是这个函数的循环:

void UART1_SendString(char *s) { while (*s) { UART1_SendByte((unsigned char)*s++); } }

调用时不要忘记回车换行:UART1_SendString("Hello World\r\n")。串口助手里如果没有\r\n,所有输出会连在一行,看起来像乱码或数据粘连。

2.3 实测:用示波器看第一个Hello World

把USB转TTL模块的TXD接单片机P3.1(TXD),RXD接P3.0(RXD),GND共地,打开串口助手选择9600波特率,下载运行后应该能看到“Hello World”。如果看不到,别急着怀疑代码,用示波器或逻辑分析仪看P3.1有没有波形。

比较好的验证方法是先发一个0x55,也就是二进制01010101。它对应的波形是间隔均匀的方波,每个bit宽度约104微秒(1/9600)。如果波形宽度明显不对,说明定时器初值和实际系统时钟不匹配;如果波形幅度不对,多半是供电或者接线问题。

这里必须反复强调一点:USB转TTL的TXD接单片机RXD,RXD接单片机TXD,交叉接。很多新手在这里栽跟头,信号怎么都出不来。另外,USB转TTL模块的电平最好和单片机系统一致,5V单片机就选5V输出的模块,3.3V模块在某些情况下也能用,但高电平识别可能不可靠,不如统一供电省心。

3. Hello World工程化:printf重定向、字符串发送与打包

3.1 重定向printf到串口:putchar与底层发送的配合

很多人在PC上写惯了printf,想在单片机上直接用printf打印变量。Keil C51的printf底层会调用putchar函数,所以只需要自己实现putchar:

char putchar(char c) { UART1_SendByte((unsigned char)c); return c; }

然后就可以直接写:

int temp = 25; printf("temp = %d\r\n", temp);

这里有一个针对STC8G1K08A的提醒:SRAM只有1KB,printf默认会占较多栈空间,如果程序里还有大数组、结构体,很容易栈溢出。而且C51的printf对浮点支持很弱,打印float需要额外配置浮点库,代码体积会明显变大。所以我实际项目里的习惯是:调试阶段用printf快速验证逻辑,产品发布前把关键日志改回自写的字符串发送函数,避免资源浪费。

3.2 发送逻辑统一封装:日志分级与模块化

工程上串口输出不要满工程到处直接调用UART1_SendString,最好统一封装一层。一个很简单的日志模块可以这样设计:

#define LOG_LEVEL_NONE 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_ERROR 2 unsigned char g_log_level = LOG_LEVEL_INFO; void log_msg(unsigned char level, char *msg) { if (level < g_log_level) { return; } UART1_SendString("[LOG] "); UART1_SendByte('0' + level); UART1_SendString(" "); UART1_SendString(msg); UART1_SendString("\r\n"); }

这样调试时把g_log_level设为INFO,全量输出;量产时改成ERROR,只留关键错误信息。改动只在一处,比到处删打印语句清爽得多。这种封装对串口项目来说不是多余,而是后面加协议解析、Boot模式切换时都会用到的地基。

3.3 乱码的真正原因与排查顺序

乱码是串口调试最常碰见的问题,没有之一。我一般按这个顺序排查:

  1. 波特率对不对。PC端串口助手和单片机两端波特率必须一致,超过一半的乱码都是这里。
  2. 时钟对不对。内部IRC设置成了11.0592MHz,代码却按12MHz计算初值,即使两端波特率显示一样,实际偏差也会很大。
  3. 数据格式对不对。模式1是8位数据、无校验、1停止位,串口助手要配成8N1。
  4. 共地问题。USB转TTL和单片机系统必须共地,否则电平参考点不一致。
  5. 电压问题。5V单片机接3.3V串口模块时,有些情况下高电平识别不够稳定,尽量统一供电。

乱码看着烦,但只要把逻辑分析仪接上TXD,一眼就能看出位宽是否正常,比盲猜快得多。

4. 串口接收与中断处理:从轮询到中断的工程化写法

4.1 查询接收为什么够用但不好用

接收数据在轮询模式下也不难:

void UART1_ReceivePoll(void) { if (RI) { RI = 0; unsigned char dat = SBUF; // 处理数据 } }

问题是单片机在查询RI期间如果去干了别的事,比如延时、刷屏、解析命令,接收到的字节就会丢。对于一秒几个字节的简单指令,轮询勉强能忍;一旦协议帧长一点、主循环忙一点,轮询接收就会让你怀疑人生。

真正可靠的串口接收必须用中断:数据来了硬件自动打断CPU,把字节存到缓冲,主循环再去处理。这也是从Hello World往工程化迈进的必经一步。

4.2 串口1中断服务函数的正确写法

STC8G的串口1使用中断号4。初始化时要打开串口中断和总中断:

ES = 1; // 串口1中断允许 EA = 1; // 总中断允许

中断服务函数里,先判断RI还是TI,再分别处理。接收数据时,先读SBUF保存起来,再清RI,这个顺序非常重要:

#define RX_BUFFER_SIZE 16 volatile unsigned char rx_buffer[RX_BUFFER_SIZE]; volatile unsigned char rx_head = 0; volatile unsigned char rx_tail = 0; void UART1_ISR(void) interrupt 4 { unsigned char dat; if (RI) { dat = SBUF; // 先读数据 RI = 0; // 再清接收标志 rx_buffer[rx_head] = dat; rx_head = (rx_head + 1) % RX_BUFFER_SIZE; } if (TI) { TI = 0; // 如果要实现中断发送,可以在这里从发送缓冲取下一字节写入SBUF } }

我用的是环形缓冲区,rx_head指向下一个写入位置,rx_tail指向下一个取出位置。主循环只要判断rx_head != rx_tail就说明有数据。为什么用环形而不是简单累加索引?因为缓冲区循环利用,不需要定期清空,代码逻辑更稳定。

ISR里不要做复杂解析,只负责存字节。真正解析协议要在主循环里做。中断里写大数组、循环几百次,都会拖慢主循环,甚至影响其他中断的实时性。这是个很重要的工程习惯,不是代码功能性问题,但会影响整个系统的稳定性。

4.3 不定长帧的结束判断:超时方法比想象中好用

接收字节容易,难的是知道一帧数据什么时候结束。常用办法有两种:固定帧长,或者“帧头+帧尾+校验”。还有一种是利用串口空闲时间判断,也就是收到第一个字节后启动定时器,定时时间到且没有新字节到来,就认为一帧结束。STC8G没有STM32那种硬件空闲中断,但用定时器完全能做到差不多的效果。

思路很简单:串口中断每收到一个字节就重新装载定时器超时值,并清除帧完成标志;定时器超时中断里把帧完成标志置1。主循环检测到帧完成标志后,去环形缓冲区按顺序解析数据。这种超时判断特别适合AT指令这类不定长文本协议,因为不需要约定特殊帧尾字符,数据内容里出现任何字符都不会误判。

我自己的经验是:如果协议是自己定义的,优先用“固定帧头+固定帧长+校验”,逻辑最简单,定位问题也容易;如果需要兼容AT指令这种不定长文本,再考虑超时判断。两种方法都不复杂,但要根据场景选,不要一套方案打天下。

4.4 踩过的坑:中断里清RI的时机

我有一次做传感器数据上传,串口中断里只做数据缓存,但隔一段时间就感觉丢字节。后来发现是中断服务函数里先清RI再读SBUF:清RI后读SBUF的瞬间,如果又有新字节进来,RI会再次置1,但这次中断还没退出,SBUF还没被读走,新数据可能覆盖掉旧值。改成先读SBUF再清RI之后,丢字节问题立刻消失。

这个坑特别容易出现在网上抄来的代码里。中断函数中寄存器的操作顺序不是随便写的,要严格按“先保存数据、再清标志”来。对应的发送方向也一样,要等TI置1后再清,顺序反了都会出诡异问题。

4.5 Boot与App共存时的中断处理:最稳妥的做法

标题里既然提到中断处理,我多说一个跟Boot/App相关的常见问题。很多同学做IAP升级,Boot程序里用了串口中断接收升级包,App程序里也用了串口中断,跳转之后发现App的串口中断永远不触发。

原因是8051中断向量表位置固定,Boot程序位于低地址,中断入口也在低地址。Boot的中断服务函数如果存在,App的中断触发后会跳到同一个向量,执行的是Boot的中断代码,而不是App里的中断处理。传统8051内核不像ARM Cortex-M有中断向量表偏移机制,多套固件共存时需要自己处理。

我的建议是:Boot程序尽量不用中断,串口接收在Boot阶段全部用轮询加超时完成。升级包接收本来就是可控流程,Boot里没必要引入中断。跳转到App之前,必须做这几件事:

  • ES = 0,关闭串口中断,实际上是关闭所有中断;
  • 清除RI、TI标志;
  • 把SP恢复到芯片复位时的初值;
  • 关闭定时器、外设,恢复默认时钟;
  • 最后通过函数指针跳转到App复位向量。
void jump_to_app(void) { EA = 0; ES = 0; RI = 0; TI = 0; SP = 0x07; // 恢复SP到初值 // App复位地址根据实际链接定位决定 ((void (code *)(void))0x0000)(); }

如果App实际起始地址不是0x0000,而是0x0400或0x1000这类地址,需要根据工程链接设置跳转到对应地址。STC8G1K08A只有8KB Flash,Boot留1KB,App就可以从0x0400开始,具体以自己项目的脚本为准。最稳妥的方案永远是Boot不用中断,App独占中断,这也是目前很多量产Bootloader的标准做法。

5. 实测验证与避坑清单

5.1 下载与供电:STC下载器的常见坑

STC单片机使用STC-ISP软件下载,操作顺序是先点击下载,然后给芯片冷启动上电,或者按复位键重新上电。如果一直提示“正在检测目标单片机”,大概率是TXD/RXD接反,或者没有共地,或者波特率选太高导致握手失败。解决方法很简单:把最低波特率和最高波特率都设为2400,降低握手速率,基本都能连上。

供电方面,SOP8封装要仔细看封装图区分VCC和GND。使用USB转TTL模块的5V输出供电时,小心电流不够。串口通信本身电流不大,但如果还带了LED、传感器,建议单独稳压供电,USB转TTL只做信号线连接。所有模块必须共地,这条说多少次都不为过。

5.2 波特率误差的容限

串口异步通信允许收发双方有一定波特率误差,通常不超过2%到3%,但115200这类高速率最好控制在1%以内。使用11.0592MHz时钟时,常见波特率误差为0。使用12MHz时钟时,我整理了一张表:

目标波特率系统时钟1T模式下分频系数实际波特率误差
960011.0592MHz7296000%
960012MHz789615+0.16%
1920011.0592MHz36192000%
11520011.0592MHz61152000%
11520012MHz6125000+8.51%

看到没有,12MHz跑115200直接没法用。如果板子已经定了12MHz,只能降低目标波特率,或者换用外部晶振。这也是我前面反复强调串口项目尽量用11.0592MHz的原因,不是玄学,是数学上算出来的。

5.3 用逻辑分析仪和串口助手配合定位问题

最后一个实用建议:手边备一个USB逻辑分析仪,24MHz采样率的十几二十块就很够用。把逻辑分析仪通道夹在单片机TXD上,地线共地,在软件里选择UART解码,设成9600、8N1,发送Hello World后就能直接看到解码出的字符串。如果解码出来是乱码,多半是波特率偏了;如果波形高电平幅度不对,检查供电和接线。

逻辑分析仪比示波器强在能直接解码,不用自己数位宽。串口助手适合看最终结果,但中间环节出了问题,还是得靠逻辑分析仪定位。调试串口不是靠猜,而是靠看波形。我自己每次写串口程序,都会先用逻辑分析仪把初始化后发的0x55抓一下,确认位宽正常,再继续后面的逻辑,这个习惯帮我省了很多排查时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询