Bài 11: Debug bootloader trên microcontroller

Debug bootloader trên MCU thường khó hơn debug application bình thường. Lý do đơn giản là nó chạy rất sớm, ít log, ít dấu vết, và có nhiều lỗi xảy ra trước cả lúc UART, CAN hay USB kịp lên.

Bài này tập trung vào cách debug theo kiểu thực chiến hơn: khi chưa có kết nối thì nhìn gì, khi đã có kết nối thì log ra sao, và mỗi kỹ thuật có giới hạn gì.

Vì sao khó debug bootloader

Bootloader chạy ngay sau reset. Lúc này hệ thống thường chưa có đủ hạ tầng để hỗ trợ debug như application. Nhiều trường hợp còn chưa bật xong clock, chưa init xong pin mux, chưa cấu hình xong UART hoặc bus giao tiếp.

Thế nên có những lỗi làm thiết bị treo rất sớm, nhìn từ bên ngoài chỉ thấy là máy không boot. Nếu không có cách để lại dấu vết ngay từ đầu, mình gần như không biết nó dừng ở bước nào.

Chia lỗi theo hai giai đoạn debug

Để đỡ rối, nên chia việc debug bootloader thành hai giai đoạn.

  • Giai đoạn chưa có kết nối debug thuận tiện: UART chưa lên, CAN chưa chạy, USB chưa enumerate hoặc log chưa ra được.
  • Giai đoạn đã có kết nối hoặc đã có dấu vết đủ dùng: Lúc này mới dùng log, dump và các kỹ thuật kiểm tra sâu hơn.

Nếu không tách hai giai đoạn này, rất dễ áp dụng sai cách. Ví dụ cố chờ UART log trong khi lỗi nằm trước cả lúc UART được init.

Debug khi chưa có kết nối giao tiếp

Đây là đoạn khó nhất, nhưng cũng là đoạn quan trọng nhất. Khi chưa có kết nối, mình vẫn còn vài cách để nhìn vào bootloader.

Dùng debug probe và xem thanh ghi trực tiếp

Nếu còn giữ được SWD, JTAG hoặc một giao diện debug tương đương, đây là cách mạnh nhất. Lúc này có thể dừng CPU, xem PC đang ở đâu, xem SP, xem VTOR, xem reset reason register, xem trạng thái clock, interrupt pending và các thanh ghi quan trọng khác.

Khi bootloader chết trước cả lúc có log, debug probe gần như là cách chắc nhất để biết thiết bị đang kẹt ở đâu.

Một cách khá hữu ích trong giai đoạn này là ghi log rất ngắn vào một vùng RAM riêng, ví dụ chỉ là vài mã stage hoặc vài giá trị thanh ghi quan trọng. Sau đó dùng debugger dừng CPU rồi đọc lại vùng RAM đó.

Cách này chỉ dùng được khi còn debugger. Nó không thay thế được GPIO hoặc LED trong các bài test ngoài hiện trường, nhưng rất hữu ích khi cần phân tích lỗi logic hoặc data khi chưa có giao tiếp kết nối.

Dùng GPIO hoặc LED để đánh dấu từng stage

Nếu không nhìn được qua log, có thể để bootloader toggle một GPIO hoặc đổi pattern LED ở từng mốc rất sớm. Cách này thô nhưng cực kỳ hiệu quả, đặc biệt là khi đã ra thành phẩm thương mại.

Ví dụ, chỉ cần 3 mốc đơn giản như vào bootloader, pass image validation, chuẩn bị jump sang application image là đã đủ cắt bớt rất nhiều hướng nghi ngờ.

Dùng reset reason và boot counter

Khi thiết bị cứ reset liên tục, reset reason register và boot counter rất có giá trị. Nó giúp phân biệt giữa watchdog reset, software reset, brown-out hay power-on reset.

Chỉ riêng việc biết thiết bị đang tự reset vì watchdog hay bị sập nguồn cũng đã đổi hẳn hướng debug.

