ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ADUC842外设测试:UART先行与ADC/DAC/IIC/SPI/PWM联调

ADUC842外设测试:UART先行与ADC/DAC/IIC/SPI/PWM联调

简介:面向嵌入式开发初学者与ADuC842微控制器使用者的一套片上外设验证代码合集,围绕模数转换、数模转换、IIC、SPI、UART与PWM六类内部模块分别建立独立工程,可用于熟悉寄存器配置、核对外设时序,并排查实际项目中的通信与采样问题。压缩包共68个文件、约80KB,以C源文件、A51汇编启动文件、hex烧录文件、Keil工程文件及obj、lst、m51等编译产物为主,另含lib841.h头文件,按PWM、DAC、ADC、UART等模块分目录组织,便于逐项查阅并单独编译验证。已有399人学习,适合作为ADuC842外设入门与调试参考。读者可获取ADC量程与精度测试、DAC正弦波输出、IIC主从通信、SPI全双工传输、UART波特率与校验设置、PWM占空比调节的实测代码,并借助现成工程快速搭建测试环境,减少从零配置寄存器的工作量。

1. 先跑串口再碰 ADC:ADUC842 内部模块测试程序的正确打开顺序

拿到 ADUC842 内部模块 ADC DAC IIC SPI UART PWM 测试程序,我一般不会先盯着 ADC 那一段看,而是先把串口打印跑通。原因很直接:这颗片子外设多、复用重,ADC 值、DAC 回读、IIC 扫描结果、SPI 收发字节、PWM 占空比最后都要靠一路 UART 吐出来;没有日志,后面每一步都像在黑匣子里猜。

这个资源适合手上已经有 ADUC842 板子、需要做外设自检或产线初测的人,也适合刚接触 8051 核混合信号外设的开发者。它解决的不是“点个灯”的问题,而是把 ADDA 链路、串行总线和定时器输出放在同一块板上逐项验证。

我常用的顺序是:串口能打印、时钟能核对、ADC 能读到已知电压、DAC 能回读、IIC 能扫到地址、SPI 能收发固定字节、PWM 能算出频率。顺序反了,后面每个模块出问题都要回头补基础。

2. 让 C51 工程先“活”起来:启动文件、时钟、UART 打印和寄存器核对

2.1 工程组织:先让每个外设都能单独编译

这类测试程序最怕把所有外设塞进一个main.c,最后串口一乱,连哪段代码在跑都看不清。我一般会把工程拆成main.c、board.h、bsp_uart.c、bsp_adc.c、bsp_dac.c、bsp_iic.c、bsp_spi.c、bsp_pwm.c,公共延时和串口打印放在bsp_uart.c里。C51 工程不需要动态内存,大数组和日志缓冲尽量放xdata,中断服务函数只做置标志,别在里面跑printf。

入口函数只保留一个简单的命令分发,先把每个模块变成可以单独触发的入口:

#include <ADUC842.H> #include "board.h" void main(void) { unsigned char cmd; clock_init(); /* 先定系统时钟,再算串口波特率 */ uart_init(); /* 串口是后面所有模块的观察窗 */ printf("\r\nADUC842 self-test\r\n"); while (1) { cmd = uart_getc(); /* 阻塞读一个命令,先不搞复杂协议 */ switch (cmd) { case '1': adc_dump(); break; case '2': dac_ramp(); break; case '3': iic_scan(); break; case '4': spi_test(); break; case '5': pwm_test(); break; default: printf("cmd:1 ADC 2 DAC 3 IIC 4 SPI 5 PWM\r\n"); break; } } }

这段代码的逻辑很朴素:上电先初始化时钟和串口,然后等一个单字符命令。clock_init()必须在uart_init()前面,因为波特率重载值依赖系统时钟。uart_getc()是阻塞的,调试阶段比中断接收更稳;等到要做连续采样时,再把串口接收改成环形缓冲。cmd只做单字节分发,避免在资源有限的片子上引入字符串解析。参数上唯一要盯的是系统时钟宏,板子焊的是多少晶振,board.h里就写多少,别拿旧工程的配置直接套。

