AdaCore Blog

Open-Source Ada: From Gateware to Application

Open-Source Ada: From Gateware to Application

by Olivier Henley

Intro­duc­tion & Moti­va­tion #

Open-Source Stack Matters

As the GNAT Aca­d­e­m­ic Pro­gram (GAP) Coor­di­na­tor at Ada­Core, I focus on thor­ough, hands-on learn­ing in sys­tem pro­gram­ming. A ful­ly open-source stack (cov­er­ing gate­ware, tool­chains, and appli­ca­tions) pro­vides the free­dom to explore and refine every lay­er, from sil­i­con-lev­el con­trol to high-lev­el abstrac­tions. This Neorv32 Basic Input/​Output Sys­tem (BIOS) project high­lights Ada as a friend­ly yet pow­er­ful alter­na­tive to C for open-source development.

Who This Post is For

  • Curi­ous devel­op­ers explor­ing Ada beyond its usu­al reputation.
  • Ada enthu­si­asts who lack the time to exper­i­ment with a soft­core Cen­tral Pro­cess­ing Unit (CPU), a proces­sor defined in a Hard­ware Descrip­tion Lan­guage (HDL) and deploy­able on recon­fig­urable log­ic like a Field-Pro­gram­ma­ble Gate Array (FPGA), rather than fixed silicon.
  • Embed­ded sys­tem new­com­ers seek­ing a prac­ti­cal intro­duc­tion to fundamentals.


Neorv32, RISC‑V, VHDL, and ULX3S #

Neorv32?

The Neorv32 Sys­tem on a Chip (SoC) is a VHDL-based RISC‑V soft­core fea­tur­ing a broad set of exten­sions and periph­er­als along with exten­sive doc­u­men­ta­tion. The Neorv32 is the brain­child of Stephan Nolt­ing, who, accord­ing to his GitHub page, is asso­ci­at­ed with the Fraun­hofer Insti­tute for Micro­elec­tron­ic Cir­cuits and Sys­tems, Ger­many.

Many open-source projects found in the wild can be tricky in prac­tice, often fea­tur­ing unfin­ished or uncon­ven­tion­al archi­tec­tures and orga­ni­za­tion, poor doc­u­men­ta­tion, con­vo­lut­ed build sys­tems, and bugs. In my expe­ri­ence, though lim­it­ed, Neorv32 avoids these pit­falls, pre­sent­ing a robust and well-doc­u­ment­ed design.

RISC‑V?

RISC‑V is an open, exten­si­ble Instruc­tion Set Archi­tec­ture (ISA) ground­ed in proven Reduced Instruc­tion Set Com­put­er (RISC) prin­ci­ples, designed to be both mod­u­lar and scal­able. In my view, the open­ness and wide­spread accep­tance of RISC‑V make it less sus­cep­ti­ble to tech­ni­cal obso­les­cence than pro­pri­etary or less wide­ly adopt­ed archi­tec­tures. RISC‑V’s exten­si­bil­i­ty ensures it can evolve rather than become obso­lete, mak­ing a lot of sense in aca­d­e­m­ic and indus­tri­al contexts.

VHDL?

VHDL is a stan­dard­ized hard­ware descrip­tion lan­guage. Like Ada, it is struc­tured, strong­ly typed, and enforces strict com­pi­la­tion rules. VHDL was orig­i­nal­ly designed based on Ada: its syn­tax is very sim­i­lar, and the seman­tics align up to a point, with both lan­guages pri­or­i­tiz­ing cor­rect­ness over con­ve­nience. Although ver­bose for some tastes, this clar­i­ty reduces unin­tend­ed errors and ensures explic­it, unam­bigu­ous design com­mu­ni­ca­tion. The Neorv32 project is an excel­lent tes­ta­ment to the com­pound­ing ben­e­fits of these attributes.

ULX3S?

The Radiona ULX3S is an open-source devel­op­ment board built around the Lat­tice ECP5 FPGA. Although the chip man­u­fac­tur­ing remains pro­pri­etary, the board design is ful­ly open. I’m using the 85,000 Look-Up Tables (85k LUT) ver­sion for its flex­i­bil­i­ty and capa­bil­i­ty. It includes var­i­ous periph­er­als but the real focus here is on the ECP5 FPGA, where we’ll deploy our cus­tom BIOS over the Neorv32 SoC.