Khi đã có kết nối, nên debug theo kiểu nào

Khi UART, CAN, USB hoặc một giao tiếp khác đã lên đủ để trao đổi dữ liệu, lúc này mới nên dùng log và dump một cách có hệ thống.

Log theo stage, không log tràn lan

Với bootloader, log nhiều không phải lúc nào cũng tốt. Log quá dày có thể làm đổi timing, làm chậm flow boot hoặc che mất lỗi gốc. Cách tốt hơn là log theo stage, mỗi mốc chỉ in ra một mã ngắn hoặc vài trường thật cần thiết.

Ví dụ, thay vì in nguyên một đống thông tin, có thể log ngắn theo kiểu: vào bootloader, pass header, pass CRC, set state pending, jump app. Làm vậy vừa gọn vừa dễ so log giữa các lần boot.

Dump đúng thứ cần dump

Dump log chỉ có ích khi dump đúng chỗ. Trong bootloader, những thứ đáng dump nhất thường là header của application image, metadata, boot state, địa chỉ vector table, vài byte đầu ở vùng flash memory vừa ghi và các thanh ghi liên quan tới reset hoặc jump.

Dump quá nhiều vừa khó đọc vừa làm chậm hệ thống. Tệ hơn nữa, nó có thể che mất lỗi timing hoặc làm thay đổi hành vi của bootloader.

So sánh log khi chạy đúng với log khi gặp lỗi

Nếu có một case boot được và một case boot lỗi, hãy log cùng format ở cả hai đường rồi so sánh mốc lệch nhau ở đâu. Đây là cách rất thực dụng và thường ra vấn đề nhanh hơn so với đọc từng dòng riêng lẻ.

Giới hạn của việc dùng log

Log không phải lúc nào cũng vô hại. Với bootloader, có vài giới hạn rất đáng lưu ý.

  • Log có thể làm đổi timing.
  • Log có thể che mất race condition hoặc watchdog issue.
  • Log qua UART có thể làm chậm bước boot nhiều hơn mình nghĩ.
  • Log quá sớm có thể không ra gì nếu clock hoặc pin chưa đúng.
  • Log quá nhiều có thể làm mình chìm trong dữ liệu nhưng vẫn không thấy nguyên nhân.

Khi debug bootloader, chỉ nên log đúng vài mốc quan trọng và vài trường thật sự cần thiết. Log quá nhiều thường làm rối hơn là giúp.

Debug phần image validation

Nếu bootloader không chấp nhận application image, đầu tiên nên kiểm tra phần validation.

  • Header có đúng format không.
  • Image size có hợp lý không.
  • CRC, checksum hoặc signature có pass không.
  • Address có nằm đúng trong vùng application image không.
  • Metadata có đang trỏ đúng vào image cần boot không.

Nhiều lỗi nhìn giống lỗi jump sang application, nhưng thật ra bootloader chưa từng chấp nhận image ngay từ đầu.

Debug bước jump sang application image

Nếu image validation đã pass nhưng jump xong lại treo, nên kiểm tra đúng vào bước handoff.

  • Stack pointer có hợp lệ không.
  • Reset handler có nằm đúng vùng application image không.
  • VTOR hoặc vector table remap đã xử lý chưa.
  • Interrupt có còn bật sai chỗ không.
  • SysTick có còn chạy không.
  • Clock và peripheral có đang ở trạng thái application image không chịu được không.

Nhóm lỗi này rất hay gặp. Bề ngoài thì giống hệt firmware hỏng, nhưng gốc lại nằm ở execution context lúc bootloader bàn giao sang application image.

Debug metadata và state machine

Boot loop hoặc rollback sai rất hay xuất phát từ metadata và state transition.

  • State hiện tại đang là Invalid, Pending, Confirmed hay Rollback.
  • State đó do bootloader ghi hay do application image ghi.
  • State transition có đúng thứ tự không.
  • Boot counter có tăng đúng không.
  • Sau khi bị gián đoạn, bootloader đọc lại metadata thành gì.