注意:不同 C51 工程的头文件对 SFR 位域命名不完全一样,下面所有寄存器名都要和工程里的board.h对照一遍。能编译不等于引脚真的动,先看串口,再看波形。

2.2 时钟与 UART:先定节拍,再谈波特率

UART 是测试程序的第一根拐杖。8051 核常见做法是用 Timer1 做波特率发生器,公式和SMOD位有关。下面这段按 12T 模式、SMOD=0写,实际板子如果用了不同时钟源或分频,重载值要重算:

#include <ADUC842.H> #include <stdio.h> #define FOSC 11059200UL #define BAUD 9600UL #define T1_RELOAD (256 - (FOSC / 12UL / 32UL / BAUD)) void uart_init(void) { SCON = 0x50; /* 串口方式1,允许接收 */ TMOD = (TMOD & 0x0F) | 0x20; /* Timer1 方式2,8 位自动重装 */ TH1 = (unsigned char)T1_RELOAD; TL1 = (unsigned char)T1_RELOAD; TR1 = 1; TI = 1; /* 让第一个 putchar 不卡死 */ } void uart_putc(char c) { while (!TI); TI = 0; SBUF = c; } char putchar(char c) /* C51 的 printf 最终走这里 */ { uart_putc(c); return c; } unsigned char uart_getc(void) { while (!RI); RI = 0; return SBUF; }

FOSC必须和实际晶振一致,BAUD先固定在 9600,方便用普通串口工具看日志。T1_RELOAD的计算用的是常见 12T 公式,若工程把定时器时钟改成 1T 或走可编程时钟,这个值会差很多,串口会出乱码。TI上电先置 1 是 C51 里常见的小动作,避免第一次printf死在等待发送完成。putchar是printf的底层出口,名字不能随便改。

串口跑通后,先发一串固定字符串,不要急着打印 ADC 值。固定字符串能同时验证波特率、TX 引脚复用和电平转换。如果收到的是乱码,先量晶振和 TX 波形,再回头改重载值;如果完全没输出,先查引脚是否被其他外设占用。

2.3 寄存器核对表:把“能编译”推进到“引脚真的动”

外设测试翻车,很多时候不是算法错,而是引脚复用或寄存器没开。我习惯在串口里打一张很短的快照,确认端口锁存器和关键控制寄存器是不是自己以为的值:

void reg_snapshot(void) { printf("P0=%02X P1=%02X P2=%02X P3=%02X\r\n", P0, P1, P2, P3); printf("ADCCON1=%02X ADCCON2=%02X ADCCON3=%02X\r\n", ADCCON1, ADCCON2, ADCCON3); printf("SPICON=%02X SPISTAT=%02X\r\n", SPICON, SPISTAT); }

这段快照不负责判断对错,只负责把“我以为”变成“实际是”。P0到P3看端口锁存值,ADCCON1到ADCCON3看 ADC 是否还停在掉电或未校准状态,SPICON和SPISTAT看 SPI 模式和忙标志。参数上不要一次打印太多,C51 的printf格式化很吃空间,先打十六进制最省事。

核对项常见错误直接后果
系统时钟宏晶振频率写错串口乱码、PWM 频率整体偏
端口复用外设引脚仍被当普通 IO波形出不来或电平被拉死
串口重载值SMOD和公式不匹配能发但全是乱码
看门狗初始化后没喂狗跑几秒就复位,日志突然断
中断优先级串口和定时器互相打断打印丢字符、采样节奏漂

3. ADC 与 DAC 的闭环:12 位转换、基准、回读与线性度

3.1 ADC 配置:通道、采集时间、基准和输入阻抗

ADUC842 的 ADC 是 12 位多通道结构,测试时不要一上来就接传感器,先接一个已知电压或把输入短接到基准分压点。下面这段只保留配置骨架,具体位域按工程头文件替换:

#define ADC_CH 0 #define ADC_VREF_MV 2500UL void adc_init(void) { ADCCON1 = 0x00; /* 先停止转换,清掉旧状态 */ ADCCON2 = (ADC_CH << 4); /* 选通道,位域按头文件核对 */ ADCCON3 = 0x00; /* 结果对齐、增益等先取默认 */ } unsigned int adc_read_once(void) { unsigned int v; ADCCON2 = (ADCCON2 & 0x0F) | (ADC_CH << 4); ADCCON3 |= 0x80; /* 启动单次转换,忙位定义看头文件 */ while (ADCCON3 & 0x80); /* 等转换结束 */ v = ((unsigned int)ADCDATAH << 8) | ADCDATAL; return v & 0x0FFF; /* 只取 12 位结果 */ }

