實(shí)戰(zhàn):從能收發(fā)到可靠收發(fā)的嵌入式設(shè)計(jì))
搞嵌入式或者汽車電子的朋友對(duì)CAN總線應(yīng)該都不陌生。車載網(wǎng)絡(luò)里到處是CAN發(fā)動(dòng)機(jī)、車身控制器、電池管理、儀表全靠一根差分對(duì)在傳數(shù)據(jù)。去年我在某個(gè)項(xiàng)目上寫了一套CAN通信代碼當(dāng)時(shí)主要是驗(yàn)證功能跑得倒也正常但一放到真實(shí)工程里就暴露出不少問(wèn)題配置寫死在代碼里、接收靠輪詢、多幀數(shù)據(jù)來(lái)不及處理會(huì)丟、出錯(cuò)之后不會(huì)恢復(fù)傳輸中斷只能重啟。這次出的升級(jí)版把這些問(wèn)題逐個(gè)收拾掉了順手把結(jié)構(gòu)重構(gòu)成能直接拿到其他項(xiàng)目復(fù)用的模塊。這篇文章主要講清楚升級(jí)版是怎么設(shè)計(jì)的、每個(gè)模塊為什么這么寫、移植時(shí)又踩了哪些坑適合正在寫或者準(zhǔn)備用CAN通信代碼的嵌入式工程師尤其是想從“能收發(fā)”往“可靠收發(fā)”走一步的朋友。1. 升級(jí)版究竟升級(jí)了什么先給初版“算算賬”1.1 初版代碼的幾個(gè)經(jīng)典痛點(diǎn)先說(shuō)初版代碼。當(dāng)時(shí)驗(yàn)證功能思路很簡(jiǎn)單初始化一個(gè)CAN控制器配置好波特率然后在主循環(huán)里輪詢接收標(biāo)志位有幀就進(jìn)中斷或者標(biāo)志判斷里讀出來(lái)打印到串口。代碼幾十行就能跑通在桌面上用兩個(gè)開發(fā)板對(duì)測(cè)效果挺理想。可事情一旦換到真實(shí)項(xiàng)目里問(wèn)題就全冒出來(lái)了。第一個(gè)痛點(diǎn)是配置硬編碼。波特率、濾波ID、工作模式全都寫在初始化函數(shù)里換一個(gè)MCU型號(hào)整個(gè)文件就得重寫甚至同一個(gè)芯片換了一個(gè)應(yīng)用場(chǎng)景也要重新翻代碼改參數(shù)。這個(gè)問(wèn)題的根源不是配置項(xiàng)本身多而是配置和驅(qū)動(dòng)邏輯攪在一起一改配置就容易把驅(qū)動(dòng)邏輯改壞。第二個(gè)痛點(diǎn)是純輪詢收發(fā)。主循環(huán)在等著一幀數(shù)據(jù)的時(shí)候如果總線上一段時(shí)間沒有報(bào)文整個(gè)CPU全被空轉(zhuǎn)占掉中斷里有數(shù)據(jù)要處理的時(shí)候主循環(huán)又在忙其他的兩邊一撞就丟幀。說(shuō)白了就是沒有緩沖機(jī)制數(shù)據(jù)來(lái)一條收一條收不過(guò)來(lái)就放棄。第三個(gè)痛點(diǎn)是錯(cuò)誤處理約等于零。初版代碼只在出錯(cuò)時(shí)點(diǎn)亮一顆LED具體是什么錯(cuò)誤、需不需要恢復(fù)、恢復(fù)之后怎么重新參與通信完全沒有設(shè)計(jì)。CAN總線上任何一個(gè)節(jié)點(diǎn)出錯(cuò)進(jìn)入bus-off狀態(tài)如果不處理整個(gè)節(jié)點(diǎn)就永遠(yuǎn)脫離網(wǎng)絡(luò)了這在整車上是很嚴(yán)重的問(wèn)題。1.2 升級(jí)版定下的四個(gè)目標(biāo)升級(jí)版動(dòng)手之前我給自己定了四個(gè)目標(biāo)后面所有代碼都是圍繞這四個(gè)目標(biāo)展開的。一是可配置。所有的波特率、過(guò)濾器、工作模式、中斷優(yōu)先級(jí)全部通過(guò)配置文件注入驅(qū)動(dòng)代碼里不允許出現(xiàn)魔法數(shù)字。這樣換個(gè)場(chǎng)景只需要改配置頭文件驅(qū)動(dòng)層一行不用動(dòng)。二是可移植。把代碼分成兩層跟具體芯片寄存器相關(guān)的HAL層和通用邏輯的協(xié)議/驅(qū)動(dòng)層。換芯片只改HAL層上層邏輯原封不動(dòng)搬走。這一步看似費(fèi)工但_project多了以后回報(bào)很高。三是可靠。錯(cuò)誤計(jì)數(shù)器要能讀出來(lái)狀態(tài)遷移要有清晰邏輯bus-off要能自動(dòng)恢復(fù)收發(fā)要有緩沖區(qū)不能讓“來(lái)不及處理”成為丟幀的借口。四是可擴(kuò)展。數(shù)據(jù)結(jié)構(gòu)里預(yù)留好擴(kuò)展幀、遠(yuǎn)程幀、CAN FD幀的位置后續(xù)如果需要診斷協(xié)議或升級(jí)到CAN FD不用把整個(gè)模塊重寫。這四條定下來(lái)整個(gè)代碼的骨架就比較清楚了。下面直接說(shuō)架構(gòu)和實(shí)現(xiàn)。2. 代碼怎么分層才經(jīng)得起折騰一個(gè)清晰的框架比什么都重要2.1 從“裸機(jī)直寫”到“分層模塊”的思路初版代碼最大的問(wèn)題就是“一把梭”。所有邏輯都堆在一個(gè)文件里寄存器操作、協(xié)議解析、應(yīng)用處理全部混在一起看著代碼不多維護(hù)起來(lái)很痛苦。升級(jí)版第一件事就是把職責(zé)拆開。我采用的是四層結(jié)構(gòu)應(yīng)用層只需要調(diào)用發(fā)送接口、注冊(cè)接收回調(diào)完全不關(guān)心CAN控制器寄存器長(zhǎng)什么樣。協(xié)議/驅(qū)動(dòng)層負(fù)責(zé)幀封裝、解析、校驗(yàn)、過(guò)濾規(guī)則匹配這里不出現(xiàn)具體寄存器操作。HAL層封裝所有跟芯片相關(guān)的寄存器讀寫、中斷入口、時(shí)鐘配置這一層換平臺(tái)時(shí)才動(dòng)。配置層一份配置文件定義波特率、過(guò)濾器、通道數(shù)量和回調(diào)函數(shù)。打個(gè)比方這就像快遞鏈條。HAL層是各家物流網(wǎng)點(diǎn)負(fù)責(zé)把包裹搬上車協(xié)議層是運(yùn)輸路線和分揀規(guī)則應(yīng)用層是收件人只關(guān)心包裹到?jīng)]到、內(nèi)容對(duì)不對(duì)。各干各的哪一環(huán)壞了只修那一環(huán)不用把整條鏈拆了。層與層之間的接口要定義干凈。我用一個(gè)can_send(ch, msg)和一個(gè)接收回調(diào)can_on_rx(ch, msg)作為上下層之間的唯一通道。HAL層收到中斷把數(shù)據(jù)搬進(jìn)緩沖區(qū)驅(qū)動(dòng)層在緩沖區(qū)里取幀應(yīng)用層在回調(diào)里拿最終結(jié)果。這樣每一層都能單獨(dú)測(cè)試也方便將來(lái)?yè)Q成RTOS環(huán)境。2.2 核心數(shù)據(jù)結(jié)構(gòu)是怎么設(shè)計(jì)的升級(jí)版里最重要的一個(gè)結(jié)構(gòu)體是報(bào)文消息所有上層收發(fā)都靠它傳遞數(shù)據(jù)。我把它設(shè)計(jì)成這樣typedef struct { uint32_t id; /* 標(biāo)準(zhǔn)幀11bit擴(kuò)展幀29bit */ uint8_t data[64]; /* 經(jīng)典CAN最多8字節(jié)CAN FD最多64字節(jié) */ uint8_t dlc; /* 當(dāng)前幀實(shí)際數(shù)據(jù)長(zhǎng)度 */ uint8_t ch; /* 通道號(hào)多路CAN時(shí)用 */ uint8_t flags; /* bit0: 擴(kuò)展幀; bit1: 遠(yuǎn)程幀; bit2: FD幀 */ uint32_t ts_ms; /* 接收時(shí)間戳單位毫秒 */ } can_msg_t;這里每個(gè)字段都有講究。id用uint32_t是為了同時(shí)容納標(biāo)準(zhǔn)幀和擴(kuò)展幀避免為ID類型單獨(dú)開兩個(gè)結(jié)構(gòu)體。data[64]看著有點(diǎn)浪費(fèi)但我特意留到64字節(jié)因?yàn)楹罄m(xù)如果做CAN FD升級(jí)數(shù)據(jù)結(jié)構(gòu)這塊就不用再動(dòng)了。flags比單純一個(gè)bool好用它把所有“幀類型”信息合成一個(gè)字節(jié)將來(lái)加新類型也容易。ts_ms是最容易被忽略的字段但調(diào)試的時(shí)候特別有用——能知道某條報(bào)文距離上一條過(guò)去了多久定位周期超時(shí)問(wèn)題靠它。配置文件里的過(guò)濾器和運(yùn)行參數(shù)也用結(jié)構(gòu)體統(tǒng)一管理typedef struct { uint32_t id; /* 參考ID */ uint32_t mask; /* 掩碼0表示不關(guān)心1表示必須匹配 */ uint8_t extended; /* 1-擴(kuò)展幀0-標(biāo)準(zhǔn)幀 */ } can_filter_t; typedef struct { uint32_t baudrate; /* 期望波特率如500000 */ uint8_t sample_pos; /* 采樣點(diǎn)百分制建議75~85 */ uint8_t mode; /* 0-正常1-回環(huán)2-靜默 */ can_filter_t filters[8]; uint8_t filter_num; uint8_t fd_enable; /* 是否啟用CAN FD */ } can_config_t;為什么過(guò)濾規(guī)則要用“ID 掩碼”而不是給一串ID列表因?yàn)楫?dāng)前市面上的CAN控制器幾乎都支持“掩碼模式”內(nèi)核里是一套位比較邏輯比逐個(gè)ID匹配省資源而且能通過(guò)一個(gè)配置覆蓋一段地址范圍的幀。比如要接收ID 0x100到0x10F的所有幀列表模式要寫16條掩碼模式一條就夠ID設(shè)為0x100掩碼設(shè)為0x1F0低4位不關(guān)心。2.3 環(huán)形緩沖區(qū)中斷和主循環(huán)之間的“快遞中轉(zhuǎn)站”這個(gè)問(wèn)題在初版代碼里完全沒處理升級(jí)版必須補(bǔ)上。真實(shí)總線上數(shù)據(jù)不會(huì)等你主循環(huán)可能在刷寫Flash或者在處理優(yōu)先級(jí)更高的事中斷里讀出來(lái)的幀不可能一直保存在寄存器里必須搬到內(nèi)存緩沖區(qū)。收發(fā)兩端速度不匹配就用一個(gè)隊(duì)列來(lái)“削峰填谷”。我用的是一塊環(huán)形緩沖區(qū)變量就兩個(gè)head寫指針和tail讀指針。#define RX_FIFO_SIZE 32 typedef struct { can_msg_t buf[RX_FIFO_SIZE]; volatile uint16_t head; volatile uint16_t tail; uint32_t overruns; } can_rx_fifo_t; static void fifo_push(can_rx_fifo_t *f, const can_msg_t *m) { uint16_t next (uint16_t)((f-head 1) % RX_FIFO_SIZE); if (next ! f-tail) { f-buf[f-head] *m; f-head next; } else { f-overruns; } } static int fifo_pop(can_rx_fifo_t *f, can_msg_t *m) { if (f-head f-tail) { return 0; } *m f-buf[f-tail]; f-tail (uint16_t)((f-tail 1) % RX_FIFO_SIZE); return 1; }這套代碼看著簡(jiǎn)單有幾個(gè)關(guān)鍵點(diǎn)必須注意。head和tail加了volatile因?yàn)樗鼈冊(cè)谥袛嗪椭餮h(huán)兩側(cè)被訪問(wèn)。單生產(chǎn)者單消費(fèi)者模式下中斷只寫head主循環(huán)只寫tail不需要加鎖但如果你用了我上面這種“先取next再判斷滿”的寫法要確認(rèn)編譯器的內(nèi)存序處理。更穩(wěn)妥的做法是在fifo_push前后關(guān)閉CAN接收中斷但那樣會(huì)增加中斷關(guān)閉時(shí)間我在實(shí)測(cè)中采用“只用兩個(gè)變量順序?qū)憽钡姆桨概芰艘荒甓鄾]出問(wèn)題。如果你的平臺(tái)有DMA或者雙核共享內(nèi)存這里需要重新評(píng)估。緩沖區(qū)大小RX_FIFO_SIZE選32幀。經(jīng)典CAN一條報(bào)文最多8字節(jié)32幀也就256字節(jié)對(duì)RAM壓力幾乎為零。但如果你跑的是那種一包接一包的刷寫場(chǎng)景建議評(píng)估峰值幀率算出一個(gè)保守值再往上加。我后面的調(diào)試經(jīng)歷里就因?yàn)檫@個(gè)吃了虧。3. 核心代碼實(shí)現(xiàn)全過(guò)程該抄作業(yè)的環(huán)節(jié)都在這了3.1 配置項(xiàng)與波特率計(jì)算用公式替代魔法數(shù)字配置文件的引入讓“換個(gè)波特率就要翻代碼”這件事徹底成為過(guò)去。但配置只是第一步波特率怎么計(jì)算成寄存器值里面的坑不少。CAN的位時(shí)間由四段組成同步段、傳播段、相位緩沖段1、相位緩沖段2。波特率公式是can_bus_clk / (presc * (1 seg1 seg2))其中同步段固定在1個(gè)TQseg1是傳播段加相位緩沖段1seg2是相位緩沖段2。采樣點(diǎn)就是(1 seg1) / (1 seg1 seg2)一般建議75%到85%之間放在比較靠后的位置是因?yàn)榭偩€存在信號(hào)傳播延遲和節(jié)點(diǎn)時(shí)鐘偏差采樣點(diǎn)太靠前容易誤判顯性位的邊沿。我寫了一個(gè)工具函數(shù)傳入外設(shè)時(shí)鐘、目標(biāo)波特率和采樣點(diǎn)百分比自動(dòng)算出預(yù)分頻和段長(zhǎng)度static int can_brr_calc(uint32_t sys_clk, uint32_t baud, uint8_t sample_point, uint32_t *brr_reg) { uint32_t target sys_clk / baud; for (uint32_t ps 1; ps 64; ps) { if (target % ps ! 0) { continue; } uint32_t tq target / ps; if (tq 8 || tq 25) { continue; } uint8_t seg1 (uint8_t)((tq * sample_point / 100) - 1); uint8_t seg2 (uint8_t)(tq - seg1 - 2); if (seg1 1 seg2 1 seg2 8) { *brr_reg (ps - 1) | ((seg1 - 1) 8) | ((seg2 - 1) 12); return 0; } } return -1; }這里brr_reg的位域定義是我按通用CAN控制器畫的示意具體芯片可能不同但“預(yù)分頻、時(shí)間段1、時(shí)間段2”三段式的思路是通用的。拿到實(shí)際數(shù)據(jù)手冊(cè)后把位域的位置對(duì)應(yīng)改一下就能用。說(shuō)一個(gè)經(jīng)驗(yàn)target sys_clk / baud能整除的情況最理想如果除不盡別硬湊。有一條CAN規(guī)則是位時(shí)間TQ總數(shù)差值不能太大8到25這個(gè)范圍是我在實(shí)際選型時(shí)常用的安全區(qū)間。波特率偏差大于1.5%左右就很難在一個(gè)長(zhǎng)報(bào)文上保證同步了所以優(yōu)先選能整除的預(yù)分頻值。采樣點(diǎn)這個(gè)值也值得認(rèn)真選。我在一個(gè)項(xiàng)目里剛開始用50%的采樣點(diǎn)點(diǎn)對(duì)點(diǎn)通信沒問(wèn)題可一旦總線掛了十幾個(gè)節(jié)點(diǎn)、線纜長(zhǎng)度超過(guò)3米錯(cuò)誤幀明顯變多。改成80%之后問(wèn)題消失。原因就是采樣點(diǎn)太靠中間干擾疊加后很容易在采樣時(shí)刻讀到錯(cuò)誤電平。建議不要低于70%。3.2 初始化流程的順序不能亂初始化代碼很多人隨手寫先配置GPIO再開啟時(shí)鐘或者先配CAN控制器再配過(guò)濾器。實(shí)際上順序錯(cuò)了大概率跑不通。我整理了一個(gè)穩(wěn)定的初始化順序?qū)懗珊瘮?shù)后就不用再動(dòng)開啟CAN外設(shè)時(shí)鐘、GPIO時(shí)鐘配置CAN_TX、CAN_RX引腳為復(fù)用功能先退出初始化模式再進(jìn)設(shè)置模式按控制器狀態(tài)機(jī)要求操作把波特率寄存器值寫入位時(shí)序寄存器設(shè)置工作模式正常/回環(huán)/靜默配置過(guò)濾器組和掩碼清空中斷標(biāo)志使能收發(fā)中斷請(qǐng)求進(jìn)入正常模式確認(rèn)模式切換成功。這里第3步和第8步是最容易漏的。很多控制器有“初始化模式”和“正常模式”之間的切換過(guò)程模式切換不是瞬間完成要讀模式狀態(tài)寄存器確認(rèn)如果直接寫一個(gè)“請(qǐng)求正常模式”的位然后立刻開中斷中斷往往沒真正觸發(fā)。初始化失敗的情況真要遇到我建議把模式狀態(tài)寄存器、時(shí)鐘狀態(tài)寄存器、錯(cuò)誤寄存器全部打出來(lái)先確認(rèn)控制器是不是真的進(jìn)了想要的狀態(tài)。別急著改GPIO配置因?yàn)榇蟛糠帧耙簧想姴荒芡ㄐ拧钡膯?wèn)題根本不是引腳配錯(cuò)而是模式?jīng)]切過(guò)去。3.3 報(bào)文發(fā)送阻塞、超時(shí)與發(fā)送完成標(biāo)志發(fā)送函數(shù)的設(shè)計(jì)核心是“不能因?yàn)榘l(fā)送失敗把主循環(huán)堵死”。我種做法是發(fā)送函數(shù)只負(fù)責(zé)“把幀寫入發(fā)送郵箱并請(qǐng)求發(fā)送”發(fā)送完成與否交給發(fā)送完成中斷來(lái)置標(biāo)志。主循環(huán)調(diào)用發(fā)送函數(shù)時(shí)等待一個(gè)超時(shí)時(shí)間超時(shí)就返回錯(cuò)誤。int can_send(uint8_t ch, can_msg_t *msg) { can_dev_t *dev g_can[ch]; uint32_t tickout get_ticks() 10; while (dev-tx_busy) { if (tick_before(get_ticks(), tickout)) { return CAN_ERR_TIMEOUT; } } if (dev-mode CAN_MODE_LOOPBACK) { /* 回環(huán)模式直接進(jìn)接收路徑不走總線 */ } fill_tx_mailbox(dev, msg); dev-tx_busy 1; return CAN_OK; }這個(gè)實(shí)現(xiàn)看著簡(jiǎn)單背后的思路是“忙標(biāo)志”管理。tx_busy在發(fā)送完成中斷里被清零void CAN_TX_ISR(can_dev_t *dev) { if (tx_ok_flag(dev)) { dev-tx_busy 0; clear_tx_ok_flag(dev); } }為什么要用發(fā)送完成中斷而不是發(fā)送請(qǐng)求后直接說(shuō)完成因?yàn)橛布戇M(jìn)郵箱只是“排隊(duì)成功”真正把報(bào)文發(fā)送到總線上還需要時(shí)間如果郵箱忙或總線正處于錯(cuò)誤恢復(fù)階段請(qǐng)求發(fā)送的動(dòng)作可能失敗。等發(fā)送完成中斷拉低tx_busy才能準(zhǔn)確知道這幀已經(jīng)送出去了。發(fā)送失敗時(shí)控制器會(huì)置一個(gè)錯(cuò)誤標(biāo)志此時(shí)及時(shí)清標(biāo)志并返回應(yīng)用層才能決定是否重發(fā)。阻塞等待的10ms是拍腦袋定的嗎不是。500kbps下最長(zhǎng)的經(jīng)典CAN報(bào)文擴(kuò)展幀8字節(jié)數(shù)據(jù)總長(zhǎng)約145ns到160ns換算下來(lái)1ms能傳幾千幀發(fā)送一幀從請(qǐng)求到送出的時(shí)間在微秒到幾百微秒量級(jí)。10ms的超時(shí)足夠?qū)捜萦帜懿m住“控制器已經(jīng)異?!边@種嚴(yán)重情況。如果你的波特率特別低比如125kbps單幀時(shí)間會(huì)更長(zhǎng)超時(shí)值要相應(yīng)放大。3.4 中斷接收、過(guò)濾器與回調(diào)處理接收這半邊是升級(jí)版的重頭戲。我的做法是CAN接收中斷里用最快的速度把郵箱里的幀搬進(jìn)環(huán)形緩沖區(qū)清標(biāo)志位然后把“有數(shù)據(jù)”這件事用一個(gè)全局標(biāo)志暴露給主循環(huán)主循環(huán)或應(yīng)用層再?gòu)木彌_區(qū)里取幀處理。中斷服務(wù)函數(shù)里只有三個(gè)動(dòng)作任何多余判斷都往外拿void CAN_RX_ISR(can_dev_t *dev) { while (rx_mailbox_pending(dev)) { can_msg_t m; read_rx_mailbox(dev, m); fifo_push(dev-rx_fifo, m); } clear_rx_flag(dev); }這里用while循環(huán)把郵箱里所有待讀幀一次搬完清一次標(biāo)志就行。如果你在中斷里只讀一幀就退出而硬件FIFO里還有好幾幀下次中斷什么時(shí)候來(lái)就不確定了——有的控制器會(huì)立刻重新觸發(fā)有的不會(huì)表現(xiàn)就是“丟幀其實(shí)是沒讀干凈”。這是我踩過(guò)的一個(gè)坑。過(guò)濾器這塊單獨(dú)說(shuō)一下。掩碼模式下的“1”到底是“必須檢查”還是“忽略”不同芯片定義不一樣有的甚至反過(guò)來(lái)。我看到很多初學(xué)者在這里栽過(guò)配置完過(guò)濾器之后一條幀都收不到或者什么幀都能收到。所以我的建議是先回環(huán)模式跑一遍確認(rèn)過(guò)濾邏輯跟預(yù)期一致再接真實(shí)總線。void CAN_FILTER_CFG(can_dev_t *dev, can_filter_t *f, int num) { /* 對(duì)每個(gè)f按芯片寄存器要求寫入ID和掩碼 */ for (int i 0; i num; i) { set_filter_id(dev, i, f[i].id); set_filter_mask(dev, i, f[i].mask); set_filter_ext(dev, i, f[i].extended); } }應(yīng)用層收數(shù)據(jù)的回調(diào)我故意做得很薄回調(diào)里只允許做兩件事拷貝消息、置一個(gè)“應(yīng)用層待處理”的信號(hào)量或標(biāo)志。絕對(duì)不要在回調(diào)里做協(xié)議解析、打印或者Flash寫入。原因很簡(jiǎn)單回調(diào)如果執(zhí)行時(shí)間過(guò)長(zhǎng)接收緩沖區(qū)很快就滿了新幀只能被丟棄。真實(shí)項(xiàng)目中我有一次把一條UDS響應(yīng)解析放在接收回調(diào)里結(jié)果刷寫速率一下子掉了30%去掉之后立刻恢復(fù)。3.5 錯(cuò)誤狀態(tài)遷移與自動(dòng)恢復(fù)錯(cuò)誤處理是升級(jí)版里最值錢的部分。CAN控制器內(nèi)部有發(fā)送錯(cuò)誤計(jì)數(shù)和接收錯(cuò)誤計(jì)數(shù)兩個(gè)寄存器根據(jù)計(jì)數(shù)大小節(jié)點(diǎn)會(huì)處于三種狀態(tài)狀態(tài)錯(cuò)誤計(jì)數(shù)范圍行為表現(xiàn)錯(cuò)誤主動(dòng)0 ~ 127正常參與通信出錯(cuò)時(shí)可以主動(dòng)發(fā)送錯(cuò)誤幀錯(cuò)誤被動(dòng)128 ~ 255只監(jiān)聽總線不能主動(dòng)發(fā)錯(cuò)誤幀重傳前需要等待6個(gè)隱藏位總線關(guān)閉256以上完全脫離總線既不收發(fā)也不應(yīng)答真實(shí)項(xiàng)目里最容易出現(xiàn)的是連上總線后誰(shuí)都不說(shuō)話查了半天發(fā)現(xiàn)某個(gè)節(jié)點(diǎn)的錯(cuò)誤計(jì)數(shù)已經(jīng)超過(guò)256處于bus-off狀態(tài)。這種情況下的恢復(fù)策略是void can_err_state_update(can_dev_t *dev) { uint8_t tec get_tec(dev); uint8_t rec get_rec(dev); if (dev-err_state ERR_BUSOFF) { if (tec 0) { /* 控制器內(nèi)部已經(jīng)恢復(fù)重新參與通信 */ dev-err_state ERR_ACTIVE; request_normal_mode(dev); } return; } if (tec 128 || rec 128) { dev-err_state ERR_PASSIVE; } else { dev-err_state ERR_ACTIVE; } }總線關(guān)閉的恢復(fù)不同控制器有不同策略。有些硬件會(huì)自動(dòng)重發(fā)等待有些需要軟件把控制器復(fù)位重來(lái)一遍。我的建議是能軟件恢復(fù)就不要硬件斷電。在整車環(huán)境里一個(gè)節(jié)點(diǎn)反復(fù)重啟會(huì)拖累整個(gè)網(wǎng)絡(luò)的穩(wěn)定性而且重啟過(guò)程中如果總線上正好在跑多幀傳輸也容易干擾其他節(jié)點(diǎn)。錯(cuò)誤中斷里除了讀計(jì)數(shù)還要記一條日志。哪怕是往一個(gè)全局?jǐn)?shù)組里塞一個(gè)錯(cuò)誤碼也比“亮個(gè)燈然后什么都不知道”強(qiáng)。我調(diào)試時(shí)最常用的做法是把錯(cuò)誤碼和時(shí)間戳打印出來(lái)看是發(fā)送錯(cuò)誤、接收錯(cuò)誤還是應(yīng)答錯(cuò)誤——總線負(fù)載高時(shí)這三種錯(cuò)誤的處理思路完全不一樣。4. 移植和調(diào)試中遇到的典型問(wèn)題這些問(wèn)題基本人人都遇到過(guò)4.1 波特率差一點(diǎn)點(diǎn)總線就靜默我遇到過(guò)一個(gè)很典型的問(wèn)題兩個(gè)板子都配置成500kbps軟件里波特率寄存器看起來(lái)也對(duì)但連上總線就是通信失敗用示波器看波形卻看不出問(wèn)題。后來(lái)用CAN協(xié)議分析儀去讀實(shí)際波特率發(fā)現(xiàn)一個(gè)節(jié)點(diǎn)實(shí)際是497kbps另一個(gè)是503kbps差了1.2%。單幀短的時(shí)候沒事一碰上連續(xù)多幀傳輸偶爾就撞出錯(cuò)誤幀表現(xiàn)成“時(shí)好時(shí)壞”。原因是兩個(gè)節(jié)點(diǎn)用了不同的時(shí)鐘源一個(gè)外接20MHz晶振精度高另一個(gè)用的是MCU內(nèi)部RC振蕩器標(biāo)稱20MHz實(shí)際偏差在±1%以上。這種問(wèn)題在實(shí)驗(yàn)室用開發(fā)板測(cè)試時(shí)不容易出現(xiàn)因?yàn)殚_發(fā)板上都有晶振一放到量產(chǎn)的控制器上就暴露了。排查思路是先用工具函數(shù)把波特率誤差算出來(lái)再確認(rèn)兩個(gè)節(jié)點(diǎn)的時(shí)鐘源精度如果對(duì)精度沒把握優(yōu)先選低波特率比如把500k降到250k容忍度會(huì)高很多。4.2 過(guò)濾器把該收的幀全擋了過(guò)濾器的問(wèn)題是“配了等于沒配”和“不配就收垃圾”兩個(gè)方向都存在。有過(guò)一次經(jīng)歷我把過(guò)濾器配置成“全部通過(guò)”結(jié)果總線上所有節(jié)點(diǎn)的報(bào)文都進(jìn)了緩沖區(qū)主循環(huán)被塞到死循環(huán)其它任務(wù)全卡住。問(wèn)題根源不是緩沖區(qū)太小而是“所有幀都接收”這個(gè)配置讓中斷頻率變得極高CPU大部分時(shí)間花在搬數(shù)據(jù)上。反過(guò)來(lái)把掩碼配成“全部必須精確匹配”之后一條幀都收不到。排查時(shí)看了半天最后發(fā)現(xiàn)是掩碼寄存器里面位定義里1表示忽略而不是必須匹配我之前理解反了。所以這個(gè)坑的教訓(xùn)是寫過(guò)濾寄存器前先把數(shù)據(jù)手冊(cè)里“mask”位的含義抄到代碼注釋里避免下次再踩。過(guò)濾器還有一個(gè)容易出錯(cuò)的地方是擴(kuò)展幀和標(biāo)準(zhǔn)幀的ID存放位置不一樣。29位擴(kuò)展ID在寄存器里被拆成兩段低16位和高13位你如果按標(biāo)準(zhǔn)幀的11位格式往寄存器里塞擴(kuò)展幀永遠(yuǎn)收不到。4.3 字節(jié)序、位序、DLC的坑CAN協(xié)議本身并沒有規(guī)定數(shù)據(jù)場(chǎng)的字節(jié)序用大端還是小端完全看應(yīng)用層怎么定義。做整車診斷的時(shí)候經(jīng)常遇到兩種格式混用有的信號(hào)按Motorola格式高字節(jié)在前有的按Intel格式低字節(jié)在前。初版代碼里我直接拿memcpy復(fù)制數(shù)據(jù)結(jié)果解析出來(lái)的值完全不對(duì)。正確的做法是寫專門的打包/解包函數(shù)所有跨字節(jié)的信號(hào)都明確指定字節(jié)序和位序不要依賴編譯器對(duì)齊和平臺(tái)端序。要警惕DLC。DLC是數(shù)據(jù)長(zhǎng)度代碼在某些變種協(xié)議里它代表的是“數(shù)據(jù)長(zhǎng)度相對(duì)8的倍數(shù)”而不是真正的字節(jié)數(shù)。CAN FD出現(xiàn)后DLC編碼方式變了8字節(jié)以下表示通常長(zhǎng)度8以上按離散值對(duì)應(yīng)到12、16、20、24等。所以我在數(shù)據(jù)結(jié)構(gòu)里專門存真實(shí)的dlc收發(fā)轉(zhuǎn)換時(shí)在驅(qū)動(dòng)層做一次編碼映射上層永遠(yuǎn)只跟“真實(shí)字節(jié)數(shù)”打交道。4.4 高負(fù)載下丟幀與接收溢出在某個(gè)刷寫場(chǎng)景里上位機(jī)以接近零間隔的方式發(fā)UDS寫入請(qǐng)求一包接一包不停地發(fā)升級(jí)版代碼出現(xiàn)了偶發(fā)丟幀。排查時(shí)先看接收緩沖區(qū)overruns計(jì)數(shù)器發(fā)現(xiàn)確實(shí)有溢出于是把RX_FIFO_SIZE從16改成32情況好了很多但壓力再大時(shí)還是偶發(fā)。進(jìn)一步的定位是主循環(huán)里在處理其它任務(wù)處理時(shí)間超過(guò)了相鄰兩幀的間隔緩沖區(qū)的讀取速度跟不上寫入速度。最終方案不是無(wú)限加大緩沖區(qū)而是做了三件事把CAN接收中斷的優(yōu)先級(jí)提到最高一級(jí)確保中斷響應(yīng)不會(huì)被別的任務(wù)阻塞中斷里只拷貝不做處理主循環(huán)里的耗時(shí)操作全部改到空閑時(shí)間片執(zhí)行。這樣改動(dòng)之后即使緩沖區(qū)只有16幀也很穩(wěn)。這里想強(qiáng)調(diào)一點(diǎn)緩沖區(qū)空間是預(yù)算不是容量。如果設(shè)計(jì)時(shí)讓緩沖區(qū)經(jīng)常處于接近滿的狀態(tài)說(shuō)明系統(tǒng)整體處理能力不足該優(yōu)化的是處理路徑而不是緩沖區(qū)。4.5 “能發(fā)不能收”的排查流程“能發(fā)不能收”是我收到問(wèn)得最多的問(wèn)題。這類問(wèn)題的排查有一個(gè)固定套路按順序走一遍大部分都能快速定位。第一先確認(rèn)是不是回環(huán)模式?jīng)]關(guān)。如果控制器的回環(huán)模式開啟發(fā)出的幀不會(huì)出現(xiàn)在總線上自己又能收到從本機(jī)看“收發(fā)都正?!绷硪慌_(tái)設(shè)備當(dāng)然什么都收不到。第二檢查接收中斷是否使能這個(gè)看似簡(jiǎn)單但很多芯片的中斷使能位是多級(jí)開關(guān)有一級(jí)漏配就全斷。第三檢查過(guò)濾器。第四確認(rèn)總線PHY芯片的STB、RS引腳電平有一款常見的收發(fā)器芯片引腳不接電阻默認(rèn)進(jìn)入靜默模式只收不發(fā)。這類問(wèn)題最容易蒙住人因?yàn)椤澳苁盏健闭f(shuō)明總線物理層沒斷但“不能發(fā)”又指向驅(qū)動(dòng)層實(shí)際上硬件配置就有問(wèn)題。排查順序最好整理成一張自檢清單我在代碼里都加了相應(yīng)的調(diào)試宏每步都往串口打一個(gè)狀態(tài)標(biāo)記把定位時(shí)間從幾小時(shí)壓縮到幾分鐘。5. 性能、可靠性與后續(xù)擴(kuò)展跑通只是第一步5.1 CPU占用率怎么量化別拍腦袋很多人說(shuō)“我的CAN代碼占CPU很少”實(shí)際上是憑感覺。量化方法很簡(jiǎn)單在接收中斷入口拉高一個(gè)GPIO出口拉低用示波器看這個(gè)GPIO的占空比就是接收中斷消耗的時(shí)間比例。同理發(fā)送完成中斷、錯(cuò)誤中斷都可以各分配一個(gè)GPIO來(lái)測(cè)。我實(shí)測(cè)過(guò)一組數(shù)據(jù)500kbps總線下幀間隔大約幾十微秒一幀接收中斷從進(jìn)入ISR到退出大概是2微秒到4微秒取決于讀取郵箱和拷貝次數(shù)占空比通常不到10%。但如果中斷里做了協(xié)議解析或者調(diào)用慢速外設(shè)這個(gè)比例能飆到50%以上。做實(shí)時(shí)系統(tǒng)時(shí)這個(gè)數(shù)字必須心中有數(shù)。5.2 重傳策略別做成“重傳風(fēng)暴”發(fā)送失敗后要不要重傳我的結(jié)論是只重傳重要的、應(yīng)用層需要保證送達(dá)的幀而且重傳要有退避機(jī)制。初版代碼里我做過(guò)一個(gè)錯(cuò)誤的示范發(fā)送失敗后無(wú)限重傳總線負(fù)載一高幾個(gè)節(jié)點(diǎn)都在瘋狂重發(fā)錯(cuò)誤幀越來(lái)越多形成惡性循環(huán)。升級(jí)版的策略是發(fā)送失敗時(shí)先進(jìn)入一個(gè)“退避狀態(tài)”間隔指數(shù)增長(zhǎng)重試最多重試3次同時(shí)把失敗次數(shù)上報(bào)到應(yīng)用層由應(yīng)用層決定是降級(jí)處理還是放棄。對(duì)于周期性報(bào)文簡(jiǎn)單跳過(guò)一幀往往比重傳更合理因?yàn)橄乱粠芸炀蛠?lái)了對(duì)于偶發(fā)性事件幀才需要可靠性保證。5.3 回環(huán)模式讓調(diào)試效率翻倍升級(jí)版的配置里保留了三種模式正常、回環(huán)、靜默。回環(huán)模式不經(jīng)過(guò)總線在內(nèi)部把發(fā)送數(shù)據(jù)直接送回接收路徑最適合單板自測(cè)。寫新代碼或者改過(guò)濾器配置之后先切到回環(huán)模式驗(yàn)證一遍驅(qū)動(dòng)邏輯再把模式改成正常接總線能省掉大量雙板聯(lián)調(diào)的痛苦。回環(huán)模式還有一個(gè)好處可以提前驗(yàn)證中斷優(yōu)先級(jí)和緩沖區(qū)是否夠用。因?yàn)榛丨h(huán)模式下收發(fā)頻率完全由你自己控制可以壓極限速率把緩沖區(qū)溢出問(wèn)題在接真實(shí)總線之前暴露出來(lái)。5.4 給CAN FD留好接口經(jīng)典CAN在8字節(jié)數(shù)據(jù)場(chǎng)和1Mbps速率之下已經(jīng)越來(lái)越緊張CAN FD把數(shù)據(jù)場(chǎng)擴(kuò)到最多64字節(jié)還能在數(shù)據(jù)段用更高的速率傳輸。升級(jí)版在設(shè)計(jì)時(shí)已經(jīng)把消息結(jié)構(gòu)的data[64]和flags的FD位預(yù)留好底層HAL層只需要增加一組寄存器操作驅(qū)動(dòng)層的幀封裝邏輯要改動(dòng)的地方不多。但有一點(diǎn)要特別提醒CAN FD不是所有控制器都支持換硬件之前要先確認(rèn)。而且CAN FD的DLC編碼規(guī)則和位時(shí)序跟前文介紹的不一樣數(shù)據(jù)段的采樣點(diǎn)也要重新算。如果你現(xiàn)在用的還是經(jīng)典CAN控制器數(shù)據(jù)結(jié)構(gòu)先按上面的方式預(yù)留將來(lái)遷移時(shí)會(huì)輕松很多。最后再分享一點(diǎn)個(gè)人體會(huì)。CAN通信代碼真正做到“可靠”不是把寄存器的每一個(gè)位配對(duì)就行而是在“配置、緩沖、異常處理、分層結(jié)構(gòu)”這些看不見的地方下功夫。我先做回環(huán)自測(cè)再用GPIO把中斷的CPU占用量出來(lái)最后才敢上真實(shí)總線。這套代碼現(xiàn)在被我抽成固定模板換平臺(tái)只需要改HAL層和配置頭文件。如果讀者你現(xiàn)在正從零寫一套CAN驅(qū)動(dòng)我建議不要只盯著“能收到數(shù)據(jù)”這個(gè)目標(biāo)多花一點(diǎn)時(shí)間想想“收到之后怎么辦、出錯(cuò)之后怎么辦”這個(gè)投入絕對(duì)值得。