Deter­min­is­tic SoC #

Curat­ing Quality

The Neorv32 is designed with robust­ness and pre­dictabil­i­ty in mind. It fol­lows a Har­vard archi­tec­ture, keep­ing instruc­tion and data mem­o­ry sep­a­rate. Its mul­ti-cycle mod­el focus­es on deter­min­is­tic behav­ior, avoid­ing spec­u­la­tive exe­cu­tion and ran­dom stalls. It also offers capa­bil­i­ties that stand out among open-source RISC‑V cores:

  • Vir­tu­al­iza­tion and safe­ty mech­a­nisms that detect mal­formed instruc­tions and priv­i­lege esca­la­tions, and that enforce address-space integrity.
  • A hard­ware RISC‑V debug mod­ule for On-Chip Debug­ging (OCD).
  • Sup­port for atom­ic oper­a­tions, which is crit­i­cal for concurrency.

Unlike soft­ware, hard­ware debug­ging is tied to phys­i­cal sig­nals, mak­ing errors much hard­er to trace and cor­rect. This real­i­ty makes good design prac­tices cru­cial from the start. In prac­tice, the Neorv32’s con­ser­v­a­tive yet adapt­able approach min­i­mizes edge cas­es and fail­ure points.

To illus­trate its sta­bil­i­ty, I’ve had the Neorv32 UART0 con­nec­tion live between my Lin­ux lap­top and the ULX3S for over a week now with­out a glitch. In con­trast, I test­ed anoth­er pop­u­lar open-source SoC project on two dif­fer­ent boards along­side a team of senior uni­ver­si­ty stu­dents, and we inde­pen­dent­ly encoun­tered per­sis­tent reli­a­bil­i­ty issues. The Neorv32 sim­ply doesn’t show those prob­lems. I high­ly rec­om­mend check­ing its doc­u­men­ta­tion, which offers detailed insights into design deci­sions, com­po­nent inte­gra­tion, and exe­cu­tion guarantees.


Tool­chain Break­down: Going Ful­ly Open-Source #

Gate­ware, Firmware?

An FPGA is a recon­fig­urable chip where hard­ware log­ic cir­cuits are defined pro­gram­mat­i­cal­ly. Unlike fixed CPUs that exe­cute instruc­tions, an FPGA imple­ments such proces­sor log­ic direct­ly. This is why we call such assem­bled code gate­ware. Instead of writ­ing firmware for a pre­de­fined CPU, we first pro­gram the processor’s archi­tec­ture in an HDL before deploy­ing com­piled firmware instruc­tions on it. In a nut­shell, LUTs and Block RAM (BRAM) are the basic build­ing blocks of an FPGA. As a gen­er­al rule, the more of these resources you have, the more com­plex your processor/​accelerator log­ic can be.

Open-Source FPGA Toolchain

If you have some expe­ri­ence with FPGA devel­op­ment, you know pro­pri­etary tool­chains usu­al­ly trans­form HDL code into a bit­stream for pro­gram­ming the FPGA. Thanks to ongo­ing aca­d­e­m­ic and open-source efforts, we can now rely on a ful­ly open ecosys­tem. Here is how the open-source flow pro­duces a usable Neorv32 bit­stream for the Lat­tice ECP5 on the ULX3S:

  • GHDL, which is notably writ­ten in Ada, pars­es the VHDL and pass­es it to Yosys, an HDL syn­the­sis tool, through a plugin.
  • Yosys, with help from Berke­ley-ABC, a sys­tem for sequen­tial syn­the­sis and ver­i­fi­ca­tion, builds and opti­mizes a netlist, a descrip­tion of the con­nec­tiv­i­ty of FPGA resources.
  • Nextp­nr takes that netlist for place-and-route, map­ping it onto the ECP5’s phys­i­cal lay­out using the Project Trel­lis database.
  • ecp­pack (part of Trel­lis), then assem­bles the final bit­stream, ready to be uploaded to the FPGA