这段代码的关键不在“启动”和“等待”,而在三个参数:通道号、基准电压、采集时间。ADC_CH要和板上实际输入的引脚一致;ADC_VREF_MV先按内部 2.5 V 档估算,若板子外接基准,就改成实测值;采集时间太短会让高阻抗信号源读数偏低,常见做法是先加长采集周期,再逐步缩短。12 位满量程 4096 份,2.5 V 基准下每码约 0.61 mV,算结果时别拿 1024 去除。

如果读数长期是 0 或 0x0FFF,先别怀疑算法。把输入短接到一个已知分压点,确认通道号、基准和结果对齐方式,再查信号源阻抗。ADC 输入前面如果有大电阻分压,采样保持电容充电不够,读出来会明显偏低,这种问题换代码解决不了。

3.2 DAC 输出与 ADC 回读:用一条斜坡找线性区

DAC 测试不要只写一个固定码就结束。我一般让 DAC 从 0 到 4095 走一条斜坡,中间隔几十个码采一次 ADC,串口直接打印码值和回读值。这样既能看 DAC 有没有输出,也能看 ADC 回读是否跟着走:

void dac_write(unsigned int code) { code &= 0x0FFF; DAC0H = (unsigned char)(code >> 8); DAC0L = (unsigned char)(code & 0xFF); } void dac_ramp(void) { unsigned int i; for (i = 0; i < 4096; i += 64) { dac_write(i); delay_ms(2); /* 等输出建立,负载重就加大 */ printf("DAC=%04u ADC=%04u\r\n", i, adc_read_once()); } }

i += 64是把 4096 个码切成 64 个测试点,串口日志不会太长,又能看出趋势。delay_ms(2)是建立等待,DAC 后面如果接了大电容或低阻负载,2 ms 可能不够,波形上会看到台阶被拉平。dac_write()先写高字节再写低字节,有些封装需要更新触发位才真正生效,工程里如果有DAC0CON或更新寄存器,要按头文件补上。

测试时先用万用表看直流平均,再用示波器看台阶。万用表读数正常但示波器台阶很脏,常见原因是输出缓冲能力不足或地线弹跳;万用表读数不跟码值走,先查基准和更新位。DAC 输出不要直接驱动大电流负载,中间加缓冲,否则高边会明显掉。

3.3 结果换算与串口日志:把码值变成可比较的数字

码值本身不直观,我习惯在日志里同时打印码值和毫伏值:

unsigned int code_to_mv(unsigned int code, unsigned int vref_mv) { return (unsigned int)(((unsigned long)code * vref_mv) / 4096UL); } void adc_dump(void) { unsigned int code = adc_read_once(); printf("CH%d code=%04u mv=%u\r\n", ADC_CH, code, code_to_mv(code, ADC_VREF_MV)); }

这里用unsigned long做中间运算,避免code * vref_mv在 16 位机上溢出。4096UL对应 12 位满量程,不要写成 4095,除非你在做端点校准。日志字段保持固定顺序,后面用脚本抓串口时才好解析。CH%d先把通道号打出来,多通道轮询时不会把线接错。

参数示例说明
ADC_VREF_MV2500按实际基准改,不要靠猜
满量程码409612 位转换的分母
单码分辨率约 0.61 mV2.5 V 基准下的理论值
斜坡步长64测试点数量与日志长度的折中
建立等待2 ms负载重时继续加

4. IIC、SPI 与 PWM 的联合联调:上拉、片选、占空比和示波器证据

4.1 IIC 测试:先扫地址,再谈读写时序

IIC 最容易出现“代码看起来对,总线就是不动”。我一般先不写寄存器,先做地址扫描。下面用软件时序做骨架,SCL/SDA 定义在可位寻址端口上,外部必须有上拉:

