logo

drewdevault.com

[mirror] blog and personal website of Drew DeVault git clone https://hacktivis.me/git/mirror/drewdevault.com.git

Kernel-hacking-with-Hare-part-3.md (24959B)


  1. ---
  2. title: "Notes from kernel hacking in Hare, part 3: serial driver"
  3. date: 2022-10-27
  4. ---
  5. Today I would like to show you the implementation of the first userspace driver
  6. for Helios: a simple serial driver. All of the code we're going to look at today
  7. runs in userspace, not in the kernel, so strictly speaking this should be "notes
  8. from OS hacking in Hare", but I won't snitch if you don't.
  9. *Note: In the [previous entry][prev] to this series, I promised to cover the
  10. userspace threading API in this post. I felt like covering this instead. Sorry!*
  11. [prev]: /2022/10/02/Kernel-hacking-with-Hare-part-2.html
  12. A serial port provides a simple protocol for transferring data between two
  13. systems. It generalizes a bit, but for our purposes we can just think of this as
  14. a terminal which you can use over a simple cable and a simple protocol. It's a
  15. standard x86\_64 feature (though one which has been out of style for a couple of
  16. decades now), and its simple design (and high utility) makes it a good choice
  17. for the first driver to write for [Helios][0]. We're going to look at the
  18. following details today:
  19. [0]: https://sr.ht/~sircmpwn/helios
  20. 1. The system's initramfs
  21. 2. The driver loader
  22. 3. The serial driver itself
  23. The initramfs used by Helios, for the time being, is just a tarball. I imported
  24. [format::tar][1] from the standard library, a module which I designed for this
  25. express purpose, and made a few minor tweaks to make it suitable for Helios'
  26. needs. I also implemented seeking within a tar entry to make it easier to write
  27. an ELF loader from it. The bootloader loads this tarball into memory, the kernel
  28. provides page capabilities to init for it, and then we can map it into memory
  29. and study it, something like this:
  30. [1]: https://docs.harelang.org/format/tar
  31. ```hare
  32. let base = rt::map_range(rt::vspace, 0, 0, &desc.pages)!;
  33. let slice = (base: *[*]u8)[..desc.length];
  34. const buf = bufio::fixed(slice, io::mode::READ);
  35. const rd = tar::read(&buf);
  36. ```
  37. Pulling a specific driver out of it looks like this:
  38. ```hare
  39. // Loads a driver from the bootstrap tarball.
  40. fn earlyload(fs: *bootstrapfs, path: str) *process = {
  41. tar::reset(&fs.rd)!;
  42. path = strings::ltrim(path, '/');
  43. for (true) {
  44. const ent = match (tar::next(&fs.rd)) {
  45. case io::EOF =>
  46. break;
  47. case let ent: tar::entry =>
  48. yield ent;
  49. case let err: tar::error =>
  50. abort("Invalid bootstrap.tar file");
  51. };
  52. defer tar::skip(&ent)!;
  53. if (ent.name == path) {
  54. // TODO: Better error handling here
  55. const proc = match (load_driver(&ent)) {
  56. case let err: io::error =>
  57. abort("Failed to load driver from boostrap");
  58. case let err: errors::error =>
  59. abort("Failed to load driver from boostrap");
  60. case let proc: *process =>
  61. yield proc;
  62. };
  63. helios::task_resume(proc.task)!;
  64. return proc;
  65. };
  66. };
  67. abort("Missing bootstrap driver");
  68. };
  69. ```
  70. This code finds a file in the tarball with the given path (e.g.
  71. `drivers/serial`), creates a process with the driver loader, then resumes the
  72. thread and the driver is running. Let's take a look at that driver loader next.
  73. The load\_driver entry point takes an I/O handle to an ELF file and loads it:
  74. ```hare
  75. fn load_driver(image: io::handle) (*process | io::error | errors::error) = {
  76. const loader = newloader(image);
  77. let earlyconf = driver_earlyconfig {
  78. cspace_radix = 12,
  79. };
  80. load_earlyconfig(&earlyconf, &loader)?;
  81. let proc = newprocess(earlyconf.cspace_radix)?;
  82. load(&loader, proc)?;
  83. load_config(proc, &loader)?;
  84. let regs = helios::context {
  85. rip = loader.header.e_entry,
  86. rsp = INIT_STACK_ADDR,
  87. ...
  88. };
  89. helios::task_writeregisters(proc.task, &regs)?;
  90. return proc;
  91. };
  92. ```
  93. This is essentially a standard ELF loader, which it calls via the more general
  94. "newprocess" and "load" functions, but drivers have an extra concern: the driver
  95. manifest. The "load\_earlyconfig" processes manifest keys which are necessary to
  96. configure prior to loading the ELF image, and the "load\_config" function takes
  97. care of the rest of the driver configuration. The remainder of the code
  98. configures the initial thread.
  99. The actual driver manifest is an INI file which is embedded in a special ELF
  100. section in driver binaries. The manifest for the serial driver looks like this:
  101. ```
  102. [driver]
  103. name=pcserial
  104. desc=Serial driver for x86_64 PCs
  105. [cspace]
  106. radix=12
  107. [capabilities]
  108. 0:serial =
  109. 1:note =
  110. 2:cspace = self
  111. 3:ioport = min=3F8, max=400
  112. 4:ioport = min=2E8, max=2F0
  113. 5:irq = irq=3, note=1
  114. 6:irq = irq=4, note=1
  115. ```
  116. Helios is a capability-oriented system, and in order to do anything useful, each
  117. process needs to have capabilities to work with. Each driver declares exactly
  118. what capabilities it needs and receives only these capabilities, and nothing
  119. else. This provides stronger isolation than Unix systems can offer (even with
  120. something like OpenBSD's pledge(2)) — this driver cannot even allocate
  121. memory.
  122. A standard x86\_64 ISA serial port uses two I/O port ranges, 0x3F8-0x400 and
  123. 0x2E8-0x2F0, as well as two IRQs, IRQ 3 and 4, together providing support for up
  124. to four serial ports. The driver first requests a "serial" capability, which is
  125. a temporary design for an IPC endpoint that the driver will use to actually
  126. process read or write requests. This will be replaced with a more sophisticated
  127. device manager system in the future. It also creates a notification capability,
  128. which is later used to deliver the IRQs, and requests a capability for its own
  129. cspace so that it can manage capability slots. This will be necessary later on.
  130. Following this it requests capabilities for the system resources it needs,
  131. namely the necessary I/O ports and IRQs, the latter configured to be delivered
  132. to the notification in capability slot 1.
  133. With the driver isolated in its own address space, running in user mode, and
  134. only able to invoke this set of capabilities, it's very limited in what kind of
  135. exploits it's vulnerable to. If there's a vulnerability here, the worst that
  136. could happen is that a malicious actor on the other end of the serial port could
  137. crash the driver, which would then be rebooted by the service manager. On Linux,
  138. a bug in the serial driver can be used to compromise the entire system.
  139. So, the driver loader parses this file and allocates the requested capabilities
  140. for the driver. I'll skip most of the code, it's just a boring INI file parser,
  141. but the important bit is the table for capability allocations:
  142. ```hare
  143. type capconfigfn = fn(
  144. proc: *process,
  145. addr: uint,
  146. config: const str,
  147. ) (void | errors::error);
  148. // Note: keep these tables alphabetized
  149. const capconfigtab: [_](const str, *capconfigfn) = [
  150. ("cspace", &cap_cspace),
  151. ("endpoint", &cap_endpoint),
  152. ("ioport", &cap_ioport),
  153. ("irq", &cap_irq),
  154. ("note", &cap_note),
  155. ("serial", &cap_serial),
  156. // TODO: More
  157. ];
  158. ```
  159. This table defines functions which, when a given INI key in the [capabilities]
  160. section is found, provisions the requested capabilities. This list is not
  161. complete; in the future all kernel objects will be added as well as
  162. userspace-defined interfaces (similar to serial) which implement various driver
  163. interfaces, such as 'fs' or 'gpu'. Let's start with the notification capability:
  164. ```hare
  165. fn cap_note(
  166. proc: *process,
  167. addr: uint,
  168. config: const str,
  169. ) (void | errors::error) = {
  170. if (config != "") {
  171. return errors::invalid;
  172. };
  173. const note = helios::newnote()?;
  174. defer helios::destroy(note)!;
  175. helios::copyto(proc.cspace, addr, note)?;
  176. };
  177. ```
  178. This capability takes no configuration arguments, so we first simply check that
  179. the value is empty. Then we create a notification, copy it into the driver's
  180. capability space at the requested capability address, then destroy our copy.
  181. Simple!
  182. The I/O port capability is a bit more involved: it does accept configuration
  183. parameters, namely what I/O port range the driver needs.
  184. ```hare
  185. fn cap_ioport(
  186. proc: *process,
  187. addr: uint,
  188. config: const str,
  189. ) (void | errors::error) = {
  190. let min = 0u16, max = 0u16;
  191. let have_min = false, have_max = false;
  192. const tok = strings::tokenize(config, ",");
  193. for (true) {
  194. let tok = match (strings::next_token(&tok)) {
  195. case void =>
  196. break;
  197. case let tok: str =>
  198. yield tok;
  199. };
  200. tok = strings::trim(tok);
  201. const (key, val) = strings::cut(tok, "=");
  202. let field = switch (key) {
  203. case "min" =>
  204. have_min = true;
  205. yield &min;
  206. case "max" =>
  207. have_max = true;
  208. yield &max;
  209. case =>
  210. return errors::invalid;
  211. };
  212. match (strconv::stou16b(val, base::HEX)) {
  213. case let u: u16 =>
  214. *field = u;
  215. case =>
  216. return errors::invalid;
  217. };
  218. };
  219. if (!have_min || !have_max) {
  220. return errors::invalid;
  221. };
  222. const ioport = helios::ioctl_issue(rt::INIT_CAP_IOCONTROL, min, max)?;
  223. defer helios::destroy(ioport)!;
  224. helios::copyto(proc.cspace, addr, ioport)?;
  225. };
  226. ```
  227. Here we split the configuration string on commas and parse each as a key/value
  228. pair delimited by an equal sign ("="), looking for a key called "min" and
  229. another called "max". At the moment the config parsing is just implemented in
  230. this function directly, but in the future it might make sense to write a small
  231. abstraction for capability configurations like this. Once we know the I/O port
  232. range the user wants, then we issue an I/O port capability for that range and
  233. copy it into the driver's cspace.
  234. IRQs are a bit more involved still. An IRQ capability must be configured to
  235. deliver IRQs to a notification object.
  236. ```hare
  237. fn cap_irq(
  238. proc: *process,
  239. addr: uint,
  240. config: const str,
  241. ) (void | errors::error) = {
  242. let irq = 0u8, note: helios::cap = 0;
  243. let have_irq = false, have_note = false;
  244. // ...config string parsing omitted...
  245. const _note = helios::copyfrom(proc.cspace, note, helios::CADDR_UNDEF)?;
  246. defer helios::destroy(_note)!;
  247. const (ct, _) = rt::identify(_note)!;
  248. if (ct != ctype::NOTIFICATION) {
  249. // TODO: More semantically meaningful errors would be nice
  250. return errors::invalid;
  251. };
  252. const irq = helios::irqctl_issue(rt::INIT_CAP_IRQCONTROL, _note, irq)?;
  253. defer helios::destroy(irq)!;
  254. helios::copyto(proc.cspace, addr, irq)?;
  255. };
  256. ```
  257. In order to do this, the driver loader copies the notification capability *from*
  258. the driver's cspace and into the loader's cspace, then creates an IRQ with that
  259. notification. It copies the new IRQ capability into the driver, then destroys
  260. its own copy of the IRQ and notification.
  261. In this manner, the driver can declaratively state which capabilities it needs,
  262. and the loader can prepare an environment for it with these capabilities
  263. prepared. Once these capabilities are present in the driver's cspace, the driver
  264. can invoke them by addressing the numbered capability slots in a send or receive
  265. syscall.
  266. To summarize, the loader takes an I/O object (which we know is sourced from the
  267. bootstrap tarball) from which an ELF file can be read, finds a driver manifest,
  268. then creates a process and fills the cspace with the requested capabilities,
  269. loads the program into its address space, and starts the process.
  270. Next, let's look at the serial driver that we just finished loading.
  271. Let me first note that this serial driver is a proof-of-concept at this time. A
  272. future serial driver will take a capability for a device manager object, then
  273. probe each serial port and provision serial devices for each working serial
  274. port. It will define an API which supports additional serial-specific features,
  275. such as configuring the baud rate. For now, it's pretty basic.
  276. This driver implements a simple event loop:
  277. 1. Configure the serial port
  278. 2. Wait for an interrupt or a read/write request from the user
  279. 3. On interrupt, process the interrupt, writing buffered data or buffering
  280. readable data
  281. 4. On a user request, buffer writes or unbuffer reads
  282. 5. GOTO 2
  283. The driver starts by defining some constants for the capability slots we set up
  284. in the manifest:
  285. ```hare
  286. def EP: helios::cap = 0;
  287. def IRQ: helios::cap = 1;
  288. def CSPACE: helios::cap = 2;
  289. def IRQ3: helios::cap = 5;
  290. def IRQ4: helios::cap = 6;
  291. ```
  292. It also defines some utility code for reading and writing to the COM registers,
  293. and constants for each of the registers defined by the interface.
  294. ```hare
  295. // COM1 port
  296. def COM1: u16 = 0x3F8;
  297. // COM2 port
  298. def COM2: u16 = 0x2E8;
  299. // Receive buffer register
  300. def RBR: u16 = 0;
  301. // Transmit holding regiser
  302. def THR: u16 = 0;
  303. // ...other registers omitted...
  304. const ioports: [_](u16, helios::cap) = [
  305. (COM1, 3), // 3 is the I/O port capability address
  306. (COM2, 4),
  307. ];
  308. fn comin(port: u16, register: u16) u8 = {
  309. for (let i = 0z; i < len(ioports); i += 1) {
  310. const (base, cap) = ioports[i];
  311. if (base != port) {
  312. continue;
  313. };
  314. return helios::ioport_in8(cap, port + register)!;
  315. };
  316. abort("invalid port");
  317. };
  318. fn comout(port: u16, register: u16, val: u8) void = {
  319. for (let i = 0z; i < len(ioports); i += 1) {
  320. const (base, cap) = ioports[i];
  321. if (base != port) {
  322. continue;
  323. };
  324. helios::ioport_out8(cap, port + register, val)!;
  325. return;
  326. };
  327. abort("invalid port");
  328. };
  329. ```
  330. We also define some statically-allocated data structures to store state for each
  331. COM port, and a function to initialize the port:
  332. ```hare
  333. type comport = struct {
  334. port: u16,
  335. rbuf: [4096]u8,
  336. wbuf: [4096]u8,
  337. rpending: []u8,
  338. wpending: []u8,
  339. };
  340. let ports: [_]comport = [
  341. comport { port = COM1, ... },
  342. comport { port = COM2, ... },
  343. ];
  344. fn com_init(com: *comport) void = {
  345. com.rpending = com.rbuf[..0];
  346. com.wpending = com.wbuf[..0];
  347. comout(com.port, IER, 0x00); // Disable interrupts
  348. comout(com.port, LCR, 0x80); // Enable divisor mode
  349. comout(com.port, DL_LSB, 0x01); // Div Low: 01: 115200 bps
  350. comout(com.port, DL_MSB, 0x00); // Div High: 00
  351. comout(com.port, LCR, 0x03); // Disable divisor mode, set parity
  352. comout(com.port, FCR, 0xC7); // Enable FIFO and clear
  353. comout(com.port, IER, ERBFI); // Enable read interrupt
  354. };
  355. ```
  356. The basics are in place. Let's turn our attention to the event loop.
  357. ```hare
  358. export fn main() void = {
  359. com_init(&ports[0]);
  360. com_init(&ports[1]);
  361. helios::irq_ack(IRQ3)!;
  362. helios::irq_ack(IRQ4)!;
  363. let poll: [_]pollcap = [
  364. pollcap { cap = IRQ, events = pollflags::RECV },
  365. pollcap { cap = EP, events = pollflags::RECV },
  366. ];
  367. for (true) {
  368. helios::poll(poll)!;
  369. if (poll[0].events & pollflags::RECV != 0) {
  370. poll_irq();
  371. };
  372. if (poll[1].events & pollflags::RECV != 0) {
  373. poll_endpoint();
  374. };
  375. };
  376. };
  377. ```
  378. We initialize two COM ports first, using the function we were just reading. Then
  379. we ACK any IRQs that might have already been pending when the driver starts up,
  380. and we enter the event loop proper. Here we are polling on two capabilities,
  381. the notification to which IRQs are delivered, and the endpoint which provides
  382. the serial driver's external API.
  383. The state for each serial port includes a read buffer and a write buffer,
  384. defined in the comport struct shown earlier. We configure the COM port to
  385. interrupt when there's data available to read, then pull it into the read
  386. buffer. If we have pending data to write, we configure it to interrupt when it's
  387. ready to write more data, otherwise we leave this interrupt turned off. The
  388. "poll\_irq" function handles these interrupts:
  389. ```hare
  390. fn poll_irq() void = {
  391. helios::wait(IRQ)!;
  392. defer helios::irq_ack(IRQ3)!;
  393. defer helios::irq_ack(IRQ4)!;
  394. for (let i = 0z; i < len(ports); i += 1) {
  395. const iir = comin(ports[i].port, IIR);
  396. if (iir & 1 == 0) {
  397. port_irq(&ports[i], iir);
  398. };
  399. };
  400. };
  401. fn port_irq(com: *comport, iir: u8) void = {
  402. if (iir & (1 << 2) != 0) {
  403. com_read(com);
  404. };
  405. if (iir & (1 << 1) != 0) {
  406. com_write(com);
  407. };
  408. };
  409. ```
  410. The IIR register is the "interrupt identification register", which tells us why
  411. the interrupt occurred. If it was because the port is readable, we call
  412. "com\_read". If the interrupt occurred because the port is writable, we call
  413. "com\_write". Let's start with com\_read. This interrupt is always enabled so
  414. that we can immediately start buffering data as the user types it into the
  415. serial port.
  416. ```hare
  417. // Reads data from the serial port's RX FIFO.
  418. fn com_read(com: *comport) size = {
  419. let n: size = 0;
  420. for (comin(com.port, LSR) & RBF == RBF; n += 1) {
  421. const ch = comin(com.port, RBR);
  422. if (len(com.rpending) < len(com.rbuf)) {
  423. // If the buffer is full we just drop chars
  424. static append(com.rpending, ch);
  425. };
  426. };
  427. // This part will be explained later:
  428. if (pending_read.reply != 0) {
  429. const n = rconsume(com, pending_read.buf);
  430. helios::send(pending_read.reply, 0, n)!;
  431. pending_read.reply = 0;
  432. };
  433. return n;
  434. };
  435. ```
  436. This code is pretty simple. For as long as the COM port is readable, read a
  437. character from it. If there's room in the read buffer, append this character to
  438. it.
  439. How about writing? Well, we need some way to fill the write buffer first. This
  440. part is pretty straightforward:
  441. ```hare
  442. // Append data to a COM port read buffer, returning the number of bytes buffered
  443. // successfully.
  444. fn com_wbuffer(com: *comport, data: []u8) size = {
  445. let z = len(data);
  446. if (z + len(com.wpending) > len(com.wbuf)) {
  447. z = len(com.wbuf) - len(com.wpending);
  448. };
  449. static append(com.wpending, data[..z]...);
  450. com_write(com);
  451. return z;
  452. };
  453. ```
  454. This code just adds data to the write buffer, making sure not to exceed the
  455. buffer length (note that in Hare this would cause an assertion, not a buffer
  456. overflow). Then we call "com\_write", which does the actual writing to the COM
  457. port.
  458. ```hare
  459. // Writes data to the serial port's TX FIFO.
  460. fn com_write(com: *comport) size = {
  461. if (comin(com.port, LSR) & THRE != THRE) {
  462. const ier = comin(com.port, IER);
  463. comout(com.port, IER, ier | ETBEI);
  464. return 0;
  465. };
  466. let i = 0z;
  467. for (i < 16 && len(com.wpending) != 0; i += 1) {
  468. comout(com.port, THR, com.wpending[0]);
  469. static delete(com.wpending[0]);
  470. };
  471. const ier = comin(com.port, IER);
  472. if (len(com.wpending) == 0) {
  473. comout(com.port, IER, ier & ~ETBEI);
  474. } else {
  475. comout(com.port, IER, ier | ETBEI);
  476. };
  477. return i;
  478. };
  479. ```
  480. If the COM port is not ready to write data, we enable an interrupt which will
  481. tell us when it is and return. Otherwise, we write up to 16 bytes &mdash; the
  482. size of the COM port's FIFO &mdash; and remove them from the write buffer. If
  483. there's more data to write, we enable the write interrupt, or we disable it if
  484. there's nothing left. When enabled, this will cause an interrupt to fire when
  485. (1) we have data to write and (2) the serial port is ready to write it, and our
  486. event loop will call this function again.
  487. That covers all of the code for driving the actual serial port. What about the
  488. interface for someone to actually use this driver?
  489. The "serial" capability defined in the manifest earlier is a temporary construct
  490. to provision some means of communicating with the driver. It provisions an
  491. endpoint capability (which is an IPC primitive on Helios) and stashes it away
  492. somewhere in the init process so that I can write some temporary test code to
  493. actually read or write to the serial port. Either request is done by "call"ing
  494. the endpoint with the desired parameters, which will cause the poll in the event
  495. loop to wake as the endpoint becomes receivable, calling "poll\_endpoint".
  496. ```hare
  497. fn poll_endpoint() void = {
  498. let addr = 0u64, amt = 0u64;
  499. const tag = helios::recv(EP, &addr, &amt);
  500. const label = rt::label(tag);
  501. switch (label) {
  502. case 0 =>
  503. const addr = addr: uintptr: *[*]u8;
  504. const buf = addr[..amt];
  505. const z = com_wbuffer(&ports[0], buf);
  506. helios::reply(0, z)!;
  507. case 1 =>
  508. const addr = addr: uintptr: *[*]u8;
  509. const buf = addr[..amt];
  510. if (len(ports[0].rpending) == 0) {
  511. const reply = helios::store_reply(helios::CADDR_UNDEF)!;
  512. pending_read = read {
  513. reply = reply,
  514. buf = buf,
  515. };
  516. } else {
  517. const n = rconsume(&ports[0], buf);
  518. helios::reply(0, n)!;
  519. };
  520. case =>
  521. abort(); // TODO: error
  522. };
  523. };
  524. ```
  525. "Calls" in Helios work similarly to seL4. Essentially, when you "call" an
  526. endpoint, the calling thread blocks to receive the reply and places a reply
  527. capability in the receiver's thread state. The receiver then processes their
  528. message and "replies" to the reply capability to wake up the calling thread and
  529. deliver the reply.
  530. The message label is used to define the requested operation. For now, 0 is read
  531. and 1 is write. For writes, we append the provided data to the write buffer and
  532. reply with the number of bytes we buffered, easy breezy.
  533. Reads are a bit more involved. If we don't immediately have any data in the read
  534. buffer, we have to wait until we do to reply. We copy the reply from its special
  535. slot in our thread state into our capability space, so we can use it later. This
  536. operation is why our manifest requires cspace = self. Then we store the reply
  537. capability and buffer in a variable and move on, waiting for a read interrupt.
  538. On the other hand, if there *is* data buffered, we consume it and reply
  539. immediately.
  540. ```hare
  541. fn rconsume(com: *comport, buf: []u8) size = {
  542. let amt = len(buf);
  543. if (amt > len(ports[0].rpending)) {
  544. amt = len(ports[0].rpending);
  545. };
  546. buf[..amt] = ports[0].rpending[..amt];
  547. static delete(ports[0].rpending[..amt]);
  548. return amt;
  549. };
  550. ```
  551. Makes sense?
  552. That basically covers the entire serial driver. Let's take a quick peek at the
  553. other side: the process which wants to read from or write to the serial port.
  554. For the time being this is all temporary code to test the driver with, and not
  555. the long-term solution for passing out devices to programs. The init process
  556. keeps a list of serial devices configured on the system:
  557. ```hare
  558. type serial = struct {
  559. proc: *process,
  560. ep: helios::cap,
  561. };
  562. let serials: []serial = [];
  563. fn register_serial(proc: *process, ep: helios::cap) void = {
  564. append(serials, serial {
  565. proc = proc,
  566. ep = ep,
  567. });
  568. };
  569. ```
  570. This function is called by the driver manifest parser like so:
  571. ```hare
  572. fn cap_serial(
  573. proc: *process,
  574. addr: uint,
  575. config: const str,
  576. ) (void | errors::error) = {
  577. if (config != "") {
  578. return errors::invalid;
  579. };
  580. const ep = helios::newendpoint()?;
  581. helios::copyto(proc.cspace, addr, ep)?;
  582. register_serial(proc, ep);
  583. };
  584. ```
  585. We make use of the serial port in the init process's main function with a little
  586. test loop to echo reads back to writes:
  587. ```hare
  588. export fn main(bi: *rt::bootinfo) void = {
  589. log::println("[init] Hello from Mercury!");
  590. const bootstrap = bootstrapfs_init(&bi.modules[0]);
  591. defer bootstrapfs_finish(&bootstrap);
  592. earlyload(&bootstrap, "/drivers/serial");
  593. log::println("[init] begin echo serial port");
  594. for (true) {
  595. let buf: [1024]u8 = [0...];
  596. const n = serial_read(buf);
  597. serial_write(buf[..n]);
  598. };
  599. };
  600. ```
  601. The "serial\_read" and "serial\_write" functions are:
  602. ```hare
  603. fn serial_write(data: []u8) size = {
  604. assert(len(data) <= rt::PAGESIZE);
  605. const page = helios::newpage()!;
  606. defer helios::destroy(page)!;
  607. let buf = helios::map(rt::vspace, 0, map_flags::W, page)!: *[*]u8;
  608. buf[..len(data)] = data[..];
  609. helios::page_unmap(page)!;
  610. // TODO: Multiple serial ports
  611. const port = &serials[0];
  612. const addr: uintptr = 0x7fff70000000; // XXX arbitrary address
  613. helios::map(port.proc.vspace, addr, 0, page)!;
  614. const reply = helios::call(port.ep, 0, addr, len(data));
  615. return rt::ipcbuf.params[0]: size;
  616. };
  617. fn serial_read(buf: []u8) size = {
  618. assert(len(buf) <= rt::PAGESIZE);
  619. const page = helios::newpage()!;
  620. defer helios::destroy(page)!;
  621. // TODO: Multiple serial ports
  622. const port = &serials[0];
  623. const addr: uintptr = 0x7fff70000000; // XXX arbitrary address
  624. helios::map(port.proc.vspace, addr, map_flags::W, page)!;
  625. const (label, n) = helios::call(port.ep, 1, addr, len(buf));
  626. helios::page_unmap(page)!;
  627. let out = helios::map(rt::vspace, 0, 0, page)!: *[*]u8;
  628. buf[..n] = out[..n];
  629. return n;
  630. };
  631. ```
  632. There is something interesting going on here. Part of this code is fairly
  633. obvious &mdash; we just invoke the IPC endpoint using helios::call,
  634. corresponding nicely to the other end's use of helios::reply, with the buffer
  635. address and size. However, the buffer address presents a problem: this buffer is
  636. in the init process's address space, so the serial port cannot read or write to
  637. it!
  638. In the long term, a more sophisticated approach to shared memory management will
  639. be developed, but for testing purposes I came up with this solution. For writes,
  640. we allocate a new page, map it into our address space, and copy the data we want
  641. to write to it. Then we unmap it, map it into the serial driver's address space
  642. instead, and perform the call. For reads, we allocate a page, map it into the
  643. serial driver, call the IPC endpoint, then unmap it from the serial driver, map
  644. it into our address space, and copy the data back out of it. In both cases, we
  645. destroy the page upon leaving this function, which frees the memory and
  646. automatically unmaps the page from any address space. Inefficient, but it works
  647. for demonstration purposes.
  648. And that's really all there is to it! Helios officially has its first driver.
  649. The next step is to develop a more robust solution for describing capability
  650. interfaces and device APIs, then build a PS/2 keyboard driver and a BIOS VGA
  651. mode 3 driver for driving the BIOS console, and combine these plus the serial
  652. driver into a tty on which we can run a simple shell.