The open-source FPGA ecosys­tem is evolv­ing, so com­pat­i­bil­i­ty can change over time. I test­ed a sim­i­lar open-source tool­chain for a dif­fer­ent major FPGA fam­i­ly but nev­er got a sta­ble result. With the Lat­tice ECP5, how­ev­er, every­thing con­sis­tent­ly worked smooth­ly. Anoth­er clear advan­tage is speed as gen­er­at­ing a new bit­stream takes under 20 sec­onds on my set­up. By con­trast, pro­pri­etary tool­chains can take min­utes, which makes a huge dif­fer­ence when iter­at­ing on development. 


Bring­ing Up Neorv32 on the ULX3S #

Board Set­up and Configuration

To con­fig­ure the Neorv32 soft­core for my ULX3S board, I used neorv32-setups, a repos­i­to­ry that pro­vides default board con­fig­u­ra­tions and build setups for a basic SoC gate­ware and firmware. This was pre­cise­ly what I need­ed since it includes a UART, which is required for our Ada BIOS demo. A giv­en board set­up con­sists of a con­straint file that maps the board’s phys­i­cal inter­faces to the FPGA pins, and a top-lev­el VHDL file that allows cus­tomiza­tion of the base SoC. In this file, you define the SoC you want by select­ing periph­er­als and set­ting key para­me­ters like the sys­tem clock speed based on what the board phys­i­cal­ly provides. 

Gen­er­at­ing and Flash­ing the Bitstream

Final­ly, you have build make­files dri­ving the open-source tool­chain to gen­er­ate the SoC bit­stream (.bit) and C firmware demos. With every­thing con­fig­ured and built, I had a Neorv32 SoC bit­stream file ready to be uploaded to the ULX3S. To give you an idea, this basic Neorv32 SoC uses 3,026 of the 85k LUTs (3% total) and 67 of the 208 DP16KD 16K block RAMs (32%), leav­ing plen­ty of room on my ECP5. To flash it, I used fujprog, anoth­er open-source project, which com­piled and trans­ferred suc­cess­ful­ly on the first attempt. 


Good­bye C, Hel­lo Ada #

Neorv32 Boot­loader Flow

Right away, I was able to test the default Neorv32 boot­loader and upload some of the pro­vid­ed demos. Dur­ing SoC syn­the­sis, the Neorv32 boot­loader firmware is embed­ded into the FPGA bit­stream. Once the FPGA is pro­grammed and starts up, exe­cu­tion jumps direct­ly to this boot­loader, which presents a com­mand menu for upload­ing and run­ning oth­er firmware. The main Neorv32 repos­i­to­ry pro­vides sev­er­al C demos designed to be exe­cut­ed through this bootloader.

For these you’ll need a RISC‑V cross‑compiler (with lib­gloss) and a ter­mi­nal pro­gram that can send raw bina­ry data with­out head­ers. I used GTK­Term on Lin­ux to ensure the firmware for­mat matched what Neorv32’s boot­loader expects. After start­ing with a hel­lo world” C demo to learn the work­flow, I moved on to write the Ada BIOS. Although set­ting up Inter­rupt Ser­vice Rou­tines (ISR) is a famil­iar con­cept, doing it on RISC‑V was new ter­ri­to­ry for me.

C to Ada, Step-by-Step

The key was to cre­ate a min­i­mal demo that exer­cised UART RX inter­rupts for incom­ing con­sole data, along with the nec­es­sary Con­trol and Sta­tus Reg­is­ter (CSR) set­up. Typ­i­cal demos skip UART receive, so I pieced togeth­er a sim­ple C exam­ple to focus on han­dling UART0 RX interrupts.

When an inter­rupt occurs, the CPU stops what it’s doing and hands exe­cu­tion to the reg­is­tered call­back. Once fin­ished, the CPU must restore its pre­vi­ous state. In prac­tice, that means a) sav­ing the cur­rent reg­is­ters, b) run­ning the inter­rupt han­dler with­in isr(), and c) restor­ing the reg­is­ters before pick­ing up where it left off.

The ref­er­ence code tack­led these steps in a sin­gle naked C func­tion, mix­ing inline assem­bly for saving/​restoring reg­is­ters with the ISR log­ic. Split­ting this into sep­a­rate C or Ada func­tions caused crash­es. To fix that, I moved the assem­bly pre­lude and postlude into trap_entry.S and exposed a trap_entry() and isr() sym­bol to the linker.

.global trap_entry
.global isr