sbit I2C_SCL = P1^0; sbit I2C_SDA = P1^1; static void i2c_delay(void) { unsigned char i = 8; while (i--); } void i2c_start(void) { I2C_SDA = 1; I2C_SCL = 1; i2c_delay(); I2C_SDA = 0; i2c_delay(); I2C_SCL = 0; } void i2c_stop(void) { I2C_SDA = 0; I2C_SCL = 1; i2c_delay(); I2C_SDA = 1; i2c_delay(); } bit i2c_write_byte(unsigned char b) { unsigned char i; for (i = 0; i < 8; i++) { I2C_SDA = (b & 0x80) ? 1 : 0; b <<= 1; i2c_delay(); I2C_SCL = 1; i2c_delay(); I2C_SCL = 0; } I2C_SDA = 1; i2c_delay(); /* 释放 SDA,等从机 ACK */ I2C_SCL = 1; i2c_delay(); return !I2C_SDA; } void iic_scan(void) { unsigned char addr; for (addr = 0x08; addr <= 0x77; addr++) { i2c_start(); if (i2c_write_byte((unsigned char)(addr << 1))) { printf("IIC ACK 0x%02X\r\n", addr); } i2c_stop(); } }

i2c_delay()里的循环次数不是标准值,只是先给一个保守的低速。上拉电阻常见取 4.7 kΩ,总线长或从机多时再往下调,但别小到灌电流超标。addr << 1是因为扫描用的是 7 位地址,总线上发的是 8 位写地址。i2c_write_byte()返回的是 ACK 状态,返回 0 表示从机没应答,不要把它当成数据位。

如果扫不到任何地址,先量 SCL/SDA 空闲电平是不是高。被拉死在低电平,常见原因是某个从机把总线拽住,或者端口配置成了强推挽。解决思路是断电重启、降低速率、检查上拉,再考虑发时钟脉冲恢复总线。地址能扫到但读写失败,再去看寄存器地址是 8 位还是 16 位、是否需要重复起始条件。

4.2 SPI 测试:CPOL/CPHA 不是“随便选”,片选要参与时序

SPI 先按模式 0 跑,也就是空闲低电平、第一个边沿采样。代码里把片选拉低、发一个字节、等完成、拉高,先用固定字节0x55和0xAA看波形:

sbit SPI_CS = P2^0; void spi_init(void) { SPICON = 0x00; /* 先按模式0、最低速,位域看头文件 */ SPI_CS = 1; } unsigned char spi_xfer(unsigned char d) { SPI_CS = 0; SPIDAT = d; while (!(SPISTAT & 0x80)); /* 等发送完成标志,位定义按头文件 */ SPI_CS = 1; return SPIDAT; } void spi_test(void) { unsigned char a = spi_xfer(0x55); unsigned char b = spi_xfer(0xAA); printf("SPI 55->%02X AA->%02X\r\n", a, b); }

SPICON里的时钟分频先取最大分频,别一上来就跑最高速。SPI_CS要参与时序,很多从机要求片选拉低后至少几个时钟的建立时间,拉高后也要保持一段。SPIDAT写入后读回的值是否等于发送值,取决于从机是否真的回传;如果总线只接了从机 MISO 悬空,读回 0xFF 很常见。

SPI 数据错一位,先查 CPOL/CPHA,再查片选是不是一直低。多个从机共用总线时,片选必须严格互斥,否则两个从机同时驱动 MISO,波形会看起来“有数据但不对”。时钟太快时,杜邦线和探头地线会把边沿弄脏,先把频率降下来,再逐步加。

4.3 PWM:把占空比写成可复算的公式

PWM 测试不要只改一个占空比看灯亮不亮。先固定周期,算出理论频率,再用示波器量。下面这段只表达写周期和比较值的动作,寄存器名按工程头文件替换:

void pwm_set(unsigned int period, unsigned int duty) { if (duty > period) duty = period; PWM0H = (unsigned char)(period >> 8); PWM0L = (unsigned char)(period & 0xFF); PWM1H = (unsigned char)(duty >> 8); PWM1L = (unsigned char)(duty & 0xFF); } void pwm_test(void) { unsigned int p = 999; /* 周期寄存器值,实际频率按时钟算 */ unsigned int d; for (d = 0; d <= p; d += 100) { pwm_set(p, d); delay_ms(200); printf("PWM period=%u duty=%u\r\n", p, d); } }

