车辆遥测平台:硬件搭建与代码解析
1. 硬件连接与组装
- LCD 背光控制 :LCD 的 15 号引脚(背光 + 电源)连接到一个由 Arduino 控制的晶体管。由于 Arduino 输出无法直接提供足够电流驱动背光,晶体管允许通过 PWM 输出控制背光电源,而不会对 CPU 造成危险。对于不同电流需求的 LCD 模块,可选择不同的 PNP 开关晶体管,如电流小于 100mA 可使用 BC557 或 2N2907,大于 200mA 则需使用 2N3906 等。
- 日志控制按钮与状态 LED :使用带中心 LED 的按钮来控制日志的开启和关闭,结合中断输入,能方便地显示当前日志状态。按钮连接在接地端和 Arduino 数字 I/O 线 3 之间,通过 1K 电阻,I/O 线 3 还通过 ATMega CPU 内部的 20K 上拉电阻连接到 +5V。当按钮按下时,输入被拉低,触发中断服务程序(ISR),该程序会设置状态 LED 的输出,并包含去抖动逻辑,以避免误判多次按钮按下。
- 硬件组装方式 :硬件组装方式取决于安装需求,如永久安装或临时连接,以及主要用于实时反馈驾驶风格还是记录数据以供后续分析。对于原型,可将所有部件安装在 PVC 项目箱中,方便拆卸。也可将车辆遥测系统永久安装在仪表盘上,但需注意振动防护。
2. 硬件安装步骤
| 步骤 | 操作 |
|---|---|
| 1 | 将 ELM327 接口适配器的 PCB 垂直安装在箱内后角,使用 6mm 垫片和 M3 螺母螺栓固定,并用塑料垫圈提供足够接触面积。 |
| 2 | 将 DB9 插座安装在后面板,将 female 接头安装到 PCB 上的 8 针 male 接头上。 |
| 3 | 使用 6mm 塑料垫片和 15mm M3 螺母螺栓将 Arduino Mega 安装在箱底,在 Arduino 顶部使用塑料垫圈防止短路,在后面板开孔让 USB 插座突出。 |
| 4 | 将 VDIP1 模块安装在子板上,使用废料板和 female PCB 安装接头,用两段式环氧树脂胶水固定,使 USB 插座前端突出前面板。 |
| 5 | 将 LCD 组件安装在箱顶,使用面板切割工具切割矩形孔,用美工刀清理边缘,钻孔安装三个菜单按钮和 LCD 安装螺栓,使用 6mm 塑料垫片使 LCD 表面略低于箱面。 |
| 6 | 将菜单按钮的一侧连接到 LCD 的接地端,使用带状电缆将左、中、右按钮的另一侧分别连接到模拟输入 8、9、10。 |
| 7 | 安装原型板,包含电源和 LCD、按钮、VDIP1 模块的连接,使用泡沫胶带或环氧树脂胶水固定 4700uF 电容器。 |
| 8 | 将 GPS 模块安装在箱的一侧,使用泡沫胶带固定,确保 GPS 天线指向天空且不被金属阻挡。 |
| 9 | 使用短带状电缆将带中心 LED 的“日志开/关”按钮硬连接到原型板,将按钮和 LED 用胶水固定在前面板。 |
3. OBDuinoMega 代码解析
- 代码结构与库依赖 :OBDuinoMega 代码分为多个源文件,这种结构有助于概念封装,使代码更易理解和维护。根据不同选项,可能需要安装 TinyGPS 和 PString 两个库。TinyGPS 是一个轻量级的 NMEA 解析器,用于解析 GPS 数据;PString 用于管理缓冲区,方便格式化和存储数据。
- 编译选项 :代码包含一系列编译修饰符,可根据需求包含或排除不同功能。例如,DEBUG 选项可跳过 OBD 接口初始化,返回硬编码值,方便在不连接汽车的情况下测试;MEGA 选项用于适配 Arduino Mega 的不同架构;ENABLE_GPS、ENABLE_VDIP 和 ENABLE_PWRFAILDETECT 选项分别启用 GPS、VDIP1 模块和电源故障检测功能。
graph TD;
A[开始] --> B{是否 DEBUG 模式};
B -- 是 --> C[跳过 OBD 初始化,返回硬编码值];
B -- 否 --> D[正常初始化 OBD 接口];
D --> E{是否 MEGA 架构};
E -- 是 --> F[应用 MEGA 相关设置];
E -- 否 --> G[应用非 MEGA 相关设置];
F --> H{是否启用 GPS};
G --> H;
H -- 是 --> I[初始化 GPS 相关设置];
H -- 否 --> J[跳过 GPS 初始化];
I --> K{是否启用 VDIP};
J --> K;
K -- 是 --> L[初始化 VDIP 相关设置];
K -- 否 --> M[跳过 VDIP 初始化];
L --> N{是否启用电源故障检测};
M --> N;
N -- 是 --> O[初始化电源故障检测];
N -- 否 --> P[跳过电源故障检测初始化];
O --> Q[完成初始化,进入主程序];
P --> Q;
4. 串口和 LCD 引脚分配
- 串口分配 :根据是否为 Mega 架构,串口分配有所不同。在 Mega 架构下,HOST、OBD2、GPS 和 VDIP 分别对应不同的串口,且设置了相应的波特率。
#ifdef MEGA
#define HOST Serial
#define HOST_BAUD_RATE 38400
#define OBD2 Serial1
#define OBD2_BAUD_RATE 38400
#define GPS Serial2
#define GPS_BAUD_RATE 57600
#define VDIP Serial3
#define VDIP_BAUD_RATE 9600
byte logActive = 0;
#else
#define OBD2 Serial
#define OBD2_BAUD_RATE 38400
#endif
- LCD 引脚分配 :LCD 引脚分配也根据架构不同而变化,Mega 架构和非 Mega 架构有不同的引脚定义。
#ifdef MEGA
#define DIPin 54 // register select RS
#define DB4Pin 56
#define DB5Pin 57
#define DB6Pin 58
#define DB7Pin 59
#define ContrastPin 6
#define EnablePin 55
#define BrightnessPin 5
#else // LCD Pins same as mpguino for a Duemilanove or equivalent
#define DIPin 4 // register select RS
#define DB4Pin 7
#define DB5Pin 8
#define DB6Pin 12
#define DB7Pin 13
#define ContrastPin 6
#define EnablePin 5
#define BrightnessPin 9
#endif
5. 菜单按钮与中断设置
- 菜单按钮设置 :OBDuinoMega 代码使用模拟引脚作为菜单按钮的数字输入,并设置了端口级中断。通过位掩码确定哪个按钮被按下,这种方式允许使用更多引脚触发中断,而不仅限于定义的中断引脚。
#ifdef MEGA // Button pins for Arduino Mega
#define lbuttonPin 62 // Left Button, on analog 8
#define mbuttonPin 63 // Middle Button, on analog 9
#define rbuttonPin 64 // Right Button, on analog 10
#define lbuttonBit 1 // pin62 is a bitmask 1 on port K
#define mbuttonBit 2 // pin63 is a bitmask 2 on port K
#define rbuttonBit 4 // pin64 is a bitmask 4 on port K
#else // Button pins for Duemilanove or equivalent
#define lbuttonPin 17 // Left Button, on analog 3
#define mbuttonPin 18 // Middle Button, on analog 4
#define rbuttonPin 19 // Right Button, on analog 5
#define lbuttonBit 8 // pin17 is a bitmask 8 on port C
#define mbuttonBit 16 // pin18 is a bitmask 16 on port C
#define rbuttonBit 32 // pin19 is a bitmask 32 on port C
#endif
#define buttonsUp 0 // start with the buttons in the 'not pressed' state
byte buttonState = buttonsUp;
- 中断设置 :根据架构不同,设置不同的端口级中断。对于 Mega 架构,使用 PCINT2;非 Mega 架构使用 PCINT1。
#ifdef MEGA
PCMSK2 |= (1 << PCINT16) | (1 << PCINT17) | (1 << PCINT18);
PCICR |= (1 << PCIE2);
#else
PCMSK1 |= (1 << PCINT11) | (1 << PCINT12) | (1 << PCINT13);
PCICR |= (1 << PCIE1);
#endif
6. 其他设置与功能
- PID 定义与处理 :代码中定义了大量支持的 PID,包括真实的 OBD-II 数据和“假”PID,方便处理不同类型的数据。每个 PID 有对应的响应字节数和简短的人类可读标签,存储在程序内存中以节省 RAM。
#define PID_SUPPORT00 0x00
#define MIL_CODE 0x01
#define FREEZE_DTC 0x02
#define FUEL_STATUS 0x03
#define LOAD_VALUE 0x04
#define COOLANT_TEMP 0x05
... etc
- 日志记录 :日志记录功能通过 logPid 字节数组确定要写入日志文件的 PID 值,使用 PString 库管理日志缓冲区。在满足条件时,将数据写入内存棒的 CSV 文件中,需注意消息长度的检查,避免出现写入错误。
if( logActive == 1 )
{
if(millis() - lastLogWrite > LOG_INTERVAL)
{
digitalWrite(VDIP_WRITE_LED, HIGH);
byte position = 0;
HOST.print(logEntry.length());
HOST.print(": ");
HOST.println(logEntry);
VDIP.print("WRF ");
VDIP.print(logEntry.length() + 1);
VDIP.print(13, BYTE);
while(position < logEntry.length())
{
if(digitalRead(VDIP_RTS_PIN) == LOW)
{
VDIP.print(vdipBuffer[position]);
position++;
} else {
HOST.println("BUFFER FULL");
}
}
VDIP.print(13, BYTE);
digitalWrite(VDIP_WRITE_LED, LOW);
lastLogWrite = millis();
}
}
车辆遥测平台:硬件搭建与代码解析
7. 关键函数解析
- elm_read 函数 :该函数是读取 ELM327 数据的核心函数。它接收一个字符数组指针和一个字节参数,用于存储响应数据和指定数组大小。函数通过循环从串口读取数据,直到遇到提示字符或数组空间用尽。读取过程中,只将大于等于空格字符(ASCII 码 0x20)的字符存入数组。若成功获取完整响应,将数组最后一个字符替换为 null 字符,并返回提示字符;若响应可能无意义,则返回 1 表示缓冲区有原始数据。
byte elm_read(char *str, byte size)
{
int b;
byte i = 0;
while ((b = OBD2.read()) != PROMPT && i < size)
{
if (b >= ' ')
str[i++] = b;
}
if (i != size)
{
str[i] = NUL;
return PROMPT;
}
else
return DATA;
}
-
elm_compact_response 函数
:此函数用于将 ELM327 的原始 ASCII 响应转换为实际的十六进制数值。它跳过响应的前几个字节,从第七个字符开始处理,使用
strtoul函数将字符转换为无符号长整型,并存储在字节数组中。最后返回转换后数值的字节数。
byte elm_compact_response(byte *buf, char *str)
{
byte i = 0;
str += 6;
while (*str != NUL)
buf[i++] = strtoul(str, &str, 16);
return i;
}
- elm_init 函数 :该函数用于初始化与 ELM327 的串口连接。首先以配置的波特率打开串口,并刷新缓冲区。接着发送软复位命令,显示初始化进度信息。为提高响应速度,关闭命令回显。然后通过循环发送请求 PID 0X0100 的命令,直到收到响应,以验证 ELM327 是否正常工作。最后根据 ELM327 自动协商的协议,设置自定义头,将请求定向到主 ECU。
void elm_init()
{
char str[STRLEN];
OBD2.begin(OBD2_BAUD_RATE);
OBD2.flush();
elm_command(str, PSTR("ATWS\r"));
lcd_gotoXY(0, 1);
if (str[0] == 'A')
lcd_print(str + 4);
else
lcd_print(str);
lcd_print_P(PSTR(" Init"));
elm_command(str, PSTR("ATE0\r"));
do
{
elm_command(str, PSTR("0100\r"));
delay(1000);
} while (elm_check_response("0100", str) != 0);
elm_command(str, PSTR("ATDPN\r"));
if (str[1] == '1') // PWM
elm_command(str, PSTR("ATSHE410F1\r"));
else if (str[1] == '2') // VPW
elm_command(str, PSTR("ATSHA810F1\r"));
else if (str[1] == '3') // ISO 9141
elm_command(str, PSTR("ATSH6810F1\r"));
else if (str[1] == '6') // CAN 11 bits
elm_command(str, PSTR("ATSH7E0\r"));
else if (str[1] == '7') // CAN 29 bits
elm_command(str, PSTR("ATSHDA10F1\r"));
}
- get_pid 函数 :该函数用于获取指定 PID 的值,既用于在 LCD 上显示,也用于日志记录。函数首先检查 PID 是否支持,若不支持则返回错误信息。根据系统是否使用 ELM327,采用不同的方法发送请求并获取响应。将响应转换为实际数值后,根据不同的 PID 应用相应的公式计算最终结果。
boolean get_pid(byte pid, char *retbuf, long *ret)
{
#ifdef ELM
char cmd_str[6]; // to send to ELM
char str[STRLEN]; // to receive from ELM
#else
byte cmd[2]; // to send the command
#endif
byte i;
byte buf[10]; // to receive the result
byte reslen;
char decs[16];
unsigned long time_now, delta_time;
static byte nbpid = 0;
if (!is_pid_supported(pid, 0))
{
sprintf_P(retbuf, PSTR("%02X N/A"), pid);
return false;
}
reslen = pgm_read_byte_near(pid_reslen + pid);
#ifdef ELM
sprintf_P(cmd_str, PSTR("01%02X\r"), pid);
elm_write(cmd_str);
elm_read(str, STRLEN);
if (elm_check_response(cmd_str, str) != 0)
{
sprintf_P(retbuf, PSTR("ERROR"));
return false;
}
elm_compact_response(buf, str);
#else
cmd[0] = 0x01; // ISO cmd 1, get PID
cmd[1] = pid;
iso_write_data(cmd, 2);
if (!iso_read_data(buf, reslen))
{
sprintf_P(retbuf, PSTR("ERROR"));
return false;
}
#endif
*ret = buf[0] * 256U + buf[1];
switch (pid)
{
case ENGINE_RPM:
#ifdef DEBUG
*ret = 1726;
#else
*ret = *ret / 4U;
#endif
sprintf_P(retbuf, PSTR("%ld RPM"), *ret);
break;
case MAF_AIR_FLOW:
#ifdef DEBUG
*ret = 2048;
#endif
long_to_dec_str(*ret, decs, 2);
sprintf_P(retbuf, PSTR("%s g/s"), decs);
break;
case FUEL_STATUS:
#ifdef DEBUG
*ret = 0x0200;
#endif
if (buf[0] == 0x01)
sprintf_P(retbuf, PSTR("OPENLOWT")); // Open due to insufficient engine temperature
else if (buf[0] == 0x02)
sprintf_P(retbuf, PSTR("CLSEOXYS")); // Closed loop, using oxygen sensor feedback to determine fuel mix. Should be almost always this
else if (buf[0] == 0x04)
sprintf_P(retbuf, PSTR("OPENLOAD")); // Open loop due to engine load, can trigger DFCO
else if (buf[0] == 0x08)
sprintf_P(retbuf, PSTR("OPENFAIL")); // Open loop due to system failure
else if (buf[0] == 0x10)
sprintf_P(retbuf, PSTR("CLSEBADF")); // Closed loop, using at least one oxygen sensor but there is a fault in the feedback system
else
sprintf_P(retbuf, PSTR("%04lX"), *ret);
break;
case LOAD_VALUE:
case THROTTLE_POS:
case REL_THR_POS:
case EGR:
case EGR_ERROR:
case FUEL_LEVEL:
case ABS_THR_POS_B:
case CMD_THR_ACTU:
#ifdef DEBUG
*ret = 17;
#else
*ret = (buf[0] * 100U) / 255U;
#endif
sprintf_P(retbuf, PSTR("%ld %%"), *ret);
break;
}
return true;
}
8. 故障诊断码处理
-
DTC 解析原理
:OBD-II 中的故障诊断码(DTC)用于指示车辆系统的故障。以响应模式 0x03 为例,其返回的数据包含特定格式的信息。首先,响应头的第一个字节(如 0x43)表示对模式 0x03 请求的响应。后续数据以每两个字节为一组表示一个故障码。将第一个字节拆分为两个半字节(nibbles),第一个半字节进一步拆分为两组两位数字,分别表示故障发生的车辆部分和故障码的定义来源。
|二进制|十六进制|代码|含义|
| ---- | ---- | ---- | ---- |
|00|0|P|动力系统代码|
|01|1|C|底盘代码|
|10|2|B|车身代码|
|11|3|U|网络代码|
|00|0|SAE|SAE 定义|
|01|1|Manufacturer|制造商定义|
|10|2|SAE in P, manufacturer in C, B, and U|在 P 中由 SAE 定义,在 C、B 和 U 中由制造商定义|
|11|3|Jointly defined in P, reserved in C, B, and U|在 P 中联合定义,在 C、B 和 U 中保留|
|0000|0|P0|动力系统,SAE 定义|
|0001|1|P1|动力系统,制造商定义|
|0010|2|P2|动力系统,SAE 定义|
|0011|3|P3|动力系统,联合定义|
|0100|4|C0|底盘,SAE 定义|
|0101|5|C1|底盘,制造商定义|
|0110|6|C2|底盘,制造商定义|
|0111|7|C3|底盘,保留供未来使用|
|1000|8|B0|车身,SAE 定义|
|1001|9|B1|车身,制造商定义|
|1010|A|B2|车身,制造商定义|
|1011|B|B3|车身,保留供未来使用|
|1100|C|U0|网络,SAE 定义|
|1101|D|U1|网络,制造商定义|
|1110|E|U2|网络,制造商定义|
|1111|F|U3|网络,保留供未来使用| - check_mil_code 函数 :该函数用于检查是否有故障诊断码(DTC)存储。首先请求 PID 0x0101,获取“检查发动机”灯(CEL)的当前状态和存储的 DTC 数量。若 CEL 亮起,显示存储的 DTC 数量,并根据连接方式(ELM327 或特定协议电路)处理响应。对于 ELM327 连接,目前仅请求 0x03 并检查响应头,未来需扩展以支持解码和显示有意义的消息;对于非 ELM327 连接,读取存储的 DTC 并将其转换为包含 P、C、B 或 U 前缀的格式,然后显示在 LCD 上。
void check_mil_code(void)
{
unsigned long n;
char str[STRLEN];
byte nb;
#ifndef ELM
byte cmd[2];
byte buf[6];
byte i, j, k;
#endif
if (!get_pid(MIL_CODE, str, &tempLong))
return;
n = (unsigned long)tempLong;
if (1L << 31 & n)
{
nb = (n >> 24) & 0x7F;
lcd_cls_print_P(PSTR("CHECK ENGINE ON"));
lcd_gotoXY(0, 1);
sprintf_P(str, PSTR("%d CODE(S) IN ECU"), nb);
lcd_print(str);
delay(2000);
lcd_cls();
#ifdef ELM
elm_command(str, PSTR("03\r"));
if (str[0] != '4' && str[1] != '3')
return;
lcd_print(str + 3);
delay(5000);
#else
cmd[0] = 0x03;
iso_write_data(cmd, 1);
for (i = 0; i < nb / 3; i++)
{
iso_read_data(buf, 6);
k = 0;
for (j = 0; j < 3; j++)
{
switch (buf[j * 2] & 0xC0)
{
case 0x00:
str[k] = 'P';
break;
case 0x40:
str[k] = 'C';
break;
case 0x80:
str[k] = 'B';
break;
case 0xC0:
str[k] = 'U';
break;
}
k++;
str[k++] = '0' + (buf[j * 2] & 0x30) >> 4;
str[k++] = '0' + (buf[j * 2] & 0x0F);
str[k++] = '0' + (buf[j * 2 + 1] & 0xF0) >> 4;
str[k++] = '0' + (buf[j * 2 + 1] & 0x0F);
}
str[k] = '\0';
lcd_print(str);
lcd_gotoXY(0, 1);
}
#endif
}
}
9. 按钮处理与菜单功能
- test_buttons 函数 :该函数用于检查按钮状态并根据不同的按钮组合执行相应操作。例如,同时按下中间和左按钮触发油箱重置;同时按下中间和右按钮重置行程和外出数据;同时按下左和右按钮显示当前显示的 PID 的文本标签;单独按下左按钮循环切换显示的“屏幕”;单独按下右按钮循环切换 LCD 的亮度设置;单独按下中间按钮进入配置菜单。处理完按钮状态后,重置按钮状态以准备下一次中断更新。
void test_buttons(void)
{
if (MIDDLE_BUTTON_PRESSED && LEFT_BUTTON_PRESSED)
{
needBacklight(true);
trip_reset(TANK, true);
}
else if (MIDDLE_BUTTON_PRESSED && RIGHT_BUTTON_PRESSED)
{
needBacklight(true);
trip_reset(TRIP, true);
trip_reset(OUTING, true);
}
else if (LEFT_BUTTON_PRESSED && RIGHT_BUTTON_PRESSED)
{
display_PID_names();
}
else if (LEFT_BUTTON_PRESSED)
{
active_screen = (active_screen + 1) % NBSCREEN;
display_PID_names();
}
else if (RIGHT_BUTTON_PRESSED)
{
char str[STRLEN] = {0};
brightnessIdx = (brightnessIdx + 1) % brightnessLength;
analogWrite(BrightnessPin, brightness[brightnessIdx]);
lcd_cls_print_P(PSTR(" LCD backlight"));
lcd_gotoXY(6, 1);
sprintf_P(str, PSTR("%d / %d"), brightnessIdx + 1, brightnessLength);
lcd_print(str);
delay(500);
}
else if (MIDDLE_BUTTON_PRESSED)
{
needBacklight(true);
config_menu();
}
if (buttonState != buttonsUp)
{
delay_reset_button();
needBacklight(false);
}
}
-
配置菜单与参数保存
:配置菜单由
config_menu函数处理,该函数包含大量嵌套的if语句。通过配置菜单更改参数后,调用params_save函数将参数保存到 EEPROM 中。该函数计算参数的循环冗余校验(CRC)值,并将参数和 CRC 值依次写入 EEPROM。启动时,params_load函数从 EEPROM 中读取参数和 CRC 值,重新计算 CRC 并与存储的 CRC 比较,若相等则更新全局参数变量。
void params_save(void)
{
uint16_t crc;
byte *p;
crc = 0;
p = (byte *)¶ms;
for (byte i = 0; i < sizeof(params_t); i++)
crc += p[i];
eeprom_write_block((const void *)¶ms, (void *)0, sizeof(params_t));
eeprom_write_word((uint16_t *)sizeof(params_t), crc);
}
void params_load(void)
{
hostPrint(" * Loading default parameters ");
params_t params_tmp;
uint16_t crc, crc_calc;
byte *p;
eeprom_read_block((void *)¶ms_tmp, (void *)0, sizeof(params_t));
crc = eeprom_read_word((const uint16_t *)sizeof(params_t));
crc_calc = 0;
p = (byte *)¶ms_tmp;
for (byte i = 0; i < sizeof(params_t); i++)
crc_calc += p[i];
if (crc == crc_calc)
params = params_tmp;
hostPrintLn("[OK]");
}
10. 内存管理与测试
- 内存分配与风险 :Arduino 等微控制器的内存资源有限,如 ATMega8 只有 1KB 的 RAM,ATMega168 有 2KB,ATMega1280 有 8KB。这些内存需要容纳程序中的静态变量、栈和堆。静态变量位于内存地址空间的底部,堆从静态区域上方开始增长,通过“堆指针”跟踪顶部位置;栈从可用 RAM 的顶部向下增长,通过“栈指针”跟踪底部位置。若堆和栈在中间相遇,程序将耗尽内存,可能导致变量值异常、函数无法返回等问题。
-
memoryTest 函数
:为了监控内存使用情况,代码中包含
memoryTest函数,该函数返回当前 RAM 中的空闲字节数。通过比较栈指针和静态分配内存范围顶部(__bss_end)或堆顶部(__brkval)的地址,计算出空闲内存大小。
extern int __bss_end;
extern int *__brkval;
int memoryTest(void)
{
int free_memory;
if ((int)__brkval == 0)
free_memory = ((int)&free_memory) - ((int)&__bss_end);
else
free_memory = ((int)&free_memory) - ((int)__brkval);
return free_memory;
}
综上所述,车辆遥测平台的搭建涉及硬件连接、组装以及复杂的代码实现。通过合理的硬件布局和优化的代码设计,可以实现车辆数据的采集、记录和显示,同时对故障诊断码进行有效处理。在开发过程中,需要注意内存管理,避免因内存不足导致程序异常。通过对各个关键函数和功能的深入理解,可以更好地定制和扩展车辆遥测平台的功能,满足不同的应用需求。
超级会员免费看
30

被折叠的 条评论
为什么被折叠?