trap_entry:
...
   sw x31, 31*4(sp)  # Save x31 register
   call isr
...
   lw x31, 31*4(sp)  # Restore x31 register
...
   mret

Ini­tial­ly I kept the isr() imple­men­ta­tion log­ic in C to ensure get­ting back to a work­ing state. Once done, I trans­lat­ed the isr() log­ic to Ada step by step, ensur­ing each oper­a­tion matched the original.

procedure Isr 
  with Export, Convention => C, External_Name => "isr";

procedure Isr is
begin
  Call_Handler;
  if Is_Exception then
    Compute_Return_Address;
  end if;
end Isr;

procedure Trap_Entry 
  with Import, Convention => C, External_Name => "trap_entry";


Neorv32 Hard­ware Abstrac­tion Lay­er #

Set­ting Up a Min­i­mal Ada Runtime

Alire, the Ada pack­age man­ag­er, already includes pack­ages to help cre­ate a basic RISC‑V Hard­ware Abstrac­tion Lay­er (HAL). bare_​runtime, a min­i­mal Ada run­time for embed­ded or restrict­ed tar­gets, is a per­fect fit. A need­ed GNAT RISC‑V cross-com­pil­er is also avail­able. To make this work, two steps are required:

  • Add and con­fig­ure depen­den­cies in the pack­age man­ag­er man­i­fest file of the neorv32_hal library. The rel­e­vant sec­tion of the alire.toml file looks like this:
[[depends-on]]
gnat_riscv64_elf = "*"
bare_runtime = "*"

[gpr-set-externals]
BARE_RUNTIME_SWITCHES = "-march=rv32i_zicsr_zifencei -mabi=ilp32"

This estab­lish­es depen­den­cies on the GNAT RISC‑V cross-com­pil­er and the bare-met­al run­time. It also pass­es spe­cif­ic build switch­es to gprbuild, the GNAT build sys­tem, ensur­ing com­pat­i­bil­i­ty with the tar­get­ed RISC‑V variant.

  • Cus­tomize the build file, neorv32_hal.gpr, accord­ing­ly:
with "bare_runtime.gpr";
project Neorv32_Hal is
   for Languages use ("Ada", "ASM_CPP");
   for Target use "riscv64-elf";
   for Runtime ("Ada") use Bare_Runtime'Runtime ("Ada");
   for Library_Name use "Neorv32_Hal";
...
end Neorv32_Hal;

Here, we ref­er­ence the bare_​runtime project file, ensure sup­port for both Ada and Assem­bly code, spec­i­fy RISC‑V as the tar­get archi­tec­ture, set bare_​runtime as the run­time, and define the cur­rent project as a library.

Cre­at­ing a Cus­tom Link­er Script and Start­up Code

The next step is to cre­ate the link­er script (link.ld) and start­up assem­bly (crt0.S). This is eas­i­ly done using the startup_​gen Alire pack­age. By pro­vid­ing key details about our tar­get, such as mem­o­ry sizes, mem­o­ry start address­es and CPU archi­tec­ture, startup_​gen gen­er­ates basic func­tion­al files:

  • crt0.S han­dles ini­tial­iza­tion: load­ing the data sec­tion, clear­ing BSS, and set­ting up global/​static objects.
  • link.ld maps firmware sec­tions accord­ing to the Neorv32 mem­o­ry lay­out: plac­ing instruc­tions in ROM and data in RAM.

For a deep­er dive into star­tup­gen, check out the relat­ed blog post.

Imple­ment­ing Inter­rupt Han­dling in Ada

As men­tioned, sav­ing and restor­ing reg­is­ters dur­ing an inter­rupt now hap­pens in assem­bly: trap_entry. What remains is the mid­dle part: the isr() that ends up call­ing the han­dler reg­is­tered for the spe­cif­ic inter­rupt. First we need to update the Machine Trap Vec­tor Reg­is­ter (Mtvec) to point to our trap_entry. This hap­pens in the inter­rupt ini­tial­iza­tion code.