若 PWM 时钟为f_pwm_clk,周期寄存器为period,输出频率大致是f_pwm_clk / (period + 1),占空比是duty / (period + 1)。p = 999只是示例,实际要拿系统时钟和预分频反算。d += 100让占空比按 10% 左右递增,方便用示波器看台阶。寄存器如果分成高字节和低字节,写入顺序要固定,最好在头文件里确认有没有影子寄存器或更新位。

先量频率,再量占空比。频率不对,查预分频和周期重载;频率对、占空比不对,查比较值是否超过周期、是否有死区或极性反转。驱动电机或大负载时,PWM 输出前面要加驱动,不要拿 MCU 引脚直接扛电流。

5. 避坑与排查:ADUC842 外设测试最常见的五个翻车点

5.1 现象:串口能打印,ADC 结果却全是 0 或 0x0FFF

现象很典型:日志在跑,通道号也换了,ADC 码值不是贴地就是贴满。原因通常不是 ADC 内核坏了,而是通道没真正切换、采集时间太短、输入悬空,或者基准没开。解决时先把输入接到一个已知分压点,确认ADCCON2的通道位确实变了,再把采集时间加长一档。若读数仍然贴边,查结果对齐方式和基准电压,别在换算公式上反复改。

5.2 现象:DAC 写进去电压不动,或者一动就掉

DAC 写码后万用表不动,常见原因是更新位没触发、输出缓冲没开、负载太重。先空载测量,确认高字节和低字节都写进去了,再看工程里有没有需要写更新寄存器的步骤。电压一动就掉,通常是输出驱动能力不够或后面接了低阻负载,中间加缓冲再测。还有一种是基准被其他模块拉低,查基准引脚的去耦电容。

5.3 现象:IIC 扫描不到地址,偶尔读到 0xFF

IIC 扫不到地址,血泪经验是先看硬件:SCL/SDA 有没有上拉、有没有接反、从机是否共地。软件上先确认发的是 7 位地址左移后的写地址,别把 8 位地址又左移一次。总线被拉死时,断电重启能恢复,但根因可能是某个从机在异常状态下拽住 SDA。降低速率、缩短总线、检查上拉阻值,再考虑总线恢复时序。

5.4 现象:SPI 数据错一位,片选一直接低

SPI 错一位,先查 CPOL/CPHA,再查片选。片选一直接低,多个从机会同时驱动 MISO,读回来的数据看起来像“随机但有点规律”。解决时把片选改成软件控制,确保每次传输前后都有明确的拉低和拉高;时钟先降到最低,确认波形干净后再提速。用逻辑分析仪看 CS、SCK、MOSI、MISO 四条线,比盯着串口猜有效得多。

5.5 现象:PWM 占空比看似对,频率却差一半

PWM 频率差一半,常见原因是预分频算错、周期寄存器重载值差一、或者把比较值当成了周期值。先固定占空比 50%,用示波器量频率,再反推时钟和分频。若频率对、占空比在小占空比时明显不准,查比较值是否落在死区或最小脉宽限制内。驱动电机时听到啸叫,不一定是 PWM 频率错,也可能是负载和电源纹波在作怪。

6. 把测试程序做成可重复自检:阈值表、日志字段和老化验证

单独跑通一次不算完,我一般会把每个模块写成“能判失败”的自检表。串口日志固定打印模块,码值,电压,状态,状态只有PASS/FAIL,这样产线或实验室换人也能看懂。下面这张表是我常用的最小判据:

模块判据我常用的阈值
ADC已知分压点回读误差连续 10 次偏差小于 1%
DAC斜坡单调性码值增加时回读不连续反向
IIC地址扫描目标地址必须连续 3 次 ACK
SPI固定字节回环0x55/0xAA连续 20 次一致
PWM频率与占空比频率误差小于 2%,占空比误差小于 3%

验证时不要只跑一遍。上电自检跑 3 次,再做 2 小时老化,日志里记录最小值、最大值和失败次数。ADC 最怕温漂和输入悬空,DAC 最怕负载变化,

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进