VHDL for Avionics: Writing DO-254 Code Without Falling into the Traps
Why “different” VHDL?
In avionics, VHDL code doesn’t need to be elegant: it needs to be predictable, verifiable and defensible in front of an auditor. Under DO-254 (ED-80) and AMC 20-152A, the authority expects you to show that the hardware does exactly what the requirements say, and nothing more.
In practice, most certification headaches I’ve seen don’t come from a badly designed algorithm. They come from coding details: a latch inferred by an incomplete combinational process, an asynchronous reset released at the wrong moment, a signal crossing two clock domains through two flip-flops “just to be safe”. These defects pass simulation and show up on the bench, or worse, in flight.
AMC 20-152A explicitly asks you to establish and apply HDL coding rules. Here are the ones I use on DAL A to D projects, with ready-to-use code snippets and, above all, the reason behind each rule. A rule without a rationale always ends up being worked around.
1. Naming for fast reviews
A systematic prefix tells the reviewer, without opening the declaration, where an object comes from and what it is allowed to do. It also lets a linter check the rule automatically.
| Prefix | Object | Example |
|---|---|---|
i_ | Input port | i_Clk, i_Rst_n |
o_ | Output port | o_Data_Valid |
s_ | Internal signal | s_Counter |
c_ | Constant | c_CNT_MAX |
G_ | Generic | G_DATA_WIDTH |
inst_ | Component instance | inst_Rst_Sync |
proc_ | Process label | proc_Counter |
A typical entity therefore looks like this:
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity pulse_counter is
generic (
G_CNT_WIDTH : natural := 8
);
port (
i_Clk : in std_logic;
i_Rst_n : in std_logic; -- synchronous reset, already resynchronized, active low
i_Pulse : in std_logic;
o_Count : out std_logic_vector(G_CNT_WIDTH - 1 downto 0);
o_Overflow : out std_logic
);
end entity pulse_counter;
I add the _n suffix to every active-low signal. The day someone writes if i_Rst_n = '1' for a reset, the mistake jumps out during review.
2. Fully synchronous, nothing hidden
Three rules go together: every process is synchronous, there are no variables, functions or procedures in the RTL, and every signal is initialized at its declaration with the same value as its reset.
Why so strict? A combinational process that misses a branch infers a latch. A variable can become a register or a wire depending on the order of assignments, and you can’t tell by reading the code. A function hides logic from the verification flow and makes code coverage harder. Each of these constructs is legal VHDL; it’s their ambiguity that becomes a problem in certification.
What we avoid:
-- DO NOT DO THIS
process (i_Pulse, s_Count) -- combinational process
variable v_Next : unsigned(7 downto 0); -- register or wire?
begin
if i_Pulse = '1' then
v_Next := s_Count + 1;
end if; -- missing else branch: latch
s_Next <= v_Next;
end process;
What we write instead:
architecture rtl of pulse_counter is
constant c_CNT_MAX : unsigned(G_CNT_WIDTH - 1 downto 0) := (others => '1');
signal s_Count : unsigned(G_CNT_WIDTH - 1 downto 0) := (others => '0');
signal s_Overflow : std_logic := '0';
begin
proc_Counter : process (i_Clk)
begin
if rising_edge(i_Clk) then
if i_Rst_n = '0' then
s_Count <= (others => '0');
s_Overflow <= '0';
elsif i_Pulse = '1' then
if s_Count = c_CNT_MAX then
s_Overflow <= '1'; -- saturate, no wrap-around
else
s_Count <= s_Count + 1;
end if;
end if;
end if;
end process proc_Counter;
o_Count <= std_logic_vector(s_Count);
o_Overflow <= s_Overflow;
end architecture rtl;
The sensitivity list contains only the clock. All the logic sits in registers, and the outputs are plain connections to those registers: there is no combinational path from an input to an output. The counter saturates instead of wrapping around, a choice that must appear in the requirements, not only in the code.
Initialization at declaration is debated, and some linters flag it by default. On SRAM-based FPGAs it sets the state after configuration, and it removes the 'U' values in simulation that sometimes mask real problems. The condition is simple: the initial value must match the reset value. Otherwise, behavior differs between power-up and a warm reset.
3. Reset: a single entry point, always resynchronized
The external reset arrives asynchronously with respect to the clock. If its release falls inside the setup/hold window, some flip-flops leave reset one cycle before the others. A state machine can then start in an inconsistent state, and the problem almost never reproduces in the lab.
My rule: one reset management block per clock domain, which filters and then resynchronizes the external reset. The rest of the design only uses this clean reset, synchronously.
library ieee;
use ieee.std_logic_1164.all;
entity rst_sync is
generic (
G_SYNC_STAGES : positive range 2 to 4 := 3
);
port (
i_Clk : in std_logic;
i_Rst_Ext_n : in std_logic; -- external reset, asynchronous, active low
o_Rst_n : out std_logic -- resynchronized reset, active low
);
end entity rst_sync;
architecture rtl of rst_sync is
-- '0' at declaration: the design starts in reset
signal s_Rst_Sync : std_logic_vector(G_SYNC_STAGES - 1 downto 0) := (others => '0');
attribute ASYNC_REG : string;
attribute ASYNC_REG of s_Rst_Sync : signal is "TRUE";
begin
proc_Rst_Sync : process (i_Clk)
begin
if rising_edge(i_Clk) then
s_Rst_Sync <= s_Rst_Sync(G_SYNC_STAGES - 2 downto 0) & i_Rst_Ext_n;
end if;
end process proc_Rst_Sync;
o_Rst_n <= s_Rst_Sync(G_SYNC_STAGES - 1);
end architecture rtl;
A few things to check during review:
- the
ASYNC_REGattribute (AMD-Xilinx syntax here; adapt it to your tool) keeps the flip-flops next to each other and excludes them from optimization; - the timing constraint on
i_Rst_Ext_nis declared as a false path, and only that one; - the
'0'initialization guarantees the whole design stays in reset while the chain fills up; - if the reset comes from a push button or a noisy supervisor, an upstream glitch filter prevents spurious resets.
One detail that avoids surprises: every register is reset, including those in the datapath. It costs a few resources, but the circuit state after reset becomes fully defined, which simplifies both verification and safety analysis.
4. Crossing clock domains: a BRAM FIFO, no tricks
For a data bus, two flip-flops in series are not enough: each bit resynchronizes independently, and the received word can mix old and new values. The robust, defensible answer is an asynchronous FIFO built on the FPGA’s block RAM and instantiated explicitly.
Why instantiate rather than infer? Because the FIFO’s CDC architecture (Gray-coded pointers, synchronization stages) is then the vendor’s, characterized and documented. You don’t have to prove the correctness of a home-made FIFO, which is a considerable verification effort.
Example with the AMD-Xilinx XPM macro, configured as block memory:
library xpm;
use xpm.vcomponents.all;
-- ...
-- active-high FIFO reset, registered in the write domain
proc_Fifo_Rst : process (i_Wr_Clk)
begin
if rising_edge(i_Wr_Clk) then
s_Fifo_Rst <= not i_Wr_Rst_n;
end if;
end process proc_Fifo_Rst;
inst_Cdc_Fifo : xpm_fifo_async
generic map (
FIFO_MEMORY_TYPE => "block", -- BRAM enforced
FIFO_WRITE_DEPTH => 512,
WRITE_DATA_WIDTH => 32,
READ_DATA_WIDTH => 32,
READ_MODE => "std",
FIFO_READ_LATENCY => 1,
CDC_SYNC_STAGES => 3,
RELATED_CLOCKS => 0,
ECC_MODE => "no_ecc",
USE_ADV_FEATURES => "0000",
DOUT_RESET_VALUE => "0",
FULL_RESET_VALUE => 0,
PROG_EMPTY_THRESH => 10,
PROG_FULL_THRESH => 10,
RD_DATA_COUNT_WIDTH => 1,
WR_DATA_COUNT_WIDTH => 1,
SIM_ASSERT_CHK => 1,
WAKEUP_TIME => 0
)
port map (
sleep => '0',
rst => s_Fifo_Rst,
wr_clk => i_Wr_Clk,
wr_en => s_Wr_En,
din => s_Wr_Data,
full => s_Full,
wr_rst_busy => s_Wr_Rst_Busy,
rd_clk => i_Rd_Clk,
rd_en => s_Rd_En,
dout => s_Rd_Data,
empty => s_Empty,
rd_rst_busy => s_Rd_Rst_Busy,
injectsbiterr => '0',
injectdbiterr => '0'
-- unused outputs left open and justified in review
);
What makes the difference in review:
- writes are blocked while
s_Full = '1'ors_Wr_Rst_Busy = '1', reads whiles_Empty = '1'ors_Rd_Rst_Busy = '1'; - the
overflowandunderflowflags, if enabled, feed a monitored status register instead of being ignored; - on PolarFire or Lattice the principle is the same with the vendor’s FIFO: you instantiate the primitive, you don’t rewrite the architecture.
For a single quasi-static control bit, a two- or three-flip-flop synchronizer is still acceptable. For a pulse, use a resynchronized toggle followed by an edge detector. But as soon as data is involved, it goes through the FIFO.
5. Determinism and code coverage: the FSM as a textbook case
Code coverage is the moment of truth: every line, every branch and every state must be exercised by a test traced to a requirement. Any unreachable code becomes either a coverage hole or a justification to write. Better to avoid it from the start.
State machines concentrate most of the traps. I write them as a single synchronous process with registered outputs:
type t_Ctrl_State is (st_IDLE, st_RUN, st_DONE);
signal s_State : t_Ctrl_State := st_IDLE;
signal s_Busy : std_logic := '0';
signal s_Done : std_logic := '0';
-- ...
proc_Ctrl_Fsm : process (i_Clk)
begin
if rising_edge(i_Clk) then
if i_Rst_n = '0' then
s_State <= st_IDLE;
s_Busy <= '0';
s_Done <= '0';
else
case s_State is
when st_IDLE =>
s_Done <= '0';
if i_Start = '1' then
s_State <= st_RUN;
s_Busy <= '1';
end if;
when st_RUN =>
if i_End = '1' then
s_State <= st_DONE;
s_Busy <= '0';
s_Done <= '1';
end if;
when st_DONE =>
s_State <= st_IDLE;
s_Done <= '0';
end case;
end if;
end if;
end process proc_Ctrl_Fsm;
o_Busy <= s_Busy;
o_Done <= s_Done;
Notice there is no when others. With an enumerated type whose states are all listed, that branch is unreachable in simulation: it will never be covered. If the safety analysis requires recovery from illegal states (SEUs in a radiation environment, for example), request it from the synthesis tool through its safe-implementation attribute. Then verify it with fault injection, not with a dead branch in the RTL.
A few habits that make coverage achievable:
- every
ifhas observable behavior in both cases, or the implicit “hold the value” branch is intentional and documented; - no “just in case” defensive logic without an associated requirement: that is untraceable code;
- generics that disable a feature are covered by configuration, or the feature is removed from the design;
- run the linter before simulation: it catches latches, incomplete processes and undeclared CDCs much earlier and much more cheaply.
Finally, determinism comes down to one question: given identical inputs, does the circuit always produce the same outputs on the same cycle? Everything above, fully synchronous logic, resynchronized reset, FIFO for CDC and unambiguous FSMs, exists so you can answer yes without hesitation.
Wrap-up: the pre-review checklist
These rules feel restrictive for the first few weeks. After that, they save time: fewer debates in review, fewer justifications to write, and coverage reports that close without acrobatics. Here is the list I go through before every code review:
None of these rules is imposed as such by DO-254. What is imposed is having rules, justifying them and proving you apply them. These particular choices have the advantage of being easy to defend in front of an auditor.
What rules do you apply on your certified projects? I’d be curious to hear your feedback, especially on initialization at declaration, which still divides people as much as ever.
I have developed a comprehensive document outlining coding rules and best practices aligned with RTCA DO-254 and ECSS standards, based on my professional experience and lessons learned from previous projects.
The document is intended to serve as a practical reference guide for your FPGA engineering teams, providing a consistent framework for developing robust, reliable, maintainable, and verifiable FPGA designs. It covers coding practices that promote design quality, facilitate verification and validation, and support compliance with applicable aerospace and safety-critical development requirements.
I believe this guide could be a valuable resource for your organization, helping standardize FPGA development practices, improve design quality, and strengthen compliance throughout the development lifecycle. See below for sample downloading.
VHDL for Avionics: Writing DO-254 Code Without Falling into the Traps
The document walks through VHDL coding rules that make FPGA designs easier to certify under DO-254 and AMC 20-152A, each with a ready-to-use code snippet and the reasoning behind it.
What’s inside
Naming conventions: systematic prefixes (i_, o_, s_, c_, G_, inst_, proc_) that make reviews faster and let a linter check the rules.
Fully synchronous design: no combinational processes, variables, functions or procedures, and every signal initialized to its reset value.
Reset handling: one resynchronized reset per clock domain to remove metastability on release.
Clock domain crossing: data always goes through an instantiated BRAM FIFO, never through home-made synchronizers.
FSMs and code coverage: single-process state machines with no unreachable code, so 100% coverage is achievable.
A pre-review checklist to apply before every code review.
What it brings to FPGA teams
Fewer certification surprises: latches, reset races and CDC errors are designed out instead of found on the bench.
Faster reviews: code reads the same way across the team, and reviewers focus on function, not style.
Coverage that closes: no dead branches to justify, so verification reports converge faster.
Defensible choices: every rule comes with a rationale an auditor can accept.
Easy onboarding: new engineers get a short, concrete reference they can apply from day one.