procedure Init is
begin
  RISCV.CSR.Mstatus.Set_Bits (2#11000_00000000#);  --  after MRET stays M-mode
  RISCV.CSR.Mtvec.Write (UInt32 (To_Integer (Trap_Entry'Address)));
  RISCV.CSR.Mie.Write (0);  --  disables all interrupts
  Asm ("fence");
end Init;

Next we need to cre­ate our Handlers table; one entry per spe­cif­ic inter­rupt. Note that Neorv32 can be built as a dual-core, so the Hart_ID_T type range from 0 to 1.

subtype Hart_Id_T is Natural range 0 .. 1;
type Trap_Code_T is (
...
  Fast_Interrupt_2, -- Uart0 RX
...
);

for Trap_Code_T use (
...
  Fast_Interrupt_2 => 16#80000012#, -— Uart0 RX
...
  );

type Interrupt_Handler is access procedure (Hart : Hart_Id_T; Trap_Code : Trap_Code_T);
...
type Handlers_T is array (Hart_Id_T, Trap_Code_T) of Interrupt_Handler;
Handlers : Handlers_T := (others => (others => Default_Handler'Access));

We install a spe­cif­ic inter­rupt call­back by index­ing it in the Handlers table:

procedure Install_Uart0_Rx_Interrupt_Handler (Hart : Harts_T; Handler : Interrupt_Handler) is
begin
  Handlers (Hart, Fast_Interrupt_2) := Handler;
end Install_Uart0_Rx_Interrupt_Handler;

Call­ing a han­dler involves check­ing the interrupt’s cause. Our call­back sig­na­ture receives the Hart_Id and the Trap_Code enum value.

procedure Call_Handler is
  Hart_Id : Hart_Id_T := Hart_Id_T (RISCV.CSR.MHARTID.Read);
  Trap_Code : Trap_Code_T := Trap_Code_T'Enum_Val (RISCV.CSR.MCAUSE.Read);
begin
  Handlers (Hart_Id, Trap_Code).all (Hart_Id, Trap_Code);
end Call_Handler;


SVD and Ada #

Mem­o­ry-Mapped Peripherals

At this point, we need a way to talk to periph­er­als like UART0, which are mem­o­ry-mapped at spe­cif­ic address­es. At a giv­en base address, there’s a range of bits for con­trol and data, and your Ada code inter­acts with these reg­is­ters to man­age the peripheral. 

In the embed­ded world, this map­ping is often doc­u­ment­ed in an XML-based for­mat called CMSIS-SVD, which defines each peripheral’s base address and reg­is­ter lay­out. Neorv32 fol­lows this con­ven­tion and pro­vides a sin­gle SVD file (.svd) cov­er­ing every poten­tial periph­er­al for a giv­en SoC configuration.

Auto-Gen­er­ate Ada Code for Registers

By using the Svd2ada Alire pack­age, you can feed in the SVD file and auto­mat­i­cal­ly gen­er­ate Ada code that reflects the struc­ture of each mem­o­ry mapped periph­er­al. For exam­ple, con­sid­er this snip­pet from the neorv32.svd file for the UART0 peripheral:

<peripheral>
  <name>UART0</name>
  <description>Primary universal asynchronous receiver and transmitter</description>
  <baseAddress>0xFFF50000</baseAddress>

  <addressBlock>
    <offset>0</offset>
    <size>0x08</size>
    <usage>registers</usage>
  </addressBlock>

  <registers>
    <register>
      <name>CTRL</name>
      <description>Control register</description>
      <addressOffset>0x00</addressOffset>
      <fields>
        <field>
          <name>UART_CTRL_EN</name>
          <bitRange>[0:0]</bitRange>
          <description>UART enable flag</description>
        </field>
        ...
      </fields>
    </register>
    ...
  </registers>
</peripheral>

From this, we see:

  • A base address of 0xFFF50000.
  • An address block span­ning 8 bytes.
  • A con­trol reg­is­ter, CRTL, with a 1‑bit field, UART_CTRL_EN, to enable or dis­able the peripheral.

After run­ning it through Svd2ada, you’ll get this neat gen­er­at­ed Ada code you can use directly:

--  Generated from neorv32.svd

UART0_Base : constant System.Address := System'To_Address (16#FFF50000#);

type Bit is mod 2**1 with Size => 1;
subtype CTRL_UART_CTRL_EN_Field is Bit;

type CTRL_Register is record
   UART_CTRL_EN : CTRL_UART_CTRL_EN_Field := 16#0#;
...
   UART_CTRL_TX_FULL : CTRL_UART_CTRL_TX_FULL_Field := 16#0#;
...
end record
   with Volatile_Full_Access, Object_Size => 32,
        Bit_Order => System.Low_Order_First;

type UART0_Peripheral is record
   CTRL : aliased CTRL_Register;
   DATA : aliased neorv32.UInt32;
end record
  with Volatile;

for UART0_Peripheral use record
   CTRL at 16#0# range 0 .. 31;
   DATA at 16#4# range 0 .. 31;
end record;

UART0_Periph : aliased UART0_Peripheral with Import, Address => UART0_Base;

Min­i­mal UART Driver

UART0_Periph is the periph­er­al instance to use. The fol­low­ing code uses it to read a char­ac­ter over the UART0:

function Read_RX return Character is
  UART_RX : Character with Volatile, Address => UART0_Periph.Data'Address;
begin
  return UART_RX;
end Read_RX;
pragma Inline (Read_RX);

Imple­ment­ing Ada.Text_IO

Now, to enrich our bare run­time and enable Ada.Text_IO rou­tines, all we need is to sup­ply our own putchar func­tion that han­dles a sin­gle char­ac­ter — here text IO goes through UART. The bare_​runtime includes a weak putchar, so once we pro­vide our cus­tom imple­men­ta­tion, the rest of the log­ic is han­dled automatically.

procedure Put_Char (C : Interfaces.C.char) with
    Export, Convention => C, External_Name => "putchar";

procedure Put_Char (C : Interfaces.C.char) is
begin
  while UART0_Periph.CTRL.UART_CTRL_TX_FULL = 1 loop
    null;
  end loop;
  Write_TX (Interfaces.C.To_Ada (C));
end Put_Char;


The neorv32_​hal + demos pack­age #

Alire Pack­age Structure

Now we’re ready to cre­ate the bios demo and pack­age it with­in the neorv32_hal in the Alire registry:

neorv32_hal
 ├ alire.toml        (the manifest pushed to the Alire upstream index)
 └ src               (contains HAL code)
 └ demos
       ├ alire.toml  (pin a backward dependency on neorv32_hal)
       └ src         (contains BIOS demo code)

The alire.toml inside demos looks like this:

name = "demos"
executables = ["bios"]

[[depends-on]]
neorv32_hal = "*"

[[pins]]
neorv32_hal = { path = ".." }

Final­ly, our BIOS code doesn’t rely on polling. It’s event-dri­ven through RX inter­rupts. All react­ing code and state han­dling hap­pens in Parse_Cmd and the pri­vate imple­men­ta­tion of the Bios_Core package.

with Bios_Core;
with Interrupts;
with Uart0;

procedure Bios is
begin
   Interrupts.Init;
   Interrupts.Install_Uart0_Rx_Interrupt_Handler (0, Bios_Core.Parse_Cmd'Access);
   Uart0.Init (19200);
   Interrupts.Global_Machine_Interrupt_Enable;
   Bios_Core.Show_Welcome;
   loop
      null;
   end loop;
end Bios;


Try It Your­self #

I hope you found all this inter­est­ing. I skipped instal­la­tion, build instruc­tions for third-par­ty tools, and plumb­ing details, but you’ll find every­thing you need in the repos­i­to­rys README. If you have any ques­tions or run into issues, don’t hes­i­tate to reach out.

Posted in #Open Source    #RISC-V    #VHDL    #Neorv32    #ULX3S    #FPGA    #Lattice    #ECP5    #GHDL    #Yosys    #NextPnr    #Gateware    #Softcore    #GAP    #GNAT Academic Program    #BIOS    #Ada   

About Olivier Henley

Olivier Henley

The author, Olivier Henley, is a UX Engineer at AdaCore. His role is exploring new markets through technical stories. Prior to joining AdaCore, Olivier was a consultant software engineer for Autodesk. Prior to that, Olivier worked on AAA game titles such as For Honor and Rainbow Six Siege in addition to many R&D gaming endeavors at Ubisoft Montreal. Olivier graduated from the Electrical Engineering program at Polytechnique Montreal. He is a co-author of patent US8884949B1, describing the invention of a novel temporal filter implicating NI technology. An Ada advocate, Olivier actively curates GitHub’s Awesome-Ada list