Nếu state machine không rõ, bootloader rất dễ rơi vào cảnh cứ boot thử, fail, rollback rồi lặp lại mãi.

Debug các lỗi liên quan tới flash memory

Nếu nghi ngờ lỗi nằm ở flash memory, nên kiểm tra các điểm rất cơ bản trước.

  • Vùng cần erase đã được erase thật chưa.
  • Dữ liệu program xuống có đúng như buffer đầu vào không.
  • Alignment có đúng yêu cầu của flash controller không.
  • Code có đang vô tình execute từ vùng flash memory đang bị ghi không.
  • Layout có cắt trúng bank boundary hoặc erase block bất lợi không.
  • Flash driver trong RAM có thật sự được copy và chạy đúng không.

Ở đây, dump raw bytes trong flash memory thường đáng tin hơn cảm giác rằng “hình như đã ghi thành công”.

Khoanh vùng lỗi theo triệu chứng

Một hướng debug khác đó là khoanh vùng từ triệu chứng bên ngoài trước, rồi mới đi sâu.

  • Boot loop: Kiểm tra metadata, state transition, boot counter, logic confirm và rollback.
  • Jump xong treo: Kiểm tra stack pointer, reset handler, VTOR, interrupt, SysTick và peripheral state.
  • Update xong không boot: Kiểm tra image validation, raw bytes trong flash memory, metadata và địa chỉ image.
  • Không vào update mode: Kiểm tra boot flag, reset source, điều kiện vào mode và giao tiếp nhận lệnh.

Lỗi do thiếu từ khóa “volatile”

Đây là một lỗi phổ biến khi bootloader được viết bằng ngôn ngữ C. Nếu một vùng nhớ có thể thay đổi ngoài luồng chạy bình thường của compiler, ví dụ thanh ghi phần cứng, vùng nhớ memory-mapped hoặc vùng RAM dùng để giữ debug log thì việc thiếu từ khóa volatile có thể làm kết quả quan sát bị sai.

Khi thiếu volatile, compiler có thể tối ưu (optimize) bỏ lần đọc, lần ghi hoặc giữ lại giá trị cũ trong thanh ghi CPU thay vì đọc lại trực tiếp từ bộ nhớ. Lúc đó mình tưởng bootloader không ghi, không đổi state hoặc không thấy thanh ghi thay đổi, nhưng thực ra lỗi nằm ở chỗ code bị tối ưu khác với điều mình đang muốn quan sát.

Với các biến hoặc vùng nhớ được truy cập bất định như vậy, nên đánh dấu đúng chỗ bằng volatile. Nếu không, việc debug rất dễ đi sai hướng ngay từ đầu.

Nghi lỗi do toolchain hoặc compiler?

Lỗi do toolchain hoặc compiler không hẳn là không có, chỉ là rất ít gặp và thường chỉ có thể debug ở mức assembly (ví dụ logic bị sai lệch do thuật toán optimization). Trước khi đổ lỗi cho compiler, linker hay IDE, nên kiểm tra lại những thứ cơ bản sau.

  • Linker script có đúng không.
  • Map file có khớp với layout mong muốn không.
  • Vector table có đúng địa chỉ không.
  • Section placement có lệch không.
  • Startup code của application image có đúng không.
  • Memory remap hoặc option byte có đang ảnh hưởng flow boot không.

Rất nhiều lỗi tưởng như “toolchain có vấn đề” thật ra chỉ là layout hoặc address đang sai từ trước.

Kết luận

Debug bootloader không nên làm theo kiểu đoán mò. Cách hiệu quả nhất là đi theo thứ tự, để lại dấu vết đủ sớm và chia vấn đề thành từng lớp rõ ràng. Khi nhìn đúng từ reset source, image validation, metadata cho tới bước jump, việc tìm ra lỗi sẽ nhanh hơn rất nhiều.

Phản hồi về bài viết

Cùng thảo luận chút nhỉ!

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.